mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Merge branch 'main' into animation-test
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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=
|
||||
|
||||
+5
@@ -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();
|
||||
|
||||
+11
@@ -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
|
||||
|
||||
+26
-4
@@ -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)
|
||||
|
||||
+12
@@ -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();
|
||||
|
||||
+74
@@ -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);
|
||||
}
|
||||
}
|
||||
+5
@@ -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
|
||||
+12
@@ -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]);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+94
-59
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
)}
|
||||
|
||||
+2
-9
@@ -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();
|
||||
});
|
||||
});
|
||||
Generated
+26
-12
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user