Merge branch 'main' into animation-test

This commit is contained in:
Anthony Stirling
2026-08-20 09:05:09 +01:00
99 changed files with 3342 additions and 1087 deletions
+4 -1
View File
@@ -462,7 +462,10 @@ jobs:
});
cleanup-v2-deployment:
environment: pr-preview
# Tearing a preview down is not a deployment - no deployment object.
environment:
name: pr-preview
deployment: false
if: github.event.action == 'closed'
runs-on: ubuntu-latest
permissions:
@@ -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
+4 -1
View File
@@ -9,7 +9,10 @@ permissions:
jobs:
cleanup:
environment: pr-preview
# Tearing a preview down is not a deployment - no deployment object.
environment:
name: pr-preview
deployment: false
if: github.event.action == 'closed'
runs-on: ubuntu-latest
permissions:
+12 -13
View File
@@ -20,7 +20,9 @@ permissions:
jobs:
build:
environment: ci-unsigned
environment:
name: ci-unsigned
deployment: false
runs-on: ubuntu-latest
strategy:
fail-fast: false
@@ -35,23 +37,20 @@ jobs:
- 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)
@@ -156,7 +155,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()
+30 -11
View File
@@ -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,7 +42,9 @@ jobs:
uses: ./.github/workflows/_runner-pick.yml
playwright-e2e-enterprise:
environment: ci-unsigned
environment:
name: ci-unsigned
deployment: false
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.
@@ -56,21 +63,31 @@ jobs:
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:
@@ -310,7 +327,9 @@ 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
environment:
name: ci-unsigned
deployment: false
needs: [pick, playwright-e2e-enterprise]
# Nightly cron + manual dispatch only (heavy build), fork-gated for the licence secret.
if: >-
+48 -15
View File
@@ -61,7 +61,9 @@ jobs:
filters: .github/config/.files.yaml
gradle-cache-prime:
environment: ci-unsigned
environment:
name: ci-unsigned
deployment: false
name: Prime shared Gradle cache
needs: [files-changed]
runs-on: ubuntu-latest
@@ -73,29 +75,48 @@ jobs:
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:
@@ -170,6 +191,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'
@@ -193,7 +216,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
@@ -205,7 +235,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
@@ -219,6 +249,7 @@ jobs:
with:
platform: windows-macos
sign: true
use_shared_cache: true
ai-engine:
if: needs.files-changed.outputs.engine == 'true'
@@ -242,6 +273,8 @@ jobs:
pull-requests: write
uses: ./.github/workflows/check-generated-models.yml
secrets: inherit
with:
use_shared_cache: true
pre-commit:
needs: [files-changed]
+21 -9
View File
@@ -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]
@@ -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
+11 -12
View File
@@ -10,7 +10,9 @@ permissions:
jobs:
check-licence:
environment: ci-unsigned
environment:
name: ci-unsigned
deployment: false
runs-on: ubuntu-latest
steps:
- name: Harden Runner
@@ -21,23 +23,20 @@ jobs:
- 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
+11 -12
View File
@@ -11,7 +11,9 @@ permissions:
jobs:
check-generate-openapi-docs:
environment: ci-unsigned
environment:
name: ci-unsigned
deployment: false
runs-on: ubuntu-latest
steps:
- name: Harden Runner
@@ -22,23 +24,20 @@ jobs:
- 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
+8 -11
View File
@@ -40,23 +40,20 @@ jobs:
- 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:
+11 -12
View File
@@ -13,7 +13,9 @@ permissions:
jobs:
migration-test:
environment: ci-unsigned
environment:
name: ci-unsigned
deployment: false
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
@@ -25,23 +27,20 @@ jobs:
- 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
+11 -12
View File
@@ -17,7 +17,9 @@ permissions:
jobs:
docker-compose-tests:
environment: ci-unsigned
environment:
name: ci-unsigned
deployment: false
runs-on: ubuntu-latest
permissions:
actions: write
@@ -33,23 +35,20 @@ jobs:
- 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
+13 -29
View File
@@ -11,7 +11,9 @@ permissions:
jobs:
playwright-e2e-live:
environment: ci-unsigned
environment:
name: ci-unsigned
deployment: false
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
@@ -21,39 +23,21 @@ jobs:
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:
@@ -43,7 +43,9 @@ jobs:
generate-frontend-license-report:
# ci-bot, not bot-identity: this job runs on PRs too, and bot-identity is main-only.
environment: ci-bot
environment:
name: ci-bot
deployment: false
if: needs.files-changed.outputs.licenses-frontend == 'true'
name: Generate Frontend License Report
needs: files-changed
@@ -319,7 +321,9 @@ jobs:
generate-backend-license-report:
# ci-bot, not bot-identity: this job runs on PRs too, and bot-identity is main-only.
environment: ci-bot
environment:
name: ci-bot
deployment: false
if: needs.files-changed.outputs.licenses-backend == 'true'
needs: files-changed
name: Generate Backend License Report
@@ -348,22 +352,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
+30 -35
View File
@@ -38,7 +38,9 @@ permissions:
jobs:
determine-matrix:
environment: ci-unsigned
environment:
name: ci-unsigned
deployment: false
if: ${{ vars.CI_PROFILE != 'lite' }}
runs-on: ubuntu-latest
outputs:
@@ -52,22 +54,19 @@ jobs:
- 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
- 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-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: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
@@ -119,7 +118,9 @@ jobs:
env:
INPUT_PLATFORM: ${{ github.event.inputs.platform }}
build-jars:
environment: ci-unsigned
environment:
name: ci-unsigned
deployment: false
needs: determine-matrix
runs-on: ubuntu-latest
strategy:
@@ -145,22 +146,19 @@ jobs:
- 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
- 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-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: Setup Node.js
if: matrix.variant.build_frontend == true
@@ -238,6 +236,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
@@ -261,17 +267,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
+3 -1
View File
@@ -127,7 +127,9 @@ jobs:
# Runs the @nightly tag (conversion scenarios) plus a 10-shard concurrency run
# of every other feature.
cucumber-nightly:
environment: ci-unsigned
environment:
name: ci-unsigned
deployment: false
name: Cucumber (nightly scenarios + full concurrency)
runs-on: ubuntu-latest
# Fork pull requests get no MAVEN_* secrets, so the image build cannot work.
+8 -11
View File
@@ -58,22 +58,19 @@ jobs:
- 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
- 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-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: Set up Docker Buildx
id: buildx
+8 -11
View File
@@ -36,22 +36,19 @@ jobs:
- 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
- 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-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: Generate Swagger documentation
run: ./gradlew :stirling-pdf:generateOpenApiDocs
+25 -12
View File
@@ -26,6 +26,10 @@ on:
required: false
type: boolean
default: false
use_shared_cache:
required: false
type: boolean
default: false
workflow_dispatch:
inputs:
platform:
@@ -59,7 +63,9 @@ 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
environment:
name: ci-signing
deployment: false
if: ${{ vars.CI_PROFILE != 'lite' }}
runs-on: ubuntu-latest
outputs:
@@ -168,6 +174,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
@@ -187,17 +211,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
+11 -12
View File
@@ -37,7 +37,9 @@ 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
environment:
name: ci-unsigned
deployment: false
runs-on: ubuntu-latest
strategy:
fail-fast: false
@@ -79,23 +81,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
+5
View File
@@ -27,3 +27,8 @@ app/core/src/main/java/stirling/software/SPDF/pdf/signature/CreateSignatureBase.
# Supabase publishable key (public by design, RLS-protected) used as a CI fallback
# default in the tauri-build workflow when the GitHub secret is unset - not a real secret.
.github/workflows/tauri-build.yml:generic-api-key:402
# Staging Supabase publishable key (public by design). Ignored here rather than with an
# inline gitleaks:allow because a trailing comment in a .properties file is part of the
# value, so the pragma would end up inside the key.
app/saas/src/main/resources/application-staging.properties:generic-api-key:16
+49 -6
View File
@@ -57,16 +57,57 @@ tasks:
- cmd: ./gradlew clean bootRun -PbuildWithFrontend=true
platforms: [linux, darwin]
# SaaS backend. dev:saas -> the PR's preview branch, staging:saas -> shared v3,
# PROFILES=none -> production against your own SAAS_DB_*. Production has no named
# task on purpose. Use `none`, not an empty value: Go template `default` treats ""
# as absent and would resolve back to dev.
dev:saas:
desc: "Start backend in SaaS flavor against Supabase"
# `dotenv:` reads from the root Taskfile's directory (".") because this
# subtaskfile is included with `dir: .`.
desc: "Start SaaS backend against the current PR's Supabase preview branch"
dotenv: ['app/.env.saas.local', 'app/.env.saas']
vars:
PROFILES: '{{.PROFILES | default "dev"}}'
cmds:
# Don't move this check into a `sh:` var: dotenv is visible in cmds but not
# during var evaluation, so the test would always see an empty value.
- cmd: |
if [ "{{.PROFILES}}" = "dev" ] && [ -z "${SAAS_DEV_PROJECT_REF:-}" ]; then
echo ">> SAAS_DEV_PROJECT_REF is not set."
echo ">> Testing a SaaS PR? Put its ref, DB password and publishable key in app/.env.saas.local."
echo ">> Wanted the shared v3 project? Use 'task backend:staging:saas' instead."
exit 1
fi
- task: _run:saas
vars:
PORT: '{{.PORT}}'
PROFILES: '{{.PROFILES}}'
AIENGINE_URL: '{{.AIENGINE_URL}}'
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
staging:saas:
desc: "Start SaaS backend against the shared v3 staging project"
cmds:
- task: _run:saas
vars:
PORT: '{{.PORT}}'
PROFILES: staging
AIENGINE_URL: '{{.AIENGINE_URL}}'
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
_run:saas:
internal: true
dotenv: ['app/.env.saas.local', 'app/.env.saas']
ignore_error: true
vars:
PORT: '{{.PORT | default "8080"}}'
# Override to "" to run the pure `saas` profile against your own SAAS_DB_*.
PROFILES: '{{.PROFILES | default "dev"}}'
# Built here rather than inline in the cmds below: the Windows line is an
# unquoted YAML scalar wrapping a cmd.exe string, so a nested {{if ne .X
# "none"}} needs escaped quotes that reach the Go template as literal
# backslashes and fail with `unexpected "\" in operand`.
PROFILE_ARGS: '{{if ne .PROFILES "none"}}--spring.profiles.include={{.PROFILES}}{{end}}'
AIENGINE_URL: '{{.AIENGINE_URL | default ""}}'
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}'
@@ -77,9 +118,11 @@ tasks:
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
cmds:
- cmd: cmd /c ".\gradlew.bat :stirling-pdf:bootRun {{if .PROFILES}}--args=\"--spring.profiles.include={{.PROFILES}}\"{{end}}"
# PROFILE_ARGS is empty when PROFILES=none, i.e. the bare `saas` profile
# against SAAS_DB_* (production).
- cmd: cmd /c ".\gradlew.bat :stirling-pdf:bootRun {{if .PROFILE_ARGS}}--args=\"{{.PROFILE_ARGS}}\"{{end}}"
platforms: [windows]
- cmd: ./gradlew :stirling-pdf:bootRun {{if .PROFILES}}--args='--spring.profiles.include={{.PROFILES}}'{{end}}
- cmd: ./gradlew :stirling-pdf:bootRun {{if .PROFILE_ARGS}}--args='{{.PROFILE_ARGS}}'{{end}}
platforms: [linux, darwin]
build:
+64 -10
View File
@@ -5,6 +5,14 @@ version: '3'
# mode flag) or use `--project editor/...` for tsc — so the editor lives
# under frontend/editor/ without each task needing a cd.
vars:
# Dev-only browser-tab label so concurrent worktrees are distinguishable. Only
# the worktree folder basename (e.g. "wt1") is exposed — never the full path,
# hostname, or user. Dropped from production builds.
DEV_LABEL:
sh: >-
{{if eq OS "windows"}}powershell -NoProfile -Command '$root = git rev-parse --show-toplevel 2>$null; if (-not $root) { $root = (Get-Location).Path }; Split-Path -Leaf $root'{{else}}basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)"{{end}}
tasks:
install:
desc: "Install dependencies"
@@ -80,16 +88,52 @@ tasks:
OPEN: '{{.OPEN | default ""}}'
env:
BACKEND_URL: '{{.BACKEND_URL}}'
# Dev-only browser-tab label so concurrent worktrees are distinguishable.
# Only the worktree folder basename (e.g. "wt1") is exposed — never the
# full path, hostname, or user. Consumed at dev-serve time by vite.config
# and dropped from production builds.
STIRLING_DEV_LABEL:
sh: >-
{{if eq OS "windows"}}powershell -NoProfile -Command '$root = git rev-parse --show-toplevel 2>$null; if (-not $root) { $root = (Get-Location).Path }; Split-Path -Leaf $root'{{else}}basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)"{{end}}
STIRLING_DEV_LABEL: '{{.DEV_LABEL}}'
cmds:
- npx vite editor --mode {{.MODE}} --port {{.PORT}}{{if .OPEN}} --open{{end}}
# Separate from dev:_run rather than a flag on it: Task sets an `env:` key even
# when its value resolves to empty, and Vite treats an empty process.env VITE_* as
# authoritative over the committed editor/.env, so folding these in blanks Supabase
# config for the core, proprietary and desktop dev servers.
dev:_run:saas:
internal: true
ignore_error: true
# The backend's own env files, so both halves target one project. Paths are
# relative to this taskfile's dir, `frontend`.
dotenv: ['../app/.env.saas.local', '../app/.env.saas']
vars:
PORT: '{{.PORT | default "5173"}}'
BACKEND_URL: '{{.BACKEND_URL | default "http://localhost:8080"}}'
OPEN: '{{.OPEN | default ""}}'
SAAS_ENV: '{{.SAAS_ENV | default "dev"}}'
env:
BACKEND_URL: '{{.BACKEND_URL}}'
STIRLING_DEV_LABEL: '{{.DEV_LABEL}}'
SAAS_ENV: '{{.SAAS_ENV}}'
# A real process.env VITE_* beats a committed .env in Vite (loadEnv applies
# process.env last), which is what lets this override editor/.env.
#
# These must stay `sh:`, not Go templates: dotenv values are visible to Task's
# embedded shell but not to templates, where {{.SAAS_DEV_PROJECT_REF}} is
# always empty.
VITE_SUPABASE_URL:
sh: |
case "${SAAS_ENV:-dev}" in
staging) ref="${SAAS_STAGING_PROJECT_REF:?set it in app/.env.saas.local}" ;;
*) ref="${SAAS_DEV_PROJECT_REF:?set it in app/.env.saas.local, or run task staging:saas}" ;;
esac
echo "https://${ref}.supabase.co"
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY:
sh: |
case "${SAAS_ENV:-dev}" in
staging) echo "${SAAS_STAGING_PUBLISHABLE_KEY:?set it in app/.env.saas.local}" ;;
*) echo "${SAAS_DEV_PUBLISHABLE_KEY:?set it in app/.env.saas.local}" ;;
esac
cmds:
- 'echo ">> frontend Supabase target: $VITE_SUPABASE_URL"'
- npx vite editor --mode saas --port {{.PORT}}{{if .OPEN}} --open{{end}}
dev:
desc: "Start frontend dev server"
cmds:
@@ -111,13 +155,23 @@ tasks:
vars: { MODE: proprietary, PORT: '{{.PORT}}', BACKEND_URL: '{{.BACKEND_URL}}', OPEN: '{{.OPEN}}' }
dev:saas:
desc: "Start frontend dev server in SaaS mode"
desc: "Start frontend dev server in SaaS mode (SAAS_ENV=dev|staging|prod)"
deps:
- task: prepare
vars: { MODE: saas }
vars:
SAAS_ENV: '{{.SAAS_ENV | default "dev"}}'
# prod routes to the plain runner, which sets no VITE_SUPABASE_* and so leaves
# the committed editor/.env alone.
RUNNER: '{{if eq .SAAS_ENV "prod"}}dev:_run{{else}}dev:_run:saas{{end}}'
cmds:
- task: dev:_run
vars: { MODE: saas, PORT: '{{.PORT}}', BACKEND_URL: '{{.BACKEND_URL}}', OPEN: '{{.OPEN}}' }
- task: '{{.RUNNER}}'
vars:
MODE: saas
PORT: '{{.PORT}}'
BACKEND_URL: '{{.BACKEND_URL}}'
OPEN: '{{.OPEN}}'
SAAS_ENV: '{{.SAAS_ENV}}'
dev:desktop:
desc: "Start frontend dev server in desktop mode"
+18 -3
View File
@@ -99,11 +99,22 @@ tasks:
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
OPEN: "true"
# Set SAAS_DEV_PROJECT_REF in app/.env.saas.local to pick the PR.
dev:saas:
desc: "Start SaaS backend + frontend concurrently on free ports"
desc: "Start SaaS backend + frontend + engine against the current PR's preview branch"
cmds:
- task: dev:_all
vars: { FRONTEND: saas, BACKEND: saas }
vars: { FRONTEND: saas, BACKEND: saas, SAAS_ENV: dev }
staging:saas:
desc: "Start SaaS backend + frontend + engine against the shared v3 staging project"
cmds:
- task: dev:_all
vars:
FRONTEND: saas
BACKEND: saas
BACKEND_TASK: backend:staging:saas
SAAS_ENV: staging
dev:all:
desc: "Start backend + frontend + engine concurrently on free ports"
@@ -115,6 +126,9 @@ tasks:
vars:
FRONTEND: '{{.FRONTEND | default "proprietary"}}'
BACKEND: '{{.BACKEND | default "proprietary"}}'
BACKEND_TASK: '{{.BACKEND_TASK | default (printf "backend:dev:%s" .BACKEND)}}'
# Only meaningful to the saas frontend; every other flavor ignores it.
SAAS_ENV: '{{.SAAS_ENV | default ""}}'
PORTS:
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173 5001{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173 5001{{end}}'
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
@@ -124,7 +138,7 @@ tasks:
- task: engine:dev
vars:
PORT: '{{.ENGINE_PORT}}'
- task: 'backend:dev:{{.BACKEND}}'
- task: '{{.BACKEND_TASK}}'
vars:
PORT: '{{.BACKEND_PORT}}'
AIENGINE_URL: 'http://localhost:{{.ENGINE_PORT}}'
@@ -134,6 +148,7 @@ tasks:
PORT: '{{.FRONTEND_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
OPEN: "true"
SAAS_ENV: '{{.SAAS_ENV}}'
# ============================================================
# Build
+35 -17
View File
@@ -1,15 +1,16 @@
###############################################################################
# Stirling-PDF SaaS environment defaults.
# Stirling-PDF SaaS environment defaults. Committed, non-secret. Real values for secrets go in
# .env.saas.local, which is loaded first and wins. Do not commit that file.
#
# This file is committed and provides non-secret defaults loaded by
# `task backend:dev:saas`. Put real values for secrets (passwords, project
# refs, edge function secrets) in `.env.saas.local` - any variable set there
# takes precedence over what's defined here.
# Three environments, each deriving its Supabase URLs, JWT issuer and JWKS from one project ref:
#
# DO NOT commit `.env.saas.local`. Only `.env.saas` is checked in.
###############################################################################
# prod PROFILES=none SAAS_DB_* the live project
# staging PROFILES=staging SAAS_STAGING_* pinned to v3, always there
# dev PROFILES=dev SAAS_DEV_* follows a SaaS PR's preview branch
#
# dev is the default for `task backend:dev:saas`. Use staging for somewhere stable; use dev when
# testing an open SaaS PR, since its preview branch is the only place those migrations are applied.
# ---------- Supabase project ----------
# ---------- Supabase project (prod / no-profile) ----------
# Project reference (the subdomain part of <ref>.supabase.co). Required.
# Set in .env.saas.local.
SAAS_DB_PROJECT_REF=
@@ -17,18 +18,35 @@ SAAS_DB_PROJECT_REF=
# Edge function secret used by billing/license rollup calls. Set in .env.saas.local.
SUPABASE_EDGE_FUNCTION_SECRET=
# ---------- Database (saas profile) ----------
# Direct JDBC URL to the Supabase Postgres. Required when running the plain
# `saas` profile (i.e. without `--spring.profiles.include=dev`).
# ---------- Database (no profile) ----------
# Direct JDBC URL to the Supabase Postgres. Required when running without
# `--spring.profiles.include=...`.
# Example: jdbc:postgresql://db.<project-ref>.supabase.co:5432/postgres
SAAS_DB_URL=
SAAS_DB_USERNAME=postgres
SAAS_DB_PASSWORD=
# ---------- Database (dev profile overrides) ----------
# Used when `--spring.profiles.include=dev` is active. The dev profile
# defaults the URL/username to the shared dev Supabase project, but the
# password must still be provided in .env.saas.local.
SAAS_DEV_DB_URL=
# ---------- staging profile ----------
# The shared long-lived v3 project. application-staging.properties defaults the ref,
# URL, database host and meter endpoint, so staging needs only the password, in
# .env.saas.local. Set SAAS_STAGING_PROJECT_REF to repoint it; everything derives.
#
# The ref and publishable key are duplicated here because the task derives the
# frontend's VITE_SUPABASE_* from them and a shell cannot read a Spring default.
# Neither is secret: the ref is a public subdomain, the key ships in the bundle.
SAAS_STAGING_PROJECT_REF=qacaivhsjtftfwtgjvva
SAAS_STAGING_PUBLISHABLE_KEY=sb_publishable_nIM8y-9ARPE7EzQwAQHKMg_40fCN6kY # gitleaks:allow
SAAS_STAGING_DB_USERNAME=postgres
SAAS_STAGING_DB_PASSWORD=
# ---------- dev profile ----------
# The SaaS PR's Supabase preview branch. Take the ref from that PR's "Supabase
# Preview" check; the profile derives URL, JWT issuer, JWKS, meter endpoint and
# database host from it, so this one value follows a different PR.
#
# A preview branch has its own password and keys; the parent project's will not
# authenticate. Both go in .env.saas.local, along with the ref.
SAAS_DEV_PROJECT_REF=
SAAS_DEV_PUBLISHABLE_KEY=
SAAS_DEV_DB_USERNAME=postgres
SAAS_DEV_DB_PASSWORD=
@@ -26,6 +26,11 @@ public class AdminPolicyManagementAuthority implements PolicyManagementAuthority
return userService.isCurrentUserAdmin();
}
@Override
public boolean canTriggerPolicies() {
return userService.isCurrentUserAdmin();
}
@Override
public Long currentUserTeamId() {
String username = userService.getCurrentUsername();
@@ -12,6 +12,17 @@ public interface PolicyManagementAuthority {
/** Whether the current user may create, edit, or delete policies (for their own team). */
boolean canEditPolicies();
/**
* Whether the current user may run a policy against its <em>configured sources</em> (the manual
* "run now" sweep). Kept separate from {@link #canEditPolicies()} because the two are distinct
* capabilities, even where a deployment grants both to the same people: a sweep operates on the
* team's configured sources using the server's stored connection credentials, which makes it a
* policy-management capability rather than ordinary use. Running a policy over the caller's
* <em>own</em> uploaded files is not covered by this and stays open to every team member — that
* is ordinary editor enforcement.
*/
boolean canTriggerPolicies();
/**
* The team that scopes the current user's policies — the team a new policy is stamped with and
* the only team whose policies the user may see/run/edit. {@code null} when it can't be
@@ -432,9 +432,10 @@ public class PolicyController {
* admin gets no say on SaaS. Team scoping (which team's policies) is enforced separately by
* {@link PolicyAccessGuard}. Every mutation routes through {@link #savePolicy} (pause/resume
* re-save with a flipped {@code enabled} flag) or {@link #deletePolicy}, so gating those two
* covers them all; runs ({@code /run}) stay open to the team. Single-user deployments (login
* disabled) have no such role, so they trust the local operator. The path allowlist for folder
* sources/outputs is enforced separately by {@link PolicyValidator} at validation time.
* covers them all; runs over the caller's own files ({@code /{id}/run}) stay open to the team,
* while source sweeps are gated by {@link #requirePolicySweepAllowed}. Single-user deployments
* (login disabled) have no such role, so they trust the local operator. The path allowlist for
* folder sources/outputs is enforced separately by {@link PolicyValidator} at validation time.
*/
private void requirePolicyEditingAllowed() {
if (!applicationProperties.getSecurity().isEnableLogin()) {
@@ -447,6 +448,25 @@ public class PolicyController {
}
}
/**
* Sweeping a policy's configured sources requires the same role as managing policies: the sweep
* operates on the team's configured sources using the server's stored connection credentials,
* which makes it a policy-management capability rather than ordinary use, and team scoping on
* its own does not express that. Deliberately narrower than it looks: it gates only the sweep,
* not {@link #runStoredPolicy}, because running a policy over documents the caller supplied is
* ordinary editor enforcement that every member performs on upload and export.
*/
private void requirePolicySweepAllowed() {
if (!applicationProperties.getSecurity().isEnableLogin()) {
return;
}
if (!policyManagementAuthority.canTriggerPolicies()) {
throw new ResponseStatusException(
HttpStatus.FORBIDDEN,
"Not permitted to run this policy against its configured sources");
}
}
@GetMapping
@Operation(
summary = "List policies",
@@ -571,8 +591,10 @@ public class PolicyController {
+ " the enabled flag (which only gates automatic triggering). Returns"
+ " the ids of the runs started (poll the run-status endpoint for each)"
+ " plus what the sweep skipped - already-processed, parked-by-failure,"
+ " and in-flight counts - so an empty result explains itself.")
+ " and in-flight counts - so an empty result explains itself. Requires"
+ " the policy-management role.")
public ResponseEntity<SweepOutcome> trigger(@PathVariable String policyId) {
requirePolicySweepAllowed();
Policy policy =
policyStore
.get(policyId)
@@ -39,6 +39,18 @@ class AdminPolicyManagementAuthorityTest {
assertFalse(authority().canEditPolicies());
}
@Test
void adminMayTriggerPolicies() {
when(userService.isCurrentUserAdmin()).thenReturn(true);
assertTrue(authority().canTriggerPolicies());
}
@Test
void nonAdminMayNotTriggerPolicies() {
when(userService.isCurrentUserAdmin()).thenReturn(false);
assertFalse(authority().canTriggerPolicies());
}
@Test
void currentUserTeamIdResolvesFromTheCurrentUsersTeam() {
Team team = new Team();
@@ -2,6 +2,7 @@ package stirling.software.proprietary.policy.controller;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -738,5 +739,78 @@ class PolicyControllerTest {
assertThat(((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.NOT_FOUND));
}
@Test
@DisplayName("trigger is forbidden for a team member who cannot manage policies")
void triggerForbiddenForMember() {
// Sweeping a policy's configured sources is a policy-management capability, so being
// in the policy's team is not on its own enough to perform it.
applicationProperties.getSecurity().setEnableLogin(true);
when(policyManagementAuthority.canTriggerPolicies()).thenReturn(false);
assertThatThrownBy(() -> controller.trigger("a"))
.isInstanceOf(ResponseStatusException.class)
.satisfies(
e ->
assertThat(((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.FORBIDDEN));
// Rejected before the policy is looked up, so no run starts.
verify(policyRunner, never()).run(any());
verify(policyStore, never()).get(any());
}
@Test
@DisplayName("trigger runs for a caller who may manage policies")
void triggerAllowedForLeader() {
applicationProperties.getSecurity().setEnableLogin(true);
when(policyManagementAuthority.canTriggerPolicies()).thenReturn(true);
Policy p = policy("a", 1L);
when(policyStore.get("a")).thenReturn(Optional.of(p));
when(policyAccessGuard.canAccess(p)).thenReturn(true);
SweepOutcome outcome = new SweepOutcome(List.of("run-a"), 1, 0, 0, 0);
when(policyRunner.run(p)).thenReturn(outcome);
ResponseEntity<SweepOutcome> response = controller.trigger("a");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
assertThat(response.getBody()).isEqualTo(outcome);
}
@Test
@DisplayName("trigger skips the role check when login is disabled")
void triggerTrustsTheLocalOperator() {
// Single-user deployments have no roles at all; the gate must not lock them out of
// their
// own sweeps.
applicationProperties.getSecurity().setEnableLogin(false);
Policy p = policy("a", null);
when(policyStore.get("a")).thenReturn(Optional.of(p));
when(policyAccessGuard.canAccess(p)).thenReturn(true);
SweepOutcome outcome = new SweepOutcome(List.of("run-a"), 1, 0, 0, 0);
when(policyRunner.run(p)).thenReturn(outcome);
assertThat(controller.trigger("a").getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
verify(policyManagementAuthority, never()).canTriggerPolicies();
}
@Test
@DisplayName("running a policy over the caller's own files stays open to any member")
void storedRunIsNotGatedByRole() {
// Editor enforcement: every member's upload/export runs the team's stored policies on
// their own documents. Gating this the way the sweep is gated would break the editor.
applicationProperties.getSecurity().setEnableLogin(true);
Policy p = policy("a", 1L);
when(policyStore.get("a")).thenReturn(Optional.of(p));
when(policyAccessGuard.canAccess(p)).thenReturn(true);
when(policyRunner.runWith(eq(p), any(), eq(PolicyProgressListener.NOOP)))
.thenReturn(handle("run-9"));
ResponseEntity<JobResponse<Void>> response =
assertDoesNotThrow(() -> controller.runStoredPolicy("a", new PolicyRunFiles()));
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
verify(policyManagementAuthority, never()).canTriggerPolicies();
verify(policyManagementAuthority, never()).canEditPolicies();
}
}
}
@@ -0,0 +1,53 @@
package stirling.software.saas.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.annotation.Profile;
import org.springframework.context.event.EventListener;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component;
import lombok.extern.slf4j.Slf4j;
/** Logs which Supabase project this backend is talking to, and its schema policy. */
@Slf4j
@Component
@Profile({"dev", "staging"})
public class SaasProjectNotice {
private final Environment environment;
private final String projectRef;
private final String ddlAuto;
public SaasProjectNotice(
Environment environment,
@Value("${app.supabase.project-ref:unknown}") String projectRef,
@Value("${spring.jpa.hibernate.ddl-auto:none}") String ddlAuto) {
this.environment = environment;
this.projectRef = projectRef;
this.ddlAuto = ddlAuto;
}
@EventListener(ApplicationReadyEvent.class)
public void announceProject() {
boolean staging = environment.matchesProfiles("staging");
if (staging) {
log.info(
"""
SaaS staging profile: Supabase project {}, ddl-auto={}. This is the SHARED \
long-lived environment, so its data and schema are not yours alone. Testing an \
open SaaS PR? Use that PR's preview branch instead \
(SAAS_DEV_PROJECT_REF in app/.env.saas.local); staging will not have its \
migrations.\
""",
projectRef,
ddlAuto);
return;
}
log.info(
"SaaS dev profile: Supabase preview branch {}, ddl-auto={}. Disposable, so Hibernate"
+ " is allowed to add the inherited tables the migrations do not create.",
projectRef,
ddlAuto);
}
}
@@ -25,6 +25,11 @@ public class TeamLeaderPolicyManagementAuthority implements PolicyManagementAuth
return teamSecurity.isCurrentUserTeamLeader();
}
@Override
public boolean canTriggerPolicies() {
return teamSecurity.isCurrentUserTeamLeader();
}
@Override
public Long currentUserTeamId() {
return teamSecurity.currentUserTeamId();
@@ -1,32 +1,40 @@
# SaaS dev profile. Points at the dev Supabase project.
# Boot: java -jar stirling-pdf.jar --spring.profiles.include=dev
# SaaS dev profile: follows the Supabase preview branch of the SaaS PR under test.
# One variable switches PR, SAAS_DEV_PROJECT_REF; everything else derives from it.
# Want a stable shared environment instead? Use the staging profile.
spring.config.import=optional:classpath:application-dev-local.properties
app.supabase.project-ref=qacaivhsjtftfwtgjvva
# Let Hibernate reconcile the entity tables so a fresh preview branch heals itself. A branch is built
# from the Supabase migrations, which cover the SaaS-owned tables but not the ~28 inherited from the
# self-hosted app -- those have only ever been created by ddl-auto. Safe here because a preview branch
# is disposable and `update` only ever adds; staging pins `none`, so keep this profile-scoped.
spring.jpa.hibernate.ddl-auto=update
stirling.supabase.url=https://qacaivhsjtftfwtgjvva.supabase.co
stirling.supabase.publishable-key=sb_publishable_nIM8y-9ARPE7EzQwAQHKMg_40fCN6kY # gitleaks:allow
# From the PR's "Supabase Preview" check. Required with no fallback: ddl-auto=update above must never
# be aimed at the shared project.
app.supabase.project-ref=${SAAS_DEV_PROJECT_REF}
spring.datasource.url=${SAAS_DEV_DB_URL:jdbc:postgresql://db.qacaivhsjtftfwtgjvva.supabase.co:5432/postgres?ApplicationName=stirling-consolidation-${user.name}}
stirling.supabase.url=https://${app.supabase.project-ref}.supabase.co
# Per-branch, not derivable. Dashboard > Settings > API.
stirling.supabase.publishable-key=${SAAS_DEV_PUBLISHABLE_KEY}
# Override the whole URL if the branch needs the pooler host rather than the direct one.
spring.datasource.url=${SAAS_DEV_DB_URL:jdbc:postgresql://db.${app.supabase.project-ref}.supabase.co:5432/postgres?ApplicationName=stirling-dev-${user.name}}
spring.datasource.username=${SAAS_DEV_DB_USERNAME:postgres}
# Password not committed; export SAAS_DEV_DB_PASSWORD or pass --spring.datasource.password=...
# A preview branch has its own password; the parent project's will not authenticate.
spring.datasource.password=${SAAS_DEV_DB_PASSWORD:}
# Conservative dev pool sizing.
spring.datasource.hikari.maximum-pool-size=2
spring.datasource.hikari.minimum-idle=1
spring.datasource.hikari.idle-timeout=60000
spring.datasource.hikari.max-lifetime=1800000
spring.datasource.hikari.keepalive-time=300000
spring.datasource.hikari.data-source-properties.ApplicationName=stirling-consolidation-${user.name}
spring.datasource.hikari.data-source-properties.ApplicationName=stirling-dev-${user.name}
logging.level.stirling.software.saas=DEBUG
logging.level.org.springframework.security.oauth2.jwt=WARN
logging.level.org.springframework.security.oauth2.server.resource=WARN
# Supabase meter edge fn the Java backend calls (server-to-server, on job close).
# URL is not a secret; auth rides the existing SUPABASE_EDGE_FUNCTION_SECRET (same
# shared secret the team-invitation flow uses — no service-role key in the Java env).
# Blank secret → the meter service no-ops with a WARN, so the app still boots.
# The billing portal is NOT here — the FE calls create-customer-portal-session directly.
payg.meter.endpoint=https://qacaivhsjtftfwtgjvva.supabase.co/functions/v1/meter-payg-units
# Server-to-server meter call. Auth rides SUPABASE_EDGE_FUNCTION_SECRET; blank secret means the meter
# service no-ops with a WARN rather than failing the boot.
payg.meter.endpoint=https://${app.supabase.project-ref}.supabase.co/functions/v1/meter-payg-units
@@ -0,0 +1,39 @@
# SaaS staging profile: the long-lived shared v3 project, pinned so it is still there tomorrow.
# For work on an open SaaS PR use the dev profile, which follows that PR's preview branch.
spring.config.import=optional:classpath:application-staging-local.properties
# Stated rather than inherited: application-saas.properties defaults to `update`, and staging's
# schema is shared and RLS-dependent, so it must not be reconciled by Hibernate.
spring.jpa.hibernate.ddl-auto=none
# Committed as a default rather than a literal, so staging needs no setup but stays repointable.
# Neither the ref nor the publishable key is secret: the ref is a public subdomain, the key ships in
# the browser bundle. Everything below derives from the ref, so an override follows through.
app.supabase.project-ref=${SAAS_STAGING_PROJECT_REF:qacaivhsjtftfwtgjvva}
stirling.supabase.url=https://${app.supabase.project-ref}.supabase.co
stirling.supabase.publishable-key=${SAAS_STAGING_PUBLISHABLE_KEY:sb_publishable_nIM8y-9ARPE7EzQwAQHKMg_40fCN6kY}
spring.datasource.url=${SAAS_STAGING_DB_URL:jdbc:postgresql://db.${app.supabase.project-ref}.supabase.co:5432/postgres?ApplicationName=stirling-staging-${user.name}}
spring.datasource.username=${SAAS_STAGING_DB_USERNAME:postgres}
# Password not committed; export SAAS_STAGING_DB_PASSWORD or pass --spring.datasource.password=...
spring.datasource.password=${SAAS_STAGING_DB_PASSWORD:}
# Conservative pool sizing: this is a shared project, so don't hold connections others need.
spring.datasource.hikari.maximum-pool-size=2
spring.datasource.hikari.minimum-idle=1
spring.datasource.hikari.idle-timeout=60000
spring.datasource.hikari.max-lifetime=1800000
spring.datasource.hikari.keepalive-time=300000
spring.datasource.hikari.data-source-properties.ApplicationName=stirling-staging-${user.name}
logging.level.stirling.software.saas=DEBUG
logging.level.org.springframework.security.oauth2.jwt=WARN
logging.level.org.springframework.security.oauth2.server.resource=WARN
# Supabase meter edge fn the Java backend calls (server-to-server, on job close).
# URL is not a secret; auth rides the existing SUPABASE_EDGE_FUNCTION_SECRET (same
# shared secret the team-invitation flow uses — no service-role key in the Java env).
# Blank secret → the meter service no-ops with a WARN, so the app still boots.
payg.meter.endpoint=https://${app.supabase.project-ref}.supabase.co/functions/v1/meter-payg-units
@@ -32,6 +32,18 @@ class TeamLeaderPolicyManagementAuthorityTest {
assertFalse(authority().canEditPolicies());
}
@Test
void teamLeaderMayTriggerPolicies() {
when(teamSecurity.isCurrentUserTeamLeader()).thenReturn(true);
assertTrue(authority().canTriggerPolicies());
}
@Test
void nonLeaderMayNotTriggerPolicies() {
when(teamSecurity.isCurrentUserTeamLeader()).thenReturn(false);
assertFalse(authority().canTriggerPolicies());
}
@Test
void currentUserTeamIdDelegatesToTeamSecurity() {
when(teamSecurity.currentUserTeamId()).thenReturn(9L);
@@ -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"
@@ -4827,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"
@@ -5057,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"
@@ -5621,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]
@@ -6685,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"
@@ -7665,7 +7673,6 @@ integrations = "Integrations"
pipelines = "Pipelines"
policies = "Policies"
procurement = "Procurement"
settings = "Settings"
sources = "Sources"
usage = "Usage & Billing"
users = "Users"
@@ -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\//,
@@ -10,7 +10,7 @@
{
"moduleName": "@cantoo/pdf-lib",
"moduleUrl": "https://github.com/cantoo-scribe/pdf-lib",
"moduleVersion": "2.6.5",
"moduleVersion": "2.8.2",
"moduleLicense": "MIT",
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
@@ -255,14 +255,14 @@
{
"moduleName": "@stripe/react-stripe-js",
"moduleUrl": "https://github.com/stripe/react-stripe-js",
"moduleVersion": "4.0.2",
"moduleVersion": "6.8.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "@stripe/stripe-js",
"moduleUrl": "https://github.com/stripe/stripe-js",
"moduleVersion": "7.9.0",
"moduleVersion": "9.10.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
@@ -287,6 +287,13 @@
"moduleLicense": "MIT",
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "@tanstack/react-table",
"moduleUrl": "https://github.com/TanStack/table",
"moduleVersion": "9.1.2",
"moduleLicense": "MIT",
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "@tanstack/react-virtual",
"moduleUrl": "https://github.com/TanStack/virtual",
@@ -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 (
<InfoBanner
<AppBanner
icon="mail"
message={
<Group
@@ -120,10 +120,6 @@ export function TeamInvitationBanner() {
}
show={shouldShow}
dismissible={false}
background="var(--mantine-color-dark-7)"
borderColor="var(--mantine-color-dark-5)"
textColor="rgba(255, 255, 255, 0.95)"
iconColor="rgba(255, 255, 255, 0.95)"
/>
);
}
@@ -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 }) {
<MeterBar
state={state}
pct={pct}
barLabel={t("payg.free.hero.barAria", "Free PDFs used")}
figure={snap.billableUsed.toLocaleString()}
capSuffix={t("payg.free.hero.capSuffix", "/ {{limit}} free PDFs", {
barLabel={t("payg.free.hero.barAria", "Free PDFs remaining")}
figure={remaining.toLocaleString()}
capSuffix={t("payg.free.hero.capSuffix", "of {{limit}} free PDFs left", {
limit: snap.billableLimit.toLocaleString(),
})}
statusLabel={stateLabel}
@@ -175,15 +177,15 @@ export function prepaidSnapshotFromWallet(
}
/**
* Prepaid capacity meter. The bar fills as the pool is drawn down ({@code used =
* total remaining}), so it WARNs when the pool is running low and DEGRADEs once
* exhausted — same bands as the free/cap meters. Prepaid is consumed ahead of the
* meter and outside the spend cap, so it reads as its own dimension.
* Prepaid capacity meter. The bar shows what is left and drains towards empty,
* while the bands still key on what is gone ({@code used = total remaining}), so
* it WARNs when the pool is running low and DEGRADEs once exhausted — same bands
* as the free/cap meters. Prepaid is consumed ahead of the meter and outside the
* spend cap, so it reads as its own dimension.
*/
export function PrepaidCapacityMeterPanel({ snap }: { snap: PrepaidSnapshot }) {
const { t } = useTranslation();
const used = Math.max(0, snap.total - snap.remaining);
const { state, pct } = meterState(used, snap.total);
const { state, pct } = remainingMeter(snap.remaining, snap.total);
const stateLabel =
state === "DEGRADED"
? t("payg.prepaid.state.exhausted", "Used up")
@@ -0,0 +1,50 @@
import { useEffect, useState } from "react";
import { useWallet } from "@app/hooks/useWallet";
import {
readCachedCredits,
writeCachedCredits,
type CachedCredits,
} from "@app/services/navFooterCache";
import { type NavFooterCredits } from "@app/components/shared/navFooter/NavFooterCreditsRow";
/** The wallet reduced to what the footer shows: figures, or null for a payer. */
function toCredits(
status: string,
freeRemaining: number,
freeAllowance: number,
): CachedCredits {
// Free teams only. The grant is a lifetime pool that survives subscribing, so
// a paying team would otherwise sit on a permanent "0 of 500" in red while
// nothing is wrong. Plan draws the same line — subscribed teams get the
// spend-vs-cap meter there, and admins get usage in the processor.
if (status === "subscribed") return null;
return { remaining: freeRemaining, total: freeAllowance };
}
/**
* Cloud builds read the free grant off the live wallet — the same snapshot the
* Plan page's free meter renders, so the sidebar and Plan can't disagree.
*
* Seeded from the last figures this browser saw, so the row is right at first
* paint and stays put while the wallet refetches underneath. Only a browser
* that has never loaded a wallet has nothing to show, and that one time the row
* animates in. The seed is read once, at first render: later reads would fight
* the live value, and the whole point is that the row stops moving.
*/
export function useFreeCreditsSummary(): NavFooterCredits | null {
const { wallet } = useWallet();
const [seed] = useState(readCachedCredits);
const live = wallet
? toCredits(wallet.status, wallet.freeRemaining, wallet.freeAllowance)
: undefined;
// useWallet reuses the snapshot reference when nothing changed, so keying on
// it writes only on a real change, not on every render.
useEffect(() => {
if (live !== undefined) writeCachedCredits(live);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [wallet]);
return (live !== undefined ? live : seed) ?? null;
}
@@ -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]);
}
+86 -4
View File
@@ -32,6 +32,14 @@
* promise see the UI flip exactly once the new state is visible — no
* intermediate flash of the old value.
*
* <h2>Freshness</h2>
*
* 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.
*
* <h2>Dev preview fallback</h2>
*
* 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<Promise<void> | 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<Wallet>("/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<typeof setInterval> | 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
@@ -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(
<InfoBanner
<AppBanner
icon="info-rounded"
title="Heads up"
message="This workspace is running in offline mode."
@@ -0,0 +1,115 @@
/* App-wide top bar. One shape, four tones — callers pick a tone, never a colour.
Named `app-banner`, not `sui-banner`: that belongs to the SUI Banner primitive. */
.app-banner {
display: flex;
align-items: center;
gap: 0.75rem;
min-height: 3.5rem;
padding: 0.75rem 1rem;
/* Full-bleed across the top of the app: square corners, one rule underneath. */
border-bottom: 1px solid var(--app-banner-border);
background: var(--app-banner-bg);
color: var(--c-text);
}
.app-banner--compact {
min-height: 2.75rem;
padding: 0.5rem 1rem;
gap: 0.5rem;
}
.app-banner--info {
--app-banner-bg: var(--c-primary-subtle);
--app-banner-border: var(--c-primary-border);
--app-banner-icon: var(--c-accent-fg, var(--c-primary));
}
/* The one bar meant to pop, so it takes the feature gradient rather than a tint.
Fixed hues by design — it doesn't follow the chosen accent. */
.app-banner--promo {
--app-banner-bg: linear-gradient(
135deg,
var(--c-hue-indigo) 0%,
var(--c-hue-purple) 100%
);
--app-banner-border: transparent;
--app-banner-icon: var(--color-text-on-accent);
color: var(--color-text-on-accent);
}
.app-banner--warning {
--app-banner-bg: var(--c-warning-subtle);
--app-banner-border: color-mix(in srgb, var(--c-warning) 32%, transparent);
--app-banner-icon: var(--c-warning);
}
.app-banner--danger {
--app-banner-bg: var(--c-danger-subtle);
--app-banner-border: color-mix(in srgb, var(--c-danger) 32%, transparent);
--app-banner-icon: var(--c-danger);
}
/* Only the icon carries the tone; text stays neutral in every tone. */
.app-banner__icon {
display: flex;
flex-shrink: 0;
align-items: center;
color: var(--app-banner-icon);
}
.app-banner__body {
display: flex;
flex: 1;
min-width: 0;
flex-direction: column;
gap: 0.125rem;
}
.app-banner__title {
font-size: 0.875rem;
font-weight: 600;
}
.app-banner__message {
font-size: 0.8125rem;
line-height: 1.4;
color: var(--c-text-muted);
}
.app-banner__body:not(:has(.app-banner__title)) .app-banner__message {
color: inherit;
font-weight: 500;
}
.app-banner--compact .app-banner__title,
.app-banner--compact .app-banner__message {
font-size: 0.75rem;
}
/* On the gradient everything is white; muted grey would disappear. */
.app-banner--promo .app-banner__message,
.app-banner--promo .app-banner__actions .sui-btn--tertiary,
.app-banner--promo .app-banner__actions .sui-ai {
color: var(--color-text-on-accent);
}
/* Lifts the premium CTA off the gradient it sits on. */
.app-banner--promo .app-banner__actions .sui-btn--primary {
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
}
.app-banner__actions {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 0.5rem;
}
/* Mantine trims the leading padding when a button has a left section, which reads
as off-centre next to the label. Even it back up. */
.app-banner__actions .sui-btn .mantine-Button-inner {
padding-inline: 0;
}
.app-banner__actions .sui-btn {
padding-inline: 0.875rem;
}
@@ -0,0 +1,194 @@
import type { ReactNode } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { AppBanner } from "@app/components/shared/AppBanner";
const meta = {
title: "Shared/AppBanner",
component: AppBanner,
parameters: { layout: "fullscreen" },
} satisfies Meta<typeof AppBanner>;
export default meta;
type Story = StoryObj<typeof meta>;
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 (
<section
style={{ display: "flex", flexDirection: "column", gap: "0.375rem" }}
>
<span
style={{
fontSize: "0.6875rem",
fontWeight: 600,
letterSpacing: "0.04em",
textTransform: "uppercase",
color: "var(--c-text-subtle)",
padding: "0 1rem",
}}
>
{caption}
</span>
{children}
</section>
);
}
/**
* 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: () => (
<div
style={{
display: "flex",
flexDirection: "column",
gap: "1.5rem",
padding: "1.5rem 0",
background: "var(--c-bg)",
}}
>
<Row caption="Upgrade prompt · UpgradeBanner (friendly)">
<AppBanner
tone="promo"
compact
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={() => {}}
/>
</Row>
<Row caption="Server needs attention · UpgradeBanner (urgent)">
<AppBanner
tone="warning"
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}
/>
</Row>
<Row caption="Free tier limit reached · AdminPlanSection">
<AppBanner
tone="warning"
icon="warning-rounded"
title="Free self-hosted limit reached"
message="You have 12 users on a plan that covers 10."
buttonText="See plans"
buttonIcon="upgrade-rounded"
onButtonClick={() => {}}
dismissible={false}
/>
</Row>
<Row caption="Team invitation · TeamInvitationBanner">
<AppBanner
icon="mail"
message="You have been invited to join the Acme Legal team."
buttonText="Accept"
onButtonClick={() => {}}
secondaryButtonText="Decline"
onSecondaryButtonClick={() => {}}
dismissible={false}
/>
</Row>
<Row caption="Set as default app · DefaultAppBanner (desktop)">
<AppBanner
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={() => {}}
/>
</Row>
<Row caption="Danger tone (available, no caller yet)">
<AppBanner
tone="danger"
icon="warning-rounded"
title="Storage is full"
message="New uploads will fail until space is freed."
buttonText="Manage storage"
onButtonClick={() => {}}
dismissible={false}
/>
</Row>
</div>
),
};
@@ -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<AppBannerProps> = ({
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 (
<div
className={[
"app-banner",
`app-banner--${tone}`,
compact ? "app-banner--compact" : "",
]
.filter(Boolean)
.join(" ")}
>
{icon != null && (
<span className="app-banner__icon" aria-hidden>
{typeof icon === "string" ? (
<LocalIcon icon={icon} width={iconSize} height={iconSize} />
) : (
icon
)}
</span>
)}
<div className="app-banner__body">
{title && <span className="app-banner__title">{title}</span>}
<span className="app-banner__message">{message}</span>
</div>
<div className="app-banner__actions">
{buttonText && onButtonClick && (
<Button
variant={TONE_BUTTON[tone].variant}
accent={TONE_BUTTON[tone].accent}
size="sm"
loading={loading}
onClick={onButtonClick}
leftSection={
<LocalIcon icon={buttonIcon} width="0.9rem" height="0.9rem" />
}
>
{buttonText}
</Button>
)}
{secondaryButtonText && onSecondaryButtonClick && (
<Button
variant="tertiary"
accent="neutral"
size="sm"
onClick={onSecondaryButtonClick}
>
{secondaryButtonText}
</Button>
)}
{dismissible && (
<ActionIcon
variant="tertiary"
accent="neutral"
size="sm"
onClick={() => onDismiss?.()}
aria-label={t("appBanner.dismiss", "Dismiss")}
>
<LocalIcon icon="close-rounded" width="1rem" height="1rem" />
</ActionIcon>
)}
</div>
</div>
);
};
@@ -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;
}
}
@@ -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 <NavFooter>: 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;
}
@@ -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<HTMLDivElement, FileSidebarProps>(
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<string | null>(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<StirlingFileStub[]>([]);
@@ -1115,7 +1084,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
)}
data-testid="open-files-page"
>
<OpenInNewIcon sx={{ fontSize: "1rem" }} />
<OpenInFullIcon sx={{ fontSize: "1rem" }} />
</ActionIcon>
<ActionIcon
variant="quiet"
@@ -1264,70 +1233,17 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
{/* Getting-started checklist, floating above the footer (SaaS only). */}
<SidebarChecklistSlot collapsed={collapsed} />
{/* Box 3 — account footer (avatar + name + settings). */}
<NavSurface className="file-sidebar-footer-box">
{/* Bottom bar: user name + settings */}
<Tooltip
label={
onOpenSettings
? `${displayName} - ${t("fileSidebar.openSettings", "Open settings")}`
: displayName
}
position="right"
withinPortal
disabled={!collapsed}
>
<div
className="file-sidebar-bottom-bar"
onClick={onOpenSettings}
role={onOpenSettings ? "button" : undefined}
tabIndex={onOpenSettings ? 0 : undefined}
onKeyDown={
onOpenSettings
? (e) => 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}
>
<div
className={`file-sidebar-bottom-avatar${
showProfilePicture
? " file-sidebar-bottom-avatar--picture"
: ""
}`}
aria-label={displayName}
>
{showProfilePicture ? (
<img
src={profilePictureUrl}
alt=""
className="file-sidebar-bottom-avatar-img"
onError={() => setPictureFailed(true)}
/>
) : (
displayName.charAt(0).toUpperCase()
)}
</div>
{!collapsed && (
<span className="file-sidebar-bottom-name sidebar-content-fade">
{displayName}
</span>
)}
{onOpenSettings && !collapsed && (
<div className="file-sidebar-bottom-settings">
<SettingsIcon sx={{ fontSize: "1.1rem" }} />
</div>
)}
</div>
</Tooltip>
</NavSurface>
{/* Box 3 — the shared footer: credits, app switch, account row. */}
<NavFooter
className="file-sidebar-footer-box"
displayName={displayName}
profilePictureUrl={profilePictureUrl}
onOpenSettings={onOpenSettings}
credits={credits}
onOpenPlan={openPlan ?? undefined}
otherApp={otherApp}
collapsed={collapsed}
/>
</div>
);
},
@@ -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<typeof InfoBanner>;
export default meta;
type Story = StoryObj<typeof meta>;
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,
},
};
@@ -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<InfoBannerProps> = ({
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 (
<Paper
p={compact ? "xs" : "sm"}
radius={0}
style={{
background: background ?? toneStyle.background,
border: "none",
borderBottom:
borderColor === "transparent"
? "none"
: `1px solid ${borderColor ?? toneStyle.border}`,
minHeight,
display: "flex",
alignItems: "center",
}}
>
<Group
gap="sm"
align="center"
wrap="nowrap"
justify="space-between"
style={{ width: "100%" }}
>
<Group
gap={compact ? "xs" : "sm"}
align="center"
wrap="nowrap"
style={{ flex: 1, minWidth: 0 }}
>
{icon != null &&
(typeof icon === "string" ? (
<LocalIcon
icon={icon}
width={iconSize}
height={iconSize}
style={{ color: iconColor ?? toneStyle.icon, flexShrink: 0 }}
/>
) : (
<div
style={{ flexShrink: 0, display: "flex", alignItems: "center" }}
>
{icon}
</div>
))}
<Stack gap={compact ? 1 : 2} style={{ flex: 1, minWidth: 0 }}>
{title && (
<Text
fw={600}
size={textSize}
style={{ color: resolvedTextColor }}
>
{title}
</Text>
)}
<Text
fw={title ? 400 : 500}
size={textSize}
style={{ color: resolvedTextColor }}
lineClamp={compact ? 1 : 2}
>
{message}
</Text>
</Stack>
</Group>
<Group gap="xs" align="center" wrap="nowrap">
{buttonText && onButtonClick && (
<Button
variant={toSharedButtonVariant(buttonVariant)}
accent={toSharedButtonAccent(
buttonColor ?? toneStyle.buttonColor,
)}
size="sm"
loading={loading}
onClick={onButtonClick}
leftSection={
<LocalIcon
icon={buttonIcon}
width={compact ? "0.75rem" : "0.9rem"}
height={compact ? "0.75rem" : "0.9rem"}
/>
}
style={buttonTextColor ? { color: buttonTextColor } : undefined}
>
{buttonText}
</Button>
)}
{secondaryButtonText && onSecondaryButtonClick && (
<Button
variant="tertiary"
accent="neutral"
size="sm"
onClick={onSecondaryButtonClick}
style={{ color: "var(--c-text-muted)" }}
>
{secondaryButtonText}
</Button>
)}
{dismissible && (
<ActionIcon
variant="tertiary"
accent="neutral"
size="sm"
onClick={handleDismiss}
aria-label={t("infoBanner.dismiss", "Dismiss")}
style={{
color: closeIconColor ?? "var(--c-text-muted)",
}}
>
<LocalIcon
icon="close-rounded"
width={compact ? "0.85rem" : "1rem"}
height={compact ? "0.85rem" : "1rem"}
/>
</ActionIcon>
)}
</Group>
</Group>
</Paper>
);
};
@@ -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;
}
@@ -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<typeof NavFooter> = {
title: "Shared/NavFooter",
component: NavFooter,
parameters: { layout: "padded" },
args: {
displayName: "admin",
onOpenSettings: () => {},
credits: { remaining: 247, total: 500 },
onOpenPlan: () => {},
otherApp: { app: "processor", onOpen: () => {} },
},
decorators: [
(S) => (
<div
style={{
width: "16.25rem",
background: "var(--c-bg)",
padding: "0.5rem",
}}
>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof NavFooter>;
/** 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: (
<NavItem
id="account-link"
label="Link Stirling account"
icon={<LinkIcon sx={{ fontSize: "1.1rem" }} />}
/>
),
},
};
/** 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: <RendersNothing /> },
};
/** A real profile picture replaces the initials disc. */
export const WithProfilePicture: Story = {
args: {
displayName: "Ada Lovelace",
profilePictureUrl:
"data:image/svg+xml;utf8," +
encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="black"/><circle cx="32" cy="24" r="12" fill="white"/><ellipse cx="32" cy="56" rx="20" ry="16" fill="white"/></svg>',
),
},
};
/** 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) => (
<div
style={{
width: "3.5rem",
background: "var(--c-bg)",
padding: "0.5rem",
}}
>
<S />
</div>
),
],
};
@@ -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 <MantineProvider>{ui}</MantineProvider>;
}
function renderFooter() {
const { container } = render(
withProviders(
<NavFooter
displayName="admin"
onOpenSettings={() => {}}
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(
<NavFooter
displayName="admin"
onOpenSettings={() => {}}
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);
});
});
@@ -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 <the other app>"
* 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: (
<NavFooterCreditsRow
credits={credits}
collapsed={collapsed}
label={t("navFooter.credits.label", "Free credits")}
onOpen={onOpenPlan}
/>
),
});
}
if (otherApp) {
rows.push({
key: "switch",
node: (
<Tooltip
label={openAppLabel(otherApp.app, t)}
position="right"
withinPortal
disabled={!collapsed}
>
<button
type="button"
className="nav-footer__row"
onClick={() => otherApp.onOpen()}
// Collapsed drops the visible label, so name the button here too.
aria-label={openAppLabel(otherApp.app, t)}
>
<span className="nav-footer__row-icon" aria-hidden>
<BrandMark height="1.125rem" />
</span>
{!collapsed && (
<>
<span className="nav-footer__row-label">
{openAppLabel(otherApp.app, t)}
</span>
{/* "Takes you there", not "opens a new tab" — both apps are
one SPA, so this navigates in place. */}
<span className="nav-footer__trailing" aria-hidden>
<ArrowForwardIcon sx={{ fontSize: "1rem" }} />
</span>
</>
)}
</button>
</Tooltip>
),
});
}
rows.push({
key: "account",
node: (
<Tooltip
label={accountLabel}
position="right"
withinPortal
disabled={!collapsed}
>
<button
type="button"
className="nav-footer__row nav-footer__account"
// Called with no args: handlers that take optional params (the
// processor's openSettings(section?)) must not receive the event.
onClick={onOpenSettings ? () => onOpenSettings() : undefined}
disabled={!onOpenSettings}
data-testid={onOpenSettings ? "config-button" : undefined}
data-tour={onOpenSettings ? "config-button" : undefined}
aria-label={accountLabel}
>
{/* Decorative: the button's own label already names the account, so
an alt/label here would just repeat it to a screen reader. */}
<span aria-hidden>
<Avatar
size="sm"
name={displayName}
src={profilePictureUrl ?? undefined}
/>
</span>
{!collapsed && (
<span className="nav-footer__row-label sidebar-content-fade">
{displayName}
</span>
)}
{onOpenSettings && !collapsed && (
<span className="nav-footer__trailing" aria-hidden>
<SettingsIcon sx={{ fontSize: "1.1rem" }} />
</span>
)}
</button>
</Tooltip>
),
});
return (
<NavSurface
className={["nav-footer", className ?? ""].filter(Boolean).join(" ")}
data-collapsed={collapsed || undefined}
data-animate={animate || undefined}
>
{rows.map((row) => (
<div key={row.key} className="nav-footer__slot">
{row.node}
</div>
))}
</NavSurface>
);
}
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");
}
@@ -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;
}
@@ -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 (
<Tooltip
label={`${label}: ${count}`}
position="right"
withinPortal
disabled={!collapsed}
>
<Row onOpen={onOpen} label={`${label}: ${count}`}>
{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.
<CreditsRing
fraction={total > 0 ? remaining / total : 0}
tone={tone}
label={`${label}: ${count}`}
/>
) : (
<>
<div className="nav-footer__credits-head">
<span className="nav-footer__dot" data-tone={tone} aria-hidden />
<span className="nav-footer__credits-label">{label}</span>
<span className="nav-footer__credits-count">{count}</span>
</div>
<ProgressBar
value={total > 0 ? remaining / total : 0}
height={6}
color={`var(--c-${tone})`}
label={`${label}: ${count}`}
/>
</>
)}
</Row>
</Tooltip>
);
}
/** 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 (
<svg
className="nav-footer__credits-ring"
viewBox="0 0 20 20"
role="img"
aria-label={label}
>
<circle
className="nav-footer__credits-ring-track"
cx="10"
cy="10"
r={RADIUS}
/>
<circle
className="nav-footer__credits-ring-fill"
cx="10"
cy="10"
r={RADIUS}
stroke={`var(--c-${tone})`}
strokeDasharray={`${filled} ${circumference - filled}`}
/>
</svg>
);
}
/**
* 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 <div className={className}>{children}</div>;
return (
<button
type="button"
className={className}
onClick={() => onOpen()}
aria-label={label}
>
{children}
</button>
);
}
@@ -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 (
<Stack gap="md">
{/* Delete All */}
<Stack gap="md">
<Text size="sm" fw={500}>
{t("changeMetadata.deleteAll.label", "Delete All Metadata")}
</Text>
<DeleteAllStep
parameters={parameters}
<>
{hasViewerContext && (
<MetadataPrefill
onParameterChange={onParameterChange}
disabled={disabled}
onExtractingChange={setIsExtractingMetadata}
/>
</Stack>
<Divider />
{/* Standard Metadata Fields */}
)}
<Stack gap="md">
<Text size="sm" fw={500}>
{t("changeMetadata.standardFields.title", "Standard Metadata")}
</Text>
<StandardMetadataStep
parameters={parameters}
onParameterChange={onParameterChange}
disabled={fieldsDisabled}
/>
{/* Delete All */}
<Stack gap="md">
<Text size="sm" fw={500}>
{t("changeMetadata.deleteAll.label", "Delete All Metadata")}
</Text>
<DeleteAllStep
parameters={parameters}
onParameterChange={onParameterChange}
disabled={disabled}
/>
</Stack>
<Divider />
{/* Standard Metadata Fields */}
<Stack gap="md">
<Text size="sm" fw={500}>
{t("changeMetadata.standardFields.title", "Standard Metadata")}
</Text>
<StandardMetadataStep
parameters={parameters}
onParameterChange={onParameterChange}
disabled={fieldsDisabled}
/>
</Stack>
<Divider />
{/* Document Dates */}
<Stack gap="md">
<Text size="sm" fw={500}>
{t("changeMetadata.dates.title", "Document Dates")}
</Text>
<DocumentDatesStep
parameters={parameters}
onParameterChange={onParameterChange}
disabled={fieldsDisabled}
/>
</Stack>
<Divider />
{/* Advanced Options */}
<Stack gap="md">
<Text size="sm" fw={500}>
{t("changeMetadata.advanced.title", "Advanced Options")}
</Text>
<AdvancedOptionsStep
parameters={parameters}
onParameterChange={onParameterChange}
disabled={fieldsDisabled}
addCustomMetadata={addCustomMetadata}
removeCustomMetadata={removeCustomMetadata}
updateCustomMetadata={updateCustomMetadata}
/>
</Stack>
</Stack>
<Divider />
{/* Document Dates */}
<Stack gap="md">
<Text size="sm" fw={500}>
{t("changeMetadata.dates.title", "Document Dates")}
</Text>
<DocumentDatesStep
parameters={parameters}
onParameterChange={onParameterChange}
disabled={fieldsDisabled}
/>
</Stack>
<Divider />
{/* Advanced Options */}
<Stack gap="md">
<Text size="sm" fw={500}>
{t("changeMetadata.advanced.title", "Advanced Options")}
</Text>
<AdvancedOptionsStep
parameters={parameters}
onParameterChange={onParameterChange}
disabled={fieldsDisabled}
addCustomMetadata={addCustomMetadata}
removeCustomMetadata={removeCustomMetadata}
updateCustomMetadata={updateCustomMetadata}
/>
</Stack>
</Stack>
</>
);
};
@@ -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 (
<Stack gap="md">
<Stack gap="xs">
@@ -183,17 +201,30 @@ export default function OverlayPdfsSettings({
<Text size="sm" fw={500}>
{t("overlay-pdfs.overlayFiles.label", "Overlay Files")}
</Text>
<Button
size="sm"
onClick={handleOpenOverlayFilesModal}
disabled={disabled}
leftSection={<LocalIcon icon="add" width="14" height="14" />}
fullWidth
>
{parameters.overlayFiles?.length > 0
? t("overlay-pdfs.overlayFiles.addMore", "Add more PDFs...")
: t("overlay-pdfs.overlayFiles.placeholder", "Choose PDF(s)...")}
</Button>
{filesModal ? (
<Button
size="sm"
onClick={handleOpenOverlayFilesModal}
disabled={disabled}
leftSection={<LocalIcon icon="add" width="14" height="14" />}
fullWidth
>
{overlayFilesButtonLabel}
</Button>
) : (
<FilePicker
multiple
accept="application/pdf"
onChange={appendOverlayFiles}
resetRef={resetOverlayPicker}
size="sm"
disabled={disabled}
leftSection={<LocalIcon icon="add" width="14" height="14" />}
fullWidth
>
{overlayFilesButtonLabel}
</FilePicker>
)}
{parameters.overlayFiles?.length > 0 &&
(() => {
@@ -41,7 +41,9 @@ interface FilesModalContextType {
setOnModalClose: (callback: () => void) => void;
}
const FilesModalContext = createContext<FilesModalContextType | null>(null);
export const FilesModalContext = createContext<FilesModalContextType | null>(
null,
);
export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({
children,
@@ -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();
});
});
@@ -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: (
@@ -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<string | null>(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,
};
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
+3
View File
@@ -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;
@@ -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));
}
@@ -71,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
+6
View File
@@ -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%;
+21 -6
View File
@@ -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 ? (
<img src={src} alt={ariaLabel ?? name} className="sui-avatar__img" />
const content = showImage ? (
<img
src={src}
alt={ariaLabel ?? name}
className="sui-avatar__img"
onError={() => setSrcFailed(true)}
/>
) : (
<span className="sui-avatar__initials" aria-hidden>
{initialsOf(name)}
{avatarInitials(name)}
</span>
);
@@ -1,6 +1,6 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { InfoBanner } from "@app/components/shared/InfoBanner";
import { AppBanner } from "@app/components/shared/AppBanner";
import { useDefaultApp } from "@app/hooks/useDefaultApp";
export const DefaultAppBanner: React.FC = () => {
@@ -15,7 +15,7 @@ export const DefaultAppBanner: React.FC = () => {
const [sessionDismissed, setSessionDismissed] = useState(false);
return (
<InfoBanner
<AppBanner
icon="picture-as-pdf-rounded"
message={t(
"defaultApp.prompt.message",
@@ -0,0 +1,7 @@
/**
* SaaS has no link concept — the signed-in account IS the SaaS account, and the
* editor's cloud wallet hook is already in this build's {@code @app/*} cascade.
* Delegating to it means the processor footer and the editor footer share one
* wallet fetch and can't disagree, so there is nothing portal-specific to do.
*/
export { useFreeCreditsSummary } from "@app/hooks/useFreeCreditsSummary";
@@ -0,0 +1,11 @@
import { useCallback } from "react";
import { useUI } from "@portal/contexts/UIContext";
/**
* SaaS processor: the settings modal it hosts carries the same Plan section the
* editor opens, so the footer's credits row lands both apps in one place.
*/
export function useOpenPlan(): (() => void) | null {
const { openSettings } = useUI();
return useCallback(() => openSettings("plan"), [openSettings]);
}
@@ -125,8 +125,6 @@
}
.portal-sidebar[data-collapsed] .portal-sidebar__footer {
margin-inline: 0.375rem;
padding-inline: 0;
align-items: center;
}
.portal-sidebar__logo {
@@ -183,10 +181,8 @@
gap: 0.125rem;
}
/* The shared <NavFooter> brings its own boxes, padding and gap; the sidebar
only positions it. */
.portal-sidebar__footer {
margin: 0 0.625rem 0.75rem;
padding: 0.5rem 0.375rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
@@ -2,6 +2,10 @@ import { useMediaQuery } from "@mantine/hooks";
import { Tooltip } from "@mantine/core";
import { ActionIcon, NavItem, NavSurface } from "@app/ui";
import { BrandSwitcher } from "@app/components/shared/BrandSwitcher";
import { NavFooter } from "@app/components/shared/navFooter/NavFooter";
import { useAccountIdentity } from "@app/hooks/useAccountIdentity";
import { useFreeCreditsSummary } from "@portal/hooks/useFreeCreditsSummary";
import { useOpenPlan } from "@portal/hooks/useOpenPlan";
import { SidebarToggleIcon } from "@app/components/shared/SidebarToggleIcon";
import { useTranslation } from "react-i18next";
import { useAppSwitch } from "@app/components/shared/AppSwitchProvider";
@@ -10,7 +14,7 @@ import { useUI } from "@portal/contexts/UIContext";
import { LinkAccountFooterItem } from "@portal/components/LinkAccountFooterItem";
import { EDITOR_URL, EDITOR_IS_SAME_APP } from "@portal/auth/editorUrl";
import { EDITOR_BASENAME } from "@app/routes/editorBasename";
import { CloseIcon, SettingsIcon } from "@portal/components/icons";
import { CloseIcon } from "@portal/components/icons";
import {
GROUP_PROCESSOR,
GROUP_PLATFORM,
@@ -41,6 +45,9 @@ export function Sidebar() {
const isMobile = useMediaQuery(MOBILE_QUERY, false, {
getInitialValueInEffect: false,
});
const { displayName, profilePictureUrl } = useAccountIdentity();
const credits = useFreeCreditsSummary();
const openPlan = useOpenPlan();
// Collapse is a desktop-only affordance: on mobile the sidebar is an
// off-canvas drawer, so the icon-rail state never applies there.
@@ -146,15 +153,17 @@ export function Sidebar() {
))}
</nav>
<NavSurface className="portal-sidebar__footer">
<LinkAccountFooterItem />
<NavItem
id="settings"
label={t("portal.nav.settings")}
icon={<SettingsIcon />}
onClick={() => openSettings()}
/>
</NavSurface>
<NavFooter
className="portal-sidebar__footer"
displayName={displayName}
profilePictureUrl={profilePictureUrl}
onOpenSettings={openSettings}
credits={credits}
onOpenPlan={openPlan ?? undefined}
otherApp={{ app: "editor", onOpen: goToEditor }}
accountExtras={<LinkAccountFooterItem />}
collapsed={collapsed}
/>
</aside>
);
}
@@ -1,6 +1,6 @@
import { useTranslation } from "react-i18next";
import { Button, Card } from "@app/ui";
import { formatPeriodDate, MeterBar, meterState } from "@app/billing";
import { formatPeriodDate, MeterBar, remainingMeter } from "@app/billing";
import type { Wallet } from "@portal/api/billing";
/**
@@ -10,8 +10,8 @@ import type { Wallet } from "@portal/api/billing";
* - No bundle → a slim "Get 12 months for the price of 10" offer nudge with a
* "Review offer" CTA (the demo's commit-nudge card), shown only when a buyer
* ({@code onBuy}, leader) is present.
* - Bundle held → the capacity meter (fills as the pool is drawn down, so it
* warns as capacity runs low) plus a "Top up" action for the leader.
* - Bundle held → the capacity meter (drains towards empty as the pool is drawn
* down, so it warns as capacity runs low) plus a "Top up" action for the leader.
*
* Prepaid is consumed before metered billing and sits outside the spend limit, so
* it reads as its own dimension. Buying/topping up opens {@code BundleCheckoutModal}
@@ -55,8 +55,7 @@ export function PrepaidCapacityCard({
const remaining = wallet.prepaidUnitsRemaining;
const total = wallet.prepaidUnitsTotal;
const used = Math.max(0, total - remaining);
const { state, pct } = meterState(used, total);
const { state, pct } = remainingMeter(remaining, total);
const stateLabel =
state === "DEGRADED"
? t("portal.billing.prepaid.state.exhausted", "Used up")
@@ -1,7 +1,7 @@
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Card } from "@app/ui";
import { formatMinor, MeterBar, meterState } from "@app/billing";
import { formatMinor, MeterBar, remainingMeter } from "@app/billing";
import type { Wallet } from "@portal/api/billing";
import type { LocalUsage } from "@portal/api/link";
@@ -15,8 +15,10 @@ interface Props {
}
/**
* The free Processor-trial meter — "X / N free PDFs used" against the one-time
* grant. Uses the shared {@link MeterBar} (same `paygf-meter` structure as the
* The free Processor-trial meter — "X of N free PDFs left" against the one-time
* grant, with what has been used alongside as the status badge. The bar shows what
* is left, so it drains towards empty as the grant is spent.
* Uses the shared {@link MeterBar} (same `paygf-meter` structure as the
* cloud plan page). The subscribed spend-vs-cap meter is a separate surface
* ({@code SpendLimitCard}); this card is only the free face.
*
@@ -30,7 +32,7 @@ export function WalletMeter({ wallet, unsynced, action }: Props) {
const pending = unsynced?.totalUnsyncedUnits ?? 0;
const used = wallet.billableUsed + pending;
const remaining = Math.max(0, wallet.freeRemaining - pending);
const { state, pct } = meterState(used, wallet.freeAllowance);
const { state, pct } = remainingMeter(remaining, wallet.freeAllowance);
const rate =
wallet.pricePerDocMinor != null && wallet.pricePerDocMinor > 0
? wallet.pricePerDocMinor
@@ -76,11 +78,14 @@ export function WalletMeter({ wallet, unsynced, action }: Props) {
<MeterBar
state={state}
pct={pct}
barLabel={t("portal.billing.walletMeter.barAria", "Free PDFs used")}
figure={used.toLocaleString()}
barLabel={t(
"portal.billing.walletMeter.barAria",
"Free PDFs remaining",
)}
figure={remaining.toLocaleString()}
capSuffix={t(
"portal.billing.walletMeter.capSuffix",
"of {{allowance}} free PDFs used",
"of {{allowance}} free PDFs left",
{
count: wallet.freeAllowance,
allowance: wallet.freeAllowance.toLocaleString(),
@@ -88,11 +93,8 @@ export function WalletMeter({ wallet, unsynced, action }: Props) {
)}
statusLabel={t(
"portal.billing.walletMeter.statusLabel",
"{{remaining}} left",
{
count: remaining,
remaining: remaining.toLocaleString(),
},
"{{used}} used",
{ count: used, used: used.toLocaleString() },
)}
/>
</div>
@@ -1,10 +1,23 @@
import { describe, expect, it, vi } from "vitest";
import { useEffect, useState } from "react";
import { render, screen } from "@testing-library/react";
import {
Component,
Suspense,
useEffect,
useState,
type ComponentType,
type ReactNode,
} from "react";
import { render, renderHook, screen, waitFor } from "@testing-library/react";
import { PortalTestProviders } from "@portal/test/TestQueryProvider";
import { useTranslatedToolCatalog } from "@app/data/useTranslatedToolRegistry";
import { PreferencesProvider } from "@app/contexts/PreferencesContext";
import { SidebarProvider } from "@app/contexts/SidebarContext";
import { Tooltip } from "@app/components/shared/Tooltip";
import type { ToolRegistry } from "@app/data/toolsTaxonomy";
import type { WorkingToolStep } from "@app/hooks/tools/shared/toolAutomation";
import {
getExecutableTools,
type WorkingToolStep,
} from "@app/hooks/tools/shared/toolAutomation";
import {
asRegistryConfig,
type ErasedToolParams,
@@ -13,6 +26,10 @@ import {
import ConvertSettings from "@app/components/tools/convert/ConvertSettings";
import { convertOperationConfig } from "@app/hooks/tools/convert/useConvertOperation";
import { defaultParameters as convertDefaults } from "@app/hooks/tools/convert/useConvertParameters";
import ChangeMetadataSingleStep from "@app/components/tools/changeMetadata/ChangeMetadataSingleStep";
import { defaultParameters as changeMetadataDefaults } from "@app/hooks/tools/changeMetadata/useChangeMetadataParameters";
import OverlayPdfsSettings from "@app/components/tools/overlayPdfs/OverlayPdfsSettings";
import { defaultParameters as overlayDefaults } from "@app/hooks/tools/overlayPdfs/useOverlayPdfsParameters";
import { PipelineStepSettings } from "@portal/components/pipelines/PipelineStepSettings";
// Override only useTranslation; keep the rest of react-i18next (initReactI18next et al.) real, so
@@ -21,6 +38,7 @@ vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({
t: (key: string, fallback?: string) => fallback ?? key,
i18n: { language: "en-US", changeLanguage: vi.fn() },
}),
}));
@@ -63,6 +81,32 @@ const convertRegistry = {
},
} as unknown as Partial<ToolRegistry>;
// The real Change Metadata automation settings. Its editor variant auto-prefills the
// form from the open document via useViewer; that path is now gated on a ViewerProvider
// so it renders here (the portal mounts none) instead of crashing on useViewer.
const changeMetadataStep = {
support: "editable",
toolId: "changeMetadata",
params: changeMetadataDefaults,
} as unknown as WorkingToolStep;
const changeMetadataRegistry = {
changeMetadata: { automationSettings: ChangeMetadataSingleStep },
} as unknown as Partial<ToolRegistry>;
// The real Overlay PDFs automation settings. Its overlay-file picker uses the
// editor FilesModal when present; that read is now optional so the portal (which
// mounts no FilesModalProvider) renders a plain file input instead of crashing.
const overlayStep = {
support: "editable",
toolId: "overlayPdfs",
params: overlayDefaults,
} as unknown as WorkingToolStep;
const overlayRegistry = {
overlayPdfs: { automationSettings: OverlayPdfsSettings },
} as unknown as Partial<ToolRegistry>;
describe("PipelineStepSettings", () => {
it("renders reused editor tool settings (which use the shared Tooltip) without app-wide Preferences/Sidebar providers", () => {
expect(() =>
@@ -94,6 +138,36 @@ describe("PipelineStepSettings", () => {
expect(screen.getByText(/Convert from/)).toBeInTheDocument();
});
it("renders the Change Metadata tool's fields in the portal, with no ViewerProvider mounted", () => {
expect(() =>
render(
<PortalTestProviders>
<PipelineStepSettings
step={changeMetadataStep}
registry={changeMetadataRegistry}
onChange={() => {}}
/>
</PortalTestProviders>,
),
).not.toThrow();
expect(screen.getByText("Standard Metadata")).toBeInTheDocument();
});
it("renders the Overlay PDFs tool's fields in the portal, with no FilesModalProvider mounted", () => {
expect(() =>
render(
<PortalTestProviders>
<PipelineStepSettings
step={overlayStep}
registry={overlayRegistry}
onChange={() => {}}
/>
</PortalTestProviders>,
),
).not.toThrow();
expect(screen.getByText("Overlay Mode")).toBeInTheDocument();
});
// Reproduces the convert-in-pipeline bug: picking a source format fires several onParameterChange
// calls in one tick (set fromExtension, auto-target, reset options). If each rebuilt from the
// step snapshot captured at render they'd clobber each other and the earlier field would be lost.
@@ -148,3 +222,91 @@ describe("PipelineStepSettings", () => {
});
});
});
// Records a render crash and swallows it (renders nothing), so one broken tool is attributed by id
// instead of aborting the whole sweep - mirroring the portal's own ErrorBoundary around the builder.
class CaptureBoundary extends Component<
{ onError: (error: Error) => void; children: ReactNode },
{ failed: boolean }
> {
state = { failed: false };
static getDerivedStateFromError() {
return { failed: true };
}
componentDidCatch(error: Error) {
this.props.onError(error);
}
render() {
return this.state.failed ? null : this.props.children;
}
}
// Automated version of the manual "add every tool" sweep: render each tool's real automation
// settings in a portal-only context (the same Preferences + Sidebar + Suspense wrappers
// PipelineStepSettings uses, and NO editor providers) and fail listing any that throw. This is the
// guard that would have caught Change Metadata (useViewer) and Overlay PDFs (useFilesModalContext).
describe("PipelineStepSettings: every tool's settings render in the portal", () => {
it("renders each tool's automation settings without throwing", async () => {
const { result } = renderHook(() => useTranslatedToolCatalog());
const catalog = result.current.allTools;
// getExecutableTools is exactly what PipelineBuilder feeds its "Add a tool" picker, so this
// sweeps precisely the tools a user can add. Narrow to "editable" (renders a settings
// component); "noSettings"/"unsupported" steps show a Banner instead and can't crash.
const editableTools = getExecutableTools(catalog)
.filter((tool) => tool.support === "editable")
.map((tool) => [tool.toolId, catalog[tool.toolId]] as const)
.filter(([, entry]) => Boolean(entry?.automationSettings));
// Guard against the filter silently matching nothing (e.g. a registry-shape change).
expect(editableTools.length).toBeGreaterThan(10);
const failures: { toolId: string; message: string }[] = [];
for (const [toolId, entry] of editableTools) {
const Settings = entry.automationSettings as ComponentType<
ToolAutomationSettingsProps<ErasedToolParams>
>;
const params = (entry.operationConfig?.defaultParameters ??
{}) as ErasedToolParams;
const caught: { error: Error | null } = { error: null };
// The sentinel sibling commits only once the lazy Settings actually renders, so we wait for a
// real render (or a caught throw) - not just the providers' wrapper DOM.
const { unmount } = render(
<PortalTestProviders>
<PreferencesProvider>
<SidebarProvider>
<CaptureBoundary
onError={(error) => {
caught.error = error;
}}
>
<Suspense fallback={null}>
<Settings
parameters={params}
onParameterChange={() => {}}
disabled={false}
/>
<span data-testid={`rendered-${toolId}`} />
</Suspense>
</CaptureBoundary>
</SidebarProvider>
</PreferencesProvider>
</PortalTestProviders>,
);
await waitFor(() =>
expect(
caught.error !== null ||
screen.queryByTestId(`rendered-${toolId}`) !== null,
).toBe(true),
);
if (caught.error) {
failures.push({ toolId, message: caught.error.message });
}
unmount();
}
expect(failures).toEqual([]);
}, 30000);
});
@@ -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 (
<span data-testid="credits">
{credits ? `${credits.remaining}/${credits.total}` : "none"}
</span>
);
}
function renderFor(initialState: LinkState) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={client}>
<LinkProvider initialState={initialState}>
<Probe />
</LinkProvider>
</QueryClientProvider>,
).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"));
});
});
@@ -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;
}
@@ -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]);
}
@@ -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,
@@ -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)",
@@ -293,6 +293,29 @@ export function computeBundleQuote(
export type MeterState = "FULL" | "WARNED" | "DEGRADED";
/**
* Meter for a balance that is spent DOWN — a free grant, a prepaid pool. The
* bar shows what is LEFT, so full reads as "plenty" and empty as "none", which
* is how the sidebar footer's credits row reads and the only direction that
* matches a figure quoting the remainder.
*
* The state bands still key on consumption, so the tone is unchanged: amber
* once 80% is gone, red once it's exhausted. Meters for money SPENT against a
* cap keep using {@link meterState} directly — there a full bar correctly means
* "at your ceiling".
*/
export function remainingMeter(
remaining: number,
total: number,
): { state: MeterState; pct: number } {
const { state } = meterState(Math.max(0, total - remaining), total);
const pct =
total > 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,
@@ -14,6 +14,7 @@ export {
docCapForMoney,
formatPeriodDate,
meterState,
remainingMeter,
PREPAID_MONTHS_GRANTED,
PREPAID_MONTHS_PAID,
PDFS_PER_USER_MONTH,
@@ -1,14 +1,21 @@
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 { useAppSwitch } from "@app/components/shared/AppSwitchProvider";
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 { switchToApp } = useAppSwitch();
const otherApp = useOtherAppSwitch();
if (!portalAccess) {
if (!otherApp) {
return (
<Logo
variant={collapsed ? "iconOnly" : "iconAndText"}
@@ -21,7 +28,7 @@ export function AppSwitcher({ collapsed }: AppSwitcherProps) {
return (
<BrandSwitcher
current="editor"
onSwitch={() => switchToApp("processor")}
onSwitch={otherApp.onOpen}
collapsed={collapsed}
/>
);
@@ -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 (
<InfoBanner
<AppBanner
icon="warning-rounded"
tone="warning"
title={t(
@@ -312,13 +312,6 @@ const UpgradeBanner: React.FC = () => {
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 && (
<InfoBanner
<AppBanner
icon="stars-rounded"
title={t("upgradeBanner.title", "Upgrade to Server Plan")}
message={t(
@@ -353,14 +346,7 @@ const UpgradeBanner: React.FC = () => {
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
/>
)}
@@ -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 = () => {
<LoginRequiredBanner show={!loginEnabled} />
{shouldShowLicenseWarning && (
<InfoBanner
<AppBanner
icon="warning-rounded"
tone="warning"
title={t(
@@ -215,13 +215,6 @@ const AdminPlanSection: React.FC = () => {
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"
/>
)}
@@ -0,0 +1,14 @@
import { useAuth } from "@app/auth/context";
import { useAppSwitch } from "@app/components/shared/AppSwitchProvider";
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 { switchToApp } = useAppSwitch();
if (!portalAccess) return null;
return { app: "processor", onOpen: () => switchToApp("processor") };
}
@@ -1,40 +0,0 @@
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 { useAppSwitch } from "@app/components/shared/AppSwitchProvider";
/**
* 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 { switchToApp } = useAppSwitch();
if (!portalAccess) {
return (
<Logo
variant={collapsed ? "iconOnly" : "iconAndText"}
iconHeight="1.6rem"
textHeight="1.3rem"
/>
);
}
return (
<BrandSwitcher
current="editor"
onSwitch={() => switchToApp("processor")}
collapsed={collapsed}
/>
);
}
@@ -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<OverviewProps> = ({ 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<OverviewProps> = ({ 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<OverviewProps> = ({ onLogoutClick }) => {
<Group align="center" gap="md">
<Avatar
src={profilePictureUrl || undefined}
radius="xl"
size={72}
color="blue"
>
{profileInitial}
</Avatar>
name={displayName}
size="xl"
/>
<div
style={{
display: "flex",
@@ -450,12 +449,9 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
<Group align="center" gap="md">
<Avatar
src={profilePictureUrl || undefined}
radius="xl"
size={72}
color="blue"
>
{profileInitial}
</Avatar>
name={displayName}
size="xl"
/>
<div
style={{
display: "flex",
@@ -0,0 +1,15 @@
import { usePortalAccess } from "@app/hooks/usePortalAccess";
import { useAppSwitch } from "@app/components/shared/AppSwitchProvider";
import { type NavFooterAppLink } from "@app/components/shared/navFooter/NavFooter";
/**
* SaaS: the editor's Supabase context never fetches /me, so processor access
* comes from the backend via {@link usePortalAccess} — the same signal the
* processor's own gate uses.
*/
export function useOtherAppSwitch(): NavFooterAppLink | null {
const portalAccess = usePortalAccess();
const { switchToApp } = useAppSwitch();
if (!portalAccess) return null;
return { app: "processor", onOpen: () => switchToApp("processor") };
}
@@ -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<T>(cb: () => T) {
return baseRenderHook(cb, {
wrapper: ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
),
});
}
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 = () => {};
@@ -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<boolean> {
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;
}
@@ -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();
});
});
+26 -12
View File
@@ -10,7 +10,7 @@
"license": "SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE",
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
"@cantoo/pdf-lib": "^2.5.3",
"@cantoo/pdf-lib": "^2.8.2",
"@dnd-kit/core": "^6.3.1",
"@embedpdf/core": "^2.14.4",
"@embedpdf/engines": "^2.14.4",
@@ -606,18 +606,22 @@
}
},
"node_modules/@cantoo/pdf-lib": {
"version": "2.6.5",
"resolved": "https://registry.npmjs.org/@cantoo/pdf-lib/-/pdf-lib-2.6.5.tgz",
"integrity": "sha512-3eMHEaqKHt/G/q+6QjT06A3lz0S/a8x3+myiSN7FNeL3uWcedO0lpfs6TWofa4C03Z1wz3tWeHoa4CsI7DrTSA==",
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/@cantoo/pdf-lib/-/pdf-lib-2.8.2.tgz",
"integrity": "sha512-f0BJM3uPOjbPR3YriSEUIaTM0qnqthjFmTZX9NGI0NDM2Tj4a8xv7Z5Hb6jzUrhfb3/9Y77+xxoOln0IIiYq+w==",
"license": "MIT",
"dependencies": {
"@pdf-lib/standard-fonts": "^1.0.0",
"@pdf-lib/upng": "^1.0.1",
"color": "^4.2.3",
"crypto-js": "^4.2.0",
"node-html-better-parser": ">=1.4.0",
"pako": "^1.0.11",
"html-entities": "^2.3.2",
"node-html-better-parser": ">=1.5.9",
"pako": "^2.2.0",
"tslib": ">=2"
},
"peerDependencies": {
"html-entities": "^2.3.2"
}
},
"node_modules/@csstools/color-helpers": {
@@ -12459,9 +12463,9 @@
}
},
"node_modules/node-html-better-parser": {
"version": "1.5.8",
"resolved": "https://registry.npmjs.org/node-html-better-parser/-/node-html-better-parser-1.5.8.tgz",
"integrity": "sha512-t/wAKvaTSKco43X+yf9+76RiMt18MtMmzd4wc7rKj+fWav6DV4ajDEKdWlLzSE8USDF5zr/06uGj0Wr/dGAFtw==",
"version": "1.5.9",
"resolved": "https://registry.npmjs.org/node-html-better-parser/-/node-html-better-parser-1.5.9.tgz",
"integrity": "sha512-z1I5UINMezJXYL9cH3h0a9KBth2G978gSLlfkpQ+CQzzVHVQy9gpARgm9eDsz1O4gn1HtgUqjdAIYxKFZm6uHQ==",
"license": "MIT",
"dependencies": {
"html-entities": "^2.3.2"
@@ -12751,9 +12755,19 @@
"license": "MIT"
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz",
"integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "(MIT AND Zlib)"
},
"node_modules/parent-module": {
+2 -1
View File
@@ -7,7 +7,7 @@
"proxy": "http://localhost:8080",
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
"@cantoo/pdf-lib": "^2.5.3",
"@cantoo/pdf-lib": "^2.8.2",
"@dnd-kit/core": "^6.3.1",
"@embedpdf/core": "^2.14.4",
"@embedpdf/engines": "^2.14.4",
@@ -171,6 +171,7 @@
},
"overrides": {
"devalue": "^5.8.1",
"pako": "^2.2.0",
"tsconfck": {
"typescript": "$typescript"
}