Merge branch 'custom_task_20260812' of https://github.com/Stirling-Tools/Stirling-PDF into custom_task_20260812

This commit is contained in:
Ludy87
2026-08-18 21:53:34 +02:00
128 changed files with 6447 additions and 3598 deletions
-1
View File
@@ -68,7 +68,6 @@ project: &project
frontend: &frontend
- *ci
- frontend/**
- .github/workflows/testdriver.yml
- testing/**
- docker/**
- scripts/translations/*.py
@@ -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: ./.github/actions/setup-task
-4
View File
@@ -7,10 +7,6 @@ on:
permissions:
contents: read
env:
SERVER_IP: ${{ secrets.NEW_VPS_IP }} # Add this to your GitHub secrets
CLEANUP_PERFORMED: "false" # Add flag to track if cleanup occurred
jobs:
cleanup:
environment: pr-preview
-221
View File
@@ -1,221 +0,0 @@
name: AI - PR Title Review
on:
pull_request:
types: [opened, edited]
branches: [main]
permissions: # required for secure-repo hardening
contents: read
jobs:
ai-title-review:
# GITHUB_TOKEN obeys this block, so it must cover every API call made below.
permissions:
contents: read # actions/checkout, git fetch/diff
issues: write # issues.listComments / createComment / updateComment on the PR
pull-requests: write # same endpoints when the target is a pull request
models: read # actions/ai-inference
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Configure Git to suppress detached HEAD warning
run: git config --global advice.detachedHead false
- name: Check if actor is repo developer
id: actor
run: |
if [[ "${{ github.actor }}" == *"[bot]" ]]; then
echo "PR opened by a bot skipping AI title review."
echo "is_repo_dev=false" >> $GITHUB_OUTPUT
exit 0
fi
if [ ! -f .github/config/repo_devs.json ]; then
echo "Error: .github/config/repo_devs.json not found" >&2
exit 1
fi
# Validate JSON and extract repo_devs
REPO_DEVS=$(jq -r '.repo_devs[]' .github/config/repo_devs.json 2>/dev/null || { echo "Error: Invalid JSON in repo_devs.json" >&2; exit 1; })
# Convert developer list into Bash array
mapfile -t DEVS_ARRAY <<< "$REPO_DEVS"
if [[ " ${DEVS_ARRAY[*]} " == *" ${{ github.actor }} "* ]]; then
echo "is_repo_dev=true" >> $GITHUB_OUTPUT
else
echo "is_repo_dev=false" >> $GITHUB_OUTPUT
fi
- name: Get PR diff
if: steps.actor.outputs.is_repo_dev == 'true'
id: get_diff
run: |
git fetch origin ${{ github.base_ref }}
git diff origin/${{ github.base_ref }}...HEAD | head -n 10000 | grep -vP '[\x00-\x08\x0B\x0C\x0E-\x1F\x7F\x{202E}\x{200B}]' > pr.diff
echo "diff<<EOF" >> $GITHUB_OUTPUT
cat pr.diff >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Check and sanitize PR title
if: steps.actor.outputs.is_repo_dev == 'true'
id: sanitize_pr_title
env:
PR_TITLE_RAW: ${{ github.event.pull_request.title }}
run: |
# Sanitize PR title: max 72 characters, only printable characters
PR_TITLE=$(echo "$PR_TITLE_RAW" | tr -d '\n\r' | head -c 72 | sed 's/[^[:print:]]//g')
if [[ ${#PR_TITLE} -lt 5 ]]; then
echo "PR title is too short. Must be at least 5 characters." >&2
fi
echo "pr_title=$PR_TITLE" >> $GITHUB_OUTPUT
- name: AI PR Title Analysis
if: steps.actor.outputs.is_repo_dev == 'true'
id: ai-title-analysis
uses: actions/ai-inference@a7805884c80886efc241e94a5351df715968a0ad # v2.1.1
with:
model: openai/gpt-4o
system-prompt-file: ".github/config/system-prompt.txt"
prompt: |
Based on the following input data:
{
"diff": "${{ steps.get_diff.outputs.diff }}",
"pr_title": "${{ steps.sanitize_pr_title.outputs.pr_title }}"
}
Respond ONLY with valid JSON in the format:
{
"improved_rating": <0-10>,
"improved_ai_title_rating": <0-10>,
"improved_title": "<ai generated title>"
}
- name: Validate and set SCRIPT_OUTPUT
if: steps.actor.outputs.is_repo_dev == 'true'
run: |
cat <<EOF > ai_response.json
${{ steps.ai-title-analysis.outputs.response }}
EOF
# Validate JSON structure
jq -e '
(keys | sort) == ["improved_ai_title_rating", "improved_rating", "improved_title"] and
(.improved_rating | type == "number" and . >= 0 and . <= 10) and
(.improved_ai_title_rating | type == "number" and . >= 0 and . <= 10) and
(.improved_title | type == "string")
' ai_response.json
if [ $? -ne 0 ]; then
echo "Invalid AI response format" >&2
cat ai_response.json >&2
exit 1
fi
# Parse JSON fields
IMPROVED_RATING=$(jq -r '.improved_rating' ai_response.json)
IMPROVED_TITLE=$(jq -r '.improved_title' ai_response.json)
# Limit comment length to 1000 characters
COMMENT=$(cat <<EOF
## 🤖 AI PR Title Suggestion
**PR-Title Rating**: $IMPROVED_RATING/10
### ⬇️ Suggested Title (copy & paste):
\`\`\`
$IMPROVED_TITLE
\`\`\`
---
*Generated by GitHub Models AI*
EOF
)
echo "$COMMENT" > /tmp/ai-title-comment.md
# Log input and output to the GitHub Step Summary
echo "### 🤖 AI PR Title Analysis" >> $GITHUB_STEP_SUMMARY
echo "### Input PR Title" >> $GITHUB_STEP_SUMMARY
echo '```bash' >> $GITHUB_STEP_SUMMARY
echo "${{ steps.sanitize_pr_title.outputs.pr_title }}" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo '### AI Response (raw JSON)' >> $GITHUB_STEP_SUMMARY
echo '```json' >> $GITHUB_STEP_SUMMARY
cat ai_response.json >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
- name: Post comment on PR if needed
if: steps.actor.outputs.is_repo_dev == 'true'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
continue-on-error: true
with:
github-token: ${{ github.token }}
script: |
const fs = require('fs');
const body = fs.readFileSync('/tmp/ai-title-comment.md', 'utf8');
const { GITHUB_REPOSITORY } = process.env;
const [owner, repo] = GITHUB_REPOSITORY.split('/');
const issue_number = context.issue.number;
const ratingMatch = body.match(/\*\*PR-Title Rating\*\*: (\d+)\/10/);
const rating = ratingMatch ? parseInt(ratingMatch[1], 10) : null;
const expectedActor = "github-actions[bot]";
const comments = await github.rest.issues.listComments({ owner, repo, issue_number });
const existing = comments.data.find(c =>
c.user?.login === expectedActor &&
c.body.includes("## 🤖 AI PR Title Suggestion")
);
if (rating === null) {
console.log("No rating found in AI response skipping.");
return;
}
if (rating <= 5) {
if (existing) {
await github.rest.issues.updateComment({
owner, repo,
comment_id: existing.id,
body
});
console.log("Updated existing suggestion comment.");
} else {
await github.rest.issues.createComment({
owner, repo, issue_number,
body
});
console.log("Created new suggestion comment.");
}
} else {
const praise = `## 🤖 AI PR Title Suggestion\n\nGreat job! The current PR title is clear and well-structured.\n\n✅ No suggestions needed.\n\n---\n*Generated by GitHub Models AI*`;
if (existing) {
await github.rest.issues.updateComment({
owner, repo,
comment_id: existing.id,
body: praise
});
console.log("Replaced suggestion with praise.");
} else {
console.log("Rating > 5 and no existing comment skipping comment.");
}
}
- name: is not repo dev
if: steps.actor.outputs.is_repo_dev != 'true'
run: |
exit 0 # Skip the AI title review for non-repo developers
- name: Clean up
if: always()
run: |
rm -f pr.diff ai_response.json /tmp/ai-title-comment.md
echo "Cleaned up temporary files."
continue-on-error: true # Ensure cleanup runs even if previous steps fail
+10 -12
View File
@@ -20,6 +20,7 @@ permissions:
jobs:
build:
environment: ci-unsigned
runs-on: ubuntu-latest
strategy:
fail-fast: false
@@ -34,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-${{ 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: ./.github/actions/setup-task
- name: Check Java formatting (Spotless)
@@ -155,7 +153,7 @@ jobs:
STIRLING_FLAVOR: ${{ matrix.flavor }}
# Configure the Gradle daemon explicitly; GRADLE_OPTS alone only
# configures the Gradle client JVM.
GRADLE_OPTS: '-Dorg.gradle.jvmargs=-Xmx4g -XX:+UseG1GC'
GRADLE_OPTS: "-Dorg.gradle.jvmargs=-Xmx4g -XX:+UseG1GC"
- name: Check Test Reports Exist
if: always()
+26 -9
View File
@@ -15,6 +15,11 @@ name: Enterprise E2E (Playwright)
on:
workflow_call:
inputs:
use_shared_cache:
required: false
type: boolean
default: false
push:
branches: ["main"]
schedule:
@@ -37,6 +42,7 @@ jobs:
uses: ./.github/workflows/_runner-pick.yml
playwright-e2e-enterprise:
environment: ci-unsigned
needs: pick
# Skip on fork PRs / untrusted authors: they have no PREMIUM_KEY_ENTERPRISE,
# so the suite can't boot premium and would fail. See the header comment.
@@ -55,21 +61,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:
@@ -309,6 +325,7 @@ jobs:
# Multi-node regression: builds + seeds the clustered stack (testing/compose/docker-compose-multinode.yml)
# and runs behave features/multinode. Licence-gated, so it runs after the Playwright job (not in parallel).
multinode-e2e:
environment: ci-unsigned
needs: [pick, playwright-e2e-enterprise]
# Nightly cron + manual dispatch only (heavy build), fork-gated for the licence secret.
if: >-
+46 -14
View File
@@ -61,6 +61,7 @@ jobs:
filters: .github/config/.files.yaml
gradle-cache-prime:
environment: ci-unsigned
name: Prime shared Gradle cache
needs: [files-changed]
runs-on: ubuntu-latest
@@ -72,29 +73,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:
@@ -169,6 +189,8 @@ jobs:
contents: read
uses: ./.github/workflows/build-enterprise.yml
secrets: inherit
with:
use_shared_cache: true
check-licence:
if: needs.files-changed.outputs.build == 'true'
@@ -192,7 +214,14 @@ jobs:
test-build-docker-images:
if: github.event_name == 'pull_request' && needs.files-changed.outputs.project == 'true'
needs: [files-changed, build, check-generateOpenApiDocs, check-licence, gradle-cache-prime]
needs:
[
files-changed,
build,
check-generateOpenApiDocs,
check-licence,
gradle-cache-prime,
]
permissions:
contents: read
packages: read
@@ -204,7 +233,7 @@ jobs:
tauri-build:
if: needs.files-changed.outputs.tauri == 'true'
needs: [files-changed]
needs: [files-changed, gradle-cache-prime]
permissions:
contents: read
pull-requests: write
@@ -218,6 +247,7 @@ jobs:
with:
platform: windows-macos
sign: true
use_shared_cache: true
ai-engine:
if: needs.files-changed.outputs.engine == 'true'
@@ -241,6 +271,8 @@ jobs:
pull-requests: write
uses: ./.github/workflows/check-generated-models.yml
secrets: inherit
with:
use_shared_cache: true
pre-commit:
needs: [files-changed]
+21 -9
View File
@@ -9,6 +9,11 @@ name: Check generated models
# post-merge safety net.
on:
workflow_call:
inputs:
use_shared_cache:
required: false
type: boolean
default: false
push:
branches: [main]
@@ -39,22 +44,29 @@ jobs:
engine/uv.lock
cache-suffix: generated-models
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
- name: Restore cache Gradle User Home
if: inputs.use_shared_cache
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
java-version: "25"
distribution: "temurin"
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Cache Gradle User Home
- name: Restore cache Gradle
if: inputs.use_shared_cache == false
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
key: gradle-generated-models-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Set up Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+9 -11
View File
@@ -10,6 +10,7 @@ permissions:
jobs:
check-licence:
environment: ci-unsigned
runs-on: ubuntu-latest
steps:
- name: Harden Runner
@@ -20,23 +21,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: ./.github/actions/setup-task
- name: Check licenses for compatibility
+9 -11
View File
@@ -11,6 +11,7 @@ permissions:
jobs:
check-generate-openapi-docs:
environment: ci-unsigned
runs-on: ubuntu-latest
steps:
- name: Harden Runner
@@ -21,23 +22,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: ./.github/actions/setup-task
- name: Generate OpenAPI documentation
+8 -11
View File
@@ -40,23 +40,20 @@ jobs:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Restore cache Gradle User Home
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
+9 -11
View File
@@ -13,6 +13,7 @@ permissions:
jobs:
migration-test:
environment: ci-unsigned
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
@@ -24,23 +25,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
-209
View File
@@ -1,209 +0,0 @@
name: Auto V2 Deploy on Push
on:
push:
branches:
- V2
- deploy-on-v2-commit
permissions:
contents: read
jobs:
deploy-v2-on-push:
environment: pr-preview
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
concurrency:
group: deploy-v2-push-V2
cancel-in-progress: true
steps:
- name: Harden Runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Get commit hashes for frontend and backend
id: commit-hashes
run: |
# Get last commit that touched the frontend folder, docker/frontend, or docker/compose
FRONTEND_HASH=$(git log -1 --format="%H" -- frontend/ docker/frontend/ docker/compose/ 2>/dev/null || echo "")
if [ -z "$FRONTEND_HASH" ]; then
FRONTEND_HASH="no-frontend-changes"
fi
# Get last commit that touched backend code, docker/backend, or docker/compose
BACKEND_HASH=$(git log -1 --format="%H" -- app/ docker/backend/ docker/compose/ 2>/dev/null || echo "")
if [ -z "$BACKEND_HASH" ]; then
BACKEND_HASH="no-backend-changes"
fi
echo "Frontend hash: $FRONTEND_HASH"
echo "Backend hash: $BACKEND_HASH"
echo "frontend_hash=$FRONTEND_HASH" >> $GITHUB_OUTPUT
echo "backend_hash=$BACKEND_HASH" >> $GITHUB_OUTPUT
# Short hashes for tags
if [ "$FRONTEND_HASH" = "no-frontend-changes" ]; then
echo "frontend_short=no-frontend" >> $GITHUB_OUTPUT
else
echo "frontend_short=${FRONTEND_HASH:0:8}" >> $GITHUB_OUTPUT
fi
if [ "$BACKEND_HASH" = "no-backend-changes" ]; then
echo "backend_short=no-backend" >> $GITHUB_OUTPUT
else
echo "backend_short=${BACKEND_HASH:0:8}" >> $GITHUB_OUTPUT
fi
- name: Convert repository owner to lowercase
id: repoowner
run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT
- name: Login to GitHub Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Check if frontend image exists
id: check-frontend
run: |
if docker manifest inspect ${IMAGE_BASE}:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }} >/dev/null 2>&1; then
echo "exists=true" >> $GITHUB_OUTPUT
echo "Frontend image already exists, skipping build"
else
echo "exists=false" >> $GITHUB_OUTPUT
echo "Frontend image needs to be built"
fi
env:
IMAGE_BASE: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test
- name: Check if backend image exists
id: check-backend
run: |
if docker manifest inspect ${IMAGE_BASE}:v2-backend-${{ steps.commit-hashes.outputs.backend_short }} >/dev/null 2>&1; then
echo "exists=true" >> $GITHUB_OUTPUT
echo "Backend image already exists, skipping build"
else
echo "exists=false" >> $GITHUB_OUTPUT
echo "Backend image needs to be built"
fi
env:
IMAGE_BASE: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test
- name: Build and push frontend image
if: steps.check-frontend.outputs.exists == 'false'
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: ./docker/frontend/Dockerfile
push: true
cache-from: type=gha,scope=stirling-v2-frontend
cache-to: type=gha,mode=max,scope=stirling-v2-frontend
tags: |
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }}
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:v2-frontend-latest
build-args: VERSION_TAG=v2-alpha
platforms: linux/amd64
- name: Build and push backend image
if: steps.check-backend.outputs.exists == 'false'
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: ./docker/backend/Dockerfile
push: true
cache-from: type=gha,scope=stirling-v2-backend
cache-to: type=gha,mode=max,scope=stirling-v2-backend
tags: |
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }}
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:v2-backend-latest
build-args: VERSION_TAG=v2-alpha
platforms: linux/amd64
- name: Set up SSH
run: |
mkdir -p ~/.ssh/
echo "${NEW_VPS_SSH_KEY}" > ../private.key
chmod 600 ../private.key
env:
NEW_VPS_SSH_KEY: ${{ secrets.NEW_VPS_SSH_KEY }}
- name: Deploy to VPS on port 3000
run: |
export UNIQUE_NAME=docker-compose-v2-$GITHUB_RUN_ID.yml
cat > $UNIQUE_NAME << EOF
version: '3.3'
services:
backend:
container_name: stirling-v2-backend
image: ${IMAGE_BASE}:v2-backend-${{ steps.commit-hashes.outputs.backend_short }}
ports:
- "13000:8080"
volumes:
- /stirling/V2/data:/usr/share/tessdata:rw
- /stirling/V2/config:/configs:rw
- /stirling/V2/logs:/logs:rw
environment:
DISABLE_ADDITIONAL_FEATURES: "true"
SECURITY_ENABLELOGIN: "false"
SYSTEM_DEFAULTLOCALE: en-US
UI_APPNAME: "Stirling-PDF V2"
UI_HOMEDESCRIPTION: "V2 Frontend/Backend Split"
UI_APPNAMENAVBAR: "V2 Deployment"
SYSTEM_MAXFILESIZE: "100"
METRICS_ENABLED: "true"
SYSTEM_GOOGLEVISIBILITY: "false"
SWAGGER_SERVER_URL: "https://demo.stirlingpdf.cloud"
baseUrl: "https://demo.stirlingpdf.cloud"
restart: on-failure:5
frontend:
container_name: stirling-v2-frontend
image: ${IMAGE_BASE}:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }}
ports:
- "3000:80"
environment:
VITE_API_BASE_URL: "http://${NEW_VPS_HOST}:13000"
depends_on:
- backend
restart: on-failure:5
EOF
# Copy to remote with unique name
scp -i ../private.key -o StrictHostKeyChecking=no $UNIQUE_NAME ${NEW_VPS_USERNAME}@${NEW_VPS_HOST}:/tmp/$UNIQUE_NAME
# SSH and rename/move atomically to avoid interference
ssh -i ../private.key -o StrictHostKeyChecking=no ${NEW_VPS_USERNAME}@${NEW_VPS_HOST} << ENDSSH
mkdir -p /stirling/V2/{data,config,logs}
mv /tmp/$UNIQUE_NAME /stirling/V2/docker-compose.yml
cd /stirling/V2
docker-compose down || true
docker-compose pull
docker-compose up -d
docker system prune -af --volumes || true
docker image prune -af --filter "until=336h" --filter "label!=keep=true" || true
ENDSSH
env:
IMAGE_BASE: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test
NEW_VPS_HOST: ${{ secrets.NEW_VPS_HOST }}
NEW_VPS_USERNAME: ${{ secrets.NEW_VPS_USERNAME }}
- name: Cleanup temporary files
if: always()
run: |
rm -f ../private.key
+9 -11
View File
@@ -17,6 +17,7 @@ permissions:
jobs:
docker-compose-tests:
environment: ci-unsigned
runs-on: ubuntu-latest
permissions:
actions: write
@@ -32,23 +33,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 -28
View File
@@ -11,6 +11,7 @@ permissions:
jobs:
playwright-e2e-live:
environment: ci-unsigned
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
@@ -20,39 +21,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:
@@ -42,6 +42,8 @@ jobs:
filters: .github/config/.files.yaml
generate-frontend-license-report:
# ci-bot, not bot-identity: this job runs on PRs too, and bot-identity is main-only.
environment: ci-bot
if: needs.files-changed.outputs.licenses-frontend == 'true'
name: Generate Frontend License Report
needs: files-changed
@@ -316,6 +318,8 @@ jobs:
GH_TOKEN: ${{ steps.setup-bot.outputs.token }}
generate-backend-license-report:
# ci-bot, not bot-identity: this job runs on PRs too, and bot-identity is main-only.
environment: ci-bot
if: needs.files-changed.outputs.licenses-backend == 'true'
needs: files-changed
name: Generate Backend License Report
@@ -344,22 +348,19 @@ jobs:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle User Home
- name: Cache Gradle
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
key: gradle-license-report-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Install Task
uses: ./.github/actions/setup-task
+40 -80
View File
@@ -38,6 +38,7 @@ permissions:
jobs:
determine-matrix:
environment: ci-unsigned
if: ${{ vars.CI_PROFILE != 'lite' }}
runs-on: ubuntu-latest
outputs:
@@ -51,22 +52,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: ./.github/actions/setup-task
@@ -118,6 +116,7 @@ jobs:
env:
INPUT_PLATFORM: ${{ github.event.inputs.platform }}
build-jars:
environment: ci-unsigned
needs: determine-matrix
runs-on: ubuntu-latest
strategy:
@@ -143,22 +142,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
@@ -204,7 +200,6 @@ jobs:
runs-on: ${{ matrix.platform }}
env:
SM_API_KEY: ${{ secrets.SM_API_KEY }}
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
RELEASE_GPG_PRIVATE_KEY: ${{ secrets.RELEASE_GPG_PRIVATE_KEY }}
steps:
- name: Harden Runner
@@ -237,6 +232,14 @@ jobs:
toolchain: stable
targets: ${{ matrix.platform == 'macos-15' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: Cache Gradle
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-tauri-releases-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
# x86_64 JDK is set up first so the aarch64 step below can leave its
# JAVA_HOME as the active one. The macOS universal JRE build needs
# jmods from both arches; the x64 path is captured into the env
@@ -260,17 +263,6 @@ jobs:
java-version: "25"
distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }}
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Install Task
uses: ./.github/actions/setup-task
@@ -295,7 +287,7 @@ jobs:
# DigiCert KeyLocker Setup (Cloud HSM)
- name: Setup DigiCert KeyLocker
id: digicert-setup
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release') }}
uses: digicert/ssm-code-signing@1d820463733701cf1484c7eb5d7d24a15ca2c454 # v1.2.1
env:
SM_API_KEY: ${{ secrets.SM_API_KEY }}
@@ -305,7 +297,7 @@ jobs:
SM_HOST: ${{ secrets.SM_HOST }}
- name: Setup DigiCert KeyLocker Certificate
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release') }}
shell: pwsh
run: |
Write-Host "Setting up DigiCert KeyLocker environment..."
@@ -344,40 +336,8 @@ jobs:
SM_API_KEY: ${{ secrets.SM_API_KEY }}
SM_CLIENT_CERT_PASSWORD: ${{ secrets.SM_CLIENT_CERT_PASSWORD }}
SM_KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
# Traditional PFX Certificate Import (fallback if KeyLocker not configured)
- name: Import Windows Code Signing Certificate
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY == '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
env:
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
shell: powershell
run: |
if ($env:WINDOWS_CERTIFICATE) {
Write-Host "Importing Windows Code Signing Certificate..."
# Decode base64 certificate and save to file
$certBytes = [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE)
$certPath = Join-Path $env:RUNNER_TEMP "certificate.pfx"
[IO.File]::WriteAllBytes($certPath, $certBytes)
# Import certificate to CurrentUser\My store
$cert = Import-PfxCertificate -FilePath $certPath -CertStoreLocation Cert:\CurrentUser\My -Password (ConvertTo-SecureString -String $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force)
# Extract and set thumbprint as environment variable
$thumbprint = $cert.Thumbprint
Write-Host "Certificate imported with thumbprint: $thumbprint"
echo "WINDOWS_CERTIFICATE_THUMBPRINT=$thumbprint" >> $env:GITHUB_ENV
# Clean up certificate file
Remove-Item $certPath
Write-Host "Windows certificate import completed."
} else {
Write-Host "⚠️ WINDOWS_CERTIFICATE secret not set - building unsigned binary"
}
- name: Import Apple Developer Certificate
if: matrix.platform == 'macos-15' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')
if: matrix.platform == 'macos-15' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release')
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
@@ -398,7 +358,7 @@ jobs:
rm certificate.p12
- name: Verify Certificate
if: matrix.platform == 'macos-15' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')
if: matrix.platform == 'macos-15' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release')
run: |
echo "Verifying Apple Developer Certificate..."
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
@@ -414,7 +374,7 @@ jobs:
# Without this, signCommand failures are opaque (Tauri captures but drops
# smctl's stderr) - running these loudly surfaces auth/env/keypair issues.
- name: Preflight smctl
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release') }}
shell: pwsh
env:
KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
@@ -445,7 +405,7 @@ jobs:
# smctl reads SM_HOST, SM_API_KEY, SM_CLIENT_CERT_FILE, SM_CLIENT_CERT_PASSWORD
# from env (set by prior DigiCert setup step). No --config-file needed.
- name: Configure Windows code signing
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release') }}
shell: bash
env:
KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
@@ -466,7 +426,7 @@ jobs:
sed "s/${KEYPAIR_ALIAS}/***/g" ./frontend/editor/src-tauri/tauri.windows.conf.json
- name: Import release GPG signing key (Linux)
if: matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')
if: matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release')
run: |
echo "$RELEASE_GPG_PRIVATE_KEY" | gpg --batch --import
gpg --list-secret-keys --keyid-format=long
@@ -498,8 +458,8 @@ jobs:
# APPIMAGETOOL_SIGN_PASSPHRASE appimagetool uses this to unlock the GPG key non-interactively
# SIGN_KEY appimagetool picks the key matching this fingerprint
# Without SIGN=1, the other two are ignored and the AppImage is built unsigned even if a key is present.
# Mirror the Windows/macOS gate: only sign on a real release/dispatch+sign or V2-master, when secret is present.
SIGN: ${{ (env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')) && '1' || '0' }}
# Mirror the Windows/macOS gate: only sign on a real release/dispatch+sign or the release branch, when secret is present.
SIGN: ${{ (env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release')) && '1' || '0' }}
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.RELEASE_GPG_PASSPHRASE }}
SIGN_KEY: ${{ vars.RELEASE_GPG_FINGERPRINT }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
@@ -525,7 +485,7 @@ jobs:
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
GPG_SIGN: ${{ (env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')) && '1' || '0' }}
GPG_SIGN: ${{ (env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release')) && '1' || '0' }}
SIGN_KEY: ${{ vars.RELEASE_GPG_FINGERPRINT }}
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.RELEASE_GPG_PASSPHRASE }}
run: |
@@ -564,7 +524,7 @@ jobs:
echo "Stripped bundled libwayland from $(basename "$AI")"
- name: Clear release GPG key from runner keyring (Linux)
if: always() && matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')
if: always() && matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release')
env:
RELEASE_GPG_FINGERPRINT: ${{ vars.RELEASE_GPG_FINGERPRINT }}
run: |
@@ -579,7 +539,7 @@ jobs:
# artifact. Tauri signs a COPY when bundling into the MSI and leaves the raw
# cargo output unsigned, so checking it produces false negatives.
- name: Verify Windows Code Signature
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release') }}
timeout-minutes: 15
shell: pwsh
run: |
@@ -911,11 +871,11 @@ jobs:
# workflow_dispatch path requires platform=='all' so a single-platform
# dispatch can't overwrite an existing release's full latest.json with a
# partial one (action-gh-release defaults overwrite_files:true).
# release / V2-master always build the full matrix so no extra guard needed.
# release event / release branch always build the full matrix so no extra guard needed.
# fail_on_unmatched_files makes a missing latest.json or installer fail loudly
# instead of silently shipping a broken auto-update.
- name: Upload binaries to Release
if: (github.event_name == 'workflow_dispatch' && github.event.inputs.test_mode != 'true' && github.event.inputs.platform == 'all') || github.event_name == 'release' || github.ref == 'refs/heads/V2-master'
if: (github.event_name == 'workflow_dispatch' && github.event.inputs.test_mode != 'true' && github.event.inputs.platform == 'all') || github.event_name == 'release' || github.ref == 'refs/heads/release'
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
with:
tag_name: v${{ needs.determine-matrix.outputs.version }}
+1
View File
@@ -127,6 +127,7 @@ jobs:
# Runs the @nightly tag (conversion scenarios) plus a 10-shard concurrency run
# of every other feature.
cucumber-nightly:
environment: ci-unsigned
name: Cucumber (nightly scenarios + full concurrency)
runs-on: ubuntu-latest
# Fork pull requests get no MAVEN_* secrets, so the image build cannot work.
+3
View File
@@ -17,6 +17,9 @@ permissions:
jobs:
push-base:
# Own environment: docker-publish is branch-locked to release/main,
# which excludes the baseDockerImage/accessIssueFix branches this runs on.
environment: docker-base-publish
if: ${{ vars.CI_PROFILE != 'lite' && github.actor == 'Frooodle' }}
runs-on: ubuntu-24.04-8core
permissions:
+22 -26
View File
@@ -20,9 +20,8 @@ on:
default: false
push:
branches:
- master
- release
- main
- V2-master
# cancel in-progress jobs if a new job is triggered
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
@@ -59,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
@@ -91,13 +87,13 @@ jobs:
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
- name: Install cosign
if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master'
if: github.ref == 'refs/heads/release'
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
with:
cosign-release: "v2.4.1"
- name: Install cosign
if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master'
if: github.ref == 'refs/heads/release'
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
with:
cosign-release: "v2.4.1"
@@ -133,8 +129,8 @@ jobs:
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
tags: |
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }},enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }},enable=${{ github.ref == 'refs/heads/release' }}
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/release' }}
- name: Build and push Unified Dockerfile (latest variant)
id: build-push-latest
@@ -158,7 +154,7 @@ jobs:
sbom: true
- name: Sign regular images
if: env.RUN_MAIN_APP == 'true' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master') && steps.build-push-latest.outputs.digest != ''
if: env.RUN_MAIN_APP == 'true' && (github.ref == 'refs/heads/release') && steps.build-push-latest.outputs.digest != ''
env:
DIGEST: ${{ steps.build-push-latest.outputs.digest }}
TAGS: ${{ steps.meta.outputs.tags }}
@@ -182,8 +178,8 @@ jobs:
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
tags: |
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-fat,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
type=raw,value=latest-fat,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-fat,enable=${{ github.ref == 'refs/heads/release' }}
type=raw,value=latest-fat,enable=${{ github.ref == 'refs/heads/release' }}
- name: Build and push Unified Dockerfile (fat variant)
id: build-push-fat
@@ -204,7 +200,7 @@ jobs:
sbom: true
- name: Sign fat images
if: env.RUN_MAIN_APP == 'true' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master') && steps.build-push-fat.outputs.digest != ''
if: env.RUN_MAIN_APP == 'true' && (github.ref == 'refs/heads/release') && steps.build-push-fat.outputs.digest != ''
env:
DIGEST: ${{ steps.build-push-fat.outputs.digest }}
TAGS: ${{ steps.meta-fat.outputs.tags }}
@@ -226,8 +222,8 @@ jobs:
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
tags: |
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-ultra-lite,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
type=raw,value=latest-ultra-lite,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-ultra-lite,enable=${{ github.ref == 'refs/heads/release' }}
type=raw,value=latest-ultra-lite,enable=${{ github.ref == 'refs/heads/release' }}
- name: Build and push Unified Dockerfile (ultra-lite variant)
id: build-push-lite
@@ -248,7 +244,7 @@ jobs:
sbom: true
- name: Sign ultra-lite images
if: env.RUN_MAIN_APP == 'true' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master') && steps.build-push-lite.outputs.digest != ''
if: env.RUN_MAIN_APP == 'true' && (github.ref == 'refs/heads/release') && steps.build-push-lite.outputs.digest != ''
env:
DIGEST: ${{ steps.build-push-lite.outputs.digest }}
TAGS: ${{ steps.meta-lite.outputs.tags }}
@@ -260,7 +256,7 @@ jobs:
done
# Standalone unoserver image — versioned independently via
# docker/unoserver/VERSION. master/V2-master: publish <version>+latest
# docker/unoserver/VERSION. release: publish <version>+latest
# only when the version is new. main/testMain: republish :alpha only
# when the source hash differs from the published image's annotation.
- name: Read unoserver image version
@@ -319,7 +315,7 @@ jobs:
fi
case "$EFFECTIVE_REF" in
refs/heads/master|refs/heads/V2-master)
refs/heads/release)
if [ "${FORCE_REBUILD}" = "true" ]; then
echo "force_unoserver_rebuild=true — building stable regardless"
mode="stable"
+12 -12
View File
@@ -4,7 +4,7 @@ on:
workflow_dispatch:
push:
branches:
- master
- release
# cancel in-progress jobs if a new job is triggered
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
@@ -23,6 +23,9 @@ permissions:
jobs:
push:
# package-publish holds SWAGGERHUB_API_KEY. It requires reviewer approval and
# is limited to main / release / v* tags, so every push to release waits on one.
environment: package-publish
if: ${{ vars.CI_PROFILE != 'lite' }}
runs-on: ubuntu-latest
steps:
@@ -33,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
+31 -44
View File
@@ -26,6 +26,10 @@ on:
required: false
type: boolean
default: false
use_shared_cache:
required: false
type: boolean
default: false
workflow_dispatch:
inputs:
platform:
@@ -57,6 +61,9 @@ permissions:
jobs:
determine-matrix:
# Only probes APPLE_CERTIFICATE for presence, so it stays on the unrestricted
# signing environment - release-signing would block every PR run.
environment: ci-signing
if: ${{ vars.CI_PROFILE != 'lite' }}
runs-on: ubuntu-latest
outputs:
@@ -103,6 +110,12 @@ jobs:
echo "matrix={\"include\":[$JOINED]}" >> $GITHUB_OUTPUT
build:
# Windows/GPG signing only runs on main (see the per-step gates below), so only
# that path needs the reviewer-gated release-signing environment. Everything else
# (PRs, merge queue, nightly) signs macOS only and uses ci-signing, which has no
# approval or branch restriction.
environment:
name: ${{ (inputs.sign && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))) && 'release-signing' || 'ci-signing' }}
needs: determine-matrix
strategy:
fail-fast: false
@@ -110,7 +123,6 @@ jobs:
runs-on: ${{ matrix.platform }}
env:
SM_API_KEY: ${{ secrets.SM_API_KEY }}
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
RELEASE_GPG_PRIVATE_KEY: ${{ secrets.RELEASE_GPG_PRIVATE_KEY }}
# Per-platform sign gate. macOS signs on any run with the cert available,
@@ -160,6 +172,24 @@ jobs:
# Save the dependency cache even if a later step fails
cache-on-failure: true
- name: Restore cache Gradle User Home
if: inputs.use_shared_cache
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Restore cache Gradle
if: inputs.use_shared_cache == false
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-tauri-build-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Set up x86_64 JDK 25 (macOS universal JRE)
if: matrix.platform == 'macos-15'
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
@@ -179,17 +209,6 @@ jobs:
java-version: "25"
distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }}
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Setup Task
uses: ./.github/actions/setup-task
@@ -264,38 +283,6 @@ jobs:
}
}
# Traditional PFX Certificate Import (fallback if KeyLocker not configured)
- name: Import Windows Code Signing Certificate
if: ${{ inputs.sign && startsWith(matrix.platform, 'windows') && env.SM_API_KEY == '' && github.ref == 'refs/heads/main' }}
env:
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
shell: powershell
run: |
if ($env:WINDOWS_CERTIFICATE) {
Write-Host "Importing Windows Code Signing Certificate..."
# Decode base64 certificate and save to file
$certBytes = [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE)
$certPath = Join-Path $env:RUNNER_TEMP "certificate.pfx"
[IO.File]::WriteAllBytes($certPath, $certBytes)
# Import certificate to CurrentUser\My store
$cert = Import-PfxCertificate -FilePath $certPath -CertStoreLocation Cert:\CurrentUser\My -Password (ConvertTo-SecureString -String $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force)
# Extract and set thumbprint as environment variable
$thumbprint = $cert.Thumbprint
Write-Host "Certificate imported with thumbprint: $thumbprint"
echo "WINDOWS_CERTIFICATE_THUMBPRINT=$thumbprint" >> $env:GITHUB_ENV
# Clean up certificate file
Remove-Item $certPath
Write-Host "Windows certificate import completed."
} else {
Write-Host "⚠️ WINDOWS_CERTIFICATE secret not set - building unsigned binary"
}
- name: Import Apple Developer Certificate
if: env.SIGN_BUNDLE == 'true' && matrix.platform == 'macos-15'
env:
+9 -11
View File
@@ -37,6 +37,7 @@ jobs:
# spring-security=true matrix entry if `task backend:build` and
# `task backend:build:ci` produce equivalent JARs (verify before wiring).
test-build-docker-images:
environment: ci-unsigned
runs-on: ubuntu-latest
strategy:
fail-fast: false
@@ -78,23 +79,20 @@ jobs:
docker system prune -af || true
echo "Disk space after cleanup:" && df -h
- name: Restore cache Gradle User Home
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Install Task
uses: ./.github/actions/setup-task
- name: Build application
-235
View File
@@ -1,235 +0,0 @@
name: UI test with TestDriverAI
on:
push:
branches: ["master", "UITest", "testdriver"]
# cancel in-progress jobs if a new job is triggered
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
# or a pull request is updated.
# It helps to save resources and time by ensuring that only the latest commit is built and tested
# This is particularly useful for long-running jobs that may take a while to complete.
# The `group` is set to a combination of the workflow name, event name, and branch name.
# This ensures that jobs are grouped by the workflow and branch, allowing for cancellation of
# in-progress jobs when a new commit is pushed to the same branch or a new pull request is opened.
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref_name || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
deploy:
environment: pr-preview
if: ${{ vars.CI_PROFILE != 'lite' }}
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Build with Gradle
run: ./gradlew build
env:
MAVEN_USER: ${{ secrets.MAVEN_USER }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
DISABLE_ADDITIONAL_FEATURES: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Get version number
id: versionNumber
run: |
VERSION=$(grep "^version =" build.gradle | awk -F'"' '{print $2}')
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
- name: Convert repository owner to lowercase
id: repoowner
run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT
- name: Login to GitHub Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Build and push test image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: ./docker/embedded/Dockerfile
push: true
cache-from: type=gha,scope=stirling-pdf-latest
cache-to: type=gha,mode=max,scope=stirling-pdf-latest
tags: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:test-${{ github.sha }}
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
platforms: linux/amd64
- name: Set up SSH
run: |
mkdir -p ~/.ssh/
echo "${NEW_VPS_SSH_KEY}" > ../private.key
sudo chmod 600 ../private.key
env:
NEW_VPS_SSH_KEY: ${{ secrets.NEW_VPS_SSH_KEY }}
- name: Deploy to VPS
run: |
cat > docker-compose.yml << EOF
version: '3.3'
services:
stirling-pdf:
container_name: stirling-pdf-test-${{ github.sha }}
image: ${IMAGE_BASE}:test-${{ github.sha }}
ports:
- "1337:8080"
volumes:
- /stirling/test-${{ github.sha }}/data:/usr/share/tessdata:rw
- /stirling/test-${{ github.sha }}/config:/configs:rw
- /stirling/test-${{ github.sha }}/logs:/logs:rw
environment:
DISABLE_ADDITIONAL_FEATURES: "true"
SECURITY_ENABLELOGIN: "false"
SYSTEM_DEFAULTLOCALE: en-US
UI_APPNAME: "Stirling-PDF Test"
UI_HOMEDESCRIPTION: "Test Deployment"
UI_APPNAMENAVBAR: "Test"
SYSTEM_MAXFILESIZE: "100"
METRICS_ENABLED: "true"
SYSTEM_GOOGLEVISIBILITY: "false"
SYSTEM_ENABLEANALYTICS: "false"
restart: on-failure:5
EOF
scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${NEW_VPS_USERNAME}@${NEW_VPS_HOST}:/tmp/docker-compose.yml
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${NEW_VPS_USERNAME}@${NEW_VPS_HOST} << EOF
mkdir -p /stirling/test-${{ github.sha }}/{data,config,logs}
mv /tmp/docker-compose.yml /stirling/test-${{ github.sha }}/docker-compose.yml
cd /stirling/test-${{ github.sha }}
docker-compose pull
docker-compose up -d
EOF
env:
IMAGE_BASE: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test
NEW_VPS_USERNAME: ${{ secrets.NEW_VPS_USERNAME }}
NEW_VPS_HOST: ${{ secrets.NEW_VPS_HOST }}
files-changed:
if: always()
name: detect what files changed
runs-on: ubuntu-latest
timeout-minutes: 3
outputs:
frontend: ${{ steps.changes.outputs.frontend }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Check for file changes
uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
id: changes
with:
filters: ".github/config/.files.yaml"
test:
environment: pr-preview
if: needs.files-changed.outputs.frontend == 'true'
needs: [deploy, files-changed]
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Run TestDriver.ai
uses: testdriverai/action@f0d0f45fdd684db628baa843fe9313f3ca3a8aa8 #1.1.3
with:
key: ${{secrets.TESTDRIVER_API_KEY}}
prerun: |
choco install go-task -y
task frontend:build
cd frontend
npm install dashcam-chrome --save
Start-Process "C:/Program Files/Google/Chrome/Application/chrome.exe" -ArgumentList "--start-maximized", "--load-extension=$(pwd)/node_modules/dashcam-chrome/build", "http://${{ secrets.NEW_VPS_HOST }}:1337"
Start-Sleep -Seconds 20
prompt: |
1. /run testing/testdriver/test.yml
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
FORCE_COLOR: "3"
cleanup:
environment: pr-preview
needs: [deploy, test]
runs-on: ubuntu-latest
if: always()
steps:
- name: Harden Runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
- name: Set up SSH
run: |
mkdir -p ~/.ssh/
echo "${NEW_VPS_SSH_KEY}" > ../private.key
sudo chmod 600 ../private.key
env:
NEW_VPS_SSH_KEY: ${{ secrets.NEW_VPS_SSH_KEY }}
- name: Cleanup deployment
if: always()
run: |
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${NEW_VPS_USERNAME}@${NEW_VPS_HOST} << EOF
cd /stirling/test-${{ github.sha }}
docker-compose down
cd /stirling
rm -rf test-${{ github.sha }}
EOF
env:
NEW_VPS_USERNAME: ${{ secrets.NEW_VPS_USERNAME }}
NEW_VPS_HOST: ${{ secrets.NEW_VPS_HOST }}
continue-on-error: true # Ensure cleanup runs even if previous steps fail
+33 -38
View File
@@ -4,6 +4,11 @@ This guide explains how to set up Windows code signing for Stirling-PDF desktop
## Overview
Releases are signed with **DigiCert KeyLocker**, a cloud HSM: the private key never
leaves DigiCert, and the runner signs through a PKCS#11 provider. The older approach
of uploading a base64 `.pfx` to a repository secret has been removed from the
workflows - the sections below describe KeyLocker, which is what actually runs.
Windows code signing is essential for:
- Preventing Windows SmartScreen warnings
- Building trust with users
@@ -49,29 +54,19 @@ openssl pkcs12 -export -out certificate.pfx -inkey private-key.key -in certifica
### Required Secrets
Navigate to your GitHub repository → Settings → Secrets and variables → Actions
Navigate to your GitHub repository → Settings → Environments → `release-signing`.
Add the following secrets:
These live in the `release-signing` environment, not at repository scope. That
environment requires reviewer approval and is limited to `main`, `release`,
`hotfix/*` and `v*` tags. All five come from the DigiCert ONE console.
#### 1. `WINDOWS_CERTIFICATE`
- **Description**: Base64-encoded .pfx certificate file
- **How to create**:
**On macOS/Linux:**
```bash
base64 -i certificate.pfx | pbcopy # Copies to clipboard
```
**On Windows (PowerShell):**
```powershell
[Convert]::ToBase64String([IO.File]::ReadAllBytes("certificate.pfx")) | Set-Clipboard
```
Paste the entire base64 string into the GitHub secret.
#### 2. `WINDOWS_CERTIFICATE_PASSWORD`
- **Description**: Password for the .pfx certificate
- **Value**: The password you set when creating/exporting the .pfx file
| Secret | Description |
| --- | --- |
| `SM_API_KEY` | KeyLocker API key. Also acts as the on/off switch: signing steps are gated on it being non-empty. |
| `SM_CLIENT_CERT_FILE_B64` | Base64-encoded PKCS#12 client authentication certificate. |
| `SM_CLIENT_CERT_PASSWORD` | Password for that client certificate. |
| `SM_KEYPAIR_ALIAS` | Alias of the signing keypair to use. |
| `SM_HOST` | DigiCert ONE host, e.g. `https://clientauth.one.digicert.com`. |
### Optional Secrets for Tauri Updater
@@ -110,23 +105,23 @@ The Windows signing configuration is already set up:
### 2. GitHub Workflow (.github/workflows/tauri-build.yml)
The workflow includes three Windows signing steps:
The workflow includes four Windows signing steps, all gated on `SM_API_KEY` being
set and the ref being the release branch:
1. **Import Certificate**: Decodes and imports the .pfx certificate into Windows certificate store
2. **Build Tauri App**: Builds and signs the application using the imported certificate
3. **Verify Signature**: Validates that both .exe and .msi files are properly signed
1. **Setup DigiCert KeyLocker**: Installs the DigiCert signing tools via `digicert/ssm-code-signing`
2. **Setup DigiCert KeyLocker Certificate**: Writes the client cert and exports the PKCS#11 config
3. **Configure Windows code signing / Build Tauri app**: Signs through the PKCS#11 provider
4. **Verify Windows Code Signature**: Validates that the .exe and .msi are properly signed
## Testing the Setup
### 1. Local Testing (Windows Only)
Before pushing to GitHub, test locally:
KeyLocker is CI-only. To check signing locally, install your own certificate into
the Windows store and point Tauri at it; the build no longer reads any certificate
from an environment variable.
```powershell
# Set environment variables
$env:WINDOWS_CERTIFICATE = [Convert]::ToBase64String([IO.File]::ReadAllBytes("certificate.pfx"))
$env:WINDOWS_CERTIFICATE_PASSWORD = "your-certificate-password"
# Build the application
cd frontend
npm run tauri build
@@ -191,9 +186,10 @@ Look for:
- Consider EV certificate for immediate reputation
### Certificate Not Found During Build
- Verify `WINDOWS_CERTIFICATE` secret is set
- Check base64 encoding is correct (no extra whitespace)
- Ensure password is correct
- Verify `SM_API_KEY` is present in the `release-signing` environment. If it is empty
the signing steps skip silently and the build succeeds unsigned.
- Check `SM_CLIENT_CERT_FILE_B64` base64 encoding is correct (no extra whitespace)
- Ensure `SM_CLIENT_CERT_PASSWORD` and `SM_KEYPAIR_ALIAS` match the DigiCert keypair
## Security Best Practices
@@ -220,11 +216,10 @@ Look for:
## Certificate Lifecycle
### Before Expiration
1. Obtain new certificate from CA (typically annual renewal)
2. Convert to .pfx format if needed
3. Update `WINDOWS_CERTIFICATE` secret with new base64-encoded certificate
4. Update `WINDOWS_CERTIFICATE_PASSWORD` if password changed
5. Test build to verify new certificate works
1. Renew the certificate in the DigiCert ONE console (typically annual)
2. If the keypair alias changed, update `SM_KEYPAIR_ALIAS` in the `release-signing` environment
3. If the client authentication certificate was reissued, update `SM_CLIENT_CERT_FILE_B64` and `SM_CLIENT_CERT_PASSWORD`
4. Test build to verify the new certificate works
### Expired Certificates
- Signed binaries remain valid (timestamp proves signing time)
@@ -0,0 +1,108 @@
package stirling.software.saas.config;
import org.hibernate.boot.model.relational.Namespace;
import org.hibernate.boot.model.relational.Sequence;
import org.hibernate.mapping.Table;
import org.hibernate.tool.schema.spi.SchemaFilter;
import org.hibernate.tool.schema.spi.SchemaFilterProvider;
/**
* Hides the migration-owned tables from Hibernate's schema management.
*
* <p>Wired on the SaaS profile only, via {@code hibernate.hbm2ddl.schema_filter_provider}.
* Self-hosted is untouched: there Hibernate rightly owns everything.
*
* <p>Why a filter rather than simply turning {@code ddl-auto} off: the SaaS database has two
* writers. The Supabase migrations own the SaaS tables, and Hibernate owns roughly thirty tables
* inherited from the self-hosted app that no migration has ever created. Turn {@code ddl-auto} off
* and a fresh preview branch is missing that second half; leave it on and Hibernate is free to
* reconcile migration-owned tables, which is how {@code team_memberships.role} ended up widened to
* varchar(255) and needed a migration to put back. A filter keeps the first half working and makes
* the second impossible.
*
* <p>Note that this is a per-table filter, not a per-schema one. Hibernate's schema management runs
* over every mapped entity regardless of namespace, so moving SaaS tables to their own schema would
* not by itself keep Hibernate out of them. {@link SaasSchemaOwnership} is the register; this class
* only applies it.
*
* <p><b>Foreign keys still cross the line, on purpose.</b> Several inherited tables reference
* migration-owned ones — {@code folders}, {@code stored_files} and {@code file_shares} all point at
* {@code users}/{@code teams}. Hibernate's {@code SchemaCreatorImpl.createForeignKeys} and {@code
* AbstractSchemaMigrator.applyForeignKeys} check {@code includeTable} against the *owning* table
* only and then emit every foreign key on it, without consulting the referenced table. So excluding
* {@code users} does not cost the branch its referential integrity, and a branch ends up matching
* staging. It does mean the referenced tables have to exist by the time Hibernate runs, which holds
* because a Supabase branch applies its migrations at build time and the app connects afterwards.
*
* <p><b>Known gap: this cannot detect drift.</b> Filtering means Hibernate never inspects these
* tables, and {@link #getValidateFilter()} extends that to {@code validate}, so nothing here
* compares a migration-owned table against its entity. Combined with the register being a
* hand-maintained list of another repo's contents (see {@link SaasSchemaOwnership}), there is
* currently no automated signal when the register, the entities and the database disagree. That is
* a deliberate trade for a boot that does not fail on differences we accept, not a claim that drift
* cannot happen; a non-fatal drift report is the missing piece and belongs outside this class.
*/
public class MigrationOwnedSchemaFilter implements SchemaFilterProvider, SchemaFilter {
/**
* The one decision this class makes. Everything Hibernate might do to a table it does not own —
* create, alter, drop, truncate — is refused.
*/
@Override
public boolean includeTable(Table table) {
return !SaasSchemaOwnership.isMigrationOwned(table.getName());
}
/**
* Namespaces are never filtered. The inherited tables and the migration-owned ones share {@code
* stirling_pdf}, so excluding the namespace would take both with it.
*/
@Override
public boolean includeNamespace(Namespace namespace) {
return true;
}
/**
* Sequences are left alone. Every id here is an identity column rather than a mapped generator,
* so there is nothing for Hibernate to create; filtering them would be dead code pretending to
* be a safeguard.
*/
@Override
public boolean includeSequence(Sequence sequence) {
return true;
}
@Override
public SchemaFilter getCreateFilter() {
return this;
}
@Override
public SchemaFilter getMigrateFilter() {
return this;
}
@Override
public SchemaFilter getDropFilter() {
return this;
}
@Override
public SchemaFilter getTruncatorFilter() {
return this;
}
/**
* Validation is filtered too, which is the one debatable call here.
*
* <p>Letting it through would give a useful signal when a migration-owned table drifts from its
* entity. But {@code ddl-auto=validate} fails startup, and it would fail on differences we have
* deliberately accepted — {@code ai_create_sessions} carries columns from a reverted feature
* that nothing maps, for instance. A boot failure over a table we have chosen not to manage is
* noise, so the rule stays uniform: Hibernate does not concern itself with these tables at all.
*/
@Override
public SchemaFilter getValidateFilter() {
return this;
}
}
@@ -0,0 +1,117 @@
package stirling.software.saas.config;
import java.util.Set;
/**
* Which side owns each table in the SaaS database.
*
* <p>The SaaS schema has two writers and always has: the Supabase migrations in the
* Stirling-PDF-SaaS repo, and Hibernate's {@code ddl-auto}. That was a convention rather than a
* rule, and it leaked twice. An older {@code ddl-auto} run widened {@code team_memberships.role} to
* varchar(255), which needed a dedicated migration to repair because RLS policies depended on the
* column. Separately {@code payg_instance_usage} went months with an entity and no migration, so it
* simply did not exist on a fresh preview branch.
*
* <p>This class makes the boundary explicit and {@code SaasSchemaOwnershipTest} makes it binding:
* every {@code @Entity} the SaaS app maps must appear in exactly one of these two sets. A new
* entity fails the build until someone states who owns its table, which is the decision that was
* previously made by accident.
*
* <p>{@link MigrationOwnedSchemaFilter} enforces it at runtime: Hibernate is never shown the
* migration-owned tables, so it cannot create, alter or drop them whatever {@code ddl-auto} says.
* Inherited tables stay under Hibernate, so a preview branch built from migrations alone still
* heals itself on first boot.
*
* <p><b>What this does not catch.</b> The register is a hand-maintained copy of what lives in
* another repository, and only one direction is enforced. The test fails when a *new* entity
* appears with no owner. It cannot notice a table changing sides: write a migration for {@code
* folders} in Stirling-PDF-SaaS and nothing here changes, the test still passes, and Hibernate
* carries on managing a table the migrations now own — which is precisely how {@code
* team_memberships.role} got widened. Adding a migration for anything in {@link #HIBERNATE_MANAGED}
* therefore means moving it to {@link #MIGRATION_OWNED} in the same change; nothing will remind
* you. Making that structural rather than remembered is what moving the SaaS tables into their own
* schema would buy, and is the reason this class is a stepping stone rather than the answer.
*/
public final class SaasSchemaOwnership {
/**
* Created and altered by the Supabase migrations. Hibernate must not touch these: the
* migrations carry constraints, defaults and RLS policies it knows nothing about and would
* reconcile away.
*/
public static final Set<String> MIGRATION_OWNED =
Set.of(
"ai_create_sessions",
"audit_events",
"authorities",
"billing_subscriptions",
"job_artifact_hash",
"legal_consent",
"linked_instance",
"payg_instance_usage",
"payg_meter_event_log",
"payg_prepaid_bundle",
"payg_shadow_charge",
"payg_team_extensions",
"persistent_logins",
"pricing_policy",
"processing_job",
"processing_job_step",
"procurement_agreement_signature",
"procurement_deal",
"procurement_quote",
"saas_team_extensions",
"saas_user_extensions",
"sessions",
"team_invitations",
"team_memberships",
"teams",
"users",
"wallet_entitlement_snapshot",
"wallet_ledger",
"wallet_policy");
/**
* Inherited from the self-hosted app, where {@code ddl-auto} owns the schema and no Supabase
* migration exists. Deliberately left under Hibernate so a fresh branch gets them on first
* boot.
*/
public static final Set<String> HIBERNATE_MANAGED =
Set.of(
"account_link_device_credential",
"account_link_metered_signature",
"account_link_sync_state",
"account_link_usage_counter",
"api_key_daily_usage",
"api_keys",
"file_encryption_keys",
"file_run_events",
"file_share_accesses",
"file_shares",
"folders",
"integration_configs",
"invite_tokens",
"jwt_signing_keys",
"policies",
"policy_assets",
"policy_completed_migrations",
"policy_processed_files",
"policy_source_doc_counts",
"policy_source_doc_totals",
"policy_sources",
"resource_grants",
"storage_cleanup_entries",
"stored_file_blobs",
"stored_files",
"user_license_settings",
"user_server_certificates",
"workflow_participants",
"workflow_sessions");
private SaasSchemaOwnership() {}
/** Case-insensitive: Hibernate hands us whatever casing the mapping used. */
public static boolean isMigrationOwned(String tableName) {
return tableName != null && MIGRATION_OWNED.contains(tableName.toLowerCase());
}
}
@@ -27,6 +27,13 @@ spring.jpa.properties.hibernate.hbm2ddl.create_namespaces=true
spring.jpa.hibernate.ddl-auto=update
# ...but only over the tables Hibernate actually owns. The SaaS database has two writers: the
# Supabase migrations own the SaaS tables, Hibernate owns ~30 inherited from the self-hosted app that
# no migration has ever created. This filter hides the former from schema management, so ddl-auto can
# still heal a fresh preview branch without being free to reconcile a migration-owned table — which
# is how team_memberships.role ended up widened to varchar(255). Register: SaasSchemaOwnership.
spring.jpa.properties.hibernate.hbm2ddl.schema_filter_provider=stirling.software.saas.config.MigrationOwnedSchemaFilter
# ---------- Supabase JWT auth ----------
# Required: set SAAS_DB_PROJECT_REF via env.
app.supabase.project-ref=${SAAS_DB_PROJECT_REF:}
@@ -0,0 +1,129 @@
package stirling.software.saas.config;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.InputStream;
import java.util.Properties;
import org.hibernate.mapping.Table;
import org.hibernate.tool.schema.spi.SchemaFilter;
import org.hibernate.tool.schema.spi.SchemaFilterProvider;
import org.junit.jupiter.api.Test;
/**
* Covers {@link MigrationOwnedSchemaFilter} and, just as importantly, its wiring.
*
* <p>{@link SaasSchemaOwnershipTest} proves the register is complete; nothing proved the filter
* applies it, or that Hibernate is even asking. A typo in the {@code
* hibernate.hbm2ddl.schema_filter_provider} key, a stale fully-qualified name after a package move,
* or a getter returning null would all leave every migration-owned table exposed to {@code
* ddl-auto} with a fully green build. Hence the property assertion below, which is the only thing
* here that would catch that.
*/
class MigrationOwnedSchemaFilterTest {
private static final String FILTER_PROPERTY =
"spring.jpa.properties.hibernate.hbm2ddl.schema_filter_provider";
private final MigrationOwnedSchemaFilter filter = new MigrationOwnedSchemaFilter();
/** "orm" is Hibernate's own default contributor; the value is irrelevant to the filter. */
private static Table table(String name) {
return new Table("orm", name);
}
@Test
void migrationOwnedTablesAreHiddenFromHibernate() {
assertThat(filter.includeTable(table("teams"))).isFalse();
assertThat(filter.includeTable(table("users"))).isFalse();
assertThat(filter.includeTable(table("team_memberships"))).isFalse();
assertThat(filter.includeTable(table("payg_instance_usage"))).isFalse();
}
@Test
void inheritedTablesStayUnderHibernate() {
assertThat(filter.includeTable(table("folders"))).isTrue();
assertThat(filter.includeTable(table("stored_files"))).isTrue();
assertThat(filter.includeTable(table("api_keys"))).isTrue();
}
@Test
void anUnknownTableIsLeftToHibernate() {
// Fail-open is the right default: an unrecognised table is either brand new or from a
// module
// we do not know about, and SaasSchemaOwnershipTest is what stops it staying unrecognised.
assertThat(filter.includeTable(table("no_such_table"))).isTrue();
}
@Test
void casingDoesNotDefeatTheFilter() {
assertThat(filter.includeTable(table("TEAMS"))).isFalse();
assertThat(filter.includeTable(table("Team_Memberships"))).isFalse();
}
/**
* Foreign keys from an inherited table into a migration-owned one survive the filter.
*
* <p>Worth pinning, because it is not obvious and it decides whether a preview branch keeps
* referential integrity. {@code folders}, {@code stored_files} and {@code file_shares} all
* reference {@code users}/{@code teams}, which the filter excludes. Hibernate 7.2's {@code
* SchemaCreatorImpl.createForeignKeys} (and {@code AbstractSchemaMigrator.applyForeignKeys})
* tests {@code includeTable} against the *owning* table only, then emits every foreign key on
* it; the referenced table is never consulted. So the constraints are still created and a
* branch matches staging.
*
* <p>The one thing this depends on is ordering: the referenced tables have to exist first. They
* do, because a Supabase branch runs its migrations at build time and the app connects after.
*/
@Test
void foreignKeysIntoMigrationOwnedTablesAreStillEmitted() {
assertThat(filter.includeTable(table("folders"))).isTrue();
assertThat(filter.includeTable(table("file_shares"))).isTrue();
assertThat(filter.includeTable(table("users"))).isFalse();
assertThat(filter.includeTable(table("teams"))).isFalse();
}
@Test
void everySchemaActionGetsTheSameFilter() {
assertThat(filter.getCreateFilter()).isSameAs(filter);
assertThat(filter.getMigrateFilter()).isSameAs(filter);
assertThat(filter.getDropFilter()).isSameAs(filter);
assertThat(filter.getTruncatorFilter()).isSameAs(filter);
assertThat(filter.getValidateFilter()).isSameAs(filter);
}
@Test
void namespacesAndSequencesAreNeverFiltered() {
// Both share the stirling_pdf namespace, so filtering it would take the inherited tables
// with it. Neither argument is read, so nulls are fine and keep the test free of Hibernate
// bootstrap machinery.
assertThat(filter.includeNamespace(null)).isTrue();
assertThat(filter.includeSequence(null)).isTrue();
}
@Test
void theFilterIsActuallyWiredIntoHibernate() throws Exception {
Properties properties = new Properties();
try (InputStream in = getClass().getResourceAsStream("/application-saas.properties")) {
assertThat(in)
.as("application-saas.properties must be on the test classpath to check wiring")
.isNotNull();
properties.load(in);
}
String configured = properties.getProperty(FILTER_PROPERTY);
assertThat(configured)
.as(
"%s is unset, so Hibernate installs its default filter and every"
+ " migration-owned table is back under ddl-auto",
FILTER_PROPERTY)
.isNotBlank();
Class<?> wired = Class.forName(configured.trim());
assertThat(SchemaFilterProvider.class)
.as("Hibernate only accepts a SchemaFilterProvider here")
.isAssignableFrom(wired);
assertThat(SchemaFilter.class).isAssignableFrom(wired);
assertThat(wired).isEqualTo(MigrationOwnedSchemaFilter.class);
}
}
@@ -0,0 +1,167 @@
package stirling.software.saas.config;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.Arrays;
import java.util.List;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.boot.persistence.autoconfigure.EntityScan;
import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider;
import org.springframework.core.type.filter.AnnotationTypeFilter;
import org.springframework.util.ClassUtils;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import stirling.software.proprietary.security.configuration.DatabaseConfig;
/**
* Makes {@link SaasSchemaOwnership} binding rather than decorative.
*
* <p>Every {@code @Entity} the SaaS app maps has to be declared as owned by either the Supabase
* migrations or Hibernate. Adding an entity without saying which fails here, at build time, instead
* of months later on a preview branch that has no such table. That is not hypothetical: {@code
* payg_instance_usage} shipped with an entity and no migration and went unnoticed until a branch
* tried to use it.
*
* <p>"Maps" is meant precisely: the scan covers the packages named by the {@code @EntityScan}
* declarations the app actually boots with, not everything under {@code stirling.software}. See
* {@link #mappedPackages()}. Note this only enforces one direction — {@link SaasSchemaOwnership}
* documents the drift it cannot see.
*/
class SaasSchemaOwnershipTest {
/**
* The packages the running app actually maps, read off the two {@code @EntityScan} declarations
* that define them rather than hardcoded.
*
* <p>Scanning all of {@code stirling.software} would be easier and wrong in a quiet way: it is
* a superset, so it would force ownership declarations for entities Hibernate never sees and
* let the register claim tables that do not exist as far as the SaaS app is concerned. Deriving
* the list means this test measures the same set Hibernate does, and follows a package being
* added or moved without anyone updating it here.
*/
private static Set<String> mappedPackages() {
Set<String> packages = new TreeSet<>();
for (Class<?> config : List.of(SaasJpaConfig.class, DatabaseConfig.class)) {
EntityScan scan = config.getAnnotation(EntityScan.class);
assertThat(scan)
.as("%s must carry @EntityScan, or its entities are not mapped", config)
.isNotNull();
packages.addAll(Arrays.asList(scan.value()));
}
return packages;
}
private static TreeMap<String, String> mappedTables() {
ClassPathScanningCandidateComponentProvider scanner =
new ClassPathScanningCandidateComponentProvider(false);
scanner.addIncludeFilter(new AnnotationTypeFilter(Entity.class));
TreeMap<String, String> byTable = new TreeMap<>();
for (String basePackage : mappedPackages()) {
for (BeanDefinition bd : scanner.findCandidateComponents(basePackage)) {
String className = bd.getBeanClassName();
Class<?> type;
try {
type =
ClassUtils.forName(
className, SaasSchemaOwnershipTest.class.getClassLoader());
} catch (ClassNotFoundException | LinkageError e) {
continue; // not on this module's runtime classpath; nothing to own
}
Table table = type.getAnnotation(Table.class);
String name =
table != null && !table.name().isBlank()
? table.name()
: camelToSnake(type.getSimpleName());
byTable.put(name.toLowerCase(), className);
}
}
return byTable;
}
/** Mirrors Spring Boot's default CamelCaseToUnderscoresNamingStrategy for an unnamed @Table. */
private static String camelToSnake(String name) {
return name.replaceAll("([a-z0-9])([A-Z])", "$1_$2").toLowerCase();
}
@Test
void everyEntityTableIsOwnedByExactlyOneSide() {
TreeMap<String, String> mapped = mappedTables();
assertThat(mapped)
.as("entity scan found nothing, so this test proves nothing")
.isNotEmpty();
// The scan is derived from @EntityScan now, so a package quietly dropped from either
// declaration would shrink it and weaken this test rather than fail it. These four straddle
// the two declarations, so losing either side fails here instead of silently checking less.
assertThat(mapped.keySet())
.as("both @EntityScan declarations must have contributed to the scan")
.contains("users", "teams", "payg_instance_usage", "folders");
Set<String> undeclared = new TreeSet<>();
Set<String> both = new TreeSet<>();
for (String table : mapped.keySet()) {
boolean migration = SaasSchemaOwnership.MIGRATION_OWNED.contains(table);
boolean hibernate = SaasSchemaOwnership.HIBERNATE_MANAGED.contains(table);
if (migration && hibernate) both.add(table);
if (!migration && !hibernate) undeclared.add(table);
}
assertThat(undeclared)
.as(
"""
These entity tables are not declared in SaasSchemaOwnership, so nobody owns \
them. Decide and add each to exactly one set:
- MIGRATION_OWNED: also add a migration in Stirling-PDF-SaaS, or the table \
will not exist on a fresh preview branch.
- HIBERNATE_MANAGED: only correct for a table inherited from the \
self-hosted app that no Supabase migration creates.
Offending tables -> entities: %s"""
.formatted(
undeclared.stream()
.map(t -> t + " (" + mapped.get(t) + ")")
.toList()))
.isEmpty();
assertThat(both)
.as("declared as owned by both sides, which is the one thing it cannot be")
.isEmpty();
}
@Test
void theTwoSetsDoNotOverlap() {
Set<String> overlap = new TreeSet<>(SaasSchemaOwnership.MIGRATION_OWNED);
overlap.retainAll(SaasSchemaOwnership.HIBERNATE_MANAGED);
assertThat(overlap).isEmpty();
}
@Test
void tableNamesAreLowercaseSoLookupsCannotMiss() {
// isMigrationOwned() lowercases its input; a capital in either set would be unreachable.
assertThat(SaasSchemaOwnership.MIGRATION_OWNED)
.allSatisfy(t -> assertThat(t).isEqualTo(t.toLowerCase()));
assertThat(SaasSchemaOwnership.HIBERNATE_MANAGED)
.allSatisfy(t -> assertThat(t).isEqualTo(t.toLowerCase()));
}
@Test
void migrationOwnedTablesIncludeTheOnesThatBitUs() {
// team_memberships is the table an old ddl-auto run widened; payg_instance_usage is the one
// that had an entity and no migration. Both must be on the migrations' side of the line.
assertThat(SaasSchemaOwnership.MIGRATION_OWNED)
.contains("team_memberships", "payg_instance_usage", "teams", "users");
}
@Test
void isMigrationOwnedIsCaseInsensitiveAndNullSafe() {
assertThat(SaasSchemaOwnership.isMigrationOwned("TEAM_MEMBERSHIPS")).isTrue();
assertThat(SaasSchemaOwnership.isMigrationOwned("team_memberships")).isTrue();
assertThat(SaasSchemaOwnership.isMigrationOwned(null)).isFalse();
assertThat(SaasSchemaOwnership.isMigrationOwned("no_such_table")).isFalse();
}
}
@@ -1879,6 +1879,9 @@ width = "Width"
[app]
description = "The Free Adobe Acrobat alternative (10M+ Downloads)"
[appBanner]
dismiss = "Dismiss"
[attachments]
convertToPdfA3b = "Convert to PDF/A-3b"
convertToPdfA3bDescription = "Creates an archival PDF with embedded attachments"
@@ -3010,6 +3013,7 @@ saturation = "Saturation and brightness"
title = "Choose color"
[common]
actions = "Actions"
back = "Back"
cancel = "Cancel"
close = "Close"
@@ -3025,6 +3029,7 @@ error = "Error"
expand = "Expand"
loading = "Loading..."
next = "Next"
open = "Open"
preview = "Preview"
previous = "Previous"
refresh = "Refresh"
@@ -4825,9 +4830,6 @@ title = "Image to PDF"
[imageToPdf]
tags = "conversion,img,jpg,picture,photo"
[infoBanner]
dismiss = "Dismiss"
[invite]
acceptError = "Failed to create account"
accountFor = "Creating account for"
@@ -5055,6 +5057,14 @@ title = "Upload from Mobile"
tags = "Multi Tool,Multi operation,UI,click drag,front end,client side,interactive,intractable,move,delete,migrate,divide"
title = "PDF Multi Tool"
[navFooter]
openEditor = "Open PDF Editor"
openProcessor = "Open PDF Processor"
[navFooter.credits]
count = "{{remaining}} of {{total}}"
label = "Free credits"
[oauth.error]
message = "Authentication was not successful. You can close this window and try again."
title = "Authentication Failed"
@@ -5619,8 +5629,8 @@ freeBody = "View, edit, merge, split, sign, watermark, compress, convert and man
freeTitle = "Unlimited PDF editing"
[payg.free.hero]
barAria = "Free PDFs used"
capSuffix = "/ {{limit}} free PDFs"
barAria = "Free PDFs remaining"
capSuffix = "of {{limit}} free PDFs left"
metaCategories = "Automation · AI · API requests"
[payg.free.member]
@@ -6339,7 +6349,6 @@ revoked = "Revoked"
unnamed = "Unnamed instance"
[portal.accountLink.instances.columns]
actions = "Actions"
instance = "Instance"
lastSeen = "Last seen"
linked = "Linked"
@@ -6684,12 +6693,12 @@ reachedTitle = "Monthly spend limit reached"
title = "Couldn't open Stripe portal"
[portal.billing.walletMeter]
barAria = "Free PDFs used"
capSuffix_one = "of {{allowance}} free PDFs used"
capSuffix_other = "of {{allowance}} free PDFs used"
barAria = "Free PDFs remaining"
capSuffix_one = "of {{allowance}} free PDF left"
capSuffix_other = "of {{allowance}} free PDFs left"
eyebrow = "Processor trial"
statusLabel_one = "{{remaining}} left"
statusLabel_other = "{{remaining}} left"
statusLabel_one = "{{used}} used"
statusLabel_other = "{{used}} used"
sub = "Use the PDF Editor for free. Pay to process PDFs automatically."
title_one = "Process {{allowance}} PDFs free"
title_other = "Process {{allowance}} PDFs free"
@@ -7262,12 +7271,11 @@ editorAction = "Editor"
empty = "No documents match this filter."
rowActions = "Row actions"
sensitiveLabel = "Sensitive"
sensitiveTitle = "Sensitive — access required"
[portal.documents.table.columns]
action = "Pipeline / Action"
actions = "Actions"
document = "Document"
labels = "Labels"
product = "Product"
status = "Status"
time = "Time"
@@ -7283,6 +7291,7 @@ host = "Host"
lastSeen = "Last seen"
region = "Region"
status = "Status"
target = "Target"
version = "Version"
[portal.editorAdmin.health.empty]
@@ -7371,6 +7380,14 @@ confirm = "Are you sure?"
dismiss = "Dismiss"
dismissSkipFile = "Skip this file"
[portal.failures.debug]
copyJson = "Copy JSON"
dismissAll = "Dismiss all ({{total}})"
dismissing = "Dismissing..."
hideJson = "Hide raw JSON ({{total}})"
refresh = "Refresh failures"
showJson = "Show raw JSON ({{total}})"
[portal.failures.disabled]
closed = "This failure is already closed."
unavailable = "Not available for this failure."
@@ -7602,11 +7619,9 @@ security = "Security"
storage = "Storage"
[portal.integrations]
addAnother = "Add another"
availableHeading = "Available"
comingSoonHeading = "Coming soon"
connect = "Connect"
connectedHeading = "Connected"
connectionCount_one = "{{count}} connection"
connectionCount_other = "{{count}} connections"
customApi = "Custom API"
@@ -7624,6 +7639,10 @@ security = "Security"
signing = "Signing"
storage = "Storage"
[portal.integrations.noResults]
description = "No integrations match your filters. Try a different category or search."
title = "No matches"
[portal.integrations.status]
connected = "Connected"
@@ -7654,7 +7673,6 @@ integrations = "Integrations"
pipelines = "Pipelines"
policies = "Policies"
procurement = "Procurement"
settings = "Settings"
sources = "Sources"
usage = "Usage & Billing"
users = "Users"
@@ -7826,10 +7844,10 @@ paused = "Paused"
[portal.pipelines.table]
name = "Pipeline"
open = "Open"
sources = "Sources"
status = "Status"
steps = "Steps"
trigger = "Trigger"
[portal.pipelines.trigger]
folder-watch = "Folder watch"
@@ -8722,9 +8740,9 @@ unused = "Unused"
[portal.sources.table]
documents = "Documents"
open = "Open"
source = "Source"
status = "Status"
type = "Type"
usedBy = "Policies"
[portal.sources.types.box]
@@ -10948,6 +10966,13 @@ approver = "Approves policy"
editor = "Editor"
processor = "Processor"
[users.columns]
capabilities = "Capabilities"
email = "Email"
person = "Person"
role = "Role"
status = "Status"
[users.confirm]
cancelInviteBody = "Cancel the invitation to {{email}}? They won't be able to join with the current link."
cancelInviteTitle = "Cancel invitation"
@@ -10967,10 +10992,8 @@ title = "No members yet"
addToTeam = "Add to team"
guestCount = "{{count}} guest"
guests = "Guests"
guestsDesc = "External collaborators, scoped to what you shared. Editor only."
ledBy = "led by {{owner}}"
org = "Organization"
orgDesc = "Owners with org-wide authority and policy approval"
owners = "{{count}} owner"
team = "{{name}} team"
teamMeta = "{{count}} people"
@@ -11010,13 +11033,15 @@ usernamePlaceholder = "jsmith"
[users.invites]
by = "Invited by {{who}}"
cancel = "Cancel"
count = "{{count}} pending"
desc = "Invited people who haven't joined yet. They hold a seat until they accept."
expiresInDays_one = "Expires in {{count}} day"
expiresInDays_other = "Expires in {{count}} days"
expiresToday = "Expires today"
title = "Pending invitations"
[users.invites.columns]
expires = "Expires"
invitee = "Invitee"
[users.loadError]
description = "Something went wrong reaching the backend, or you don't have access. Try again."
title = "Couldn't load members"
@@ -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\//,
@@ -3,7 +3,7 @@ import { Group, Text } from "@mantine/core";
import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
import { InfoBanner } from "@app/components/shared/InfoBanner";
import { AppBanner } from "@app/components/shared/AppBanner";
import { useSaaSTeam } from "@app/contexts/SaaSTeamContext";
/**
@@ -105,7 +105,7 @@ export function TeamInvitationBanner() {
);
return (
<InfoBanner
<AppBanner
icon="mail"
message={
<Group
@@ -120,10 +120,6 @@ export function TeamInvitationBanner() {
}
show={shouldShow}
dismissible={false}
background="var(--mantine-color-dark-7)"
borderColor="var(--mantine-color-dark-5)"
textColor="rgba(255, 255, 255, 0.95)"
iconColor="rgba(255, 255, 255, 0.95)"
/>
);
}
@@ -12,6 +12,7 @@ import {
formatPeriodDate,
MeterBar,
meterState,
remainingMeter,
} from "@app/billing";
import "@app/components/shared/config/configSections/Payg.css";
import "@app/components/shared/config/configSections/PaygFree.css";
@@ -48,7 +49,8 @@ export function useFreeSnapshot(): FreeSnapshot {
export function FreeMeterPanel({ snap }: { snap: FreeSnapshot }) {
const { t } = useTranslation();
const { state, pct } = meterState(snap.billableUsed, snap.billableLimit);
const remaining = Math.max(0, snap.billableLimit - snap.billableUsed);
const { state, pct } = remainingMeter(remaining, snap.billableLimit);
const stateLabel =
state === "DEGRADED"
? t("payg.free.state.limitReached", "Limit reached")
@@ -60,9 +62,9 @@ export function FreeMeterPanel({ snap }: { snap: FreeSnapshot }) {
<MeterBar
state={state}
pct={pct}
barLabel={t("payg.free.hero.barAria", "Free PDFs used")}
figure={snap.billableUsed.toLocaleString()}
capSuffix={t("payg.free.hero.capSuffix", "/ {{limit}} free PDFs", {
barLabel={t("payg.free.hero.barAria", "Free PDFs remaining")}
figure={remaining.toLocaleString()}
capSuffix={t("payg.free.hero.capSuffix", "of {{limit}} free PDFs left", {
limit: snap.billableLimit.toLocaleString(),
})}
statusLabel={stateLabel}
@@ -175,15 +177,15 @@ export function prepaidSnapshotFromWallet(
}
/**
* Prepaid capacity meter. The bar fills as the pool is drawn down ({@code used =
* total remaining}), so it WARNs when the pool is running low and DEGRADEs once
* exhausted — same bands as the free/cap meters. Prepaid is consumed ahead of the
* meter and outside the spend cap, so it reads as its own dimension.
* Prepaid capacity meter. The bar shows what is left and drains towards empty,
* while the bands still key on what is gone ({@code used = total remaining}), so
* it WARNs when the pool is running low and DEGRADEs once exhausted — same bands
* as the free/cap meters. Prepaid is consumed ahead of the meter and outside the
* spend cap, so it reads as its own dimension.
*/
export function PrepaidCapacityMeterPanel({ snap }: { snap: PrepaidSnapshot }) {
const { t } = useTranslation();
const used = Math.max(0, snap.total - snap.remaining);
const { state, pct } = meterState(used, snap.total);
const { state, pct } = remainingMeter(snap.remaining, snap.total);
const stateLabel =
state === "DEGRADED"
? t("payg.prepaid.state.exhausted", "Used up")
@@ -0,0 +1,50 @@
import { useEffect, useState } from "react";
import { useWallet } from "@app/hooks/useWallet";
import {
readCachedCredits,
writeCachedCredits,
type CachedCredits,
} from "@app/services/navFooterCache";
import { type NavFooterCredits } from "@app/components/shared/navFooter/NavFooterCreditsRow";
/** The wallet reduced to what the footer shows: figures, or null for a payer. */
function toCredits(
status: string,
freeRemaining: number,
freeAllowance: number,
): CachedCredits {
// Free teams only. The grant is a lifetime pool that survives subscribing, so
// a paying team would otherwise sit on a permanent "0 of 500" in red while
// nothing is wrong. Plan draws the same line — subscribed teams get the
// spend-vs-cap meter there, and admins get usage in the processor.
if (status === "subscribed") return null;
return { remaining: freeRemaining, total: freeAllowance };
}
/**
* Cloud builds read the free grant off the live wallet — the same snapshot the
* Plan page's free meter renders, so the sidebar and Plan can't disagree.
*
* Seeded from the last figures this browser saw, so the row is right at first
* paint and stays put while the wallet refetches underneath. Only a browser
* that has never loaded a wallet has nothing to show, and that one time the row
* animates in. The seed is read once, at first render: later reads would fight
* the live value, and the whole point is that the row stops moving.
*/
export function useFreeCreditsSummary(): NavFooterCredits | null {
const { wallet } = useWallet();
const [seed] = useState(readCachedCredits);
const live = wallet
? toCredits(wallet.status, wallet.freeRemaining, wallet.freeAllowance)
: undefined;
// useWallet reuses the snapshot reference when nothing changed, so keying on
// it writes only on a real change, not on every render.
useEffect(() => {
if (live !== undefined) writeCachedCredits(live);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [wallet]);
return (live !== undefined ? live : seed) ?? null;
}
@@ -0,0 +1,13 @@
import { useCallback } from "react";
import { useNavigate } from "react-router-dom";
/**
* Cloud editor builds open the settings modal on its Plan section, which is
* where the free grant is explained and the Processor plan is switched on.
* Routed rather than called directly because the modal is URL-driven here
* (`/settings/*`), the same path the admin tour uses to open it.
*/
export function useOpenPlan(): (() => void) | null {
const navigate = useNavigate();
return useCallback(() => navigate("/settings/plan"), [navigate]);
}
+86 -4
View File
@@ -32,6 +32,14 @@
* promise see the UI flip exactly once the new state is visible — no
* intermediate flash of the old value.
*
* <h2>Freshness</h2>
*
* The figures drain as metered work runs, so a mounted consumer re-reads the
* wallet every {@link WALLET_POLL_MS} and again whenever the tab regains
* visibility. Those refreshes are silent — they leave {@code loading} and
* {@code error} alone and only commit fresher data — so consumers that gate on
* those flags don't flicker on a background tick.
*
* <h2>Dev preview fallback</h2>
*
* When the hook is rendered outside the saas app (e.g. on {@code
@@ -178,6 +186,13 @@ function reuseIfEqual(prev: Wallet | null, next: Wallet): Wallet {
return prev;
}
/**
* How often a mounted consumer re-reads the wallet. Matches the app query
* client's staleTime, so the sidebar meter and anything cached elsewhere age
* out on the same clock.
*/
const WALLET_POLL_MS = 30_000;
export function useWallet(): UseWalletResult {
// Resolved once: the dev-preview side-channel when rendered outside the real
// app (saas /dev/payg-preview route), else null (every real build + desktop).
@@ -201,13 +216,29 @@ export function useWallet(): UseWalletResult {
// "the request fired." Cleared when no load is pending.
const inFlight = useRef<Promise<void> | null>(null);
// Set for refreshes the user didn't ask for (the poll below). Silence governs
// whether a load may RAISE `loading` / `error`, never whether it may clear
// them: consumers gate on both — the limit modals do
// `if (loading || !wallet) return null`, and Plan swaps in an error alert —
// so a background tick must not blink an open modal out or replace a working
// page over a transient failure. Clearing is always the latest request's job,
// silent or not; a silent load that skipped the clear would strand `loading`
// true after superseding a visible one, which suppresses those modals for the
// rest of the session.
const silentRefresh = useRef(false);
useEffect(() => {
const reqId = ++latestReqId.current;
let cancelled = false;
const silent = silentRefresh.current;
silentRefresh.current = false;
const promise = (async () => {
setLoading(true);
setError(null);
if (!silent) {
setLoading(true);
setError(null);
}
if (devPreview) {
const synth = devPreview.buildWallet(devPreview.role());
@@ -221,11 +252,22 @@ export function useWallet(): UseWalletResult {
const res = await apiClient.get<Wallet>("/api/v1/payg/wallet");
if (cancelled || reqId !== latestReqId.current) return;
setWallet((prev) => reuseIfEqual(prev, res.data));
// Fresh data retires any earlier failure, including one a silent poll
// is recovering from — otherwise Plan keeps its alert over good data.
setError(null);
} catch (e: unknown) {
if (cancelled || reqId !== latestReqId.current) return;
console.warn("[useWallet] fetch failed", e);
setError(e instanceof Error ? e.message : "Failed to load wallet");
if (!silent) {
console.warn("[useWallet] fetch failed", e);
setError(e instanceof Error ? e.message : "Failed to load wallet");
}
// A failed background refresh is a non-event: the last good snapshot
// stands and the next tick self-heals, so it neither surfaces nor
// logs — otherwise an offline tab warns every WALLET_POLL_MS.
} finally {
// Deliberately not gated on `silent`: whichever load is latest owns
// settling the flag, or a silent refresh that supersedes a visible one
// leaves it stuck true.
if (!cancelled && reqId === latestReqId.current) {
setLoading(false);
}
@@ -242,6 +284,46 @@ export function useWallet(): UseWalletResult {
};
}, [devPreview, refetchTick]);
// The wallet drains as automation, AI and API work runs, so a figure fetched
// on mount goes stale while the user watches it. Refresh on a timer, and
// immediately on returning to the tab — coming back to a stale number is the
// case people actually notice. Hidden tabs don't poll, and the dev-preview
// wallet is synthesised locally so there is nothing to re-read.
useEffect(() => {
if (devPreview) return;
let timer: ReturnType<typeof setInterval> | undefined;
const refresh = () => {
silentRefresh.current = true;
setRefetchTick((t) => t + 1);
};
const stop = () => {
if (timer !== undefined) {
clearInterval(timer);
timer = undefined;
}
};
const start = () => {
stop();
timer = setInterval(refresh, WALLET_POLL_MS);
};
const onVisibilityChange = () => {
if (document.visibilityState === "visible") {
refresh();
start();
} else {
stop();
}
};
if (document.visibilityState === "visible") start();
document.addEventListener("visibilitychange", onVisibilityChange);
return () => {
stop();
document.removeEventListener("visibilitychange", onVisibilityChange);
};
}, [devPreview]);
const refetch = useCallback(async () => {
setRefetchTick((t) => t + 1);
// Snapshot the next-tick promise so the caller awaits this refetch
@@ -4,7 +4,7 @@ import { AppLayout } from "@app/components/AppLayout";
import { BannerProvider, useBanner } from "@app/contexts/BannerContext";
import { NavigationProvider } from "@app/contexts/NavigationContext";
import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider";
import { InfoBanner } from "@app/components/shared/InfoBanner";
import { AppBanner } from "@app/components/shared/AppBanner";
const meta = {
title: "Components/AppLayout",
@@ -49,7 +49,7 @@ function BannerSetter() {
const { setBanner } = useBanner();
useEffect(() => {
setBanner(
<InfoBanner
<AppBanner
icon="info-rounded"
title="Heads up"
message="This workspace is running in offline mode."
@@ -0,0 +1,115 @@
/* App-wide top bar. One shape, four tones — callers pick a tone, never a colour.
Named `app-banner`, not `sui-banner`: that belongs to the SUI Banner primitive. */
.app-banner {
display: flex;
align-items: center;
gap: 0.75rem;
min-height: 3.5rem;
padding: 0.75rem 1rem;
/* Full-bleed across the top of the app: square corners, one rule underneath. */
border-bottom: 1px solid var(--app-banner-border);
background: var(--app-banner-bg);
color: var(--c-text);
}
.app-banner--compact {
min-height: 2.75rem;
padding: 0.5rem 1rem;
gap: 0.5rem;
}
.app-banner--info {
--app-banner-bg: var(--c-primary-subtle);
--app-banner-border: var(--c-primary-border);
--app-banner-icon: var(--c-accent-fg, var(--c-primary));
}
/* The one bar meant to pop, so it takes the feature gradient rather than a tint.
Fixed hues by design — it doesn't follow the chosen accent. */
.app-banner--promo {
--app-banner-bg: linear-gradient(
135deg,
var(--c-hue-indigo) 0%,
var(--c-hue-purple) 100%
);
--app-banner-border: transparent;
--app-banner-icon: var(--color-text-on-accent);
color: var(--color-text-on-accent);
}
.app-banner--warning {
--app-banner-bg: var(--c-warning-subtle);
--app-banner-border: color-mix(in srgb, var(--c-warning) 32%, transparent);
--app-banner-icon: var(--c-warning);
}
.app-banner--danger {
--app-banner-bg: var(--c-danger-subtle);
--app-banner-border: color-mix(in srgb, var(--c-danger) 32%, transparent);
--app-banner-icon: var(--c-danger);
}
/* Only the icon carries the tone; text stays neutral in every tone. */
.app-banner__icon {
display: flex;
flex-shrink: 0;
align-items: center;
color: var(--app-banner-icon);
}
.app-banner__body {
display: flex;
flex: 1;
min-width: 0;
flex-direction: column;
gap: 0.125rem;
}
.app-banner__title {
font-size: 0.875rem;
font-weight: 600;
}
.app-banner__message {
font-size: 0.8125rem;
line-height: 1.4;
color: var(--c-text-muted);
}
.app-banner__body:not(:has(.app-banner__title)) .app-banner__message {
color: inherit;
font-weight: 500;
}
.app-banner--compact .app-banner__title,
.app-banner--compact .app-banner__message {
font-size: 0.75rem;
}
/* On the gradient everything is white; muted grey would disappear. */
.app-banner--promo .app-banner__message,
.app-banner--promo .app-banner__actions .sui-btn--tertiary,
.app-banner--promo .app-banner__actions .sui-ai {
color: var(--color-text-on-accent);
}
/* Lifts the premium CTA off the gradient it sits on. */
.app-banner--promo .app-banner__actions .sui-btn--primary {
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
}
.app-banner__actions {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 0.5rem;
}
/* Mantine trims the leading padding when a button has a left section, which reads
as off-centre next to the label. Even it back up. */
.app-banner__actions .sui-btn .mantine-Button-inner {
padding-inline: 0;
}
.app-banner__actions .sui-btn {
padding-inline: 0.875rem;
}
@@ -0,0 +1,194 @@
import type { ReactNode } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { AppBanner } from "@app/components/shared/AppBanner";
const meta = {
title: "Shared/AppBanner",
component: AppBanner,
parameters: { layout: "fullscreen" },
} satisfies Meta<typeof AppBanner>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Info: Story = {
args: {
icon: "info-rounded",
title: "Heads up",
message: "This document contains form fields that will be flattened.",
},
};
export const Promo: Story = {
args: {
tone: "promo",
icon: "stars-rounded",
title: "Upgrade to Server Plan",
message:
"Get the most out of Stirling PDF with unlimited users and advanced features.",
buttonText: "Upgrade Now",
buttonIcon: "upgrade-rounded",
onButtonClick: () => {},
compact: true,
},
};
export const Warning: Story = {
args: {
tone: "warning",
icon: "warning-rounded",
title: "Action required",
message: "Some pages could not be processed and were skipped.",
buttonText: "Review",
onButtonClick: () => {},
},
};
export const Danger: Story = {
args: {
tone: "danger",
icon: "warning-rounded",
title: "This server needs admin attention",
message: "Review the license requirements to keep this server compliant.",
buttonText: "See info",
buttonIcon: "info-rounded",
onButtonClick: () => {},
dismissible: false,
},
};
export const Compact: Story = {
args: {
compact: true,
icon: "info-rounded",
message: "Autosave is enabled for this file.",
dismissible: false,
},
};
/** Message-only, no title: the message takes the title's weight so the bar still reads. */
export const MessageOnly: Story = {
args: {
icon: "picture-as-pdf-rounded",
message:
"Make Stirling PDF your default application for opening PDF files.",
buttonText: "Set Default",
onButtonClick: () => {},
secondaryButtonText: "Don't remind me again",
onSecondaryButtonClick: () => {},
},
};
function Row({ caption, children }: { caption: string; children: ReactNode }) {
return (
<section
style={{ display: "flex", flexDirection: "column", gap: "0.375rem" }}
>
<span
style={{
fontSize: "0.6875rem",
fontWeight: 600,
letterSpacing: "0.04em",
textTransform: "uppercase",
color: "var(--c-text-subtle)",
padding: "0 1rem",
}}
>
{caption}
</span>
{children}
</section>
);
}
/**
* Every top bar the app can show, in one place: each entry mirrors a real caller,
* so a change to the component is visible against the whole set at once. Renders a
* composition rather than the component, so it takes no args of its own.
*/
export const AllTopBars: StoryObj = {
render: () => (
<div
style={{
display: "flex",
flexDirection: "column",
gap: "1.5rem",
padding: "1.5rem 0",
background: "var(--c-bg)",
}}
>
<Row caption="Upgrade prompt · UpgradeBanner (friendly)">
<AppBanner
tone="promo"
compact
icon="stars-rounded"
title="Upgrade to Server Plan"
message="Get the most out of Stirling PDF with unlimited users and advanced features."
buttonText="Upgrade Now"
buttonIcon="upgrade-rounded"
onButtonClick={() => {}}
/>
</Row>
<Row caption="Server needs attention · UpgradeBanner (urgent)">
<AppBanner
tone="warning"
icon="warning-rounded"
title="This server needs admin attention"
message="Review the license requirements to keep this server compliant."
buttonText="See info"
buttonIcon="info-rounded"
onButtonClick={() => {}}
dismissible={false}
/>
</Row>
<Row caption="Free tier limit reached · AdminPlanSection">
<AppBanner
tone="warning"
icon="warning-rounded"
title="Free self-hosted limit reached"
message="You have 12 users on a plan that covers 10."
buttonText="See plans"
buttonIcon="upgrade-rounded"
onButtonClick={() => {}}
dismissible={false}
/>
</Row>
<Row caption="Team invitation · TeamInvitationBanner">
<AppBanner
icon="mail"
message="You have been invited to join the Acme Legal team."
buttonText="Accept"
onButtonClick={() => {}}
secondaryButtonText="Decline"
onSecondaryButtonClick={() => {}}
dismissible={false}
/>
</Row>
<Row caption="Set as default app · DefaultAppBanner (desktop)">
<AppBanner
icon="picture-as-pdf-rounded"
message="Make Stirling PDF your default application for opening PDF files."
buttonText="Set Default"
onButtonClick={() => {}}
secondaryButtonText="Don't remind me again"
onSecondaryButtonClick={() => {}}
/>
</Row>
<Row caption="Danger tone (available, no caller yet)">
<AppBanner
tone="danger"
icon="warning-rounded"
title="Storage is full"
message="New uploads will fail until space is freed."
buttonText="Manage storage"
onButtonClick={() => {}}
dismissible={false}
/>
</Row>
</div>
),
};
@@ -0,0 +1,124 @@
import React, { ReactNode } from "react";
import { Button } from "@app/ui/Button";
import { ActionIcon } from "@app/ui/ActionIcon";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
import "@app/components/shared/AppBanner.css";
/** Picks the whole look. Callers choose meaning, never colours. */
export type AppBannerTone = "info" | "promo" | "warning" | "danger";
/** Tone decides the button too, so the CTA can't drift from the bar it sits on. */
const TONE_BUTTON = {
info: { variant: "secondary", accent: "default" },
promo: { variant: "primary", accent: "premium" },
warning: { variant: "primary", accent: "warning" },
danger: { variant: "primary", accent: "danger" },
} as const;
interface AppBannerProps {
/** A LocalIcon name, or a pre-rendered node (e.g. a logo) dropped in as-is. */
icon?: string | ReactNode;
title?: ReactNode;
message: ReactNode;
buttonText?: string;
buttonIcon?: string;
onButtonClick?: () => void;
/** Muted secondary action, e.g. "Don't remind me again". */
secondaryButtonText?: string;
onSecondaryButtonClick?: () => void;
onDismiss?: () => void;
dismissible?: boolean;
loading?: boolean;
show?: boolean;
tone?: AppBannerTone;
compact?: boolean;
}
/** The app's top bar: dismissible messaging above the workspace. */
export const AppBanner: React.FC<AppBannerProps> = ({
icon,
title,
message,
buttonText,
buttonIcon = "check-circle-rounded",
onButtonClick,
secondaryButtonText,
onSecondaryButtonClick,
onDismiss,
dismissible = true,
loading = false,
show = true,
tone = "info",
compact = false,
}) => {
const { t } = useTranslation();
if (!show) return null;
const iconSize = compact ? "1rem" : "1.25rem";
return (
<div
className={[
"app-banner",
`app-banner--${tone}`,
compact ? "app-banner--compact" : "",
]
.filter(Boolean)
.join(" ")}
>
{icon != null && (
<span className="app-banner__icon" aria-hidden>
{typeof icon === "string" ? (
<LocalIcon icon={icon} width={iconSize} height={iconSize} />
) : (
icon
)}
</span>
)}
<div className="app-banner__body">
{title && <span className="app-banner__title">{title}</span>}
<span className="app-banner__message">{message}</span>
</div>
<div className="app-banner__actions">
{buttonText && onButtonClick && (
<Button
variant={TONE_BUTTON[tone].variant}
accent={TONE_BUTTON[tone].accent}
size="sm"
loading={loading}
onClick={onButtonClick}
leftSection={
<LocalIcon icon={buttonIcon} width="0.9rem" height="0.9rem" />
}
>
{buttonText}
</Button>
)}
{secondaryButtonText && onSecondaryButtonClick && (
<Button
variant="tertiary"
accent="neutral"
size="sm"
onClick={onSecondaryButtonClick}
>
{secondaryButtonText}
</Button>
)}
{dismissible && (
<ActionIcon
variant="tertiary"
accent="neutral"
size="sm"
onClick={() => onDismiss?.()}
aria-label={t("appBanner.dismiss", "Dismiss")}
>
<LocalIcon icon="close-rounded" width="1rem" height="1rem" />
</ActionIcon>
)}
</div>
</div>
);
};
@@ -48,9 +48,55 @@
transform: matrix(0.483871, -0.017568, 0, 0.338028, 23.887097, 26.886428);
}
/* One-shot "thinking" drift — the two parallelograms swap past each other and
settle back. Same motion the chat FAB loops while the agent works, but this
pair starts and ends at rest (translate 0, full opacity) so a single
iteration can end without snapping. Callers apply it for one beat; see
NavFooter.css for the hover use. */
@keyframes sui-brandmark-drift-a {
0%,
100% {
transform: translate(0, 0);
opacity: 1;
}
25% {
transform: translate(-1px, -5px);
opacity: 0.55;
}
50% {
transform: translate(-6px, 0);
opacity: 0.9;
}
75% {
transform: translate(-1px, 5px);
opacity: 0.6;
}
}
@keyframes sui-brandmark-drift-b {
0%,
100% {
transform: translate(0, 0);
opacity: 1;
}
25% {
transform: translate(1px, 5px);
opacity: 0.85;
}
50% {
transform: translate(6px, 0);
opacity: 0.5;
}
75% {
transform: translate(1px, -5px);
opacity: 0.85;
}
}
@media (prefers-reduced-motion: reduce) {
.sui-brandmark__a,
.sui-brandmark__b {
transition: none;
animation: none;
}
}
@@ -75,16 +75,13 @@
padding: 0.25rem 0;
overflow: hidden;
}
.file-sidebar-footer-box {
padding: 0.25rem 0;
flex-shrink: 0;
}
/* The footer is the shared <NavFooter>: it brings its own boxes and padding,
so this class only positions it in the column. */
/* Collapsed rail: the file tree isn't rendered, so hide its (empty) box and
let the boxes stack at the top — controls, then the settings footer right
after — instead of the files box stretching to fill. */
.file-sidebar[data-collapsed="true"] .file-sidebar-controls,
.file-sidebar[data-collapsed="true"] .file-sidebar-footer-box {
.file-sidebar[data-collapsed="true"] .file-sidebar-controls {
padding: 0.25rem;
}
.file-sidebar[data-collapsed="true"] .file-sidebar-files-box {
@@ -538,86 +535,3 @@
pointer-events: none;
animation: none;
}
/* ---- Bottom bar (user + settings) ---- */
.file-sidebar-bottom-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 6px;
flex-shrink: 0;
min-height: 40px;
}
/* Bottom bar settings icon tracks the right edge during collapse animation */
.file-sidebar-bottom-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
background-color: var(--c-accent-text);
color: var(--c-text-on-primary);
font-size: 12px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
user-select: none;
overflow: hidden;
}
/* No colored disc behind an actual photo; keep it for the initials fallback. */
.file-sidebar-bottom-avatar--picture {
background-color: transparent;
}
.file-sidebar-bottom-avatar-img {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
}
.file-sidebar-bottom-name {
flex: 1;
font-size: 13px;
font-weight: 500;
color: var(--c-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.file-sidebar-bottom-bar[role="button"]:hover {
background-color: var(--c-hover);
}
.file-sidebar-bottom-bar[role="button"]:focus-visible {
outline: 2px solid var(--c-primary);
outline-offset: -2px;
}
.file-sidebar-bottom-settings {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 6px;
color: var(--c-text-subtle);
padding: 0;
flex-shrink: 0;
margin-left: auto;
}
.file-sidebar[data-collapsed="true"] .file-sidebar-bottom-settings {
width: 32px;
height: 32px;
}
.file-sidebar[data-collapsed="true"] .file-sidebar-bottom-bar {
justify-content: center;
padding: 8px 0;
}
@@ -22,13 +22,15 @@ import {
} from "@app/contexts/NavigationContext";
import { useViewer } from "@app/contexts/ViewerContext";
import { useFileHandler } from "@app/hooks/useFileHandler";
import { useAuth } from "@app/auth/UseSession";
import { useProfilePictureUrl } from "@app/hooks/useProfilePictureUrl";
import { useAccountIdentity } from "@app/hooks/useAccountIdentity";
import { useFreeCreditsSummary } from "@app/hooks/useFreeCreditsSummary";
import { useOtherAppSwitch } from "@app/hooks/useOtherAppSwitch";
import { useOpenPlan } from "@app/hooks/useOpenPlan";
import { NavFooter } from "@app/components/shared/navFooter/NavFooter";
import {
useIndexedDB,
useIndexedDBRevision,
} from "@app/contexts/IndexedDBContext";
import { accountService } from "@app/services/accountService";
import { GoogleDriveIcon } from "@app/components/shared/CloudStorageIcons";
import { AppSwitcher } from "@app/components/shared/AppSwitcher";
import { SidebarToggleIcon } from "@app/components/shared/SidebarToggleIcon";
@@ -37,8 +39,7 @@ import FolderOpenIcon from "@mui/icons-material/FolderOpen";
import FolderSpecialIcon from "@mui/icons-material/FolderSpecial";
import UploadFileIcon from "@mui/icons-material/UploadFile";
import AddIcon from "@mui/icons-material/Add";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import SettingsIcon from "@mui/icons-material/Settings";
import OpenInFullIcon from "@mui/icons-material/OpenInFull";
import type { FileId } from "@app/types/file";
import { FileItem } from "@app/components/shared/FileSidebarFileItem";
import { useLabelName } from "@app/data/labelDisplay";
@@ -241,43 +242,11 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
const { addFiles } = useFileHandler();
const indexedDB = useIndexedDB();
// Each auth layer derives its own displayName from its native user shape.
// Fall back to the proprietary REST endpoint only when the auth
// context yields nothing - then to "User" as a generic last resort.
const { displayName: authDisplayName, isAnonymous } = useAuth();
const [accountUsername, setAccountUsername] = useState<string | null>(null);
const displayName =
authDisplayName ?? accountUsername ?? t("auth.displayName.user", "User");
const profilePictureUrl = useProfilePictureUrl();
const [pictureFailed, setPictureFailed] = useState(false);
useEffect(() => setPictureFailed(false), [profilePictureUrl]);
const showProfilePicture = !!profilePictureUrl && !pictureFailed;
useEffect(() => {
if (!config?.enableLogin) {
setAccountUsername(null);
return;
}
if (authDisplayName) {
// The auth context has a name; don't bother hitting the REST
// endpoint, but clear any stale cached value from a prior call.
setAccountUsername(null);
return;
}
accountService
.getAccountData()
.then((data) => {
// Always reflect the latest result - including clearing it on
// sign-out, when the endpoint returns no username (or 401s into
// the catch branch below). Without this, signing out would leave
// the old username on screen.
setAccountUsername(data?.username ?? null);
})
.catch(() => {
setAccountUsername(null);
});
}, [config?.enableLogin, authDisplayName]);
const { displayName, profilePictureUrl, isAnonymous } =
useAccountIdentity();
const credits = useFreeCreditsSummary();
const otherApp = useOtherAppSwitch();
const openPlan = useOpenPlan();
// Leaf files = user-visible files (excludes intermediate tool outputs)
const [allFileStubs, setAllFileStubs] = useState<StirlingFileStub[]>([]);
@@ -1115,7 +1084,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
)}
data-testid="open-files-page"
>
<OpenInNewIcon sx={{ fontSize: "1rem" }} />
<OpenInFullIcon sx={{ fontSize: "1rem" }} />
</ActionIcon>
<ActionIcon
variant="quiet"
@@ -1264,70 +1233,17 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
{/* Getting-started checklist, floating above the footer (SaaS only). */}
<SidebarChecklistSlot collapsed={collapsed} />
{/* Box 3 — account footer (avatar + name + settings). */}
<NavSurface className="file-sidebar-footer-box">
{/* Bottom bar: user name + settings */}
<Tooltip
label={
onOpenSettings
? `${displayName} - ${t("fileSidebar.openSettings", "Open settings")}`
: displayName
}
position="right"
withinPortal
disabled={!collapsed}
>
<div
className="file-sidebar-bottom-bar"
onClick={onOpenSettings}
role={onOpenSettings ? "button" : undefined}
tabIndex={onOpenSettings ? 0 : undefined}
onKeyDown={
onOpenSettings
? (e) => e.key === "Enter" && onOpenSettings()
: undefined
}
data-testid={onOpenSettings ? "config-button" : undefined}
data-tour={onOpenSettings ? "config-button" : undefined}
aria-label={
onOpenSettings
? t("fileSidebar.openSettings", "Open settings")
: displayName
}
style={onOpenSettings ? { cursor: "pointer" } : undefined}
>
<div
className={`file-sidebar-bottom-avatar${
showProfilePicture
? " file-sidebar-bottom-avatar--picture"
: ""
}`}
aria-label={displayName}
>
{showProfilePicture ? (
<img
src={profilePictureUrl}
alt=""
className="file-sidebar-bottom-avatar-img"
onError={() => setPictureFailed(true)}
/>
) : (
displayName.charAt(0).toUpperCase()
)}
</div>
{!collapsed && (
<span className="file-sidebar-bottom-name sidebar-content-fade">
{displayName}
</span>
)}
{onOpenSettings && !collapsed && (
<div className="file-sidebar-bottom-settings">
<SettingsIcon sx={{ fontSize: "1.1rem" }} />
</div>
)}
</div>
</Tooltip>
</NavSurface>
{/* Box 3 — the shared footer: credits, app switch, account row. */}
<NavFooter
className="file-sidebar-footer-box"
displayName={displayName}
profilePictureUrl={profilePictureUrl}
onOpenSettings={onOpenSettings}
credits={credits}
onOpenPlan={openPlan ?? undefined}
otherApp={otherApp}
collapsed={collapsed}
/>
</div>
);
},
@@ -1,38 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { InfoBanner } from "@app/components/shared/InfoBanner";
const meta = {
title: "Shared/InfoBanner",
component: InfoBanner,
parameters: { layout: "padded" },
} satisfies Meta<typeof InfoBanner>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
icon: "info-rounded",
title: "Heads up",
message: "This document contains form fields that will be flattened.",
},
};
export const Warning: Story = {
args: {
tone: "warning",
icon: "warning-rounded",
title: "Action required",
message: "Some pages could not be processed and were skipped.",
buttonText: "Review",
onButtonClick: () => {},
},
};
export const Compact: Story = {
args: {
compact: true,
icon: "info-rounded",
message: "Autosave is enabled for this file.",
dismissible: false,
},
};
@@ -1,263 +0,0 @@
import React, { ReactNode } from "react";
import { Paper, Group, Text, Stack } from "@mantine/core";
import { Button, type ButtonVariant, type ButtonAccent } from "@app/ui/Button";
import { ActionIcon } from "@app/ui/ActionIcon";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
type InfoBannerTone = "info" | "warning";
const toneStyles: Record<
InfoBannerTone,
{
background: string;
border: string;
text: string;
icon: string;
buttonColor: string;
}
> = {
info: {
background: "var(--mantine-color-blue-0)",
border: "var(--mantine-color-blue-2)",
text: "var(--mantine-color-blue-9)",
icon: "var(--mantine-color-blue-6)",
buttonColor: "blue",
},
warning: {
background: "var(--mantine-color-orange-0)",
border: "var(--mantine-color-orange-3)",
text: "var(--color-amber-dark)",
icon: "var(--mantine-color-orange-7)",
buttonColor: "orange",
},
};
function toSharedButtonVariant(
variant: "light" | "filled" | "white" | "outline" | "subtle",
): ButtonVariant {
switch (variant) {
case "filled":
return "primary";
case "outline":
return "secondary";
case "subtle":
return "tertiary";
case "light":
case "white":
default:
return "secondary";
}
}
function toSharedButtonAccent(color: string | undefined): ButtonAccent {
// Mantine colours may carry a shade suffix (e.g. "orange.7"); use the hue.
const hue = (color ?? "").split(".")[0];
switch (hue) {
case "red":
return "danger";
case "green":
return "success";
case "yellow":
case "orange":
return "warning";
case "blue":
default:
return "default";
}
}
interface InfoBannerProps {
/**
* Either a LocalIcon name (string) for the standard sized icon slot, or a
* pre-rendered ReactNode (e.g. a logo image) which is dropped in as-is.
*/
icon?: string | ReactNode;
title?: ReactNode;
message: ReactNode;
buttonText?: string;
buttonIcon?: string;
onButtonClick?: () => void;
/** Optional muted secondary action (e.g. "Don't remind me again"). */
secondaryButtonText?: string;
onSecondaryButtonClick?: () => void;
onDismiss?: () => void;
dismissible?: boolean;
loading?: boolean;
show?: boolean;
tone?: InfoBannerTone;
background?: string;
borderColor?: string;
textColor?: string;
iconColor?: string;
buttonColor?: string;
buttonVariant?: "light" | "filled" | "white" | "outline" | "subtle";
/** Override the button label colour (for dark/custom theme variants). */
buttonTextColor?: string;
minHeight?: number | string;
closeIconColor?: string;
compact?: boolean;
}
/**
* Generic info banner component for displaying dismissible messages at the top of the app
*/
export const InfoBanner: React.FC<InfoBannerProps> = ({
icon,
title,
message,
buttonText,
buttonIcon = "check-circle-rounded",
onButtonClick,
secondaryButtonText,
onSecondaryButtonClick,
onDismiss,
dismissible = true,
loading = false,
show = true,
tone = "info",
background,
borderColor,
textColor,
iconColor,
buttonColor,
buttonVariant = "light",
buttonTextColor,
minHeight = 56,
closeIconColor,
compact = false,
}) => {
const { t } = useTranslation();
if (!show) {
return null;
}
const toneStyle = toneStyles[tone] ?? toneStyles.info;
const resolvedTextColor = textColor ?? toneStyle.text;
const handleDismiss = () => {
onDismiss?.();
};
const iconSize = compact ? "1rem" : "1.2rem";
const textSize = compact ? "xs" : "sm";
return (
<Paper
p={compact ? "xs" : "sm"}
radius={0}
style={{
background: background ?? toneStyle.background,
border: "none",
borderBottom:
borderColor === "transparent"
? "none"
: `1px solid ${borderColor ?? toneStyle.border}`,
minHeight,
display: "flex",
alignItems: "center",
}}
>
<Group
gap="sm"
align="center"
wrap="nowrap"
justify="space-between"
style={{ width: "100%" }}
>
<Group
gap={compact ? "xs" : "sm"}
align="center"
wrap="nowrap"
style={{ flex: 1, minWidth: 0 }}
>
{icon != null &&
(typeof icon === "string" ? (
<LocalIcon
icon={icon}
width={iconSize}
height={iconSize}
style={{ color: iconColor ?? toneStyle.icon, flexShrink: 0 }}
/>
) : (
<div
style={{ flexShrink: 0, display: "flex", alignItems: "center" }}
>
{icon}
</div>
))}
<Stack gap={compact ? 1 : 2} style={{ flex: 1, minWidth: 0 }}>
{title && (
<Text
fw={600}
size={textSize}
style={{ color: resolvedTextColor }}
>
{title}
</Text>
)}
<Text
fw={title ? 400 : 500}
size={textSize}
style={{ color: resolvedTextColor }}
lineClamp={compact ? 1 : 2}
>
{message}
</Text>
</Stack>
</Group>
<Group gap="xs" align="center" wrap="nowrap">
{buttonText && onButtonClick && (
<Button
variant={toSharedButtonVariant(buttonVariant)}
accent={toSharedButtonAccent(
buttonColor ?? toneStyle.buttonColor,
)}
size="sm"
loading={loading}
onClick={onButtonClick}
leftSection={
<LocalIcon
icon={buttonIcon}
width={compact ? "0.75rem" : "0.9rem"}
height={compact ? "0.75rem" : "0.9rem"}
/>
}
style={buttonTextColor ? { color: buttonTextColor } : undefined}
>
{buttonText}
</Button>
)}
{secondaryButtonText && onSecondaryButtonClick && (
<Button
variant="tertiary"
accent="neutral"
size="sm"
onClick={onSecondaryButtonClick}
style={{ color: "var(--c-text-muted)" }}
>
{secondaryButtonText}
</Button>
)}
{dismissible && (
<ActionIcon
variant="tertiary"
accent="neutral"
size="sm"
onClick={handleDismiss}
aria-label={t("infoBanner.dismiss", "Dismiss")}
style={{
color: closeIconColor ?? "var(--c-text-muted)",
}}
>
<LocalIcon
icon="close-rounded"
width={compact ? "0.85rem" : "1rem"}
height={compact ? "0.85rem" : "1rem"}
/>
</ActionIcon>
)}
</Group>
</Group>
</Paper>
);
};
@@ -0,0 +1,156 @@
/* Shared sidebar footer: one surface holding the link-account CTA, the credits
meter, the other-app switch and the account row, hairline-separated.
Structural only — every colour comes from a --c-* semantic token. */
.nav-footer {
display: flex;
flex-direction: column;
flex-shrink: 0;
/* Vertical only: the slots carry the horizontal padding so their separator
runs the full width of the surface. */
padding: 0.25rem 0;
overflow: hidden;
}
.nav-footer__slot {
padding-inline: 0.375rem;
}
/* Separators are drawn by the slots themselves, never as their own elements.
A slot whose contents render nothing (the link-account CTA returns null once
the org is linked, and an element is truthy even when it renders null) is
:empty, so it is skipped by both rules below — it can't leave a line behind,
and it can't push one to the top or bottom of the surface. A rule that only
ever matches a slot PRECEDED by another visible slot cannot draw a leading
separator, whatever the caller passes in. */
.nav-footer__slot:empty {
display: none;
}
.nav-footer__slot:not(:empty) ~ .nav-footer__slot:not(:empty) {
border-top: 1px solid var(--c-border-subtle);
margin-top: 0.25rem;
padding-top: 0.25rem;
}
/* Fades the rows up on the first footer mount of a page session only. They are
seeded from cache, so they're already present at first paint; replaying this
on every later mount (switching apps, remounting a view) would animate
content that never changed and read as a twitch. */
@keyframes nav-footer-row-in {
from {
opacity: 0;
transform: translateY(0.25rem);
}
to {
opacity: 1;
transform: none;
}
}
.nav-footer[data-animate] .nav-footer__slot:not(:empty) {
animation: nav-footer-row-in var(--motion-enter) both;
}
@media (prefers-reduced-motion: reduce) {
.nav-footer[data-animate] .nav-footer__slot:not(:empty) {
animation: none;
}
}
/* ---- Rows (link-account, credits, switch, account) ---- */
.nav-footer__row {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
min-height: 2.25rem;
padding: 0.25rem 0.375rem;
border: 0;
border-radius: 0.5rem;
background: none;
color: var(--c-text);
font: inherit;
text-align: left;
cursor: pointer;
}
.nav-footer__row:disabled {
cursor: default;
}
.nav-footer__row:not(:disabled):hover {
background-color: var(--c-hover);
}
.nav-footer__row:focus-visible {
outline: 2px solid var(--c-primary);
outline-offset: -2px;
}
.nav-footer__row-icon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 1.625rem;
}
/* Hovering the switch row plays the mark's "thinking" drift once — the same
motion the chat FAB loops, for a single beat, as a hint that the row hands
off to the other app. One iteration only: it starts and ends at rest, so
nothing snaps when it finishes, and re-entering the row replays it. */
.nav-footer__row:hover .sui-brandmark__a {
animation: sui-brandmark-drift-a 1.1s ease-in-out 1;
}
.nav-footer__row:hover .sui-brandmark__b {
animation: sui-brandmark-drift-b 1.1s ease-in-out 1;
}
.nav-footer__row-label {
flex: 1;
min-width: 0;
font-size: 0.8125rem;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Trailing affordance on a row: the account row's gear, the switch row's
leaving-this-app arrow. */
.nav-footer__trailing {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-left: auto;
color: var(--c-text-subtle);
}
/* Rows contributed by a caller (the link-account NavItem) sit in the same
surface, so match this footer's row metrics rather than the nav rail's. */
.nav-footer .sui-navitem {
min-height: 2.25rem;
padding: 0.25rem 0.375rem;
margin: 0;
border-radius: 0.5rem;
font-size: 0.8125rem;
}
/* ---- Collapsed icon rail ---- */
.nav-footer[data-collapsed] .nav-footer__slot {
padding-inline: 0.25rem;
}
.nav-footer[data-collapsed] .nav-footer__row {
justify-content: center;
padding-inline: 0;
}
.nav-footer[data-collapsed] .sui-navitem {
justify-content: center;
padding-inline: 0;
}
@@ -0,0 +1,119 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import LinkIcon from "@mui/icons-material/Link";
import { NavItem } from "@app/ui/NavItem";
import { NavFooter } from "@app/components/shared/navFooter/NavFooter";
/** Stands in for a CTA that has decided it has nothing to show. */
function RendersNothing() {
return null;
}
const meta: Meta<typeof NavFooter> = {
title: "Shared/NavFooter",
component: NavFooter,
parameters: { layout: "padded" },
args: {
displayName: "admin",
onOpenSettings: () => {},
credits: { remaining: 247, total: 500 },
onOpenPlan: () => {},
otherApp: { app: "processor", onOpen: () => {} },
},
decorators: [
(S) => (
<div
style={{
width: "16.25rem",
background: "var(--c-bg)",
padding: "0.5rem",
}}
>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof NavFooter>;
/** The editor's footer: credits, "Open PDF Processor", the account row. */
export const InEditor: Story = {};
/** The processor's footer. Same three boxes, opposite switch target. */
export const InProcessor: Story = {
args: { otherApp: { app: "editor", onOpen: () => {} } },
};
/** Self-hosted processor: no wallet, so no meter, and the link-account CTA
* rides along in the account box. */
export const WithLinkAccountCta: Story = {
args: {
credits: null,
otherApp: { app: "editor", onOpen: () => {} },
accountExtras: (
<NavItem
id="account-link"
label="Link Stirling account"
icon={<LinkIcon sx={{ fontSize: "1.1rem" }} />}
/>
),
},
};
/** Regression guard: the processor always passes its link-account CTA, but that
* component renders null once the org is linked. An element is truthy even
* when it renders nothing, so this must not leave a separator above the first
* visible row. */
export const ExtrasThatRenderNothing: Story = {
args: { accountExtras: <RendersNothing /> },
};
/** A real profile picture replaces the initials disc. */
export const WithProfilePicture: Story = {
args: {
displayName: "Ada Lovelace",
profilePictureUrl:
"data:image/svg+xml;utf8," +
encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="black"/><circle cx="32" cy="24" r="12" fill="white"/><ellipse cx="32" cy="56" rx="20" ry="16" fill="white"/></svg>',
),
},
};
/** Credits running low — the dot and bar shift to the warning tone at 20% left. */
export const CreditsLow: Story = {
args: { credits: { remaining: 42, total: 500 } },
};
/** Allowance exhausted. */
export const CreditsExhausted: Story = {
args: { credits: { remaining: 0, total: 500 } },
};
/** Core OSS: no wallet, no second app, settings only. */
export const MinimalBuild: Story = {
args: { credits: null, otherApp: null },
};
/** No settings handler — the account row is inert identity, not a button. */
export const NoSettings: Story = {
args: { onOpenSettings: undefined },
};
/** Collapsed icon rail: labels become tooltips. */
export const Collapsed: Story = {
args: { collapsed: true },
decorators: [
(S) => (
<div
style={{
width: "3.5rem",
background: "var(--c-bg)",
padding: "0.5rem",
}}
>
<S />
</div>
),
],
};
@@ -0,0 +1,58 @@
import { describe, it, expect } from "vitest";
import { cleanup, render } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { NavFooter } from "@app/components/shared/navFooter/NavFooter";
/** The footer's tooltips need Mantine's theme context. */
function withProviders(ui: React.ReactNode) {
return <MantineProvider>{ui}</MantineProvider>;
}
function renderFooter() {
const { container } = render(
withProviders(
<NavFooter
displayName="admin"
onOpenSettings={() => {}}
credits={{ remaining: 247, total: 500 }}
otherApp={{ app: "processor", onOpen: () => {} }}
/>,
),
);
return container.querySelector(".nav-footer") as HTMLElement;
}
describe("NavFooter — enter animation", () => {
it("plays once per page session, not on every remount", () => {
// The rows are seeded from cache, so they're present at first paint. Every
// later mount — switching apps, remounting a view — would otherwise replay
// the fade on content that never changed, which reads as a twitch.
expect(renderFooter().dataset.animate).toBe("true");
cleanup();
expect(renderFooter().dataset.animate).toBeUndefined();
cleanup();
expect(renderFooter().dataset.animate).toBeUndefined();
});
});
describe("NavFooter — separators", () => {
it("never renders a divider beside a row that renders nothing", () => {
// Dividers are CSS between adjacent non-empty slots, so an extras element
// that returns null (the linked org's link-account CTA) can't leave a line.
const { container } = render(
withProviders(
<NavFooter
displayName="admin"
onOpenSettings={() => {}}
credits={null}
otherApp={null}
accountExtras={<>{null}</>}
/>,
),
);
const slots = container.querySelectorAll(".nav-footer__slot");
const filled = [...slots].filter((s) => s.childElementCount > 0);
expect(filled).toHaveLength(1);
expect(container.querySelectorAll(".nav-footer__divider")).toHaveLength(0);
});
});
@@ -0,0 +1,213 @@
import { useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Tooltip } from "@mantine/core";
import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
import SettingsIcon from "@mui/icons-material/Settings";
import { Avatar, NavSurface } from "@app/ui";
import { BrandMark } from "@app/components/shared/BrandMark";
import { type AppSwitchTarget } from "@app/components/shared/AppSwitch";
import {
NavFooterCreditsRow,
type NavFooterCredits,
} from "@app/components/shared/navFooter/NavFooterCreditsRow";
import "@app/components/shared/navFooter/NavFooter.css";
export interface NavFooterAppLink {
/** The app this footer is NOT in — the one the row opens. */
app: AppSwitchTarget;
onOpen: () => void;
}
export interface NavFooterProps {
/** Name shown next to the avatar, and the source of its initials fallback. */
displayName: string;
/** Profile picture; initials are drawn when absent or the URL fails to load. */
profilePictureUrl?: string | null;
/** Omit to render the account row as static text (no settings affordance). */
onOpenSettings?: () => void;
/** Null/undefined hides the meter — builds with no wallet never show it. */
credits?: NavFooterCredits | null;
/** Opens the plan surface from the credits row; omit to leave it inert. */
onOpenPlan?: () => void;
/** Null/undefined hides the switch row — e.g. no access to the other app. */
otherApp?: NavFooterAppLink | null;
/** Extra rows above the account row (the self-hosted link-account CTA). */
accountExtras?: ReactNode;
/** Icon-rail state: labels collapse to tooltips. */
collapsed?: boolean;
className?: string;
}
/**
* Whether the enter animation has already played this page session. The rows
* are seeded from cache now, so they're present from first paint and every
* later mount — switching apps, remounting a view — would otherwise replay the
* animation on content that never changed, which reads as the UI twitching.
*/
let hasPlayedEnter = false;
/**
* The bottom section every sidebar ends with, shared by the editor and the
* processor so both present the same rows. ONE surface, hairline-separated, in
* this order:
*
* 1. caller-contributed rows (the self-hosted link-account CTA)
* 2. free credits remaining
* 3. "Open <the other app>"
* 4. the account row — avatar, name, settings
*
* Purely presentational: each app resolves its own identity, wallet and
* app-switch access and passes them in, so this file carries no build-specific
* gating. A row whose data is absent is dropped, and so is the separator that
* would have sat beside it.
*/
export function NavFooter({
displayName,
profilePictureUrl,
onOpenSettings,
credits,
onOpenPlan,
otherApp,
accountExtras,
collapsed = false,
className,
}: NavFooterProps) {
const { t } = useTranslation();
const [animate] = useState(() => {
if (hasPlayedEnter) return false;
hasPlayedEnter = true;
return true;
});
const settingsLabel = t("fileSidebar.openSettings", "Open settings");
const accountLabel = onOpenSettings
? `${displayName} - ${settingsLabel}`
: displayName;
// One surface, hairline-separated rows. Each row gets a slot; the separators
// are drawn by CSS between adjacent NON-EMPTY slots (see NavFooter.css), so a
// row that renders nothing — the link-account CTA returns null once the org is
// linked, and an element is truthy even then — can't leave a line behind.
const rows: Array<{ key: string; node: ReactNode }> = [];
if (accountExtras) rows.push({ key: "extras", node: accountExtras });
if (credits) {
rows.push({
key: "credits",
node: (
<NavFooterCreditsRow
credits={credits}
collapsed={collapsed}
label={t("navFooter.credits.label", "Free credits")}
onOpen={onOpenPlan}
/>
),
});
}
if (otherApp) {
rows.push({
key: "switch",
node: (
<Tooltip
label={openAppLabel(otherApp.app, t)}
position="right"
withinPortal
disabled={!collapsed}
>
<button
type="button"
className="nav-footer__row"
onClick={() => otherApp.onOpen()}
// Collapsed drops the visible label, so name the button here too.
aria-label={openAppLabel(otherApp.app, t)}
>
<span className="nav-footer__row-icon" aria-hidden>
<BrandMark height="1.125rem" />
</span>
{!collapsed && (
<>
<span className="nav-footer__row-label">
{openAppLabel(otherApp.app, t)}
</span>
{/* "Takes you there", not "opens a new tab" — both apps are
one SPA, so this navigates in place. */}
<span className="nav-footer__trailing" aria-hidden>
<ArrowForwardIcon sx={{ fontSize: "1rem" }} />
</span>
</>
)}
</button>
</Tooltip>
),
});
}
rows.push({
key: "account",
node: (
<Tooltip
label={accountLabel}
position="right"
withinPortal
disabled={!collapsed}
>
<button
type="button"
className="nav-footer__row nav-footer__account"
// Called with no args: handlers that take optional params (the
// processor's openSettings(section?)) must not receive the event.
onClick={onOpenSettings ? () => onOpenSettings() : undefined}
disabled={!onOpenSettings}
data-testid={onOpenSettings ? "config-button" : undefined}
data-tour={onOpenSettings ? "config-button" : undefined}
aria-label={accountLabel}
>
{/* Decorative: the button's own label already names the account, so
an alt/label here would just repeat it to a screen reader. */}
<span aria-hidden>
<Avatar
size="sm"
name={displayName}
src={profilePictureUrl ?? undefined}
/>
</span>
{!collapsed && (
<span className="nav-footer__row-label sidebar-content-fade">
{displayName}
</span>
)}
{onOpenSettings && !collapsed && (
<span className="nav-footer__trailing" aria-hidden>
<SettingsIcon sx={{ fontSize: "1.1rem" }} />
</span>
)}
</button>
</Tooltip>
),
});
return (
<NavSurface
className={["nav-footer", className ?? ""].filter(Boolean).join(" ")}
data-collapsed={collapsed || undefined}
data-animate={animate || undefined}
>
{rows.map((row) => (
<div key={row.key} className="nav-footer__slot">
{row.node}
</div>
))}
</NavSurface>
);
}
function openAppLabel(
app: AppSwitchTarget,
t: (key: string, fallback: string) => string,
): string {
return app === "editor"
? t("navFooter.openEditor", "Open PDF Editor")
: t("navFooter.openProcessor", "Open PDF Processor");
}
@@ -0,0 +1,83 @@
/* Free-credits meter inside the sidebar footer. The row base (padding, hover,
focus) comes from NavFooter.css; these rules are the meter itself. */
.nav-footer__credits {
flex-direction: column;
align-items: stretch;
gap: 0.375rem;
cursor: default;
}
/* Inert by default, so it must not read as hoverable; the actionable variant
opts back into the shared row hover. */
.nav-footer__credits:hover {
background: none;
}
.nav-footer__credits--actionable {
cursor: pointer;
}
.nav-footer__credits--actionable:hover {
background-color: var(--c-hover);
}
.nav-footer__credits-head {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
}
.nav-footer__dot {
width: 0.4375rem;
height: 0.4375rem;
border-radius: 50%;
flex-shrink: 0;
background-color: var(--c-success);
}
.nav-footer__dot[data-tone="warning"] {
background-color: var(--c-warning);
}
.nav-footer__dot[data-tone="danger"] {
background-color: var(--c-danger);
}
.nav-footer__credits-label {
flex: 1;
min-width: 0;
font-weight: 500;
color: var(--c-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.nav-footer__credits-count {
flex-shrink: 0;
color: var(--c-text-muted);
font-variant-numeric: tabular-nums;
}
/* ---- Collapsed rail ---- */
/* Rotated so the fill starts at 12 o'clock and runs clockwise. */
.nav-footer__credits-ring {
width: 1.25rem;
height: 1.25rem;
margin-inline: auto;
transform: rotate(-90deg);
}
.nav-footer__credits-ring-track,
.nav-footer__credits-ring-fill {
fill: none;
stroke-width: 3;
}
.nav-footer__credits-ring-track {
stroke: var(--c-surface-sunken);
}
.nav-footer__credits-ring-fill {
stroke-linecap: round;
}
@@ -0,0 +1,158 @@
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Tooltip } from "@mantine/core";
import { ProgressBar } from "@app/ui";
import "@app/components/shared/navFooter/NavFooterCreditsRow.css";
export interface NavFooterCredits {
/** Free credits still available to spend. */
remaining: number;
/** Size of the free allowance — the "of N" denominator. */
total: number;
}
/** Remaining-credit bands, mirroring the usage meters' 80% / 100% thresholds. */
function creditsTone(remaining: number, total: number): string {
if (remaining <= 0) return "danger";
return total > 0 && remaining / total <= 0.2 ? "warning" : "success";
}
interface NavFooterCreditsRowProps {
credits: NavFooterCredits;
/** Icon rail: the figures drop and the bar alone carries the state. */
collapsed: boolean;
/** Row label, passed in so the meter owns no copy of its own. */
label: string;
/** Opens the plan surface. Omit to render the meter as inert text. */
onOpen?: () => void;
}
/**
* The free-credits meter as it appears in the sidebar footer: a state dot, the
* label, "X of Y" remaining, and a fill bar underneath. Figures are clamped
* here so a wallet that reports more remaining than the allowance (or negative)
* can't overflow the bar.
*
* Rendered as a {@code nav-footer__row}, so it inherits that row's metrics
* from NavFooter.css and only brings its own meter styling.
*/
export function NavFooterCreditsRow({
credits,
collapsed,
label,
onOpen,
}: NavFooterCreditsRowProps) {
const { t } = useTranslation();
const total = Math.max(0, credits.total);
const remaining = Math.min(Math.max(0, credits.remaining), total);
const tone = creditsTone(remaining, total);
const count = t("navFooter.credits.count", "{{remaining}} of {{total}}", {
remaining: remaining.toLocaleString(),
total: total.toLocaleString(),
});
return (
<Tooltip
label={`${label}: ${count}`}
position="right"
withinPortal
disabled={!collapsed}
>
<Row onOpen={onOpen} label={`${label}: ${count}`}>
{collapsed ? (
// The rail is one icon wide, so a full-width bar would read as a
// stray line; a ring carries the same fraction at icon size.
<CreditsRing
fraction={total > 0 ? remaining / total : 0}
tone={tone}
label={`${label}: ${count}`}
/>
) : (
<>
<div className="nav-footer__credits-head">
<span className="nav-footer__dot" data-tone={tone} aria-hidden />
<span className="nav-footer__credits-label">{label}</span>
<span className="nav-footer__credits-count">{count}</span>
</div>
<ProgressBar
value={total > 0 ? remaining / total : 0}
height={6}
color={`var(--c-${tone})`}
label={`${label}: ${count}`}
/>
</>
)}
</Row>
</Tooltip>
);
}
/** Icon-sized donut carrying the same remaining fraction as the expanded bar. */
function CreditsRing({
fraction,
tone,
label,
}: {
fraction: number;
tone: string;
label: string;
}) {
const RADIUS = 8;
const circumference = 2 * Math.PI * RADIUS;
const filled = Math.min(1, Math.max(0, fraction)) * circumference;
return (
<svg
className="nav-footer__credits-ring"
viewBox="0 0 20 20"
role="img"
aria-label={label}
>
<circle
className="nav-footer__credits-ring-track"
cx="10"
cy="10"
r={RADIUS}
/>
<circle
className="nav-footer__credits-ring-fill"
cx="10"
cy="10"
r={RADIUS}
stroke={`var(--c-${tone})`}
strokeDasharray={`${filled} ${circumference - filled}`}
/>
</svg>
);
}
/**
* The meter is a button only where there is a plan surface to open — otherwise
* it stays a plain div, so a build with nowhere to go doesn't advertise a
* click that does nothing.
*/
function Row({
onOpen,
label,
children,
}: {
onOpen?: () => void;
label: string;
children: ReactNode;
}) {
const className = `nav-footer__row nav-footer__credits${
onOpen ? " nav-footer__credits--actionable" : ""
}`;
if (!onOpen) return <div className={className}>{children}</div>;
return (
<button
type="button"
className={className}
onClick={() => onOpen()}
aria-label={label}
>
{children}
</button>
);
}
@@ -1,5 +1,7 @@
import { useContext, useEffect, useState } from "react";
import { Stack, Divider, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { ViewerContext } from "@app/contexts/ViewerContext";
import {
ChangeMetadataParameters,
createCustomMetadataFunctions,
@@ -19,6 +21,31 @@ interface ChangeMetadataSingleStepProps {
disabled?: boolean;
}
/**
* Pre-fills the form from the currently open document's existing metadata.
* Isolated in its own component so it only mounts where a ViewerProvider exists
* (the editor and the in-editor Automate modal). The pipeline builder has no
* viewer and no single "current document", so it is skipped there rather than
* crashing on useViewer.
*/
const MetadataPrefill = ({
onParameterChange,
onExtractingChange,
}: {
onParameterChange: ChangeMetadataSingleStepProps["onParameterChange"];
onExtractingChange: (extracting: boolean) => void;
}) => {
const { isExtractingMetadata } = useMetadataExtraction({
updateParameter: onParameterChange,
});
useEffect(() => {
onExtractingChange(isExtractingMetadata);
}, [isExtractingMetadata, onExtractingChange]);
return null;
};
const ChangeMetadataSingleStep = ({
parameters,
onParameterChange,
@@ -26,77 +53,85 @@ const ChangeMetadataSingleStep = ({
}: ChangeMetadataSingleStepProps) => {
const { t } = useTranslation();
// Auto-prefill reads the viewer/file contexts, which only exist in the editor.
// Gate on the viewer so the pipeline builder renders the fields without it.
const hasViewerContext = useContext(ViewerContext) !== null;
const [isExtractingMetadata, setIsExtractingMetadata] = useState(false);
// Get custom metadata functions using the utility
const { addCustomMetadata, removeCustomMetadata, updateCustomMetadata } =
createCustomMetadataFunctions(parameters, onParameterChange);
// Extract metadata from uploaded files
const { isExtractingMetadata } = useMetadataExtraction({
updateParameter: onParameterChange,
});
const isDeleteAllEnabled = parameters.deleteAll;
const fieldsDisabled = disabled || isDeleteAllEnabled || isExtractingMetadata;
return (
<Stack gap="md">
{/* Delete All */}
<Stack gap="md">
<Text size="sm" fw={500}>
{t("changeMetadata.deleteAll.label", "Delete All Metadata")}
</Text>
<DeleteAllStep
parameters={parameters}
<>
{hasViewerContext && (
<MetadataPrefill
onParameterChange={onParameterChange}
disabled={disabled}
onExtractingChange={setIsExtractingMetadata}
/>
</Stack>
<Divider />
{/* Standard Metadata Fields */}
)}
<Stack gap="md">
<Text size="sm" fw={500}>
{t("changeMetadata.standardFields.title", "Standard Metadata")}
</Text>
<StandardMetadataStep
parameters={parameters}
onParameterChange={onParameterChange}
disabled={fieldsDisabled}
/>
{/* Delete All */}
<Stack gap="md">
<Text size="sm" fw={500}>
{t("changeMetadata.deleteAll.label", "Delete All Metadata")}
</Text>
<DeleteAllStep
parameters={parameters}
onParameterChange={onParameterChange}
disabled={disabled}
/>
</Stack>
<Divider />
{/* Standard Metadata Fields */}
<Stack gap="md">
<Text size="sm" fw={500}>
{t("changeMetadata.standardFields.title", "Standard Metadata")}
</Text>
<StandardMetadataStep
parameters={parameters}
onParameterChange={onParameterChange}
disabled={fieldsDisabled}
/>
</Stack>
<Divider />
{/* Document Dates */}
<Stack gap="md">
<Text size="sm" fw={500}>
{t("changeMetadata.dates.title", "Document Dates")}
</Text>
<DocumentDatesStep
parameters={parameters}
onParameterChange={onParameterChange}
disabled={fieldsDisabled}
/>
</Stack>
<Divider />
{/* Advanced Options */}
<Stack gap="md">
<Text size="sm" fw={500}>
{t("changeMetadata.advanced.title", "Advanced Options")}
</Text>
<AdvancedOptionsStep
parameters={parameters}
onParameterChange={onParameterChange}
disabled={fieldsDisabled}
addCustomMetadata={addCustomMetadata}
removeCustomMetadata={removeCustomMetadata}
updateCustomMetadata={updateCustomMetadata}
/>
</Stack>
</Stack>
<Divider />
{/* Document Dates */}
<Stack gap="md">
<Text size="sm" fw={500}>
{t("changeMetadata.dates.title", "Document Dates")}
</Text>
<DocumentDatesStep
parameters={parameters}
onParameterChange={onParameterChange}
disabled={fieldsDisabled}
/>
</Stack>
<Divider />
{/* Advanced Options */}
<Stack gap="md">
<Text size="sm" fw={500}>
{t("changeMetadata.advanced.title", "Advanced Options")}
</Text>
<AdvancedOptionsStep
parameters={parameters}
onParameterChange={onParameterChange}
disabled={fieldsDisabled}
addCustomMetadata={addCustomMetadata}
removeCustomMetadata={removeCustomMetadata}
updateCustomMetadata={updateCustomMetadata}
/>
</Stack>
</Stack>
</>
);
};
@@ -1,3 +1,4 @@
import { useContext, useRef } from "react";
import {
Stack,
Text,
@@ -7,6 +8,7 @@ import {
Divider,
} from "@mantine/core";
import { Button } from "@app/ui/Button";
import { FilePicker } from "@app/ui/FilePicker";
import { ActionIcon } from "@app/ui/ActionIcon";
import { SegmentedControl } from "@app/ui/SegmentedControl";
import { useTranslation } from "react-i18next";
@@ -15,7 +17,7 @@ import {
type OverlayMode,
} from "@app/hooks/tools/overlayPdfs/useOverlayPdfsParameters";
import LocalIcon from "@app/components/shared/LocalIcon";
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
import { FilesModalContext } from "@app/contexts/FilesModalContext";
import styles from "@app/components/tools/overlayPdfs/OverlayPdfsSettings.module.css";
import { Z_INDEX_AUTOMATE_DROPDOWN } from "@app/styles/zIndex";
@@ -34,7 +36,12 @@ export default function OverlayPdfsSettings({
disabled = false,
}: OverlayPdfsSettingsProps) {
const { t } = useTranslation();
const { openFilesModal } = useFilesModalContext();
// Read optionally: the portal pipeline builder mounts no FilesModalProvider.
// Present (editor tool + Automate modal) -> keep the workspace file picker;
// absent (portal) -> fall back to the plain file input below.
const filesModal = useContext(FilesModalContext);
// Clears the FilePicker so the same file can be re-selected (Mantine resetRef).
const resetOverlayPicker = useRef<() => void>(null);
const handleOverlayFilesChange = (files: File[]) => {
onParameterChange("overlayFiles", files);
@@ -66,8 +73,8 @@ export default function OverlayPdfsSettings({
};
const handleOpenOverlayFilesModal = () => {
if (disabled) return;
openFilesModal({
if (disabled || !filesModal) return;
filesModal.openFilesModal({
customHandler: (files: File[]) => {
handleOverlayFilesChange([
...(parameters.overlayFiles || []),
@@ -77,6 +84,17 @@ export default function OverlayPdfsSettings({
});
};
const appendOverlayFiles = (files: File[]) => {
if (files.length === 0) return;
handleOverlayFilesChange([...(parameters.overlayFiles || []), ...files]);
resetOverlayPicker.current?.();
};
const overlayFilesButtonLabel =
parameters.overlayFiles?.length > 0
? t("overlay-pdfs.overlayFiles.addMore", "Add more PDFs...")
: t("overlay-pdfs.overlayFiles.placeholder", "Choose PDF(s)...");
return (
<Stack gap="md">
<Stack gap="xs">
@@ -183,17 +201,30 @@ export default function OverlayPdfsSettings({
<Text size="sm" fw={500}>
{t("overlay-pdfs.overlayFiles.label", "Overlay Files")}
</Text>
<Button
size="sm"
onClick={handleOpenOverlayFilesModal}
disabled={disabled}
leftSection={<LocalIcon icon="add" width="14" height="14" />}
fullWidth
>
{parameters.overlayFiles?.length > 0
? t("overlay-pdfs.overlayFiles.addMore", "Add more PDFs...")
: t("overlay-pdfs.overlayFiles.placeholder", "Choose PDF(s)...")}
</Button>
{filesModal ? (
<Button
size="sm"
onClick={handleOpenOverlayFilesModal}
disabled={disabled}
leftSection={<LocalIcon icon="add" width="14" height="14" />}
fullWidth
>
{overlayFilesButtonLabel}
</Button>
) : (
<FilePicker
multiple
accept="application/pdf"
onChange={appendOverlayFiles}
resetRef={resetOverlayPicker}
size="sm"
disabled={disabled}
leftSection={<LocalIcon icon="add" width="14" height="14" />}
fullWidth
>
{overlayFilesButtonLabel}
</FilePicker>
)}
{parameters.overlayFiles?.length > 0 &&
(() => {
@@ -41,7 +41,9 @@ interface FilesModalContextType {
setOnModalClose: (callback: () => void) => void;
}
const FilesModalContext = createContext<FilesModalContextType | null>(null);
export const FilesModalContext = createContext<FilesModalContextType | null>(
null,
);
export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({
children,
@@ -30,4 +30,15 @@ describe("automatable tools", () => {
expect(offeredWithoutConfig).toEqual([]);
});
// Reorganize Pages has an automatable form (organization mode + page-order string) and a
// context-free settings component, but its registry entry once left automationSettings null,
// so both Automate and the pipeline builder showed "no configurable settings". Guard the wiring.
test("Reorganize Pages exposes automation settings so it is configurable, not no-settings", () => {
const { result } = renderHook(() => useTranslatedToolCatalog());
expect(
result.current.regularTools.reorganizePages?.automationSettings,
).toBeTruthy();
});
});
@@ -700,7 +700,10 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
endpoints: ["rearrange-pages"],
operationConfig: asRegistryConfig(reorganizePagesOperationConfig),
synonyms: getSynonyms(t, "reorganizePages"),
automationSettings: null,
automationSettings: lazySettings(
() =>
import("@app/components/tools/reorganizePages/ReorganizePagesSettings"),
),
},
scalePages: {
icon: (
@@ -0,0 +1,64 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAuth } from "@app/auth/UseSession";
import { useProfilePictureUrl } from "@app/hooks/useProfilePictureUrl";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { accountService } from "@app/services/accountService";
export interface AccountIdentity {
/** Never empty — falls back to a generic "User" so a row is never blank. */
displayName: string;
profilePictureUrl: string | null;
isAnonymous: boolean;
}
/**
* The signed-in identity as the UI should draw it: one name and one picture,
* resolved the same way everywhere. Every surface that shows "who am I" (the
* editor and processor sidebar footers, the account settings page) reads this,
* so a user can't see one initial in the sidebar and a different one in
* settings.
*
* Resolution order for the name: the auth layer's own displayName (each layer
* derives it from its native user shape), then the proprietary REST endpoint,
* then a generic last resort.
*/
export function useAccountIdentity(): AccountIdentity {
const { t } = useTranslation();
const { config } = useAppConfig();
const { displayName: authDisplayName, isAnonymous } = useAuth();
const profilePictureUrl = useProfilePictureUrl();
const [accountUsername, setAccountUsername] = useState<string | null>(null);
useEffect(() => {
if (!config?.enableLogin) {
setAccountUsername(null);
return;
}
if (authDisplayName) {
// The auth context has a name; don't bother hitting the REST
// endpoint, but clear any stale cached value from a prior call.
setAccountUsername(null);
return;
}
accountService
.getAccountData()
.then((data) => {
// Always reflect the latest result - including clearing it on
// sign-out, when the endpoint returns no username (or 401s into
// the catch branch below). Without this, signing out would leave
// the old username on screen.
setAccountUsername(data?.username ?? null);
})
.catch(() => {
setAccountUsername(null);
});
}, [config?.enableLogin, authDisplayName]);
return {
displayName:
authDisplayName ?? accountUsername ?? t("auth.displayName.user", "User"),
profilePictureUrl,
isAnonymous,
};
}
@@ -0,0 +1,12 @@
import { type NavFooterCredits } from "@app/components/shared/navFooter/NavFooterCreditsRow";
/**
* Free credits left on this team's allowance, for the sidebar footer meter.
* Null hides the meter entirely.
*
* Core has no wallet — self-hosted installs aren't metered — so there is
* nothing to show. Cloud builds override this with the live wallet figure.
*/
export function useFreeCreditsSummary(): NavFooterCredits | null {
return null;
}
@@ -0,0 +1,10 @@
/**
* Opens the plan surface behind the sidebar footer's free-credits row, or null
* when this build has none (the row is then inert text rather than a button).
*
* Core ships no wallet and no plan section, so there is nothing to open. Builds
* that meter usage override this with their own surface.
*/
export function useOpenPlan(): (() => void) | null {
return null;
}
@@ -0,0 +1,12 @@
import { type NavFooterAppLink } from "@app/components/shared/navFooter/NavFooter";
/**
* The sibling app this build can switch to (editor ⇄ processor), or null when
* there is none. The single gate behind both the brand switcher and the
* sidebar footer's "Open ..." row, so the two can never disagree about access.
*
* Core ships no processor, so there is nothing to switch to.
*/
export function useOtherAppSwitch(): NavFooterAppLink | null {
return null;
}
+3
View File
@@ -6,5 +6,8 @@ export const qk = {
["editor", "endpointEnabled", endpoint] as const,
footerInfo: () => ["editor", "footerInfo"] as const,
groupEnabled: (group: string) => ["editor", "groupEnabled", group] as const,
/** Keyed on the asking identity: two users must never share one answer. */
portalAccess: (userId: string | null) =>
["editor", "portalAccess", userId] as const,
users: () => ["editor", "users"] as const,
} as const;
@@ -0,0 +1,73 @@
/**
* Last-known sidebar-footer state, so the rows are correct at first paint
* instead of arriving a request later.
*
* The footer is mounted by both apps, and the editor and processor are separate
* React trees with separate query caches — so without this, every navigation
* between them (and every remount inside them) re-ran the fetches and the rows
* visibly popped in and shoved each other around. Persisting to storage rather
* than to an in-memory cache is what makes it survive that boundary, and a
* reload.
*
* Deliberately stale-then-revalidate: what's stored is only ever what the
* backend last said, every reader refetches immediately and overwrites, and
* nothing is gated on it — the processor enforces its own access server-side,
* and a stale credit figure is replaced within a second of the wallet landing.
*/
const CREDITS_KEY = "stirling.navFooter.credits";
const OTHER_APP_KEY = "stirling.navFooter.otherApp";
/** Figures, or null for a team that sees no meter at all (a paying one). */
export type CachedCredits = { remaining: number; total: number } | null;
function read(key: string): string | null {
try {
return localStorage.getItem(key);
} catch {
// Private mode / storage disabled — behave as a first-ever load.
return null;
}
}
function write(key: string, value: string): void {
try {
localStorage.setItem(key, value);
} catch {
// Nothing to do: the cache is an optimisation, never a correctness input.
}
}
/** `undefined` when this browser has never seen an answer. */
export function readCachedCredits(): CachedCredits | undefined {
const raw = read(CREDITS_KEY);
if (raw === null) return undefined;
if (raw === "none") return null;
try {
const parsed: unknown = JSON.parse(raw);
if (
typeof parsed === "object" &&
parsed !== null &&
typeof (parsed as CachedCredits & object).remaining === "number" &&
typeof (parsed as CachedCredits & object).total === "number"
) {
return parsed as CachedCredits;
}
} catch {
// Corrupt entry — fall through and treat it as never-seen.
}
return undefined;
}
export function writeCachedCredits(credits: CachedCredits): void {
write(CREDITS_KEY, credits === null ? "none" : JSON.stringify(credits));
}
/** `undefined` when this browser has never seen an answer. */
export function readCachedOtherApp(): boolean | undefined {
const raw = read(OTHER_APP_KEY);
return raw === null ? undefined : raw === "true";
}
export function writeCachedOtherApp(canOpen: boolean): void {
write(OTHER_APP_KEY, String(canOpen));
}
@@ -185,7 +185,7 @@ export class UpdateService {
*/
async getCurrentVersionFromGitHub(): Promise<string> {
const url =
"https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/V2-master/build.gradle";
"https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/release/build.gradle";
try {
const response = await fetch(url);
+10
View File
@@ -32,6 +32,9 @@ html[data-app-theme="light"] {
--c-primary: var(--p-blue-500);
--c-primary-hover: var(--p-blue-600);
--c-primary-subtle: color-mix(in srgb, var(--p-blue-500) 10%, transparent);
/* Accent used as TEXT (links, toggles): a deeper hue that clears 4.5:1 on
light surfaces, where --c-primary itself does not. */
--c-accent-text: var(--p-blue-700);
--c-success: var(--p-green-600);
--c-danger: var(--p-red-600);
@@ -68,6 +71,11 @@ html[data-app-theme="light"] {
var(--c-success) 10%,
var(--c-surface)
);
--c-warning-subtle: color-mix(
in srgb,
var(--c-warning) 10%,
var(--c-surface)
);
/* ── Decorative / brand / categorical palette ──────────────────────────
Fixed hues that intentionally do NOT follow the chosen accent: brand
@@ -144,6 +152,8 @@ html[data-app-theme="midnight"] {
--c-text-muted: var(--p-zinc-200);
--c-text-subtle: var(--p-zinc-250);
--c-text-on-primary: var(--p-white);
/* Accent used as TEXT on dark surfaces: a lighter step for 4.5:1. */
--c-accent-text: var(--p-blue-400);
--c-btn-solid: var(--c-text);
--c-btn-inverse: var(--p-ink);
--c-btn-secondary: var(--p-c-1a1a1d);
@@ -62,6 +62,7 @@
/* ── Z-index ladder ── */
--z-dropdown: 25;
--z-drawer: 50;
--z-popover: 150;
--z-toast: 200;
/* Fullscreen tool-picker surfaces (editor) */
--z-fullscreen-icon-svg: 1;
+6
View File
@@ -46,6 +46,12 @@
height: 2.5rem;
font-size: 1rem;
}
/* Account-settings hero disc. */
.sui-avatar--xl {
width: 4.5rem;
height: 4.5rem;
font-size: 1.75rem;
}
.sui-avatar__img {
width: 100%;
+21 -6
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import "@app/ui/Avatar.css";
export type AvatarSize = "xs" | "sm" | "md" | "lg";
export type AvatarSize = "xs" | "sm" | "md" | "lg" | "xl";
export type AvatarTone =
| "blue"
| "purple"
@@ -23,10 +24,12 @@ export interface AvatarProps {
className?: string;
}
function initialsOf(name: string): string {
function avatarInitials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return "?";
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
// Single word (a username or an email) reads as one letter — two letters of
// "admin" ("AD") looks like a different person's initials, not a truncation.
if (parts.length === 1) return parts[0].slice(0, 1).toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
@@ -43,6 +46,13 @@ export function Avatar({
ariaLabel,
className,
}: AvatarProps) {
// A picture URL that 404s (expired signed URL, deleted upload) must not leave
// an empty disc — fall back to the same initials the no-picture case shows, so
// every surface rendering this identity agrees on what it draws.
const [srcFailed, setSrcFailed] = useState(false);
useEffect(() => setSrcFailed(false), [src]);
const showImage = Boolean(src) && !srcFailed;
const classes = [
"sui-avatar",
`sui-avatar--${size}`,
@@ -53,11 +63,16 @@ export function Avatar({
.filter(Boolean)
.join(" ");
const content = src ? (
<img src={src} alt={ariaLabel ?? name} className="sui-avatar__img" />
const content = showImage ? (
<img
src={src}
alt={ariaLabel ?? name}
className="sui-avatar__img"
onError={() => setSrcFailed(true)}
/>
) : (
<span className="sui-avatar__initials" aria-hidden>
{initialsOf(name)}
{avatarInitials(name)}
</span>
);
+376
View File
@@ -0,0 +1,376 @@
/*
* DataTable: the one Stirling table. Appearance is owned entirely here: the
* canonical surface, the header/row grammar, the standardized loading / empty /
* error states, and every cell KIND (`sui-dtc__*`). Call-sites choose a kind and
* supply data; they never style a cell. This is what lets every table look and
* behave the same.
*/
.sui-datatable {
width: 100%;
}
/* The one canonical surface: every table wears it; call-sites don't wrap it. */
.sui-datatable__frame {
border: 1px solid var(--c-border);
border-radius: var(--radius-md);
background: var(--c-surface);
overflow: hidden;
}
.sui-datatable__toolbar {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
padding: 0.75rem 0.875rem;
border-bottom: 1px solid var(--c-border);
}
.sui-datatable__scroll {
width: 100%;
overflow-x: auto;
}
.sui-datatable__table {
width: 100%;
border-collapse: collapse;
font-size: 0.8125rem;
}
.sui-datatable__caption {
caption-side: top;
text-align: left;
padding: 0.625rem 0.875rem;
color: var(--c-text-subtle);
font-size: 0.75rem;
}
/* Header */
.sui-datatable__th {
text-align: left;
font-weight: 600;
color: var(--c-text-subtle);
font-size: 0.6875rem;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.625rem 0.875rem;
border-bottom: 1px solid var(--c-border);
white-space: nowrap;
}
.sui-datatable__th--right {
text-align: right;
}
.sui-datatable__th--fit {
width: 1%;
}
/* Accessible-only header text for blank affordance/action columns. */
.sui-datatable__th-sr {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.sui-datatable__sort {
display: inline-flex;
align-items: center;
gap: 0.25rem;
background: none;
border: none;
padding: 0;
margin: 0;
cursor: pointer;
font: inherit;
letter-spacing: inherit;
text-transform: inherit;
color: inherit;
}
.sui-datatable__th--right .sui-datatable__sort {
flex-direction: row-reverse;
}
.sui-datatable__sort:hover {
color: var(--c-text);
}
.sui-datatable__sort:focus-visible {
outline: 0.125rem solid var(--c-primary);
outline-offset: 0.125rem;
border-radius: var(--radius-sm);
}
.sui-datatable__sort-icon {
display: inline-flex;
color: var(--c-text-subtle);
opacity: 0;
transition: opacity var(--motion-fast);
}
.sui-datatable__sort:hover .sui-datatable__sort-icon {
opacity: 0.5;
}
.sui-datatable__sort-icon--asc,
.sui-datatable__sort-icon--desc {
opacity: 1;
color: var(--c-primary);
}
.sui-datatable__sort-icon--desc svg {
transform: rotate(180deg);
}
/* Body */
.sui-datatable__td {
padding: 0.625rem 0.875rem;
color: var(--c-text-muted);
border-bottom: 1px solid var(--c-border-subtle);
vertical-align: middle;
}
.sui-datatable__table tbody tr:last-child .sui-datatable__td {
border-bottom: none;
}
.sui-datatable__td--right {
text-align: right;
}
.sui-datatable__td--nowrap {
white-space: nowrap;
}
.sui-datatable__td--fit {
width: 1%;
white-space: nowrap;
}
/* Density (the compact variant) */
.sui-datatable--compact .sui-datatable__th,
.sui-datatable--compact .sui-datatable__td {
padding: 0.375rem 0.625rem;
}
/* Interactive rows */
.sui-datatable__row--interactive {
cursor: pointer;
transition: background var(--motion-fast);
}
.sui-datatable__row--interactive:hover {
background: var(--c-hover);
}
.sui-datatable__row--interactive:focus-visible {
outline: 0.125rem solid var(--c-primary);
outline-offset: -0.125rem;
}
/* Non-actionable rows (e.g. coming-soon): de-emphasized to read as disabled.
Muting is done with accessible colours + a faded (non-text) icon rather than
row opacity, which would blend text under the WCAG contrast floor. */
.sui-datatable__row--muted .sui-dtc__entity-name {
color: var(--c-text-muted);
font-weight: 500;
}
.sui-datatable__row--muted .sui-dtc__entity-icon {
opacity: 0.4;
}
.sui-datatable__chevron {
display: inline-flex;
color: var(--c-text-subtle);
}
/* Full-width state cell (empty / error) */
.sui-datatable__state {
padding: 2rem;
text-align: center;
color: var(--c-text-subtle);
}
.sui-datatable__state--error {
color: var(--c-danger);
}
.sui-datatable__state--node {
padding: 0;
}
/*
* Cell kinds (sui-dtc__*): the locked cell vocabulary. These absorb the
* per-namespace portal-*__ cell styles (mono / muted / cell-stack / name-cell /
* caret …) that every table used to re-implement.
*/
.sui-dtc__text {
color: var(--c-text-muted);
}
/* Labeled value ("Category: action"): full-strength text, bold label. */
.sui-dtc__text--labeled {
color: var(--c-text);
}
.sui-dtc__text-label {
font-weight: 600;
}
.sui-dtc__mono {
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--c-text-muted);
}
.sui-dtc__muted {
color: var(--c-text-subtle);
}
.sui-dtc__num {
font-variant-numeric: tabular-nums;
}
.sui-dtc__labels {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.375rem;
}
/* entity: leading icon + bold name + optional muted note */
.sui-dtc__entity {
display: flex;
align-items: center;
gap: 0.625rem;
min-width: 0;
}
.sui-dtc__entity-icon {
display: inline-flex;
align-items: center;
flex: none;
color: var(--c-text-muted);
}
/* The component sizes bare icons; self-styled marks (Avatar) render as-is. */
.sui-dtc__entity-icon svg {
width: 1.2rem;
height: 1.2rem;
}
.sui-dtc__entity-body {
display: flex;
flex-direction: column;
gap: 0.1875rem;
min-width: 0;
}
.sui-dtc__entity-head {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 0.25rem;
}
.sui-dtc__entity-name {
font-weight: 600;
color: var(--c-text);
}
.sui-dtc__entity-suffix {
font-size: 0.75rem;
font-weight: 400;
color: var(--c-text-subtle);
}
.sui-dtc__note {
font-size: 0.75rem;
color: var(--c-text-subtle);
}
.sui-dtc__actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.25rem;
}
.sui-dtc__menu-item--danger {
color: var(--c-danger);
}
/* Grouped tables: section header row + per-group "show all" toggle. */
.sui-datatable__group-cell {
padding: 0.5rem 0.875rem;
background: var(--c-surface-sunken, var(--c-hover));
border-bottom: 1px solid var(--c-border);
}
.sui-datatable__table
tbody
tr.sui-datatable__group:not(:first-child)
.sui-datatable__group-cell {
border-top: 1px solid var(--c-border);
}
.sui-datatable__group-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.sui-datatable__group-title {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 0.5rem;
}
.sui-datatable__group-title strong {
font-size: 0.8125rem;
color: var(--c-text);
}
.sui-datatable__group-meta {
font-size: 0.75rem;
color: var(--c-text-subtle);
}
.sui-datatable__group-more {
padding: 0.5rem 0.875rem;
}
.sui-datatable__show-all {
background: none;
border: none;
padding: 0;
cursor: pointer;
font: inherit;
font-size: 0.75rem;
color: var(--c-accent-text);
}
.sui-datatable__show-all:hover {
text-decoration: underline;
}
.sui-dtc__progress {
display: flex;
align-items: center;
gap: 0.5rem;
}
.sui-dtc__progress-bar {
display: block;
width: 5rem;
}
.sui-dtc__progress-pct {
font-variant-numeric: tabular-nums;
color: var(--c-text-muted);
min-width: 2.5rem;
}
.sui-dtc__links {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.75rem;
}
/* Neutral chip-style link (matches the invoice-link look): the colour is not an
* accent - the trailing glyph + hover fill carry the affordance. */
.sui-dtc__link {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
border-radius: var(--radius-sm);
color: var(--c-text);
text-decoration: none;
font-weight: 500;
border: 1px solid transparent;
transition:
background var(--motion-fast),
border-color var(--motion-fast);
}
.sui-dtc__link:hover {
background: var(--c-surface-sunken);
border-color: var(--c-border);
}
.sui-dtc__link:focus-visible {
outline: none;
border-color: var(--c-primary);
background: var(--c-surface-sunken);
}
.sui-dtc__select {
min-width: 13rem;
}
@@ -0,0 +1,262 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Button } from "@app/ui/Button";
import { column, DataTable, type DataTableColumn } from "@app/ui/DataTable";
interface Region {
id: string;
name: string;
code: string;
status: "healthy" | "degraded";
docs: number;
latency: number;
auto: boolean;
}
const REGIONS: Region[] = [
{
id: "1",
name: "US East",
code: "us-east-1",
status: "healthy",
docs: 12481,
latency: 41,
auto: true,
},
{
id: "2",
name: "US West",
code: "us-west-2",
status: "healthy",
docs: 8210,
latency: 63,
auto: false,
},
{
id: "3",
name: "EU West",
code: "eu-west-1",
status: "degraded",
docs: 3044,
latency: 190,
auto: true,
},
{
id: "4",
name: "AP South",
code: "ap-south-1",
status: "healthy",
docs: 5622,
latency: 88,
auto: false,
},
];
const tone = (r: Region) => ({
tone: r.status === "healthy" ? ("success" as const) : ("warning" as const),
label: r.status,
});
const COLUMNS: DataTableColumn<Region>[] = [
column.entity({
key: "name",
header: "Region",
primary: (r) => r.name,
}),
column.mono({ key: "code", header: "Code", get: (r) => r.code }),
column.badge({ key: "status", header: "Status", get: tone }),
column.number({
key: "docs",
header: "Docs 24h",
get: (r) => r.docs,
format: (n) => n.toLocaleString(),
}),
column.number({
key: "latency",
header: "P95",
get: (r) => r.latency,
format: (n) => `${n} ms`,
}),
];
const SORTABLE_COLUMNS: DataTableColumn<Region>[] = [
column.entity({
key: "name",
header: "Region",
primary: (r) => r.name,
sortable: true,
}),
column.mono({
key: "code",
header: "Code",
get: (r) => r.code,
sortable: true,
}),
column.badge({ key: "status", header: "Status", get: tone, sortable: true }),
column.number({
key: "docs",
header: "Docs 24h",
get: (r) => r.docs,
format: (n) => n.toLocaleString(),
sortable: true,
}),
column.number({
key: "latency",
header: "P95",
get: (r) => r.latency,
format: (n) => `${n} ms`,
sortable: true,
}),
];
const meta: Meta<typeof DataTable> = {
title: "Compound/DataTable",
component: DataTable,
tags: ["autodocs"],
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof DataTable>;
/** Columns come from the `column` vocabulary; call-sites never style a cell. */
export const Basic: Story = {
render: () => (
<DataTable<Region> columns={COLUMNS} rows={REGIONS} rowKey={(r) => r.id} />
),
};
/** Sorting is opt-in per column (`sortable: true`); click a header. */
export const Sortable: Story = {
render: () => (
<DataTable<Region>
columns={SORTABLE_COLUMNS}
rows={REGIONS}
rowKey={(r) => r.id}
defaultSort={{ key: "docs", direction: "desc" }}
/>
),
};
/** `onRowClick` + a chevron affordance. The line above shows the click land. */
export const Interactive: Story = {
render: () => {
function Bound() {
const [clicked, setClicked] = useState<Region | null>(null);
return (
<>
<p
style={{
marginBottom: "0.75rem",
fontSize: "0.8125rem",
color: "var(--c-text-muted)",
}}
>
{clicked
? `Clicked: ${clicked.name} (${clicked.code})`
: "Click a row to fire onRowClick."}
</p>
<DataTable<Region>
columns={COLUMNS}
rows={REGIONS}
rowKey={(r) => r.id}
onRowClick={setClicked}
rowAffordance="chevron"
/>
</>
);
}
return <Bound />;
},
};
/** A trailing action column (icon-only kebab), locked to the design system. */
export const WithActions: Story = {
render: () => (
<DataTable<Region>
columns={[
...COLUMNS,
column.actions({
key: "actions",
get: () => [
{
label: "Row actions",
glyph: "kebab",
iconOnly: true,
onClick: () => {},
},
],
}),
]}
rows={REGIONS}
rowKey={(r) => r.id}
/>
),
};
/** First-load skeleton mirrors the real column layout. */
export const Loading: Story = {
render: () => (
<DataTable<Region>
columns={COLUMNS}
rows={[]}
rowKey={(r) => r.id}
loading
/>
),
};
/** Standardized empty slot. */
export const Empty: Story = {
render: () => (
<DataTable<Region>
columns={COLUMNS}
rows={[]}
rowKey={(r) => r.id}
empty="No regions deployed yet."
/>
),
};
/** Standardized error slot (announced as an alert). */
export const ErrorState: Story = {
render: () => (
<DataTable<Region>
columns={COLUMNS}
rows={[]}
rowKey={(r) => r.id}
error="Couldn't load regions. Try again."
/>
),
};
/** Optional toolbar slot above the table, inside the surface. */
export const WithToolbar: Story = {
render: () => (
<DataTable<Region>
columns={COLUMNS}
rows={REGIONS}
rowKey={(r) => r.id}
toolbar={
<>
<strong style={{ fontSize: "0.8125rem" }}>Regions</strong>
<span style={{ flex: 1 }} />
<Button variant="secondary" size="sm">
Export
</Button>
</>
}
/>
),
};
/** The one look choice: the `compact` variant. */
export const Compact: Story = {
render: () => (
<DataTable<Region>
columns={COLUMNS}
rows={REGIONS}
rowKey={(r) => r.id}
variant="compact"
/>
),
};
+516
View File
@@ -0,0 +1,516 @@
import { type KeyboardEvent, type ReactNode, useMemo, useState } from "react";
import {
type ColumnDef,
createColumnHelper,
createSortedRowModel,
flexRender,
type RowData,
rowSortingFeature,
sortFn_alphanumeric,
sortFn_basic,
type SortingState,
tableFeatures,
useTable,
} from "@tanstack/react-table";
import { useTranslation } from "react-i18next";
import { Skeleton } from "@app/ui/Skeleton";
import {
type CellAction,
type DataTableColumn,
renderCellActions,
} from "@app/ui/dataTableColumns";
import "@app/ui/DataTable.css";
export * from "@app/ui/dataTableColumns";
/** Per-column presentation carried through TanStack's typed `meta` slot. */
interface ColumnMeta {
align: "left" | "right";
nowrap: boolean;
fit: boolean;
/** Visually-hidden header text for blank affordance/action columns, so the
* column still has an accessible name (avoids axe `empty-table-header`). */
srHeader?: string;
}
/**
* Feature registry for every DataTable, built once. Sorting is always
* registered so any column can opt in; the core row model defaults in.
*/
const DATA_TABLE_FEATURES = tableFeatures({
rowSortingFeature,
sortedRowModel: createSortedRowModel(),
columnMeta: {} as ColumnMeta,
// Register the comparators the column vocabulary uses. Without this v9 falls
// back to a case-sensitive `basic` sort and warns per column.
sortFns: { alphanumeric: sortFn_alphanumeric, basic: sortFn_basic },
});
type DataTableFeatures = typeof DATA_TABLE_FEATURES;
/** Closed appearance dial — the only look choice a call-site may make. */
export type DataTableVariant = "default" | "compact";
/**
* A collapsible section of rows under a locked header. Group headers are
* structured (title + muted meta + optional right-aligned actions), never raw
* markup, so grouped tables stay as opinionated as flat ones. Provide `groups`
* instead of `rows`.
*/
export interface DataTableGroup<T> {
key: string;
title: string;
/** Muted sub-text on the header (e.g. "5 people · led by Dana"). */
meta?: string;
/** Right-aligned header actions (e.g. "Add to team", a kebab menu). */
actions?: CellAction[];
rows: T[];
/** Collapse rows past this count behind a "Show all N" toggle. */
collapseAfter?: number;
/** Render the group's rows greyed/disabled (non-actionable, e.g. coming-soon). */
muted?: boolean;
}
export interface DataTableProps<T> {
/** Columns built with the `column` vocabulary — never raw JSX. */
columns: DataTableColumn<T>[];
/** Flat rows. Provide this OR `groups`, not both. */
rows?: T[];
/** Grouped rows with section headers. Takes precedence over `rows`. */
groups?: DataTableGroup<T>[];
rowKey: (row: T) => string;
/** Makes rows interactive (hover + click + keyboard). */
onRowClick?: (row: T) => void;
/** Per-row interactivity gate, checked only when `onRowClick` is set. */
isRowInteractive?: (row: T) => boolean;
/** Trailing affordance drawn on interactive rows. */
rowAffordance?: "none" | "chevron";
/** Initial sort, applied to the matching sortable column. */
defaultSort?: { key: string; direction?: "asc" | "desc" };
/** First-load state: renders column-shaped skeleton rows under the header. */
loading?: boolean;
/** Skeleton row count while loading. Defaults to 6. */
skeletonRows?: number;
/** Error slot — replaces the rows with an alert message row. */
error?: ReactNode;
/** Shown when there are no rows (and not loading / no error). Text or a node. */
empty?: ReactNode;
/** Content above the table (filters, search, actions), inside the surface. */
toolbar?: ReactNode;
/** The only appearance choice. */
variant?: DataTableVariant;
/** Accessible caption for the table. */
caption?: string;
/** Labels for a group's "show all / show less" toggle (pass translated).
* `showAll` receives the group's total row count. */
collapseLabels?: { showAll: (total: number) => string; showLess: string };
}
function ChevronGlyph() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden>
<path
d="m9 6 6 6-6 6"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function SortGlyph() {
return (
<svg
width="10"
height="10"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden
>
<path d="M12 8 6 15h12z" />
</svg>
);
}
const CHEVRON_COLUMN_KEY = "__affordance";
/**
* The shared Stirling table. Call-sites supply data + behaviour; the component
* owns 100% of the appearance. Columns come from the `column` vocabulary (typed
* cell kinds, no raw markup), the surface / density / states are standardized
* here, and the only look choice exposed is the closed `variant`. Behaviour -
* sorting today, more later - is opt-in per column or via props.
*/
export function DataTable<T extends RowData>({
columns,
rows = [],
groups,
rowKey,
onRowClick,
isRowInteractive,
rowAffordance = "none",
defaultSort,
loading = false,
skeletonRows = 6,
error,
empty,
toolbar,
variant = "default",
caption,
collapseLabels = {
showAll: (n) => `Show all ${n}`,
showLess: "Show less",
},
}: DataTableProps<T>) {
const { t } = useTranslation();
const [sorting, setSorting] = useState<SortingState>(
defaultSort
? [{ id: defaultSort.key, desc: defaultSort.direction === "desc" }]
: [],
);
const [openGroups, setOpenGroups] = useState<Set<string>>(new Set());
const toggleGroup = (key: string) =>
setOpenGroups((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
// The data source is either grouped or flat; TanStack (headers, sorting for
// the flat path) is fed the flattened rows.
const flatRows = useMemo(
() => (groups ? groups.flatMap((g) => g.rows) : rows),
[groups, rows],
);
const interactive = Boolean(onRowClick);
const showChevron = interactive && rowAffordance === "chevron";
// A row that holds its own controls (actions/links/select/caps) can't also be
// a `role="button"` (a button may not contain interactive descendants); it
// keeps the click as a mouse shortcut, and the inner control is the keyboard path.
const rowsContainControls = columns.some((c) => c.interactive);
const effectiveColumns = useMemo<DataTableColumn<T>[]>(() => {
if (!showChevron) return columns;
return [
...columns,
{
key: CHEVRON_COLUMN_KEY,
header: "",
align: "right",
nowrap: true,
fit: true,
sortable: false,
renderCell: (row) =>
(isRowInteractive?.(row) ?? true) ? (
<span className="sui-datatable__chevron" aria-hidden>
<ChevronGlyph />
</span>
) : null,
},
];
}, [columns, showChevron, isRowInteractive]);
const tanstackColumns = useMemo<ColumnDef<DataTableFeatures, T>[]>(() => {
const helper = createColumnHelper<DataTableFeatures, T>();
return effectiveColumns.map((c) => {
// A blank header (trailing affordance/action columns) still needs an
// accessible name for assistive tech.
const srHeader = c.header
? undefined
: c.key === CHEVRON_COLUMN_KEY
? t("common.open", "Open")
: t("common.actions", "Actions");
const meta: ColumnMeta = {
align: c.align,
nowrap: c.nowrap,
fit: c.fit,
srHeader,
};
if (c.sortable && c.sortValue) {
const sortValue = c.sortValue;
return helper.accessor((row: T): unknown => sortValue(row), {
id: c.key,
header: () => c.header,
cell: (ctx) => c.renderCell(ctx.row.original),
enableSorting: true,
sortUndefined: "last",
sortFn: c.sortFn ?? "basic",
meta,
});
}
return helper.display({
id: c.key,
header: () => c.header,
cell: (ctx) => c.renderCell(ctx.row.original),
meta,
});
});
}, [effectiveColumns, t]);
const table = useTable({
features: DATA_TABLE_FEATURES,
data: flatRows,
columns: tanstackColumns,
state: { sorting },
onSortingChange: setSorting,
getRowId: (row) => rowKey(row),
});
const colCount = effectiveColumns.length;
// Shared row wiring so grouped rows behave like flat ones (interactivity +
// the affordance column) instead of being a second-class path.
const rowProps = (original: T, muted?: boolean) => {
const rowInteractive =
interactive && (isRowInteractive?.(original) ?? true);
// A row that owns the whole interaction takes the button role + keyboard
// handling; a row with its own controls keeps just the mouse click.
const asButton = rowInteractive && !rowsContainControls;
return {
className: [
"sui-datatable__row",
rowInteractive ? "sui-datatable__row--interactive" : "",
muted ? "sui-datatable__row--muted" : "",
]
.filter(Boolean)
.join(" "),
onClick: rowInteractive ? () => onRowClick?.(original) : undefined,
tabIndex: asButton ? 0 : undefined,
role: asButton ? ("button" as const) : undefined,
onKeyDown: asButton
? (e: KeyboardEvent<HTMLTableRowElement>) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onRowClick?.(original);
}
}
: undefined,
};
};
// No rows at all - covers a grouped table whose groups are all empty (or an
// empty groups list), which would otherwise render a header-only table.
const noRows = groups
? groups.every((g) => g.rows.length === 0)
: rows.length === 0;
let body: ReactNode;
if (loading) {
body = Array.from({ length: skeletonRows }).map((_, r) => (
<tr key={`skeleton-${r}`} className="sui-datatable__row">
{effectiveColumns.map((c) => (
<td key={c.key} className={cellClass(c.align, c.nowrap, c.fit)}>
<Skeleton
height="0.75rem"
width={c.align === "right" || c.fit ? "40%" : "70%"}
/>
</td>
))}
</tr>
));
} else if (error != null) {
body = (
<tr>
<td
className="sui-datatable__state sui-datatable__state--error"
colSpan={colCount}
role="alert"
>
{error}
</td>
</tr>
);
} else if (noRows) {
const isNode = typeof empty === "object" && empty !== null;
body = (
<tr>
<td
className={
isNode
? "sui-datatable__state sui-datatable__state--node"
: "sui-datatable__state"
}
colSpan={colCount}
>
{empty ?? "No data"}
</td>
</tr>
);
} else if (groups) {
body = groups.flatMap((g) => {
const limit = g.collapseAfter ?? Infinity;
const open = openGroups.has(g.key);
const overflow = g.rows.length > limit;
const shown = overflow && !open ? g.rows.slice(0, limit) : g.rows;
const header = (
<tr key={`group-${g.key}`} className="sui-datatable__group">
<td colSpan={colCount} className="sui-datatable__group-cell">
<div className="sui-datatable__group-head">
<div className="sui-datatable__group-title">
<strong>{g.title}</strong>
{g.meta && (
<span className="sui-datatable__group-meta">{g.meta}</span>
)}
</div>
{g.actions &&
g.actions.length > 0 &&
renderCellActions(g.actions)}
</div>
</td>
</tr>
);
const rowEls = shown.map((row) => (
<tr
key={rowKey(row)}
data-row-key={rowKey(row)}
{...rowProps(row, g.muted)}
>
{effectiveColumns.map((c) => (
<td key={c.key} className={cellClass(c.align, c.nowrap, c.fit)}>
{c.renderCell(row)}
</td>
))}
</tr>
));
const moreEl = overflow ? (
<tr key={`more-${g.key}`}>
<td colSpan={colCount} className="sui-datatable__group-more">
<button
type="button"
className="sui-datatable__show-all"
onClick={() => toggleGroup(g.key)}
>
{open
? collapseLabels.showLess
: collapseLabels.showAll(g.rows.length)}
</button>
</td>
</tr>
) : null;
return moreEl ? [header, ...rowEls, moreEl] : [header, ...rowEls];
});
} else {
body = table.getRowModel().rows.map((row) => (
<tr key={row.id} data-row-key={row.id} {...rowProps(row.original)}>
{row.getAllCells().map((cell) => {
const meta = cell.column.columnDef.meta;
return (
<td
key={cell.id}
className={cellClass(
meta?.align ?? "left",
meta?.nowrap ?? false,
meta?.fit ?? false,
)}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
);
})}
</tr>
));
}
return (
<div className={`sui-datatable sui-datatable--${variant}`}>
<div className="sui-datatable__frame">
{toolbar && <div className="sui-datatable__toolbar">{toolbar}</div>}
<div className="sui-datatable__scroll">
<table className="sui-datatable__table">
{caption && (
<caption className="sui-datatable__caption">{caption}</caption>
)}
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => {
const meta = header.column.columnDef.meta;
const align = meta?.align ?? "left";
const canSort = header.column.getCanSort();
const sorted = header.column.getIsSorted();
const label = header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
);
return (
<th
key={header.id}
scope="col"
className={headerClass(align, meta?.fit ?? false)}
aria-sort={
canSort
? sorted === "asc"
? "ascending"
: sorted === "desc"
? "descending"
: "none"
: undefined
}
>
{canSort ? (
<button
type="button"
className="sui-datatable__sort"
onClick={header.column.getToggleSortingHandler()}
>
{label}
<span
className={`sui-datatable__sort-icon sui-datatable__sort-icon--${sorted || "none"}`}
>
<SortGlyph />
</span>
</button>
) : meta?.srHeader ? (
<span className="sui-datatable__th-sr">
{meta.srHeader}
</span>
) : (
label
)}
</th>
);
})}
</tr>
))}
</thead>
<tbody>{body}</tbody>
</table>
</div>
</div>
</div>
);
}
function cellClass(
align: "left" | "right",
nowrap: boolean,
fit: boolean,
): string {
return [
"sui-datatable__td",
`sui-datatable__td--${align}`,
nowrap ? "sui-datatable__td--nowrap" : "",
fit ? "sui-datatable__td--fit" : "",
]
.filter(Boolean)
.join(" ");
}
function headerClass(align: "left" | "right", fit: boolean): string {
return [
"sui-datatable__th",
`sui-datatable__th--${align}`,
fit ? "sui-datatable__th--fit" : "",
]
.filter(Boolean)
.join(" ");
}
+2 -10
View File
@@ -4,28 +4,20 @@
}
.sui-dd__menu {
position: absolute;
top: calc(100% + var(--space-1));
/* Positioned (fixed, portaled to <body>) entirely by the Menu component. */
min-width: 12rem;
padding: var(--space-1);
background: var(--c-surface);
border: 1px solid var(--c-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
z-index: var(--z-dropdown);
z-index: var(--z-popover);
animation: fadeInUp var(--motion-enter) both;
display: flex;
flex-direction: column;
gap: 0.0625rem;
}
.sui-dd__menu--start {
left: 0;
}
.sui-dd__menu--end {
right: 0;
}
.sui-dd__item {
display: flex;
align-items: center;
+78 -13
View File
@@ -6,12 +6,14 @@ import {
useContext,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
type ReactElement,
type ReactNode,
} from "react";
import { createPortal } from "react-dom";
import "@app/ui/Dropdown.css";
type Alignment = "start" | "end";
@@ -20,6 +22,8 @@ interface DropdownContextValue {
open: boolean;
setOpen: (open: boolean) => void;
triggerRef: React.RefObject<HTMLElement | null>;
/** The portaled menu element, so click-outside can exclude it. */
menuRef: React.RefObject<HTMLDivElement | null>;
menuId: string;
align: Alignment;
}
@@ -68,15 +72,20 @@ function Root({
const triggerRef = useRef<HTMLElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null);
const menuId = useId();
// Click-outside + Escape close.
// Click-outside + Escape close. The menu is portaled to <body>, so it is not
// inside containerRef - check it separately or a click on it would close the
// menu before the item's handler runs.
useEffect(() => {
if (!open) return;
function onDocClick(e: MouseEvent) {
const target = e.target as Node;
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node)
!containerRef.current.contains(target) &&
!(menuRef.current && menuRef.current.contains(target))
) {
setOpen(false);
}
@@ -96,7 +105,7 @@ function Root({
}, [open, setOpen]);
const value = useMemo<DropdownContextValue>(
() => ({ open, setOpen, triggerRef, menuId, align }),
() => ({ open, setOpen, triggerRef, menuRef, menuId, align }),
[open, setOpen, menuId, align],
);
@@ -150,23 +159,79 @@ export interface DropdownMenuProps {
}
function Menu({ children, className, width }: DropdownMenuProps) {
const { open, menuId, align } = useDropdownCtx();
if (!open) return null;
const style =
width !== undefined
const { open, menuId, align, triggerRef, menuRef } = useDropdownCtx();
// Fixed position tracked to the trigger. Portaling to <body> keeps the menu
// out of any `overflow` ancestor (e.g. a table's horizontal scroll area),
// which would otherwise clip it and add a scrollbar.
const [pos, setPos] = useState<{
top?: number;
bottom?: number;
left?: number;
right?: number;
maxHeight: number;
} | null>(null);
useLayoutEffect(() => {
if (!open) return;
const place = () => {
const el = triggerRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
const gap = 4;
const margin = 8;
const spaceBelow = window.innerHeight - r.bottom - margin;
const spaceAbove = r.top - margin;
// Flip above when there's more room there, so a trigger near the viewport
// bottom doesn't open a fixed menu that runs off-screen and can't scroll.
const below = spaceBelow >= spaceAbove;
const horizontal =
align === "end"
? { right: window.innerWidth - r.right }
: { left: r.left };
setPos({
...horizontal,
...(below
? { top: r.bottom + gap }
: { bottom: window.innerHeight - r.top + gap }),
maxHeight: Math.max(0, (below ? spaceBelow : spaceAbove) - gap),
});
};
place();
// Track the trigger while scrolling/resizing (capture catches inner scrollers).
window.addEventListener("scroll", place, true);
window.addEventListener("resize", place);
return () => {
window.removeEventListener("scroll", place, true);
window.removeEventListener("resize", place);
};
}, [open, align, triggerRef]);
if (!open || !pos) return null;
const style: React.CSSProperties = {
position: "fixed",
// Explicit auto (not undefined) so the CSS fallback `top`/`left` can't leak
// in on the axis this placement isn't pinning.
top: pos.top ?? "auto",
bottom: pos.bottom ?? "auto",
left: pos.left ?? "auto",
right: pos.right ?? "auto",
maxHeight: pos.maxHeight,
overflowY: "auto",
...(width !== undefined
? { minWidth: typeof width === "number" ? `${width}px` : width }
: undefined;
return (
: {}),
};
return createPortal(
<div
id={menuId}
role="menu"
className={["sui-dd__menu", `sui-dd__menu--${align}`, className ?? ""]
.filter(Boolean)
.join(" ")}
ref={menuRef}
className={["sui-dd__menu", className ?? ""].filter(Boolean).join(" ")}
style={style}
>
{children}
</div>
</div>,
document.body,
);
}
-73
View File
@@ -1,73 +0,0 @@
.sui-table-wrap {
width: 100%;
overflow-x: auto;
}
.sui-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8125rem;
}
/* Header text kept for assistive tech only, so a column of controls can be named
without putting a heading above it. Defined here rather than borrowing a global
utility, since the portal loads its own stylesheet. */
.sui-table__th-sr {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
.sui-table__th {
text-align: left;
font-weight: 600;
color: var(--c-text-subtle);
font-size: 0.6875rem;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.625rem 0.875rem;
border-bottom: 1px solid var(--c-border);
white-space: nowrap;
}
.sui-table__th--right,
.sui-table__td--right {
text-align: right;
}
.sui-table__th--center,
.sui-table__td--center {
text-align: center;
}
.sui-table__td {
padding: 0.625rem 0.875rem;
color: var(--c-text-muted);
border-bottom: 1px solid var(--c-border-subtle);
vertical-align: middle;
}
.sui-table tbody tr:last-child .sui-table__td {
border-bottom: none;
}
.sui-table__row--interactive {
cursor: pointer;
transition: background var(--motion-fast);
}
.sui-table__row--interactive:hover {
background: var(--c-hover);
}
.sui-table__row--interactive:focus-visible {
outline: 0.125rem solid var(--c-primary);
outline-offset: -0.125rem;
}
.sui-table__empty {
padding: 2rem;
text-align: center;
color: var(--c-text-subtle);
}
@@ -1,109 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Table, type TableColumn } from "@app/ui/Table";
import { StatusBadge } from "@app/ui/StatusBadge";
interface Region {
id: string;
name: string;
code: string;
status: "healthy" | "degraded";
docs: number;
latency: string;
}
const REGIONS: Region[] = [
{
id: "1",
name: "US East",
code: "us-east-1",
status: "healthy",
docs: 12481,
latency: "41 ms",
},
{
id: "2",
name: "US West",
code: "us-west-2",
status: "healthy",
docs: 8210,
latency: "63 ms",
},
{
id: "3",
name: "EU West",
code: "eu-west-1",
status: "degraded",
docs: 3044,
latency: "190 ms",
},
];
const COLUMNS: TableColumn<Region>[] = [
{ key: "name", header: "Region", render: (r) => r.name },
{
key: "code",
header: "Code",
render: (r) => (
<code style={{ fontFamily: "var(--font-mono)" }}>{r.code}</code>
),
},
{
key: "status",
header: "Status",
render: (r) => (
<StatusBadge
tone={r.status === "healthy" ? "success" : "warning"}
size="sm"
>
{r.status}
</StatusBadge>
),
},
{
key: "docs",
header: "Docs 24h",
align: "right",
render: (r) => r.docs.toLocaleString(),
},
{ key: "latency", header: "P95", align: "right", render: (r) => r.latency },
];
const meta: Meta<typeof Table> = {
title: "Compound/Table",
component: Table,
tags: ["autodocs"],
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof Table>;
/** Presentational table — columns own their cell renderers; pass pre-sorted rows. */
export const Basic: Story = {
render: () => (
<Table<Region> columns={COLUMNS} rows={REGIONS} rowKey={(r) => r.id} />
),
};
/** With `onRowClick`, rows become focusable + hoverable (keyboard: Enter/Space). */
export const Interactive: Story = {
render: () => (
<Table<Region>
columns={COLUMNS}
rows={REGIONS}
rowKey={(r) => r.id}
onRowClick={() => {}}
/>
),
};
/** Empty body slot. */
export const Empty: Story = {
render: () => (
<Table<Region>
columns={COLUMNS}
rows={[]}
rowKey={(r) => r.id}
empty="No regions deployed yet."
/>
),
};
-141
View File
@@ -1,141 +0,0 @@
import type { ReactNode } from "react";
import "@app/ui/Surface.css";
import "@app/ui/Table.css";
export interface TableColumn<T> {
/** Stable column id. */
key: string;
header: ReactNode;
/**
* Hides the header visually but keeps it for assistive tech. For a trailing column of controls
* or chevrons, where a visible heading would be noise but a blank one leaves the cells below it
* unlabelled.
*/
headerHidden?: boolean;
/** Cell renderer for a row. */
render: (row: T) => ReactNode;
align?: "left" | "right" | "center";
/** Optional fixed/min width (any CSS length). */
width?: string;
}
export interface TableProps<T> {
columns: TableColumn<T>[];
rows: T[];
/** Stable key per row. */
rowKey: (row: T) => string;
/** Makes rows interactive (hover + click + keyboard). */
onRowClick?: (row: T) => void;
/**
* Per-row gate for interactivity, checked only when {@link onRowClick} is set. A row for which
* this returns false is inert: no click/keyboard, and not announced as a button. Defaults to
* all rows interactive.
*/
isRowInteractive?: (row: T) => boolean;
/**
* Set when rows render controls of their own. The row keeps its click as a mouse shortcut but
* stops announcing itself as a button, because a button may not contain other controls and a
* {@code <tr role="button">} is no longer a row to a screen reader. That row control is then the
* keyboard path to the same action, so nothing is lost by leaving the row itself inert.
*/
rowsContainControls?: boolean;
/** Rendered in place of the body when there are no rows. */
empty?: ReactNode;
className?: string;
}
/**
* Minimal data table primitive. Columns own their own cell renderers, so the
* table stays presentational — callers pre-sort/filter and pass the rows they
* want shown. Rows become focusable buttons-in-disguise when `onRowClick` is
* set.
*/
export function Table<T>({
columns,
rows,
rowKey,
onRowClick,
isRowInteractive,
rowsContainControls = false,
empty,
className,
}: TableProps<T>) {
const interactive = Boolean(onRowClick);
return (
<div
className={["sui-surface", "sui-table-wrap", className ?? ""]
.filter(Boolean)
.join(" ")}
>
<table className="sui-table">
<thead>
<tr>
{columns.map((c) => (
<th
key={c.key}
scope="col"
className={`sui-table__th sui-table__th--${c.align ?? "left"}`}
style={c.width ? { width: c.width } : undefined}
>
{c.headerHidden ? (
<span className="sui-table__th-sr">{c.header}</span>
) : (
c.header
)}
</th>
))}
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr>
<td className="sui-table__empty" colSpan={columns.length}>
{empty ?? "No data"}
</td>
</tr>
) : (
rows.map((row) => {
const rowInteractive =
interactive && (isRowInteractive?.(row) ?? true);
// Only a row that owns the whole interaction takes the button role and the keyboard
// handling that goes with it; see rowsContainControls.
const rowIsControl = rowInteractive && !rowsContainControls;
return (
<tr
key={rowKey(row)}
className={
rowInteractive
? "sui-table__row sui-table__row--interactive"
: "sui-table__row"
}
onClick={rowInteractive ? () => onRowClick?.(row) : undefined}
tabIndex={rowIsControl ? 0 : undefined}
role={rowIsControl ? "button" : undefined}
onKeyDown={
rowIsControl
? (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onRowClick?.(row);
}
}
: undefined
}
>
{columns.map((c) => (
<td
key={c.key}
className={`sui-table__td sui-table__td--${c.align ?? "left"}`}
>
{c.render(row)}
</td>
))}
</tr>
);
})
)}
</tbody>
</table>
</div>
);
}
@@ -0,0 +1,569 @@
import { Fragment, type ReactNode } from "react";
import { StatusBadge, type StatusTone } from "@app/ui/StatusBadge";
import { Chip, type ChipAccent } from "@app/ui/Chip";
import { Button } from "@app/ui/Button";
import { Dropdown } from "@app/ui/Dropdown";
import { ProgressBar } from "@app/ui/ProgressBar";
import { Select, type SelectOption } from "@app/ui/Select";
/**
* The column vocabulary for {@link DataTable}. Call-sites pick a cell KIND and
* supply the data + semantics; the component owns 100% of the appearance. There
* is no raw-JSX / className escape hatch by design; a cell can only look the way
* the design system draws its kind, so every table looks and behaves the same.
*/
type Align = "left" | "right";
type SortValue = string | number | boolean | null | undefined;
/**
* Which built-in comparator sorts a column. Set by the builder from the cell's
* data type - `alphanumeric` (case-insensitive, natural: `v2` before `v10`) for
* text, `basic` (raw numeric) for numbers. Call-sites never choose this.
*/
export type DataTableSortFn = "alphanumeric" | "basic";
/** Opaque, fully-resolved column. Produced only by the {@link column} builders. */
export interface DataTableColumn<T> {
key: string;
header: ReactNode;
align: Align;
/** Prevent wrapping (mono/number values). */
nowrap: boolean;
/** Shrink the column to its content (actions / affordances). */
fit: boolean;
sortable: boolean;
sortValue?: (row: T) => SortValue;
/** Comparator kind, derived from the cell type. Only set when sortable. */
sortFn?: DataTableSortFn;
/** Cell renders its own interactive control (button/link/select/chip). Rows
* containing one drop their `role="button"` so a button never nests inside a
* button - the control is the keyboard path instead. */
interactive?: boolean;
/** Internal, design-system-owned renderer. Call-sites never supply this. */
renderCell: (row: T) => ReactNode;
}
/** The only design-system glyph a cell may use (icon-only actions). */
export type CellGlyph = "kebab";
function KebabGlyph() {
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden
>
<circle cx="12" cy="5" r="1.6" />
<circle cx="12" cy="12" r="1.6" />
<circle cx="12" cy="19" r="1.6" />
</svg>
);
}
/** An item in a kebab action menu. */
export interface CellMenuItem {
label: string;
tone?: "default" | "danger";
disabled?: boolean;
onClick: () => void;
/** Draw a divider above this item. */
dividerBefore?: boolean;
}
/** A row/group action. A locked button, or a kebab menu when `menu` is set. */
export interface CellAction {
label: string;
glyph?: CellGlyph;
/** Icon-only (uses `label` as the accessible name). */
iconOnly?: boolean;
tone?: "default" | "danger";
onClick?: () => void;
loading?: boolean;
disabled?: boolean;
/** When set, the button opens this menu instead of firing `onClick`. */
menu?: CellMenuItem[];
}
/** Renders a row of locked action buttons / kebab menus. Shared by the
* `actions` cell kind and grouped-table headers. */
export function renderCellActions(actions: CellAction[]): ReactNode {
return (
<div className="sui-dtc__actions" onClick={(e) => e.stopPropagation()}>
{actions.map((a) =>
a.menu ? (
<Dropdown.Root key={a.label}>
<Dropdown.Trigger>
<Button
variant={a.iconOnly ? "tertiary" : "secondary"}
size="sm"
shape={a.iconOnly ? "circle" : undefined}
leftSection={a.glyph ? <KebabGlyph /> : undefined}
loading={a.loading}
disabled={a.disabled}
aria-label={a.iconOnly ? a.label : undefined}
>
{a.iconOnly ? undefined : a.label}
</Button>
</Dropdown.Trigger>
<Dropdown.Menu width={210}>
{a.menu.map((m) => (
<Fragment key={m.label}>
{m.dividerBefore && <Dropdown.Divider />}
<Dropdown.Item
onSelect={m.onClick}
disabled={m.disabled}
className={
m.tone === "danger"
? "sui-dtc__menu-item--danger"
: undefined
}
>
{m.label}
</Dropdown.Item>
</Fragment>
))}
</Dropdown.Menu>
</Dropdown.Root>
) : (
<Button
key={a.label}
variant={a.iconOnly ? "tertiary" : "secondary"}
accent={a.tone === "danger" ? "danger" : undefined}
size="sm"
shape={a.iconOnly ? "circle" : undefined}
leftSection={a.glyph ? <KebabGlyph /> : undefined}
loading={a.loading}
disabled={a.disabled}
aria-label={a.iconOnly ? a.label : undefined}
onClick={(e) => {
e.stopPropagation();
a.onClick?.();
}}
>
{a.iconOnly ? undefined : a.label}
</Button>
),
)}
</div>
);
}
/** An external link inside a cell. */
export interface CellLink {
label: string;
href: string;
ariaLabel?: string;
}
interface Common {
key: string;
header: ReactNode;
sortable?: boolean;
}
function base<T>(
o: Common,
extra: Pick<DataTableColumn<T>, "align" | "nowrap" | "fit" | "renderCell"> & {
sortValue?: (row: T) => SortValue;
sortFn?: DataTableSortFn;
interactive?: boolean;
},
): DataTableColumn<T> {
return {
key: o.key,
header: o.header,
align: extra.align,
nowrap: extra.nowrap,
fit: extra.fit,
sortable: !!o.sortable,
sortValue: o.sortable ? extra.sortValue : undefined,
sortFn: o.sortable ? extra.sortFn : undefined,
interactive: extra.interactive,
renderCell: extra.renderCell,
};
}
function text<T>(
o: Common & {
get: (row: T) => string;
/** Optional bold label rendered before the value as "Label: value". */
label?: (row: T) => string | null | undefined;
sortBy?: (row: T) => SortValue;
},
): DataTableColumn<T> {
return base<T>(o, {
align: "left",
nowrap: false,
fit: false,
sortValue: o.sortBy ?? ((r) => o.get(r)),
sortFn: "alphanumeric",
renderCell: (r) => {
const label = o.label?.(r);
return label ? (
<span className="sui-dtc__text sui-dtc__text--labeled">
<strong className="sui-dtc__text-label">{label}:</strong> {o.get(r)}
</span>
) : (
<span className="sui-dtc__text">{o.get(r)}</span>
);
},
});
}
function mono<T>(
o: Common & { get: (row: T) => string; sortBy?: (row: T) => SortValue },
): DataTableColumn<T> {
return base<T>(o, {
align: "left",
nowrap: true,
fit: false,
sortValue: o.sortBy ?? ((r) => o.get(r)),
sortFn: "alphanumeric",
renderCell: (r) => <code className="sui-dtc__mono">{o.get(r)}</code>,
});
}
function muted<T>(
o: Common & {
get: (row: T) => string | null | undefined;
placeholder?: string;
/** Override the sort key (e.g. an ISO date behind a "3 days ago" label). */
sortBy?: (row: T) => SortValue;
},
): DataTableColumn<T> {
return base<T>(o, {
align: "left",
nowrap: false,
fit: false,
sortValue: o.sortBy ?? ((r) => o.get(r) ?? undefined),
sortFn: "alphanumeric",
renderCell: (r) => (
<span className="sui-dtc__muted">
{o.get(r) || (o.placeholder ?? "-")}
</span>
),
});
}
function number<T>(
o: Common & {
get: (row: T) => number | null | undefined;
format?: (n: number, row: T) => string;
placeholder?: string;
/** Override the sort key (e.g. a raw count behind a formatted label). */
sortBy?: (row: T) => SortValue;
},
): DataTableColumn<T> {
return base<T>(o, {
align: "right",
nowrap: true,
fit: false,
sortValue: o.sortBy ?? ((r) => o.get(r) ?? undefined),
sortFn: "basic",
renderCell: (r) => {
const n = o.get(r);
if (n == null) {
return (
<span className="sui-dtc__num sui-dtc__muted">
{o.placeholder ?? "-"}
</span>
);
}
return (
<span className="sui-dtc__num">
{o.format ? o.format(n, r) : String(n)}
</span>
);
},
});
}
function badge<T>(
o: Common & {
get: (row: T) => { tone: StatusTone; label: string };
sortBy?: (row: T) => SortValue;
},
): DataTableColumn<T> {
return base<T>(o, {
align: "left",
nowrap: true,
fit: false,
sortValue: o.sortBy ?? ((r) => o.get(r).label),
sortFn: "alphanumeric",
renderCell: (r) => {
const b = o.get(r);
return (
<StatusBadge tone={b.tone} size="sm">
{b.label}
</StatusBadge>
);
},
});
}
/**
* A user-defined label, rendered as a dot-less pill. Use this ONLY for labels
* that come from data / the user (e.g. a document's classification). Values from
* a fixed set we define (types, environments, providers) are `text`, not pills.
*/
export interface CellLabel {
label: string;
accent?: ChipAccent;
}
function labels<T>(
o: Common & { get: (row: T) => CellLabel[] },
): DataTableColumn<T> {
return base<T>(o, {
align: "left",
nowrap: false,
fit: false,
sortValue: (r) => o.get(r)[0]?.label ?? undefined,
sortFn: "alphanumeric",
renderCell: (r) => (
<div className="sui-dtc__labels">
{o.get(r).map((l) => (
<Chip
key={l.label}
accent={l.accent ?? "neutral"}
size="sm"
showDot={false}
>
{l.label}
</Chip>
))}
</div>
),
});
}
/**
* An interactive capability chip: click to grant, remove to revoke, dashed to
* offer adding. A functional cell (it toggles state), distinct from static
* `labels`.
*/
export interface CellCap {
label: string;
accent?: ChipAccent;
onClick?: () => void;
onRemove?: () => void;
dashed?: boolean;
}
function caps<T>(
o: Common & { get: (row: T) => CellCap[] },
): DataTableColumn<T> {
return base<T>(o, {
align: "left",
nowrap: false,
fit: false,
interactive: true,
renderCell: (r) => (
<div className="sui-dtc__labels">
{o.get(r).map((c) => (
<Chip
key={c.label}
accent={c.accent ?? "neutral"}
size="sm"
showDot={false}
dashed={c.dashed}
onClick={c.onClick}
onRemove={c.onRemove}
>
{c.label}
</Chip>
))}
</div>
),
});
}
function entity<T>(
o: Common & {
/** Semantic leading icon (component owns its size + colour container). */
icon?: (row: T) => ReactNode;
primary: (row: T) => string;
/** Muted inline suffix after the name, its own node (e.g. "(you)"). */
suffix?: (row: T) => string | null | undefined;
/** Secondary muted line under the name. */
note?: (row: T) => string | null | undefined;
sortBy?: (row: T) => SortValue;
},
): DataTableColumn<T> {
return base<T>(o, {
align: "left",
nowrap: false,
fit: false,
sortValue: o.sortBy ?? ((r) => o.primary(r)),
sortFn: "alphanumeric",
renderCell: (r) => {
const icon = o.icon?.(r);
const suffix = o.suffix?.(r);
const note = o.note?.(r);
return (
<div className="sui-dtc__entity">
{icon != null && (
<span className="sui-dtc__entity-icon" aria-hidden>
{icon}
</span>
)}
<div className="sui-dtc__entity-body">
<span className="sui-dtc__entity-head">
<span className="sui-dtc__entity-name">{o.primary(r)}</span>
{suffix && (
<span className="sui-dtc__entity-suffix">{suffix}</span>
)}
</span>
{note && <span className="sui-dtc__note">{note}</span>}
</div>
</div>
);
},
});
}
function actions<T>(o: {
key: string;
header?: ReactNode;
get: (row: T) => CellAction[];
}): DataTableColumn<T> {
return {
key: o.key,
header: o.header ?? "",
align: "right",
nowrap: true,
fit: true,
sortable: false,
interactive: true,
renderCell: (r) => renderCellActions(o.get(r)),
};
}
function progress<T>(
o: Common & {
get: (row: T) => { value: number; label?: string };
/** Accessible name for the bar (it has no visible text). Defaults to the
* shown percent; pass a description like "Load for us-east-1" when useful. */
ariaLabel?: (row: T) => string;
},
): DataTableColumn<T> {
return base<T>(o, {
align: "left",
nowrap: true,
fit: false,
sortValue: (r) => o.get(r).value,
sortFn: "basic",
renderCell: (r) => {
const p = o.get(r);
const shown = p.label ?? `${Math.round(p.value * 100)}%`;
return (
<div className="sui-dtc__progress">
<span className="sui-dtc__progress-bar">
<ProgressBar
value={p.value}
thresholded
height={6}
label={o.ariaLabel?.(r) ?? shown}
/>
</span>
<span className="sui-dtc__progress-pct">{shown}</span>
</div>
);
},
});
}
function links<T>(o: {
key: string;
header?: ReactNode;
get: (row: T) => CellLink[];
}): DataTableColumn<T> {
return {
key: o.key,
header: o.header ?? "",
align: "right",
nowrap: true,
fit: true,
sortable: false,
interactive: true,
renderCell: (r) => (
<div className="sui-dtc__links">
{o.get(r).map((l) => (
<a
key={l.label}
className="sui-dtc__link"
href={l.href}
target="_blank"
rel="noopener noreferrer"
aria-label={l.ariaLabel}
>
{l.label}
</a>
))}
</div>
),
};
}
function select<T>(o: {
key: string;
header: ReactNode;
get: (row: T) => {
value?: string | null;
defaultValue?: string;
options: SelectOption[];
ariaLabel?: string;
disabled?: boolean;
};
/** Omit for an uncontrolled select (local UI state only). */
onChange?: (row: T, value: string | null) => void;
}): DataTableColumn<T> {
return {
key: o.key,
header: o.header,
align: "left",
nowrap: true,
fit: false,
sortable: false,
interactive: true,
renderCell: (r) => {
const s = o.get(r);
const change = o.onChange;
return (
<div className="sui-dtc__select">
<Select
options={s.options}
value={s.value}
defaultValue={s.defaultValue}
onChange={change ? (v) => change(r, v) : undefined}
aria-label={s.ariaLabel}
disabled={s.disabled}
inputSize="sm"
/>
</div>
);
},
};
}
/**
* The DataTable column vocabulary. Each builder produces a locked-appearance
* column; call-sites choose the kind + supply data, never styling.
*/
export const column = {
text,
mono,
muted,
number,
badge,
labels,
caps,
entity,
actions,
progress,
links,
select,
};
+1 -1
View File
@@ -40,7 +40,7 @@ export * from "@app/ui/Collapsible";
export * from "@app/ui/Tabs";
export * from "@app/ui/Dropdown";
export * from "@app/ui/Drawer";
export * from "@app/ui/Table";
export * from "@app/ui/DataTable";
// Forms
export * from "@app/ui/FormField";
@@ -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]);
}
@@ -95,12 +95,6 @@ export const DOCUMENT_STATUS_TONE: Record<DocumentStatus, StatusTone> = {
error: "danger",
};
export const PRODUCT_CHIP_TONE: Record<ProductType, ChipAccent> = {
API: "brand",
Editor: "success",
Automation: "warning",
};
/** Classification chip accent: danger when unclassified, warning when it needs a look. */
export function classificationTone(doc: ReviewDocument): ChipAccent {
if (doc.classification === "Unclassified") return "danger";
@@ -123,8 +123,6 @@
}
.portal-sidebar[data-collapsed] .portal-sidebar__footer {
margin-inline: 0.375rem;
padding-inline: 0;
align-items: center;
}
.portal-sidebar__logo {
@@ -179,10 +177,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 { useNavigate } from "react-router-dom";
@@ -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,13 +1,6 @@
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import {
Button,
Card,
EmptyState,
StatusBadge,
Table,
type TableColumn,
} from "@app/ui";
import { column, DataTable, type DataTableColumn, EmptyState } from "@app/ui";
import type { LinkedInstanceRow } from "@portal/api/link";
interface Props {
@@ -45,78 +38,67 @@ export function LinkedInstancesTable({
revokingId,
}: Props) {
const { t } = useTranslation();
const cols: TableColumn<LinkedInstanceRow>[] = [
{
const cols: DataTableColumn<LinkedInstanceRow>[] = [
column.entity({
key: "name",
header: t("portal.accountLink.instances.columns.instance", "Instance"),
render: (i) => (
<div className="portal-link__cell-stack">
<span className="portal-link__cell-strong">
{i.name ??
t("portal.accountLink.instances.unnamed", "Unnamed instance")}
</span>
<code className="portal-link__device-id">{i.deviceId}</code>
</div>
),
},
{
sortable: true,
primary: (i) =>
i.name ?? t("portal.accountLink.instances.unnamed", "Unnamed instance"),
note: (i) => i.deviceId,
}),
column.badge({
key: "status",
header: t("portal.accountLink.instances.columns.status", "Status"),
render: (i) =>
i.revoked ? (
<StatusBadge tone="danger" size="sm">
{t("portal.accountLink.instances.revoked", "Revoked")}
</StatusBadge>
) : (
<StatusBadge tone="success" size="sm">
{t("portal.accountLink.instances.active", "Active")}
</StatusBadge>
),
},
{
sortable: true,
get: (i) =>
i.revoked
? {
tone: "danger",
label: t("portal.accountLink.instances.revoked", "Revoked"),
}
: {
tone: "success",
label: t("portal.accountLink.instances.active", "Active"),
},
}),
column.muted({
key: "lastSeen",
header: t("portal.accountLink.instances.columns.lastSeen", "Last seen"),
render: (i) => (
<span className="portal-link__muted">
{relativeTime(i.lastSeenAt, t)}
</span>
),
},
{
sortable: true,
// Sort on the real ISO timestamp, not the "3d ago" label.
sortBy: (i) => i.lastSeenAt ?? undefined,
get: (i) => relativeTime(i.lastSeenAt, t),
}),
column.muted({
key: "created",
header: t("portal.accountLink.instances.columns.linked", "Linked"),
render: (i) => (
<span className="portal-link__muted">
{relativeTime(i.createdAt, t)}
</span>
),
},
{
sortable: true,
sortBy: (i) => i.createdAt ?? undefined,
get: (i) => relativeTime(i.createdAt, t),
}),
column.actions({
key: "actions",
header: (
<span className="sr-only">
{t("portal.accountLink.instances.columns.actions", "Actions")}
</span>
),
align: "right",
render: (i) =>
i.revoked ? null : (
<Button
variant="secondary"
accent="danger"
size="sm"
loading={revokingId === i.instanceId}
onClick={() => onRevoke(i)}
>
{t("portal.accountLink.instances.revoke", "Revoke")}
</Button>
),
},
get: (i) =>
i.revoked
? []
: [
{
label: t("portal.accountLink.instances.revoke", "Revoke"),
tone: "danger",
loading: revokingId === i.instanceId,
onClick: () => onRevoke(i),
},
],
}),
];
return (
<Card padding="none">
{instances.length === 0 ? (
<DataTable<LinkedInstanceRow>
columns={cols}
rows={instances}
rowKey={(i) => String(i.instanceId)}
empty={
<EmptyState
size="compact"
title={t(
@@ -128,13 +110,7 @@ export function LinkedInstancesTable({
"Link this org's account, then register your self-hosted instances to see them here.",
)}
/>
) : (
<Table
columns={cols}
rows={instances}
rowKey={(i) => String(i.instanceId)}
/>
)}
</Card>
}
/>
);
}
@@ -3,11 +3,12 @@ import { useTranslation } from "react-i18next";
import {
Button,
Card,
type CellLink,
column,
DataTable,
type DataTableColumn,
EmptyState,
Skeleton,
StatusBadge,
Table,
type TableColumn,
} from "@app/ui";
import { formatMinor, formatPeriodDate } from "@app/billing";
import { fetchInvoices, type Invoice } from "@portal/api/billing";
@@ -80,96 +81,78 @@ export function InvoicesList() {
// Date · Amount · Status · Description (product name) · Actions
// The monospace invoice id is dropped — users care about "what was it for",
// not the internal id.
const columns: TableColumn<Invoice>[] = [
{
const columns: DataTableColumn<Invoice>[] = [
column.text({
key: "date",
header: t("portal.billing.invoices.columnDate", "Date"),
render: (inv) =>
inv.createdAt ? formatPeriodDate(inv.createdAt, { year: true }) : "—",
},
{
sortable: true,
// Sort chronologically on the raw ISO timestamp, not the formatted label.
sortBy: (inv) => inv.createdAt ?? undefined,
get: (inv) =>
inv.createdAt ? formatPeriodDate(inv.createdAt, { year: true }) : "-",
}),
column.number({
key: "pdfs",
header: t(
"portal.billing.invoices.columnPdfsProcessed",
"PDFs processed",
),
align: "right",
// Billed units on the invoice's metered line item; "—" when the
sortable: true,
// Billed units on the invoice's metered line item; blank when the
// line-item table isn't synced into the Stripe mirror.
render: (inv) =>
inv.pdfsProcessed == null ? "—" : inv.pdfsProcessed.toLocaleString(),
},
{
get: (inv) => inv.pdfsProcessed,
format: (n) => n.toLocaleString(),
}),
column.number({
key: "amount",
header: t("portal.billing.invoices.columnAmount", "Amount"),
align: "right",
render: (inv) =>
inv.totalMinor == null
? "—"
: formatMinor(inv.totalMinor, inv.currency),
},
{
sortable: true,
get: (inv) => inv.totalMinor,
format: (n, inv) => formatMinor(n, inv.currency),
}),
column.badge({
key: "status",
header: t("portal.billing.invoices.columnStatus", "Status"),
render: (inv) => (
<StatusBadge tone={statusTone(inv.status)} size="sm">
{inv.status}
</StatusBadge>
),
},
{
sortable: true,
get: (inv) => ({ tone: statusTone(inv.status), label: inv.status }),
}),
column.text({
key: "description",
header: t("portal.billing.invoices.columnDescription", "Description"),
render: (inv) => (
<span className="portal-billing__invoice-desc">
{inv.description ??
t("portal.billing.invoices.descriptionFallback", "Invoice")}
</span>
),
},
{
sortable: true,
get: (inv) =>
inv.description ??
t("portal.billing.invoices.descriptionFallback", "Invoice"),
}),
column.links({
key: "actions",
header: "",
align: "right",
render: (inv) => (
<div className="portal-billing__invoice-actions">
{inv.hostedInvoiceUrl && (
<a
className="portal-billing__invoice-link"
href={inv.hostedInvoiceUrl}
target="_blank"
rel="noopener noreferrer"
aria-label={t(
"portal.billing.invoices.viewAriaLabel",
"View invoice {{number}} in Stripe",
{
number: inv.number ?? inv.id,
},
)}
>
{t("portal.billing.invoices.viewLink", "View ↗")}
</a>
)}
{inv.invoicePdf && (
<a
className="portal-billing__invoice-link"
href={inv.invoicePdf}
target="_blank"
rel="noopener noreferrer"
aria-label={t(
"portal.billing.invoices.downloadAriaLabel",
"Download invoice {{number}} as PDF",
{
number: inv.number ?? inv.id,
},
)}
>
{t("portal.billing.invoices.pdfLink", "PDF ↓")}
</a>
)}
</div>
),
},
get: (inv) => {
const out: CellLink[] = [];
if (inv.hostedInvoiceUrl) {
out.push({
label: t("portal.billing.invoices.viewLink", "View ↗"),
href: inv.hostedInvoiceUrl,
ariaLabel: t(
"portal.billing.invoices.viewAriaLabel",
"View invoice {{number}} in Stripe",
{ number: inv.number ?? inv.id },
),
});
}
if (inv.invoicePdf) {
out.push({
label: t("portal.billing.invoices.pdfLink", "PDF ↓"),
href: inv.invoicePdf,
ariaLabel: t(
"portal.billing.invoices.downloadAriaLabel",
"Download invoice {{number}} as PDF",
{ number: inv.number ?? inv.id },
),
});
}
return out;
},
}),
];
return (
@@ -209,11 +192,11 @@ export function InvoicesList() {
{invoices !== null && invoices.length > 0 && (
<>
<Table
className="portal-billing__flush-table"
<DataTable
columns={columns}
rows={visibleRows}
rowKey={(inv) => inv.id}
defaultSort={{ key: "date", direction: "desc" }}
/>
{hasMore && (
<div className="portal-billing__invoice-footer">
@@ -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,11 +1,32 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { Extraction } from "@portal/api/documents";
import { documentsFor } from "@portal/mocks/documents";
import { DocumentExtractions } from "@portal/components/documents/DocumentExtractions";
import "@portal/views/Documents.css";
const ALL = documentsFor("enterprise");
const NON_SENSITIVE = ALL.find((d) => !d.sensitive)!;
const SENSITIVE = ALL.find((d) => d.sensitive)!;
// The mock documents ship without extractions, so seed a realistic set here -
// a spread of confidence levels so the table (and its confidence sort) is
// actually reviewable.
const EXTRACTIONS: Extraction[] = [
{ field: "Counterparty", value: "Acme Services LLC", confidence: 0.98 },
{ field: "Effective date", value: "2026-01-14", confidence: 0.94 },
{ field: "Contract value", value: "$248,000.00", confidence: 0.87 },
{ field: "Governing law", value: "Delaware", confidence: 0.72 },
{ field: "Auto-renewal", value: "Yes (12 months)", confidence: 0.55 },
];
const NON_SENSITIVE = {
...ALL.find((d) => !d.sensitive)!,
extractions: EXTRACTIONS,
fieldsExtracted: EXTRACTIONS.length,
};
const SENSITIVE = {
...ALL.find((d) => d.sensitive)!,
extractions: EXTRACTIONS,
fieldsExtracted: EXTRACTIONS.length,
};
const meta: Meta<typeof DocumentExtractions> = {
title: "Portal/Documents/DocumentExtractions",
@@ -23,7 +44,7 @@ const meta: Meta<typeof DocumentExtractions> = {
export default meta;
type Story = StoryObj<typeof DocumentExtractions>;
/** No extraction data exists yet - the table shows its empty state. */
/** Extracted fields with a mix of confidence levels; click a header to sort. */
export const Default: Story = {};
/** Sensitive doc with no active grant — content stays masked. */
@@ -35,3 +56,10 @@ export const Masked: Story = {
export const Unlocked: Story = {
args: { doc: SENSITIVE, unlocked: true },
};
/** No extraction data yet — the empty state. */
export const Empty: Story = {
args: {
doc: { ...NON_SENSITIVE, extractions: [], fieldsExtracted: 0 },
},
};
@@ -1,6 +1,6 @@
import { useTranslation } from "react-i18next";
import LockRounded from "@mui/icons-material/LockRounded";
import { StatusBadge, Table, type TableColumn } from "@app/ui";
import { column, DataTable, type DataTableColumn } from "@app/ui";
import { type Extraction, type ReviewDocument } from "@portal/api/documents";
import {
confidencePct,
@@ -24,32 +24,30 @@ export function DocumentExtractions({
}: DocumentExtractionsProps) {
const { t } = useTranslation();
const cols: TableColumn<Extraction>[] = [
{
const cols: DataTableColumn<Extraction>[] = [
column.text({
key: "field",
header: t("portal.documents.extractions.columns.field"),
render: (e) => <span className="portal-documents__field">{e.field}</span>,
},
{
sortable: true,
get: (e) => e.field,
}),
column.mono({
key: "value",
header: t("portal.documents.extractions.columns.value"),
render: (e) => <span className="portal-documents__mono">{e.value}</span>,
},
{
get: (e) => e.value,
}),
column.badge({
key: "confidence",
header: t("portal.documents.extractions.columns.confidence"),
align: "right",
width: "7rem",
render: (e) => (
<StatusBadge
tone={confidenceTone(e.confidence)}
size="sm"
showDot={false}
>
{confidencePct(e.confidence)}
</StatusBadge>
),
},
sortable: true,
// Sort on the whole-percent integer, not the "92%" label (keeps decimals
// out of the natural-sort comparator).
sortBy: (e) => Math.round(e.confidence * 100),
get: (e) => ({
tone: confidenceTone(e.confidence),
label: confidencePct(e.confidence),
}),
}),
];
if (doc.sensitive && !unlocked) {
@@ -66,7 +64,7 @@ export function DocumentExtractions({
}
return (
<Table<Extraction>
<DataTable<Extraction>
columns={cols}
rows={doc.extractions}
rowKey={(e) => e.field}
@@ -1,12 +1,15 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import LockRounded from "@mui/icons-material/LockRounded";
import { Button, Chip, StatusBadge, Table, type TableColumn } from "@app/ui";
import {
type CellLabel,
column,
DataTable,
type DataTableColumn,
} from "@app/ui";
import {
classificationTone,
DOCUMENT_STATUS_LABEL,
DOCUMENT_STATUS_TONE,
PRODUCT_CHIP_TONE,
type ReviewDocument,
} from "@portal/api/documents";
@@ -15,160 +18,105 @@ interface ReviewQueueTableProps {
onRowClick: (doc: ReviewDocument) => void;
}
function BoltIcon() {
return (
<svg
width="10"
height="10"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden
>
<path d="M13 2 4 14h6l-1 8 9-12h-6l1-8z" />
</svg>
);
}
function KebabIcon() {
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden
>
<circle cx="12" cy="5" r="1.6" />
<circle cx="12" cy="12" r="1.6" />
<circle cx="12" cy="19" r="1.6" />
</svg>
);
}
/** The document stream - one row per document your org has processed. */
export function ReviewQueueTable({
documents,
onRowClick,
}: ReviewQueueTableProps) {
const { t } = useTranslation();
const columns = useMemo<TableColumn<ReviewDocument>[]>(
const columns = useMemo<DataTableColumn<ReviewDocument>[]>(
() => [
{
column.entity({
key: "document",
header: t("portal.documents.table.columns.document"),
render: (d) => (
<div className="portal-documents__doc-cell">
<div className="portal-documents__doc-head">
<span className="portal-documents__name">{d.name}</span>
{d.classification && (
<Chip accent={classificationTone(d)} size="sm">
{d.classification}
</Chip>
)}
{d.auto && (
<Chip accent="success" size="sm" leadingIcon={<BoltIcon />}>
{t("portal.documents.table.auto")}
</Chip>
)}
{d.sensitive && (
// role="img" so the label is allowed and the icon reads as one thing: aria-label
// is ignored on a bare span, leaving the padlock silent.
<span
className="portal-documents__lock"
role="img"
title={t("portal.documents.table.sensitiveTitle")}
aria-label={t("portal.documents.table.sensitiveLabel")}
>
<LockRounded style={{ fontSize: "0.95rem" }} />
</span>
)}
</div>
{d.note && <span className="portal-documents__note">{d.note}</span>}
</div>
),
},
{
sortable: true,
primary: (d) => d.name,
note: (d) => d.note,
}),
column.labels({
key: "labels",
header: t("portal.documents.table.columns.labels", "Labels"),
get: (d) => {
const out: CellLabel[] = [];
if (d.classification) {
out.push({
label: d.classification,
accent: classificationTone(d),
});
}
if (d.auto) {
out.push({
label: t("portal.documents.table.auto"),
accent: "success",
});
}
if (d.sensitive) {
out.push({
label: t("portal.documents.table.sensitiveLabel"),
accent: "warning",
});
}
return out;
},
}),
column.text({
key: "product",
header: t("portal.documents.table.columns.product"),
width: "7rem",
render: (d) => (
<Chip accent={PRODUCT_CHIP_TONE[d.product]} size="sm" showDot={false}>
{d.product}
</Chip>
),
},
{
sortable: true,
get: (d) => d.product,
}),
column.text({
key: "action",
header: t("portal.documents.table.columns.action"),
width: "12rem",
render: (d) =>
d.product === "Editor" || !d.action ? (
<span className="portal-documents__editor-action">
{t("portal.documents.table.editorAction")}
</span>
) : (
<span className="portal-documents__action">{d.action}</span>
),
},
{
sortable: true,
get: (d) =>
d.product === "Editor" || !d.action
? t("portal.documents.table.editorAction")
: d.action,
}),
column.muted({
key: "user",
header: t("portal.documents.table.columns.user"),
width: "8rem",
render: (d) => (
<span className="portal-documents__muted">{d.user || "-"}</span>
),
},
{
sortable: true,
get: (d) => d.user,
}),
column.badge({
key: "status",
header: t("portal.documents.table.columns.status"),
width: "10rem",
render: (d) => (
<StatusBadge tone={DOCUMENT_STATUS_TONE[d.status]} size="sm">
{t(DOCUMENT_STATUS_LABEL[d.status])}
{d.status === "in-review" && d.reviewer ? ` · ${d.reviewer}` : ""}
</StatusBadge>
),
},
{
sortable: true,
get: (d) => ({
tone: DOCUMENT_STATUS_TONE[d.status],
label:
t(DOCUMENT_STATUS_LABEL[d.status]) +
(d.status === "in-review" && d.reviewer ? ` · ${d.reviewer}` : ""),
}),
}),
column.muted({
key: "time",
header: t("portal.documents.table.columns.time"),
width: "7rem",
render: (d) => (
<span className="portal-documents__muted">{d.time}</span>
),
},
{
get: (d) => d.time,
}),
column.actions({
key: "actions",
header: t("portal.documents.table.columns.actions"),
headerHidden: true,
width: "3rem",
render: (d) => (
<Button
variant="quiet"
size="sm"
shape="circle"
leftSection={<KebabIcon />}
aria-label={t("portal.documents.table.rowActions")}
onClick={(e) => {
e.stopPropagation();
onRowClick(d);
}}
/>
),
},
get: (d) => [
{
label: t("portal.documents.table.rowActions"),
glyph: "kebab",
iconOnly: true,
onClick: () => onRowClick(d),
},
],
}),
],
[t, onRowClick],
);
return (
<Table<ReviewDocument>
className="portal-documents__table"
<DataTable<ReviewDocument>
columns={columns}
rows={documents}
rowKey={(d) => d.id}
onRowClick={onRowClick}
// Every row carries its own actions button, which opens the same document.
rowsContainControls
empty={t("portal.documents.table.empty")}
/>
);
@@ -1,17 +1,8 @@
import { useTranslation } from "react-i18next";
import {
Card,
Chip,
type ChipAccent,
EmptyState,
StatusBadge,
Table,
type TableColumn,
} from "@app/ui";
import { column, DataTable, type DataTableColumn, EmptyState } from "@app/ui";
import {
INSTANCE_STATUS_LABEL,
INSTANCE_STATUS_TONE,
TARGET_META,
type EditorInstance,
} from "@portal/api/editorDeploy";
@@ -22,13 +13,6 @@ const TARGET_LABEL: Record<EditorInstance["target"], string> = {
kubernetes: "K8s",
};
/** Map the target palette tone onto the shared Chip accent set. */
const TARGET_CHIP_ACCENT: Record<"neutral" | "blue" | "purple", ChipAccent> = {
neutral: "neutral",
blue: "default",
purple: "premium",
};
interface Props {
instances: EditorInstance[];
}
@@ -37,69 +21,65 @@ interface Props {
export function InstanceHealthTable({ instances }: Props) {
const { t } = useTranslation();
const cols: TableColumn<EditorInstance>[] = [
{
const cols: DataTableColumn<EditorInstance>[] = [
column.entity({
key: "host",
header: t("portal.editorAdmin.health.columns.host"),
render: (i) => (
<div className="portal-editor__cell-stack">
<span className="portal-editor__cell-strong">{i.host}</span>
<span className="portal-editor__cell-muted">
<Chip
size="sm"
accent={TARGET_CHIP_ACCENT[TARGET_META[i.target].tone]}
>
{TARGET_LABEL[i.target]}
</Chip>
</span>
</div>
),
},
{
sortable: true,
primary: (i) => i.host,
}),
column.text({
key: "target",
header: t("portal.editorAdmin.health.columns.target", "Target"),
sortable: true,
get: (i) => TARGET_LABEL[i.target],
}),
column.mono({
key: "version",
header: t("portal.editorAdmin.health.columns.version"),
render: (i) => <code className="portal-editor__mono">{i.version}</code>,
},
{
sortable: true,
get: (i) => i.version,
}),
column.mono({
key: "region",
header: t("portal.editorAdmin.health.columns.region"),
render: (i) => <span className="portal-editor__mono">{i.region}</span>,
},
{
sortable: true,
get: (i) => i.region,
}),
column.badge({
key: "status",
header: t("portal.editorAdmin.health.columns.status"),
render: (i) => (
<StatusBadge tone={INSTANCE_STATUS_TONE[i.status]} size="sm">
{t(INSTANCE_STATUS_LABEL[i.status])}
</StatusBadge>
),
},
{
sortable: true,
get: (i) => ({
tone: INSTANCE_STATUS_TONE[i.status],
label: t(INSTANCE_STATUS_LABEL[i.status]),
}),
}),
column.muted({
key: "lastSeen",
header: t("portal.editorAdmin.health.columns.lastSeen"),
render: (i) => <span className="portal-editor__muted">{i.lastSeen}</span>,
},
{
get: (i) => i.lastSeen,
}),
column.number({
key: "activeUsers",
header: t("portal.editorAdmin.health.columns.activeUsers"),
align: "right",
render: (i) => (
<span className="portal-editor__mono">{i.activeUsers}</span>
),
},
sortable: true,
get: (i) => i.activeUsers,
}),
];
return (
<Card padding="none">
{instances.length === 0 ? (
<DataTable<EditorInstance>
columns={cols}
rows={instances}
rowKey={(i) => i.id}
empty={
<EmptyState
size="compact"
title={t("portal.editorAdmin.health.empty.title")}
description={t("portal.editorAdmin.health.empty.description")}
/>
) : (
<Table columns={cols} rows={instances} rowKey={(i) => i.id} />
)}
</Card>
}
/>
);
}
@@ -69,7 +69,7 @@ export function FileRunEventList() {
const debugPanel = !import.meta.env.DEV ? null : (
<div className="portal-failures__debug">
<Button variant="secondary" size="sm" onClick={() => void refresh()}>
Refresh failures
{t("portal.failures.debug.refresh", "Refresh failures")}
</Button>
<Button
variant="secondary"
@@ -77,14 +77,24 @@ export function FileRunEventList() {
disabled={clearing || (events?.length ?? 0) === 0}
onClick={() => void dismissAll()}
>
{clearing ? "Dismissing..." : `Dismiss all (${events?.length ?? 0})`}
{clearing
? t("portal.failures.debug.dismissing", "Dismissing...")
: t("portal.failures.debug.dismissAll", "Dismiss all ({{total}})", {
total: events?.length ?? 0,
})}
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => setShowJson((shown) => !shown)}
>
{showJson ? "Hide" : "Show"} raw JSON ({events?.length ?? 0})
{showJson
? t("portal.failures.debug.hideJson", "Hide raw JSON ({{total}})", {
total: events?.length ?? 0,
})
: t("portal.failures.debug.showJson", "Show raw JSON ({{total}})", {
total: events?.length ?? 0,
})}
</Button>
<Button
variant="secondary"
@@ -95,7 +105,7 @@ export function FileRunEventList() {
)
}
>
Copy JSON
{t("portal.failures.debug.copyJson", "Copy JSON")}
</Button>
{showJson && (
<pre className="portal-failures__debug-json">
@@ -2,15 +2,14 @@ import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Button,
Card,
column,
DataTable,
type DataTableColumn,
EmptyState,
MetricCard,
MetricStrip,
StatusBadge,
Table,
Tabs,
type TabItem,
type TableColumn,
} from "@app/ui";
import { useTier } from "@portal/contexts/TierContext";
import { useSectionFlags } from "@portal/hooks/useAsync";
@@ -22,10 +21,8 @@ import {
} from "@portal/api/infrastructure";
import { AuditExportModal } from "@portal/components/infrastructure/AuditExportModal";
import { SectionHeader } from "@portal/components/infrastructure/SectionHeader";
import { TableSkeleton } from "@portal/components/infrastructure/TableSkeleton";
import {
AUDIT_CAT_LABEL,
AUDIT_CAT_TONE,
AUDIT_STATUS_LABEL,
AUDIT_TONE,
} from "@portal/components/infrastructure/infraFormat";
@@ -57,55 +54,52 @@ export function AuditTab() {
},
];
const cols: TableColumn<AuditEvent>[] = [
{
const cols: DataTableColumn<AuditEvent>[] = [
column.mono({
key: "timestamp",
header: t("portal.infrastructure.audit.columns.timestamp"),
render: (e) => <span className="portal-infra__mono">{e.timestamp}</span>,
},
{
sortable: true,
get: (e) => e.timestamp,
}),
column.text({
key: "event",
header: t("portal.infrastructure.audit.columns.event"),
render: (e) => (
<div className="portal-infra__event">
<StatusBadge tone={AUDIT_CAT_TONE[e.category]} size="sm">
{t(AUDIT_CAT_LABEL[e.category])}
</StatusBadge>
<span>{e.action}</span>
</div>
),
},
{
sortable: true,
// "Category: action" on one line - the category as a bold label, no
// status-coloured dot. Colour is reserved for the Status column, where it
// actually signals an outcome.
label: (e) => t(AUDIT_CAT_LABEL[e.category]),
get: (e) => e.action,
}),
column.mono({
key: "actor",
header: t("portal.infrastructure.audit.columns.actor"),
render: (e) => <span className="portal-infra__mono">{e.actor}</span>,
},
{
sortable: true,
get: (e) => e.actor,
}),
column.text({
key: "target",
header: t("portal.infrastructure.audit.columns.target"),
render: (e) => e.target,
},
{
sortable: true,
get: (e) => e.target,
}),
column.badge({
key: "status",
header: t("portal.infrastructure.audit.columns.status"),
render: (e) => (
<StatusBadge tone={AUDIT_TONE[e.status]} size="sm">
{t(AUDIT_STATUS_LABEL[e.status])}
</StatusBadge>
),
},
{
sortable: true,
get: (e) => ({
tone: AUDIT_TONE[e.status],
label: t(AUDIT_STATUS_LABEL[e.status]),
}),
}),
column.number({
key: "latency",
header: t("portal.infrastructure.audit.columns.latency"),
align: "right",
render: (e) => (
<span className="portal-infra__mono">
{t("portal.infrastructure.audit.latencyValue", {
value: e.latencyMs,
})}
</span>
),
},
sortable: true,
get: (e) => e.latencyMs,
format: (n) =>
t("portal.infrastructure.audit.latencyValue", { value: n }),
}),
];
const state = useAuditLog(tier);
@@ -171,31 +165,31 @@ export function AuditTab() {
/>
)}
<Card padding="none">
{isLoading && <TableSkeleton rows={6} cols={6} />}
{!isLoading && forbidden && (
<EmptyState
size="compact"
title={t("portal.infrastructure.audit.forbidden.title")}
description={t("portal.infrastructure.audit.forbidden.description")}
/>
)}
{!isLoading && !forbidden && isEmpty && (
<EmptyState
size="compact"
title={t("portal.infrastructure.audit.empty.title")}
description={t("portal.infrastructure.audit.empty.description")}
/>
)}
{!isEmpty && data && (
<Table
columns={cols}
rows={rows}
rowKey={(e) => e.id}
empty={t("portal.infrastructure.audit.noEventsInCategory")}
/>
)}
</Card>
<DataTable
columns={cols}
rows={rows}
rowKey={(e) => e.id}
loading={isLoading}
empty={
forbidden ? (
<EmptyState
size="compact"
title={t("portal.infrastructure.audit.forbidden.title")}
description={t(
"portal.infrastructure.audit.forbidden.description",
)}
/>
) : isEmpty ? (
<EmptyState
size="compact"
title={t("portal.infrastructure.audit.empty.title")}
description={t("portal.infrastructure.audit.empty.description")}
/>
) : (
t("portal.infrastructure.audit.noEventsInCategory")
)
}
/>
</div>
);
}
@@ -1,23 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Card } from "@app/ui";
import { TableSkeleton } from "@portal/components/infrastructure/TableSkeleton";
import "@portal/views/Infrastructure.css";
const meta: Meta<typeof TableSkeleton> = {
title: "Portal/Infrastructure/TableSkeleton",
component: TableSkeleton,
parameters: { layout: "padded" },
decorators: [
(S) => (
<Card padding="none" style={{ maxWidth: "60rem" }}>
<S />
</Card>
),
],
};
export default meta;
type Story = StoryObj<typeof TableSkeleton>;
export const Regions: Story = { args: { rows: 3, cols: 9 } };
export const AuditLog: Story = { args: { rows: 6, cols: 6 } };
@@ -1,16 +0,0 @@
import { Skeleton } from "@app/ui";
/** Placeholder grid shown inside a table card while rows are loading. */
export function TableSkeleton({ rows, cols }: { rows: number; cols: number }) {
return (
<div className="portal-infra__table-skel" aria-hidden>
{Array.from({ length: rows }).map((_, r) => (
<div key={r} className="portal-infra__table-skel-row">
{Array.from({ length: cols }).map((_, c) => (
<Skeleton key={c} height="0.75rem" />
))}
</div>
))}
</div>
);
}
@@ -39,12 +39,3 @@ export const AUDIT_CAT_LABEL: Record<AuditCategory, string> = {
processing: "portal.infrastructure.auditCatLabel.processing",
security: "portal.infrastructure.auditCatLabel.security",
};
export const AUDIT_CAT_TONE: Record<AuditCategory, StatusTone> = {
auth: "info",
config: "neutral",
elevation: "purple",
policy: "purple",
processing: "success",
security: "warning",
};
@@ -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);
});
@@ -1,13 +1,11 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import AccountTreeRounded from "@mui/icons-material/AccountTreeRounded";
import ChevronRightRoundedIcon from "@mui/icons-material/ChevronRightRounded";
import {
Chip,
StatusBadge,
column,
DataTable,
type DataTableColumn,
type StatusTone,
Table,
type TableColumn,
} from "@app/ui";
import type { PipelineStatus, PipelineView } from "@portal/api/pipelines";
@@ -24,87 +22,56 @@ interface PipelinesTableProps {
export function PipelinesTable({ pipelines, onRowClick }: PipelinesTableProps) {
const { t } = useTranslation();
const columns = useMemo<TableColumn<PipelineView>[]>(
const columns = useMemo<DataTableColumn<PipelineView>[]>(
() => [
{
column.entity({
key: "name",
header: t("portal.pipelines.table.name"),
render: (p) => (
<div className="portal-pipelines__name-cell">
<span className="portal-pipelines__pipe-dot" aria-hidden>
<AccountTreeRounded style={{ fontSize: "1.2rem" }} />
</span>
<div className="portal-pipelines__name-text">
<strong>{p.name}</strong>
<Chip accent="neutral" size="sm">
{t(`portal.pipelines.trigger.${p.trigger}`, {
defaultValue: p.trigger,
})}
</Chip>
</div>
</div>
),
},
{
sortable: true,
icon: () => <AccountTreeRounded />,
primary: (p) => p.name,
}),
column.text({
key: "trigger",
header: t("portal.pipelines.table.trigger", "Trigger"),
sortable: true,
get: (p) =>
t(`portal.pipelines.trigger.${p.trigger}`, {
defaultValue: p.trigger,
}),
}),
column.badge({
key: "status",
header: t("portal.pipelines.table.status"),
render: (p) => (
<StatusBadge tone={STATUS_TONE[p.status]} size="sm">
{t(`portal.pipelines.status.${p.status}`)}
</StatusBadge>
),
},
{
sortable: true,
get: (p) => ({
tone: STATUS_TONE[p.status],
label: t(`portal.pipelines.status.${p.status}`),
}),
}),
column.number({
key: "steps",
header: t("portal.pipelines.table.steps"),
align: "right",
render: (p) => (
<span
className={
p.steps.length === 0 ? "portal-pipelines__muted" : undefined
}
>
{p.steps.length}
</span>
),
},
{
sortable: true,
get: (p) => p.steps.length,
}),
column.number({
key: "sources",
header: t("portal.pipelines.table.sources"),
align: "right",
render: (p) => (
<span
className={
p.sources.length === 0 ? "portal-pipelines__muted" : undefined
}
>
{p.sources.length}
</span>
),
},
{
key: "open",
header: t("portal.pipelines.table.open"),
headerHidden: true,
align: "right",
width: "2.5rem",
render: () => (
<span className="portal-pipelines__caret" aria-hidden>
<ChevronRightRoundedIcon style={{ fontSize: "1.25rem" }} />
</span>
),
},
sortable: true,
get: (p) => p.sources.length,
}),
],
[t],
);
return (
<Table<PipelineView>
className="portal-pipelines__table"
<DataTable<PipelineView>
columns={columns}
rows={pipelines}
rowKey={(p) => p.id}
onRowClick={onRowClick}
rowAffordance="chevron"
/>
);
}
@@ -1,9 +1,8 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Button, Chip, StatusBadge, Table, type TableColumn } from "@app/ui";
import { column, DataTable, type DataTableColumn } from "@app/ui";
import type { CatalogueEntry } from "@portal/api/policies";
import { PolicyCategoryBadge } from "@portal/components/policies/PolicyCategoryIcon";
import "@portal/views/Policies.css";
import { policyCategoryIcon } from "@app/components/policies/policyCategoryIcon";
interface PolicyCatalogueTableProps {
entries: CatalogueEntry[];
@@ -15,10 +14,9 @@ interface PolicyCatalogueTableProps {
}
/**
* The policy catalogue as a proper data table (Policy / Enforces / Applies to /
* Docs / Status), replacing the stacked full-width cards that read as "blocky".
* Same shared Table + StatusBadge + Chip primitives the Sources, Documents and
* Home policy tables use, so every list page in the portal now reads alike.
* The policy catalogue as a data table (Policy / Enforces / Applies to / Docs /
* Status). The status column resolves per row to a state badge, an info chip
* (coming soon / requires AI), or a "Set up" call to action.
*/
export function PolicyCatalogueTable({
entries,
@@ -28,109 +26,81 @@ export function PolicyCatalogueTable({
}: PolicyCatalogueTableProps) {
const { t } = useTranslation();
const columns = useMemo<TableColumn<CatalogueEntry>[]>(
const columns = useMemo<DataTableColumn<CatalogueEntry>[]>(
() => [
{
column.entity({
key: "policy",
header: t("portal.policies.table.policy", "Policy"),
render: (entry) => (
<div className="portal-policies__cell">
<PolicyCategoryBadge category={entry.category} />
<strong className="portal-policies__cell-name">
{t(entry.category.label)}
</strong>
</div>
),
},
{
sortable: true,
icon: (entry) => policyCategoryIcon(entry.category.id),
primary: (entry) => t(entry.category.label),
}),
column.text({
key: "enforces",
header: t("portal.policies.table.enforces", "Enforces"),
render: (entry) => (
<div className="portal-policies__rulechips">
{entry.config.rules.map((r) => (
<Chip key={r} accent="neutral" size="sm">
{t(r)}
</Chip>
))}
</div>
),
},
{
get: (entry) => entry.config.rules.map((r) => t(r)).join(", "),
}),
column.muted({
key: "scope",
header: t("portal.policies.table.appliesTo", "Applies to"),
render: (entry) => (
<span className="portal-policies__muted">
{t(entry.config.scopeLabel)}
</span>
),
},
{
sortable: true,
get: (entry) => t(entry.config.scopeLabel),
}),
column.number({
key: "docs",
header: t("portal.policies.table.docs", "Docs enforced"),
align: "right",
width: "8rem",
render: (entry) => (
<span className="portal-policies__docs">
{entry.policy ? entry.policy.stats.enforced.toLocaleString() : "—"}
</span>
),
},
{
sortable: true,
get: (entry) => (entry.policy ? entry.policy.stats.enforced : null),
format: (n) => n.toLocaleString(),
}),
column.badge({
key: "status",
header: t("portal.policies.table.status", "Status"),
align: "right",
width: "8.5rem",
render: (entry) => {
sortable: true,
// Every state reads as one badge; the "set up" affordance is the row
// itself (click + chevron), so there's no bespoke button in the cell.
get: (entry) => {
if (entry.category.comingSoon) {
// One consistent neutral chip for every "Upgrade to Enterprise" —
// the same action should read the same on every row.
return (
<Chip accent="neutral" size="sm">
{t("portal.policies.card.comingSoon")}
</Chip>
);
return {
tone: "neutral",
label: t("portal.policies.card.comingSoon"),
};
}
if (isLocked?.(entry)) {
return (
<Chip accent="neutral" size="sm">
{lockedLabel ?? t("portal.policies.card.requiresAiEngine")}
</Chip>
);
return {
tone: "neutral",
label: lockedLabel ?? t("portal.policies.card.requiresAiEngine"),
};
}
if (entry.policy) {
const paused = entry.policy.state.status === "paused";
return (
<StatusBadge tone={paused ? "warning" : "success"} size="sm">
{paused
? t("portal.policies.status.paused")
: t("portal.policies.status.active")}
</StatusBadge>
);
return {
tone: paused ? "warning" : "success",
label: paused
? t("portal.policies.status.paused")
: t("portal.policies.status.active"),
};
}
return (
<Button size="sm" variant="secondary" onClick={() => onOpen(entry)}>
{t("portal.policySummary.action.setUp")}
</Button>
);
return {
tone: "neutral",
label: t("portal.policySummary.action.setUp"),
};
},
},
}),
],
[t, onOpen, isLocked, lockedLabel],
);
return (
<Table<CatalogueEntry>
className="portal-policies__table"
<DataTable<CatalogueEntry>
columns={columns}
rows={entries}
rowKey={(e) => e.category.id}
onRowClick={(entry) =>
entry.category.comingSoon || isLocked?.(entry)
? undefined
: onOpen(entry)
onRowClick={onOpen}
isRowInteractive={(e) =>
!(e.category.comingSoon || (isLocked?.(e) ?? false))
}
// A category with no policy yet renders a "set up" button, which opens the same thing.
rowsContainControls
rowAffordance="chevron"
/>
);
}

Some files were not shown because too many files have changed in this diff Show More