diff --git a/.github/config/.files.yaml b/.github/config/.files.yaml index e6e4f08230..b5cc0527b0 100644 --- a/.github/config/.files.yaml +++ b/.github/config/.files.yaml @@ -1,7 +1,7 @@ -# CI routing infra. Editing the top-level router (build.yml) or this filter -# config re-runs every area's jobs, so every job-gating filter below includes -# *ci. That makes a change to how jobs are dispatched actually exercise those -# jobs (self-testing), instead of a router edit only matching the project filter. +# CI routing infrastructure. Changes to the top-level router (build.yml) or +# this filter configuration rerun every area's jobs. Every job-gating filter +# therefore includes *ci, so routing changes exercise the jobs they affect +# instead of matching only the project filter. ci: &ci - .github/workflows/build.yml - .github/config/.files.yaml @@ -24,9 +24,9 @@ openapi: &openapi docker-base: &docker-base - docker/base/Dockerfile -# Dockerfiles only (base + embedded + unoserver). Gates the slow multi-arch -# (arm64) leg of the PR docker test build: arm64 is only rebuilt when a -# Dockerfile itself changes, not on every code PR. +# Dockerfiles only (base, embedded, and unoserver). The slow multi-architecture +# (arm64) leg of the PR Docker test build runs only when a Dockerfile changes, +# rather than for every code PR. dockerfiles: &dockerfiles - docker/**/Dockerfile* @@ -68,7 +68,6 @@ project: &project frontend: &frontend - *ci - frontend/** - - .github/workflows/testdriver.yml - testing/** - docker/** - scripts/translations/*.py @@ -88,8 +87,8 @@ frontend: &frontend - .github/workflows/e2e-stubbed.yml - .github/workflows/e2e-live.yml -# Files that affect the Tauri desktop bundle. Gate the multi-OS Tauri build -# job on changes to any of these. +# Files that affect the Tauri desktop bundle. Changes to any of these files +# trigger the multi-OS Tauri build job. tauri: &tauri - *ci - frontend/editor/src-tauri/** @@ -102,9 +101,9 @@ tauri: &tauri - Taskfile.yml - .taskfiles/desktop.yml -# Files that affect the AI engine (Python tool models, fixers, tests). Gate -# the engine validation job on changes to engine sources or to the Java -# tool surfaces it generates models from. +# Files that affect the AI engine, including its Python tool models, fixers, +# and tests. The engine validation job also runs when the Java tool surfaces +# used to generate those models change. engine: &engine - *ci - engine/** @@ -114,10 +113,10 @@ engine: &engine - .taskfiles/engine.yml # Files that can make the committed generated API models (frontend tool API -# types + engine tool models) go stale: the Java tool surfaces they derive from, -# the generators, the generated files themselves (to catch a hand-edit), and the -# tasks that drive generation. Deliberately excludes the broad frontend/docker/ -# testing globs, so a CSS-only PR does not boot the backend to rebuild the spec. +# types and engine tool models) stale: their Java sources, generators, +# generated outputs (to catch hand edits), and generation tasks. Broad +# frontend, Docker, and testing globs are intentionally excluded, so a CSS-only +# PR does not start the backend to rebuild the specification. generated-models: &generated-models - *ci - *openapi @@ -141,8 +140,8 @@ licenses-backend: &licenses-backend - ".github/workflows/frontend-backend-licenses-update.yml" - *build -# Files that can affect premium / enterprise behaviour. Gate the enterprise -# Playwright job on changes to any of these on PRs. +# Files that can affect premium or enterprise behaviour. Changes to any of +# these files trigger the enterprise Playwright job for pull requests. proprietary: &proprietary - *ci - app/proprietary/** diff --git a/.github/workflows/PR-Auto-Deploy-V2.yml b/.github/workflows/PR-Auto-Deploy-V2.yml index 1ae657fa76..0f07aabbe5 100644 --- a/.github/workflows/PR-Auto-Deploy-V2.yml +++ b/.github/workflows/PR-Auto-Deploy-V2.yml @@ -39,7 +39,7 @@ jobs: pr_ref: ${{ steps.resolve.outputs.ref }} steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit @@ -121,7 +121,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit @@ -334,7 +334,7 @@ jobs: # under the exact same access rules as the app preview. - name: Detect Storybook changes id: sb-changes - uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 + uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 with: list-files: json filters: | @@ -472,7 +472,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit diff --git a/.github/workflows/PR-Demo-Comment-with-react.yml b/.github/workflows/PR-Demo-Comment-with-react.yml index 8e0e66032e..410aa82dc9 100644 --- a/.github/workflows/PR-Demo-Comment-with-react.yml +++ b/.github/workflows/PR-Demo-Comment-with-react.yml @@ -70,7 +70,7 @@ jobs: enable_prototypes: ${{ steps.check-prototypes-flag.outputs.enable_prototypes }} steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit @@ -177,7 +177,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit @@ -191,22 +191,19 @@ jobs: # untrusted tree gets built below - never leave credentials in .git/config persist-credentials: false - - name: Set up JDK 25 - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 - with: - java-version: "25" - distribution: "temurin" - - - name: Cache Gradle User Home + - name: Cache Gradle uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.gradle/caches ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} - restore-keys: | - gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- - gradle-${{ runner.os }}-${{ runner.arch }}- + key: gradle-deploy-pr-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + + - name: Set up JDK 25 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + with: + java-version: "25" + distribution: "temurin" - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 @@ -485,7 +482,7 @@ jobs: issues: write # add/remove labels, delete the command comment steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit diff --git a/.github/workflows/PR-Demo-cleanup.yml b/.github/workflows/PR-Demo-cleanup.yml index 7b4ee8b3a3..1407939994 100644 --- a/.github/workflows/PR-Demo-cleanup.yml +++ b/.github/workflows/PR-Demo-cleanup.yml @@ -7,10 +7,6 @@ on: permissions: contents: read -env: - SERVER_IP: ${{ secrets.NEW_VPS_IP }} # Add this to your GitHub secrets - CLEANUP_PERFORMED: "false" # Add flag to track if cleanup occurred - jobs: cleanup: environment: pr-preview @@ -23,7 +19,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit diff --git a/.github/workflows/_runner-pick.yml b/.github/workflows/_runner-pick.yml index 0b3f76a8cd..d65831c5df 100644 --- a/.github/workflows/_runner-pick.yml +++ b/.github/workflows/_runner-pick.yml @@ -38,7 +38,7 @@ jobs: is_fork: ${{ steps.decide.outputs.is_fork }} steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit diff --git a/.github/workflows/ai-engine.yml b/.github/workflows/ai-engine.yml index 056dc35ca3..015934030f 100644 --- a/.github/workflows/ai-engine.yml +++ b/.github/workflows/ai-engine.yml @@ -20,7 +20,7 @@ jobs: pull-requests: write steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit diff --git a/.github/workflows/ai_pr_title_review.yml b/.github/workflows/ai_pr_title_review.yml deleted file mode 100644 index 9922177b4b..0000000000 --- a/.github/workflows/ai_pr_title_review.yml +++ /dev/null @@ -1,221 +0,0 @@ -name: AI - PR Title Review - -on: - pull_request: - types: [opened, edited] - branches: [main] - -permissions: # required for secure-repo hardening - contents: read - -jobs: - ai-title-review: - # GITHUB_TOKEN obeys this block, so it must cover every API call made below. - permissions: - contents: read # actions/checkout, git fetch/diff - issues: write # issues.listComments / createComment / updateComment on the PR - pull-requests: write # same endpoints when the target is a pull request - models: read # actions/ai-inference - - runs-on: ubuntu-latest - - steps: - - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 - with: - egress-policy: audit - - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: 0 - - - name: Configure Git to suppress detached HEAD warning - run: git config --global advice.detachedHead false - - - name: Check if actor is repo developer - id: actor - run: | - if [[ "${{ github.actor }}" == *"[bot]" ]]; then - echo "PR opened by a bot – skipping AI title review." - echo "is_repo_dev=false" >> $GITHUB_OUTPUT - exit 0 - fi - if [ ! -f .github/config/repo_devs.json ]; then - echo "Error: .github/config/repo_devs.json not found" >&2 - exit 1 - fi - # Validate JSON and extract repo_devs - REPO_DEVS=$(jq -r '.repo_devs[]' .github/config/repo_devs.json 2>/dev/null || { echo "Error: Invalid JSON in repo_devs.json" >&2; exit 1; }) - # Convert developer list into Bash array - mapfile -t DEVS_ARRAY <<< "$REPO_DEVS" - if [[ " ${DEVS_ARRAY[*]} " == *" ${{ github.actor }} "* ]]; then - echo "is_repo_dev=true" >> $GITHUB_OUTPUT - else - echo "is_repo_dev=false" >> $GITHUB_OUTPUT - fi - - - name: Get PR diff - if: steps.actor.outputs.is_repo_dev == 'true' - id: get_diff - run: | - git fetch origin ${{ github.base_ref }} - git diff origin/${{ github.base_ref }}...HEAD | head -n 10000 | grep -vP '[\x00-\x08\x0B\x0C\x0E-\x1F\x7F\x{202E}\x{200B}]' > pr.diff - echo "diff<> $GITHUB_OUTPUT - cat pr.diff >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT - - - name: Check and sanitize PR title - if: steps.actor.outputs.is_repo_dev == 'true' - id: sanitize_pr_title - env: - PR_TITLE_RAW: ${{ github.event.pull_request.title }} - run: | - # Sanitize PR title: max 72 characters, only printable characters - PR_TITLE=$(echo "$PR_TITLE_RAW" | tr -d '\n\r' | head -c 72 | sed 's/[^[:print:]]//g') - if [[ ${#PR_TITLE} -lt 5 ]]; then - echo "PR title is too short. Must be at least 5 characters." >&2 - fi - echo "pr_title=$PR_TITLE" >> $GITHUB_OUTPUT - - - name: AI PR Title Analysis - if: steps.actor.outputs.is_repo_dev == 'true' - id: ai-title-analysis - uses: actions/ai-inference@a7805884c80886efc241e94a5351df715968a0ad # v2.1.1 - with: - model: openai/gpt-4o - system-prompt-file: ".github/config/system-prompt.txt" - prompt: | - Based on the following input data: - - { - "diff": "${{ steps.get_diff.outputs.diff }}", - "pr_title": "${{ steps.sanitize_pr_title.outputs.pr_title }}" - } - - Respond ONLY with valid JSON in the format: - { - "improved_rating": <0-10>, - "improved_ai_title_rating": <0-10>, - "improved_title": "" - } - - - name: Validate and set SCRIPT_OUTPUT - if: steps.actor.outputs.is_repo_dev == 'true' - run: | - cat < ai_response.json - ${{ steps.ai-title-analysis.outputs.response }} - EOF - - # Validate JSON structure - jq -e ' - (keys | sort) == ["improved_ai_title_rating", "improved_rating", "improved_title"] and - (.improved_rating | type == "number" and . >= 0 and . <= 10) and - (.improved_ai_title_rating | type == "number" and . >= 0 and . <= 10) and - (.improved_title | type == "string") - ' ai_response.json - if [ $? -ne 0 ]; then - echo "Invalid AI response format" >&2 - cat ai_response.json >&2 - exit 1 - fi - # Parse JSON fields - IMPROVED_RATING=$(jq -r '.improved_rating' ai_response.json) - IMPROVED_TITLE=$(jq -r '.improved_title' ai_response.json) - # Limit comment length to 1000 characters - COMMENT=$(cat < /tmp/ai-title-comment.md - # Log input and output to the GitHub Step Summary - echo "### 🤖 AI PR Title Analysis" >> $GITHUB_STEP_SUMMARY - echo "### Input PR Title" >> $GITHUB_STEP_SUMMARY - echo '```bash' >> $GITHUB_STEP_SUMMARY - echo "${{ steps.sanitize_pr_title.outputs.pr_title }}" >> $GITHUB_STEP_SUMMARY - echo '```' >> $GITHUB_STEP_SUMMARY - echo '### AI Response (raw JSON)' >> $GITHUB_STEP_SUMMARY - echo '```json' >> $GITHUB_STEP_SUMMARY - cat ai_response.json >> $GITHUB_STEP_SUMMARY - echo '```' >> $GITHUB_STEP_SUMMARY - - - name: Post comment on PR if needed - if: steps.actor.outputs.is_repo_dev == 'true' - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - continue-on-error: true - with: - github-token: ${{ github.token }} - script: | - const fs = require('fs'); - const body = fs.readFileSync('/tmp/ai-title-comment.md', 'utf8'); - const { GITHUB_REPOSITORY } = process.env; - const [owner, repo] = GITHUB_REPOSITORY.split('/'); - const issue_number = context.issue.number; - - const ratingMatch = body.match(/\*\*PR-Title Rating\*\*: (\d+)\/10/); - const rating = ratingMatch ? parseInt(ratingMatch[1], 10) : null; - - const expectedActor = "github-actions[bot]"; - const comments = await github.rest.issues.listComments({ owner, repo, issue_number }); - - const existing = comments.data.find(c => - c.user?.login === expectedActor && - c.body.includes("## 🤖 AI PR Title Suggestion") - ); - - if (rating === null) { - console.log("No rating found in AI response – skipping."); - return; - } - - if (rating <= 5) { - if (existing) { - await github.rest.issues.updateComment({ - owner, repo, - comment_id: existing.id, - body - }); - console.log("Updated existing suggestion comment."); - } else { - await github.rest.issues.createComment({ - owner, repo, issue_number, - body - }); - console.log("Created new suggestion comment."); - } - } else { - const praise = `## 🤖 AI PR Title Suggestion\n\nGreat job! The current PR title is clear and well-structured.\n\n✅ No suggestions needed.\n\n---\n*Generated by GitHub Models AI*`; - - if (existing) { - await github.rest.issues.updateComment({ - owner, repo, - comment_id: existing.id, - body: praise - }); - console.log("Replaced suggestion with praise."); - } else { - console.log("Rating > 5 and no existing comment – skipping comment."); - } - } - - - name: is not repo dev - if: steps.actor.outputs.is_repo_dev != 'true' - run: | - exit 0 # Skip the AI title review for non-repo developers - - - name: Clean up - if: always() - run: | - rm -f pr.diff ai_response.json /tmp/ai-title-comment.md - echo "Cleaned up temporary files." - continue-on-error: true # Ensure cleanup runs even if previous steps fail diff --git a/.github/workflows/aur-publish.yml b/.github/workflows/aur-publish.yml index f1ca2be8ca..af0dc85aa6 100644 --- a/.github/workflows/aur-publish.yml +++ b/.github/workflows/aur-publish.yml @@ -26,7 +26,7 @@ jobs: jar_sha256: ${{ steps.hashes.outputs.jar_sha256 }} steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit @@ -71,7 +71,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit diff --git a/.github/workflows/auto-labelerV2.yml b/.github/workflows/auto-labelerV2.yml index bcbd0fcba5..8e6d974f3c 100644 --- a/.github/workflows/auto-labelerV2.yml +++ b/.github/workflows/auto-labelerV2.yml @@ -18,7 +18,7 @@ jobs: issues: write # labels are applied through the issues API steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit diff --git a/.github/workflows/backend-build.yml b/.github/workflows/backend-build.yml index 3cba3c8d39..6623940bce 100644 --- a/.github/workflows/backend-build.yml +++ b/.github/workflows/backend-build.yml @@ -20,6 +20,7 @@ permissions: jobs: build: + environment: ci-unsigned runs-on: ubuntu-latest strategy: fail-fast: false @@ -28,29 +29,26 @@ jobs: flavor: [core, proprietary, saas] steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Restore cache Gradle User Home + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-${{ matrix.jdk-version }}-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + - name: Set up JDK ${{ matrix.jdk-version }} uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: java-version: ${{ matrix.jdk-version }} distribution: "temurin" - - name: Cache Gradle User Home - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-${{ matrix.jdk-version }}-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} - restore-keys: | - gradle-${{ runner.os }}-${{ runner.arch }}-jdk-${{ matrix.jdk-version }}- - gradle-${{ runner.os }}-${{ runner.arch }}- - - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 - name: Check Java formatting (Spotless) @@ -155,7 +153,7 @@ jobs: STIRLING_FLAVOR: ${{ matrix.flavor }} # Configure the Gradle daemon explicitly; GRADLE_OPTS alone only # configures the Gradle client JVM. - GRADLE_OPTS: '-Dorg.gradle.jvmargs=-Xmx4g -XX:+UseG1GC' + GRADLE_OPTS: "-Dorg.gradle.jvmargs=-Xmx4g -XX:+UseG1GC" - name: Check Test Reports Exist if: always() diff --git a/.github/workflows/build-enterprise.yml b/.github/workflows/build-enterprise.yml index f1301b6fd2..b4a8373ccc 100644 --- a/.github/workflows/build-enterprise.yml +++ b/.github/workflows/build-enterprise.yml @@ -15,6 +15,11 @@ name: Enterprise E2E (Playwright) on: workflow_call: + inputs: + use_shared_cache: + required: false + type: boolean + default: false push: branches: ["main"] schedule: @@ -37,6 +42,7 @@ jobs: uses: ./.github/workflows/_runner-pick.yml playwright-e2e-enterprise: + environment: ci-unsigned needs: pick # Skip on fork PRs / untrusted authors: they have no PREMIUM_KEY_ENTERPRISE, # so the suite can't boot premium and would fail. See the header comment. @@ -50,26 +56,36 @@ jobs: SYSTEM_ENABLEANALYTICS: "false" steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Set up JDK 25 - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + + - name: Restore cache Gradle User Home + if: inputs.use_shared_cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - java-version: "25" - distribution: "temurin" - - name: Cache Gradle User Home + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + + - name: Restore cache Gradle + if: inputs.use_shared_cache == false uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.gradle/caches ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} - restore-keys: | - gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- - gradle-${{ runner.os }}-${{ runner.arch }}- + key: gradle-playwright-e2e-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + + - name: Set up JDK 25 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + with: + java-version: "25" + distribution: "temurin" + - name: Set up Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -309,6 +325,7 @@ jobs: # Multi-node regression: builds + seeds the clustered stack (testing/compose/docker-compose-multinode.yml) # and runs behave features/multinode. Licence-gated, so it runs after the Playwright job (not in parallel). multinode-e2e: + environment: ci-unsigned needs: [pick, playwright-e2e-enterprise] # Nightly cron + manual dispatch only (heavy build), fork-gated for the licence secret. if: >- @@ -324,7 +341,7 @@ jobs: MN_COMPOSE: docker-compose-multinode.yml steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit - name: Checkout repository diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 939b701ce9..566262d24f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -48,53 +48,73 @@ jobs: proprietary: ${{ steps.changes.outputs.proprietary }} steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Check for file changes - uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 + uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 id: changes with: filters: .github/config/.files.yaml gradle-cache-prime: + environment: ci-unsigned name: Prime shared Gradle cache needs: [files-changed] runs-on: ubuntu-latest timeout-minutes: 15 steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Set up JDK 25 - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 - with: - java-version: "25" - distribution: "temurin" - - name: Cache Gradle User Home - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + + - name: Calculate Gradle cache key + id: gradle-cache-key + shell: bash + run: | + echo "key=gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}" >> "$GITHUB_OUTPUT" + + - name: Cache Gradle (lookup-only) + id: cache-gradle-restore + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.gradle/caches ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} - restore-keys: | - gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- - gradle-${{ runner.os }}-${{ runner.arch }}- + key: ${{ steps.gradle-cache-key.outputs.key }} + lookup-only: true + + - name: Set up JDK 25 + if: steps.cache-gradle-restore.outputs.cache-hit != 'true' + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + with: + java-version: "25" + distribution: "temurin" + - name: Resolve backend dependencies - run: ./gradlew :stirling-pdf:classes -PnoSpotless --no-daemon + if: steps.cache-gradle-restore.outputs.cache-hit != 'true' + run: ./gradlew :stirling-pdf:classes --no-daemon env: STIRLING_FLAVOR: saas MAVEN_USER: ${{ secrets.MAVEN_USER }} MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }} MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }} + - name: Save cache Gradle User Home + if: steps.cache-gradle-restore.outputs.cache-hit != 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ steps.gradle-cache-key.outputs.key }} + build: needs: [files-changed, gradle-cache-prime] permissions: @@ -169,6 +189,8 @@ jobs: contents: read uses: ./.github/workflows/build-enterprise.yml secrets: inherit + with: + use_shared_cache: true check-licence: if: needs.files-changed.outputs.build == 'true' @@ -192,7 +214,14 @@ jobs: test-build-docker-images: if: github.event_name == 'pull_request' && needs.files-changed.outputs.project == 'true' - needs: [files-changed, build, check-generateOpenApiDocs, check-licence, gradle-cache-prime] + needs: + [ + files-changed, + build, + check-generateOpenApiDocs, + check-licence, + gradle-cache-prime, + ] permissions: contents: read packages: read @@ -204,7 +233,7 @@ jobs: tauri-build: if: needs.files-changed.outputs.tauri == 'true' - needs: [files-changed] + needs: [files-changed, gradle-cache-prime] permissions: contents: read pull-requests: write @@ -218,6 +247,7 @@ jobs: with: platform: windows-macos sign: true + use_shared_cache: true ai-engine: if: needs.files-changed.outputs.engine == 'true' @@ -241,6 +271,8 @@ jobs: pull-requests: write uses: ./.github/workflows/check-generated-models.yml secrets: inherit + with: + use_shared_cache: true pre-commit: needs: [files-changed] @@ -306,7 +338,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit diff --git a/.github/workflows/check-generated-models.yml b/.github/workflows/check-generated-models.yml index 833b9c8e47..fafffcc241 100644 --- a/.github/workflows/check-generated-models.yml +++ b/.github/workflows/check-generated-models.yml @@ -9,6 +9,11 @@ name: Check generated models # post-merge safety net. on: workflow_call: + inputs: + use_shared_cache: + required: false + type: boolean + default: false push: branches: [main] @@ -23,7 +28,7 @@ jobs: pull-requests: write steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit @@ -39,22 +44,29 @@ jobs: engine/uv.lock cache-suffix: generated-models - - name: Set up JDK 25 - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + - name: Restore cache Gradle User Home + if: inputs.use_shared_cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - java-version: "25" - distribution: "temurin" + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} - - name: Cache Gradle User Home + - name: Restore cache Gradle + if: inputs.use_shared_cache == false uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.gradle/caches ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} - restore-keys: | - gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- - gradle-${{ runner.os }}-${{ runner.arch }}- + key: gradle-generated-models-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + + - name: Set up JDK 25 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + with: + java-version: "25" + distribution: "temurin" - name: Set up Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 diff --git a/.github/workflows/check-licence.yml b/.github/workflows/check-licence.yml index b1692c904d..17c64d5c64 100644 --- a/.github/workflows/check-licence.yml +++ b/.github/workflows/check-licence.yml @@ -10,33 +10,31 @@ permissions: jobs: check-licence: + environment: ci-unsigned runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Restore cache Gradle User Home + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + - name: Set up JDK 25 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: java-version: "25" distribution: "temurin" - - name: Cache Gradle User Home - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} - restore-keys: | - gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- - gradle-${{ runner.os }}-${{ runner.arch }}- - - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 - name: Check licenses for compatibility diff --git a/.github/workflows/check-openapi.yml b/.github/workflows/check-openapi.yml index c26bf60aba..ed83447335 100644 --- a/.github/workflows/check-openapi.yml +++ b/.github/workflows/check-openapi.yml @@ -11,33 +11,31 @@ permissions: jobs: check-generate-openapi-docs: + environment: ci-unsigned runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Restore cache Gradle User Home + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + - name: Set up JDK 25 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: java-version: "25" distribution: "temurin" - - name: Cache Gradle User Home - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} - restore-keys: | - gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- - gradle-${{ runner.os }}-${{ runner.arch }}- - - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 - name: Generate OpenAPI documentation diff --git a/.github/workflows/check_toml.yml b/.github/workflows/check_toml.yml index eff416c379..1681546e71 100644 --- a/.github/workflows/check_toml.yml +++ b/.github/workflows/check_toml.yml @@ -28,7 +28,7 @@ jobs: pull-requests: write # Allow writing to pull requests steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit @@ -125,7 +125,7 @@ jobs: const changedFiles = files .filter(file => file.status !== "removed" && - /^frontend\/public\/locales\/[a-zA-Z-]+\/translation\.toml$/.test(file.filename) + /^frontend\/editor\/public\/locales\/[a-zA-Z-]+\/translation\.toml$/.test(file.filename) ) .map(file => file.filename); diff --git a/.github/workflows/coverage-aggregate.yml b/.github/workflows/coverage-aggregate.yml index 507525f254..61ef8793c4 100644 --- a/.github/workflows/coverage-aggregate.yml +++ b/.github/workflows/coverage-aggregate.yml @@ -34,29 +34,26 @@ jobs: timeout-minutes: 15 steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Restore cache Gradle User Home + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + - name: Set up JDK 25 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: java-version: "25" distribution: "temurin" - - name: Cache Gradle User Home - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} - restore-keys: | - gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- - gradle-${{ runner.os }}-${{ runner.arch }}- - - name: Install uv uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: diff --git a/.github/workflows/db-migration-test.yml b/.github/workflows/db-migration-test.yml index 377d2daf98..ccb46d3988 100644 --- a/.github/workflows/db-migration-test.yml +++ b/.github/workflows/db-migration-test.yml @@ -13,34 +13,32 @@ permissions: jobs: migration-test: + environment: ci-unsigned runs-on: ubuntu-latest timeout-minutes: 30 steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Restore cache Gradle User Home + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + - name: Set up JDK 25 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: java-version: 25 distribution: temurin - - name: Cache Gradle User Home - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} - restore-keys: | - gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- - gradle-${{ runner.os }}-${{ runner.arch }}- - # Keep the normal formatting path here so this smoke test exercises the # same Gradle configuration as the backend build. - name: Build Stirling-PDF JAR diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 07f1f8ce1a..b27596ddfb 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit diff --git a/.github/workflows/deploy-on-v2-commit.yml b/.github/workflows/deploy-on-v2-commit.yml deleted file mode 100644 index 21d12044f5..0000000000 --- a/.github/workflows/deploy-on-v2-commit.yml +++ /dev/null @@ -1,209 +0,0 @@ -name: Auto V2 Deploy on Push - -on: - push: - branches: - - V2 - - deploy-on-v2-commit - -permissions: - contents: read - -jobs: - deploy-v2-on-push: - environment: pr-preview - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - concurrency: - group: deploy-v2-push-V2 - cancel-in-progress: true - - steps: - - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 - with: - egress-policy: audit - - - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - - - name: Get commit hashes for frontend and backend - id: commit-hashes - run: | - # Get last commit that touched the frontend folder, docker/frontend, or docker/compose - FRONTEND_HASH=$(git log -1 --format="%H" -- frontend/ docker/frontend/ docker/compose/ 2>/dev/null || echo "") - if [ -z "$FRONTEND_HASH" ]; then - FRONTEND_HASH="no-frontend-changes" - fi - - # Get last commit that touched backend code, docker/backend, or docker/compose - BACKEND_HASH=$(git log -1 --format="%H" -- app/ docker/backend/ docker/compose/ 2>/dev/null || echo "") - if [ -z "$BACKEND_HASH" ]; then - BACKEND_HASH="no-backend-changes" - fi - - echo "Frontend hash: $FRONTEND_HASH" - echo "Backend hash: $BACKEND_HASH" - - echo "frontend_hash=$FRONTEND_HASH" >> $GITHUB_OUTPUT - echo "backend_hash=$BACKEND_HASH" >> $GITHUB_OUTPUT - - # Short hashes for tags - if [ "$FRONTEND_HASH" = "no-frontend-changes" ]; then - echo "frontend_short=no-frontend" >> $GITHUB_OUTPUT - else - echo "frontend_short=${FRONTEND_HASH:0:8}" >> $GITHUB_OUTPUT - fi - - if [ "$BACKEND_HASH" = "no-backend-changes" ]; then - echo "backend_short=no-backend" >> $GITHUB_OUTPUT - else - echo "backend_short=${BACKEND_HASH:0:8}" >> $GITHUB_OUTPUT - fi - - - name: Convert repository owner to lowercase - id: repoowner - run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT - - - name: Login to GitHub Container Registry - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ github.token }} - - - name: Check if frontend image exists - id: check-frontend - run: | - if docker manifest inspect ${IMAGE_BASE}:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }} >/dev/null 2>&1; then - echo "exists=true" >> $GITHUB_OUTPUT - echo "Frontend image already exists, skipping build" - else - echo "exists=false" >> $GITHUB_OUTPUT - echo "Frontend image needs to be built" - fi - - env: - IMAGE_BASE: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test - - name: Check if backend image exists - id: check-backend - run: | - if docker manifest inspect ${IMAGE_BASE}:v2-backend-${{ steps.commit-hashes.outputs.backend_short }} >/dev/null 2>&1; then - echo "exists=true" >> $GITHUB_OUTPUT - echo "Backend image already exists, skipping build" - else - echo "exists=false" >> $GITHUB_OUTPUT - echo "Backend image needs to be built" - fi - - env: - IMAGE_BASE: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test - - - name: Build and push frontend image - if: steps.check-frontend.outputs.exists == 'false' - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 - with: - context: . - file: ./docker/frontend/Dockerfile - push: true - cache-from: type=gha,scope=stirling-v2-frontend - cache-to: type=gha,mode=max,scope=stirling-v2-frontend - tags: | - ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }} - ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:v2-frontend-latest - build-args: VERSION_TAG=v2-alpha - platforms: linux/amd64 - - - name: Build and push backend image - if: steps.check-backend.outputs.exists == 'false' - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 - with: - context: . - file: ./docker/backend/Dockerfile - push: true - cache-from: type=gha,scope=stirling-v2-backend - cache-to: type=gha,mode=max,scope=stirling-v2-backend - tags: | - ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }} - ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:v2-backend-latest - build-args: VERSION_TAG=v2-alpha - platforms: linux/amd64 - - - name: Set up SSH - run: | - mkdir -p ~/.ssh/ - echo "${NEW_VPS_SSH_KEY}" > ../private.key - chmod 600 ../private.key - - env: - NEW_VPS_SSH_KEY: ${{ secrets.NEW_VPS_SSH_KEY }} - - name: Deploy to VPS on port 3000 - run: | - export UNIQUE_NAME=docker-compose-v2-$GITHUB_RUN_ID.yml - - cat > $UNIQUE_NAME << EOF - version: '3.3' - services: - backend: - container_name: stirling-v2-backend - image: ${IMAGE_BASE}:v2-backend-${{ steps.commit-hashes.outputs.backend_short }} - ports: - - "13000:8080" - volumes: - - /stirling/V2/data:/usr/share/tessdata:rw - - /stirling/V2/config:/configs:rw - - /stirling/V2/logs:/logs:rw - environment: - DISABLE_ADDITIONAL_FEATURES: "true" - SECURITY_ENABLELOGIN: "false" - SYSTEM_DEFAULTLOCALE: en-US - UI_APPNAME: "Stirling-PDF V2" - UI_HOMEDESCRIPTION: "V2 Frontend/Backend Split" - UI_APPNAMENAVBAR: "V2 Deployment" - SYSTEM_MAXFILESIZE: "100" - METRICS_ENABLED: "true" - SYSTEM_GOOGLEVISIBILITY: "false" - SWAGGER_SERVER_URL: "https://demo.stirlingpdf.cloud" - baseUrl: "https://demo.stirlingpdf.cloud" - restart: on-failure:5 - - frontend: - container_name: stirling-v2-frontend - image: ${IMAGE_BASE}:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }} - ports: - - "3000:80" - environment: - VITE_API_BASE_URL: "http://${NEW_VPS_HOST}:13000" - depends_on: - - backend - restart: on-failure:5 - EOF - - # Copy to remote with unique name - scp -i ../private.key -o StrictHostKeyChecking=no $UNIQUE_NAME ${NEW_VPS_USERNAME}@${NEW_VPS_HOST}:/tmp/$UNIQUE_NAME - - # SSH and rename/move atomically to avoid interference - ssh -i ../private.key -o StrictHostKeyChecking=no ${NEW_VPS_USERNAME}@${NEW_VPS_HOST} << ENDSSH - mkdir -p /stirling/V2/{data,config,logs} - mv /tmp/$UNIQUE_NAME /stirling/V2/docker-compose.yml - cd /stirling/V2 - docker-compose down || true - docker-compose pull - docker-compose up -d - docker system prune -af --volumes || true - docker image prune -af --filter "until=336h" --filter "label!=keep=true" || true - ENDSSH - - env: - IMAGE_BASE: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test - NEW_VPS_HOST: ${{ secrets.NEW_VPS_HOST }} - NEW_VPS_USERNAME: ${{ secrets.NEW_VPS_USERNAME }} - - name: Cleanup temporary files - if: always() - run: | - rm -f ../private.key diff --git a/.github/workflows/docker-compose-tests.yml b/.github/workflows/docker-compose-tests.yml index 05b66ba34b..039c73e5db 100644 --- a/.github/workflows/docker-compose-tests.yml +++ b/.github/workflows/docker-compose-tests.yml @@ -17,6 +17,7 @@ permissions: jobs: docker-compose-tests: + environment: ci-unsigned runs-on: ubuntu-latest permissions: actions: write @@ -25,30 +26,27 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit - name: Checkout Repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Restore cache Gradle User Home + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + - name: Set up JDK 25 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: java-version: "25" distribution: "temurin" - - name: Cache Gradle User Home - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} - restore-keys: | - gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- - gradle-${{ runner.os }}-${{ runner.arch }}- - # When the PR changes the base image, test.sh builds it locally # (stirling-pdf-base:local) into the daemon image store. A buildx # container builder can't see that store, so skip it here and let diff --git a/.github/workflows/e2e-live.yml b/.github/workflows/e2e-live.yml index 3c5c12d6e7..43d66dd1cf 100644 --- a/.github/workflows/e2e-live.yml +++ b/.github/workflows/e2e-live.yml @@ -11,48 +11,31 @@ permissions: jobs: playwright-e2e-live: + environment: ci-unsigned runs-on: ubuntu-latest timeout-minutes: 30 steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Restore cache Gradle User Home + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + - name: Set up JDK 25 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: java-version: "25" distribution: "temurin" - - name: Cache Gradle User Home - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} - restore-keys: | - gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- - gradle-${{ runner.os }}-${{ runner.arch }}- - # Gradle does not retry 429s, and a cold cache resolving the buildscript - # classpath is exactly where Maven Central rate-limits us. Retry it here, - # where a failure is cheap, instead of inside the backgrounded bootRun. - - name: Prime Gradle dependencies - env: - MAVEN_USER: ${{ secrets.MAVEN_USER }} - MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }} - MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }} - run: | - for attempt in 1 2 3; do - if ./gradlew --quiet -PnoSpotless :stirling-pdf:classes; then - exit 0 - fi - echo "::warning::Gradle dependency resolution failed (attempt $attempt of 3)" - sleep $((attempt * 30)) - done - echo "::error::Gradle could not resolve dependencies after 3 attempts" - exit 1 + - name: Set up Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: diff --git a/.github/workflows/e2e-stubbed.yml b/.github/workflows/e2e-stubbed.yml index df3c5fa82a..d02005936c 100644 --- a/.github/workflows/e2e-stubbed.yml +++ b/.github/workflows/e2e-stubbed.yml @@ -27,7 +27,7 @@ jobs: project: stubbed-webkit steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit - name: Checkout repository diff --git a/.github/workflows/frontend-a11y.yml b/.github/workflows/frontend-a11y.yml index 95050a5db3..8f97120d96 100644 --- a/.github/workflows/frontend-a11y.yml +++ b/.github/workflows/frontend-a11y.yml @@ -21,7 +21,7 @@ jobs: timeout-minutes: 25 steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit - name: Checkout repository diff --git a/.github/workflows/frontend-backend-licenses-update.yml b/.github/workflows/frontend-backend-licenses-update.yml index f2cca9cee4..7fd0136718 100644 --- a/.github/workflows/frontend-backend-licenses-update.yml +++ b/.github/workflows/frontend-backend-licenses-update.yml @@ -28,7 +28,7 @@ jobs: licenses-backend: ${{ steps.changes.outputs.licenses-backend }} steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit @@ -36,12 +36,14 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Check for file changes - uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 + uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 id: changes with: filters: .github/config/.files.yaml generate-frontend-license-report: + # ci-bot, not bot-identity: this job runs on PRs too, and bot-identity is main-only. + environment: ci-bot if: needs.files-changed.outputs.licenses-frontend == 'true' name: Generate Frontend License Report needs: files-changed @@ -52,7 +54,7 @@ jobs: repository-projects: write # Required for enabling automerge steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit @@ -316,6 +318,8 @@ jobs: GH_TOKEN: ${{ steps.setup-bot.outputs.token }} generate-backend-license-report: + # ci-bot, not bot-identity: this job runs on PRs too, and bot-identity is main-only. + environment: ci-bot if: needs.files-changed.outputs.licenses-backend == 'true' needs: files-changed name: Generate Backend License Report @@ -326,7 +330,7 @@ jobs: repository-projects: write # Required for enabling automerge steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit @@ -344,22 +348,19 @@ jobs: app-id: ${{ secrets.GH_APP_ID }} private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - - name: Set up JDK 25 - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 - with: - java-version: "25" - distribution: "temurin" - - - name: Cache Gradle User Home + - name: Cache Gradle uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.gradle/caches ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} - restore-keys: | - gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- - gradle-${{ runner.os }}-${{ runner.arch }}- + key: gradle-license-report-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + + - name: Set up JDK 25 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + with: + java-version: "25" + distribution: "temurin" - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 diff --git a/.github/workflows/frontend-validation.yml b/.github/workflows/frontend-validation.yml index 7e2602c311..133d940b28 100644 --- a/.github/workflows/frontend-validation.yml +++ b/.github/workflows/frontend-validation.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit - name: Checkout repository diff --git a/.github/workflows/manage-label.yml b/.github/workflows/manage-label.yml index 571479b030..1a8bdd112d 100644 --- a/.github/workflows/manage-label.yml +++ b/.github/workflows/manage-label.yml @@ -15,7 +15,7 @@ jobs: issues: write steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit diff --git a/.github/workflows/multiOSReleases.yml b/.github/workflows/multiOSReleases.yml index c712467e7b..0f0b2d3585 100644 --- a/.github/workflows/multiOSReleases.yml +++ b/.github/workflows/multiOSReleases.yml @@ -38,6 +38,7 @@ permissions: jobs: determine-matrix: + environment: ci-unsigned if: ${{ vars.CI_PROFILE != 'lite' }} runs-on: ubuntu-latest outputs: @@ -45,29 +46,26 @@ jobs: version: ${{ steps.versionNumber.outputs.versionNumber }} steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Cache Gradle + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-tauri-releases-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + - name: Set up JDK 25 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: java-version: "25" distribution: "temurin" - - name: Cache Gradle User Home - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} - restore-keys: | - gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- - gradle-${{ runner.os }}-${{ runner.arch }}- - - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 - name: Get version number @@ -118,6 +116,7 @@ jobs: env: INPUT_PLATFORM: ${{ github.event.inputs.platform }} build-jars: + environment: ci-unsigned needs: determine-matrix runs-on: ubuntu-latest strategy: @@ -137,29 +136,26 @@ jobs: file_suffix: "-server" steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Cache Gradle + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-tauri-releases-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + - name: Set up JDK 25 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: java-version: "25" distribution: "temurin" - - name: Cache Gradle User Home - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} - restore-keys: | - gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- - gradle-${{ runner.os }}-${{ runner.arch }}- - - name: Setup Node.js if: matrix.variant.build_frontend == true uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -204,11 +200,10 @@ jobs: runs-on: ${{ matrix.platform }} env: SM_API_KEY: ${{ secrets.SM_API_KEY }} - WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} RELEASE_GPG_PRIVATE_KEY: ${{ secrets.RELEASE_GPG_PRIVATE_KEY }} steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit allowed-endpoints: > @@ -237,6 +232,14 @@ jobs: toolchain: stable targets: ${{ matrix.platform == 'macos-15' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }} + - name: Cache Gradle + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-tauri-releases-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + # x86_64 JDK is set up first so the aarch64 step below can leave its # JAVA_HOME as the active one. The macOS universal JRE build needs # jmods from both arches; the x64 path is captured into the env @@ -260,17 +263,6 @@ jobs: java-version: "25" distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }} - - name: Cache Gradle User Home - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} - restore-keys: | - gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- - gradle-${{ runner.os }}-${{ runner.arch }}- - - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 @@ -295,7 +287,7 @@ jobs: # DigiCert KeyLocker Setup (Cloud HSM) - name: Setup DigiCert KeyLocker id: digicert-setup - if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }} + if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release') }} uses: digicert/ssm-code-signing@1d820463733701cf1484c7eb5d7d24a15ca2c454 # v1.2.1 env: SM_API_KEY: ${{ secrets.SM_API_KEY }} @@ -305,7 +297,7 @@ jobs: SM_HOST: ${{ secrets.SM_HOST }} - name: Setup DigiCert KeyLocker Certificate - if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }} + if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release') }} shell: pwsh run: | Write-Host "Setting up DigiCert KeyLocker environment..." @@ -344,40 +336,8 @@ jobs: SM_API_KEY: ${{ secrets.SM_API_KEY }} SM_CLIENT_CERT_PASSWORD: ${{ secrets.SM_CLIENT_CERT_PASSWORD }} SM_KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }} - # Traditional PFX Certificate Import (fallback if KeyLocker not configured) - - name: Import Windows Code Signing Certificate - if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY == '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }} - env: - WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} - WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} - shell: powershell - run: | - if ($env:WINDOWS_CERTIFICATE) { - Write-Host "Importing Windows Code Signing Certificate..." - - # Decode base64 certificate and save to file - $certBytes = [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE) - $certPath = Join-Path $env:RUNNER_TEMP "certificate.pfx" - [IO.File]::WriteAllBytes($certPath, $certBytes) - - # Import certificate to CurrentUser\My store - $cert = Import-PfxCertificate -FilePath $certPath -CertStoreLocation Cert:\CurrentUser\My -Password (ConvertTo-SecureString -String $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force) - - # Extract and set thumbprint as environment variable - $thumbprint = $cert.Thumbprint - Write-Host "Certificate imported with thumbprint: $thumbprint" - echo "WINDOWS_CERTIFICATE_THUMBPRINT=$thumbprint" >> $env:GITHUB_ENV - - # Clean up certificate file - Remove-Item $certPath - - Write-Host "Windows certificate import completed." - } else { - Write-Host "⚠️ WINDOWS_CERTIFICATE secret not set - building unsigned binary" - } - - name: Import Apple Developer Certificate - if: matrix.platform == 'macos-15' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') + if: matrix.platform == 'macos-15' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release') env: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} @@ -398,7 +358,7 @@ jobs: rm certificate.p12 - name: Verify Certificate - if: matrix.platform == 'macos-15' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') + if: matrix.platform == 'macos-15' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release') run: | echo "Verifying Apple Developer Certificate..." KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db @@ -414,7 +374,7 @@ jobs: # Without this, signCommand failures are opaque (Tauri captures but drops # smctl's stderr) - running these loudly surfaces auth/env/keypair issues. - name: Preflight smctl - if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }} + if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release') }} shell: pwsh env: KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }} @@ -445,7 +405,7 @@ jobs: # smctl reads SM_HOST, SM_API_KEY, SM_CLIENT_CERT_FILE, SM_CLIENT_CERT_PASSWORD # from env (set by prior DigiCert setup step). No --config-file needed. - name: Configure Windows code signing - if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }} + if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release') }} shell: bash env: KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }} @@ -466,7 +426,7 @@ jobs: sed "s/${KEYPAIR_ALIAS}/***/g" ./frontend/editor/src-tauri/tauri.windows.conf.json - name: Import release GPG signing key (Linux) - if: matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') + if: matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release') run: | echo "$RELEASE_GPG_PRIVATE_KEY" | gpg --batch --import gpg --list-secret-keys --keyid-format=long @@ -498,8 +458,8 @@ jobs: # APPIMAGETOOL_SIGN_PASSPHRASE appimagetool uses this to unlock the GPG key non-interactively # SIGN_KEY appimagetool picks the key matching this fingerprint # Without SIGN=1, the other two are ignored and the AppImage is built unsigned even if a key is present. - # Mirror the Windows/macOS gate: only sign on a real release/dispatch+sign or V2-master, when secret is present. - SIGN: ${{ (env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')) && '1' || '0' }} + # Mirror the Windows/macOS gate: only sign on a real release/dispatch+sign or the release branch, when secret is present. + SIGN: ${{ (env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release')) && '1' || '0' }} APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.RELEASE_GPG_PASSPHRASE }} SIGN_KEY: ${{ vars.RELEASE_GPG_FINGERPRINT }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} @@ -525,7 +485,7 @@ jobs: env: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - GPG_SIGN: ${{ (env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')) && '1' || '0' }} + GPG_SIGN: ${{ (env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release')) && '1' || '0' }} SIGN_KEY: ${{ vars.RELEASE_GPG_FINGERPRINT }} APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.RELEASE_GPG_PASSPHRASE }} run: | @@ -564,7 +524,7 @@ jobs: echo "Stripped bundled libwayland from $(basename "$AI")" - name: Clear release GPG key from runner keyring (Linux) - if: always() && matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') + if: always() && matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release') env: RELEASE_GPG_FINGERPRINT: ${{ vars.RELEASE_GPG_FINGERPRINT }} run: | @@ -579,7 +539,7 @@ jobs: # artifact. Tauri signs a COPY when bundling into the MSI and leaves the raw # cargo output unsigned, so checking it produces false negatives. - name: Verify Windows Code Signature - if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }} + if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release') }} timeout-minutes: 15 shell: pwsh run: | @@ -740,7 +700,7 @@ jobs: contents: write steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit @@ -911,11 +871,11 @@ jobs: # workflow_dispatch path requires platform=='all' so a single-platform # dispatch can't overwrite an existing release's full latest.json with a # partial one (action-gh-release defaults overwrite_files:true). - # release / V2-master always build the full matrix so no extra guard needed. + # release event / release branch always build the full matrix so no extra guard needed. # fail_on_unmatched_files makes a missing latest.json or installer fail loudly # instead of silently shipping a broken auto-update. - name: Upload binaries to Release - if: (github.event_name == 'workflow_dispatch' && github.event.inputs.test_mode != 'true' && github.event.inputs.platform == 'all') || github.event_name == 'release' || github.ref == 'refs/heads/V2-master' + if: (github.event_name == 'workflow_dispatch' && github.event.inputs.test_mode != 'true' && github.event.inputs.platform == 'all') || github.event_name == 'release' || github.ref == 'refs/heads/release' uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: tag_name: v${{ needs.determine-matrix.outputs.version }} diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 48b3299827..c92d17f027 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -23,7 +23,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit @@ -74,7 +74,7 @@ jobs: timeout-minutes: 60 steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit @@ -127,6 +127,7 @@ jobs: # Runs the @nightly tag (conversion scenarios) plus a 10-shard concurrency run # of every other feature. cucumber-nightly: + environment: ci-unsigned name: Cucumber (nightly scenarios + full concurrency) runs-on: ubuntu-latest # Fork pull requests get no MAVEN_* secrets, so the image build cannot work. @@ -137,7 +138,7 @@ jobs: contents: read steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit diff --git a/.github/workflows/package-managers.yml b/.github/workflows/package-managers.yml index 751b3c1877..777c8d6ed4 100644 --- a/.github/workflows/package-managers.yml +++ b/.github/workflows/package-managers.yml @@ -28,7 +28,7 @@ jobs: jar_sha256: ${{ steps.hashes.outputs.jar_sha256 }} steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit @@ -80,7 +80,7 @@ jobs: contents: write steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit diff --git a/.github/workflows/pr-conflict-labeler.yml b/.github/workflows/pr-conflict-labeler.yml index 362421d0f8..c642b6cf3d 100644 --- a/.github/workflows/pr-conflict-labeler.yml +++ b/.github/workflows/pr-conflict-labeler.yml @@ -32,7 +32,7 @@ jobs: pull-requests: write # pulls.get/list plus add/remove the label on PRs steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit diff --git a/.github/workflows/pre_commit.yml b/.github/workflows/pre_commit.yml index 18c4782927..db67ccefe6 100644 --- a/.github/workflows/pre_commit.yml +++ b/.github/workflows/pre_commit.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit @@ -34,7 +34,7 @@ jobs: cache-suffix: pre-commit - name: Install Task - uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0 + uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 - name: Run pre-commit checks run: task pre-commit diff --git a/.github/workflows/push-docker-base.yml b/.github/workflows/push-docker-base.yml index f7dd1bcab4..97c227f23c 100644 --- a/.github/workflows/push-docker-base.yml +++ b/.github/workflows/push-docker-base.yml @@ -17,6 +17,9 @@ permissions: jobs: push-base: + # Own environment: docker-publish is branch-locked to release/main, + # which excludes the baseDockerImage/accessIssueFix branches this runs on. + environment: docker-base-publish if: ${{ vars.CI_PROFILE != 'lite' && github.actor == 'Frooodle' }} runs-on: ubuntu-24.04-8core permissions: @@ -45,7 +48,7 @@ jobs: echo "version=${VERSION}" >> $GITHUB_OUTPUT - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit diff --git a/.github/workflows/push-docker.yml b/.github/workflows/push-docker.yml index c9caf1b2f2..ea379cf7c6 100644 --- a/.github/workflows/push-docker.yml +++ b/.github/workflows/push-docker.yml @@ -20,9 +20,8 @@ on: default: false push: branches: - - master + - release - main - - V2-master # cancel in-progress jobs if a new job is triggered # This is useful to avoid running multiple builds for the same branch if a new commit is pushed @@ -53,29 +52,26 @@ jobs: RUN_UNOSERVER: ${{ github.event_name != 'workflow_dispatch' || inputs.build_unoserver }} steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Cache Gradle + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-push-docker-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + - name: Set up JDK 25 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: java-version: "25" distribution: "temurin" - - name: Cache Gradle User Home - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} - restore-keys: | - gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- - gradle-${{ runner.os }}-${{ runner.arch }}- - - name: Set up Docker Buildx id: buildx uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 @@ -91,13 +87,13 @@ jobs: MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }} - name: Install cosign - if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' + if: github.ref == 'refs/heads/release' uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 with: cosign-release: "v2.4.1" - name: Install cosign - if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' + if: github.ref == 'refs/heads/release' uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 with: cosign-release: "v2.4.1" @@ -133,8 +129,8 @@ jobs: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf ${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf tags: | - type=raw,value=${{ steps.versionNumber.outputs.versionNumber }},enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }} - type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }} + type=raw,value=${{ steps.versionNumber.outputs.versionNumber }},enable=${{ github.ref == 'refs/heads/release' }} + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/release' }} - name: Build and push Unified Dockerfile (latest variant) id: build-push-latest @@ -158,7 +154,7 @@ jobs: sbom: true - name: Sign regular images - if: env.RUN_MAIN_APP == 'true' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master') && steps.build-push-latest.outputs.digest != '' + if: env.RUN_MAIN_APP == 'true' && (github.ref == 'refs/heads/release') && steps.build-push-latest.outputs.digest != '' env: DIGEST: ${{ steps.build-push-latest.outputs.digest }} TAGS: ${{ steps.meta.outputs.tags }} @@ -182,8 +178,8 @@ jobs: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf ${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf tags: | - type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-fat,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }} - type=raw,value=latest-fat,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }} + type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-fat,enable=${{ github.ref == 'refs/heads/release' }} + type=raw,value=latest-fat,enable=${{ github.ref == 'refs/heads/release' }} - name: Build and push Unified Dockerfile (fat variant) id: build-push-fat @@ -204,7 +200,7 @@ jobs: sbom: true - name: Sign fat images - if: env.RUN_MAIN_APP == 'true' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master') && steps.build-push-fat.outputs.digest != '' + if: env.RUN_MAIN_APP == 'true' && (github.ref == 'refs/heads/release') && steps.build-push-fat.outputs.digest != '' env: DIGEST: ${{ steps.build-push-fat.outputs.digest }} TAGS: ${{ steps.meta-fat.outputs.tags }} @@ -226,8 +222,8 @@ jobs: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf ${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf tags: | - type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-ultra-lite,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }} - type=raw,value=latest-ultra-lite,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }} + type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-ultra-lite,enable=${{ github.ref == 'refs/heads/release' }} + type=raw,value=latest-ultra-lite,enable=${{ github.ref == 'refs/heads/release' }} - name: Build and push Unified Dockerfile (ultra-lite variant) id: build-push-lite @@ -248,7 +244,7 @@ jobs: sbom: true - name: Sign ultra-lite images - if: env.RUN_MAIN_APP == 'true' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master') && steps.build-push-lite.outputs.digest != '' + if: env.RUN_MAIN_APP == 'true' && (github.ref == 'refs/heads/release') && steps.build-push-lite.outputs.digest != '' env: DIGEST: ${{ steps.build-push-lite.outputs.digest }} TAGS: ${{ steps.meta-lite.outputs.tags }} @@ -260,7 +256,7 @@ jobs: done # Standalone unoserver image — versioned independently via - # docker/unoserver/VERSION. master/V2-master: publish +latest + # docker/unoserver/VERSION. release: publish +latest # only when the version is new. main/testMain: republish :alpha only # when the source hash differs from the published image's annotation. - name: Read unoserver image version @@ -319,7 +315,7 @@ jobs: fi case "$EFFECTIVE_REF" in - refs/heads/master|refs/heads/V2-master) + refs/heads/release) if [ "${FORCE_REBUILD}" = "true" ]; then echo "force_unoserver_rebuild=true — building stable regardless" mode="stable" diff --git a/.github/workflows/rollback-latest.yml b/.github/workflows/rollback-latest.yml index 442a6f499e..3e49f727c6 100644 --- a/.github/workflows/rollback-latest.yml +++ b/.github/workflows/rollback-latest.yml @@ -19,7 +19,7 @@ jobs: packages: write steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml index 2eab52b1d4..f0129277de 100644 --- a/.github/workflows/scorecards.yml +++ b/.github/workflows/scorecards.yml @@ -35,7 +35,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit @@ -75,6 +75,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4 + uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 with: sarif_file: results.sarif diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index ab9078831a..af255de3cf 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -17,7 +17,7 @@ jobs: pull-requests: write steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit diff --git a/.github/workflows/swagger.yml b/.github/workflows/swagger.yml index 733ce9e7ed..1bfc94be5b 100644 --- a/.github/workflows/swagger.yml +++ b/.github/workflows/swagger.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: push: branches: - - master + - release # cancel in-progress jobs if a new job is triggered # This is useful to avoid running multiple builds for the same branch if a new commit is pushed @@ -23,33 +23,33 @@ permissions: jobs: push: + # package-publish holds SWAGGERHUB_API_KEY. It requires reviewer approval and + # is limited to main / release / v* tags, so every push to release waits on one. + environment: package-publish if: ${{ vars.CI_PROFILE != 'lite' }} runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Cache Gradle + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-swagger-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + - name: Set up JDK 25 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: java-version: "25" distribution: "temurin" - - name: Cache Gradle User Home - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} - restore-keys: | - gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- - gradle-${{ runner.os }}-${{ runner.arch }}- - - name: Generate Swagger documentation run: ./gradlew :stirling-pdf:generateOpenApiDocs diff --git a/.github/workflows/sync-portal-docs.yml b/.github/workflows/sync-portal-docs.yml index 5ff4f465c7..636cd5f2e1 100644 --- a/.github/workflows/sync-portal-docs.yml +++ b/.github/workflows/sync-portal-docs.yml @@ -33,7 +33,7 @@ jobs: pull-requests: write steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit diff --git a/.github/workflows/sync_files_v2.yml b/.github/workflows/sync_files_v2.yml index 7e6f999618..124c992c71 100644 --- a/.github/workflows/sync_files_v2.yml +++ b/.github/workflows/sync_files_v2.yml @@ -37,7 +37,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit @@ -66,7 +66,7 @@ jobs: uv sync --project engine --locked --group tools - name: Install Task - uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0 + uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 - name: Sync translation TOML files run: | diff --git a/.github/workflows/tauri-build.yml b/.github/workflows/tauri-build.yml index 5c04f6009f..e3f3122773 100644 --- a/.github/workflows/tauri-build.yml +++ b/.github/workflows/tauri-build.yml @@ -26,6 +26,10 @@ on: required: false type: boolean default: false + use_shared_cache: + required: false + type: boolean + default: false workflow_dispatch: inputs: platform: @@ -57,13 +61,16 @@ permissions: jobs: determine-matrix: + # Only probes APPLE_CERTIFICATE for presence, so it stays on the unrestricted + # signing environment - release-signing would block every PR run. + environment: ci-signing if: ${{ vars.CI_PROFILE != 'lite' }} runs-on: ubuntu-latest outputs: matrix: ${{ steps.set-matrix.outputs.matrix }} steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit @@ -103,6 +110,12 @@ jobs: echo "matrix={\"include\":[$JOINED]}" >> $GITHUB_OUTPUT build: + # Windows/GPG signing only runs on main (see the per-step gates below), so only + # that path needs the reviewer-gated release-signing environment. Everything else + # (PRs, merge queue, nightly) signs macOS only and uses ci-signing, which has no + # approval or branch restriction. + environment: + name: ${{ (inputs.sign && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))) && 'release-signing' || 'ci-signing' }} needs: determine-matrix strategy: fail-fast: false @@ -110,7 +123,6 @@ jobs: runs-on: ${{ matrix.platform }} env: SM_API_KEY: ${{ secrets.SM_API_KEY }} - WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} RELEASE_GPG_PRIVATE_KEY: ${{ secrets.RELEASE_GPG_PRIVATE_KEY }} # Per-platform sign gate. macOS signs on any run with the cert available, @@ -120,7 +132,7 @@ jobs: SIGN_BUNDLE: ${{ inputs.sign && (matrix.platform == 'macos-15' && secrets.APPLE_CERTIFICATE != '' || github.ref == 'refs/heads/main') }} steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit @@ -150,7 +162,7 @@ jobs: # only recompiles the app crate. Written on main; PRs and the merge queue # restore from it. - name: Cache Rust build - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: workspaces: frontend/editor/src-tauri # Stable key shared across workflows so the nightly warmer. @@ -160,6 +172,24 @@ jobs: # Save the dependency cache even if a later step fails cache-on-failure: true + - name: Restore cache Gradle User Home + if: inputs.use_shared_cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + + - name: Restore cache Gradle + if: inputs.use_shared_cache == false + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-tauri-build-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + - name: Set up x86_64 JDK 25 (macOS universal JRE) if: matrix.platform == 'macos-15' uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 @@ -179,17 +209,6 @@ jobs: java-version: "25" distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }} - - name: Cache Gradle User Home - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} - restore-keys: | - gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- - gradle-${{ runner.os }}-${{ runner.arch }}- - - name: Setup Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 @@ -264,38 +283,6 @@ jobs: } } - # Traditional PFX Certificate Import (fallback if KeyLocker not configured) - - name: Import Windows Code Signing Certificate - if: ${{ inputs.sign && startsWith(matrix.platform, 'windows') && env.SM_API_KEY == '' && github.ref == 'refs/heads/main' }} - env: - WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} - WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} - shell: powershell - run: | - if ($env:WINDOWS_CERTIFICATE) { - Write-Host "Importing Windows Code Signing Certificate..." - - # Decode base64 certificate and save to file - $certBytes = [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE) - $certPath = Join-Path $env:RUNNER_TEMP "certificate.pfx" - [IO.File]::WriteAllBytes($certPath, $certBytes) - - # Import certificate to CurrentUser\My store - $cert = Import-PfxCertificate -FilePath $certPath -CertStoreLocation Cert:\CurrentUser\My -Password (ConvertTo-SecureString -String $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force) - - # Extract and set thumbprint as environment variable - $thumbprint = $cert.Thumbprint - Write-Host "Certificate imported with thumbprint: $thumbprint" - echo "WINDOWS_CERTIFICATE_THUMBPRINT=$thumbprint" >> $env:GITHUB_ENV - - # Clean up certificate file - Remove-Item $certPath - - Write-Host "Windows certificate import completed." - } else { - Write-Host "⚠️ WINDOWS_CERTIFICATE secret not set - building unsigned binary" - } - - name: Import Apple Developer Certificate if: env.SIGN_BUNDLE == 'true' && matrix.platform == 'macos-15' env: @@ -703,7 +690,7 @@ jobs: pull-requests: write steps: - name: Harden the runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit @@ -795,7 +782,7 @@ jobs: if: always() steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit diff --git a/.github/workflows/test-build-docker.yml b/.github/workflows/test-build-docker.yml index 4ce06e6247..12d5a35a1f 100644 --- a/.github/workflows/test-build-docker.yml +++ b/.github/workflows/test-build-docker.yml @@ -37,6 +37,7 @@ jobs: # spring-security=true matrix entry if `task backend:build` and # `task backend:build:ci` produce equivalent JARs (verify before wiring). test-build-docker-images: + environment: ci-unsigned runs-on: ubuntu-latest strategy: fail-fast: false @@ -53,7 +54,7 @@ jobs: cache-scope: stirling-pdf-fat steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit @@ -78,23 +79,20 @@ jobs: docker system prune -af || true echo "Disk space after cleanup:" && df -h + - name: Restore cache Gradle User Home + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + - name: Set up JDK 25 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: java-version: "25" distribution: "temurin" - - name: Cache Gradle User Home - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} - restore-keys: | - gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- - gradle-${{ runner.os }}-${{ runner.arch }}- - - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 - name: Build application @@ -191,7 +189,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit diff --git a/.github/workflows/testdriver.yml b/.github/workflows/testdriver.yml deleted file mode 100644 index f98d874dfa..0000000000 --- a/.github/workflows/testdriver.yml +++ /dev/null @@ -1,235 +0,0 @@ -name: UI test with TestDriverAI - -on: - push: - branches: ["master", "UITest", "testdriver"] - -# cancel in-progress jobs if a new job is triggered -# This is useful to avoid running multiple builds for the same branch if a new commit is pushed -# or a pull request is updated. -# It helps to save resources and time by ensuring that only the latest commit is built and tested -# This is particularly useful for long-running jobs that may take a while to complete. -# The `group` is set to a combination of the workflow name, event name, and branch name. -# This ensures that jobs are grouped by the workflow and branch, allowing for cancellation of -# in-progress jobs when a new commit is pushed to the same branch or a new pull request is opened. -concurrency: - group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref_name || github.ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - deploy: - environment: pr-preview - if: ${{ vars.CI_PROFILE != 'lite' }} - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - steps: - - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 - with: - egress-policy: audit - - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Set up JDK 25 - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 - with: - java-version: "25" - distribution: "temurin" - - - name: Cache Gradle User Home - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} - restore-keys: | - gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- - gradle-${{ runner.os }}-${{ runner.arch }}- - - - name: Build with Gradle - run: ./gradlew build - env: - MAVEN_USER: ${{ secrets.MAVEN_USER }} - MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }} - MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }} - DISABLE_ADDITIONAL_FEATURES: true - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - - - name: Get version number - id: versionNumber - run: | - VERSION=$(grep "^version =" build.gradle | awk -F'"' '{print $2}') - echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT - - - name: Convert repository owner to lowercase - id: repoowner - run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT - - - name: Login to GitHub Container Registry - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ github.token }} - - - name: Build and push test image - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 - with: - context: . - file: ./docker/embedded/Dockerfile - push: true - cache-from: type=gha,scope=stirling-pdf-latest - cache-to: type=gha,mode=max,scope=stirling-pdf-latest - tags: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:test-${{ github.sha }} - build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }} - platforms: linux/amd64 - - - name: Set up SSH - run: | - mkdir -p ~/.ssh/ - echo "${NEW_VPS_SSH_KEY}" > ../private.key - sudo chmod 600 ../private.key - - env: - NEW_VPS_SSH_KEY: ${{ secrets.NEW_VPS_SSH_KEY }} - - name: Deploy to VPS - run: | - cat > docker-compose.yml << EOF - version: '3.3' - services: - stirling-pdf: - container_name: stirling-pdf-test-${{ github.sha }} - image: ${IMAGE_BASE}:test-${{ github.sha }} - ports: - - "1337:8080" - volumes: - - /stirling/test-${{ github.sha }}/data:/usr/share/tessdata:rw - - /stirling/test-${{ github.sha }}/config:/configs:rw - - /stirling/test-${{ github.sha }}/logs:/logs:rw - environment: - DISABLE_ADDITIONAL_FEATURES: "true" - SECURITY_ENABLELOGIN: "false" - SYSTEM_DEFAULTLOCALE: en-US - UI_APPNAME: "Stirling-PDF Test" - UI_HOMEDESCRIPTION: "Test Deployment" - UI_APPNAMENAVBAR: "Test" - SYSTEM_MAXFILESIZE: "100" - METRICS_ENABLED: "true" - SYSTEM_GOOGLEVISIBILITY: "false" - SYSTEM_ENABLEANALYTICS: "false" - restart: on-failure:5 - EOF - - scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${NEW_VPS_USERNAME}@${NEW_VPS_HOST}:/tmp/docker-compose.yml - - ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${NEW_VPS_USERNAME}@${NEW_VPS_HOST} << EOF - mkdir -p /stirling/test-${{ github.sha }}/{data,config,logs} - mv /tmp/docker-compose.yml /stirling/test-${{ github.sha }}/docker-compose.yml - cd /stirling/test-${{ github.sha }} - docker-compose pull - docker-compose up -d - EOF - - env: - IMAGE_BASE: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test - NEW_VPS_USERNAME: ${{ secrets.NEW_VPS_USERNAME }} - NEW_VPS_HOST: ${{ secrets.NEW_VPS_HOST }} - files-changed: - if: always() - name: detect what files changed - runs-on: ubuntu-latest - timeout-minutes: 3 - outputs: - frontend: ${{ steps.changes.outputs.frontend }} - steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 - with: - egress-policy: audit - - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Check for file changes - uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 - id: changes - with: - filters: ".github/config/.files.yaml" - - test: - environment: pr-preview - if: needs.files-changed.outputs.frontend == 'true' - needs: [deploy, files-changed] - runs-on: ubuntu-latest - steps: - - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 - with: - egress-policy: audit - - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Set up Node - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - cache: "npm" - cache-dependency-path: frontend/package-lock.json - - - name: Run TestDriver.ai - uses: testdriverai/action@f0d0f45fdd684db628baa843fe9313f3ca3a8aa8 #1.1.3 - with: - key: ${{secrets.TESTDRIVER_API_KEY}} - prerun: | - choco install go-task -y - task frontend:build - cd frontend - npm install dashcam-chrome --save - Start-Process "C:/Program Files/Google/Chrome/Application/chrome.exe" -ArgumentList "--start-maximized", "--load-extension=$(pwd)/node_modules/dashcam-chrome/build", "http://${{ secrets.NEW_VPS_HOST }}:1337" - Start-Sleep -Seconds 20 - prompt: | - 1. /run testing/testdriver/test.yml - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - FORCE_COLOR: "3" - - cleanup: - environment: pr-preview - needs: [deploy, test] - runs-on: ubuntu-latest - if: always() - - steps: - - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 - with: - egress-policy: audit - - - name: Set up SSH - run: | - mkdir -p ~/.ssh/ - echo "${NEW_VPS_SSH_KEY}" > ../private.key - sudo chmod 600 ../private.key - - env: - NEW_VPS_SSH_KEY: ${{ secrets.NEW_VPS_SSH_KEY }} - - name: Cleanup deployment - if: always() - run: | - ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${NEW_VPS_USERNAME}@${NEW_VPS_HOST} << EOF - cd /stirling/test-${{ github.sha }} - docker-compose down - cd /stirling - rm -rf test-${{ github.sha }} - EOF - env: - NEW_VPS_USERNAME: ${{ secrets.NEW_VPS_USERNAME }} - NEW_VPS_HOST: ${{ secrets.NEW_VPS_HOST }} - continue-on-error: true # Ensure cleanup runs even if previous steps fail diff --git a/WINDOWS_SIGNING.md b/WINDOWS_SIGNING.md index 58ffd6e657..95cbbd24e2 100644 --- a/WINDOWS_SIGNING.md +++ b/WINDOWS_SIGNING.md @@ -4,6 +4,11 @@ This guide explains how to set up Windows code signing for Stirling-PDF desktop ## Overview +Releases are signed with **DigiCert KeyLocker**, a cloud HSM: the private key never +leaves DigiCert, and the runner signs through a PKCS#11 provider. The older approach +of uploading a base64 `.pfx` to a repository secret has been removed from the +workflows - the sections below describe KeyLocker, which is what actually runs. + Windows code signing is essential for: - Preventing Windows SmartScreen warnings - Building trust with users @@ -49,29 +54,19 @@ openssl pkcs12 -export -out certificate.pfx -inkey private-key.key -in certifica ### Required Secrets -Navigate to your GitHub repository → Settings → Secrets and variables → Actions +Navigate to your GitHub repository → Settings → Environments → `release-signing`. -Add the following secrets: +These live in the `release-signing` environment, not at repository scope. That +environment requires reviewer approval and is limited to `main`, `release`, +`hotfix/*` and `v*` tags. All five come from the DigiCert ONE console. -#### 1. `WINDOWS_CERTIFICATE` -- **Description**: Base64-encoded .pfx certificate file -- **How to create**: - -**On macOS/Linux:** -```bash -base64 -i certificate.pfx | pbcopy # Copies to clipboard -``` - -**On Windows (PowerShell):** -```powershell -[Convert]::ToBase64String([IO.File]::ReadAllBytes("certificate.pfx")) | Set-Clipboard -``` - -Paste the entire base64 string into the GitHub secret. - -#### 2. `WINDOWS_CERTIFICATE_PASSWORD` -- **Description**: Password for the .pfx certificate -- **Value**: The password you set when creating/exporting the .pfx file +| Secret | Description | +| --- | --- | +| `SM_API_KEY` | KeyLocker API key. Also acts as the on/off switch: signing steps are gated on it being non-empty. | +| `SM_CLIENT_CERT_FILE_B64` | Base64-encoded PKCS#12 client authentication certificate. | +| `SM_CLIENT_CERT_PASSWORD` | Password for that client certificate. | +| `SM_KEYPAIR_ALIAS` | Alias of the signing keypair to use. | +| `SM_HOST` | DigiCert ONE host, e.g. `https://clientauth.one.digicert.com`. | ### Optional Secrets for Tauri Updater @@ -110,23 +105,23 @@ The Windows signing configuration is already set up: ### 2. GitHub Workflow (.github/workflows/tauri-build.yml) -The workflow includes three Windows signing steps: +The workflow includes four Windows signing steps, all gated on `SM_API_KEY` being +set and the ref being the release branch: -1. **Import Certificate**: Decodes and imports the .pfx certificate into Windows certificate store -2. **Build Tauri App**: Builds and signs the application using the imported certificate -3. **Verify Signature**: Validates that both .exe and .msi files are properly signed +1. **Setup DigiCert KeyLocker**: Installs the DigiCert signing tools via `digicert/ssm-code-signing` +2. **Setup DigiCert KeyLocker Certificate**: Writes the client cert and exports the PKCS#11 config +3. **Configure Windows code signing / Build Tauri app**: Signs through the PKCS#11 provider +4. **Verify Windows Code Signature**: Validates that the .exe and .msi are properly signed ## Testing the Setup ### 1. Local Testing (Windows Only) -Before pushing to GitHub, test locally: +KeyLocker is CI-only. To check signing locally, install your own certificate into +the Windows store and point Tauri at it; the build no longer reads any certificate +from an environment variable. ```powershell -# Set environment variables -$env:WINDOWS_CERTIFICATE = [Convert]::ToBase64String([IO.File]::ReadAllBytes("certificate.pfx")) -$env:WINDOWS_CERTIFICATE_PASSWORD = "your-certificate-password" - # Build the application cd frontend npm run tauri build @@ -191,9 +186,10 @@ Look for: - Consider EV certificate for immediate reputation ### Certificate Not Found During Build -- Verify `WINDOWS_CERTIFICATE` secret is set -- Check base64 encoding is correct (no extra whitespace) -- Ensure password is correct +- Verify `SM_API_KEY` is present in the `release-signing` environment. If it is empty + the signing steps skip silently and the build succeeds unsigned. +- Check `SM_CLIENT_CERT_FILE_B64` base64 encoding is correct (no extra whitespace) +- Ensure `SM_CLIENT_CERT_PASSWORD` and `SM_KEYPAIR_ALIAS` match the DigiCert keypair ## Security Best Practices @@ -220,11 +216,10 @@ Look for: ## Certificate Lifecycle ### Before Expiration -1. Obtain new certificate from CA (typically annual renewal) -2. Convert to .pfx format if needed -3. Update `WINDOWS_CERTIFICATE` secret with new base64-encoded certificate -4. Update `WINDOWS_CERTIFICATE_PASSWORD` if password changed -5. Test build to verify new certificate works +1. Renew the certificate in the DigiCert ONE console (typically annual) +2. If the keypair alias changed, update `SM_KEYPAIR_ALIAS` in the `release-signing` environment +3. If the client authentication certificate was reissued, update `SM_CLIENT_CERT_FILE_B64` and `SM_CLIENT_CERT_PASSWORD` +4. Test build to verify the new certificate works ### Expired Certificates - Signed binaries remain valid (timestamp proves signing time) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventController.java b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventController.java index a14f243678..aac1c3364b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventController.java @@ -22,15 +22,13 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import stirling.software.common.model.ApplicationProperties; -import stirling.software.proprietary.policy.config.PolicyManagementAuthority; - /** - * Read and triage recorded failures for the caller's team. Note the absence of a team parameter: - * the team comes from the authenticated principal, never the request. + * Read and triage recorded failures. Note the absence of a team parameter: the team comes from the + * authenticated principal, never the request. * - *

Reviewing failures is a leader-level capability, gated the same way policy editing is: see - * {@link #requireFailureReviewAllowed()}. + *

Every endpoint is open to any authenticated user and scoped in the service instead: a leader + * reads and closes the whole team's failures, everyone else their own. Nothing here decides who may + * do what, so the two cannot drift apart. */ @Slf4j @RestController @@ -46,21 +44,21 @@ public class FileRunEventController { private static final int DEFAULT_LIMIT = 50; private final FileRunEventService service; - private final PolicyManagementAuthority policyManagementAuthority; - private final ApplicationProperties applicationProperties; @GetMapping @Operation( summary = "List recorded failures", description = - "Failures recorded for the caller's team, newest first. Each row carries its" - + " available actions already resolved.") + "Failures the caller may see, newest first: their team's for a leader, their own" + + " for everyone else. Each row carries its available actions already" + + " resolved.") public FileRunEventsResponse list( // Spring's converter 400s on a value outside the enum, so no hand-rolled parse. @RequestParam(required = false) FileRunEventStatus status, @RequestParam(required = false) String kindId, @RequestParam(required = false) Integer limit) { - requireFailureReviewAllowed(); + // No role gate: the service scopes the read instead, so a member gets their own failures + // and a leader the team's. int cappedLimit = Math.min(limit == null ? DEFAULT_LIMIT : Math.max(1, limit), MAX_LIMIT); List events = @@ -82,7 +80,8 @@ public class FileRunEventController { @PathVariable String eventId, @PathVariable String actionId, @RequestBody(required = false) ActionRequest request) { - requireFailureReviewAllowed(); + // No role gate: the service decides, which lets someone close their own failure while + // still keeping a colleague's out of reach. Map inputs = request == null ? Map.of() : request.safeInputs(); try { FileRunEvent updated = service.dispatch(eventId, actionId, inputs); @@ -97,9 +96,9 @@ public class FileRunEventController { summary = "Report a failure hit in the editor", description = "For failures the server never sees, because the editor calls tools directly." - + " Open to any authenticated user, unlike the read and triage endpoints:" - + " whoever's work failed can say so, and a leader reviews it. Rejected" - + " with 400 if it names more files than one report may carry.") + + " Open to any authenticated user: whoever's work failed can say so, and" + + " reads it back scoped to themselves. Rejected with 400 if it names" + + " more files than one report may carry.") public ResponseEntity report(@RequestBody EditorFailureReport report) { if (report == null || !report.hasOperation()) { throw new ResponseStatusException( @@ -128,8 +127,8 @@ public class FileRunEventController { summary = "Close the incidents about files deleted from the editor", description = "Deleting the document leaves nothing to act on, so its incidents drop out of" - + " the queue while the rows stay for audit. Open to any authenticated" - + " user, and applies only to their own editor rows.") + + " the queue while the rows stay for audit. Applies only to the" + + " caller's own editor rows, however senior they are.") public ResponseEntity filesRemoved(@RequestBody(required = false) RemovedFiles request) { service.forgetFiles(request == null ? List.of() : request.safeFileIds()); // No body: the editor is telling the server, not asking it anything. @@ -143,29 +142,11 @@ public class FileRunEventController { "The failure registry. Lets a client describe kinds it was not built with, and" + " doubles as the probe for whether failure tracking exists at all.") public List kinds() { - requireFailureReviewAllowed(); + // The registry is copy and metadata, not anyone's data, and a member needs it to render the + // failures they can already see. return Arrays.stream(FailureKind.values()).map(FailureKindView::of).toList(); } - /** - * Triage is for a team leader (SaaS) or admin (self-hosted), mirroring {@code - * PolicyController.requirePolicyEditingAllowed()} rather than inventing a second notion of who - * manages a team's automation: a member can trigger runs, a leader reviews them. - * - *

Login disabled means a single-user deployment with no roles to tell apart, the same - * carve-out the policy endpoints make. Team scoping is separate, and lives in the service. - */ - private void requireFailureReviewAllowed() { - if (!applicationProperties.getSecurity().isEnableLogin()) { - return; - } - if (!policyManagementAuthority.canEditPolicies()) { - throw new ResponseStatusException( - HttpStatus.FORBIDDEN, - "Recorded failures may only be reviewed by a team leader"); - } - } - /** * A closed row is a conflict rather than a bad request: the request was well-formed and would * have been valid a moment earlier. diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventRepository.java index 1bee2398f7..1930b36e6a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventRepository.java @@ -20,26 +20,33 @@ public interface FileRunEventRepository extends JpaRepository{@code actor} narrows to one person's own failures. Null means the whole team, which only + * a leader ever asks for: see {@code FileRunEventService#readScope}. */ @Query( "select e from FileRunEventEntity e where ((:teamId is null and e.teamId is null) or" + " e.teamId = :teamId) and e.status in :statuses" - + " and (:kindId is null or e.kindId = :kindId) order by e.lastSeenAt desc") + + " and (:kindId is null or e.kindId = :kindId)" + + " and (:actor is null or e.actor = :actor) order by e.lastSeenAt desc") List findByTeamAndStatusIn( @Param("teamId") Long teamId, @Param("statuses") List statuses, @Param("kindId") String kindId, + @Param("actor") String actor, Pageable pageable); /** As {@link #findByTeamAndStatusIn} but for exactly one status, for the surface's filters. */ @Query( "select e from FileRunEventEntity e where ((:teamId is null and e.teamId is null) or" + " e.teamId = :teamId) and e.status = :status" - + " and (:kindId is null or e.kindId = :kindId) order by e.lastSeenAt desc") + + " and (:kindId is null or e.kindId = :kindId)" + + " and (:actor is null or e.actor = :actor) order by e.lastSeenAt desc") List findByTeamAndStatus( @Param("teamId") Long teamId, @Param("status") FileRunEventStatus status, @Param("kindId") String kindId, + @Param("actor") String actor, Pageable pageable); /** diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventService.java b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventService.java index 57eb4698e4..fa858b6e22 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventService.java @@ -13,12 +13,14 @@ import stirling.software.common.service.UserServiceInterface; import stirling.software.proprietary.policy.config.PolicyManagementAuthority; /** - * Reads and acts on incidents for the calling user's team. + * Reads and acts on the incidents the calling user is allowed to see, which is where that decision + * is made: a leader reads and closes the whole team's failures, everyone else their own. Keeping it + * here rather than on the endpoints means the read and the triage cannot drift apart. * *

Team scoping mirrors {@code PolicyAccessGuard}: everyone sees only their own team's rows, the * team always comes from the authenticated principal, and scoping applies only when login is * enabled so single-user deployments keep working. When the team cannot be resolved the caller - * reads nothing; see {@link #scope()}. + * reads nothing; see {@link #readScope()}. */ @Slf4j @Service @@ -41,7 +43,9 @@ public class FileRunEventService { */ public List report(EditorFailureReport report) { FailureKind kind = FailureKind.byErrorCode(report.errorCode()).orElse(FailureKind.UNKNOWN); - Long teamId = scope().teamId(); + // The caller's team, not their read scope: recording is open to everyone, and a reader who + // may see nothing still has their failure filed under the team it happened in. + Long teamId = currentTeamId(); String actor = currentActor(); String detail = detailFor(report); @@ -81,10 +85,14 @@ public class FileRunEventService { * cleared cache or another device never will. Rows left open that way are retention's problem, * not this method's. * + *

Narrowed to the caller's own rows however senior they are, which is why it passes {@link + * #currentActor()} rather than the read scope's actor: file ids are minted by each client, so a + * leader reading with a null actor would match every unattributed row in the team. + * * @return how many incidents were closed */ public int forgetFiles(List fileIds) { - TeamScope scope = scope(); + ReadScope scope = readScope(); if (!scope.permitted()) { return 0; } @@ -92,13 +100,16 @@ public class FileRunEventService { return store.markFilesRemoved(scope.teamId(), currentActor(), named); } - /** The calling user's events, newest first. Empty when their team cannot be resolved. */ + /** + * The events the caller may read, newest first: the team's for a leader, their own for everyone + * else. Empty when their team cannot be resolved. + */ public List list(FileRunEventStatus status, String kindId, int limit) { - TeamScope scope = scope(); + ReadScope scope = readScope(); if (!scope.permitted()) { return List.of(); } - return store.list(scope.teamId(), status, kindId, limit); + return store.list(scope.teamId(), status, kindId, scope.actor(), limit); } /** @@ -108,7 +119,13 @@ public class FileRunEventService { * event's kind does not declare the action, or the event is already closed */ public FileRunEvent dispatch(String eventId, String actionId, Map inputs) { - TeamScope scope = scope(); + // Whoever can see it can close it: a leader for the whole team, everyone else for the + // failures they caused. Someone who fixes their own problem should not have to ask a leader + // to clear the row. + // + // Closing the row is all this covers. Acting on the document behind it, such as supplying a + // password for a retry, would need its own permission, and no such action exists yet. + ReadScope scope = readScope(); if (!scope.permitted()) { // Reported as "no such event", the same as an id from another team, so the response // does @@ -118,6 +135,12 @@ public class FileRunEventService { } FileRunEvent event = store.find(eventId, scope.teamId()) + // Reported as "no such event" rather than a refusal, so a member cannot + // learn that a colleague's incident exists by trying to close it. + .filter( + found -> + scope.actor() == null + || scope.actor().equals(found.actor())) .orElseThrow( () -> new FailureActionException( @@ -179,31 +202,55 @@ public class FileRunEventService { } /** - * Which rows the caller may touch, since a null team id means two different things. Login - * disabled is the self-hosted setup with no users or teams, where unteamed rows are everyone's, - * as {@code PolicyAccessGuard} also treats them. Login enabled with no resolvable team reads - * nothing, because unteamed rows there are shared by every team's ad-hoc runs. + * Which rows the caller may read. A leader reviews the whole team's, as before. Everyone else + * reads the failures they caused themselves: a member can already report one, so letting them + * see their own back is what makes telling them about it worth anything, and it exposes nothing + * of a colleague's. + * + *

A null team id means two different things. Login disabled is the self-hosted setup with no + * users or teams, where unteamed rows are everyone's, as {@code PolicyAccessGuard} also treats + * them. Login enabled with no resolvable team reads nothing, because unteamed rows there are + * shared by every team's ad-hoc runs. */ - private TeamScope scope() { + private ReadScope readScope() { if (!enforced()) { - return TeamScope.of(null); + return ReadScope.wholeTeam(null); } - Long teamId = policyManagementAuthority.currentUserTeamId(); - return teamId == null ? TeamScope.denied() : TeamScope.of(teamId); + Long teamId = currentTeamId(); + if (teamId == null) { + return ReadScope.denied(); + } + if (policyManagementAuthority.canEditPolicies()) { + return ReadScope.wholeTeam(teamId); + } + // Narrowing to "mine" needs a name to narrow by. Without one the filter would be dropped + // and a member would read the whole team, so refuse rather than widen. + String actor = currentActor(); + return actor == null ? ReadScope.denied() : ReadScope.mine(teamId, actor); } /** - * The caller's readable team, or a refusal. {@code teamId} is only meaningful when permitted. + * What the caller may read. {@code actor} is the person to narrow to, or null for the whole + * team; both are only meaningful when permitted. */ - private record TeamScope(boolean permitted, Long teamId) { + private record ReadScope(boolean permitted, Long teamId, String actor) { - static TeamScope of(Long teamId) { - return new TeamScope(true, teamId); + static ReadScope wholeTeam(Long teamId) { + return new ReadScope(true, teamId, null); } - static TeamScope denied() { - return new TeamScope(false, null); + static ReadScope mine(Long teamId, String actor) { + return new ReadScope(true, teamId, actor); } + + static ReadScope denied() { + return new ReadScope(false, null, null); + } + } + + /** The team a row belongs to, which is nobody's when there are no teams to belong to. */ + private Long currentTeamId() { + return enforced() ? policyManagementAuthority.currentUserTeamId() : null; } private String currentActor() { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventStore.java index cf336ab76e..2ed86e61b2 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventStore.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventStore.java @@ -121,16 +121,19 @@ public class FileRunEventStore { * *

Both filters live in the query, before the limit: filtering an already-limited page could * return nothing while matching rows exist. + * + *

{@code actor} narrows to one person's own failures, or reads the whole team when null. Who + * gets which is the service's decision, not this method's. */ @Transactional(readOnly = true) public List list( - Long teamId, FileRunEventStatus status, String kindId, int limit) { + Long teamId, FileRunEventStatus status, String kindId, String actor, int limit) { Pageable page = PageRequest.of(0, Math.max(1, limit)); List rows = status == null ? repository.findByTeamAndStatusIn( - teamId, FileRunEventStatus.open(), kindId, page) - : repository.findByTeamAndStatus(teamId, status, kindId, page); + teamId, FileRunEventStatus.open(), kindId, actor, page) + : repository.findByTeamAndStatus(teamId, status, kindId, actor, page); return rows.stream().map(FileRunEvent::of).toList(); } 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 1a424176ce..0d75866a6f 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 @@ -130,7 +130,15 @@ public class PolicyEngine { // worker. String principal = currentActingPrincipal(); return submitForPrincipal( - principal, principal, policyId, definition, inputs, listener, null, null); + principal, + principal, + principal, + policyId, + definition, + inputs, + listener, + null, + null); } /** Run a stored policy on demand. {@code enabled} gates triggers, not explicit runs. */ @@ -158,6 +166,10 @@ public class PolicyEngine { // they can download their enforced file; otherwise an org-wide policy's output is owned by // the admin and the triggering user is denied it. Trigger-fired runs have no such user, so // the owner owns those outputs. + // + // The triggering user is also carried on the run, as the actor of any failure it records: + // null for a trigger-fired run, which is what makes an unattended incident ownerless rather + // than the owner's problem. Three identities, deliberately not interchangeable. String triggeringUser = currentActingPrincipal(); String fileOwner = triggeringUser != null ? triggeringUser : policy.owner(); // Stored supporting files (certificates, watermark images, ...) load here, before the @@ -172,6 +184,7 @@ public class PolicyEngine { return submitForPrincipal( policy.owner(), fileOwner, + triggeringUser, policy.id(), definition, // main's asset-resolved inputs, not the raw ones: stored certificates and watermark @@ -185,6 +198,7 @@ public class PolicyEngine { private PolicyRunHandle submitForPrincipal( String billingPrincipal, String fileOwner, + String triggeringUser, String policyId, PipelineDefinition definition, PolicyInputs inputs, @@ -199,7 +213,8 @@ public class PolicyEngine { if (policyId != null) { taskManager.putMetadata(runId, "policyId", policyId); } - PolicyRun run = new PolicyRun(runId, policyId, definition, sourceId, fileIdentity); + PolicyRun run = + new PolicyRun(runId, policyId, definition, sourceId, fileIdentity, triggeringUser); registry.register(run); CompletableFuture completion = new CompletableFuture<>(); PolicyProgressListener tracking = trackingListener(runId, run, listener); @@ -364,12 +379,14 @@ public class PolicyEngine { taskManager.setError(run.getRunId(), message); // No exception to classify here: nothing was thrown by a tool, the run simply was not // admitted. Record it explicitly so a run lost to load pressure is still accounted for. + // Attributed like any other failure: a user whose run was refused is still the person + // holding that document, and an unattended sweep's run carries no triggering user. failureRecorder.recordRunFailureAs( FailureKind.UNKNOWN, run.getRunId(), run.getPolicyId(), run.getSourceId(), - null, + run.getTriggeringUser(), message); completion.complete(run); } @@ -379,6 +396,11 @@ public class PolicyEngine { /** * Record why a run failed. Called after the run's own state transition and task-manager update, * so a recording problem cannot change the outcome the caller observes. + * + *

The actor is the run's triggering user, not the MDC audit principal: that carries the + * BILLING identity, which for a stored policy is always its owner. Reading it here filed every + * failure under the owner — hiding an attended failure from the member who caused it and holds + * the document, and leaving an unattended sweep's failure looking attended. */ private void recordFailure(PolicyRun run, String message, Throwable cause) { failureRecorder.recordRunFailure( @@ -386,7 +408,7 @@ public class PolicyEngine { run.getPolicyId(), run.getSourceId(), run.getFileIdentity(), - MDC.get(AUDIT_PRINCIPAL_MDC_KEY), + run.getTriggeringUser(), message, cause); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyRun.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyRun.java index 65cfa658ca..8eb0cfcc73 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyRun.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyRun.java @@ -35,6 +35,14 @@ public class PolicyRun { */ private final String fileIdentity; + /** + * The user who triggered this run, or null when nothing attended it (a trigger-fired sweep). + * Recorded as a failure's actor, so an attended failure is handed to the person holding the + * document. Deliberately not the billing principal: a shared policy is billed to its owner, who + * may never have touched the file. + */ + private final String triggeringUser; + private final Instant createdAt = Instant.now(); private volatile PolicyRunStatus status = PolicyRunStatus.PENDING; @@ -63,22 +71,24 @@ public class PolicyRun { private volatile Instant updatedAt = Instant.now(); /** - * Both references are required rather than defaulted: a run with neither is a real case (a - * user's upload, an ad-hoc pipeline), but it should be stated at the call site. Overloads that - * omitted them would make losing the attribution the frictionless option, which is how both - * fields went unpopulated in the first place. + * All three attribution references are required rather than defaulted: a run with none is a + * real case (an unattended sweep of a generator pipeline), but it should be stated at the call + * site. Overloads that omitted them would make losing the attribution the frictionless option, + * which is how {@code sourceId} and {@code fileIdentity} went unpopulated in the first place. */ public PolicyRun( String runId, String policyId, PipelineDefinition definition, String sourceId, - String fileIdentity) { + String fileIdentity, + String triggeringUser) { this.runId = runId; this.policyId = policyId; this.sourceId = sourceId; this.definition = definition; this.fileIdentity = fileIdentity; + this.triggeringUser = triggeringUser; } public int stepCount() { diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventControllerTest.java index 088cffe8ec..58b8d1b408 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventControllerTest.java @@ -51,9 +51,7 @@ class FileRunEventControllerTest { List.of(new AcknowledgeAction(store), new DismissAction(store))); controller = new FileRunEventController( - new FileRunEventService(store, registry, authority, userService, props), - authority, - props); + new FileRunEventService(store, registry, authority, userService, props)); lenient().when(authority.canEditPolicies()).thenReturn(true); lenient().when(authority.currentUserTeamId()).thenReturn(TEAM); @@ -61,14 +59,27 @@ class FileRunEventControllerTest { } private FileRunEvent given(FailureKind kind, Long teamId, String fileId) { + return recorded("author@example.com", kind, teamId, fileId, "run-1"); + } + + /** + * As {@link #given} but naming who hit it, in its own run. A RUN-scoped kind keys on the run, + * so two rows sharing one run id are one incident, however they differ otherwise. + */ + private FileRunEvent givenHitBy(String actor, FailureKind kind, Long teamId, String fileId) { + return recorded(actor, kind, teamId, fileId, "run-" + fileId); + } + + private FileRunEvent recorded( + String actor, FailureKind kind, Long teamId, String fileId, String runId) { return store.record( new RecordFailure( kind, FailureOrigin.POLICY, teamId, - "author@example.com", + actor, "policy-1", - "run-1", + runId, null, fileId, "the raw failure message")); @@ -281,48 +292,68 @@ class FileRunEventControllerTest { } @Nested - @DisplayName("only a team leader may review failures") + @DisplayName("a leader reviews the team's failures, everyone else their own") class Authorization { @Test - void aMemberCannotListThem() { + void aMemberSeesTheirOwnFailuresAndNobodyElses() { + // A member can report a failure, so they get to see it back. It must not widen to a + // colleague's. + givenHitBy("reviewer@example.com", FailureKind.UNKNOWN, TEAM, "mine"); + givenHitBy("colleague@example.com", FailureKind.UNKNOWN, TEAM, "theirs"); when(authority.canEditPolicies()).thenReturn(false); - assertThatThrownBy(() -> controller.list(null, null, null)) - .isInstanceOf(ResponseStatusException.class) - .satisfies( - e -> - assertThat(((ResponseStatusException) e).getStatusCode()) - .isEqualTo(HttpStatus.FORBIDDEN)); + assertThat(controller.list(null, null, null).events()) + .extracting(FileRunEventView::fileId) + .containsExactly("mine"); } @Test - void aMemberCannotDispatchAnAction() { - // The read being refused is not enough on its own: an id learned any other way must - // not let a member close another user's failure. - FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f-1"); - when(authority.canEditPolicies()).thenReturn(false); + void aLeaderSeesTheWholeTeams() { + givenHitBy("reviewer@example.com", FailureKind.UNKNOWN, TEAM, "mine"); + givenHitBy("colleague@example.com", FailureKind.UNKNOWN, TEAM, "theirs"); + when(authority.canEditPolicies()).thenReturn(true); - assertThatThrownBy(() -> controller.act(event.id(), "DISMISS", null)) - .isInstanceOf(ResponseStatusException.class) - .satisfies( - e -> - assertThat(((ResponseStatusException) e).getStatusCode()) - .isEqualTo(HttpStatus.FORBIDDEN)); + assertThat(controller.list(null, null, null).events()) + .extracting(FileRunEventView::fileId) + .containsExactlyInAnyOrder("mine", "theirs"); } @Test - void theRegistryIsAlsoLeaderOnly() { + void aMemberMayCloseTheirOwn() { + // Someone who fixes their own problem should not have to ask a leader to clear the row. + FileRunEvent mine = + givenHitBy("reviewer@example.com", FailureKind.UNKNOWN, TEAM, "mine"); when(authority.canEditPolicies()).thenReturn(false); - assertThatThrownBy(() -> controller.kinds()) - .isInstanceOf(ResponseStatusException.class); + assertThat(controller.act(mine.id(), "DISMISS", null).status()) + .isEqualTo(FileRunEventStatus.DISMISSED); + } + + @Test + void aMemberCannotCloseAColleaguesEvenKnowingTheId() { + // Refusing the read is not enough on its own: an id learned any other way must not work + // either. Answered as not-found rather than forbidden, so trying does not confirm the + // row exists. + FileRunEvent theirs = + givenHitBy("colleague@example.com", FailureKind.UNKNOWN, TEAM, "theirs"); + when(authority.canEditPolicies()).thenReturn(false); + + assertThat(statusOf(() -> controller.act(theirs.id(), "DISMISS", null))) + .isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + void theRegistryIsOpenBecauseItIsCopyNotData() { + // A member renders the failures they can see, so they need the labels for them. No role + // stub: the point is that kinds() never asks. + assertThat(controller.kinds()).isNotEmpty(); } @Test void loginDisabledTrustsTheLocalOperator() { - // A single-user deployment has no roles to distinguish, so the role gate must not lock - // the only user out of their own failures. + // A single-user deployment has no roles to distinguish, so the narrowing must not leave + // the only user reading nothing. ApplicationProperties unsecured = new ApplicationProperties(); unsecured.getSecurity().setEnableLogin(false); FileRunEventController noLogin = @@ -335,9 +366,7 @@ class FileRunEventControllerTest { new DismissAction(store))), authority, userService, - unsecured), - authority, - unsecured); + unsecured)); assertThatCode(() -> noLogin.list(null, null, null)).doesNotThrowAnyException(); // Not merely permitted: the role is never consulted at all, which is what makes the @@ -382,9 +411,7 @@ class FileRunEventControllerTest { new DismissAction(store))), authority, userService, - unsecured), - authority, - unsecured); + unsecured)); given(FailureKind.UNKNOWN, null, "unteamed"); given(FailureKind.UNKNOWN, TEAM, "teamed"); @@ -399,9 +426,9 @@ class FileRunEventControllerTest { class Reporting { @Test - void aMemberMayReportEvenThoughTheyMayNotRead() { - // The asymmetry is the point: anyone whose work failed can say so, but only a leader - // reviews the queue. + void aMemberMayReportAndThenSeeTheirOwnReport() { + // Reporting was always open to a member; reading their own back is the round trip that + // makes the report worth anything to them. when(authority.canEditPolicies()).thenReturn(false); assertThatCode( @@ -410,8 +437,9 @@ class FileRunEventControllerTest { new EditorFailureReport( "compress", "E004", List.of("f-1"), "boom"))) .doesNotThrowAnyException(); - assertThatThrownBy(() -> controller.list(null, null, null)) - .isInstanceOf(ResponseStatusException.class); + assertThat(controller.list(null, null, null).events()) + .extracting(FileRunEventView::fileId) + .containsExactly("f-1"); } @Test @@ -441,7 +469,7 @@ class FileRunEventControllerTest { new EditorFailureReport("compress", "E004", atLimit, "boom"); assertThat(controller.report(report).getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); - assertThat(store.list(TEAM, null, null, EditorFailureReport.MAX_FILE_IDS + 10)) + assertThat(store.list(TEAM, null, null, null, EditorFailureReport.MAX_FILE_IDS + 10)) .hasSize(EditorFailureReport.MAX_FILE_IDS); } @@ -463,7 +491,7 @@ class FileRunEventControllerTest { overLimit, "boom")))) .isEqualTo(HttpStatus.BAD_REQUEST); - assertThat(store.list(TEAM, null, null, EditorFailureReport.MAX_FILE_IDS + 10)) + assertThat(store.list(TEAM, null, null, null, EditorFailureReport.MAX_FILE_IDS + 10)) .isEmpty(); } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventHttpIntegrationTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventHttpIntegrationTest.java index b3ee4b7d15..375e46f4ad 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventHttpIntegrationTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventHttpIntegrationTest.java @@ -438,11 +438,8 @@ class FileRunEventHttpIntegrationTest { } @Bean - FileRunEventController fileRunEventController( - FileRunEventService service, - PolicyManagementAuthority authority, - ApplicationProperties props) { - return new FileRunEventController(service, authority, props); + FileRunEventController fileRunEventController(FileRunEventService service) { + return new FileRunEventController(service); } } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventServiceTest.java index fbbe8e6359..4e6508bed9 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventServiceTest.java @@ -54,6 +54,9 @@ class FileRunEventServiceTest { lenient().when(authority.currentUserTeamId()).thenReturn(TEAM); lenient().when(userService.getCurrentUsername()).thenReturn(ACTOR); + // A leader unless a test says otherwise: most of these are about team scoping, which is + // what a leader sees. The member narrowing has its own tests. + lenient().when(authority.canEditPolicies()).thenReturn(true); } private FileRunEvent given(FailureKind kind, Long teamId, String fileId) { @@ -291,7 +294,7 @@ class FileRunEventServiceTest { } @Nested - @DisplayName("team scoping") + @DisplayName("read scoping") class Scoping { @Test @@ -304,6 +307,68 @@ class FileRunEventServiceTest { .containsExactly("mine"); } + @Test + void aMemberReadsOnlyTheFailuresTheyCaused() { + // Reporting is open to a member, so reading their own back is what lets us tell them + // anything at all. A colleague's must not come with it. + store.record(RecordFailure.forEditor(FailureKind.UNKNOWN, TEAM, ACTOR, "mine", "boom")); + store.record( + RecordFailure.forEditor( + FailureKind.UNKNOWN, TEAM, "colleague@example.com", "theirs", "boom")); + when(authority.canEditPolicies()).thenReturn(false); + + assertThat(service.list(null, null, 50)) + .extracting(FileRunEvent::fileId) + .containsExactly("mine"); + } + + @Test + void aMemberWithNoResolvableNameReadsNothingRatherThanEverything() { + // Narrowing to "mine" needs a name to narrow by. Dropping the filter would hand the + // whole team to someone who may not have it. + given(FailureKind.UNKNOWN, TEAM, "mine"); + when(authority.canEditPolicies()).thenReturn(false); + when(userService.getCurrentUsername()).thenReturn(null); + + assertThat(service.list(null, null, 50)).isEmpty(); + } + + @Test + void aMemberCannotActOnAColleaguesRowEvenKnowingItsId() { + // Refusing the read is not enough on its own: an id learned any other way must not work + // either. Reported as not-found, so trying does not confirm the row exists. + FileRunEvent theirs = + store.record( + RecordFailure.forEditor( + FailureKind.UNKNOWN, + TEAM, + "colleague@example.com", + "theirs", + "boom")); + when(authority.canEditPolicies()).thenReturn(false); + + assertThatThrownBy(() -> service.dispatch(theirs.id(), "DISMISS", Map.of())) + .isInstanceOf(FailureActionException.class) + .extracting(e -> ((FailureActionException) e).getReason()) + .isEqualTo(FailureActionException.Reason.EVENT_NOT_FOUND); + + assertThat(store.find(theirs.id(), TEAM).orElseThrow().status()) + .isEqualTo(FileRunEventStatus.NEW); + } + + @Test + void aMemberMayCloseTheirOwn() { + // Someone who fixes their own problem should not have to ask a leader to clear the row. + FileRunEvent mine = + store.record( + RecordFailure.forEditor( + FailureKind.UNKNOWN, TEAM, ACTOR, "mine", "boom")); + when(authority.canEditPolicies()).thenReturn(false); + + assertThat(service.dispatch(mine.id(), "DISMISS", Map.of()).status()) + .isEqualTo(FileRunEventStatus.DISMISSED); + } + @Test void aCallerWhoseTeamCannotBeResolvedReadsNothing() { // A run with no stored policy is recorded unteamed, and those rows are shared by every @@ -399,7 +464,7 @@ class FileRunEventServiceTest { service.report( new EditorFailureReport("remove-password", "E004", List.of("f-1"), "boom")); - FileRunEvent event = store.list(TEAM, null, null, 10).getFirst(); + FileRunEvent event = store.list(TEAM, null, null, null, 10).getFirst(); assertThat(event.kind()).isEqualTo(FailureKind.INPUT_PASSWORD_PROTECTED); assertThat(event.origin()).isEqualTo(FailureOrigin.TOOL); assertThat(event.fileId()).isEqualTo("f-1"); @@ -412,16 +477,32 @@ class FileRunEventServiceTest { // session. service.report(new EditorFailureReport("compress", "E004", List.of("f-1"), "boom")); - FileRunEvent event = store.list(TEAM, null, null, 10).getFirst(); + FileRunEvent event = store.list(TEAM, null, null, null, 10).getFirst(); assertThat(event.teamId()).isEqualTo(TEAM); assertThat(event.actor()).isEqualTo(ACTOR); } + @Test + void stillFilesTheRowUnderTheTeamWhenTheReporterCannotBeNamed() { + // Recording is open to everyone and takes the caller's team, not their read scope: a + // reporter who cannot be named reads nothing back, but the row is still the team's + // rather than dropping into the unteamed bucket every team shares. No role stub either, + // since recording never asks. + when(userService.getCurrentUsername()).thenReturn(null); + + service.report(new EditorFailureReport("compress", "E004", List.of("f-1"), "boom")); + + assertThat(store.list(TEAM, null, null, null, 10)) + .singleElement() + .extracting(FileRunEvent::teamId) + .isEqualTo(TEAM); + } + @Test void recordsAnUnrecognisedCodeAsUnknownRatherThanDroppingIt() { service.report(new EditorFailureReport("ocr", "E999", List.of("f-1"), "no idea")); - assertThat(store.list(TEAM, null, null, 10).getFirst().kind()) + assertThat(store.list(TEAM, null, null, null, 10).getFirst().kind()) .isEqualTo(FailureKind.UNKNOWN); } @@ -429,7 +510,7 @@ class FileRunEventServiceTest { void recordsAnAbsentCodeAsUnknown() { service.report(new EditorFailureReport("ocr", null, List.of("f-1"), "network died")); - assertThat(store.list(TEAM, null, null, 10).getFirst().kind()) + assertThat(store.list(TEAM, null, null, null, 10).getFirst().kind()) .isEqualTo(FailureKind.UNKNOWN); } @@ -439,7 +520,7 @@ class FileRunEventServiceTest { new EditorFailureReport( "compress", "E004", List.of("f-1", "f-2", "f-3"), "boom")); - assertThat(store.list(TEAM, null, null, 10)) + assertThat(store.list(TEAM, null, null, null, 10)) .hasSize(3) .extracting(FileRunEvent::fileId) .containsExactlyInAnyOrder("f-1", "f-2", "f-3"); @@ -451,7 +532,7 @@ class FileRunEventServiceTest { service.report( new EditorFailureReport("compress", "E004", List.of("f-1"), "boom again")); - assertThat(store.list(TEAM, null, null, 10)) + assertThat(store.list(TEAM, null, null, null, 10)) .singleElement() .extracting(FileRunEvent::occurrences) .isEqualTo(2); @@ -461,7 +542,7 @@ class FileRunEventServiceTest { void recordsOneUnattributedIncidentWhenNoFileWasNamed() { service.report(new EditorFailureReport("compress", "E004", List.of(), "boom")); - assertThat(store.list(TEAM, null, null, 10)) + assertThat(store.list(TEAM, null, null, null, 10)) .singleElement() .extracting(FileRunEvent::fileId) .isNull(); @@ -476,7 +557,7 @@ class FileRunEventServiceTest { service.report(new EditorFailureReport("compress", "E004", many, "boom")); - assertThat(store.list(TEAM, null, null, 200)).hasSize(60); + assertThat(store.list(TEAM, null, null, null, 200)).hasSize(60); } @Test @@ -486,7 +567,7 @@ class FileRunEventServiceTest { service.report( new EditorFailureReport("remove-password", "E004", List.of("f-1"), "boom")); - FileRunEvent event = store.list(TEAM, null, null, 10).getFirst(); + FileRunEvent event = store.list(TEAM, null, null, null, 10).getFirst(); assertThat(event.detail()).contains("remove-password"); assertThat(event.fileId()).isEqualTo("f-1"); } @@ -499,7 +580,7 @@ class FileRunEventServiceTest { new EditorFailureReport( "compress", "E004", List.of("f-1"), "Failed on Q4 report.pdf")); - assertThat(store.list(TEAM, null, null, 10).getFirst().detail()) + assertThat(store.list(TEAM, null, null, null, 10).getFirst().detail()) .isEqualTo("compress: Failed on Q4 report.pdf"); } @@ -540,7 +621,7 @@ class FileRunEventServiceTest { reportedBy("alice@example.com", "a-1"); reportedBy("bob@example.com", "b-1"); - assertThat(store.list(TEAM, null, null, 10)) + assertThat(store.list(TEAM, null, null, null, 10)) .extracting(FileRunEvent::actor) .containsExactlyInAnyOrder("alice@example.com", "bob@example.com"); } @@ -550,7 +631,7 @@ class FileRunEventServiceTest { reportedBy("alice@example.com", "a-1"); reportedBy("alice@example.com", "a-2"); - assertThat(store.list(TEAM, null, null, 10)) + assertThat(store.list(TEAM, null, null, null, 10)) .extracting(FileRunEvent::fileId) .containsExactlyInAnyOrder("a-1", "a-2"); } @@ -560,7 +641,7 @@ class FileRunEventServiceTest { reportedBy("alice@example.com", "a-1"); reportedBy("alice@example.com", "a-1"); - assertThat(store.list(TEAM, null, null, 10)) + assertThat(store.list(TEAM, null, null, null, 10)) .singleElement() .extracting(FileRunEvent::occurrences) .isEqualTo(2); @@ -612,8 +693,11 @@ class FileRunEventServiceTest { } @Test - void aColleaguesIncidentIsUntouched() { - // File ids come from the client, so naming one must not close someone else's row. + void aColleaguesIncidentIsUntouchedEvenForALeader() { + // File ids come from the client, so naming one must not close someone else's row. The + // caller here is a leader, who reads the whole team: this path narrows to their own + // rows + // regardless, since a null actor would otherwise match every unattributed row. store.record( RecordFailure.forEditor( FailureKind.UNKNOWN, TEAM, "employee@example.com", "f-1", "theirs")); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventStoreDbTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventStoreDbTest.java index 949904dfb9..982d804354 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventStoreDbTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventStoreDbTest.java @@ -55,11 +55,16 @@ class FileRunEventStoreDbTest { } private RecordFailure failure(FailureKind kind, Long teamId, String fileId) { + return failure(kind, teamId, "author@example.com", fileId); + } + + /** As {@link #failure} but naming the actor, which is what the read scope narrows by. */ + private RecordFailure failure(FailureKind kind, Long teamId, String actor, String fileId) { return new RecordFailure( kind, FailureOrigin.POLICY, teamId, - "author@example.com", + actor, "policy-1", "run-1", null, @@ -74,16 +79,37 @@ class FileRunEventStoreDbTest { store.record(failure(FailureKind.UNKNOWN, OTHER_TEAM, "theirs")); store.record(failure(FailureKind.UNKNOWN, null, "unteamed")); - assertThat(store.list(TEAM, null, null, 10)) + assertThat(store.list(TEAM, null, null, null, 10)) .extracting(FileRunEvent::fileId) .containsExactly("ours"); // A plain `e.teamId = :teamId` would return nothing here: SQL equality against NULL is // never true, which is what the explicit null branch in the JPQL exists for. - assertThat(store.list(null, null, null, 10)) + assertThat(store.list(null, null, null, null, 10)) .extracting(FileRunEvent::fileId) .containsExactly("unteamed"); } + @Test + @DisplayName("actor narrowing is enforced by the query, within the team") + void actorNarrowingIsEnforcedBySql() { + // The clause that makes a member read only their own rows. Exercised here rather than only + // against the in-memory repository, which reimplements the filter in Java and would agree + // with a query that had lost it. + store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "mine@example.com", "f1")); + store.record( + failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "theirs@example.com", "f2")); + store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, null, "f3")); + + assertThat(store.list(TEAM, null, null, "mine@example.com", 10)) + .extracting(FileRunEvent::fileId) + .containsExactly("f1"); + // A null actor is "no filter", which is what a leader reads with: the whole team, including + // the rows nobody is named on. + assertThat(store.list(TEAM, null, null, null, 10)) + .extracting(FileRunEvent::fileId) + .containsExactlyInAnyOrder("f1", "f2", "f3"); + } + @Test @DisplayName("a fold lands on the row's current state, not the caller's snapshot") void foldTargetsTheCurrentRowNotACallersSnapshot() { @@ -170,7 +196,7 @@ class FileRunEventStoreDbTest { assertThat(replacement.id()).isNotEqualTo(first.id()); assertThat(replacement.occurrences()).isEqualTo(1); - assertThat(store.list(TEAM, null, null, 10)).hasSize(1); + assertThat(store.list(TEAM, null, null, null, 10)).hasSize(1); } @Test @@ -188,7 +214,7 @@ class FileRunEventStoreDbTest { FileRunEvent folded = store.record(secondSweep); assertThat(folded.occurrences()).isEqualTo(2); - assertThat(store.list(TEAM, null, null, 10)) + assertThat(store.list(TEAM, null, null, null, 10)) .as("one incident per document, however many runs it failed in") .extracting(FileRunEvent::fileId) .containsExactlyInAnyOrder("file-hash-a", "file-hash-b"); @@ -267,7 +293,7 @@ class FileRunEventStoreDbTest { store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "newer-" + i)); } - assertThat(store.list(TEAM, null, "UNKNOWN", 1)) + assertThat(store.list(TEAM, null, "UNKNOWN", null, 1)) .extracting(FileRunEvent::fileId) .containsExactly("old-unknown"); } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventStoreTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventStoreTest.java index 714d65c29c..97de1952e9 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventStoreTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventStoreTest.java @@ -281,7 +281,7 @@ class FileRunEventStoreTest { store.record(failure(FailureKind.UNKNOWN, TEAM, "mine", "a")); store.record(failure(FailureKind.UNKNOWN, OTHER_TEAM, "theirs", "b")); - assertThat(store.list(TEAM, null, null, 50)) + assertThat(store.list(TEAM, null, null, null, 50)) .extracting(FileRunEvent::fileId) .containsExactly("mine"); } @@ -293,7 +293,7 @@ class FileRunEventStoreTest { store.record(failure(FailureKind.UNKNOWN, null, "unteamed", "a")); store.record(failure(FailureKind.UNKNOWN, TEAM, "teamed", "b")); - assertThat(store.list(null, null, null, 50)) + assertThat(store.list(null, null, null, null, 50)) .extracting(FileRunEvent::fileId) .containsExactly("unteamed"); } @@ -305,8 +305,8 @@ class FileRunEventStoreTest { FileRunEvent event = store.record(failure(FailureKind.UNKNOWN, TEAM, "f1", "boom")); store.applyStatus(event.id(), TEAM, FileRunEventStatus.DISMISSED, "reviewer"); - assertThat(store.list(TEAM, null, null, 10)).isEmpty(); - assertThat(store.list(TEAM, FileRunEventStatus.DISMISSED, null, 10)).hasSize(1); + assertThat(store.list(TEAM, null, null, null, 10)).isEmpty(); + assertThat(store.list(TEAM, FileRunEventStatus.DISMISSED, null, null, 10)).hasSize(1); } @Test @@ -314,7 +314,7 @@ class FileRunEventStoreTest { FileRunEvent event = store.record(failure(FailureKind.UNKNOWN, TEAM, "f1", "boom")); store.applyStatus(event.id(), TEAM, FileRunEventStatus.ACKNOWLEDGED, "reviewer"); - assertThat(store.list(TEAM, null, null, 10)).hasSize(1); + assertThat(store.list(TEAM, null, null, null, 10)).hasSize(1); } @Test @@ -323,7 +323,7 @@ class FileRunEventStoreTest { store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "closed", "b")); store.applyStatus(open.id(), TEAM, FileRunEventStatus.ACKNOWLEDGED, "me"); - assertThat(store.list(TEAM, FileRunEventStatus.ACKNOWLEDGED, null, 50)) + assertThat(store.list(TEAM, FileRunEventStatus.ACKNOWLEDGED, null, null, 50)) .extracting(FileRunEvent::fileId) .containsExactly("open"); } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/failure/InMemoryFileRunEventRepository.java b/app/proprietary/src/test/java/stirling/software/proprietary/failure/InMemoryFileRunEventRepository.java index 1873f6265b..6dbb16322b 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/failure/InMemoryFileRunEventRepository.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/failure/InMemoryFileRunEventRepository.java @@ -54,9 +54,18 @@ class InMemoryFileRunEventRepository implements FileRunEventRepository { return kindId == null || kindId.equals(entity.getKindId()); } + /** Null means the whole team, matching the JPQL's {@code :actor is null} branch. */ + private static boolean sameActor(FileRunEventEntity entity, String actor) { + return actor == null || actor.equals(entity.getActor()); + } + @Override public List findByTeamAndStatus( - Long teamId, FileRunEventStatus status, String kindId, Pageable pageable) { + Long teamId, + FileRunEventStatus status, + String kindId, + String actor, + Pageable pageable) { return page( newestFirst( rows.values().stream() @@ -64,7 +73,8 @@ class InMemoryFileRunEventRepository implements FileRunEventRepository { e -> sameTeam(e, teamId) && e.getStatus() == status - && sameKind(e, kindId)) + && sameKind(e, kindId) + && sameActor(e, actor)) .toList()), pageable); } @@ -142,7 +152,11 @@ class InMemoryFileRunEventRepository implements FileRunEventRepository { @Override public List findByTeamAndStatusIn( - Long teamId, List statuses, String kindId, Pageable pageable) { + Long teamId, + List statuses, + String kindId, + String actor, + Pageable pageable) { return page( newestFirst( rows.values().stream() @@ -150,7 +164,8 @@ class InMemoryFileRunEventRepository implements FileRunEventRepository { e -> sameTeam(e, teamId) && statuses.contains(e.getStatus()) - && sameKind(e, kindId)) + && sameKind(e, kindId) + && sameActor(e, actor)) .toList()), pageable); } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/failure/PolicyFailureAttributionTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/failure/PolicyFailureAttributionTest.java new file mode 100644 index 0000000000..2a71c236a7 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/failure/PolicyFailureAttributionTest.java @@ -0,0 +1,264 @@ +package stirling.software.proprietary.failure; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.BeforeEach; +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.junit.jupiter.api.io.TempDir; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.slf4j.MDC; +import org.springframework.core.io.ByteArrayResource; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.service.FileStorage; +import stirling.software.common.service.InternalApiClient; +import stirling.software.common.service.JobOwnershipService; +import stirling.software.common.service.JobQueue; +import stirling.software.common.service.ResourceMonitor; +import stirling.software.common.service.TaskManager; +import stirling.software.common.service.ToolMetadataService; +import stirling.software.common.service.UserServiceInterface; +import stirling.software.common.util.TempFileManager; +import stirling.software.common.util.TempFileRegistry; +import stirling.software.proprietary.policy.asset.InProcessPolicyAssetStore; +import stirling.software.proprietary.policy.asset.PolicyAssetResolver; +import stirling.software.proprietary.policy.config.PolicyManagementAuthority; +import stirling.software.proprietary.policy.engine.PolicyEngine; +import stirling.software.proprietary.policy.engine.PolicyExecutor; +import stirling.software.proprietary.policy.engine.PolicyRunRegistry; +import stirling.software.proprietary.policy.model.OutputSpec; +import stirling.software.proprietary.policy.model.PipelineStep; +import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.model.PolicyInputs; +import stirling.software.proprietary.policy.output.InlineOutputSink; +import stirling.software.proprietary.policy.output.PolicyOutputResolver; +import stirling.software.proprietary.policy.progress.PolicyProgressListener; +import stirling.software.proprietary.policy.source.InProcessSourceStore; +import stirling.software.proprietary.policy.store.PolicyStore; + +import tools.jackson.databind.json.JsonMapper; + +/** + * Pins what the engine records as a failure's actor against what a reader gets back, because the + * two sides used to assert independently: the engine's test passed {@code any()} for the actor, and + * the service's fixtures assumed an actor the engine never actually produced. Every collaborator + * between the failing tool call and the read is real here, so a regression in either one fails. + * + *

This is what makes this PR's promise hold. Reads are narrowed to the rows the caller is the + * actor on, so if the engine names the wrong person, a member reads nothing at all. + * + *

The bug it exists for: the engine recorded the BILLING principal as the actor, which for a + * stored policy is always its owner. So an attended failure was filed under someone who never + * touched the document, and the member who did could not see it. + */ +@ExtendWith(MockitoExtension.class) +class PolicyFailureAttributionTest { + + private static final String ROTATE = "/api/v1/general/rotate-pdf"; + private static final Long TEAM = 3L; + + @Mock private InternalApiClient internalApiClient; + @Mock private ToolMetadataService toolMetadataService; + @Mock private TaskManager taskManager; + @Mock private FileStorage fileStorage; + @Mock private JobOwnershipService jobOwnershipService; + @Mock private ResourceMonitor resourceMonitor; + @Mock private JobQueue jobQueue; + @Mock private PolicyStore policyStore; + @Mock private PolicyManagementAuthority authority; + @Mock private UserServiceInterface userService; + + @TempDir Path tempDir; + + private PolicyEngine engine; + private FileRunEventService service; + + @BeforeEach + void setUp() { + ApplicationProperties props = new ApplicationProperties(); + props.getSecurity().setEnableLogin(true); + props.getSystem().getTempFileManagement().setBaseTmpDir(tempDir.toString()); + props.getSystem().getTempFileManagement().setPrefix("failure-attribution-test-"); + + FileRunEventStore store = new FileRunEventStore(new InMemoryFileRunEventRepository()); + service = + new FileRunEventService( + store, + new FailureActionRegistry( + List.of(new AcknowledgeAction(store), new DismissAction(store))), + authority, + userService, + props); + + PolicyFailureRecorder recorder = + new PolicyFailureRecorder( + new FailureClassifier(JsonMapper.builder().build()), store, policyStore); + PolicyExecutor executor = + new PolicyExecutor( + internalApiClient, + toolMetadataService, + new TempFileManager(new TempFileRegistry(), props), + JsonMapper.builder().build()); + engine = + new PolicyEngine( + executor, + taskManager, + new PolicyRunRegistry(new ApplicationProperties()), + recorder, + fileStorage, + jobOwnershipService, + List.of(new InlineOutputSink(fileStorage)), + new PolicyOutputResolver(new InProcessSourceStore()), + resourceMonitor, + jobQueue, + new PolicyAssetResolver(new InProcessPolicyAssetStore())); + + lenient() + .when(jobOwnershipService.createScopedJobKey(anyString())) + .thenAnswer(invocation -> invocation.getArgument(0)); + lenient().when(resourceMonitor.shouldQueueJob(anyInt())).thenReturn(false); + lenient().when(toolMetadataService.isMultiInput(anyString())).thenReturn(false); + // The team is resolved from the policy, so the recorded row lands in the reader's team. + lenient().when(policyStore.get(anyString())).thenReturn(Optional.of(sharedPolicy())); + lenient().when(authority.currentUserTeamId()).thenReturn(TEAM); + } + + /** Alice's policy, shared with her team. Bob is a member of it and does not own it. */ + private static Policy sharedPolicy() { + return new Policy( + "p1", + "rotate", + "alice", + true, + List.of(), + List.of(new PipelineStep(ROTATE, Map.of())), + OutputSpec.inline(), + TEAM); + } + + /** + * Run the shared policy so its single tool step fails, as {@code triggeredBy} (null = sweep). + */ + private void runAndFail(String triggeredBy, String sourceId, String fileIdentity) + throws Exception { + when(internalApiClient.post(eq(ROTATE), any())).thenThrow(new RuntimeException("boom")); + if (triggeredBy != null) { + MDC.put("auditPrincipal", triggeredBy); + } + try { + engine.runPolicy( + sharedPolicy(), + PolicyInputs.of(List.of(pdf())), + PolicyProgressListener.NOOP, + sourceId, + fileIdentity) + .completion() + .get(10, TimeUnit.SECONDS); + } finally { + MDC.remove("auditPrincipal"); + } + } + + private static ByteArrayResource pdf() { + return new ByteArrayResource("input".getBytes()) { + @Override + public String getFilename() { + return "input.pdf"; + } + }; + } + + /** Read as a plain member, who is narrowed to the rows they are the actor on. */ + private FileRunEvent asMember(String reader) { + lenient().when(userService.getCurrentUsername()).thenReturn(reader); + lenient().when(authority.canEditPolicies()).thenReturn(false); + List visible = service.list(null, null, 10); + return visible.isEmpty() ? null : visible.getFirst(); + } + + /** Read as a team leader, who reviews the whole team's incidents. */ + private FileRunEvent asReviewer(String reader) { + lenient().when(userService.getCurrentUsername()).thenReturn(reader); + lenient().when(authority.canEditPolicies()).thenReturn(true); + return service.list(null, null, 10).getFirst(); + } + + @Nested + @DisplayName("a non-owner runs a shared policy on their own upload") + class AttendedByANonOwner { + + @Test + void theTriggeringUserCanReadTheFailureTheyCaused() throws Exception { + runAndFail("bob", null, "bob-doc-1"); + + // The whole point: Bob's read scope narrows to his own rows, so the row only reaches + // him if the engine named him. Before the fix this list was empty. + FileRunEvent mine = asMember("bob"); + assertThat(mine).as("bob must be able to see the failure he caused").isNotNull(); + assertThat(mine.actor()).isEqualTo("bob"); + } + + @Test + void thePolicyOwnerIsNotNamedAsTheActorMerelyForBeingBilled() throws Exception { + runAndFail("bob", null, "bob-doc-1"); + + // Alice owns the policy and pays for the run, but she never touched the document. + assertThat(asReviewer("alice").actor()).isEqualTo("bob"); + } + + @Test + void aColleagueWhoDidNotTriggerItCannotSeeItAtAll() throws Exception { + runAndFail("bob", null, "bob-doc-1"); + + assertThat(asMember("carol")).isNull(); + } + } + + @Nested + @DisplayName("an unattended sweep pulls a file from a source") + class UnattendedSweep { + + @Test + void theRowIsRecordedWithNoActorWhileStillBillingTheOwner() throws Exception { + runAndFail(null, "src-watched-folder", "file-hash-1"); + + assertThat(asReviewer("alice").actor()) + .as("a trigger-fired run has no user to name") + .isNull(); + } + + @Test + void theSourceThatFedItIsStillRecorded() throws Exception { + runAndFail(null, "src-watched-folder", "file-hash-1"); + + FileRunEvent unattended = asReviewer("alice"); + assertThat(unattended.sourceId()).isEqualTo("src-watched-folder"); + assertThat(unattended.fileId()).isEqualTo("file-hash-1"); + } + + @Test + void aMemberDoesNotInheritAnUnattendedFailureAsTheirOwn() throws Exception { + // An unowned row must not fall to whoever happens to be reading: with no actor there is + // nothing for a member's narrowed read to match. + runAndFail(null, "src-watched-folder", "file-hash-1"); + + assertThat(asMember("bob")).isNull(); + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/failure/PolicyFailureRecorderTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/failure/PolicyFailureRecorderTest.java index e1a17843f0..73c5add6ad 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/failure/PolicyFailureRecorderTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/failure/PolicyFailureRecorderTest.java @@ -95,7 +95,7 @@ class PolicyFailureRecorderTest { "Policy run failed: locked", passwordFailure()); - FileRunEvent event = store.list(TEAM, null, null, 10).getFirst(); + FileRunEvent event = store.list(TEAM, null, null, null, 10).getFirst(); assertThat(event.kind()).isEqualTo(FailureKind.INPUT_PASSWORD_PROTECTED); assertThat(event.runId()).isEqualTo("run-1"); assertThat(event.policyId()).isEqualTo("policy-1"); @@ -121,7 +121,7 @@ class PolicyFailureRecorderTest { "Policy run failed: something we do not recognise", new RuntimeException("boom")); - FileRunEvent event = store.list(TEAM, null, null, 10).getFirst(); + FileRunEvent event = store.list(TEAM, null, null, null, 10).getFirst(); assertThat(event.kind()).isEqualTo(FailureKind.UNKNOWN); assertThat(event.detail()).contains("something we do not recognise"); } @@ -139,7 +139,7 @@ class PolicyFailureRecorderTest { "Policy run failed: java.lang.NullPointerException", new RuntimeException("npe")); - FileRunEvent event = store.list(TEAM, null, null, 10).getFirst(); + FileRunEvent event = store.list(TEAM, null, null, null, 10).getFirst(); assertThat(event.kind()).isEqualTo(FailureKind.UNKNOWN); assertThat(event.detail()).contains("NullPointerException"); } @@ -152,7 +152,7 @@ class PolicyFailureRecorderTest { recorder.recordRunFailureAs( FailureKind.UNKNOWN, "run-3", "policy-1", null, null, "could not be queued"); - assertThat(store.list(TEAM, null, null, 10)).hasSize(1); + assertThat(store.list(TEAM, null, null, null, 10)).hasSize(1); } @Test @@ -180,7 +180,7 @@ class PolicyFailureRecorderTest { "locked", passwordFailure()); - List events = store.list(TEAM, null, null, 10); + List events = store.list(TEAM, null, null, null, 10); assertThat(events).hasSize(2); assertThat(events).allMatch(event -> event.occurrences() == 1); assertThat(events) @@ -203,7 +203,7 @@ class PolicyFailureRecorderTest { "locked", passwordFailure()); - FileRunEvent event = store.list(TEAM, null, null, 10).getFirst(); + FileRunEvent event = store.list(TEAM, null, null, null, 10).getFirst(); assertThat(event.sourceId()).isEqualTo("src-s3-invoices"); assertThat(event.actor()).isNull(); } @@ -218,7 +218,7 @@ class PolicyFailureRecorderTest { recorder.recordRunFailureAs( FailureKind.UNKNOWN, "run-2", "policy-1", "src-b", null, "unreachable"); - assertThat(store.list(TEAM, null, null, 10)) + assertThat(store.list(TEAM, null, null, null, 10)) .hasSize(2) .extracting(FileRunEvent::sourceId) .containsExactlyInAnyOrder("src-a", "src-b"); @@ -245,7 +245,7 @@ class PolicyFailureRecorderTest { "locked", passwordFailure()); - assertThat(store.list(TEAM, null, null, 10)) + assertThat(store.list(TEAM, null, null, null, 10)) .singleElement() .extracting(FileRunEvent::occurrences) .isEqualTo(2); @@ -263,7 +263,7 @@ class PolicyFailureRecorderTest { recorder.recordRunFailure( "run-1", "policy-1", null, null, null, "boom", new RuntimeException()); - assertThat(store.list(TEAM, null, null, 10)).hasSize(1); + assertThat(store.list(TEAM, null, null, null, 10)).hasSize(1); } @Test @@ -273,8 +273,8 @@ class PolicyFailureRecorderTest { recorder.recordRunFailure( "run-1", null, null, null, null, "boom", new RuntimeException()); - assertThat(store.list(null, null, null, 10)).hasSize(1); - assertThat(store.list(TEAM, null, null, 10)).isEmpty(); + assertThat(store.list(null, null, null, null, 10)).hasSize(1); + assertThat(store.list(TEAM, null, null, null, 10)).isEmpty(); } @Test @@ -293,7 +293,7 @@ class PolicyFailureRecorderTest { new RuntimeException())) .doesNotThrowAnyException(); // Still recorded, just unteamed: a lookup problem must not lose the incident. - assertThat(store.list(null, null, null, 10)).hasSize(1); + assertThat(store.list(null, null, null, null, 10)).hasSize(1); } } @@ -344,7 +344,7 @@ class PolicyFailureRecorderTest { "no cause", null)) .doesNotThrowAnyException(); - assertThat(store.list(TEAM, null, null, 10).getFirst().kind()) + assertThat(store.list(TEAM, null, null, null, 10).getFirst().kind()) .isEqualTo(FailureKind.UNKNOWN); } } @@ -362,7 +362,7 @@ class PolicyFailureRecorderTest { recorder.recordRunFailure( "run-1", "policy-1", null, null, null, "boom", new IOException("x")); - List events = store.list(TEAM, null, null, 10); + List events = store.list(TEAM, null, null, null, 10); assertThat(events).hasSize(1); assertThat(events.getFirst().occurrences()).isEqualTo(2); } @@ -377,7 +377,7 @@ class PolicyFailureRecorderTest { recorder.recordRunFailure( "run-2", "policy-1", null, null, null, "boom", new IOException("x")); - assertThat(store.list(TEAM, null, null, 10)).hasSize(2); + assertThat(store.list(TEAM, null, null, null, 10)).hasSize(2); } } } 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 c38f5c7d89..84e9998b90 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 @@ -216,7 +216,7 @@ class PolicyControllerTest { } private static PolicyRunHandle handle(String runId) { - PolicyRun run = new PolicyRun(runId, null, definitionWithStep(), null, null); + PolicyRun run = new PolicyRun(runId, null, definitionWithStep(), null, null, null); return new PolicyRunHandle(runId, CompletableFuture.completedFuture(run)); } @@ -317,7 +317,7 @@ class PolicyControllerTest { @Test @DisplayName("returns the run view when present") void found() { - PolicyRun run = new PolicyRun("run-3", null, definitionWithStep(), null, null); + PolicyRun run = new PolicyRun("run-3", null, definitionWithStep(), null, null, null); when(runRegistry.get("run-3")).thenReturn(run); ResponseEntity response = controller.status("run-3"); @@ -346,11 +346,11 @@ class PolicyControllerTest { @Test @DisplayName("excludes ad-hoc runs and runs owned by others") void filtersRuns() { - PolicyRun adHoc = new PolicyRun("adhoc", null, definitionWithStep(), null, null); + PolicyRun adHoc = new PolicyRun("adhoc", null, definitionWithStep(), null, null, null); PolicyRun ownedStored = - new PolicyRun("owned", "policy-A", definitionWithStep(), null, null); + new PolicyRun("owned", "policy-A", definitionWithStep(), null, null, null); PolicyRun otherStored = - new PolicyRun("other", "policy-B", definitionWithStep(), null, null); + new PolicyRun("other", "policy-B", definitionWithStep(), null, null, null); when(runRegistry.all()).thenReturn(List.of(adHoc, ownedStored, otherStored)); // ownedByCurrentUser: strip then re-apply scope reproduces the key only for the owned diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyEngineTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyEngineTest.java index b3b45dcd96..1efe094fe4 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyEngineTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyEngineTest.java @@ -9,6 +9,7 @@ import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.doThrow; @@ -231,20 +232,15 @@ class PolicyEngineTest { @Test void recordsWhichSourceFedAFailedRun() throws Exception { // The source is threaded onto the run so an unattended failure is attributable: there is no - // user to name for a file that arrived from a bucket. + // user to name for a file that arrived from a bucket. The actor is asserted null rather + // than + // any(): a loose matcher here is what let the owner be recorded as the actor unnoticed. when(toolMetadataService.isMultiInput(ROTATE)).thenReturn(false); when(internalApiClient.post(eq(ROTATE), any())).thenThrow(new RuntimeException("boom")); PolicyRunHandle handle = engine.runPolicy( - new Policy( - "p1", - "rotate", - "owner", - true, - List.of(), - List.of(new PipelineStep(ROTATE, Map.of())), - OutputSpec.inline()), + policyOwnedBy("owner"), PolicyInputs.of(List.of(pdf("input", "input.pdf"))), PolicyProgressListener.NOOP, "src-s3-invoices", @@ -257,11 +253,136 @@ class PolicyEngineTest { any(), eq("src-s3-invoices"), eq("file-hash-1"), - any(), + isNull(), anyString(), any(Throwable.class)); } + @Test + void anAttendedFailureIsRecordedAgainstWhoTriggeredItNotThePolicysOwner() throws Exception { + // Bob runs Alice's shared policy on his own upload and it fails. The row must name Bob: he + // is the one whose browser holds the document, and a member's read scope narrows to their + // own rows, so filing it under Alice hides it from the only person who can act on it. + when(toolMetadataService.isMultiInput(ROTATE)).thenReturn(false); + when(internalApiClient.post(eq(ROTATE), any())).thenThrow(new RuntimeException("boom")); + + MDC.put("auditPrincipal", "bob"); // the request thread's acting user + try { + engine.runPolicy( + policyOwnedBy("alice"), + PolicyInputs.of(List.of(pdf("input", "input.pdf"))), + PolicyProgressListener.NOOP, + null, + "bob-doc-1") + .completion() + .get(10, TimeUnit.SECONDS); + } finally { + MDC.remove("auditPrincipal"); + } + + verify(failureRecorder) + .recordRunFailure( + anyString(), + any(), + isNull(), + eq("bob-doc-1"), + eq("bob"), + anyString(), + any(Throwable.class)); + } + + @Test + void anUnattendedFailureIsRecordedWithNoActorWhileStillBillingTheOwner() throws Exception { + // The two identities are deliberately different, and this pins both at once: usage is + // charged to the owner (MDC audit principal on the worker), but the failure has no actor, + // which is what makes it UNOWNED and hands the owner actions to the team's reviewer. + when(toolMetadataService.isMultiInput(ROTATE)).thenReturn(false); + String[] principalAtDispatch = {""}; + when(internalApiClient.post(eq(ROTATE), any())) + .thenAnswer( + invocation -> { + principalAtDispatch[0] = MDC.get("auditPrincipal"); + throw new RuntimeException("boom"); + }); + + // No MDC and no security context: exactly a trigger-fired sweep. + engine.runPolicy( + policyOwnedBy("alice"), + PolicyInputs.of(List.of(pdf("input", "input.pdf"))), + PolicyProgressListener.NOOP, + "src-watched-folder", + "file-hash-1") + .completion() + .get(10, TimeUnit.SECONDS); + + assertEquals("alice", principalAtDispatch[0], "billing must still be the policy owner"); + verify(failureRecorder) + .recordRunFailure( + anyString(), + any(), + eq("src-watched-folder"), + eq("file-hash-1"), + isNull(), + anyString(), + any(Throwable.class)); + } + + @Test + void anAdHocFailureIsRecordedAgainstTheSubmittingUser() throws Exception { + // An ad-hoc run has no stored policy, so the submitter is both payer and actor. Asserted so + // the two entry points cannot drift apart. + when(toolMetadataService.isMultiInput(ROTATE)).thenReturn(false); + when(internalApiClient.post(eq(ROTATE), any())).thenThrow(new RuntimeException("boom")); + + MDC.put("auditPrincipal", "bob"); + try { + engine.submit( + definition(new PipelineStep(ROTATE, Map.of())), + PolicyInputs.of(List.of(pdf("input", "input.pdf"))), + PolicyProgressListener.NOOP) + .completion() + .get(10, TimeUnit.SECONDS); + } finally { + MDC.remove("auditPrincipal"); + } + + verify(failureRecorder) + .recordRunFailure( + anyString(), + any(), + any(), + any(), + eq("bob"), + anyString(), + any(Throwable.class)); + } + + @Test + void aRunRefusedAtAdmissionIsRecordedAgainstWhoeverTriggeredIt() throws Exception { + // The queue-full path records its own row, and it is attended: the user is still holding + // the + // document, so it must reach them rather than landing as an ownerless incident. + when(resourceMonitor.shouldQueueJob(anyInt())).thenReturn(true); + CompletableFuture rejected = new CompletableFuture<>(); + rejected.completeExceptionally(new RuntimeException("Job queue full")); + doReturn(rejected).when(jobQueue).queueJob(anyString(), anyInt(), any(), anyLong()); + + MDC.put("auditPrincipal", "bob"); + try { + engine.runPolicy( + policyOwnedBy("alice"), + PolicyInputs.of(List.of(pdf("input", "input.pdf"))), + PolicyProgressListener.NOOP, + null, + "bob-doc-1"); + } finally { + MDC.remove("auditPrincipal"); + } + + verify(failureRecorder) + .recordRunFailureAs(any(), anyString(), any(), isNull(), eq("bob"), anyString()); + } + @Test void recordingAFailureNeverChangesTheRunsOutcome() throws Exception { // Recording is best-effort: losing the incident row is bad, but turning a classified @@ -540,6 +661,17 @@ class PolicyEngineTest { return new PipelineDefinition("test", List.of(steps), OutputSpec.inline()); } + private static Policy policyOwnedBy(String owner) { + return new Policy( + "p1", + "rotate", + owner, + true, + List.of(), + List.of(new PipelineStep(ROTATE, Map.of())), + OutputSpec.inline()); + } + private void stubEndpoint(String endpoint, Resource body) { when(internalApiClient.post(eq(endpoint), any())).thenReturn(ResponseEntity.ok(body)); } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunRegistryTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunRegistryTest.java index 1eb0a1a1fe..692fe84c37 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunRegistryTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunRegistryTest.java @@ -101,6 +101,7 @@ class PolicyRunRegistryTest { null, new PipelineDefinition(runId, List.of(), List.of()), null, + null, null); registry.register(run); return run; diff --git a/app/saas/src/main/java/stirling/software/saas/config/MigrationOwnedSchemaFilter.java b/app/saas/src/main/java/stirling/software/saas/config/MigrationOwnedSchemaFilter.java new file mode 100644 index 0000000000..4b2496bf15 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/config/MigrationOwnedSchemaFilter.java @@ -0,0 +1,108 @@ +package stirling.software.saas.config; + +import org.hibernate.boot.model.relational.Namespace; +import org.hibernate.boot.model.relational.Sequence; +import org.hibernate.mapping.Table; +import org.hibernate.tool.schema.spi.SchemaFilter; +import org.hibernate.tool.schema.spi.SchemaFilterProvider; + +/** + * Hides the migration-owned tables from Hibernate's schema management. + * + *

Wired on the SaaS profile only, via {@code hibernate.hbm2ddl.schema_filter_provider}. + * Self-hosted is untouched: there Hibernate rightly owns everything. + * + *

Why a filter rather than simply turning {@code ddl-auto} off: the SaaS database has two + * writers. The Supabase migrations own the SaaS tables, and Hibernate owns roughly thirty tables + * inherited from the self-hosted app that no migration has ever created. Turn {@code ddl-auto} off + * and a fresh preview branch is missing that second half; leave it on and Hibernate is free to + * reconcile migration-owned tables, which is how {@code team_memberships.role} ended up widened to + * varchar(255) and needed a migration to put back. A filter keeps the first half working and makes + * the second impossible. + * + *

Note that this is a per-table filter, not a per-schema one. Hibernate's schema management runs + * over every mapped entity regardless of namespace, so moving SaaS tables to their own schema would + * not by itself keep Hibernate out of them. {@link SaasSchemaOwnership} is the register; this class + * only applies it. + * + *

Foreign keys still cross the line, on purpose. Several inherited tables reference + * migration-owned ones — {@code folders}, {@code stored_files} and {@code file_shares} all point at + * {@code users}/{@code teams}. Hibernate's {@code SchemaCreatorImpl.createForeignKeys} and {@code + * AbstractSchemaMigrator.applyForeignKeys} check {@code includeTable} against the *owning* table + * only and then emit every foreign key on it, without consulting the referenced table. So excluding + * {@code users} does not cost the branch its referential integrity, and a branch ends up matching + * staging. It does mean the referenced tables have to exist by the time Hibernate runs, which holds + * because a Supabase branch applies its migrations at build time and the app connects afterwards. + * + *

Known gap: this cannot detect drift. Filtering means Hibernate never inspects these + * tables, and {@link #getValidateFilter()} extends that to {@code validate}, so nothing here + * compares a migration-owned table against its entity. Combined with the register being a + * hand-maintained list of another repo's contents (see {@link SaasSchemaOwnership}), there is + * currently no automated signal when the register, the entities and the database disagree. That is + * a deliberate trade for a boot that does not fail on differences we accept, not a claim that drift + * cannot happen; a non-fatal drift report is the missing piece and belongs outside this class. + */ +public class MigrationOwnedSchemaFilter implements SchemaFilterProvider, SchemaFilter { + + /** + * The one decision this class makes. Everything Hibernate might do to a table it does not own — + * create, alter, drop, truncate — is refused. + */ + @Override + public boolean includeTable(Table table) { + return !SaasSchemaOwnership.isMigrationOwned(table.getName()); + } + + /** + * Namespaces are never filtered. The inherited tables and the migration-owned ones share {@code + * stirling_pdf}, so excluding the namespace would take both with it. + */ + @Override + public boolean includeNamespace(Namespace namespace) { + return true; + } + + /** + * Sequences are left alone. Every id here is an identity column rather than a mapped generator, + * so there is nothing for Hibernate to create; filtering them would be dead code pretending to + * be a safeguard. + */ + @Override + public boolean includeSequence(Sequence sequence) { + return true; + } + + @Override + public SchemaFilter getCreateFilter() { + return this; + } + + @Override + public SchemaFilter getMigrateFilter() { + return this; + } + + @Override + public SchemaFilter getDropFilter() { + return this; + } + + @Override + public SchemaFilter getTruncatorFilter() { + return this; + } + + /** + * Validation is filtered too, which is the one debatable call here. + * + *

Letting it through would give a useful signal when a migration-owned table drifts from its + * entity. But {@code ddl-auto=validate} fails startup, and it would fail on differences we have + * deliberately accepted — {@code ai_create_sessions} carries columns from a reverted feature + * that nothing maps, for instance. A boot failure over a table we have chosen not to manage is + * noise, so the rule stays uniform: Hibernate does not concern itself with these tables at all. + */ + @Override + public SchemaFilter getValidateFilter() { + return this; + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/config/SaasSchemaOwnership.java b/app/saas/src/main/java/stirling/software/saas/config/SaasSchemaOwnership.java new file mode 100644 index 0000000000..dffd1eccf1 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/config/SaasSchemaOwnership.java @@ -0,0 +1,117 @@ +package stirling.software.saas.config; + +import java.util.Set; + +/** + * Which side owns each table in the SaaS database. + * + *

The SaaS schema has two writers and always has: the Supabase migrations in the + * Stirling-PDF-SaaS repo, and Hibernate's {@code ddl-auto}. That was a convention rather than a + * rule, and it leaked twice. An older {@code ddl-auto} run widened {@code team_memberships.role} to + * varchar(255), which needed a dedicated migration to repair because RLS policies depended on the + * column. Separately {@code payg_instance_usage} went months with an entity and no migration, so it + * simply did not exist on a fresh preview branch. + * + *

This class makes the boundary explicit and {@code SaasSchemaOwnershipTest} makes it binding: + * every {@code @Entity} the SaaS app maps must appear in exactly one of these two sets. A new + * entity fails the build until someone states who owns its table, which is the decision that was + * previously made by accident. + * + *

{@link MigrationOwnedSchemaFilter} enforces it at runtime: Hibernate is never shown the + * migration-owned tables, so it cannot create, alter or drop them whatever {@code ddl-auto} says. + * Inherited tables stay under Hibernate, so a preview branch built from migrations alone still + * heals itself on first boot. + * + *

What this does not catch. The register is a hand-maintained copy of what lives in + * another repository, and only one direction is enforced. The test fails when a *new* entity + * appears with no owner. It cannot notice a table changing sides: write a migration for {@code + * folders} in Stirling-PDF-SaaS and nothing here changes, the test still passes, and Hibernate + * carries on managing a table the migrations now own — which is precisely how {@code + * team_memberships.role} got widened. Adding a migration for anything in {@link #HIBERNATE_MANAGED} + * therefore means moving it to {@link #MIGRATION_OWNED} in the same change; nothing will remind + * you. Making that structural rather than remembered is what moving the SaaS tables into their own + * schema would buy, and is the reason this class is a stepping stone rather than the answer. + */ +public final class SaasSchemaOwnership { + + /** + * Created and altered by the Supabase migrations. Hibernate must not touch these: the + * migrations carry constraints, defaults and RLS policies it knows nothing about and would + * reconcile away. + */ + public static final Set MIGRATION_OWNED = + Set.of( + "ai_create_sessions", + "audit_events", + "authorities", + "billing_subscriptions", + "job_artifact_hash", + "legal_consent", + "linked_instance", + "payg_instance_usage", + "payg_meter_event_log", + "payg_prepaid_bundle", + "payg_shadow_charge", + "payg_team_extensions", + "persistent_logins", + "pricing_policy", + "processing_job", + "processing_job_step", + "procurement_agreement_signature", + "procurement_deal", + "procurement_quote", + "saas_team_extensions", + "saas_user_extensions", + "sessions", + "team_invitations", + "team_memberships", + "teams", + "users", + "wallet_entitlement_snapshot", + "wallet_ledger", + "wallet_policy"); + + /** + * Inherited from the self-hosted app, where {@code ddl-auto} owns the schema and no Supabase + * migration exists. Deliberately left under Hibernate so a fresh branch gets them on first + * boot. + */ + public static final Set HIBERNATE_MANAGED = + Set.of( + "account_link_device_credential", + "account_link_metered_signature", + "account_link_sync_state", + "account_link_usage_counter", + "api_key_daily_usage", + "api_keys", + "file_encryption_keys", + "file_run_events", + "file_share_accesses", + "file_shares", + "folders", + "integration_configs", + "invite_tokens", + "jwt_signing_keys", + "policies", + "policy_assets", + "policy_completed_migrations", + "policy_processed_files", + "policy_source_doc_counts", + "policy_source_doc_totals", + "policy_sources", + "resource_grants", + "storage_cleanup_entries", + "stored_file_blobs", + "stored_files", + "user_license_settings", + "user_server_certificates", + "workflow_participants", + "workflow_sessions"); + + private SaasSchemaOwnership() {} + + /** Case-insensitive: Hibernate hands us whatever casing the mapping used. */ + public static boolean isMigrationOwned(String tableName) { + return tableName != null && MIGRATION_OWNED.contains(tableName.toLowerCase()); + } +} diff --git a/app/saas/src/main/resources/application-saas.properties b/app/saas/src/main/resources/application-saas.properties index f0630aaac1..e3c32c429e 100644 --- a/app/saas/src/main/resources/application-saas.properties +++ b/app/saas/src/main/resources/application-saas.properties @@ -27,6 +27,13 @@ spring.jpa.properties.hibernate.hbm2ddl.create_namespaces=true spring.jpa.hibernate.ddl-auto=update +# ...but only over the tables Hibernate actually owns. The SaaS database has two writers: the +# Supabase migrations own the SaaS tables, Hibernate owns ~30 inherited from the self-hosted app that +# no migration has ever created. This filter hides the former from schema management, so ddl-auto can +# still heal a fresh preview branch without being free to reconcile a migration-owned table — which +# is how team_memberships.role ended up widened to varchar(255). Register: SaasSchemaOwnership. +spring.jpa.properties.hibernate.hbm2ddl.schema_filter_provider=stirling.software.saas.config.MigrationOwnedSchemaFilter + # ---------- Supabase JWT auth ---------- # Required: set SAAS_DB_PROJECT_REF via env. app.supabase.project-ref=${SAAS_DB_PROJECT_REF:} diff --git a/app/saas/src/test/java/stirling/software/saas/config/MigrationOwnedSchemaFilterTest.java b/app/saas/src/test/java/stirling/software/saas/config/MigrationOwnedSchemaFilterTest.java new file mode 100644 index 0000000000..956654384e --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/config/MigrationOwnedSchemaFilterTest.java @@ -0,0 +1,129 @@ +package stirling.software.saas.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.InputStream; +import java.util.Properties; + +import org.hibernate.mapping.Table; +import org.hibernate.tool.schema.spi.SchemaFilter; +import org.hibernate.tool.schema.spi.SchemaFilterProvider; +import org.junit.jupiter.api.Test; + +/** + * Covers {@link MigrationOwnedSchemaFilter} and, just as importantly, its wiring. + * + *

{@link SaasSchemaOwnershipTest} proves the register is complete; nothing proved the filter + * applies it, or that Hibernate is even asking. A typo in the {@code + * hibernate.hbm2ddl.schema_filter_provider} key, a stale fully-qualified name after a package move, + * or a getter returning null would all leave every migration-owned table exposed to {@code + * ddl-auto} with a fully green build. Hence the property assertion below, which is the only thing + * here that would catch that. + */ +class MigrationOwnedSchemaFilterTest { + + private static final String FILTER_PROPERTY = + "spring.jpa.properties.hibernate.hbm2ddl.schema_filter_provider"; + + private final MigrationOwnedSchemaFilter filter = new MigrationOwnedSchemaFilter(); + + /** "orm" is Hibernate's own default contributor; the value is irrelevant to the filter. */ + private static Table table(String name) { + return new Table("orm", name); + } + + @Test + void migrationOwnedTablesAreHiddenFromHibernate() { + assertThat(filter.includeTable(table("teams"))).isFalse(); + assertThat(filter.includeTable(table("users"))).isFalse(); + assertThat(filter.includeTable(table("team_memberships"))).isFalse(); + assertThat(filter.includeTable(table("payg_instance_usage"))).isFalse(); + } + + @Test + void inheritedTablesStayUnderHibernate() { + assertThat(filter.includeTable(table("folders"))).isTrue(); + assertThat(filter.includeTable(table("stored_files"))).isTrue(); + assertThat(filter.includeTable(table("api_keys"))).isTrue(); + } + + @Test + void anUnknownTableIsLeftToHibernate() { + // Fail-open is the right default: an unrecognised table is either brand new or from a + // module + // we do not know about, and SaasSchemaOwnershipTest is what stops it staying unrecognised. + assertThat(filter.includeTable(table("no_such_table"))).isTrue(); + } + + @Test + void casingDoesNotDefeatTheFilter() { + assertThat(filter.includeTable(table("TEAMS"))).isFalse(); + assertThat(filter.includeTable(table("Team_Memberships"))).isFalse(); + } + + /** + * Foreign keys from an inherited table into a migration-owned one survive the filter. + * + *

Worth pinning, because it is not obvious and it decides whether a preview branch keeps + * referential integrity. {@code folders}, {@code stored_files} and {@code file_shares} all + * reference {@code users}/{@code teams}, which the filter excludes. Hibernate 7.2's {@code + * SchemaCreatorImpl.createForeignKeys} (and {@code AbstractSchemaMigrator.applyForeignKeys}) + * tests {@code includeTable} against the *owning* table only, then emits every foreign key on + * it; the referenced table is never consulted. So the constraints are still created and a + * branch matches staging. + * + *

The one thing this depends on is ordering: the referenced tables have to exist first. They + * do, because a Supabase branch runs its migrations at build time and the app connects after. + */ + @Test + void foreignKeysIntoMigrationOwnedTablesAreStillEmitted() { + assertThat(filter.includeTable(table("folders"))).isTrue(); + assertThat(filter.includeTable(table("file_shares"))).isTrue(); + assertThat(filter.includeTable(table("users"))).isFalse(); + assertThat(filter.includeTable(table("teams"))).isFalse(); + } + + @Test + void everySchemaActionGetsTheSameFilter() { + assertThat(filter.getCreateFilter()).isSameAs(filter); + assertThat(filter.getMigrateFilter()).isSameAs(filter); + assertThat(filter.getDropFilter()).isSameAs(filter); + assertThat(filter.getTruncatorFilter()).isSameAs(filter); + assertThat(filter.getValidateFilter()).isSameAs(filter); + } + + @Test + void namespacesAndSequencesAreNeverFiltered() { + // Both share the stirling_pdf namespace, so filtering it would take the inherited tables + // with it. Neither argument is read, so nulls are fine and keep the test free of Hibernate + // bootstrap machinery. + assertThat(filter.includeNamespace(null)).isTrue(); + assertThat(filter.includeSequence(null)).isTrue(); + } + + @Test + void theFilterIsActuallyWiredIntoHibernate() throws Exception { + Properties properties = new Properties(); + try (InputStream in = getClass().getResourceAsStream("/application-saas.properties")) { + assertThat(in) + .as("application-saas.properties must be on the test classpath to check wiring") + .isNotNull(); + properties.load(in); + } + + String configured = properties.getProperty(FILTER_PROPERTY); + assertThat(configured) + .as( + "%s is unset, so Hibernate installs its default filter and every" + + " migration-owned table is back under ddl-auto", + FILTER_PROPERTY) + .isNotBlank(); + + Class wired = Class.forName(configured.trim()); + assertThat(SchemaFilterProvider.class) + .as("Hibernate only accepts a SchemaFilterProvider here") + .isAssignableFrom(wired); + assertThat(SchemaFilter.class).isAssignableFrom(wired); + assertThat(wired).isEqualTo(MigrationOwnedSchemaFilter.class); + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/config/SaasSchemaOwnershipTest.java b/app/saas/src/test/java/stirling/software/saas/config/SaasSchemaOwnershipTest.java new file mode 100644 index 0000000000..e07b1042e7 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/config/SaasSchemaOwnershipTest.java @@ -0,0 +1,167 @@ +package stirling.software.saas.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.boot.persistence.autoconfigure.EntityScan; +import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider; +import org.springframework.core.type.filter.AnnotationTypeFilter; +import org.springframework.util.ClassUtils; + +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +import stirling.software.proprietary.security.configuration.DatabaseConfig; + +/** + * Makes {@link SaasSchemaOwnership} binding rather than decorative. + * + *

Every {@code @Entity} the SaaS app maps has to be declared as owned by either the Supabase + * migrations or Hibernate. Adding an entity without saying which fails here, at build time, instead + * of months later on a preview branch that has no such table. That is not hypothetical: {@code + * payg_instance_usage} shipped with an entity and no migration and went unnoticed until a branch + * tried to use it. + * + *

"Maps" is meant precisely: the scan covers the packages named by the {@code @EntityScan} + * declarations the app actually boots with, not everything under {@code stirling.software}. See + * {@link #mappedPackages()}. Note this only enforces one direction — {@link SaasSchemaOwnership} + * documents the drift it cannot see. + */ +class SaasSchemaOwnershipTest { + + /** + * The packages the running app actually maps, read off the two {@code @EntityScan} declarations + * that define them rather than hardcoded. + * + *

Scanning all of {@code stirling.software} would be easier and wrong in a quiet way: it is + * a superset, so it would force ownership declarations for entities Hibernate never sees and + * let the register claim tables that do not exist as far as the SaaS app is concerned. Deriving + * the list means this test measures the same set Hibernate does, and follows a package being + * added or moved without anyone updating it here. + */ + private static Set mappedPackages() { + Set packages = new TreeSet<>(); + for (Class config : List.of(SaasJpaConfig.class, DatabaseConfig.class)) { + EntityScan scan = config.getAnnotation(EntityScan.class); + assertThat(scan) + .as("%s must carry @EntityScan, or its entities are not mapped", config) + .isNotNull(); + packages.addAll(Arrays.asList(scan.value())); + } + return packages; + } + + private static TreeMap mappedTables() { + ClassPathScanningCandidateComponentProvider scanner = + new ClassPathScanningCandidateComponentProvider(false); + scanner.addIncludeFilter(new AnnotationTypeFilter(Entity.class)); + TreeMap byTable = new TreeMap<>(); + for (String basePackage : mappedPackages()) { + for (BeanDefinition bd : scanner.findCandidateComponents(basePackage)) { + String className = bd.getBeanClassName(); + Class type; + try { + type = + ClassUtils.forName( + className, SaasSchemaOwnershipTest.class.getClassLoader()); + } catch (ClassNotFoundException | LinkageError e) { + continue; // not on this module's runtime classpath; nothing to own + } + Table table = type.getAnnotation(Table.class); + String name = + table != null && !table.name().isBlank() + ? table.name() + : camelToSnake(type.getSimpleName()); + byTable.put(name.toLowerCase(), className); + } + } + return byTable; + } + + /** Mirrors Spring Boot's default CamelCaseToUnderscoresNamingStrategy for an unnamed @Table. */ + private static String camelToSnake(String name) { + return name.replaceAll("([a-z0-9])([A-Z])", "$1_$2").toLowerCase(); + } + + @Test + void everyEntityTableIsOwnedByExactlyOneSide() { + TreeMap mapped = mappedTables(); + assertThat(mapped) + .as("entity scan found nothing, so this test proves nothing") + .isNotEmpty(); + // The scan is derived from @EntityScan now, so a package quietly dropped from either + // declaration would shrink it and weaken this test rather than fail it. These four straddle + // the two declarations, so losing either side fails here instead of silently checking less. + assertThat(mapped.keySet()) + .as("both @EntityScan declarations must have contributed to the scan") + .contains("users", "teams", "payg_instance_usage", "folders"); + + Set undeclared = new TreeSet<>(); + Set both = new TreeSet<>(); + for (String table : mapped.keySet()) { + boolean migration = SaasSchemaOwnership.MIGRATION_OWNED.contains(table); + boolean hibernate = SaasSchemaOwnership.HIBERNATE_MANAGED.contains(table); + if (migration && hibernate) both.add(table); + if (!migration && !hibernate) undeclared.add(table); + } + + assertThat(undeclared) + .as( + """ + These entity tables are not declared in SaasSchemaOwnership, so nobody owns \ + them. Decide and add each to exactly one set: + - MIGRATION_OWNED: also add a migration in Stirling-PDF-SaaS, or the table \ + will not exist on a fresh preview branch. + - HIBERNATE_MANAGED: only correct for a table inherited from the \ + self-hosted app that no Supabase migration creates. + Offending tables -> entities: %s""" + .formatted( + undeclared.stream() + .map(t -> t + " (" + mapped.get(t) + ")") + .toList())) + .isEmpty(); + + assertThat(both) + .as("declared as owned by both sides, which is the one thing it cannot be") + .isEmpty(); + } + + @Test + void theTwoSetsDoNotOverlap() { + Set overlap = new TreeSet<>(SaasSchemaOwnership.MIGRATION_OWNED); + overlap.retainAll(SaasSchemaOwnership.HIBERNATE_MANAGED); + assertThat(overlap).isEmpty(); + } + + @Test + void tableNamesAreLowercaseSoLookupsCannotMiss() { + // isMigrationOwned() lowercases its input; a capital in either set would be unreachable. + assertThat(SaasSchemaOwnership.MIGRATION_OWNED) + .allSatisfy(t -> assertThat(t).isEqualTo(t.toLowerCase())); + assertThat(SaasSchemaOwnership.HIBERNATE_MANAGED) + .allSatisfy(t -> assertThat(t).isEqualTo(t.toLowerCase())); + } + + @Test + void migrationOwnedTablesIncludeTheOnesThatBitUs() { + // team_memberships is the table an old ddl-auto run widened; payg_instance_usage is the one + // that had an entity and no migration. Both must be on the migrations' side of the line. + assertThat(SaasSchemaOwnership.MIGRATION_OWNED) + .contains("team_memberships", "payg_instance_usage", "teams", "users"); + } + + @Test + void isMigrationOwnedIsCaseInsensitiveAndNullSafe() { + assertThat(SaasSchemaOwnership.isMigrationOwned("TEAM_MEMBERSHIPS")).isTrue(); + assertThat(SaasSchemaOwnership.isMigrationOwned("team_memberships")).isTrue(); + assertThat(SaasSchemaOwnership.isMigrationOwned(null)).isFalse(); + assertThat(SaasSchemaOwnership.isMigrationOwned("no_such_table")).isFalse(); + } +} diff --git a/build.gradle b/build.gradle index 2b2d0c625f..cadf4bafa6 100644 --- a/build.gradle +++ b/build.gradle @@ -30,7 +30,7 @@ ext { openSamlVersion = "5.2.1" commonmarkVersion = "0.28.0" googleJavaFormatVersion = "1.35.0" - logback = "1.5.32" + logback = "1.6.1" commonsIoVersion = "2.22.0" commonsLang3 = "3.20.0" rhinoVersion = "1.9.1" @@ -44,9 +44,9 @@ ext { batikVersion = "1.19" jpdfiumVersion = "1.0.4" jwtVersion = "0.13.0" - awsSdkVersion = "2.44.12" + awsSdkVersion = "2.51.3" jschVersion = "2.28.6" - commonsNetVersion = "3.11.1" + commonsNetVersion = "3.13.0" smbjVersion = "0.14.0" tinkVersion = "1.23.0" testcontainersMinioVersion = "1.21.4" diff --git a/docker/backend/Dockerfile b/docker/backend/Dockerfile index 454fdedcfd..fd26ee2fb7 100644 --- a/docker/backend/Dockerfile +++ b/docker/backend/Dockerfile @@ -45,7 +45,7 @@ RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo li --no-daemon # Stage 2: Extract Spring Boot Layers -FROM eclipse-temurin:25-jre-noble@sha256:2f1da100788559b397bcf48c736169ea5b070bde84e55f203bbee8e83d87a175 AS jar-extract +FROM eclipse-temurin:25-jre-noble@sha256:fbcf915c585659b30eb766ada4d6d7cfc9ec1040bf521e95bf61b10a25af73db AS jar-extract WORKDIR /tmp COPY --from=app-build /app/app/core/build/libs/*.jar app.jar RUN java -Djarmode=tools -jar app.jar extract --layers --destination /layers diff --git a/docker/base/Dockerfile b/docker/base/Dockerfile index 6cffbf88f9..95e4e84795 100644 --- a/docker/base/Dockerfile +++ b/docker/base/Dockerfile @@ -364,7 +364,7 @@ RUN --mount=type=cache,target=/root/.cache/pip,sharing=locked \ # Final runtime image - the actual base image -FROM eclipse-temurin:25-jre-noble@sha256:2f1da100788559b397bcf48c736169ea5b070bde84e55f203bbee8e83d87a175 AS runtime +FROM eclipse-temurin:25-jre-noble@sha256:fbcf915c585659b30eb766ada4d6d7cfc9ec1040bf521e95bf61b10a25af73db AS runtime SHELL ["/bin/bash", "-o", "pipefail", "-c"] diff --git a/docker/embedded/Dockerfile b/docker/embedded/Dockerfile index de7da3789e..d4ad422476 100644 --- a/docker/embedded/Dockerfile +++ b/docker/embedded/Dockerfile @@ -61,7 +61,7 @@ RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo li --no-daemon # Stage 2: Extract Spring Boot Layers -FROM eclipse-temurin:25-jre-noble@sha256:2f1da100788559b397bcf48c736169ea5b070bde84e55f203bbee8e83d87a175 AS jar-extract +FROM eclipse-temurin:25-jre-noble@sha256:fbcf915c585659b30eb766ada4d6d7cfc9ec1040bf521e95bf61b10a25af73db AS jar-extract WORKDIR /tmp COPY --from=app-build /app/app/core/build/libs/*.jar app.jar RUN java -Djarmode=tools -jar app.jar extract --layers --destination /layers diff --git a/docker/embedded/Dockerfile.fat b/docker/embedded/Dockerfile.fat index 300729e560..58eafec851 100644 --- a/docker/embedded/Dockerfile.fat +++ b/docker/embedded/Dockerfile.fat @@ -56,7 +56,7 @@ RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo li --no-daemon # Stage 2: Extract Spring Boot Layers -FROM eclipse-temurin:25-jre-noble@sha256:2f1da100788559b397bcf48c736169ea5b070bde84e55f203bbee8e83d87a175 AS jar-extract +FROM eclipse-temurin:25-jre-noble@sha256:fbcf915c585659b30eb766ada4d6d7cfc9ec1040bf521e95bf61b10a25af73db AS jar-extract WORKDIR /tmp COPY --from=app-build /app/app/core/build/libs/*.jar app.jar RUN java -Djarmode=tools -jar app.jar extract --layers --destination /layers diff --git a/docker/embedded/Dockerfile.ultra-lite b/docker/embedded/Dockerfile.ultra-lite index 68a65dea6a..d4a21b7812 100644 --- a/docker/embedded/Dockerfile.ultra-lite +++ b/docker/embedded/Dockerfile.ultra-lite @@ -57,7 +57,7 @@ RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo li # Stage 2: Runtime image # glibc base (not Alpine/musl): JPDFium's PDFium natives are glibc-linked. -FROM eclipse-temurin:25-jre-noble@sha256:2f1da100788559b397bcf48c736169ea5b070bde84e55f203bbee8e83d87a175 +FROM eclipse-temurin:25-jre-noble@sha256:fbcf915c585659b30eb766ada4d6d7cfc9ec1040bf521e95bf61b10a25af73db ENV DEBIAN_FRONTEND=noninteractive \ LANG=C.UTF-8 \ diff --git a/engine/pyproject.toml b/engine/pyproject.toml index 3f2c277fbb..87e7cc99c0 100644 --- a/engine/pyproject.toml +++ b/engine/pyproject.toml @@ -64,7 +64,7 @@ updater-signatures = [ ] # Pinned repository-wide pre-commit tooling. pre-commit = [ - "codespell==2.4.2", + "codespell==2.4.3", "ruff==0.15.5", "tomli-w==1.2.0", ] diff --git a/engine/uv.lock b/engine/uv.lock index 0b25716add..fbfa665bc6 100644 --- a/engine/uv.lock +++ b/engine/uv.lock @@ -414,11 +414,11 @@ wheels = [ [[package]] name = "codespell" -version = "2.4.2" +version = "2.4.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2d/9d/1d0903dff693160f893ca6abcabad545088e7a2ee0a6deae7c24e958be69/codespell-2.4.2.tar.gz", hash = "sha256:3c33be9ae34543807f088aeb4832dfad8cb2dae38da61cac0a7045dd376cfdf3", size = 352058, upload-time = "2026-03-05T18:10:42.936Z" } +sdist = { url = "https://files.pythonhosted.org/packages/80/19/45e941380f69c042b43423513d201e6592346f992394347f5e7174c31407/codespell-2.4.3.tar.gz", hash = "sha256:cbe085e331227b37bb86ef8bddd08dc768c704ee9a07ca869852c093fa2793e2", size = 352773, upload-time = "2026-07-15T11:51:54.159Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/42/a1/52fa05533e95fe45bcc09bcf8a503874b1c08f221a4e35608017e0938f55/codespell-2.4.2-py3-none-any.whl", hash = "sha256:97e0c1060cf46bd1d5db89a936c98db8c2b804e1fdd4b5c645e82a1ec6b1f886", size = 353715, upload-time = "2026-03-05T18:10:41.398Z" }, + { url = "https://files.pythonhosted.org/packages/8b/bf/bdb951d34eb169140b546f44be9ec4525d1acefb9eb5572071f5492b19fc/codespell-2.4.3-py3-none-any.whl", hash = "sha256:af2505b335e8573dbd2d384d1c4ef498f4006f4ba2d6fceca01e55b91f52628a", size = 340736, upload-time = "2026-07-15T11:51:52.925Z" }, ] [[package]] @@ -718,7 +718,7 @@ engine-dev = [ { name = "ruff", specifier = "==0.15.5" }, ] pre-commit = [ - { name = "codespell", specifier = "==2.4.2" }, + { name = "codespell", specifier = "==2.4.3" }, { name = "ruff", specifier = "==0.15.5" }, { name = "tomli-w", specifier = "==1.2.0" }, ] diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 4d5e271103..12ad26ceec 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -1879,6 +1879,9 @@ width = "Width" [app] description = "The Free Adobe Acrobat alternative (10M+ Downloads)" +[appBanner] +dismiss = "Dismiss" + [attachments] convertToPdfA3b = "Convert to PDF/A-3b" convertToPdfA3bDescription = "Creates an archival PDF with embedded attachments" @@ -3010,6 +3013,7 @@ saturation = "Saturation and brightness" title = "Choose color" [common] +actions = "Actions" back = "Back" cancel = "Cancel" close = "Close" @@ -3025,6 +3029,7 @@ error = "Error" expand = "Expand" loading = "Loading..." next = "Next" +open = "Open" preview = "Preview" previous = "Previous" refresh = "Refresh" @@ -4825,9 +4830,6 @@ title = "Image to PDF" [imageToPdf] tags = "conversion,img,jpg,picture,photo" -[infoBanner] -dismiss = "Dismiss" - [invite] acceptError = "Failed to create account" accountFor = "Creating account for" @@ -5055,6 +5057,14 @@ title = "Upload from Mobile" tags = "Multi Tool,Multi operation,UI,click drag,front end,client side,interactive,intractable,move,delete,migrate,divide" title = "PDF Multi Tool" +[navFooter] +openEditor = "Open PDF Editor" +openProcessor = "Open PDF Processor" + +[navFooter.credits] +count = "{{remaining}} of {{total}}" +label = "Free credits" + [oauth.error] message = "Authentication was not successful. You can close this window and try again." title = "Authentication Failed" @@ -5619,8 +5629,8 @@ freeBody = "View, edit, merge, split, sign, watermark, compress, convert and man freeTitle = "Unlimited PDF editing" [payg.free.hero] -barAria = "Free PDFs used" -capSuffix = "/ {{limit}} free PDFs" +barAria = "Free PDFs remaining" +capSuffix = "of {{limit}} free PDFs left" metaCategories = "Automation · AI · API requests" [payg.free.member] @@ -6339,7 +6349,6 @@ revoked = "Revoked" unnamed = "Unnamed instance" [portal.accountLink.instances.columns] -actions = "Actions" instance = "Instance" lastSeen = "Last seen" linked = "Linked" @@ -6684,12 +6693,12 @@ reachedTitle = "Monthly spend limit reached" title = "Couldn't open Stripe portal" [portal.billing.walletMeter] -barAria = "Free PDFs used" -capSuffix_one = "of {{allowance}} free PDFs used" -capSuffix_other = "of {{allowance}} free PDFs used" +barAria = "Free PDFs remaining" +capSuffix_one = "of {{allowance}} free PDF left" +capSuffix_other = "of {{allowance}} free PDFs left" eyebrow = "Processor trial" -statusLabel_one = "{{remaining}} left" -statusLabel_other = "{{remaining}} left" +statusLabel_one = "{{used}} used" +statusLabel_other = "{{used}} used" sub = "Use the PDF Editor for free. Pay to process PDFs automatically." title_one = "Process {{allowance}} PDFs free" title_other = "Process {{allowance}} PDFs free" @@ -7262,12 +7271,11 @@ editorAction = "Editor" empty = "No documents match this filter." rowActions = "Row actions" sensitiveLabel = "Sensitive" -sensitiveTitle = "Sensitive — access required" [portal.documents.table.columns] action = "Pipeline / Action" -actions = "Actions" document = "Document" +labels = "Labels" product = "Product" status = "Status" time = "Time" @@ -7283,6 +7291,7 @@ host = "Host" lastSeen = "Last seen" region = "Region" status = "Status" +target = "Target" version = "Version" [portal.editorAdmin.health.empty] @@ -7371,6 +7380,14 @@ confirm = "Are you sure?" dismiss = "Dismiss" dismissSkipFile = "Skip this file" +[portal.failures.debug] +copyJson = "Copy JSON" +dismissAll = "Dismiss all ({{total}})" +dismissing = "Dismissing..." +hideJson = "Hide raw JSON ({{total}})" +refresh = "Refresh failures" +showJson = "Show raw JSON ({{total}})" + [portal.failures.disabled] closed = "This failure is already closed." unavailable = "Not available for this failure." @@ -7602,11 +7619,9 @@ security = "Security" storage = "Storage" [portal.integrations] -addAnother = "Add another" availableHeading = "Available" comingSoonHeading = "Coming soon" connect = "Connect" -connectedHeading = "Connected" connectionCount_one = "{{count}} connection" connectionCount_other = "{{count}} connections" customApi = "Custom API" @@ -7624,6 +7639,10 @@ security = "Security" signing = "Signing" storage = "Storage" +[portal.integrations.noResults] +description = "No integrations match your filters. Try a different category or search." +title = "No matches" + [portal.integrations.status] connected = "Connected" @@ -7654,7 +7673,6 @@ integrations = "Integrations" pipelines = "Pipelines" policies = "Policies" procurement = "Procurement" -settings = "Settings" sources = "Sources" usage = "Usage & Billing" users = "Users" @@ -7826,10 +7844,10 @@ paused = "Paused" [portal.pipelines.table] name = "Pipeline" -open = "Open" sources = "Sources" status = "Status" steps = "Steps" +trigger = "Trigger" [portal.pipelines.trigger] folder-watch = "Folder watch" @@ -8722,9 +8740,9 @@ unused = "Unused" [portal.sources.table] documents = "Documents" -open = "Open" source = "Source" status = "Status" +type = "Type" usedBy = "Policies" [portal.sources.types.box] @@ -10948,6 +10966,13 @@ approver = "Approves policy" editor = "Editor" processor = "Processor" +[users.columns] +capabilities = "Capabilities" +email = "Email" +person = "Person" +role = "Role" +status = "Status" + [users.confirm] cancelInviteBody = "Cancel the invitation to {{email}}? They won't be able to join with the current link." cancelInviteTitle = "Cancel invitation" @@ -10967,10 +10992,8 @@ title = "No members yet" addToTeam = "Add to team" guestCount = "{{count}} guest" guests = "Guests" -guestsDesc = "External collaborators, scoped to what you shared. Editor only." ledBy = "led by {{owner}}" org = "Organization" -orgDesc = "Owners with org-wide authority and policy approval" owners = "{{count}} owner" team = "{{name}} team" teamMeta = "{{count}} people" @@ -11010,13 +11033,15 @@ usernamePlaceholder = "jsmith" [users.invites] by = "Invited by {{who}}" cancel = "Cancel" -count = "{{count}} pending" -desc = "Invited people who haven't joined yet. They hold a seat until they accept." expiresInDays_one = "Expires in {{count}} day" expiresInDays_other = "Expires in {{count}} days" expiresToday = "Expires today" title = "Pending invitations" +[users.invites.columns] +expires = "Expires" +invitee = "Invitee" + [users.loadError] description = "Something went wrong reaching the backend, or you don't have access. Try again." title = "Couldn't load members" diff --git a/frontend/editor/scripts/lint/theme-lint.mjs b/frontend/editor/scripts/lint/theme-lint.mjs index 494fc03cca..97b32ef208 100644 --- a/frontend/editor/scripts/lint/theme-lint.mjs +++ b/frontend/editor/scripts/lint/theme-lint.mjs @@ -642,7 +642,6 @@ const CODE_EXEMPT_PATH = [ /mantineTheme|\/theme\.ts$|toolsTaxonomy|LayoutPreview|PageNumberPreview|CloudStorageIcons|BrandMarks/, /\/onboarding\//, /addStamp|addWatermark|\/tooltips\//, - /UpgradeBanner|AdminPlanSection/, // Stories are checked like app code; colour-as-data lines opt out with // `theme-allow-color`. /\.test\.[jt]sx?$|\/types\//, diff --git a/frontend/editor/src-tauri/Cargo.lock b/frontend/editor/src-tauri/Cargo.lock index c7d25757c9..fe8352f5a7 100644 --- a/frontend/editor/src-tauri/Cargo.lock +++ b/frontend/editor/src-tauri/Cargo.lock @@ -276,9 +276,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64" -version = "0.23.0" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b25655df2c3cdd83c5e5b293b88acd880332b2ddadd7c30ac43144fdc0033da9" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" [[package]] name = "bit-set" @@ -4551,7 +4551,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" name = "stirling-pdf" version = "0.1.0" dependencies = [ - "base64 0.23.0", + "base64 0.23.1", "core-foundation 0.10.1", "core-services", "keyring", diff --git a/frontend/editor/src/cloud/components/shared/TeamInvitationBanner.tsx b/frontend/editor/src/cloud/components/shared/TeamInvitationBanner.tsx index 3b373e9c3b..638c752f9e 100644 --- a/frontend/editor/src/cloud/components/shared/TeamInvitationBanner.tsx +++ b/frontend/editor/src/cloud/components/shared/TeamInvitationBanner.tsx @@ -3,7 +3,7 @@ import { Group, Text } from "@mantine/core"; import { Button } from "@app/ui/Button"; import { useTranslation } from "react-i18next"; import LocalIcon from "@app/components/shared/LocalIcon"; -import { InfoBanner } from "@app/components/shared/InfoBanner"; +import { AppBanner } from "@app/components/shared/AppBanner"; import { useSaaSTeam } from "@app/contexts/SaaSTeamContext"; /** @@ -105,7 +105,7 @@ export function TeamInvitationBanner() { ); return ( - ); } diff --git a/frontend/editor/src/cloud/components/shared/config/configSections/usageMeters.tsx b/frontend/editor/src/cloud/components/shared/config/configSections/usageMeters.tsx index 8811e537b2..c4407cae7f 100644 --- a/frontend/editor/src/cloud/components/shared/config/configSections/usageMeters.tsx +++ b/frontend/editor/src/cloud/components/shared/config/configSections/usageMeters.tsx @@ -12,6 +12,7 @@ import { formatPeriodDate, MeterBar, meterState, + remainingMeter, } from "@app/billing"; import "@app/components/shared/config/configSections/Payg.css"; import "@app/components/shared/config/configSections/PaygFree.css"; @@ -48,7 +49,8 @@ export function useFreeSnapshot(): FreeSnapshot { export function FreeMeterPanel({ snap }: { snap: FreeSnapshot }) { const { t } = useTranslation(); - const { state, pct } = meterState(snap.billableUsed, snap.billableLimit); + const remaining = Math.max(0, snap.billableLimit - snap.billableUsed); + const { state, pct } = remainingMeter(remaining, snap.billableLimit); const stateLabel = state === "DEGRADED" ? t("payg.free.state.limitReached", "Limit reached") @@ -60,9 +62,9 @@ export function FreeMeterPanel({ snap }: { snap: FreeSnapshot }) { { + if (live !== undefined) writeCachedCredits(live); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [wallet]); + + return (live !== undefined ? live : seed) ?? null; +} diff --git a/frontend/editor/src/cloud/hooks/useOpenPlan.ts b/frontend/editor/src/cloud/hooks/useOpenPlan.ts new file mode 100644 index 0000000000..4d532319f6 --- /dev/null +++ b/frontend/editor/src/cloud/hooks/useOpenPlan.ts @@ -0,0 +1,13 @@ +import { useCallback } from "react"; +import { useNavigate } from "react-router-dom"; + +/** + * Cloud editor builds open the settings modal on its Plan section, which is + * where the free grant is explained and the Processor plan is switched on. + * Routed rather than called directly because the modal is URL-driven here + * (`/settings/*`), the same path the admin tour uses to open it. + */ +export function useOpenPlan(): (() => void) | null { + const navigate = useNavigate(); + return useCallback(() => navigate("/settings/plan"), [navigate]); +} diff --git a/frontend/editor/src/cloud/hooks/useWallet.ts b/frontend/editor/src/cloud/hooks/useWallet.ts index 0a3f78b3ce..ed3cb2ce6b 100644 --- a/frontend/editor/src/cloud/hooks/useWallet.ts +++ b/frontend/editor/src/cloud/hooks/useWallet.ts @@ -32,6 +32,14 @@ * promise see the UI flip exactly once the new state is visible — no * intermediate flash of the old value. * + *

Freshness

+ * + * The figures drain as metered work runs, so a mounted consumer re-reads the + * wallet every {@link WALLET_POLL_MS} and again whenever the tab regains + * visibility. Those refreshes are silent — they leave {@code loading} and + * {@code error} alone and only commit fresher data — so consumers that gate on + * those flags don't flicker on a background tick. + * *

Dev preview fallback

* * When the hook is rendered outside the saas app (e.g. on {@code @@ -178,6 +186,13 @@ function reuseIfEqual(prev: Wallet | null, next: Wallet): Wallet { return prev; } +/** + * How often a mounted consumer re-reads the wallet. Matches the app query + * client's staleTime, so the sidebar meter and anything cached elsewhere age + * out on the same clock. + */ +const WALLET_POLL_MS = 30_000; + export function useWallet(): UseWalletResult { // Resolved once: the dev-preview side-channel when rendered outside the real // app (saas /dev/payg-preview route), else null (every real build + desktop). @@ -201,13 +216,29 @@ export function useWallet(): UseWalletResult { // "the request fired." Cleared when no load is pending. const inFlight = useRef | null>(null); + // Set for refreshes the user didn't ask for (the poll below). Silence governs + // whether a load may RAISE `loading` / `error`, never whether it may clear + // them: consumers gate on both — the limit modals do + // `if (loading || !wallet) return null`, and Plan swaps in an error alert — + // so a background tick must not blink an open modal out or replace a working + // page over a transient failure. Clearing is always the latest request's job, + // silent or not; a silent load that skipped the clear would strand `loading` + // true after superseding a visible one, which suppresses those modals for the + // rest of the session. + const silentRefresh = useRef(false); + useEffect(() => { const reqId = ++latestReqId.current; let cancelled = false; + const silent = silentRefresh.current; + silentRefresh.current = false; + const promise = (async () => { - setLoading(true); - setError(null); + if (!silent) { + setLoading(true); + setError(null); + } if (devPreview) { const synth = devPreview.buildWallet(devPreview.role()); @@ -221,11 +252,22 @@ export function useWallet(): UseWalletResult { const res = await apiClient.get("/api/v1/payg/wallet"); if (cancelled || reqId !== latestReqId.current) return; setWallet((prev) => reuseIfEqual(prev, res.data)); + // Fresh data retires any earlier failure, including one a silent poll + // is recovering from — otherwise Plan keeps its alert over good data. + setError(null); } catch (e: unknown) { if (cancelled || reqId !== latestReqId.current) return; - console.warn("[useWallet] fetch failed", e); - setError(e instanceof Error ? e.message : "Failed to load wallet"); + if (!silent) { + console.warn("[useWallet] fetch failed", e); + setError(e instanceof Error ? e.message : "Failed to load wallet"); + } + // A failed background refresh is a non-event: the last good snapshot + // stands and the next tick self-heals, so it neither surfaces nor + // logs — otherwise an offline tab warns every WALLET_POLL_MS. } finally { + // Deliberately not gated on `silent`: whichever load is latest owns + // settling the flag, or a silent refresh that supersedes a visible one + // leaves it stuck true. if (!cancelled && reqId === latestReqId.current) { setLoading(false); } @@ -242,6 +284,46 @@ export function useWallet(): UseWalletResult { }; }, [devPreview, refetchTick]); + // The wallet drains as automation, AI and API work runs, so a figure fetched + // on mount goes stale while the user watches it. Refresh on a timer, and + // immediately on returning to the tab — coming back to a stale number is the + // case people actually notice. Hidden tabs don't poll, and the dev-preview + // wallet is synthesised locally so there is nothing to re-read. + useEffect(() => { + if (devPreview) return; + + let timer: ReturnType | undefined; + const refresh = () => { + silentRefresh.current = true; + setRefetchTick((t) => t + 1); + }; + const stop = () => { + if (timer !== undefined) { + clearInterval(timer); + timer = undefined; + } + }; + const start = () => { + stop(); + timer = setInterval(refresh, WALLET_POLL_MS); + }; + const onVisibilityChange = () => { + if (document.visibilityState === "visible") { + refresh(); + start(); + } else { + stop(); + } + }; + + if (document.visibilityState === "visible") start(); + document.addEventListener("visibilitychange", onVisibilityChange); + return () => { + stop(); + document.removeEventListener("visibilitychange", onVisibilityChange); + }; + }, [devPreview]); + const refetch = useCallback(async () => { setRefetchTick((t) => t + 1); // Snapshot the next-tick promise so the caller awaits this refetch diff --git a/frontend/editor/src/core/components/AppLayout.stories.tsx b/frontend/editor/src/core/components/AppLayout.stories.tsx index 4d7e6780cf..69aceef9d7 100644 --- a/frontend/editor/src/core/components/AppLayout.stories.tsx +++ b/frontend/editor/src/core/components/AppLayout.stories.tsx @@ -4,7 +4,7 @@ import { AppLayout } from "@app/components/AppLayout"; import { BannerProvider, useBanner } from "@app/contexts/BannerContext"; import { NavigationProvider } from "@app/contexts/NavigationContext"; import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider"; -import { InfoBanner } from "@app/components/shared/InfoBanner"; +import { AppBanner } from "@app/components/shared/AppBanner"; const meta = { title: "Components/AppLayout", @@ -49,7 +49,7 @@ function BannerSetter() { const { setBanner } = useBanner(); useEffect(() => { setBanner( - ; +export default meta; +type Story = StoryObj; + +export const Info: Story = { + args: { + icon: "info-rounded", + title: "Heads up", + message: "This document contains form fields that will be flattened.", + }, +}; + +export const Promo: Story = { + args: { + tone: "promo", + icon: "stars-rounded", + title: "Upgrade to Server Plan", + message: + "Get the most out of Stirling PDF with unlimited users and advanced features.", + buttonText: "Upgrade Now", + buttonIcon: "upgrade-rounded", + onButtonClick: () => {}, + compact: true, + }, +}; + +export const Warning: Story = { + args: { + tone: "warning", + icon: "warning-rounded", + title: "Action required", + message: "Some pages could not be processed and were skipped.", + buttonText: "Review", + onButtonClick: () => {}, + }, +}; + +export const Danger: Story = { + args: { + tone: "danger", + icon: "warning-rounded", + title: "This server needs admin attention", + message: "Review the license requirements to keep this server compliant.", + buttonText: "See info", + buttonIcon: "info-rounded", + onButtonClick: () => {}, + dismissible: false, + }, +}; + +export const Compact: Story = { + args: { + compact: true, + icon: "info-rounded", + message: "Autosave is enabled for this file.", + dismissible: false, + }, +}; + +/** Message-only, no title: the message takes the title's weight so the bar still reads. */ +export const MessageOnly: Story = { + args: { + icon: "picture-as-pdf-rounded", + message: + "Make Stirling PDF your default application for opening PDF files.", + buttonText: "Set Default", + onButtonClick: () => {}, + secondaryButtonText: "Don't remind me again", + onSecondaryButtonClick: () => {}, + }, +}; + +function Row({ caption, children }: { caption: string; children: ReactNode }) { + return ( +
+ + {caption} + + {children} +
+ ); +} + +/** + * Every top bar the app can show, in one place: each entry mirrors a real caller, + * so a change to the component is visible against the whole set at once. Renders a + * composition rather than the component, so it takes no args of its own. + */ +export const AllTopBars: StoryObj = { + render: () => ( +
+ + {}} + /> + + + + {}} + dismissible={false} + /> + + + + {}} + dismissible={false} + /> + + + + {}} + secondaryButtonText="Decline" + onSecondaryButtonClick={() => {}} + dismissible={false} + /> + + + + {}} + secondaryButtonText="Don't remind me again" + onSecondaryButtonClick={() => {}} + /> + + + + {}} + dismissible={false} + /> + +
+ ), +}; diff --git a/frontend/editor/src/core/components/shared/AppBanner.tsx b/frontend/editor/src/core/components/shared/AppBanner.tsx new file mode 100644 index 0000000000..ee0ba03741 --- /dev/null +++ b/frontend/editor/src/core/components/shared/AppBanner.tsx @@ -0,0 +1,124 @@ +import React, { ReactNode } from "react"; +import { Button } from "@app/ui/Button"; +import { ActionIcon } from "@app/ui/ActionIcon"; +import { useTranslation } from "react-i18next"; +import LocalIcon from "@app/components/shared/LocalIcon"; +import "@app/components/shared/AppBanner.css"; + +/** Picks the whole look. Callers choose meaning, never colours. */ +export type AppBannerTone = "info" | "promo" | "warning" | "danger"; + +/** Tone decides the button too, so the CTA can't drift from the bar it sits on. */ +const TONE_BUTTON = { + info: { variant: "secondary", accent: "default" }, + promo: { variant: "primary", accent: "premium" }, + warning: { variant: "primary", accent: "warning" }, + danger: { variant: "primary", accent: "danger" }, +} as const; + +interface AppBannerProps { + /** A LocalIcon name, or a pre-rendered node (e.g. a logo) dropped in as-is. */ + icon?: string | ReactNode; + title?: ReactNode; + message: ReactNode; + buttonText?: string; + buttonIcon?: string; + onButtonClick?: () => void; + /** Muted secondary action, e.g. "Don't remind me again". */ + secondaryButtonText?: string; + onSecondaryButtonClick?: () => void; + onDismiss?: () => void; + dismissible?: boolean; + loading?: boolean; + show?: boolean; + tone?: AppBannerTone; + compact?: boolean; +} + +/** The app's top bar: dismissible messaging above the workspace. */ +export const AppBanner: React.FC = ({ + icon, + title, + message, + buttonText, + buttonIcon = "check-circle-rounded", + onButtonClick, + secondaryButtonText, + onSecondaryButtonClick, + onDismiss, + dismissible = true, + loading = false, + show = true, + tone = "info", + compact = false, +}) => { + const { t } = useTranslation(); + if (!show) return null; + + const iconSize = compact ? "1rem" : "1.25rem"; + + return ( +
+ {icon != null && ( + + {typeof icon === "string" ? ( + + ) : ( + icon + )} + + )} + +
+ {title && {title}} + {message} +
+ +
+ {buttonText && onButtonClick && ( + + )} + {secondaryButtonText && onSecondaryButtonClick && ( + + )} + {dismissible && ( + onDismiss?.()} + aria-label={t("appBanner.dismiss", "Dismiss")} + > + + + )} +
+
+ ); +}; diff --git a/frontend/editor/src/core/components/shared/BrandMark.css b/frontend/editor/src/core/components/shared/BrandMark.css index 7ddff9b4c7..df05ff1307 100644 --- a/frontend/editor/src/core/components/shared/BrandMark.css +++ b/frontend/editor/src/core/components/shared/BrandMark.css @@ -48,9 +48,55 @@ transform: matrix(0.483871, -0.017568, 0, 0.338028, 23.887097, 26.886428); } +/* One-shot "thinking" drift — the two parallelograms swap past each other and + settle back. Same motion the chat FAB loops while the agent works, but this + pair starts and ends at rest (translate 0, full opacity) so a single + iteration can end without snapping. Callers apply it for one beat; see + NavFooter.css for the hover use. */ +@keyframes sui-brandmark-drift-a { + 0%, + 100% { + transform: translate(0, 0); + opacity: 1; + } + 25% { + transform: translate(-1px, -5px); + opacity: 0.55; + } + 50% { + transform: translate(-6px, 0); + opacity: 0.9; + } + 75% { + transform: translate(-1px, 5px); + opacity: 0.6; + } +} + +@keyframes sui-brandmark-drift-b { + 0%, + 100% { + transform: translate(0, 0); + opacity: 1; + } + 25% { + transform: translate(1px, 5px); + opacity: 0.85; + } + 50% { + transform: translate(6px, 0); + opacity: 0.5; + } + 75% { + transform: translate(1px, -5px); + opacity: 0.85; + } +} + @media (prefers-reduced-motion: reduce) { .sui-brandmark__a, .sui-brandmark__b { transition: none; + animation: none; } } diff --git a/frontend/editor/src/core/components/shared/FileSidebar.css b/frontend/editor/src/core/components/shared/FileSidebar.css index 590f59fa7d..2347d9a2b2 100644 --- a/frontend/editor/src/core/components/shared/FileSidebar.css +++ b/frontend/editor/src/core/components/shared/FileSidebar.css @@ -75,16 +75,13 @@ padding: 0.25rem 0; overflow: hidden; } -.file-sidebar-footer-box { - padding: 0.25rem 0; - flex-shrink: 0; -} +/* The footer is the shared : it brings its own boxes and padding, + so this class only positions it in the column. */ /* Collapsed rail: the file tree isn't rendered, so hide its (empty) box and let the boxes stack at the top — controls, then the settings footer right after — instead of the files box stretching to fill. */ -.file-sidebar[data-collapsed="true"] .file-sidebar-controls, -.file-sidebar[data-collapsed="true"] .file-sidebar-footer-box { +.file-sidebar[data-collapsed="true"] .file-sidebar-controls { padding: 0.25rem; } .file-sidebar[data-collapsed="true"] .file-sidebar-files-box { @@ -538,86 +535,3 @@ pointer-events: none; animation: none; } - -/* ---- Bottom bar (user + settings) ---- */ -.file-sidebar-bottom-bar { - display: flex; - align-items: center; - gap: 8px; - padding: 4px 6px; - flex-shrink: 0; - min-height: 40px; -} - -/* Bottom bar settings icon tracks the right edge during collapse animation */ - -.file-sidebar-bottom-avatar { - width: 28px; - height: 28px; - border-radius: 50%; - background-color: var(--c-accent-text); - color: var(--c-text-on-primary); - font-size: 12px; - font-weight: 600; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - user-select: none; - overflow: hidden; -} - -/* No colored disc behind an actual photo; keep it for the initials fallback. */ -.file-sidebar-bottom-avatar--picture { - background-color: transparent; -} - -.file-sidebar-bottom-avatar-img { - width: 100%; - height: 100%; - border-radius: 50%; - object-fit: cover; -} - -.file-sidebar-bottom-name { - flex: 1; - font-size: 13px; - font-weight: 500; - color: var(--c-text); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - min-width: 0; -} - -.file-sidebar-bottom-bar[role="button"]:hover { - background-color: var(--c-hover); -} - -.file-sidebar-bottom-bar[role="button"]:focus-visible { - outline: 2px solid var(--c-primary); - outline-offset: -2px; -} - -.file-sidebar-bottom-settings { - display: flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - border-radius: 6px; - color: var(--c-text-subtle); - padding: 0; - flex-shrink: 0; - margin-left: auto; -} - -.file-sidebar[data-collapsed="true"] .file-sidebar-bottom-settings { - width: 32px; - height: 32px; -} - -.file-sidebar[data-collapsed="true"] .file-sidebar-bottom-bar { - justify-content: center; - padding: 8px 0; -} diff --git a/frontend/editor/src/core/components/shared/FileSidebar.tsx b/frontend/editor/src/core/components/shared/FileSidebar.tsx index 2514e7f556..1c06236027 100644 --- a/frontend/editor/src/core/components/shared/FileSidebar.tsx +++ b/frontend/editor/src/core/components/shared/FileSidebar.tsx @@ -22,13 +22,15 @@ import { } from "@app/contexts/NavigationContext"; import { useViewer } from "@app/contexts/ViewerContext"; import { useFileHandler } from "@app/hooks/useFileHandler"; -import { useAuth } from "@app/auth/UseSession"; -import { useProfilePictureUrl } from "@app/hooks/useProfilePictureUrl"; +import { useAccountIdentity } from "@app/hooks/useAccountIdentity"; +import { useFreeCreditsSummary } from "@app/hooks/useFreeCreditsSummary"; +import { useOtherAppSwitch } from "@app/hooks/useOtherAppSwitch"; +import { useOpenPlan } from "@app/hooks/useOpenPlan"; +import { NavFooter } from "@app/components/shared/navFooter/NavFooter"; import { useIndexedDB, useIndexedDBRevision, } from "@app/contexts/IndexedDBContext"; -import { accountService } from "@app/services/accountService"; import { GoogleDriveIcon } from "@app/components/shared/CloudStorageIcons"; import { AppSwitcher } from "@app/components/shared/AppSwitcher"; import { SidebarToggleIcon } from "@app/components/shared/SidebarToggleIcon"; @@ -37,8 +39,7 @@ import FolderOpenIcon from "@mui/icons-material/FolderOpen"; import FolderSpecialIcon from "@mui/icons-material/FolderSpecial"; import UploadFileIcon from "@mui/icons-material/UploadFile"; import AddIcon from "@mui/icons-material/Add"; -import OpenInNewIcon from "@mui/icons-material/OpenInNew"; -import SettingsIcon from "@mui/icons-material/Settings"; +import OpenInFullIcon from "@mui/icons-material/OpenInFull"; import type { FileId } from "@app/types/file"; import { FileItem } from "@app/components/shared/FileSidebarFileItem"; import { useLabelName } from "@app/data/labelDisplay"; @@ -241,43 +242,11 @@ const FileSidebar = forwardRef( const { addFiles } = useFileHandler(); const indexedDB = useIndexedDB(); - // Each auth layer derives its own displayName from its native user shape. - // Fall back to the proprietary REST endpoint only when the auth - // context yields nothing - then to "User" as a generic last resort. - const { displayName: authDisplayName, isAnonymous } = useAuth(); - const [accountUsername, setAccountUsername] = useState(null); - const displayName = - authDisplayName ?? accountUsername ?? t("auth.displayName.user", "User"); - - const profilePictureUrl = useProfilePictureUrl(); - const [pictureFailed, setPictureFailed] = useState(false); - useEffect(() => setPictureFailed(false), [profilePictureUrl]); - const showProfilePicture = !!profilePictureUrl && !pictureFailed; - - useEffect(() => { - if (!config?.enableLogin) { - setAccountUsername(null); - return; - } - if (authDisplayName) { - // The auth context has a name; don't bother hitting the REST - // endpoint, but clear any stale cached value from a prior call. - setAccountUsername(null); - return; - } - accountService - .getAccountData() - .then((data) => { - // Always reflect the latest result - including clearing it on - // sign-out, when the endpoint returns no username (or 401s into - // the catch branch below). Without this, signing out would leave - // the old username on screen. - setAccountUsername(data?.username ?? null); - }) - .catch(() => { - setAccountUsername(null); - }); - }, [config?.enableLogin, authDisplayName]); + const { displayName, profilePictureUrl, isAnonymous } = + useAccountIdentity(); + const credits = useFreeCreditsSummary(); + const otherApp = useOtherAppSwitch(); + const openPlan = useOpenPlan(); // Leaf files = user-visible files (excludes intermediate tool outputs) const [allFileStubs, setAllFileStubs] = useState([]); @@ -1115,7 +1084,7 @@ const FileSidebar = forwardRef( )} data-testid="open-files-page" > - + ( {/* Getting-started checklist, floating above the footer (SaaS only). */} - {/* Box 3 — account footer (avatar + name + settings). */} - - {/* Bottom bar: user name + settings */} - -
e.key === "Enter" && onOpenSettings() - : undefined - } - data-testid={onOpenSettings ? "config-button" : undefined} - data-tour={onOpenSettings ? "config-button" : undefined} - aria-label={ - onOpenSettings - ? t("fileSidebar.openSettings", "Open settings") - : displayName - } - style={onOpenSettings ? { cursor: "pointer" } : undefined} - > -
- {showProfilePicture ? ( - setPictureFailed(true)} - /> - ) : ( - displayName.charAt(0).toUpperCase() - )} -
- {!collapsed && ( - - {displayName} - - )} - {onOpenSettings && !collapsed && ( -
- -
- )} -
-
-
+ {/* Box 3 — the shared footer: credits, app switch, account row. */} + ); }, diff --git a/frontend/editor/src/core/components/shared/InfoBanner.stories.tsx b/frontend/editor/src/core/components/shared/InfoBanner.stories.tsx deleted file mode 100644 index 5fad071d05..0000000000 --- a/frontend/editor/src/core/components/shared/InfoBanner.stories.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { InfoBanner } from "@app/components/shared/InfoBanner"; - -const meta = { - title: "Shared/InfoBanner", - component: InfoBanner, - parameters: { layout: "padded" }, -} satisfies Meta; -export default meta; -type Story = StoryObj; - -export const Default: Story = { - args: { - icon: "info-rounded", - title: "Heads up", - message: "This document contains form fields that will be flattened.", - }, -}; - -export const Warning: Story = { - args: { - tone: "warning", - icon: "warning-rounded", - title: "Action required", - message: "Some pages could not be processed and were skipped.", - buttonText: "Review", - onButtonClick: () => {}, - }, -}; - -export const Compact: Story = { - args: { - compact: true, - icon: "info-rounded", - message: "Autosave is enabled for this file.", - dismissible: false, - }, -}; diff --git a/frontend/editor/src/core/components/shared/InfoBanner.tsx b/frontend/editor/src/core/components/shared/InfoBanner.tsx deleted file mode 100644 index 2056b6a92f..0000000000 --- a/frontend/editor/src/core/components/shared/InfoBanner.tsx +++ /dev/null @@ -1,263 +0,0 @@ -import React, { ReactNode } from "react"; -import { Paper, Group, Text, Stack } from "@mantine/core"; -import { Button, type ButtonVariant, type ButtonAccent } from "@app/ui/Button"; -import { ActionIcon } from "@app/ui/ActionIcon"; -import { useTranslation } from "react-i18next"; -import LocalIcon from "@app/components/shared/LocalIcon"; - -type InfoBannerTone = "info" | "warning"; - -const toneStyles: Record< - InfoBannerTone, - { - background: string; - border: string; - text: string; - icon: string; - buttonColor: string; - } -> = { - info: { - background: "var(--mantine-color-blue-0)", - border: "var(--mantine-color-blue-2)", - text: "var(--mantine-color-blue-9)", - icon: "var(--mantine-color-blue-6)", - buttonColor: "blue", - }, - warning: { - background: "var(--mantine-color-orange-0)", - border: "var(--mantine-color-orange-3)", - text: "var(--color-amber-dark)", - icon: "var(--mantine-color-orange-7)", - buttonColor: "orange", - }, -}; - -function toSharedButtonVariant( - variant: "light" | "filled" | "white" | "outline" | "subtle", -): ButtonVariant { - switch (variant) { - case "filled": - return "primary"; - case "outline": - return "secondary"; - case "subtle": - return "tertiary"; - case "light": - case "white": - default: - return "secondary"; - } -} - -function toSharedButtonAccent(color: string | undefined): ButtonAccent { - // Mantine colours may carry a shade suffix (e.g. "orange.7"); use the hue. - const hue = (color ?? "").split(".")[0]; - switch (hue) { - case "red": - return "danger"; - case "green": - return "success"; - case "yellow": - case "orange": - return "warning"; - case "blue": - default: - return "default"; - } -} - -interface InfoBannerProps { - /** - * Either a LocalIcon name (string) for the standard sized icon slot, or a - * pre-rendered ReactNode (e.g. a logo image) which is dropped in as-is. - */ - icon?: string | ReactNode; - title?: ReactNode; - message: ReactNode; - buttonText?: string; - buttonIcon?: string; - onButtonClick?: () => void; - /** Optional muted secondary action (e.g. "Don't remind me again"). */ - secondaryButtonText?: string; - onSecondaryButtonClick?: () => void; - onDismiss?: () => void; - dismissible?: boolean; - loading?: boolean; - show?: boolean; - tone?: InfoBannerTone; - background?: string; - borderColor?: string; - textColor?: string; - iconColor?: string; - buttonColor?: string; - buttonVariant?: "light" | "filled" | "white" | "outline" | "subtle"; - /** Override the button label colour (for dark/custom theme variants). */ - buttonTextColor?: string; - minHeight?: number | string; - closeIconColor?: string; - compact?: boolean; -} - -/** - * Generic info banner component for displaying dismissible messages at the top of the app - */ -export const InfoBanner: React.FC = ({ - icon, - title, - message, - buttonText, - buttonIcon = "check-circle-rounded", - onButtonClick, - secondaryButtonText, - onSecondaryButtonClick, - onDismiss, - dismissible = true, - loading = false, - show = true, - tone = "info", - background, - borderColor, - textColor, - iconColor, - buttonColor, - buttonVariant = "light", - buttonTextColor, - minHeight = 56, - closeIconColor, - compact = false, -}) => { - const { t } = useTranslation(); - if (!show) { - return null; - } - - const toneStyle = toneStyles[tone] ?? toneStyles.info; - const resolvedTextColor = textColor ?? toneStyle.text; - const handleDismiss = () => { - onDismiss?.(); - }; - - const iconSize = compact ? "1rem" : "1.2rem"; - const textSize = compact ? "xs" : "sm"; - - return ( - - - - {icon != null && - (typeof icon === "string" ? ( - - ) : ( -
- {icon} -
- ))} - - {title && ( - - {title} - - )} - - {message} - - -
- - {buttonText && onButtonClick && ( - - )} - {secondaryButtonText && onSecondaryButtonClick && ( - - )} - {dismissible && ( - - - - )} - -
-
- ); -}; diff --git a/frontend/editor/src/core/components/shared/navFooter/NavFooter.css b/frontend/editor/src/core/components/shared/navFooter/NavFooter.css new file mode 100644 index 0000000000..aa2688cd35 --- /dev/null +++ b/frontend/editor/src/core/components/shared/navFooter/NavFooter.css @@ -0,0 +1,156 @@ +/* Shared sidebar footer: one surface holding the link-account CTA, the credits + meter, the other-app switch and the account row, hairline-separated. + Structural only — every colour comes from a --c-* semantic token. */ + +.nav-footer { + display: flex; + flex-direction: column; + flex-shrink: 0; + /* Vertical only: the slots carry the horizontal padding so their separator + runs the full width of the surface. */ + padding: 0.25rem 0; + overflow: hidden; +} + +.nav-footer__slot { + padding-inline: 0.375rem; +} + +/* Separators are drawn by the slots themselves, never as their own elements. + A slot whose contents render nothing (the link-account CTA returns null once + the org is linked, and an element is truthy even when it renders null) is + :empty, so it is skipped by both rules below — it can't leave a line behind, + and it can't push one to the top or bottom of the surface. A rule that only + ever matches a slot PRECEDED by another visible slot cannot draw a leading + separator, whatever the caller passes in. */ +.nav-footer__slot:empty { + display: none; +} + +.nav-footer__slot:not(:empty) ~ .nav-footer__slot:not(:empty) { + border-top: 1px solid var(--c-border-subtle); + margin-top: 0.25rem; + padding-top: 0.25rem; +} + +/* Fades the rows up on the first footer mount of a page session only. They are + seeded from cache, so they're already present at first paint; replaying this + on every later mount (switching apps, remounting a view) would animate + content that never changed and read as a twitch. */ +@keyframes nav-footer-row-in { + from { + opacity: 0; + transform: translateY(0.25rem); + } + to { + opacity: 1; + transform: none; + } +} + +.nav-footer[data-animate] .nav-footer__slot:not(:empty) { + animation: nav-footer-row-in var(--motion-enter) both; +} + +@media (prefers-reduced-motion: reduce) { + .nav-footer[data-animate] .nav-footer__slot:not(:empty) { + animation: none; + } +} + +/* ---- Rows (link-account, credits, switch, account) ---- */ + +.nav-footer__row { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + min-height: 2.25rem; + padding: 0.25rem 0.375rem; + border: 0; + border-radius: 0.5rem; + background: none; + color: var(--c-text); + font: inherit; + text-align: left; + cursor: pointer; +} + +.nav-footer__row:disabled { + cursor: default; +} + +.nav-footer__row:not(:disabled):hover { + background-color: var(--c-hover); +} + +.nav-footer__row:focus-visible { + outline: 2px solid var(--c-primary); + outline-offset: -2px; +} + +.nav-footer__row-icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 1.625rem; +} + +/* Hovering the switch row plays the mark's "thinking" drift once — the same + motion the chat FAB loops, for a single beat, as a hint that the row hands + off to the other app. One iteration only: it starts and ends at rest, so + nothing snaps when it finishes, and re-entering the row replays it. */ +.nav-footer__row:hover .sui-brandmark__a { + animation: sui-brandmark-drift-a 1.1s ease-in-out 1; +} +.nav-footer__row:hover .sui-brandmark__b { + animation: sui-brandmark-drift-b 1.1s ease-in-out 1; +} + +.nav-footer__row-label { + flex: 1; + min-width: 0; + font-size: 0.8125rem; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Trailing affordance on a row: the account row's gear, the switch row's + leaving-this-app arrow. */ +.nav-footer__trailing { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-left: auto; + color: var(--c-text-subtle); +} + +/* Rows contributed by a caller (the link-account NavItem) sit in the same + surface, so match this footer's row metrics rather than the nav rail's. */ +.nav-footer .sui-navitem { + min-height: 2.25rem; + padding: 0.25rem 0.375rem; + margin: 0; + border-radius: 0.5rem; + font-size: 0.8125rem; +} + +/* ---- Collapsed icon rail ---- */ + +.nav-footer[data-collapsed] .nav-footer__slot { + padding-inline: 0.25rem; +} + +.nav-footer[data-collapsed] .nav-footer__row { + justify-content: center; + padding-inline: 0; +} + +.nav-footer[data-collapsed] .sui-navitem { + justify-content: center; + padding-inline: 0; +} diff --git a/frontend/editor/src/core/components/shared/navFooter/NavFooter.stories.tsx b/frontend/editor/src/core/components/shared/navFooter/NavFooter.stories.tsx new file mode 100644 index 0000000000..31a04be596 --- /dev/null +++ b/frontend/editor/src/core/components/shared/navFooter/NavFooter.stories.tsx @@ -0,0 +1,119 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import LinkIcon from "@mui/icons-material/Link"; +import { NavItem } from "@app/ui/NavItem"; +import { NavFooter } from "@app/components/shared/navFooter/NavFooter"; + +/** Stands in for a CTA that has decided it has nothing to show. */ +function RendersNothing() { + return null; +} + +const meta: Meta = { + title: "Shared/NavFooter", + component: NavFooter, + parameters: { layout: "padded" }, + args: { + displayName: "admin", + onOpenSettings: () => {}, + credits: { remaining: 247, total: 500 }, + onOpenPlan: () => {}, + otherApp: { app: "processor", onOpen: () => {} }, + }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +/** The editor's footer: credits, "Open PDF Processor", the account row. */ +export const InEditor: Story = {}; + +/** The processor's footer. Same three boxes, opposite switch target. */ +export const InProcessor: Story = { + args: { otherApp: { app: "editor", onOpen: () => {} } }, +}; + +/** Self-hosted processor: no wallet, so no meter, and the link-account CTA + * rides along in the account box. */ +export const WithLinkAccountCta: Story = { + args: { + credits: null, + otherApp: { app: "editor", onOpen: () => {} }, + accountExtras: ( + } + /> + ), + }, +}; + +/** Regression guard: the processor always passes its link-account CTA, but that + * component renders null once the org is linked. An element is truthy even + * when it renders nothing, so this must not leave a separator above the first + * visible row. */ +export const ExtrasThatRenderNothing: Story = { + args: { accountExtras: }, +}; + +/** A real profile picture replaces the initials disc. */ +export const WithProfilePicture: Story = { + args: { + displayName: "Ada Lovelace", + profilePictureUrl: + "data:image/svg+xml;utf8," + + encodeURIComponent( + '', + ), + }, +}; + +/** Credits running low — the dot and bar shift to the warning tone at 20% left. */ +export const CreditsLow: Story = { + args: { credits: { remaining: 42, total: 500 } }, +}; + +/** Allowance exhausted. */ +export const CreditsExhausted: Story = { + args: { credits: { remaining: 0, total: 500 } }, +}; + +/** Core OSS: no wallet, no second app, settings only. */ +export const MinimalBuild: Story = { + args: { credits: null, otherApp: null }, +}; + +/** No settings handler — the account row is inert identity, not a button. */ +export const NoSettings: Story = { + args: { onOpenSettings: undefined }, +}; + +/** Collapsed icon rail: labels become tooltips. */ +export const Collapsed: Story = { + args: { collapsed: true }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; diff --git a/frontend/editor/src/core/components/shared/navFooter/NavFooter.test.tsx b/frontend/editor/src/core/components/shared/navFooter/NavFooter.test.tsx new file mode 100644 index 0000000000..a32063e56f --- /dev/null +++ b/frontend/editor/src/core/components/shared/navFooter/NavFooter.test.tsx @@ -0,0 +1,58 @@ +import { describe, it, expect } from "vitest"; +import { cleanup, render } from "@testing-library/react"; +import { MantineProvider } from "@mantine/core"; +import { NavFooter } from "@app/components/shared/navFooter/NavFooter"; + +/** The footer's tooltips need Mantine's theme context. */ +function withProviders(ui: React.ReactNode) { + return {ui}; +} + +function renderFooter() { + const { container } = render( + withProviders( + {}} + credits={{ remaining: 247, total: 500 }} + otherApp={{ app: "processor", onOpen: () => {} }} + />, + ), + ); + return container.querySelector(".nav-footer") as HTMLElement; +} + +describe("NavFooter — enter animation", () => { + it("plays once per page session, not on every remount", () => { + // The rows are seeded from cache, so they're present at first paint. Every + // later mount — switching apps, remounting a view — would otherwise replay + // the fade on content that never changed, which reads as a twitch. + expect(renderFooter().dataset.animate).toBe("true"); + cleanup(); + expect(renderFooter().dataset.animate).toBeUndefined(); + cleanup(); + expect(renderFooter().dataset.animate).toBeUndefined(); + }); +}); + +describe("NavFooter — separators", () => { + it("never renders a divider beside a row that renders nothing", () => { + // Dividers are CSS between adjacent non-empty slots, so an extras element + // that returns null (the linked org's link-account CTA) can't leave a line. + const { container } = render( + withProviders( + {}} + credits={null} + otherApp={null} + accountExtras={<>{null}} + />, + ), + ); + const slots = container.querySelectorAll(".nav-footer__slot"); + const filled = [...slots].filter((s) => s.childElementCount > 0); + expect(filled).toHaveLength(1); + expect(container.querySelectorAll(".nav-footer__divider")).toHaveLength(0); + }); +}); diff --git a/frontend/editor/src/core/components/shared/navFooter/NavFooter.tsx b/frontend/editor/src/core/components/shared/navFooter/NavFooter.tsx new file mode 100644 index 0000000000..373c91bccf --- /dev/null +++ b/frontend/editor/src/core/components/shared/navFooter/NavFooter.tsx @@ -0,0 +1,213 @@ +import { useState, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { Tooltip } from "@mantine/core"; +import ArrowForwardIcon from "@mui/icons-material/ArrowForward"; +import SettingsIcon from "@mui/icons-material/Settings"; +import { Avatar, NavSurface } from "@app/ui"; +import { BrandMark } from "@app/components/shared/BrandMark"; +import { type AppSwitchTarget } from "@app/components/shared/AppSwitch"; +import { + NavFooterCreditsRow, + type NavFooterCredits, +} from "@app/components/shared/navFooter/NavFooterCreditsRow"; +import "@app/components/shared/navFooter/NavFooter.css"; + +export interface NavFooterAppLink { + /** The app this footer is NOT in — the one the row opens. */ + app: AppSwitchTarget; + onOpen: () => void; +} + +export interface NavFooterProps { + /** Name shown next to the avatar, and the source of its initials fallback. */ + displayName: string; + /** Profile picture; initials are drawn when absent or the URL fails to load. */ + profilePictureUrl?: string | null; + /** Omit to render the account row as static text (no settings affordance). */ + onOpenSettings?: () => void; + /** Null/undefined hides the meter — builds with no wallet never show it. */ + credits?: NavFooterCredits | null; + /** Opens the plan surface from the credits row; omit to leave it inert. */ + onOpenPlan?: () => void; + /** Null/undefined hides the switch row — e.g. no access to the other app. */ + otherApp?: NavFooterAppLink | null; + /** Extra rows above the account row (the self-hosted link-account CTA). */ + accountExtras?: ReactNode; + /** Icon-rail state: labels collapse to tooltips. */ + collapsed?: boolean; + className?: string; +} + +/** + * Whether the enter animation has already played this page session. The rows + * are seeded from cache now, so they're present from first paint and every + * later mount — switching apps, remounting a view — would otherwise replay the + * animation on content that never changed, which reads as the UI twitching. + */ +let hasPlayedEnter = false; + +/** + * The bottom section every sidebar ends with, shared by the editor and the + * processor so both present the same rows. ONE surface, hairline-separated, in + * this order: + * + * 1. caller-contributed rows (the self-hosted link-account CTA) + * 2. free credits remaining + * 3. "Open " + * 4. the account row — avatar, name, settings + * + * Purely presentational: each app resolves its own identity, wallet and + * app-switch access and passes them in, so this file carries no build-specific + * gating. A row whose data is absent is dropped, and so is the separator that + * would have sat beside it. + */ +export function NavFooter({ + displayName, + profilePictureUrl, + onOpenSettings, + credits, + onOpenPlan, + otherApp, + accountExtras, + collapsed = false, + className, +}: NavFooterProps) { + const { t } = useTranslation(); + const [animate] = useState(() => { + if (hasPlayedEnter) return false; + hasPlayedEnter = true; + return true; + }); + + const settingsLabel = t("fileSidebar.openSettings", "Open settings"); + const accountLabel = onOpenSettings + ? `${displayName} - ${settingsLabel}` + : displayName; + + // One surface, hairline-separated rows. Each row gets a slot; the separators + // are drawn by CSS between adjacent NON-EMPTY slots (see NavFooter.css), so a + // row that renders nothing — the link-account CTA returns null once the org is + // linked, and an element is truthy even then — can't leave a line behind. + const rows: Array<{ key: string; node: ReactNode }> = []; + + if (accountExtras) rows.push({ key: "extras", node: accountExtras }); + + if (credits) { + rows.push({ + key: "credits", + node: ( + + ), + }); + } + + if (otherApp) { + rows.push({ + key: "switch", + node: ( + + + + ), + }); + } + + rows.push({ + key: "account", + node: ( + + + + ), + }); + + return ( + + {rows.map((row) => ( +
+ {row.node} +
+ ))} +
+ ); +} + +function openAppLabel( + app: AppSwitchTarget, + t: (key: string, fallback: string) => string, +): string { + return app === "editor" + ? t("navFooter.openEditor", "Open PDF Editor") + : t("navFooter.openProcessor", "Open PDF Processor"); +} diff --git a/frontend/editor/src/core/components/shared/navFooter/NavFooterCreditsRow.css b/frontend/editor/src/core/components/shared/navFooter/NavFooterCreditsRow.css new file mode 100644 index 0000000000..b37def6b2d --- /dev/null +++ b/frontend/editor/src/core/components/shared/navFooter/NavFooterCreditsRow.css @@ -0,0 +1,83 @@ +/* Free-credits meter inside the sidebar footer. The row base (padding, hover, + focus) comes from NavFooter.css; these rules are the meter itself. */ + +.nav-footer__credits { + flex-direction: column; + align-items: stretch; + gap: 0.375rem; + cursor: default; +} + +/* Inert by default, so it must not read as hoverable; the actionable variant + opts back into the shared row hover. */ +.nav-footer__credits:hover { + background: none; +} + +.nav-footer__credits--actionable { + cursor: pointer; +} +.nav-footer__credits--actionable:hover { + background-color: var(--c-hover); +} + +.nav-footer__credits-head { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.8125rem; +} + +.nav-footer__dot { + width: 0.4375rem; + height: 0.4375rem; + border-radius: 50%; + flex-shrink: 0; + background-color: var(--c-success); +} +.nav-footer__dot[data-tone="warning"] { + background-color: var(--c-warning); +} +.nav-footer__dot[data-tone="danger"] { + background-color: var(--c-danger); +} + +.nav-footer__credits-label { + flex: 1; + min-width: 0; + font-weight: 500; + color: var(--c-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.nav-footer__credits-count { + flex-shrink: 0; + color: var(--c-text-muted); + font-variant-numeric: tabular-nums; +} + +/* ---- Collapsed rail ---- */ + +/* Rotated so the fill starts at 12 o'clock and runs clockwise. */ +.nav-footer__credits-ring { + width: 1.25rem; + height: 1.25rem; + margin-inline: auto; + transform: rotate(-90deg); +} + +.nav-footer__credits-ring-track, +.nav-footer__credits-ring-fill { + fill: none; + stroke-width: 3; +} + +.nav-footer__credits-ring-track { + stroke: var(--c-surface-sunken); +} + +.nav-footer__credits-ring-fill { + stroke-linecap: round; +} diff --git a/frontend/editor/src/core/components/shared/navFooter/NavFooterCreditsRow.tsx b/frontend/editor/src/core/components/shared/navFooter/NavFooterCreditsRow.tsx new file mode 100644 index 0000000000..94e941d71c --- /dev/null +++ b/frontend/editor/src/core/components/shared/navFooter/NavFooterCreditsRow.tsx @@ -0,0 +1,158 @@ +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { Tooltip } from "@mantine/core"; +import { ProgressBar } from "@app/ui"; +import "@app/components/shared/navFooter/NavFooterCreditsRow.css"; + +export interface NavFooterCredits { + /** Free credits still available to spend. */ + remaining: number; + /** Size of the free allowance — the "of N" denominator. */ + total: number; +} + +/** Remaining-credit bands, mirroring the usage meters' 80% / 100% thresholds. */ +function creditsTone(remaining: number, total: number): string { + if (remaining <= 0) return "danger"; + return total > 0 && remaining / total <= 0.2 ? "warning" : "success"; +} + +interface NavFooterCreditsRowProps { + credits: NavFooterCredits; + /** Icon rail: the figures drop and the bar alone carries the state. */ + collapsed: boolean; + /** Row label, passed in so the meter owns no copy of its own. */ + label: string; + /** Opens the plan surface. Omit to render the meter as inert text. */ + onOpen?: () => void; +} + +/** + * The free-credits meter as it appears in the sidebar footer: a state dot, the + * label, "X of Y" remaining, and a fill bar underneath. Figures are clamped + * here so a wallet that reports more remaining than the allowance (or negative) + * can't overflow the bar. + * + * Rendered as a {@code nav-footer__row}, so it inherits that row's metrics + * from NavFooter.css and only brings its own meter styling. + */ +export function NavFooterCreditsRow({ + credits, + collapsed, + label, + onOpen, +}: NavFooterCreditsRowProps) { + const { t } = useTranslation(); + + const total = Math.max(0, credits.total); + const remaining = Math.min(Math.max(0, credits.remaining), total); + const tone = creditsTone(remaining, total); + const count = t("navFooter.credits.count", "{{remaining}} of {{total}}", { + remaining: remaining.toLocaleString(), + total: total.toLocaleString(), + }); + + return ( + + + {collapsed ? ( + // The rail is one icon wide, so a full-width bar would read as a + // stray line; a ring carries the same fraction at icon size. + 0 ? remaining / total : 0} + tone={tone} + label={`${label}: ${count}`} + /> + ) : ( + <> +
+ + {label} + {count} +
+ 0 ? remaining / total : 0} + height={6} + color={`var(--c-${tone})`} + label={`${label}: ${count}`} + /> + + )} +
+
+ ); +} + +/** Icon-sized donut carrying the same remaining fraction as the expanded bar. */ +function CreditsRing({ + fraction, + tone, + label, +}: { + fraction: number; + tone: string; + label: string; +}) { + const RADIUS = 8; + const circumference = 2 * Math.PI * RADIUS; + const filled = Math.min(1, Math.max(0, fraction)) * circumference; + + return ( + + + + + ); +} + +/** + * The meter is a button only where there is a plan surface to open — otherwise + * it stays a plain div, so a build with nowhere to go doesn't advertise a + * click that does nothing. + */ +function Row({ + onOpen, + label, + children, +}: { + onOpen?: () => void; + label: string; + children: ReactNode; +}) { + const className = `nav-footer__row nav-footer__credits${ + onOpen ? " nav-footer__credits--actionable" : "" + }`; + if (!onOpen) return
{children}
; + return ( + + ); +} diff --git a/frontend/editor/src/core/components/tools/changeMetadata/ChangeMetadataSingleStep.tsx b/frontend/editor/src/core/components/tools/changeMetadata/ChangeMetadataSingleStep.tsx index 2eff20b23b..07ba4e7e05 100644 --- a/frontend/editor/src/core/components/tools/changeMetadata/ChangeMetadataSingleStep.tsx +++ b/frontend/editor/src/core/components/tools/changeMetadata/ChangeMetadataSingleStep.tsx @@ -1,5 +1,7 @@ +import { useContext, useEffect, useState } from "react"; import { Stack, Divider, Text } from "@mantine/core"; import { useTranslation } from "react-i18next"; +import { ViewerContext } from "@app/contexts/ViewerContext"; import { ChangeMetadataParameters, createCustomMetadataFunctions, @@ -19,6 +21,31 @@ interface ChangeMetadataSingleStepProps { disabled?: boolean; } +/** + * Pre-fills the form from the currently open document's existing metadata. + * Isolated in its own component so it only mounts where a ViewerProvider exists + * (the editor and the in-editor Automate modal). The pipeline builder has no + * viewer and no single "current document", so it is skipped there rather than + * crashing on useViewer. + */ +const MetadataPrefill = ({ + onParameterChange, + onExtractingChange, +}: { + onParameterChange: ChangeMetadataSingleStepProps["onParameterChange"]; + onExtractingChange: (extracting: boolean) => void; +}) => { + const { isExtractingMetadata } = useMetadataExtraction({ + updateParameter: onParameterChange, + }); + + useEffect(() => { + onExtractingChange(isExtractingMetadata); + }, [isExtractingMetadata, onExtractingChange]); + + return null; +}; + const ChangeMetadataSingleStep = ({ parameters, onParameterChange, @@ -26,77 +53,85 @@ const ChangeMetadataSingleStep = ({ }: ChangeMetadataSingleStepProps) => { const { t } = useTranslation(); + // Auto-prefill reads the viewer/file contexts, which only exist in the editor. + // Gate on the viewer so the pipeline builder renders the fields without it. + const hasViewerContext = useContext(ViewerContext) !== null; + const [isExtractingMetadata, setIsExtractingMetadata] = useState(false); + // Get custom metadata functions using the utility const { addCustomMetadata, removeCustomMetadata, updateCustomMetadata } = createCustomMetadataFunctions(parameters, onParameterChange); - // Extract metadata from uploaded files - const { isExtractingMetadata } = useMetadataExtraction({ - updateParameter: onParameterChange, - }); - const isDeleteAllEnabled = parameters.deleteAll; const fieldsDisabled = disabled || isDeleteAllEnabled || isExtractingMetadata; return ( - - {/* Delete All */} - - - {t("changeMetadata.deleteAll.label", "Delete All Metadata")} - - + {hasViewerContext && ( + - - - - - {/* Standard Metadata Fields */} + )} - - {t("changeMetadata.standardFields.title", "Standard Metadata")} - - + {/* Delete All */} + + + {t("changeMetadata.deleteAll.label", "Delete All Metadata")} + + + + + + + {/* Standard Metadata Fields */} + + + {t("changeMetadata.standardFields.title", "Standard Metadata")} + + + + + + + {/* Document Dates */} + + + {t("changeMetadata.dates.title", "Document Dates")} + + + + + + + {/* Advanced Options */} + + + {t("changeMetadata.advanced.title", "Advanced Options")} + + + - - - - {/* Document Dates */} - - - {t("changeMetadata.dates.title", "Document Dates")} - - - - - - - {/* Advanced Options */} - - - {t("changeMetadata.advanced.title", "Advanced Options")} - - - - + ); }; diff --git a/frontend/editor/src/core/components/tools/overlayPdfs/OverlayPdfsSettings.tsx b/frontend/editor/src/core/components/tools/overlayPdfs/OverlayPdfsSettings.tsx index 2648990248..e7246bb4e3 100644 --- a/frontend/editor/src/core/components/tools/overlayPdfs/OverlayPdfsSettings.tsx +++ b/frontend/editor/src/core/components/tools/overlayPdfs/OverlayPdfsSettings.tsx @@ -1,3 +1,4 @@ +import { useContext, useRef } from "react"; import { Stack, Text, @@ -7,6 +8,7 @@ import { Divider, } from "@mantine/core"; import { Button } from "@app/ui/Button"; +import { FilePicker } from "@app/ui/FilePicker"; import { ActionIcon } from "@app/ui/ActionIcon"; import { SegmentedControl } from "@app/ui/SegmentedControl"; import { useTranslation } from "react-i18next"; @@ -15,7 +17,7 @@ import { type OverlayMode, } from "@app/hooks/tools/overlayPdfs/useOverlayPdfsParameters"; import LocalIcon from "@app/components/shared/LocalIcon"; -import { useFilesModalContext } from "@app/contexts/FilesModalContext"; +import { FilesModalContext } from "@app/contexts/FilesModalContext"; import styles from "@app/components/tools/overlayPdfs/OverlayPdfsSettings.module.css"; import { Z_INDEX_AUTOMATE_DROPDOWN } from "@app/styles/zIndex"; @@ -34,7 +36,12 @@ export default function OverlayPdfsSettings({ disabled = false, }: OverlayPdfsSettingsProps) { const { t } = useTranslation(); - const { openFilesModal } = useFilesModalContext(); + // Read optionally: the portal pipeline builder mounts no FilesModalProvider. + // Present (editor tool + Automate modal) -> keep the workspace file picker; + // absent (portal) -> fall back to the plain file input below. + const filesModal = useContext(FilesModalContext); + // Clears the FilePicker so the same file can be re-selected (Mantine resetRef). + const resetOverlayPicker = useRef<() => void>(null); const handleOverlayFilesChange = (files: File[]) => { onParameterChange("overlayFiles", files); @@ -66,8 +73,8 @@ export default function OverlayPdfsSettings({ }; const handleOpenOverlayFilesModal = () => { - if (disabled) return; - openFilesModal({ + if (disabled || !filesModal) return; + filesModal.openFilesModal({ customHandler: (files: File[]) => { handleOverlayFilesChange([ ...(parameters.overlayFiles || []), @@ -77,6 +84,17 @@ export default function OverlayPdfsSettings({ }); }; + const appendOverlayFiles = (files: File[]) => { + if (files.length === 0) return; + handleOverlayFilesChange([...(parameters.overlayFiles || []), ...files]); + resetOverlayPicker.current?.(); + }; + + const overlayFilesButtonLabel = + parameters.overlayFiles?.length > 0 + ? t("overlay-pdfs.overlayFiles.addMore", "Add more PDFs...") + : t("overlay-pdfs.overlayFiles.placeholder", "Choose PDF(s)..."); + return ( @@ -183,17 +201,30 @@ export default function OverlayPdfsSettings({ {t("overlay-pdfs.overlayFiles.label", "Overlay Files")} - + {filesModal ? ( + + ) : ( + } + fullWidth + > + {overlayFilesButtonLabel} + + )} {parameters.overlayFiles?.length > 0 && (() => { diff --git a/frontend/editor/src/core/contexts/FilesModalContext.tsx b/frontend/editor/src/core/contexts/FilesModalContext.tsx index 73d1b0477f..17585ae7e8 100644 --- a/frontend/editor/src/core/contexts/FilesModalContext.tsx +++ b/frontend/editor/src/core/contexts/FilesModalContext.tsx @@ -41,7 +41,9 @@ interface FilesModalContextType { setOnModalClose: (callback: () => void) => void; } -const FilesModalContext = createContext(null); +export const FilesModalContext = createContext( + null, +); export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ children, diff --git a/frontend/editor/src/core/data/automatableToolsHaveOperationConfig.test.tsx b/frontend/editor/src/core/data/automatableToolsHaveOperationConfig.test.tsx index 461c07ebdf..c8942942df 100644 --- a/frontend/editor/src/core/data/automatableToolsHaveOperationConfig.test.tsx +++ b/frontend/editor/src/core/data/automatableToolsHaveOperationConfig.test.tsx @@ -30,4 +30,15 @@ describe("automatable tools", () => { expect(offeredWithoutConfig).toEqual([]); }); + + // Reorganize Pages has an automatable form (organization mode + page-order string) and a + // context-free settings component, but its registry entry once left automationSettings null, + // so both Automate and the pipeline builder showed "no configurable settings". Guard the wiring. + test("Reorganize Pages exposes automation settings so it is configurable, not no-settings", () => { + const { result } = renderHook(() => useTranslatedToolCatalog()); + + expect( + result.current.regularTools.reorganizePages?.automationSettings, + ).toBeTruthy(); + }); }); diff --git a/frontend/editor/src/core/data/useTranslatedToolRegistry.tsx b/frontend/editor/src/core/data/useTranslatedToolRegistry.tsx index 0a39da2fb9..5ae8075d0f 100644 --- a/frontend/editor/src/core/data/useTranslatedToolRegistry.tsx +++ b/frontend/editor/src/core/data/useTranslatedToolRegistry.tsx @@ -700,7 +700,10 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { endpoints: ["rearrange-pages"], operationConfig: asRegistryConfig(reorganizePagesOperationConfig), synonyms: getSynonyms(t, "reorganizePages"), - automationSettings: null, + automationSettings: lazySettings( + () => + import("@app/components/tools/reorganizePages/ReorganizePagesSettings"), + ), }, scalePages: { icon: ( diff --git a/frontend/editor/src/core/hooks/useAccountIdentity.ts b/frontend/editor/src/core/hooks/useAccountIdentity.ts new file mode 100644 index 0000000000..026ac08dab --- /dev/null +++ b/frontend/editor/src/core/hooks/useAccountIdentity.ts @@ -0,0 +1,64 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useAuth } from "@app/auth/UseSession"; +import { useProfilePictureUrl } from "@app/hooks/useProfilePictureUrl"; +import { useAppConfig } from "@app/contexts/AppConfigContext"; +import { accountService } from "@app/services/accountService"; + +export interface AccountIdentity { + /** Never empty — falls back to a generic "User" so a row is never blank. */ + displayName: string; + profilePictureUrl: string | null; + isAnonymous: boolean; +} + +/** + * The signed-in identity as the UI should draw it: one name and one picture, + * resolved the same way everywhere. Every surface that shows "who am I" (the + * editor and processor sidebar footers, the account settings page) reads this, + * so a user can't see one initial in the sidebar and a different one in + * settings. + * + * Resolution order for the name: the auth layer's own displayName (each layer + * derives it from its native user shape), then the proprietary REST endpoint, + * then a generic last resort. + */ +export function useAccountIdentity(): AccountIdentity { + const { t } = useTranslation(); + const { config } = useAppConfig(); + const { displayName: authDisplayName, isAnonymous } = useAuth(); + const profilePictureUrl = useProfilePictureUrl(); + const [accountUsername, setAccountUsername] = useState(null); + + useEffect(() => { + if (!config?.enableLogin) { + setAccountUsername(null); + return; + } + if (authDisplayName) { + // The auth context has a name; don't bother hitting the REST + // endpoint, but clear any stale cached value from a prior call. + setAccountUsername(null); + return; + } + accountService + .getAccountData() + .then((data) => { + // Always reflect the latest result - including clearing it on + // sign-out, when the endpoint returns no username (or 401s into + // the catch branch below). Without this, signing out would leave + // the old username on screen. + setAccountUsername(data?.username ?? null); + }) + .catch(() => { + setAccountUsername(null); + }); + }, [config?.enableLogin, authDisplayName]); + + return { + displayName: + authDisplayName ?? accountUsername ?? t("auth.displayName.user", "User"), + profilePictureUrl, + isAnonymous, + }; +} diff --git a/frontend/editor/src/core/hooks/useFreeCreditsSummary.ts b/frontend/editor/src/core/hooks/useFreeCreditsSummary.ts new file mode 100644 index 0000000000..52702a3f74 --- /dev/null +++ b/frontend/editor/src/core/hooks/useFreeCreditsSummary.ts @@ -0,0 +1,12 @@ +import { type NavFooterCredits } from "@app/components/shared/navFooter/NavFooterCreditsRow"; + +/** + * Free credits left on this team's allowance, for the sidebar footer meter. + * Null hides the meter entirely. + * + * Core has no wallet — self-hosted installs aren't metered — so there is + * nothing to show. Cloud builds override this with the live wallet figure. + */ +export function useFreeCreditsSummary(): NavFooterCredits | null { + return null; +} diff --git a/frontend/editor/src/core/hooks/useOpenPlan.ts b/frontend/editor/src/core/hooks/useOpenPlan.ts new file mode 100644 index 0000000000..da6fe2207e --- /dev/null +++ b/frontend/editor/src/core/hooks/useOpenPlan.ts @@ -0,0 +1,10 @@ +/** + * Opens the plan surface behind the sidebar footer's free-credits row, or null + * when this build has none (the row is then inert text rather than a button). + * + * Core ships no wallet and no plan section, so there is nothing to open. Builds + * that meter usage override this with their own surface. + */ +export function useOpenPlan(): (() => void) | null { + return null; +} diff --git a/frontend/editor/src/core/hooks/useOtherAppSwitch.ts b/frontend/editor/src/core/hooks/useOtherAppSwitch.ts new file mode 100644 index 0000000000..612589899b --- /dev/null +++ b/frontend/editor/src/core/hooks/useOtherAppSwitch.ts @@ -0,0 +1,12 @@ +import { type NavFooterAppLink } from "@app/components/shared/navFooter/NavFooter"; + +/** + * The sibling app this build can switch to (editor ⇄ processor), or null when + * there is none. The single gate behind both the brand switcher and the + * sidebar footer's "Open ..." row, so the two can never disagree about access. + * + * Core ships no processor, so there is nothing to switch to. + */ +export function useOtherAppSwitch(): NavFooterAppLink | null { + return null; +} diff --git a/frontend/editor/src/core/query/keys.ts b/frontend/editor/src/core/query/keys.ts index a7a68ea256..5354b56b63 100644 --- a/frontend/editor/src/core/query/keys.ts +++ b/frontend/editor/src/core/query/keys.ts @@ -6,5 +6,8 @@ export const qk = { ["editor", "endpointEnabled", endpoint] as const, footerInfo: () => ["editor", "footerInfo"] as const, groupEnabled: (group: string) => ["editor", "groupEnabled", group] as const, + /** Keyed on the asking identity: two users must never share one answer. */ + portalAccess: (userId: string | null) => + ["editor", "portalAccess", userId] as const, users: () => ["editor", "users"] as const, } as const; diff --git a/frontend/editor/src/core/services/httpErrorHandler.ts b/frontend/editor/src/core/services/httpErrorHandler.ts index ed7108b6a6..55b0a425f9 100644 --- a/frontend/editor/src/core/services/httpErrorHandler.ts +++ b/frontend/editor/src/core/services/httpErrorHandler.ts @@ -1,4 +1,4 @@ -// frontend/src/services/httpErrorHandler.ts +// frontend/editor/src/core/services/httpErrorHandler.ts import { alert } from "@app/components/toast"; import { broadcastErroredFiles, diff --git a/frontend/editor/src/core/services/navFooterCache.ts b/frontend/editor/src/core/services/navFooterCache.ts new file mode 100644 index 0000000000..cbde941c81 --- /dev/null +++ b/frontend/editor/src/core/services/navFooterCache.ts @@ -0,0 +1,73 @@ +/** + * Last-known sidebar-footer state, so the rows are correct at first paint + * instead of arriving a request later. + * + * The footer is mounted by both apps, and the editor and processor are separate + * React trees with separate query caches — so without this, every navigation + * between them (and every remount inside them) re-ran the fetches and the rows + * visibly popped in and shoved each other around. Persisting to storage rather + * than to an in-memory cache is what makes it survive that boundary, and a + * reload. + * + * Deliberately stale-then-revalidate: what's stored is only ever what the + * backend last said, every reader refetches immediately and overwrites, and + * nothing is gated on it — the processor enforces its own access server-side, + * and a stale credit figure is replaced within a second of the wallet landing. + */ +const CREDITS_KEY = "stirling.navFooter.credits"; +const OTHER_APP_KEY = "stirling.navFooter.otherApp"; + +/** Figures, or null for a team that sees no meter at all (a paying one). */ +export type CachedCredits = { remaining: number; total: number } | null; + +function read(key: string): string | null { + try { + return localStorage.getItem(key); + } catch { + // Private mode / storage disabled — behave as a first-ever load. + return null; + } +} + +function write(key: string, value: string): void { + try { + localStorage.setItem(key, value); + } catch { + // Nothing to do: the cache is an optimisation, never a correctness input. + } +} + +/** `undefined` when this browser has never seen an answer. */ +export function readCachedCredits(): CachedCredits | undefined { + const raw = read(CREDITS_KEY); + if (raw === null) return undefined; + if (raw === "none") return null; + try { + const parsed: unknown = JSON.parse(raw); + if ( + typeof parsed === "object" && + parsed !== null && + typeof (parsed as CachedCredits & object).remaining === "number" && + typeof (parsed as CachedCredits & object).total === "number" + ) { + return parsed as CachedCredits; + } + } catch { + // Corrupt entry — fall through and treat it as never-seen. + } + return undefined; +} + +export function writeCachedCredits(credits: CachedCredits): void { + write(CREDITS_KEY, credits === null ? "none" : JSON.stringify(credits)); +} + +/** `undefined` when this browser has never seen an answer. */ +export function readCachedOtherApp(): boolean | undefined { + const raw = read(OTHER_APP_KEY); + return raw === null ? undefined : raw === "true"; +} + +export function writeCachedOtherApp(canOpen: boolean): void { + write(OTHER_APP_KEY, String(canOpen)); +} diff --git a/frontend/editor/src/core/services/updateService.ts b/frontend/editor/src/core/services/updateService.ts index 043c53bc36..8b23d26deb 100644 --- a/frontend/editor/src/core/services/updateService.ts +++ b/frontend/editor/src/core/services/updateService.ts @@ -185,7 +185,7 @@ export class UpdateService { */ async getCurrentVersionFromGitHub(): Promise { const url = - "https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/V2-master/build.gradle"; + "https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/release/build.gradle"; try { const response = await fetch(url); diff --git a/frontend/editor/src/core/theme/colors.css b/frontend/editor/src/core/theme/colors.css index 3caf240da4..dd3c2aec7c 100644 --- a/frontend/editor/src/core/theme/colors.css +++ b/frontend/editor/src/core/theme/colors.css @@ -32,6 +32,9 @@ html[data-app-theme="light"] { --c-primary: var(--p-blue-500); --c-primary-hover: var(--p-blue-600); --c-primary-subtle: color-mix(in srgb, var(--p-blue-500) 10%, transparent); + /* Accent used as TEXT (links, toggles): a deeper hue that clears 4.5:1 on + light surfaces, where --c-primary itself does not. */ + --c-accent-text: var(--p-blue-700); --c-success: var(--p-green-600); --c-danger: var(--p-red-600); @@ -68,6 +71,11 @@ html[data-app-theme="light"] { var(--c-success) 10%, var(--c-surface) ); + --c-warning-subtle: color-mix( + in srgb, + var(--c-warning) 10%, + var(--c-surface) + ); /* ── Decorative / brand / categorical palette ────────────────────────── Fixed hues that intentionally do NOT follow the chosen accent: brand @@ -144,6 +152,8 @@ html[data-app-theme="midnight"] { --c-text-muted: var(--p-zinc-200); --c-text-subtle: var(--p-zinc-250); --c-text-on-primary: var(--p-white); + /* Accent used as TEXT on dark surfaces: a lighter step for 4.5:1. */ + --c-accent-text: var(--p-blue-400); --c-btn-solid: var(--c-text); --c-btn-inverse: var(--p-ink); --c-btn-secondary: var(--p-c-1a1a1d); diff --git a/frontend/editor/src/core/theme/dimensions.css b/frontend/editor/src/core/theme/dimensions.css index aa0a1b38cc..554baddcca 100644 --- a/frontend/editor/src/core/theme/dimensions.css +++ b/frontend/editor/src/core/theme/dimensions.css @@ -62,6 +62,7 @@ /* ── Z-index ladder ── */ --z-dropdown: 25; --z-drawer: 50; + --z-popover: 150; --z-toast: 200; /* Fullscreen tool-picker surfaces (editor) */ --z-fullscreen-icon-svg: 1; diff --git a/frontend/editor/src/core/ui/Avatar.css b/frontend/editor/src/core/ui/Avatar.css index e2e8dc63bd..360c15c566 100644 --- a/frontend/editor/src/core/ui/Avatar.css +++ b/frontend/editor/src/core/ui/Avatar.css @@ -46,6 +46,12 @@ height: 2.5rem; font-size: 1rem; } +/* Account-settings hero disc. */ +.sui-avatar--xl { + width: 4.5rem; + height: 4.5rem; + font-size: 1.75rem; +} .sui-avatar__img { width: 100%; diff --git a/frontend/editor/src/core/ui/Avatar.tsx b/frontend/editor/src/core/ui/Avatar.tsx index c7cfac501b..42e7aca11f 100644 --- a/frontend/editor/src/core/ui/Avatar.tsx +++ b/frontend/editor/src/core/ui/Avatar.tsx @@ -1,6 +1,7 @@ +import { useEffect, useState } from "react"; import "@app/ui/Avatar.css"; -export type AvatarSize = "xs" | "sm" | "md" | "lg"; +export type AvatarSize = "xs" | "sm" | "md" | "lg" | "xl"; export type AvatarTone = | "blue" | "purple" @@ -23,10 +24,12 @@ export interface AvatarProps { className?: string; } -function initialsOf(name: string): string { +function avatarInitials(name: string): string { const parts = name.trim().split(/\s+/).filter(Boolean); if (parts.length === 0) return "?"; - if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); + // Single word (a username or an email) reads as one letter — two letters of + // "admin" ("AD") looks like a different person's initials, not a truncation. + if (parts.length === 1) return parts[0].slice(0, 1).toUpperCase(); return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); } @@ -43,6 +46,13 @@ export function Avatar({ ariaLabel, className, }: AvatarProps) { + // A picture URL that 404s (expired signed URL, deleted upload) must not leave + // an empty disc — fall back to the same initials the no-picture case shows, so + // every surface rendering this identity agrees on what it draws. + const [srcFailed, setSrcFailed] = useState(false); + useEffect(() => setSrcFailed(false), [src]); + const showImage = Boolean(src) && !srcFailed; + const classes = [ "sui-avatar", `sui-avatar--${size}`, @@ -53,11 +63,16 @@ export function Avatar({ .filter(Boolean) .join(" "); - const content = src ? ( - {ariaLabel + const content = showImage ? ( + {ariaLabel setSrcFailed(true)} + /> ) : ( - {initialsOf(name)} + {avatarInitials(name)} ); diff --git a/frontend/editor/src/core/ui/DataTable.css b/frontend/editor/src/core/ui/DataTable.css new file mode 100644 index 0000000000..c7572efa90 --- /dev/null +++ b/frontend/editor/src/core/ui/DataTable.css @@ -0,0 +1,376 @@ +/* + * DataTable: the one Stirling table. Appearance is owned entirely here: the + * canonical surface, the header/row grammar, the standardized loading / empty / + * error states, and every cell KIND (`sui-dtc__*`). Call-sites choose a kind and + * supply data; they never style a cell. This is what lets every table look and + * behave the same. + */ + +.sui-datatable { + width: 100%; +} + +/* The one canonical surface: every table wears it; call-sites don't wrap it. */ +.sui-datatable__frame { + border: 1px solid var(--c-border); + border-radius: var(--radius-md); + background: var(--c-surface); + overflow: hidden; +} + +.sui-datatable__toolbar { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; + padding: 0.75rem 0.875rem; + border-bottom: 1px solid var(--c-border); +} + +.sui-datatable__scroll { + width: 100%; + overflow-x: auto; +} + +.sui-datatable__table { + width: 100%; + border-collapse: collapse; + font-size: 0.8125rem; +} + +.sui-datatable__caption { + caption-side: top; + text-align: left; + padding: 0.625rem 0.875rem; + color: var(--c-text-subtle); + font-size: 0.75rem; +} + +/* Header */ +.sui-datatable__th { + text-align: left; + font-weight: 600; + color: var(--c-text-subtle); + font-size: 0.6875rem; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 0.625rem 0.875rem; + border-bottom: 1px solid var(--c-border); + white-space: nowrap; +} +.sui-datatable__th--right { + text-align: right; +} +.sui-datatable__th--fit { + width: 1%; +} +/* Accessible-only header text for blank affordance/action columns. */ +.sui-datatable__th-sr { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.sui-datatable__sort { + display: inline-flex; + align-items: center; + gap: 0.25rem; + background: none; + border: none; + padding: 0; + margin: 0; + cursor: pointer; + font: inherit; + letter-spacing: inherit; + text-transform: inherit; + color: inherit; +} +.sui-datatable__th--right .sui-datatable__sort { + flex-direction: row-reverse; +} +.sui-datatable__sort:hover { + color: var(--c-text); +} +.sui-datatable__sort:focus-visible { + outline: 0.125rem solid var(--c-primary); + outline-offset: 0.125rem; + border-radius: var(--radius-sm); +} +.sui-datatable__sort-icon { + display: inline-flex; + color: var(--c-text-subtle); + opacity: 0; + transition: opacity var(--motion-fast); +} +.sui-datatable__sort:hover .sui-datatable__sort-icon { + opacity: 0.5; +} +.sui-datatable__sort-icon--asc, +.sui-datatable__sort-icon--desc { + opacity: 1; + color: var(--c-primary); +} +.sui-datatable__sort-icon--desc svg { + transform: rotate(180deg); +} + +/* Body */ +.sui-datatable__td { + padding: 0.625rem 0.875rem; + color: var(--c-text-muted); + border-bottom: 1px solid var(--c-border-subtle); + vertical-align: middle; +} +.sui-datatable__table tbody tr:last-child .sui-datatable__td { + border-bottom: none; +} +.sui-datatable__td--right { + text-align: right; +} +.sui-datatable__td--nowrap { + white-space: nowrap; +} +.sui-datatable__td--fit { + width: 1%; + white-space: nowrap; +} + +/* Density (the compact variant) */ +.sui-datatable--compact .sui-datatable__th, +.sui-datatable--compact .sui-datatable__td { + padding: 0.375rem 0.625rem; +} + +/* Interactive rows */ +.sui-datatable__row--interactive { + cursor: pointer; + transition: background var(--motion-fast); +} +.sui-datatable__row--interactive:hover { + background: var(--c-hover); +} +.sui-datatable__row--interactive:focus-visible { + outline: 0.125rem solid var(--c-primary); + outline-offset: -0.125rem; +} + +/* Non-actionable rows (e.g. coming-soon): de-emphasized to read as disabled. + Muting is done with accessible colours + a faded (non-text) icon rather than + row opacity, which would blend text under the WCAG contrast floor. */ +.sui-datatable__row--muted .sui-dtc__entity-name { + color: var(--c-text-muted); + font-weight: 500; +} +.sui-datatable__row--muted .sui-dtc__entity-icon { + opacity: 0.4; +} + +.sui-datatable__chevron { + display: inline-flex; + color: var(--c-text-subtle); +} + +/* Full-width state cell (empty / error) */ +.sui-datatable__state { + padding: 2rem; + text-align: center; + color: var(--c-text-subtle); +} +.sui-datatable__state--error { + color: var(--c-danger); +} +.sui-datatable__state--node { + padding: 0; +} + +/* + * Cell kinds (sui-dtc__*): the locked cell vocabulary. These absorb the + * per-namespace portal-*__ cell styles (mono / muted / cell-stack / name-cell / + * caret …) that every table used to re-implement. + */ +.sui-dtc__text { + color: var(--c-text-muted); +} +/* Labeled value ("Category: action"): full-strength text, bold label. */ +.sui-dtc__text--labeled { + color: var(--c-text); +} +.sui-dtc__text-label { + font-weight: 600; +} +.sui-dtc__mono { + font-family: var(--font-mono); + font-size: 0.75rem; + color: var(--c-text-muted); +} +.sui-dtc__muted { + color: var(--c-text-subtle); +} +.sui-dtc__num { + font-variant-numeric: tabular-nums; +} +.sui-dtc__labels { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.375rem; +} + +/* entity: leading icon + bold name + optional muted note */ +.sui-dtc__entity { + display: flex; + align-items: center; + gap: 0.625rem; + min-width: 0; +} +.sui-dtc__entity-icon { + display: inline-flex; + align-items: center; + flex: none; + color: var(--c-text-muted); +} +/* The component sizes bare icons; self-styled marks (Avatar) render as-is. */ +.sui-dtc__entity-icon svg { + width: 1.2rem; + height: 1.2rem; +} +.sui-dtc__entity-body { + display: flex; + flex-direction: column; + gap: 0.1875rem; + min-width: 0; +} +.sui-dtc__entity-head { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 0.25rem; +} +.sui-dtc__entity-name { + font-weight: 600; + color: var(--c-text); +} +.sui-dtc__entity-suffix { + font-size: 0.75rem; + font-weight: 400; + color: var(--c-text-subtle); +} +.sui-dtc__note { + font-size: 0.75rem; + color: var(--c-text-subtle); +} + +.sui-dtc__actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.25rem; +} +.sui-dtc__menu-item--danger { + color: var(--c-danger); +} + +/* Grouped tables: section header row + per-group "show all" toggle. */ +.sui-datatable__group-cell { + padding: 0.5rem 0.875rem; + background: var(--c-surface-sunken, var(--c-hover)); + border-bottom: 1px solid var(--c-border); +} +.sui-datatable__table + tbody + tr.sui-datatable__group:not(:first-child) + .sui-datatable__group-cell { + border-top: 1px solid var(--c-border); +} +.sui-datatable__group-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} +.sui-datatable__group-title { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 0.5rem; +} +.sui-datatable__group-title strong { + font-size: 0.8125rem; + color: var(--c-text); +} +.sui-datatable__group-meta { + font-size: 0.75rem; + color: var(--c-text-subtle); +} +.sui-datatable__group-more { + padding: 0.5rem 0.875rem; +} +.sui-datatable__show-all { + background: none; + border: none; + padding: 0; + cursor: pointer; + font: inherit; + font-size: 0.75rem; + color: var(--c-accent-text); +} +.sui-datatable__show-all:hover { + text-decoration: underline; +} + +.sui-dtc__progress { + display: flex; + align-items: center; + gap: 0.5rem; +} +.sui-dtc__progress-bar { + display: block; + width: 5rem; +} +.sui-dtc__progress-pct { + font-variant-numeric: tabular-nums; + color: var(--c-text-muted); + min-width: 2.5rem; +} + +.sui-dtc__links { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.75rem; +} +/* Neutral chip-style link (matches the invoice-link look): the colour is not an + * accent - the trailing glyph + hover fill carry the affordance. */ +.sui-dtc__link { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.5rem; + border-radius: var(--radius-sm); + color: var(--c-text); + text-decoration: none; + font-weight: 500; + border: 1px solid transparent; + transition: + background var(--motion-fast), + border-color var(--motion-fast); +} +.sui-dtc__link:hover { + background: var(--c-surface-sunken); + border-color: var(--c-border); +} +.sui-dtc__link:focus-visible { + outline: none; + border-color: var(--c-primary); + background: var(--c-surface-sunken); +} + +.sui-dtc__select { + min-width: 13rem; +} diff --git a/frontend/editor/src/core/ui/DataTable.stories.tsx b/frontend/editor/src/core/ui/DataTable.stories.tsx new file mode 100644 index 0000000000..630537ace9 --- /dev/null +++ b/frontend/editor/src/core/ui/DataTable.stories.tsx @@ -0,0 +1,262 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Button } from "@app/ui/Button"; +import { column, DataTable, type DataTableColumn } from "@app/ui/DataTable"; + +interface Region { + id: string; + name: string; + code: string; + status: "healthy" | "degraded"; + docs: number; + latency: number; + auto: boolean; +} + +const REGIONS: Region[] = [ + { + id: "1", + name: "US East", + code: "us-east-1", + status: "healthy", + docs: 12481, + latency: 41, + auto: true, + }, + { + id: "2", + name: "US West", + code: "us-west-2", + status: "healthy", + docs: 8210, + latency: 63, + auto: false, + }, + { + id: "3", + name: "EU West", + code: "eu-west-1", + status: "degraded", + docs: 3044, + latency: 190, + auto: true, + }, + { + id: "4", + name: "AP South", + code: "ap-south-1", + status: "healthy", + docs: 5622, + latency: 88, + auto: false, + }, +]; + +const tone = (r: Region) => ({ + tone: r.status === "healthy" ? ("success" as const) : ("warning" as const), + label: r.status, +}); + +const COLUMNS: DataTableColumn[] = [ + column.entity({ + key: "name", + header: "Region", + primary: (r) => r.name, + }), + column.mono({ key: "code", header: "Code", get: (r) => r.code }), + column.badge({ key: "status", header: "Status", get: tone }), + column.number({ + key: "docs", + header: "Docs 24h", + get: (r) => r.docs, + format: (n) => n.toLocaleString(), + }), + column.number({ + key: "latency", + header: "P95", + get: (r) => r.latency, + format: (n) => `${n} ms`, + }), +]; + +const SORTABLE_COLUMNS: DataTableColumn[] = [ + column.entity({ + key: "name", + header: "Region", + primary: (r) => r.name, + sortable: true, + }), + column.mono({ + key: "code", + header: "Code", + get: (r) => r.code, + sortable: true, + }), + column.badge({ key: "status", header: "Status", get: tone, sortable: true }), + column.number({ + key: "docs", + header: "Docs 24h", + get: (r) => r.docs, + format: (n) => n.toLocaleString(), + sortable: true, + }), + column.number({ + key: "latency", + header: "P95", + get: (r) => r.latency, + format: (n) => `${n} ms`, + sortable: true, + }), +]; + +const meta: Meta = { + title: "Compound/DataTable", + component: DataTable, + tags: ["autodocs"], + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +/** Columns come from the `column` vocabulary; call-sites never style a cell. */ +export const Basic: Story = { + render: () => ( + columns={COLUMNS} rows={REGIONS} rowKey={(r) => r.id} /> + ), +}; + +/** Sorting is opt-in per column (`sortable: true`); click a header. */ +export const Sortable: Story = { + render: () => ( + + columns={SORTABLE_COLUMNS} + rows={REGIONS} + rowKey={(r) => r.id} + defaultSort={{ key: "docs", direction: "desc" }} + /> + ), +}; + +/** `onRowClick` + a chevron affordance. The line above shows the click land. */ +export const Interactive: Story = { + render: () => { + function Bound() { + const [clicked, setClicked] = useState(null); + return ( + <> +

+ {clicked + ? `Clicked: ${clicked.name} (${clicked.code})` + : "Click a row to fire onRowClick."} +

+ + columns={COLUMNS} + rows={REGIONS} + rowKey={(r) => r.id} + onRowClick={setClicked} + rowAffordance="chevron" + /> + + ); + } + return ; + }, +}; + +/** A trailing action column (icon-only kebab), locked to the design system. */ +export const WithActions: Story = { + render: () => ( + + columns={[ + ...COLUMNS, + column.actions({ + key: "actions", + get: () => [ + { + label: "Row actions", + glyph: "kebab", + iconOnly: true, + onClick: () => {}, + }, + ], + }), + ]} + rows={REGIONS} + rowKey={(r) => r.id} + /> + ), +}; + +/** First-load skeleton mirrors the real column layout. */ +export const Loading: Story = { + render: () => ( + + columns={COLUMNS} + rows={[]} + rowKey={(r) => r.id} + loading + /> + ), +}; + +/** Standardized empty slot. */ +export const Empty: Story = { + render: () => ( + + columns={COLUMNS} + rows={[]} + rowKey={(r) => r.id} + empty="No regions deployed yet." + /> + ), +}; + +/** Standardized error slot (announced as an alert). */ +export const ErrorState: Story = { + render: () => ( + + columns={COLUMNS} + rows={[]} + rowKey={(r) => r.id} + error="Couldn't load regions. Try again." + /> + ), +}; + +/** Optional toolbar slot above the table, inside the surface. */ +export const WithToolbar: Story = { + render: () => ( + + columns={COLUMNS} + rows={REGIONS} + rowKey={(r) => r.id} + toolbar={ + <> + Regions + + + + } + /> + ), +}; + +/** The one look choice: the `compact` variant. */ +export const Compact: Story = { + render: () => ( + + columns={COLUMNS} + rows={REGIONS} + rowKey={(r) => r.id} + variant="compact" + /> + ), +}; diff --git a/frontend/editor/src/core/ui/DataTable.tsx b/frontend/editor/src/core/ui/DataTable.tsx new file mode 100644 index 0000000000..ccb5578016 --- /dev/null +++ b/frontend/editor/src/core/ui/DataTable.tsx @@ -0,0 +1,516 @@ +import { type KeyboardEvent, type ReactNode, useMemo, useState } from "react"; +import { + type ColumnDef, + createColumnHelper, + createSortedRowModel, + flexRender, + type RowData, + rowSortingFeature, + sortFn_alphanumeric, + sortFn_basic, + type SortingState, + tableFeatures, + useTable, +} from "@tanstack/react-table"; +import { useTranslation } from "react-i18next"; +import { Skeleton } from "@app/ui/Skeleton"; +import { + type CellAction, + type DataTableColumn, + renderCellActions, +} from "@app/ui/dataTableColumns"; +import "@app/ui/DataTable.css"; + +export * from "@app/ui/dataTableColumns"; + +/** Per-column presentation carried through TanStack's typed `meta` slot. */ +interface ColumnMeta { + align: "left" | "right"; + nowrap: boolean; + fit: boolean; + /** Visually-hidden header text for blank affordance/action columns, so the + * column still has an accessible name (avoids axe `empty-table-header`). */ + srHeader?: string; +} + +/** + * Feature registry for every DataTable, built once. Sorting is always + * registered so any column can opt in; the core row model defaults in. + */ +const DATA_TABLE_FEATURES = tableFeatures({ + rowSortingFeature, + sortedRowModel: createSortedRowModel(), + columnMeta: {} as ColumnMeta, + // Register the comparators the column vocabulary uses. Without this v9 falls + // back to a case-sensitive `basic` sort and warns per column. + sortFns: { alphanumeric: sortFn_alphanumeric, basic: sortFn_basic }, +}); +type DataTableFeatures = typeof DATA_TABLE_FEATURES; + +/** Closed appearance dial — the only look choice a call-site may make. */ +export type DataTableVariant = "default" | "compact"; + +/** + * A collapsible section of rows under a locked header. Group headers are + * structured (title + muted meta + optional right-aligned actions), never raw + * markup, so grouped tables stay as opinionated as flat ones. Provide `groups` + * instead of `rows`. + */ +export interface DataTableGroup { + key: string; + title: string; + /** Muted sub-text on the header (e.g. "5 people · led by Dana"). */ + meta?: string; + /** Right-aligned header actions (e.g. "Add to team", a kebab menu). */ + actions?: CellAction[]; + rows: T[]; + /** Collapse rows past this count behind a "Show all N" toggle. */ + collapseAfter?: number; + /** Render the group's rows greyed/disabled (non-actionable, e.g. coming-soon). */ + muted?: boolean; +} + +export interface DataTableProps { + /** Columns built with the `column` vocabulary — never raw JSX. */ + columns: DataTableColumn[]; + /** Flat rows. Provide this OR `groups`, not both. */ + rows?: T[]; + /** Grouped rows with section headers. Takes precedence over `rows`. */ + groups?: DataTableGroup[]; + rowKey: (row: T) => string; + + /** Makes rows interactive (hover + click + keyboard). */ + onRowClick?: (row: T) => void; + /** Per-row interactivity gate, checked only when `onRowClick` is set. */ + isRowInteractive?: (row: T) => boolean; + /** Trailing affordance drawn on interactive rows. */ + rowAffordance?: "none" | "chevron"; + + /** Initial sort, applied to the matching sortable column. */ + defaultSort?: { key: string; direction?: "asc" | "desc" }; + + /** First-load state: renders column-shaped skeleton rows under the header. */ + loading?: boolean; + /** Skeleton row count while loading. Defaults to 6. */ + skeletonRows?: number; + /** Error slot — replaces the rows with an alert message row. */ + error?: ReactNode; + /** Shown when there are no rows (and not loading / no error). Text or a node. */ + empty?: ReactNode; + + /** Content above the table (filters, search, actions), inside the surface. */ + toolbar?: ReactNode; + /** The only appearance choice. */ + variant?: DataTableVariant; + /** Accessible caption for the table. */ + caption?: string; + /** Labels for a group's "show all / show less" toggle (pass translated). + * `showAll` receives the group's total row count. */ + collapseLabels?: { showAll: (total: number) => string; showLess: string }; +} + +function ChevronGlyph() { + return ( + + + + ); +} + +function SortGlyph() { + return ( + + + + ); +} + +const CHEVRON_COLUMN_KEY = "__affordance"; + +/** + * The shared Stirling table. Call-sites supply data + behaviour; the component + * owns 100% of the appearance. Columns come from the `column` vocabulary (typed + * cell kinds, no raw markup), the surface / density / states are standardized + * here, and the only look choice exposed is the closed `variant`. Behaviour - + * sorting today, more later - is opt-in per column or via props. + */ +export function DataTable({ + columns, + rows = [], + groups, + rowKey, + onRowClick, + isRowInteractive, + rowAffordance = "none", + defaultSort, + loading = false, + skeletonRows = 6, + error, + empty, + toolbar, + variant = "default", + caption, + collapseLabels = { + showAll: (n) => `Show all ${n}`, + showLess: "Show less", + }, +}: DataTableProps) { + const { t } = useTranslation(); + const [sorting, setSorting] = useState( + defaultSort + ? [{ id: defaultSort.key, desc: defaultSort.direction === "desc" }] + : [], + ); + const [openGroups, setOpenGroups] = useState>(new Set()); + const toggleGroup = (key: string) => + setOpenGroups((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + + // The data source is either grouped or flat; TanStack (headers, sorting for + // the flat path) is fed the flattened rows. + const flatRows = useMemo( + () => (groups ? groups.flatMap((g) => g.rows) : rows), + [groups, rows], + ); + + const interactive = Boolean(onRowClick); + const showChevron = interactive && rowAffordance === "chevron"; + // A row that holds its own controls (actions/links/select/caps) can't also be + // a `role="button"` (a button may not contain interactive descendants); it + // keeps the click as a mouse shortcut, and the inner control is the keyboard path. + const rowsContainControls = columns.some((c) => c.interactive); + + const effectiveColumns = useMemo[]>(() => { + if (!showChevron) return columns; + return [ + ...columns, + { + key: CHEVRON_COLUMN_KEY, + header: "", + align: "right", + nowrap: true, + fit: true, + sortable: false, + renderCell: (row) => + (isRowInteractive?.(row) ?? true) ? ( + + + + ) : null, + }, + ]; + }, [columns, showChevron, isRowInteractive]); + + const tanstackColumns = useMemo[]>(() => { + const helper = createColumnHelper(); + return effectiveColumns.map((c) => { + // A blank header (trailing affordance/action columns) still needs an + // accessible name for assistive tech. + const srHeader = c.header + ? undefined + : c.key === CHEVRON_COLUMN_KEY + ? t("common.open", "Open") + : t("common.actions", "Actions"); + const meta: ColumnMeta = { + align: c.align, + nowrap: c.nowrap, + fit: c.fit, + srHeader, + }; + if (c.sortable && c.sortValue) { + const sortValue = c.sortValue; + return helper.accessor((row: T): unknown => sortValue(row), { + id: c.key, + header: () => c.header, + cell: (ctx) => c.renderCell(ctx.row.original), + enableSorting: true, + sortUndefined: "last", + sortFn: c.sortFn ?? "basic", + meta, + }); + } + return helper.display({ + id: c.key, + header: () => c.header, + cell: (ctx) => c.renderCell(ctx.row.original), + meta, + }); + }); + }, [effectiveColumns, t]); + + const table = useTable({ + features: DATA_TABLE_FEATURES, + data: flatRows, + columns: tanstackColumns, + state: { sorting }, + onSortingChange: setSorting, + getRowId: (row) => rowKey(row), + }); + + const colCount = effectiveColumns.length; + + // Shared row wiring so grouped rows behave like flat ones (interactivity + + // the affordance column) instead of being a second-class path. + const rowProps = (original: T, muted?: boolean) => { + const rowInteractive = + interactive && (isRowInteractive?.(original) ?? true); + // A row that owns the whole interaction takes the button role + keyboard + // handling; a row with its own controls keeps just the mouse click. + const asButton = rowInteractive && !rowsContainControls; + return { + className: [ + "sui-datatable__row", + rowInteractive ? "sui-datatable__row--interactive" : "", + muted ? "sui-datatable__row--muted" : "", + ] + .filter(Boolean) + .join(" "), + onClick: rowInteractive ? () => onRowClick?.(original) : undefined, + tabIndex: asButton ? 0 : undefined, + role: asButton ? ("button" as const) : undefined, + onKeyDown: asButton + ? (e: KeyboardEvent) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onRowClick?.(original); + } + } + : undefined, + }; + }; + + // No rows at all - covers a grouped table whose groups are all empty (or an + // empty groups list), which would otherwise render a header-only table. + const noRows = groups + ? groups.every((g) => g.rows.length === 0) + : rows.length === 0; + + let body: ReactNode; + if (loading) { + body = Array.from({ length: skeletonRows }).map((_, r) => ( + + {effectiveColumns.map((c) => ( + + + + ))} + + )); + } else if (error != null) { + body = ( + + + {error} + + + ); + } else if (noRows) { + const isNode = typeof empty === "object" && empty !== null; + body = ( + + + {empty ?? "No data"} + + + ); + } else if (groups) { + body = groups.flatMap((g) => { + const limit = g.collapseAfter ?? Infinity; + const open = openGroups.has(g.key); + const overflow = g.rows.length > limit; + const shown = overflow && !open ? g.rows.slice(0, limit) : g.rows; + const header = ( + + +
+
+ {g.title} + {g.meta && ( + {g.meta} + )} +
+ {g.actions && + g.actions.length > 0 && + renderCellActions(g.actions)} +
+ + + ); + const rowEls = shown.map((row) => ( + + {effectiveColumns.map((c) => ( + + {c.renderCell(row)} + + ))} + + )); + const moreEl = overflow ? ( + + + + + + ) : null; + return moreEl ? [header, ...rowEls, moreEl] : [header, ...rowEls]; + }); + } else { + body = table.getRowModel().rows.map((row) => ( + + {row.getAllCells().map((cell) => { + const meta = cell.column.columnDef.meta; + return ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ); + })} + + )); + } + + return ( +
+
+ {toolbar &&
{toolbar}
} +
+ + {caption && ( + + )} + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const meta = header.column.columnDef.meta; + const align = meta?.align ?? "left"; + const canSort = header.column.getCanSort(); + const sorted = header.column.getIsSorted(); + const label = header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + ); + return ( + + ); + })} + + ))} + + {body} +
{caption}
+ {canSort ? ( + + ) : meta?.srHeader ? ( + + {meta.srHeader} + + ) : ( + label + )} +
+
+
+
+ ); +} + +function cellClass( + align: "left" | "right", + nowrap: boolean, + fit: boolean, +): string { + return [ + "sui-datatable__td", + `sui-datatable__td--${align}`, + nowrap ? "sui-datatable__td--nowrap" : "", + fit ? "sui-datatable__td--fit" : "", + ] + .filter(Boolean) + .join(" "); +} + +function headerClass(align: "left" | "right", fit: boolean): string { + return [ + "sui-datatable__th", + `sui-datatable__th--${align}`, + fit ? "sui-datatable__th--fit" : "", + ] + .filter(Boolean) + .join(" "); +} diff --git a/frontend/editor/src/core/ui/Dropdown.css b/frontend/editor/src/core/ui/Dropdown.css index 8b84af29f1..d71026d881 100644 --- a/frontend/editor/src/core/ui/Dropdown.css +++ b/frontend/editor/src/core/ui/Dropdown.css @@ -4,28 +4,20 @@ } .sui-dd__menu { - position: absolute; - top: calc(100% + var(--space-1)); + /* Positioned (fixed, portaled to ) entirely by the Menu component. */ min-width: 12rem; padding: var(--space-1); background: var(--c-surface); border: 1px solid var(--c-border); border-radius: var(--radius-md); box-shadow: var(--shadow-lg); - z-index: var(--z-dropdown); + z-index: var(--z-popover); animation: fadeInUp var(--motion-enter) both; display: flex; flex-direction: column; gap: 0.0625rem; } -.sui-dd__menu--start { - left: 0; -} -.sui-dd__menu--end { - right: 0; -} - .sui-dd__item { display: flex; align-items: center; diff --git a/frontend/editor/src/core/ui/Dropdown.tsx b/frontend/editor/src/core/ui/Dropdown.tsx index bf481c9ea5..25019d7c3c 100644 --- a/frontend/editor/src/core/ui/Dropdown.tsx +++ b/frontend/editor/src/core/ui/Dropdown.tsx @@ -6,12 +6,14 @@ import { useContext, useEffect, useId, + useLayoutEffect, useMemo, useRef, useState, type ReactElement, type ReactNode, } from "react"; +import { createPortal } from "react-dom"; import "@app/ui/Dropdown.css"; type Alignment = "start" | "end"; @@ -20,6 +22,8 @@ interface DropdownContextValue { open: boolean; setOpen: (open: boolean) => void; triggerRef: React.RefObject; + /** The portaled menu element, so click-outside can exclude it. */ + menuRef: React.RefObject; menuId: string; align: Alignment; } @@ -68,15 +72,20 @@ function Root({ const triggerRef = useRef(null); const containerRef = useRef(null); + const menuRef = useRef(null); const menuId = useId(); - // Click-outside + Escape close. + // Click-outside + Escape close. The menu is portaled to , so it is not + // inside containerRef - check it separately or a click on it would close the + // menu before the item's handler runs. useEffect(() => { if (!open) return; function onDocClick(e: MouseEvent) { + const target = e.target as Node; if ( containerRef.current && - !containerRef.current.contains(e.target as Node) + !containerRef.current.contains(target) && + !(menuRef.current && menuRef.current.contains(target)) ) { setOpen(false); } @@ -96,7 +105,7 @@ function Root({ }, [open, setOpen]); const value = useMemo( - () => ({ open, setOpen, triggerRef, menuId, align }), + () => ({ open, setOpen, triggerRef, menuRef, menuId, align }), [open, setOpen, menuId, align], ); @@ -150,23 +159,79 @@ export interface DropdownMenuProps { } function Menu({ children, className, width }: DropdownMenuProps) { - const { open, menuId, align } = useDropdownCtx(); - if (!open) return null; - const style = - width !== undefined + const { open, menuId, align, triggerRef, menuRef } = useDropdownCtx(); + // Fixed position tracked to the trigger. Portaling to keeps the menu + // out of any `overflow` ancestor (e.g. a table's horizontal scroll area), + // which would otherwise clip it and add a scrollbar. + const [pos, setPos] = useState<{ + top?: number; + bottom?: number; + left?: number; + right?: number; + maxHeight: number; + } | null>(null); + + useLayoutEffect(() => { + if (!open) return; + const place = () => { + const el = triggerRef.current; + if (!el) return; + const r = el.getBoundingClientRect(); + const gap = 4; + const margin = 8; + const spaceBelow = window.innerHeight - r.bottom - margin; + const spaceAbove = r.top - margin; + // Flip above when there's more room there, so a trigger near the viewport + // bottom doesn't open a fixed menu that runs off-screen and can't scroll. + const below = spaceBelow >= spaceAbove; + const horizontal = + align === "end" + ? { right: window.innerWidth - r.right } + : { left: r.left }; + setPos({ + ...horizontal, + ...(below + ? { top: r.bottom + gap } + : { bottom: window.innerHeight - r.top + gap }), + maxHeight: Math.max(0, (below ? spaceBelow : spaceAbove) - gap), + }); + }; + place(); + // Track the trigger while scrolling/resizing (capture catches inner scrollers). + window.addEventListener("scroll", place, true); + window.addEventListener("resize", place); + return () => { + window.removeEventListener("scroll", place, true); + window.removeEventListener("resize", place); + }; + }, [open, align, triggerRef]); + + if (!open || !pos) return null; + const style: React.CSSProperties = { + position: "fixed", + // Explicit auto (not undefined) so the CSS fallback `top`/`left` can't leak + // in on the axis this placement isn't pinning. + top: pos.top ?? "auto", + bottom: pos.bottom ?? "auto", + left: pos.left ?? "auto", + right: pos.right ?? "auto", + maxHeight: pos.maxHeight, + overflowY: "auto", + ...(width !== undefined ? { minWidth: typeof width === "number" ? `${width}px` : width } - : undefined; - return ( + : {}), + }; + return createPortal( + , + document.body, ); } diff --git a/frontend/editor/src/core/ui/Table.css b/frontend/editor/src/core/ui/Table.css deleted file mode 100644 index fa89fd04d6..0000000000 --- a/frontend/editor/src/core/ui/Table.css +++ /dev/null @@ -1,73 +0,0 @@ -.sui-table-wrap { - width: 100%; - overflow-x: auto; -} - -.sui-table { - width: 100%; - border-collapse: collapse; - font-size: 0.8125rem; -} - -/* Header text kept for assistive tech only, so a column of controls can be named - without putting a heading above it. Defined here rather than borrowing a global - utility, since the portal loads its own stylesheet. */ -.sui-table__th-sr { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip-path: inset(50%); - white-space: nowrap; - border: 0; -} - -.sui-table__th { - text-align: left; - font-weight: 600; - color: var(--c-text-subtle); - font-size: 0.6875rem; - text-transform: uppercase; - letter-spacing: 0.04em; - padding: 0.625rem 0.875rem; - border-bottom: 1px solid var(--c-border); - white-space: nowrap; -} -.sui-table__th--right, -.sui-table__td--right { - text-align: right; -} -.sui-table__th--center, -.sui-table__td--center { - text-align: center; -} - -.sui-table__td { - padding: 0.625rem 0.875rem; - color: var(--c-text-muted); - border-bottom: 1px solid var(--c-border-subtle); - vertical-align: middle; -} -.sui-table tbody tr:last-child .sui-table__td { - border-bottom: none; -} - -.sui-table__row--interactive { - cursor: pointer; - transition: background var(--motion-fast); -} -.sui-table__row--interactive:hover { - background: var(--c-hover); -} -.sui-table__row--interactive:focus-visible { - outline: 0.125rem solid var(--c-primary); - outline-offset: -0.125rem; -} - -.sui-table__empty { - padding: 2rem; - text-align: center; - color: var(--c-text-subtle); -} diff --git a/frontend/editor/src/core/ui/Table.stories.tsx b/frontend/editor/src/core/ui/Table.stories.tsx deleted file mode 100644 index 2e7e0f64f1..0000000000 --- a/frontend/editor/src/core/ui/Table.stories.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { Table, type TableColumn } from "@app/ui/Table"; -import { StatusBadge } from "@app/ui/StatusBadge"; - -interface Region { - id: string; - name: string; - code: string; - status: "healthy" | "degraded"; - docs: number; - latency: string; -} - -const REGIONS: Region[] = [ - { - id: "1", - name: "US East", - code: "us-east-1", - status: "healthy", - docs: 12481, - latency: "41 ms", - }, - { - id: "2", - name: "US West", - code: "us-west-2", - status: "healthy", - docs: 8210, - latency: "63 ms", - }, - { - id: "3", - name: "EU West", - code: "eu-west-1", - status: "degraded", - docs: 3044, - latency: "190 ms", - }, -]; - -const COLUMNS: TableColumn[] = [ - { key: "name", header: "Region", render: (r) => r.name }, - { - key: "code", - header: "Code", - render: (r) => ( - {r.code} - ), - }, - { - key: "status", - header: "Status", - render: (r) => ( - - {r.status} - - ), - }, - { - key: "docs", - header: "Docs 24h", - align: "right", - render: (r) => r.docs.toLocaleString(), - }, - { key: "latency", header: "P95", align: "right", render: (r) => r.latency }, -]; - -const meta: Meta = { - title: "Compound/Table", - component: Table, - tags: ["autodocs"], - parameters: { layout: "padded" }, -}; -export default meta; -type Story = StoryObj; - -/** Presentational table — columns own their cell renderers; pass pre-sorted rows. */ -export const Basic: Story = { - render: () => ( - columns={COLUMNS} rows={REGIONS} rowKey={(r) => r.id} /> - ), -}; - -/** With `onRowClick`, rows become focusable + hoverable (keyboard: Enter/Space). */ -export const Interactive: Story = { - render: () => ( - - columns={COLUMNS} - rows={REGIONS} - rowKey={(r) => r.id} - onRowClick={() => {}} - /> - ), -}; - -/** Empty body slot. */ -export const Empty: Story = { - render: () => ( - - columns={COLUMNS} - rows={[]} - rowKey={(r) => r.id} - empty="No regions deployed yet." - /> - ), -}; diff --git a/frontend/editor/src/core/ui/Table.tsx b/frontend/editor/src/core/ui/Table.tsx deleted file mode 100644 index 417df3d6c1..0000000000 --- a/frontend/editor/src/core/ui/Table.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import type { ReactNode } from "react"; -import "@app/ui/Surface.css"; -import "@app/ui/Table.css"; - -export interface TableColumn { - /** Stable column id. */ - key: string; - header: ReactNode; - /** - * Hides the header visually but keeps it for assistive tech. For a trailing column of controls - * or chevrons, where a visible heading would be noise but a blank one leaves the cells below it - * unlabelled. - */ - headerHidden?: boolean; - /** Cell renderer for a row. */ - render: (row: T) => ReactNode; - align?: "left" | "right" | "center"; - /** Optional fixed/min width (any CSS length). */ - width?: string; -} - -export interface TableProps { - columns: TableColumn[]; - rows: T[]; - /** Stable key per row. */ - rowKey: (row: T) => string; - /** Makes rows interactive (hover + click + keyboard). */ - onRowClick?: (row: T) => void; - /** - * Per-row gate for interactivity, checked only when {@link onRowClick} is set. A row for which - * this returns false is inert: no click/keyboard, and not announced as a button. Defaults to - * all rows interactive. - */ - isRowInteractive?: (row: T) => boolean; - /** - * Set when rows render controls of their own. The row keeps its click as a mouse shortcut but - * stops announcing itself as a button, because a button may not contain other controls and a - * {@code } is no longer a row to a screen reader. That row control is then the - * keyboard path to the same action, so nothing is lost by leaving the row itself inert. - */ - rowsContainControls?: boolean; - /** Rendered in place of the body when there are no rows. */ - empty?: ReactNode; - className?: string; -} - -/** - * Minimal data table primitive. Columns own their own cell renderers, so the - * table stays presentational — callers pre-sort/filter and pass the rows they - * want shown. Rows become focusable buttons-in-disguise when `onRowClick` is - * set. - */ -export function Table({ - columns, - rows, - rowKey, - onRowClick, - isRowInteractive, - rowsContainControls = false, - empty, - className, -}: TableProps) { - const interactive = Boolean(onRowClick); - return ( -
- - - - {columns.map((c) => ( - - ))} - - - - {rows.length === 0 ? ( - - - - ) : ( - rows.map((row) => { - const rowInteractive = - interactive && (isRowInteractive?.(row) ?? true); - // Only a row that owns the whole interaction takes the button role and the keyboard - // handling that goes with it; see rowsContainControls. - const rowIsControl = rowInteractive && !rowsContainControls; - return ( - onRowClick?.(row) : undefined} - tabIndex={rowIsControl ? 0 : undefined} - role={rowIsControl ? "button" : undefined} - onKeyDown={ - rowIsControl - ? (e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - onRowClick?.(row); - } - } - : undefined - } - > - {columns.map((c) => ( - - ))} - - ); - }) - )} - -
- {c.headerHidden ? ( - {c.header} - ) : ( - c.header - )} -
- {empty ?? "No data"} -
- {c.render(row)} -
-
- ); -} diff --git a/frontend/editor/src/core/ui/dataTableColumns.tsx b/frontend/editor/src/core/ui/dataTableColumns.tsx new file mode 100644 index 0000000000..df31dc384a --- /dev/null +++ b/frontend/editor/src/core/ui/dataTableColumns.tsx @@ -0,0 +1,569 @@ +import { Fragment, type ReactNode } from "react"; +import { StatusBadge, type StatusTone } from "@app/ui/StatusBadge"; +import { Chip, type ChipAccent } from "@app/ui/Chip"; +import { Button } from "@app/ui/Button"; +import { Dropdown } from "@app/ui/Dropdown"; +import { ProgressBar } from "@app/ui/ProgressBar"; +import { Select, type SelectOption } from "@app/ui/Select"; + +/** + * The column vocabulary for {@link DataTable}. Call-sites pick a cell KIND and + * supply the data + semantics; the component owns 100% of the appearance. There + * is no raw-JSX / className escape hatch by design; a cell can only look the way + * the design system draws its kind, so every table looks and behaves the same. + */ + +type Align = "left" | "right"; +type SortValue = string | number | boolean | null | undefined; + +/** + * Which built-in comparator sorts a column. Set by the builder from the cell's + * data type - `alphanumeric` (case-insensitive, natural: `v2` before `v10`) for + * text, `basic` (raw numeric) for numbers. Call-sites never choose this. + */ +export type DataTableSortFn = "alphanumeric" | "basic"; + +/** Opaque, fully-resolved column. Produced only by the {@link column} builders. */ +export interface DataTableColumn { + key: string; + header: ReactNode; + align: Align; + /** Prevent wrapping (mono/number values). */ + nowrap: boolean; + /** Shrink the column to its content (actions / affordances). */ + fit: boolean; + sortable: boolean; + sortValue?: (row: T) => SortValue; + /** Comparator kind, derived from the cell type. Only set when sortable. */ + sortFn?: DataTableSortFn; + /** Cell renders its own interactive control (button/link/select/chip). Rows + * containing one drop their `role="button"` so a button never nests inside a + * button - the control is the keyboard path instead. */ + interactive?: boolean; + /** Internal, design-system-owned renderer. Call-sites never supply this. */ + renderCell: (row: T) => ReactNode; +} + +/** The only design-system glyph a cell may use (icon-only actions). */ +export type CellGlyph = "kebab"; + +function KebabGlyph() { + return ( + + + + + + ); +} + +/** An item in a kebab action menu. */ +export interface CellMenuItem { + label: string; + tone?: "default" | "danger"; + disabled?: boolean; + onClick: () => void; + /** Draw a divider above this item. */ + dividerBefore?: boolean; +} + +/** A row/group action. A locked button, or a kebab menu when `menu` is set. */ +export interface CellAction { + label: string; + glyph?: CellGlyph; + /** Icon-only (uses `label` as the accessible name). */ + iconOnly?: boolean; + tone?: "default" | "danger"; + onClick?: () => void; + loading?: boolean; + disabled?: boolean; + /** When set, the button opens this menu instead of firing `onClick`. */ + menu?: CellMenuItem[]; +} + +/** Renders a row of locked action buttons / kebab menus. Shared by the + * `actions` cell kind and grouped-table headers. */ +export function renderCellActions(actions: CellAction[]): ReactNode { + return ( +
e.stopPropagation()}> + {actions.map((a) => + a.menu ? ( + + + + + + {a.menu.map((m) => ( + + {m.dividerBefore && } + + {m.label} + + + ))} + + + ) : ( + + ), + )} +
+ ); +} + +/** An external link inside a cell. */ +export interface CellLink { + label: string; + href: string; + ariaLabel?: string; +} + +interface Common { + key: string; + header: ReactNode; + sortable?: boolean; +} + +function base( + o: Common, + extra: Pick, "align" | "nowrap" | "fit" | "renderCell"> & { + sortValue?: (row: T) => SortValue; + sortFn?: DataTableSortFn; + interactive?: boolean; + }, +): DataTableColumn { + return { + key: o.key, + header: o.header, + align: extra.align, + nowrap: extra.nowrap, + fit: extra.fit, + sortable: !!o.sortable, + sortValue: o.sortable ? extra.sortValue : undefined, + sortFn: o.sortable ? extra.sortFn : undefined, + interactive: extra.interactive, + renderCell: extra.renderCell, + }; +} + +function text( + o: Common & { + get: (row: T) => string; + /** Optional bold label rendered before the value as "Label: value". */ + label?: (row: T) => string | null | undefined; + sortBy?: (row: T) => SortValue; + }, +): DataTableColumn { + return base(o, { + align: "left", + nowrap: false, + fit: false, + sortValue: o.sortBy ?? ((r) => o.get(r)), + sortFn: "alphanumeric", + renderCell: (r) => { + const label = o.label?.(r); + return label ? ( + + {label}: {o.get(r)} + + ) : ( + {o.get(r)} + ); + }, + }); +} + +function mono( + o: Common & { get: (row: T) => string; sortBy?: (row: T) => SortValue }, +): DataTableColumn { + return base(o, { + align: "left", + nowrap: true, + fit: false, + sortValue: o.sortBy ?? ((r) => o.get(r)), + sortFn: "alphanumeric", + renderCell: (r) => {o.get(r)}, + }); +} + +function muted( + o: Common & { + get: (row: T) => string | null | undefined; + placeholder?: string; + /** Override the sort key (e.g. an ISO date behind a "3 days ago" label). */ + sortBy?: (row: T) => SortValue; + }, +): DataTableColumn { + return base(o, { + align: "left", + nowrap: false, + fit: false, + sortValue: o.sortBy ?? ((r) => o.get(r) ?? undefined), + sortFn: "alphanumeric", + renderCell: (r) => ( + + {o.get(r) || (o.placeholder ?? "-")} + + ), + }); +} + +function number( + o: Common & { + get: (row: T) => number | null | undefined; + format?: (n: number, row: T) => string; + placeholder?: string; + /** Override the sort key (e.g. a raw count behind a formatted label). */ + sortBy?: (row: T) => SortValue; + }, +): DataTableColumn { + return base(o, { + align: "right", + nowrap: true, + fit: false, + sortValue: o.sortBy ?? ((r) => o.get(r) ?? undefined), + sortFn: "basic", + renderCell: (r) => { + const n = o.get(r); + if (n == null) { + return ( + + {o.placeholder ?? "-"} + + ); + } + return ( + + {o.format ? o.format(n, r) : String(n)} + + ); + }, + }); +} + +function badge( + o: Common & { + get: (row: T) => { tone: StatusTone; label: string }; + sortBy?: (row: T) => SortValue; + }, +): DataTableColumn { + return base(o, { + align: "left", + nowrap: true, + fit: false, + sortValue: o.sortBy ?? ((r) => o.get(r).label), + sortFn: "alphanumeric", + renderCell: (r) => { + const b = o.get(r); + return ( + + {b.label} + + ); + }, + }); +} + +/** + * A user-defined label, rendered as a dot-less pill. Use this ONLY for labels + * that come from data / the user (e.g. a document's classification). Values from + * a fixed set we define (types, environments, providers) are `text`, not pills. + */ +export interface CellLabel { + label: string; + accent?: ChipAccent; +} + +function labels( + o: Common & { get: (row: T) => CellLabel[] }, +): DataTableColumn { + return base(o, { + align: "left", + nowrap: false, + fit: false, + sortValue: (r) => o.get(r)[0]?.label ?? undefined, + sortFn: "alphanumeric", + renderCell: (r) => ( +
+ {o.get(r).map((l) => ( + + {l.label} + + ))} +
+ ), + }); +} + +/** + * An interactive capability chip: click to grant, remove to revoke, dashed to + * offer adding. A functional cell (it toggles state), distinct from static + * `labels`. + */ +export interface CellCap { + label: string; + accent?: ChipAccent; + onClick?: () => void; + onRemove?: () => void; + dashed?: boolean; +} + +function caps( + o: Common & { get: (row: T) => CellCap[] }, +): DataTableColumn { + return base(o, { + align: "left", + nowrap: false, + fit: false, + interactive: true, + renderCell: (r) => ( +
+ {o.get(r).map((c) => ( + + {c.label} + + ))} +
+ ), + }); +} + +function entity( + o: Common & { + /** Semantic leading icon (component owns its size + colour container). */ + icon?: (row: T) => ReactNode; + primary: (row: T) => string; + /** Muted inline suffix after the name, its own node (e.g. "(you)"). */ + suffix?: (row: T) => string | null | undefined; + /** Secondary muted line under the name. */ + note?: (row: T) => string | null | undefined; + sortBy?: (row: T) => SortValue; + }, +): DataTableColumn { + return base(o, { + align: "left", + nowrap: false, + fit: false, + sortValue: o.sortBy ?? ((r) => o.primary(r)), + sortFn: "alphanumeric", + renderCell: (r) => { + const icon = o.icon?.(r); + const suffix = o.suffix?.(r); + const note = o.note?.(r); + return ( +
+ {icon != null && ( + + {icon} + + )} +
+ + {o.primary(r)} + {suffix && ( + {suffix} + )} + + {note && {note}} +
+
+ ); + }, + }); +} + +function actions(o: { + key: string; + header?: ReactNode; + get: (row: T) => CellAction[]; +}): DataTableColumn { + return { + key: o.key, + header: o.header ?? "", + align: "right", + nowrap: true, + fit: true, + sortable: false, + interactive: true, + renderCell: (r) => renderCellActions(o.get(r)), + }; +} + +function progress( + o: Common & { + get: (row: T) => { value: number; label?: string }; + /** Accessible name for the bar (it has no visible text). Defaults to the + * shown percent; pass a description like "Load for us-east-1" when useful. */ + ariaLabel?: (row: T) => string; + }, +): DataTableColumn { + return base(o, { + align: "left", + nowrap: true, + fit: false, + sortValue: (r) => o.get(r).value, + sortFn: "basic", + renderCell: (r) => { + const p = o.get(r); + const shown = p.label ?? `${Math.round(p.value * 100)}%`; + return ( +
+ + + + {shown} +
+ ); + }, + }); +} + +function links(o: { + key: string; + header?: ReactNode; + get: (row: T) => CellLink[]; +}): DataTableColumn { + return { + key: o.key, + header: o.header ?? "", + align: "right", + nowrap: true, + fit: true, + sortable: false, + interactive: true, + renderCell: (r) => ( +
+ {o.get(r).map((l) => ( + + {l.label} + + ))} +
+ ), + }; +} + +function select(o: { + key: string; + header: ReactNode; + get: (row: T) => { + value?: string | null; + defaultValue?: string; + options: SelectOption[]; + ariaLabel?: string; + disabled?: boolean; + }; + /** Omit for an uncontrolled select (local UI state only). */ + onChange?: (row: T, value: string | null) => void; +}): DataTableColumn { + return { + key: o.key, + header: o.header, + align: "left", + nowrap: true, + fit: false, + sortable: false, + interactive: true, + renderCell: (r) => { + const s = o.get(r); + const change = o.onChange; + return ( +
+ onChangeRole(m, (value ?? m.role) as RoleId)} - /> -
- )} + const groups = useMemo[]>(() => { + function ownerNames(owners: string[]): string { + return owners.map((u) => nameByUsername.get(u) ?? u).join(", "); + } + // A team whose name/membership is system-managed - no rename/delete. + function isManagedTeam(team: TeamGroup): boolean { + return SYSTEM_TEAMS.has(team.name) || team.isPersonal === true; + } + function teamKebabHasItems(team: TeamGroup): boolean { + return ( + capabilities.manageGrants || + (!isManagedTeam(team) && + (capabilities.renameTeam || capabilities.deleteTeam)) + ); + } + function teamActions(team: TeamGroup): CellAction[] { + const acts: CellAction[] = [ + { + label: t("users.group.addToTeam", "Add to team"), + onClick: () => onAddToTeam(team), + }, + ]; + if (teamKebabHasItems(team)) { + const items: CellMenuItem[] = []; + if (capabilities.manageGrants) { + items.push( + processorTeamIds.has(team.id) + ? { + label: t( + "users.team.revokeProcessor", + "Revoke Processor from team", + ), + onClick: () => onRevokeTeamProcessor(team), + } + : { + label: t( + "users.team.grantProcessor", + "Grant Processor to team", + ), + onClick: () => onGrantTeamProcessor(team), + }, + ); + } + if (!isManagedTeam(team)) { + const divider = capabilities.manageGrants; + if (capabilities.renameTeam) { + items.push({ + label: t("users.action.rename", "Rename team"), + onClick: () => onRenameTeam(team), + dividerBefore: divider, + }); + } + if (capabilities.deleteTeam) { + items.push({ + label: t("users.action.deleteTeam", "Delete team"), + tone: "danger", + onClick: () => onDeleteTeam(team), + dividerBefore: divider && !capabilities.renameTeam, + }); + } + } + acts.push({ + label: t("users.teamActions", "Team actions"), + glyph: "kebab", + iconOnly: true, + menu: items, + }); + } + return acts; + } - {rowKebab(m)} - - ); - } - - /** Rows for a group, collapsing past COLLAPSED_LIMIT behind a toggle. */ - function renderMembers(list: Member[], key: string) { - const isOpen = expanded.has(key); - const overflow = list.length > COLLAPSED_LIMIT; - const shown = overflow && !isOpen ? list.slice(0, COLLAPSED_LIMIT) : list; - return ( - <> - {shown.map(renderRow)} - {overflow && ( - - )} - - ); - } + const gs: DataTableGroup[] = []; + if (capabilities.orgGroup && dir.organization.length > 0) { + gs.push({ + key: "org", + title: t("users.group.org", "Organization"), + meta: t("users.group.owners", "{{count}} owner", { + count: dir.organization.length, + }), + rows: dir.organization, + collapseAfter: COLLAPSED_LIMIT, + }); + } + for (const team of dir.teams) { + const led = + team.owners.length > 0 + ? ` · ${t("users.group.ledBy", "led by {{owner}}", { + owner: ownerNames(team.owners), + })}` + : ""; + gs.push({ + key: `team-${team.id}`, + title: t("users.group.team", "{{name}} team", { name: team.name }), + meta: + t("users.group.teamMeta", "{{count}} people", { + count: team.members.length, + }) + led, + actions: teamActions(team), + rows: team.members, + collapseAfter: COLLAPSED_LIMIT, + }); + } + if (showGuests && dir.guests.length > 0) { + gs.push({ + key: "guests", + title: t("users.group.guests", "Guests"), + meta: t("users.group.guestCount", "{{count}} guest", { + count: dir.guests.length, + }), + rows: dir.guests, + collapseAfter: COLLAPSED_LIMIT, + }); + } + return gs; + }, [ + t, + dir, + nameByUsername, + capabilities, + showGuests, + processorTeamIds, + onAddToTeam, + onGrantTeamProcessor, + onRevokeTeamProcessor, + onRenameTeam, + onDeleteTeam, + ]); return ( -
- {/* Organization (a single-org deployment only; SaaS has no org). */} - {capabilities.orgGroup && dir.organization.length > 0 && ( -
-
-
- {t("users.group.org", "Organization")} - - {t( - "users.group.orgDesc", - "Owners with org-wide authority and policy approval", - )} - -
- - {t("users.group.owners", "{{count}} owner", { - count: dir.organization.length, - })} - -
- {renderMembers(dir.organization, "org")} -
- )} - - {/* Teams */} - {dir.teams.map((team) => ( -
-
-
- - {t("users.group.team", "{{name}} team", { name: team.name })} - - - {t("users.group.teamMeta", "{{count}} people", { - count: team.members.length, - })} - {team.owners.length > 0 && - ` · ${t("users.group.ledBy", "led by {{owner}}", { - owner: ownerNames(team.owners), - })}`} - -
-
- - {teamKebabHasItems(team) && ( - - - - - - {capabilities.manageGrants && - (processorTeamIds.has(team.id) ? ( - onRevokeTeamProcessor(team)}> - {t( - "users.team.revokeProcessor", - "Revoke Processor from team", - )} - - ) : ( - onGrantTeamProcessor(team)}> - {t( - "users.team.grantProcessor", - "Grant Processor to team", - )} - - ))} - {!isManagedTeam(team) && - (capabilities.renameTeam || capabilities.deleteTeam) && ( - <> - {capabilities.manageGrants && } - {capabilities.renameTeam && ( - onRenameTeam(team)}> - {t("users.action.rename", "Rename team")} - - )} - {capabilities.deleteTeam && ( - onDeleteTeam(team)} - > - {t("users.action.deleteTeam", "Delete team")} - - )} - - )} - - - )} -
-
- {renderMembers(team.members, `team-${team.id}`)} -
- ))} - - {/* Guests (parked in the live app; shown when showGuests is set). */} - {showGuests && dir.guests.length > 0 && ( -
-
-
- {t("users.group.guests", "Guests")} - - {t( - "users.group.guestsDesc", - "External collaborators, scoped to what you shared. Editor only.", - )} - -
- - {t("users.group.guestCount", "{{count}} guest", { - count: dir.guests.length, - })} - -
- {renderMembers(dir.guests, "guests")} -
- )} -
+ + columns={columns} + groups={groups} + rowKey={(m) => String(m.id)} + collapseLabels={{ + showAll: (count) => t("users.showAll", "Show all {{count}}", { count }), + showLess: t("users.showLess", "Show less"), + }} + /> ); } diff --git a/frontend/editor/src/portal/hooks/useFreeCreditsSummary.test.tsx b/frontend/editor/src/portal/hooks/useFreeCreditsSummary.test.tsx new file mode 100644 index 0000000000..fb48250fed --- /dev/null +++ b/frontend/editor/src/portal/hooks/useFreeCreditsSummary.test.tsx @@ -0,0 +1,110 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { cleanup, render, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { LinkProvider, type LinkState } from "@portal/contexts/LinkContext"; +import { useFreeCreditsSummary } from "@portal/hooks/useFreeCreditsSummary"; + +const fetchWallet = vi.fn(); +vi.mock("@portal/api/billing", () => ({ + fetchWallet: () => fetchWallet(), +})); + +function Probe() { + const credits = useFreeCreditsSummary(); + return ( + + {credits ? `${credits.remaining}/${credits.total}` : "none"} + + ); +} + +function renderFor(initialState: LinkState) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + + + + + , + ).getByTestId("credits"); +} + +describe("useFreeCreditsSummary (self-hosted) — wallet behind the link gate", () => { + beforeEach(() => { + // The figures persist across mounts now, so isolate the suite from itself. + localStorage.clear(); + fetchWallet.mockReset(); + fetchWallet.mockResolvedValue({ + status: "free", + freeRemaining: 247, + freeAllowance: 500, + }); + }); + + it("unlinked reads no wallet at all", async () => { + const el = renderFor("unlinked"); + await waitFor(() => expect(el.textContent).toBe("none")); + expect(fetchWallet).not.toHaveBeenCalled(); + }); + + it("linked surfaces the free grant", async () => { + const el = renderFor("linked-free"); + await waitFor(() => expect(el.textContent).toBe("247/500")); + }); + + it("hides the meter once the team subscribes", async () => { + // The grant is a lifetime pool that survives subscribing, so a paying team + // would otherwise sit on a spent meter forever. + fetchWallet.mockResolvedValue({ + status: "subscribed", + freeRemaining: 0, + freeAllowance: 500, + }); + const el = renderFor("linked-subscribed"); + // The row holds its space while the wallet loads, then drops once the + // answer says this team is paying. + await waitFor(() => expect(el.textContent).toBe("none")); + }); + + it("hides the meter when the wallet read fails", async () => { + fetchWallet.mockRejectedValue(new Error("saas unreachable")); + const el = renderFor("linked-subscribed"); + await waitFor(() => expect(el.textContent).toBe("none")); + }); + + it("ignores cached figures once the instance is unlinked", async () => { + // The cache survives an unlink and nothing rewrites it afterwards, so the + // linkage gate has to cover the seed too, not just the fetch. + const linked = renderFor("linked-free"); + await waitFor(() => expect(linked.textContent).toBe("247/500")); + cleanup(); + + fetchWallet.mockClear(); + const unlinked = renderFor("unlinked"); + expect(unlinked.textContent).toBe("none"); + expect(fetchWallet).not.toHaveBeenCalled(); + }); + + it("shows the last known figures while the wallet reloads", async () => { + // What stops the row popping in — and resizing the footer — every time the + // processor mounts. + const el = renderFor("linked-free"); + await waitFor(() => expect(el.textContent).toBe("247/500")); + cleanup(); + + let release: (v: unknown) => void = () => {}; + fetchWallet.mockReturnValue( + new Promise((resolve) => { + release = resolve; + }), + ); + const second = renderFor("linked-free"); + // Seeded before the refetch lands... + expect(second.textContent).toBe("247/500"); + release({ status: "free", freeRemaining: 12, freeAllowance: 500 }); + // ...then updated in place, without the row ever being absent. + await waitFor(() => expect(second.textContent).toBe("12/500")); + }); +}); diff --git a/frontend/editor/src/portal/hooks/useFreeCreditsSummary.ts b/frontend/editor/src/portal/hooks/useFreeCreditsSummary.ts new file mode 100644 index 0000000000..3fd814ebb1 --- /dev/null +++ b/frontend/editor/src/portal/hooks/useFreeCreditsSummary.ts @@ -0,0 +1,65 @@ +import { useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useLink } from "@portal/contexts/LinkContext"; +import { fetchWallet } from "@portal/api/billing"; +import { qk } from "@portal/queries/keys"; +import { + readCachedCredits, + writeCachedCredits, + type CachedCredits, +} from "@app/services/navFooterCache"; +import { type NavFooterCredits } from "@app/components/shared/navFooter/NavFooterCreditsRow"; + +/** + * Free credits left on this team's allowance, for the processor's sidebar + * footer meter. Null hides the meter. + * + * This is the portal's own seam rather than the editor's {@code + * @app/hooks/useFreeCreditsSummary}, because self-hosted resolves {@code @app/*} + * as proprietary → core: the cloud wallet hook isn't in that cascade, and the + * implementation can't move down into proprietary either, since core/desktop + * builds ship no portal and must never resolve {@code @portal}. Keeping it here + * means only builds that actually have a processor pull in the wallet read. + * + * Self-hosted reads the same {@code GET /api/v1/payg/wallet} the Usage page's + * trial meter renders — {@code apiClient.saas} with the admin's Supabase JWT, + * since the wallet lives in the cloud even when the instance doesn't. Gated on + * linkage: an unlinked instance has no wallet to read. + * + * Free teams only, matching the editor and the Plan page. The grant is a + * lifetime pool that survives subscribing, so a paying team would otherwise sit + * on a permanent "0 of 500" in red; their usage lives on Usage & Billing. + */ +export function useFreeCreditsSummary(): NavFooterCredits | null { + const { isLinked } = useLink(); + // Shared query key, so the footer rides the same cached snapshot as any other + // wallet reader rather than adding a fetch per mount. + const { data: wallet } = useQuery({ + queryKey: qk.wallet(isLinked), + queryFn: fetchWallet, + enabled: isLinked, + }); + // Shared with the editor's seam, so crossing between the two apps shows the + // figures the other one last saw rather than re-fetching into an empty row. + const [seed] = useState(readCachedCredits); + + const live: CachedCredits | undefined = !wallet + ? undefined + : wallet.status === "subscribed" + ? null + : { remaining: wallet.freeRemaining, total: wallet.freeAllowance }; + + useEffect(() => { + // Only once linked: an unlinked instance never asks, so it has no answer of + // its own and must not overwrite what the editor recorded. + if (isLinked && live !== undefined) writeCachedCredits(live); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [wallet, isLinked]); + + // Linkage gates the seed as well as the fetch. The cache outlives an unlink + // — nothing refetches or rewrites it once the instance stops asking — so + // without this an unlinked instance would keep showing the figures from when + // it was linked, indefinitely. + if (!isLinked) return null; + return (live !== undefined ? live : seed) ?? null; +} diff --git a/frontend/editor/src/portal/hooks/useOpenPlan.ts b/frontend/editor/src/portal/hooks/useOpenPlan.ts new file mode 100644 index 0000000000..57b30c91d0 --- /dev/null +++ b/frontend/editor/src/portal/hooks/useOpenPlan.ts @@ -0,0 +1,13 @@ +import { useCallback } from "react"; +import { useView } from "@portal/contexts/ViewContext"; + +/** + * Self-hosted processor: settings carries no Plan section (it is a cloud + * surface, and this build's registry has none), so the footer's credits row + * opens the portal's own Usage & Billing view instead — the same figures, on + * the surface this flavor actually owns. + */ +export function useOpenPlan(): (() => void) | null { + const { setActiveView } = useView(); + return useCallback(() => setActiveView("usage"), [setActiveView]); +} diff --git a/frontend/editor/src/portal/queries/keys.ts b/frontend/editor/src/portal/queries/keys.ts index 6c29430cb4..e1c46a59c0 100644 --- a/frontend/editor/src/portal/queries/keys.ts +++ b/frontend/editor/src/portal/queries/keys.ts @@ -20,6 +20,8 @@ export const qk = { // Keyed on linkage: an unlinked account has no deal to read, so linking must not // serve the unlinked (null) snapshot back from cache. procurement: (linked: boolean) => ["portal", "procurement", linked] as const, + // Same reasoning: an unlinked instance has no wallet in the cloud. + wallet: (linked: boolean) => ["portal", "wallet", linked] as const, // Tier-dependent documents: (tier: Tier) => ["portal", "documents", tier] as const, diff --git a/frontend/editor/src/portal/theme/mantineTheme.ts b/frontend/editor/src/portal/theme/mantineTheme.ts index 7108a90073..e371b4aeb8 100644 --- a/frontend/editor/src/portal/theme/mantineTheme.ts +++ b/frontend/editor/src/portal/theme/mantineTheme.ts @@ -163,6 +163,10 @@ export const mantineTheme = createTheme({ CloseButton: { defaultProps: { "aria-label": "Close" } }, Modal: { defaultProps: { closeButtonProps: { "aria-label": "Close" } } }, Drawer: { defaultProps: { closeButtonProps: { "aria-label": "Close" } } }, + // The portal's md default radius (8px) is right for cards and buttons but + // rounds a 20px checkbox into a circle. Pin it to the smaller radius the + // editor's checkboxes use so the box reads as a checkbox. + Checkbox: { styles: { input: { borderRadius: "var(--radius-sm)" } } }, }, fontFamily: "var(--font-sans)", fontFamilyMonospace: "var(--font-mono)", diff --git a/frontend/editor/src/portal/views/Integrations.test.tsx b/frontend/editor/src/portal/views/Integrations.test.tsx index c5e96d8666..8c4e684bfa 100644 --- a/frontend/editor/src/portal/views/Integrations.test.tsx +++ b/frontend/editor/src/portal/views/Integrations.test.tsx @@ -68,39 +68,35 @@ describe("Integrations view", () => { ).toBeInTheDocument(); }); - it("groups connections of the same type and expands to the instances", async () => { + it("groups connections of the same type, instances shown as rows (no expand)", async () => { fetchIntegrations.mockResolvedValue([ bucket(1, "Claims"), bucket(2, "Archive"), ]); render(); - // One connected group row for S3 with the instance count, not two rows. - const group = await screen.findByText( - "portal.integrations.connectionCount", - ); - expect(group).toBeInTheDocument(); - - fireEvent.click(screen.getByText("portal.connections.types.s3.label")); + // Instances are rows directly under the S3 vendor group - no expand click. expect(await screen.findByText("Claims")).toBeInTheDocument(); expect(screen.getByText("Archive")).toBeInTheDocument(); + // Vendor group header shows the instance count and the "add another" action. expect( - screen.getByText("portal.integrations.addAnother"), + screen.getByText("portal.integrations.connectionCount"), ).toBeInTheDocument(); + // Each connected vendor group offers a Connect action (to add another). + expect( + screen.getAllByText("portal.integrations.connect").length, + ).toBeGreaterThan(0); // The available band remains for the other, unconnected vendors. expect( screen.getByText(/portal\.integrations\.availableHeading/), ).toBeInTheDocument(); }); - it("deletes an instance from the expanded group", async () => { + it("deletes an instance directly from its row", async () => { fetchIntegrations.mockResolvedValueOnce([bucket(5, "Claims")]); fetchIntegrations.mockResolvedValueOnce([]); render(); - fireEvent.click( - await screen.findByText("portal.connections.types.s3.label"), - ); fireEvent.click(await screen.findByText("portal.connections.delete")); await waitFor(() => expect(deleteIntegration).toHaveBeenCalledWith(5)); @@ -115,9 +111,6 @@ describe("Integrations view", () => { ); render(); - fireEvent.click( - await screen.findByText("portal.connections.types.s3.label"), - ); fireEvent.click(await screen.findByText("portal.connections.delete")); expect( diff --git a/frontend/editor/src/portal/views/Integrations.tsx b/frontend/editor/src/portal/views/Integrations.tsx index 993583a364..793c21f880 100644 --- a/frontend/editor/src/portal/views/Integrations.tsx +++ b/frontend/editor/src/portal/views/Integrations.tsx @@ -2,8 +2,15 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import AddRoundedIcon from "@mui/icons-material/AddRounded"; import SearchRoundedIcon from "@mui/icons-material/SearchRounded"; -import ExpandMoreRoundedIcon from "@mui/icons-material/ExpandMoreRounded"; -import { Banner, Button, Skeleton } from "@app/ui"; +import { + Banner, + Button, + column, + DataTable, + type DataTableColumn, + type DataTableGroup, + EmptyState, +} from "@app/ui"; import { errorMessage } from "@portal/api/http"; import { deleteIntegration, @@ -30,12 +37,11 @@ import "@portal/views/Integrations.css"; /** * The integrations catalogue: everything Stirling can talk to, in one place. * - * Three bands in one list. Connected first — stored connections grouped by - * vendor, expandable when a vendor has several (two S3 buckets is normal, not - * an error), each instance editable and one click from "add another". Then - * Available — the supported vendors, each saying what it works with (sources, - * policies, pipelines) so it's obvious whether a vendor feeds documents in or - * receives them. Coming-soon source connectors close the list greyed out, so + * Three bands, one grouped table. Connected first - stored connections grouped + * by vendor (two S3 buckets is normal, not an error), every instance a row you + * can edit or remove, with "add another" on the vendor's group header. Then + * Available - the supported vendors, each saying what it works with (sources, + * policies, pipelines). Coming-soon source connectors close the list so * "do you support X?" is answered honestly instead of hidden. * * Setup itself stays in the shared {@link ConnectionModal}; every entry point @@ -72,6 +78,20 @@ interface TypeGroup { connections: IntegrationConfig[]; } +/** One normalized row across the three bands, so a single grouped table renders + * connected instances, available vendors, and coming-soon vendors alike. */ +type IntegrationRow = { + key: string; + brandId: string; + title: string; + subtitle: string; + worksWith: WorksWith[]; +} & ( + | { kind: "instance"; connection: IntegrationConfig; canManage: boolean } + | { kind: "available"; typeId: string } + | { kind: "soon" } +); + export function Integrations() { const { t } = useTranslation(); const [connections, setConnections] = useState( @@ -82,13 +102,13 @@ export function Integrations() { >(undefined); const [filter, setFilter] = useState("all"); const [query, setQuery] = useState(""); - const [expanded, setExpanded] = useState>(new Set()); const [modal, setModal] = useState<{ open: boolean; editing: IntegrationConfig | null; fixedTypeId?: string; }>({ open: false, editing: null }); const [busy, setBusy] = useState(false); + const [deletingId, setDeletingId] = useState(null); const [error, setError] = useState(null); const refresh = useCallback(async () => { @@ -192,45 +212,152 @@ export function Integrations() { return counts; }, [catalogue]); - function toggleExpand(typeId: string) { - setExpanded((current) => { - const next = new Set(current); - if (next.has(typeId)) next.delete(typeId); - else next.add(typeId); - return next; - }); - } - - function openCreate(typeId: string) { + const openCreate = useCallback((typeId: string) => { setModal({ open: true, editing: null, fixedTypeId: typeId }); - } + }, []); - function openEdit(connection: IntegrationConfig) { + const openEdit = useCallback((connection: IntegrationConfig) => { setModal({ open: true, editing: connection }); - } + }, []); - async function remove(connection: IntegrationConfig) { - if (busy) return; - setBusy(true); - setError(null); - try { - await deleteIntegration(connection.id); - await refresh(); - } catch (e) { - setError(errorMessage(e)); - } finally { - setBusy(false); - } - } + const remove = useCallback( + async (connection: IntegrationConfig) => { + if (busy) return; + setBusy(true); + setDeletingId(connection.id); + setError(null); + try { + await deleteIntegration(connection.id); + await refresh(); + } catch (e) { + setError(errorMessage(e)); + } finally { + setBusy(false); + setDeletingId(null); + } + }, + [busy, refresh], + ); const isLoading = connections === null; - const chip = (kind: WorksWith) => ( - - {t(`portal.integrations.worksWith.${kind}`)} - + const worksWithText = useCallback( + (list: WorksWith[]) => + list.map((w) => t(`portal.integrations.worksWith.${w}`)).join(", "), + [t], ); + const columns = useMemo[]>( + () => [ + column.entity({ + key: "integration", + header: t("portal.integrations.table.integration"), + icon: (r) => , + primary: (r) => r.title, + note: (r) => r.subtitle || undefined, + }), + column.text({ + key: "worksWith", + header: t("portal.integrations.table.worksWith"), + get: (r) => worksWithText(r.worksWith), + }), + column.actions({ + key: "actions", + get: (r) => { + if (r.kind === "instance") { + return r.canManage + ? [ + { + label: t("portal.connections.edit"), + disabled: busy, + onClick: () => openEdit(r.connection), + }, + { + label: t("portal.connections.delete"), + tone: "danger", + loading: busy && deletingId === r.connection.id, + disabled: busy, + onClick: () => void remove(r.connection), + }, + ] + : []; + } + if (r.kind === "available") { + return [ + { + label: t("portal.integrations.connect"), + onClick: () => openCreate(r.typeId), + }, + ]; + } + return []; + }, + }), + ], + [t, busy, deletingId, remove, openEdit, openCreate, worksWithText], + ); + + const tableGroups = useMemo[]>(() => { + const gs: DataTableGroup[] = []; + for (const { type, connections: list } of connectedGroups) { + gs.push({ + key: `connected-${type.id}`, + title: t(type.labelKey), + meta: + list.length > 1 + ? t("portal.integrations.connectionCount", { count: list.length }) + : t("portal.integrations.status.connected"), + actions: [ + { + label: t("portal.integrations.connect"), + onClick: () => openCreate(type.id), + }, + ], + rows: list.map((c) => ({ + kind: "instance" as const, + key: `i-${c.id}`, + brandId: type.id, + title: c.name, + subtitle: connectionDetail(c), + worksWith: worksWith(type), + connection: c, + canManage: !!c.canManage, + })), + }); + } + if (availableTypes.length > 0) { + gs.push({ + key: "available", + title: t("portal.integrations.availableHeading"), + rows: availableTypes.map((type) => ({ + kind: "available" as const, + key: `a-${type.id}`, + brandId: type.id, + title: t(type.labelKey), + subtitle: t(type.descriptionKey), + worksWith: worksWith(type), + typeId: type.id, + })), + }); + } + if (comingSoon.length > 0) { + gs.push({ + key: "soon", + title: t("portal.integrations.comingSoonHeading"), + muted: true, + rows: comingSoon.map((entry) => ({ + kind: "soon" as const, + key: `s-${entry.type}`, + brandId: entry.type, + title: t(entry.labelKey), + subtitle: t(entry.descriptionKey), + worksWith: ["sources"], + })), + }); + } + return gs; + }, [connectedGroups, availableTypes, comingSoon, t, openCreate]); + return (
@@ -301,187 +428,23 @@ export function Integrations() { {error && } - {isLoading ? ( -
- {Array.from({ length: 4 }).map((_, i) => ( - - ))} -
+ {!isLoading && tableGroups.length === 0 ? ( + ) : ( -
-
- {t("portal.integrations.table.integration")} - {t("portal.integrations.table.worksWith")} - -
- - {connectedGroups.length > 0 && ( -
- {t("portal.integrations.connectedHeading")} ·{" "} - {connectedGroups.length} -
- )} - {connectedGroups.map(({ type, connections: list }) => { - const open = expanded.has(type.id); - return ( -
- - {open && ( -
- {list.map((connection) => ( -
- - {connection.name} - - - {connectionDetail(connection)} - - {connection.canManage && ( - - - - - )} -
- ))} -
- -
-
- )} -
- ); - })} - - {availableTypes.length > 0 && ( -
- {t("portal.integrations.availableHeading")} ·{" "} - {availableTypes.length} -
- )} - {availableTypes.map((type) => ( -
- - - - - {t(type.labelKey)} - - - {t(type.descriptionKey)} - - - - - {worksWith(type).map(chip)} - - - - -
- ))} - - {comingSoon.length > 0 && ( -
- {t("portal.integrations.comingSoonHeading")} · {comingSoon.length} -
- )} - {comingSoon.map((entry) => ( -
- - - - - {t(entry.labelKey)} - - - {t(entry.descriptionKey)} - - - - - {chip("sources")} - - - - {t("portal.sources.builder.comingSoon")} - - -
- ))} -
+ + columns={columns} + groups={tableGroups} + rowKey={(r) => r.key} + loading={isLoading} + skeletonRows={5} + /> )} DELETE /invitations/{id} -> refetch drops the invite. fireEvent.click( diff --git a/frontend/editor/src/portal/views/Users.tsx b/frontend/editor/src/portal/views/Users.tsx index 535bf3e627..79c6c11611 100644 --- a/frontend/editor/src/portal/views/Users.tsx +++ b/frontend/editor/src/portal/views/Users.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useSearchParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { Button, EmptyState, Skeleton } from "@app/ui"; @@ -71,12 +71,14 @@ export function Users() { }, [searchParams, setSearchParams]); // Scroll to and flash the row for ?member= (deep link from the super - // search), once the roster has rendered; then strip the param. + // search), once the roster has rendered; then strip the param. Scoped to the + // roster so a pending-invitation row sharing the id can't match first. + const rosterRef = useRef(null); useEffect(() => { const memberId = searchParams.get("member"); if (memberId === null || usersState.loading) return; - const row = document.querySelector( - `[data-member-id="${CSS.escape(memberId)}"]`, + const row = rosterRef.current?.querySelector( + `[data-row-key="${CSS.escape(memberId)}"]`, ); if (row) { row.scrollIntoView({ block: "center" }); @@ -343,28 +345,30 @@ export function Users() { )} {!loading && members.length > 0 && ( - openInvite(team.id)} - onResetPassword={setResetPwMember} - onMoveToTeam={setMoveMember} - onToggleEnabled={toggleEnabled} - onUnlock={unlock} - onDisableMfa={disableMfa} - onRemove={removeUser} - onRenameTeam={(team) => - setRenameTarget({ id: team.id, name: team.name }) - } - onDeleteTeam={deleteTeamAction} - /> +
+ openInvite(team.id)} + onResetPassword={setResetPwMember} + onMoveToTeam={setMoveMember} + onToggleEnabled={toggleEnabled} + onUnlock={unlock} + onDisableMfa={disableMfa} + onRemove={removeUser} + onRenameTeam={(team) => + setRenameTarget({ id: team.id, name: team.name }) + } + onDeleteTeam={deleteTeamAction} + /> +
)} 0 + ? Math.min(100, Math.max(0, (Math.max(0, remaining) / total) * 100)) + : 0; + return { state, pct }; +} + /** Warn (≥80%) / degrade (≥100%) band for a usage meter; mirrors the BE thresholds. */ export function meterState( used: number, diff --git a/frontend/editor/src/proprietary/billing/index.ts b/frontend/editor/src/proprietary/billing/index.ts index 687adec541..2fe9dffd93 100644 --- a/frontend/editor/src/proprietary/billing/index.ts +++ b/frontend/editor/src/proprietary/billing/index.ts @@ -14,6 +14,7 @@ export { docCapForMoney, formatPeriodDate, meterState, + remainingMeter, PREPAID_MONTHS_GRANTED, PREPAID_MONTHS_PAID, PDFS_PER_USER_MONTH, diff --git a/frontend/editor/src/proprietary/components/shared/AppSwitcher.tsx b/frontend/editor/src/proprietary/components/shared/AppSwitcher.tsx index 2e02db3068..9ba0b6438d 100644 --- a/frontend/editor/src/proprietary/components/shared/AppSwitcher.tsx +++ b/frontend/editor/src/proprietary/components/shared/AppSwitcher.tsx @@ -1,15 +1,21 @@ -import { useNavigate } from "react-router-dom"; -import { useAuth } from "@app/auth/context"; import { Logo } from "@app/ui/Logo"; import { BrandSwitcher } from "@app/components/shared/BrandSwitcher"; import { type AppSwitcherProps } from "@core/components/shared/AppSwitcher"; -import { PORTAL_BASENAME } from "@app/routes/portalBasename"; +import { useOtherAppSwitch } from "@app/hooks/useOtherAppSwitch"; +/** + * Sidebar brand header for builds that ship the processor. When this user can + * open it, the Stirling logo doubles as the editor⇄processor switcher: the mark + * morphs into a chevron and opens the switch menu (the same BrandSwitcher the + * processor sidebar uses). Users without access get a plain logo. + * + * The access gate lives in {@link useOtherAppSwitch} so this header and the + * sidebar footer's "Open PDF Processor" row are driven by one answer. + */ export function AppSwitcher({ collapsed }: AppSwitcherProps) { - const { portalAccess } = useAuth(); - const navigate = useNavigate(); + const otherApp = useOtherAppSwitch(); - if (!portalAccess) { + if (!otherApp) { return ( navigate(PORTAL_BASENAME)} + onSwitch={otherApp.onOpen} collapsed={collapsed} /> ); diff --git a/frontend/editor/src/proprietary/components/shared/UpgradeBanner.tsx b/frontend/editor/src/proprietary/components/shared/UpgradeBanner.tsx index c3b5ae0307..365c64a030 100644 --- a/frontend/editor/src/proprietary/components/shared/UpgradeBanner.tsx +++ b/frontend/editor/src/proprietary/components/shared/UpgradeBanner.tsx @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"; import { useNavigate, useLocation } from "react-router-dom"; import { isAuthRoute } from "@core/constants/routes"; import { useCheckout } from "@app/contexts/CheckoutContext"; -import { InfoBanner } from "@app/components/shared/InfoBanner"; +import { AppBanner } from "@app/components/shared/AppBanner"; import { SERVER_LICENSE_REQUEST_EVENT, type ServerLicenseRequestPayload, @@ -300,7 +300,7 @@ const UpgradeBanner: React.FC = () => { ); return ( - { buttonIcon="info-rounded" onButtonClick={buttonText ? handleSeeInfo : undefined} dismissible={false} - minHeight={60} - background="#FFF4E6" - borderColor="var(--mantine-color-orange-7)" - textColor="#9A3412" - iconColor="#EA580C" - buttonVariant="filled" - buttonColor="orange.7" /> ); }; @@ -341,7 +334,7 @@ const UpgradeBanner: React.FC = () => { return ( <> {friendlyVisible && ( - { onButtonClick={handleUpgrade} onDismiss={handleFriendlyDismiss} show={friendlyVisible} - background="linear-gradient(135deg, #667eea 0%, #764ba2 100%)" - borderColor="transparent" - textColor="#fff" - iconColor="#fff" - closeIconColor="#fff" - buttonVariant="filled" - buttonColor="blue" - minHeight={48} + tone="promo" compact /> )} diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminPlanSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminPlanSection.tsx index f683861e8d..c69040f0e7 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminPlanSection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminPlanSection.tsx @@ -12,7 +12,7 @@ import AvailablePlansSection from "@app/components/shared/config/configSections/ import StaticPlanSection from "@app/components/shared/config/configSections/plan/StaticPlanSection"; import LicenseKeySection from "@app/components/shared/config/configSections/plan/LicenseKeySection"; import { alert } from "@app/components/toast"; -import { InfoBanner } from "@app/components/shared/InfoBanner"; +import { AppBanner } from "@app/components/shared/AppBanner"; import { useLicenseAlert } from "@app/hooks/useLicenseAlert"; import { getPreferredCurrency, @@ -200,7 +200,7 @@ const AdminPlanSection: React.FC = () => { {shouldShowLicenseWarning && ( - { buttonIcon="upgrade-rounded" onButtonClick={scrollToPlans} dismissible={false} - minHeight={68} - background="#FFF4E6" - borderColor="var(--mantine-color-orange-7)" - textColor="#9A3412" - iconColor="#EA580C" - buttonVariant="filled" - buttonColor="orange.7" /> )} diff --git a/frontend/editor/src/proprietary/hooks/useOtherAppSwitch.ts b/frontend/editor/src/proprietary/hooks/useOtherAppSwitch.ts new file mode 100644 index 0000000000..8bf07b5c2f --- /dev/null +++ b/frontend/editor/src/proprietary/hooks/useOtherAppSwitch.ts @@ -0,0 +1,15 @@ +import { useNavigate } from "react-router-dom"; +import { useAuth } from "@app/auth/context"; +import { PORTAL_BASENAME } from "@app/routes/portalBasename"; +import { type NavFooterAppLink } from "@app/components/shared/navFooter/NavFooter"; + +/** + * Self-hosted: the Spring session carries `portalAccess`, so the switch to the + * processor is offered exactly when that flag is set. + */ +export function useOtherAppSwitch(): NavFooterAppLink | null { + const { portalAccess } = useAuth(); + const navigate = useNavigate(); + if (!portalAccess) return null; + return { app: "processor", onOpen: () => navigate(PORTAL_BASENAME) }; +} diff --git a/frontend/editor/src/saas/components/shared/AppSwitcher.tsx b/frontend/editor/src/saas/components/shared/AppSwitcher.tsx deleted file mode 100644 index 364f094478..0000000000 --- a/frontend/editor/src/saas/components/shared/AppSwitcher.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { useNavigate } from "react-router-dom"; -import { Logo } from "@app/ui/Logo"; -import { BrandSwitcher } from "@app/components/shared/BrandSwitcher"; -import { type AppSwitcherProps } from "@core/components/shared/AppSwitcher"; -import { usePortalAccess } from "@app/hooks/usePortalAccess"; -import { PORTAL_BASENAME } from "@app/routes/portalBasename"; - -/** - * SaaS sidebar brand header. When the backend says this user can open the - * processor (`/api/v1/auth/me` → `portalAccess` — the exact signal the - * processor's own gate uses), the Stirling logo doubles as the - * editor⇄processor switcher: the mark morphs into a chevron and opens the - * switch menu (same BrandSwitcher the processor sidebar uses). Users without - * access get a plain logo. - * - * Deliberately NOT gated on the editor's Supabase auth context: that context - * never fetches /me, so it can't know about portal access (and its session - * state doesn't always mirror the backend login that actually grants it). - */ -export function AppSwitcher({ collapsed }: AppSwitcherProps) { - const portalAccess = usePortalAccess(); - const navigate = useNavigate(); - - if (!portalAccess) { - return ( - - ); - } - - return ( - navigate(PORTAL_BASENAME)} - collapsed={collapsed} - /> - ); -} diff --git a/frontend/editor/src/saas/components/shared/config/configSections/Overview.tsx b/frontend/editor/src/saas/components/shared/config/configSections/Overview.tsx index fac87148fe..3a28152425 100644 --- a/frontend/editor/src/saas/components/shared/config/configSections/Overview.tsx +++ b/frontend/editor/src/saas/components/shared/config/configSections/Overview.tsx @@ -1,7 +1,6 @@ import React, { useState } from "react"; import { Alert, - Avatar, Divider, Group, Image, @@ -11,10 +10,12 @@ import { TextInput, Modal, } from "@mantine/core"; +import { Avatar } from "@app/ui/Avatar"; import { Button as DSButton } from "@app/ui/Button"; import { FilePicker } from "@app/ui/FilePicker"; import { useTranslation } from "react-i18next"; import { useAuth } from "@app/auth/UseSession"; +import { useAccountIdentity } from "@app/hooks/useAccountIdentity"; import { isUserAnonymous, linkEmailIdentity, @@ -46,6 +47,8 @@ const Overview: React.FC = ({ onLogoutClick }) => { refreshProfilePicture, refreshProfilePictureMetadata, } = useAuth(); + // Same name + initials the sidebar footer draws, so the two discs agree. + const { displayName } = useAccountIdentity(); const PROFILE_BUCKET = "profile-pictures"; @@ -67,7 +70,6 @@ const Overview: React.FC = ({ onLogoutClick }) => { const provider = profilePictureMetadata?.provider; const profilePath = user ? `${user.id}/avatar` : null; - const profileInitial = user?.email?.trim()?.charAt(0)?.toUpperCase() || "U"; const handleProfileUpload = async (file: File | null) => { if (!file || !user || !profilePath) { @@ -410,12 +412,9 @@ const Overview: React.FC = ({ onLogoutClick }) => { - {profileInitial} - + name={displayName} + size="xl" + />
= ({ onLogoutClick }) => { - {profileInitial} - + name={displayName} + size="xl" + />
navigate(PORTAL_BASENAME) }; +} diff --git a/frontend/editor/src/saas/hooks/usePortalAccess.test.tsx b/frontend/editor/src/saas/hooks/usePortalAccess.test.tsx index a0e8ba618d..809138850a 100644 --- a/frontend/editor/src/saas/hooks/usePortalAccess.test.tsx +++ b/frontend/editor/src/saas/hooks/usePortalAccess.test.tsx @@ -1,5 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { renderHook, waitFor } from "@testing-library/react"; +import { renderHook as baseRenderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; const get = vi.fn(); let currentUserId: string | null = null; @@ -20,10 +22,28 @@ function meReturning(portalAccess: boolean) { return { data: { user: { portalAccess } } }; } +// A fresh client per render, so one test's cached answer can't satisfy the +// next — each case exercises a cold cache unless it deliberately shares one. +let client: QueryClient; + +function renderHook(cb: () => T) { + return baseRenderHook(cb, { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }); +} + describe("usePortalAccess", () => { beforeEach(() => { + // The hook now remembers the last answer across mounts, so without this a + // prior test's result seeds the next one. + localStorage.clear(); get.mockReset(); currentUserId = null; + client = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0, staleTime: 0 } }, + }); }); it("reports the backend's answer for the signed-in user", async () => { @@ -82,12 +102,31 @@ describe("usePortalAccess", () => { expect(first.result.current).toBe(false); first.unmount(); - // The failure isn't sticky. + // The failure isn't sticky — a cold cache asks again. + client.clear(); get.mockResolvedValue(meReturning(true)); const second = renderHook(() => usePortalAccess()); await waitFor(() => expect(second.result.current).toBe(true)); }); + it("shows the last known answer at first paint, then revalidates", async () => { + // What stops the switcher and the footer's "Open ..." row popping in a + // request late on every mount. + currentUserId = "admin-1"; + get.mockResolvedValue(meReturning(true)); + const first = renderHook(() => usePortalAccess()); + await waitFor(() => expect(first.result.current).toBe(true)); + first.unmount(); + + client.clear(); + get.mockResolvedValue(meReturning(false)); + const second = renderHook(() => usePortalAccess()); + // Seeded from the remembered answer before the request lands... + expect(second.result.current).toBe(true); + // ...and corrected once the backend disagrees. + await waitFor(() => expect(second.result.current).toBe(false)); + }); + it("ignores a response that lands after unmount", async () => { currentUserId = "admin-1"; let resolveMe: (v: unknown) => void = () => {}; diff --git a/frontend/editor/src/saas/hooks/usePortalAccess.ts b/frontend/editor/src/saas/hooks/usePortalAccess.ts index 442061cbe1..6e91f0864c 100644 --- a/frontend/editor/src/saas/hooks/usePortalAccess.ts +++ b/frontend/editor/src/saas/hooks/usePortalAccess.ts @@ -1,52 +1,64 @@ import { useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import apiClient from "@app/services/apiClient"; import { useAuth } from "@app/auth/UseSession"; +import { + readCachedOtherApp, + writeCachedOtherApp, +} from "@app/services/navFooterCache"; +import { qk } from "@app/query/keys"; + +async function fetchPortalAccess(): Promise { + const res = await apiClient.get<{ user?: { portalAccess?: boolean } }>( + "/api/v1/auth/me", + ); + return res.data.user?.portalAccess === true; +} /** * Whether the current user can open the processor (admin portal), straight * from the backend (`/api/v1/auth/me` → `portalAccess`) — the same signal the * processor's own SaasPortalGate uses. Components that must mirror processor - * access (e.g. the sidebar's editor⇄processor switcher) ask here. + * access (the sidebar's editor⇄processor switcher and its footer row) ask here. * * The editor's Supabase auth context can't *answer* this — it never fetches - * /me — so it is used only to identify who is asking. Keying the effect on - * that identity is what keeps the answer per-user: the SPA can swap users + * /me — so it is used only to identify who is asking. That identity is the + * cache key, which is what keeps the answer per-user: the SPA can swap users * without a reload (Supabase fires SIGNED_OUT/SIGNED_IN in place; only the - * settings Logout button hard-navigates), so any answer held beyond the - * current identity would leak to whoever signs in next. + * settings Logout button hard-navigates), and a keyed cache addresses each + * identity separately rather than holding one answer that would have to be + * invalidated on the swap — the bug class this hook once had. * - * Deliberately unmemoised beyond the mount: the one consumer (the sidebar - * switcher) mounts once, so a cross-mount cache would only add user-scoped - * state that has to be invalidated on identity change — the bug class this - * hook already had once. Guests skip the request entirely. + * Cached through the app query client, so leaving the editor for the processor + * and coming back resolves from cache: the switcher is there on first paint + * instead of appearing a request later. Guests skip the request entirely. */ export function usePortalAccess(): boolean { const { user } = useAuth(); const userId = user?.id ?? null; - const [access, setAccess] = useState(false); + // The query cache is per-tree and per-load, so it can't help a cold start or + // the hop into the processor, which mounts its own client. Seed from the last + // answer this browser saw so the switcher and the footer's "Open ..." row are + // there at first paint. Marked ancient so it still revalidates immediately. + const [seed] = useState(readCachedOtherApp); + + const { data, isSuccess } = useQuery({ + queryKey: qk.portalAccess(userId), + queryFn: fetchPortalAccess, + // Signed out: nothing to ask, and any previous answer is void. + enabled: userId !== null, + // Backend unreachable or guest (401) means no access now; a later refetch + // asks again rather than trusting the failure. + retry: false, + initialData: seed, + initialDataUpdatedAt: 0, + }); useEffect(() => { - // Signed out: nothing to ask, and any previous answer is void. - if (userId === null) { - setAccess(false); - return; - } + // Only a real answer is recorded — a failed probe is not one, so the next + // mount trusts the last backend response rather than a network blip. + if (isSuccess && data !== undefined) writeCachedOtherApp(data); + }, [isSuccess, data]); - let cancelled = false; - apiClient - .get<{ user?: { portalAccess?: boolean } }>("/api/v1/auth/me") - .then((res) => { - if (!cancelled) setAccess(res.data.user?.portalAccess === true); - }) - .catch(() => { - // Backend unreachable or guest (401): no access now; a remount or - // identity change asks again rather than trusting a failure. - if (!cancelled) setAccess(false); - }); - return () => { - cancelled = true; - }; - }, [userId]); - - return access; + return data === true; } diff --git a/frontend/editor/src/saas/hooks/useWallet.poll.test.tsx b/frontend/editor/src/saas/hooks/useWallet.poll.test.tsx new file mode 100644 index 0000000000..b72eac67f8 --- /dev/null +++ b/frontend/editor/src/saas/hooks/useWallet.poll.test.tsx @@ -0,0 +1,158 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, act, waitFor } from "@testing-library/react"; +import { expectConsole } from "@app/tests/failOnConsole"; + +const get = vi.fn(); +vi.mock("@app/services/apiClient", () => ({ + default: { get: (...args: unknown[]) => get(...args) }, +})); +vi.mock("@app/hooks/walletDevPreview", () => ({ + getWalletDevPreview: () => null, +})); +vi.mock("@app/services/billing", () => ({ createPortalSession: vi.fn() })); +vi.mock("@app/platform/openExternal", () => ({ openExternal: vi.fn() })); + +const { useWallet } = await import("@app/hooks/useWallet"); + +/** Full enough for the hook's deep-compare, which reads every field. */ +function walletWith(freeRemaining: number) { + return { + data: { + teamId: 1, + status: "free", + role: "leader", + billingPeriodStart: "2026-08-01", + billingPeriodEnd: "2026-08-31", + billableUsed: 500 - freeRemaining, + billableLimit: 500, + freeAllowance: 500, + freeRemaining, + pricePerDocMinor: 2, + bundleRatePerCreditMinor: null, + currency: "usd", + estimatedBillMinor: 0, + capUsd: null, + noCap: false, + stripeSubscriptionId: null, + spendUnitsThisPeriod: 0, + docsProcessedThisPeriod: 0, + uniquePdfsThisPeriod: 0, + sizeMultiplierPdfsThisPeriod: 0, + billingMode: "metered", + prepaidUnitsRemaining: 0, + prepaidUnitsTotal: 0, + prepaidExpiresAt: null, + recent: [], + members: [], + categoryBreakdown: { api: 0, ai: 0, automation: 0 }, + categoryDocs: { api: 0, ai: 0, automation: 0 }, + }, + }; +} + +describe("useWallet — keeping the figures fresh", () => { + beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + get.mockReset(); + get.mockResolvedValue(walletWith(500)); + }); + afterEach(() => vi.useRealTimers()); + + it("re-reads the wallet on the poll interval", async () => { + const { result } = renderHook(() => useWallet()); + await waitFor(() => expect(result.current.wallet).not.toBeNull()); + expect(get).toHaveBeenCalledTimes(1); + + get.mockResolvedValue(walletWith(480)); + await act(async () => { + vi.advanceTimersByTime(30_000); + }); + + await waitFor(() => expect(result.current.wallet?.freeRemaining).toBe(480)); + }); + + it("polls silently, so consumers gating on loading/error don't flicker", async () => { + const { result } = renderHook(() => useWallet()); + await waitFor(() => expect(result.current.wallet).not.toBeNull()); + + // A poll that fails must leave the last good snapshot, and must not raise + // `error` — Plan swaps a working page for an alert on that. + get.mockRejectedValue(new Error("network blip")); + await act(async () => { + vi.advanceTimersByTime(30_000); + }); + + expect(result.current.loading).toBe(false); + expect(result.current.error).toBeNull(); + expect(result.current.wallet?.freeRemaining).toBe(500); + }); + + it("settles loading when a silent poll supersedes an in-flight visible load", async () => { + // The mount load raises `loading`; a poll firing before it lands cancels it. + // If clearing the flag were the silent load's to skip, both would decline + // and `loading` would stay true forever — which permanently suppresses the + // limit modals, since they do `if (loading || !wallet) return null`. + const visibility = vi.spyOn(document, "visibilityState", "get"); + visibility.mockReturnValue("visible"); + + let landMount: (v: unknown) => void = () => {}; + get.mockReturnValueOnce( + new Promise((resolve) => { + landMount = resolve; + }), + ); + const { result } = renderHook(() => useWallet()); + expect(result.current.loading).toBe(true); + + get.mockResolvedValue(walletWith(470)); + await act(async () => { + document.dispatchEvent(new Event("visibilitychange")); + }); + await act(async () => { + landMount(walletWith(500)); + }); + + await waitFor(() => expect(result.current.wallet?.freeRemaining).toBe(470)); + expect(result.current.loading).toBe(false); + visibility.mockRestore(); + }); + + it("clears a stale error once a silent poll succeeds", async () => { + // The visible mount load failing is meant to be logged; only the silent + // retries stay quiet. + expectConsole.warn(/\[useWallet\] fetch failed/); + get.mockRejectedValueOnce(new Error("network blip")); + const { result } = renderHook(() => useWallet()); + await waitFor(() => expect(result.current.error).not.toBeNull()); + + get.mockResolvedValue(walletWith(500)); + await act(async () => { + vi.advanceTimersByTime(30_000); + }); + + await waitFor(() => expect(result.current.error).toBeNull()); + expect(result.current.wallet?.freeRemaining).toBe(500); + }); + + it("stops polling while the tab is hidden and re-reads on return", async () => { + const visibility = vi.spyOn(document, "visibilityState", "get"); + visibility.mockReturnValue("visible"); + const { result } = renderHook(() => useWallet()); + await waitFor(() => expect(result.current.wallet).not.toBeNull()); + const afterMount = get.mock.calls.length; + + visibility.mockReturnValue("hidden"); + await act(async () => { + document.dispatchEvent(new Event("visibilitychange")); + vi.advanceTimersByTime(120_000); + }); + expect(get).toHaveBeenCalledTimes(afterMount); + + visibility.mockReturnValue("visible"); + await act(async () => { + document.dispatchEvent(new Event("visibilitychange")); + }); + await waitFor(() => expect(get.mock.calls.length).toBe(afterMount + 1)); + visibility.mockRestore(); + }); +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c7ce36f314..d50868bd60 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -50,6 +50,7 @@ "@supabase/supabase-js": "^2.47.13", "@tailwindcss/postcss": "^4.1.13", "@tanstack/react-query": "^5.101.4", + "@tanstack/react-table": "^9.1.2", "@tanstack/react-virtual": "^3.13.12", "@tauri-apps/api": "^2.10.1", "@tauri-apps/plugin-dialog": "2.7.0", @@ -4936,6 +4937,44 @@ "react": "^18 || ^19" } }, + "node_modules/@tanstack/react-store": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.11.1.tgz", + "integrity": "sha512-HaIGKI3YLmjBYIvy5DFDY23oNaYZIsTZfngey07Uh5iLVJgM3bIGCnZeOFOqzjFld9JHWcaHJnasD/bKoGKwJQ==", + "license": "MIT", + "dependencies": { + "@tanstack/store": "0.11.1", + "use-sync-external-store": "^1.6.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/react-table": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-9.1.2.tgz", + "integrity": "sha512-YQPZFJ1nIi/bjjwsPZVouABgahDcl7Gdm33CdTStUJBn0DjEVJ2uhSTVmIoWt9MVKdQziXGAsXipSzy949Hygg==", + "license": "MIT", + "dependencies": { + "@tanstack/react-store": "^0.11.0", + "@tanstack/table-core": "9.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=18" + } + }, "node_modules/@tanstack/react-virtual": { "version": "3.13.23", "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.23.tgz", @@ -4953,6 +4992,32 @@ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/@tanstack/store": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.11.1.tgz", + "integrity": "sha512-mzTOBhypOuDJAy/D8n2MfUZ1HFkXnmSETviRyhqEC8LUE7/IZQExOTxMANj3KjTofYTkFNpBY67qaVrT41YccA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/table-core": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-9.1.2.tgz", + "integrity": "sha512-ONpWQeass1sfg80CWF1NSwQ8r3GiqxA2lT/EdqIcrDEPZ0Z+0mM94eQoFYLPN0Kztzj8TQVb2+PrSZSItqA61g==", + "license": "MIT", + "dependencies": { + "@tanstack/store": "^0.11.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tanstack/virtual-core": { "version": "3.13.23", "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.23.tgz", diff --git a/frontend/package.json b/frontend/package.json index 3fd1f8e106..90a0e10b05 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -47,6 +47,7 @@ "@supabase/supabase-js": "^2.47.13", "@tailwindcss/postcss": "^4.1.13", "@tanstack/react-query": "^5.101.4", + "@tanstack/react-table": "^9.1.2", "@tanstack/react-virtual": "^3.13.12", "@tauri-apps/api": "^2.10.1", "@tauri-apps/plugin-dialog": "2.7.0",