diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000000..40d0b73379 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-settings.json", + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "node", + "args": [ + "${CLAUDE_PROJECT_DIR}/scripts/lint/comment-lint-hook.mjs" + ], + "timeout": 60 + } + ] + } + ] + } +} diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 883c4f7f46..4ed61e37ec 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,11 +8,6 @@ updates: - package-ecosystem: "gradle" # See documentation for possible values directories: - "/" # Location of package manifests - - "/app/common" - - "/app/core" - - "/app/proprietary" - - "/app/saas" - - "/buildSrc" schedule: interval: "weekly" cooldown: diff --git a/.github/labeler-config-srvaroa.yml b/.github/labeler-config-srvaroa.yml index bd1947d649..ea9613ea20 100644 --- a/.github/labeler-config-srvaroa.yml +++ b/.github/labeler-config-srvaroa.yml @@ -67,6 +67,7 @@ labels: - 'frontend/**' - 'frontend/.*' - 'frontend/**/.*' + - '.taskfiles/frontend.yml' - label: 'Tauri' files: diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index d9eb6dbe10..41666ff608 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -20,6 +20,7 @@ Closes #(issue_number) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code +- [ ] Every comment I added says something the code does not ([guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/CODE_COMMENTS.md)) - [ ] My changes generate no new warnings ### Documentation diff --git a/.github/workflows/PR-Auto-Deploy-V2.yml b/.github/workflows/PR-Auto-Deploy-V2.yml index 6420712640..c55d70fd4e 100644 --- a/.github/workflows/PR-Auto-Deploy-V2.yml +++ b/.github/workflows/PR-Auto-Deploy-V2.yml @@ -182,7 +182,7 @@ jobs: fetch-depth: 0 # Fetch full history for commit hash detection - name: Set up Docker Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - name: Get version number id: versionNumber @@ -353,7 +353,7 @@ jobs: - name: Install Task for Storybook if: steps.sb-changes.outputs.storybook == 'true' - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Build and deploy Storybook id: storybook diff --git a/.github/workflows/PR-Demo-Comment-with-react.yml b/.github/workflows/PR-Demo-Comment-with-react.yml index 111dba441f..5a4fcc8053 100644 --- a/.github/workflows/PR-Demo-Comment-with-react.yml +++ b/.github/workflows/PR-Demo-Comment-with-react.yml @@ -206,7 +206,7 @@ jobs: distribution: "temurin" - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Run Gradle Command run: | if [ "${{ needs.check-comment.outputs.disable_security }}" == "true" ]; then @@ -222,7 +222,7 @@ jobs: STIRLING_PDF_DESKTOP_UI: false - name: Set up Docker Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - name: Login to GitHub Container Registry uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 diff --git a/.github/workflows/Saas-Dev-Deploy.yml b/.github/workflows/Saas-Dev-Deploy.yml new file mode 100644 index 0000000000..84aa57d5f2 --- /dev/null +++ b/.github/workflows/Saas-Dev-Deploy.yml @@ -0,0 +1,246 @@ +name: Auto SaaS Dev Deployment + +on: + push: + branches: + - saas-prod + workflow_dispatch: + +permissions: + contents: read + +env: + FRONTEND_PORT: "901" + BACKEND_PORT: "902" + DEPLOY_DIR: /stirling/SAAS-DEV + +jobs: + deploy-saas-dev: + runs-on: ubuntu-latest + environment: saas-dev + concurrency: + group: saas-dev-deploy + cancel-in-progress: true + permissions: + contents: read + packages: write + + steps: + - name: Harden Runner + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 + with: + egress-policy: audit + + - name: Check SaaS configuration + id: config + env: + PROJECT_REF: ${{ secrets.SAAS_DB_PROJECT_REF }} + run: | + echo "supabase_url=https://${PROJECT_REF}.supabase.co" >> "$GITHUB_OUTPUT" + echo "meter_endpoint=https://${PROJECT_REF}.supabase.co/functions/v1/meter-payg-units" >> "$GITHUB_OUTPUT" + + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + + - 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: Convert repository owner to lowercase + id: repoowner + run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT + + - name: Get commit hash + id: commit-hash + run: echo "app_short=$(git rev-parse --short=8 HEAD)" >> $GITHUB_OUTPUT + + - name: Build and push backend image + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + file: ./docker/backend/Dockerfile + push: true + cache-from: type=gha,scope=stirling-saas-backend + cache-to: type=gha,mode=max,scope=stirling-saas-backend + tags: | + ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:saas-backend-${{ steps.commit-hash.outputs.app_short }} + ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:saas-backend-latest + build-args: | + VERSION_TAG=v2-alpha + STIRLING_FLAVOR=saas + platforms: linux/amd64 + + - name: Build and push frontend image + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + file: ./docker/frontend/Dockerfile + push: true + cache-from: type=gha,scope=stirling-saas-frontend + cache-to: type=gha,mode=max,scope=stirling-saas-frontend + tags: | + ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:saas-frontend-${{ steps.commit-hash.outputs.app_short }} + ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:saas-frontend-latest + build-args: | + VERSION_TAG=v2-alpha + STIRLING_FLAVOR=saas + VITE_BUILD_MODE=development + VITE_SUPABASE_URL=${{ steps.config.outputs.supabase_url }} + VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY=${{ secrets.SAAS_SUPABASE_PUBLISHABLE_KEY }} + platforms: linux/amd64 + + - name: Build and push AI engine image + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + file: ./engine/Dockerfile + push: true + cache-from: type=gha,scope=stirling-saas-engine + cache-to: type=gha,mode=max,scope=stirling-saas-engine + tags: | + ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:saas-engine-${{ steps.commit-hash.outputs.app_short }} + ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:saas-engine-latest + platforms: linux/amd64 + + - name: Set up SSH + env: + SSH_KEY: ${{ secrets.NEW_VPS_SSH_KEY }} + run: | + mkdir -p ~/.ssh/ + echo "$SSH_KEY" > ../private.key + sudo chmod 600 ../private.key + + - name: Deploy to VPS + env: + IMAGE_BASE: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test + IMAGE_TAG: ${{ steps.commit-hash.outputs.app_short }} + GHCR_USER: ${{ github.actor }} + GHCR_TOKEN: ${{ github.token }} + VPS_USERNAME: ${{ secrets.NEW_VPS_USERNAME }} + VPS_HOST: ${{ secrets.NEW_VPS_HOST }} + SAAS_DB_URL: ${{ secrets.SAAS_DB_URL }} + SAAS_DB_USERNAME: ${{ secrets.SAAS_DB_USERNAME || 'postgres' }} + SAAS_DB_PASSWORD: ${{ secrets.SAAS_DB_PASSWORD }} + SAAS_DB_PROJECT_REF: ${{ secrets.SAAS_DB_PROJECT_REF }} + SUPABASE_EDGE_FUNCTION_SECRET: ${{ secrets.SUPABASE_EDGE_FUNCTION_SECRET }} + PAYG_METER_ENDPOINT: ${{ steps.config.outputs.meter_endpoint }} + STIRLING_KEYGEN_ENABLED: ${{ secrets.KEYGEN_ACCOUNT_ID != '' && secrets.KEYGEN_API_TOKEN != '' && secrets.KEYGEN_POLICY_ID != '' }} + KEYGEN_ACCOUNT_ID: ${{ secrets.KEYGEN_ACCOUNT_ID }} + KEYGEN_API_TOKEN: ${{ secrets.KEYGEN_API_TOKEN }} + KEYGEN_POLICY_ID: ${{ secrets.KEYGEN_POLICY_ID }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + VOYAGE_API_KEY: ${{ secrets.VOYAGE_API_KEY }} + run: | + set -euo pipefail + + BASE_URL="http://${VPS_HOST}:${FRONTEND_PORT}" + + yaml() { + printf "'%s'" "$(printf '%s' "$1" | sed -e "s/'/''/g" -e 's/\$/$$/g')" + } + + ENGINE_SECRET="$(openssl rand -hex 32)" + AI_BACKEND_VARS=" + SYSTEM_AIENGINE_ENABLED: \"true\" + SYSTEM_AIENGINE_URL: \"http://saas-engine:5001\" + APP_AI_SERVICEBASEURL: \"http://saas-engine:5001\" + STIRLING_ENGINE_SHARED_SECRET: $(yaml "$ENGINE_SECRET")" + AI_SERVICE=" + + saas-engine: + container_name: stirling-saas-dev-engine + image: ${IMAGE_BASE}:saas-engine-${IMAGE_TAG} + environment: + ANTHROPIC_API_KEY: $(yaml "$ANTHROPIC_API_KEY") + VOYAGE_API_KEY: $(yaml "$VOYAGE_API_KEY") + STIRLING_ENGINE_SHARED_SECRET: $(yaml "$ENGINE_SECRET") + restart: on-failure:5" + + cat > docker-compose.yml << EOF + version: '3.3' + services: + saas-backend: + container_name: stirling-saas-dev-backend + image: ${IMAGE_BASE}:saas-backend-${IMAGE_TAG} + ports: + - "${BACKEND_PORT}:8080" + volumes: + - ${DEPLOY_DIR}/config:/configs:rw + - ${DEPLOY_DIR}/logs:/logs:rw + - ${DEPLOY_DIR}/storage:/storage:rw + environment: + SPRING_PROFILES_ACTIVE: "saas" + DISABLE_ADDITIONAL_FEATURES: "false" + SAAS_DB_URL: $(yaml "$SAAS_DB_URL") + SAAS_DB_USERNAME: $(yaml "$SAAS_DB_USERNAME") + SAAS_DB_PASSWORD: $(yaml "$SAAS_DB_PASSWORD") + SAAS_DB_PROJECT_REF: $(yaml "$SAAS_DB_PROJECT_REF") + SUPABASE_EDGE_FUNCTION_SECRET: $(yaml "$SUPABASE_EDGE_FUNCTION_SECRET") + PAYG_METER_ENDPOINT: $(yaml "$PAYG_METER_ENDPOINT") + STIRLING_KEYGEN_ENABLED: $(yaml "$STIRLING_KEYGEN_ENABLED") + KEYGEN_ACCOUNT_ID: $(yaml "$KEYGEN_ACCOUNT_ID") + KEYGEN_API_TOKEN: $(yaml "$KEYGEN_API_TOKEN") + KEYGEN_POLICY_ID: $(yaml "$KEYGEN_POLICY_ID") + SYSTEM_DEFAULTLOCALE: en-US + SYSTEM_MAXFILESIZE: "100" + METRICS_ENABLED: "true" + SYSTEM_GOOGLEVISIBILITY: "false" + SWAGGER_SERVER_URL: "${BASE_URL}" + baseUrl: "${BASE_URL}"${AI_BACKEND_VARS} + restart: on-failure:5 + + saas-frontend: + container_name: stirling-saas-dev-frontend + image: ${IMAGE_BASE}:saas-frontend-${IMAGE_TAG} + ports: + - "${FRONTEND_PORT}:80" + environment: + VITE_API_BASE_URL: "http://saas-backend:8080" + depends_on: + - saas-backend + restart: on-failure:5${AI_SERVICE} + EOF + + SSH_OPTS=(-i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null) + + scp "${SSH_OPTS[@]}" docker-compose.yml "${VPS_USERNAME}@${VPS_HOST}:/tmp/saas-dev-docker-compose.yml" + + ssh "${SSH_OPTS[@]}" -T "${VPS_USERNAME}@${VPS_HOST}" << ENDSSH + set -e + mkdir -p ${DEPLOY_DIR}/{config,logs,storage} + mv /tmp/saas-dev-docker-compose.yml ${DEPLOY_DIR}/docker-compose.yml + chmod 600 ${DEPLOY_DIR}/docker-compose.yml + cd ${DEPLOY_DIR} + printf '%s' "${GHCR_TOKEN}" | docker login ghcr.io -u "${GHCR_USER}" --password-stdin + docker-compose down --remove-orphans 2>/dev/null || true + docker-compose pull + docker-compose up -d + docker logout ghcr.io >/dev/null 2>&1 || true + docker image prune -af --filter "until=336h" --filter "label!=keep=true" || true + ENDSSH + + - name: Wait for the backend to answer + env: + VPS_HOST: ${{ secrets.NEW_VPS_HOST }} + run: | + URL="http://${VPS_HOST}:${BACKEND_PORT}/api/v1/info/status" + for i in $(seq 1 60); do + code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "$URL" || true) + if [ "$code" = "200" ]; then echo "Healthy after $((i * 10))s"; exit 0; fi + sleep 10 + done + echo "::error::SaaS dev backend did not become healthy within 10 minutes" + exit 1 + + - name: Cleanup temporary files + if: always() + run: rm -f ../private.key docker-compose.yml + continue-on-error: true diff --git a/.github/workflows/ai-engine.yml b/.github/workflows/ai-engine.yml index caa5af1acb..29b8312f4f 100644 --- a/.github/workflows/ai-engine.yml +++ b/.github/workflows/ai-engine.yml @@ -34,10 +34,9 @@ jobs: cache-dependency-glob: | engine/pyproject.toml engine/uv.lock - cache-suffix: ai-engine - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Quality-check engine id: engine-check diff --git a/.github/workflows/backend-build.yml b/.github/workflows/backend-build.yml index 09c6bbc9a8..33b631ef86 100644 --- a/.github/workflows/backend-build.yml +++ b/.github/workflows/backend-build.yml @@ -52,7 +52,7 @@ jobs: distribution: "temurin" - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Check Java formatting (Spotless) # Runs once per matrix combination - pick the cheapest leg # (core - no proprietary, no saas) so we don't wait for the diff --git a/.github/workflows/build-enterprise.yml b/.github/workflows/build-enterprise.yml index a5a346db88..a3f10d82d4 100644 --- a/.github/workflows/build-enterprise.yml +++ b/.github/workflows/build-enterprise.yml @@ -95,7 +95,7 @@ jobs: cache: "npm" cache-dependency-path: frontend/package-lock.json - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Install Playwright (chromium only) run: task e2e:install -- chromium - name: Build frontend (needed for playwright's vite preview webServer) diff --git a/.github/workflows/check-generated-models.yml b/.github/workflows/check-generated-models.yml index 476f9d21a7..276a1d7530 100644 --- a/.github/workflows/check-generated-models.yml +++ b/.github/workflows/check-generated-models.yml @@ -42,7 +42,6 @@ jobs: cache-dependency-glob: | engine/pyproject.toml engine/uv.lock - cache-suffix: generated-models - name: Restore cache Gradle User Home if: inputs.use_shared_cache @@ -76,7 +75,7 @@ jobs: cache-dependency-path: frontend/package-lock.json - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Verify generated models are up to date id: models-check diff --git a/.github/workflows/check-licence.yml b/.github/workflows/check-licence.yml index 7626122884..38b49097ed 100644 --- a/.github/workflows/check-licence.yml +++ b/.github/workflows/check-licence.yml @@ -38,7 +38,7 @@ jobs: distribution: "temurin" - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Check licenses for compatibility run: task backend:licenses:check env: diff --git a/.github/workflows/check-openapi.yml b/.github/workflows/check-openapi.yml index 47341834a3..5046c18e8c 100644 --- a/.github/workflows/check-openapi.yml +++ b/.github/workflows/check-openapi.yml @@ -39,7 +39,7 @@ jobs: distribution: "temurin" - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Generate OpenAPI documentation run: task backend:swagger env: diff --git a/.github/workflows/docker-compose-tests.yml b/.github/workflows/docker-compose-tests.yml index cf5887a205..b37babfdf6 100644 --- a/.github/workflows/docker-compose-tests.yml +++ b/.github/workflows/docker-compose-tests.yml @@ -57,7 +57,7 @@ jobs: # runtime token isn't exposed) since the docker driver can't use it. - name: Set up Docker Buildx if: inputs.docker-base-changed != 'true' - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 # Expose ACTIONS_RUNTIME_TOKEN / ACTIONS_RESULTS_URL for docker buildx type=gha cache backend. - name: Expose GitHub runtime for Buildx cache diff --git a/.github/workflows/e2e-live.yml b/.github/workflows/e2e-live.yml index b04f5022cc..505addd3d7 100644 --- a/.github/workflows/e2e-live.yml +++ b/.github/workflows/e2e-live.yml @@ -45,7 +45,7 @@ jobs: cache: "npm" cache-dependency-path: frontend/package-lock.json - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Install Playwright (chromium only) run: task e2e:install -- chromium - name: Build frontend (production bundle for vite preview) diff --git a/.github/workflows/e2e-stubbed.yml b/.github/workflows/e2e-stubbed.yml index 5038a7585a..2553f38f84 100644 --- a/.github/workflows/e2e-stubbed.yml +++ b/.github/workflows/e2e-stubbed.yml @@ -44,7 +44,7 @@ jobs: cache: "npm" cache-dependency-path: frontend/package-lock.json - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Build frontend (production bundle for vite preview) env: VITE_BUILD_FOR_PREVIEW: "1" diff --git a/.github/workflows/frontend-a11y.yml b/.github/workflows/frontend-a11y.yml index 247d8375fc..f96496e181 100644 --- a/.github/workflows/frontend-a11y.yml +++ b/.github/workflows/frontend-a11y.yml @@ -36,7 +36,7 @@ jobs: cache: "npm" cache-dependency-path: frontend/package-lock.json - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: a11y gate (changed stories) run: task frontend:storybook:a11y:changed -- origin/${{ github.base_ref || 'main' }} - name: Upload scan reports diff --git a/.github/workflows/frontend-backend-licenses-update.yml b/.github/workflows/frontend-backend-licenses-update.yml index 96e0edd8ac..44cf4afb75 100644 --- a/.github/workflows/frontend-backend-licenses-update.yml +++ b/.github/workflows/frontend-backend-licenses-update.yml @@ -97,7 +97,7 @@ jobs: run: npm ci --ignore-scripts --audit=false --fund=false - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Generate frontend license report (Push only) if: github.event_name == 'push' @@ -367,7 +367,7 @@ jobs: distribution: "temurin" - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Check licenses and generate report id: license-check diff --git a/.github/workflows/frontend-validation.yml b/.github/workflows/frontend-validation.yml index 2650a945d6..a553c48280 100644 --- a/.github/workflows/frontend-validation.yml +++ b/.github/workflows/frontend-validation.yml @@ -27,7 +27,7 @@ jobs: cache: "npm" cache-dependency-path: frontend/package-lock.json - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Quality-check frontend id: frontend-check run: task frontend:check:all diff --git a/.github/workflows/multiOSReleases.yml b/.github/workflows/multiOSReleases.yml index 9071997ad7..6d4258d085 100644 --- a/.github/workflows/multiOSReleases.yml +++ b/.github/workflows/multiOSReleases.yml @@ -69,7 +69,7 @@ jobs: distribution: "temurin" - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Get version number id: versionNumber run: | @@ -169,7 +169,7 @@ jobs: cache-dependency-path: frontend/package-lock.json - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Build JAR run: ./gradlew build ${{ matrix.variant.build_frontend && '-PbuildWithFrontend=true' || '' }} -x spotlessApply -x spotlessCheck -x test -x sonarqube @@ -268,7 +268,7 @@ jobs: distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }} - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 # Build the universal JRE before desktop:prepare so the jlink:runtime # task short-circuits on its `test -d runtime/jre` status check. diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 5daa60f56f..63484ee7c0 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -38,7 +38,7 @@ jobs: cache-dependency-path: frontend/package-lock.json - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Install all Playwright browsers run: task e2e:install @@ -89,7 +89,7 @@ jobs: cache-dependency-path: frontend/package-lock.json - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: a11y gate (every story, ${{ matrix.theme }}) run: task frontend:storybook:a11y:${{ matrix.theme }} @@ -162,7 +162,7 @@ jobs: engine/uv.lock - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Start the fat image with login and storage enabled run: docker compose -f docker/embedded/compose/test_cicd.yml up -d --build diff --git a/.github/workflows/pre_commit.yml b/.github/workflows/pre_commit.yml index 674822363b..f3442086a0 100644 --- a/.github/workflows/pre_commit.yml +++ b/.github/workflows/pre_commit.yml @@ -31,10 +31,14 @@ jobs: cache-dependency-glob: | engine/pyproject.toml engine/uv.lock - cache-suffix: pre-commit - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Run pre-commit checks run: task pre-commit + + # The fixture corpus checks the comment rules themselves, so it runs here + # rather than on every local commit. + - name: Check the comment-lint fixture corpus + run: task pre-commit:comment-lint:selftest diff --git a/.github/workflows/push-docker-base.yml b/.github/workflows/push-docker-base.yml index 9da49ad7ae..6bfae2b300 100644 --- a/.github/workflows/push-docker-base.yml +++ b/.github/workflows/push-docker-base.yml @@ -69,7 +69,7 @@ jobs: - name: Set up Docker Buildx id: buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - name: Set up QEMU uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 diff --git a/.github/workflows/push-docker.yml b/.github/workflows/push-docker.yml index 844a77b489..b3c6d442b6 100644 --- a/.github/workflows/push-docker.yml +++ b/.github/workflows/push-docker.yml @@ -85,10 +85,10 @@ jobs: - name: Set up Docker Buildx id: buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Get version number id: versionNumber run: echo "versionNumber=$(./gradlew printVersion --quiet | tail -1)" >> $GITHUB_OUTPUT diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml index dbda06764b..8c7169e856 100644 --- a/.github/workflows/scorecards.yml +++ b/.github/workflows/scorecards.yml @@ -75,6 +75,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 + uses: github/codeql-action/upload-sarif@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8 with: sarif_file: results.sarif diff --git a/.github/workflows/swagger.yml b/.github/workflows/swagger.yml index 1f53edd17b..d7408dcfde 100644 --- a/.github/workflows/swagger.yml +++ b/.github/workflows/swagger.yml @@ -63,7 +63,7 @@ jobs: SWAGGERHUB_USER: "Frooodle" - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Get version number id: versionNumber run: echo "versionNumber=$(./gradlew printVersion --quiet | tail -1)" >> $GITHUB_OUTPUT diff --git a/.github/workflows/sync_files_v2.yml b/.github/workflows/sync_files_v2.yml index a199fd6cbd..d38c14ddba 100644 --- a/.github/workflows/sync_files_v2.yml +++ b/.github/workflows/sync_files_v2.yml @@ -59,14 +59,13 @@ jobs: cache-dependency-glob: | engine/pyproject.toml engine/uv.lock - cache-suffix: sync-files - name: Install Python dependencies run: | uv sync --project engine --locked --group tools - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Sync translation TOML files run: | diff --git a/.github/workflows/tauri-build.yml b/.github/workflows/tauri-build.yml index 3b7a0b04fa..157c4ead0c 100644 --- a/.github/workflows/tauri-build.yml +++ b/.github/workflows/tauri-build.yml @@ -212,7 +212,7 @@ jobs: distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }} - name: Setup Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Build universal macOS JRE if: matrix.platform == 'macos-15' diff --git a/.github/workflows/test-build-docker.yml b/.github/workflows/test-build-docker.yml index 4717c9c387..04a6380586 100644 --- a/.github/workflows/test-build-docker.yml +++ b/.github/workflows/test-build-docker.yml @@ -127,7 +127,7 @@ jobs: distribution: "temurin" - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Build application run: task backend:build env: @@ -142,7 +142,7 @@ jobs: - name: Set up Docker Buildx id: buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - name: Set base image and platform for this build id: build-params @@ -229,7 +229,7 @@ jobs: - name: Set up Docker Buildx id: buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - name: Build docker/unoserver/Dockerfile uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 diff --git a/.gitignore b/.gitignore index 1290056e05..ca683dc5d4 100644 --- a/.gitignore +++ b/.gitignore @@ -298,8 +298,13 @@ docs/type3/signatures/ **/application-dev-local.properties -# Claude -.claude/ +# Claude. Contents are ignored so personal config stays local, with the two +# shared pieces re-included: settings.json (the comment-lint hook) and skills/. +# The directory itself cannot be ignored or git will not look inside it. +.claude/* +!.claude/settings.json +!.claude/skills/ +.claude/settings.local.json # Playwright MCP screenshots / traces .playwright-mcp/ diff --git a/.taskfiles/backend.yml b/.taskfiles/backend.yml index 08a12b9535..3e43321937 100644 --- a/.taskfiles/backend.yml +++ b/.taskfiles/backend.yml @@ -40,12 +40,15 @@ tasks: AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}' AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}' SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN | default ""}}' + # Set by dev:linked. Inline rather than in `env:` so an empty value emits nothing + # and cannot blank the committed default. + ACCOUNT_LINK_SAAS_BASE_URL: '{{.ACCOUNT_LINK_SAAS_BASE_URL | default ""}}' env: SERVER_PORT: '{{.PORT}}' cmds: - - cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"' + - cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}{{if .ACCOUNT_LINK_SAAS_BASE_URL}}STIRLING_BILLING_ACCOUNT_LINK_ENABLED=true STIRLING_BILLING_ACCOUNT_LINK_SAAS_BASE_URL={{.ACCOUNT_LINK_SAAS_BASE_URL}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"' platforms: [windows] - - cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}./gradlew :stirling-pdf:bootRun' + - cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}{{if .ACCOUNT_LINK_SAAS_BASE_URL}}STIRLING_BILLING_ACCOUNT_LINK_ENABLED=true STIRLING_BILLING_ACCOUNT_LINK_SAAS_BASE_URL={{.ACCOUNT_LINK_SAAS_BASE_URL}} {{end}}./gradlew :stirling-pdf:bootRun' platforms: [linux, darwin] dev:bundled: @@ -84,6 +87,8 @@ tasks: AIENGINE_URL: '{{.AIENGINE_URL}}' AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}' AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}' + APP_BASE_URL: '{{.APP_BASE_URL}}' + BASE_PATH: '{{.BASE_PATH}}' staging:saas: desc: "Start SaaS backend against the shared v3 staging project" @@ -95,10 +100,47 @@ tasks: AIENGINE_URL: '{{.AIENGINE_URL}}' AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}' AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}' + APP_BASE_URL: '{{.APP_BASE_URL}}' + BASE_PATH: '{{.BASE_PATH}}' + + dev:linked: + desc: "Self-hosted backend linked to a locally running SaaS backend (see task linked:*)" + ignore_error: true + vars: + PORT: '{{.PORT | default "8080"}}' + SAAS_BASE_URL: '{{.SAAS_BASE_URL | default "http://localhost:8081"}}' + cmds: + - 'echo ">> self-hosted :{{.PORT}} linking to SaaS at {{.SAAS_BASE_URL}}"' + # The two backends run different STIRLING_FLAVOURs, which are different Gradle + # project graphs sharing one build/ tree. Waiting avoids overlapping builds; it + # does not make the sharing safe, so avoid rebuilding one while the other runs. + - cmd: | + n=0 + while [ "$n" -lt 150 ]; do + if curl -s -m 2 "{{.SAAS_BASE_URL}}" >/dev/null 2>&1; then + echo ">> SaaS backend is up, starting self-hosted" + break + fi + n=$((n + 1)) + {{if eq OS "windows"}}powershell -NoProfile -Command "Start-Sleep -Seconds 2"{{else}}sleep 2{{end}} + done + if [ "$n" -ge 150 ]; then + echo ">> SaaS backend never answered; starting anyway" + fi + - task: dev:proprietary + vars: + PORT: '{{.PORT}}' + ACCOUNT_LINK_SAAS_BASE_URL: '{{.SAAS_BASE_URL}}' _run:saas: internal: true - dotenv: ['app/.env.saas.local', 'app/.env.saas'] + # The frontend files are here only for RUN_SUBPATH, which the authorize URL needs. + # Last, because dotenv is set-if-absent: app/* still decides everything else. + dotenv: + - 'app/.env.saas.local' + - 'app/.env.saas' + - 'frontend/editor/.env.saas.local' + - 'frontend/editor/.env.saas' ignore_error: true vars: PORT: '{{.PORT | default "8080"}}' @@ -111,12 +153,29 @@ tasks: AIENGINE_URL: '{{.AIENGINE_URL | default ""}}' AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}' AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}' + # Empty is the same as unset: the property defaults to empty and is blank-checked. + APP_BASE_URL: '{{.APP_BASE_URL | default ""}}' + # Relocates configs/pipeline/logs, for a second backend in the same directory. + # Empty is the same as unset: the reader blank-checks it. + BASE_PATH: '{{.BASE_PATH | default ""}}' env: SERVER_PORT: '{{.PORT}}' STIRLING_FLAVOR: saas + STIRLING_BASE_PATH: '{{.BASE_PATH}}' AIENGINE_URL: '{{.AIENGINE_URL}}' AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}' AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}' + # Appends RUN_SUBPATH: the approval page is at /link, so a subpath build + # serves it at /app/link. An explicit value still wins. + SYSTEM_FRONTENDURL: + sh: | + if [ -n "${SYSTEM_FRONTENDURL:-}" ]; then + echo "${SYSTEM_FRONTENDURL}" + elif [ -n "{{.APP_BASE_URL}}" ] && [ -n "${RUN_SUBPATH:-}" ]; then + echo "{{.APP_BASE_URL}}/${RUN_SUBPATH}" + else + echo "{{.APP_BASE_URL}}" + fi cmds: # PROFILE_ARGS is empty when PROFILES=none, i.e. the bare `saas` profile # against SAAS_DB_* (production). diff --git a/.taskfiles/frontend.yml b/.taskfiles/frontend.yml index 844306824d..9727538961 100644 --- a/.taskfiles/frontend.yml +++ b/.taskfiles/frontend.yml @@ -23,7 +23,7 @@ tasks: - package-lock.json - package.json status: - - test -d node_modules + - npm ls --depth=0 env: CI: '{{ .CI | default "false" }}' @@ -121,17 +121,17 @@ tasks: sh: | case "${SAAS_ENV:-dev}" in staging) ref="${SAAS_STAGING_PROJECT_REF:?set it in app/.env.saas.local}" ;; - *) ref="${SAAS_DEV_PROJECT_REF:?set it in app/.env.saas.local, or run task staging:saas}" ;; + *) ref="${SAAS_DEV_PROJECT_REF:?set it in app/.env.saas.local, or pass SAAS_ENV=staging}" ;; esac echo "https://${ref}.supabase.co" VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: sh: | case "${SAAS_ENV:-dev}" in staging) echo "${SAAS_STAGING_PUBLISHABLE_KEY:?set it in app/.env.saas.local}" ;; - *) echo "${SAAS_DEV_PUBLISHABLE_KEY:?set it in app/.env.saas.local}" ;; + *) echo "${SAAS_DEV_PUBLISHABLE_KEY:?set it in app/.env.saas.local, or pass SAAS_ENV=staging}" ;; esac cmds: - - 'echo ">> frontend Supabase target: $VITE_SUPABASE_URL"' + - 'echo ">> frontend {{.SAAS_ENV}}: Supabase $VITE_SUPABASE_URL, backend $BACKEND_URL"' - npx vite editor --mode saas --port {{.PORT}}{{if .OPEN}} --open{{end}} dev: @@ -173,6 +173,16 @@ tasks: OPEN: '{{.OPEN}}' SAAS_ENV: '{{.SAAS_ENV}}' + staging:saas: + desc: "Start frontend dev server against the shared v3 staging project" + cmds: + - task: dev:saas + vars: + SAAS_ENV: staging + PORT: '{{.PORT}}' + BACKEND_URL: '{{.BACKEND_URL}}' + OPEN: '{{.OPEN}}' + dev:desktop: desc: "Start frontend dev server in desktop mode" deps: diff --git a/.taskfiles/pre-commit.yml b/.taskfiles/pre-commit.yml index 444115010f..f65a296160 100644 --- a/.taskfiles/pre-commit.yml +++ b/.taskfiles/pre-commit.yml @@ -11,6 +11,7 @@ vars: '.github/scripts/*.py' 'app/core/src/main/resources/static/python/*.py' ':(exclude)*split_photos.py' + ':(exclude)scripts/lint/fixtures/*' SPELL_FILES: >- '*.html' '*.css' @@ -59,6 +60,7 @@ tasks: - task: gitleaks - task: whitespace - task: toml-sort + - task: comment-lint fix: desc: "Auto-fix formatting, spelling, and secrets issues across the repo" @@ -75,6 +77,7 @@ tasks: vars: { FIX: '1' } - task: codespell - task: gitleaks + - task: comment-lint install: desc: "Install the pinned pre-commit Python tools" @@ -130,6 +133,85 @@ tasks: cmds: - "{{.GITLEAKS_BIN}} git --pre-commit --redact --staged --verbose" + comment-lint: + desc: "Check comment quality on the lines this branch adds" + summary: | + Blocks a comment that restates the code below it, a section banner, or a + block of commented-out code. Everything else it reports is advisory. + + Scoped to added lines, so touching an old file never surfaces the standing + backlog. The standard is devGuide/CODE_COMMENTS.md. + + With no arguments it diffs the working tree against HEAD, which is what a + pre-commit run wants: the lines you are about to commit. On a CI pull request + it diffs against the target branch instead, via GITHUB_BASE_REF. + + To ask what a whole branch adds instead, use the branch variant, which + needs no argument passing: + task comment-lint:branch + + Full tree (report only): task pre-commit:comment-lint:all + Fixture corpus: task pre-commit:comment-lint:selftest + # Depends on the frontend install because the .ts/.tsx half of the rule set + # runs as an oxlint plugin. Without it the TS engine warns and skips, which + # would leave the frontend silently unchecked on CI. + deps: [":frontend:install"] + cmds: + - node scripts/lint/comment-lint.mjs {{.CLI_ARGS}} + + comment-lint:branch: + desc: "Check comment quality on everything this branch adds over its base" + summary: | + Like `task comment-lint`, but scoped to the whole branch rather than to + uncommitted work, so it still reports after you commit. + + Exists as its own task because passing `-- --since origin/main` through Task + is not portable: with the npm build of Task the launcher is a PowerShell + script, and PowerShell strips the `--` before Task sees it, leaving Task to + print its own usage. + + Override the base with BASE=. + vars: + BASE: '{{.BASE | default "origin/main"}}' + deps: [":frontend:install"] + cmds: + - node scripts/lint/comment-lint.mjs --since {{.BASE}} + + comment-lint:ci: + desc: "Comment gate as CI runs it: fixture corpus, then the diff" + summary: | + The corpus checks the rules themselves rather than the code under review, so + it belongs on CI and not on every local commit. Run this before changing a + rule, and let CI run it on every pull request. + deps: [":frontend:install"] + cmds: + - node scripts/lint/comment-lint.mjs --selftest + - node scripts/lint/comment-lint.mjs {{.CLI_ARGS}} + + comment-lint:hook: + desc: "Comment gate for the editor hook: everything this turn changed" + summary: | + Same scope as `task comment-lint`, kept as its own name so the hook has a + stable entry point and the taskfile shows every way the linter is invoked. + + Not in the frontend-install dependency chain on purpose: this runs at the end + of every turn, so it stays as short as it can be. If oxlint is missing the TS + half warns and skips. + cmds: + - node scripts/lint/comment-lint.mjs + + comment-lint:all: + desc: "Report every comment finding in the tree (never fails)" + deps: [":frontend:install"] + cmds: + - node scripts/lint/comment-lint.mjs --all + + comment-lint:selftest: + desc: "Check both comment-lint engines against the fixture corpus" + deps: [":frontend:install"] + cmds: + - node scripts/lint/comment-lint.mjs --selftest + gitleaks-bin: internal: true desc: "Ensure the pinned, checksum-verified gitleaks binary is cached in .task/bin" diff --git a/AGENTS.md b/AGENTS.md index 7383ab3c3d..88543ca3c8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,6 +21,43 @@ Task `desc:` fields should describe **what** the task does, not **how** it does - `task docker:build` — build standard Docker image - `task docker:up` — start Docker compose stack +## Comments + +A comment must carry information the code cannot. If a reader could derive it from the code in front of them, delete it. + +Comment the current state. Not what the code used to do, not what changed, not why it changed: git holds that. Where history explains the shape, state the reason instead, so "this used to reimplement the modal internals" becomes "thin wrapper over the shared Modal: duplicating its portal and focus trap is how dialogs drift apart". Future state goes in a TODO with an issue. + +Write a comment when it does one of these four jobs: + +- **Contract.** What a caller must know that the signature cannot say: preconditions, invariants, units, ownership and lifetime, thread-safety, error semantics, side effects. Document the contract of everything a caller outside the file can reach, and nothing else. Goes on the type/method/module as Javadoc, JSDoc, or a docstring. +- **Why.** The constraint the code satisfies, the bug it avoids, the alternative rejected and the reason. +- **Hazard.** "Must stay in sync with X", "order matters because Y", "do not remove, it prevents Z". +- **Map.** A short orientation at the top of a genuinely complex file: what it owns, and what it deliberately does not. + +Never write: + +- A comment that restates the next line. `// Handle drag start` above `handleDragStart` is noise. +- Section banners or position markers: `// --- Types ---`, `// Helpers`, `// =====`. +- Step narration in a function body (`// Step 1:`, `// Then we`). If the steps need labels they need names: extract functions. Numbering a genuinely numbered thing, like a wizard step, is fine. +- Commented-out code. Delete it. +- Doc tags that restate the signature. `@param blob - The blob` says nothing; omit the tag rather than pad it. +- Docs on self-explanatory members with no constraint to state. + +Two tests before keeping a comment: + +- **Delete it.** Is any information lost? If not, it stays deleted. +- **Could a name carry it instead?** A better identifier, an extracted function, or a named constant beats a comment. Prefer the code change. + +A comment at the end of a line usually decodes that line, and that is worth keeping: `{0x25, 0x50} // "%PDF"`, `50L * 1024 * 1024 // 50 MB`. The rules that compare a comment against the code below it do not apply there, but a trailing TODO or a trailing bit of history is judged like any other. + +A reference is supplementary, never load-bearing: the comment must survive deleting it. `// See #1234` is a dead end; `// saving first loses every annotation (#6865)` is not. Prefer a spec (`RFC 3161`) or CVE where one applies. + +A TODO needs an issue, not an owner: `// TODO(#1234): re-enable the gate once account syncing lands`. If it is not worth an issue, it is not worth a TODO. A question is not a TODO. + +A comment block over ~12 lines outside a file or type header usually means the code needs restructuring, or that the prose is product documentation and belongs in the docs repo. + +`task comment-lint` checks the mechanical part of this on the lines you add, and runs inside `task pre-commit`. Reasoning, worked examples and the linter's own rules: @devGuide/CODE_COMMENTS.md + ## Common Development Commands ### Build and Test @@ -70,7 +107,7 @@ The project structure is defined in `engine/pyproject.toml`. Any new dependencie - Avoid nested functions and nested classes unless the language construct requires them. - Prefer composition to inheritance when combining concepts. - Avoid speculative abstractions. Add a layer only when it removes real duplication or clarifies lifecycle. -- Add comments sparingly and only when they explain non-obvious intent. +- Comments follow the repo-wide rules in the "Comments" section above. #### Python Typing and Models - Deserialize into Pydantic models as early as possible. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 65fc4bc262..371630193b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,6 +42,7 @@ Please make sure your Pull Request adheres to the following guidelines: - Keep commits atomic. One commit should contain one change. If you want to make multiple changes, submit multiple Pull Requests. - Commits should be clear, concise, and easy to understand. - References to the Issue number in the Pull Request and/or Commit message. +- Every comment in the diff should say something the code does not. See [Code comments](devGuide/CODE_COMMENTS.md); `task comment-lint` checks the mechanical part. ## Translations diff --git a/Taskfile.yml b/Taskfile.yml index 92dcdcc742..eaec7dec92 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -121,6 +121,92 @@ tasks: cmds: - task: dev:_all + # No engine: linking never calls it. + linked:staging: + desc: "SaaS on the shared v3 project + a self-hosted instance linked to it" + cmds: + - task: linked:_all + vars: { SAAS_ENV: staging } + + linked:dev: + desc: "SaaS on the current PR's preview branch + a self-hosted instance linked to it" + cmds: + - task: linked:_all + vars: { SAAS_ENV: dev } + + linked:_all: + internal: true + vars: + SAAS_ENV: '{{.SAAS_ENV | default "staging"}}' + PORTS: + sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8081 5174 8080 5173{{else}}{{.FIND_FREE_PORT_SH}} 8081 5174 8080 5173{{end}}' + SAAS_BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}' + SAAS_FRONTEND_PORT: '{{index (splitList "\n" .PORTS) 1}}' + APP_BACKEND_PORT: '{{index (splitList "\n" .PORTS) 2}}' + APP_FRONTEND_PORT: '{{index (splitList "\n" .PORTS) 3}}' + deps: + # APP_BASE_URL is the SaaS *frontend*: the approval page is served by vite, not + # by the API. BASE_PATH moves this backend's configs/pipeline aside so it does not + # race the self-hosted one, which keeps ./configs and its existing database. + - task: 'backend:{{.SAAS_ENV}}:saas' + vars: + PORT: '{{.SAAS_BACKEND_PORT}}' + APP_BASE_URL: 'http://localhost:{{.SAAS_FRONTEND_PORT}}' + BASE_PATH: 'tmp/linked-saas' + - task: frontend:dev:saas + vars: + PORT: '{{.SAAS_FRONTEND_PORT}}' + BACKEND_URL: 'http://localhost:{{.SAAS_BACKEND_PORT}}' + SAAS_ENV: '{{.SAAS_ENV}}' + - task: backend:dev:linked + vars: + PORT: '{{.APP_BACKEND_PORT}}' + SAAS_BASE_URL: 'http://localhost:{{.SAAS_BACKEND_PORT}}' + - task: frontend:dev:proprietary + vars: + PORT: '{{.APP_FRONTEND_PORT}}' + BACKEND_URL: 'http://localhost:{{.APP_BACKEND_PORT}}' + OPEN: "true" + - task: linked:_ready + vars: + SAAS_BACKEND_PORT: '{{.SAAS_BACKEND_PORT}}' + SAAS_FRONTEND_PORT: '{{.SAAS_FRONTEND_PORT}}' + APP_BACKEND_PORT: '{{.APP_BACKEND_PORT}}' + APP_FRONTEND_PORT: '{{.APP_FRONTEND_PORT}}' + + # Waits for all four to answer, then prints where they landed. + linked:_ready: + internal: true + cmds: + - cmd: | + n=0 + ok=0 + while [ "$n" -lt 150 ]; do + ok=1 + for u in "http://localhost:{{.SAAS_BACKEND_PORT}}" \ + "http://localhost:{{.SAAS_FRONTEND_PORT}}" \ + "http://localhost:{{.APP_BACKEND_PORT}}" \ + "http://localhost:{{.APP_FRONTEND_PORT}}"; do + # Not -o /dev/null: Windows curl.exe treats it as a real path and exits 23. + curl -s -m 2 "$u" >/dev/null 2>&1 || ok=0 + done + if [ "$ok" = 1 ]; then break; fi + n=$((n + 1)) + # `sleep` is a binary, not a builtin, and Windows has none. + {{if eq OS "windows"}}powershell -NoProfile -Command "Start-Sleep -Seconds 2"{{else}}sleep 2{{end}} + done + echo "" + if [ "$ok" = 1 ]; then + echo ">> all four answering" + else + echo ">> still waiting on one or more after 5 minutes; addresses below anyway" + fi + echo ">> self-hosted UI http://localhost:{{.APP_FRONTEND_PORT}}/processor" + echo ">> self-hosted api http://localhost:{{.APP_BACKEND_PORT}}" + echo ">> saas UI http://localhost:{{.SAAS_FRONTEND_PORT}}" + echo ">> saas api http://localhost:{{.SAAS_BACKEND_PORT}}" + echo "" + dev:_all: internal: true vars: @@ -180,6 +266,20 @@ tasks: cmds: - task: frontend:lint - task: engine:lint + - task: comment-lint + + comment-lint: + desc: "Check comment quality on the lines this branch adds" + aliases: [comments] + cmds: + - task: pre-commit:comment-lint + vars: { CLI_ARGS: '{{.CLI_ARGS}}' } + + comment-lint:branch: + desc: "Check comment quality on everything this branch adds over its base" + cmds: + - task: pre-commit:comment-lint:branch + vars: { BASE: '{{.BASE}}' } fix: desc: "Auto-fix all components" diff --git a/app/common/build.gradle b/app/common/build.gradle index 8af68bcb76..8ac1dfe0a9 100644 --- a/app/common/build.gradle +++ b/app/common/build.gradle @@ -3,6 +3,10 @@ bootRun { enabled = false } dependencies { + // Security-hardening utilities (zip-slip, SSRF, filename sanitization, command injection). + // Declared as api here so core + proprietary (which depend on common) get it transitively, + // keeping it off modules that don't need it (e.g. saas). + api 'io.github.pixee:java-security-toolkit:1.2.3' api "com.google.guava:guava:${guavaVersion}" api 'org.springframework.boot:spring-boot-starter-webmvc' api 'org.springframework.boot:spring-boot-starter-aspectj' @@ -22,7 +26,10 @@ dependencies { api "org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3" // Simple Java Mail for EML/MSG parsing (replaces direct Angus Mail usage) api 'org.simplejavamail:simple-java-mail:9.3.2' - api 'org.simplejavamail:outlook-module:9.3.2' // MSG file support + // MSG file support; exclude commons-math3 (only HSSF/formula needs it, MSG parsing doesn't) + api('org.simplejavamail:outlook-module:9.3.2') { + exclude group: 'org.apache.commons', module: 'commons-math3' + } api 'jakarta.mail:jakarta.mail-api:2.1.5' runtimeOnly 'org.eclipse.angus:angus-mail:2.0.5' @@ -36,12 +43,30 @@ dependencies { api "com.stirling:jpdfium:${jpdfiumVersion}" - // -PjpdfiumPlatforms=all|none| - // 'none' skips natives entirely (windows-arm64 builds, until JPDFium ships that platform). - def jpdfiumPlatformsProp = (project.findProperty('jpdfiumPlatforms') ?: 'all').toString().trim() - def jpdfiumAllPlatforms = ['linux-x64', 'linux-arm64', 'darwin-x64', 'darwin-arm64', 'windows-x64'] + // -PjpdfiumPlatforms=auto|all|none| (windows-arm64 natives not published yet) + def jpdfiumPlatformsProp = (project.findProperty('jpdfiumPlatforms') ?: 'auto').toString().trim() + def jpdfiumAllPlatforms = ['linux-x64', 'linux-arm64', 'linux-musl-x64', 'linux-musl-arm64', 'darwin-x64', 'darwin-arm64', 'windows-x64'] def jpdfiumPlatforms - if (jpdfiumPlatformsProp == 'all') { + if (jpdfiumPlatformsProp == 'auto') { + def osName = System.getProperty('os.name').toLowerCase() + def osArch = System.getProperty('os.arch').toLowerCase() + def isArm64 = osArch.contains('aarch64') || osArch.contains('arm64') + if (osName.contains('linux')) { + jpdfiumPlatforms = isArm64 ? ['linux-arm64'] : ['linux-x64'] + } else if (osName.contains('mac')) { + jpdfiumPlatforms = isArm64 ? ['darwin-arm64'] : ['darwin-x64'] + } else if (osName.contains('win')) { + if (isArm64) { + logger.lifecycle("JPDFium natives are not available for windows-arm64; set -PjpdfiumPlatforms=none to skip bundling natives.") + jpdfiumPlatforms = [] + } else { + jpdfiumPlatforms = ['windows-x64'] + } + } else { + // Fallback: bundle all platforms when host can't be determined + jpdfiumPlatforms = jpdfiumAllPlatforms + } + } else if (jpdfiumPlatformsProp == 'all') { jpdfiumPlatforms = jpdfiumAllPlatforms } else if (jpdfiumPlatformsProp == 'none') { jpdfiumPlatforms = [] @@ -51,7 +76,7 @@ dependencies { def jpdfiumInvalid = jpdfiumPlatforms.findAll { !jpdfiumAllPlatforms.contains(it) } if (jpdfiumInvalid) { throw new GradleException("Unknown jpdfiumPlatforms value(s): ${jpdfiumInvalid.join(', ')}. " + - "Valid: ${jpdfiumAllPlatforms.join(', ')}, 'all' or 'none'.") + "Valid: ${jpdfiumAllPlatforms.join(', ')}, 'auto', 'all' or 'none'.") } logger.lifecycle("JPDFium native platforms: ${jpdfiumPlatforms ? jpdfiumPlatforms.join(', ') : 'none'}") jpdfiumPlatforms.each { platform -> diff --git a/app/common/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java b/app/common/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java index 5e8f7fe336..291bf1dce6 100644 --- a/app/common/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java +++ b/app/common/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java @@ -48,7 +48,7 @@ public class EndpointConfiguration { private final ApplicationProperties applicationProperties; @Getter private Map endpointStatuses = new ConcurrentHashMap<>(); private Map> endpointGroups = new ConcurrentHashMap<>(); - private Set disabledGroups = new HashSet<>(); + private Set disabledGroups = ConcurrentHashMap.newKeySet(); private Map endpointDisableReasons = new ConcurrentHashMap<>(); private Map groupDisableReasons = new ConcurrentHashMap<>(); private Map> endpointAlternatives = new ConcurrentHashMap<>(); diff --git a/app/common/src/main/java/stirling/software/SPDF/pdf/parser/TabulaTableParser.java b/app/common/src/main/java/stirling/software/SPDF/pdf/parser/TabulaTableParser.java index b85ddbb08e..d3c516d1fe 100644 --- a/app/common/src/main/java/stirling/software/SPDF/pdf/parser/TabulaTableParser.java +++ b/app/common/src/main/java/stirling/software/SPDF/pdf/parser/TabulaTableParser.java @@ -237,7 +237,7 @@ public class TabulaTableParser implements TableParser { score -= 0.3f; } - return Math.max(0f, Math.min(1f, score)); + return Math.clamp(score, 0f, 1f); } private Bounds tableBounds(Table table) { diff --git a/app/common/src/main/java/stirling/software/common/util/propertyeditor/StringToMapPropertyEditor.java b/app/common/src/main/java/stirling/software/common/util/propertyeditor/StringToMapPropertyEditor.java index 63476d5568..7ab1013a8a 100644 --- a/app/common/src/main/java/stirling/software/common/util/propertyeditor/StringToMapPropertyEditor.java +++ b/app/common/src/main/java/stirling/software/common/util/propertyeditor/StringToMapPropertyEditor.java @@ -15,7 +15,8 @@ public class StringToMapPropertyEditor extends PropertyEditorSupport { @Override public void setAsText(String text) throws IllegalArgumentException { try { - TypeReference> typeRef = new TypeReference<>() {}; + TypeReference> typeRef = + new TypeReference>() {}; Map map = objectMapper.readValue(text, typeRef); setValue(map); } catch (Exception e) { diff --git a/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java b/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java index 72e5eae9a2..c144f9726e 100644 --- a/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java +++ b/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java @@ -113,6 +113,16 @@ class RequestUriUtilsTest { assertTrue(RequestUriUtils.isFrontendRoute("", "/split-pdf")); } + @Test + void testIsFrontendRoute_editorRouteOwnedByFrontend() { + // /editor (and its tool routes) is an SPA route: a direct-nav/refresh must + // serve index.html, not the auth filter's 302-to-/login. Regression test for + // the editor moving from / to /editor, whose refresh bounced processor users + // to the processor because the redirect dropped the return path. + assertTrue(RequestUriUtils.isFrontendRoute("", "/editor")); + assertTrue(RequestUriUtils.isFrontendRoute("/app", "/app/editor")); + } + @Test void testIsFrontendRoute_filesRouteOwnedByFrontend() { // /files and /files/ are FileManagerView routes - they diff --git a/app/core/.gitignore b/app/core/.gitignore index 7d9dd62931..c207c6c09a 100644 --- a/app/core/.gitignore +++ b/app/core/.gitignore @@ -106,6 +106,7 @@ SwaggerDoc.json # Log file *.log +*.log.gz # BlueJ files *.ctxt diff --git a/app/core/build.gradle b/app/core/build.gradle index 76828445a3..0e1533b6e8 100644 --- a/app/core/build.gradle +++ b/app/core/build.gradle @@ -62,8 +62,16 @@ dependencies { // CVE-2022-25647: Explicit gson to prevent unsafe deserialization (tabula would pull 2.8.7) implementation "com.google.code.gson:gson:${gsonVersion}" implementation 'org.apache.pdfbox:jbig2-imageio:3.0.5' - implementation 'com.opencsv:opencsv:5.12.0' // https://mvnrepository.com/artifact/com.opencsv/opencsv - implementation 'org.apache.poi:poi-ooxml:5.5.1' + // OpenCSV: Stirling-PDF only uses CSVWriter, not the opencsv-bean module. + // Exclude commons-beanutils + commons-collections. + implementation('com.opencsv:opencsv:5.12.0') { + exclude group: 'commons-beanutils', module: 'commons-beanutils' + exclude group: 'commons-collections', module: 'commons-collections' + } + // POI: only XSSF (modern Excel) is used, not HSSF/FormulaEvaluator which need commons-math3. + implementation('org.apache.poi:poi-ooxml:5.5.1') { + exclude group: 'org.apache.commons', module: 'commons-math3' + } // Batik only bridge module needed (transitively pulls anim, gvt, util, css, dom, svg-dom) // Replaces batik-all which included unused codec, svggen, transcoder, script modules @@ -129,6 +137,10 @@ bootJar { exclude 'META-INF/*.RSA' exclude 'META-INF/*.EC' + // Exclude source maps from production JAR, dev-only debugging artifacts, not needed at runtime + exclude 'static/pdfjs-legacy/**/*.map' + exclude 'static/**/*.map' + manifest { attributes( 'Implementation-Title': 'Stirling-PDF', diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/EditTextController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/EditTextController.java index 17d1d7d8a7..b76f52144a 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/EditTextController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/EditTextController.java @@ -237,7 +237,7 @@ public class EditTextController { Matcher matcher = edit.pattern().matcher(joined); List spans = new ArrayList<>(); - StringBuffer interpolation = new StringBuffer(); + StringBuilder interpolation = new StringBuilder(); int previousAppendPosition = 0; while (matcher.find()) { if (matcher.start() == matcher.end()) { diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java index 7f93fb3d64..b6cef0b2d6 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java @@ -95,7 +95,8 @@ public class UIDataController { try (InputStream is = resource.getInputStream()) { Map> licenseData = - objectMapper.readValue(is, new TypeReference<>() {}); + objectMapper.readValue( + is, new TypeReference>>() {}); data.setDependencies(licenseData.get("dependencies")); } catch (IOException e) { log.error("Failed to load licenses data", e); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormPayloadParser.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormPayloadParser.java index f48f419a6d..5236706f74 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormPayloadParser.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormPayloadParser.java @@ -25,12 +25,15 @@ final class FormPayloadParser { private static final String KEY_VALUE = "value"; private static final String KEY_DEFAULT_VALUE = "defaultValue"; - private static final TypeReference> MAP_TYPE = new TypeReference<>() {}; + private static final TypeReference> MAP_TYPE = + new TypeReference>() {}; private static final TypeReference> - MODIFY_FIELD_LIST_TYPE = new TypeReference<>() {}; + MODIFY_FIELD_LIST_TYPE = + new TypeReference>() {}; private static final TypeReference> NEW_FIELD_LIST_TYPE = new TypeReference<>() {}; - private static final TypeReference> STRING_LIST_TYPE = new TypeReference<>() {}; + private static final TypeReference> STRING_LIST_TYPE = + new TypeReference>() {}; private FormPayloadParser() {} diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AddCommentsController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AddCommentsController.java index dc2dd22863..09b1d282e9 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AddCommentsController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AddCommentsController.java @@ -96,7 +96,9 @@ public class AddCommentsController { List dtos; try { - dtos = objectMapper.readValue(commentsJson, new TypeReference<>() {}); + dtos = + objectMapper.readValue( + commentsJson, new TypeReference>() {}); } catch (JacksonException e) { throw new ResponseStatusException( HttpStatus.BAD_REQUEST, "comments must be a JSON array of CommentSpec objects"); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/OCRController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/OCRController.java index 4803184a33..e10b86a866 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/OCRController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/OCRController.java @@ -114,6 +114,7 @@ public class OCRController { List selectedLanguages = request.getLanguages(); boolean sidecar = request.isSidecar(); Boolean deskew = request.isDeskew(); + Boolean rotatePages = request.isRotatePages(); Boolean clean = request.isClean(); Boolean cleanFinal = request.isCleanFinal(); String ocrType = request.getOcrType(); @@ -154,6 +155,7 @@ public class OCRController { selectedLanguages, sidecar, deskew, + rotatePages, clean, cleanFinal, ocrType, @@ -236,6 +238,7 @@ public class OCRController { List selectedLanguages, Boolean sidecar, Boolean deskew, + Boolean rotatePages, Boolean clean, Boolean cleanFinal, String ocrType, @@ -268,6 +271,10 @@ public class OCRController { if (deskew != null && deskew) { command.add("--deskew"); } + if (rotatePages != null && rotatePages) { + // Tesseract OSD-based automatic page orientation correction (90/180/270) + command.add("--rotate-pages"); + } if (clean != null && clean) { command.add("--clean"); } diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactController.java index 7a186235cd..1c694da2b0 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactController.java @@ -221,6 +221,10 @@ public class RedactController { .normalizeFonts(false) .fixToUnicode(false) .glyphAware(true) + .ligatureAware(true) + .bidiAware(true) + .graphemeSafe(true) + .sanitizeStructure(false) // WIP/Experimental API .redactMetadata(true) .build(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/TextRedactionService.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/TextRedactionService.java index 9cf4e6c700..c0b74f5428 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/TextRedactionService.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/TextRedactionService.java @@ -110,6 +110,10 @@ class TextRedactionService { .fixToUnicode(false) .repairWidths(false) .glyphAware(true) + .ligatureAware(true) + .bidiAware(true) + .graphemeSafe(true) + .sanitizeStructure(false) .build(); try (PdfDocument checkDoc = PdfDocument.open(tempIn.toPath())) { diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/misc/ProcessPdfWithOcrRequest.java b/app/core/src/main/java/stirling/software/SPDF/model/api/misc/ProcessPdfWithOcrRequest.java index 2955d7160f..daa6930412 100644 --- a/app/core/src/main/java/stirling/software/SPDF/model/api/misc/ProcessPdfWithOcrRequest.java +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/misc/ProcessPdfWithOcrRequest.java @@ -25,6 +25,11 @@ public class ProcessPdfWithOcrRequest extends PDFFile { @Schema(description = "Deskew the input file if set to true") private boolean deskew; + @Schema( + description = + "Auto-correct page orientation (90/180/270) using Tesseract OSD if set to true") + private boolean rotatePages; + @Schema(description = "Clean the input file if set to true") private boolean clean; diff --git a/app/core/src/main/java/stirling/software/SPDF/service/WeeklyActiveUsersService.java b/app/core/src/main/java/stirling/software/SPDF/service/WeeklyActiveUsersService.java index 3b1ae1d048..23d8247218 100644 --- a/app/core/src/main/java/stirling/software/SPDF/service/WeeklyActiveUsersService.java +++ b/app/core/src/main/java/stirling/software/SPDF/service/WeeklyActiveUsersService.java @@ -4,7 +4,9 @@ import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import lombok.extern.slf4j.Slf4j; @@ -21,7 +23,7 @@ public class WeeklyActiveUsersService { private final Map activeBrowsers = new ConcurrentHashMap<>(); // Track total unique browsers seen (overall) - private long totalUniqueBrowsers = 0; + private final AtomicLong totalUniqueBrowsers = new AtomicLong(0); // Application start time private final Instant startTime = Instant.now(); @@ -36,12 +38,12 @@ public class WeeklyActiveUsersService { return; } - boolean isNewBrowser = !activeBrowsers.containsKey(browserId); - activeBrowsers.put(browserId, Instant.now()); + Instant now = Instant.now(); + Instant previous = activeBrowsers.put(browserId, now); - if (isNewBrowser) { - totalUniqueBrowsers++; - log.debug("New browser recorded: {} (Total: {})", browserId, totalUniqueBrowsers); + if (previous == null) { + long total = totalUniqueBrowsers.incrementAndGet(); + log.debug("New browser recorded: {} (Total: {})", browserId, total); } } @@ -61,7 +63,7 @@ public class WeeklyActiveUsersService { * @return Total unique browsers count */ public long getTotalUniqueBrowsers() { - return totalUniqueBrowsers; + return totalUniqueBrowsers.get(); } /** @@ -88,7 +90,8 @@ public class WeeklyActiveUsersService { activeBrowsers.entrySet().removeIf(entry -> entry.getValue().isBefore(sevenDaysAgo)); } - /** Manual cleanup trigger (can be called by scheduled task if needed) */ + /** Scheduled cleanup trigger running every hour */ + @Scheduled(fixedRate = 3600000) public void performCleanup() { int sizeBefore = activeBrowsers.size(); cleanupOldEntries(); diff --git a/app/core/src/main/resources/logback.xml b/app/core/src/main/resources/logback.xml index c0779735ae..ebdeeda64b 100644 --- a/app/core/src/main/resources/logback.xml +++ b/app/core/src/main/resources/logback.xml @@ -16,8 +16,9 @@ %d %p %c{1} [%thread] %m%n - ${LOG_PATH}/auth-%d{yyyy-MM-dd}.log - 1 + ${LOG_PATH}/auth-%d{yyyy-MM-dd}.log.gz + 7 + 64MB @@ -28,8 +29,9 @@ %d %p %c{1} [%thread] %m%n - ${LOG_PATH}/info-%d{yyyy-MM-dd}.log - 1 + ${LOG_PATH}/info-%d{yyyy-MM-dd}.log.gz + 7 + 256MB diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index fdfe40b352..ecf7ea8538 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -186,7 +186,7 @@ system: maxDPI: 500 # Maximum allowed DPI for PDF to image conversion corsAllowedOrigins: [] # List of allowed origins for CORS (e.g. ['http://localhost:5173', 'https://app.example.com']). WARNING: leaving this empty falls back to allowing ALL origins (with credentials), it does NOT disable CORS. Set explicit origins to lock it down. backendUrl: "" # Backend base URL for SAML/OAuth/API callbacks (e.g. 'http://localhost:8080' for dev, 'https://api.example.com' for production). REQUIRED for SSO authentication to work correctly. This is where your IdP will send SAML responses and OAuth callbacks. Leave empty to default to 'http://localhost:8080' in development. - frontendUrl: "" # Frontend URL for invite email links (e.g. 'https://app.example.com'). Optional - if not set, will use backendUrl. This is the URL users click in invite emails. + frontendUrl: "" # Base URL of the web app, as a browser reaches it (e.g. 'https://app.example.com', or 'https://example.com/app' if served under a base path). Optional - if not set, will use backendUrl. Used for any link handed to a browser: invite emails, share links, mobile QR codes, and the account-link handshake. enableMobileScanner: true # Enable mobile phone QR code upload feature. Requires frontendUrl to be configured. enableMobileSignature: true # Enable drawing signatures on a phone via QR code from the Sign tool. Requires frontendUrl to be configured. mobileScannerSettings: diff --git a/app/core/src/main/resources/static/3rdPartyLicenses.json b/app/core/src/main/resources/static/3rdPartyLicenses.json index a0dae640e0..6b846053e4 100644 --- a/app/core/src/main/resources/static/3rdPartyLicenses.json +++ b/app/core/src/main/resources/static/3rdPartyLicenses.json @@ -94,7 +94,7 @@ { "moduleName": "com.fasterxml.jackson.core:jackson-core", "moduleUrl": "https://github.com/FasterXML/jackson-core", - "moduleVersion": "2.22.1", + "moduleVersion": "2.22.2", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" }, @@ -108,7 +108,7 @@ { "moduleName": "com.fasterxml.jackson.core:jackson-databind", "moduleUrl": "https://github.com/FasterXML/jackson", - "moduleVersion": "2.22.1", + "moduleVersion": "2.22.2", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" }, @@ -143,7 +143,7 @@ { "moduleName": "com.fasterxml.jackson:jackson-bom", "moduleUrl": "https://github.com/FasterXML/jackson-bom", - "moduleVersion": "2.22.1", + "moduleVersion": "2.22.2", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, @@ -440,42 +440,14 @@ { "moduleName": "com.stirling:jpdfium", "moduleUrl": "https://github.com/Stirling-Tools/JPDFium", - "moduleVersion": "1.0.4", - "moduleLicense": "MIT License", - "moduleLicenseUrl": "https://opensource.org/licenses/MIT" - }, - { - "moduleName": "com.stirling:jpdfium-natives-darwin-arm64", - "moduleUrl": "https://github.com/Stirling-Tools/JPDFium", - "moduleVersion": "1.0.4", - "moduleLicense": "MIT License", - "moduleLicenseUrl": "https://opensource.org/licenses/MIT" - }, - { - "moduleName": "com.stirling:jpdfium-natives-darwin-x64", - "moduleUrl": "https://github.com/Stirling-Tools/JPDFium", - "moduleVersion": "1.0.4", - "moduleLicense": "MIT License", - "moduleLicenseUrl": "https://opensource.org/licenses/MIT" - }, - { - "moduleName": "com.stirling:jpdfium-natives-linux-arm64", - "moduleUrl": "https://github.com/Stirling-Tools/JPDFium", - "moduleVersion": "1.0.4", + "moduleVersion": "1.1.3", "moduleLicense": "MIT License", "moduleLicenseUrl": "https://opensource.org/licenses/MIT" }, { "moduleName": "com.stirling:jpdfium-natives-linux-x64", "moduleUrl": "https://github.com/Stirling-Tools/JPDFium", - "moduleVersion": "1.0.4", - "moduleLicense": "MIT License", - "moduleLicenseUrl": "https://opensource.org/licenses/MIT" - }, - { - "moduleName": "com.stirling:jpdfium-natives-windows-x64", - "moduleUrl": "https://github.com/Stirling-Tools/JPDFium", - "moduleVersion": "1.0.4", + "moduleVersion": "1.1.3", "moduleLicense": "MIT License", "moduleLicenseUrl": "https://opensource.org/licenses/MIT" }, @@ -521,36 +493,18 @@ "moduleLicense": "GNU General Public License, version 2 with the GNU Classpath Exception", "moduleLicenseUrl": "https://www.gnu.org/software/classpath/license.html" }, - { - "moduleName": "com.twelvemonkeys.common:common-image", - "moduleVersion": "3.13.1", - "moduleLicense": "The BSD License", - "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" - }, { "moduleName": "com.twelvemonkeys.common:common-image", "moduleVersion": "3.14.0", "moduleLicense": "The BSD License", "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" }, - { - "moduleName": "com.twelvemonkeys.common:common-io", - "moduleVersion": "3.13.1", - "moduleLicense": "The BSD License", - "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" - }, { "moduleName": "com.twelvemonkeys.common:common-io", "moduleVersion": "3.14.0", "moduleLicense": "The BSD License", "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" }, - { - "moduleName": "com.twelvemonkeys.common:common-lang", - "moduleVersion": "3.13.1", - "moduleLicense": "The BSD License", - "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" - }, { "moduleName": "com.twelvemonkeys.common:common-lang", "moduleVersion": "3.14.0", @@ -569,12 +523,6 @@ "moduleLicense": "The BSD License", "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" }, - { - "moduleName": "com.twelvemonkeys.imageio:imageio-core", - "moduleVersion": "3.13.1", - "moduleLicense": "The BSD License", - "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" - }, { "moduleName": "com.twelvemonkeys.imageio:imageio-core", "moduleVersion": "3.14.0", @@ -587,12 +535,6 @@ "moduleLicense": "The BSD License", "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" }, - { - "moduleName": "com.twelvemonkeys.imageio:imageio-metadata", - "moduleVersion": "3.13.1", - "moduleLicense": "The BSD License", - "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" - }, { "moduleName": "com.twelvemonkeys.imageio:imageio-metadata", "moduleVersion": "3.14.0", @@ -605,24 +547,12 @@ "moduleLicense": "The BSD License", "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" }, - { - "moduleName": "com.twelvemonkeys.imageio:imageio-tiff", - "moduleVersion": "3.13.1", - "moduleLicense": "The BSD License", - "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" - }, { "moduleName": "com.twelvemonkeys.imageio:imageio-tiff", "moduleVersion": "3.14.0", "moduleLicense": "The BSD License", "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" }, - { - "moduleName": "com.twelvemonkeys.imageio:imageio-webp", - "moduleVersion": "3.13.1", - "moduleLicense": "The BSD License", - "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" - }, { "moduleName": "com.twelvemonkeys.imageio:imageio-webp", "moduleVersion": "3.14.0", @@ -769,13 +699,6 @@ "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, - { - "moduleName": "commons-beanutils:commons-beanutils", - "moduleUrl": "https://commons.apache.org/proper/commons-beanutils", - "moduleVersion": "1.11.0", - "moduleLicense": "Apache-2.0", - "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" - }, { "moduleName": "commons-cli:commons-cli", "moduleUrl": "http://commons.apache.org/proper/commons-cli/", @@ -790,13 +713,6 @@ "moduleLicense": "Apache-2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" }, - { - "moduleName": "commons-collections:commons-collections", - "moduleUrl": "http://commons.apache.org/collections/", - "moduleVersion": "3.2.2", - "moduleLicense": "Apache License, Version 2.0", - "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" - }, { "moduleName": "commons-io:commons-io", "moduleUrl": "https://commons.apache.org/proper/commons-io/", @@ -1360,13 +1276,6 @@ "moduleLicense": "Apache-2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" }, - { - "moduleName": "org.apache.commons:commons-math3", - "moduleUrl": "http://commons.apache.org/proper/commons-math/", - "moduleVersion": "3.6.1", - "moduleLicense": "Apache License, Version 2.0", - "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" - }, { "moduleName": "org.apache.commons:commons-text", "moduleUrl": "https://commons.apache.org/proper/commons-text", diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkClient.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkClient.java index bdd9df10a8..558341745d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkClient.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkClient.java @@ -24,22 +24,6 @@ import tools.jackson.databind.node.ObjectNode; /** * Outbound calls from a self-hosted instance to its linked SaaS backend (combined-billing "Mode * A"). - * - *

Calls: - * - *

    - *
  • {@link #register} — relays the admin's short-lived Supabase JWT to {@code POST - * /api/v1/account-link/register}; the SaaS side mints + returns a device credential. - *
  • {@link #fetchEntitlement} — authenticates with the stored device credential against {@code - * GET /api/v1/instance/entitlement}; what the local gate consults. - *
  • {@link #reportUsage} — daily usage sync ({@code POST /api/v1/instance/sync}); reports - * cumulative units and returns the refreshed entitlement. - *
  • {@link #revokeSelf} — self-revokes the credential on local unlink ({@code POST - * /api/v1/instance/revoke-self}). - *
- * - *

Uses {@code java.net.http.HttpClient} (the established self-hosted outbound pattern; see - * {@code AiEngineClient}); base URL + client are injectable so tests can stub SaaS. */ @Slf4j @Service @@ -72,13 +56,7 @@ public class AccountLinkClient { this.httpClient = httpClient; } - /** The device credential a successful {@link #register} returns. */ - public record RegisterResult(String deviceId, String deviceSecret, Long teamId) {} - - /** - * A non-2xx reply from the SaaS account-link API. Carries the upstream status so the caller can - * map auth failures (401/403) through rather than masking everything as a 502. - */ + /** A non-2xx reply from the SaaS account-link API. */ public static class UpstreamException extends IOException { private final int status; @@ -92,11 +70,7 @@ public class AccountLinkClient { } } - /** - * Authoritative deny (401/403) — the device credential is revoked or invalid. Unlike a - * transport/server failure (which returns {@code null} and fails open), the cache must BLOCK on - * this. Unchecked so it propagates through {@link #fetchEntitlement}'s transport try/catch. - */ + /** Authoritative deny (401/403) — the device credential is revoked or invalid. */ public static final class RevokedException extends RuntimeException { private final int status; @@ -110,46 +84,142 @@ public class AccountLinkClient { } } + /** What the SaaS side hands back when it records a connect handshake. */ + public record ConnectRequestResult( + String requestId, int expiresInSeconds, String authorizeUrl) {} + + public enum ConnectClaimOutcome { + /** Approved and collected; the credential fields are populated. */ + GRANTED, + /** A re-authentication was approved. */ + CONFIRMED, + /** No human decision yet. */ + PENDING, + /** Declined, expired or already used. */ + REJECTED, + /** SaaS unreachable or erroring. */ + UNAVAILABLE + } + + public record ConnectClaimResult( + ConnectClaimOutcome outcome, String deviceId, String deviceSecret, Long teamId) { + static ConnectClaimResult of(ConnectClaimOutcome outcome) { + return new ConnectClaimResult(outcome, null, null, null); + } + } + + /** Opens a connect handshake. */ + public ConnectRequestResult connectRequest( + String name, String callbackUrl, String nonce, String claimSecret) throws IOException { + return connectRequest(name, callbackUrl, nonce, claimSecret, null); + } + /** - * Relays the admin Supabase JWT to the SaaS register endpoint and returns the minted - * credential. - * - * @throws IOException on transport failure or a non-2xx response (caller surfaces to the - * admin). + * As {@link #connectRequest}, but presenting an existing device credential so the SaaS side + * treats this as a re-authentication and pins the handshake to the team we already belong to. */ - public RegisterResult register(String supabaseJwt, String instanceName) throws IOException { - String body = - instanceName == null || instanceName.isBlank() - ? "{}" - : "{\"name\":" + mapper.writeValueAsString(instanceName) + "}"; - HttpRequest request = + public ConnectRequestResult connectRequest( + String name, + String callbackUrl, + String nonce, + String claimSecret, + DeviceCredential credential) + throws IOException { + ObjectNode root = mapper.createObjectNode(); + if (name != null && !name.isBlank()) { + root.put("name", name); + } + root.put("callbackUrl", callbackUrl); + root.put("nonce", nonce); + root.put("claimSecret", claimSecret); + + HttpRequest.Builder builder = HttpRequest.newBuilder() - .uri(uri("/api/v1/account-link/register")) - .header("Authorization", "Bearer " + supabaseJwt) + .uri(uri("/api/v1/account-link/connect/request")) .header("Content-Type", "application/json") .header("Accept", "application/json") .timeout(timeout()) - .POST(HttpRequest.BodyPublishers.ofString(body)) - .build(); + .POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(root))); + if (credential != null) { + builder.header(HEADER_DEVICE_ID, credential.getDeviceId()) + .header(HEADER_DEVICE_SECRET, credential.getDeviceSecret()); + } - HttpResponse response = send(request); + HttpResponse response = send(builder.build()); if (response.statusCode() / 100 != 2) { throw new UpstreamException(response.statusCode(), response.body()); } - JsonNode root = mapper.readTree(response.body()); - String deviceId = text(root, "deviceId"); - String deviceSecret = text(root, "deviceSecret"); - if (deviceId == null || deviceSecret == null) { - throw new IOException("SaaS register response missing deviceId/deviceSecret"); + JsonNode body = mapper.readTree(response.body()); + String requestId = text(body, "requestId"); + if (requestId == null) { + throw new IOException("SaaS connect response missing requestId"); + } + String authorizeUrl = text(body, "authorizeUrl"); + if (authorizeUrl == null || !isAbsoluteHttpUrl(authorizeUrl)) { + throw new IOException("SaaS connect response carried no usable authorizeUrl"); + } + return new ConnectRequestResult(requestId, body.path("expiresIn").asInt(0), authorizeUrl); + } + + /** + * Collects the device credential for an approved handshake, proving possession of the claim + * secret. + */ + public ConnectClaimResult connectClaim(String requestId, String claimSecret) { + HttpResponse response; + try { + ObjectNode root = mapper.createObjectNode(); + root.put("requestId", requestId); + root.put("claimSecret", claimSecret); + HttpRequest request = + HttpRequest.newBuilder() + .uri(uri("/api/v1/account-link/connect/claim")) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .timeout(timeout()) + .POST( + HttpRequest.BodyPublishers.ofString( + mapper.writeValueAsString(root))) + .build(); + response = send(request); + } catch (Exception e) { + log.debug("Connect claim failed (transport): {}", e.getMessage()); + return ConnectClaimResult.of(ConnectClaimOutcome.UNAVAILABLE); + } + int status = response.statusCode(); + if (status == 202) { + return ConnectClaimResult.of(ConnectClaimOutcome.PENDING); + } + if (status >= 500 && status <= 599) { + return ConnectClaimResult.of(ConnectClaimOutcome.UNAVAILABLE); + } + if (status < 200 || status > 299) { + return ConnectClaimResult.of(ConnectClaimOutcome.REJECTED); + } + try { + JsonNode body = mapper.readTree(response.body()); + Long teamId = body.hasNonNull("teamId") ? body.get("teamId").asLong() : null; + // A re-authentication says so explicitly and carries no credential, so an absent + // credential is only an error when we were expecting one. + if ("confirmed".equals(text(body, "status"))) { + return new ConnectClaimResult(ConnectClaimOutcome.CONFIRMED, null, null, teamId); + } + String deviceId = text(body, "deviceId"); + String deviceSecret = text(body, "deviceSecret"); + if (deviceId == null || deviceSecret == null) { + log.warn("Connect claim succeeded but the reply carried no credential"); + return ConnectClaimResult.of(ConnectClaimOutcome.REJECTED); + } + return new ConnectClaimResult( + ConnectClaimOutcome.GRANTED, deviceId, deviceSecret, teamId); + } catch (RuntimeException e) { + log.debug("Connect claim parse failed: {}", e.getMessage()); + return ConnectClaimResult.of(ConnectClaimOutcome.REJECTED); } - Long teamId = root.hasNonNull("teamId") ? root.get("teamId").asLong() : null; - return new RegisterResult(deviceId, deviceSecret, teamId); } /** * Revokes this instance's own credential on the SaaS side, authenticated by that credential. - * Best-effort: returns {@code false} if SaaS is unreachable or rejects, so the caller (local - * unlink) can still clear locally and log the orphan for follow-up. Idempotent on SaaS. */ public boolean revokeSelf(String deviceId, String deviceSecret) { try { @@ -174,17 +244,7 @@ public class AccountLinkClient { } } - /** - * Fetches the current entitlement using the stored device credential. Three outcomes: - * - *

    - *
  • 2xx → the parsed snapshot. - *
  • 401/403 → {@link RevokedException} (authoritative deny — revoked/invalid credential); - * the caller must BLOCK, not fail open. - *
  • transport failure, other non-2xx (e.g. 5xx), or a malformed body → {@code null} - * ("unknown" — the caller fails open). - *
- */ + /** Fetches the current entitlement using the stored device credential. */ public InstanceEntitlement fetchEntitlement(String deviceId, String deviceSecret) { HttpResponse response; try { @@ -224,9 +284,6 @@ public class AccountLinkClient { /** * Reports the period's cumulative per-category units to {@code POST /api/v1/instance/sync} and * returns the fresh entitlement in the same reply — one round-trip both reports and refreshes. - * SaaS bills the delta against its last-seen cumulative, so resending the same totals is - * idempotent. Same three outcomes as {@link #fetchEntitlement}; on {@code null} the caller must - * not advance its last-synced markers so the usage retries next sync. */ public InstanceEntitlement reportUsage( String deviceId, @@ -360,4 +417,19 @@ public class AccountLinkClient { private static String text(JsonNode node, String field) { return node.hasNonNull(field) ? node.get(field).asText() : null; } + + /** Absolute http(s) with a host. */ + static boolean isAbsoluteHttpUrl(String candidate) { + try { + URI uri = URI.create(candidate.strip()); + String scheme = uri.getScheme(); + return uri.isAbsolute() + && scheme != null + && ("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme)) + && uri.getHost() != null + && !uri.getHost().isBlank(); + } catch (IllegalArgumentException e) { + return false; + } + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkController.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkController.java index 52af366df4..b1826b7cca 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkController.java @@ -16,21 +16,11 @@ import org.springframework.web.bind.annotation.RestController; import io.swagger.v3.oas.annotations.Hidden; +import jakarta.servlet.http.HttpServletRequest; + import lombok.extern.slf4j.Slf4j; -/** - * Same-origin account-link surface on the self-hosted instance (combined-billing "Mode A"). - * - *

The portal (served from this same origin, admin authenticated by the existing self-hosted - * security chain) calls these. {@code POST /link} relays the admin's Supabase JWT to the SaaS - * backend, which mints + returns a device credential we store locally. {@code GET /status} backs - * the portal's link card; {@code GET /usage} exposes locally-accrued unsynced usage the portal adds - * to SaaS-synced spend; {@code POST /sync-now} forces an immediate usage sync (ops "reconcile now" - * / test aid). - * - *

Admin-only, {@code @Profile("!saas")}, gated behind {@code - * stirling.billing.account-link.enabled} — off → bean absent → 404. - */ +/** Same-origin account-link surface on the self-hosted instance (combined billing). */ @Slf4j @Hidden @RestController @@ -41,51 +31,110 @@ import lombok.extern.slf4j.Slf4j; public class AccountLinkController { private final AccountLinkService service; + private final ConnectService connectService; private final LocalUsageService localUsageService; // Present only when metering is on (its own flag); absent → /sync-now reports 409. private final ObjectProvider syncServiceProvider; public AccountLinkController( AccountLinkService service, + ConnectService connectService, LocalUsageService localUsageService, ObjectProvider syncServiceProvider) { this.service = service; + this.connectService = connectService; this.localUsageService = localUsageService; this.syncServiceProvider = syncServiceProvider; } - /** {@code supabaseJwt} is the admin's short-lived token the portal already holds. */ - public record LinkRequest(String supabaseJwt, String name) {} + /** {@code callbackUrl} is the portal telling us where its own callback route lives. */ + public record ConnectStartRequest(String name, String callbackUrl) {} - @PostMapping("/link") - public ResponseEntity link(@RequestBody LinkRequest req) { - if (req == null || req.supabaseJwt() == null || req.supabaseJwt().isBlank()) { - return ResponseEntity.badRequest() - .body(java.util.Map.of("error", "supabaseJwt is required")); - } + /** {@code nonce} comes from the callback fragment the approval page redirected to. */ + public record ConnectCompleteRequest(String nonce) {} + + /** + * Opens a browser-mediated link handshake and returns the approval URL to send the admin to. + */ + @PostMapping("/connect/start") + public ResponseEntity connectStart( + @RequestBody(required = false) ConnectStartRequest req, HttpServletRequest http) { try { - return ResponseEntity.ok(service.link(req.supabaseJwt(), req.name())); + return ResponseEntity.ok( + connectService.start(req != null ? req.name() : null, callbackHint(req, http))); } catch (AccountLinkClient.UpstreamException e) { - // Auth failures are the admin's token, not a gateway fault: surface 401/403 as-is so - // the portal can prompt a re-sign-in. Anything else upstream → 502. Don't echo the - // raw upstream body back to the browser. - HttpStatus status = - e.status() == HttpStatus.UNAUTHORIZED.value() - || e.status() == HttpStatus.FORBIDDEN.value() - ? HttpStatus.valueOf(e.status()) - : HttpStatus.BAD_GATEWAY; - log.warn("Account-link register rejected upstream: HTTP {}", e.status()); - return ResponseEntity.status(status).body(java.util.Map.of("error", "LINK_FAILED")); - } catch (IOException e) { - // Don't echo e.getMessage() to the browser: a DNS/connection/TLS failure can carry the - // configured SaaS host/IP. Log it server-side; return the same opaque body the - // UpstreamException branch does. - log.warn("Account-link failed (transport): {}", e.getMessage()); + log.warn("Account-link connect rejected upstream: HTTP {}", e.status()); return ResponseEntity.status(HttpStatus.BAD_GATEWAY) - .body(java.util.Map.of("error", "LINK_FAILED")); + .body(java.util.Map.of("error", "CONNECT_FAILED")); + } catch (IOException e) { + // Same reasoning as /link: a transport message can carry the configured SaaS host. + log.warn("Account-link connect failed (transport): {}", e.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_GATEWAY) + .body(java.util.Map.of("error", "CONNECT_FAILED")); } } + /** Re-establishes the admin's SaaS session for a server that is already linked. */ + @PostMapping("/connect/reauth") + public ResponseEntity connectReauth( + @RequestBody(required = false) ConnectStartRequest req, HttpServletRequest http) { + try { + return ResponseEntity.ok(connectService.startReauth(callbackHint(req, http))); + } catch (AccountLinkClient.UpstreamException e) { + log.warn("Account-link reauth rejected upstream: HTTP {}", e.status()); + return ResponseEntity.status(HttpStatus.BAD_GATEWAY) + .body(java.util.Map.of("error", "CONNECT_FAILED")); + } catch (IOException e) { + log.warn("Account-link reauth failed: {}", e.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_GATEWAY) + .body(java.util.Map.of("error", "CONNECT_FAILED")); + } + } + + /** Called by the callback page with the nonce it found in the fragment. */ + @PostMapping("/connect/complete") + public ResponseEntity connectComplete( + @RequestBody(required = false) ConnectCompleteRequest req) { + return ResponseEntity.ok(connectService.complete(req != null ? req.nonce() : null)); + } + + /** Everything we know about where the admin's browser is, for the callback. */ + private static ConnectService.CallbackHint callbackHint( + ConnectStartRequest req, HttpServletRequest http) { + return new ConnectService.CallbackHint( + req != null ? req.callbackUrl() : null, http.getHeader("Origin"), baseUrlOf(http)); + } + + /** + * This instance's base URL as the browser reached it, including any context path so a subpath + * deployment builds a callback that actually resolves. + */ + private static String baseUrlOf(HttpServletRequest request) { + String forwardedProto = firstHop(request.getHeader("X-Forwarded-Proto")); + String forwardedHost = firstHop(request.getHeader("X-Forwarded-Host")); + String scheme = forwardedProto != null ? forwardedProto : request.getScheme(); + String hostPort; + if (forwardedHost != null) { + hostPort = forwardedHost; + } else { + int port = request.getServerPort(); + boolean defaultPort = + ("http".equals(scheme) && port == 80) + || ("https".equals(scheme) && port == 443); + hostPort = defaultPort ? request.getServerName() : request.getServerName() + ":" + port; + } + String context = request.getContextPath() == null ? "" : request.getContextPath(); + return scheme + "://" + hostPort + context; + } + + private static String firstHop(String headerValue) { + if (headerValue == null || headerValue.isBlank()) { + return null; + } + String first = headerValue.split(",")[0].strip(); + return first.isEmpty() ? null : first; + } + @GetMapping("/status") public ResponseEntity status() { return ResponseEntity.ok(service.status()); @@ -106,12 +155,7 @@ public class AccountLinkController { return ResponseEntity.ok(localUsageService.currentPeriodUnsynced()); } - /** - * Forces an immediate usage sync to SaaS — the same work the daily scheduler does. An admin - * "reconcile now" action (and a test aid so you don't wait on the scheduler). Idempotent: - * re-reports the current cumulative, so a repeat trigger bills nothing. {@code 204} once run; - * {@code 409} when metering is off (the sync bean is absent). - */ + /** Forces an immediate usage sync to SaaS — the same work the daily scheduler does. */ @PostMapping("/sync-now") public ResponseEntity syncNow() { UsageSyncService sync = syncServiceProvider.getIfAvailable(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkProperties.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkProperties.java index 6d1f1fb151..619aa10e86 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkProperties.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkProperties.java @@ -8,29 +8,17 @@ import org.springframework.stereotype.Component; import lombok.Getter; import lombok.Setter; -/** - * Self-hosted side of combined-billing "Mode A" (connected self-hosted). - * - *

Binds the {@code stirling.billing.account-link.*} keys. {@link #enabled} mirrors the same flag - * the gated beans test with {@code @ConditionalOnProperty}; it is kept here only so non-conditional - * code (e.g. the gate's flag-off short-circuit, exposed status) can read it. The whole feature is - * off by default and dark — when off nothing gates and the link endpoints 404. - */ +/** Self-hosted side of combined billing: this instance bills through a linked SaaS team. */ @Getter @Setter @Component @ConfigurationProperties(prefix = "stirling.billing.account-link") public class AccountLinkProperties { - /** Master switch. When {@code false} (default) the feature is fully inert. */ + /** Master switch. */ private boolean enabled = false; - /** - * Base URL of the SaaS backend this instance links to (register + entitlement live there). - * - *

STUB: defaults to the public cloud host; an operator overrides it for staging. There is no - * existing SaaS-base-url property in the self-hosted profile, so this is introduced here. - */ + /** Base URL of the SaaS backend this instance links to (register + entitlement live there). */ private String saasBaseUrl = "https://stirling.com/app"; /** Cached entitlement is reused for this long before a refresh is attempted. */ @@ -39,20 +27,18 @@ public class AccountLinkProperties { /** Connect/read timeout for the outbound SaaS calls. */ private int requestTimeoutSeconds = 10; - /** Phase 2 usage metering + daily sync. Keyed under {@code …account-link.metering.*}. */ + /** Phase 2 usage metering + daily sync. */ private final Metering metering = new Metering(); /** - * Dedicated billing switch, separate from {@link #enabled} so the link plumbing can be - * enabled (e.g. to test linking) without ever turning on real usage metering, reporting, or cap - * enforcement. Both default off; metering requires the master flag too. This is the production - * safety key — flipping it on is what actually bills linked instances. + * Separate from {@link #enabled} so linking can be exercised without billing anything. Both + * default off, and metering needs the master flag as well. */ @Getter @Setter public static class Metering { - /** Turns on usage metering, the daily sync, and cap enforcement. Default off. */ + /** Turns on usage metering, the daily sync, and cap enforcement. */ private boolean enabled = false; /** @@ -65,12 +51,7 @@ public class AccountLinkProperties { */ private int graceDays = 3; - /** - * Dedup window for identical input sets. A re-run of the same inputs within this window is - * treated as workflow chaining and not re-charged; the same inputs run again after it are - * billed afresh. Mirrors the cloud's {@code payg.lineage.workflow-window} so the same op - * costs the same on the instance and in the cloud. - */ + /** Dedup window for identical input sets. */ private Duration workflowWindow = Duration.ofMinutes(5); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkService.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkService.java index 1bb27d9cd6..e1283d83ba 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkService.java @@ -1,6 +1,5 @@ package stirling.software.proprietary.accountlink; -import java.io.IOException; import java.util.Optional; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -9,13 +8,7 @@ import org.springframework.stereotype.Service; import lombok.extern.slf4j.Slf4j; -/** - * Linking orchestrator (self-hosted side of combined-billing "Mode A"). - * - *

{@link #link} is the same-origin action the portal triggers: it relays the admin's Supabase - * JWT to the SaaS register endpoint, then persists the returned device credential secure-at-rest. - * The credential — not the JWT — authenticates all later unattended entitlement calls. - */ +/** Linking orchestrator (self-hosted side of combined billing). */ @Slf4j @Service @Profile("!saas") @@ -38,24 +31,9 @@ public class AccountLinkService { /** Status of this instance's link, for the portal's "Account link" card. */ public record LinkStatus(boolean linked, String deviceId, Long teamId, String linkedAt) {} - /** - * Registers this instance with the SaaS team behind {@code supabaseJwt} and stores the - * credential. - * - * @throws IOException if the SaaS register call fails (surfaced to the admin as a link error). - */ - public LinkStatus link(String supabaseJwt, String instanceName) throws IOException { - AccountLinkClient.RegisterResult result = client.register(supabaseJwt, instanceName); - credentialStore.save(result.deviceId(), result.deviceSecret(), result.teamId()); - entitlementCache.invalidate(); - log.info("Account-link: instance linked to team {}", result.teamId()); - return status(); - } - /** * Unlinks this instance — best-effort tells SaaS to revoke first (so the row gets {@code - * revoked_at} set), then clears locally regardless. If SaaS is unreachable the local clear - * still proceeds (admin's intent must win); the orphan row can be revoked from the portal. + * revoked_at} set), then clears locally regardless. */ public void unlink() { credentialStore diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncState.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncState.java index fbac6a8603..2715b4743b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncState.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncState.java @@ -12,7 +12,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; /** - * Singleton row holding this instance's daily-sync bookkeeping (combined-billing "Mode A"). + * Singleton row holding this instance's daily-sync bookkeeping (combined billing). * *

{@link #lastSyncSeq} is reserved (incremented + persisted) before each report so it * is strictly monotonic across restarts and partial failures — SaaS dedups replays by comparing it, diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncStateRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncStateRepository.java index 15b5e3842d..d0cdf36f90 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncStateRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncStateRepository.java @@ -2,5 +2,5 @@ package stirling.software.proprietary.accountlink; import org.springframework.data.jpa.repository.JpaRepository; -/** Persistence for the singleton {@link AccountLinkSyncState} (combined-billing "Mode A"). */ +/** Persistence for the singleton {@link AccountLinkSyncState} (combined billing). */ public interface AccountLinkSyncStateRepository extends JpaRepository {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/ConnectService.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/ConnectService.java new file mode 100644 index 0000000000..978d6d494e --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/ConnectService.java @@ -0,0 +1,276 @@ +package stirling.software.proprietary.accountlink; + +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Base64; +import java.util.Locale; +import java.util.Optional; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.model.ApplicationProperties; + +/** Browser-mediated account linking, instance side. */ +@Slf4j +@Service +@Profile("!saas") +@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true") +public class ConnectService { + + /** Frontend route that consumes the callback fragment. */ + static final String CALLBACK_PATH = "/account-link/callback"; + + private static final int SECRET_BYTES = 32; + + private final AccountLinkClient client; + private final ConnectStateRepository stateRepo; + private final DeviceCredentialStore credentialStore; + private final EntitlementCache entitlementCache; + private final ApplicationProperties applicationProperties; + private final SecureRandom random = new SecureRandom(); + + public ConnectService( + AccountLinkClient client, + ConnectStateRepository stateRepo, + DeviceCredentialStore credentialStore, + EntitlementCache entitlementCache, + ApplicationProperties applicationProperties) { + this.client = client; + this.stateRepo = stateRepo; + this.credentialStore = credentialStore; + this.entitlementCache = entitlementCache; + this.applicationProperties = applicationProperties; + } + + public enum Phase { + /** Nothing in flight and not linked. */ + NONE, + /** A handshake is open, waiting for a leader to approve it on the SaaS site. */ + PENDING, + /** Linked. */ + LINKED, + /** The handshake outlived its window; start a new one. */ + EXPIRED, + /** Declined or already used; start a new one. */ + REJECTED, + /** SaaS could not be reached; the handshake is still valid and can be retried. */ + UNAVAILABLE + } + + /** What the portal renders. */ + public record ConnectStatus( + Phase phase, String authorizeUrl, Long secondsRemaining, Long teamId) { + static ConnectStatus of(Phase phase) { + return new ConnectStatus(phase, null, null, null); + } + } + + /** Everything we know about where the admin's browser actually is, in decreasing authority. */ + public record CallbackHint( + String requestedCallbackUrl, String browserOrigin, String derivedBaseUrl) {} + + /** Opens a handshake and returns where to send the admin. */ + @Transactional + public ConnectStatus start(String name, CallbackHint hint) throws IOException { + if (credentialStore.isLinked()) { + return status(); + } + return open(name, hint, null); + } + + /** + * Opens a handshake that only re-establishes the admin's browser session, for an instance that + * is already linked. + */ + @Transactional + public ConnectStatus startReauth(CallbackHint hint) throws IOException { + DeviceCredential credential = + credentialStore + .get() + .orElseThrow( + () -> + new IOException( + "This server is not linked, so there is no session" + + " to re-establish")); + return open(credential.getDeviceId(), hint, credential); + } + + private ConnectStatus open(String name, CallbackHint hint, DeviceCredential credential) + throws IOException { + String callbackUrl = resolveCallbackUrl(hint); + if (callbackUrl == null) { + throw new IOException( + "Cannot determine where to send the admin back to; set system.frontendUrl"); + } + String nonce = randomSecret(); + String claimSecret = randomSecret(); + + AccountLinkClient.ConnectRequestResult created = + client.connectRequest(name, callbackUrl, nonce, claimSecret, credential); + + LocalDateTime now = LocalDateTime.now(); + ConnectState state = new ConnectState(); + state.setId(ConnectState.SINGLETON_ID); + state.setRequestId(created.requestId()); + state.setNonce(nonce); + state.setClaimSecret(claimSecret); + state.setCallbackUrl(callbackUrl); + state.setAuthorizeUrl(created.authorizeUrl()); + state.setCreatedAt(now); + state.setExpiresAt( + now.plusSeconds(created.expiresInSeconds() > 0 ? created.expiresInSeconds() : 900)); + stateRepo.save(state); + + log.info("Account-link connect: handshake {} opened", created.requestId()); + return pendingStatus(state, now); + } + + /** Finishes a handshake from the callback the approval page redirected to. */ + @Transactional + public ConnectStatus complete(String nonce) { + Optional found = stateRepo.findById(ConnectState.SINGLETON_ID); + if (found.isEmpty()) { + // Already finished (a double-submitted callback) or never started. + return status(); + } + ConnectState state = found.get(); + if (state.isExpired(LocalDateTime.now())) { + stateRepo.delete(state); + return ConnectStatus.of(Phase.EXPIRED); + } + if (nonce == null || !nonceMatches(nonce, state.getNonce())) { + log.warn( + "Account-link connect: callback for handshake {} had a bad nonce", + state.getRequestId()); + return ConnectStatus.of(Phase.REJECTED); + } + + AccountLinkClient.ConnectClaimResult claim = + client.connectClaim(state.getRequestId(), state.getClaimSecret()); + return switch (claim.outcome()) { + case GRANTED -> { + credentialStore.save(claim.deviceId(), claim.deviceSecret(), claim.teamId()); + entitlementCache.invalidate(); + stateRepo.delete(state); + log.info("Account-link connect: linked to team {}", claim.teamId()); + yield new ConnectStatus(Phase.LINKED, null, null, claim.teamId()); + } + case CONFIRMED -> { + stateRepo.delete(state); + log.info( + "Account-link connect: session re-established for team {}", claim.teamId()); + yield new ConnectStatus(Phase.LINKED, null, null, claim.teamId()); + } + case PENDING -> + // The admin reached the callback before the approval committed. The row stays, + // so a retry finishes it. + ConnectStatus.of(Phase.PENDING); + case REJECTED -> { + stateRepo.delete(state); + yield ConnectStatus.of(Phase.REJECTED); + } + case UNAVAILABLE -> ConnectStatus.of(Phase.UNAVAILABLE); + }; + } + + @Transactional(readOnly = true) + public ConnectStatus status() { + Optional credential = credentialStore.get(); + if (credential.isPresent()) { + return new ConnectStatus(Phase.LINKED, null, null, credential.get().getTeamId()); + } + Optional state = stateRepo.findById(ConnectState.SINGLETON_ID); + if (state.isEmpty()) { + return ConnectStatus.of(Phase.NONE); + } + LocalDateTime now = LocalDateTime.now(); + if (state.get().isExpired(now)) { + return ConnectStatus.of(Phase.EXPIRED); + } + return pendingStatus(state.get(), now); + } + + private static ConnectStatus pendingStatus(ConnectState state, LocalDateTime now) { + long remaining = Duration.between(now, state.getExpiresAt()).toSeconds(); + return new ConnectStatus( + Phase.PENDING, state.getAuthorizeUrl(), Math.max(remaining, 0), null); + } + + /** Decides the callback, preferring knowledge over inference. */ + String resolveCallbackUrl(CallbackHint hint) { + String configured = applicationProperties.getSystem().getFrontendUrl(); + if (configured != null && !configured.isBlank()) { + return trimTrailingSlash(configured.strip()) + CALLBACK_PATH; + } + String browserOrigin = originOf(hint.browserOrigin()); + if (browserOrigin != null) { + String requested = hint.requestedCallbackUrl(); + if (requested != null && browserOrigin.equals(originOf(requested))) { + return requested.strip(); + } + return browserOrigin + CALLBACK_PATH; + } + return hint.derivedBaseUrl() == null || hint.derivedBaseUrl().isBlank() + ? null + : trimTrailingSlash(hint.derivedBaseUrl().strip()) + CALLBACK_PATH; + } + + /** Scheme, host and port of an absolute http(s) URL; null if it is not one. */ + private static String originOf(String candidate) { + if (candidate == null || candidate.isBlank()) { + return null; + } + URI uri; + try { + uri = new URI(candidate.strip()); + } catch (URISyntaxException e) { + return null; + } + if (uri.getScheme() == null || uri.getHost() == null) { + return null; + } + String scheme = uri.getScheme().toLowerCase(Locale.ROOT); + if (!"http".equals(scheme) && !"https".equals(scheme)) { + return null; + } + int port = uri.getPort(); + boolean defaultPort = + port == -1 + || ("http".equals(scheme) && port == 80) + || ("https".equals(scheme) && port == 443); + return defaultPort + ? scheme + "://" + uri.getHost() + : scheme + "://" + uri.getHost() + ":" + port; + } + + private static String trimTrailingSlash(String value) { + return value.replaceAll("/+$", ""); + } + + private String randomSecret() { + byte[] buf = new byte[SECRET_BYTES]; + random.nextBytes(buf); + return Base64.getUrlEncoder().withoutPadding().encodeToString(buf); + } + + /** Constant-time so a caller cannot probe the nonce a character at a time. */ + private static boolean nonceMatches(String candidate, String expected) { + if (expected == null) { + return false; + } + return MessageDigest.isEqual( + candidate.getBytes(StandardCharsets.UTF_8), + expected.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/ConnectState.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/ConnectState.java new file mode 100644 index 0000000000..c0dcea032e --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/ConnectState.java @@ -0,0 +1,60 @@ +package stirling.software.proprietary.accountlink; + +import java.io.Serializable; +import java.time.LocalDateTime; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** The one in-flight "connect this server" handshake, instance side. */ +@Entity +@Table(name = "account_link_connect_state") +@NoArgsConstructor +@Getter +@Setter +public class ConnectState implements Serializable { + + private static final long serialVersionUID = 1L; + + public static final Long SINGLETON_ID = 1L; + + @Id + @Column(name = "id") + private Long id = SINGLETON_ID; + + /** Opaque handle the SaaS side gave us; identifies the handshake on both sides. */ + @Column(name = "request_id", nullable = false, length = 64) + private String requestId; + + /** Correlator we minted. */ + @Column(name = "nonce", nullable = false, length = 128) + private String nonce; + + /** Secret we minted and sent to SaaS server to server. */ + @Column(name = "claim_secret", nullable = false, length = 128) + private String claimSecret; + + /** Where we asked the approval page to send the admin back to. */ + @Column(name = "callback_url", nullable = false, length = 2048) + private String callbackUrl; + + /** The approval URL handed to the browser, so a reload can offer it again. */ + @Column(name = "authorize_url", nullable = false, length = 2048) + private String authorizeUrl; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @Column(name = "expires_at", nullable = false) + private LocalDateTime expiresAt; + + public boolean isExpired(LocalDateTime now) { + return expiresAt != null && expiresAt.isBefore(now); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/ConnectStateRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/ConnectStateRepository.java new file mode 100644 index 0000000000..995dfccde1 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/ConnectStateRepository.java @@ -0,0 +1,6 @@ +package stirling.software.proprietary.accountlink; + +import org.springframework.data.jpa.repository.JpaRepository; + +/** Data access for the singleton {@link ConnectState} row. */ +public interface ConnectStateRepository extends JpaRepository {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/DeviceCredential.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/DeviceCredential.java index 4625572310..7da486b741 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/DeviceCredential.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/DeviceCredential.java @@ -13,8 +13,8 @@ import lombok.NoArgsConstructor; import lombok.Setter; /** - * The device credential this self-hosted instance received when it linked a SaaS account - * (combined-billing "Mode A"). Singleton — one instance links to exactly one SaaS team. + * The device credential this self-hosted instance received when it linked a SaaS account (combined + * billing). Singleton — one instance links to exactly one SaaS team. * *

Unlike the SaaS side (which stores only a hash), the instance must keep the plaintext {@code * deviceSecret} so it can present it on every unattended entitlement call. It lives in the local diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementGate.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementGate.java index 018684b73f..c0cc901e1f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementGate.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementGate.java @@ -8,7 +8,7 @@ import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; /** - * Decides whether a request may proceed under combined-billing "Mode A" on a self-hosted instance. + * Decides whether a request may proceed under combined billing on a self-hosted instance. * *

Rules (in order): * diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptor.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptor.java index 0cd71c9bda..9e73267561 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptor.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptor.java @@ -39,8 +39,8 @@ import stirling.software.proprietary.policy.controller.PolicyRunRoutes; import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken; /** - * Request-time gate + meter for combined-billing "Mode A". {@code preHandle} blocks billable (API / - * AI / automation) work when the instance is unlinked or over its limit; manual tools pass through. + * Request-time gate + meter for combined billing. {@code preHandle} blocks billable (API / AI / + * automation) work when the instance is unlinked or over its limit; manual tools pass through. * {@code afterCompletion} meters a successful billable op into the per-period cumulative counter. * *

Blocking responds {@code 402} with a machine-readable body the FE maps to a "link to activate" diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/MeteredInputSignature.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/MeteredInputSignature.java index 1ed49d6a8b..278380a5da 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/MeteredInputSignature.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/MeteredInputSignature.java @@ -16,11 +16,11 @@ import lombok.NoArgsConstructor; /** * The last time the instance metered a given input set this period — the local equivalent of the - * cloud's lineage join (combined-billing "Mode A"). The meter dedups on a rolling workflow - * window: an identical input set re-submitted within the window (see {@link - * AccountLinkProperties.Metering}) is treated as workflow chaining and not re-charged, while the - * same inputs run again after the window are billed afresh — matching the cloud's 5-minute open-job - * window so the same operation costs the same on the instance and in the cloud. + * cloud's lineage join (combined billing). The meter dedups on a rolling workflow window: an + * identical input set re-submitted within the window (see {@link AccountLinkProperties.Metering}) + * is treated as workflow chaining and not re-charged, while the same inputs run again after the + * window are billed afresh — matching the cloud's 5-minute open-job window so the same operation + * costs the same on the instance and in the cloud. * *

{@code lastMeteredAt} is refreshed on every sighting (the window slides, as recording a cloud * artifact touches its job). One row per {@code (period, signature)}; the unique constraint also diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/MeteredInputSignatureRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/MeteredInputSignatureRepository.java index 863f503f61..a31310a7b3 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/MeteredInputSignatureRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/MeteredInputSignatureRepository.java @@ -5,7 +5,7 @@ import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; -/** Persistence for the per-period metered input-set signatures (combined-billing "Mode A"). */ +/** Persistence for the per-period metered input-set signatures (combined billing). */ public interface MeteredInputSignatureRepository extends JpaRepository { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounter.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounter.java index b90af07958..4c06089040 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounter.java @@ -17,10 +17,10 @@ import lombok.NoArgsConstructor; import stirling.software.proprietary.billing.BillingCategory; /** - * Durable per-(billing period, category) cumulative usage counter for combined-billing "Mode A". - * Each successful billable op increments its row; the daily sync reports the cumulative totals and - * SaaS bills the delta since the last sync. The cumulative model is idempotent (a resend bills - * nothing) and tamper-evident (a counter that drops is a signal). One row per {@code (period_start, + * Durable per-(billing period, category) cumulative usage counter for combined billing. Each + * successful billable op increments its row; the daily sync reports the cumulative totals and SaaS + * bills the delta since the last sync. The cumulative model is idempotent (a resend bills nothing) + * and tamper-evident (a counter that drops is a signal). One row per {@code (period_start, * category)}, auto-created by Hibernate; only the flag-gated {@link UsageMeterService} writes it. */ @Entity diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounterRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounterRepository.java index 2140775abc..3013d00a52 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounterRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounterRepository.java @@ -9,7 +9,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.transaction.annotation.Transactional; -/** Persistence for the per-period/per-category usage counters (combined-billing "Mode A"). */ +/** Persistence for the per-period/per-category usage counters (combined billing). */ public interface UsageCounterRepository extends JpaRepository { /** diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageSyncService.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageSyncService.java index 4c4ce2377c..a12a26eb8a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageSyncService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageSyncService.java @@ -18,8 +18,8 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.proprietary.billing.BillingCategory; /** - * Daily usage sender for combined-billing "Mode A". Reports each period's cumulative per-category - * usage to SaaS, which bills the delta against its own last-seen totals. + * Daily usage sender for combined billing. Reports each period's cumulative per-category usage to + * SaaS, which bills the delta against its own last-seen totals. * *

Resilience: the sync seq is persisted before the report so it never regresses across * restarts/failures; a transport failure leaves the {@code lastSyncedUnits} markers untouched so diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditLevel.java b/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditLevel.java index 59adc2af80..c2b0e53eb7 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditLevel.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditLevel.java @@ -59,7 +59,7 @@ public enum AuditLevel { */ public static AuditLevel fromInt(int level) { // Ensure level is within valid bounds - int boundedLevel = Math.min(Math.max(level, 0), 3); + int boundedLevel = Math.clamp(level, 0, 3); for (AuditLevel auditLevel : values()) { if (auditLevel.level == boundedLevel) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/billing/ContentHasher.java b/app/proprietary/src/main/java/stirling/software/proprietary/billing/ContentHasher.java index 232dd499dc..182c162f6f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/billing/ContentHasher.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/billing/ContentHasher.java @@ -11,9 +11,9 @@ import java.util.HexFormat; /** * SHA-256 content fingerprint shared by the SaaS charge path and the linked self-hosted instance's - * meter (combined-billing "Mode A"), so both derive an identical signature for the same - * bytes — the basis for lineage dedup. Pure, no Spring: fixed 64 KiB buffer (allocation independent - * of file size), hardware-accelerated by the JVM where available. + * meter (combined billing), so both derive an identical signature for the same bytes — the + * basis for lineage dedup. Pure, no Spring: fixed 64 KiB buffer (allocation independent of file + * size), hardware-accelerated by the JVM where available. * *

Lives in {@code :proprietary} (not {@code :common}) so it stays out of the community core * build yet is reachable from {@code :saas} (which depends on {@code :proprietary}). diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyJobStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyJobStore.java index 8e93871859..f03992ed4d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyJobStore.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyJobStore.java @@ -17,16 +17,16 @@ import org.springframework.data.redis.core.ScanOptions; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Component; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; - import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.common.cluster.JobStore; import stirling.software.common.cluster.JobStoreEntry; +import tools.jackson.core.JacksonException; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.ObjectMapper; + /** * Valkey-backed {@link JobStore}. Each job is one hash; a reverse index maps fileId to jobId. * @@ -44,8 +44,10 @@ public class ValkeyJobStore implements JobStore { private static final String FILE_INDEX_PREFIX = "stirling:file2job:"; private static final ObjectMapper MAPPER = new ObjectMapper(); - private static final TypeReference> LIST_STRING = new TypeReference<>() {}; - private static final TypeReference> MAP_STRING = new TypeReference<>() {}; + private static final TypeReference> LIST_STRING = + new TypeReference>() {}; + private static final TypeReference> MAP_STRING = + new TypeReference>() {}; private final StringRedisTemplate template; @@ -265,7 +267,7 @@ public class ValkeyJobStore implements JobStore { } try { return MAPPER.readValue(v.toString(), MAP_STRING); - } catch (JsonProcessingException e) { + } catch (JacksonException e) { log.warn( "JobStore {} field 'resultMeta' is not valid JSON '{}' - treating as empty", key, @@ -277,7 +279,7 @@ public class ValkeyJobStore implements JobStore { private static String writeJson(Object value) { try { return MAPPER.writeValueAsString(value); - } catch (JsonProcessingException e) { + } catch (JacksonException e) { throw new IllegalStateException("Failed to JSON-serialize JobStore field", e); } } @@ -286,7 +288,7 @@ public class ValkeyJobStore implements JobStore { try { List parsed = MAPPER.readValue(json, LIST_STRING); return parsed == null ? new ArrayList<>() : parsed; - } catch (JsonProcessingException e) { + } catch (JacksonException e) { log.warn( "JobStore {} field 'fileIds' is not valid JSON '{}' - treating as empty", key, diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/config/AuditConfigurationProperties.java b/app/proprietary/src/main/java/stirling/software/proprietary/config/AuditConfigurationProperties.java index 366d91b11c..ac6c25ac5e 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/config/AuditConfigurationProperties.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/config/AuditConfigurationProperties.java @@ -35,7 +35,7 @@ public class AuditConfigurationProperties { // Ensure level is within valid bounds (0-3) int configLevel = auditConfig.getLevel(); - this.level = Math.min(Math.max(configLevel, 0), 3); + this.level = Math.clamp(configLevel, 0, 3); // Retention days (0 means infinite) this.retentionDays = auditConfig.getRetentionDays(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/UsageRestController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/UsageRestController.java index 1230d928cc..65bf8240a4 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/UsageRestController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/UsageRestController.java @@ -48,7 +48,7 @@ public class UsageRestController { @RequestParam(value = "dataType", defaultValue = "all") String dataType, @RequestParam(value = "days", defaultValue = "30") Integer days) { - int lookbackDays = Math.max(1, Math.min(days, 365)); + int lookbackDays = Math.clamp(days, 1, 365); // Get audit events filtered by type List events = getEventsByDataType(dataType, lookbackDays); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/UserLicenseSettings.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/UserLicenseSettings.java index bb7f52142a..1683ad9134 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/model/UserLicenseSettings.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/UserLicenseSettings.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.model; +import java.io.Serial; import java.io.Serializable; import jakarta.persistence.*; @@ -19,7 +20,7 @@ import lombok.*; @ToString public class UserLicenseSettings implements Serializable { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; public static final Long SINGLETON_ID = 1L; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java index 4bfef06c9b..d83b684166 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java @@ -70,21 +70,23 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler { if (!response.isCommitted()) { if (authentication != null) { - if (authentication instanceof Saml2Authentication samlAuthentication) { - // Handle SAML2 logout redirection - getRedirect_saml2(request, response, samlAuthentication); - } else if (authentication instanceof OAuth2AuthenticationToken oAuthToken) { - // Handle OAuth2 logout redirection - getRedirect_oauth2(request, response, oAuthToken); - } else if (authentication instanceof UsernamePasswordAuthenticationToken) { - // Handle Username/Password logout - getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH); - } else { - // Handle unknown authentication types - log.error( - "Authentication class unknown: {}", - authentication.getClass().getSimpleName()); - getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH); + switch (authentication) { + case Saml2Authentication samlAuthentication -> + // Handle SAML2 logout redirection + getRedirect_saml2(request, response, samlAuthentication); + case OAuth2AuthenticationToken oAuthToken -> + // Handle OAuth2 logout redirection + getRedirect_oauth2(request, response, oAuthToken); + case UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken -> + // Handle Username/Password logout + getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH); + default -> { + // Handle unknown authentication types + log.error( + "Authentication class unknown: {}", + authentication.getClass().getSimpleName()); + getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH); + } } } else { if (jwtService != null) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java index 9fc4428f73..7c5b412d33 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java @@ -357,12 +357,12 @@ public class SecurityConfiguration { req -> { String uri = req.getRequestURI(); String contextPath = req.getContextPath(); - // Check if it's a public auth endpoint or static - // resource return RequestUriUtils.isStaticResource( contextPath, uri) || RequestUriUtils.isPublicAuthEndpoint( - uri, contextPath); + uri, contextPath) + || RequestUriUtils.isFrontendRoute( + contextPath, uri); }) .permitAll() .anyRequest() @@ -392,40 +392,40 @@ public class SecurityConfiguration { // Handle OAUTH2 Logins if (securityProperties.isOauth2Active()) { http.oauth2Login( - oauth2 -> { - oauth2.loginPage("/login") - .authorizationEndpoint( - authorizationEndpoint -> { - if (clientRegistrationRepository != null) { - authorizationEndpoint - .authorizationRequestResolver( - new TauriAuthorizationRequestResolver( - clientRegistrationRepository)); - } - }) - .successHandler( - new CustomOAuth2AuthenticationSuccessHandler( - loginAttemptService, - securityProperties.getOauth2(), - userService, - jwtService, - licenseSettingsService, - applicationProperties)) - .failureHandler(new CustomOAuth2AuthenticationFailureHandler()) - // Add existing Authorities from the database - .userInfoEndpoint( - userInfoEndpoint -> - userInfoEndpoint - .oidcUserService( - new CustomOAuth2UserService( - securityProperties - .getOauth2(), - userService, - loginAttemptService)) - .userAuthoritiesMapper( - oAuth2userAuthoritiesMapper)) - .permitAll(); - }); + oauth2 -> + oauth2.loginPage("/login") + .authorizationEndpoint( + authorizationEndpoint -> { + if (clientRegistrationRepository != null) { + authorizationEndpoint + .authorizationRequestResolver( + new TauriAuthorizationRequestResolver( + clientRegistrationRepository)); + } + }) + .successHandler( + new CustomOAuth2AuthenticationSuccessHandler( + loginAttemptService, + securityProperties.getOauth2(), + userService, + jwtService, + licenseSettingsService, + applicationProperties)) + .failureHandler( + new CustomOAuth2AuthenticationFailureHandler()) + // Add existing Authorities from the database + .userInfoEndpoint( + userInfoEndpoint -> + userInfoEndpoint + .oidcUserService( + new CustomOAuth2UserService( + securityProperties + .getOauth2(), + userService, + loginAttemptService)) + .userAuthoritiesMapper( + oAuth2userAuthoritiesMapper)) + .permitAll()); } // Handle SAML if (securityProperties.isSaml2Active() && runningProOrHigher) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java index 86a1c5fe0c..6661c86395 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java @@ -703,17 +703,18 @@ public class AuthController { } private long extractEpochMillis(Object claimValue) { - if (claimValue == null) { - return -1L; - } - - if (claimValue instanceof java.util.Date date) { - return date.getTime(); - } - - if (claimValue instanceof Number number) { - long epochSeconds = number.longValue(); - return epochSeconds * 1000L; + switch (claimValue) { + case null -> { + return -1L; + } + case java.util.Date date -> { + return date.getTime(); + } + case Number number -> { + long epochSeconds = number.longValue(); + return epochSeconds * 1000L; + } + default -> {} } return -1L; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java index fdacda72b2..2385eb011f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java @@ -760,14 +760,14 @@ public class UserController { for (Object principal : principals) { List sessionsInformation = sessionRegistry.getAllSessions(principal, false); - if (principal instanceof UserDetails detailsUser) { - userNameP = detailsUser.getUsername(); - } else if (principal instanceof OAuth2User oAuth2User) { - userNameP = oAuth2User.getName(); - } else if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2User) { - userNameP = saml2User.name(); - } else if (principal instanceof String stringUser) { - userNameP = stringUser; + switch (principal) { + case null -> {} + case UserDetails detailsUser -> userNameP = detailsUser.getUsername(); + case OAuth2User oAuth2User -> userNameP = oAuth2User.getName(); + case CustomSaml2AuthenticatedPrincipal saml2User -> + userNameP = saml2User.name(); + case String stringUser -> userNameP = stringUser; + default -> {} } if (userNameP.equalsIgnoreCase(username)) { for (SessionInformation sessionInfo : sessionsInformation) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/Authority.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/Authority.java index 659f7691bd..4ffea54740 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/Authority.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/Authority.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.security.model; +import java.io.Serial; import java.io.Serializable; import org.springframework.security.core.GrantedAuthority; @@ -28,7 +29,7 @@ import lombok.Setter; @Setter public class Authority implements GrantedAuthority, Serializable { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/InviteToken.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/InviteToken.java index 975220bf48..062cce058f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/InviteToken.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/InviteToken.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.security.model; +import java.io.Serial; import java.io.Serializable; import java.time.LocalDateTime; @@ -18,7 +19,7 @@ import lombok.Setter; @Setter public class InviteToken implements Serializable { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationFailureHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationFailureHandler.java index 784a9f0a2f..670b08c53f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationFailureHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationFailureHandler.java @@ -36,57 +36,62 @@ public class CustomOAuth2AuthenticationFailureHandler AuthenticationException exception) throws IOException, ServletException { - if (exception instanceof BadCredentialsException) { - log.error("BadCredentialsException", exception); - getRedirectStrategy().sendRedirect(request, response, "/login?error=badCredentials"); - return; - } - if (exception instanceof DisabledException) { - log.error("User is deactivated: ", exception); - getRedirectStrategy().sendRedirect(request, response, "/logout?userIsDisabled=true"); - return; - } - if (exception instanceof LockedException) { - log.error("Account locked: ", exception); - getRedirectStrategy().sendRedirect(request, response, "/logout?error=locked"); - return; - } - if (exception instanceof OAuth2AuthenticationException oAuth2Exception) { - OAuth2Error error = oAuth2Exception.getError(); - - String errorCode = error.getErrorCode(); - - if ("Password must not be null".equals(error.getErrorCode())) { - errorCode = "userAlreadyExistsWeb"; + switch (exception) { + case BadCredentialsException badCredentialsException -> { + log.error("BadCredentialsException", exception); + getRedirectStrategy() + .sendRedirect(request, response, "/login?error=badCredentials"); + return; } + case DisabledException disabledException -> { + log.error("User is deactivated: ", exception); + getRedirectStrategy() + .sendRedirect(request, response, "/logout?userIsDisabled=true"); + return; + } + case LockedException lockedException -> { + log.error("Account locked: ", exception); + getRedirectStrategy().sendRedirect(request, response, "/logout?error=locked"); + return; + } + case OAuth2AuthenticationException oAuth2Exception -> { + OAuth2Error error = oAuth2Exception.getError(); - log.error( - "OAuth2 Authentication error: {}", - errorCode != null ? errorCode : exception.getMessage(), - exception); - String errorValue = errorCode != null ? errorCode : "oauth2AuthenticationError"; - clearRedirectCookie(response); - boolean tauriState = TauriOAuthUtils.isTauriState(request); - String redirectUrl; - if (tauriState) { - String basePath = - TauriOAuthUtils.defaultTauriCallbackPath(request.getContextPath()); - redirectUrl = basePath; - String stateParam = request.getParameter("state"); - if (stateParam != null && !stateParam.isBlank()) { - redirectUrl = appendQueryParam(redirectUrl, "state", stateParam); - // Extract and pass nonce for CSRF validation - String nonce = TauriOAuthUtils.extractNonceFromState(stateParam); - if (nonce != null) { - redirectUrl = appendQueryParam(redirectUrl, "nonce", nonce); - } + String errorCode = error.getErrorCode(); + + if ("Password must not be null".equals(error.getErrorCode())) { + errorCode = "userAlreadyExistsWeb"; } - redirectUrl = appendQueryParam(redirectUrl, "errorOAuth", errorValue); - } else { - redirectUrl = buildFailureRedirectUrl(request, errorValue); + + log.error( + "OAuth2 Authentication error: {}", + errorCode != null ? errorCode : exception.getMessage(), + exception); + String errorValue = errorCode != null ? errorCode : "oauth2AuthenticationError"; + clearRedirectCookie(response); + boolean tauriState = TauriOAuthUtils.isTauriState(request); + String redirectUrl; + if (tauriState) { + String basePath = + TauriOAuthUtils.defaultTauriCallbackPath(request.getContextPath()); + redirectUrl = basePath; + String stateParam = request.getParameter("state"); + if (stateParam != null && !stateParam.isBlank()) { + redirectUrl = appendQueryParam(redirectUrl, "state", stateParam); + // Extract and pass nonce for CSRF validation + String nonce = TauriOAuthUtils.extractNonceFromState(stateParam); + if (nonce != null) { + redirectUrl = appendQueryParam(redirectUrl, "nonce", nonce); + } + } + redirectUrl = appendQueryParam(redirectUrl, "errorOAuth", errorValue); + } else { + redirectUrl = buildFailureRedirectUrl(request, errorValue); + } + getRedirectStrategy().sendRedirect(request, response, redirectUrl); + return; } - getRedirectStrategy().sendRedirect(request, response, redirectUrl); - return; + default -> {} } log.error("Unhandled authentication exception", exception); super.onAuthenticationFailure(request, response, exception); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2ResponseAuthenticationConverter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2ResponseAuthenticationConverter.java index b2ce4adb68..96dcdecd03 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2ResponseAuthenticationConverter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2ResponseAuthenticationConverter.java @@ -61,7 +61,12 @@ public class CustomSaml2ResponseAuthenticationConverter @Override public Saml2Authentication convert(ResponseToken responseToken) { - Assertion assertion = responseToken.getResponse().getAssertions().getFirst(); + List assertions = responseToken.getResponse().getAssertions(); + if (assertions == null || assertions.isEmpty()) { + log.error("SAML response contains no assertions"); + return null; + } + Assertion assertion = assertions.getFirst(); Map> attributes = extractAttributes(assertion); // Debug log with actual values diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/CustomOAuth2UserService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/CustomOAuth2UserService.java index c1057c7e36..b8054c89d9 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/CustomOAuth2UserService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/CustomOAuth2UserService.java @@ -213,8 +213,11 @@ public class CustomOAuth2UserService implements OAuth2UserService {} + case UserDetails detailsUser -> usernameP = detailsUser.getUsername(); + case OAuth2User oAuth2User -> usernameP = oAuth2User.getName(); + case CustomSaml2AuthenticatedPrincipal saml2User -> + usernameP = saml2User.name(); + case String stringUser -> usernameP = stringUser; + default -> {} } if (usernameP.equalsIgnoreCase(username)) { sessionRegistry.expireSession(sessionsInformation.getSessionId()); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/session/SessionPersistentRegistry.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/session/SessionPersistentRegistry.java index e615416e59..1f3a4e84ff 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/session/SessionPersistentRegistry.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/session/SessionPersistentRegistry.java @@ -47,14 +47,13 @@ public class SessionPersistentRegistry implements SessionRegistry { List sessionInformations = new ArrayList<>(); String principalName = null; - if (principal instanceof UserDetails detailsUser) { - principalName = detailsUser.getUsername(); - } else if (principal instanceof OAuth2User oAuth2User) { - principalName = oAuth2User.getName(); - } else if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2User) { - principalName = saml2User.name(); - } else if (principal instanceof String stringUser) { - principalName = stringUser; + switch (principal) { + case null -> {} + case UserDetails detailsUser -> principalName = detailsUser.getUsername(); + case OAuth2User oAuth2User -> principalName = oAuth2User.getName(); + case CustomSaml2AuthenticatedPrincipal saml2User -> principalName = saml2User.name(); + case String stringUser -> principalName = stringUser; + default -> {} } if (principalName != null) { @@ -78,14 +77,13 @@ public class SessionPersistentRegistry implements SessionRegistry { public void registerNewSession(String sessionId, Object principal) { String principalName = null; - if (principal instanceof UserDetails detailsUser) { - principalName = detailsUser.getUsername(); - } else if (principal instanceof OAuth2User oAuth2User) { - principalName = oAuth2User.getName(); - } else if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2User) { - principalName = saml2User.name(); - } else if (principal instanceof String stringUser) { - principalName = stringUser; + switch (principal) { + case null -> {} + case UserDetails detailsUser -> principalName = detailsUser.getUsername(); + case OAuth2User oAuth2User -> principalName = oAuth2User.getName(); + case CustomSaml2AuthenticatedPrincipal saml2User -> principalName = saml2User.name(); + case String stringUser -> principalName = stringUser; + default -> {} } if (principalName != null) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/converter/JsonMapConverter.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/converter/JsonMapConverter.java index 1c9f7ab765..5576ebb181 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/converter/JsonMapConverter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/converter/JsonMapConverter.java @@ -3,16 +3,16 @@ package stirling.software.proprietary.storage.converter; import java.util.HashMap; import java.util.Map; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; - import jakarta.persistence.AttributeConverter; import jakarta.persistence.Converter; import lombok.extern.slf4j.Slf4j; +import tools.jackson.core.JacksonException; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; + /** * JPA AttributeConverter for storing Map as JSON in database columns. * @@ -33,7 +33,7 @@ public class JsonMapConverter implements AttributeConverter, try { return objectMapper.writeValueAsString(attribute); - } catch (JsonProcessingException e) { + } catch (JacksonException e) { log.error("Failed to convert map to JSON", e); throw new RuntimeException("Failed to convert map to JSON", e); } @@ -48,7 +48,7 @@ public class JsonMapConverter implements AttributeConverter, try { // Try normal parsing first return objectMapper.readValue(dbData, new TypeReference>() {}); - } catch (JsonProcessingException e) { + } catch (JacksonException e) { // Fallback: try double-parsing for legacy double-encoded data // This handles data that was stored as JSON strings instead of JSON objects log.debug("Attempting double-decode fallback for legacy metadata format"); @@ -69,7 +69,7 @@ public class JsonMapConverter implements AttributeConverter, return objectMapper.readValue( node.asText(), new TypeReference>() {}); } - } catch (JsonProcessingException e2) { + } catch (JacksonException e2) { log.error("Failed to parse metadata even with double-decode fallback", e2); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/FileShare.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/FileShare.java index 1b0fd86f78..6ddd0c8a86 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/FileShare.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/FileShare.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.storage.model; +import java.io.Serial; import java.io.Serializable; import java.time.LocalDateTime; @@ -46,7 +47,7 @@ import stirling.software.proprietary.security.model.User; @Setter public class FileShare implements Serializable { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/FileShareAccess.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/FileShareAccess.java index 49f75a4a4c..cb2f5d5209 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/FileShareAccess.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/FileShareAccess.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.storage.model; +import java.io.Serial; import java.io.Serializable; import java.time.LocalDateTime; @@ -39,7 +40,7 @@ import stirling.software.proprietary.security.model.User; @Setter public class FileShareAccess implements Serializable { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StorageCleanupEntry.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StorageCleanupEntry.java index 3158f4c041..68afe20173 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StorageCleanupEntry.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StorageCleanupEntry.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.storage.model; +import java.io.Serial; import java.io.Serializable; import java.time.LocalDateTime; @@ -24,7 +25,7 @@ import lombok.Setter; @Setter public class StorageCleanupEntry implements Serializable { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFile.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFile.java index db80bd1e91..1b098672b6 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFile.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFile.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.storage.model; +import java.io.Serial; import java.io.Serializable; import java.time.LocalDateTime; import java.util.HashSet; @@ -45,7 +46,7 @@ import stirling.software.proprietary.workflow.model.WorkflowSession; @Setter public class StoredFile implements Serializable { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFileBlob.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFileBlob.java index 52ef1107fc..4abcffd3e6 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFileBlob.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFileBlob.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.storage.model; +import java.io.Serial; import java.io.Serializable; import jakarta.persistence.Column; @@ -19,7 +20,7 @@ import lombok.Setter; @Setter public class StoredFileBlob implements Serializable { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; @Id @Column(name = "storage_key", nullable = false, length = 128) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/web/AuditWebFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/web/AuditWebFilter.java index b6f5b47f3b..70847a0702 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/web/AuditWebFilter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/web/AuditWebFilter.java @@ -7,6 +7,7 @@ import org.slf4j.MDC; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; @@ -64,7 +65,7 @@ public class AuditWebFilter extends OncePerRequestFilter { if (auth != null && auth.getAuthorities() != null) { String roles = auth.getAuthorities().stream() - .map(a -> a.getAuthority()) + .map(GrantedAuthority::getAuthority) .reduce((a, b) -> a + "," + b) .orElse(""); MDC.put("userRoles", roles); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/SigningSessionController.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/SigningSessionController.java index 4e95707217..b224a09841 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/SigningSessionController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/SigningSessionController.java @@ -20,8 +20,6 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.server.ResponseStatusException; -import com.fasterxml.jackson.databind.ObjectMapper; - import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -39,11 +37,14 @@ import stirling.software.proprietary.workflow.dto.CertificateInfo; import stirling.software.proprietary.workflow.dto.CertificateValidationResponse; import stirling.software.proprietary.workflow.dto.ParticipantRequest; import stirling.software.proprietary.workflow.dto.WorkflowCreationRequest; +import stirling.software.proprietary.workflow.model.WorkflowParticipant; import stirling.software.proprietary.workflow.model.WorkflowSession; import stirling.software.proprietary.workflow.service.CertificateSubmissionValidator; import stirling.software.proprietary.workflow.service.SigningFinalizationService; import stirling.software.proprietary.workflow.service.WorkflowSessionService; +import tools.jackson.databind.ObjectMapper; + @Slf4j @RestController @RequestMapping("/api/v1/security") @@ -259,7 +260,9 @@ public class SigningSessionController { + "database until manual cleanup.", sessionId, session.getParticipants() != null - ? session.getParticipants().stream().map(p -> p.getEmail()).toList() + ? session.getParticipants().stream() + .map(WorkflowParticipant::getEmail) + .toList() : "unknown", e); throw new ResponseStatusException( diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/WorkflowParticipantController.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/WorkflowParticipantController.java index 5f903e4b56..4df0c93e1d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/WorkflowParticipantController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/WorkflowParticipantController.java @@ -5,6 +5,7 @@ import java.nio.charset.StandardCharsets; import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; import java.util.HashMap; +import java.util.List; import java.util.Map; import org.springframework.http.ContentDisposition; @@ -429,7 +430,7 @@ public class WorkflowParticipantController { java.util.List> wetSigs = objectMapper.readValue( request.getWetSignaturesData(), - new TypeReference>>() {}); + new TypeReference>>() {}); if (wetSigs.size() > WetSignatureMetadata.MAX_SIGNATURES_PER_PARTICIPANT) { throw new ResponseStatusException( HttpStatus.BAD_REQUEST, "Too many wet signatures submitted"); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/WorkflowParticipant.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/WorkflowParticipant.java index 2e6091b963..b119565c13 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/WorkflowParticipant.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/WorkflowParticipant.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.workflow.model; +import java.io.Serial; import java.io.Serializable; import java.time.LocalDateTime; import java.util.ArrayList; @@ -51,7 +52,7 @@ import stirling.software.proprietary.storage.model.ShareAccessRole; @Setter public class WorkflowParticipant implements Serializable { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/WorkflowSession.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/WorkflowSession.java index 3fc6b53b44..7df5af710f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/WorkflowSession.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/WorkflowSession.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.workflow.model; +import java.io.Serial; import java.io.Serializable; import java.time.LocalDateTime; import java.util.ArrayList; @@ -53,7 +54,7 @@ import stirling.software.proprietary.storage.model.StoredFile; @Setter public class WorkflowSession implements Serializable { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/SigningFinalizationService.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/SigningFinalizationService.java index e5e122df45..3fce8c69dd 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/SigningFinalizationService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/SigningFinalizationService.java @@ -217,16 +217,13 @@ public class SigningFinalizationService { wetSignatures.size(), session.getSessionId()); - PDDocument document = pdfDocumentFactory.load(new ByteArrayInputStream(pdfBytes)); - try { + try (PDDocument document = pdfDocumentFactory.load(new ByteArrayInputStream(pdfBytes))) { for (WetSignatureMetadata wetSig : wetSignatures) { applyWetSignatureToPage(document, wetSig); } ByteArrayOutputStream baos = new ByteArrayOutputStream(); document.save(baos); return baos.toByteArray(); - } finally { - document.close(); } } @@ -242,11 +239,10 @@ public class SigningFinalizationService { } PDPage page = document.getPage(pageIndex); - PDPageContentStream contentStream = - new PDPageContentStream( - document, page, PDPageContentStream.AppendMode.APPEND, true, true); - try { + try (PDPageContentStream contentStream = + new PDPageContentStream( + document, page, PDPageContentStream.AppendMode.APPEND, true, true)) { // Use WetSignatureMetadata.extractBase64Data() to strip data URL prefix String base64Data = wetSig.extractBase64Data(); if (base64Data == null || base64Data.isBlank()) { @@ -279,8 +275,6 @@ public class SigningFinalizationService { pdfY, width, height); - } finally { - contentStream.close(); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/WorkflowSessionService.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/WorkflowSessionService.java index 4c60c60df2..a2db5deb5a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/WorkflowSessionService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/WorkflowSessionService.java @@ -954,21 +954,22 @@ public class WorkflowSessionService { Object pemObject = pemParser.readObject(); JcaPEMKeyConverter converter = new JcaPEMKeyConverter().setProvider("BC"); PrivateKeyInfo keyInfo; - if (pemObject instanceof PKCS8EncryptedPrivateKeyInfo encrypted) { - InputDecryptorProvider decryptor = - new JceOpenSSLPKCS8DecryptorProviderBuilder().build(password); - keyInfo = encrypted.decryptPrivateKeyInfo(decryptor); - } else if (pemObject instanceof PEMEncryptedKeyPair encryptedKeyPair) { - PEMDecryptorProvider decryptor = - new JcePEMDecryptorProviderBuilder().build(password); - keyInfo = encryptedKeyPair.decryptKeyPair(decryptor).getPrivateKeyInfo(); - } else if (pemObject instanceof PEMKeyPair keyPair) { - keyInfo = keyPair.getPrivateKeyInfo(); - } else if (pemObject instanceof PrivateKeyInfo info) { - keyInfo = info; - } else { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "Unsupported PEM private key format"); + switch (pemObject) { + case PKCS8EncryptedPrivateKeyInfo encrypted -> { + InputDecryptorProvider decryptor = + new JceOpenSSLPKCS8DecryptorProviderBuilder().build(password); + keyInfo = encrypted.decryptPrivateKeyInfo(decryptor); + } + case PEMEncryptedKeyPair encryptedKeyPair -> { + PEMDecryptorProvider decryptor = + new JcePEMDecryptorProviderBuilder().build(password); + keyInfo = encryptedKeyPair.decryptKeyPair(decryptor).getPrivateKeyInfo(); + } + case PEMKeyPair keyPair -> keyInfo = keyPair.getPrivateKeyInfo(); + case PrivateKeyInfo info -> keyInfo = info; + case null, default -> + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "Unsupported PEM private key format"); } return converter.getPrivateKey(keyInfo); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/util/WorkflowMapper.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/util/WorkflowMapper.java index b2f53c2824..8d61ae032c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/util/WorkflowMapper.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/util/WorkflowMapper.java @@ -4,14 +4,14 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; -import com.fasterxml.jackson.databind.ObjectMapper; - import stirling.software.proprietary.workflow.dto.ParticipantResponse; import stirling.software.proprietary.workflow.dto.WetSignatureMetadata; import stirling.software.proprietary.workflow.dto.WorkflowSessionResponse; import stirling.software.proprietary.workflow.model.WorkflowParticipant; import stirling.software.proprietary.workflow.model.WorkflowSession; +import tools.jackson.databind.ObjectMapper; + /** * Utility class for mapping workflow entities to DTOs. Centralizes conversion logic for consistent * API responses. diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkClientTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkClientTest.java index 969111e9f5..2bce17d732 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkClientTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkClientTest.java @@ -21,9 +21,9 @@ import org.mockito.ArgumentCaptor; import tools.jackson.databind.ObjectMapper; /** - * Stubs the {@link HttpClient} so the SaaS endpoint is never actually called. Confirms register - * relays the JWT and parses the credential, and that entitlement parsing + the fail-open (null on - * unreachable) behaviour hold. + * Stubs the {@link HttpClient} so the SaaS endpoint is never actually called. Confirms the connect + * handshake refuses an authorize URL it would not navigate to and carries no user token, and that + * entitlement parsing + the fail-open (null on unreachable) behaviour hold. */ class AccountLinkClientTest { @@ -48,39 +48,79 @@ class AccountLinkClientTest { return resp; } + // register() is gone with the JWT relay, and with it the two tests that asserted this client + // sends an Authorization: Bearer header. Nothing here carries a user token any more. + @Test @SuppressWarnings("unchecked") - void registerRelaysJwtAndParsesCredential() throws Exception { - // Build the stub response first: nesting response() inside when() trips Mockito's - // unfinished-stubbing check (inner when() runs mid outer when()). + void connectRequestRefusesAnAuthorizeUrlItWouldNotNavigateTo() throws Exception { + // The reply drives a browser navigation, so a non-absolute or non-http(s) value must fail + // loudly here rather than reach the admin. HttpResponse resp = - response(201, "{\"deviceId\":\"dev-1\",\"deviceSecret\":\"sec-1\",\"teamId\":42}"); - ArgumentCaptor captor = ArgumentCaptor.forClass(HttpRequest.class); - when(httpClient.send(captor.capture(), any(HttpResponse.BodyHandler.class))) - .thenReturn(resp); + response(201, "{\"requestId\":\"req-1\",\"authorizeUrl\":\"/link?request=req-1\"}"); + when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp); - AccountLinkClient.RegisterResult result = client.register("jwt-token", "My Server"); - - assertEquals("dev-1", result.deviceId()); - assertEquals("sec-1", result.deviceSecret()); - assertEquals(42L, result.teamId()); - - HttpRequest sent = captor.getValue(); - assertEquals("Bearer jwt-token", sent.headers().firstValue("Authorization").orElse(null)); - assertEquals( - "https://saas.example.com/api/v1/account-link/register", sent.uri().toString()); + assertThrows( + java.io.IOException.class, + () -> client.connectRequest("n", "https://pdf.example.com/cb", "nonce", "secret")); } @Test @SuppressWarnings("unchecked") - void registerThrowsUpstreamExceptionWithStatusOnNon2xx() throws Exception { - HttpResponse resp = response(401, "{\"error\":\"unauthorized\"}"); + void connectRequestParsesTheAuthorizeUrlItIsGiven() throws Exception { + HttpResponse resp = + response( + 201, + "{\"requestId\":\"req-1\",\"expiresIn\":900," + + "\"authorizeUrl\":\"https://app.example.com/link?request=req-1\"}"); + ArgumentCaptor captor = ArgumentCaptor.forClass(HttpRequest.class); + when(httpClient.send(captor.capture(), any(HttpResponse.BodyHandler.class))) + .thenReturn(resp); + + AccountLinkClient.ConnectRequestResult result = + client.connectRequest("n", "https://pdf.example.com/cb", "nonce", "secret"); + + assertEquals("req-1", result.requestId()); + assertEquals("https://app.example.com/link?request=req-1", result.authorizeUrl()); + // No user token on this call, by design. + assertEquals(null, captor.getValue().headers().firstValue("Authorization").orElse(null)); + } + + @Test + @SuppressWarnings("unchecked") + void connectClaimGrantsTheCredentialOnSuccess() throws Exception { + HttpResponse resp = + response(200, "{\"deviceId\":\"dev-1\",\"deviceSecret\":\"sec-1\",\"teamId\":7}"); when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp); - AccountLinkClient.UpstreamException ex = - assertThrows( - AccountLinkClient.UpstreamException.class, - () -> client.register("jwt", null)); - assertEquals(401, ex.status()); + + AccountLinkClient.ConnectClaimResult result = client.connectClaim("req-1", "secret"); + + assertEquals(AccountLinkClient.ConnectClaimOutcome.GRANTED, result.outcome()); + assertEquals("dev-1", result.deviceId()); + assertEquals("sec-1", result.deviceSecret()); + } + + @Test + @SuppressWarnings("unchecked") + void connectClaimMapsTheStatusItIsGiven() throws Exception { + // The whole point of these four: a claim consumes the request server-side, so + // reading 200 as anything but success loses the credential irrecoverably. + assertEquals(AccountLinkClient.ConnectClaimOutcome.PENDING, claimOutcome(202, "{}")); + assertEquals(AccountLinkClient.ConnectClaimOutcome.UNAVAILABLE, claimOutcome(503, "{}")); + assertEquals(AccountLinkClient.ConnectClaimOutcome.REJECTED, claimOutcome(400, "{}")); + assertEquals( + AccountLinkClient.ConnectClaimOutcome.CONFIRMED, + claimOutcome(200, "{\"status\":\"confirmed\",\"teamId\":7}")); + } + + @SuppressWarnings("unchecked") + private AccountLinkClient.ConnectClaimOutcome claimOutcome(int status, String body) + throws Exception { + // Built before the when(), not inside it: response() stubs a mock of its own, and + // Mockito cannot have that happen mid-stubbing. + HttpResponse resp = response(status, body); + when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp); + return client.connectClaim("req-1", "secret").outcome(); } @Test diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkControllerTest.java index 41f544de7e..6e13021a40 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkControllerTest.java @@ -1,6 +1,7 @@ package stirling.software.proprietary.accountlink; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -14,16 +15,15 @@ import org.springframework.beans.factory.ObjectProvider; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import stirling.software.proprietary.accountlink.AccountLinkController.LinkRequest; - /** - * The local (self-hosted) account-link controller's error mapping: an upstream auth rejection - * surfaces as 401/403 (so the portal can prompt a re-sign-in) while other upstream / transport - * faults are a 502. + * The local (self-hosted) account-link controller's error mapping. Every upstream or transport + * failure is a 502, and the response body never echoes the exception, because a DNS or TLS message + * can carry the configured SaaS host. */ class AccountLinkControllerTest { private AccountLinkService service; + private ConnectService connectService; private UsageSyncService syncService; private ObjectProvider syncProvider; private AccountLinkController controller; @@ -32,47 +32,54 @@ class AccountLinkControllerTest { @SuppressWarnings("unchecked") void setUp() { service = mock(AccountLinkService.class); + connectService = mock(ConnectService.class); syncService = mock(UsageSyncService.class); syncProvider = mock(ObjectProvider.class); controller = - new AccountLinkController(service, mock(LocalUsageService.class), syncProvider); + new AccountLinkController( + service, connectService, mock(LocalUsageService.class), syncProvider); } - @Test - void link_missingJwt_returns400() { - ResponseEntity resp = controller.link(new LinkRequest(" ", null)); - assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); - } + // These asserted POST /link's error mapping, which distinguished 401/403 so the portal could + // prompt a re-sign-in. That endpoint is gone with the JWT relay, and the distinction went with + // it: connect/start carries no user token, so an upstream refusal is never the admin's session + // and everything non-transport is a plain gateway failure. @Test - void link_upstreamUnauthorized_maps401() throws Exception { - when(service.link("jwt", null)) - .thenThrow(new AccountLinkClient.UpstreamException(401, "bad token")); - ResponseEntity resp = controller.link(new LinkRequest("jwt", null)); - assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); - } - - @Test - void link_upstreamForbidden_maps403() throws Exception { - when(service.link("jwt", null)) - .thenThrow(new AccountLinkClient.UpstreamException(403, "forbidden")); - ResponseEntity resp = controller.link(new LinkRequest("jwt", null)); - assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); - } - - @Test - void link_upstreamServerError_maps502() throws Exception { - when(service.link("jwt", null)) + void connectStart_upstreamFailure_maps502() throws Exception { + when(connectService.start(any(), any())) .thenThrow(new AccountLinkClient.UpstreamException(500, "boom")); - ResponseEntity resp = controller.link(new LinkRequest("jwt", null)); + + ResponseEntity resp = controller.connectStart(null, request()); + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_GATEWAY); } @Test - void link_transportFailure_maps502() throws Exception { - when(service.link("jwt", null)).thenThrow(new IOException("connection refused")); - ResponseEntity resp = controller.link(new LinkRequest("jwt", null)); + void connectStart_transportFailure_maps502WithoutLeakingTheHost() throws Exception { + when(connectService.start(any(), any())) + .thenThrow(new IOException("connection refused to saas.internal:8081")); + + ResponseEntity resp = controller.connectStart(null, request()); + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_GATEWAY); + // The body must not echo the exception: a DNS/TLS message can carry the configured SaaS + // host. + assertThat(String.valueOf(resp.getBody())).doesNotContain("saas.internal"); + } + + @Test + void connectReauth_onAnUnlinkedServer_maps502() throws Exception { + when(connectService.startReauth(any())).thenThrow(new IOException("not linked")); + + ResponseEntity resp = controller.connectReauth(null, request()); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_GATEWAY); + } + + /** Minimal request: the controller only reads Origin and the forwarded/host details from it. */ + private static jakarta.servlet.http.HttpServletRequest request() { + return new org.springframework.mock.web.MockHttpServletRequest(); } @Test diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkServiceTest.java index b909fb37a0..410ef8a8c7 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkServiceTest.java @@ -3,12 +3,10 @@ package stirling.software.proprietary.accountlink; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import java.io.IOException; import java.time.LocalDateTime; import java.util.Optional; @@ -30,33 +28,25 @@ class AccountLinkServiceTest { service = new AccountLinkService(client, store, cache); } + // The two link() tests here are gone with the JWT relay. Storing a credential and invalidating + // the entitlement cache is now ConnectService's job and is covered by ConnectServiceTest; what + // remains in this service is status and unlink. + @Test - void link_storesCredentialAndInvalidatesCache() throws IOException { - when(client.register("jwt", "name")) - .thenReturn(new AccountLinkClient.RegisterResult("dev-1", "sec-1", 7L)); + void status_linkedFromTheStoredCredential() { DeviceCredential stored = new DeviceCredential(); stored.setDeviceId("dev-1"); stored.setTeamId(7L); stored.setLinkedAt(LocalDateTime.now()); when(store.get()).thenReturn(Optional.of(stored)); - AccountLinkService.LinkStatus status = service.link("jwt", "name"); + AccountLinkService.LinkStatus status = service.status(); - verify(store).save("dev-1", "sec-1", 7L); - verify(cache).invalidate(); assertTrue(status.linked()); assertEquals("dev-1", status.deviceId()); assertEquals(7L, status.teamId()); } - @Test - void link_propagatesRegisterFailure() throws IOException { - when(client.register(any(), any())).thenThrow(new IOException("boom")); - org.junit.jupiter.api.Assertions.assertThrows( - IOException.class, () -> service.link("jwt", null)); - verify(cache, org.mockito.Mockito.never()).invalidate(); - } - @Test void status_unlinkedWhenNoCredential() { when(store.get()).thenReturn(Optional.empty()); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/ConnectServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/ConnectServiceTest.java new file mode 100644 index 0000000000..5af25d7250 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/ConnectServiceTest.java @@ -0,0 +1,406 @@ +package stirling.software.proprietary.accountlink; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.time.LocalDateTime; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.accountlink.AccountLinkClient.ConnectClaimOutcome; +import stirling.software.proprietary.accountlink.AccountLinkClient.ConnectClaimResult; +import stirling.software.proprietary.accountlink.AccountLinkClient.ConnectRequestResult; +import stirling.software.proprietary.accountlink.ConnectService.Phase; + +/** Unit tests for the instance half of the connect handshake. */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class ConnectServiceTest { + + private static final String NONCE = "the-nonce"; + private static final String CLAIM_SECRET = "the-claim-secret"; + private static final String AUTHORIZE_URL = "https://app.example.com/link?request=req-1"; + + @Mock private AccountLinkClient client; + @Mock private ConnectStateRepository stateRepo; + @Mock private DeviceCredentialStore credentialStore; + @Mock private EntitlementCache entitlementCache; + + private ApplicationProperties applicationProperties; + private ConnectService service; + + @BeforeEach + void setUp() { + applicationProperties = new ApplicationProperties(); + service = + new ConnectService( + client, + stateRepo, + credentialStore, + entitlementCache, + applicationProperties); + } + + private void configureFrontendUrl(String url) { + applicationProperties.getSystem().setFrontendUrl(url); + } + + @Test + void start_advertisesTheConfiguredFrontendUrlInPreferenceToTheRequest() throws Exception { + configureFrontendUrl("https://pdf.example.com/"); + stubCreate(); + + service.start("prod-1", fromRequest("http://10.0.0.5:8080")); + + verify(client) + .connectRequest( + anyString(), + // Trailing slash trimmed, and the request's own view ignored. + org.mockito.ArgumentMatchers.eq( + "https://pdf.example.com" + ConnectService.CALLBACK_PATH), + anyString(), + anyString(), + // A first link carries no credential; that is what makes it a first link. + org.mockito.ArgumentMatchers.isNull()); + } + + @Test + void start_fallsBackToTheAddressTheRequestArrivedOn() throws Exception { + stubCreate(); + + service.start(null, fromRequest("https://pdf.internal:8443/stirling")); + + ArgumentCaptor callback = ArgumentCaptor.forClass(String.class); + verify(client).connectRequest(any(), callback.capture(), anyString(), anyString(), any()); + // Context path preserved, so a subpath deployment gets a callback that resolves. + assertThat(callback.getValue()) + .isEqualTo("https://pdf.internal:8443/stirling" + ConnectService.CALLBACK_PATH); + } + + @Test + void start_withNoAddressAtAllFailsRatherThanGuessing() { + assertThat(catchIo(() -> service.start(null, fromRequest(null)))) + .hasMessageContaining("system.frontendUrl"); + verifyNoInteractions(client); + } + + @Test + void resolveCallback_honoursThePortalsOwnCallbackWhenTheBrowserOriginAgrees() { + // The frontend is the only party that knows its router's base path. + String requested = "http://localhost:5173/app/account-link/callback"; + + assertThat( + service.resolveCallbackUrl( + new ConnectService.CallbackHint( + requested, + "http://localhost:5173", + "http://localhost:8080"))) + .isEqualTo(requested); + } + + @Test + void resolveCallback_ignoresACallbackFromADifferentOrigin() { + assertThat( + service.resolveCallbackUrl( + new ConnectService.CallbackHint( + "https://evil.example.com/steal", + "http://localhost:5173", + "http://localhost:8080"))) + .isEqualTo("http://localhost:5173" + ConnectService.CALLBACK_PATH); + } + + @Test + void resolveCallback_prefersTheBrowserOriginOverTheApiRequest() { + // The whole point: :5173 is where the admin is, :8080 is where the call landed. + assertThat( + service.resolveCallbackUrl( + new ConnectService.CallbackHint( + null, "http://localhost:5173", "http://localhost:8080"))) + .isEqualTo("http://localhost:5173" + ConnectService.CALLBACK_PATH); + } + + @Test + void resolveCallback_letsConfigurationBeatEverything() { + configureFrontendUrl("https://pdf.example.com/"); + + assertThat( + service.resolveCallbackUrl( + new ConnectService.CallbackHint( + "http://localhost:5173/account-link/callback", + "http://localhost:5173", + "http://localhost:8080"))) + .isEqualTo("https://pdf.example.com" + ConnectService.CALLBACK_PATH); + } + + @Test + void resolveCallback_ignoresAnUnusableOriginHeader() { + // "null" is what a browser sends for an opaque origin; it must not become a callback. + assertThat( + service.resolveCallbackUrl( + new ConnectService.CallbackHint( + null, "null", "http://localhost:8080"))) + .isEqualTo("http://localhost:8080" + ConnectService.CALLBACK_PATH); + } + + @Test + void start_sendsTheAdminWhereverSaaSSaidToSendThem() throws Exception { + stubCreate(); + + ConnectService.ConnectStatus status = + service.start(null, fromRequest("https://pdf.example.com")); + + assertThat(status.phase()).isEqualTo(Phase.PENDING); + // Not composed here: only the SaaS side knows where its approval page lives, so an + // instance configuring that could only get it wrong. + assertThat(status.authorizeUrl()).isEqualTo(AUTHORIZE_URL); + } + + @Test + void start_keepsTheNonceAndClaimSecretItSent() throws Exception { + stubCreate(); + + service.start(null, fromRequest("https://pdf.example.com")); + + ArgumentCaptor nonce = ArgumentCaptor.forClass(String.class); + ArgumentCaptor secret = ArgumentCaptor.forClass(String.class); + verify(client).connectRequest(any(), anyString(), nonce.capture(), secret.capture(), any()); + + ArgumentCaptor saved = ArgumentCaptor.forClass(ConnectState.class); + verify(stateRepo).save(saved.capture()); + assertThat(saved.getValue().getNonce()).isEqualTo(nonce.getValue()); + assertThat(saved.getValue().getClaimSecret()).isEqualTo(secret.getValue()); + // Two independent secrets, not one value used twice. + assertThat(nonce.getValue()).isNotEqualTo(secret.getValue()); + } + + @Test + void start_whenAlreadyLinkedDoesNothing() throws Exception { + when(credentialStore.isLinked()).thenReturn(true); + when(credentialStore.get()).thenReturn(Optional.of(credential(7L))); + + ConnectService.ConnectStatus status = + service.start(null, fromRequest("https://pdf.example.com")); + + assertThat(status.phase()).isEqualTo(Phase.LINKED); + verifyNoInteractions(client); + verify(stateRepo, never()).save(any()); + } + + @Test + void complete_withTheRightNonceStoresTheCredentialAndClearsTheHandshake() { + ConnectState state = openHandshake(); + when(stateRepo.findById(ConnectState.SINGLETON_ID)).thenReturn(Optional.of(state)); + when(client.connectClaim("req-1", CLAIM_SECRET)) + .thenReturn(new ConnectClaimResult(ConnectClaimOutcome.GRANTED, "dev", "sec", 7L)); + + ConnectService.ConnectStatus status = service.complete(NONCE); + + assertThat(status.phase()).isEqualTo(Phase.LINKED); + assertThat(status.teamId()).isEqualTo(7L); + verify(credentialStore).save("dev", "sec", 7L); + verify(entitlementCache).invalidate(); + verify(stateRepo).delete(state); + } + + @Test + void complete_withAWrongNonceClaimsNothingAndLeavesTheHandshakeAlone() { + ConnectState state = openHandshake(); + when(stateRepo.findById(ConnectState.SINGLETON_ID)).thenReturn(Optional.of(state)); + + ConnectService.ConnectStatus status = service.complete("not-the-nonce"); + + assertThat(status.phase()).isEqualTo(Phase.REJECTED); + // The important half: an unverified caller cannot cancel a legitimate handshake. + verify(stateRepo, never()).delete(any()); + verifyNoInteractions(credentialStore); + verify(client, never()).connectClaim(anyString(), anyString()); + } + + @Test + void complete_withNoNonceAtAllIsRejected() { + when(stateRepo.findById(ConnectState.SINGLETON_ID)) + .thenReturn(Optional.of(openHandshake())); + + assertThat(service.complete(null).phase()).isEqualTo(Phase.REJECTED); + verify(client, never()).connectClaim(anyString(), anyString()); + } + + @Test + void complete_whenSaaSHasNotCommittedTheApprovalKeepsTheHandshake() { + when(stateRepo.findById(ConnectState.SINGLETON_ID)) + .thenReturn(Optional.of(openHandshake())); + when(client.connectClaim(anyString(), anyString())) + .thenReturn(ConnectClaimResult.of(ConnectClaimOutcome.PENDING)); + + assertThat(service.complete(NONCE).phase()).isEqualTo(Phase.PENDING); + verify(stateRepo, never()).delete(any()); + } + + @Test + void complete_whenSaaSIsUnreachableKeepsTheHandshakeForARetry() { + when(stateRepo.findById(ConnectState.SINGLETON_ID)) + .thenReturn(Optional.of(openHandshake())); + when(client.connectClaim(anyString(), anyString())) + .thenReturn(ConnectClaimResult.of(ConnectClaimOutcome.UNAVAILABLE)); + + assertThat(service.complete(NONCE).phase()).isEqualTo(Phase.UNAVAILABLE); + verify(stateRepo, never()).delete(any()); + verifyNoInteractions(credentialStore); + } + + @Test + void complete_whenDeclinedClearsTheHandshake() { + ConnectState state = openHandshake(); + when(stateRepo.findById(ConnectState.SINGLETON_ID)).thenReturn(Optional.of(state)); + when(client.connectClaim(anyString(), anyString())) + .thenReturn(ConnectClaimResult.of(ConnectClaimOutcome.REJECTED)); + + assertThat(service.complete(NONCE).phase()).isEqualTo(Phase.REJECTED); + verify(stateRepo).delete(state); + verifyNoInteractions(credentialStore); + } + + @Test + void complete_onAnExpiredHandshakeClearsItWithoutClaiming() { + ConnectState state = openHandshake(); + state.setExpiresAt(LocalDateTime.now().minusSeconds(1)); + when(stateRepo.findById(ConnectState.SINGLETON_ID)).thenReturn(Optional.of(state)); + + assertThat(service.complete(NONCE).phase()).isEqualTo(Phase.EXPIRED); + verify(stateRepo).delete(state); + verify(client, never()).connectClaim(anyString(), anyString()); + } + + @Test + void startReauth_presentsTheCredentialSoSaaSCanPinTheTeam() throws Exception { + when(credentialStore.get()).thenReturn(Optional.of(credential(7L))); + when(client.connectRequest(any(), anyString(), anyString(), anyString(), any())) + .thenReturn(new ConnectRequestResult("req-1", 900, AUTHORIZE_URL)); + + service.startReauth(fromRequest("https://pdf.example.com")); + + // Sending the credential is what makes the pinning trustworthy: the team comes from + // something only this instance holds. + verify(client) + .connectRequest( + any(), + anyString(), + anyString(), + anyString(), + org.mockito.ArgumentMatchers.argThat( + c -> c != null && "dev".equals(c.getDeviceId()))); + } + + @Test + void startReauth_onAnUnlinkedServerFails() { + assertThat(catchIo(() -> service.startReauth(fromRequest("https://pdf.example.com")))) + .hasMessageContaining("not linked"); + verifyNoInteractions(client); + } + + @Test + void complete_onAConfirmedReauthKeepsTheExistingCredential() { + ConnectState state = openHandshake(); + when(stateRepo.findById(ConnectState.SINGLETON_ID)).thenReturn(Optional.of(state)); + when(client.connectClaim(anyString(), anyString())) + .thenReturn(new ConnectClaimResult(ConnectClaimOutcome.CONFIRMED, null, null, 7L)); + + ConnectService.ConnectStatus status = service.complete(NONCE); + + assertThat(status.phase()).isEqualTo(Phase.LINKED); + assertThat(status.teamId()).isEqualTo(7L); + // Nothing to store: a second credential would orphan the one we already hold. + verify(credentialStore, never()).save(anyString(), anyString(), any()); + verify(stateRepo).delete(state); + } + + @Test + void status_reportsNothingInFlightWhenThereIsNoHandshakeOrCredential() { + assertThat(service.status().phase()).isEqualTo(Phase.NONE); + } + + @Test + void status_reportsAnExpiredHandshakeRatherThanOfferingAStaleLink() { + ConnectState state = openHandshake(); + state.setExpiresAt(LocalDateTime.now().minusSeconds(1)); + when(stateRepo.findById(ConnectState.SINGLETON_ID)).thenReturn(Optional.of(state)); + + ConnectService.ConnectStatus status = service.status(); + + assertThat(status.phase()).isEqualTo(Phase.EXPIRED); + assertThat(status.authorizeUrl()).isNull(); + } + + @Test + void status_countsDownWhileAHandshakeIsOpen() { + when(stateRepo.findById(ConnectState.SINGLETON_ID)) + .thenReturn(Optional.of(openHandshake())); + + ConnectService.ConnectStatus status = service.status(); + + assertThat(status.phase()).isEqualTo(Phase.PENDING); + assertThat(status.secondsRemaining()).isPositive(); + assertThat(status.authorizeUrl()).isEqualTo("https://app.example.com/link?request=req-1"); + } + + /** A start with nothing but the reconstructed request URL, as a headless caller would send. */ + private static ConnectService.CallbackHint fromRequest(String derivedBaseUrl) { + return new ConnectService.CallbackHint(null, null, derivedBaseUrl); + } + + private void stubCreate() throws Exception { + // The five-argument overload: a first link passes a null credential rather than none. + when(client.connectRequest(any(), anyString(), anyString(), anyString(), any())) + .thenReturn(new ConnectRequestResult("req-1", 900, AUTHORIZE_URL)); + } + + private static ConnectState openHandshake() { + ConnectState state = new ConnectState(); + state.setId(ConnectState.SINGLETON_ID); + state.setRequestId("req-1"); + state.setNonce(NONCE); + state.setClaimSecret(CLAIM_SECRET); + state.setCallbackUrl("https://pdf.example.com/account-link/callback"); + state.setAuthorizeUrl("https://app.example.com/link?request=req-1"); + state.setCreatedAt(LocalDateTime.now()); + state.setExpiresAt(LocalDateTime.now().plusMinutes(10)); + return state; + } + + private static DeviceCredential credential(Long teamId) { + DeviceCredential credential = new DeviceCredential(); + credential.setDeviceId("dev"); + credential.setDeviceSecret("sec"); + credential.setTeamId(teamId); + credential.setLinkedAt(LocalDateTime.now()); + return credential; + } + + /** Runs a throwing call and returns the exception, so the assertion reads in one line. */ + private static Throwable catchIo(ThrowingCall call) { + try { + call.run(); + throw new AssertionError("expected the call to fail"); + } catch (Exception e) { + return e; + } + } + + private interface ThrowingCall { + void run() throws Exception; + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/AccountLinkController.java b/app/saas/src/main/java/stirling/software/saas/accountlink/AccountLinkController.java index 646791f76e..4703c32496 100644 --- a/app/saas/src/main/java/stirling/software/saas/accountlink/AccountLinkController.java +++ b/app/saas/src/main/java/stirling/software/saas/accountlink/AccountLinkController.java @@ -4,14 +4,12 @@ import java.util.List; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Profile; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -19,25 +17,9 @@ import io.swagger.v3.oas.annotations.Hidden; import lombok.extern.slf4j.Slf4j; -import stirling.software.common.model.enumeration.TeamRole; -import stirling.software.proprietary.model.TeamMembership; -import stirling.software.proprietary.security.database.repository.UserRepository; -import stirling.software.proprietary.security.model.User; -import stirling.software.proprietary.security.repository.TeamMembershipRepository; -import stirling.software.saas.util.AuthenticationUtils; +import stirling.software.saas.accountlink.LeaderTeamResolver.LeaderTeam; -/** - * Account-link registration surface (combined-billing "Mode A"). - * - *

A self-hosted instance's local backend calls {@code POST /register} with the admin's - * short-lived Supabase JWT (validated by the existing {@code SupabaseSecurityConfig} chain — no new - * auth here). We resolve the caller's team, mint a device credential bound to it, and return the - * secret exactly once. Ongoing entitlement reads authenticate with that device credential, not this - * JWT. - * - *

Whole surface gated behind {@code stirling.billing.account-link.enabled}: off → beans absent → - * 404. Leader-only, and the team is always derived from the caller (never the request body). - */ +/** Team-wide management of linked instances (combined billing). */ @Slf4j @Hidden @RestController @@ -47,25 +29,13 @@ import stirling.software.saas.util.AuthenticationUtils; public class AccountLinkController { private final AccountLinkService service; - private final TeamMembershipRepository memberRepo; - private final UserRepository userRepository; + private final LeaderTeamResolver leaderTeams; - public AccountLinkController( - AccountLinkService service, - TeamMembershipRepository memberRepo, - UserRepository userRepository) { + public AccountLinkController(AccountLinkService service, LeaderTeamResolver leaderTeams) { this.service = service; - this.memberRepo = memberRepo; - this.userRepository = userRepository; + this.leaderTeams = leaderTeams; } - /** Optional display name for the instance (hostname / label). */ - public record RegisterRequest(String name) {} - - /** {@code deviceSecret} is plaintext and returned exactly once — the caller must store it. */ - public record RegisterResponse( - Long instanceId, Long teamId, String deviceId, String deviceSecret, String name) {} - public record InstanceRow( Long instanceId, String deviceId, @@ -74,31 +44,10 @@ public class AccountLinkController { String lastSeenAt, boolean revoked) {} - @PostMapping("/register") - @PreAuthorize("isAuthenticated()") - public ResponseEntity register( - @RequestBody(required = false) RegisterRequest req, Authentication auth) { - LeaderTeam lt = resolveLeaderTeam(auth); - if (lt.error() != null) { - return ResponseEntity.status(lt.error()).build(); - } - String name = req != null ? req.name() : null; - AccountLinkService.RegisteredInstance reg = - service.register(lt.teamId(), lt.userId(), name); - return ResponseEntity.status(HttpStatus.CREATED) - .body( - new RegisterResponse( - reg.instanceId(), - lt.teamId(), - reg.deviceId(), - reg.deviceSecret(), - reg.name())); - } - @GetMapping("/instances") @PreAuthorize("isAuthenticated()") public ResponseEntity> list(Authentication auth) { - LeaderTeam lt = resolveLeaderTeam(auth); + LeaderTeam lt = leaderTeams.resolve(auth); if (lt.error() != null) { return ResponseEntity.status(lt.error()).build(); } @@ -124,38 +73,11 @@ public class AccountLinkController { @PostMapping("/instances/{instanceId}/revoke") @PreAuthorize("isAuthenticated()") public ResponseEntity revoke(@PathVariable Long instanceId, Authentication auth) { - LeaderTeam lt = resolveLeaderTeam(auth); + LeaderTeam lt = leaderTeams.resolve(auth); if (lt.error() != null) { return ResponseEntity.status(lt.error()).build(); } boolean ok = service.revoke(lt.teamId(), instanceId); return ok ? ResponseEntity.noContent().build() : ResponseEntity.notFound().build(); } - - // --------------------------------------------------------------------------------------- - // Helpers — team always derived from the caller; instance linking is a leader (billing) action. - // --------------------------------------------------------------------------------------- - - /** - * Resolved caller team, or an {@code error} status to return (teamId/userId null when error). - */ - private record LeaderTeam(Long teamId, Long userId, HttpStatus error) {} - - private LeaderTeam resolveLeaderTeam(Authentication auth) { - User user; - try { - user = AuthenticationUtils.getCurrentUser(auth, userRepository); - } catch (SecurityException e) { - return new LeaderTeam(null, null, HttpStatus.UNAUTHORIZED); - } - List rows = memberRepo.findPrimaryMembership(user.getId()); - if (rows.isEmpty()) { - return new LeaderTeam(null, null, HttpStatus.FORBIDDEN); - } - TeamMembership m = rows.getFirst(); - if (m.getRole() != TeamRole.LEADER) { - return new LeaderTeam(null, null, HttpStatus.FORBIDDEN); - } - return new LeaderTeam(m.getTeam().getId(), user.getId(), null); - } } diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/AccountLinkService.java b/app/saas/src/main/java/stirling/software/saas/accountlink/AccountLinkService.java index c31fc1b03e..fe6a0cc0e6 100644 --- a/app/saas/src/main/java/stirling/software/saas/accountlink/AccountLinkService.java +++ b/app/saas/src/main/java/stirling/software/saas/accountlink/AccountLinkService.java @@ -18,16 +18,7 @@ import org.springframework.transaction.annotation.Transactional; import lombok.extern.slf4j.Slf4j; -/** - * Account-link instance registration + lifecycle (combined-billing "Mode A"). - * - *

Mints a {@code device_id} (public) + {@code device_secret} (high-entropy, returned once) bound - * to a team, persisting only the SHA-256 hash of the secret. The instance authenticates its - * unattended entitlement reads with that credential. - * - *

Gated behind {@code stirling.billing.account-link.enabled}: when off the bean is absent, so - * {@link AccountLinkController} (which depends on it) drops out too and its endpoints 404. - */ +/** Account-link instance registration + lifecycle (combined billing). */ @Slf4j @Service @Profile("saas") @@ -80,10 +71,7 @@ public class AccountLinkService { return repo.findByTeamIdOrderByCreatedAtDesc(teamId); } - /** - * Revokes an instance iff it belongs to {@code teamId}. Returns false if not found or owned by - * a different team (so a caller can never revoke another team's instance). Idempotent. - */ + /** Revokes an instance iff it belongs to {@code teamId}. */ @Transactional public boolean revoke(Long teamId, Long instanceId) { Optional found = repo.findById(instanceId); @@ -99,13 +87,30 @@ public class AccountLinkService { return true; } + /** + * Resolves an active instance from a device credential, or empty if it does not authenticate. + */ + @Transactional(readOnly = true) + public Optional resolveActiveInstance(String deviceId, String deviceSecret) { + if (deviceId == null || deviceSecret == null) { + return Optional.empty(); + } + return repo.findByDeviceIdAndRevokedAtIsNull(deviceId) + .filter( + instance -> + MessageDigest.isEqual( + sha256Hex(deviceSecret).getBytes(StandardCharsets.UTF_8), + instance.getDeviceSecretHash() + .getBytes(StandardCharsets.UTF_8))); + } + private String randomSecret() { byte[] buf = new byte[SECRET_BYTES]; random.nextBytes(buf); return Base64.getUrlEncoder().withoutPadding().encodeToString(buf); } - /** SHA-256 hex of a value. The device secret is high-entropy, so no salt is required. */ + /** SHA-256 hex of a value. */ static String sha256Hex(String value) { try { MessageDigest md = MessageDigest.getInstance("SHA-256"); diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/ConnectController.java b/app/saas/src/main/java/stirling/software/saas/accountlink/ConnectController.java new file mode 100644 index 0000000000..95f2ba5de7 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/accountlink/ConnectController.java @@ -0,0 +1,277 @@ +package stirling.software.saas.accountlink; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.Optional; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Profile; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.Hidden; + +import jakarta.servlet.http.HttpServletRequest; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.saas.accountlink.LeaderTeamResolver.LeaderTeam; + +/** Browser-mediated "connect this server" handshake. */ +@Slf4j +@Hidden +@RestController +@RequestMapping("/api/v1/account-link/connect") +@Profile("saas") +@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true") +public class ConnectController { + + /** Same headers the device-credential filter uses on the {@code /api/v1/instance} paths. */ + static final String HEADER_DEVICE_ID = "X-Device-Id"; + + static final String HEADER_DEVICE_SECRET = "X-Device-Secret"; + + /** Frontend route serving the approval page. */ + static final String LINK_PATH = "/link"; + + private final ConnectRequestService service; + private final LeaderTeamResolver leaderTeams; + private final AccountLinkService accountLinkService; + private final ApplicationProperties applicationProperties; + + public ConnectController( + ConnectRequestService service, + LeaderTeamResolver leaderTeams, + AccountLinkService accountLinkService, + ApplicationProperties applicationProperties) { + this.service = service; + this.leaderTeams = leaderTeams; + this.accountLinkService = accountLinkService; + this.applicationProperties = applicationProperties; + } + + /** Sent by the instance's own backend, before it holds any credential. */ + public record CreateBody(String name, String callbackUrl, String nonce, String claimSecret) {} + + /** {@code authorizeUrl} is where the instance should send its admin. */ + public record CreateResponse(String requestId, int expiresIn, String authorizeUrl) {} + + /** What the approval page renders. */ + public record ViewResponse( + String requestId, + String name, + String callbackOrigin, + boolean insecureTransport, + String mode, + String status) {} + + /** Where the approver's browser goes next, and the correlator the instance is waiting on. */ + public record ApproveResponse(String callbackUrl, String nonce) {} + + public record ClaimBody(String requestId, String claimSecret) {} + + public record ClaimResponse(String deviceId, String deviceSecret, Long teamId) {} + + /** Opens a handshake. */ + @PostMapping("/request") + public ResponseEntity request( + @RequestBody(required = false) CreateBody body, HttpServletRequest http) { + if (body == null) { + return ResponseEntity.badRequest().body(Map.of("error", "BAD_REQUEST")); + } + String deviceId = http.getHeader(HEADER_DEVICE_ID); + String deviceSecret = http.getHeader(HEADER_DEVICE_SECRET); + boolean reauthRequested = deviceId != null || deviceSecret != null; + + ConnectRequestService.CreateResult result; + if (reauthRequested) { + Long pinnedTeamId = + accountLinkService + .resolveActiveInstance(deviceId, deviceSecret) + .map(LinkedInstance::getTeamId) + .orElse(null); + result = + service.createReauth( + body.name(), + body.callbackUrl(), + body.nonce(), + body.claimSecret(), + clientIp(http), + pinnedTeamId); + } else { + result = + service.create( + body.name(), + body.callbackUrl(), + body.nonce(), + body.claimSecret(), + clientIp(http)); + } + if (result.isRejected()) { + return switch (result.rejection()) { + case RATE_LIMITED -> + ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS) + .body(Map.of("error", "RATE_LIMITED")); + case BAD_CALLBACK -> + ResponseEntity.badRequest().body(Map.of("error", "BAD_CALLBACK")); + case BAD_NONCE -> ResponseEntity.badRequest().body(Map.of("error", "BAD_NONCE")); + case BAD_SECRET -> ResponseEntity.badRequest().body(Map.of("error", "BAD_SECRET")); + // A credential was offered and did not authenticate. Same answer as any other bad + // credential, and deliberately not distinguishable from "revoked". + case NOT_LINKED -> + ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "NOT_LINKED")); + }; + } + return ResponseEntity.status(HttpStatus.CREATED) + .body( + new CreateResponse( + result.requestId(), + result.expiresInSeconds(), + authorizeUrl(result.requestId(), http))); + } + + /** + * Where to send the admin to approve a handshake. {@code system.frontendUrl} is the web app's + * own base URL, including any base path; without it the API's origin has to serve the app too. + */ + private String authorizeUrl(String requestId, HttpServletRequest http) { + String frontendUrl = applicationProperties.getSystem().getFrontendUrl(); + String base = + frontendUrl != null && !frontendUrl.isBlank() + ? frontendUrl.strip().replaceAll("/+$", "") + : requestOrigin(http); + return base + + LINK_PATH + + "?request=" + + URLEncoder.encode(requestId, StandardCharsets.UTF_8); + } + + /** Scheme, host and context path as the browser reached us, honouring a reverse proxy. */ + private static String requestOrigin(HttpServletRequest request) { + String proto = firstHop(request.getHeader("X-Forwarded-Proto")); + String host = firstHop(request.getHeader("X-Forwarded-Host")); + String scheme = proto != null ? proto : request.getScheme(); + // A forwarded host already carries its own port, if it needs one. + String hostPort = + host != null + ? host + : Origins.hostPort( + scheme, request.getServerName(), request.getServerPort()); + String context = request.getContextPath() == null ? "" : request.getContextPath(); + return scheme + "://" + hostPort + context; + } + + private static String firstHop(String headerValue) { + if (headerValue == null || headerValue.isBlank()) { + return null; + } + String first = headerValue.split(",")[0].strip(); + return first.isEmpty() ? null : first; + } + + /** Detail for the approval page. */ + @GetMapping("/{requestId}") + @PreAuthorize("isAuthenticated()") + public ResponseEntity view(@PathVariable String requestId) { + return service.lookup(requestId) + .map( + v -> + ResponseEntity.ok( + new ViewResponse( + v.requestId(), + v.name(), + v.callbackOrigin(), + v.insecureTransport(), + v.mode().name(), + v.status().name()))) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + + /** Approves a handshake. */ + @PostMapping("/{requestId}/approve") + @PreAuthorize("isAuthenticated()") + public ResponseEntity approve(@PathVariable String requestId, Authentication auth) { + Optional view = service.lookup(requestId); + if (view.isEmpty()) { + return ResponseEntity.notFound().build(); + } + boolean reauth = view.get().mode() == ConnectRequest.Mode.REAUTH; + LeaderTeam lt = reauth ? leaderTeams.resolveMember(auth) : leaderTeams.resolve(auth); + if (lt.isError()) { + return ResponseEntity.status(lt.error()).build(); + } + ConnectRequestService.ApproveResult result = + service.approve(requestId, lt.teamId(), lt.userId()); + if (result.isRejected()) { + return switch (result.rejection()) { + // Named separately so the page can say "you are signed in to a different account" + // rather than implying the request itself was bad. + case WRONG_TEAM -> + ResponseEntity.status(HttpStatus.CONFLICT) + .body(Map.of("error", "WRONG_TEAM")); + case UNAVAILABLE -> ResponseEntity.notFound().build(); + }; + } + return ResponseEntity.ok( + new ApproveResponse(result.target().callbackUrl(), result.target().nonce())); + } + + @PostMapping("/{requestId}/deny") + @PreAuthorize("isAuthenticated()") + public ResponseEntity deny(@PathVariable String requestId, Authentication auth) { + LeaderTeam lt = leaderTeams.resolve(auth); + if (lt.isError()) { + return ResponseEntity.status(lt.error()).build(); + } + return service.deny(requestId) + ? ResponseEntity.noContent().build() + : ResponseEntity.notFound().build(); + } + + /** Collects the device credential. */ + @PostMapping("/claim") + public ResponseEntity claim(@RequestBody(required = false) ClaimBody body) { + if (body == null) { + return ResponseEntity.badRequest().body(Map.of("error", "BAD_REQUEST")); + } + ConnectRequestService.ClaimResult result = + service.claim(body.requestId(), body.claimSecret()); + return switch (result.outcome()) { + case GRANTED -> + ResponseEntity.ok( + new ClaimResponse( + result.deviceId(), result.deviceSecret(), result.teamId())); + // A re-authentication carries no credential: the instance already has one. It only + // needs to know the browser leg succeeded, and which team it was confirmed against. + case CONFIRMED -> + ResponseEntity.ok(Map.of("status", "confirmed", "teamId", result.teamId())); + case PENDING -> + ResponseEntity.status(HttpStatus.ACCEPTED).body(Map.of("status", "pending")); + case REJECTED -> ResponseEntity.badRequest().body(Map.of("error", "CONNECT_REJECTED")); + }; + } + + /** + * Source address for the creation cap. + * + *

Deliberately not reading {@code X-Forwarded-For}: the caller sets it, so keying a cap on + * it lets one rotate fake addresses and have no cap at all. {@code + * server.forward-headers-strategy} is NATIVE, so the container has already resolved the real + * client from trusted proxies. + */ + private static String clientIp(HttpServletRequest request) { + String remote = request.getRemoteAddr(); + return remote == null || remote.length() <= 45 ? remote : remote.substring(0, 45); + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/ConnectRequest.java b/app/saas/src/main/java/stirling/software/saas/accountlink/ConnectRequest.java new file mode 100644 index 0000000000..f9c204eef3 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/accountlink/ConnectRequest.java @@ -0,0 +1,103 @@ +package stirling.software.saas.accountlink; + +import java.time.LocalDateTime; + +import org.hibernate.annotations.CreationTimestamp; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** One in-flight "connect this server" handshake. Short lived and single use. */ +@Entity +@Table( + name = "account_link_connect_request", + indexes = @Index(name = "idx_alcr_ip_created", columnList = "requester_ip,created_at")) +@Getter +@Setter +@NoArgsConstructor +public class ConnectRequest { + + public enum Mode { + LINK, + REAUTH + } + + public enum Status { + PENDING, + APPROVED, + DENIED, + CONSUMED + } + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "request_id", nullable = false, unique = true, length = 64) + private String requestId; + + @Column(name = "name", length = 255) + private String name; + + /** + * Read back from here on approval, never from the request: that is what stops an open redirect. + */ + @Column(name = "callback_url", nullable = false, length = 2048) + private String callbackUrl; + + @Column(name = "callback_origin", nullable = false, length = 255) + private String callbackOrigin; + + @Column(name = "nonce", nullable = false, length = 128) + private String nonce; + + /** SHA-256; the secret itself is never stored. */ + @Column(name = "claim_secret_hash", nullable = false, length = 64) + private String claimSecretHash; + + @Enumerated(EnumType.STRING) + @Column(name = "mode", nullable = false, length = 16) + private Mode mode = Mode.LINK; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 16) + private Status status = Status.PENDING; + + /** LINK: set on approval. REAUTH: pinned at creation, so approval can only confirm it. */ + @Column(name = "team_id") + private Long teamId; + + @Column(name = "approved_by_user_id") + private Long approvedByUserId; + + @Column(name = "requester_ip", length = 45) + private String requesterIp; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "expires_at", nullable = false) + private LocalDateTime expiresAt; + + @Column(name = "approved_at") + private LocalDateTime approvedAt; + + @Column(name = "consumed_at") + private LocalDateTime consumedAt; + + public boolean isExpired(LocalDateTime now) { + return expiresAt != null && expiresAt.isBefore(now); + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/ConnectRequestCleanupService.java b/app/saas/src/main/java/stirling/software/saas/accountlink/ConnectRequestCleanupService.java new file mode 100644 index 0000000000..7712ee297a --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/accountlink/ConnectRequestCleanupService.java @@ -0,0 +1,47 @@ +package stirling.software.saas.accountlink; + +import java.time.LocalDateTime; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Profile; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Removes connect requests that are past use. + * + *

Needed rather than merely tidy: anyone can create a row on {@code POST /connect/request}, and + * nothing else deletes one. Requests hold a callback URL and the requester's address, so they are + * swept soon after expiry rather than kept. + */ +@Slf4j +@Service +@Profile("saas") +@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true") +@RequiredArgsConstructor +public class ConnectRequestCleanupService { + + /** Long enough to answer "what happened to my link?" the next morning, and no longer. */ + private static final int RETAIN_HOURS = 24; + + private final ConnectRequestRepository repo; + + @Scheduled(cron = "0 30 3 * * *") + @Transactional + public void purgeExpired() { + try { + LocalDateTime cutoff = LocalDateTime.now().minusHours(RETAIN_HOURS); + int deleted = repo.deleteByExpiresAtBefore(cutoff); + if (deleted > 0) { + log.info("Account-link connect: purged {} expired requests", deleted); + } + } catch (Exception e) { + // A failed sweep must not take the scheduler down; the next run retries. + log.error("Account-link connect: purge failed", e); + } + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/ConnectRequestRepository.java b/app/saas/src/main/java/stirling/software/saas/accountlink/ConnectRequestRepository.java new file mode 100644 index 0000000000..6c5dec098b --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/accountlink/ConnectRequestRepository.java @@ -0,0 +1,28 @@ +package stirling.software.saas.accountlink; + +import java.time.LocalDateTime; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import jakarta.persistence.LockModeType; + +/** Data access for {@link ConnectRequest}. */ +public interface ConnectRequestRepository extends JpaRepository { + + Optional findByRequestId(String requestId); + + /** Row-locking read used by approve, deny and claim. */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT r FROM ConnectRequest r WHERE r.requestId = :requestId") + Optional findByRequestIdForUpdate(@Param("requestId") String requestId); + + /** Backs the per-IP creation cap, since creating a request needs no authentication. */ + long countByRequesterIpAndCreatedAtAfter(String requesterIp, LocalDateTime after); + + /** Sweeps rows past use, whatever they settled as. Anyone can create these. */ + int deleteByExpiresAtBefore(LocalDateTime cutoff); +} diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/ConnectRequestService.java b/app/saas/src/main/java/stirling/software/saas/accountlink/ConnectRequestService.java new file mode 100644 index 0000000000..64abc12fa6 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/accountlink/ConnectRequestService.java @@ -0,0 +1,391 @@ +package stirling.software.saas.accountlink; + +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.time.LocalDateTime; +import java.util.Base64; +import java.util.Locale; +import java.util.Optional; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.extern.slf4j.Slf4j; + +/** The "connect this server" handshake, SaaS side. */ +@Slf4j +@Service +@Profile("saas") +@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true") +public class ConnectRequestService { + + /** + * Long enough for the approver to sign in, pick the right account and read the origin. Sized + * for the slowest real route: signing up, waiting for a confirmation email, and coming back. + */ + static final int LIFETIME_MINUTES = 30; + + /** Creating a request needs no authentication, so the only brake is per-source volume. */ + static final int MAX_REQUESTS_PER_IP = 10; + + private static final int REQUEST_ID_BYTES = 32; + private static final int MAX_NONCE_LENGTH = 128; + private static final int MAX_CALLBACK_LENGTH = 2048; + private static final int MAX_NAME_LENGTH = 255; + + private final ConnectRequestRepository repo; + private final AccountLinkService accountLinkService; + private final SecureRandom random = new SecureRandom(); + + public ConnectRequestService( + ConnectRequestRepository repo, AccountLinkService accountLinkService) { + this.repo = repo; + this.accountLinkService = accountLinkService; + } + + /** Rejected creation attempts, so the controller can pick a status without parsing messages. */ + public enum CreateRejection { + BAD_CALLBACK, + BAD_NONCE, + BAD_SECRET, + RATE_LIMITED, + /** + * A re-authentication was asked for by something that could not prove it is a linked + * instance. + */ + NOT_LINKED + } + + /** Either a created request id, or the reason we would not create one. */ + public record CreateResult(String requestId, int expiresInSeconds, CreateRejection rejection) { + static CreateResult ok(String requestId, int expiresInSeconds) { + return new CreateResult(requestId, expiresInSeconds, null); + } + + static CreateResult rejected(CreateRejection rejection) { + return new CreateResult(null, 0, rejection); + } + + public boolean isRejected() { + return rejection != null; + } + } + + /** What the approval page shows. */ + public record ConnectView( + String requestId, + String name, + String callbackOrigin, + boolean insecureTransport, + ConnectRequest.Mode mode, + ConnectRequest.Status status) {} + + /** Where to send the browser once approved, plus the correlator the instance is expecting. */ + public record ApprovalTarget(String callbackUrl, String nonce) {} + + public enum ClaimOutcome { + /** Approved and collected; {@code credential} is populated. */ + GRANTED, + /** A re-authentication was approved. */ + CONFIRMED, + /** Still waiting on a human. */ + PENDING, + /** Declined, expired, unknown, already collected, or a bad claim secret. */ + REJECTED + } + + public record ClaimResult( + ClaimOutcome outcome, String deviceId, String deviceSecret, Long teamId) { + static ClaimResult of(ClaimOutcome outcome) { + return new ClaimResult(outcome, null, null, null); + } + } + + /** Records a handshake on behalf of an instance that has no credential yet. */ + @Transactional + public CreateResult create( + String name, String callbackUrl, String nonce, String claimSecret, String requesterIp) { + return create(name, callbackUrl, nonce, claimSecret, requesterIp, null); + } + + /** + * As {@link #create}, but for an instance that is already linked and only needs its admin's + * browser signed in again. + */ + @Transactional + public CreateResult createReauth( + String name, + String callbackUrl, + String nonce, + String claimSecret, + String requesterIp, + Long pinnedTeamId) { + if (pinnedTeamId == null) { + return CreateResult.rejected(CreateRejection.NOT_LINKED); + } + return create(name, callbackUrl, nonce, claimSecret, requesterIp, pinnedTeamId); + } + + private CreateResult create( + String name, + String callbackUrl, + String nonce, + String claimSecret, + String requesterIp, + Long pinnedTeamId) { + if (nonce == null || nonce.isBlank() || nonce.length() > MAX_NONCE_LENGTH) { + return CreateResult.rejected(CreateRejection.BAD_NONCE); + } + if (claimSecret == null || claimSecret.isBlank()) { + return CreateResult.rejected(CreateRejection.BAD_SECRET); + } + Optional parsed = validateCallback(callbackUrl); + if (parsed.isEmpty()) { + return CreateResult.rejected(CreateRejection.BAD_CALLBACK); + } + LocalDateTime now = LocalDateTime.now(); + if (requesterIp != null + && repo.countByRequesterIpAndCreatedAtAfter(requesterIp, now.minusHours(1)) + >= MAX_REQUESTS_PER_IP) { + return CreateResult.rejected(CreateRejection.RATE_LIMITED); + } + + URI uri = parsed.get(); + ConnectRequest request = new ConnectRequest(); + request.setRequestId(randomToken()); + request.setName(trim(name, MAX_NAME_LENGTH)); + request.setCallbackUrl(uri.toString()); + request.setCallbackOrigin(originOf(uri)); + request.setNonce(nonce); + request.setClaimSecretHash(sha256Hex(claimSecret)); + request.setStatus(ConnectRequest.Status.PENDING); + request.setMode( + pinnedTeamId == null ? ConnectRequest.Mode.LINK : ConnectRequest.Mode.REAUTH); + request.setTeamId(pinnedTeamId); + request.setRequesterIp(requesterIp); + request.setExpiresAt(now.plusMinutes(LIFETIME_MINUTES)); + repo.save(request); + + // Never log the nonce or the claim secret; both are live. The request id is the safe + // handle for correlating a support request against this row. + log.info( + "Account-link connect: request {} created for origin {}", + request.getRequestId(), + request.getCallbackOrigin()); + return CreateResult.ok(request.getRequestId(), LIFETIME_MINUTES * 60); + } + + /** The approver's view of a handshake. */ + @Transactional(readOnly = true) + public Optional lookup(String requestId) { + return repo.findByRequestId(requestId) + .filter(r -> !r.isExpired(LocalDateTime.now())) + .map( + r -> + new ConnectView( + r.getRequestId(), + r.getName(), + r.getCallbackOrigin(), + !"https".equals(schemeOf(r.getCallbackOrigin())), + r.getMode(), + r.getStatus())); + } + + /** Why an approval was refused, so the page can say something useful. */ + public enum ApproveRejection { + /** Unknown, expired, or already settled. */ + UNAVAILABLE, + /** The approver's team is not the team this server already belongs to. */ + WRONG_TEAM + } + + public record ApproveResult(ApprovalTarget target, ApproveRejection rejection) { + public boolean isRejected() { + return target == null; + } + } + + /** Binds a pending handshake to the approver's team and returns where to send them next. */ + @Transactional + public ApproveResult approve(String requestId, Long teamId, Long userId) { + Optional found = repo.findByRequestIdForUpdate(requestId); + if (found.isEmpty()) { + return new ApproveResult(null, ApproveRejection.UNAVAILABLE); + } + ConnectRequest request = found.get(); + LocalDateTime now = LocalDateTime.now(); + if (request.isExpired(now) || request.getStatus() != ConnectRequest.Status.PENDING) { + return new ApproveResult(null, ApproveRejection.UNAVAILABLE); + } + Long pinned = request.getTeamId(); + if (pinned != null && !pinned.equals(teamId)) { + log.warn( + "Account-link connect: request {} approved by team {} but is pinned to team {};" + + " refusing", + requestId, + teamId, + pinned); + return new ApproveResult(null, ApproveRejection.WRONG_TEAM); + } + request.setStatus(ConnectRequest.Status.APPROVED); + request.setTeamId(teamId); + request.setApprovedByUserId(userId); + request.setApprovedAt(now); + repo.save(request); + log.info( + "Account-link connect: request {} approved for team {} ({})", + requestId, + teamId, + request.getMode()); + return new ApproveResult( + new ApprovalTarget(request.getCallbackUrl(), request.getNonce()), null); + } + + /** Declines a pending handshake. */ + @Transactional + public boolean deny(String requestId) { + Optional found = repo.findByRequestIdForUpdate(requestId); + if (found.isEmpty()) { + return false; + } + ConnectRequest request = found.get(); + if (request.getStatus() != ConnectRequest.Status.PENDING) { + return false; + } + request.setStatus(ConnectRequest.Status.DENIED); + repo.save(request); + log.info("Account-link connect: request {} denied", requestId); + return true; + } + + /** Collects the device credential for an approved handshake. */ + @Transactional + public ClaimResult claim(String requestId, String claimSecret) { + if (requestId == null || claimSecret == null) { + return ClaimResult.of(ClaimOutcome.REJECTED); + } + Optional found = repo.findByRequestIdForUpdate(requestId); + if (found.isEmpty()) { + return ClaimResult.of(ClaimOutcome.REJECTED); + } + ConnectRequest request = found.get(); + if (!secretMatches(claimSecret, request.getClaimSecretHash())) { + // Same answer as an unknown id: a caller probing ids learns nothing from the + // difference. + log.warn("Account-link connect: claim for request {} had a bad secret", requestId); + return ClaimResult.of(ClaimOutcome.REJECTED); + } + if (request.isExpired(LocalDateTime.now())) { + return ClaimResult.of(ClaimOutcome.REJECTED); + } + return switch (request.getStatus()) { + case PENDING -> ClaimResult.of(ClaimOutcome.PENDING); + case APPROVED -> mint(request); + case DENIED, CONSUMED -> ClaimResult.of(ClaimOutcome.REJECTED); + }; + } + + /** Settles an approved handshake. */ + private ClaimResult mint(ConnectRequest request) { + if (request.getMode() == ConnectRequest.Mode.REAUTH) { + request.setStatus(ConnectRequest.Status.CONSUMED); + request.setConsumedAt(LocalDateTime.now()); + repo.save(request); + log.info( + "Account-link connect: request {} re-authenticated for team {}", + request.getRequestId(), + request.getTeamId()); + return new ClaimResult(ClaimOutcome.CONFIRMED, null, null, request.getTeamId()); + } + AccountLinkService.RegisteredInstance registered = + accountLinkService.register( + request.getTeamId(), request.getApprovedByUserId(), request.getName()); + request.setStatus(ConnectRequest.Status.CONSUMED); + request.setConsumedAt(LocalDateTime.now()); + repo.save(request); + log.info( + "Account-link connect: request {} claimed, instance {} bound to team {}", + request.getRequestId(), + registered.instanceId(), + request.getTeamId()); + return new ClaimResult( + ClaimOutcome.GRANTED, + registered.deviceId(), + registered.deviceSecret(), + request.getTeamId()); + } + + /** Absolute http(s) URL, with a host, no credentials and no fragment of its own. */ + static Optional validateCallback(String candidate) { + if (candidate == null || candidate.isBlank() || candidate.length() > MAX_CALLBACK_LENGTH) { + return Optional.empty(); + } + URI uri; + try { + uri = new URI(candidate.strip()); + } catch (URISyntaxException e) { + return Optional.empty(); + } + if (!uri.isAbsolute() || uri.getScheme() == null) { + return Optional.empty(); + } + String scheme = uri.getScheme().toLowerCase(Locale.ROOT); + if (!"http".equals(scheme) && !"https".equals(scheme)) { + return Optional.empty(); + } + if (uri.getHost() == null || uri.getHost().isBlank()) { + return Optional.empty(); + } + if (uri.getUserInfo() != null || uri.getFragment() != null) { + return Optional.empty(); + } + return Optional.of(uri); + } + + /** Scheme, host and port, with the default port omitted so origins compare cleanly. */ + static String originOf(URI uri) { + String scheme = uri.getScheme().toLowerCase(Locale.ROOT); + return scheme + "://" + Origins.hostPort(scheme, uri.getHost(), uri.getPort()); + } + + private static String schemeOf(String origin) { + int sep = origin.indexOf("://"); + return sep < 0 ? "" : origin.substring(0, sep); + } + + private static String trim(String value, int max) { + if (value == null) { + return null; + } + String stripped = value.strip(); + if (stripped.isEmpty()) { + return null; + } + return stripped.length() <= max ? stripped : stripped.substring(0, max); + } + + private String randomToken() { + byte[] buf = new byte[REQUEST_ID_BYTES]; + random.nextBytes(buf); + return Base64.getUrlEncoder().withoutPadding().encodeToString(buf); + } + + /** Constant-time comparison so a claim cannot be brute-forced a byte at a time. */ + private static boolean secretMatches(String candidate, String expectedHash) { + if (expectedHash == null) { + return false; + } + return MessageDigest.isEqual( + sha256Hex(candidate).getBytes(StandardCharsets.UTF_8), + expectedHash.getBytes(StandardCharsets.UTF_8)); + } + + private static String sha256Hex(String value) { + return AccountLinkService.sha256Hex(value); + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/DeviceCredentialAuthenticationFilter.java b/app/saas/src/main/java/stirling/software/saas/accountlink/DeviceCredentialAuthenticationFilter.java index a2fd13a095..17cb340d60 100644 --- a/app/saas/src/main/java/stirling/software/saas/accountlink/DeviceCredentialAuthenticationFilter.java +++ b/app/saas/src/main/java/stirling/software/saas/accountlink/DeviceCredentialAuthenticationFilter.java @@ -19,7 +19,7 @@ import jakarta.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; /** - * Authenticates a linked self-hosted instance by its device credential (combined-billing "Mode A"). + * Authenticates a linked self-hosted instance by its device credential (combined billing). * *

Reads {@code X-Device-Id} + {@code X-Device-Secret}, looks up the active {@link * LinkedInstance}, and constant-time compares the SHA-256 of the presented secret against the diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/InstanceController.java b/app/saas/src/main/java/stirling/software/saas/accountlink/InstanceController.java index 7c98d5e59b..9c313a1dfa 100644 --- a/app/saas/src/main/java/stirling/software/saas/accountlink/InstanceController.java +++ b/app/saas/src/main/java/stirling/software/saas/accountlink/InstanceController.java @@ -32,9 +32,9 @@ import stirling.software.saas.payg.policy.PricingPolicy; import stirling.software.saas.payg.policy.PricingPolicyService; /** - * Instance-facing surface (combined-billing "Mode A"), authenticated by the device - * credential — not a user JWT. Separate path prefix ({@code /api/v1/instance/**}) so the device - * credential is scoped here and nowhere else. + * Instance-facing surface (combined billing), authenticated by the device credential — not a + * user JWT. Separate path prefix ({@code /api/v1/instance/**}) so the device credential is scoped + * here and nowhere else. * *

{@code GET /whoami} is the MVP round-trip proof: a registered instance presenting a valid * device credential gets back its resolved {@code instanceId} + {@code teamId}. {@code GET diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/LeaderTeamResolver.java b/app/saas/src/main/java/stirling/software/saas/accountlink/LeaderTeamResolver.java new file mode 100644 index 0000000000..7118562a28 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/accountlink/LeaderTeamResolver.java @@ -0,0 +1,68 @@ +package stirling.software.saas.accountlink; + +import java.util.List; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Profile; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Component; + +import stirling.software.common.model.enumeration.TeamRole; +import stirling.software.proprietary.model.TeamMembership; +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; +import stirling.software.saas.util.AuthenticationUtils; + +/** Who is allowed to bind a self-hosted instance to a team. */ +@Component +@Profile("saas") +@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true") +public class LeaderTeamResolver { + + private final TeamMembershipRepository memberRepo; + private final UserRepository userRepository; + + public LeaderTeamResolver(TeamMembershipRepository memberRepo, UserRepository userRepository) { + this.memberRepo = memberRepo; + this.userRepository = userRepository; + } + + /** + * Resolved caller, or an {@code error} status to return ({@code teamId}/{@code userId} null). + */ + public record LeaderTeam(Long teamId, Long userId, HttpStatus error) { + public boolean isError() { + return error != null; + } + } + + /** Caller must lead their team. */ + public LeaderTeam resolve(Authentication auth) { + return resolve(auth, true); + } + + /** Caller need only belong to a team. */ + public LeaderTeam resolveMember(Authentication auth) { + return resolve(auth, false); + } + + private LeaderTeam resolve(Authentication auth, boolean requireLeader) { + User user; + try { + user = AuthenticationUtils.getCurrentUser(auth, userRepository); + } catch (SecurityException e) { + return new LeaderTeam(null, null, HttpStatus.UNAUTHORIZED); + } + List rows = memberRepo.findPrimaryMembership(user.getId()); + if (rows.isEmpty()) { + return new LeaderTeam(null, null, HttpStatus.FORBIDDEN); + } + TeamMembership membership = rows.getFirst(); + if (requireLeader && membership.getRole() != TeamRole.LEADER) { + return new LeaderTeam(null, null, HttpStatus.FORBIDDEN); + } + return new LeaderTeam(membership.getTeam().getId(), user.getId(), null); + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/LinkedInstance.java b/app/saas/src/main/java/stirling/software/saas/accountlink/LinkedInstance.java index ec92c97758..f460e82d5b 100644 --- a/app/saas/src/main/java/stirling/software/saas/accountlink/LinkedInstance.java +++ b/app/saas/src/main/java/stirling/software/saas/accountlink/LinkedInstance.java @@ -16,7 +16,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; /** - * One self-hosted instance that has linked a SaaS account (combined-billing "Mode A", {@code + * One self-hosted instance that has linked a SaaS account (combined billing, {@code * linked_instance}, V22). * *

Created by {@code POST /api/v1/account-link/register}, authenticated with the admin's diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/LinkedInstanceAuthenticationToken.java b/app/saas/src/main/java/stirling/software/saas/accountlink/LinkedInstanceAuthenticationToken.java index af883bd66a..e393a162db 100644 --- a/app/saas/src/main/java/stirling/software/saas/accountlink/LinkedInstanceAuthenticationToken.java +++ b/app/saas/src/main/java/stirling/software/saas/accountlink/LinkedInstanceAuthenticationToken.java @@ -6,7 +6,7 @@ import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; /** - * Authentication for a linked self-hosted instance (combined-billing "Mode A"). + * Authentication for a linked self-hosted instance (combined billing). * *

Deliberately not a user: the principal is the instance ({@code instanceId}) bound to * a {@code teamId}, with the single authority {@code ROLE_LINKED_INSTANCE}. It carries no {@code diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/Origins.java b/app/saas/src/main/java/stirling/software/saas/accountlink/Origins.java new file mode 100644 index 0000000000..629e0f7fc1 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/accountlink/Origins.java @@ -0,0 +1,22 @@ +package stirling.software.saas.accountlink; + +/** + * Origin formatting shared by the connect handshake. + * + *

One place on purpose: the origin a request arrives on and the origin parsed out of a callback + * URL are compared with each other, so if either side stopped omitting the default port the + * comparison would start failing quietly. + */ +final class Origins { + + private Origins() {} + + /** {@code host} or {@code host:port}, dropping a port that is the scheme's default. */ + static String hostPort(String scheme, String host, int port) { + boolean isDefault = + port <= 0 + || ("http".equals(scheme) && port == 80) + || ("https".equals(scheme) && port == 443); + return isDefault ? host : host + ":" + port; + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateController.java b/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateController.java index 28bbfbdea0..77a87a27bb 100644 --- a/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateController.java +++ b/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateController.java @@ -26,8 +26,8 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.tags.Tag; @@ -167,7 +167,7 @@ public class AiCreateController { if (request.constraints() != null) { try { constraintsPayload = objectMapper.writeValueAsString(request.constraints()); - } catch (JsonProcessingException exc) { + } catch (JacksonException exc) { throw new ResponseStatusException( HttpStatus.BAD_REQUEST, "Invalid constraints payload", exc); } @@ -202,7 +202,7 @@ public class AiCreateController { String payload; try { payload = objectMapper.writeValueAsString(request.draftSections()); - } catch (JsonProcessingException exc) { + } catch (JacksonException exc) { throw new ResponseStatusException( HttpStatus.BAD_REQUEST, "Invalid draft sections payload", exc); } @@ -392,7 +392,7 @@ public class AiCreateController { objectMapper .getTypeFactory() .constructCollectionType(List.class, DraftSection.class)); - } catch (JsonProcessingException exc) { + } catch (JacksonException exc) { log.warn("Failed to parse draft sections payload", exc); return null; } @@ -408,7 +408,7 @@ public class AiCreateController { objectMapper .getTypeFactory() .constructMapType(Map.class, String.class, Object.class)); - } catch (JsonProcessingException exc) { + } catch (JacksonException exc) { log.warn("Failed to parse outline constraints payload", exc); return null; } diff --git a/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateInternalController.java b/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateInternalController.java index 60c8dc4615..04b8302e7e 100644 --- a/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateInternalController.java +++ b/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateInternalController.java @@ -14,8 +14,8 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.tags.Tag; @@ -61,7 +61,7 @@ public class AiCreateInternalController { try { outlineConstraintsPayload = objectMapper.writeValueAsString(request.outlineConstraints()); - } catch (JsonProcessingException exc) { + } catch (JacksonException exc) { throw new ResponseStatusException( HttpStatus.BAD_REQUEST, "Invalid outline constraints payload", exc); } @@ -70,7 +70,7 @@ public class AiCreateInternalController { if (request.draftSections() != null) { try { draftSectionsPayload = objectMapper.writeValueAsString(request.draftSections()); - } catch (JsonProcessingException exc) { + } catch (JacksonException exc) { throw new ResponseStatusException( HttpStatus.BAD_REQUEST, "Invalid draft sections payload", exc); } @@ -136,7 +136,7 @@ public class AiCreateInternalController { .getTypeFactory() .constructCollectionType( List.class, AiCreateController.DraftSection.class)); - } catch (JsonProcessingException exc) { + } catch (JacksonException exc) { log.warn("Failed to parse draft sections payload", exc); return null; } @@ -152,7 +152,7 @@ public class AiCreateInternalController { objectMapper .getTypeFactory() .constructMapType(Map.class, String.class, Object.class)); - } catch (JsonProcessingException exc) { + } catch (JacksonException exc) { log.warn("Failed to parse outline constraints payload", exc); return null; } diff --git a/app/saas/src/main/java/stirling/software/saas/billing/service/StripeUsageReportingService.java b/app/saas/src/main/java/stirling/software/saas/billing/service/StripeUsageReportingService.java index d9445483a3..ae5d3d943a 100644 --- a/app/saas/src/main/java/stirling/software/saas/billing/service/StripeUsageReportingService.java +++ b/app/saas/src/main/java/stirling/software/saas/billing/service/StripeUsageReportingService.java @@ -12,7 +12,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; diff --git a/app/saas/src/main/java/stirling/software/saas/config/SaasSchemaOwnership.java b/app/saas/src/main/java/stirling/software/saas/config/SaasSchemaOwnership.java index dffd1eccf1..7853c56856 100644 --- a/app/saas/src/main/java/stirling/software/saas/config/SaasSchemaOwnership.java +++ b/app/saas/src/main/java/stirling/software/saas/config/SaasSchemaOwnership.java @@ -41,6 +41,7 @@ public final class SaasSchemaOwnership { */ public static final Set MIGRATION_OWNED = Set.of( + "account_link_connect_request", "ai_create_sessions", "audit_events", "authorities", @@ -78,6 +79,7 @@ public final class SaasSchemaOwnership { */ public static final Set HIBERNATE_MANAGED = Set.of( + "account_link_connect_state", "account_link_device_credential", "account_link_metered_signature", "account_link_sync_state", diff --git a/app/saas/src/main/java/stirling/software/saas/legal/LegalDocumentRegistry.java b/app/saas/src/main/java/stirling/software/saas/legal/LegalDocumentRegistry.java index ffbe030863..48dd6a1c3b 100644 --- a/app/saas/src/main/java/stirling/software/saas/legal/LegalDocumentRegistry.java +++ b/app/saas/src/main/java/stirling/software/saas/legal/LegalDocumentRegistry.java @@ -13,8 +13,8 @@ import java.util.regex.Pattern; import org.springframework.core.io.ClassPathResource; import org.springframework.stereotype.Service; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; import jakarta.annotation.PostConstruct; @@ -56,28 +56,26 @@ public class LegalDocumentRegistry { subprocessorUrl = root.path("subprocessorUrl").asText(""); eulaUrl = root.path("eulaUrl").asText(""); JsonNode docs = root.path("documents"); - docs.fieldNames() - .forEachRemaining( - id -> { - JsonNode d = docs.get(id); - List parts = - objectMapper.convertValue( - d.path("parts"), - objectMapper - .getTypeFactory() - .constructCollectionType( - List.class, String.class)); - documents.put( + docs.forEachEntry( + (id, d) -> { + List parts = + objectMapper.convertValue( + d.path("parts"), + objectMapper + .getTypeFactory() + .constructCollectionType( + List.class, String.class)); + documents.put( + id, + new LegalDocumentMeta( id, - new LegalDocumentMeta( - id, - d.path("label").asText(id), - d.path("displayName").asText(id), - d.path("version").asText("0"), - d.path("effectiveDate").asText(""), - d.path("status").asText("draft"), - parts == null ? List.of() : parts)); - }); + d.path("label").asText(id), + d.path("displayName").asText(id), + d.path("version").asText("0"), + d.path("effectiveDate").asText(""), + d.path("status").asText("draft"), + parts == null ? List.of() : parts)); + }); log.info("[legal] loaded {} document(s) from {}", documents.size(), MANIFEST); } diff --git a/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementGuard.java b/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementGuard.java index ae8474fa42..441ebf1220 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementGuard.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementGuard.java @@ -20,7 +20,7 @@ import org.springframework.stereotype.Component; import org.springframework.web.method.HandlerMethod; import org.springframework.web.servlet.HandlerInterceptor; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.ObjectMapper; import io.micrometer.core.instrument.Counter; import io.micrometer.core.instrument.MeterRegistry; diff --git a/app/saas/src/main/java/stirling/software/saas/payg/instance/InstanceUsageIngestService.java b/app/saas/src/main/java/stirling/software/saas/payg/instance/InstanceUsageIngestService.java index c672540870..67e9c581d7 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/instance/InstanceUsageIngestService.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/instance/InstanceUsageIngestService.java @@ -18,12 +18,12 @@ import stirling.software.saas.payg.model.ProcessType; import stirling.software.saas.payg.repository.PaygInstanceUsageRepository; /** - * Ingests a linked instance's daily usage sync (combined-billing "Mode A"). The instance reports a - * monotonic cumulative unit total per {@link BillingCategory}; we bill only the delta since the - * last sync via {@link JobChargeService#chargeStandalone} (reusing the in-cloud free-grant split, - * ledger DEBIT, Stripe meter and idempotency). Idempotent (a resend → delta 0 → no charge) and - * tamper-evident (a backwards total is refused; a monotonic {@code syncSeq} dedups replays). The - * cap is enforced at the instance gate, not here. Gated behind {@code account-link.enabled}. + * Ingests a linked instance's daily usage sync (combined billing). The instance reports a monotonic + * cumulative unit total per {@link BillingCategory}; we bill only the delta since the last sync via + * {@link JobChargeService#chargeStandalone} (reusing the in-cloud free-grant split, ledger DEBIT, + * Stripe meter and idempotency). Idempotent (a resend → delta 0 → no charge) and tamper-evident (a + * backwards total is refused; a monotonic {@code syncSeq} dedups replays). The cap is enforced at + * the instance gate, not here. Gated behind {@code account-link.enabled}. */ @Slf4j @Service diff --git a/app/saas/src/main/java/stirling/software/saas/payg/instance/PaygInstanceUsage.java b/app/saas/src/main/java/stirling/software/saas/payg/instance/PaygInstanceUsage.java index 45a6435746..345cf46b75 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/instance/PaygInstanceUsage.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/instance/PaygInstanceUsage.java @@ -19,9 +19,9 @@ import lombok.Setter; /** * Last-seen cumulative usage a linked self-hosted instance has reported for one {@code (team, - * billing period, category)} (combined-billing "Mode A"). The instance reports monotonic cumulative - * unit totals on its daily sync; SaaS bills {@code reportedCumulative - lastCumulativeUnits} via - * the standard charge path and advances this row. {@code lastSyncSeq} dedups replays. + * billing period, category)} (combined billing). The instance reports monotonic cumulative unit + * totals on its daily sync; SaaS bills {@code reportedCumulative - lastCumulativeUnits} via the + * standard charge path and advances this row. {@code lastSyncSeq} dedups replays. */ @Entity @Table( diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java b/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java index 1c495b3afd..c08778bdbc 100644 --- a/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java +++ b/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java @@ -18,7 +18,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.ObjectMapper; import io.swagger.v3.oas.annotations.Hidden; diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/legal/AgreementAssembler.java b/app/saas/src/main/java/stirling/software/saas/procurement/legal/AgreementAssembler.java index c641f2e596..e75f3cbb32 100644 --- a/app/saas/src/main/java/stirling/software/saas/procurement/legal/AgreementAssembler.java +++ b/app/saas/src/main/java/stirling/software/saas/procurement/legal/AgreementAssembler.java @@ -10,7 +10,7 @@ import java.util.Map; import org.springframework.stereotype.Service; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/license/KeygenEnterpriseLicenseService.java b/app/saas/src/main/java/stirling/software/saas/procurement/license/KeygenEnterpriseLicenseService.java index a11ad05755..a1c09a9cb9 100644 --- a/app/saas/src/main/java/stirling/software/saas/procurement/license/KeygenEnterpriseLicenseService.java +++ b/app/saas/src/main/java/stirling/software/saas/procurement/license/KeygenEnterpriseLicenseService.java @@ -16,8 +16,8 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java b/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java index a739e538b2..26bfb733dd 100644 --- a/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java +++ b/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java @@ -10,8 +10,8 @@ import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; @@ -707,7 +707,7 @@ public class ProcurementService { private String writeLineItems(QuoteBreakdown breakdown) { try { return OBJECT_MAPPER.writeValueAsString(breakdown.lineItems()); - } catch (JsonProcessingException e) { + } catch (JacksonException e) { log.warn("[procurement] failed to serialise line items", e); return "[]"; } diff --git a/app/saas/src/main/java/stirling/software/saas/security/SupabaseSecurityConfig.java b/app/saas/src/main/java/stirling/software/saas/security/SupabaseSecurityConfig.java index 36f3a4c06e..ea1c9bca9b 100644 --- a/app/saas/src/main/java/stirling/software/saas/security/SupabaseSecurityConfig.java +++ b/app/saas/src/main/java/stirling/software/saas/security/SupabaseSecurityConfig.java @@ -16,6 +16,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Profile; import org.springframework.core.annotation.Order; +import org.springframework.core.env.Environment; import org.springframework.http.HttpMethod; import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.config.Customizer; @@ -71,6 +72,7 @@ public class SupabaseSecurityConfig { private final SaasTeamService saasTeamService; private final ApplicationProperties applicationProperties; private final ApiKeyAuthenticationService apiKeyAuthenticationService; + private final Environment environment; @Value("${app.supabase.issuer:}") private String issuer; @@ -105,6 +107,17 @@ public class SupabaseSecurityConfig { .permitAll() .requestMatchers("/actuator/health", "/api/v1/config/**") .permitAll() + // Account-link connect handshake: an instance calls these + // before it holds any credential, so there is nothing to + // authenticate with yet. Neither grants anything on its + // own — /request records an intent a human must approve, + // and /claim requires a secret only the instance that + // created the request has ever held. + .requestMatchers( + HttpMethod.POST, + "/api/v1/account-link/connect/request", + "/api/v1/account-link/connect/claim") + .permitAll() .requestMatchers( req -> RequestUriUtils.isStaticResource( @@ -144,7 +157,7 @@ public class SupabaseSecurityConfig { SupabaseSecurityConfig ::toAuthentication))); - // Device-credential auth for linked self-hosted instances (combined-billing Mode A). + // Device-credential auth for linked self-hosted instances (combined billing). // The filter bean exists only when stirling.billing.account-link.enabled=true; when off it // is absent here, so the instance surface cannot authenticate at all until release. DeviceCredentialAuthenticationFilter deviceFilter = @@ -268,6 +281,28 @@ public class SupabaseSecurityConfig { } } + /** + * Loopback on any port, as Spring origin patterns. Only added outside production; see {@link + * #corsConfigurationSource()}. + */ + private static final List LOOPBACK_ANY_PORT = + List.of("http://localhost:[*]", "http://127.0.0.1:[*]"); + + /** + * Profiles that mean "a developer's machine or a preview environment", never the production + * deployment. Production runs the bare {@code saas} profile. + */ + private static final List NON_PRODUCTION_PROFILES = List.of("dev", "staging", "local"); + + private boolean isNonProduction() { + for (String profile : environment.getActiveProfiles()) { + if (NON_PRODUCTION_PROFILES.contains(profile)) { + return true; + } + } + return false; + } + @Bean CorsConfigurationSource corsConfigurationSource() { CorsConfiguration cfg = new CorsConfiguration(); @@ -297,7 +332,23 @@ public class SupabaseSecurityConfig { origins.add(desktopOrigin); } } - if (origins.stream().anyMatch(o -> o.contains("*"))) { + // Outside production, allow loopback on ANY port. Several dev servers run side by side + // (editor, saas web app, one per flavour under test) and their ports move, so pinning a + // list means every new local environment shows up as an opaque CORS failure. Unlike a + // wildcard subdomain, a wildcard port on loopback cannot be taken over: nothing but this + // machine can answer on it, so there is no lapsed-DNS or abandoned-vhost risk. Absent in + // production, where the profile check below is false. + if (!operatorOverride && isNonProduction()) { + origins.addAll(LOOPBACK_ANY_PORT); + log.info( + "Non-production profile active: allowing loopback CORS origins on any port {}", + LOOPBACK_ANY_PORT); + } + // Loopback port wildcards are exempt: the warning below is about hostname takeover, which + // does not apply to an origin only this machine can serve. + if (origins.stream() + .filter(o -> !LOOPBACK_ANY_PORT.contains(o)) + .anyMatch(o -> o.contains("*"))) { log.warn( "CORS origins contain a wildcard paired with allowCredentials=true: {}." + " Wildcard subdomains can be taken over by an attacker (lapsed DNS," diff --git a/app/saas/src/main/java/stirling/software/saas/service/RateLimitService.java b/app/saas/src/main/java/stirling/software/saas/service/RateLimitService.java index 775c5862ae..8a006a6934 100644 --- a/app/saas/src/main/java/stirling/software/saas/service/RateLimitService.java +++ b/app/saas/src/main/java/stirling/software/saas/service/RateLimitService.java @@ -113,19 +113,13 @@ public class RateLimitService { public void cleanupExpiredBuckets() { long now = System.currentTimeMillis(); - int hourlyRemoved = - (int) - hourlyLimits.entrySet().stream() - .filter(e -> e.getValue().getResetTime() < now) - .peek(e -> hourlyLimits.remove(e.getKey())) - .count(); + int hourlyBefore = hourlyLimits.size(); + hourlyLimits.entrySet().removeIf(e -> e.getValue().getResetTime() < now); + int hourlyRemoved = hourlyBefore - hourlyLimits.size(); - int dailyRemoved = - (int) - dailyLimits.entrySet().stream() - .filter(e -> e.getValue().getResetTime() < now) - .peek(e -> dailyLimits.remove(e.getKey())) - .count(); + int dailyBefore = dailyLimits.size(); + dailyLimits.entrySet().removeIf(e -> e.getValue().getResetTime() < now); + int dailyRemoved = dailyBefore - dailyLimits.size(); if (hourlyRemoved + dailyRemoved > 0) { log.debug( diff --git a/app/saas/src/main/java/stirling/software/saas/service/SaasTeamService.java b/app/saas/src/main/java/stirling/software/saas/service/SaasTeamService.java index 0038cca42a..d81e0d9ee7 100644 --- a/app/saas/src/main/java/stirling/software/saas/service/SaasTeamService.java +++ b/app/saas/src/main/java/stirling/software/saas/service/SaasTeamService.java @@ -519,8 +519,8 @@ public class SaasTeamService { * membership and its wallet) rather than deleting it, so a plain team is never orphaned. The * only real hazard is a team the user is the last leader of that still carries live * billing: an active paid/PAYG subscription, or a non-revoked linked self-hosted instance - * ("Mode A"). Those block the join until the plan is cancelled / leadership transferred / - * instances revoked. An unpaid, unlinked team (personal or shared) no longer blocks. + * (combined billing). Those block the join until the plan is cancelled / leadership transferred + * / instances revoked. An unpaid, unlinked team (personal or shared) no longer blocks. * *

The home team and the team being joined are excluded: neither is left by the join (home is * parked, the joined team is kept), so their live billing cannot be stranded. diff --git a/app/saas/src/test/java/stirling/software/saas/accountlink/AccountLinkControllerTest.java b/app/saas/src/test/java/stirling/software/saas/accountlink/AccountLinkControllerTest.java index e12888edb3..b9c6f22305 100644 --- a/app/saas/src/test/java/stirling/software/saas/accountlink/AccountLinkControllerTest.java +++ b/app/saas/src/test/java/stirling/software/saas/accountlink/AccountLinkControllerTest.java @@ -1,6 +1,7 @@ package stirling.software.saas.accountlink; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @@ -23,8 +24,7 @@ import stirling.software.proprietary.model.TeamMembership; import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.repository.TeamMembershipRepository; -import stirling.software.saas.accountlink.AccountLinkController.RegisterRequest; -import stirling.software.saas.accountlink.AccountLinkController.RegisterResponse; +import stirling.software.saas.accountlink.AccountLinkController.InstanceRow; import stirling.software.saas.util.AuthenticationUtils; /** @@ -44,20 +44,27 @@ class AccountLinkControllerTest { @BeforeEach void setUp() { - controller = new AccountLinkController(service, memberRepo, userRepository); + // Real resolver over the mocked repositories: the leader ladder moved into + // LeaderTeamResolver, and these tests are still asserting that ladder's behaviour + // through the controller. + controller = + new AccountLinkController( + service, new LeaderTeamResolver(memberRepo, userRepository)); auth = new AnonymousAuthenticationToken( "k", "anonymousUser", List.of(new SimpleGrantedAuthority("ROLE_USER"))); } + // The leader ladder used to be asserted through POST /register, which has been removed along + // with the JWT relay. It is exercised through /instances instead: same resolver, same rungs. + @Test - void register_unauthenticated_returns401() { + void list_unauthenticated_returns401() { try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) { mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository)) .thenThrow(new SecurityException("not authenticated")); - ResponseEntity resp = - controller.register(new RegisterRequest("host"), auth); + ResponseEntity> resp = controller.list(auth); assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); verifyNoInteractions(service); @@ -65,14 +72,14 @@ class AccountLinkControllerTest { } @Test - void register_noMembership_returns403() { + void list_noMembership_returns403() { User user = mockUser(42L); try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) { mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository)) .thenReturn(user); when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of()); - ResponseEntity resp = controller.register(null, auth); + ResponseEntity> resp = controller.list(auth); assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); verifyNoInteractions(service); @@ -80,7 +87,7 @@ class AccountLinkControllerTest { } @Test - void register_nonLeader_returns403() { + void list_nonLeader_returns403() { User user = mockUser(42L); TeamMembership member = membership(7L, TeamRole.MEMBER); try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) { @@ -88,7 +95,7 @@ class AccountLinkControllerTest { .thenReturn(user); when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of(member)); - ResponseEntity resp = controller.register(null, auth); + ResponseEntity> resp = controller.list(auth); assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); verifyNoInteractions(service); @@ -96,27 +103,20 @@ class AccountLinkControllerTest { } @Test - void register_leader_mintsCredentialForCallerTeam() { + void list_leader_readsOnlyTheCallersTeam() { User user = mockUser(42L); TeamMembership leader = membership(7L, TeamRole.LEADER); - when(service.register(7L, 42L, "host")) - .thenReturn( - new AccountLinkService.RegisteredInstance(99L, "dev-x", "sec-x", "host")); + when(service.list(7L)).thenReturn(List.of()); try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) { mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository)) .thenReturn(user); when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of(leader)); - ResponseEntity resp = - controller.register(new RegisterRequest("host"), auth); + ResponseEntity> resp = controller.list(auth); - assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.CREATED); - RegisterResponse body = resp.getBody(); - assertThat(body).isNotNull(); - // Team comes from the caller's membership and is surfaced in the response. - assertThat(body.teamId()).isEqualTo(7L); - assertThat(body.instanceId()).isEqualTo(99L); - assertThat(body.deviceSecret()).isEqualTo("sec-x"); + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); + // The team comes from the caller's membership, never from the request. + verify(service).list(7L); } } diff --git a/app/saas/src/test/java/stirling/software/saas/accountlink/ConnectControllerTest.java b/app/saas/src/test/java/stirling/software/saas/accountlink/ConnectControllerTest.java new file mode 100644 index 0000000000..d58c4c4098 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/accountlink/ConnectControllerTest.java @@ -0,0 +1,129 @@ +package stirling.software.saas.accountlink; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.mock.web.MockHttpServletRequest; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.saas.accountlink.ConnectController.CreateBody; +import stirling.software.saas.accountlink.ConnectController.CreateResponse; + +/** + * The authorize URL the instance is told to send its admin to. Everything else on this controller + * delegates; this is the only decision it makes on its own. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class ConnectControllerTest { + + private static final CreateBody BODY = + new CreateBody("prod-1", "https://pdf.example.com/account-link/callback", "n", "s"); + + @Mock private ConnectRequestService service; + @Mock private LeaderTeamResolver leaderTeams; + @Mock private AccountLinkService accountLinkService; + + private ApplicationProperties applicationProperties; + private ConnectController controller; + + @BeforeEach + void setUp() { + applicationProperties = new ApplicationProperties(); + controller = + new ConnectController( + service, leaderTeams, accountLinkService, applicationProperties); + when(service.create(anyString(), anyString(), anyString(), anyString(), any())) + .thenReturn(ConnectRequestService.CreateResult.ok("req-1", 1800)); + } + + private String authorizeUrl(MockHttpServletRequest request) { + Object body = controller.request(BODY, request).getBody(); + assertThat(body).isInstanceOf(CreateResponse.class); + return ((CreateResponse) body).authorizeUrl(); + } + + private static MockHttpServletRequest request(String scheme, String host, int port) { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setScheme(scheme); + request.setServerName(host); + request.setServerPort(port); + return request; + } + + @Test + void prefersTheConfiguredFrontendUrl() { + applicationProperties.getSystem().setFrontendUrl("https://app.example.com/app/"); + + // Trailing slash trimmed, base path kept, and the API's own origin ignored. + assertThat(authorizeUrl(request("https", "api.example.com", 443))) + .isEqualTo("https://app.example.com/app/link?request=req-1"); + } + + @Test + void fallsBackToTheOriginTheApiWasReachedOn() { + assertThat(authorizeUrl(request("https", "api.example.com", 443))) + .isEqualTo("https://api.example.com/link?request=req-1"); + } + + @Test + void keepsANonDefaultPortAndTheContextPath() { + MockHttpServletRequest request = request("http", "localhost", 8081); + request.setContextPath("/stirling"); + + assertThat(authorizeUrl(request)) + .isEqualTo("http://localhost:8081/stirling/link?request=req-1"); + } + + @Test + void honoursTheForwardedSchemeAndHost() { + MockHttpServletRequest request = request("http", "10.0.0.5", 8080); + request.addHeader("X-Forwarded-Proto", "https"); + request.addHeader("X-Forwarded-Host", "api.example.com"); + + assertThat(authorizeUrl(request)).isEqualTo("https://api.example.com/link?request=req-1"); + } + + @Test + void takesOnlyTheFirstForwardedHop() { + MockHttpServletRequest request = request("http", "10.0.0.5", 8080); + request.addHeader("X-Forwarded-Proto", "https, http"); + request.addHeader("X-Forwarded-Host", "api.example.com, evil.example.com"); + + assertThat(authorizeUrl(request)).isEqualTo("https://api.example.com/link?request=req-1"); + } + + @Test + void percentEncodesTheRequestId() { + when(service.create(anyString(), anyString(), anyString(), anyString(), any())) + .thenReturn(ConnectRequestService.CreateResult.ok("a b&c", 1800)); + + assertThat(authorizeUrl(request("https", "api.example.com", 443))) + .isEqualTo("https://api.example.com/link?request=a+b%26c"); + } + + @Test + void aBodylessRequestIsRejectedBeforeAnythingIsRecorded() { + assertThat(controller.request(null, request("https", "api.example.com", 443)).getBody()) + .isEqualTo(java.util.Map.of("error", "BAD_REQUEST")); + } + + @Test + void offeringNoCredentialTakesTheFirstLinkPath() { + authorizeUrl(request("https", "api.example.com", 443)); + + // createReauth is the credentialled path; a first link must not reach it. + org.mockito.Mockito.verify(service, org.mockito.Mockito.never()) + .createReauth(anyString(), anyString(), anyString(), anyString(), any(), isNull()); + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/accountlink/ConnectRequestServiceTest.java b/app/saas/src/test/java/stirling/software/saas/accountlink/ConnectRequestServiceTest.java new file mode 100644 index 0000000000..e15d9749f2 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/accountlink/ConnectRequestServiceTest.java @@ -0,0 +1,356 @@ +package stirling.software.saas.accountlink; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.time.LocalDateTime; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import stirling.software.saas.accountlink.ConnectRequestService.ClaimOutcome; +import stirling.software.saas.accountlink.ConnectRequestService.CreateRejection; + +/** + * Unit tests for the connect handshake's security properties, which are the reason this flow is + * safe rather than an open redirect: the callback is validated once and then read back from + * storage, the claim secret authenticates the collection, and one approval mints exactly one + * credential. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class ConnectRequestServiceTest { + + private static final String CALLBACK = "https://pdf.example.com/account-link/callback"; + private static final String NONCE = "nonce-value"; + private static final String CLAIM_SECRET = "claim-secret-value"; + + @Mock private ConnectRequestRepository repo; + @Mock private AccountLinkService accountLinkService; + + private ConnectRequestService service; + + @BeforeEach + void setUp() { + service = new ConnectRequestService(repo, accountLinkService); + } + + @Test + void create_storesTheValidatedCallbackAndItsOrigin() { + ConnectRequestService.CreateResult result = + service.create("prod-1", CALLBACK, NONCE, CLAIM_SECRET, "10.0.0.1"); + + assertThat(result.isRejected()).isFalse(); + assertThat(result.requestId()).isNotBlank(); + + ArgumentCaptor saved = ArgumentCaptor.forClass(ConnectRequest.class); + verify(repo).save(saved.capture()); + ConnectRequest row = saved.getValue(); + assertThat(row.getCallbackUrl()).isEqualTo(CALLBACK); + assertThat(row.getCallbackOrigin()).isEqualTo("https://pdf.example.com"); + assertThat(row.getStatus()).isEqualTo(ConnectRequest.Status.PENDING); + assertThat(row.getName()).isEqualTo("prod-1"); + // The claim secret is only ever stored as a hash. + assertThat(row.getClaimSecretHash()).isNotEqualTo(CLAIM_SECRET).hasSize(64); + } + + @Test + void create_keepsANonDefaultPortInTheOrigin() { + service.create( + null, "http://pdf.internal:8080/account-link/callback", NONCE, CLAIM_SECRET, null); + + ArgumentCaptor saved = ArgumentCaptor.forClass(ConnectRequest.class); + verify(repo).save(saved.capture()); + assertThat(saved.getValue().getCallbackOrigin()).isEqualTo("http://pdf.internal:8080"); + } + + @ParameterizedTest + @ValueSource( + strings = { + "/account-link/callback", // not absolute + "ftp://pdf.example.com/cb", // wrong scheme + "javascript:alert(1)", // not a hierarchical http(s) URL + "https://user:pw@pdf.example.com/cb", // credentials in the URL + "https://pdf.example.com/cb#already", // would collide with our fragment + "https:///cb" // no host + }) + void create_refusesCallbacksWeWouldNotWantToRedirectTo(String callback) { + ConnectRequestService.CreateResult result = + service.create(null, callback, NONCE, CLAIM_SECRET, null); + + assertThat(result.rejection()).isEqualTo(CreateRejection.BAD_CALLBACK); + verify(repo, never()).save(any()); + } + + @Test + void create_refusesAMissingNonce() { + assertThat(service.create(null, CALLBACK, " ", CLAIM_SECRET, null).rejection()) + .isEqualTo(CreateRejection.BAD_NONCE); + verify(repo, never()).save(any()); + } + + @Test + void create_namesTheSecretWhenTheSecretIsWhatIsMissing() { + assertThat(service.create(null, CALLBACK, NONCE, " ", null).rejection()) + .isEqualTo(CreateRejection.BAD_SECRET); + verify(repo, never()).save(any()); + } + + @Test + void create_isCappedPerSourceAddress() { + when(repo.countByRequesterIpAndCreatedAtAfter(anyString(), any())) + .thenReturn((long) ConnectRequestService.MAX_REQUESTS_PER_IP); + + ConnectRequestService.CreateResult result = + service.create(null, CALLBACK, NONCE, CLAIM_SECRET, "10.0.0.1"); + + assertThat(result.rejection()).isEqualTo(CreateRejection.RATE_LIMITED); + verify(repo, never()).save(any()); + } + + @Test + void lookup_flagsPlaintextTransportSoTheApproverCanSeeIt() { + ConnectRequest row = pending(); + row.setCallbackOrigin("http://pdf.internal:8080"); + when(repo.findByRequestId("req")).thenReturn(Optional.of(row)); + + assertThat(service.lookup("req")).get().extracting("insecureTransport").isEqualTo(true); + } + + @Test + void lookup_hidesAnExpiredHandshake() { + ConnectRequest row = pending(); + row.setExpiresAt(LocalDateTime.now().minusMinutes(1)); + when(repo.findByRequestId("req")).thenReturn(Optional.of(row)); + + assertThat(service.lookup("req")).isEmpty(); + } + + @Test + void approve_bindsTheTeamAndReturnsTheStoredCallback() { + ConnectRequest row = pending(); + when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row)); + + ConnectRequestService.ApproveResult result = service.approve("req", 7L, 42L); + + assertThat(result.isRejected()).isFalse(); + // The destination comes from the row, never from the caller. + assertThat(result.target().callbackUrl()).isEqualTo(CALLBACK); + assertThat(result.target().nonce()).isEqualTo(NONCE); + assertThat(row.getStatus()).isEqualTo(ConnectRequest.Status.APPROVED); + assertThat(row.getTeamId()).isEqualTo(7L); + assertThat(row.getApprovedByUserId()).isEqualTo(42L); + // Approval on its own must not mint anything. + verifyNoInteractions(accountLinkService); + } + + @Test + void approve_isSingleUse() { + ConnectRequest row = pending(); + row.setStatus(ConnectRequest.Status.APPROVED); + when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row)); + + assertThat(service.approve("req", 7L, 42L).isRejected()).isTrue(); + } + + @Test + void approve_refusesAnExpiredHandshake() { + ConnectRequest row = pending(); + row.setExpiresAt(LocalDateTime.now().minusSeconds(1)); + when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row)); + + assertThat(service.approve("req", 7L, 42L).isRejected()).isTrue(); + } + + @Test + void createReauth_pinsTheTeamItWasToldByTheCredential() { + ConnectRequestService.CreateResult result = + service.createReauth(null, CALLBACK, NONCE, CLAIM_SECRET, null, 7L); + + assertThat(result.isRejected()).isFalse(); + ArgumentCaptor saved = ArgumentCaptor.forClass(ConnectRequest.class); + verify(repo).save(saved.capture()); + assertThat(saved.getValue().getMode()).isEqualTo(ConnectRequest.Mode.REAUTH); + assertThat(saved.getValue().getTeamId()).isEqualTo(7L); + } + + @Test + void createReauth_withoutAnAuthenticatedInstanceIsRefused() { + // The controller passes null when the offered device credential did not authenticate. + assertThat( + service.createReauth(null, CALLBACK, NONCE, CLAIM_SECRET, null, null) + .rejection()) + .isEqualTo(CreateRejection.NOT_LINKED); + verify(repo, never()).save(any()); + } + + @Test + void create_leavesTheTeamOpenForAFirstLink() { + service.create("n", CALLBACK, NONCE, CLAIM_SECRET, null); + + ArgumentCaptor saved = ArgumentCaptor.forClass(ConnectRequest.class); + verify(repo).save(saved.capture()); + assertThat(saved.getValue().getMode()).isEqualTo(ConnectRequest.Mode.LINK); + // Approval is what decides the team on a first link. + assertThat(saved.getValue().getTeamId()).isNull(); + } + + @Test + void approve_refusesAnApproverFromADifferentTeam() { + ConnectRequest row = reauthPinnedTo(7L); + when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row)); + + ConnectRequestService.ApproveResult result = service.approve("req", 99L, 42L); + + // This is the "signed in to the wrong account" case, and it must not silently rebind. + assertThat(result.rejection()).isEqualTo(ConnectRequestService.ApproveRejection.WRONG_TEAM); + assertThat(row.getStatus()).isEqualTo(ConnectRequest.Status.PENDING); + assertThat(row.getTeamId()).isEqualTo(7L); + } + + @Test + void approve_acceptsTheTeamTheServerAlreadyBelongsTo() { + ConnectRequest row = reauthPinnedTo(7L); + when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row)); + + assertThat(service.approve("req", 7L, 42L).isRejected()).isFalse(); + assertThat(row.getStatus()).isEqualTo(ConnectRequest.Status.APPROVED); + } + + @Test + void claim_onAReauthConfirmsWithoutMintingASecondCredential() { + ConnectRequest row = reauthPinnedTo(7L); + row.setStatus(ConnectRequest.Status.APPROVED); + row.setApprovedByUserId(42L); + when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row)); + + ConnectRequestService.ClaimResult result = service.claim("req", CLAIM_SECRET); + + assertThat(result.outcome()).isEqualTo(ClaimOutcome.CONFIRMED); + assertThat(result.deviceId()).isNull(); + assertThat(result.deviceSecret()).isNull(); + assertThat(result.teamId()).isEqualTo(7L); + assertThat(row.getStatus()).isEqualTo(ConnectRequest.Status.CONSUMED); + // A second credential would orphan the one the instance already holds. + verifyNoInteractions(accountLinkService); + } + + @Test + void claim_mintsOnceForAnApprovedHandshake() { + ConnectRequest row = approved(); + when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row)); + when(accountLinkService.register(anyLong(), anyLong(), any())) + .thenReturn( + new AccountLinkService.RegisteredInstance(9L, "dev-id", "dev-secret", "n")); + + ConnectRequestService.ClaimResult result = service.claim("req", CLAIM_SECRET); + + assertThat(result.outcome()).isEqualTo(ClaimOutcome.GRANTED); + assertThat(result.deviceId()).isEqualTo("dev-id"); + assertThat(result.deviceSecret()).isEqualTo("dev-secret"); + assertThat(result.teamId()).isEqualTo(7L); + assertThat(row.getStatus()).isEqualTo(ConnectRequest.Status.CONSUMED); + verify(accountLinkService).register(7L, 42L, "n"); + } + + @Test + void claim_refusesASecondCollection() { + ConnectRequest row = approved(); + row.setStatus(ConnectRequest.Status.CONSUMED); + when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row)); + + assertThat(service.claim("req", CLAIM_SECRET).outcome()).isEqualTo(ClaimOutcome.REJECTED); + verifyNoInteractions(accountLinkService); + } + + @Test + void claim_withTheWrongSecretMintsNothing() { + ConnectRequest row = approved(); + when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row)); + + assertThat(service.claim("req", "not-the-secret").outcome()) + .isEqualTo(ClaimOutcome.REJECTED); + assertThat(row.getStatus()).isEqualTo(ConnectRequest.Status.APPROVED); + verifyNoInteractions(accountLinkService); + } + + @Test + void claim_beforeApprovalTellsTheInstanceToWait() { + when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(pending())); + + assertThat(service.claim("req", CLAIM_SECRET).outcome()).isEqualTo(ClaimOutcome.PENDING); + verifyNoInteractions(accountLinkService); + } + + @Test + void claim_afterDenialIsTerminal() { + ConnectRequest row = pending(); + row.setStatus(ConnectRequest.Status.DENIED); + when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row)); + + assertThat(service.claim("req", CLAIM_SECRET).outcome()).isEqualTo(ClaimOutcome.REJECTED); + verifyNoInteractions(accountLinkService); + } + + @Test + void claim_onAnExpiredHandshakeMintsNothing() { + ConnectRequest row = approved(); + row.setExpiresAt(LocalDateTime.now().minusSeconds(1)); + when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row)); + + assertThat(service.claim("req", CLAIM_SECRET).outcome()).isEqualTo(ClaimOutcome.REJECTED); + verifyNoInteractions(accountLinkService); + } + + @Test + void claim_forAnUnknownIdLooksTheSameAsABadSecret() { + when(repo.findByRequestIdForUpdate("nope")).thenReturn(Optional.empty()); + + assertThat(service.claim("nope", CLAIM_SECRET).outcome()).isEqualTo(ClaimOutcome.REJECTED); + } + + private static ConnectRequest pending() { + ConnectRequest row = new ConnectRequest(); + row.setRequestId("req"); + row.setName("n"); + row.setCallbackUrl(CALLBACK); + row.setCallbackOrigin("https://pdf.example.com"); + row.setNonce(NONCE); + row.setClaimSecretHash(AccountLinkService.sha256Hex(CLAIM_SECRET)); + row.setStatus(ConnectRequest.Status.PENDING); + row.setExpiresAt(LocalDateTime.now().plusMinutes(10)); + return row; + } + + /** A re-authentication whose team came from the instance's credential, not from a browser. */ + private static ConnectRequest reauthPinnedTo(Long teamId) { + ConnectRequest row = pending(); + row.setMode(ConnectRequest.Mode.REAUTH); + row.setTeamId(teamId); + return row; + } + + private static ConnectRequest approved() { + ConnectRequest row = pending(); + row.setStatus(ConnectRequest.Status.APPROVED); + row.setTeamId(7L); + row.setApprovedByUserId(42L); + row.setApprovedAt(LocalDateTime.now()); + return row; + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementGuardTest.java b/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementGuardTest.java index 0a77de3e7e..a9ca7642a6 100644 --- a/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementGuardTest.java +++ b/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementGuardTest.java @@ -28,8 +28,8 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.web.method.HandlerMethod; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.simple.SimpleMeterRegistry; diff --git a/app/saas/src/test/java/stirling/software/saas/security/SupabaseSecurityConfigMoreTest.java b/app/saas/src/test/java/stirling/software/saas/security/SupabaseSecurityConfigMoreTest.java index da719e7ba5..4727d78463 100644 --- a/app/saas/src/test/java/stirling/software/saas/security/SupabaseSecurityConfigMoreTest.java +++ b/app/saas/src/test/java/stirling/software/saas/security/SupabaseSecurityConfigMoreTest.java @@ -14,6 +14,8 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.env.Environment; +import org.springframework.mock.env.MockEnvironment; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.jwt.JwtDecoder; @@ -48,13 +50,19 @@ class SupabaseSecurityConfigMoreTest { apiKeyAuthenticationService; private SupabaseSecurityConfig config(ApplicationProperties props) { + return config(props, new MockEnvironment()); + } + + /** Loopback CORS origins are only added outside production, so the environment decides. */ + private SupabaseSecurityConfig config(ApplicationProperties props, Environment environment) { return new SupabaseSecurityConfig( userService, teamService, supabaseUserService, saasTeamService, props, - apiKeyAuthenticationService); + apiKeyAuthenticationService, + environment); } @Nested @@ -204,6 +212,50 @@ class SupabaseSecurityConfigMoreTest { .hasSize(1); } + @Test + @DisplayName("production does not allow loopback on arbitrary ports") + void productionHasNoLoopbackWildcard() { + CorsConfiguration cfg = + cors(config(new ApplicationProperties()).corsConfigurationSource()); + + assertThat(cfg.getAllowedOriginPatterns()) + .doesNotContain("http://localhost:[*]", "http://127.0.0.1:[*]"); + } + + @Test + @DisplayName("non-production allows loopback on any port so dev servers can move") + void devAllowsAnyLoopbackPort() { + // Several dev servers run side by side and their ports change; pinning a list turns + // every new local environment into an opaque CORS failure. + MockEnvironment dev = new MockEnvironment(); + dev.setActiveProfiles("saas", "dev"); + + CorsConfiguration cfg = + cors(config(new ApplicationProperties(), dev).corsConfigurationSource()); + + assertThat(cfg.getAllowedOriginPatterns()) + .contains("http://localhost:[*]", "http://127.0.0.1:[*]") + // Still credentialed, which is the reason the pattern form matters. + .contains("https://stirling.com"); + assertThat(cfg.getAllowCredentials()).isTrue(); + } + + @Test + @DisplayName("an operator origin list is respected verbatim even in dev") + void operatorOverrideSuppressesLoopbackWildcard() { + ApplicationProperties props = new ApplicationProperties(); + props.getSystem().setCorsAllowedOrigins(List.of("https://custom.example.com")); + MockEnvironment dev = new MockEnvironment(); + dev.setActiveProfiles("saas", "dev"); + + CorsConfiguration cfg = cors(config(props, dev).corsConfigurationSource()); + + // An operator who set the list meant it; we do not widen it behind their back. + assertThat(cfg.getAllowedOriginPatterns()) + .contains("https://custom.example.com") + .doesNotContain("http://localhost:[*]"); + } + @Test @DisplayName("operator override replaces the default origin list") void operatorOverrideUsed() { diff --git a/build.gradle b/build.gradle index 6382e75c12..6ee8baca47 100644 --- a/build.gradle +++ b/build.gradle @@ -38,11 +38,11 @@ ext { gsonVersion = "2.14.0" guavaVersion = "33.6.0-jre" jinjavaVersion = "2.8.4" - jackson2Version = "2.22.1" + jackson2Version = "2.22.2" bucket4jVersion = "8.19.0" - archunitVersion = "1.4.2" + archunitVersion = "1.5.0" batikVersion = "1.19" - jpdfiumVersion = "1.0.4" + jpdfiumVersion = "1.1.3" jwtVersion = "0.13.0" awsSdkVersion = "2.51.3" jschVersion = "2.28.6" @@ -265,7 +265,6 @@ subprojects { dependencies { implementation 'org.springframework.boot:spring-boot-starter-actuator' - implementation 'io.github.pixee:java-security-toolkit:1.2.3' //tmp for security bumps implementation "ch.qos.logback:logback-core:$logback" @@ -307,7 +306,7 @@ subprojects { systemProperty 'apple.awt.UIElement', 'true' testLogging { - events "started", "failed" + events "skipped", "failed" showExceptions = true showCauses = true showStackTraces = true @@ -543,6 +542,13 @@ subprojects { } } + // Lazy initialization defers bean creation until first use, + // reducing dev-mode RSS significantly (heap drops ~40-60%). + // Enable with: ./gradlew bootRun -PlazyInit=true + if (rootProject.findProperty('lazyInit') == 'true') { + runtimeArgs.add("-Dspring.main.lazy-initialization=true") + logger.lifecycle("Lazy initialization enabled (-PlazyInit=true)") + } jvmArgs = runtimeArgs } } diff --git a/devGuide/CODE_COMMENTS.md b/devGuide/CODE_COMMENTS.md new file mode 100644 index 0000000000..739744142f --- /dev/null +++ b/devGuide/CODE_COMMENTS.md @@ -0,0 +1,232 @@ +# Code comments + +A comment must carry information the code cannot. If a reader could derive it from +the code in front of them, delete it: a redundant comment still has to be +maintained, will eventually contradict the code, and dilutes the comments that +matter. + +The operative rules are in `AGENTS.md`, kept short so they stay in an agent's +context. This document is the reasoning and the worked examples behind them, plus +how to run the linter. + +## Comment the current state + +Describe the code as it is. Not what it used to be, not what changed, not why it +changed. A comment that narrates history is stale the moment the next change +lands, and git already holds that record. + +When you know the history and it explains the shape of the code, the useful half is +the reason, not the sequence. State the reason: + +```java +// Don't: +// This used to reimplement the modal internals, which is how the procurement +// dialogs drifted from the billing ones. + +// Do: +// Thin wrapper over the shared Modal: duplicating its portal and focus trap is +// how dialogs drift apart. +``` + +Future state is the exception, and it belongs in a TODO with an issue. + +## The four jobs + +**Contract.** What a caller must know that the signature cannot say: +preconditions, invariants, units, ownership and lifetime, thread-safety, error +semantics, side effects. + +The bound is the surface, not the volume: document the contract of everything a +caller outside the file can reach, and nothing else. Inside that surface say +whatever a caller needs; outside it a comment earns its place on the same terms as +any other. + +```java +/** + * Authority on which filesystem locations a policy may read or write. Fail-closed + * in order: denied entirely under the saas profile; Stirling's own config dir is + * always rejected; the path must resolve within policies.allowedFolderRoots. + * + *

Compared after normalisation so {@code ..} cannot escape a root. Symlink + * escape is not defended: an operator who roots an allowlist on a symlink to a + * sensitive location is trusted. + */ +``` + +**Why.** The constraint the code satisfies, the bug it avoids, the alternative +rejected and the reason. + +```java +// whenComplete runs on the worker thread after the run finishes, so the +// terminal event never races the step events. +handle.completion() +``` + +A reference is supplementary, never load-bearing: the comment must survive +deleting it. `// See #1234` is a dead end. + +```java +// flatten() reads the annotation list that save() clears, so saving first loses +// every annotation (#6865). +document.flatten(annotations); +``` + +Prefer a spec (`RFC 3161`, `ISO 4217`) or a CVE where one applies. Both are +immutable; a ticket can be closed, moved or made private. + +**Hazard.** "Must stay in sync with X." "Order matters because Y." "Do not remove, +it prevents Z." + +**Map.** A short orientation at the head of a genuinely complex file: what it owns, +and what it deliberately does not. + +## The test that decides it + +A comment earns its place when it sits at a different level of detail than the line +below it: lower, stating a precise fact the code implies but does not say, or +higher, giving intent a reader would otherwise assemble from ten lines. +Same-altitude is the definition of redundant. + +- **Delete it.** Is any information lost? If not, it stays deleted. +- **Could a name carry it instead?** A better identifier, an extracted function or + a named constant beats a comment. Prefer the code change. + +## What not to write + +| Don't | Instead | +| --- | --- | +| `// Handle drag start` above `handleDragStart` | Nothing. The name already says it. | +| `// ─── Types ───`, `// Helpers`, `// ====` | If a file needs internal signposting, split the file. | +| `// Step 1:` narrating a function body | Extract functions. If the steps need labels they need names. | +| `// No longer needed`, `// Previously this used X` | State why the code is as it is now, or nothing. | +| Commented-out code | Delete it. Git remembers. | +| `@param blob - The blob to download` | Omit the tag rather than pad it. | +| Docs on a self-explanatory member | Nothing, unless there is a real constraint to state. | + +Step numbering is fine where it labels a genuinely numbered thing, such as a wizard +step or a step in a written test procedure. It is narration when it numbers the +lines of one function. + +## Comments at the end of a line + +A trailing comment usually does a different job from one above the code: it decodes +the line it sits on. Those are worth keeping, and the linter leaves them alone. + +```java +byte[] pdfBytes = {0x25, 0x50, 0x44, 0x46}; // "%PDF" +long maxAttachmentSize = 50L * 1024 * 1024; // 50 MB +double buffer = 0.10; // 10% headroom +default -> toBytes(value, 2); // MB +``` + +Each overlaps in words with the code and each adds the interpretation the code +leaves implicit, which is the lower-altitude case the test above asks for. So +`CMT001` does not judge trailing comments; on this codebase it would have been +wrong about roughly six in seven of them. + +What still applies is anything that does not depend on the code below: a trailing +`// TODO fix this` is as unowned as one on its own line, and a trailing +`// this used to run before the flush` narrates history wherever it sits. + +A comment block over about 12 lines, outside a file or type header, is usually a +sign the code needs restructuring. If it is genuinely product documentation, it +belongs in the docs repo. + +## TODOs + +A TODO needs an issue, because an issue is the only part that will close it: + +```java +// TODO(#1234): re-enable the checkout gate once account syncing lands +``` + +An owner is not a substitute: a username goes stale when someone changes team and +means nothing to an outside contributor. If the work is not worth an issue it is +not worth a TODO, and the options are to do it now or leave the code alone. A +question is not a TODO. + +## Per language + +**Java.** Google Java Style, which this repo already formats to. Its §7.3.1 +exception applies: omit Javadoc on a self-explanatory member where there is +genuinely nothing to add, but do not cite it to skip something a reader needs. +Summary fragments are noun or verb phrases, not sentences starting "This method +returns". + +**TypeScript.** JSDoc on the `@app/*` seams, exported hooks, and anything crossing +a layer boundary. No `@param`/`@returns` that restates a typed signature. JSX +comments follow the same rules as any other. + +**Python.** Docstrings on modules, public functions and Pydantic models where the +contract is not obvious from the type. + +## The linter + +```bash +task comment-lint # what the working tree adds over HEAD +task comment-lint:branch # what the branch adds over origin/main (BASE= to change) +task pre-commit:comment-lint:ci # the fixture corpus, then the diff +``` + +`comment-lint` is the pre-commit question, so it reports nothing once you have +committed; on a CI pull request it compares against the target branch via +`GITHUB_BASE_REF`. `comment-lint:branch` is the review question. The corpus checks +the rules themselves rather than the code under review, so it runs on CI and before +a rule change, not on every local commit. + +`task comment-lint` also runs inside `task pre-commit`, and as a Claude Code `Stop` +hook, so an agent is told before it finishes a turn and fixes the comment inside +that turn. Stop rather than per file write: a run costs the same for one file as for +twenty-five, and half of all writes in a turn go to a file already written in it. + +Findings are scoped to comment text that is new, not to lines git calls new, so +reindenting or moving code does not resurface comments you did not write. + +The rules are the `RULES` object in +[`scripts/lint/comment-rules.mjs`](../scripts/lint/comment-rules.mjs); the exact +condition for each is the predicate of the same name in that file, with the +readings it deliberately excludes beside it. + +**Every rule blocks.** A rule that only warns is a rule nobody acts on. So a +finding you believe is wrong is a bug in the rule, not something to live with: +narrow the rule, or mark the line and say why. + +Every comment form the repo writes is covered: `//` and `/* */`, Javadoc and JSDoc, +JSX comments, `#`, and Python docstrings. `CMT007` reads all three parameter +conventions in use here, Javadoc/JSDoc `@param`, Sphinx `:param name:` and Google +`name: description` under `Args:`. + +Two engines, one rule set. `.ts`/`.tsx`/`.mjs` go to an oxlint JS plugin, so +comments come from the parser: a `//` inside a string is not a comment, and JSX +`{/* … */}` is. `.java`/`.py` go to a line scanner. Neither reads the other's +files, so they cannot disagree about one file. `scripts/lint/fixtures/` is the +corpus that keeps them meaning the same thing. + +### When a finding is wrong + +Name the rule on the line above: + +```ts +// comment-lint-allow: CMT002 +// ─── kept deliberately, because ─── +``` + +There is no form that disables every rule, and the directive has to earn its +place. `CMT008` reports one that names something which is not a rule, and one that +silences nothing, so a typo does not read as a suppression and a stale +suppression does not sit there blinding the line. The whole comment must be the +directive; prose that mentions the syntax is just prose. + +If you reach for this more than occasionally the rule is wrong: fix it in +`comment-rules.mjs` and update the fixture corpus in the same commit, so the diff +shows what moved. + +### The existing backlog + +`task pre-commit:comment-lint:all` reports the whole tree and never fails. There is +a standing backlog being cleared by directory; diff scoping is what keeps it off +whoever touches a file first. + +To turn the editor hook off, put `{ "env": { "COMMENT_LINT_HOOK": "0" } }` in +`.claude/settings.local.json`. The commit-time gate still applies, so you lose the +early warning rather than the check. diff --git a/devGuide/README.md b/devGuide/README.md index 5e8486f013..2ddaff63c3 100644 --- a/devGuide/README.md +++ b/devGuide/README.md @@ -8,6 +8,7 @@ This directory contains all development-related documentation for Stirling PDF. - **[DeveloperGuide.md](../DeveloperGuide.md)** - Main developer setup and architecture guide (in repo root) - **[Taskfile.yml](../Taskfile.yml)** - Unified task runner for all build/dev/test/lint commands - **[EXCEPTION_HANDLING_GUIDE.md](./EXCEPTION_HANDLING_GUIDE.md)** - Exception handling patterns and i18n best practices +- **[CODE_COMMENTS.md](./CODE_COMMENTS.md)** - What a comment is for, what not to write, and the `task comment-lint` rules - **[HowToAddNewLanguage.md](./HowToAddNewLanguage.md)** - Internationalization and translation guide - **[STORAGE_ENCRYPTION_AT_REST.md](./STORAGE_ENCRYPTION_AT_REST.md)** - Encryption at rest for stored files: key setup, migration, revocation, rotation diff --git a/docker/backend/Dockerfile b/docker/backend/Dockerfile index 39bb60bdef..ec7d7a7304 100644 --- a/docker/backend/Dockerfile +++ b/docker/backend/Dockerfile @@ -45,7 +45,7 @@ RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo li --no-daemon # Stage 2: Extract Spring Boot Layers -FROM eclipse-temurin:25-jre-noble@sha256:fbcf915c585659b30eb766ada4d6d7cfc9ec1040bf521e95bf61b10a25af73db AS jar-extract +FROM eclipse-temurin:25-jre-noble@sha256:b4c93a50fc67612798db73d68ca3b0ee4ebdd51736e59cca370e689b9797037e AS jar-extract WORKDIR /tmp COPY --from=app-build /app/app/core/build/libs/*.jar app.jar RUN java -Djarmode=tools -jar app.jar extract --layers --destination /layers diff --git a/docker/base/Dockerfile b/docker/base/Dockerfile index 06e1b6e601..0c73738214 100644 --- a/docker/base/Dockerfile +++ b/docker/base/Dockerfile @@ -5,7 +5,7 @@ ARG TARGETPLATFORM # Stage 1: Build and strip Calibre -FROM ubuntu:noble@sha256:561618e2c15bf2397621dd04f96926663a3b5616c189cf7e38db7e82f5c538ea AS calibre-build +FROM ubuntu:noble@sha256:33ceb71981b602c1a7443a53469e4dba065f7503eab3078a2d7a57a2ab987517 AS calibre-build ARG TARGETPLATFORM ARG CALIBRE_VERSION=9.13.0 ARG CALIBRE_STRIP_WEBENGINE=false @@ -274,7 +274,7 @@ RUN if [ "${CALIBRE_STRIP_WEBENGINE}" = "true" ]; then \ # Stage 2: Build Ghostscript from source -FROM ubuntu:noble@sha256:561618e2c15bf2397621dd04f96926663a3b5616c189cf7e38db7e82f5c538ea AS gs-build +FROM ubuntu:noble@sha256:33ceb71981b602c1a7443a53469e4dba065f7503eab3078a2d7a57a2ab987517 AS gs-build ARG TARGETPLATFORM ARG GS_VERSION=10.07.1 @@ -298,7 +298,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ # Stage 3: Build PDF Tools (QPDF and ImageMagick 7) -FROM ubuntu:noble@sha256:561618e2c15bf2397621dd04f96926663a3b5616c189cf7e38db7e82f5c538ea AS pdf-tools-build +FROM ubuntu:noble@sha256:33ceb71981b602c1a7443a53469e4dba065f7503eab3078a2d7a57a2ab987517 AS pdf-tools-build ARG TARGETPLATFORM ARG QPDF_VERSION=12.4.0 ARG IM_VERSION=7.1.2-29 @@ -343,7 +343,7 @@ RUN mkdir -p /magick-export/usr/bin \ # Stage 4: Build Python venv -FROM ubuntu:noble@sha256:561618e2c15bf2397621dd04f96926663a3b5616c189cf7e38db7e82f5c538ea AS python-venv-build +FROM ubuntu:noble@sha256:33ceb71981b602c1a7443a53469e4dba065f7503eab3078a2d7a57a2ab987517 AS python-venv-build ARG TARGETPLATFORM ARG UNOSERVER_VERSION=3.7 @@ -368,7 +368,7 @@ RUN --mount=type=cache,target=/root/.cache/pip,sharing=locked \ # Final runtime image - the actual base image -FROM eclipse-temurin:25-jre-noble@sha256:fbcf915c585659b30eb766ada4d6d7cfc9ec1040bf521e95bf61b10a25af73db AS runtime +FROM eclipse-temurin:25-jre-noble@sha256:b4c93a50fc67612798db73d68ca3b0ee4ebdd51736e59cca370e689b9797037e AS runtime SHELL ["/bin/bash", "-o", "pipefail", "-c"] diff --git a/docker/embedded/Dockerfile b/docker/embedded/Dockerfile index cac30d88b1..80e163dd9a 100644 --- a/docker/embedded/Dockerfile +++ b/docker/embedded/Dockerfile @@ -61,7 +61,7 @@ RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo li --no-daemon # Stage 2: Extract Spring Boot Layers -FROM eclipse-temurin:25-jre-noble@sha256:fbcf915c585659b30eb766ada4d6d7cfc9ec1040bf521e95bf61b10a25af73db AS jar-extract +FROM eclipse-temurin:25-jre-noble@sha256:b4c93a50fc67612798db73d68ca3b0ee4ebdd51736e59cca370e689b9797037e AS jar-extract WORKDIR /tmp COPY --from=app-build /app/app/core/build/libs/*.jar app.jar RUN java -Djarmode=tools -jar app.jar extract --layers --destination /layers diff --git a/docker/embedded/Dockerfile.fat b/docker/embedded/Dockerfile.fat index 42c9a16af2..38c8bcc8f1 100644 --- a/docker/embedded/Dockerfile.fat +++ b/docker/embedded/Dockerfile.fat @@ -61,7 +61,7 @@ RUN --mount=type=cache,id=stirling-pdf-npm-cache,target=/root/.npm,sharing=locke --no-daemon # Stage 2: Extract Spring Boot Layers -FROM eclipse-temurin:25-jre-noble@sha256:fbcf915c585659b30eb766ada4d6d7cfc9ec1040bf521e95bf61b10a25af73db AS jar-extract +FROM eclipse-temurin:25-jre-noble@sha256:b4c93a50fc67612798db73d68ca3b0ee4ebdd51736e59cca370e689b9797037e AS jar-extract WORKDIR /tmp COPY --from=app-build /app/app/core/build/libs/*.jar app.jar RUN java -Djarmode=tools -jar app.jar extract --layers --destination /layers diff --git a/docker/embedded/Dockerfile.ultra-lite b/docker/embedded/Dockerfile.ultra-lite index f1389c1600..104bfd8937 100644 --- a/docker/embedded/Dockerfile.ultra-lite +++ b/docker/embedded/Dockerfile.ultra-lite @@ -62,7 +62,7 @@ RUN --mount=type=cache,id=stirling-pdf-npm-cache,target=/root/.npm,sharing=locke # Stage 2: Runtime image # glibc base (not Alpine/musl): JPDFium's PDFium natives are glibc-linked. -FROM eclipse-temurin:25-jre-noble@sha256:fbcf915c585659b30eb766ada4d6d7cfc9ec1040bf521e95bf61b10a25af73db +FROM eclipse-temurin:25-jre-noble@sha256:b4c93a50fc67612798db73d68ca3b0ee4ebdd51736e59cca370e689b9797037e ENV DEBIAN_FRONTEND=noninteractive \ LANG=C.UTF-8 \ diff --git a/docker/unoserver/Dockerfile b/docker/unoserver/Dockerfile index da5ba27371..3667c4bf5a 100644 --- a/docker/unoserver/Dockerfile +++ b/docker/unoserver/Dockerfile @@ -1,7 +1,7 @@ # Standalone unoserver image for Stirling-PDF remote UNO mode. # Pinned to unoserver 3.7 to match Stirling-PDF's client (avoids wire mismatch). -FROM ubuntu:noble@sha256:561618e2c15bf2397621dd04f96926663a3b5616c189cf7e38db7e82f5c538ea +FROM ubuntu:noble@sha256:33ceb71981b602c1a7443a53469e4dba065f7503eab3078a2d7a57a2ab987517 ARG UNOSERVER_VERSION=3.7 # ~120 MB of CJK fonts — opt-in. diff --git a/engine/pyproject.toml b/engine/pyproject.toml index 23c3bc078c..d924eab264 100644 --- a/engine/pyproject.toml +++ b/engine/pyproject.toml @@ -20,7 +20,7 @@ engine = [ # No `voyageai` extra either; stirling.documents.voyage speaks its API directly. "pydantic-ai-slim[anthropic,openai]>=1.107.2,<2.0.0", "pydantic-settings>=2.15.0", - "python-dotenv>=1.2.2", + "python-dotenv>=1.2.3", "sqlite-vec>=0.1.9", "uvicorn>=0.52.3", ] @@ -42,7 +42,7 @@ cucumber = [ "pillow>=12.3.0", "pypdf[crypto]>=6.15.0", "qrcode[pil]>=8.2", - "reportlab>=5.0.0", + "reportlab>=5.0.1", "requests>=2.34.2", ] # Shared Python utilities used by repository scripts and CI workflows. @@ -51,7 +51,7 @@ tools = [ "defusedxml>=0.7.1", "fonttools>=4.63.0", "fpdf2>=2.8.8", - "openai>=2.53.0", + "openai>=3.3.1", "requests>=2.34.2", "tomli-w>=1.2.0", "tomlkit>=0.15.1", diff --git a/engine/src/stirling/models/tool_models.py b/engine/src/stirling/models/tool_models.py index 6650942b6f..dff977a7f7 100644 --- a/engine/src/stirling/models/tool_models.py +++ b/engine/src/stirling/models/tool_models.py @@ -725,6 +725,9 @@ class OcrPdfParams(ApiModel): ) ocr_type: OcrType = Field(..., description="Specify the OCR type, e.g., 'skip-text', 'force-ocr', or 'Normal'") remove_images_after: bool | None = Field(None, description="Remove images from the output PDF if set to true") + rotate_pages: bool | None = Field( + None, description="Auto-correct page orientation (90/180/270) using Tesseract OSD if set to true" + ) sidecar: bool | None = Field(None, description="Include OCR text in a sidecar text file if set to true") diff --git a/engine/uv.lock b/engine/uv.lock index a3286b100d..ae023292b7 100644 --- a/engine/uv.lock +++ b/engine/uv.lock @@ -466,7 +466,7 @@ cucumber = [ { name = "pillow", specifier = ">=12.3.0" }, { name = "pypdf", extras = ["crypto"], specifier = ">=6.15.0" }, { name = "qrcode", extras = ["pil"], specifier = ">=8.2" }, - { name = "reportlab", specifier = ">=5.0.0" }, + { name = "reportlab", specifier = ">=5.0.1" }, { name = "requests", specifier = ">=2.34.2" }, ] engine = [ @@ -479,7 +479,7 @@ engine = [ { name = "pydantic", specifier = ">=2.13.4" }, { name = "pydantic-ai-slim", extras = ["anthropic", "openai"], specifier = ">=1.107.2,<2.0.0" }, { name = "pydantic-settings", specifier = ">=2.15.0" }, - { name = "python-dotenv", specifier = ">=1.2.2" }, + { name = "python-dotenv", specifier = ">=1.2.3" }, { name = "sqlite-vec", specifier = ">=0.1.9" }, { name = "uvicorn", specifier = ">=0.52.3" }, ] @@ -501,7 +501,7 @@ tools = [ { name = "defusedxml", specifier = ">=0.7.1" }, { name = "fonttools", specifier = ">=4.63.0" }, { name = "fpdf2", specifier = ">=2.8.8" }, - { name = "openai", specifier = ">=2.53.0" }, + { name = "openai", specifier = ">=3.3.1" }, { name = "requests", specifier = ">=2.34.2" }, { name = "tomli-w", specifier = ">=1.2.0" }, { name = "tomlkit", specifier = ">=0.15.1" }, @@ -844,21 +844,19 @@ wheels = [ [[package]] name = "openai" -version = "2.53.0" +version = "3.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "distro" }, - { name = "httpx" }, + { name = "httpx2" }, { name = "jiter" }, { name = "pydantic" }, { name = "sniffio" }, - { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ef/cf/36e3e7235fdf6d125c052acc0970924611b17a20a4fe580596faf4566a65/openai-2.53.0.tar.gz", hash = "sha256:baf5802ad08980e1d9d561e1b996e800c8bcd14af5847c6d0e7a5cc59e4d4116", size = 1099435, upload-time = "2026-08-03T21:42:01.664Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/9c/ba0c292b4032ede74c249ca314ad64eb1bb5a03a843f6e01facb02f80cd8/openai-3.3.1.tar.gz", hash = "sha256:6f22807de1a976c932cecda620e8172a8c3fdbaeed29c7f21564e0c2410edf56", size = 1282113, upload-time = "2026-08-19T16:31:35.006Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/78/0f/cc6afea3542a5142c5d8fc8211c5e059a8375105d004a41dfa2c7948dbb0/openai-2.53.0-py3-none-any.whl", hash = "sha256:c694ffc747a3c4d1663ef2b07b811315a476164ee5efa3a993967349ebca7618", size = 1659829, upload-time = "2026-08-03T21:41:59.581Z" }, + { url = "https://files.pythonhosted.org/packages/6a/db/2b7a1b3de659bb82aef979116c74e809982b13e42c057759767552b5155f/openai-3.3.1-py3-none-any.whl", hash = "sha256:9652df7fdf8ee6f5bd58e0a12f2b1d414a18e0f06bb7a9a57c8643a5f5469bd3", size = 1690337, upload-time = "2026-08-19T16:31:32.812Z" }, ] [[package]] @@ -1262,11 +1260,11 @@ wheels = [ [[package]] name = "python-dotenv" -version = "1.2.2" +version = "1.2.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload-time = "2026-08-16T16:54:54.067Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, + { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" }, ] [[package]] @@ -1373,15 +1371,15 @@ wheels = [ [[package]] name = "reportlab" -version = "5.0.0" +version = "5.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "charset-normalizer" }, { name = "pillow" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/41/d6/4b7b0cf56880eb96533e607967be6a939e344675601e033d113a0bfa1f4e/reportlab-5.0.0.tar.gz", hash = "sha256:e4494a0c6623ae213bb856fba523171b2b54a7bf629fda02d5e525a7b899a784", size = 3701928, upload-time = "2026-06-18T11:34:31.145Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4a/51/dbe28534ae12c852f61be91f039f343305fd1f34f1c66b8de75afae7a525/reportlab-5.0.1.tar.gz", hash = "sha256:ebd13154be1c8515e665de70bd2d303ae9ddc3ef47e44afd5116441ca0283a26", size = 3945711, upload-time = "2026-08-20T13:48:16.461Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/07/70085c17a369605f15e301d10ab902115019b1126c7253d964afc230c7d6/reportlab-5.0.0-py3-none-any.whl", hash = "sha256:9d5a3affa84919e1111ede580031266a570e93b1ce388219621347965ff1d93c", size = 1956710, upload-time = "2026-06-18T11:34:29.07Z" }, + { url = "https://files.pythonhosted.org/packages/db/cb/dacbc268cb68d0428ea2cbd85266195a9ab3e677449589ddae59bd7542ac/reportlab-5.0.1-py3-none-any.whl", hash = "sha256:1c36e6bb0e71780c72331eba60da7f602e8d4389a8723825af71342e49d791e8", size = 1957258, upload-time = "2026-08-20T13:48:14.026Z" }, ] [[package]] @@ -1566,18 +1564,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/13/bc/8c13eb66537dce1d2bd3a57132902f38d0e7f5bb46fa9f4daed9fe9d76ee/tomlkit-0.15.1-py3-none-any.whl", hash = "sha256:177a05aece5a8ca5266fd3c448abb47b8d352f09d477d3ca8332db4d89b24304", size = 49449, upload-time = "2026-07-17T01:48:05.728Z" }, ] -[[package]] -name = "tqdm" -version = "4.70.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/21/3b/6c24bec5be5e743ffd99576daa5cc077722fc7d5bbc00bd133fa0c698dc6/tqdm-4.70.0.tar.gz", hash = "sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220", size = 795438, upload-time = "2026-07-27T11:33:15.271Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/1c/01bfd571a64e7f270e6bab5e33777debe0edc56759233ce84f27dec92d14/tqdm-4.70.0-py3-none-any.whl", hash = "sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953", size = 80184, upload-time = "2026-07-27T11:33:13.167Z" }, -] - [[package]] name = "truststore" version = "0.10.4" diff --git a/frontend/editor/public/locales/ar-AR/translation.toml b/frontend/editor/public/locales/ar-AR/translation.toml index 4c4d45e2b4..4180d77811 100644 --- a/frontend/editor/public/locales/ar-AR/translation.toml +++ b/frontend/editor/public/locales/ar-AR/translation.toml @@ -3526,7 +3526,6 @@ label = "إحداثي Y" [crop.error] failed = "فشل قصّ PDF" -invalidArea = "منطقة القص تتجاوز حدود PDF" [crop.preview] title = "معاينة منطقة القص" diff --git a/frontend/editor/public/locales/az-AZ/translation.toml b/frontend/editor/public/locales/az-AZ/translation.toml index f73930133e..0033be05d5 100644 --- a/frontend/editor/public/locales/az-AZ/translation.toml +++ b/frontend/editor/public/locales/az-AZ/translation.toml @@ -3526,7 +3526,6 @@ label = "Y mövqeyi" [crop.error] failed = "PDF-i kəsmək alınmadı" -invalidArea = "Kəsmə sahəsi PDF sərhədlərini aşır" [crop.preview] title = "Kəsmə sahəsinin seçimi" diff --git a/frontend/editor/public/locales/bg-BG/translation.toml b/frontend/editor/public/locales/bg-BG/translation.toml index dc6ef92e39..07cf5a73d3 100644 --- a/frontend/editor/public/locales/bg-BG/translation.toml +++ b/frontend/editor/public/locales/bg-BG/translation.toml @@ -3526,7 +3526,6 @@ label = "Y позиция" [crop.error] failed = "Неуспешно изрязване на PDF" -invalidArea = "Областта за изрязване излиза извън границите на PDF" [crop.preview] title = "Избор на област за изрязване" diff --git a/frontend/editor/public/locales/bo-CN/translation.toml b/frontend/editor/public/locales/bo-CN/translation.toml index 86d0b4f10d..21193f5f2d 100644 --- a/frontend/editor/public/locales/bo-CN/translation.toml +++ b/frontend/editor/public/locales/bo-CN/translation.toml @@ -3526,7 +3526,6 @@ label = "Yཡི་གནས་བབ།" [crop.error] failed = "སོན་བཟང་མ་འདང་བ། PDF" -invalidArea = "སོན་འདེབས་རྒྱ་ཁྱོན་དེ་PDFམཚམས་ཐིག་ལས་བརྒལ་ཡོད།" [crop.preview] title = "སོན་བཟང་ཁུལ་འདེམས་པ།" diff --git a/frontend/editor/public/locales/ca-CA/translation.toml b/frontend/editor/public/locales/ca-CA/translation.toml index 498da94436..c22244ac98 100644 --- a/frontend/editor/public/locales/ca-CA/translation.toml +++ b/frontend/editor/public/locales/ca-CA/translation.toml @@ -3526,7 +3526,6 @@ label = "Posició Y" [crop.error] failed = "No s'ha pogut retallar el PDF" -invalidArea = "L'àrea de retall s'estén més enllà dels límits del PDF" [crop.preview] title = "Selecció de l'àrea de retall" diff --git a/frontend/editor/public/locales/cs-CZ/translation.toml b/frontend/editor/public/locales/cs-CZ/translation.toml index 7b234329c7..f2161674f4 100644 --- a/frontend/editor/public/locales/cs-CZ/translation.toml +++ b/frontend/editor/public/locales/cs-CZ/translation.toml @@ -3526,7 +3526,6 @@ label = "Pozice Y" [crop.error] failed = "Oříznutí PDF se nezdařilo" -invalidArea = "Oblast ořezu přesahuje hranice PDF" [crop.preview] title = "Výběr oblasti ořezu" diff --git a/frontend/editor/public/locales/da-DK/translation.toml b/frontend/editor/public/locales/da-DK/translation.toml index f4c068da92..a0ddd7f4d9 100644 --- a/frontend/editor/public/locales/da-DK/translation.toml +++ b/frontend/editor/public/locales/da-DK/translation.toml @@ -3526,7 +3526,6 @@ label = "Y-position" [crop.error] failed = "Kunne ikke beskære PDF" -invalidArea = "Beskæringsområdet strækker sig ud over PDF'ens grænser" [crop.preview] title = "Valg af beskæringsområde" diff --git a/frontend/editor/public/locales/de-DE/translation.toml b/frontend/editor/public/locales/de-DE/translation.toml index 7c1bc79a8c..81c88d7599 100644 --- a/frontend/editor/public/locales/de-DE/translation.toml +++ b/frontend/editor/public/locales/de-DE/translation.toml @@ -3526,7 +3526,6 @@ label = "Y-Position" [crop.error] failed = "PDF zuschneiden fehlgeschlagen" -invalidArea = "Zuschneidebereich überschreitet die PDF-Grenzen" [crop.preview] title = "Zuschneidebereich-Auswahl" diff --git a/frontend/editor/public/locales/el-GR/translation.toml b/frontend/editor/public/locales/el-GR/translation.toml index 5269791aec..57ebd2eb6b 100644 --- a/frontend/editor/public/locales/el-GR/translation.toml +++ b/frontend/editor/public/locales/el-GR/translation.toml @@ -3526,7 +3526,6 @@ label = "Θέση Y" [crop.error] failed = "Αποτυχία περικοπής του PDF" -invalidArea = "Η περιοχή περικοπής εκτείνεται πέρα από τα όρια του PDF" [crop.preview] title = "Επιλογή περιοχής περικοπής" diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml index 8fdd452928..d2b2aa38e3 100644 --- a/frontend/editor/public/locales/en-GB/translation.toml +++ b/frontend/editor/public/locales/en-GB/translation.toml @@ -3526,7 +3526,6 @@ label = "Y Position" [crop.error] failed = "Failed to crop PDF" -invalidArea = "Crop area extends beyond PDF boundaries" [crop.preview] title = "Crop Area Selection" diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 51431428fa..12896a6a77 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -3001,6 +3001,293 @@ summary_one = "Ran 1 tool" summary_other = "Ran {{count}} tools" unknownTool = "Unknown tool" +[classification.families] +correspondence = "Correspondence" +education = "Education" +engineering = "Engineering" +finance = "Financial" +forms = "Forms" +government = "Government" +health = "Medical" +hr = "HR" +legal = "Legal" +operations = "Operations" +projects = "Projects" +property = "Property" +reports = "Reports" +sales = "Marketing" +travel = "Travel" + +[classification.labels] +academic-record = "Academic record" +action-plan = "Action plan" +addendum = "Addendum" +advertisement = "Advertisement" +affidavit = "Affidavit" +agenda = "Agenda" +amendment = "Amendment" +analytics-report = "Analytics report" +announcement = "Announcement" +annual-report = "Annual report" +api-documentation = "API documentation" +application-form = "Application form" +appraisal-report = "Appraisal report" +architecture-document = "Architecture document" +articles-of-incorporation = "Articles of incorporation" +assignment-brief = "Assignment brief" +audit-report = "Audit report" +balance-sheet = "Balance sheet" +bank-statement = "Bank statement" +benefits-summary = "Benefits summary" +bill-of-lading = "Bill of lading" +bill-of-materials = "Bill of materials" +blueprint = "Blueprint" +board-report = "Board report" +board-resolution = "Board resolution" +booking-confirmation = "Booking confirmation" +brochure = "Brochure" +budget = "Budget" +business-plan = "Business plan" +business-proposal = "Business proposal" +bylaws = "Bylaws" +campaign-brief = "Campaign brief" +case-study = "Case study" +cash-flow-statement = "Cash flow statement" +catalog = "Catalog" +cease-and-desist = "Cease and desist" +certificate = "Certificate" +certificate-of-completion = "Certificate of completion" +change-log = "Change log" +checklist = "Checklist" +claim-form = "Claim form" +closing-statement = "Closing statement" +complaint-letter = "Complaint letter" +compliance-document = "Compliance document" +confirmation-letter = "Confirmation letter" +consent-form = "Consent form" +contract = "Contract" +course-syllabus = "Course syllabus" +court-filing = "Court filing" +cover-letter = "Cover letter" +credit-note = "Credit note" +customs-declaration = "Customs declaration" +customs-form = "Customs form" +cv = "CV" +datasheet = "Datasheet" +debit-note = "Debit note" +deed = "Deed" +delivery-note = "Delivery note" +demand-letter = "Demand letter" +design-document = "Design document" +diploma = "Diploma" +discharge-summary = "Discharge summary" +dissertation = "Dissertation" +donation-receipt = "Donation receipt" +dunning-letter = "Dunning letter" +email-thread = "Email thread" +employee-handbook = "Employee handbook" +employment-contract = "Employment contract" +estimate = "Estimate" +event-agenda = "Event agenda" +event-program = "Event program" +eviction-notice = "Eviction notice" +exam-paper = "Exam paper" +expense-report = "Expense report" +expense-summary = "Expense summary" +explanation-of-benefits = "Explanation of benefits" +fact-sheet = "Fact sheet" +faq-document = "FAQ document" +feasibility-study = "Feasibility study" +feedback-form = "Feedback form" +financial-forecast = "Financial forecast" +financial-statement = "Financial statement" +floor-plan = "Floor plan" +flyer = "Flyer" +form = "Form" +franchise-agreement = "Franchise agreement" +freight-document = "Freight document" +gift-certificate = "Gift certificate" +glossary = "Glossary" +government-notice = "Government notice" +grade-report = "Grade report" +grant-agreement = "Grant agreement" +grant-application = "Grant application" +hoa-document = "HOA document" +home-inspection-report = "Home inspection report" +hr-memo = "HR memo" +hr-policy = "HR policy" +immigration-document = "Immigration document" +immunization-record = "Immunization record" +incident-report = "Incident report" +income-statement = "Income statement" +index = "Index" +inspection-report = "Inspection report" +insurance-certificate = "Insurance certificate" +insurance-claim = "Insurance claim" +insurance-policy = "Insurance policy" +intake-form = "Intake form" +inventory-list = "Inventory list" +investment-summary = "Investment summary" +invitation = "Invitation" +invoice = "Invoice" +itinerary = "Itinerary" +job-application = "Job application" +job-description = "Job description" +lab-report = "Lab report" +lease-agreement = "Lease agreement" +leave-request = "Leave request" +legal-brief = "Legal brief" +legal-filing = "Legal filing" +legal-notice = "Legal notice" +legal-opinion = "Legal opinion" +lesson-plan = "Lesson plan" +letter = "Letter" +letter-of-intent = "Letter of intent" +license = "License" +license-agreement = "License agreement" +loan-agreement = "Loan agreement" +loan-document = "Loan document" +maintenance-log = "Maintenance log" +manual = "Manual" +market-research = "Market research" +marketing-plan = "Marketing plan" +media-kit = "Media kit" +medical-invoice = "Medical invoice" +medical-report = "Medical report" +meeting-agenda = "Meeting agenda" +meeting-minutes = "Meeting minutes" +meeting-notes = "Meeting notes" +membership-document = "Membership document" +memo = "Memo" +memorandum-of-understanding = "Memorandum of understanding" +mortgage-document = "Mortgage document" +nda = "NDA" +newsletter = "Newsletter" +non-compete-agreement = "Non-compete agreement" +notice = "Notice" +offer-letter = "Offer letter" +onboarding-document = "Onboarding document" +order-confirmation = "Order confirmation" +order-form = "Order form" +organization-chart = "Organization chart" +packing-slip = "Packing slip" +partnership-agreement = "Partnership agreement" +patent = "Patent" +pathology-report = "Pathology report" +payment-reminder = "Payment reminder" +payroll-document = "Payroll document" +payslip = "Payslip" +performance-review = "Performance review" +permit = "Permit" +petition = "Petition" +pitch-deck = "Pitch deck" +power-of-attorney = "Power of attorney" +prescription = "Prescription" +presentation = "Presentation" +press-release = "Press release" +price-list = "Price list" +pricing-sheet = "Pricing sheet" +privacy-policy = "Privacy policy" +product-sheet = "Product sheet" +proforma-invoice = "Proforma invoice" +progress-report = "Progress report" +project-charter = "Project charter" +project-plan = "Project plan" +promotional-material = "Promotional material" +property-listing = "Property listing" +proposal = "Proposal" +public-notice = "Public notice" +purchase-agreement = "Purchase agreement" +purchase-order = "Purchase order" +quality-report = "Quality report" +quarterly-report = "Quarterly report" +questionnaire = "Questionnaire" +quick-start-guide = "Quick start guide" +quote = "Quote" +radiology-report = "Radiology report" +receipt = "Receipt" +recommendation-letter = "Recommendation letter" +reference-letter = "Reference letter" +referral-letter = "Referral letter" +registration-confirmation = "Registration confirmation" +registration-form = "Registration form" +regulatory-filing = "Regulatory filing" +release-notes = "Release notes" +remittance-advice = "Remittance advice" +rental-agreement = "Rental agreement" +report = "Report" +request-for-proposal = "Request for proposal" +request-for-quotation = "Request for quotation" +requirements-document = "Requirements document" +research-abstract = "Research abstract" +research-paper = "Research paper" +reservation = "Reservation" +resignation-letter = "Resignation letter" +resume = "Resume" +retrospective = "Retrospective" +return-authorization = "Return authorization" +risk-assessment = "Risk assessment" +roadmap = "Roadmap" +safety-data-sheet = "Safety data sheet" +safety-procedure = "Safety procedure" +sales-proposal = "Sales proposal" +sales-report = "Sales report" +schematic = "Schematic" +scope-of-work = "Scope of work" +service-agreement = "Service agreement" +service-report = "Service report" +settlement-agreement = "Settlement agreement" +shareholder-agreement = "Shareholder agreement" +shipping-confirmation = "Shipping confirmation" +specification = "Specification" +sponsorship-agreement = "Sponsorship agreement" +standard-operating-procedure = "Standard operating procedure" +statement-of-account = "Statement of account" +statement-of-work = "Statement of work" +status-report = "Status report" +stock-report = "Stock report" +study-guide = "Study guide" +subpoena = "Subpoena" +subscription-confirmation = "Subscription confirmation" +supply-order = "Supply order" +survey-form = "Survey form" +survey-results = "Survey results" +sustainability-report = "Sustainability report" +table-of-contents = "Table of contents" +tax-form = "Tax form" +tax-return = "Tax return" +tax-statement = "Tax statement" +technical-drawing = "Technical drawing" +technical-specification = "Technical specification" +tenancy-agreement = "Tenancy agreement" +tender-document = "Tender document" +termination-letter = "Termination letter" +terms-and-conditions = "Terms and conditions" +terms-of-service = "Terms of service" +test-plan = "Test plan" +test-report = "Test report" +thesis = "Thesis" +ticket = "Ticket" +timeline = "Timeline" +timesheet = "Timesheet" +title-document = "Title document" +training-material = "Training material" +transcript = "Transcript" +travel-itinerary = "Travel itinerary" +trust-document = "Trust document" +user-guide = "User guide" +utility-bill = "Utility bill" +vendor-agreement = "Vendor agreement" +visa-document = "Visa document" +waiver = "Waiver" +warehouse-receipt = "Warehouse receipt" +warranty-document = "Warranty document" +waybill = "Waybill" +white-paper = "White paper" +will = "Will" +work-instruction = "Work instruction" +work-order = "Work order" + [cloudBadge] tooltip = "This operation will use your cloud credits" @@ -3240,7 +3527,7 @@ enterEmailConfirm = "To confirm deletion, please type your email address ({{emai guestDescription = "You are signed in as a guest. Consider upgrading your account above." label = "Overview" manageAccountPreferences = "Manage your account preferences" -signedInAs = "Signed in as" +signedInAs = "Account" title = "Account Settings" [config.account.profilePicture] @@ -3351,6 +3638,39 @@ integration = "Integration Configuration" security = "Security Configuration" system = "System Configuration" +[connect] +loading = "Checking this request." +redirecting = "Returning you to your server." + +[connect.confirm] +acknowledge = "I recognise this address and want to connect it to my team" +approve = "Connect server" +deny = "Decline" +lead = "A Stirling server is asking to connect to your team. Check the address below is yours before you approve." +originLabel = "Address" +signedInAs = "Signed in as" +switchAccount = "Use a different account" +title = "Connect this server?" +unknownAccount = "an unknown account" + +[connect.confirm.insecure] +body = "This address does not use HTTPS, so your sign-in will be sent over an unencrypted connection. Only approve it on a network you trust." +label = "Not an encrypted address" + +[connect.declined] +body = "Nothing was connected. You can close this page." +title = "Request declined" + +[connect.error] +failed = "That did not go through. Only a team owner can connect a server." + +[connect.meta] +title = "Connect a server" + +[connect.notFound] +body = "This connection request is not valid. It may have expired, or already been used. Start another one from your server." +title = "Request not valid" + [convert] autoRotate = "Auto Rotate" autoRotateDescription = "Automatically rotate images to better fit the PDF page" @@ -3526,7 +3846,6 @@ label = "Y Position" [crop.error] failed = "Failed to crop PDF" -invalidArea = "Crop area extends beyond PDF boundaries" [crop.preview] title = "Crop Area Selection" @@ -3821,7 +4140,6 @@ mobileShort = "Mobile" mobileUpload = "Mobile Upload" mobileUploadNotAvailable = "Mobile upload not enabled" moreOptions = "More options" -myFiles = "My Files" nextFile = "Next file" noFiles = "No files available" noFilesFound = "No files found matching your search" @@ -3922,9 +4240,9 @@ duplicateFailed = "Could not duplicate file" expand = "Expand sidebar" googleDrive = "Google Drive" googleDriveDisabled = "Google Drive is not configured" -leaveMyFiles = "Leave My Files" +leaveMyFiles = "Leave File library" library = "PDF Library" -myFiles = "My Files" +myFiles = "File library" noFiles = "No files yet" openFileManager = "Browse all files & folders" openFromComputer = "Open from computer" @@ -3967,7 +4285,7 @@ addToWorkspaceCount = "Add {{count}} to workspace" allFiles = "All files" back = "Back" backToFolder = "Back to {{folder}}" -backToMyFiles = "Back to My Files" +backToMyFiles = "Back to File library" breadcrumbs = "Folder path" bulkActions = "Actions" cancel = "Cancel" @@ -4019,7 +4337,6 @@ localFoldersUnavailable = "Folders are cloud-only - save a file to the cloud to moveSkippedRemote_one = "{{count}} file couldn't be moved on the server (no permission or already deleted)." moveSkippedRemote_other = "{{count}} files couldn't be moved on the server (no permission or already deleted)." moveTo = "Move to…" -myFiles = "My Files" newFolder = "New folder" newFolderStorageDisabled = "Server folder storage isn't enabled. Ask your admin to turn it on." newFolderTabUnavailable = "Switch to All or Cloud to create folders." @@ -6490,6 +6807,34 @@ after = "to enable account linking against the hosted Stirling account. In dev y before = "Set" title = "SaaS login not configured" +[portal.accountLink.connect.callback] +continue = "Continue" +linkedNotSignedIn = "You are not signed in to Stirling in this browser, so usage and billing will ask you to sign in." +modalTitle = "Connecting this server" +retry = "Try again" +signedInAnyway = "You are signed in to Stirling, so billing and usage will load. Only the server link is incomplete." +working = "Finishing the connection." + +[portal.accountLink.connect.callback.expired] +body = "Connection requests are short lived. Start another one." +title = "Request expired" + +[portal.accountLink.connect.callback.linked] +body = "This server is connected to your Stirling account." +title = "Server connected" + +[portal.accountLink.connect.callback.malformed] +body = "This page was opened without a valid connection response. Start the connection from settings." +title = "Could not read the response" + +[portal.accountLink.connect.callback.rejected] +body = "This request was declined or has already been used. Start another one if that was not intended." +title = "Connection not completed" + +[portal.accountLink.connect.callback.unfinished] +body = "Stirling did not confirm the connection. This is usually temporary." +title = "Not finished yet" + [portal.accountLink.gate] action = "Link account" description = "Link this org's Stirling account to use billable features." @@ -6523,17 +6868,24 @@ minutesAgo_other = "{{count}}m ago" never = "never" [portal.accountLink.modal] -linkSubtitle = "Sign in to the account this server should bill against." -linkTitle = "Link your Stirling account" -reauthSubtitle = "Your session expired — sign back in to your Stirling account. Your instance stays linked." +cancel = "Cancel" +continueLink = "Continue to Stirling" +continueReauth = "Sign in again" +linkSubtitle = "Connect this server to the Stirling account it should bill against." +linkTitle = "Connect your Stirling account" +noAuthorizeUrl = "Stirling did not return somewhere to continue. Try again in a moment." +reauthSubtitle = "Your Stirling session expired. Sign in again to keep seeing usage and billing. This server stays connected either way." reauthTitle = "Sign in again" -simulateSignIn = "Simulate sign-in (dev)" +startFailed = "Could not reach Stirling to start the connection. Check this server's outbound network access, then try again." +step1 = "We send you to stirling.com to sign in. Any sign-in method works there, including Google and single sign-on." +step2 = "You check this server's address and approve it. A team owner has to do this the first time." +step3 = "Stirling brings you straight back here and finishes up." [portal.accountLink.modal.loginNotConfigured] -after = "to enable in-app linking against the hosted Stirling account." +after = "so this server can finish the connection when you come back." and = "and" before = "Set" -title = "SaaS login not configured" +title = "Stirling connection not configured" [portal.accountLink.panel] instancesSub = "Every self-hosted instance registered to this org. Revoke a credential to immediately cut off its unattended access." @@ -8823,7 +9175,6 @@ appEditor = "Editor" appProcessor = "Processor" linkAccount = "Link Stirling account" primaryNav = "Primary navigation" -switchApp = "Switch app" [portal.shell.topbar] closeNav = "Close navigation" @@ -9302,6 +9653,16 @@ automate = "Automate" config = "Config" files = "Files" +[quickNav] +editor = "Editor" +home = "Stirling" +invite = "Invite" +landmark = "Quick navigation" +noProcessorAccess = "Ask an admin for processor access" +notifications = "Notifications" +processor = "Processor" +reader = "Reader" + [read] tags = "view,open,display,read,viewer,PDF viewer,PDF reader,open PDF,view PDF,display PDF,preview,browse" @@ -11802,6 +12163,10 @@ title = "Watermark Text" image = "Image" text = "Text" +[workbench.sessionRestore] +none = "Your previous files are no longer stored on this device." +partial = "Restored {{restored}} of {{total}} files. The rest are no longer stored on this device." + [workbenchBar] activeFiles = "Active Files" annotations = "Annotations" diff --git a/frontend/editor/public/locales/es-ES/translation.toml b/frontend/editor/public/locales/es-ES/translation.toml index 73aeb53429..5fffd6eae8 100644 --- a/frontend/editor/public/locales/es-ES/translation.toml +++ b/frontend/editor/public/locales/es-ES/translation.toml @@ -3526,7 +3526,6 @@ label = "Posición Y" [crop.error] failed = "Error al recortar PDF" -invalidArea = "El área de recorte se extiende más allá de los límites del PDF" [crop.preview] title = "Selección de Área de Recorte" diff --git a/frontend/editor/public/locales/eu-ES/translation.toml b/frontend/editor/public/locales/eu-ES/translation.toml index b5a2388e67..018bfbabc5 100644 --- a/frontend/editor/public/locales/eu-ES/translation.toml +++ b/frontend/editor/public/locales/eu-ES/translation.toml @@ -3526,7 +3526,6 @@ label = "Y posizioa" [crop.error] failed = "Huts egin du PDFa mozteak" -invalidArea = "Mozketa-area PDFaren mugak baino harago doa" [crop.preview] title = "Mozketa-arearen hautapena" diff --git a/frontend/editor/public/locales/fa-IR/translation.toml b/frontend/editor/public/locales/fa-IR/translation.toml index 14a40c4694..000d039dfa 100644 --- a/frontend/editor/public/locales/fa-IR/translation.toml +++ b/frontend/editor/public/locales/fa-IR/translation.toml @@ -3526,7 +3526,6 @@ label = "موقعیت Y" [crop.error] failed = "برش PDF ناموفق بود" -invalidArea = "ناحیه برش از مرزهای PDF فراتر رفته است" [crop.preview] title = "انتخاب ناحیه برش" diff --git a/frontend/editor/public/locales/fr-FR/translation.toml b/frontend/editor/public/locales/fr-FR/translation.toml index 5550076052..e7d8a2a9ab 100644 --- a/frontend/editor/public/locales/fr-FR/translation.toml +++ b/frontend/editor/public/locales/fr-FR/translation.toml @@ -3526,7 +3526,6 @@ label = "Position Y" [crop.error] failed = "Échec du recadrage du PDF" -invalidArea = "La zone de recadrage dépasse les limites du PDF" [crop.preview] title = "Sélection de la zone de recadrage" diff --git a/frontend/editor/public/locales/ga-IE/translation.toml b/frontend/editor/public/locales/ga-IE/translation.toml index 4d3ad56168..1507ae92df 100644 --- a/frontend/editor/public/locales/ga-IE/translation.toml +++ b/frontend/editor/public/locales/ga-IE/translation.toml @@ -3526,7 +3526,6 @@ label = "Suíomh Y" [crop.error] failed = "Theip ar an PDF a bhearradh" -invalidArea = "Téann an limistéar bearrtha thar theorainneacha an PDF" [crop.preview] title = "Roghnú Limistéir Bhearrtha" diff --git a/frontend/editor/public/locales/hi-IN/translation.toml b/frontend/editor/public/locales/hi-IN/translation.toml index ae5993b387..bd769216a8 100644 --- a/frontend/editor/public/locales/hi-IN/translation.toml +++ b/frontend/editor/public/locales/hi-IN/translation.toml @@ -3526,7 +3526,6 @@ label = "Y स्थान" [crop.error] failed = "PDF क्रॉप करने में विफल" -invalidArea = "क्रॉप क्षेत्र PDF सीमाओं से बाहर जा रहा है" [crop.preview] title = "क्रॉप क्षेत्र चयन" diff --git a/frontend/editor/public/locales/hr-HR/translation.toml b/frontend/editor/public/locales/hr-HR/translation.toml index 587933825b..371c1b782c 100644 --- a/frontend/editor/public/locales/hr-HR/translation.toml +++ b/frontend/editor/public/locales/hr-HR/translation.toml @@ -3526,7 +3526,6 @@ label = "Y položaj" [crop.error] failed = "Izrezivanje PDF-a nije uspjelo" -invalidArea = "Područje izrezivanja prelazi granice PDF-a" [crop.preview] title = "Odabir područja izrezivanja" diff --git a/frontend/editor/public/locales/hu-HU/translation.toml b/frontend/editor/public/locales/hu-HU/translation.toml index 98d393c047..c8fb681564 100644 --- a/frontend/editor/public/locales/hu-HU/translation.toml +++ b/frontend/editor/public/locales/hu-HU/translation.toml @@ -3526,7 +3526,6 @@ label = "Y pozíció" [crop.error] failed = "A PDF vágása sikertelen" -invalidArea = "A vágási terület túlnyúlik a PDF határain" [crop.preview] title = "Vágási terület kiválasztása" diff --git a/frontend/editor/public/locales/id-ID/translation.toml b/frontend/editor/public/locales/id-ID/translation.toml index 15fe756aa6..83751f1dce 100644 --- a/frontend/editor/public/locales/id-ID/translation.toml +++ b/frontend/editor/public/locales/id-ID/translation.toml @@ -3526,7 +3526,6 @@ label = "Posisi Y" [crop.error] failed = "Gagal memangkas PDF" -invalidArea = "Area pangkas melampaui batas PDF" [crop.preview] title = "Pilihan Area Pangkas" diff --git a/frontend/editor/public/locales/it-IT/translation.toml b/frontend/editor/public/locales/it-IT/translation.toml index a99f00a343..592f644216 100644 --- a/frontend/editor/public/locales/it-IT/translation.toml +++ b/frontend/editor/public/locales/it-IT/translation.toml @@ -3526,7 +3526,6 @@ label = "Posizione Y" [crop.error] failed = "Impossibile ritagliare il PDF" -invalidArea = "L’area di ritaglio supera i limiti del PDF" [crop.preview] title = "Selezione area di ritaglio" diff --git a/frontend/editor/public/locales/ja-JP/translation.toml b/frontend/editor/public/locales/ja-JP/translation.toml index dcc5735dbd..a8d6730ba4 100644 --- a/frontend/editor/public/locales/ja-JP/translation.toml +++ b/frontend/editor/public/locales/ja-JP/translation.toml @@ -3526,7 +3526,6 @@ label = "Y 位置" [crop.error] failed = "PDF の切り抜きに失敗しました" -invalidArea = "切り抜き範囲が PDF の境界を超えています" [crop.preview] title = "切り抜き範囲の選択" diff --git a/frontend/editor/public/locales/ko-KR/translation.toml b/frontend/editor/public/locales/ko-KR/translation.toml index 833f710c7f..33ca27a071 100644 --- a/frontend/editor/public/locales/ko-KR/translation.toml +++ b/frontend/editor/public/locales/ko-KR/translation.toml @@ -3526,7 +3526,6 @@ label = "Y 위치" [crop.error] failed = "PDF 자르기에 실패했습니다" -invalidArea = "자르기 영역이 PDF 경계를 벗어났습니다" [crop.preview] title = "자르기 영역 선택" diff --git a/frontend/editor/public/locales/ml-ML/translation.toml b/frontend/editor/public/locales/ml-ML/translation.toml index 53e8ef302f..10235abe24 100644 --- a/frontend/editor/public/locales/ml-ML/translation.toml +++ b/frontend/editor/public/locales/ml-ML/translation.toml @@ -3526,7 +3526,6 @@ label = "Y സ്ഥാനം" [crop.error] failed = "PDF ക്രോപ്പ് ചെയ്യാൻ കഴിഞ്ഞില്ല" -invalidArea = "ക്രോപ്പ് ഏരിയ PDF അതിരുകൾക്ക് പുറത്തേക്ക് നീളുന്നു" [crop.preview] title = "ക്രോപ്പ് ഏരിയ തിരഞ്ഞെടുപ്പ്" diff --git a/frontend/editor/public/locales/nl-NL/translation.toml b/frontend/editor/public/locales/nl-NL/translation.toml index d4ada20e32..e5841842f2 100644 --- a/frontend/editor/public/locales/nl-NL/translation.toml +++ b/frontend/editor/public/locales/nl-NL/translation.toml @@ -3526,7 +3526,6 @@ label = "Y-positie" [crop.error] failed = "PDF bijsnijden mislukt" -invalidArea = "Bijsnijgebied valt buiten PDF-randen" [crop.preview] title = "Selectie bijsnijgebied" diff --git a/frontend/editor/public/locales/no-NB/translation.toml b/frontend/editor/public/locales/no-NB/translation.toml index 9402f04f22..1dd1167219 100644 --- a/frontend/editor/public/locales/no-NB/translation.toml +++ b/frontend/editor/public/locales/no-NB/translation.toml @@ -3526,7 +3526,6 @@ label = "Y-posisjon" [crop.error] failed = "Kunne ikke beskjære PDF" -invalidArea = "Beskjæringsområdet går utenfor PDF-grensene" [crop.preview] title = "Valg av beskjæringsområde" diff --git a/frontend/editor/public/locales/pl-PL/translation.toml b/frontend/editor/public/locales/pl-PL/translation.toml index 566a681f9a..c327cf2483 100644 --- a/frontend/editor/public/locales/pl-PL/translation.toml +++ b/frontend/editor/public/locales/pl-PL/translation.toml @@ -3526,7 +3526,6 @@ label = "Pozycja Y" [crop.error] failed = "Nie udało się przyciąć PDF" -invalidArea = "Obszar przycięcia wykracza poza granice PDF" [crop.preview] title = "Wybór obszaru przycięcia" diff --git a/frontend/editor/public/locales/pt-BR/translation.toml b/frontend/editor/public/locales/pt-BR/translation.toml index d89968fdfa..8455176984 100644 --- a/frontend/editor/public/locales/pt-BR/translation.toml +++ b/frontend/editor/public/locales/pt-BR/translation.toml @@ -3526,7 +3526,6 @@ label = "Posição Y" [crop.error] failed = "Falha ao recortar o PDF" -invalidArea = "A área de corte se estende além dos limites do PDF" [crop.preview] title = "Seleção da área de corte" diff --git a/frontend/editor/public/locales/pt-PT/translation.toml b/frontend/editor/public/locales/pt-PT/translation.toml index 0f9aebeff7..0b24388d75 100644 --- a/frontend/editor/public/locales/pt-PT/translation.toml +++ b/frontend/editor/public/locales/pt-PT/translation.toml @@ -3526,7 +3526,6 @@ label = "Posição Y" [crop.error] failed = "Falha ao recortar o PDF" -invalidArea = "A área de recorte excede os limites do PDF" [crop.preview] title = "Seleção da área de recorte" diff --git a/frontend/editor/public/locales/ro-RO/translation.toml b/frontend/editor/public/locales/ro-RO/translation.toml index 0f7c1555b8..2a3f4eba7d 100644 --- a/frontend/editor/public/locales/ro-RO/translation.toml +++ b/frontend/editor/public/locales/ro-RO/translation.toml @@ -3526,7 +3526,6 @@ label = "Poziția Y" [crop.error] failed = "Nu s-a putut decupa PDF-ul" -invalidArea = "Zona de decupare depășește limitele PDF-ului" [crop.preview] title = "Selecție zonă de decupare" diff --git a/frontend/editor/public/locales/ru-RU/translation.toml b/frontend/editor/public/locales/ru-RU/translation.toml index eae673f896..7d32f58a6f 100644 --- a/frontend/editor/public/locales/ru-RU/translation.toml +++ b/frontend/editor/public/locales/ru-RU/translation.toml @@ -3526,7 +3526,6 @@ label = "Положение Y" [crop.error] failed = "Не удалось обрезать PDF" -invalidArea = "Область обрезки выходит за границы PDF" [crop.preview] title = "Выбор области обрезки" diff --git a/frontend/editor/public/locales/sk-SK/translation.toml b/frontend/editor/public/locales/sk-SK/translation.toml index 1906d101d0..9383f6a866 100644 --- a/frontend/editor/public/locales/sk-SK/translation.toml +++ b/frontend/editor/public/locales/sk-SK/translation.toml @@ -3526,7 +3526,6 @@ label = "Pozícia Y" [crop.error] failed = "Nepodarilo sa orezať PDF" -invalidArea = "Oblasť orezania presahuje hranice PDF" [crop.preview] title = "Výber oblasti orezania" diff --git a/frontend/editor/public/locales/sl-SI/translation.toml b/frontend/editor/public/locales/sl-SI/translation.toml index 120606f790..c6c9cdbd54 100644 --- a/frontend/editor/public/locales/sl-SI/translation.toml +++ b/frontend/editor/public/locales/sl-SI/translation.toml @@ -3526,7 +3526,6 @@ label = "Položaj Y" [crop.error] failed = "Obrezovanje PDF-ja ni uspelo" -invalidArea = "Območje obrezovanja presega meje PDF-ja" [crop.preview] title = "Izbira območja obrezovanja" diff --git a/frontend/editor/public/locales/sr-LATN-RS/translation.toml b/frontend/editor/public/locales/sr-LATN-RS/translation.toml index 2e4cb53c7b..c639e19155 100644 --- a/frontend/editor/public/locales/sr-LATN-RS/translation.toml +++ b/frontend/editor/public/locales/sr-LATN-RS/translation.toml @@ -3526,7 +3526,6 @@ label = "Y pozicija" [crop.error] failed = "Nije uspelo isecanje PDF-a" -invalidArea = "Oblast isečka prelazi granice PDF-a" [crop.preview] title = "Izbor oblasti za isecanje" diff --git a/frontend/editor/public/locales/sv-SE/translation.toml b/frontend/editor/public/locales/sv-SE/translation.toml index bd61dbedff..ba2b56e5e0 100644 --- a/frontend/editor/public/locales/sv-SE/translation.toml +++ b/frontend/editor/public/locales/sv-SE/translation.toml @@ -3526,7 +3526,6 @@ label = "Y-position" [crop.error] failed = "Det gick inte att beskära PDF" -invalidArea = "Beskärningsområdet sträcker sig utanför PDF:ens gränser" [crop.preview] title = "Val av beskärningsområde" diff --git a/frontend/editor/public/locales/th-TH/translation.toml b/frontend/editor/public/locales/th-TH/translation.toml index 6b1c927d9f..6a294505f1 100644 --- a/frontend/editor/public/locales/th-TH/translation.toml +++ b/frontend/editor/public/locales/th-TH/translation.toml @@ -3526,7 +3526,6 @@ label = "ตำแหน่ง Y" [crop.error] failed = "ครอบตัด PDF ไม่สำเร็จ" -invalidArea = "พื้นที่ครอบตัดเกินขอบเขตของ PDF" [crop.preview] title = "การเลือกพื้นที่ครอบตัด" diff --git a/frontend/editor/public/locales/tr-TR/translation.toml b/frontend/editor/public/locales/tr-TR/translation.toml index 6ac3a86d07..ae580843e5 100644 --- a/frontend/editor/public/locales/tr-TR/translation.toml +++ b/frontend/editor/public/locales/tr-TR/translation.toml @@ -3526,7 +3526,6 @@ label = "Y Konumu" [crop.error] failed = "PDF kırpılamadı" -invalidArea = "Kırpma alanı PDF sınırlarının dışına taşıyor" [crop.preview] title = "Kırpma Alanı Seçimi" diff --git a/frontend/editor/public/locales/uk-UA/translation.toml b/frontend/editor/public/locales/uk-UA/translation.toml index d51189230b..2f9e2daeef 100644 --- a/frontend/editor/public/locales/uk-UA/translation.toml +++ b/frontend/editor/public/locales/uk-UA/translation.toml @@ -3526,7 +3526,6 @@ label = "Позиція Y" [crop.error] failed = "Не вдалося обрізати PDF" -invalidArea = "Область обрізки виходить за межі PDF" [crop.preview] title = "Вибір області обрізки" diff --git a/frontend/editor/public/locales/vi-VN/translation.toml b/frontend/editor/public/locales/vi-VN/translation.toml index a574d29833..566568dade 100644 --- a/frontend/editor/public/locales/vi-VN/translation.toml +++ b/frontend/editor/public/locales/vi-VN/translation.toml @@ -3526,7 +3526,6 @@ label = "Vị trí Y" [crop.error] failed = "Không cắt được PDF" -invalidArea = "Vùng cắt vượt quá ranh giới PDF" [crop.preview] title = "Chọn vùng cắt" diff --git a/frontend/editor/public/locales/zh-BO/translation.toml b/frontend/editor/public/locales/zh-BO/translation.toml index 895fbc5c82..7a64d7453e 100644 --- a/frontend/editor/public/locales/zh-BO/translation.toml +++ b/frontend/editor/public/locales/zh-BO/translation.toml @@ -3526,7 +3526,6 @@ label = "Y 位置" [crop.error] failed = "裁剪 PDF 失败" -invalidArea = "裁剪区域超出 PDF 边界" [crop.preview] title = "裁剪区域选择" diff --git a/frontend/editor/public/locales/zh-CN/translation.toml b/frontend/editor/public/locales/zh-CN/translation.toml index ccf73691ff..b29e3442d4 100644 --- a/frontend/editor/public/locales/zh-CN/translation.toml +++ b/frontend/editor/public/locales/zh-CN/translation.toml @@ -3526,7 +3526,6 @@ label = "Y 位置" [crop.error] failed = "裁剪 PDF 失败" -invalidArea = "裁剪区域超出 PDF 边界" [crop.preview] title = "裁剪区域选择" diff --git a/frontend/editor/public/locales/zh-TW/translation.toml b/frontend/editor/public/locales/zh-TW/translation.toml index 269d9c9d59..efb8adb8b9 100644 --- a/frontend/editor/public/locales/zh-TW/translation.toml +++ b/frontend/editor/public/locales/zh-TW/translation.toml @@ -3526,7 +3526,6 @@ label = "Y 位置" [crop.error] failed = "裁切 PDF 失敗" -invalidArea = "裁切區域超出 PDF 邊界" [crop.preview] title = "裁切區域選擇" diff --git a/frontend/editor/src-tauri/Cargo.lock b/frontend/editor/src-tauri/Cargo.lock index fe8352f5a7..a875adc740 100644 --- a/frontend/editor/src-tauri/Cargo.lock +++ b/frontend/editor/src-tauri/Cargo.lock @@ -2429,9 +2429,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.33" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" dependencies = [ "value-bag", ] diff --git a/frontend/editor/src/assets/3rdPartyLicenses.json b/frontend/editor/src/assets/3rdPartyLicenses.json index 1cd5f1f8bc..b2ec99d368 100644 --- a/frontend/editor/src/assets/3rdPartyLicenses.json +++ b/frontend/editor/src/assets/3rdPartyLicenses.json @@ -227,14 +227,14 @@ { "moduleName": "@mui/icons-material", "moduleUrl": "https://github.com/mui/material-ui", - "moduleVersion": "9.2.0", + "moduleVersion": "9.3.1", "moduleLicense": "MIT", "moduleLicenseUrl": "https://opensource.org/licenses/MIT" }, { "moduleName": "@mui/material", "moduleUrl": "https://github.com/mui/material-ui", - "moduleVersion": "9.2.0", + "moduleVersion": "9.3.1", "moduleLicense": "MIT", "moduleLicenseUrl": "https://opensource.org/licenses/MIT" }, @@ -297,7 +297,7 @@ { "moduleName": "@tanstack/react-virtual", "moduleUrl": "https://github.com/TanStack/virtual", - "moduleVersion": "3.13.23", + "moduleVersion": "3.14.10", "moduleLicense": "MIT", "moduleLicenseUrl": "https://opensource.org/licenses/MIT" }, diff --git a/frontend/editor/src/core/App.tsx b/frontend/editor/src/core/App.tsx index 81db0564b8..c51c1fb85d 100644 --- a/frontend/editor/src/core/App.tsx +++ b/frontend/editor/src/core/App.tsx @@ -1,6 +1,7 @@ import { Suspense, lazy } from "react"; import { Routes, Route } from "react-router-dom"; import { AppProviders } from "@app/components/AppProviders"; +import { AppFrame } from "@app/components/layout/AppFrame"; import { AppLayout } from "@app/components/AppLayout"; import { LoadingFallback } from "@app/components/shared/LoadingFallback"; import { ThemeProvider } from "@app/components/shared/ThemeProvider"; @@ -53,18 +54,21 @@ export default function App() { } /> - {/* All other routes need AppProviders for backend integration */} - - - - - - - } - /> + {/* The app, under a shared frame so the rail renders once outside it. */} + }> + {/* All other routes need AppProviders for backend integration */} + + + + + + + } + /> + ); diff --git a/frontend/editor/src/core/api/signing.ts b/frontend/editor/src/core/api/signing.ts new file mode 100644 index 0000000000..c58fad8aac --- /dev/null +++ b/frontend/editor/src/core/api/signing.ts @@ -0,0 +1,21 @@ +import apiClient from "@app/services/apiClient"; +import type { + SignRequestSummary, + SessionSummary, +} from "@app/types/signingSession"; + +export interface SigningSessions { + signRequests: SignRequestSummary[]; + mySessions: SessionSummary[]; +} + +/** The two lists the signing UI always needs together. */ +export async function fetchSigningSessions(): Promise { + const [requests, sessions] = await Promise.all([ + apiClient.get( + "/api/v1/security/cert-sign/sign-requests", + ), + apiClient.get("/api/v1/security/cert-sign/sessions"), + ]); + return { signRequests: requests.data, mySessions: sessions.data }; +} diff --git a/frontend/editor/src/core/components/AppProviders.tsx b/frontend/editor/src/core/components/AppProviders.tsx index 01b2e42a82..ed1194eb4b 100644 --- a/frontend/editor/src/core/components/AppProviders.tsx +++ b/frontend/editor/src/core/components/AppProviders.tsx @@ -39,6 +39,7 @@ import { RedactionProvider } from "@app/contexts/RedactionContext"; import { FormFillProvider } from "@app/tools/formFill/FormFillContext"; import { FolderFileContextProvider } from "@app/contexts/FolderFileContext"; import { FolderProvider } from "@app/contexts/FolderContext"; +import { WorkbenchSessionPersistence } from "@app/components/session/WorkbenchSessionPersistence"; // Component to initialize scarf tracking (must be inside AppConfigProvider) function ScarfTrackingInitializer() { @@ -163,6 +164,7 @@ export function AppProviders({ + {children} diff --git a/frontend/editor/src/core/components/fileManager/FileSourceButtons.tsx b/frontend/editor/src/core/components/fileManager/FileSourceButtons.tsx index 6ac3e3c91e..20ebaa191e 100644 --- a/frontend/editor/src/core/components/fileManager/FileSourceButtons.tsx +++ b/frontend/editor/src/core/components/fileManager/FileSourceButtons.tsx @@ -173,7 +173,7 @@ const FileSourceButtons: React.FC = ({ mb="xs" style={{ paddingLeft: "1rem" }} > - {t("fileManager.myFiles", "My Files")} + {t("fileSidebar.myFiles", "File library")} {buttons} diff --git a/frontend/editor/src/core/components/filesPage/FolderTreePanel.tsx b/frontend/editor/src/core/components/filesPage/FolderTreePanel.tsx index eb7f19170c..b56915b757 100644 --- a/frontend/editor/src/core/components/filesPage/FolderTreePanel.tsx +++ b/frontend/editor/src/core/components/filesPage/FolderTreePanel.tsx @@ -121,7 +121,7 @@ export function FolderTreePanel({ active }: FolderTreePanelProps) {

- {t("filesPage.myFiles", "My Files")} + {t("fileSidebar.myFiles", "File library")}
diff --git a/frontend/editor/src/core/components/filesPage/filesPageReturnRoute.ts b/frontend/editor/src/core/components/filesPage/filesPageReturnRoute.ts index 5e6292d62f..69a1bc1c53 100644 --- a/frontend/editor/src/core/components/filesPage/filesPageReturnRoute.ts +++ b/frontend/editor/src/core/components/filesPage/filesPageReturnRoute.ts @@ -1,6 +1,6 @@ /** * Stores the route the user came from when they open files into the - * workbench from My Files. Lets the workbench show a "Back to My Files" + * workbench from the file library. Lets the workbench show a "Back to File library" * affordance and return to the exact folder they were browsing. * * Persisted in sessionStorage so a hard reload keeps the return path diff --git a/frontend/editor/src/core/components/layout/AppFrame.css b/frontend/editor/src/core/components/layout/AppFrame.css new file mode 100644 index 0000000000..0cc20b30c6 --- /dev/null +++ b/frontend/editor/src/core/components/layout/AppFrame.css @@ -0,0 +1,18 @@ +/* ========== APP FRAME ========== */ +/* The rail's column, then whichever app is mounted, so a switch changes only the app. */ +.app-frame { + display: flex; + height: 100vh; + height: 100dvh; /* track mobile browser chrome */ + overflow: hidden; + background-color: var(--c-bg); +} + +/* min-width: 0 so the app shrinks instead of forcing the frame past the window. */ +.app-frame__content { + flex: 1; + min-width: 0; + height: 100%; +} + +/* The rail hides itself below the mobile breakpoint - see QuickNavRailContainer.css. */ diff --git a/frontend/editor/src/core/components/layout/AppFrame.tsx b/frontend/editor/src/core/components/layout/AppFrame.tsx new file mode 100644 index 0000000000..38fa92ecba --- /dev/null +++ b/frontend/editor/src/core/components/layout/AppFrame.tsx @@ -0,0 +1,22 @@ +import { Suspense } from "react"; +import { Outlet } from "react-router-dom"; +import { LoadingFallback } from "@app/components/shared/LoadingFallback"; +import { QuickNavHostProvider } from "@app/contexts/QuickNavHostContext"; +import { QuickNavRailHost } from "@app/components/shared/quickNav/QuickNavRailHost"; +import "@app/components/layout/AppFrame.css"; + +/** The rail renders once outside both apps; Suspense sits inside it, not above. */ +export function AppFrame() { + return ( + +
+ +
+ }> + + +
+
+
+ ); +} diff --git a/frontend/editor/src/core/components/layout/NoAppChrome.tsx b/frontend/editor/src/core/components/layout/NoAppChrome.tsx new file mode 100644 index 0000000000..04416c02f1 --- /dev/null +++ b/frontend/editor/src/core/components/layout/NoAppChrome.tsx @@ -0,0 +1,8 @@ +import { Outlet } from "react-router-dom"; +import { useSuppressQuickNavRail } from "@app/contexts/QuickNavHostContext"; + +/** Pages that aren't the app: inside the frame for its providers, but with no rail. */ +export function NoAppChrome() { + useSuppressQuickNavRail(); + return ; +} diff --git a/frontend/editor/src/core/components/layout/Workbench.module.css b/frontend/editor/src/core/components/layout/Workbench.module.css index dd2b4a12bd..22d6fdc43c 100644 --- a/frontend/editor/src/core/components/layout/Workbench.module.css +++ b/frontend/editor/src/core/components/layout/Workbench.module.css @@ -12,10 +12,8 @@ .workbenchBarReopenTab { position: absolute; top: 100%; - /* Right-align with the retract handle inside the bar: the bar's right - margin (--nav-gutter) + 1px border + 8px bar padding + the handle's own - 6px inset. */ - right: calc(var(--nav-gutter) + 15px); + /* Aligns with the retract handle: 8px bar padding plus its own 6px inset. */ + right: 14px; display: flex; align-items: center; justify-content: center; diff --git a/frontend/editor/src/core/components/layout/Workbench.tsx b/frontend/editor/src/core/components/layout/Workbench.tsx index 903c552fd2..0cac20e574 100644 --- a/frontend/editor/src/core/components/layout/Workbench.tsx +++ b/frontend/editor/src/core/components/layout/Workbench.tsx @@ -1,4 +1,4 @@ -import { useState, Suspense, lazy } from "react"; +import { useState, useEffect, useRef, Suspense, lazy } from "react"; import { useTranslation } from "react-i18next"; import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; import { Box, Loader, Center, Stack, Text } from "@mantine/core"; @@ -15,6 +15,7 @@ import { VIEWER_SUPPORTED_EXTENSIONS } from "@app/utils/fileUtils"; import { useAppConfig } from "@app/contexts/AppConfigContext"; import { useSigningOverlay } from "@app/contexts/SigningOverlayContext"; import { useCookieConsent } from "@app/hooks/useCookieConsent"; +import { useIsPhone } from "@app/hooks/useIsMobile"; import styles from "@app/components/layout/Workbench.module.css"; import WorkbenchBar from "@app/components/shared/WorkbenchBar"; @@ -58,10 +59,13 @@ export default function Workbench() { setPageEditorFunctions, setSidebarsVisible, customWorkbenchViews, + readerMode, } = useToolWorkflow(); const { handleToolSelect } = useToolWorkflow(); const { overlay: signingOverlay } = useSigningOverlay(); + // Below this width the rail, and the bell it carries, is gone. + const isPhone = useIsPhone(); // Get navigation state - this is the source of truth const { selectedTool: selectedToolId } = useNavigationState(); @@ -92,8 +96,20 @@ export default function Workbench() { !isBaseWorkbench(currentView) || // Shared signing drives the viewer from the sidebar with no file in context. (currentView === "viewer" && !!signingOverlay?.file); - const showWorkbenchBar = topControlsAvailable && hasWorkbenchContent; - const showFloatingSearch = topControlsAvailable && !hasWorkbenchContent; + // Reading hides the bar; the rail's Reader entry is the way back. + const showWorkbenchBar = + topControlsAvailable && hasWorkbenchContent && !readerMode; + const showFloatingSearch = + topControlsAvailable && !hasWorkbenchContent && !readerMode; + + // On the transition, so reading sets the toolbar's start state without locking it. + const prevReaderModeRef = useRef(readerMode); + useEffect(() => { + if (readerMode !== prevReaderModeRef.current) { + setViewerToolbarCollapsed(readerMode); + prevReaderModeRef.current = readerMode; + } + }, [readerMode]); const handlePreviewClose = () => { setPreviewFile(null); @@ -126,7 +142,7 @@ export default function Workbench() { } } - // The "My Files" workbench is available regardless of whether files are + // The file-library workbench is available regardless of whether files are // currently loaded into the workbench - it lives on top of the IDB store. if (currentView === "myFiles") { return ; @@ -249,10 +265,8 @@ export default function Workbench() { data-tour="workbench" style={{ backgroundColor: "var(--c-bg)", minWidth: 0 }} > - {/* The bell normally rides in the workbench bar. Wherever that bar is not shown - My Files, - an empty workbench, a custom view without top controls - it gets its own corner, rather - than those being the places a user cannot see that something of theirs failed. */} - {!showWorkbenchBar && ( + {/* Phone only: above that the rail carries the bell, and here no bar does. */} + {isPhone && !showWorkbenchBar && (
diff --git a/frontend/editor/src/core/components/layout/WorkspaceFrame.css b/frontend/editor/src/core/components/layout/WorkspaceFrame.css new file mode 100644 index 0000000000..05cabe95f9 --- /dev/null +++ b/frontend/editor/src/core/components/layout/WorkspaceFrame.css @@ -0,0 +1,16 @@ +/* ========== WORKSPACE FRAME ========== */ +/* Rail and sidebar side by side, full height. Shared by both apps. */ +.workspace-frame { + display: flex; + height: 100%; + flex-shrink: 0; + background-color: var(--c-bg); +} + +/* On mobile the sidebar is a fixed drawer, so the frame stops laying out. */ +@media (max-width: 48rem) { + .workspace-frame { + display: block; + height: auto; + } +} diff --git a/frontend/editor/src/core/components/notifications/NotificationBell.css b/frontend/editor/src/core/components/notifications/NotificationBell.css index d43e6755a1..fe8fe54fed 100644 --- a/frontend/editor/src/core/components/notifications/NotificationBell.css +++ b/frontend/editor/src/core/components/notifications/NotificationBell.css @@ -10,7 +10,7 @@ position: relative; padding: var(--sp-2, 0.5rem); border: none; - border-radius: var(--radius-md, 0.375rem); + border-radius: var(--radius-md); background: transparent; color: var(--c-text-muted); cursor: pointer; @@ -67,6 +67,12 @@ box-shadow: 0 10px 30px rgb(0 0 0 / 25%); } +/* The rail's bell is at the foot of a full-height column, so its panel rises beside it. */ +.notification-bell__panel--rail { + inset-inline-start: calc(var(--nav-rail-w) + var(--nav-gutter)); + inset-block-end: var(--nav-gutter); +} + .notification-bell__heading { margin: 0 0 var(--sp-2, 0.5rem); font-size: 0.875rem; diff --git a/frontend/editor/src/core/components/notifications/NotificationBell.tsx b/frontend/editor/src/core/components/notifications/NotificationBell.tsx index 41d2464722..06ee62e5c7 100644 --- a/frontend/editor/src/core/components/notifications/NotificationBell.tsx +++ b/frontend/editor/src/core/components/notifications/NotificationBell.tsx @@ -1,31 +1,16 @@ -import { - Fragment, - useEffect, - useId, - useLayoutEffect, - useRef, - useState, -} from "react"; +import { useLayoutEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { BellIcon, Button } from "@app/ui"; -import DividerWithText from "@app/components/shared/DividerWithText"; -import EncryptedPdfUnlockModal from "@app/components/shared/EncryptedPdfUnlockModal"; import { useNotifications } from "@app/hooks/useNotifications"; import { useNotificationActions } from "@app/components/notifications/notificationActions"; -import { - NotificationItem, - type PasswordPrompt, -} from "@app/components/notifications/NotificationItem"; +import { NotificationPanel } from "@app/components/notifications/NotificationPanel"; +import { useNotificationPasswordPrompt } from "@app/components/notifications/useNotificationPasswordPrompt"; import { useNotificationsAvailable } from "@app/components/notifications/useNotificationsAvailable"; import "@app/components/notifications/NotificationBell.css"; -/** - * Renders whatever the server sends without knowing which subsystem produced it or what its actions - * mean, so a new source or failure kind needs no change here. In core because both shells mount it. - */ +/** For the narrow layouts where the rail, which carries the bell, is off screen. */ export function NotificationBell() { - // A build with no notifications API gets no bell at all, rather than one that polls a - // nonexistent endpoint forever to show nothing. + // No API means no bell at all, rather than one polling an endpoint that isn't there. const available = useNotificationsAvailable(); if (!available) return null; return ; @@ -33,53 +18,17 @@ export function NotificationBell() { function MountedNotificationBell() { const { t } = useTranslation(); - const { notifications, unreadCount, documentStateFor, markAllSeen, refresh } = - useNotifications(); + const { unreadCount } = useNotifications(); const registry = useNotificationActions(); const [open, setOpen] = useState(false); const container = useRef(null); - const headingId = useId(); - // Where the new ones stop, frozen when the panel opens (opening marks everything read). - const [firstSeenId, setFirstSeenId] = useState(null); // Viewport-fixed, because the workbench bar clips its own overflow. const [anchor, setAnchor] = useState<{ top: number; right: number } | null>( null, ); - /** Held by the panel, not the row: the panel closes on any outside click. */ - const [prompt, setPrompt] = useState(null); - // Dropped as soon as the prompt closes. Never stashed, never logged. - const [password, setPassword] = useState(""); - const [promptBusy, setPromptBusy] = useState(false); - const [promptError, setPromptError] = useState(null); - - const closePrompt = () => { - setPrompt(null); - setPassword(""); - setPromptError(null); - }; - - const submitPrompt = async () => { - if (!prompt || promptBusy || password === "") return; - setPromptBusy(true); - setPromptError(null); - const outcome = await prompt.spec.run(prompt.context, password); - setPromptBusy(false); - // The prompt stays open, so a second attempt costs a keystroke rather than a re-open. - if (outcome && !outcome.ok) { - setPromptError( - outcome.message ?? - t( - "notifications.action.failed", - "That did not work. Try again in a moment.", - ), - ); - return; - } - closePrompt(); - // The incident was resolved server-side, so the list is re-read rather than patched here. - refresh(); - if (prompt.spec.closesPanel) setOpen(false); - }; + const { requestPassword, promptModal } = useNotificationPasswordPrompt(() => + setOpen(false), + ); useLayoutEffect(() => { if (!open) return; @@ -100,57 +49,17 @@ function MountedNotificationBell() { }; }, [open]); - // Opening marks them read, not closing: waiting would leave the badge lit while they read. - const toggle = () => { - setOpen((wasOpen) => { - if (!wasOpen) { - // Before marking, or there is nothing left to read. - setFirstSeenId(notifications[unreadCount]?.id ?? null); - markAllSeen(); - } - return !wasOpen; - }); - }; - - /** - * How many count as new. No boundary id means all of them were; one that has since left the list - * leaves nothing to divide on, so it reads as none rather than guessing at a row. - */ - const boundaryIndex = firstSeenId - ? notifications.findIndex((notification) => notification.id === firstSeenId) - : notifications.length; - const dividedAt = Math.max(0, boundaryIndex); - - useEffect(() => { - if (!open) return; - const closeOnOutside = (event: MouseEvent) => { - const target = event.target as HTMLElement; - if (container.current?.contains(target)) return; - // The overflow menu is portaled out, so a click in it would read as outside the panel. - if (target.closest(".notification-bell__menu")) return; - setOpen(false); - }; - const closeOnEscape = (event: KeyboardEvent) => { - if (event.key === "Escape") setOpen(false); - }; - document.addEventListener("mousedown", closeOnOutside); - document.addEventListener("keydown", closeOnEscape); - return () => { - document.removeEventListener("mousedown", closeOnOutside); - document.removeEventListener("keydown", closeOnEscape); - }; - }, [open]); - return (
{open && ( -
setOpen(false)} + registry={registry} + onRequestPassword={requestPassword} style={anchor ? { top: anchor.top, right: anchor.right } : undefined} - > -

- {t("notifications.title", "Notifications")} -

- - {notifications.length === 0 ? ( -

- {t("notifications.empty", "You're all caught up.")} -

- ) : ( -
    - {notifications.map((notification, index) => ( - - {index === 0 && dividedAt > 0 && ( -
  • - -
  • - )} - {/* Only with something on both sides: a lone "Earlier" over everything says - nothing the empty badge has not. */} - {index === dividedAt && dividedAt > 0 && ( -
  • - -
  • - )} - setOpen(false)} - onRequestPassword={setPrompt} - /> -
    - ))} -
- )} -
+ /> )} - {/* Beside the panel, not inside it: it has to outlive the panel dismissing behind it. */} - void submitPrompt()} - onSkip={closePrompt} - /> + {promptModal}
); } diff --git a/frontend/editor/src/core/components/notifications/NotificationPanel.tsx b/frontend/editor/src/core/components/notifications/NotificationPanel.tsx new file mode 100644 index 0000000000..40221b44a2 --- /dev/null +++ b/frontend/editor/src/core/components/notifications/NotificationPanel.tsx @@ -0,0 +1,146 @@ +import { Fragment, useEffect, useId, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import DividerWithText from "@app/components/shared/DividerWithText"; +import { useNotifications } from "@app/hooks/useNotifications"; +import type { ClientActionRegistry } from "@app/components/notifications/notificationActions"; +import { + NotificationItem, + type PasswordPrompt, +} from "@app/components/notifications/NotificationItem"; +import "@app/components/notifications/NotificationBell.css"; + +/** Named so a trigger in another tree can point at it with aria-controls. */ +export const NOTIFICATIONS_PANEL_ID = "quick-nav-notifications-panel"; + +export interface NotificationPanelProps { + onClose: () => void; + id?: string; + /** Passed in: its document handover has to run whether the panel is open or not. */ + registry: ClientActionRegistry; + style?: React.CSSProperties; + className?: string; + /** Hand a password-collecting action up to the host, which outlives this panel. */ + onRequestPassword: (prompt: PasswordPrompt) => void; +} + +/** Mounted only while open, since mounting is what marks everything read. */ +export function NotificationPanel({ + onClose, + registry, + id, + style, + className, + onRequestPassword, +}: NotificationPanelProps) { + const { t } = useTranslation(); + const { notifications, unreadCount, documentStateFor, markAllSeen } = + useNotifications(); + const panel = useRef(null); + const headingId = useId(); + // Frozen on open, since opening marks them all read. + const [firstSeenId, setFirstSeenId] = useState(null); + + // On mount, not on close: waiting leaves the badge lit while they read. + const marked = useRef(false); + useEffect(() => { + if (marked.current) return; + marked.current = true; + // Before marking, or there is nothing left to divide on. + setFirstSeenId(notifications[unreadCount]?.id ?? null); + markAllSeen(); + }, [notifications, unreadCount, markAllSeen]); + + // No boundary means all were new; one that has left the list means none. + const boundaryIndex = firstSeenId + ? notifications.findIndex((notification) => notification.id === firstSeenId) + : notifications.length; + const dividedAt = Math.max(0, boundaryIndex); + + // Focus goes back to the opener only if it is still inside the panel on close. + useEffect(() => { + const opener = document.activeElement as HTMLElement | null; + panel.current?.focus(); + return () => { + if (panel.current?.contains(document.activeElement)) opener?.focus(); + }; + }, []); + + useEffect(() => { + const closeOnOutside = (event: MouseEvent) => { + const target = event.target as HTMLElement; + if (panel.current?.contains(target)) return; + // A trigger closes this itself; counting it as outside would reopen it. + if (target.closest?.("[data-notifications-trigger]")) return; + // The overflow menu is portaled out, so a click in it would read as outside the panel. + if (target.closest?.(".notification-bell__menu")) return; + onClose(); + }; + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") onClose(); + }; + document.addEventListener("mousedown", closeOnOutside); + document.addEventListener("keydown", closeOnEscape); + return () => { + document.removeEventListener("mousedown", closeOnOutside); + document.removeEventListener("keydown", closeOnEscape); + }; + }, [onClose]); + + return ( + + ); +} diff --git a/frontend/editor/src/core/components/notifications/useNotificationPasswordPrompt.tsx b/frontend/editor/src/core/components/notifications/useNotificationPasswordPrompt.tsx new file mode 100644 index 0000000000..b37d74733a --- /dev/null +++ b/frontend/editor/src/core/components/notifications/useNotificationPasswordPrompt.tsx @@ -0,0 +1,69 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import EncryptedPdfUnlockModal from "@app/components/shared/EncryptedPdfUnlockModal"; +import { refreshNotificationsNow } from "@app/hooks/useNotifications"; +import type { PasswordPrompt } from "@app/components/notifications/NotificationItem"; + +/** + * The password an action asked for, owned above the panel rather than by the row that offered it: + * the panel unmounts on any outside click, which would take a prompt a row owned with it. + */ +export function useNotificationPasswordPrompt(closePanel: () => void) { + const { t } = useTranslation(); + const [prompt, setPrompt] = useState(null); + // Held only while the prompt is open, and dropped as soon as it closes. + const [password, setPassword] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const close = () => { + setPrompt(null); + setPassword(""); + setError(null); + }; + + const submit = async () => { + if (!prompt || busy || password === "") return; + setBusy(true); + setError(null); + const outcome = await prompt.spec.run(prompt.context, password); + setBusy(false); + // The prompt stays open, so a second attempt costs a keystroke rather than a re-open. + if (outcome && !outcome.ok) { + setError( + outcome.message ?? + t( + "notifications.action.failed", + "That did not work. Try again in a moment.", + ), + ); + return; + } + close(); + // The incident was resolved server-side, so the list is re-read rather than patched here. + refreshNotificationsNow(); + if (prompt.spec.closesPanel) closePanel(); + }; + + return { + requestPassword: setPrompt, + /* Rendered beside the panel, not inside it: it has to outlive the panel dismissing behind it. */ + promptModal: ( + void submit()} + onSkip={close} + /> + ), + }; +} diff --git a/frontend/editor/src/core/components/session/WorkbenchSessionPersistence.test.tsx b/frontend/editor/src/core/components/session/WorkbenchSessionPersistence.test.tsx new file mode 100644 index 0000000000..b05928a93e --- /dev/null +++ b/frontend/editor/src/core/components/session/WorkbenchSessionPersistence.test.tsx @@ -0,0 +1,475 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, waitFor, act } from "@testing-library/react"; + +const mocks = vi.hoisted(() => ({ + getLeafStirlingFileStubs: vi.fn(), + alert: vi.fn(), + setActiveFileId: vi.fn(), + restoreWorkbench: vi.fn(), + workbench: "viewer" as string, + authUser: null as { id: string } | null, + authLoading: false, + pathname: "/editor", + activeFileId: null as string | null, +})); + +vi.mock("@app/services/fileStorage", () => ({ + fileStorage: { getLeafStirlingFileStubs: mocks.getLeafStirlingFileStubs }, +})); +vi.mock("@app/components/toast", () => ({ alert: mocks.alert })); +vi.mock("@app/contexts/NavigationContext", () => ({ + useNavigationState: () => ({ workbench: mocks.workbench }), + useNavigationActions: () => ({ + actions: { restoreWorkbench: mocks.restoreWorkbench }, + }), +})); +vi.mock("react-router-dom", () => ({ + useLocation: () => ({ pathname: mocks.pathname }), +})); +vi.mock("@app/auth/UseSession", () => ({ + useAuth: () => ({ user: mocks.authUser, loading: mocks.authLoading }), +})); +vi.mock("@app/contexts/ViewerContext", () => ({ + useViewer: () => ({ + activeFileId: mocks.activeFileId, + setActiveFileId: mocks.setActiveFileId, + }), +})); + +import { WorkbenchSessionPersistence } from "@app/components/session/WorkbenchSessionPersistence"; +import { fingerprintOwner } from "@app/services/workbenchSession"; +import { + FileStoreContext, + FileActionsContext, +} from "@app/contexts/file/contexts"; +import type { StirlingFileStub } from "@app/types/fileContext"; + +const SESSION_KEY = "stirling.workbench.session"; + +function stub( + id: string, + originalFileId: string, + versionNumber = 1, +): StirlingFileStub { + return { id, originalFileId, versionNumber, name: `${id}.pdf` } as never; +} + +// A minimal stand-in for the FileContext store: mutable state plus subscribers. +function makeStore(open: StirlingFileStub[] = [], selected: string[] = []) { + const listeners = new Set<() => void>(); + const state = { + files: { + ids: open.map((s) => s.id), + byId: Object.fromEntries(open.map((s) => [s.id, s])), + }, + ui: { selectedFileIds: selected }, + }; + return { + state, + getState: () => state as never, + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + // reopenView waits on this to know the restored bytes have landed. + selectors: { + getFiles: (ids: string[]) => ids.map((id) => ({ id })), + } as never, + notify: () => listeners.forEach((listener) => listener()), + }; +} + +const actions = { + addStirlingFileStubs: vi.fn().mockResolvedValue([]), + setSelectedFiles: vi.fn(), +}; + +function mount(store: ReturnType) { + return render( + + + + + , + ); +} + +beforeEach(() => { + // The shared setup stubs crypto.subtle.digest to one constant for every input, so every account + // would fingerprint alike - and ownership is exactly what these tests are about. + vi.spyOn(globalThis.crypto.subtle, "digest").mockImplementation( + async (_algorithm: AlgorithmIdentifier, data: BufferSource) => { + const bytes = ArrayBuffer.isView(data) + ? new Uint8Array(data.buffer, data.byteOffset, data.byteLength) + : new Uint8Array(data); + let hash = 0x811c9dc5; + for (const byte of bytes) { + hash = Math.imul(hash ^ byte, 0x01000193) >>> 0; + } + const out = new Uint8Array(32); + for (let i = 0; i < out.length; i++) { + hash = Math.imul(hash ^ i, 0x01000193) >>> 0; + out[i] = hash & 0xff; + } + return out.buffer; + }, + ); + sessionStorage.clear(); + vi.clearAllMocks(); + actions.addStirlingFileStubs.mockResolvedValue([]); + mocks.getLeafStirlingFileStubs.mockResolvedValue([]); + mocks.workbench = "viewer"; + mocks.authUser = null; + mocks.authLoading = false; + mocks.pathname = "/editor"; + mocks.activeFileId = null; +}); +afterEach(() => vi.useRealTimers()); + +describe("restore", () => { + it("refills an empty workbench with each file's current leaf, in saved order", async () => { + sessionStorage.setItem( + SESSION_KEY, + JSON.stringify({ + v: 2, + fileIds: ["root-a", "root-b"], + selectedFileIds: ["root-b"], + }), + ); + // root-a forked while the user was away: v3 must win over the stale v1 leaf. + mocks.getLeafStirlingFileStubs.mockResolvedValue([ + stub("a-v1", "root-a", 1), + stub("a-v3", "root-a", 3), + stub("root-b", "root-b", 1), + ]); + + mount(makeStore()); + + await waitFor(() => + expect(actions.addStirlingFileStubs).toHaveBeenCalled(), + ); + const restored = actions.addStirlingFileStubs.mock.calls[0][0]; + expect(restored.map((s: StirlingFileStub) => s.id)).toEqual([ + "a-v3", + "root-b", + ]); + expect(actions.setSelectedFiles).toHaveBeenCalledWith(["root-b"]); + expect(mocks.alert).not.toHaveBeenCalled(); + }); + + it("does not touch a workbench that already holds files", async () => { + sessionStorage.setItem( + SESSION_KEY, + JSON.stringify({ v: 2, fileIds: ["root-a"], selectedFileIds: [] }), + ); + mount(makeStore([stub("already-open", "already-open")])); + + await act(async () => {}); + expect(actions.addStirlingFileStubs).not.toHaveBeenCalled(); + }); + + it("restores what still exists and says how much is gone", async () => { + sessionStorage.setItem( + SESSION_KEY, + JSON.stringify({ + v: 2, + fileIds: ["root-a", "gone"], + selectedFileIds: [], + }), + ); + mocks.getLeafStirlingFileStubs.mockResolvedValue([ + stub("root-a", "root-a"), + ]); + + mount(makeStore()); + + await waitFor(() => expect(mocks.alert).toHaveBeenCalled()); + expect(actions.addStirlingFileStubs.mock.calls[0][0]).toHaveLength(1); + expect(mocks.alert.mock.calls[0][0].alertType).toBe("warning"); + }); + + it("does not say 'the rest' when nothing at all could be restored", async () => { + sessionStorage.setItem( + SESSION_KEY, + JSON.stringify({ + v: 2, + fileIds: ["gone-1", "gone-2"], + selectedFileIds: [], + }), + ); + mocks.getLeafStirlingFileStubs.mockResolvedValue([]); + + mount(makeStore()); + + await waitFor(() => expect(mocks.alert).toHaveBeenCalled()); + expect(mocks.alert.mock.calls[0][0].title).toBe( + "workbench.sessionRestore.none", + ); + }); + + it("does nothing when no session was recorded", async () => { + mount(makeStore()); + await act(async () => {}); + expect(actions.addStirlingFileStubs).not.toHaveBeenCalled(); + expect(mocks.getLeafStirlingFileStubs).not.toHaveBeenCalled(); + }); + + it("reopens the document the user was viewing, at its current version", async () => { + sessionStorage.setItem( + SESSION_KEY, + JSON.stringify({ + v: 2, + fileIds: ["root-a"], + selectedFileIds: ["root-a"], + workbench: "fileEditor", + activeFileId: "root-a", + }), + ); + mocks.getLeafStirlingFileStubs.mockResolvedValue([ + stub("a-v2", "root-a", 2), + ]); + + mount(makeStore()); + + await waitFor(() => expect(mocks.setActiveFileId).toHaveBeenCalled()); + expect(mocks.setActiveFileId).toHaveBeenCalledWith("a-v2"); + expect(mocks.restoreWorkbench).toHaveBeenCalledWith("fileEditor"); + }); + + it("leaves a URL-owned view to the return path", async () => { + sessionStorage.setItem( + SESSION_KEY, + JSON.stringify({ + v: 2, + fileIds: ["root-a"], + selectedFileIds: [], + workbench: "myFiles", + }), + ); + mocks.getLeafStirlingFileStubs.mockResolvedValue([ + stub("root-a", "root-a"), + ]); + + mount(makeStore()); + + await waitFor(() => + expect(actions.addStirlingFileStubs).toHaveBeenCalled(), + ); + expect(mocks.restoreWorkbench).not.toHaveBeenCalled(); + }); +}); + +describe("whose workbench it is", () => { + // Records hold a fingerprint of the owner, never the account id. + const record = async (userId: string | null) => + sessionStorage.setItem( + SESSION_KEY, + JSON.stringify({ + v: 2, + fileIds: ["root-a"], + selectedFileIds: [], + userId: userId == null ? null : await fingerprintOwner(userId), + }), + ); + + it("does not open one user's workbench for the next person in the tab", async () => { + await record("user-a"); + mocks.authUser = { id: "user-b" }; + mocks.getLeafStirlingFileStubs.mockResolvedValue([ + stub("root-a", "root-a"), + ]); + + mount(makeStore()); + + await act(async () => {}); + expect(actions.addStirlingFileStubs).not.toHaveBeenCalled(); + // The record is theirs now - the previous person's files are gone from it, so they cannot + // resurface later in the session. + const taken = JSON.parse(sessionStorage.getItem(SESSION_KEY)!); + expect(taken.fileIds).toEqual([]); + expect(taken.userId).toBe(await fingerprintOwner("user-b")); + }); + + it("reopens it for the user who left it", async () => { + await record("user-a"); + mocks.authUser = { id: "user-a" }; + mocks.getLeafStirlingFileStubs.mockResolvedValue([ + stub("root-a", "root-a"), + ]); + + mount(makeStore()); + + await waitFor(() => + expect(actions.addStirlingFileStubs).toHaveBeenCalled(), + ); + }); + + it("waits for the session before deciding", async () => { + await record("user-a"); + mocks.authUser = null; + mocks.authLoading = true; + mocks.getLeafStirlingFileStubs.mockResolvedValue([ + stub("root-a", "root-a"), + ]); + + mount(makeStore()); + + await act(async () => {}); + // Neither restored nor discarded - who is signed in is not known yet. + expect(actions.addStirlingFileStubs).not.toHaveBeenCalled(); + expect(sessionStorage.getItem(SESSION_KEY)).not.toBeNull(); + }); +}); + +describe("a lost session that comes back", () => { + const rerenderWith = ( + view: ReturnType, + store: ReturnType, + ) => + view.rerender( + + + + + , + ); + + it("survives a blip on the identity check", async () => { + // A failed /auth/me - flaky wifi, a backend redeploy, a refreshSession() that did not land - + // briefly reads as nobody signed in. It must not be mistaken for signing out. + sessionStorage.setItem( + SESSION_KEY, + JSON.stringify({ + v: 2, + fileIds: ["root-a"], + selectedFileIds: [], + userId: await fingerprintOwner("user-a"), + }), + ); + mocks.authUser = { id: "user-a" }; + const store = makeStore([stub("f1", "f1")]); + const view = mount(store); + await act(async () => {}); + + mocks.authUser = null; + rerenderWith(view, store); + await act(async () => {}); + + expect(sessionStorage.getItem(SESSION_KEY)).not.toBeNull(); + + // ...and once the identity is back, the workbench is still being recorded. + mocks.authUser = { id: "user-a" }; + rerenderWith(view, store); + // Let the fingerprint land: writes hold off while a known identity has none yet. + await act(async () => {}); + store.state.files.ids = ["f2" as never]; + store.state.files.byId = { f2: stub("f2", "root-b") } as never; + act(() => store.notify()); + view.unmount(); + expect(JSON.parse(sessionStorage.getItem(SESSION_KEY)!).fileIds).toEqual([ + "root-b", + ]); + }); +}); + +describe("on the login screen", () => { + it("neither restores nor records - signing out must not rebuild the workbench there", async () => { + mocks.pathname = "/login"; + sessionStorage.setItem( + SESSION_KEY, + JSON.stringify({ v: 2, fileIds: ["root-a"], selectedFileIds: [] }), + ); + mocks.getLeafStirlingFileStubs.mockResolvedValue([ + stub("root-a", "root-a"), + ]); + + const store = makeStore(); + const { unmount } = mount(store); + await act(async () => {}); + expect(actions.addStirlingFileStubs).not.toHaveBeenCalled(); + + // And the unmount flush must not write either. + store.state.files.ids = ["f1" as never]; + store.state.files.byId = { f1: stub("f1", "f1") } as never; + act(() => store.notify()); + unmount(); + expect(JSON.parse(sessionStorage.getItem(SESSION_KEY)!).fileIds).toEqual([ + "root-a", + ]); + }); +}); + +describe("writer", () => { + it("mirrors the open files and selection as original ids, debounced", async () => { + vi.useFakeTimers(); + const store = makeStore(); + mount(store); + + store.state.files.ids = ["v2" as never]; + store.state.files.byId = { v2: stub("v2", "root-a", 2) } as never; + store.state.ui.selectedFileIds = ["v2"]; + act(() => store.notify()); + + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + expect(JSON.parse(sessionStorage.getItem(SESSION_KEY)!)).toMatchObject({ + fileIds: ["root-a"], + selectedFileIds: ["root-a"], + workbench: "viewer", + }); + }); + + it("records the current view, so the return lands where the user left", async () => { + vi.useFakeTimers(); + mocks.workbench = "fileEditor"; + const store = makeStore([stub("f1", "f1")]); + mount(store); + + act(() => store.notify()); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + expect(JSON.parse(sessionStorage.getItem(SESSION_KEY)!).workbench).toBe( + "fileEditor", + ); + }); + + it("writes nothing until the restore has settled", () => { + vi.useFakeTimers(); + sessionStorage.setItem( + SESSION_KEY, + JSON.stringify({ v: 2, fileIds: ["root-a"], selectedFileIds: [] }), + ); + // Restore is still awaiting storage, so this mount's empty state is not the truth. + mocks.getLeafStirlingFileStubs.mockReturnValue(new Promise(() => {})); + + const store = makeStore(); + const { unmount } = mount(store); + act(() => store.notify()); + unmount(); + + expect(JSON.parse(sessionStorage.getItem(SESSION_KEY)!).fileIds).toEqual([ + "root-a", + ]); + }); + + it("flushes on unmount, so the state at the shell switch survives", () => { + vi.useFakeTimers(); + const store = makeStore(); + const { unmount } = mount(store); + + store.state.files.ids = ["f1" as never]; + store.state.files.byId = { f1: stub("f1", "f1") } as never; + act(() => store.notify()); + unmount(); + + expect(JSON.parse(sessionStorage.getItem(SESSION_KEY)!).fileIds).toEqual([ + "f1", + ]); + }); +}); diff --git a/frontend/editor/src/core/components/session/WorkbenchSessionPersistence.tsx b/frontend/editor/src/core/components/session/WorkbenchSessionPersistence.tsx new file mode 100644 index 0000000000..86bf72502c --- /dev/null +++ b/frontend/editor/src/core/components/session/WorkbenchSessionPersistence.tsx @@ -0,0 +1,301 @@ +// The editor/processor shell switch unmounts every editor provider, and a reload starts from nothing: +// this mirrors the workbench into sessionStorage and refills an empty one from that record on mount. +import { useCallback, useContext, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + FileStoreContext, + type FileStateStore, +} from "@app/contexts/file/contexts"; +import { useFileActions } from "@app/contexts/FileContext"; +import { + useNavigationActions, + useNavigationState, +} from "@app/contexts/NavigationContext"; +import { useViewer } from "@app/contexts/ViewerContext"; +import { useAuth } from "@app/auth/UseSession"; +import { useLocation } from "react-router-dom"; +import { isAuthRoute } from "@app/constants/routes"; +import { fileStorage } from "@app/services/fileStorage"; +import { alert } from "@app/components/toast"; +import { WORKBENCH_SESSION_RESTORE } from "@app/constants/featureFlags"; +import { + beginRestoredView, + clearWorkbenchSession, + fingerprintOwner, + resumeWorkbenchSession, + endRestoredView, + isSeedableView, + originalIdOf, + readWorkbenchSession, + writeWorkbenchSession, +} from "@app/services/workbenchSession"; +import type { WorkbenchType } from "@app/types/workbench"; +import type { StirlingFileStub } from "@app/types/fileContext"; +import type { FileId } from "@app/types/file"; + +const WRITE_DEBOUNCE_MS = 300; + +// Current leaf per original id; a forked chain resolves to the highest version. +function leafByOriginalId( + leaves: StirlingFileStub[], +): Map { + const map = new Map(); + for (const leaf of leaves) { + const key = originalIdOf(leaf); + const current = map.get(key); + if (!current || (leaf.versionNumber ?? 1) > (current.versionNumber ?? 1)) { + map.set(key, leaf); + } + } + return map; +} + +/** How long to wait for the NEXT file to hydrate before giving up on holding the view. Restarted on + * each arrival, so a slow device with large documents keeps the view as long as it makes progress. */ +const SETTLE_TIMEOUT_MS = 5000; + +/** Released a beat late, so effects reacting to the same commit still see the restore in progress. */ +const RELEASE_GRACE_MS = 250; + +/** + * Reopen the recorded view, then hold the restore guard until the files have hydrated. + * + * The view is written ONCE. Re-asserting it after hydration would also overwrite a view the user + * picked in the meantime; holding the guard is what keeps HomePage's defaults off it instead. + */ +function reopenView( + store: FileStateStore, + reopen: (view: WorkbenchType) => void, + { + view, + fileCount, + token, + }: { view: WorkbenchType; fileCount: number; token: number }, +): void { + reopen(view); + const loaded = () => + store.selectors.getFiles(store.getState().files.ids).length; + + const release = () => + setTimeout(() => endRestoredView(token), RELEASE_GRACE_MS); + if (loaded() >= fileCount) { + release(); + return; + } + + let timer: ReturnType; + const stop = () => { + clearTimeout(timer); + unsubscribe(); + release(); + }; + const waitForNext = () => { + clearTimeout(timer); + timer = setTimeout(stop, SETTLE_TIMEOUT_MS); + }; + + let seen = loaded(); + const unsubscribe = store.subscribe(() => { + const now = loaded(); + if (now >= fileCount) return stop(); + // Progress, not completion: give the remaining files a fresh window. + if (now > seen) { + seen = now; + waitForNext(); + } + }); + waitForNext(); +} + +export function WorkbenchSessionPersistence() { + const store = useContext(FileStoreContext); + const { actions } = useFileActions(); + const { workbench } = useNavigationState(); + const { actions: navigationActions } = useNavigationActions(); + const { activeFileId, setActiveFileId } = useViewer(); + const { user, loading: authLoading } = useAuth(); + // Login/signup mount the editor's providers too. Nothing there is the user's workbench, so this + // records nothing and restores nothing - otherwise signing out rebuilds it on the login screen. + const onAuthRoute = isAuthRoute(useLocation().pathname); + const userId = user?.id != null ? String(user.id) : null; + // Fingerprinted, never stored raw - see fingerprintOwner. Computed asynchronously, so writes + // hold off until it lands rather than stamping the record "nobody's" and then failing its own + // ownership check. + const [owner, setOwner] = useState(null); + useEffect(() => { + if (userId == null) { + setOwner(null); + return; + } + let cancelled = false; + void fingerprintOwner(userId).then((fingerprint) => { + if (!cancelled) setOwner(fingerprint); + }); + return () => { + cancelled = true; + }; + }, [userId]); + const { t } = useTranslation(); + // Captured before the writer below can overwrite it with the empty boot state. + const [saved] = useState(readWorkbenchSession); + const restoreStarted = useRef(false); + // Until the restore has run, this mount's empty state is not the truth to record. + const restoreSettled = useRef(false); + + // Published so a build's restore setting is legible without reading the bundle. + useEffect(() => { + document.documentElement.dataset.workbenchRestore = String( + WORKBENCH_SESSION_RESTORE, + ); + }, []); + + const write = useCallback(() => { + if (!store || !restoreSettled.current) return; + // A known identity whose fingerprint has not landed yet: wait, do not stamp it as nobody's. + if (userId != null && owner == null) return; + const state = store.getState(); + const toOriginal = (id: FileId): string | null => { + const stub = state.files.byId[id]; + return stub ? originalIdOf(stub) : null; + }; + const isPresent = (id: string | null): id is string => id !== null; + writeWorkbenchSession({ + fileIds: state.files.ids.map(toOriginal).filter(isPresent), + selectedFileIds: state.ui.selectedFileIds + .map(toOriginal) + .filter(isPresent), + workbench, + userId: owner, + activeFileId: activeFileId + ? (toOriginal(activeFileId as FileId) ?? undefined) + : undefined, + }); + }, [store, workbench, activeFileId, userId, owner]); + + // Read by the file subscription, which must not resubscribe on every view change. + const writeRef = useRef(write); + writeRef.current = write; + + useEffect(() => { + if (!store || onAuthRoute) return; + // This mount is a new session: undo any suspension left by a sign-out in this page's lifetime. + resumeWorkbenchSession(); + let timer: ReturnType | undefined; + const unsubscribe = store.subscribe(() => { + clearTimeout(timer); + timer = setTimeout(() => writeRef.current(), WRITE_DEBOUNCE_MS); + }); + return () => { + clearTimeout(timer); + // Flush, so the state at the moment of the shell switch is what survives. + writeRef.current(); + unsubscribe(); + }; + }, [store, onAuthRoute]); + + // Changing view touches no file state, so the subscription above never sees it. + useEffect(() => write(), [write]); + + useEffect(() => { + if (restoreStarted.current) return; + if (onAuthRoute) return; + // Who is signed in decides whether this record is theirs to reopen, so settle that first. + if (authLoading) return; + restoreStarted.current = true; + + const nothingToDo = + !WORKBENCH_SESSION_RESTORE || + !store || + !saved || + saved.fileIds.length === 0 || + store.getState().files.ids.length > 0; + if (nothingToDo) { + restoreSettled.current = true; + return; + } + + void (async () => { + // A tab can outlive a sign-out (the logout clears it, but a 401 bounce or an expiry does + // not), and the next person to sign in here must not open the last person's documents. + const currentOwner = + userId == null ? null : await fingerprintOwner(userId); + if ((saved.userId ?? null) !== currentOwner) { + clearWorkbenchSession(); + restoreSettled.current = true; + return; + } + + // Held while the files land: they are added one at a time, and each landing re-runs the + // default-view heuristic, which must not overwrite the recorded view mid-restore. + let held: number | null = null; + try { + // Resolve each id to its CURRENT leaf: a policy or another tab may have versioned it since. + const leaves = leafByOriginalId( + await fileStorage.getLeafStirlingFileStubs(), + ); + const stubs = saved.fileIds + .map((id) => leaves.get(id)) + .filter((stub): stub is StirlingFileStub => stub !== undefined); + + if (stubs.length > 0) { + const view = isSeedableView(saved.workbench) ? saved.workbench : null; + if (view) held = beginRestoredView(); + // The same entry point My Files uses, so a restored file is governed by the same rules as + // any other file entering the workbench - including whether a policy has already run on it. + await actions.addStirlingFileStubs(stubs); + const selected = saved.selectedFileIds + .map((id) => leaves.get(id)?.id) + .filter((id): id is FileId => id !== undefined); + if (selected.length > 0) actions.setSelectedFiles(selected); + // After the files land: the viewer drops an active id it cannot find. + const active = saved.activeFileId + ? leaves.get(saved.activeFileId)?.id + : undefined; + if (active) setActiveFileId(active as string); + if (view && held !== null) { + reopenView(store, navigationActions.restoreWorkbench, { + view, + fileCount: stubs.length, + token: held, + }); + held = null; // reopenView owns the release from here. + } + } + + const missing = saved.fileIds.length - stubs.length; + if (missing > 0) { + alert({ + alertType: "warning", + title: + stubs.length === 0 + ? t( + "workbench.sessionRestore.none", + "Your previous files are no longer stored on this device.", + ) + : t( + "workbench.sessionRestore.partial", + "Restored {{restored}} of {{total}} files. The rest are no longer stored on this device.", + { restored: stubs.length, total: saved.fileIds.length }, + ), + }); + } + } finally { + if (held !== null) endRestoredView(held); + // Even a failed restore must release the writer, or the record freezes for the session. + restoreSettled.current = true; + } + })(); + }, [ + saved, + store, + actions, + navigationActions, + setActiveFileId, + t, + authLoading, + userId, + onAuthRoute, + ]); + + return null; +} diff --git a/frontend/editor/src/core/components/shared/AppBanner.css b/frontend/editor/src/core/components/shared/AppBanner.css index b3f3f9343a..8151fe916a 100644 --- a/frontend/editor/src/core/components/shared/AppBanner.css +++ b/frontend/editor/src/core/components/shared/AppBanner.css @@ -24,17 +24,10 @@ --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-bg: var(--c-bg-raised); + --app-banner-border: var(--c-border-subtle); + --app-banner-icon: var(--c-text-muted); } .app-banner--warning { @@ -86,16 +79,18 @@ 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); +.app-banner--promo .app-banner__icon { + width: 1.75rem; + height: 1.75rem; + justify-content: center; + border-radius: var(--radius-md); + background: var(--c-surface-sunken); + box-shadow: inset 0 0 0 1px var(--c-border-subtle); } -/* 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--promo.app-banner--compact .app-banner__icon { + width: 1.5rem; + height: 1.5rem; } .app-banner__actions { diff --git a/frontend/editor/src/core/components/shared/AppBanner.tsx b/frontend/editor/src/core/components/shared/AppBanner.tsx index ee0ba03741..79ff268dd2 100644 --- a/frontend/editor/src/core/components/shared/AppBanner.tsx +++ b/frontend/editor/src/core/components/shared/AppBanner.tsx @@ -11,7 +11,7 @@ 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" }, + promo: { variant: "primary", accent: "default" }, warning: { variant: "primary", accent: "warning" }, danger: { variant: "primary", accent: "danger" }, } as const; diff --git a/frontend/editor/src/core/components/shared/AppSwitch.tsx b/frontend/editor/src/core/components/shared/AppSwitch.tsx index 71d35ed5ad..fbbe85ccdb 100644 --- a/frontend/editor/src/core/components/shared/AppSwitch.tsx +++ b/frontend/editor/src/core/components/shared/AppSwitch.tsx @@ -11,12 +11,7 @@ interface AppSwitchMenuItemsProps { onSwitch: (app: AppSwitchTarget) => void; } -/** - * The editor / processor items for the app-switch menu. Rendered inside the - * BrandSwitcher's logo dropdown, which both apps use as their switcher. The - * mark is the shared , which recolours itself from the theme - * tokens, so no colour-scheme prop needs threading down here. - */ +/** The editor / processor items for an app-switch menu. */ export function AppSwitchMenuItems({ current, onSwitch, diff --git a/frontend/editor/src/core/components/shared/AppSwitcher.tsx b/frontend/editor/src/core/components/shared/AppSwitcher.tsx deleted file mode 100644 index aaf55148ef..0000000000 --- a/frontend/editor/src/core/components/shared/AppSwitcher.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Logo } from "@app/ui/Logo"; - -export interface AppSwitcherProps { - /** Icon-only brand mark for the collapsed rail. */ - collapsed?: boolean; -} - -/** - * Sidebar brand header. Core has no admin portal to switch to, so it just - * shows the Stirling logo. Builds that bundle the portal (proprietary/saas) - * shadow this with a version whose logo doubles as the editor⇄processor - * switcher. - */ -export function AppSwitcher({ collapsed }: AppSwitcherProps) { - return ( - - ); -} diff --git a/frontend/editor/src/core/components/shared/BrandSwitcher.css b/frontend/editor/src/core/components/shared/BrandSwitcher.css deleted file mode 100644 index dc1659ea15..0000000000 --- a/frontend/editor/src/core/components/shared/BrandSwitcher.css +++ /dev/null @@ -1,15 +0,0 @@ -/* Logo + app-switch dropdown, shared between the editor and the processor. - The logo itself is the trigger (its mark morphs into a chevron on hover). */ -.sui-brand-switcher { - display: flex; - align-items: center; - flex: 1; - min-width: 0; -} - -/* Tighten the ghost-button padding so the lockup sits flush like a plain logo, - and negative-margin it back so the hover surface still extends past the text. */ -.sui-brand-switcher__trigger.sui-btn { - --button-padding-x: 0.375rem; - margin-inline: -0.375rem; -} diff --git a/frontend/editor/src/core/components/shared/BrandSwitcher.stories.tsx b/frontend/editor/src/core/components/shared/BrandSwitcher.stories.tsx deleted file mode 100644 index 92deb518b2..0000000000 --- a/frontend/editor/src/core/components/shared/BrandSwitcher.stories.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { BrandSwitcher } from "@app/components/shared/BrandSwitcher"; - -const meta: Meta = { - title: "Brand/BrandSwitcher", - component: BrandSwitcher, - parameters: { layout: "centered" }, - args: { current: "processor", onSwitch: () => {} }, - argTypes: { - current: { control: "inline-radio", options: ["editor", "processor"] }, - }, -}; -export default meta; -type Story = StoryObj; - -export const Playground: Story = {}; diff --git a/frontend/editor/src/core/components/shared/BrandSwitcher.tsx b/frontend/editor/src/core/components/shared/BrandSwitcher.tsx deleted file mode 100644 index 474173fee2..0000000000 --- a/frontend/editor/src/core/components/shared/BrandSwitcher.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { useState } from "react"; -import { useTranslation } from "react-i18next"; -import { Button, Dropdown } from "@app/ui"; -import { Logo } from "@app/ui/Logo"; -import { BrandMark } from "@app/components/shared/BrandMark"; -import { - AppSwitchMenuItems, - type AppSwitchTarget, -} from "@app/components/shared/AppSwitch"; -import "@app/components/shared/BrandSwitcher.css"; - -interface BrandSwitcherProps { - /** The app this is rendered in (shown active in the menu). */ - current: AppSwitchTarget; - /** Called with the selected app (only for the non-current one). */ - onSwitch: (app: AppSwitchTarget) => void; - /** Icon-only: drop the wordmark, keep the morphing mark as the trigger. */ - collapsed?: boolean; - className?: string; -} - -/** - * Brand lockup that doubles as the editor⇄processor switcher. The whole logo - * is the dropdown trigger: on hover / focus / open the mark morphs into a - * downward chevron (see BrandMark), so no separate chevron button is needed. - * Shared so the editor and the processor present one identical header. - */ -export function BrandSwitcher({ - current, - onSwitch, - collapsed = false, - className, -}: BrandSwitcherProps) { - const { t } = useTranslation(); - const [open, setOpen] = useState(false); - - return ( -
- - - - - - - - -
- ); -} diff --git a/frontend/editor/src/core/components/shared/BrandTile.tsx b/frontend/editor/src/core/components/shared/BrandTile.tsx new file mode 100644 index 0000000000..e8ccba6db8 --- /dev/null +++ b/frontend/editor/src/core/components/shared/BrandTile.tsx @@ -0,0 +1,29 @@ +interface BrandTileProps { + /** CSS length. Omit to let the caller's CSS size it. */ + size?: string; + className?: string; +} + +/** The mark in a rounded square. Decorative: call sites carry the accessible name. */ +export function BrandTile({ size, className }: BrandTileProps) { + return ( + + + + + + ); +} diff --git a/frontend/editor/src/core/components/shared/FileSidebar.css b/frontend/editor/src/core/components/shared/FileSidebar.css index 2347d9a2b2..76db9deab4 100644 --- a/frontend/editor/src/core/components/shared/FileSidebar.css +++ b/frontend/editor/src/core/components/shared/FileSidebar.css @@ -1,7 +1,9 @@ /* ========== FILE SIDEBAR ========== */ .file-sidebar { - background-color: var(--c-bg); + /* One solid panel, with a rule only on the workbench side, so it frames the document. */ + background-color: var(--c-surface); + border-inline-end: 1px solid var(--c-border-subtle); display: flex; flex-direction: column; height: 100%; @@ -37,12 +39,19 @@ gap: 0.5rem; } -/* ---- Brand header (logo / editor⇄processor switcher) ---- */ -.file-sidebar-brand { +/* Flattened here; two classes deep to beat .sui-nav-surface without relying on order. */ +.file-sidebar .sui-nav-surface { + background: transparent; + border: 0; + border-radius: 0; +} + +/* ---- Header row (wordmark + collapse toggle) ---- */ +.file-sidebar-header { display: flex; align-items: center; - min-height: 40px; - padding: 0 0.375rem; + min-height: var(--nav-header-h); + padding: 0 var(--nav-gutter); flex-shrink: 0; } @@ -51,9 +60,9 @@ flex-shrink: 0; } -.file-sidebar[data-collapsed="true"] .file-sidebar-brand { - flex-direction: column; - gap: 0.25rem; +/* Collapsed the row holds only the toggle, so centre it. */ +.file-sidebar[data-collapsed="true"] .file-sidebar-header { + justify-content: center; padding: 0; } .file-sidebar[data-collapsed="true"] .file-sidebar-collapse-toggle { diff --git a/frontend/editor/src/core/components/shared/FileSidebar.tsx b/frontend/editor/src/core/components/shared/FileSidebar.tsx index f9337bd880..1dcf4a7301 100644 --- a/frontend/editor/src/core/components/shared/FileSidebar.tsx +++ b/frontend/editor/src/core/components/shared/FileSidebar.tsx @@ -24,7 +24,6 @@ import { useViewer } from "@app/contexts/ViewerContext"; import { useFileHandler } from "@app/hooks/useFileHandler"; 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 { @@ -32,8 +31,7 @@ import { useIndexedDBRevision, } from "@app/contexts/IndexedDBContext"; import { GoogleDriveIcon } from "@app/components/shared/CloudStorageIcons"; -import { AppSwitcher } from "@app/components/shared/AppSwitcher"; -import { SidebarToggleIcon } from "@app/components/shared/SidebarToggleIcon"; +import { SidebarHeader } from "@app/components/shared/SidebarHeader"; import type { StirlingFileStub } from "@app/types/fileContext"; import FolderOpenIcon from "@mui/icons-material/FolderOpen"; import FolderSpecialIcon from "@mui/icons-material/FolderSpecial"; @@ -78,8 +76,9 @@ import { WATCHED_FOLDERS_ENABLED } from "@app/constants/featureFlags"; import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; import "@app/components/shared/FileSidebar.css"; -const COLLAPSED_WIDTH = "3.5rem"; -const EXPANDED_WIDTH = "16.25rem"; // ~260px +// Shared with the processor sidebar via tokens, so the two cannot drift. +const COLLAPSED_WIDTH = "var(--sidebar-collapsed-w)"; +const EXPANDED_WIDTH = "var(--sidebar-w)"; // Inlined to avoid a circular import with WatchedFoldersRegistration. const WATCHED_FOLDER_VIEW_ID = "watchedFolder"; @@ -98,9 +97,11 @@ export interface FileSidebarProps { collapsed?: boolean; onToggleCollapse?: () => void; onOpenSettings?: () => void; - /** Accessible name override for the toggle button. */ + /** The quick nav rail owns the account control, so the footer drops its own row. */ + accountHoisted?: boolean; + /** Accessible name override for the collapse toggle. */ toggleAriaLabel?: string; - /** Icon override for the toggle button (e.g. back-arrow on /files). */ + /** Icon override for the collapse toggle (e.g. back-arrow on /files). */ toggleIcon?: React.ReactNode; /** Override the Open-from-computer handler (e.g. upload to /files folder). */ onUploadFiles?: (files: File[]) => void | Promise; @@ -155,11 +156,12 @@ const FileSidebar = forwardRef( collapsed = false, onToggleCollapse, onOpenSettings, + accountHoisted = false, + toggleAriaLabel, + toggleIcon, onUploadFiles, onPickGoogleDriveFiles, extraAction, - toggleAriaLabel, - toggleIcon, }, ref, ) { @@ -249,7 +251,6 @@ const FileSidebar = forwardRef( const { displayName, profilePictureUrl, isAnonymous } = useAccountIdentity(); const credits = useFreeCreditsSummary(); - const otherApp = useOtherAppSwitch(); const openPlan = useOpenPlan(); // Leaf files = user-visible files (excludes intermediate tool outputs) @@ -943,25 +944,12 @@ const FileSidebar = forwardRef(
)}
-
- - {onToggleCollapse && ( - onToggleCollapse()} - aria-label={ - toggleAriaLabel ?? - (collapsed - ? t("fileSidebar.expand", "Expand sidebar") - : t("fileSidebar.collapse", "Collapse sidebar")) - } - > - {toggleIcon ?? } - - )} -
+ {/* Box 1 — top controls (open / my files / cloud). No title. File search lives in the global super search (top bar), not here. */} @@ -984,7 +972,7 @@ const FileSidebar = forwardRef( {/* Tooltips only fire when collapsed - when expanded the visible text label below already identifies each row, so a tooltip would just flash a duplicate. Distinct icons (UploadFile for - "Open from computer" vs FolderOpen for "My Files") so the + "Open from computer" vs FolderOpen for "File library") so the collapsed rail isn't two identical folder icons either. */} ( onClick={() => { // "Open from computer" goes straight to the native OS file // picker. The full file manager (recent + drives + folders) - // is reachable via "My Files" below. + // is reachable via "File library" below. nativeFileInputRef.current?.click(); }} role="button" @@ -1080,7 +1068,7 @@ const FileSidebar = forwardRef( )} ( }} role="button" tabIndex={0} - aria-label={t("fileSidebar.myFiles", "My Files")} + aria-label={t("fileSidebar.myFiles", "File library")} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); @@ -1105,7 +1093,7 @@ const FileSidebar = forwardRef( {!collapsed && ( - {t("fileSidebar.myFiles", "My Files")} + {t("fileSidebar.myFiles", "File library")} )}
@@ -1370,15 +1358,15 @@ const FileSidebar = forwardRef( {/* Getting-started checklist, floating above the footer (SaaS only). */} - {/* Box 3 — the shared footer: credits, app switch, account row. */} + {/* Box 3 — the shared footer: credits, plan, and the account row unless hoisted. */} diff --git a/frontend/editor/src/core/components/shared/SidebarHeader.tsx b/frontend/editor/src/core/components/shared/SidebarHeader.tsx new file mode 100644 index 0000000000..1a2befa074 --- /dev/null +++ b/frontend/editor/src/core/components/shared/SidebarHeader.tsx @@ -0,0 +1,33 @@ +import { Logo } from "@app/ui/Logo"; +import { SidebarToggleButton } from "@app/components/shared/SidebarToggleButton"; + +export interface SidebarHeaderProps { + collapsed?: boolean; + onToggleCollapse?: () => void; + toggleAriaLabel?: string; + toggleIcon?: React.ReactNode; + className?: string; +} + +/** The wordmark and the collapse toggle; the brand mark sits in the rail beside it. */ +export function SidebarHeader({ + collapsed, + onToggleCollapse, + toggleAriaLabel, + toggleIcon, + className, +}: SidebarHeaderProps) { + return ( +
+ {!collapsed && } + {onToggleCollapse && ( + + )} +
+ ); +} diff --git a/frontend/editor/src/core/components/shared/SidebarToggleButton.tsx b/frontend/editor/src/core/components/shared/SidebarToggleButton.tsx new file mode 100644 index 0000000000..3a1345360b --- /dev/null +++ b/frontend/editor/src/core/components/shared/SidebarToggleButton.tsx @@ -0,0 +1,36 @@ +import { useTranslation } from "react-i18next"; +import { ActionIcon } from "@app/ui/ActionIcon"; +import { SidebarToggleIcon } from "@app/components/shared/SidebarToggleIcon"; + +export interface SidebarToggleButtonProps { + collapsed?: boolean; + onToggle: () => void; + ariaLabel?: string; + icon?: React.ReactNode; +} + +/** Opens and closes the sidebar; on /files the caller swaps in a back arrow. */ +export function SidebarToggleButton({ + collapsed, + onToggle, + ariaLabel, + icon, +}: SidebarToggleButtonProps) { + const { t } = useTranslation(); + return ( + onToggle()} + aria-label={ + ariaLabel ?? + (collapsed + ? t("fileSidebar.expand", "Expand sidebar") + : t("fileSidebar.collapse", "Collapse sidebar")) + } + > + {icon ?? } + + ); +} diff --git a/frontend/editor/src/core/components/shared/Tooltip.tsx b/frontend/editor/src/core/components/shared/Tooltip.tsx index 55c06a1533..b8128bf637 100644 --- a/frontend/editor/src/core/components/shared/Tooltip.tsx +++ b/frontend/editor/src/core/components/shared/Tooltip.tsx @@ -13,7 +13,7 @@ import { addEventListenerWithCleanup } from "@app/utils/genericUtils"; import { useTooltipPosition } from "@app/hooks/useTooltipPosition"; import { TooltipTip } from "@app/types/tips"; import { TooltipContent } from "@app/components/shared/tooltip/TooltipContent"; -import { useSidebarContext } from "@app/contexts/SidebarContext"; +import { useOptionalSidebarContext } from "@app/contexts/SidebarContext"; import { useLogoAssets } from "@app/hooks/useLogoAssets"; import styles from "@app/components/shared/tooltip/Tooltip.module.css"; import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex"; @@ -59,6 +59,29 @@ export interface TooltipProps { showCloseButton?: boolean; } +/** Split out so only tooltips with a header need the logo and the providers behind it. */ +function TooltipHeader({ + header, +}: { + header: NonNullable; +}) { + const { tooltipLogo } = useLogoAssets(); + return ( +
+
+ {header.logo || ( + Stirling PDF + )} +
+ {header.title} +
+ ); +} + export const Tooltip: React.FC = ({ sidebarTooltip = false, position, @@ -85,7 +108,6 @@ export const Tooltip: React.FC = ({ const { t } = useTranslation(); const [internalOpen, setInternalOpen] = useState(false); const [isPinned, setIsPinned] = useState(false); - const { tooltipLogo } = useLogoAssets(); const triggerRef = useRef(null); const tooltipRef = useRef(null); @@ -105,9 +127,9 @@ export const Tooltip: React.FC = ({ }, []); // Always call the hook unconditionally to satisfy React's rules of hooks. - // The context is only used when sidebarTooltip is true. - const sidebarContextValue = useSidebarContext(); - const sidebarContext = sidebarTooltip ? sidebarContextValue : null; + // Optional: the plain tooltip renders outside the provider. + const sidebarContextValue = useOptionalSidebarContext(); + const sidebarContext = sidebarTooltip ? (sidebarContextValue ?? null) : null; const isControlled = controlledOpen !== undefined; const open = (isControlled ? !!controlledOpen : internalOpen) && !disabled; @@ -443,20 +465,7 @@ export const Tooltip: React.FC = ({ } /> )} - {header && ( -
-
- {header.logo || ( - Stirling PDF - )} -
- {header.title} -
- )} + {header && } - {/* Left: optional "Back to My Files" + view switcher */} + {/* Left: optional "Back to File library" + view switcher */}
{returnRoute && hasFiles && ( <> @@ -501,7 +503,7 @@ export default function WorkbenchBar({ : "filesPage.backToMyFiles", returnRoute.label ? `Back to ${returnRoute.label}` - : "Back to My Files", + : "Back to File library", { folder: returnRoute.label ?? "" }, )} leftSection={} @@ -511,7 +513,7 @@ export default function WorkbenchBar({ ? t("filesPage.backToFolder", "Back to {{folder}}", { folder: returnRoute.label, }) - : t("filesPage.backToMyFiles", "Back to My Files")} + : t("filesPage.backToMyFiles", "Back to File library")}
@@ -603,9 +605,13 @@ export default function WorkbenchBar({ enforcingProgress={enforcingProgress} /> )} - {/* Last in the globals, so it is the rightmost control. */} -
- + {isPhone && ( + <> + {/* Last in the globals, so it is the rightmost control. */} +
+ + + )}
); diff --git a/frontend/editor/src/core/components/shared/navFooter/NavFooter.tsx b/frontend/editor/src/core/components/shared/navFooter/NavFooter.tsx index 373c91bccf..848c0cb4a5 100644 --- a/frontend/editor/src/core/components/shared/navFooter/NavFooter.tsx +++ b/frontend/editor/src/core/components/shared/navFooter/NavFooter.tsx @@ -33,6 +33,8 @@ export interface NavFooterProps { otherApp?: NavFooterAppLink | null; /** Extra rows above the account row (the self-hosted link-account CTA). */ accountExtras?: ReactNode; + /** False where the rail owns the account control, so only one avatar is drawn. */ + showAccount?: boolean; /** Icon-rail state: labels collapse to tooltips. */ collapsed?: boolean; className?: string; @@ -69,6 +71,7 @@ export function NavFooter({ onOpenPlan, otherApp, accountExtras, + showAccount = true, collapsed = false, className, }: NavFooterProps) { @@ -144,49 +147,51 @@ export function NavFooter({ }); } - rows.push({ - key: "account", - node: ( - - - - ), - }); + {!collapsed && ( + + {displayName} + + )} + {onOpenSettings && !collapsed && ( + + + + )} + + + ), + }); + } + + if (rows.length === 0) return null; return ( void; +} + +export function QuickNavBrand({ onReturnHome }: QuickNavBrandProps) { + const { t } = useTranslation(); + const label = t("quickNav.home", "Stirling"); + + return ( +
+ + + +
+ ); +} diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavHostBridge.tsx b/frontend/editor/src/core/components/shared/quickNav/QuickNavHostBridge.tsx new file mode 100644 index 0000000000..c6db541627 --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavHostBridge.tsx @@ -0,0 +1,98 @@ +import { useCallback, useMemo, useState } from "react"; +import { useAccountIdentity } from "@app/hooks/useAccountIdentity"; +import { + NotificationPanel, + NOTIFICATIONS_PANEL_ID, +} from "@app/components/notifications/NotificationPanel"; +import { useNotificationActions } from "@app/components/notifications/notificationActions"; +import { useNotificationPasswordPrompt } from "@app/components/notifications/useNotificationPasswordPrompt"; +import { useQuickNavToolReasons } from "@app/components/shared/quickNav/useQuickNavToolReasons"; +import { useNotificationsAvailable } from "@app/components/notifications/useNotificationsAvailable"; +import { useSigningBadgeCount } from "@app/hooks/signing/useSigningBadgeCount"; +import { + useRegisterQuickNavHost, + type QuickNavToolReasons, +} from "@app/contexts/QuickNavHostContext"; +import type { ToolId } from "@app/types/toolId"; + +export interface QuickNavHostBridgeProps { + portalAccess?: boolean; + readerMode?: boolean; + onSetReaderMode?: (on: boolean) => void; + onOpenSettings: () => void; + requestNavigation?: (go: () => void) => void; + onGoToDefaultState?: () => void; + onSelectTool?: (toolId: ToolId) => void; + activeTool?: ToolId | null; + /** Merged over the reasons worked out here, for what only the app can see. */ + toolReasons?: QuickNavToolReasons; +} + +/** Registers with the rail what only the app can see, and owns the notifications panel. */ +export function QuickNavHostBridge({ + portalAccess = false, + readerMode = false, + onSetReaderMode, + onOpenSettings, + requestNavigation, + onSelectTool, + activeTool = null, + onGoToDefaultState, + toolReasons, +}: QuickNavHostBridgeProps) { + const { displayName, profilePictureUrl } = useAccountIdentity(); + const signingBadge = useSigningBadgeCount(); + const notificationsAvailable = useNotificationsAvailable(); + // Built even when closed: it carries a one-shot document pickup that would sit unclaimed. + const notificationActions = useNotificationActions(); + const endpointReasons = useQuickNavToolReasons(); + const mergedToolReasons = useMemo(() => { + // An empty map from the app is silence, not an answer. + const extra = + toolReasons && Object.keys(toolReasons).length > 0 ? toolReasons : null; + if (!endpointReasons && !extra) return undefined; + return { ...endpointReasons, ...extra }; + }, [endpointReasons, toolReasons]); + const [notificationsOpen, setNotificationsOpen] = useState(false); + const closeNotifications = useCallback(() => setNotificationsOpen(false), []); + const { requestPassword, promptModal } = + useNotificationPasswordPrompt(closeNotifications); + + useRegisterQuickNavHost( + { + identity: { displayName, profilePictureUrl }, + signingBadge, + portalAccess, + readerMode, + activeTool, + notificationsOpen, + toolReasons: mergedToolReasons, + }, + { + openSettings: onOpenSettings, + requestNavigation, + selectTool: onSelectTool, + setReaderMode: onSetReaderMode, + goToDefaultState: onGoToDefaultState, + toggleNotifications: () => setNotificationsOpen((open) => !open), + }, + ); + + if (!notificationsAvailable) return null; + return ( + <> + {/* Mounted only while open, so a closed panel never subscribes to the poll. */} + {notificationsOpen && ( + + )} + {/* Outside the panel: an unlock closes it, and the prompt reports back afterwards. */} + {promptModal} + + ); +} diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRail.css b/frontend/editor/src/core/components/shared/quickNav/QuickNavRail.css new file mode 100644 index 0000000000..4d6845f592 --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRail.css @@ -0,0 +1,173 @@ +/* ========== QUICK NAV RAIL ========== */ + +.quick-nav-rail { + display: flex; + flex-direction: column; + align-items: center; + height: 100%; + width: 100%; +} + +.quick-nav-rail-group { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--quicknav-item-gap); + flex-shrink: 0; + width: 100%; +} + +/* The glyph stays at the sidebar's scale; the button fills the rail for hit area. */ +.quick-nav-rail-item { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 100%; + height: 2.25rem; + padding: 0; + border: none; + border-radius: var(--radius-md); + background: transparent; + color: var(--c-text-subtle); + cursor: pointer; + transition: + background-color var(--motion-fast), + color var(--motion-fast); +} + +.quick-nav-rail-item svg, +.quick-nav-rail-item img { + width: 1.125rem; + height: 1.125rem; + color: inherit; + fill: currentColor; +} + +/* Taller than wide (71x79), so height drives the width. */ +.quick-nav-rail-item .sui-brandmark { + width: auto; + height: 1.125rem; +} + +.quick-nav-rail-item[aria-disabled="true"] .sui-brandmark, +.quick-nav-rail-item[aria-disabled="true"] svg[viewBox="0 0 256 256"] { + filter: grayscale(1); +} + +.quick-nav-rail-item:hover { + background: var(--c-hover); + color: var(--c-text); +} + +/* Opacity rather than a colour: there is no disabled-text token. */ +.quick-nav-rail-item[aria-disabled="true"] { + opacity: 0.5; + cursor: not-allowed; +} +.quick-nav-rail-item[aria-disabled="true"]:hover { + background: transparent; + color: var(--c-primary); +} + +.quick-nav-rail-item:focus-visible { + outline: 0.125rem solid var(--c-primary); + outline-offset: -0.125rem; +} + +.quick-nav-rail-badge { + position: absolute; + top: 0.125rem; + inset-inline-end: 0.125rem; + min-width: 0.875rem; + height: 0.875rem; + padding: 0 0.1875rem; + border-radius: var(--radius-pill); + /* The solid step: 9px numerals need the darker end of the ramp. */ + background: var(--c-danger-solid); + color: var(--c-text-on-primary); + font-size: 0.5625rem; + font-weight: var(--font-weight-semibold); + line-height: 0.875rem; + text-align: center; + font-variant-numeric: tabular-nums; + pointer-events: none; +} + +.quick-nav-rail-badge[data-tone="warning"] { + background: var(--c-warning-solid); +} + +/* Top margin only: the group below supplies the other half, centring the rule. */ +.quick-nav-rail-divider { + width: 100%; + height: 0; + margin: var(--quicknav-item-gap) 0 0; + border: 0; + border-top: 1px solid var(--c-border); +} + +.quick-nav-rail-footer { + margin-top: auto; + display: flex; + flex-direction: column; + align-items: center; + gap: var(--quicknav-item-gap); + width: 100%; + flex-shrink: 0; +} + +/* ---- Brand: one header row tall, so it lines up with the sidebar's wordmark ---- */ +.quick-nav-brand { + width: 100%; + height: var(--nav-header-h); + flex-shrink: 0; + /* The bar's inset supplies part of the gap; only the remainder is added here. */ + margin-bottom: calc(var(--quicknav-item-gap) - var(--quicknav-surface-pad)); +} + +.quick-nav-brand-button { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + border: none; + background: transparent; + padding: 0; + cursor: pointer; +} + +.quick-nav-brand-button:focus-visible { + outline: 0.125rem solid var(--c-primary); + outline-offset: -0.125rem; + border-radius: var(--radius-md); +} + +/* The "on" state: a solid block with the glyph knocked out, hover owning the tints. */ +.quick-nav-rail-item[aria-current="true"], +.quick-nav-rail-item[aria-current="true"]:hover, +.quick-nav-rail-item[aria-pressed="true"], +.quick-nav-rail-item[aria-pressed="true"]:hover { + background: var(--c-text); + color: var(--c-surface); +} + +/* On a dark ground full ink is white, so mix the block back toward the surface. */ +[data-theme="dark"] .quick-nav-rail-item[aria-current="true"], +[data-theme="dark"] .quick-nav-rail-item[aria-current="true"]:hover, +[data-theme="dark"] .quick-nav-rail-item[aria-pressed="true"], +[data-theme="dark"] .quick-nav-rail-item[aria-pressed="true"]:hover, +html[data-app-theme="midnight"] .quick-nav-rail-item[aria-current="true"], +html[data-app-theme="midnight"] .quick-nav-rail-item[aria-current="true"]:hover, +html[data-app-theme="midnight"] .quick-nav-rail-item[aria-pressed="true"], +html[data-app-theme="midnight"] .quick-nav-rail-item[aria-pressed="true"]:hover, +[data-mantine-color-scheme="dark"] .quick-nav-rail-item[aria-current="true"], +[data-mantine-color-scheme="dark"] + .quick-nav-rail-item[aria-current="true"]:hover, +[data-mantine-color-scheme="dark"] .quick-nav-rail-item[aria-pressed="true"], +[data-mantine-color-scheme="dark"] + .quick-nav-rail-item[aria-pressed="true"]:hover { + background: color-mix(in srgb, var(--c-text) 80%, var(--c-surface)); + color: var(--c-surface); +} diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailAccount.css b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailAccount.css new file mode 100644 index 0000000000..c12a6a1f71 --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailAccount.css @@ -0,0 +1,29 @@ +/* The account control, pinned to the bottom of the bar. */ + +.quick-nav-rail-account { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-1); + flex-shrink: 0; + /* Further than the shortcut gap: a filled disc reads heavier than a line glyph. */ + margin-top: var(--space-2); + /* Matches the slack centring the brand mark leaves at the top. */ + padding-bottom: 0.3125rem; +} + +.quick-nav-rail-avatar-target { + display: inline-flex; +} + +/* Appearance comes from the shared Avatar; only the button reset is ours. */ +.quick-nav-rail-avatar { + border: none; + padding: 0; + user-select: none; +} + +.quick-nav-rail-avatar:focus-visible { + outline: 0.125rem solid var(--c-primary); + outline-offset: 0.125rem; +} diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailAccount.tsx b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailAccount.tsx new file mode 100644 index 0000000000..1404925de6 --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailAccount.tsx @@ -0,0 +1,45 @@ +import { useTranslation } from "react-i18next"; +import { Tooltip } from "@app/components/shared/Tooltip"; +import { Avatar } from "@app/ui/Avatar"; +import type { QuickNavIdentity } from "@app/contexts/QuickNavHostContext"; +import "@app/components/shared/quickNav/QuickNavRailAccount.css"; + +export interface QuickNavRailAccountProps { + onOpenSettings: () => void; + /** Null between apps; the disc still renders, so the bar keeps its shape. */ + identity: QuickNavIdentity | null; +} + +/** The avatar opens settings, so there is no separate gear beside it. */ +export function QuickNavRailAccount({ + onOpenSettings, + identity, +}: QuickNavRailAccountProps) { + const { t } = useTranslation(); + const displayName = + identity?.displayName ?? t("auth.displayName.user", "User"); + const profilePictureUrl = identity?.profilePictureUrl ?? null; + const label = `${displayName} — ${t("fileSidebar.openSettings", "Open settings")}`; + + return ( +
+ + {/* A span, not the Avatar: Tooltip binds by cloning its child. */} + + + + +
+ ); +} diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailBase.test.tsx b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailBase.test.tsx new file mode 100644 index 0000000000..328f2d8f4e --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailBase.test.tsx @@ -0,0 +1,123 @@ +import { describe, it, expect } from "vitest"; +import { render } from "@testing-library/react"; +import { + QuickNavRailBase, + type QuickNavEntry, +} from "@app/components/shared/quickNav/QuickNavRailBase"; + +/** The rail needs no providers. */ +function withProviders(ui: React.ReactNode) { + return <>{ui}; +} + +function entry( + id: string, + overrides: Partial = {}, +): QuickNavEntry { + return { + id, + label: id, + icon: null, + onClick: () => {}, + ...overrides, + }; +} + +const PROCESSOR = entry("processor"); +const WITHIN = [entry("files"), entry("reader")]; + +function renderRail(groups: QuickNavEntry[][]) { + const { container } = render( + withProviders(), + ); + return { + labels: [...container.querySelectorAll(".quick-nav-rail-item")].map((b) => + b.getAttribute("aria-label"), + ), + dividers: container.querySelectorAll(".quick-nav-rail-divider").length, + }; +} + +describe("QuickNavRailBase — groups", () => { + it("divides one group from the next", () => { + const { labels, dividers } = renderRail([[PROCESSOR], WITHIN]); + + expect(labels).toEqual(["processor", "files", "reader"]); + expect(dividers).toBe(1); + }); + + it("drops an empty group, and the divider with it", () => { + const { labels, dividers } = renderRail([[], WITHIN]); + + expect(labels).toEqual(["files", "reader"]); + expect(dividers).toBe(0); + }); +}); + +describe("QuickNavRailBase — entry state", () => { + it("reports on/off for a toggle and nothing for the rest", () => { + // Nothing here is a view you occupy, so only a real toggle has state. + const { container } = render( + withProviders( + , + ), + ); + + const state = [...container.querySelectorAll(".quick-nav-rail-item")].map( + (b) => [b.getAttribute("aria-label"), b.getAttribute("aria-pressed")], + ); + expect(state).toEqual([ + ["processor", null], + ["reader", "true"], + ["files", null], + ]); + expect(container.querySelectorAll("[aria-current]")).toHaveLength(0); + }); + + it("keeps a disabled entry in the tab order so its reason stays reachable", () => { + // The tooltip carrying the reason is only reachable while it can be focused. + const { container } = render( + withProviders( + , + ), + ); + + const automate = container.querySelector('[aria-label="automate"]')!; + expect(automate.getAttribute("aria-disabled")).toBe("true"); + expect(automate.hasAttribute("disabled")).toBe(false); + }); + + it("keeps an unavailable entry rendered, disabled rather than dropped", () => { + // Slots must not appear and vanish as access resolves. + const { container } = render( + withProviders( + , + ), + ); + + const processor = container.querySelector('[aria-label="processor"]'); + expect(processor).not.toBeNull(); + expect(processor?.getAttribute("aria-disabled")).toBe("true"); + // aria-disabled, not the disabled attribute: it stays focusable for its tooltip. + expect(processor?.hasAttribute("disabled")).toBe(false); + }); +}); diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailBase.tsx b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailBase.tsx new file mode 100644 index 0000000000..5c0be45db0 --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailBase.tsx @@ -0,0 +1,101 @@ +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { Tooltip } from "@app/components/shared/Tooltip"; +import "@app/components/shared/quickNav/QuickNavRail.css"; + +export type QuickNavTarget = "reader" | "editor" | "files" | "processor"; + +export interface QuickNavEntry { + id: string; + label: string; + icon: ReactNode; + /** The app you are in, drawn with an edge bar. */ + current?: boolean; + /** Only for entries that toggle something; use `current` for the app you are in. */ + pressed?: boolean; + /** Inert, with `reason` as its tooltip. Entries are dimmed, never dropped. */ + disabled?: boolean; + reason?: string; + badge?: number; + /** Popup semantics for an entry whose panel is rendered in another tree. */ + expanded?: boolean; + controls?: string; + /** "danger" waits on the user; "warning" is awareness only. */ + badgeTone?: "danger" | "warning"; + onClick: () => void; +} + +export interface QuickNavRailBaseProps { + /** Divided by a rule; empty groups are dropped. */ + groups: QuickNavEntry[][]; + footer?: ReactNode; +} + +/** Exported so footer entries reuse it rather than a lookalike. */ +export function RailButton({ + label, + icon, + pressed, + disabled, + reason, + badge, + badgeTone = "danger", + current, + expanded, + controls, + onClick, +}: Omit) { + return ( + + + + ); +} + +export function QuickNavRailBase({ groups, footer }: QuickNavRailBaseProps) { + const { t } = useTranslation(); + const populated = groups.filter((entries) => entries.length > 0); + return ( + + ); +} diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailContainer.css b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailContainer.css new file mode 100644 index 0000000000..606c2c3831 --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailContainer.css @@ -0,0 +1,38 @@ +.quick-nav-rail-container { + /* On the column, so the parts outside the nav inherit them too. */ + --quicknav-item-gap: var(--space-3); + --quicknav-surface-pad: 0.375rem; + + width: var(--nav-rail-w); + height: 100%; + flex-shrink: 0; + box-sizing: border-box; + /* On the column, not the bar, so the fill covers the gutters too. */ + background-color: var(--c-surface); + border-inline-end: 1px solid var(--c-border-subtle); + display: flex; + flex-direction: column; + padding-block: var(--nav-gutter); + padding-inline: calc(var(--nav-gutter) / 2); +} + +/* Child selector to beat .sui-nav-surface, which would win on order. */ +.quick-nav-rail-container > .quick-nav-rail-surface { + background: transparent; + border: 0; + border-radius: 0; +} + +.quick-nav-rail-surface { + flex: 1; + min-height: 0; + /* No inline padding: the bar is one target wide and would squeeze the buttons. */ + padding: var(--quicknav-surface-pad) 0; +} + +/* Below this width the sidebar is an off-canvas drawer, and the rail is just noise. */ +@media (max-width: 48rem) { + .quick-nav-rail-container { + display: none; + } +} diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailContainer.tsx b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailContainer.tsx new file mode 100644 index 0000000000..154700518e --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailContainer.tsx @@ -0,0 +1,83 @@ +import { useTranslation } from "react-i18next"; +import { NavSurface } from "@app/ui/NavSurface"; +import LocalIcon from "@app/components/shared/LocalIcon"; +import { QuickNavBrand } from "@app/components/shared/quickNav/QuickNavBrand"; +import type { QuickNavIdentity } from "@app/contexts/QuickNavHostContext"; +import { + QuickNavRailBase, + RailButton, + type QuickNavRailBaseProps, +} from "@app/components/shared/quickNav/QuickNavRailBase"; +import { QuickNavRailAccount } from "@app/components/shared/quickNav/QuickNavRailAccount"; +import { QuickNavRailNotifications } from "@app/components/shared/quickNav/QuickNavRailNotifications"; +import "@app/components/shared/quickNav/QuickNavRailContainer.css"; + +export type { + QuickNavEntry, + QuickNavTarget, +} from "@app/components/shared/quickNav/QuickNavRailBase"; + +export interface QuickNavRailContainerProps extends Omit< + QuickNavRailBaseProps, + "footer" +> { + /** The rail owns the account control, so the sidebars drop their own row. */ + onOpenSettings?: () => void; + /** Omitted in builds with no processor to invite anyone into. */ + onInvite?: () => void; + onToggleNotifications?: () => void; + notificationsOpen?: boolean; + identity?: QuickNavIdentity | null; + onReturnHome: () => void; +} + +/** The fixed-width column the rail sits in. */ +export function QuickNavRailContainer({ + onOpenSettings, + onInvite, + onToggleNotifications, + notificationsOpen, + identity = null, + onReturnHome, + ...railProps +}: QuickNavRailContainerProps) { + const { t } = useTranslation(); + return ( +
+ + + + + {onInvite && ( + + } + onClick={onInvite} + /> + )} + {onOpenSettings && ( + + )} +
+ } + /> +
+
+ ); +} diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailHost.tsx b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailHost.tsx new file mode 100644 index 0000000000..e7a1b6e8c6 --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailHost.tsx @@ -0,0 +1,182 @@ +import { useTranslation } from "react-i18next"; +import { useLocation, useNavigate } from "react-router-dom"; +import LocalIcon from "@app/components/shared/LocalIcon"; +import { QuickNavRailContainer } from "@app/components/shared/quickNav/QuickNavRailContainer"; +import type { QuickNavEntry } from "@app/components/shared/quickNav/QuickNavRailBase"; +import type { ToolId } from "@app/types/toolId"; +import { useQuickNavHost } from "@app/contexts/QuickNavHostContext"; +import { requestReaderMode } from "@app/utils/pendingReaderMode"; +import { + saveEditorReturnPath, + takeEditorReturnPath, +} from "@app/services/workbenchSession"; +import { EDITOR_BASENAME } from "@app/routes/editorBasename"; +import { PORTAL_BASENAME } from "@app/routes/portalBasename"; +import { HAS_PORTAL } from "@app/routes/hasPortal"; + +const SIZE = "1.125rem"; + +/** Entries come from the URL, not either app's context, so the rail survives a switch. */ +export function QuickNavRailHost() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { pathname } = useLocation(); + const host = useQuickNavHost(); + + const appMounted = Boolean(host?.appMounted); + + const inPortal = pathname.startsWith(PORTAL_BASENAME); + + // Only the app knows its own default state. + const returnHome = () => { + const reset = host?.actions.current?.goToDefaultState; + if (reset) reset(); + else navigate(inPortal ? PORTAL_BASENAME : EDITOR_BASENAME); + }; + + // Guarded where the app supplies a guard, so leaving mid-edit still prompts. + const go = (to: string) => { + const guard = host?.actions.current?.requestNavigation; + if (guard) guard(() => navigate(to)); + else navigate(to); + }; + + // Through the app where possible: its route only selects a tool on a fresh mount. + const openTool = (toolId: ToolId, route: string) => { + const select = host?.actions.current?.selectTool; + if (select) select(toolId); + else go(route); + }; + + const openingTool = (id: ToolId) => ({ current: host?.activeTool === id }); + + const unusable = (id: ToolId) => { + const reason = host?.toolReasons?.[id]; + return { disabled: Boolean(reason), reason }; + }; + + const apps: QuickNavEntry[] = [ + { + id: "processor", + label: t("quickNav.processor", "Processor"), + // Two literals, not a computed name: the offline icon bundle scans for `icon="..."`. + icon: inPortal ? ( + + ) : ( + + ), + current: inPortal, + disabled: HAS_PORTAL && !inPortal && !host?.portalAccess, + reason: + HAS_PORTAL && !inPortal && !host?.portalAccess + ? t("quickNav.noProcessorAccess", "Ask an admin for processor access") + : undefined, + onClick: () => { + if (inPortal) { + returnHome(); + return; + } + saveEditorReturnPath(); + go(PORTAL_BASENAME); + }, + }, + { + id: "editor", + label: t("quickNav.editor", "Editor"), + icon: inPortal ? ( + + ) : ( + + ), + current: !inPortal, + onClick: () => { + if (!inPortal) { + returnHome(); + return; + } + // Back to where you left the editor, not its front door. + navigate(takeEditorReturnPath() ?? EDITOR_BASENAME); + }, + }, + ]; + + const within: QuickNavEntry[] = [ + { + id: "files", + label: t("fileSidebar.myFiles", "File library"), + icon: ( + + ), + onClick: () => go("/files"), + }, + { + id: "reader", + label: t("quickNav.reader", "Reader"), + icon: ( + + ), + pressed: Boolean(host?.readerMode), + // From the processor there is no editor to toggle - see pendingReaderMode. + onClick: () => { + const setMode = host?.actions.current?.setReaderMode; + if (setMode) { + setMode(!host?.readerMode); + return; + } + requestReaderMode(); + go(EDITOR_BASENAME); + }, + }, + { + id: "automate", + label: t("quickAccess.automate", "Automate"), + icon: ( + + ), + ...openingTool("automate"), + ...unusable("automate"), + onClick: () => openTool("automate", "/automate"), + }, + { + id: "sharedSign", + label: t("home.sharedSign.title", "Shared Signing"), + icon: ( + + ), + badge: host?.signingBadge, + badgeTone: "warning", + ...openingTool("sharedSign"), + ...unusable("sharedSign"), + onClick: () => openTool("sharedSign", "/shared-sign"), + }, + ]; + + // Read at click time, so it's always the mounted app's. + const openSettings = () => host?.actions.current?.openSettings?.(); + + // A route that isn't the app hides the bar - see useSuppressQuickNavRail. + if (!appMounted || host?.chromeless) return null; + + return ( + go(`${PORTAL_BASENAME}/users`) + : undefined + } + onToggleNotifications={() => + host?.actions.current?.toggleNotifications?.() + } + notificationsOpen={host?.notificationsOpen} + /> + ); +} diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailNotifications.test.tsx b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailNotifications.test.tsx new file mode 100644 index 0000000000..f111e0fa75 --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailNotifications.test.tsx @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import type { AppNotification } from "@app/services/notifications"; +import { QuickNavRailNotifications } from "@app/components/shared/quickNav/QuickNavRailNotifications"; + +const fetchNotifications = vi.fn(); + +vi.mock("@app/services/notifications", () => ({ + fetchNotifications: (...args: unknown[]) => fetchNotifications(...args), +})); + +vi.mock("@app/services/localFilePresence", () => ({ + hasLocalFile: () => Promise.resolve(false), +})); + +const h = vi.hoisted(() => ({ notificationsAvailable: true })); + +vi.mock("@app/components/notifications/useNotificationsAvailable", () => ({ + useNotificationsAvailable: () => h.notificationsAvailable, +})); + +function notification(id: string): AppNotification { + return { + id, + kind: "PIPELINE_FAILED", + title: id, + createdAt: "2026-01-01T00:00:00Z", + fileId: null, + sourceId: null, + count: 1, + actions: [], + } as unknown as AppNotification; +} + +describe("QuickNavRailNotifications", () => { + beforeEach(() => { + window.localStorage.clear(); + fetchNotifications + .mockReset() + .mockResolvedValue({ notifications: [], viewerReviewsTeam: true }); + h.notificationsAvailable = true; + }); + + it("keeps out of a build with no notifications API, and off its timer", async () => { + // No endpoint to poll and nothing it could show. + h.notificationsAvailable = false; + + const { container } = render( + {}} />, + ); + + await Promise.resolve(); + expect(container.querySelector(".quick-nav-rail-item")).toBeNull(); + expect(fetchNotifications).not.toHaveBeenCalled(); + }); + + it("carries the unread count on the icon", async () => { + // A reviewer's response, so nothing is filtered for want of a local document. + fetchNotifications.mockResolvedValue({ + notifications: [notification("a"), notification("b")], + viewerReviewsTeam: true, + }); + + render( {}} />); + + expect(await screen.findByText("2")).toBeTruthy(); + }); + + it("asks the mounted app to open the panel rather than opening one itself", async () => { + const onToggle = vi.fn(); + const { container } = render( + , + ); + + await waitFor(() => expect(fetchNotifications).toHaveBeenCalled()); + fireEvent.click(container.querySelector(".quick-nav-rail-item")!); + + expect(onToggle).toHaveBeenCalledTimes(1); + // No panel of its own: a row's actions would have no workbench to act on. + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("stays pressable before an app has registered, doing nothing", async () => { + // Between apps there is briefly no handler. + const { container } = render(); + + await waitFor(() => expect(fetchNotifications).toHaveBeenCalled()); + const button = container.querySelector(".quick-nav-rail-item")!; + expect(() => fireEvent.click(button)).not.toThrow(); + }); +}); diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailNotifications.tsx b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailNotifications.tsx new file mode 100644 index 0000000000..aee294de1c --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailNotifications.tsx @@ -0,0 +1,51 @@ +import { useTranslation } from "react-i18next"; +import LocalIcon from "@app/components/shared/LocalIcon"; +import { RailButton } from "@app/components/shared/quickNav/QuickNavRailBase"; +import { useNotifications } from "@app/hooks/useNotifications"; +import { useNotificationsAvailable } from "@app/components/notifications/useNotificationsAvailable"; +import { NOTIFICATIONS_PANEL_ID } from "@app/components/notifications/NotificationPanel"; + +export interface QuickNavRailNotificationsProps { + onToggle?: () => void; + /** Whether the app's panel is open, which this button reports but does not own. */ + open?: boolean; +} + +/** The count is read here; the app owns the panel - see NotificationPanel. */ +export function QuickNavRailNotifications({ + onToggle, + open = false, +}: QuickNavRailNotificationsProps) { + // Gated before the count is read: subscribing starts the poll. + const available = useNotificationsAvailable(); + if (!available) return null; + return ; +} + +function MountedRailNotifications({ + onToggle, + open, +}: QuickNavRailNotificationsProps) { + const { t } = useTranslation(); + const { unreadCount } = useNotifications(); + + return ( + // Read by the panel's outside-click handler; on a wrapper, RailButton's props being fixed. + + + } + badge={unreadCount} + expanded={Boolean(open)} + controls={NOTIFICATIONS_PANEL_ID} + onClick={() => onToggle?.()} + /> + + ); +} diff --git a/frontend/editor/src/core/components/shared/quickNav/useQuickNavToolReasons.test.tsx b/frontend/editor/src/core/components/shared/quickNav/useQuickNavToolReasons.test.tsx new file mode 100644 index 0000000000..6295ddde66 --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/useQuickNavToolReasons.test.tsx @@ -0,0 +1,139 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { useQuickNavToolReasons } from "@app/components/shared/quickNav/useQuickNavToolReasons"; + +const h = vi.hoisted(() => ({ + endpointStatus: {} as Record, + endpointDetails: {} as Record, + loading: false, + configLoading: false, + groupSigningEnabled: true, +})); + +vi.mock("@app/hooks/useEndpointConfig", () => ({ + useMultipleEndpointsEnabled: () => ({ + endpointStatus: h.endpointStatus, + endpointDetails: h.endpointDetails, + loading: h.loading, + error: null, + refetch: async () => {}, + }), +})); + +vi.mock("@app/contexts/AppConfigContext", () => ({ + useAppConfig: () => ({ + config: null, + loading: h.configLoading, + error: null, + refetch: async () => {}, + }), +})); + +vi.mock("@app/hooks/useGroupSigningEnabled", () => ({ + useGroupSigningEnabled: () => h.groupSigningEnabled, +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (_key: string, fallback?: string) => fallback ?? _key, + }), +})); + +describe("useQuickNavToolReasons", () => { + beforeEach(() => { + window.localStorage.clear(); + h.endpointStatus = {}; + h.endpointDetails = {}; + h.loading = false; + h.configLoading = false; + h.groupSigningEnabled = true; + }); + + it("admits it does not know rather than reporting nothing wrong", () => { + h.loading = true; + h.endpointStatus = { automate: false }; + + expect( + renderHook(() => useQuickNavToolReasons()).result.current, + ).toBeNull(); + }); + + it("reports what it last knew while the answer is being fetched again", () => { + // Each app has its own query cache, and a reload has none at all. + h.endpointStatus = { automate: false }; + h.endpointDetails = { automate: { reason: "CONFIG" } }; + renderHook(() => useQuickNavToolReasons()); + + h.loading = true; + h.endpointStatus = {}; + h.endpointDetails = {}; + + const { result } = renderHook(() => useQuickNavToolReasons()); + expect(result.current?.automate).toBe("Disabled by server administrator"); + }); + + it("forgets a reason once the server stops reporting it", () => { + h.endpointStatus = { automate: false }; + h.endpointDetails = { automate: { reason: "CONFIG" } }; + renderHook(() => useQuickNavToolReasons()); + + h.endpointStatus = { automate: true }; + h.endpointDetails = {}; + expect(renderHook(() => useQuickNavToolReasons()).result.current).toEqual( + {}, + ); + + // The cleared state, not the old reason, is what a reload reads back. + h.loading = true; + expect(renderHook(() => useQuickNavToolReasons()).result.current).toEqual( + {}, + ); + }); + + it("says nothing about an endpoint the server reports as available", () => { + h.endpointStatus = { automate: true }; + + expect(renderHook(() => useQuickNavToolReasons()).result.current).toEqual( + {}, + ); + }); + + it("blames the administrator when the endpoint was turned off by config", () => { + h.endpointStatus = { automate: false }; + h.endpointDetails = { automate: { reason: "CONFIG" } }; + + const { result } = renderHook(() => useQuickNavToolReasons()); + // The tool picker's label with its trailing colon stripped. + expect(result.current?.automate).toBe("Disabled by server administrator"); + }); + + it("blames the missing dependency when that is what the server said", () => { + h.endpointStatus = { automate: false }; + h.endpointDetails = { automate: { reason: "DEPENDENCY" } }; + + const { result } = renderHook(() => useQuickNavToolReasons()); + expect(result.current?.automate).toBe( + "Unavailable - required tool missing on server", + ); + }); + + it("greys out shared signing when the server has the feature switched off", () => { + // A whole feature rather than a removable endpoint, so it has its own signal. + h.groupSigningEnabled = false; + + const { result } = renderHook(() => useQuickNavToolReasons()); + expect(result.current?.sharedSign).toBe( + "Collaborative signing isn't enabled on this server", + ); + }); + + it("waits for the config before judging shared signing", () => { + // The config loads separately and reads as "off" before it arrives. + h.configLoading = true; + h.groupSigningEnabled = false; + + expect( + renderHook(() => useQuickNavToolReasons()).result.current, + ).toBeNull(); + }); +}); diff --git a/frontend/editor/src/core/components/shared/quickNav/useQuickNavToolReasons.ts b/frontend/editor/src/core/components/shared/quickNav/useQuickNavToolReasons.ts new file mode 100644 index 0000000000..235f4d4f08 --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/useQuickNavToolReasons.ts @@ -0,0 +1,131 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useMultipleEndpointsEnabled } from "@app/hooks/useEndpointConfig"; +import { useAppConfig } from "@app/contexts/AppConfigContext"; +import { useGroupSigningEnabled } from "@app/hooks/useGroupSigningEnabled"; +import { getDisabledLabel } from "@app/components/tools/fullscreen/shared"; +import type { QuickNavToolReasons } from "@app/contexts/QuickNavHostContext"; +import type { ToolId } from "@app/types/toolId"; + +const ENTRY_ENDPOINTS = { + automate: ["automate"], +} satisfies Partial>; + +// Object.keys widens to string, which a tool-id-keyed record can't be indexed by. +const ENDPOINT_ENTRIES = Object.keys( + ENTRY_ENDPOINTS, +) as (keyof typeof ENTRY_ENDPOINTS)[]; + +/** Shared signing is a feature toggle rather than an endpoint, so it has its own cause. */ +type EndpointCause = "missingDependency" | "disabledByAdmin"; +type Cause = EndpointCause | "groupSigningOff"; +type Causes = Partial>; +const CAUSES: Cause[] = [ + "missingDependency", + "disabledByAdmin", + "groupSigningOff", +]; + +/** Causes, not sentences, so a language change can't resurrect stale text. */ +const STORAGE_KEY = "stirling.quickNav.toolCauses"; + +function readRemembered(): Causes | null { + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return null; + } + const known = Object.entries(parsed as Record).filter( + ([, cause]) => CAUSES.includes(cause as Cause), + ) as [ToolId, Cause][]; + return Object.fromEntries(known); + } catch { + return null; + } +} + +function remember(causes: Causes): void { + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(causes)); + } catch { + // Won't survive the next reload. + } +} + +function causesFor( + endpointStatus: Record, + endpointDetails: Record, +): Causes { + const causes: Causes = {}; + for (const entry of ENDPOINT_ENTRIES) { + const off = ENTRY_ENDPOINTS[entry].filter( + (name) => endpointStatus[name] === false, + ); + if (off.length === 0) continue; + causes[entry] = off.some( + (name) => endpointDetails[name]?.reason === "DEPENDENCY", + ) + ? "missingDependency" + : "disabledByAdmin"; + } + return causes; +} + +/** Why a rail entry can't be used. Null means no answer yet, an empty map nothing wrong. */ +export function useQuickNavToolReasons(): QuickNavToolReasons | null { + const { t } = useTranslation(); + const endpoints = useMemo(() => Object.values(ENTRY_ENDPOINTS).flat(), []); + const { endpointStatus, endpointDetails, loading } = + useMultipleEndpointsEnabled(endpoints); + + // Read once: later reads would fight the live answer. + const [remembered] = useState(readRemembered); + + const { loading: configLoading } = useAppConfig(); + const groupSigningEnabled = useGroupSigningEnabled(); + + const live = useMemo(() => { + // A half answer would dim entries it can't see yet. + if (loading || configLoading) return null; + const causes = causesFor(endpointStatus, endpointDetails); + if (!groupSigningEnabled) causes.sharedSign = "groupSigningOff"; + return causes; + }, [ + loading, + configLoading, + endpointStatus, + endpointDetails, + groupSigningEnabled, + ]); + + // Keyed on contents: the object is rebuilt every render. + const liveKey = live ? JSON.stringify(live) : null; + useEffect(() => { + if (liveKey) remember(JSON.parse(liveKey) as Causes); + }, [liveKey]); + + const causes = live ?? remembered; + + return useMemo(() => { + if (!causes) return null; + const reasons: QuickNavToolReasons = {}; + for (const entry of Object.keys(causes) as ToolId[]) { + const cause = causes[entry]; + if (cause === "groupSigningOff") { + // The tool's own wording, minus the full stop. + reasons[entry] = t( + "sharedSign.disabledBody", + "Collaborative signing isn't enabled on this server.", + ).replace(/\.\s*$/, ""); + continue; + } + if (!cause) continue; + // These labels normally sit in front of a tool name, hence the trailing colon. + const { key, fallback } = getDisabledLabel(cause); + reasons[entry] = t(key, fallback).replace(/:\s*$/, ""); + } + return reasons; + }, [causes, t]); +} diff --git a/frontend/editor/src/core/components/tools/RightSidebar.tsx b/frontend/editor/src/core/components/tools/RightSidebar.tsx index d66d30c442..031ecd8e20 100644 --- a/frontend/editor/src/core/components/tools/RightSidebar.tsx +++ b/frontend/editor/src/core/components/tools/RightSidebar.tsx @@ -115,7 +115,7 @@ export default function RightSidebar() { const computedWidth = () => { if (isMobile) return "100%"; - if (!isPanelVisible) return "3.5rem"; + if (!isPanelVisible) return "var(--nav-rail-w)"; return expandedWidth; }; @@ -181,7 +181,8 @@ export default function RightSidebar() { content={tool.name} position="left" arrow - delay={300} + // No delay: collapsed to icons, the tooltip is the only label. + delay={0} > { const { t } = useTranslation(); + const stampImageWithUrl = useFileWithUrl( + parameters.stampType === "image" ? (parameters.stampImage ?? null) : null, + ); return ( @@ -679,10 +683,10 @@ const StampSetupSettings = ({ > {t("chooseFile", "Choose File")} - {parameters.stampImage && ( + {parameters.stampImage && stampImageWithUrl && ( Selected stamp image diff --git a/frontend/editor/src/core/components/tools/crop/CropSettings.tsx b/frontend/editor/src/core/components/tools/crop/CropSettings.tsx index 82516059b0..0229613959 100644 --- a/frontend/editor/src/core/components/tools/crop/CropSettings.tsx +++ b/frontend/editor/src/core/components/tools/crop/CropSettings.tsx @@ -1,13 +1,5 @@ import { useState, useEffect } from "react"; -import { - Stack, - Text, - Box, - Group, - Center, - Alert, - Checkbox, -} from "@mantine/core"; +import { Stack, Text, Box, Group, Center, Checkbox } from "@mantine/core"; import { ActionIcon } from "@app/ui/ActionIcon"; import { useTranslation } from "react-i18next"; import RestartAltIcon from "@mui/icons-material/RestartAlt"; @@ -161,7 +153,6 @@ const CropSettings = ({ parameters, disabled = false }: CropSettingsProps) => { ); } - const isCropValid = parameters.isCropAreaValid(pdfBounds); const isFullCrop = parameters.isFullPDFCrop(pdfBounds); return ( @@ -239,18 +230,6 @@ const CropSettings = ({ parameters, disabled = false }: CropSettingsProps) => { showAutomationInfo={false} /> )} - - {/* Validation Alert - Only show when autoCrop is false */} - {!parameters.parameters.autoCrop && !isCropValid && ( - - - {t( - "crop.error.invalidArea", - "Crop area extends beyond PDF boundaries", - )} - - - )} ); }; diff --git a/frontend/editor/src/core/components/tools/shared/ReviewToolStep.tsx b/frontend/editor/src/core/components/tools/shared/ReviewToolStep.tsx index ef596c2a0d..8f86faff17 100644 --- a/frontend/editor/src/core/components/tools/shared/ReviewToolStep.tsx +++ b/frontend/editor/src/core/components/tools/shared/ReviewToolStep.tsx @@ -14,6 +14,25 @@ import { saveOperationResults } from "@app/services/operationResultsSaveService" import { useFileActions, useFileSelectors } from "@app/contexts/FileContext"; import i18n from "@app/i18n"; +/** + * Nearest scrolling ancestor - in the right rail that is the tool panel's + * ScrollArea viewport, whose overflow is `scroll`, not `auto`. + */ +function findScrollParent(element: HTMLElement): HTMLElement | null { + let node = element.parentElement; + while (node) { + const { overflowY } = getComputedStyle(node); + if ( + /(auto|scroll|overlay)/.test(overflowY) && + node.scrollHeight > node.clientHeight + ) { + return node; + } + node = node.parentElement; + } + return null; +} + export interface ReviewToolStepProps { isVisible: boolean; operation: ToolOperationHook; @@ -81,26 +100,37 @@ function ReviewStepContent({ } }; - // Auto-scroll to bottom when content appears + // Reveal the results when they appear, or the download button lands below the + // fold behind a tall settings step and reads as missing. useEffect(() => { - if ( - stepRef.current && - (previewFiles.length > 0 || - operation.downloadUrl || - operation.errorMessage) - ) { - const scrollableContainer = stepRef.current.closest( - '[style*="overflow: auto"]', - ) as HTMLElement; - if (scrollableContainer) { - setTimeout(() => { - scrollableContainer.scrollTo({ - top: scrollableContainer.scrollHeight, - behavior: "smooth", - }); - }, 100); // Small delay to ensure content is rendered + const hasContent = + previewFiles.length > 0 || + operation.downloadUrl || + operation.errorMessage; + if (!stepRef.current || !hasContent) return; + + // Small delay so the step has been laid out before it is measured. + const timer = setTimeout(() => { + const step = stepRef.current; + const scroller = step && findScrollParent(step); + if (!step || !scroller) return; + + const stepRect = step.getBoundingClientRect(); + const viewRect = scroller.getBoundingClientRect(); + // Move the least that brings the step into view, and only ever the panel + // itself - scrollIntoView() drags every ancestor and unpins the header. + const delta = Math.min( + stepRect.top - viewRect.top, + stepRect.bottom - viewRect.bottom, + ); + if (delta > 1) { + scroller.scrollTo({ + top: scroller.scrollTop + delta, + behavior: "smooth", + }); } - } + }, 100); + return () => clearTimeout(timer); }, [previewFiles.length, operation.downloadUrl, operation.errorMessage]); return ( diff --git a/frontend/editor/src/core/components/tools/shared/createToolFlow.module.css b/frontend/editor/src/core/components/tools/shared/createToolFlow.module.css new file mode 100644 index 0000000000..bc7abb630d --- /dev/null +++ b/frontend/editor/src/core/components/tools/shared/createToolFlow.module.css @@ -0,0 +1,22 @@ +/* The tool panel scrolls as a single column, so a tall settings step (PDF/UA is + the worst offender) pushes the primary action below the fold with nothing to + say it is there. Pinning keeps it reachable; when the flow already fits, + sticky is inert and nothing moves. */ +.executeFooter { + position: sticky; + bottom: 0; + z-index: 2; + background: var(--c-surface, var(--mantine-color-body)); + display: flex; + flex-direction: column; + gap: var(--mantine-spacing-sm); + /* Bleed across the flow's own padding so content cannot scroll through the + gutters beside the button. The margin cancels the padding, so an unpinned + footer still sits exactly where it did. */ + margin-inline: calc(var(--mantine-spacing-sm) * -1); + padding-inline: var(--mantine-spacing-sm); + /* Paint-only skirt covering the strip below the button once pinned; a padding + here would change the resting layout. */ + box-shadow: 0 var(--mantine-spacing-sm) 0 0 + var(--c-surface, var(--mantine-color-body)); +} diff --git a/frontend/editor/src/core/components/tools/shared/createToolFlow.tsx b/frontend/editor/src/core/components/tools/shared/createToolFlow.tsx index aafa9b3ea1..4b03de0a4b 100644 --- a/frontend/editor/src/core/components/tools/shared/createToolFlow.tsx +++ b/frontend/editor/src/core/components/tools/shared/createToolFlow.tsx @@ -13,6 +13,7 @@ import { import { StirlingFile } from "@app/types/fileContext"; import type { TooltipTip } from "@app/types/tips"; import type { ExecuteDisabledReason } from "@app/hooks/tools/shared/toolOperationTypes"; +import classes from "@app/components/tools/shared/createToolFlow.module.css"; export interface FilesStepConfig { selectedFiles: StirlingFile[]; @@ -152,8 +153,14 @@ export function createToolFlow( : eb.paramsValid === false ? "invalidParams" : null; + // Pin the action only while it is the last thing in the flow; with a + // review below it, a sticky footer would float over the results. return ( - <> +
( data-tour="run-button" /> {config.belowExecuteButton} - +
); })()} diff --git a/frontend/editor/src/core/components/viewer/useViewerWorkbenchBarButtons.tsx b/frontend/editor/src/core/components/viewer/useViewerWorkbenchBarButtons.tsx index 86b0e7ff39..5cb4b3e59b 100644 --- a/frontend/editor/src/core/components/viewer/useViewerWorkbenchBarButtons.tsx +++ b/frontend/editor/src/core/components/viewer/useViewerWorkbenchBarButtons.tsx @@ -185,23 +185,24 @@ export function useViewerWorkbenchBarButtons( section: "top" as const, order: 10, render: ({ disabled }) => ( - - - -
+ +
+ {/* Inside the Popover: Tooltip binds by cloning, and Popover passes no ref on. */} + -
-
- -
- -
-
- - + +
+
+ +
+ +
+
+
), }, { diff --git a/frontend/editor/src/core/constants/featureFlags.ts b/frontend/editor/src/core/constants/featureFlags.ts index a60770ac3a..8981dce2d6 100644 --- a/frontend/editor/src/core/constants/featureFlags.ts +++ b/frontend/editor/src/core/constants/featureFlags.ts @@ -11,3 +11,6 @@ // Annotated as `boolean` (not the literal `false`) so call sites aren't treated // as constant/unreachable conditions by the type checker and linter. export const WATCHED_FOLDERS_ENABLED: boolean = false; + +// Refill an empty workbench from the tab's last session (survives a provider remount or a reload). +export const WORKBENCH_SESSION_RESTORE: boolean = true; diff --git a/frontend/editor/src/core/contexts/NavigationContext.tsx b/frontend/editor/src/core/contexts/NavigationContext.tsx index a7cb95d349..f158991378 100644 --- a/frontend/editor/src/core/contexts/NavigationContext.tsx +++ b/frontend/editor/src/core/contexts/NavigationContext.tsx @@ -94,6 +94,9 @@ export interface NavigationWarningHandlers { // Navigation context actions interface export interface NavigationContextActions { setWorkbench: (workbench: WorkbenchType) => void; + /** Reopen a view the user already had, bypassing the unsaved-changes prompt that + * guards a user-initiated switch - a restore is not the user leaving anything. */ + restoreWorkbench: (workbench: WorkbenchType) => void; setSelectedTool: (toolId: ToolId | null) => void; setToolAndWorkbench: ( toolId: ToolId | null, @@ -221,6 +224,10 @@ export const NavigationProvider: React.FC<{ [state.workbench, state.hasUnsavedChanges], ); + const restoreWorkbench = useCallback((workbench: WorkbenchType) => { + dispatch({ type: "SET_WORKBENCH", payload: { workbench } }); + }, []); + const setSelectedTool = useCallback((toolId: ToolId | null) => { dispatch({ type: "SET_SELECTED_TOOL", payload: { toolId } }); }, []); @@ -402,6 +409,7 @@ export const NavigationProvider: React.FC<{ const actions: NavigationContextActions = useMemo( () => ({ setWorkbench, + restoreWorkbench, setSelectedTool, setToolAndWorkbench, setHasUnsavedChanges, @@ -419,6 +427,7 @@ export const NavigationProvider: React.FC<{ }), [ setWorkbench, + restoreWorkbench, setSelectedTool, setToolAndWorkbench, setHasUnsavedChanges, diff --git a/frontend/editor/src/core/contexts/QuickNavHostContext.test.tsx b/frontend/editor/src/core/contexts/QuickNavHostContext.test.tsx new file mode 100644 index 0000000000..0a348262fa --- /dev/null +++ b/frontend/editor/src/core/contexts/QuickNavHostContext.test.tsx @@ -0,0 +1,159 @@ +import { describe, it, expect } from "vitest"; +import { render, act } from "@testing-library/react"; +import { + QuickNavHostProvider, + useQuickNavHost, + useRegisterQuickNavHost, + useSuppressQuickNavRail, +} from "@app/contexts/QuickNavHostContext"; + +function Probe({ onRead }: { onRead: (value: unknown) => void }) { + const host = useQuickNavHost(); + onRead({ + appMounted: host?.appMounted, + chromeless: host?.chromeless, + identity: host?.identity, + activeTool: host?.activeTool, + openSettings: Boolean(host?.actions.current?.openSettings), + }); + return null; +} + +function App() { + useRegisterQuickNavHost( + { identity: { displayName: "Ada", profilePictureUrl: null } }, + { openSettings: () => {} }, + ); + return null; +} + +function AppWithTool({ tool }: { tool: "automate" | null }) { + useRegisterQuickNavHost({ activeTool: tool }, {}); + return null; +} + +function LoginRoute() { + useSuppressQuickNavRail(); + return null; +} + +function setup() { + let latest: Record = {}; + const view = render( + + (latest = value as Record)} /> + + , + ); + return { view, read: () => latest }; +} + +describe("QuickNavHostContext", () => { + it("keeps what the app published after it unmounts, but drops its handlers", () => { + // Data survives the gap between one app unmounting and the next registering. + const { view, read } = setup(); + + expect(read().appMounted).toBe(true); + expect(read().identity).toEqual({ + displayName: "Ada", + profilePictureUrl: null, + }); + expect(read().openSettings).toBe(true); + + view.rerender( + + {}} /> + , + ); + + // Re-read through a fresh probe in the same provider. + let after: Record = {}; + view.rerender( + + (after = value as Record)} /> + , + ); + expect(after.appMounted).toBe(true); + expect(after.openSettings).toBe(false); + }); + + it("clears the open tool when the next app registers without one", () => { + let latest: Record = {}; + const view = render( + + (latest = value as Record)} + /> + + , + ); + expect(latest.activeTool).toBe("automate"); + + act(() => { + view.rerender( + + (latest = value as Record)} + /> + + , + ); + }); + expect(latest.activeTool).toBe(null); + }); + + it("hides the bar while a route with no app chrome is on screen", () => { + // appMounted is sticky, so it can't answer "is an app on screen now". + const { view, read } = setup(); + expect(read().chromeless).toBe(false); + + act(() => { + view.rerender( + + {}} /> + + + , + ); + }); + + let during: Record = {}; + view.rerender( + + (during = value as Record)} + /> + + + , + ); + expect(during.chromeless).toBe(true); + }); + + it("brings the bar back when that route leaves", () => { + const { view } = setup(); + + act(() => { + view.rerender( + + {}} /> + + + , + ); + }); + + let after: Record = {}; + act(() => { + view.rerender( + + (after = value as Record)} + /> + + , + ); + }); + expect(after.chromeless).toBe(false); + }); +}); diff --git a/frontend/editor/src/core/contexts/QuickNavHostContext.tsx b/frontend/editor/src/core/contexts/QuickNavHostContext.tsx new file mode 100644 index 0000000000..1a19cffc6f --- /dev/null +++ b/frontend/editor/src/core/contexts/QuickNavHostContext.tsx @@ -0,0 +1,208 @@ +import type { ToolId } from "@app/types/toolId"; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; + +export type QuickNavToolReasons = Partial>; + +export interface QuickNavIdentity { + displayName: string; + profilePictureUrl: string | null; +} + +export interface QuickNavHostData { + /** Sticky: one app unmounts before the next one registers. */ + appMounted: boolean; + identity: QuickNavIdentity | null; + signingBadge: number; + portalAccess: boolean; + readerMode: boolean; + activeTool: ToolId | null; + /** The app owns the panel; the rail's bell only reports its state. */ + notificationsOpen: boolean; + /** Translated; absent means usable. */ + toolReasons: QuickNavToolReasons; + /** Mirrors `openSettings`, which lives in a ref and so cannot trigger a render. */ + hasSettings: boolean; +} + +export interface QuickNavHostActions { + openSettings?: () => void; + /** The editor reads its tool from the URL only on mount. */ + selectTool?: (toolId: ToolId) => void; + setReaderMode?: (on: boolean) => void; + toggleNotifications?: () => void; + goToDefaultState?: () => void; + requestNavigation?: (go: () => void) => void; +} + +interface QuickNavHostValue extends QuickNavHostData { + /** Reset on unmount, unlike the data above. */ + chromeless: boolean; + setChromeless: (chromeless: boolean) => void; + /** A ref, so a click reaches the app currently mounted. */ + actions: React.RefObject; + setData: (data: Partial) => void; + setActions: (actions: QuickNavHostActions) => void; +} + +const EMPTY_REASONS: QuickNavToolReasons = {}; + +const EMPTY_DATA: QuickNavHostData = { + appMounted: false, + toolReasons: EMPTY_REASONS, + identity: null, + signingBadge: 0, + portalAccess: false, + readerMode: false, + activeTool: null, + notificationsOpen: false, + hasSettings: false, +}; + +function sameReasons( + next: QuickNavToolReasons, + prev: QuickNavToolReasons, +): boolean { + const nextKeys = Object.keys(next); + if (nextKeys.length !== Object.keys(prev).length) return false; + return nextKeys.every((key) => next[key as ToolId] === prev[key as ToolId]); +} + +const QuickNavHostContext = createContext(null); + +/** Outside both apps' providers, so each app registers what only it knows. */ +export function QuickNavHostProvider({ children }: { children: ReactNode }) { + const [data, setDataState] = useState(EMPTY_DATA); + const [chromeless, setChromelessState] = useState(false); + const actions = useRef({}); + + const setData = useCallback((next: Partial) => { + setDataState((prev) => { + const merged = { ...prev, ...next }; + const unchanged = + merged.appMounted === prev.appMounted && + merged.signingBadge === prev.signingBadge && + merged.portalAccess === prev.portalAccess && + merged.readerMode === prev.readerMode && + merged.activeTool === prev.activeTool && + merged.notificationsOpen === prev.notificationsOpen && + merged.hasSettings === prev.hasSettings && + merged.identity?.displayName === prev.identity?.displayName && + merged.identity?.profilePictureUrl === + prev.identity?.profilePictureUrl && + // Compared by value: the object is rebuilt every render. + sameReasons(merged.toolReasons, prev.toolReasons); + return unchanged ? prev : merged; + }); + }, []); + + const setActions = useCallback((next: QuickNavHostActions) => { + actions.current = next; + }, []); + + const setChromeless = useCallback((next: boolean) => { + setChromelessState(next); + }, []); + + const value = useMemo( + () => ({ + ...data, + chromeless, + actions, + setData, + setActions, + setChromeless, + }), + [data, chromeless, setData, setActions, setChromeless], + ); + + return ( + + {children} + + ); +} + +export function useQuickNavHost(): QuickNavHostValue | null { + return useContext(QuickNavHostContext); +} + +/** No-ops outside the provider. */ +export function useRegisterQuickNavHost( + data: Partial, + actions: QuickNavHostActions, +): void { + const host = useQuickNavHost(); + const { + identity, + signingBadge, + portalAccess, + readerMode, + activeTool, + notificationsOpen, + toolReasons, + } = data; + const hasSettings = Boolean(actions.openSettings); + + useEffect(() => { + host?.setData({ + appMounted: true, + identity: identity ?? null, + signingBadge: signingBadge ?? 0, + portalAccess: portalAccess ?? false, + readerMode: readerMode ?? false, + // Cleared, not omitted as toolReasons is: a stale tool marks an entry. + activeTool: activeTool ?? null, + notificationsOpen: notificationsOpen ?? false, + // Omitted when unknown, so the last answer survives a re-fetch. + ...(toolReasons ? { toolReasons } : {}), + hasSettings, + }); + // By field: identity is rebuilt every render. + }, [ + host, + identity?.displayName, + identity?.profilePictureUrl, + signingBadge, + portalAccess, + readerMode, + activeTool, + notificationsOpen, + toolReasons, + hasSettings, + ]); + + const setActions = host?.setActions; + + // No deps: a click has to reach the current closure. + useEffect(() => { + setActions?.(actions); + }); + + // Handlers only: clearing the data too would blink the controls mid-switch. + useEffect( + () => () => { + setActions?.({}); + }, + [setActions], + ); +} + +/** `appMounted` is sticky, so a screen that isn't the app has to say so itself. */ +export function useSuppressQuickNavRail(active = true): void { + const host = useQuickNavHost(); + const setChromeless = host?.setChromeless; + useEffect(() => { + if (!active) return; + setChromeless?.(true); + return () => setChromeless?.(false); + }, [active, setChromeless]); +} diff --git a/frontend/editor/src/core/contexts/SidebarContext.tsx b/frontend/editor/src/core/contexts/SidebarContext.tsx index ac9ddbb0df..ce0e5184bb 100644 --- a/frontend/editor/src/core/contexts/SidebarContext.tsx +++ b/frontend/editor/src/core/contexts/SidebarContext.tsx @@ -62,6 +62,11 @@ export function SidebarProvider({ children }: SidebarProviderProps) { ); } +/** For components that render outside a SidebarProvider, such as the rail's tooltips. */ +export function useOptionalSidebarContext(): SidebarContextValue | undefined { + return useContext(SidebarContext); +} + export function useSidebarContext(): SidebarContextValue { const context = useContext(SidebarContext); if (context === undefined) { diff --git a/frontend/editor/src/core/contexts/ToolWorkflowContext.tsx b/frontend/editor/src/core/contexts/ToolWorkflowContext.tsx index 6bc61fa294..b4ef874184 100644 --- a/frontend/editor/src/core/contexts/ToolWorkflowContext.tsx +++ b/frontend/editor/src/core/contexts/ToolWorkflowContext.tsx @@ -29,6 +29,8 @@ import { isBaseWorkbench, } from "@app/types/workbench"; import { useNavigationUrlSync } from "@app/hooks/useUrlSync"; +import { stripBasePath } from "@app/constants/app"; +import { EDITOR_BASENAME } from "@app/routes/editorBasename"; import { filterToolRegistryByQuery } from "@app/utils/toolSearch"; import { useToolHistory } from "@app/hooks/tools/useUserToolActivity"; import { @@ -218,8 +220,8 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) { const setReaderMode = useCallback( (mode: boolean) => { if (mode) { + // Reading is a mode the open document is put into, not a tool run on it. actions.setWorkbench("viewer"); - actions.setSelectedTool("read"); } dispatch({ type: "SET_READER_MODE", payload: mode }); }, @@ -373,15 +375,28 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) { // This runs once to navigate to the user's preferred tab (read/automate) // instead of always starting on the tools tab. const hasAppliedStartupView = React.useRef(false); + // Set when the startup view picks the tool, so the URL sync knows this + // selection came from a preference and must not be written to the address. + const startupSelectedToolRef = React.useRef(null); useEffect(() => { if (hasAppliedStartupView.current) return; + // The URL wins: the startup view decides what you see when you arrive at the + // editor's home, never what a deep link to a tool shows. Without this, a + // "Reader" preference rewrote every / link to /read. + const path = stripBasePath(window.location.pathname); + if (path !== "/" && path !== EDITOR_BASENAME) { + hasAppliedStartupView.current = true; + return; + } const startupView = preferences.defaultStartupView; if (startupView === "read") { hasAppliedStartupView.current = true; + startupSelectedToolRef.current = "read"; setReaderMode(true); actions.setSelectedTool("read"); } else if (startupView === "automate") { hasAppliedStartupView.current = true; + startupSelectedToolRef.current = "automate"; actions.setSelectedTool("automate"); setLeftPanelView("toolContent"); } @@ -573,6 +588,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) { handleBackToTools, allTools, true, + startupSelectedToolRef, ); // Ref-backed wrappers so callback identities stay stable across renders. diff --git a/frontend/editor/src/core/extensions/accountLogout.ts b/frontend/editor/src/core/extensions/accountLogout.ts index e4eddd7274..df4e8f4273 100644 --- a/frontend/editor/src/core/extensions/accountLogout.ts +++ b/frontend/editor/src/core/extensions/accountLogout.ts @@ -1,3 +1,5 @@ +import { suspendWorkbenchSession } from "@app/services/workbenchSession"; + type SignOutFn = () => Promise; interface AccountLogoutDeps { @@ -21,6 +23,10 @@ export function useAccountLogout() { "1", ); } + // The tab outlives the session; the next person to sign in here must not + // inherit this workbench. Suspends writing too - signing out unmounts the + // editor, and its flush would otherwise write the record straight back. + suspendWorkbenchSession(); await signOut(); } finally { redirectToLogin(); diff --git a/frontend/editor/src/core/hooks/signing/useSigningSessions.test.tsx b/frontend/editor/src/core/hooks/signing/useSigningSessions.test.tsx new file mode 100644 index 0000000000..14e473e33c --- /dev/null +++ b/frontend/editor/src/core/hooks/signing/useSigningSessions.test.tsx @@ -0,0 +1,319 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, waitFor, act } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { baseQueryOptions } from "@app/query/queryClient"; +import { TestQueryProvider } from "@app/tests/utils/TestQueryProvider"; +import { useSigningSessions } from "@app/hooks/signing/useSigningSessions"; +import { fetchSigningSessions } from "@app/api/signing"; +import { alert } from "@app/components/toast"; +import { expectConsole } from "@app/tests/failOnConsole"; + +vi.mock("@app/api/signing", () => ({ fetchSigningSessions: vi.fn() })); +vi.mock("@app/components/toast", () => ({ alert: vi.fn() })); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (_k: string, fallback?: string) => fallback ?? _k, + }), +})); + +const mockFetch = vi.mocked(fetchSigningSessions); +const mockAlert = vi.mocked(alert); + +const EMPTY = { signRequests: [], mySessions: [] }; + +function setVisibility(state: "visible" | "hidden") { + Object.defineProperty(document, "visibilityState", { + configurable: true, + get: () => state, + }); + // Bubbles, as the real event does: query-core listens for it on window. + document.dispatchEvent(new Event("visibilitychange", { bubbles: true })); +} + +describe("useSigningSessions", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFetch.mockResolvedValue(EMPTY); + }); + + afterEach(() => { + vi.useRealTimers(); + setVisibility("visible"); + }); + + it("dedupes concurrent observers of the same key", async () => { + const { result } = renderHook( + () => ({ + badge: useSigningSessions({ + enabled: true, + autoRefreshInterval: 60000, + }), + launcher: useSigningSessions({ enabled: true }), + controller: useSigningSessions({ + enabled: true, + autoRefreshInterval: 15000, + }), + }), + { wrapper: TestQueryProvider }, + ); + + await waitFor(() => expect(result.current.badge.loading).toBe(false)); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("does not fetch while disabled", async () => { + vi.useFakeTimers(); + const { result } = renderHook( + () => useSigningSessions({ enabled: false, autoRefreshInterval: 15000 }), + { wrapper: TestQueryProvider }, + ); + + expect(mockFetch).not.toHaveBeenCalled(); + await act(async () => { + vi.advanceTimersByTime(60000); + }); + expect(mockFetch).not.toHaveBeenCalled(); + expect(result.current.signRequests).toEqual([]); + }); + + it("starts fetching when enabled flips on", async () => { + const { result, rerender } = renderHook( + ({ on }: { on: boolean }) => useSigningSessions({ enabled: on }), + { wrapper: TestQueryProvider, initialProps: { on: false } }, + ); + + expect(mockFetch).not.toHaveBeenCalled(); + rerender({ on: true }); + await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(result.current.loading).toBe(false)); + }); + + it("polls on the interval", async () => { + vi.useFakeTimers(); + const { result } = renderHook( + () => useSigningSessions({ enabled: true, autoRefreshInterval: 15000 }), + { wrapper: TestQueryProvider }, + ); + + expect(result.current.loading).toBe(true); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(mockFetch).toHaveBeenCalledTimes(1); + + await act(async () => { + await vi.advanceTimersByTimeAsync(15000); + }); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it("does not raise the spinner while a background poll is in flight", async () => { + // Real timers, a held-open poll, and every render recorded. Asserting on + // result.current alone is not enough: waitFor returns as soon as the fetch + // count moves, before React has re-rendered, so a spinner that did flip on + // would be missed. + const seen: boolean[] = []; + const { result } = renderHook( + () => { + const state = useSigningSessions({ + enabled: true, + autoRefreshInterval: 50, + }); + seen.push(state.loading); + return state; + }, + { wrapper: TestQueryProvider }, + ); + await waitFor(() => expect(result.current.loading).toBe(false)); + + // Marked before the poll: waitFor flushes renders, so recording after it + // would skip straight past the in-flight one. + const fromPollStart = seen.length; + + let release: (v: unknown) => void = () => {}; + mockFetch.mockReturnValueOnce( + new Promise((resolve) => { + release = resolve; + }) as never, + ); + + await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2)); + + // Give React room to render the in-flight state, if it produces one. + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 30)); + }); + + // Mid-poll: this is what the old `silent` flag bought. + expect(seen.slice(fromPollStart)).not.toContain(true); + expect(result.current.loading).toBe(false); + + await act(async () => { + release(EMPTY); + }); + }); + + it("shows the spinner for a user-initiated refresh, not a background poll", async () => { + // Real timers: the in-flight window has to be observable, which is exactly + // what a fake-timer act() hides. + const { result } = renderHook(() => useSigningSessions({ enabled: true }), { + wrapper: TestQueryProvider, + }); + await waitFor(() => expect(result.current.loading).toBe(false)); + + let release: (v: unknown) => void = () => {}; + mockFetch.mockReturnValueOnce( + new Promise((resolve) => { + release = resolve; + }) as never, + ); + + let done: Promise; + act(() => { + done = result.current.refetch(); + }); + await waitFor(() => expect(result.current.loading).toBe(true)); + + await act(async () => { + release(EMPTY); + await done; + }); + expect(result.current.loading).toBe(false); + }); + + it("toasts a first-load failure", async () => { + expectConsole.error(/Failed to fetch signing data/); + mockFetch.mockRejectedValue(new Error("down")); + + const { result } = renderHook(() => useSigningSessions({ enabled: true }), { + wrapper: TestQueryProvider, + }); + + await waitFor(() => expect(result.current.error).toBeTruthy()); + expect(mockAlert).toHaveBeenCalledTimes(1); + }); + + it("stays silent when a background poll fails after a success", async () => { + vi.useFakeTimers(); + mockFetch.mockResolvedValueOnce(EMPTY); + + const { result } = renderHook( + () => useSigningSessions({ enabled: true, autoRefreshInterval: 15000 }), + { wrapper: TestQueryProvider }, + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(result.current.loading).toBe(false); + expect(mockAlert).not.toHaveBeenCalled(); + + mockFetch.mockRejectedValue(new Error("flaky")); + await act(async () => { + await vi.advanceTimersByTimeAsync(15000); + }); + + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockAlert).not.toHaveBeenCalled(); + }); + + it("toasts an explicit refetch failure even with data on screen", async () => { + expectConsole.error(/Failed to fetch signing data/); + const { result } = renderHook(() => useSigningSessions({ enabled: true }), { + wrapper: TestQueryProvider, + }); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(mockAlert).not.toHaveBeenCalled(); + + mockFetch.mockRejectedValue(new Error("nope")); + await act(async () => { + await result.current.refetch(); + }); + + expect(mockAlert).toHaveBeenCalledTimes(1); + }); + + it("stops polling while the tab is hidden", async () => { + vi.useFakeTimers(); + const { result } = renderHook( + () => useSigningSessions({ enabled: true, autoRefreshInterval: 15000 }), + { wrapper: TestQueryProvider }, + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(result.current.loading).toBe(false); + expect(mockFetch).toHaveBeenCalledTimes(1); + + setVisibility("hidden"); + await act(async () => { + await vi.advanceTimersByTimeAsync(60000); + }); + // Four intervals elapsed with the tab in the background. + expect(mockFetch).toHaveBeenCalledTimes(1); + + setVisibility("visible"); + await act(async () => { + await vi.advanceTimersByTimeAsync(15000); + }); + expect(mockFetch.mock.calls.length).toBeGreaterThan(1); + }); + + it("refetches on becoming visible rather than waiting out the interval", async () => { + vi.useFakeTimers(); + // The app client turns focus refetching off globally; TestQueryProvider + // does not, and would pass this on the library default alone. + const client = new QueryClient({ + defaultOptions: { + queries: { ...baseQueryOptions, retry: false, gcTime: Infinity }, + }, + }); + const { result } = renderHook( + () => useSigningSessions({ enabled: true, autoRefreshInterval: 15000 }), + { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }, + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(result.current.loading).toBe(false); + expect(mockFetch).toHaveBeenCalledTimes(1); + + setVisibility("hidden"); + await act(async () => { + await vi.advanceTimersByTimeAsync(60000); + }); + expect(mockFetch).toHaveBeenCalledTimes(1); + + setVisibility("visible"); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it("stops polling once unmounted", async () => { + vi.useFakeTimers(); + const { unmount } = renderHook( + () => useSigningSessions({ enabled: true, autoRefreshInterval: 15000 }), + { wrapper: TestQueryProvider }, + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(mockFetch).toHaveBeenCalledTimes(1); + + unmount(); + await act(async () => { + await vi.advanceTimersByTimeAsync(60000); + }); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/editor/src/core/hooks/signing/useSigningSessions.ts b/frontend/editor/src/core/hooks/signing/useSigningSessions.ts index 785e792414..e001af5441 100644 --- a/frontend/editor/src/core/hooks/signing/useSigningSessions.ts +++ b/frontend/editor/src/core/hooks/signing/useSigningSessions.ts @@ -1,9 +1,14 @@ -import { useState, useCallback, useEffect } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; -import apiClient from "@app/services/apiClient"; +import { fetchSigningSessions } from "@app/api/signing"; +import { qk } from "@app/query/keys"; import { alert } from "@app/components/toast"; import { SignRequestSummary, SessionSummary } from "@app/types/signingSession"; +const EMPTY_REQUESTS: SignRequestSummary[] = []; +const EMPTY_SESSIONS: SessionSummary[] = []; + export interface UseSigningSessionsOptions { enabled?: boolean; autoRefreshInterval?: number; // milliseconds, 0 to disable @@ -18,8 +23,8 @@ export interface UseSigningSessionsResult { } /** - * Hook to fetch signing sessions data (sign requests and user's sessions). - * Supports auto-refresh for real-time updates. + * Signing sessions. Background polls never raise the spinner or a toast; only a + * first load or an explicit refetch does. */ export const useSigningSessions = ( options: UseSigningSessionsOptions = {}, @@ -27,83 +32,64 @@ export const useSigningSessions = ( const { enabled = true, autoRefreshInterval = 0 } = options; const { t } = useTranslation(); - const [signRequests, setSignRequests] = useState([]); - const [mySessions, setMySessions] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); + const { data, isLoading, isLoadingError, error, refetch } = useQuery({ + queryKey: qk.signingSessions(), + queryFn: fetchSigningSessions, + enabled, + staleTime: 0, + refetchInterval: autoRefreshInterval > 0 ? autoRefreshInterval : false, + refetchIntervalInBackground: false, + // The interval pauses while unfocused, so returning has to catch up: the + // client-wide default of false would hold stale data until the next tick. + refetchOnWindowFocus: autoRefreshInterval > 0, + }); - const fetchData = useCallback( - async (opts?: { silent?: boolean }) => { - if (!enabled) return; + const notifyFailure = useCallback(() => { + console.error("Failed to fetch signing data"); + alert({ + alertType: "warning", + title: t("common.error"), + body: t("certSign.fetchFailed", "Failed to load signing data"), + expandable: false, + durationMs: 2500, + }); + }, [t]); - // Background auto-refreshes pass { silent: true } to skip the loading spinner - // and failure toasts; only the initial load and explicit refetch surface errors. - const silent = opts?.silent ?? false; - - if (!silent) setLoading(true); - setError(null); - - try { - const [requestsResponse, sessionsResponse] = await Promise.all([ - apiClient.get( - "/api/v1/security/cert-sign/sign-requests", - ), - apiClient.get( - "/api/v1/security/cert-sign/sessions", - ), - ]); - - setSignRequests(requestsResponse.data); - setMySessions(sessionsResponse.data); - } catch (err) { - const errorObj = - err instanceof Error - ? err - : new Error("Failed to fetch signing data"); - setError(errorObj); - console.error("Failed to fetch signing data:", err); - - if (!silent) { - alert({ - alertType: "warning", - title: t("common.error"), - body: t("certSign.fetchFailed", "Failed to load signing data"), - expandable: false, - durationMs: 2500, - }); - } - } finally { - if (!silent) setLoading(false); - } - }, - [enabled, t], - ); - - // Initial fetch + // isLoadingError is "failed with nothing cached", i.e. a first load. A poll + // that fails after a success keeps the old data and stays silent. + const reportedRef = useRef(false); useEffect(() => { - if (enabled) { - fetchData(); - } - }, [enabled, fetchData]); - - // Auto-refresh - useEffect(() => { - if (!enabled || !autoRefreshInterval || autoRefreshInterval <= 0) { + if (!isLoadingError) { + reportedRef.current = false; return; } + if (reportedRef.current) return; + reportedRef.current = true; + notifyFailure(); + }, [isLoadingError, notifyFailure]); - const interval = setInterval(() => { - fetchData({ silent: true }); - }, autoRefreshInterval); + // Neither isLoading nor isFetching alone matches the old `silent` flag: a + // user-initiated refresh showed the spinner even with data on screen, a + // background poll never did. isFetching cannot tell them apart, so track it. + const [refreshing, setRefreshing] = useState(false); - return () => clearInterval(interval); - }, [enabled, autoRefreshInterval, fetchData]); + const explicitRefetch = useCallback(async () => { + setRefreshing(true); + try { + const result = await refetch(); + // Reported here rather than by the effect: a user-initiated refresh + // should say so even when stale data is already on screen. + if (result.error && !reportedRef.current) notifyFailure(); + } finally { + setRefreshing(false); + } + }, [refetch, notifyFailure]); return { - signRequests, - mySessions, - loading, - error, - refetch: fetchData, + signRequests: data?.signRequests ?? EMPTY_REQUESTS, + mySessions: data?.mySessions ?? EMPTY_SESSIONS, + loading: isLoading || refreshing, + error: (error as Error | null) ?? null, + refetch: explicitRefetch, }; }; diff --git a/frontend/editor/src/core/hooks/tools/crop/useCropParameters.ts b/frontend/editor/src/core/hooks/tools/crop/useCropParameters.ts index cc37421cdf..62e7ede14b 100644 --- a/frontend/editor/src/core/hooks/tools/crop/useCropParameters.ts +++ b/frontend/editor/src/core/hooks/tools/crop/useCropParameters.ts @@ -8,6 +8,7 @@ import { Rectangle, PDFBounds, constrainCropAreaToPDF, + createDefaultCropArea, createFullPDFCropArea, roundCropArea, isRectangle, @@ -29,6 +30,8 @@ export type CropParametersHook = BaseParametersHook & { setCropArea: (cropArea: Rectangle, pdfBounds?: PDFBounds) => void; /** Get current crop area as CropArea object */ getCropArea: () => Rectangle; + /** Reset to default inset crop area inside PDF bounds */ + resetToDefaultCropArea: (pdfBounds: PDFBounds) => void; /** Reset to full PDF dimensions */ resetToFullPDF: (pdfBounds: PDFBounds) => void; /** Check if current crop area is valid for the PDF */ @@ -76,6 +79,15 @@ export const useCropParameters = (): CropParametersHook => { [baseHook], ); + // Reset to default crop area inside PDF bounds (10% inset) + const resetToDefaultCropArea = useCallback( + (pdfBounds: PDFBounds) => { + const defaultCropArea = createDefaultCropArea(pdfBounds); + setCropArea(defaultCropArea); + }, + [setCropArea], + ); + // Reset to cover entire PDF const resetToFullPDF = useCallback( (pdfBounds: PDFBounds) => { @@ -85,31 +97,11 @@ export const useCropParameters = (): CropParametersHook => { [setCropArea], ); - // Check if current crop area is valid for the given PDF bounds + // Check if current crop area is valid (dimensions must be non-zero; out-of-bounds coordinates clamp automatically) const isCropAreaValid = useCallback( - (pdfBounds?: PDFBounds): boolean => { + (_pdfBounds?: PDFBounds): boolean => { const cropArea = getCropArea(); - - // Basic validation - if ( - cropArea.x < 0 || - cropArea.y < 0 || - cropArea.width <= 0 || - cropArea.height <= 0 - ) { - return false; - } - - // PDF bounds validation if provided - if (pdfBounds) { - const tolerance = 0.01; // Small tolerance for floating point precision - return ( - cropArea.x + cropArea.width <= pdfBounds.actualWidth + tolerance && - cropArea.y + cropArea.height <= pdfBounds.actualHeight + tolerance - ); - } - - return true; + return cropArea.width > 0 && cropArea.height > 0; }, [getCropArea], ); @@ -174,6 +166,7 @@ export const useCropParameters = (): CropParametersHook => { validateParameters: () => validateParameters(), setCropArea, getCropArea, + resetToDefaultCropArea, resetToFullPDF, isCropAreaValid, isFullPDFCrop, diff --git a/frontend/editor/src/core/hooks/useUrlSync.test.tsx b/frontend/editor/src/core/hooks/useUrlSync.test.tsx new file mode 100644 index 0000000000..e35c7dbd44 --- /dev/null +++ b/frontend/editor/src/core/hooks/useUrlSync.test.tsx @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { useRef } from "react"; +import type { ToolId } from "@app/types/toolId"; + +const h = vi.hoisted(() => ({ + updateToolRoute: vi.fn(), + clearToolRoute: vi.fn(), +})); + +vi.mock("@app/utils/urlRouting", () => ({ + parseToolRoute: () => ({ workbench: "fileEditor", toolId: null }), + updateToolRoute: h.updateToolRoute, + clearToolRoute: h.clearToolRoute, +})); +vi.mock("@app/utils/scarfTracking", () => ({ firePixel: vi.fn() })); +vi.mock("@app/contexts/AppConfigContext", () => ({ + useAppConfig: () => ({ config: { premiumEnabled: true } }), +})); + +import { useNavigationUrlSync } from "@app/hooks/useUrlSync"; + +const registry = { + read: { name: "Read", workbench: "viewer" }, + compress: { name: "Compress", workbench: "fileEditor" }, +} as never; + +/** Drives the hook the way ToolWorkflowContext does, with a startup marker. */ +function useHarness(selectedTool: ToolId | null, startupTool: ToolId | null) { + const ref = useRef(startupTool); + useNavigationUrlSync(selectedTool, vi.fn(), vi.fn(), registry, true, ref); + return ref; +} + +describe("useNavigationUrlSync — startup-view selections", () => { + beforeEach(() => h.updateToolRoute.mockClear()); + + // The default-startup-view preference selects a tool to change the *view*. + // Writing it to the address turned every visit to /editor into /read. + it("never writes the URL for the startup-applied tool", () => { + const { rerender } = renderHook( + ({ tool }: { tool: ToolId | null }) => useHarness(tool, "read" as ToolId), + { initialProps: { tool: null as ToolId | null } }, + ); + rerender({ tool: "read" as ToolId }); + expect(h.updateToolRoute).not.toHaveBeenCalled(); + }); + + // The effect re-runs whenever the registry identity changes, so a marker that + // was consumed on first sight let the second run write /read anyway. + it("survives a re-run for the same tool", () => { + const { rerender } = renderHook( + ({ tool }: { tool: ToolId | null }) => useHarness(tool, "read" as ToolId), + { initialProps: { tool: null as ToolId | null } }, + ); + rerender({ tool: "read" as ToolId }); + rerender({ tool: "read" as ToolId }); + rerender({ tool: "read" as ToolId }); + expect(h.updateToolRoute).not.toHaveBeenCalled(); + }); + + it("still writes the URL when the user picks a different tool", () => { + const { rerender } = renderHook( + ({ tool }: { tool: ToolId | null }) => useHarness(tool, "read" as ToolId), + { initialProps: { tool: null as ToolId | null } }, + ); + rerender({ tool: "read" as ToolId }); + rerender({ tool: "compress" as ToolId }); + expect(h.updateToolRoute).toHaveBeenCalledWith("compress", registry, false); + }); + + it("writes the URL for a tool chosen without a startup marker", () => { + const { rerender } = renderHook( + ({ tool }: { tool: ToolId | null }) => useHarness(tool, null), + { initialProps: { tool: null as ToolId | null } }, + ); + rerender({ tool: "read" as ToolId }); + expect(h.updateToolRoute).toHaveBeenCalledWith("read", registry, false); + }); +}); diff --git a/frontend/editor/src/core/hooks/useUrlSync.ts b/frontend/editor/src/core/hooks/useUrlSync.ts index 5fad71ba29..76fd67178a 100644 --- a/frontend/editor/src/core/hooks/useUrlSync.ts +++ b/frontend/editor/src/core/hooks/useUrlSync.ts @@ -2,7 +2,7 @@ * URL synchronization hooks for tool routing with registry support */ -import { useEffect, useCallback, useRef } from "react"; +import { useEffect, useCallback, useRef, type MutableRefObject } from "react"; import { ToolId } from "@app/types/toolId"; import { parseToolRoute, @@ -24,6 +24,11 @@ export function useNavigationUrlSync( clearToolSelection: () => void, registry: ToolRegistry, enableSync: boolean = true, + /** + * Tool the default-startup-view preference selected, if any. That selection + * sets the view, not the address, so it must not be written to the URL. + */ + startupSelectedToolRef?: MutableRefObject, ) { const { config } = useAppConfig(); const premiumEnabled = config?.premiumEnabled; @@ -77,8 +82,16 @@ export function useNavigationUrlSync( useEffect(() => { if (!enableSync) return; + const startupTool = startupSelectedToolRef?.current ?? null; + if (selectedTool) { - updateToolRoute(selectedTool, registry, false); // Use pushState for user navigation + // A startup-view selection is a view preference, not a navigation: writing + // it here rewrote /editor to /read on every load. The effect re-runs + // whenever the registry identity changes, so the marker has to survive + // until the selection actually moves off it (cleared below). + if (startupTool !== selectedTool) { + updateToolRoute(selectedTool, registry, false); // Use pushState for user navigation + } } else if (prevSelectedTool.current !== null) { // Only clear URL if we had a tool before (user navigated away) // Don't clear on initial load when both current and previous are null @@ -88,8 +101,19 @@ export function useNavigationUrlSync( } } + // Spent once the user leaves the startup-applied tool, so re-picking it + // later is a real navigation and does update the URL. + if ( + startupSelectedToolRef && + startupTool !== null && + prevSelectedTool.current === startupTool && + selectedTool !== startupTool + ) { + startupSelectedToolRef.current = null; + } + prevSelectedTool.current = selectedTool; - }, [selectedTool, registry, enableSync]); + }, [selectedTool, registry, enableSync, startupSelectedToolRef]); // Handle browser back/forward navigation useEffect(() => { diff --git a/frontend/editor/src/core/pages/HomePage.tsx b/frontend/editor/src/core/pages/HomePage.tsx index e0c5303447..6434acbc9e 100644 --- a/frontend/editor/src/core/pages/HomePage.tsx +++ b/frontend/editor/src/core/pages/HomePage.tsx @@ -1,4 +1,11 @@ -import { forwardRef, useCallback, useEffect, useRef, useState } from "react"; +import { + forwardRef, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { useTranslation } from "react-i18next"; import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; import { Group } from "@mantine/core"; @@ -14,7 +21,9 @@ import { useFileContext } from "@app/contexts/file/fileHooks"; import { useNavigationState, useNavigationActions, + useNavigationGuard, } from "@app/contexts/NavigationContext"; +import { isApplyingRestoredView } from "@app/services/workbenchSession"; import { useViewer } from "@app/contexts/ViewerContext"; import { useLocation, useNavigate } from "react-router-dom"; import AppsIcon from "@mui/icons-material/AppsRounded"; @@ -27,10 +36,21 @@ import FileSidebar from "@app/components/shared/FileSidebar"; import FileManager from "@app/components/FileManager"; import LocalIcon from "@app/components/shared/LocalIcon"; import AppConfigModal from "@app/components/shared/AppConfigModalLazy"; -import { getStartupNavigationAction } from "@app/utils/homePageNavigation"; +import { + getStartupNavigationAction, + getDefaultWorkbenchForFileCount, +} from "@app/utils/homePageNavigation"; import { EDITOR_BASENAME } from "@app/routes/editorBasename"; import { stripBasePath } from "@app/constants/app"; import { HomePageExtensions } from "@app/components/home/HomePageExtensions"; +import { QuickNavHostBridge } from "@app/components/shared/quickNav/QuickNavHostBridge"; +import type { QuickNavToolReasons } from "@app/contexts/QuickNavHostContext"; +import { + getToolDisabledReason, + getDisabledLabel, +} from "@app/components/tools/fullscreen/shared"; +import { useOtherAppSwitch } from "@app/hooks/useOtherAppSwitch"; +import { consumeReaderModeRequest } from "@app/utils/pendingReaderMode"; import { FilesPageProvider, useFilesPage, @@ -41,6 +61,7 @@ import { FolderTreePanel } from "@app/components/filesPage/FolderTreePanel"; import type { FileSidebarProps } from "@app/components/shared/FileSidebar"; import { Button } from "@app/ui/Button"; +import "@app/components/layout/WorkspaceFrame.css"; import "@app/pages/HomePage.css"; const SIDEBAR_COLLAPSED_STORAGE_KEY = "stirling.fileSidebarCollapsed"; @@ -89,9 +110,11 @@ export default function HomePage() { handleToolSelect, handleBackToTools, readerMode, + setReaderMode, setLeftPanelView, toolAvailability, customWorkbenchViews, + toolRegistry, } = useToolWorkflow(); const navigate = useNavigate(); @@ -102,6 +125,7 @@ export default function HomePage() { const [activeMobileView, setActiveMobileView] = useState("tools"); const isProgrammaticScroll = useRef(false); const [configModalOpen, setConfigModalOpen] = useState(false); + const otherApp = useOtherAppSwitch(); const location = useLocation(); // Persisted user preference for the FileSidebar collapsed state. Auto- // collapse on /files is layered on top in the transition effect below and @@ -151,8 +175,64 @@ export default function HomePage() { const { activeFiles } = useFileContext(); const navigationState = useNavigationState(); + const { requestNavigation } = useNavigationGuard(); + + // From the processor's Reader entry. Ref-guarded: one-shot, and StrictMode double-invokes. + const consumedReaderRequest = useRef(false); + useEffect(() => { + if (consumedReaderRequest.current) return; + consumedReaderRequest.current = true; + if (consumeReaderModeRequest()) setReaderMode(true); + }, [setReaderMode]); const { actions } = useNavigationActions(); + const { searchInterfaceActions } = useViewer(); + + // Reading hides both search controls, so leave it first. e.code, for non-QWERTY layouts. + const focusSearchAfterRestore = useRef(false); + useEffect(() => { + if (!readerMode) return; + const onKeyDown = (e: KeyboardEvent) => { + const combo = (e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey; + if (!combo) return; + if (e.code !== "KeyK" && e.code !== "KeyF") return; + // Same carve-out the search itself makes: a dialog owns the keyboard. + if ((e.target as HTMLElement | null)?.closest?.('[role="dialog"]')) + return; + e.preventDefault(); + setReaderMode(false); + if (e.code === "KeyK") { + focusSearchAfterRestore.current = true; + return; + } + // Visibility is state, so it can open before the bar it renders in exists. + searchInterfaceActions.open(); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [readerMode, setReaderMode, searchInterfaceActions]); + + useEffect(() => { + if (readerMode || !focusSearchAfterRestore.current) return; + focusSearchAfterRestore.current = false; + requestAnimationFrame(() => + window.dispatchEvent(new Event("superSearch:focus")), + ); + }, [readerMode]); + + // Clean slate: no tool, out of the file library and reading. + const goToDefaultState = useCallback(() => { + handleBackToTools(); + if (location.pathname.startsWith("/files")) navigate(EDITOR_BASENAME); + actions.setWorkbench(getDefaultWorkbenchForFileCount(activeFiles.length)); + }, [ + handleBackToTools, + location.pathname, + navigate, + actions, + activeFiles.length, + ]); + // Sync the /files* URL into the workbench state so the file manager view // takes over the workbench area when the user lands on it. This is the // only state-of-truth for the active workbench, so keep the URL pinned. @@ -161,8 +241,14 @@ export default function HomePage() { if (navigationState.workbench !== "myFiles") { actions.setWorkbench("myFiles"); } - } else if (navigationState.workbench === "myFiles") { - // Leaving the file manager - drop back to a sensible default. + } else if ( + navigationState.workbench === "myFiles" && + !isApplyingRestoredView() + ) { + // The URL no longer supports the file manager - drop back to a sensible default. Stays a + // state check rather than a transition one: HomePage remounts without NavigationContext + // (a share link, a login bounce), and the view has to be corrected on arrival too. + // Skipped mid-restore, which is reopening a recorded view onto files still loading. actions.setWorkbench(activeFiles.length > 1 ? "fileEditor" : "viewer"); } }, [ @@ -187,6 +273,17 @@ export default function HomePage() { prevWorkbenchRef.current = curr; // fileSidebarCollapsed read as snapshot on transition only. }, [navigationState.workbench]); + // Imperative, so the toggle still works while reading. Never persisted: not a preference. + const prevReaderModeRef = useRef(readerMode); + useEffect(() => { + if (readerMode !== prevReaderModeRef.current) { + setFileSidebarCollapsed( + readerMode ? true : readPersistedSidebarCollapsed(), + ); + prevReaderModeRef.current = readerMode; + } + }, [readerMode]); + const { setActiveFileIndex } = useViewer(); const prevFileCountRef = useRef(activeFiles.length); @@ -204,7 +301,9 @@ export default function HomePage() { navigationState.workbench, ); - if (action) { + // A session restore fills an empty workbench too, but it already knows which view the user + // left - so it wins over this heuristic rather than being overwritten by it. + if (action && !isApplyingRestoredView()) { actions.setWorkbench(action.workbench); if (typeof action.activeFileIndex === "number") { setActiveFileIndex(action.activeFileIndex); @@ -233,6 +332,38 @@ export default function HomePage() { const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo"); + // The tool picker's own helpers, so the wording can't drift. + const quickNavToolReasons = useMemo(() => { + const reasons: QuickNavToolReasons = {}; + for (const id of ["automate", "sharedSign"] as const) { + const tool = toolRegistry[id]; + if (!tool) continue; + const disabledReason = getToolDisabledReason( + id, + tool, + toolAvailability, + config?.premiumEnabled, + ); + if (!disabledReason) continue; + const { key, fallback } = getDisabledLabel(disabledReason); + reasons[id] = t(key, fallback).replace(/:\s*$/, ""); + } + return reasons; + }, [toolRegistry, toolAvailability, config?.premiumEnabled, t]); + + // Shared with the sidebar's own toggle. On /files it leaves rather than collapses. + const handleSidebarToggle = useCallback(() => { + if (navigationState.workbench === "myFiles") { + navigate(EDITOR_BASENAME); + return; + } + setFileSidebarCollapsed((c) => { + const next = !c; + writePersistedSidebarCollapsed(next); + return next; + }); + }, [navigationState.workbench, navigate]); + const [showSwipeHint, setShowSwipeHint] = useState( () => !readSwipeHintSeen(), ); @@ -386,6 +517,17 @@ export default function HomePage() { return (
+ setConfigModalOpen(true)} + requestNavigation={requestNavigation} + readerMode={readerMode} + onSetReaderMode={setReaderMode} + onGoToDefaultState={goToDefaultState} + onSelectTool={handleToolSelect} + activeTool={selectedToolKey} + toolReasons={quickNavToolReasons} + /> {isMobile ? (
- - ) : undefined - } - onToggleCollapse={() => { - if (navigationState.workbench === "myFiles") { - navigate(EDITOR_BASENAME); - return; +
+ { - const next = !c; - writePersistedSidebarCollapsed(next); - return next; - }); - }} - onOpenSettings={() => setConfigModalOpen(true)} - /> + toggleIcon={ + navigationState.workbench === "myFiles" ? ( + + ) : undefined + } + active={navigationState.workbench === "myFiles"} + // Forced: a deep link to /files has no transition to collapse on. + collapsed={ + navigationState.workbench === "myFiles" || + fileSidebarCollapsed + } + onToggleCollapse={handleSidebarToggle} + onOpenSettings={() => setConfigModalOpen(true)} + /> +
{!hideToolPanel && } diff --git a/frontend/editor/src/core/query/keys.ts b/frontend/editor/src/core/query/keys.ts index 5354b56b63..3e44395de0 100644 --- a/frontend/editor/src/core/query/keys.ts +++ b/frontend/editor/src/core/query/keys.ts @@ -1,13 +1,18 @@ /** Editor query keys: ["editor", , ...params]. */ export const qk = { + /** The admin directory payload: a different endpoint and shape to qk.users(). */ + adminUsers: () => ["editor", "adminUsers"] as const, appConfig: () => ["editor", "appConfig"] as const, endpointsAvailability: () => ["editor", "endpointsAvailability"] as const, endpointEnabled: (endpoint: string) => ["editor", "endpointEnabled", endpoint] as const, footerInfo: () => ["editor", "footerInfo"] as const, groupEnabled: (group: string) => ["editor", "groupEnabled", group] as const, + signingSessions: () => ["editor", "signingSessions"] as const, /** Keyed on the asking identity: two users must never share one answer. */ portalAccess: (userId: string | null) => ["editor", "portalAccess", userId] as const, + teamDetails: (teamId: number) => ["editor", "teamDetails", teamId] as const, + teams: () => ["editor", "teams"] as const, users: () => ["editor", "users"] as const, } as const; diff --git a/frontend/editor/src/core/routes/hasPortal.ts b/frontend/editor/src/core/routes/hasPortal.ts new file mode 100644 index 0000000000..3d7f107ac3 --- /dev/null +++ b/frontend/editor/src/core/routes/hasPortal.ts @@ -0,0 +1,2 @@ +/** Whether this build ships the processor. Shadowed per build. */ +export const HAS_PORTAL = false; diff --git a/frontend/editor/src/core/services/httpErrorHandler.basePath.test.ts b/frontend/editor/src/core/services/httpErrorHandler.basePath.test.ts new file mode 100644 index 0000000000..27a4231a2c --- /dev/null +++ b/frontend/editor/src/core/services/httpErrorHandler.basePath.test.ts @@ -0,0 +1,76 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@app/components/toast", () => ({ alert: vi.fn() })); +vi.mock("@app/services/specialErrorToasts", () => ({ + showSpecialErrorToast: vi.fn(() => false), +})); +vi.mock("@app/services/saasErrorInterceptor", () => ({ + handleSaaSError: vi.fn(() => false), +})); +vi.mock("@app/services/errorUtils", () => ({ + broadcastErroredFiles: vi.fn(), + extractErrorFileIds: vi.fn(() => []), + normalizeAxiosErrorData: vi.fn(async (d: unknown) => d), +})); + +const hrefs: string[] = []; + +/** Serve the app from `base`, sitting on `pathname`, then load the handler fresh. */ +async function loadAt(base: string, pathname: string) { + document.head.innerHTML = ``; + Object.defineProperty(window, "location", { + configurable: true, + value: { + pathname, + search: "", + origin: "http://localhost:3000", + get href() { + // Absolute: jsdom resolves against this. + return "http://localhost:3000" + pathname; + }, + set href(v: string) { + hrefs.push(v); + }, + }, + }); + vi.resetModules(); + return (await import("@app/services/httpErrorHandler")).handleHttpError; +} + +const unauthorized = { + isAxiosError: true, + message: "unauthorized", + config: {}, + response: { status: 401, data: {} }, +}; + +beforeEach(() => { + hrefs.length = 0; + sessionStorage.clear(); + localStorage.clear(); +}); +afterEach(() => vi.resetModules()); + +describe("401 return path is router-relative", () => { + // Login replays this through navigate(), which re-applies the router + // basename. Carrying /app here produced /app/app/compress. + it("strips the base path on a subpath deploy", async () => { + const handle = await loadAt("/app/", "/app/compress"); + await handle(unauthorized); + + expect(sessionStorage.getItem("stirling_post_login_path")).toBe( + "/compress", + ); + expect(hrefs[0]).toBe("/app/login?from=%2Fcompress"); + }); + + it("is unchanged at the origin root", async () => { + const handle = await loadAt("/", "/compress"); + await handle(unauthorized); + + expect(sessionStorage.getItem("stirling_post_login_path")).toBe( + "/compress", + ); + expect(hrefs[0]).toBe("/login?from=%2Fcompress"); + }); +}); diff --git a/frontend/editor/src/core/services/httpErrorHandler.ts b/frontend/editor/src/core/services/httpErrorHandler.ts index dd0c692d97..30272c381e 100644 --- a/frontend/editor/src/core/services/httpErrorHandler.ts +++ b/frontend/editor/src/core/services/httpErrorHandler.ts @@ -12,7 +12,8 @@ import { clampText, extractAxiosErrorMessage, } from "@app/services/httpErrorUtils"; -import { withBasePath } from "@app/constants/app"; +import { stripBasePath, withBasePath } from "@app/constants/app"; +import { isSafePostLoginRedirect } from "@app/services/postLoginRedirect"; // Module-scoped state to reduce global variable usage const recentSpecialByEndpoint: Record = {}; @@ -21,26 +22,9 @@ const SPECIAL_SUPPRESS_MS = 1500; // brief window to suppress generic duplicate // Mirrors the key in proprietary/auth/springAuthClient.ts; AuthCallback consumes it. const POST_LOGIN_REDIRECT_STORAGE_KEY = "stirling_post_login_path"; -function isSafePostLoginPath(path: string): boolean { - if ( - !path.startsWith("/") || - path.startsWith("//") || - path.startsWith("/\\") - ) { - return false; - } - const lowered = path.toLowerCase(); - return ( - !lowered.startsWith("/login") && - !lowered.startsWith("/auth/") && - !lowered.startsWith("/oauth2") && - !lowered.startsWith("/saml2") - ); -} - function stashPostLoginRedirect(path: string): void { try { - if (typeof window === "undefined" || !isSafePostLoginPath(path)) return; + if (typeof window === "undefined" || !isSafePostLoginRedirect(path)) return; window.sessionStorage.setItem(POST_LOGIN_REDIRECT_STORAGE_KEY, path); } catch { // sessionStorage unavailable (private mode) — fail open @@ -128,7 +112,11 @@ export async function handleHttpError(error: unknown): Promise { console.debug("[httpErrorHandler] 401 detected, redirecting to login"); // Spring 302-strips the ?from= query from /login, so stash the return // path in sessionStorage (AuthCallback reads it after SSO round-trip). - const currentLocation = window.location.pathname + window.location.search; + // Router-relative, not browser-relative: every consumer replays this + // through navigate(), which re-applies the basename. Keeping the base + // path here yields /app/app/ on a subpath deploy. + const currentLocation = + stripBasePath(window.location.pathname) + window.location.search; stashPostLoginRedirect(currentLocation); let hadStoredJwt = false; try { diff --git a/frontend/editor/src/core/services/postLoginRedirect.test.ts b/frontend/editor/src/core/services/postLoginRedirect.test.ts new file mode 100644 index 0000000000..9f1687f392 --- /dev/null +++ b/frontend/editor/src/core/services/postLoginRedirect.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { isSafePostLoginRedirect } from "@app/services/postLoginRedirect"; + +// Core default. Rejects off-origin forms and the auth routes every build has +// (/login, /auth/…); Spring SSO routes are the proprietary override's concern. +describe("isSafePostLoginRedirect (core base)", () => { + it("accepts same-origin router paths", () => { + expect(isSafePostLoginRedirect("/editor")).toBe(true); + expect(isSafePostLoginRedirect("/compress")).toBe(true); + expect(isSafePostLoginRedirect("/editor?foo=bar")).toBe(true); + expect(isSafePostLoginRedirect("/oauth/consent?x=1")).toBe(true); + expect(isSafePostLoginRedirect("/")).toBe(true); + }); + + it("rejects empty and non-string values", () => { + expect(isSafePostLoginRedirect(null)).toBe(false); + expect(isSafePostLoginRedirect(undefined)).toBe(false); + expect(isSafePostLoginRedirect("")).toBe(false); + expect(isSafePostLoginRedirect(42 as unknown)).toBe(false); + }); + + it("rejects off-origin and protocol-relative forms", () => { + expect(isSafePostLoginRedirect("//evil.example.com")).toBe(false); + expect(isSafePostLoginRedirect("/\\evil.example.com")).toBe(false); + expect(isSafePostLoginRedirect("https://evil.example.com")).toBe(false); + expect(isSafePostLoginRedirect("editor")).toBe(false); + }); + + it("rejects the universal auth routes so returning back can never loop", () => { + expect(isSafePostLoginRedirect("/login")).toBe(false); + expect(isSafePostLoginRedirect("/login?next=%2Feditor")).toBe(false); + expect(isSafePostLoginRedirect("/auth/callback")).toBe(false); + }); + + it("leaves the Spring SSO routes to the proprietary override", () => { + expect(isSafePostLoginRedirect("/oauth2/authorize")).toBe(true); + expect(isSafePostLoginRedirect("/saml2/login")).toBe(true); + }); +}); diff --git a/frontend/editor/src/core/services/postLoginRedirect.ts b/frontend/editor/src/core/services/postLoginRedirect.ts new file mode 100644 index 0000000000..d9ffe82940 --- /dev/null +++ b/frontend/editor/src/core/services/postLoginRedirect.ts @@ -0,0 +1,15 @@ +/** + * Is `path` safe to send a user back to after they log in? + */ +export function isSafePostLoginRedirect(path: unknown): path is string { + if (typeof path !== "string" || path.length === 0) return false; + if ( + !path.startsWith("/") || + path.startsWith("//") || + path.startsWith("/\\") + ) { + return false; + } + const lowered = path.toLowerCase(); + return !lowered.startsWith("/login") && !lowered.startsWith("/auth/"); +} diff --git a/frontend/editor/src/core/services/workbenchSession.test.ts b/frontend/editor/src/core/services/workbenchSession.test.ts new file mode 100644 index 0000000000..0258b83623 --- /dev/null +++ b/frontend/editor/src/core/services/workbenchSession.test.ts @@ -0,0 +1,197 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { + originalIdOf, + readWorkbenchSession, + writeWorkbenchSession, + saveEditorReturnPath, + takeEditorReturnPath, + isSeedableView, + clearWorkbenchSession, + suspendWorkbenchSession, + resumeWorkbenchSession, +} from "@app/services/workbenchSession"; +import type { StirlingFileStub } from "@app/types/fileContext"; + +const SESSION_KEY = "stirling.workbench.session"; + +beforeEach(() => { + sessionStorage.clear(); + resumeWorkbenchSession(); +}); + +describe("workbench session record", () => { + it("round-trips the open files and selection", () => { + writeWorkbenchSession({ fileIds: ["a", "b"], selectedFileIds: ["b"] }); + expect(readWorkbenchSession()).toMatchObject({ + fileIds: ["a", "b"], + selectedFileIds: ["b"], + }); + }); + + it("returns null when nothing was recorded", () => { + expect(readWorkbenchSession()).toBeNull(); + }); + + it("rejects a malformed record instead of throwing", () => { + sessionStorage.setItem(SESSION_KEY, "not json"); + expect(readWorkbenchSession()).toBeNull(); + sessionStorage.setItem( + SESSION_KEY, + JSON.stringify({ v: 2, fileIds: "nope" }), + ); + expect(readWorkbenchSession()).toBeNull(); + }); + + it("drops non-string ids and defaults a missing selection", () => { + sessionStorage.setItem( + SESSION_KEY, + JSON.stringify({ v: 2, fileIds: ["a", 7, null, "b"] }), + ); + expect(readWorkbenchSession()).toMatchObject({ + fileIds: ["a", "b"], + selectedFileIds: [], + }); + }); +}); + +describe("editor return path", () => { + it("captures the live address bar and is consumed by the first take", () => { + // The editor writes its tool route via raw history.pushState, so the save + // must read window.location, not a lagging router location. + window.history.pushState({}, "", "/compress?x=1"); + saveEditorReturnPath(); + expect(takeEditorReturnPath()).toBe("/compress?x=1"); + expect(takeEditorReturnPath()).toBeNull(); + window.history.pushState({}, "", "/"); + }); +}); + +describe("originalIdOf", () => { + it("prefers the original id and falls back to the file id", () => { + expect( + originalIdOf({ id: "v3", originalFileId: "root" } as StirlingFileStub), + ).toBe("root"); + expect( + originalIdOf({ id: "v1", originalFileId: "" } as StirlingFileStub), + ).toBe("v1"); + }); +}); + +describe("record hygiene", () => { + it("discards a record written by an older schema", () => { + // No version stamp: a shape this build no longer understands. + sessionStorage.setItem( + SESSION_KEY, + JSON.stringify({ fileIds: ["a"], selectedFileIds: [] }), + ); + expect(readWorkbenchSession()).toBeNull(); + + // v1 recorded userId before it meant anything, so those must go too rather than + // look like a workbench that legitimately belongs to an anonymous session. + sessionStorage.setItem( + SESSION_KEY, + JSON.stringify({ + v: 1, + fileIds: ["a"], + selectedFileIds: [], + userId: null, + }), + ); + expect(readWorkbenchSession()).toBeNull(); + }); + + it("drops the previous record when a write fails, rather than leaving it stale", () => { + writeWorkbenchSession({ fileIds: ["old"], selectedFileIds: [] }); + const setItem = vi + .spyOn(Storage.prototype, "setItem") + .mockImplementation(() => { + throw new Error("QuotaExceededError"); + }); + + writeWorkbenchSession({ fileIds: ["new"], selectedFileIds: [] }); + setItem.mockRestore(); + + // Better to restore nothing than to restore a workbench the user has moved on from. + expect(readWorkbenchSession()).toBeNull(); + }); + + it("records who the workbench belonged to", () => { + writeWorkbenchSession({ + fileIds: ["a"], + selectedFileIds: [], + userId: "user-1", + }); + expect(readWorkbenchSession()?.userId).toBe("user-1"); + }); + + it("stays gone after sign-out, even though the teardown writes once more", () => { + writeWorkbenchSession({ fileIds: ["a", "b"], selectedFileIds: [] }); + + suspendWorkbenchSession(); + // Signing out unmounts the editor, whose flush writes the workbench one last time - + // with no user attached. Clearing alone let that recreate the record. + writeWorkbenchSession({ + fileIds: ["a", "b"], + selectedFileIds: [], + userId: null, + }); + + expect(sessionStorage.getItem(SESSION_KEY)).toBeNull(); + }); + + it("keeps the owner when the identity is momentarily unknown", () => { + // A sign-out teardown and a failed /auth/me both write with no user attached. Losing the + // owner here would make the record unrestorable for the person it belongs to. + writeWorkbenchSession({ + fileIds: ["a", "b"], + selectedFileIds: [], + userId: "user-a", + }); + + writeWorkbenchSession({ + fileIds: ["a", "b"], + selectedFileIds: [], + userId: null, + }); + + expect(readWorkbenchSession()?.userId).toBe("user-a"); + }); + + it("still records for a genuinely anonymous session", () => { + // Core has no auth at all, so null is the normal owner there and must keep working. + writeWorkbenchSession({ + fileIds: ["a"], + selectedFileIds: [], + userId: null, + }); + expect(readWorkbenchSession()?.fileIds).toEqual(["a"]); + }); + + it("records again once a new editor session starts", () => { + suspendWorkbenchSession(); + resumeWorkbenchSession(); + writeWorkbenchSession({ fileIds: ["a"], selectedFileIds: [] }); + expect(readWorkbenchSession()?.fileIds).toEqual(["a"]); + }); + + it("clears on request", () => { + writeWorkbenchSession({ fileIds: ["a"], selectedFileIds: [] }); + clearWorkbenchSession(); + expect(readWorkbenchSession()).toBeNull(); + }); +}); + +describe("views the restore may reopen", () => { + it("accepts the workbench views a session can land on", () => { + expect(isSeedableView("viewer")).toBe(true); + expect(isSeedableView("fileEditor")).toBe(true); + expect(isSeedableView("pageEditor")).toBe(true); + }); + + it("leaves URL-owned and tool-owned views alone", () => { + // HomePage pins myFiles to /files and bounces it elsewhere; custom views belong to a tool. + expect(isSeedableView("myFiles")).toBe(false); + expect(isSeedableView("custom:compare")).toBe(false); + expect(isSeedableView(undefined)).toBe(false); + }); +}); diff --git a/frontend/editor/src/core/services/workbenchSession.ts b/frontend/editor/src/core/services/workbenchSession.ts new file mode 100644 index 0000000000..2f23db8655 --- /dev/null +++ b/frontend/editor/src/core/services/workbenchSession.ts @@ -0,0 +1,176 @@ +// The tab's last editor session (open files, selection, view), so a processor switch or reload +// does not cost the user their workbench. sessionStorage on purpose: per-tab, tabs never clobber. + +import type { StirlingFileStub } from "@app/types/fileContext"; +import { stripBasePath } from "@app/constants/app"; + +const SESSION_KEY = "stirling.workbench.session"; +/** Bumped when the record's shape or meaning changes, so an old one is discarded rather than + * half-read. v2: `userId` became meaningful - v1 records were written without a real owner and + * would otherwise look like they belonged to an anonymous session forever. */ +const SESSION_VERSION = 2; +const RETURN_PATH_KEY = "stirling.workbench.editorReturnPath"; + +// All ids are ORIGINAL file ids - a file's stable identity across versions. +export interface WorkbenchSession { + fileIds: string[]; + selectedFileIds: string[]; + /** Which view was on screen. Absent for a record written before this was tracked. */ + workbench?: string; + activeFileId?: string; + /** Fingerprint of who the workbench belonged to, so the next person in this tab does not + * inherit it. Never the account id itself - see {@link fingerprintOwner}. */ + userId?: string | null; +} + +/** A file's stable identity across versions - what the session records. */ +export function originalIdOf(stub: StirlingFileStub): string { + return stub.originalFileId || (stub.id as string); +} + +export function readWorkbenchSession(): WorkbenchSession | null { + try { + const raw = sessionStorage.getItem(SESSION_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as Partial & { + v?: number; + }; + if (parsed.v !== SESSION_VERSION) return null; + if (!Array.isArray(parsed.fileIds)) return null; + return { + fileIds: parsed.fileIds.filter((id) => typeof id === "string"), + selectedFileIds: Array.isArray(parsed.selectedFileIds) + ? parsed.selectedFileIds.filter((id) => typeof id === "string") + : [], + workbench: + typeof parsed.workbench === "string" ? parsed.workbench : undefined, + activeFileId: + typeof parsed.activeFileId === "string" + ? parsed.activeFileId + : undefined, + userId: typeof parsed.userId === "string" ? parsed.userId : null, + }; + } catch { + return null; + } +} + +// Sign-out clears the record, but signing out also tears the editor down - and that teardown +// flushes the workbench one last time, recreating what we just deleted (with no user attached). +// So a sign-out has to stop writing too, not merely clear. +let writesSuspended = false; + +/** Sign-out: drop the record and stop recording, so the teardown cannot put it back. */ +export function suspendWorkbenchSession(): void { + writesSuspended = true; + clearWorkbenchSession(); +} + +/** A fresh editor mount is a new session, so recording starts again. */ +export function resumeWorkbenchSession(): void { + writesSuspended = false; +} + +export function writeWorkbenchSession(session: WorkbenchSession): void { + if (writesSuspended) return; + try { + // Never downgrade a known owner to "nobody". Signing out and a failed identity check both + // read as no user, and dropping the owner would either hand the workbench to whoever signs + // in next or lose it for the person it belongs to. Keeping the owner leaves the restore's + // ownership check to decide, which it does with a settled identity. + const owner = session.userId ?? readWorkbenchSession()?.userId ?? null; + sessionStorage.setItem( + SESSION_KEY, + JSON.stringify({ ...session, userId: owner, v: SESSION_VERSION }), + ); + } catch { + // Storage refused (quota, privacy mode). setItem is atomic, so the PREVIOUS record would + // survive and restore an older workbench - drop it, so the failure is "no restore" instead. + clearWorkbenchSession(); + } +} + +/** + * A one-way fingerprint of the signed-in user. Owners are only ever compared, never read back, so + * the account id itself never needs to reach storage. Falls back to a non-cryptographic digest + * where SubtleCrypto is absent (a self-hosted instance served over plain http): the fingerprint + * only has to tell two accounts sharing one tab apart, and the files it gates are reachable from + * My Files regardless, since IndexedDB is per-origin. + */ +export async function fingerprintOwner(userId: string): Promise { + if (globalThis.crypto?.subtle) { + const digest = await globalThis.crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(userId), + ); + return Array.from(new Uint8Array(digest)) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join("") + .slice(0, 32); + } + let hash = 0x811c9dc5; + for (let i = 0; i < userId.length; i++) { + hash ^= userId.charCodeAt(i); + hash = Math.imul(hash, 0x01000193) >>> 0; + } + return `fnv-${hash.toString(16)}`; +} + +/** Drop the record: on sign-out, and whenever it would otherwise be restored for the wrong person. */ +export function clearWorkbenchSession(): void { + try { + sessionStorage.removeItem(SESSION_KEY); + } catch { + // A record we cannot remove is also one we cannot read. + } +} + +/** Views a restore may seed directly. "myFiles" is URL-owned (HomePage pins it to /files) and a + * custom view belongs to its tool - the editor return path restores those instead. */ +const SEEDABLE_VIEWS = ["viewer", "fileEditor", "pageEditor"]; + +// Raised while a restore is applying its recorded view, so writers that pick a default view from +// whatever is loaded at the time defer to the restore rather than race it. +let applyingRestoredView = false; +let restoreGeneration = 0; + +/** Returns a token for endRestoredView, so a stale release cannot end a newer restore. */ +export function beginRestoredView(): number { + applyingRestoredView = true; + return ++restoreGeneration; +} + +export function endRestoredView(token: number): void { + if (token === restoreGeneration) applyingRestoredView = false; +} + +export function isApplyingRestoredView(): boolean { + return applyingRestoredView; +} + +export function isSeedableView( + view: string | undefined, +): view is "viewer" | "fileEditor" | "pageEditor" { + return view !== undefined && SEEDABLE_VIEWS.includes(view); +} + +export function saveEditorReturnPath(): void { + try { + const path = + stripBasePath(window.location.pathname) + window.location.search; + sessionStorage.setItem(RETURN_PATH_KEY, path); + } catch { + // Best-effort: the switch back just lands on the editor root. + } +} + +/** One-shot: consumed by the switch back so a stale path cannot linger. */ +export function takeEditorReturnPath(): string | null { + try { + const path = sessionStorage.getItem(RETURN_PATH_KEY); + if (path !== null) sessionStorage.removeItem(RETURN_PATH_KEY); + return path; + } catch { + return null; + } +} diff --git a/frontend/editor/src/core/tests/live/viewer-sidebar-add-buttons.spec.ts b/frontend/editor/src/core/tests/live/viewer-sidebar-add-buttons.spec.ts index df3ce94b23..a2e2c9c7e3 100644 --- a/frontend/editor/src/core/tests/live/viewer-sidebar-add-buttons.spec.ts +++ b/frontend/editor/src/core/tests/live/viewer-sidebar-add-buttons.spec.ts @@ -60,7 +60,8 @@ function fixture(filename: string): string { } async function openSamplePdfInViewer(page: import("@playwright/test").Page) { - await page.goto("/read"); + // Not /read: reading collapses the workbench bar these sidebars are toggled from. + await page.goto("/"); await page.waitForLoadState("domcontentloaded"); await page .locator('[data-testid="file-input"]') diff --git a/frontend/editor/src/core/tests/stubbed/engine-capabilities.spec.ts b/frontend/editor/src/core/tests/stubbed/engine-capabilities.spec.ts index 6107fb5276..c5b24dd6ac 100644 --- a/frontend/editor/src/core/tests/stubbed/engine-capabilities.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/engine-capabilities.spec.ts @@ -107,6 +107,12 @@ test.describe("engine capabilities", { tag: "@engine-capability" }, () => { await uploadFiles(page, SAMPLE_PDF); + // Dropped before the reload boots, so it cannot reopen the file for us: the eye + // below toggles, and whether the restore runs is a build flag this spec does not own. + await page.addInitScript(() => + sessionStorage.removeItem("stirling.workbench.session"), + ); + // Full reload: FileContext rehydrates from IndexedDB, not from memory. await page.reload({ waitUntil: "domcontentloaded" }); diff --git a/frontend/editor/src/core/tests/stubbed/viewer-sidebar-add-buttons.spec.ts b/frontend/editor/src/core/tests/stubbed/viewer-sidebar-add-buttons.spec.ts index 67df3865e1..f0da928122 100644 --- a/frontend/editor/src/core/tests/stubbed/viewer-sidebar-add-buttons.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/viewer-sidebar-add-buttons.spec.ts @@ -23,7 +23,8 @@ const SAMPLE_PDF = path.join( ); async function openViewerWithSample(page: import("@playwright/test").Page) { - await page.goto("/read"); + // Not /read: reading collapses the workbench bar these sidebars are toggled from. + await page.goto("/"); await page.waitForLoadState("domcontentloaded"); await page .locator('[data-testid="file-input"]') diff --git a/frontend/editor/src/core/tests/stubbed/workbench-session-restore.spec.ts b/frontend/editor/src/core/tests/stubbed/workbench-session-restore.spec.ts new file mode 100644 index 0000000000..c1a71ac48c --- /dev/null +++ b/frontend/editor/src/core/tests/stubbed/workbench-session-restore.spec.ts @@ -0,0 +1,213 @@ +import { test, expect } from "@app/tests/helpers/stub-test-base"; +import { uploadFiles } from "@app/tests/helpers/ui-helpers"; +import path from "path"; + +const FIXTURES_DIR = path.join(import.meta.dirname, "../test-fixtures"); +const SAMPLES = [ + "compare_sample_a.pdf", + "compare_sample_b.pdf", + "sample.pdf", + "rotated-pages.pdf", + "annotations_out_of_order.pdf", +].map((name) => path.join(FIXTURES_DIR, name)); + +// Read from the running app, not imported: a spec resolves @app/* to a different layer than +// the browser build does, so an imported WORKBENCH_SESSION_RESTORE can disagree with reality. +async function restoreEnabled( + page: import("@playwright/test").Page, +): Promise { + await page.waitForFunction( + () => document.documentElement.dataset.workbenchRestore !== undefined, + null, + { timeout: 20000 }, + ); + return page.evaluate( + () => document.documentElement.dataset.workbenchRestore === "true", + ); +} + +const NO_RESTORE = "this build ships the workbench restore off"; +const NO_PORTAL = "this build ships no processor to switch to"; + +// Switching editor -> processor unmounts every editor provider; the session record +// in sessionStorage is what brings the workbench back on return. +test.describe("Workbench survives the editor/processor switch", () => { + test.use({ + stubOptions: { + enableLogin: true, + user: { + id: 44, + username: "owner", + email: "owner@example.com", + role: "ROLE_USER", + portalAccess: true, + }, + }, + seedJwt: true, + }); + + test("open files and the library return after a round-trip", async ({ + page, + }) => { + test.skip(!(await restoreEnabled(page)), NO_RESTORE); + + // Portal endpoints the processor shell fetches on mount. + for (const [pattern, json] of [ + ["**/api/v1/policies", []], + ["**/api/v1/policies/runs", []], + ["**/api/v1/policies/overview", { pipelines: [] }], + ["**/api/v1/sources", { sources: [] }], + ["**/api/v1/team/my", []], + ] as const) { + await page.route(pattern, (route) => route.fulfill({ json })); + } + + await uploadFiles(page, SAMPLES); + await expect(page.locator(".file-sidebar-file-item")).toHaveCount( + SAMPLES.length, + { timeout: 15000 }, + ); + + // Uploading lands on the file grid, not the viewer - so the return has a + // view it can get wrong (NavigationContext boots to "viewer"). + await expect( + page.getByRole("radio", { name: /Active Files/i }), + ).toBeChecked(); + + // Out through the rail's processor mark, the only chrome that offers the switch. + const processorMark = page.getByRole("button", { name: /^Processor$/i }); + test.skip( + !(await processorMark.isVisible({ timeout: 5_000 }).catch(() => false)), + NO_PORTAL, + ); + await processorMark.click(); + await expect(page).toHaveURL(/\/processor/, { timeout: 15000 }); + + // Split the two halves of the feature: if this fails the writer is at fault, + // if it passes but the view below is wrong the seeding is. + expect( + await page.evaluate(() => ({ + session: JSON.parse( + sessionStorage.getItem("stirling.workbench.session") ?? "{}", + ), + returnPath: sessionStorage.getItem( + "stirling.workbench.editorReturnPath", + ), + })), + ).toMatchObject({ + session: { workbench: "fileEditor" }, + returnPath: "/editor", + }); + + // Load the editor cold. Every provider mounts from nothing here, which is + // the loss the restore has to cover on the way back. + await page.goto("/editor", { waitUntil: "domcontentloaded" }); + + await expect(page.locator(".file-sidebar-file-item")).toHaveCount( + SAMPLES.length, + { timeout: 20000 }, + ); + await expect(page.getByText(/compare_sample_a/i).first()).toBeVisible(); + await expect( + page.getByRole("radio", { name: /Active Files/i }), + ).toBeChecked({ timeout: 15000 }); + await expect(page.locator(".file-sidebar-loading")).toHaveCount(0, { + timeout: 15000, + }); + }); +}); + +test.describe("The view survives a reload", () => { + test.use({ + stubOptions: { + enableLogin: true, + user: { + id: 44, + username: "owner", + email: "o@e.com", + role: "ROLE_USER", + portalAccess: true, + }, + }, + seedJwt: true, + }); + + const currentView = (page: import("@playwright/test").Page) => + page.evaluate(() => { + const r = Array.from( + document.querySelectorAll("input[type=radio]"), + ).find((x) => x.checked); + return r?.value ?? "none"; + }); + + test("comes back on the same view the user left", async ({ page }) => { + test.skip(!(await restoreEnabled(page)), NO_RESTORE); + + await uploadFiles(page, SAMPLES.slice(0, 3)); + await expect(page.locator(".file-sidebar-file-item")).toHaveCount(3, { + timeout: 15000, + }); + + // Open a document, so the view under test is the viewer rather than the grid. + await page + .getByRole("button", { name: /Open in Viewer/i }) + .first() + .click({ force: true }); + await expect + .poll(() => currentView(page), { timeout: 10000 }) + .toBe("viewer"); + + // Whatever the workbench settled on is what a reload must reproduce. + const before = await currentView(page); + await page.waitForTimeout(600); + + await page.reload({ waitUntil: "domcontentloaded" }); + await expect(page.locator(".file-sidebar-file-item")).toHaveCount(3, { + timeout: 20000, + }); + await page.waitForTimeout(3000); + expect(await currentView(page)).toBe(before); + }); + + // The conjunction neither neighbour covers: the spec above proves the VIEW comes back, + // engine-capabilities proves stored bytes decode, and nothing proved that the file the + // restore reopened is one whose pixels actually arrive. + test("a file the restore reopened renders its pages", async ({ page }) => { + test.setTimeout(120_000); + test.skip(!(await restoreEnabled(page)), NO_RESTORE); + + await uploadFiles(page, SAMPLES.slice(0, 3)); + await expect(page.locator(".file-sidebar-file-item")).toHaveCount(3, { + timeout: 15_000, + }); + + await page + .getByRole("button", { name: /Open in Viewer/i }) + .first() + .click({ force: true }); + await expect + .poll(() => currentView(page), { timeout: 10_000 }) + .toBe("viewer"); + // Let the record settle: the writer debounces, so a reload can outrun it. + await page.waitForTimeout(600); + + await page.reload({ waitUntil: "domcontentloaded" }); + await expect(page.locator(".file-sidebar-file-item")).toHaveCount(3, { + timeout: 30_000, + }); + + // A tile that decoded has non-zero naturalWidth. The restore resolves each recorded id + // to its current leaf, so an empty tile here means it reopened something unreadable. + const tile = page + .locator('[data-page-index="0"]') + .first() + .locator('img[src^="blob:"]') + .first(); + await expect(tile).toBeAttached({ timeout: 30_000 }); + await expect + .poll(() => tile.evaluate((img: HTMLImageElement) => img.naturalWidth), { + timeout: 30_000, + }) + .toBeGreaterThan(0); + }); +}); diff --git a/frontend/editor/src/core/theme/colors.css b/frontend/editor/src/core/theme/colors.css index 31f9666271..1a74158022 100644 --- a/frontend/editor/src/core/theme/colors.css +++ b/frontend/editor/src/core/theme/colors.css @@ -46,7 +46,7 @@ html[data-app-theme="light"] { non-text floor applies. Scheme-independent: a filled badge reads white on either ground. */ --c-success-solid: var(--p-green-700); - --c-danger-solid: var(--p-red-600); + --c-danger-solid: var(--p-red-700); --c-warning-solid: var(--p-amber-700); --c-neutral-solid: var(--p-gray-600); --c-accent-solid: var(--p-blue-600); diff --git a/frontend/editor/src/core/theme/dimensions.css b/frontend/editor/src/core/theme/dimensions.css index 554baddcca..50531641cb 100644 --- a/frontend/editor/src/core/theme/dimensions.css +++ b/frontend/editor/src/core/theme/dimensions.css @@ -30,7 +30,12 @@ --radius-nav: 0.625rem; --nav-gutter: 0.5rem; - --nav-rail-w: 3.5rem; + /* Every minimised rail is this wide, so they line up as one column of icons. */ + --nav-rail-w: 3rem; + /* Header row, so the rail's brand and a sidebar's wordmark line up. */ + --nav-header-h: 3rem; + --sidebar-w: 16.25rem; + --sidebar-collapsed-w: var(--nav-rail-w); /* ── Layout sizing ── */ --footer-height: 2rem; diff --git a/frontend/editor/src/core/tools/Convert.tsx b/frontend/editor/src/core/tools/Convert.tsx index eb9a4a02c5..733a21b6e4 100644 --- a/frontend/editor/src/core/tools/Convert.tsx +++ b/frontend/editor/src/core/tools/Convert.tsx @@ -36,7 +36,6 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { }); setSelectedFiles(matching.map((file) => file.fileId)); }; - const scrollContainerRef = useRef(null); const convertParams = useConvertParameters(); const convertOperation = useConvertOperation(convertParams.parameters); @@ -48,16 +47,6 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const skipNextSelectionResetRef = useRef(false); const previousSelectionRef = useRef(""); - const scrollToBottom = () => { - if (scrollContainerRef.current) { - scrollContainerRef.current.scrollTo({ - top: scrollContainerRef.current.scrollHeight, - behavior: "smooth", - }); - } - }; - - const hasFiles = selectedFiles.length > 0; const hasResults = convertOperation.files.length > 0 || convertOperation.downloadUrl !== null || @@ -115,18 +104,6 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { convertParams.parameters.toExtension, ]); - useEffect(() => { - if (hasFiles) { - setTimeout(scrollToBottom, 100); - } - }, [hasFiles]); - - useEffect(() => { - if (hasResults) { - setTimeout(scrollToBottom, 100); - } - }, [hasResults]); - const handleConvert = async () => { try { await convertOperation.executeOperation( diff --git a/frontend/editor/src/core/tools/formFill/FieldInput.tsx b/frontend/editor/src/core/tools/formFill/FieldInput.tsx index 9662298dc4..9a8943ea47 100644 --- a/frontend/editor/src/core/tools/formFill/FieldInput.tsx +++ b/frontend/editor/src/core/tools/formFill/FieldInput.tsx @@ -68,8 +68,11 @@ function FieldInputInner({ ); case "checkbox": { - const isChecked = !!value && value !== "Off"; - const onValue = (field.widgets && field.widgets[0]?.exportValue) || "Yes"; + const exportVal = field.widgets && field.widgets[0]?.exportValue; + const isChecked = exportVal + ? value === exportVal || value === "Yes" + : !!value && value !== "Off"; + const onValue = exportVal || "Yes"; return ( @@ -168,7 +170,7 @@ export function FormSaveBar({ {isDirty && ( - + @@ -186,7 +188,7 @@ export function FormSaveBar({ loading={saving} disabled={applying || policyEnforcing} onClick={handleDownload} - style={{ flex: 1 }} + style={{ flex: "1 1 10rem", minWidth: 0 }} > {t("viewer.formBar.download", "Download PDF")} diff --git a/frontend/editor/src/core/tools/formFill/providers/PdfiumFormProvider.ts b/frontend/editor/src/core/tools/formFill/providers/PdfiumFormProvider.ts index 87d18c3f2f..471bba5e43 100644 --- a/frontend/editor/src/core/tools/formFill/providers/PdfiumFormProvider.ts +++ b/frontend/editor/src/core/tools/formFill/providers/PdfiumFormProvider.ts @@ -92,7 +92,7 @@ function toFormField( // Derive value string let value = f.value; if (type === "checkbox") { - value = f.isChecked ? "Yes" : "Off"; + value = f.isChecked ? f.widgets[0]?.exportValue || "Yes" : "Off"; } else if (type === "radio") { // Use widget index as the canonical radio value. // This avoids issues with duplicate exportValues across widgets diff --git a/frontend/editor/src/core/types/toolApiTypes.ts b/frontend/editor/src/core/types/toolApiTypes.ts index 55938d231e..d980029624 100644 --- a/frontend/editor/src/core/types/toolApiTypes.ts +++ b/frontend/editor/src/core/types/toolApiTypes.ts @@ -1022,6 +1022,10 @@ export interface ProcessPdfWithOcrRequest { * Remove images from the output PDF if set to true */ removeImagesAfter?: boolean; + /** + * Auto-correct page orientation (90/180/270) using Tesseract OSD if set to true + */ + rotatePages?: boolean; /** * Include OCR text in a sidecar text file if set to true */ diff --git a/frontend/editor/src/core/ui/Modal.css b/frontend/editor/src/core/ui/Modal.css index 2988ec920e..e41ffb0eda 100644 --- a/frontend/editor/src/core/ui/Modal.css +++ b/frontend/editor/src/core/ui/Modal.css @@ -1,11 +1,16 @@ +/* The inset is symmetric so the panel sits in the optical centre of the viewport rather than + riding the top edge. It is published as a var because .sui-modal's max-height has to be the + viewport minus both halves of it — if the two drift apart a tall modal overflows the backdrop + and, because the panel is centre-aligned, loses its header off the top of the screen. */ .sui-modal__backdrop { + --modal-inset-block: 2.5rem; position: fixed; inset: 0; background: rgba(0, 0, 0, 0.55); display: flex; - align-items: flex-start; + align-items: center; justify-content: center; - padding: 5rem 1.5rem 1.5rem; + padding: var(--modal-inset-block) 1.5rem; z-index: 100; animation: fadeIn 0.18s ease both; overscroll-behavior: contain; @@ -24,20 +29,19 @@ display: flex; flex-direction: column; width: 100%; - max-height: calc(100vh - 6.5rem); - max-height: calc(100dvh - 6.5rem); /* mobile browser chrome shrinks 100vh */ + max-height: calc(100vh - var(--modal-inset-block) * 2); + /* mobile browser chrome shrinks 100vh */ + max-height: calc(100dvh - var(--modal-inset-block) * 2); overflow: hidden; animation: scaleIn 0.2s cubic-bezier(0.4, 0, 0.2, 1) both; } -/* Phones: drop the tall top inset so the modal gets the vertical space */ +/* Phones: tighten the inset so the modal gets the vertical space. Only the variable moves — + the max-height above follows it, so the pair cannot fall out of step. */ @media (max-width: 30rem) { .sui-modal__backdrop { - padding: 1rem 0.75rem; - align-items: center; - } - .sui-modal { - max-height: calc(100dvh - 2rem); + --modal-inset-block: 1rem; + padding-inline: 0.75rem; } } diff --git a/frontend/editor/src/core/ui/NavSurface.tsx b/frontend/editor/src/core/ui/NavSurface.tsx index 38947fb91f..37caaa063e 100644 --- a/frontend/editor/src/core/ui/NavSurface.tsx +++ b/frontend/editor/src/core/ui/NavSurface.tsx @@ -2,8 +2,8 @@ import { forwardRef, type HTMLAttributes } from "react"; import "@app/ui/NavSurface.css"; export interface NavSurfaceProps extends HTMLAttributes { - /** Element to render; `section`/`aside` when the box is a landmark. */ - as?: "div" | "section" | "aside"; + /** Element to render; `section`/`aside`/`nav` when the box is a landmark. */ + as?: "div" | "section" | "aside" | "nav"; } /** diff --git a/frontend/editor/src/core/utils/cropCoordinates.ts b/frontend/editor/src/core/utils/cropCoordinates.ts index 5a275c85ea..4b3bf1c600 100644 --- a/frontend/editor/src/core/utils/cropCoordinates.ts +++ b/frontend/editor/src/core/utils/cropCoordinates.ts @@ -204,7 +204,21 @@ export const isPointInThumbnail = ( }; /** - * Create a default crop area that covers the entire PDF + * Create a default crop area inside PDF bounds (10% inset from each edge, centered) + */ +export const createDefaultCropArea = (pdfBounds: PDFBounds): Rectangle => { + const insetX = pdfBounds.actualWidth * 0.1; + const insetY = pdfBounds.actualHeight * 0.1; + return { + x: Math.round(insetX * 10) / 10, + y: Math.round(insetY * 10) / 10, + width: Math.round((pdfBounds.actualWidth - insetX * 2) * 10) / 10, + height: Math.round((pdfBounds.actualHeight - insetY * 2) * 10) / 10, + }; +}; + +/** + * Create a crop area that covers the entire PDF */ export const createFullPDFCropArea = (pdfBounds: PDFBounds): Rectangle => { return { diff --git a/frontend/editor/src/core/utils/homePageNavigation.ts b/frontend/editor/src/core/utils/homePageNavigation.ts index 001e026710..7a91bfec65 100644 --- a/frontend/editor/src/core/utils/homePageNavigation.ts +++ b/frontend/editor/src/core/utils/homePageNavigation.ts @@ -1,4 +1,4 @@ -import type { WorkbenchType } from "@app/types/workbench"; +import { getDefaultWorkbench, type WorkbenchType } from "@app/types/workbench"; export type StartupWorkbench = "viewer" | "fileEditor"; @@ -7,6 +7,13 @@ export interface StartupNavigationAction { activeFileIndex?: number; } +/** Several files means the file editor; one or none the viewer. */ +export function getDefaultWorkbenchForFileCount( + fileCount: number, +): WorkbenchType { + return fileCount > 1 ? "fileEditor" : getDefaultWorkbench(); +} + export function getStartupNavigationAction( previousFileCount: number, currentFileCount: number, diff --git a/frontend/editor/src/core/utils/pendingReaderMode.ts b/frontend/editor/src/core/utils/pendingReaderMode.ts new file mode 100644 index 0000000000..faacfba6b8 --- /dev/null +++ b/frontend/editor/src/core/utils/pendingReaderMode.ts @@ -0,0 +1,13 @@ +let pending = false; + +/** Carries "open in reading mode" across an app switch, and deliberately not a reload. */ +export function requestReaderMode(): void { + pending = true; +} + +/** True once per request. */ +export function consumeReaderModeRequest(): boolean { + if (!pending) return false; + pending = false; + return true; +} diff --git a/frontend/editor/src/core/utils/viewTransition.test.ts b/frontend/editor/src/core/utils/viewTransition.test.ts new file mode 100644 index 0000000000..5fb11c8aa2 --- /dev/null +++ b/frontend/editor/src/core/utils/viewTransition.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { withViewTransition } from "@app/utils/viewTransition"; + +// The stub carries only the field the helper reads, hence the cast through unknown. +type MutableDoc = { startViewTransition?: unknown }; +const doc = document as unknown as MutableDoc; + +function stubApi(): ReturnType { + const start = vi.fn((cb: () => void) => { + cb(); + return { finished: Promise.resolve() }; + }); + doc.startViewTransition = start; + return start; +} + +function stubReducedMotion(reduced: boolean): void { + vi.stubGlobal("matchMedia", (query: string) => ({ + matches: reduced && query.includes("prefers-reduced-motion"), + media: query, + addEventListener: () => {}, + removeEventListener: () => {}, + })); +} + +afterEach(() => { + delete doc.startViewTransition; + vi.unstubAllGlobals(); +}); + +describe("withViewTransition", () => { + it("runs the update inside a transition when one is possible", async () => { + const start = stubApi(); + stubReducedMotion(false); + const update = vi.fn(); + + await withViewTransition(update); + + expect(start).toHaveBeenCalledTimes(1); + expect(update).toHaveBeenCalledTimes(1); + }); + + it("skips the transition when the user asked for less motion", async () => { + // The state change must still happen - only the animation is dropped. + const start = stubApi(); + stubReducedMotion(true); + const update = vi.fn(); + + await withViewTransition(update); + + expect(start).not.toHaveBeenCalled(); + expect(update).toHaveBeenCalledTimes(1); + }); + + it("still applies the update where the API is unavailable", async () => { + stubReducedMotion(false); + const update = vi.fn(); + + await withViewTransition(update); + + expect(update).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/editor/src/core/utils/viewTransition.ts b/frontend/editor/src/core/utils/viewTransition.ts index 049c3e7673..85a05ca78d 100644 --- a/frontend/editor/src/core/utils/viewTransition.ts +++ b/frontend/editor/src/core/utils/viewTransition.ts @@ -4,21 +4,20 @@ type ViewTransitionDoc = Document & { startViewTransition?: (cb: () => void) => { finished: Promise }; }; -/** - * Run a state update inside a View Transition so the browser cross-fades - * (and morphs any elements sharing a {@code view-transition-name}) between - * the before/after DOMs. - * - * Falls back to a plain synchronous update when the API is unavailable - * (Firefox <130, JSDOM, motion-reduced preference). - */ +/** Runs a state update in a View Transition, plainly where that is unavailable. */ export function withViewTransition(update: () => void): Promise { if (typeof document === "undefined") { update(); return Promise.resolve(); } + // Callers don't each check: reduced motion still gets the state change. + const reduced = + typeof window !== "undefined" && + typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + const doc = document as ViewTransitionDoc; - if (doc.startViewTransition) { + if (doc.startViewTransition && !reduced) { return doc.startViewTransition(() => flushSync(update)).finished; } update(); diff --git a/frontend/editor/src/desktop/components/session/WorkbenchSessionPersistence.tsx b/frontend/editor/src/desktop/components/session/WorkbenchSessionPersistence.tsx new file mode 100644 index 0000000000..2957ac3735 --- /dev/null +++ b/frontend/editor/src/desktop/components/session/WorkbenchSessionPersistence.tsx @@ -0,0 +1,4 @@ +// Stub: desktop opens OS-launched files on boot; a session restore would collide with that. +export function WorkbenchSessionPersistence() { + return null; +} diff --git a/frontend/editor/src/desktop/components/shared/AppSwitcher.tsx b/frontend/editor/src/desktop/components/shared/AppSwitcher.tsx deleted file mode 100644 index 21896d9a1d..0000000000 --- a/frontend/editor/src/desktop/components/shared/AppSwitcher.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { Logo } from "@app/ui/Logo"; -import { type AppSwitcherProps } from "@core/components/shared/AppSwitcher"; - -/** - * Desktop inherits proprietary's layers but does not ship the portal (see - * desktop/routes/adminRouteExtensions), so there's nothing to switch to — - * shadow the brand header back to a plain logo. (Also avoids the desktop - * bundle referencing @portal via the proprietary switcher's imports.) - */ -export function AppSwitcher({ collapsed }: AppSwitcherProps) { - return ( - - ); -} diff --git a/frontend/editor/src/desktop/extensions/accountLogout.ts b/frontend/editor/src/desktop/extensions/accountLogout.ts index b97b06ec6e..ca75c9c5e9 100644 --- a/frontend/editor/src/desktop/extensions/accountLogout.ts +++ b/frontend/editor/src/desktop/extensions/accountLogout.ts @@ -1,4 +1,5 @@ import { connectionModeService } from "@app/services/connectionModeService"; +import { suspendWorkbenchSession } from "@app/services/workbenchSession"; type SignOutFn = () => Promise; @@ -16,6 +17,10 @@ export function useAccountLogout() { redirectToLogin, }: AccountLogoutDeps): Promise => { try { + // The tab outlives the session; the next person to sign in here must not + // inherit this workbench. Suspends writing too - signing out unmounts the + // editor, and its flush would otherwise write the record straight back. + suspendWorkbenchSession(); await signOut(); const currentConfig = await connectionModeService.getCurrentConfig(); diff --git a/frontend/editor/src/desktop/routes/hasPortal.ts b/frontend/editor/src/desktop/routes/hasPortal.ts new file mode 100644 index 0000000000..0eec40f4c9 --- /dev/null +++ b/frontend/editor/src/desktop/routes/hasPortal.ts @@ -0,0 +1,2 @@ +/** Desktop inherits proprietary's app but never ships the portal. */ +export const HAS_PORTAL = false; diff --git a/frontend/editor/src/portal/PortalProviders.tsx b/frontend/editor/src/portal/PortalProviders.tsx index 35c9fc7e88..a82f1c7405 100644 --- a/frontend/editor/src/portal/PortalProviders.tsx +++ b/frontend/editor/src/portal/PortalProviders.tsx @@ -1,52 +1,24 @@ import { TierProvider } from "@portal/contexts/TierContext"; -import { LinkProvider, useLink } from "@portal/contexts/LinkContext"; +import { LinkProvider } from "@portal/contexts/LinkContext"; import { UIProvider, useUI } from "@portal/contexts/UIContext"; -import type { SupabaseLoginSession } from "@app/auth/ui/useSupabaseLogin"; import { LinkAccountModal } from "@portal/components/account-link/LinkAccountModal"; -import { - AccountLinkProvider, - useAccountLinkContext, -} from "@portal/contexts/AccountLinkContext"; +import { AccountLinkProvider } from "@portal/contexts/AccountLinkContext"; +import { ConnectCallbackHost } from "@portal/components/account-link/ConnectCallbackHost"; import { PortalChrome } from "@portal/components/PortalChrome"; -/** - * The one and only account-link login modal. Mounted at the app root (never - * nested in another overlay) and driven by UIContext, so any "Link account" CTA - * — sidebar, billing prompt, feature gate, Settings panel — opens this exact - * instance. Linking is finished by the shared {@link useAccountLinkContext} - * orchestration. - */ +/** The one and only account-link modal. */ function LinkModalHost() { const { linkModalOpen, linkModalMode, closeLinkModal } = useUI(); - const { markSaasSessionChanged } = useLink(); - const link = useAccountLinkContext(); - // "reauth" only refreshes the browser SaaS session for attended reads — the - // sign-in already applied it to the Supabase client, so we just signal a - // refetch. It must NOT call completeLink (that re-registers → duplicate row). - const onLinked = - linkModalMode === "reauth" - ? () => markSaasSessionChanged() - : (session: SupabaseLoginSession) => link.completeLink(session); return ( ); } -/** - * Self-hosted provider stack. The account-link layer (LinkProvider + - * AccountLinkProvider + the login modal) wraps the shared chrome; the tier is - * derived from the link/subscription state (see usePlanTier). TierProvider sits - * inside LinkProvider because the self-hosted usePlanTier reads useLink. - * - * The SaaS build shadows this file to drop the account-link layer entirely — the - * signed-in account IS the SaaS account, so there is nothing to link and the - * tier comes from the wallet. - */ +/** Self-hosted provider stack. */ export function PortalProviders() { return ( @@ -55,6 +27,7 @@ export function PortalProviders() { + diff --git a/frontend/editor/src/portal/api/link.test.ts b/frontend/editor/src/portal/api/link.test.ts index e03d5bf25b..9c05952549 100644 --- a/frontend/editor/src/portal/api/link.test.ts +++ b/frontend/editor/src/portal/api/link.test.ts @@ -34,7 +34,6 @@ import { fetchInstances, fetchLocalUsage, fetchStatus, - linkInstance, revokeInstance, unlinkInstance, } from "@portal/api/link"; @@ -55,21 +54,14 @@ describe("api/link — local backend (this instance)", () => { expect(status.linked).toBe(false); }); - it("links this instance via the local endpoint, never returning a secret", async () => { - const status = await linkInstance({ - supabaseJwt: "jwt_abc", - name: "node-1", - }); - expect(status.linked).toBe(true); - expect(status.name).toBe("node-1"); - // Contract: the device secret is stored server-side, never sent to the portal. + it("never exposes the device credential in a status read", async () => { + // Contract: the device secret is stored server-side and the portal never sees it. + const status = await fetchStatus(); expect(status).not.toHaveProperty("deviceSecret"); expect(status).not.toHaveProperty("deviceId"); - expect(await (await fetchStatus()).linked).toBe(true); }); it("unlinks this instance", async () => { - await linkInstance({ supabaseJwt: "jwt_abc" }); // unlink returns 204 (no body); the status is read back separately. await unlinkInstance(); expect((await fetchStatus()).linked).toBe(false); @@ -84,18 +76,6 @@ describe("api/link — local backend (this instance)", () => { ); expect(usage.totalUnsyncedUnits).toBeGreaterThanOrEqual(0); }); - - it("forwards the SaaS JWT in the link body", async () => { - let seenBody: unknown = null; - server.events.on("request:start", async ({ request }) => { - if (request.method === "POST" && request.url.endsWith("/link")) { - seenBody = await request.clone().json(); - } - }); - await linkInstance({ supabaseJwt: "jwt_xyz", name: "n" }); - expect(seenBody).toMatchObject({ supabaseJwt: "jwt_xyz" }); - server.events.removeAllListeners(); - }); }); describe("api/link — SaaS backend (team-wide)", () => { diff --git a/frontend/editor/src/portal/api/link.ts b/frontend/editor/src/portal/api/link.ts index 696ccbe5b2..c3684b3dd2 100644 --- a/frontend/editor/src/portal/api/link.ts +++ b/frontend/editor/src/portal/api/link.ts @@ -1,13 +1,5 @@ import { apiClient } from "@portal/api/http"; -/** Body for POST /api/v1/account-link/link — the SaaS JWT + optional name. */ -export interface LinkInstanceRequest { - /** Admin's SaaS session JWT, obtained via the hosted-login popup. */ - supabaseJwt: string; - /** Optional label for this instance. */ - name?: string; -} - /** Link status for this instance (GET /api/v1/account-link/status). */ export interface LinkStatus { linked: boolean; @@ -15,12 +7,7 @@ export interface LinkStatus { name: string | null; } -/** - * Locally-accrued usage not yet reported to SaaS (GET /api/v1/account-link/usage). - * The portal adds this on top of the SaaS-synced spend so "current usage" - * includes work done since the last daily sync. Per-category unsynced units for - * the current period; all zero when metering is off or nothing is pending. - */ +/** Locally-accrued usage not yet reported to SaaS (GET /api/v1/account-link/usage). */ export interface LocalUsage { /** ISO timestamp of the current period start; null when unknown (not yet synced). */ periodStart: string | null; @@ -42,93 +29,88 @@ export interface LinkedInstanceRow { revoked: boolean; } -/** - * Account-link client (combined-billing "Mode A"). Two distinct surfaces: - * - * THIS instance — apiClient.local (Spring admin bearer auto-attached): - * - POST /api/v1/account-link/link — hand the local backend the admin's - * SaaS JWT in the body. It registers - * with SaaS + stores the device - * secret SERVER-SIDE; the portal - * NEVER receives or renders it. - * - GET /api/v1/account-link/status — Linked / Not-linked for this - * instance. - * - POST /api/v1/account-link/unlink — drop this instance's link (local - * backend best-effort tells SaaS). - * - * TEAM-WIDE management — apiClient.saas (admin's Supabase JWT auto-attached - * from the in-app account-link login): - * - GET /api/v1/account-link/instances — every linked instance - * - POST /api/v1/account-link/instances/{id}/revoke - * - * The team-wide endpoints are served by the hosted SaaS Java backend (the - * local backend has no such routes), so they go through apiClient.saas. In - * Storybook/tests, wildcard MSW handlers match both the local and absolute - * SaaS URLs. - */ +/** Account-link client (combined billing). */ const BASE = "/api/v1/account-link"; -/** - * Link THIS instance. The local backend takes the SaaS JWT, registers with - * SaaS, and persists the device secret itself; the response carries only the - * resulting link status. No secret is returned. - */ -export async function linkInstance( - req: LinkInstanceRequest, -): Promise { - return apiClient.local.json(`${BASE}/link`, { - method: "POST", - body: req, - }); -} - /** Linked / Not-linked for this instance. */ export async function fetchStatus(): Promise { return apiClient.local.json(`${BASE}/status`); } /** - * Locally-accrued usage not yet reported to SaaS — the portal adds this on top - * of the SaaS-synced spend so "current usage" includes work done since the last - * daily sync. Local-backend call; returns zeros when metering is off. + * Locally-accrued usage not yet reported to SaaS — the portal adds this on top of the SaaS-synced spend so "current usage" includes work done since the last daily sync. */ export async function fetchLocalUsage(): Promise { return apiClient.local.json(`${BASE}/usage`); } -/** - * Drop this instance's link. The local backend best-effort tells SaaS to - * revoke before clearing the credential locally, then returns 204 — there's no - * body, so the caller sets the known unlinked status itself. - */ +/** Drop this instance's link. */ export async function unlinkInstance(): Promise { await apiClient.local.json(`${BASE}/unlink`, { method: "POST" }); } -/** - * Nudge the local backend to sync + refresh its cached entitlement now. Called - * right after a checkout completes so the instance's request-time gate reflects - * the new subscription immediately instead of waiting out its entitlement-cache - * TTL. Best-effort — the caller swallows failures (metering off → 409, or the - * local backend unreachable); the scheduled sync / TTL refresh is the backstop. - */ +/** Nudge the local backend to sync + refresh its cached entitlement now. */ export async function triggerLocalSync(): Promise { await apiClient.local.json(`${BASE}/sync-now`, { method: "POST" }); } +/** Where a browser-mediated connect handshake has got to. */ +export type ConnectPhase = + | "NONE" + | "PENDING" + | "LINKED" + | "EXPIRED" + | "REJECTED" + | "UNAVAILABLE"; + +export interface ConnectStatus { + phase: ConnectPhase; + /** Approval page to send the admin to. */ + authorizeUrl: string | null; + secondsRemaining: number | null; + teamId: number | null; +} + +const CONNECT = `${BASE}/connect`; + +/** Open a handshake and get the approval URL to send the admin to. */ +export async function startConnect( + name?: string, + callbackUrl?: string, +): Promise { + return apiClient.local.json(`${CONNECT}/start`, { + method: "POST", + body: { name, callbackUrl }, + }); +} + +/** Re-establish the SaaS session for a server that is already linked. */ +export async function startReauth( + callbackUrl?: string, +): Promise { + return apiClient.local.json(`${CONNECT}/reauth`, { + method: "POST", + body: { callbackUrl }, + }); +} + +/** Finish a handshake using the nonce the approval page put in the callback fragment. */ +export async function completeConnect(nonce: string): Promise { + return apiClient.local.json(`${CONNECT}/complete`, { + method: "POST", + body: { nonce }, + }); +} + /** - * Every linked instance for the team — SaaS-direct call with the admin's - * Supabase JWT (no longer takes an accessToken parameter; the saas client - * resolves the live session itself). + * Every linked instance for the team — SaaS-direct call with the admin's Supabase JWT (no longer takes an accessToken parameter; the saas client resolves the live session itself). */ export async function fetchInstances(): Promise { return apiClient.saas.json(`${BASE}/instances`); } -/** - * Revoke a linked instance — SaaS-direct call with the admin's Supabase JWT. - */ +/** Revoke a linked instance — SaaS-direct call with the admin's Supabase JWT. */ export async function revokeInstance(instanceId: number): Promise { await apiClient.saas.json(`${BASE}/instances/${instanceId}/revoke`, { method: "POST", diff --git a/frontend/editor/src/portal/auth/saasSupabase.ts b/frontend/editor/src/portal/auth/saasSupabase.ts index 1005d63a63..45598c369b 100644 --- a/frontend/editor/src/portal/auth/saasSupabase.ts +++ b/frontend/editor/src/portal/auth/saasSupabase.ts @@ -22,11 +22,12 @@ const key = import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY; export const isSaasSupabaseConfigured = Boolean(url && key); -/** OAuth providers the hosted SaaS login offers (mirrors the SaaS editor login). */ -export const SAAS_OAUTH_PROVIDERS = ["google", "github", "apple", "azure"]; - -/** sessionStorage marker set before an SSO redirect so the return can finish the link. */ -export const PENDING_LINK_KEY = "stirling-account-link-pending"; +/* + * SAAS_OAUTH_PROVIDERS and PENDING_LINK_KEY are gone. They served an in-portal SSO sign-in that + * could not work: the provider only redirects to allow-listed URLs, so a customer's origin was + * never returned to and the admin was left on stirling.com. Provider choice now happens on our own + * origin during the connect handshake, where the redirect can actually complete. + */ let configured = false; diff --git a/frontend/editor/src/portal/components/AppShell.tsx b/frontend/editor/src/portal/components/AppShell.tsx index fe8f775fd0..ec6c720297 100644 --- a/frontend/editor/src/portal/components/AppShell.tsx +++ b/frontend/editor/src/portal/components/AppShell.tsx @@ -7,8 +7,11 @@ import { PortalSearchBar } from "@portal/components/PortalSearchBar"; import { useUI } from "@portal/contexts/UIContext"; import { MenuIcon, SearchIcon } from "@portal/components/icons"; import { Logo } from "@app/ui/Logo"; +import "@app/components/layout/WorkspaceFrame.css"; +import { QuickNavHostBridge } from "@app/components/shared/quickNav/QuickNavHostBridge"; import "@portal/components/AppShell.css"; import { NotificationBell } from "@app/components/notifications/NotificationBell"; +import { useIsPhone } from "@app/hooks/useIsMobile"; /** * Compact header shown only under the mobile breakpoint (CSS-hidden on @@ -58,8 +61,10 @@ function MobileTopbar() { * prop-free. */ export function AppShell({ children }: { children: ReactNode }) { - const { mobileNavOpen, closeMobileNav } = useUI(); + const { mobileNavOpen, closeMobileNav, openSettings } = useUI(); const { pathname } = useLocation(); + // Below this width the rail, and the bell it carries, is gone. + const isPhone = useIsPhone(); // Navigating (tap on a nav row, back button, deep link) always dismisses the // drawer. Depends on pathname only: the close fn's identity changes with any @@ -79,7 +84,11 @@ export function AppShell({ children }: { children: ReactNode }) { return (
- + {/* portalAccess: being here is proof the processor is available. */} + openSettings()} /> +
+ +
{mobileNavOpen && (
-
- -
+ {/* Phone only: above that the rail carries it, and this would be a second. */} + {isPhone && ( +
+ +
+ )}
{children}
diff --git a/frontend/editor/src/portal/components/EditorStatusCard.tsx b/frontend/editor/src/portal/components/EditorStatusCard.tsx index e4918ed859..774dc4d1be 100644 --- a/frontend/editor/src/portal/components/EditorStatusCard.tsx +++ b/frontend/editor/src/portal/components/EditorStatusCard.tsx @@ -11,31 +11,9 @@ import { import { type EditorInstance } from "@portal/api/editorDeploy"; import { DownloadEditorModal } from "@portal/components/DownloadEditorModal"; import "@portal/theme/surface.css"; +import { BrandTile } from "@app/components/shared/BrandTile"; import "@portal/components/EditorStatusCard.css"; -/** The Stirling brand mark, drawn at the hero size. Decorative. */ -function StirlingMark() { - return ( - - - - - - ); -} - /** The instance to headline: the busiest healthy one, else the first. */ function primaryInstance(instances: EditorInstance[]): EditorInstance | null { if (instances.length === 0) return null; @@ -126,7 +104,7 @@ export function EditorStatusCard({ footer }: EditorStatusCardProps) { >
- +
diff --git a/frontend/editor/src/portal/components/PortalSearchBar.css b/frontend/editor/src/portal/components/PortalSearchBar.css index 8e253b6136..847991af20 100644 --- a/frontend/editor/src/portal/components/PortalSearchBar.css +++ b/frontend/editor/src/portal/components/PortalSearchBar.css @@ -1,5 +1,4 @@ -/* Unpainted strip at the top of the main column. Height matches the sidebar's - logo row (.portal-sidebar__logo, 51px) so the search lines up with the brand. */ +/* Unpainted strip at the top of the main column, matching the sidebar header's height. */ .portal-searchbar { display: flex; align-items: center; diff --git a/frontend/editor/src/portal/components/Sidebar.css b/frontend/editor/src/portal/components/Sidebar.css index 48b54b177d..43f8c44695 100644 --- a/frontend/editor/src/portal/components/Sidebar.css +++ b/frontend/editor/src/portal/components/Sidebar.css @@ -1,8 +1,10 @@ .portal-sidebar { - width: 15rem; + width: var(--sidebar-w); height: 100vh; height: 100dvh; /* track mobile browser chrome */ - background: var(--c-bg); + /* Matches the editor's sidebar: one solid panel with a rule on the content side. */ + background: var(--c-surface); + border-inline-end: 1px solid var(--c-border-subtle); display: flex; flex-direction: column; flex-shrink: 0; @@ -21,10 +23,6 @@ /* Nav labels stay on one line and are clipped by the narrowing rail so they reveal/hide cleanly as the width animates rather than wrapping. */ -.portal-sidebar__nav, -.portal-sidebar__footer { - overflow-x: hidden; -} .portal-sidebar .sui-navitem__label, .portal-sidebar__section-label { white-space: nowrap; @@ -34,6 +32,8 @@ .portal-sidebar__close { display: none; flex-shrink: 0; + /* Trailing edge: on mobile this is the row's only control. */ + margin-left: auto; } .portal-sidebar__collapse { @@ -78,23 +78,16 @@ /* ---- Collapsed icon rail (desktop only) ---- */ .portal-sidebar[data-collapsed] { - width: var(--nav-rail-w); -} -.portal-sidebar[data-collapsed] .portal-sidebar__logo { - flex-direction: column; - height: auto; - padding: 0.5rem 0; - gap: 0.375rem; -} -.portal-sidebar[data-collapsed] .portal-sidebar__collapse { - margin-left: 0; + width: var(--sidebar-collapsed-w); } +/* Flush, like the nav: the selected row runs the full width of the rail. */ .portal-sidebar[data-collapsed] .portal-sidebar__nav { - padding-inline: 0.375rem; + padding-inline: 0; } +/* Stretch, not centre: a centred group shrinks to its content, so rows can't fill the rail. */ .portal-sidebar[data-collapsed] .portal-sidebar__section { padding-inline: 0; - align-items: center; + align-items: stretch; } .portal-sidebar[data-collapsed] .portal-sidebar__section-label { display: none; @@ -111,23 +104,16 @@ margin-inline: 0; padding-inline: 0; width: 100%; -} -/* Neutralise the active-item edge-bar geometry (negative margins + overhang) - that assumes the full-width rail. */ -.portal-sidebar[data-collapsed] .sui-navitem.is-active { - width: 100%; - margin-inline: 0; - border-left: none; - border-radius: 0.5rem; - padding-left: 0; + /* A square target, so it takes the rail's radius rather than NavItem's pill. */ + border-radius: var(--radius-md); } .portal-sidebar[data-collapsed] .portal-sidebar__footer { margin-inline: 0.375rem; } -.portal-sidebar__logo { - height: 3.1875rem; /* 51px */ - padding: 0 0.875rem; +.portal-sidebar__header { + height: var(--nav-header-h); + padding: 0 var(--nav-gutter); display: flex; align-items: center; gap: 0.5rem; @@ -136,35 +122,58 @@ .portal-sidebar__nav { flex: 0 1 auto; overflow-y: auto; - padding: 0.75rem 0.625rem; + overflow-x: clip; + /* No inline inset above the rows, so a row is full width and needs no bleed past the clip. */ + padding: var(--nav-gutter) 0; display: flex; flex-direction: column; - gap: 0.5rem; + gap: var(--nav-gutter); } .portal-sidebar .sui-navitem { - margin-inline: 0.25rem; - padding-inline: 0.625rem; + margin-inline: 0; + padding-inline: 1.75rem; } -.portal-sidebar .sui-navitem.is-active { - width: calc(100% + 0.75rem); - margin-inline: -0.375rem; +/* The selected view, marked as the rail marks the current app: a knocked-out solid block. */ +.portal-sidebar .sui-navitem.is-active, +.portal-sidebar .sui-navitem.is-active:hover { + background: var(--c-text); + color: var(--c-surface); border-radius: 0; - border-left: 3px solid var(--c-primary); - padding-left: calc(1.25rem - 3px); +} + +/* On a dark ground full ink is white, so mix the block back toward the surface. */ +[data-theme="dark"] .portal-sidebar .sui-navitem.is-active, +[data-theme="dark"] .portal-sidebar .sui-navitem.is-active:hover, +html[data-app-theme="midnight"] .portal-sidebar .sui-navitem.is-active, +html[data-app-theme="midnight"] .portal-sidebar .sui-navitem.is-active:hover, +[data-mantine-color-scheme="dark"] .portal-sidebar .sui-navitem.is-active, +[data-mantine-color-scheme="dark"] + .portal-sidebar + .sui-navitem.is-active:hover { + background: color-mix(in srgb, var(--c-text) 80%, var(--c-surface)); + color: var(--c-surface); } .portal-sidebar__section { - padding: 0.5rem 0.375rem 0.375rem; + padding: 0.5rem 0 0.375rem; display: flex; flex-direction: column; gap: 0.375rem; } +/* Flattened here; two classes deep to beat .sui-nav-surface regardless of load order. */ +.portal-sidebar .sui-nav-surface { + background: transparent; + border: 0; + border-radius: 0; +} + .portal-sidebar__section-label { margin: 0; - padding: 0 0.5rem; + /* Its own 0.5rem, plus the inset the nav and section no longer add. */ + padding: 0 1.375rem; font-size: 0.8125rem; font-weight: 600; letter-spacing: 0.02em; @@ -181,4 +190,18 @@ only positions it. */ .portal-sidebar__footer { margin: 0 0.625rem 0.75rem; + overflow-x: hidden; +} + +/* Fills the frame, not the viewport: a 100vh sticky column would overhang it. */ +.workspace-frame .portal-sidebar { + height: 100%; + position: static; +} + +@media (max-width: 48rem) { + .workspace-frame .portal-sidebar { + position: fixed; + height: auto; + } } diff --git a/frontend/editor/src/portal/components/Sidebar.tsx b/frontend/editor/src/portal/components/Sidebar.tsx index 8ce7008d67..2895ad4ec4 100644 --- a/frontend/editor/src/portal/components/Sidebar.tsx +++ b/frontend/editor/src/portal/components/Sidebar.tsx @@ -1,19 +1,16 @@ 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 { SidebarToggleButton } from "@app/components/shared/SidebarToggleButton"; +import { Logo } from "@app/ui/Logo"; 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"; import { useView, type ViewId } from "@portal/contexts/ViewContext"; 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 } from "@portal/components/icons"; import { GROUP_PROCESSOR, @@ -29,19 +26,17 @@ const NAV_SECTIONS: NavGroup[] = [ ]; /** Must match the shell breakpoint in AppShell.css / Sidebar.css. */ -const MOBILE_QUERY = "(max-width: 48rem)"; +export const MOBILE_QUERY = "(max-width: 48rem)"; export function Sidebar() { const { activeView, setActiveView } = useView(); const { - openSettings, mobileNavOpen, closeMobileNav, sidebarCollapsed, toggleSidebarCollapsed, } = useUI(); const { t } = useTranslation(); - const navigate = useNavigate(); const isMobile = useMediaQuery(MOBILE_QUERY, false, { getInitialValueInEffect: false, }); @@ -53,14 +48,6 @@ export function Sidebar() { // off-canvas drawer, so the icon-rail state never applies there. const collapsed = sidebarCollapsed && !isMobile; - // Editor and portal are one SPA when the editor serves this origin's root, so - // the switch stays client-side; an absolute EDITOR_URL (dev cross-app setup) - // needs a full page load. - const goToEditor = () => { - if (EDITOR_IS_SAME_APP) navigate(EDITOR_BASENAME); - else window.location.href = EDITOR_URL; - }; - // Procurement is no longer a nav tab — it lives on Home as the deal-status hero and expands into // a takeover modal (matching the marketing prototype). @@ -106,25 +93,13 @@ export function Sidebar() { // Off-canvas on mobile: remove from the tab order and accessibility tree. inert={isMobile && !mobileNavOpen} > -
- +
+ {!collapsed && } - - - + } collapsed={collapsed} /> diff --git a/frontend/editor/src/portal/components/account-link/ConnectCallbackHost.tsx b/frontend/editor/src/portal/components/account-link/ConnectCallbackHost.tsx new file mode 100644 index 0000000000..fab501da4a --- /dev/null +++ b/frontend/editor/src/portal/components/account-link/ConnectCallbackHost.tsx @@ -0,0 +1,184 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { Modal } from "@app/ui"; +import { PORTAL_BASENAME } from "@app/routes/portalBasename"; +import { withBasePath } from "@app/constants/app"; +import { + completeConnect, + startConnect, + type ConnectPhase, +} from "@portal/api/link"; +import { ensureSaasSupabase } from "@portal/auth/saasSupabase"; +import { useAccountLinkContext } from "@portal/contexts/AccountLinkContext"; +import { + ConnectCallbackView, + type ConnectCallbackState, +} from "@portal/components/account-link/ConnectCallbackView"; +import "@portal/views/ConnectCallback.css"; + +/** What the callback route hands over, read from the URL fragment before stripping it. */ +export interface AccountLinkReturn { + type: string | null; + nonce: string | null; + accessToken: string | null; + refreshToken: string | null; +} + +interface LocationState { + accountLinkReturn?: AccountLinkReturn; +} + +/** + * Finishes the handshake and reports the outcome, over the portal the admin + * started from. + * + * Mounted alongside the other portal-wide modal rather than being its own route: + * the result is a step in a task, so the page behind it should still be there. + */ +export function ConnectCallbackHost() { + const location = useLocation(); + const navigate = useNavigate(); + const { t } = useTranslation(); + const { refresh } = useAccountLinkContext(); + const handover = (location.state as LocationState | null)?.accountLinkReturn; + + const [state, setState] = useState(null); + const [sessionRestored, setSessionRestored] = useState(false); + const nonceRef = useRef(null); + const startedRef = useRef(false); + + const finish = useCallback( + async (nonce: string) => { + setState("working"); + try { + const outcome = toViewState((await completeConnect(nonce)).phase); + setState(outcome); + // The portal read its status on mount, before this existed. Without this + // the page behind the modal still says unlinked until a reload. + if (outcome === "linked") await refresh(); + } catch { + // Could not reach our own backend. The handshake is still open, so this + // is worth another attempt rather than a restart. + setState("retry"); + } + }, + [refresh], + ); + + useEffect(() => { + if (!handover || startedRef.current) return; + startedRef.current = true; + + const { type, nonce, accessToken, refreshToken } = handover; + if (type !== "link" || !nonce) { + setState("malformed"); + return; + } + nonceRef.current = nonce; + + void (async () => { + if (accessToken && refreshToken) { + try { + const supabase = ensureSaasSupabase(); + // Logged, not swallowed: silently this resurfaces later as "session + // expired" on the usage page, with nothing tying it back here. + if (!supabase) { + console.warn( + "[account-link] no Supabase client: VITE_SUPABASE_URL / VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY are not set for this build", + ); + } else { + const { error } = await supabase.auth.setSession({ + access_token: accessToken, + refresh_token: refreshToken, + }); + if (error) { + console.warn("[account-link] setSession failed:", error.message); + } else { + setSessionRestored(true); + } + } + } catch (e) { + console.warn("[account-link] session hand-off threw:", e); + } + } else { + console.warn( + "[account-link] callback carried no tokens; the approval page had no session to pass", + ); + } + await finish(nonce); + })(); + }, [handover, finish]); + + /** + * Retry means different things either side of a still-valid handshake: finish the one we have, or open a new one when it is past saving. + */ + const onRetry = useCallback(() => { + if (state === "retry" && nonceRef.current) { + void finish(nonceRef.current); + return; + } + setState("working"); + // Same callback the modal sends. Without it the backend falls back to the bare + // origin, which drops the app's base path and lands the return on nothing. + void startConnect( + window.location.hostname, + new URL( + withBasePath("/account-link/callback"), + window.location.origin, + ).toString(), + ) + .then((status) => { + if (status.authorizeUrl) { + window.location.assign(status.authorizeUrl); + } else { + setState("rejected"); + } + }) + .catch(() => setState("retry")); + }, [state, finish]); + + // Drops the handover with it, so a back navigation does not reopen the result. + const done = useCallback(() => { + setState(null); + navigate(PORTAL_BASENAME, { replace: true }); + }, [navigate]); + + if (!state) return null; + + return ( + + + + ); +} + +/** + * PENDING and UNAVAILABLE collapse into one "try again" state: both mean the handshake is intact but unfinished, which is the same thing to do about it. + */ +function toViewState(phase: ConnectPhase): ConnectCallbackState { + switch (phase) { + case "LINKED": + return "linked"; + case "EXPIRED": + return "expired"; + case "PENDING": + case "UNAVAILABLE": + return "retry"; + default: + return "rejected"; + } +} diff --git a/frontend/editor/src/portal/components/account-link/ConnectCallbackView.tsx b/frontend/editor/src/portal/components/account-link/ConnectCallbackView.tsx new file mode 100644 index 0000000000..4a22592e45 --- /dev/null +++ b/frontend/editor/src/portal/components/account-link/ConnectCallbackView.tsx @@ -0,0 +1,162 @@ +import { useTranslation } from "react-i18next"; +import { Banner, Button, Spinner } from "@app/ui"; + +/** Outcomes of returning from the approval page. */ +export type ConnectCallbackState = + | "working" + | "linked" + | "retry" + | "expired" + | "rejected" + | "malformed"; + +export interface ConnectCallbackViewProps { + state: ConnectCallbackState; + /** True once the SaaS session landed, regardless of how the link itself went. */ + sessionRestored: boolean; + onRetry: () => void; + onDone: () => void; +} + +/** Presentation for the account-link callback. */ +export function ConnectCallbackView({ + state, + sessionRestored, + onRetry, + onDone, +}: ConnectCallbackViewProps) { + const { t } = useTranslation(); + + if (state === "working") { + return ( +
+ +

+ {t( + "portal.accountLink.connect.callback.working", + "Finishing the connection.", + )} +

+
+ ); + } + + if (state === "linked") { + return ( +
+ + {t( + "portal.accountLink.connect.callback.linked.body", + "This server is connected to your Stirling account.", + )} + + {/* The inverse of the failure note below: the link took but the sign-in did + not, which otherwise only shows up later as "session expired" on a page + that gives no hint the two are related. */} + {sessionRestored ? null : ( +

+ {t( + "portal.accountLink.connect.callback.linkedNotSignedIn", + "You are not signed in to Stirling in this browser, so usage and billing will ask you to sign in.", + )} +

+ )} + +
+ ); + } + + const { tone, title, body, retryable } = failure(state, t); + return ( +
+ + {body} + + {/* The SaaS sign-in and the server link are separate outcomes. Say so when + one worked and the other did not, or the admin re-runs the whole thing + to fix a problem that is already half solved. */} + {sessionRestored ? ( +

+ {t( + "portal.accountLink.connect.callback.signedInAnyway", + "You are signed in to Stirling, so billing and usage will load. Only the server link is incomplete.", + )} +

+ ) : null} + +
+ ); +} + +type Translate = ReturnType["t"]; + +function failure(state: ConnectCallbackState, t: Translate) { + switch (state) { + case "expired": + return { + tone: "warning" as const, + title: t( + "portal.accountLink.connect.callback.expired.title", + "Request expired", + ), + body: t( + "portal.accountLink.connect.callback.expired.body", + "Connection requests are short lived. Start another one.", + ), + retryable: true, + }; + case "rejected": + return { + tone: "warning" as const, + title: t( + "portal.accountLink.connect.callback.rejected.title", + "Connection not completed", + ), + body: t( + "portal.accountLink.connect.callback.rejected.body", + "This request was declined or has already been used. Start another one if that was not intended.", + ), + retryable: true, + }; + case "malformed": + return { + tone: "danger" as const, + title: t( + "portal.accountLink.connect.callback.malformed.title", + "Could not read the response", + ), + body: t( + "portal.accountLink.connect.callback.malformed.body", + "This page was opened without a valid connection response. Start the connection from settings.", + ), + retryable: false, + }; + default: + return { + tone: "warning" as const, + // Not "retry.*": that key is the button label, and TOML cannot hold a + // value and a table under the same name. + title: t( + "portal.accountLink.connect.callback.unfinished.title", + "Not finished yet", + ), + body: t( + "portal.accountLink.connect.callback.unfinished.body", + "Stirling did not confirm the connection. This is usually temporary.", + ), + retryable: true, + }; + } +} diff --git a/frontend/editor/src/portal/components/account-link/LinkAccountCard.stories.tsx b/frontend/editor/src/portal/components/account-link/LinkAccountCard.stories.tsx index af949e9dc9..f20f777280 100644 --- a/frontend/editor/src/portal/components/account-link/LinkAccountCard.stories.tsx +++ b/frontend/editor/src/portal/components/account-link/LinkAccountCard.stories.tsx @@ -9,8 +9,9 @@ const base: UseAccountLink = { status: { linked: false, name: null }, phase: "idle", error: null, - completeLink: async () => {}, + unlink: async () => {}, + refresh: async () => {}, }; const meta: Meta = { diff --git a/frontend/editor/src/portal/components/account-link/LinkAccountModal.css b/frontend/editor/src/portal/components/account-link/LinkAccountModal.css new file mode 100644 index 0000000000..bc96172f0a --- /dev/null +++ b/frontend/editor/src/portal/components/account-link/LinkAccountModal.css @@ -0,0 +1,26 @@ +/* Connect-account modal. Imported by the component rather than relying on the + account-link view's stylesheet: this modal is mounted at the app root, so it + renders on pages that never import that view. */ + +.portal-link__modal-body { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.portal-link__steps { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin: 0; + padding-left: 1.25rem; + font-size: 0.875rem; + line-height: 1.5; + color: var(--c-text-muted); +} + +.portal-link__modal-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; +} diff --git a/frontend/editor/src/portal/components/account-link/LinkAccountModal.stories.tsx b/frontend/editor/src/portal/components/account-link/LinkAccountModal.stories.tsx index 8228e3b1bd..b7ba6647bf 100644 --- a/frontend/editor/src/portal/components/account-link/LinkAccountModal.stories.tsx +++ b/frontend/editor/src/portal/components/account-link/LinkAccountModal.stories.tsx @@ -8,16 +8,19 @@ const meta: Meta = { args: { open: true, onClose: () => {}, - onLinked: async () => {}, }, }; export default meta; type Story = StoryObj; -/** Default "link" mode — sign in to register this instance against a Stirling account. */ +/** + * "link" mode — explains the trip to Stirling and starts the handshake. There is no + * sign-in form: a sign-in started on a self-hosted origin cannot complete, because + * the provider will not redirect back to a hostname it does not know. + */ export const Default: Story = {}; -/** "reauth" mode — an already-linked instance's session expired and needs a fresh sign-in. */ +/** "reauth" mode — the server stays linked; only the browser session is renewed. */ export const Reauth: Story = { args: { mode: "reauth" }, }; diff --git a/frontend/editor/src/portal/components/account-link/LinkAccountModal.test.tsx b/frontend/editor/src/portal/components/account-link/LinkAccountModal.test.tsx new file mode 100644 index 0000000000..018d435b31 --- /dev/null +++ b/frontend/editor/src/portal/components/account-link/LinkAccountModal.test.tsx @@ -0,0 +1,128 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { act, render, waitFor } from "@testing-library/react"; +import { MantineProvider } from "@mantine/core"; + +/** The modal every "link account" CTA in the portal opens. */ +const { startConnect, startReauth } = vi.hoisted(() => ({ + startConnect: vi.fn(), + startReauth: vi.fn(), +})); + +vi.mock("@portal/api/link", () => ({ startConnect, startReauth })); +vi.mock("@portal/auth/saasSupabase", () => ({ + isSaasSupabaseConfigured: true, +})); + +import { LinkAccountModal } from "@portal/components/account-link/LinkAccountModal"; + +const AUTHORIZE = "http://localhost:5174/link?request=req-1"; + +function renderModal(mode?: "link" | "reauth") { + return render( + + {}} mode={mode} /> + , + ); +} + +/** Clicks the primary action (the secondary one is Cancel). */ +function clickContinue(getAllByRole: (role: string) => HTMLElement[]) { + const buttons = getAllByRole("button"); + act(() => buttons[buttons.length - 1].click()); +} + +describe("LinkAccountModal", () => { + let assign: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + startConnect.mockResolvedValue({ + phase: "PENDING", + authorizeUrl: AUTHORIZE, + secondsRemaining: 900, + teamId: null, + }); + startReauth.mockResolvedValue({ + phase: "PENDING", + authorizeUrl: AUTHORIZE, + secondsRemaining: 900, + teamId: null, + }); + assign = vi.fn(); + Object.defineProperty(window, "location", { + configurable: true, + value: { + origin: "http://localhost:5173", + hostname: "localhost", + assign, + }, + }); + }); + + it("offers no sign-in form, because a sign-in started here cannot complete", () => { + const { container } = renderModal(); + + // The provider buttons this modal used to carry sent the admin to Stirling and + // abandoned them there. Nothing should collect credentials on this origin. + expect(container.querySelector("input[type=password]")).toBeNull(); + expect(container.querySelector("input[type=email]")).toBeNull(); + }); + + it("starts a link handshake and hands the browser to Stirling", async () => { + const { getAllByRole } = renderModal(); + + clickContinue(getAllByRole); + + await waitFor(() => expect(startConnect).toHaveBeenCalled()); + // Callback built from this page's own origin, which the backend then checks + // against the request's Origin header. + expect(startConnect).toHaveBeenCalledWith( + "localhost", + "http://localhost:5173/account-link/callback", + ); + await waitFor(() => expect(assign).toHaveBeenCalledWith(AUTHORIZE)); + expect(startReauth).not.toHaveBeenCalled(); + }); + + it("uses the reauth endpoint when only the session needs renewing", async () => { + const { getAllByRole } = renderModal("reauth"); + + clickContinue(getAllByRole); + + // A different endpoint on purpose: reauth presents the device credential so + // Stirling pins the handshake to the team that already owns this server. + await waitFor(() => + expect(startReauth).toHaveBeenCalledWith( + "http://localhost:5173/account-link/callback", + ), + ); + expect(startConnect).not.toHaveBeenCalled(); + await waitFor(() => expect(assign).toHaveBeenCalledWith(AUTHORIZE)); + }); + + it("stays put and explains itself when the handshake cannot start", async () => { + startConnect.mockRejectedValue(new Error("offline")); + + const { getAllByRole } = renderModal(); + clickContinue(getAllByRole); + + await waitFor(() => expect(startConnect).toHaveBeenCalled()); + expect(assign).not.toHaveBeenCalled(); + }); + + it("does not navigate when there is nothing to navigate to", async () => { + // Already linked: the backend reports status without an authorize URL. + startConnect.mockResolvedValue({ + phase: "LINKED", + authorizeUrl: null, + secondsRemaining: null, + teamId: 7, + }); + + const { getAllByRole } = renderModal(); + clickContinue(getAllByRole); + + await waitFor(() => expect(startConnect).toHaveBeenCalled()); + expect(assign).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/editor/src/portal/components/account-link/LinkAccountModal.tsx b/frontend/editor/src/portal/components/account-link/LinkAccountModal.tsx index e46504d309..8bd47a9eb4 100644 --- a/frontend/editor/src/portal/components/account-link/LinkAccountModal.tsx +++ b/frontend/editor/src/portal/components/account-link/LinkAccountModal.tsx @@ -1,63 +1,60 @@ -import { useEffect } from "react"; +import { useCallback, useState } from "react"; import { useTranslation } from "react-i18next"; import { Banner, Button, Modal } from "@app/ui"; -import SupabaseLoginForm from "@app/auth/ui/SupabaseLoginForm"; -import { - useSupabaseLogin, - type SupabaseLoginSession, -} from "@app/auth/ui/useSupabaseLogin"; -import "@app/auth/ui/auth-theme.css"; -import { - ensureSaasSupabase, - isSaasSupabaseConfigured, - PENDING_LINK_KEY, - SAAS_OAUTH_PROVIDERS, -} from "@portal/auth/saasSupabase"; +import { withBasePath } from "@app/constants/app"; +import { startConnect, startReauth } from "@portal/api/link"; +import { isSaasSupabaseConfigured } from "@portal/auth/saasSupabase"; +import "@portal/components/account-link/LinkAccountModal.css"; interface Props { open: boolean; onClose: () => void; /** - * "link" registers this instance against the signed-in account; "reauth" only - * refreshes an expired SaaS session (the instance is already linked). The mode - * is persisted across the OAuth redirect so the SSO-return handler doesn't - * re-register on a reauth. + * "link" connects this server to a team for the first time; "reauth" only re-establishes the browser's Stirling session for a server that is already linked. */ mode?: "link" | "reauth"; - /** Called with the SaaS session after a successful sign-in. */ - onLinked: (session: SupabaseLoginSession) => void | Promise; } -/** - * In-app account-link login. Signs the admin in to their Stirling (SaaS) account - * via the shared Supabase login (SSO + email/password), then hands the resulting - * session to the caller to register this instance. No popup; the device secret - * never reaches the browser. SSO redirects away and is finished by useAccountLink - * on return. - */ -export function LinkAccountModal({ - open, - onClose, - mode = "link", - onLinked, -}: Props) { +/** Sends the admin off to Stirling to connect this server. */ +export function LinkAccountModal({ open, onClose, mode = "link" }: Props) { const { t } = useTranslation(); - useEffect(() => { - if (open) ensureSaasSupabase(); - }, [open]); - const reauth = mode === "reauth"; - const login = useSupabaseLogin({ - providers: SAAS_OAUTH_PROVIDERS, - // Return to the current page after SSO; the SSO-return handler in - // useAccountLink reads the persisted mode so it links vs. only refreshes. - redirectTo: window.location.href, - onBeforeOAuth: () => sessionStorage.setItem(PENDING_LINK_KEY, mode), - onSuccess: async (session) => { - await onLinked(session); - onClose(); - }, - }); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const begin = useCallback(async () => { + setBusy(true); + setError(null); + try { + const callbackUrl = new URL( + withBasePath("/account-link/callback"), + window.location.origin, + ).toString(); + const status = reauth + ? await startReauth(callbackUrl) + : await startConnect(window.location.hostname, callbackUrl); + if (status.authorizeUrl) { + window.location.assign(status.authorizeUrl); + return; + } + // Already linked, or a handshake we cannot act on. Nothing to navigate to. + setError( + t( + "portal.accountLink.modal.noAuthorizeUrl", + "Stirling did not return somewhere to continue. Try again in a moment.", + ), + ); + } catch { + setError( + t( + "portal.accountLink.modal.startFailed", + "Could not reach Stirling to start the connection. Check this server's outbound network access, then try again.", + ), + ); + } finally { + setBusy(false); + } + }, [reauth, t]); return ( - {isSaasSupabaseConfigured ? ( - - ) : ( -
+
+
    +
  1. + {t( + "portal.accountLink.modal.step1", + "We send you to stirling.com to sign in. Any sign-in method works there, including Google and single sign-on.", + )} +
  2. +
  3. + {t( + "portal.accountLink.modal.step2", + "You check this server's address and approve it. A team owner has to do this the first time.", + )} +
  4. +
  5. + {t( + "portal.accountLink.modal.step3", + "Stirling brings you straight back here and finishes up.", + )} +
  6. +
+ + {!isSaasSupabaseConfigured && ( {t("portal.accountLink.modal.loginNotConfigured.before", "Set")}{" "} @@ -101,25 +117,27 @@ export function LinkAccountModal({ VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY{" "} {t( "portal.accountLink.modal.loginNotConfigured.after", - "to enable in-app linking against the hosted Stirling account.", + "so this server can finish the connection when you come back.", )} - {import.meta.env.DEV && ( - - )} + )} + + {error && {error}} + +
+ +
- )} +
); } diff --git a/frontend/editor/src/portal/components/policies/ClassificationLabelsSection.tsx b/frontend/editor/src/portal/components/policies/ClassificationLabelsSection.tsx index 7a21cae2b7..4b3fae7652 100644 --- a/frontend/editor/src/portal/components/policies/ClassificationLabelsSection.tsx +++ b/frontend/editor/src/portal/components/policies/ClassificationLabelsSection.tsx @@ -61,7 +61,7 @@ export function ClassificationLabelsSection() { )} - {family.name} + {t(`classification.families.${family.id}`, family.name)} } diff --git a/frontend/editor/src/portal/contexts/AccountLinkContext.tsx b/frontend/editor/src/portal/contexts/AccountLinkContext.tsx index d3f4bf3678..0ab5fc4da7 100644 --- a/frontend/editor/src/portal/contexts/AccountLinkContext.tsx +++ b/frontend/editor/src/portal/contexts/AccountLinkContext.tsx @@ -5,15 +5,7 @@ import { } from "@portal/hooks/useAccountLink"; /** - * Single app-wide {@link useAccountLink} instance. The link flow is orchestrated - * in exactly one place so that: - * - status is fetched once on mount (not per consumer), and - * - the SSO-return effect fires once — two instances would both call - * {@link UseAccountLink.completeLink} on return and re-register the device - * credential, leaving a duplicate linked_instance row. - * - * Consumers (the top-level link modal host, the Settings account-link panel, - * the link card) read this shared instance instead of calling the hook again. + * Single app-wide {@link useAccountLink} instance, so status is fetched once on mount rather than per consumer. */ const AccountLinkContext = createContext(null); diff --git a/frontend/editor/src/portal/contexts/LinkContext.tsx b/frontend/editor/src/portal/contexts/LinkContext.tsx index d088647586..7fd7cfe536 100644 --- a/frontend/editor/src/portal/contexts/LinkContext.tsx +++ b/frontend/editor/src/portal/contexts/LinkContext.tsx @@ -8,7 +8,7 @@ import { } from "react"; /** - * The "linked" dimension of the account-link surface (combined-billing "Mode A"), + * The "linked" dimension of the account-link surface (combined billing), * a sibling to TierContext. It answers one question the rest of the portal asks: * has this self-hosted org linked its SaaS account, and if so, is it on the free * grant or actively subscribed? @@ -59,13 +59,6 @@ interface LinkContextValue { isLinked: boolean; /** Convenience for `LINK_INFO[linkState].unlocked` — billable features usable. */ featuresUnlocked: boolean; - /** - * Bumps whenever the browser's SaaS session changes (e.g. a re-sign-in after - * expiry). Attended SaaS reads (the wallet) key off this to refetch with the - * fresh token without re-establishing the instance link. - */ - saasSessionNonce: number; - markSaasSessionChanged: () => void; } const LinkContext = createContext(null); @@ -78,11 +71,6 @@ export function LinkProvider({ initialState?: LinkState; }) { const [linkState, setLinkState] = useState(initialState); - const [saasSessionNonce, setSaasSessionNonce] = useState(0); - const markSaasSessionChanged = useCallback( - () => setSaasSessionNonce((n) => n + 1), - [], - ); const value = useMemo(() => { const unlocked = LINK_INFO[linkState].unlocked; return { @@ -90,10 +78,8 @@ export function LinkProvider({ setLinkState, isLinked: linkState !== "unlinked", featuresUnlocked: unlocked, - saasSessionNonce, - markSaasSessionChanged, }; - }, [linkState, saasSessionNonce, markSaasSessionChanged]); + }, [linkState]); return {children}; } diff --git a/frontend/editor/src/portal/hooks/useAccountLink.test.tsx b/frontend/editor/src/portal/hooks/useAccountLink.test.tsx deleted file mode 100644 index b327322778..0000000000 --- a/frontend/editor/src/portal/hooks/useAccountLink.test.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { act, render, waitFor } from "@testing-library/react"; -import { LinkProvider } from "@portal/contexts/LinkContext"; - -/** - * The SSO-return path is mode-aware: a "reauth" return must only refresh the - * session, NOT re-register the instance (re-registering mints a duplicate device - * credential). This is the exact regression that slipped through once, so it gets - * a dedicated guard. - */ -const { linkInstance, fetchStatus, unlinkInstance, getSession } = vi.hoisted( - () => ({ - linkInstance: vi.fn(), - fetchStatus: vi.fn(), - unlinkInstance: vi.fn(), - getSession: vi.fn(), - }), -); - -vi.mock("@portal/api/link", () => ({ - linkInstance, - fetchStatus, - unlinkInstance, -})); -vi.mock("@portal/auth/saasSupabase", () => ({ - PENDING_LINK_KEY: "stirling_pending_link", - isSaasSupabaseConfigured: true, - SAAS_OAUTH_PROVIDERS: [], - ensureSaasSupabase: () => ({ auth: { getSession } }), -})); - -import { useAccountLink } from "@portal/hooks/useAccountLink"; -import { PENDING_LINK_KEY } from "@portal/auth/saasSupabase"; - -function Probe() { - useAccountLink(); - return null; -} - -const renderHook = () => - render( - - - , - ); - -beforeEach(() => { - linkInstance.mockReset().mockResolvedValue({ linked: true, name: null }); - fetchStatus.mockReset().mockResolvedValue({ linked: true, name: null }); - unlinkInstance.mockReset(); - getSession.mockReset().mockResolvedValue({ - data: { session: { access_token: "tok" } }, - }); - sessionStorage.clear(); -}); -afterEach(() => sessionStorage.clear()); - -describe("useAccountLink — SSO return", () => { - it("reauth mode refreshes the session WITHOUT re-registering", async () => { - sessionStorage.setItem(PENDING_LINK_KEY, "reauth"); - renderHook(); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - expect(linkInstance).not.toHaveBeenCalled(); - }); - - it("link mode registers the instance with the returned token", async () => { - sessionStorage.setItem(PENDING_LINK_KEY, "link"); - renderHook(); - await waitFor(() => expect(linkInstance).toHaveBeenCalledTimes(1)); - expect(linkInstance.mock.calls[0][0].supabaseJwt).toBe("tok"); - }); -}); diff --git a/frontend/editor/src/portal/hooks/useAccountLink.ts b/frontend/editor/src/portal/hooks/useAccountLink.ts index dd4894f6d6..3e47df5220 100644 --- a/frontend/editor/src/portal/hooks/useAccountLink.ts +++ b/frontend/editor/src/portal/hooks/useAccountLink.ts @@ -1,33 +1,9 @@ import { useCallback, useEffect, useState } from "react"; -import type { SupabaseLoginSession } from "@app/auth/ui/useSupabaseLogin"; -import { - ensureSaasSupabase, - isSaasSupabaseConfigured, - PENDING_LINK_KEY, -} from "@portal/auth/saasSupabase"; -import { - fetchStatus, - linkInstance, - unlinkInstance, - type LinkStatus, -} from "@portal/api/link"; -import { useApplyLinkFacts, useLink } from "@portal/contexts/LinkContext"; +import { isSaasSupabaseConfigured } from "@portal/auth/saasSupabase"; +import { fetchStatus, unlinkInstance, type LinkStatus } from "@portal/api/link"; +import { useApplyLinkFacts } from "@portal/contexts/LinkContext"; -/** - * Orchestrates the account-link flow for THIS instance: - * - * 1. The admin signs in to their Stirling account IN-APP (LinkAccountModal → - * shared Supabase login), minting a short-term SaaS JWT. - * 2. {@link completeLink} POSTs that JWT to the LOCAL backend (api/link.ts), - * which registers with SaaS and stores the device secret server-side. - * 3. The resulting Linked / Not-linked status is read back. - * - * Email/password resolves inline (the modal calls completeLink). SSO redirects - * the browser to the provider and back; the returned session is finished here on - * mount (see the pending-link effect). The device secret is never received or - * rendered. Subscription state is resolved separately from the wallet, so a fresh - * link marks the org linked-free. - */ +/** Reads and clears THIS instance's link status. */ export type LinkPhase = "idle" | "linking" | "error"; @@ -38,84 +14,32 @@ export interface UseAccountLink { status: LinkStatus | null; phase: LinkPhase; error: string | null; - /** Finish linking THIS instance with a SaaS session minted by the login modal. */ - completeLink: (session: SupabaseLoginSession, name?: string) => Promise; /** Unlink this instance. */ unlink: () => Promise; + /** Re-read the status, for when something outside this hook changed it. */ + refresh: () => Promise; } export function useAccountLink(): UseAccountLink { const applyLinkFacts = useApplyLinkFacts(); - const { markSaasSessionChanged } = useLink(); const [status, setStatus] = useState(null); const [phase, setPhase] = useState("idle"); const [error, setError] = useState(null); - const completeLink = useCallback( - async (session: SupabaseLoginSession, name?: string) => { - setPhase("linking"); - setError(null); - try { - const next = await linkInstance({ - supabaseJwt: session.access_token, - name, - }); - setStatus(next); - setPhase("idle"); - if (next.linked) applyLinkFacts(true, false); - } catch (e) { - setError(e instanceof Error ? e.message : String(e)); - setPhase("error"); - } - }, - [applyLinkFacts], - ); - - // Read the current link status on mount. - useEffect(() => { - let cancelled = false; - void fetchStatus() - .then((s) => { - if (!cancelled) { - setStatus(s); - // A linked instance is at least linked-free; subscription comes from the wallet. - if (s.linked) applyLinkFacts(true, false); - } - }) - .catch(() => { - // Status endpoint absent (flag off) / unreachable → leave status null, - // which renders as "Not linked". Don't surface an error or leak an - // unhandled rejection for the expected flag-off case. - if (!cancelled) setStatus({ linked: false, name: null }); - }); - return () => { - cancelled = true; - }; + const refresh = useCallback(async () => { + try { + const s = await fetchStatus(); + setStatus(s); + // A linked instance is at least linked-free; subscription comes from the wallet. + if (s.linked) applyLinkFacts(true, false); + } catch { + setStatus({ linked: false, name: null }); + } }, [applyLinkFacts]); - // SSO return: an SSO sign-in we kicked off has redirected back and the SaaS - // session is now in the shared Supabase client. The pending marker carries the - // mode: "reauth" only refreshes attended reads (the instance is already linked - // — re-registering would mint a duplicate credential); anything else links. useEffect(() => { - const supabase = ensureSaasSupabase(); - const pending = sessionStorage.getItem(PENDING_LINK_KEY); - if (!supabase || pending === null) return; - let cancelled = false; - void supabase.auth.getSession().then(({ data }) => { - sessionStorage.removeItem(PENDING_LINK_KEY); - const token = data.session?.access_token; - if (!token || cancelled) return; - if (pending === "reauth") { - markSaasSessionChanged(); - } else { - void completeLink({ access_token: token }); - } - }); - return () => { - cancelled = true; - }; - }, [completeLink, markSaasSessionChanged]); + void refresh(); + }, [refresh]); const unlink = useCallback(async () => { setPhase("linking"); @@ -136,7 +60,7 @@ export function useAccountLink(): UseAccountLink { status, phase, error, - completeLink, unlink, + refresh, }; } diff --git a/frontend/editor/src/portal/mocks/handlers/link.ts b/frontend/editor/src/portal/mocks/handlers/link.ts index 1724ce375f..bd7e6844f2 100644 --- a/frontend/editor/src/portal/mocks/handlers/link.ts +++ b/frontend/editor/src/portal/mocks/handlers/link.ts @@ -1,5 +1,4 @@ import { http, HttpResponse, delay } from "msw"; -import type { LinkInstanceRequest } from "@portal/api/link"; import { getLocalStatus, getLocalUsage, @@ -12,14 +11,13 @@ import { /** * Account-link MSW handlers. Two surfaces: * - * - LOCAL backend (this instance): link / status / unlink. `link` mutates the - * in-memory store and flips local status so the surface behaves like a real - * backend within a session. The device secret stays server-side — never - * returned over the wire, matching the real contract. + * - LOCAL backend (this instance): the connect handshake, status and unlink. + * `connect/complete` mutates the in-memory store and flips local status so the + * surface behaves like a real backend within a session. The device secret stays + * server-side — never returned over the wire, matching the real contract. * - SaaS backend (team-wide): instances / revoke. * - * Mirrors the real AccountLinkController paths so MSW can be dropped with no code - * change. + * Mirrors the real controller paths so MSW can be dropped with no code change. */ export const linkHandlers = [ http.get("/api/v1/account-link/status", async () => { @@ -27,15 +25,38 @@ export const linkHandlers = [ return HttpResponse.json(getLocalStatus()); }), - http.post("/api/v1/account-link/link", async ({ request }) => { + // Opening a handshake hands back where to send the admin. The real backend gets + // that URL from SaaS rather than composing it, so the mock returns one too. + http.post("*/api/v1/account-link/connect/start", async () => { await delay(120); - let name: string | undefined; - try { - name = ((await request.json()) as LinkInstanceRequest)?.name; - } catch { - // empty body — name stays undefined - } - return HttpResponse.json(linkLocal(name), { status: 201 }); + return HttpResponse.json({ + phase: "PENDING", + authorizeUrl: "https://app.stirling.test/link?request=mock-request", + secondsRemaining: 900, + teamId: null, + }); + }), + + http.post("*/api/v1/account-link/connect/reauth", async () => { + await delay(120); + return HttpResponse.json({ + phase: "PENDING", + authorizeUrl: "https://app.stirling.test/link?request=mock-reauth", + secondsRemaining: 900, + teamId: null, + }); + }), + + // The callback's completion step. Flips the store to linked, as a real claim would. + http.post("*/api/v1/account-link/connect/complete", async () => { + await delay(120); + linkLocal("mock-server"); + return HttpResponse.json({ + phase: "LINKED", + authorizeUrl: null, + secondsRemaining: null, + teamId: 7, + }); }), http.get("/api/v1/account-link/usage", async () => { diff --git a/frontend/editor/src/portal/mocks/link.ts b/frontend/editor/src/portal/mocks/link.ts index 92b4b90f2c..eb29ef5ba4 100644 --- a/frontend/editor/src/portal/mocks/link.ts +++ b/frontend/editor/src/portal/mocks/link.ts @@ -2,7 +2,7 @@ * Account-link fixtures. Types live in api/link.ts (the backend contract); * this module only builds fake data for Storybook and tests. * - * "Mode A" combined billing: a self-hosted instance links the org's SaaS account + * Combined billing: a self-hosted instance links the org's SaaS account * so its unattended calls bill against the org wallet. Two surfaces: * * - THIS instance: the local backend (`POST /api/v1/account-link/link`, diff --git a/frontend/editor/src/portal/views/ConnectCallback.css b/frontend/editor/src/portal/views/ConnectCallback.css new file mode 100644 index 0000000000..35e9b09ef7 --- /dev/null +++ b/frontend/editor/src/portal/views/ConnectCallback.css @@ -0,0 +1,32 @@ +/* Account-link callback. A transient page the admin passes through, so it is + centred and says one thing rather than trying to be a settings screen. */ + +.portal-connect-callback { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + max-width: 30rem; + margin: 4rem auto; + padding: 0 1rem; + text-align: center; +} + +.portal-connect-callback > * { + width: 100%; +} + +/* The button is the one thing that should not stretch to the banner's width. */ +.portal-connect-callback button { + width: auto; +} + +.portal-connect-callback p { + margin: 0; + font-size: 0.875rem; + color: var(--c-text-muted); +} + +.portal-connect-callback__note { + font-size: 0.8125rem; +} diff --git a/frontend/editor/src/portal/views/ConnectCallback.test.tsx b/frontend/editor/src/portal/views/ConnectCallback.test.tsx new file mode 100644 index 0000000000..bdadee03e4 --- /dev/null +++ b/frontend/editor/src/portal/views/ConnectCallback.test.tsx @@ -0,0 +1,175 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { act, render, waitFor } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { MantineProvider } from "@mantine/core"; + +/** + * The callback handles a live session token in a URL fragment, so the behaviour worth pinning is what it does with it: strip it immediately, refuse anything it cannot verify, and keep the two outcomes (SaaS sign-in, server link) independent of each other. + */ +const { completeConnect, startConnect, setSession, refresh } = vi.hoisted( + () => ({ + completeConnect: vi.fn(), + startConnect: vi.fn(), + setSession: vi.fn(), + refresh: vi.fn(), + }), +); + +vi.mock("@portal/api/link", () => ({ completeConnect, startConnect })); +vi.mock("@portal/auth/saasSupabase", () => ({ + ensureSaasSupabase: () => ({ auth: { setSession } }), +})); +vi.mock("@portal/contexts/AccountLinkContext", () => ({ + useAccountLinkContext: () => ({ refresh }), +})); + +import ConnectCallback from "@portal/views/ConnectCallback"; +import { ConnectCallbackHost } from "@portal/components/account-link/ConnectCallbackHost"; + +const NONCE = "the-nonce"; + +function landOn(fragment: string) { + window.history.replaceState(null, "", `/account-link/callback${fragment}`); +} + +/** + * Route and host together: the route reads the fragment, the portal renders the + * outcome. Exercising them apart would test the hand-off rather than the flow. + */ +function renderFlow() { + return render( + + + + + } /> + } /> + + + , + ); +} + +describe("account-link callback", () => { + beforeEach(() => { + vi.clearAllMocks(); + completeConnect.mockResolvedValue({ + phase: "LINKED", + authorizeUrl: null, + secondsRemaining: null, + teamId: 7, + }); + setSession.mockResolvedValue({ error: null }); + }); + + it("removes the token-bearing fragment from the URL", async () => { + landOn(`#type=link&nonce=${NONCE}&access_token=at&refresh_token=rt`); + + renderFlow(); + + // Synchronous, before any await: the fragment must not survive long enough + // to be read from the address bar or land in a history entry. + expect(window.location.hash).toBe(""); + await waitFor(() => expect(completeConnect).toHaveBeenCalled()); + }); + + it("lands on the portal rather than leaving the result on a bare page", async () => { + landOn(`#type=link&nonce=${NONCE}&access_token=at&refresh_token=rt`); + + const { getByTestId } = renderFlow(); + + await waitFor(() => expect(getByTestId("portal")).toBeTruthy()); + }); + + it("re-reads the link status, so the page behind agrees with the modal", async () => { + landOn(`#type=link&nonce=${NONCE}`); + + renderFlow(); + + await waitFor(() => expect(refresh).toHaveBeenCalled()); + }); + + it("deposits the session and then finishes the link with the nonce", async () => { + landOn(`#type=link&nonce=${NONCE}&access_token=at&refresh_token=rt`); + + renderFlow(); + + await waitFor(() => + expect(setSession).toHaveBeenCalledWith({ + access_token: "at", + refresh_token: "rt", + }), + ); + await waitFor(() => expect(completeConnect).toHaveBeenCalledWith(NONCE)); + }); + + it("finishes the link even when the session hand-off fails", async () => { + landOn(`#type=link&nonce=${NONCE}&access_token=at&refresh_token=rt`); + setSession.mockRejectedValue(new Error("nope")); + + renderFlow(); + + // The two outcomes are independent: a failed sign-in must not strand the + // server unlinked. + await waitFor(() => expect(completeConnect).toHaveBeenCalledWith(NONCE)); + }); + + it("links without a session when the fragment carries no tokens", async () => { + landOn(`#type=link&nonce=${NONCE}`); + + renderFlow(); + + await waitFor(() => expect(completeConnect).toHaveBeenCalledWith(NONCE)); + expect(setSession).not.toHaveBeenCalled(); + }); + + it("refuses a fragment with no nonce", async () => { + landOn("#type=link&access_token=at&refresh_token=rt"); + + renderFlow(); + + await waitFor(() => expect(window.location.hash).toBe("")); + expect(completeConnect).not.toHaveBeenCalled(); + expect(setSession).not.toHaveBeenCalled(); + }); + + it("refuses a fragment that is not a link response", async () => { + landOn(`#type=something-else&nonce=${NONCE}&access_token=at`); + + renderFlow(); + + await waitFor(() => expect(window.location.hash).toBe("")); + expect(completeConnect).not.toHaveBeenCalled(); + }); + + it("refuses a bare page load", async () => { + landOn(""); + + renderFlow(); + + expect(completeConnect).not.toHaveBeenCalled(); + expect(setSession).not.toHaveBeenCalled(); + }); + + it("offers a retry rather than a restart while the handshake is still open", async () => { + landOn(`#type=link&nonce=${NONCE}`); + completeConnect.mockResolvedValue({ + phase: "UNAVAILABLE", + authorizeUrl: null, + secondsRemaining: null, + teamId: null, + }); + + const { getAllByRole } = renderFlow(); + + await waitFor(() => expect(completeConnect).toHaveBeenCalledTimes(1)); + // Last button, not the only one: the modal shell contributes a close button. + const buttons = getAllByRole("button"); + act(() => buttons[buttons.length - 1].click()); + + // Retries the existing handshake; starting a new one would waste the + // approval a human just gave. + await waitFor(() => expect(completeConnect).toHaveBeenCalledTimes(2)); + expect(startConnect).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/editor/src/portal/views/ConnectCallback.tsx b/frontend/editor/src/portal/views/ConnectCallback.tsx new file mode 100644 index 0000000000..2fecd5244e --- /dev/null +++ b/frontend/editor/src/portal/views/ConnectCallback.tsx @@ -0,0 +1,41 @@ +import { useEffect, useRef } from "react"; +import { useNavigate } from "react-router-dom"; +import { PORTAL_BASENAME } from "@app/routes/portalBasename"; +import type { AccountLinkReturn } from "@portal/components/account-link/ConnectCallbackHost"; + +/** + * Return leg of the account-link handshake. Stirling redirects here with the + * admin's session in the URL fragment. + * + * This route only reads the fragment and hands it to the portal, which owns the + * rest. Rendering the outcome here would put it on an empty page; the portal is + * where the admin started, so that is where the result belongs. + */ +export default function ConnectCallback() { + const navigate = useNavigate(); + const startedRef = useRef(false); + + useEffect(() => { + if (startedRef.current) return; + startedRef.current = true; + + const params = new URLSearchParams(window.location.hash.replace(/^#/, "")); + // Before anything else: the fragment carries a live session token. + window.history.replaceState( + null, + "", + `${window.location.pathname}${window.location.search}`, + ); + + const accountLinkReturn: AccountLinkReturn = { + type: params.get("type"), + nonce: params.get("nonce"), + accessToken: params.get("access_token"), + refreshToken: params.get("refresh_token"), + }; + // Router state, not the URL: the tokens are live and must not be re-shareable. + navigate(PORTAL_BASENAME, { replace: true, state: { accountLinkReturn } }); + }, [navigate]); + + return null; +} diff --git a/frontend/editor/src/proprietary/App.tsx b/frontend/editor/src/proprietary/App.tsx index c64935bb16..ca040d4bbe 100644 --- a/frontend/editor/src/proprietary/App.tsx +++ b/frontend/editor/src/proprietary/App.tsx @@ -18,6 +18,8 @@ const MobileScannerPage = lazy(() => import("@app/pages/MobileScannerPage")); const MobileSignPage = lazy(() => import("@app/pages/MobileSignPage")); import { WATCHED_FOLDERS_ENABLED } from "@app/constants/featureFlags"; import { getAdminRouteExtensions } from "@app/routes/adminRouteExtensions"; +import { AppFrame } from "@app/components/layout/AppFrame"; +import { NoAppChrome } from "@app/components/layout/NoAppChrome"; import { RootGate } from "@app/routes/RootGate"; // Import global styles @@ -80,40 +82,52 @@ export default function App() { } /> - {/* Admin-only route-set (the portal): its own top-level shell, mounted - before the catch-all. Absent from core/desktop builds (empty stub). */} - {getAdminRouteExtensions()} + {/* Both apps, under a shared frame so the rail renders once outside them. */} + }> + {/* The portal: its own shell, before the catch-all. An empty stub in core. */} + {getAdminRouteExtensions()} - {/* All other routes need AppProviders for backend integration. - RootGate makes "/" route by role BEFORE any of it mounts, so a user - bound for the processor never boots the editor on the way. */} - - - - - } /> - {/* Self-hosted has no signup - accounts are created by an - admin. Old links land on login instead. */} - } - /> - } /> - } /> - } /> - {/* The editor and its tool routes - Landing handles auth logic */} - } /> - - - {WATCHED_FOLDERS_ENABLED && } - - - - } - /> + {/* All other routes need AppProviders for backend integration. RootGate + routes "/" by role before any of it mounts. */} + + + + + {/* Not the app: no rail over any of these, ever. */} + }> + } /> + {/* Self-hosted has no signup: old links land on login. */} + } + /> + } + /> + } + /> + } + /> + + {/* The editor and its tool routes - Landing handles auth logic */} + } /> + + + {WATCHED_FOLDERS_ENABLED && } + + + + } + /> + ); diff --git a/frontend/editor/src/proprietary/auth/spring/UseSession.tsx b/frontend/editor/src/proprietary/auth/spring/UseSession.tsx index 289f2ec895..27a722b2d7 100644 --- a/frontend/editor/src/proprietary/auth/spring/UseSession.tsx +++ b/frontend/editor/src/proprietary/auth/spring/UseSession.tsx @@ -12,6 +12,7 @@ import { type AuthUser, type AuthTranslate, } from "@app/auth/types"; +import { suspendWorkbenchSession } from "@app/services/workbenchSession"; /** * Strip the configured base path so route comparisons work under subpath @@ -97,6 +98,11 @@ export function SpringAuthProvider({ const signOut = useCallback(async () => { try { setError(null); + // Signing out is deliberate, unlike an identity check that merely failed: drop the + // workbench record here and stop recording, so the teardown that follows cannot + // write it back for whoever signs in next. + suspendWorkbenchSession(); + const { error } = await springAuth.signOut(); // Always clear the in-memory session: springAuth.signOut() removes the diff --git a/frontend/editor/src/proprietary/auth/spring/springAuthClient.ts b/frontend/editor/src/proprietary/auth/spring/springAuthClient.ts index b9583e3dee..6d887ce8a9 100644 --- a/frontend/editor/src/proprietary/auth/spring/springAuthClient.ts +++ b/frontend/editor/src/proprietary/auth/spring/springAuthClient.ts @@ -16,6 +16,7 @@ import { AxiosError, type AxiosRequestConfig } from "axios"; import { getSpringAuthConfig } from "@app/auth/config"; import { type OAuthProvider } from "@app/auth/spring/oauthTypes"; import { resetOAuthState } from "@app/auth/spring/oauthStorage"; +import { isSafePostLoginRedirect } from "@app/services/postLoginRedirect"; import type { AuthUser as User, AuthSession as Session, @@ -100,23 +101,10 @@ function persistRedirectPath(path: string): void { } } -// Same-origin relative path, not pointing at auth plumbing. Rejects protocol-relative -// URLs to guard against open-redirect abuse if the stored value is tampered with. -export function isSafePostLoginRedirect(path: unknown): path is string { - if (typeof path !== "string" || path.length === 0) return false; - if (!path.startsWith("/") || path.startsWith("//")) return false; - if (path.startsWith("/\\")) return false; - const lowered = path.toLowerCase(); - if ( - lowered.startsWith("/login") || - lowered.startsWith("/auth/") || - lowered.startsWith("/oauth2") || - lowered.startsWith("/saml2") - ) { - return false; - } - return true; -} +// The safe-return-path rule lives in the shared @app/services/postLoginRedirect +// extension point (proprietary override adds the Spring SSO routes). Re-exported +// here so existing importers via @app/auth keep resolving it. +export { isSafePostLoginRedirect }; export function setPostLoginRedirectPath( path: string | null | undefined, diff --git a/frontend/editor/src/proprietary/auth/supabase/UseSession.tsx b/frontend/editor/src/proprietary/auth/supabase/UseSession.tsx index 385f3f4e35..07670bfff5 100644 --- a/frontend/editor/src/proprietary/auth/supabase/UseSession.tsx +++ b/frontend/editor/src/proprietary/auth/supabase/UseSession.tsx @@ -16,6 +16,7 @@ import type { import { getSupabaseClient } from "@app/auth/supabase/supabaseClient"; import { AuthContext } from "@app/auth/context"; import { isAdminRole } from "@app/auth/roles"; +import { getApiBaseUrl } from "@app/services/apiClientConfig"; import { defaultTranslate, type AuthContextValue, @@ -154,14 +155,23 @@ export function SupabaseAuthProvider({ return; } let cancelled = false; + // Same API base the rest of the app uses: SaaS serves the frontend and the + // API from different hosts, so a root-relative path never reaches /me. + const apiBase = (getApiBaseUrl() || "").replace(/\/+$/, ""); + const meUrl = `${apiBase}/api/v1/auth/me`; const loadAccess = () => { - void fetch("/api/v1/auth/me", { + void fetch(meUrl, { headers: { Authorization: `Bearer ${token}`, Accept: "application/json", }, }) - .then((res) => (res.ok ? res.json() : null)) + .then((res) => { + // Must throw, not resolve null: swallowing a non-ok leaves + // portalAccess undefined and hangs the portal gate on a spinner. + if (!res.ok) throw new Error(`auth/me responded ${res.status}`); + return res.json(); + }) .then( ( data: { diff --git a/frontend/editor/src/proprietary/auth/supabase/portalAccessFetch.test.tsx b/frontend/editor/src/proprietary/auth/supabase/portalAccessFetch.test.tsx new file mode 100644 index 0000000000..3b3d9b37c8 --- /dev/null +++ b/frontend/editor/src/proprietary/auth/supabase/portalAccessFetch.test.tsx @@ -0,0 +1,128 @@ +import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; +import { render, waitFor } from "@testing-library/react"; +import { useContext } from "react"; + +const h = vi.hoisted(() => ({ apiBase: "/" })); + +vi.mock("@app/services/apiClientConfig", () => ({ + getApiBaseUrl: () => h.apiBase, +})); + +const sbSession = { + access_token: "supabase-token", + user: { + id: "u1", + email: "user@example.com", + is_anonymous: false, + app_metadata: {}, + user_metadata: {}, + }, +}; + +vi.mock("@app/auth/supabase/supabaseClient", () => ({ + getSupabaseClient: () => ({ + auth: { + getSession: () => Promise.resolve({ data: { session: sbSession } }), + onAuthStateChange: () => ({ + data: { subscription: { unsubscribe: () => {} } }, + }), + refreshSession: () => Promise.resolve({ data: {}, error: null }), + signOut: () => Promise.resolve({ error: null }), + }, + }), +})); + +import { SupabaseAuthProvider } from "@app/auth/supabase/UseSession"; +import { AuthContext } from "@app/auth/context"; + +function Probe() { + const v = useContext(AuthContext); + return ( + <> + {String(v?.portalAccess)} + {/* Raw, un-defaulted value: this is what SaasPortalGate reads to decide + "access not known yet" vs "denied". undefined = spinner forever. */} + {String(v?.user?.portalAccess)} + + ); +} + +const mount = () => + render( + + + , + ); + +let fetchMock: ReturnType; + +beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); +}); +afterEach(() => vi.unstubAllGlobals()); + +describe("supabase provider portalAccess lookup", () => { + // SaaS serves the frontend and the API from different hosts; a root-relative + // path silently missed /me, so a granted non-admin was denied the Processor. + it("calls /me on the configured API base, not the page origin", async () => { + h.apiBase = "https://api.example.com"; + fetchMock.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ user: { portalAccess: true } }), + }); + mount(); + await waitFor(() => + expect(fetchMock).toHaveBeenCalledWith( + "https://api.example.com/api/v1/auth/me", + expect.anything(), + ), + ); + }); + + it("keeps a same-origin base as a single leading slash", async () => { + h.apiBase = "/"; + fetchMock.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ user: { portalAccess: true } }), + }); + mount(); + await waitFor(() => + expect(fetchMock).toHaveBeenCalledWith( + "/api/v1/auth/me", + expect.anything(), + ), + ); + }); + + it("grants access when /me says so", async () => { + h.apiBase = "https://api.example.com"; + fetchMock.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ user: { portalAccess: true } }), + }); + const { getByTestId } = mount(); + await waitFor(() => expect(getByTestId("access").textContent).toBe("true")); + }); + + // A non-ok used to resolve to null and return early, leaving the raw + // portalAccess undefined forever - SaasPortalGate reads that as "still + // loading" and hangs on a spinner instead of falling back. + it("resolves the raw portalAccess when /me returns non-ok", async () => { + h.apiBase = "https://api.example.com"; + fetchMock.mockResolvedValue({ + ok: false, + status: 401, + json: () => Promise.resolve({}), + }); + const { getByTestId } = mount(); + await waitFor(() => expect(getByTestId("raw").textContent).toBe("false")); + }); + + it("leaves the raw portalAccess defined when the request rejects", async () => { + h.apiBase = "https://api.example.com"; + fetchMock.mockRejectedValue(new Error("network down")); + const { getByTestId } = mount(); + await waitFor(() => expect(getByTestId("raw").textContent).toBe("false")); + }); +}); diff --git a/frontend/editor/src/proprietary/components/policies/ClassificationCategoryManager.tsx b/frontend/editor/src/proprietary/components/policies/ClassificationCategoryManager.tsx index 143e344f82..bc29f74bae 100644 --- a/frontend/editor/src/proprietary/components/policies/ClassificationCategoryManager.tsx +++ b/frontend/editor/src/proprietary/components/policies/ClassificationCategoryManager.tsx @@ -40,7 +40,9 @@ export function ClassificationCategoryManager({ - {category.name} + + {t(`classification.families.${category.id}`, category.name)} + {count !== undefined && ( {count} )} diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.reentry.test.tsx b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.reentry.test.tsx new file mode 100644 index 0000000000..dc8ff6397d --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.reentry.test.tsx @@ -0,0 +1,119 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; + +// A file can re-enter the workbench without being a new upload (My Files reopen, session restore). +// The persisted dispatch record must stop the upload policy (and its billing) firing a second time. + +const mocks = vi.hoisted(() => ({ + workspace: [] as Array<{ id: string; derivedFromTool?: boolean }>, + runStoredPolicy: vi.fn(), + getPolicyRun: vi.fn(), + listPolicyRuns: vi.fn(), + getStirlingFile: vi.fn(), +})); + +vi.mock("@app/hooks/useAiEngineEnabled", () => ({ + useAiEngineEnabled: () => true, +})); +vi.mock("@app/contexts/FileContext", () => ({ + useAllFiles: () => ({ fileStubs: mocks.workspace }), + useFileManagement: () => ({ + addFiles: vi.fn(), + updateStirlingFileStub: vi.fn(), + }), + useFileContext: () => ({ consumeFiles: vi.fn() }), +})); +vi.mock("@app/contexts/IndexedDBContext", () => ({ + useIndexedDB: () => ({ bumpRevision: vi.fn() }), +})); +vi.mock("@app/hooks/usePolicies", () => ({ + usePolicies: () => ({ + policies: { + security: { + configured: true, + status: "active", + backendId: "backend-security", + runOn: "upload", + order: 0, + outputMode: "new_version", + outputName: "", + }, + }, + }), +})); +vi.mock("@app/services/policyApi", () => ({ + runStoredPolicy: mocks.runStoredPolicy, + getPolicyRun: mocks.getPolicyRun, + listPolicyRuns: mocks.listPolicyRuns, + downloadPolicyOutput: vi.fn(), + resolvePolicyRunTarget: () => "saas", +})); +vi.mock("@app/services/fileStorage", () => ({ + fileStorage: { + getStirlingFile: mocks.getStirlingFile, + getStirlingFileStub: vi.fn().mockResolvedValue(null), + persistVersionedOutputs: vi.fn(), + updateFileMetadata: vi.fn().mockResolvedValue(true), + }, +})); + +import { usePolicyAutoRun } from "@app/components/policies/usePolicyAutoRun"; +import { + markDispatched, + resetPolicyRuns, +} from "@app/components/policies/policyRunStore"; + +beforeEach(() => { + localStorage.clear(); + resetPolicyRuns(); + vi.clearAllMocks(); + mocks.listPolicyRuns.mockResolvedValue([]); + mocks.getStirlingFile.mockResolvedValue( + new File(["x"], "doc.pdf", { type: "application/pdf" }), + ); + mocks.runStoredPolicy.mockResolvedValue("run-0"); + // Completed with no outputs: the run settles without the import machinery. + mocks.getPolicyRun.mockResolvedValue({ + runId: "run-0", + policyId: null, + status: "COMPLETED", + currentStep: 1, + stepCount: 1, + error: null, + outputs: [], + }); +}); + +describe("upload policies and files re-entering the workbench", () => { + it("does not re-run on a file the policy already ran on", async () => { + markDispatched("security", "already-enforced"); + mocks.workspace = [{ id: "already-enforced" }, { id: "fresh-upload" }]; + + renderHook(() => usePolicyAutoRun()); + + await waitFor(() => expect(mocks.runStoredPolicy).toHaveBeenCalledTimes(1)); + expect(mocks.getStirlingFile).toHaveBeenCalledWith("fresh-upload"); + expect(mocks.getStirlingFile).not.toHaveBeenCalledWith("already-enforced"); + }); + + it("stays silent when every file in the workbench has already been enforced", async () => { + markDispatched("security", "one"); + markDispatched("security", "two"); + mocks.workspace = [{ id: "one" }, { id: "two" }]; + + renderHook(() => usePolicyAutoRun()); + + // Give the dispatch effect a tick to (wrongly) fire before asserting silence. + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(mocks.runStoredPolicy).not.toHaveBeenCalled(); + }); + + it("still skips a policy's own output, which is not an upload at all", async () => { + mocks.workspace = [{ id: "policy-output", derivedFromTool: true }]; + + renderHook(() => usePolicyAutoRun()); + + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(mocks.runStoredPolicy).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/editor/src/proprietary/components/shared/AppSwitcher.tsx b/frontend/editor/src/proprietary/components/shared/AppSwitcher.tsx deleted file mode 100644 index 9ba0b6438d..0000000000 --- a/frontend/editor/src/proprietary/components/shared/AppSwitcher.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { Logo } from "@app/ui/Logo"; -import { BrandSwitcher } from "@app/components/shared/BrandSwitcher"; -import { type AppSwitcherProps } from "@core/components/shared/AppSwitcher"; -import { useOtherAppSwitch } from "@app/hooks/useOtherAppSwitch"; - -/** - * Sidebar brand header for builds that ship the processor. When this user can - * open it, the Stirling logo doubles as the editor⇄processor switcher: the mark - * morphs into a chevron and opens the switch menu (the same BrandSwitcher the - * processor sidebar uses). Users without access get a plain logo. - * - * The access gate lives in {@link useOtherAppSwitch} so this header and the - * sidebar footer's "Open PDF Processor" row are driven by one answer. - */ -export function AppSwitcher({ collapsed }: AppSwitcherProps) { - const otherApp = useOtherAppSwitch(); - - if (!otherApp) { - return ( - - ); - } - - return ( - - ); -} diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/PeopleSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/PeopleSection.tsx index d955cb341b..6b14d2b421 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/PeopleSection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/PeopleSection.tsx @@ -1,5 +1,4 @@ -import { useState, useEffect } from "react"; -import { isAxiosError } from "axios"; +import { useMemo, useState } from "react"; import { Trans, useTranslation } from "react-i18next"; import { Stack, @@ -20,14 +19,12 @@ import { import { Button } from "@app/ui/Button"; import { ActionIcon } from "@app/ui/ActionIcon"; import LocalIcon from "@app/components/shared/LocalIcon"; -import { alert } from "@app/components/toast"; import { userManagementService, User, } from "@app/services/userManagementService"; -import { teamService, Team } from "@app/services/teamService"; +import { type Team } from "@app/services/teamService"; import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex"; -import { useAppConfig } from "@app/contexts/AppConfigContext"; import InviteMembersModal from "@app/components/shared/InviteMembersModal"; import { useLoginRequired } from "@app/hooks/useLoginRequired"; import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner"; @@ -36,17 +33,109 @@ import UpdateSeatsButton from "@app/components/shared/UpdateSeatsButton"; import { useLicense } from "@app/contexts/LicenseContext"; import ChangeUserPasswordModal from "@app/components/shared/ChangeUserPasswordModal"; import { useAuth } from "@app/auth/UseSession"; +import { + useAdminUsers, + useTeams, + useAdminMutation, + useInvalidateAdminDirectory, +} from "@app/hooks/useAdminDirectory"; + +const EXAMPLE_USERS: User[] = [ + { + id: 1, + username: "admin", + email: "admin@example.com", + enabled: true, + roleName: "ROLE_ADMIN", + rolesAsString: "ROLE_ADMIN", + authenticationType: "password", + isActive: true, + lastRequest: Date.now(), + team: { id: 1, name: "Engineering" }, + }, + { + id: 2, + username: "john.doe", + email: "john.doe@example.com", + enabled: true, + roleName: "ROLE_USER", + rolesAsString: "ROLE_USER", + authenticationType: "password", + isActive: false, + lastRequest: Date.now() - 86400000, + team: { id: 1, name: "Engineering" }, + }, + { + id: 3, + username: "jane.smith", + email: "jane.smith@example.com", + enabled: true, + roleName: "ROLE_USER", + rolesAsString: "ROLE_USER", + authenticationType: "oauth", + isActive: true, + lastRequest: Date.now(), + team: { id: 2, name: "Marketing" }, + }, + { + id: 4, + username: "bob.wilson", + email: "bob.wilson@example.com", + enabled: false, + roleName: "ROLE_USER", + rolesAsString: "ROLE_USER", + authenticationType: "password", + isActive: false, + lastRequest: Date.now() - 604800000, + team: undefined, + }, +]; + +const EXAMPLE_TEAMS: Team[] = [ + { id: 1, name: "Engineering", userCount: 3 }, + { id: 2, name: "Marketing", userCount: 2 }, +]; + +const EXAMPLE_LICENSE = { + maxAllowedUsers: 10, + availableSlots: 6, + grandfatheredUserCount: 0, + licenseMaxUsers: 5, + premiumEnabled: true, + totalUsers: 4, +}; export default function PeopleSection() { const { t } = useTranslation(); - const { config } = useAppConfig(); const { loginEnabled } = useLoginRequired(); const { user: currentUser } = useAuth(); const navigate = useNavigate(); const { licenseInfo: globalLicenseInfo } = useLicense(); - const [users, setUsers] = useState([]); - const [teams, setTeams] = useState([]); - const [loading, setLoading] = useState(true); + const admin = useAdminUsers(loginEnabled); + const { data: fetchedTeams } = useTeams(loginEnabled); + const refreshDirectory = useInvalidateAdminDirectory(); + + // Session and MFA state arrive alongside the roster, keyed by username. + const fetchedUsers = useMemo(() => { + if (!admin.data) return []; + return admin.data.users.map((user) => ({ + ...user, + isActive: admin.data.userSessions[user.username] || false, + lastRequest: admin.data.userLastRequest[user.username] || undefined, + mfaEnabled: + ( + admin.data.userSettings?.[user.username] as + | Record + | undefined + )?.mfaEnabled === "true", + })); + }, [admin.data]); + + // Login off means the endpoints are not callable, so the table shows a + // worked example instead of an empty state. + const users = loginEnabled ? fetchedUsers : EXAMPLE_USERS; + const teams = loginEnabled ? (fetchedTeams ?? []) : EXAMPLE_TEAMS; + const loading = loginEnabled && admin.isPending; const [searchQuery, setSearchQuery] = useState(""); const [inviteModalOpened, setInviteModalOpened] = useState(false); const [editUserModalOpened, setEditUserModalOpened] = useState(false); @@ -54,19 +143,20 @@ export default function PeopleSection() { useState(false); const [passwordUser, setPasswordUser] = useState(null); const [selectedUser, setSelectedUser] = useState(null); - const [processing, setProcessing] = useState(false); - const [mailEnabled, setMailEnabled] = useState(false); - const [lockedUsers, setLockedUsers] = useState([]); - - // License information - const [licenseInfo, setLicenseInfo] = useState<{ - maxAllowedUsers: number; - availableSlots: number; - grandfatheredUserCount: number; - licenseMaxUsers: number; - premiumEnabled: boolean; - totalUsers: number; - } | null>(null); + const mailEnabled = loginEnabled ? (admin.data?.mailEnabled ?? false) : false; + const lockedUsers = loginEnabled ? (admin.data?.lockedUsers ?? []) : []; + const licenseInfo = loginEnabled + ? admin.data + ? { + maxAllowedUsers: admin.data.maxAllowedUsers, + availableSlots: admin.data.availableSlots, + grandfatheredUserCount: admin.data.grandfatheredUserCount, + licenseMaxUsers: admin.data.licenseMaxUsers, + premiumEnabled: admin.data.premiumEnabled, + totalUsers: admin.data.totalUsers, + } + : null + : EXAMPLE_LICENSE; const hasNoSlots = licenseInfo ? licenseInfo.availableSlots === 0 : false; const handleAddMembersClick = () => { if (!loginEnabled) { @@ -115,253 +205,103 @@ export default function PeopleSection() { teamId: undefined as number | undefined, }); - useEffect(() => { - fetchData(); - }, []); + const updateUserRole = useAdminMutation({ + write: (payload: { username: string; role: string; teamId?: number }) => + userManagementService.updateUserRole(payload), + // A role edit can also move the user, which changes both teams' counts. + invalidates: ["users", "teams"], + success: t("workspace.people.editMember.success"), + errorFallback: t("workspace.people.editMember.error"), + onDone: () => closeEditModal(), + }); - useEffect(() => { - if (config) { - console.log( - "[PeopleSection] Email invites enabled:", - config.enableEmailInvites, - ); - } - }, [config]); + const toggleEnabled = useAdminMutation({ + write: (user: User) => + userManagementService.toggleUserEnabled(user.username, !user.enabled), + invalidates: ["users"], + success: t("workspace.people.toggleEnabled.success"), + errorFallback: t("workspace.people.toggleEnabled.error"), + }); - const fetchData = async () => { - try { - setLoading(true); + const deleteUser = useAdminMutation({ + write: (username: string) => userManagementService.deleteUser(username), + invalidates: ["users", "teams"], + success: t( + "workspace.people.deleteUserSuccess", + "User deleted successfully", + ), + errorFallback: t( + "workspace.people.deleteUserError", + "Failed to delete user", + ), + }); - if (loginEnabled) { - const [adminData, teamsData] = await Promise.all([ - userManagementService.getUsers(), - teamService.getTeams(), - ]); + const unlockUser = useAdminMutation({ + write: (username: string) => userManagementService.unlockUser(username), + invalidates: ["users"], + success: t( + "workspace.people.unlockUserSuccess", + "User account unlocked successfully", + ), + errorFallback: t( + "workspace.people.unlockUserError", + "Failed to unlock user account", + ), + }); - // Enrich users with session data - const enrichedUsers = adminData.users.map((user) => ({ - ...user, - isActive: adminData.userSessions[user.username] || false, - lastRequest: adminData.userLastRequest[user.username] || undefined, - mfaEnabled: - ( - adminData.userSettings?.[user.username] as - | Record - | undefined - )?.mfaEnabled === "true", - })); + const disableMfa = useAdminMutation({ + write: (username: string) => + userManagementService.disableMfaByAdmin(username), + invalidates: ["users"], + success: t( + "workspace.people.mfa.adminDisableSuccess", + "MFA disabled successfully for user", + ), + errorFallback: t( + "workspace.people.mfa.adminDisableError", + "Failed to disable MFA for user", + ), + }); - setUsers(enrichedUsers); - setTeams(teamsData); - - // Store license information - setLicenseInfo({ - maxAllowedUsers: adminData.maxAllowedUsers, - availableSlots: adminData.availableSlots, - grandfatheredUserCount: adminData.grandfatheredUserCount, - licenseMaxUsers: adminData.licenseMaxUsers, - premiumEnabled: adminData.premiumEnabled, - totalUsers: adminData.totalUsers, - }); - setMailEnabled(adminData.mailEnabled); - setLockedUsers(adminData.lockedUsers || []); - } else { - // Provide example data when login is disabled - const exampleUsers: User[] = [ - { - id: 1, - username: "admin", - email: "admin@example.com", - enabled: true, - roleName: "ROLE_ADMIN", - rolesAsString: "ROLE_ADMIN", - authenticationType: "password", - isActive: true, - lastRequest: Date.now(), - team: { id: 1, name: "Engineering" }, - }, - { - id: 2, - username: "john.doe", - email: "john.doe@example.com", - enabled: true, - roleName: "ROLE_USER", - rolesAsString: "ROLE_USER", - authenticationType: "password", - isActive: false, - lastRequest: Date.now() - 86400000, - team: { id: 1, name: "Engineering" }, - }, - { - id: 3, - username: "jane.smith", - email: "jane.smith@example.com", - enabled: true, - roleName: "ROLE_USER", - rolesAsString: "ROLE_USER", - authenticationType: "oauth", - isActive: true, - lastRequest: Date.now(), - team: { id: 2, name: "Marketing" }, - }, - { - id: 4, - username: "bob.wilson", - email: "bob.wilson@example.com", - enabled: false, - roleName: "ROLE_USER", - rolesAsString: "ROLE_USER", - authenticationType: "password", - isActive: false, - lastRequest: Date.now() - 604800000, - team: undefined, - }, - ]; - - const exampleTeams: Team[] = [ - { id: 1, name: "Engineering", userCount: 3 }, - { id: 2, name: "Marketing", userCount: 2 }, - ]; - - setUsers(exampleUsers); - setTeams(exampleTeams); - setMailEnabled(false); - setLockedUsers([]); - - // Example license information - setLicenseInfo({ - maxAllowedUsers: 10, - availableSlots: 6, - grandfatheredUserCount: 0, - licenseMaxUsers: 5, - premiumEnabled: true, - totalUsers: 4, - }); - } - } catch (error) { - console.error("[PeopleSection] Failed to fetch people data:", error); - alert({ alertType: "error", title: "Failed to load people data" }); - } finally { - setLoading(false); - } - }; - - const handleUpdateUserRole = async () => { + const handleUpdateUserRole = () => { if (!selectedUser) return; - - try { - setProcessing(true); - await userManagementService.updateUserRole({ - username: selectedUser.username, - role: editForm.role, - teamId: editForm.teamId, - }); - alert({ - alertType: "success", - title: t("workspace.people.editMember.success"), - }); - closeEditModal(); - fetchData(); - } catch (error: unknown) { - console.error("[PeopleSection] Failed to update user:", error); - const errorMessage = isAxiosError(error) - ? error.response?.data?.message || - error.response?.data?.error || - error.message - : (error instanceof Error ? error.message : undefined) || - t("workspace.people.editMember.error"); - alert({ alertType: "error", title: errorMessage }); - } finally { - setProcessing(false); - } + updateUserRole.mutate({ + username: selectedUser.username, + role: editForm.role, + teamId: editForm.teamId, + }); }; - const handleToggleEnabled = async (user: User) => { - try { - await userManagementService.toggleUserEnabled( - user.username, - !user.enabled, - ); - alert({ - alertType: "success", - title: t("workspace.people.toggleEnabled.success"), - }); - fetchData(); - } catch (error: unknown) { - console.error("[PeopleSection] Failed to toggle user status:", error); - const errorMessage = isAxiosError(error) - ? error.response?.data?.message || - error.response?.data?.error || - error.message - : (error instanceof Error ? error.message : undefined) || - t("workspace.people.toggleEnabled.error"); - alert({ alertType: "error", title: errorMessage }); - } + const handleToggleEnabled = (user: User) => { + toggleEnabled.mutate(user); }; - const handleDeleteUser = async (user: User) => { + const handleDeleteUser = (user: User) => { const confirmMessage = t( "workspace.people.confirmDelete", "Are you sure you want to delete this user? This action cannot be undone.", ); - if (!window.confirm(`${confirmMessage}\n\nUser: ${user.username}`)) { - return; - } + if ( + !window.confirm(`${confirmMessage} - try { - await userManagementService.deleteUser(user.username); - alert({ - alertType: "success", - title: t( - "workspace.people.deleteUserSuccess", - "User deleted successfully", - ), - }); - fetchData(); - } catch (error: unknown) { - console.error("[PeopleSection] Failed to delete user:", error); - const errorMessage = isAxiosError(error) - ? error.response?.data?.message || - error.response?.data?.error || - error.message - : (error instanceof Error ? error.message : undefined) || - t("workspace.people.deleteUserError", "Failed to delete user"); - alert({ alertType: "error", title: errorMessage }); - } +User: ${user.username}`) + ) + return; + deleteUser.mutate(user.username); }; - const handleUnlockUser = async (user: User) => { + const handleUnlockUser = (user: User) => { const confirmMessage = t( "workspace.people.confirmUnlock", "Are you sure you want to unlock this user account?", ); - if (!window.confirm(`${confirmMessage}\n\nUser: ${user.username}`)) { - return; - } + if ( + !window.confirm(`${confirmMessage} - try { - await userManagementService.unlockUser(user.username); - alert({ - alertType: "success", - title: t( - "workspace.people.unlockUserSuccess", - "User account unlocked successfully", - ), - }); - fetchData(); - } catch (error: unknown) { - console.error("[PeopleSection] Failed to unlock user:", error); - const errorMessage = isAxiosError(error) - ? error.response?.data?.message || - error.response?.data?.error || - error.message - : (error instanceof Error ? error.message : undefined) || - t( - "workspace.people.unlockUserError", - "Failed to unlock user account", - ); - alert({ alertType: "error", title: errorMessage }); - } +User: ${user.username}`) + ) + return; + unlockUser.mutate(user.username); }; const openEditModal = (user: User) => { @@ -549,7 +489,7 @@ export default function PeopleSection() { - + )} @@ -891,40 +831,7 @@ export default function PeopleSection() { height="1rem" /> } - onClick={async () => { - try { - await userManagementService.disableMfaByAdmin( - user.username, - ); - alert({ - alertType: "success", - title: t( - "workspace.people.mfa.adminDisableSuccess", - "MFA disabled successfully for user", - ), - }); - } catch (error: unknown) { - console.error( - "[PeopleSection] Failed to disable MFA for user:", - error, - ); - const errorMessage = isAxiosError(error) - ? error.response?.data?.message || - error.response?.data?.error || - error.message - : (error instanceof Error - ? error.message - : undefined) || - t( - "workspace.people.mfa.adminDisableError", - "Failed to disable MFA for user", - ); - alert({ - alertType: "error", - title: errorMessage, - }); - } - }} + onClick={() => disableMfa.mutate(user.username)} disabled={!loginEnabled} > {t( @@ -968,14 +875,14 @@ export default function PeopleSection() { setInviteModalOpened(false)} - onSuccess={fetchData} + onSuccess={refreshDirectory} /> @@ -1075,7 +982,7 @@ export default function PeopleSection() { /> + + {/* The reported name is deliberately not shown. The requester chooses it on an + unauthenticated endpoint, so it is the field an attacker would set to look + familiar, and its honest value is the hostname already in the address. It + still labels the server in the linked-instances list, after the decision. */} +
+ {t("connect.confirm.originLabel", "Address")} + {pending?.insecureTransport ? ( + + + + + + ) : null} +
+
{pending?.callbackOrigin}
+ + + {error ? {error} : null} + + setAcknowledged(e.currentTarget.checked)} + label={t( + "connect.confirm.acknowledge", + "I recognise this address and want to connect it to my team", + )} + /> + +
+ + +
+
+ ); +} diff --git a/frontend/editor/src/saas/routes/Login.tsx b/frontend/editor/src/saas/routes/Login.tsx index 418616104e..ba251561fd 100644 --- a/frontend/editor/src/saas/routes/Login.tsx +++ b/frontend/editor/src/saas/routes/Login.tsx @@ -14,6 +14,7 @@ import { getBaseUrl, withBasePath, } from "@app/constants/app"; +import { isSafePostLoginRedirect } from "@app/services/postLoginRedirect"; import LinkRoundedIcon from "@mui/icons-material/LinkRounded"; // Import login components @@ -48,14 +49,14 @@ export default function Login() { } }, []); - // Same-origin relative path to return to after login (e.g. the OAuth - // consent page). Same sanitization rules as AuthCallback's `next`. + // Same-origin router path to return to after login (e.g. the OAuth consent + // page, or the editor a 401 bounced the user off). `?next=` is what this app + // writes; `?from=` is what the shared core 401 handler writes. const nextPath = useMemo(() => { try { - const next = new URL(window.location.href).searchParams.get("next"); - return next && next.startsWith("/") && !next.startsWith("//") - ? next - : null; + const params = new URL(window.location.href).searchParams; + const candidate = params.get("next") ?? params.get("from"); + return isSafePostLoginRedirect(candidate) ? candidate : null; } catch (_) { return null; } diff --git a/frontend/editor/src/saas/routes/ResumePendingConnect.tsx b/frontend/editor/src/saas/routes/ResumePendingConnect.tsx new file mode 100644 index 0000000000..37950047bd --- /dev/null +++ b/frontend/editor/src/saas/routes/ResumePendingConnect.tsx @@ -0,0 +1,38 @@ +import { useEffect, useRef } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; +import { useAuth } from "@app/auth/UseSession"; +import { readPendingConnect } from "@app/routes/pendingConnect"; + +/** + * Sends a newly signed-in visitor back to the approval page they were pulled away + * from. + * + * Mounted app-wide, not only in the auth callback: a confirmation email can land the + * visitor anywhere in the app with a session, and only the ones below resolve the + * request themselves. + */ +export function ResumePendingConnect() { + const { session, loading } = useAuth(); + const navigate = useNavigate(); + const location = useLocation(); + const handled = useRef(false); + + useEffect(() => { + if (loading || !session || handled.current) return; + if ( + location.pathname === "/link" || + location.pathname === "/auth/callback" + ) { + return; + } + handled.current = true; + const requestId = readPendingConnect(); + if (requestId) { + navigate(`/link?request=${encodeURIComponent(requestId)}`, { + replace: true, + }); + } + }, [loading, session, location.pathname, navigate]); + + return null; +} diff --git a/frontend/editor/src/saas/routes/connect.css b/frontend/editor/src/saas/routes/connect.css new file mode 100644 index 0000000000..5a709abfbe --- /dev/null +++ b/frontend/editor/src/saas/routes/connect.css @@ -0,0 +1,93 @@ +/* Connect-approval page. The origin is the thing the approver has to actually + read, so it gets the visual weight and everything else stays quiet. */ + +.saas-connect { + display: flex; + flex-direction: column; + gap: 1rem; + text-align: left; +} + +.saas-connect__title { + margin: 0; + font-size: 1.25rem; + font-weight: 600; + color: var(--c-text); +} + +.saas-connect__lead { + margin: 0; + font-size: 0.875rem; + color: var(--c-text-muted); +} + +/* Sits inside the facts panel rather than beside the email: at this width a + right-aligned action wraps onto its own line and reads as a third field. */ +.saas-connect__switch { + display: block; + margin-top: 0.125rem; + padding: 0; + border: 0; + background: none; + font: inherit; + font-size: 0.8125rem; + color: var(--c-accent-text); + cursor: pointer; +} + +.saas-connect__switch:hover:not(:disabled) { + text-decoration: underline; +} + +.saas-connect__switch:disabled { + color: var(--c-text-muted); + cursor: default; +} + +.saas-connect__facts { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.375rem 1rem; + margin: 0; + padding: 0.875rem; + background: var(--c-surface-sunken); + border: 1px solid var(--c-border); + border-radius: 0.375rem; + font-size: 0.875rem; +} + +.saas-connect__facts dt { + margin: 0; + color: var(--c-text-muted); +} + +.saas-connect__facts dd { + margin: 0; + color: var(--c-text); + overflow-wrap: anywhere; +} + +/* Monospaced so a lookalike hostname is harder to skim past. */ +.saas-connect__origin { + font-family: var(--font-mono, ui-monospace, monospace); + font-weight: 600; +} + +.saas-connect__origin-label { + display: flex; + align-items: center; + gap: 0.375rem; +} + +.saas-connect__insecure { + display: inline-flex; + flex: none; + color: var(--c-warning); + cursor: help; +} + +.saas-connect__actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; +} diff --git a/frontend/editor/src/saas/routes/pendingConnect.ts b/frontend/editor/src/saas/routes/pendingConnect.ts new file mode 100644 index 0000000000..c44af277be --- /dev/null +++ b/frontend/editor/src/saas/routes/pendingConnect.ts @@ -0,0 +1,61 @@ +/** + * Remembers that the visitor arrived wanting to connect a server, so a sign-in + * detour can return them to the approval page. + * + * localStorage, not sessionStorage: the confirmation email opens a new tab, and + * sessionStorage is per-tab — empty exactly when it is needed. + * + * Only the request id, which is already in the URL and carries no secret. This + * decides where the approver lands, never whether the link happens. + * + * Reading does not consume it: the request may be open in another tab, or the page + * closed and reopened, or the reader mounted twice. Only a recorded decision, or a + * request that is settled or gone, retires it. + */ +const KEY = "stirling-pending-connect"; + +/** Matches the server's request lifetime, so a stale intent cannot hijack a later sign-in. */ +const TTL_MS = 30 * 60 * 1000; + +interface Stored { + requestId: string; + at: number; +} + +export function rememberPendingConnect(requestId: string): void { + try { + const value: Stored = { requestId, at: Date.now() }; + window.localStorage.setItem(KEY, JSON.stringify(value)); + } catch { + // Private browsing or a full quota; nothing to fall back to. + } +} + +/** Drops the intent without reading it, once it has been acted on. */ +export function clearPendingConnect(): void { + try { + window.localStorage.removeItem(KEY); + } catch { + // Unwritable store; nothing to remove. + } +} + +/** The pending request, or null when absent or expired. Leaves it in place. */ +export function readPendingConnect(): string | null { + try { + const raw = window.localStorage.getItem(KEY); + if (!raw) return null; + const value = JSON.parse(raw) as Stored; + if (typeof value?.requestId !== "string" || typeof value?.at !== "number") { + clearPendingConnect(); + return null; + } + if (Date.now() - value.at > TTL_MS) { + clearPendingConnect(); + return null; + } + return value.requestId; + } catch { + return null; + } +} diff --git a/frontend/editor/src/saas/services/apiClient.test.ts b/frontend/editor/src/saas/services/apiClient.test.ts index 45646bf8d8..af4a1d90f4 100644 --- a/frontend/editor/src/saas/services/apiClient.test.ts +++ b/frontend/editor/src/saas/services/apiClient.test.ts @@ -219,10 +219,11 @@ describe("apiClient", () => { // Import apiClient after mocking const { default: apiClient } = await import("@app/services/apiClient"); - // Mock window.location for redirect test + // On /editor when the session dies: the return path must ride along so the + // login screen sends the user back here, not to the role-based landing. Object.defineProperty(window, "location", { writable: true, - value: { href: "" }, + value: { href: "", pathname: "/editor", search: "" }, }); const mockAdapter = vi.fn((config) => { @@ -245,8 +246,50 @@ describe("apiClient", () => { } catch (_) { // Verify refresh was attempted expect(supabase.auth.refreshSession).toHaveBeenCalled(); - // Verify redirect to login - expect(window.location.href).toBe("/login"); + // Verify redirect to login carries the return path + expect(window.location.href).toBe("/login?next=%2Feditor"); + } + }); + + it("does not redirect (or loop) when already on the login page", async () => { + expectConsole.error(/\[API Client\] Token refresh failed/); + const oldSession = { access_token: "old", user: { id: "user-123" } }; + vi.mocked(supabase.auth.getSession).mockResolvedValue({ + data: { session: oldSession as unknown as Session }, + error: null, + }); + vi.mocked(supabase.auth.refreshSession).mockResolvedValue({ + data: { user: null, session: null }, + error: { + name: "AuthError", + message: "Refresh failed", + status: 400, + code: "auth_error", + } as unknown as AuthError, + }); + + const { default: apiClient } = await import("@app/services/apiClient"); + + Object.defineProperty(window, "location", { + writable: true, + value: { href: "", pathname: "/login", search: "?next=%2Feditor" }, + }); + + apiClient.defaults.adapter = vi.fn((config) => + Promise.reject( + Object.assign(new Error("Unauthorized"), { + response: { status: 401, data: { error: "Unauthorized" } }, + config, + }), + ), + ); + + try { + await apiClient.get("/api/v1/test"); + expect(true).toBe(false); + } catch (_) { + // Left untouched: no second redirect off the login page. + expect(window.location.href).toBe(""); } }); }); diff --git a/frontend/editor/src/saas/services/apiClient.ts b/frontend/editor/src/saas/services/apiClient.ts index 64cd05fd01..73ebe97e47 100644 --- a/frontend/editor/src/saas/services/apiClient.ts +++ b/frontend/editor/src/saas/services/apiClient.ts @@ -5,8 +5,9 @@ import { classifyPaygError, handlePaygError, } from "@app/services/paygErrorInterceptor"; -import { withBasePath } from "@app/constants/app"; +import { stripBasePath, withBasePath } from "@app/constants/app"; import { getBrowserId } from "@app/utils/browserIdentifier"; +import { isSafePostLoginRedirect } from "@app/services/postLoginRedirect"; // Helper: decode base64url JWT payload safely function decodeJwtPayload(token: string): Record | null { @@ -113,6 +114,21 @@ function refreshSessionOnce(): ReturnType { return inFlightRefresh; } +// Hard-redirect to /login, carrying where the user was so the login screen can +// return them there instead of falling through to the role-based landing (which +// sends processor users to the processor - the "refresh /editor bounces me to +// the processor" bug). Router-relative, matching what Login reads via `?next=`. +function redirectToLogin(): void { + const loginPath = withBasePath("/login"); + // Already on the login page: another redirect would just loop. + if (window.location.pathname === loginPath) return; + const returnPath = + stripBasePath(window.location.pathname) + window.location.search; + window.location.href = isSafePostLoginRedirect(returnPath) + ? `${loginPath}?next=${encodeURIComponent(returnPath)}` + : loginPath; +} + // Response interceptor for handling token refresh apiClient.interceptors.response.use( (response) => response, @@ -173,7 +189,7 @@ apiClient.interceptors.response.use( // The session genuinely can't be recovered. Send protected requests // to login; public ones just fail quietly (no redirect). if (!isPublicEndpoint) { - window.location.href = withBasePath("/login"); + redirectToLogin(); } return Promise.reject(error); @@ -194,10 +210,7 @@ apiClient.interceptors.response.use( console.debug( "[API Client] No session to refresh, 401 on protected endpoint", ); - const loginPath = withBasePath("/login"); - if (window.location.pathname !== loginPath) { - window.location.href = loginPath; - } + redirectToLogin(); return Promise.reject(error); } } catch (refreshError) { diff --git a/frontend/editor/vite.config.ts b/frontend/editor/vite.config.ts index 96ce29c480..22b8ce4e24 100644 --- a/frontend/editor/vite.config.ts +++ b/frontend/editor/vite.config.ts @@ -259,6 +259,16 @@ export default defineConfig(async ({ mode, command }) => { }; return { + // Per-mode: the default is one shared node_modules/.vite, so two dev servers in + // different modes re-optimize over each other and the browser 504s on a stale dep + // hash. Anchored to frontend/ because a relative path resolves against the vite + // root (editor/) and would create a second node_modules there. + cacheDir: resolve( + import.meta.dirname, + "..", + "node_modules", + `.vite-${effectiveMode}`, + ), define: { __DEV_WORKTREE_LABEL__: JSON.stringify(devWorktreeLabel), }, diff --git a/frontend/oxlint.comments.config.ts b/frontend/oxlint.comments.config.ts new file mode 100644 index 0000000000..a3a238893c --- /dev/null +++ b/frontend/oxlint.comments.config.ts @@ -0,0 +1,31 @@ +import { defineConfig } from "oxlint"; + +// Comment-quality rules only, kept out of oxlint.config.ts on purpose. +// +// The main config is run with --max-warnings=0 over the whole frontend, and the +// existing tree still has several hundred findings from these rules. Enabling +// them there would fail every build until the cleanup lands. So they live here +// and are driven by `task comment-lint`, which scopes findings to the lines a +// branch actually added. +// +// Fold this into oxlint.config.ts once the tree is clean; that is the step that +// also buys IDE squiggles, and the point at which this file goes away. + +export default defineConfig({ + jsPlugins: ["../scripts/lint/comment-lint-oxlint-plugin.mjs"], + categories: { correctness: "off" }, + ignorePatterns: [ + "dist", + "dist-portal", + "node_modules", + "playwright-report", + "storybook-static", + "test-results", + "editor/dist", + "editor/public", + "editor/src-tauri", + ], + rules: { + "comments/quality": "error", + }, +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6ade3d2a28..85d011f49b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -41,7 +41,7 @@ "@mantine/dates": "^8.3.1", "@mantine/dropzone": "^8.3.1", "@mantine/hooks": "^8.3.1", - "@mui/icons-material": "^9.2.0", + "@mui/icons-material": "^9.3.1", "@mui/material": "^9.0.0", "@posthog/react": "^1.8.2", "@reactour/tour": "^3.8.0", @@ -49,9 +49,9 @@ "@stripe/stripe-js": "^9.10.0", "@supabase/supabase-js": "^2.47.13", "@tailwindcss/postcss": "^4.1.13", - "@tanstack/react-query": "^5.101.4", + "@tanstack/react-query": "^5.102.0", "@tanstack/react-table": "^9.1.2", - "@tanstack/react-virtual": "^3.13.12", + "@tanstack/react-virtual": "^3.14.10", "@tauri-apps/api": "^2.10.1", "@tauri-apps/plugin-dialog": "2.7.0", "@tauri-apps/plugin-fs": "2.5.0", @@ -87,7 +87,7 @@ "web-vitals": "^5.1.0" }, "devDependencies": { - "@iconify-json/material-symbols": "^1.2.83", + "@iconify-json/material-symbols": "^1.2.89", "@iconify/react": "^6.0.2", "@iconify/utils": "^3.1.4", "@playwright/test": "^1.55.0", @@ -481,9 +481,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", - "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -1982,9 +1982,9 @@ "license": "MIT" }, "node_modules/@iconify-json/material-symbols": { - "version": "1.2.83", - "resolved": "https://registry.npmjs.org/@iconify-json/material-symbols/-/material-symbols-1.2.83.tgz", - "integrity": "sha512-4I2rfNlaoyn4zIcdJDxUMuPV1pVp8Tgwy+eJvyAZuOpmPtOsniQ8Dug6wzRD5s9KLyQA3smFvuBphVdYG7NWQA==", + "version": "1.2.89", + "resolved": "https://registry.npmjs.org/@iconify-json/material-symbols/-/material-symbols-1.2.89.tgz", + "integrity": "sha512-wYJjKAOptNhc8Bq65PPJVEz/4Ozm+b3ZWEB3H65yGw37dr9AvXibW0GkEdVD7qrCtbD6aC6fY7SLmQCuUGtEAQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -2360,9 +2360,9 @@ "license": "MIT" }, "node_modules/@mui/core-downloads-tracker": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-9.2.0.tgz", - "integrity": "sha512-+XMav+ZaXkZKUFUgzjrfMEedfyJKxxviAske2q8N8CWDMeqZdDU2lWMkiUPiB388hGaDqhwvOAwkrsc/pUyp8g==", + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-9.3.1.tgz", + "integrity": "sha512-IAyAFNQbT7hysJ9HXphiOmWJF7G1OglzHanqCgvQgH9LA2ydxtmaTBDbcBqw6euZesyShiwvpvbnYO1GY1AyXQ==", "license": "MIT", "funding": { "type": "opencollective", @@ -2370,12 +2370,12 @@ } }, "node_modules/@mui/icons-material": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-9.2.0.tgz", - "integrity": "sha512-VgBd3z7Qc3vd/thcNSMC03nHRh/U4DzMUd+1dRyJTbm/hGo7+N6N4GDuJZDNHa6LZhhwG6Cu1X3DNvrVv8sNag==", + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-9.3.1.tgz", + "integrity": "sha512-rZj5ccG7vkpV38o/l4ys+chfE9GFypmvZr9dSNBoYVCNBD9yC6KfKq1TYpEMshWbjic7GKQ8MA1LQlcpGgcq9Q==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.29.2" + "@babel/runtime": "^7.29.7" }, "engines": { "node": ">=14.0.0" @@ -2385,7 +2385,7 @@ "url": "https://opencollective.com/mui-org" }, "peerDependencies": { - "@mui/material": "^9.2.0", + "@mui/material": "^9.3.1", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, @@ -2396,22 +2396,22 @@ } }, "node_modules/@mui/material": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/@mui/material/-/material-9.2.0.tgz", - "integrity": "sha512-+YTRSgGKGrrRo2XJZXs7JRA6qHoHWvNtxyqxnrRJTBmIuLOUpxxh7m4G9lF4tWberxGFY+EqkkRPgJCl+fSMJg==", + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-9.3.1.tgz", + "integrity": "sha512-NahAEGIXqS1K0bA4th1jeFxBguS59NOcLbMA0vU+fSaPWKjtwGBGGHeTwlc9PSmzMjOZKceeFWESB8fVHr31hA==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.29.2", - "@mui/core-downloads-tracker": "^9.2.0", - "@mui/system": "^9.2.0", - "@mui/types": "^9.1.1", - "@mui/utils": "^9.2.0", + "@babel/runtime": "^7.29.7", + "@mui/core-downloads-tracker": "^9.3.1", + "@mui/system": "^9.3.0", + "@mui/types": "^9.3.0", + "@mui/utils": "^9.3.0", "@popperjs/core": "^2.11.8", "@types/react-transition-group": "^4.4.12", "clsx": "^2.1.1", "csstype": "^3.2.3", "prop-types": "^15.8.1", - "react-is": "^19.2.6", + "react-is": "^19.2.8", "react-transition-group": "^4.4.5" }, "engines": { @@ -2424,7 +2424,7 @@ "peerDependencies": { "@emotion/react": "^11.5.0", "@emotion/styled": "^11.3.0", - "@mui/material-pigment-css": "^9.2.0", + "@mui/material-pigment-css": "^9.3.0", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" @@ -2445,13 +2445,13 @@ } }, "node_modules/@mui/private-theming": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-9.2.0.tgz", - "integrity": "sha512-w9wpyDxGPGnAACPB2hKhCDmILJIAvQxrfjUbIAEa0AznX1rOjaz5N+yB1uuw8ixnJcpEh/tPbD9oEe19wcWPHw==", + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-9.3.0.tgz", + "integrity": "sha512-ERvqk5pejf9aRnQcDILSWGtFmsEMiVxlQ4+xsVCjsEvmK0fV9BiVP/cQwAF5dwyDFbve4lTrlcgeFEecVzTNiA==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.29.2", - "@mui/utils": "^9.2.0", + "@babel/runtime": "^7.29.7", + "@mui/utils": "^9.3.0", "prop-types": "^15.8.1" }, "engines": { @@ -2472,12 +2472,12 @@ } }, "node_modules/@mui/styled-engine": { - "version": "9.1.1", - "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-9.1.1.tgz", - "integrity": "sha512-neaYKdJfvEG54q8efHLJR7swpHG/gfSv9xGqW5iTSMsubD7yPCPFrhVBt284j1DOF3uZaaDJSHQL7gz6jGF21Q==", + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-9.3.0.tgz", + "integrity": "sha512-x9+KYxhjoHYZ4nioxdKnvQWdw0RScbhoZfQ4tv3Db742683U3wlYSp6zV6Us78dMFnKnyTrPTkQKyutp14gnKA==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.29.2", + "@babel/runtime": "^7.29.7", "@emotion/cache": "^11.14.0", "@emotion/serialize": "^1.3.3", "@emotion/sheet": "^1.4.0", @@ -2506,16 +2506,16 @@ } }, "node_modules/@mui/system": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/@mui/system/-/system-9.2.0.tgz", - "integrity": "sha512-YvUJwKoGVtbnOm2PyPi5TvX2d1rOA6sqSpEWVs4WmXNIaFTuYmNUaVdU2o1NKUEe31URnD3E8ZVUMcsLQXwcYg==", + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-9.3.0.tgz", + "integrity": "sha512-0l4LqHJxZj65xSrioniGsxm7VNoGXonPo203oZjhBUvIDPeBqRTb7Mqc45Qxs6sO6WGR7WE/9cJ6lb4lkvHkjg==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.29.2", - "@mui/private-theming": "^9.2.0", - "@mui/styled-engine": "^9.1.1", - "@mui/types": "^9.1.1", - "@mui/utils": "^9.2.0", + "@babel/runtime": "^7.29.7", + "@mui/private-theming": "^9.3.0", + "@mui/styled-engine": "^9.3.0", + "@mui/types": "^9.3.0", + "@mui/utils": "^9.3.0", "clsx": "^2.1.1", "csstype": "^3.2.3", "prop-types": "^15.8.1" @@ -2546,12 +2546,12 @@ } }, "node_modules/@mui/types": { - "version": "9.1.1", - "resolved": "https://registry.npmjs.org/@mui/types/-/types-9.1.1.tgz", - "integrity": "sha512-Zjt7u8wNvDg40rPTGoL+TnfkpuSKjwubsNSFRH1KAVZLcaV4I3AFNHIFbvH7p4F3alEibSbdd90xAgn5Rnfndg==", + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-9.3.0.tgz", + "integrity": "sha512-2JSxyfpEFWNUB2vKs/T1BvkfyNisMHWph8bLMj8T0uHwmLl/0qfAwQkfwMT6kxLXN9uIum9AEbECXU8er3amIg==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.29.2" + "@babel/runtime": "^7.29.7" }, "peerDependencies": { "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" @@ -2563,17 +2563,17 @@ } }, "node_modules/@mui/utils": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-9.2.0.tgz", - "integrity": "sha512-OsUH5zhlSOM4xmLl53+agug1M1UyWb4zxFxWQCqwKTKUeQPvTENtg3JhrroBD2qpCLKsX5W/DYGERJ4mBUbc8g==", + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-9.3.0.tgz", + "integrity": "sha512-2HZdHwWJ6eB+7lVGSOHsByGw8jeRulT4g0NZ608Wb8Q57DE2jbNqrWPFuJsvkQQiBiTmlpvQL3i+/62zsiPrkw==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.29.2", - "@mui/types": "^9.1.1", + "@babel/runtime": "^7.29.7", + "@mui/types": "^9.3.0", "@types/prop-types": "^15.7.15", "clsx": "^2.1.1", "prop-types": "^15.8.1", - "react-is": "^19.2.6" + "react-is": "^19.2.8" }, "engines": { "node": ">=14.0.0" @@ -5219,9 +5219,9 @@ } }, "node_modules/@tanstack/query-core": { - "version": "5.101.4", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz", - "integrity": "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==", + "version": "5.102.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.102.0.tgz", + "integrity": "sha512-tvBzr11Q7StuMCEsIJdqX8TAWt6WZIzfw/yrSAjObZDerwTTPeCxeLXN2R8ZSn4ZxFpQV819Xn8QmoO+vtnDvw==", "license": "MIT", "funding": { "type": "github", @@ -5229,12 +5229,12 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.101.4", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.4.tgz", - "integrity": "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==", + "version": "5.102.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.102.0.tgz", + "integrity": "sha512-0GyVyEcGt9M7jHPCua16hNVtstUXE2R4HsnNubFYcDs7DJaLzh1dSiXsADDU/cNn/SqrLfa0vRKyjUmbx4rZLA==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.101.4" + "@tanstack/query-core": "5.102.0" }, "funding": { "type": "github", @@ -5283,12 +5283,12 @@ } }, "node_modules/@tanstack/react-virtual": { - "version": "3.13.23", - "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.23.tgz", - "integrity": "sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ==", + "version": "3.14.10", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.10.tgz", + "integrity": "sha512-SRyoUbdFMRHuYXMijV5H4ZarQWpXkj3iANq8OFre+pybeVap8ZJjZ3Nz9bVjx4d8PfobVUQUdKyyyHYk3E+djw==", "license": "MIT", "dependencies": { - "@tanstack/virtual-core": "3.13.23" + "@tanstack/virtual-core": "3.17.8" }, "funding": { "type": "github", @@ -5326,9 +5326,9 @@ } }, "node_modules/@tanstack/virtual-core": { - "version": "3.13.23", - "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.23.tgz", - "integrity": "sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==", + "version": "3.17.8", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.8.tgz", + "integrity": "sha512-BfEvehNpOT75r5Ksc5xW6NZuXujTfb7nlSEyVu4XHG3gdxNg1KqXruWbDewXOUaUYIo4oRbSfkjIajz4MAT8tA==", "license": "MIT", "funding": { "type": "github", @@ -14225,9 +14225,9 @@ } }, "node_modules/react-is": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz", - "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz", + "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==", "license": "MIT" }, "node_modules/react-markdown": { diff --git a/frontend/package.json b/frontend/package.json index 71a71661e7..877c9f1b3b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -38,7 +38,7 @@ "@mantine/dates": "^8.3.1", "@mantine/dropzone": "^8.3.1", "@mantine/hooks": "^8.3.1", - "@mui/icons-material": "^9.2.0", + "@mui/icons-material": "^9.3.1", "@mui/material": "^9.0.0", "@posthog/react": "^1.8.2", "@reactour/tour": "^3.8.0", @@ -46,9 +46,9 @@ "@stripe/stripe-js": "^9.10.0", "@supabase/supabase-js": "^2.47.13", "@tailwindcss/postcss": "^4.1.13", - "@tanstack/react-query": "^5.101.4", + "@tanstack/react-query": "^5.102.0", "@tanstack/react-table": "^9.1.2", - "@tanstack/react-virtual": "^3.13.12", + "@tanstack/react-virtual": "^3.14.10", "@tauri-apps/api": "^2.10.1", "@tauri-apps/plugin-dialog": "2.7.0", "@tauri-apps/plugin-fs": "2.5.0", @@ -109,7 +109,7 @@ ] }, "devDependencies": { - "@iconify-json/material-symbols": "^1.2.83", + "@iconify-json/material-symbols": "^1.2.89", "@iconify/react": "^6.0.2", "@iconify/utils": "^3.1.4", "@playwright/test": "^1.55.0", diff --git a/scripts/lint/comment-lint-hook.mjs b/scripts/lint/comment-lint-hook.mjs new file mode 100644 index 0000000000..af573c6a91 --- /dev/null +++ b/scripts/lint/comment-lint-hook.mjs @@ -0,0 +1,119 @@ +#!/usr/bin/env node +// Claude Code Stop hook: check the comments this turn wrote, before it ends. +// +// Wired up by .claude/settings.json. Exit 2 stops Claude from finishing and shows +// stderr to it, so the comment is fixed inside the same turn and never reaches a +// diff, a CI run, or a reviewer. +// +// Stop rather than PostToolUse, measured over 605 real turns: a run costs the same +// whether it looks at one file or twenty-five, because node startup and one git +// diff dominate and the TS engine is spawned once for the batch. Per write it was +// 40 minutes of hook latency across those turns, and up to 35 seconds inside a +// single heavy one; per turn it is under a second, flat. Half of all writes were +// to a file already written that turn, so most of that work was repeated. +// +// To turn it off, set COMMENT_LINT_HOOK=0. Claude Code has no way to disable one +// hook (only disableAllHooks, which turns off everyone's), so the opt-out lives +// here instead. Per developer, in .claude/settings.local.json: +// +// { "env": { "COMMENT_LINT_HOOK": "0" } } +// +// The commit-time gate still applies either way, so opting out costs you the +// early warning, not the check. + +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO = resolve(HERE, "..", ".."); + +// Invoked through Task so the taskfile stays the one place that defines how the +// linter is called. +const TASK_NAME = "pre-commit:comment-lint:hook"; +const OFF = new Set(["0", "off", "false", "no"]); + +// The linter's own exit codes: 1 when it found something, 2 when its engine could +// not run. The hook's codes mean different things, so they are mapped explicitly. +const FOUND = 1; +const ENGINE_BROKEN = 2; + +if (OFF.has((process.env.COMMENT_LINT_HOOK ?? "").toLowerCase())) process.exit(0); + +const payload = readStdin(); + +// Blocking the stop puts Claude back to work, which ends in another stop and +// another chance to block. Claude Code sets this flag once a Stop hook has +// already blocked in this turn, so a rule the agent cannot satisfy costs one +// extra attempt rather than looping. The commit gate still catches whatever +// survives. +if (payload?.stop_hook_active) process.exit(0); + +const result = run(); +if (result.status === 0) process.exit(0); + +if (result.status === ENGINE_BROKEN) { + process.stderr.write("comment-lint could not run, so comments in this turn were not checked.\n"); + process.exit(1); +} + +// Anything else is Task itself failing, which means the check did not happen. +if (result.status !== FOUND) { + process.stderr.write(`comment-lint did not run (task exit ${result.status}), so comments in this turn were not checked.\n`); + process.exit(1); +} + +// The linter's own report already names the file, line, rule and the standard, so +// it is passed through rather than rewritten. +process.stderr.write(`${result.output.trim()}\n\nFix these before finishing.\n`); +process.exit(2); + +function readStdin() { + try { + return JSON.parse(readFileSync(0, "utf8")); + } catch { + return null; + } +} + +// `task` on PATH is a shell wrapper that boots Node to launch the Go binary the +// npm package already ships, which costs about 400ms. Prefer the binary; fall +// back to the wrapper when the layout is not one of the ones probed, or when Task +// came from somewhere else entirely. +function taskCommand() { + const nodeDir = dirname(process.execPath); + const exe = process.platform === "win32" ? "task.exe" : "task"; + const candidates = [ + resolve(nodeDir, "node_modules/@go-task/cli/bin", exe), + resolve(nodeDir, "../lib/node_modules/@go-task/cli/bin", exe), + resolve(REPO, "node_modules/@go-task/cli/bin", exe), + ]; + for (const candidate of candidates) { + if (existsSync(candidate)) return { command: candidate, shell: false }; + } + return { command: process.platform === "win32" ? "task.cmd" : "task", shell: process.platform === "win32" }; +} + +function run() { + const { command, shell } = taskCommand(); + try { + // --output=interleaved because the root Taskfile sets `output: prefixed`, + // which would put the task name in front of every reported finding. + // --exit-code because Task otherwise reports its own 201 for any failed task, + // which hides whether the linter found something or could not run. + // Task's stderr is captured rather than inherited: it announces its own + // "Failed to run task" for any non-zero command, which would reach Claude + // alongside the findings and read as a tooling error. + const output = execFileSync(command, [TASK_NAME, "--silent", "--output=interleaved", "--exit-code"], { + cwd: REPO, + encoding: "utf8", + maxBuffer: 1 << 26, + shell, + stdio: ["ignore", "pipe", "pipe"], + }); + return { status: 0, output }; + } catch (error) { + return { status: error.status ?? -1, output: error.stdout ?? "" }; + } +} diff --git a/scripts/lint/comment-lint-oxlint-plugin.mjs b/scripts/lint/comment-lint-oxlint-plugin.mjs new file mode 100644 index 0000000000..045b23518b --- /dev/null +++ b/scripts/lint/comment-lint-oxlint-plugin.mjs @@ -0,0 +1,126 @@ +// oxlint JS plugin: the comment-quality rules for .ts and .tsx. +// +// This engine owns the frontend outright; comment-lint.mjs never scans TS. The +// reason to run oxlint here rather than scan lines is that comment tokens come +// from the parser, so a `//` inside a string or regex is not a comment, JSX +// `{/* … */}` is, and positions are exact. Rule decisions themselves live in +// comment-rules.mjs, shared with the Java/Python engine. +// +// Reported as one rule with the CMT id in the message, because oxlint config +// severity is per rule name and every finding here shares one on/off switch. +// +// Enabled by frontend/oxlint.comments.config.ts. `context.report` needs +// `node.range`; passing start/end throws. + +import { analyse, isTestPath, isGenerated, isExcludedPath, ruleLabel } from "./comment-rules.mjs"; + +const comments = { + create(context) { + return { + "Program:exit"() { + const sourceCode = context.sourceCode; + const filename = context.filename ?? context.getFilename?.() ?? ""; + if (isExcludedPath(filename)) return; + + const text = sourceCode.text; + if (isGenerated(text)) return; + + const lines = sourceCode.getLines(); + const tokens = sourceCode.getAllComments(); + if (tokens.length === 0) return; + + const runs = groupIntoRuns(tokens, lines); + const findings = analyse({ lines, runs, isTestFile: isTestPath(filename) }); + + // Ranges come from the run entries rather than the enclosing token, so a + // finding on the eighth line of a doc block points at that line instead + // of at the opening `/**`. + const ranges = new Map(); + for (const run of runs) { + for (const entry of run.lines) ranges.set(entry.line, entry.range); + } + + for (const finding of findings) { + context.report({ + message: `${ruleLabel(finding.rule)}: ${finding.detail}`, + node: { type: "Line", range: ranges.get(finding.line) ?? [0, 1] }, + }); + } + }, + }; + }, +}; + +// Adjacent comment lines with no code between them form one run, which is the +// unit the block-length and dead-code rules judge. A comment sharing its line +// with code is a trailing note, not part of any run. +function groupIntoRuns(tokens, lines) { + const runs = []; + let current = null; + + for (const token of tokens) { + const entries = expand(token, lines); + if (entries.length === 0) continue; + + const kind = token.type === "Line" ? "line" : token.value.startsWith("*") ? "doc" : "block"; + const startLine = entries[0].line; + const trailing = entries[0].trailing === true; + const contiguous = !trailing && current && startLine === current.endLine + 1 && current.kind === kind && !current.trailing; + + if (contiguous) { + current.lines.push(...entries); + current.endLine = entries[entries.length - 1].line; + continue; + } + current = { startLine, endLine: entries[entries.length - 1].line, kind, trailing, lines: entries }; + runs.push(current); + + // Code sits in front of a trailing comment, so nothing can continue it. + if (trailing) current = null; + } + + return runs; +} + +// One entry per physical line, with the leading `*` of a doc block stripped so +// the rules see the prose rather than the box drawing around it. Each entry +// carries its own source range so findings can be reported where they are. +function expand(token, lines) { + const start = token.loc.start.line; + const column = token.loc.start.column + 1; + const before = (lines[start - 1] ?? "").slice(0, token.loc.start.column).trim(); + + // Code in front of the comment makes it a trailing note. Marked rather than + // dropped, so CMT004 and CMT009 still see it: a TODO is a TODO wherever it + // sits. The rules that compare a comment against the code below it stay out, + // because a trailing comment usually decodes the line it sits on. + // + // A block comment counts as trailing only when it also closes on that line. + // One that runs on has its bulk on lines of its own, so it is judged as the + // block it is. + const sameLine = token.loc.start.line === token.loc.end.line; + const trailing = before.length > 0 && !before.startsWith("{") && (token.type === "Line" || sameLine); + + if (token.type === "Line") { + return [{ line: start, column, body: token.value, range: token.range, trailing }]; + } + + // token.value is the text between the delimiters, so it begins two chars in. + let offset = token.range[0] + 2; + return token.value.split("\n").map((raw, index) => { + const range = [offset, offset + Math.max(raw.length, 1)]; + offset += raw.length + 1; + return { + line: start + index, + column: index === 0 ? column : 1, + body: raw.replace(/^\s*\*+/, "").trim(), + range, + trailing, + }; + }); +} + +export default { + meta: { name: "comments" }, + rules: { quality: comments }, +}; diff --git a/scripts/lint/comment-lint.mjs b/scripts/lint/comment-lint.mjs new file mode 100644 index 0000000000..55b0cb4039 --- /dev/null +++ b/scripts/lint/comment-lint.mjs @@ -0,0 +1,743 @@ +#!/usr/bin/env node + +// comment-lint - the comment-quality gate. Standard: devGuide/CODE_COMMENTS.md +// +// Owns .java and engine .py directly, and delegates .ts/.tsx to oxlint (see +// comment-lint-oxlint-plugin.mjs) so the frontend is judged against real comment +// tokens rather than lines. Both paths share the rules in comment-rules.mjs, so +// a finding means the same thing whichever engine produced it. +// +// node scripts/lint/comment-lint.mjs default: everything this +// working tree adds over HEAD, +// or over the target branch on CI +// node scripts/lint/comment-lint.mjs --since main findings on lines this branch added +// node scripts/lint/comment-lint.mjs --all whole tree, report only, never fails +// node scripts/lint/comment-lint.mjs those files, every line +// node scripts/lint/comment-lint.mjs --selftest run the fixture corpus +// --quiet ...saying nothing unless it fails +// node scripts/lint/comment-lint.mjs --json machine-readable findings +// +// Exits non-zero for any finding on a line in scope: every rule blocks, because a +// warning is a finding nobody acts on. --all never fails, because the tree still +// has a backlog; it is the mode for working through it. + +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { dirname, isAbsolute, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + analyse, + commentBodiesOf, + normaliseComment, + isExcludedPath, + isGenerated, + isTestPath, + ruleLabel, + RULES, +} from "./comment-rules.mjs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO = resolve(HERE, "..", ".."); +const FRONTEND = join(REPO, "frontend"); + +// The Python this repo owns and formats: the engine service, plus the helper +// scripts that pre-commit already runs ruff over. Vendored and sample .py +// elsewhere in the tree is not ours to restyle. +const JAVA = /\.java$/; +const PYTHON = /^(engine|scripts|\.github\/scripts)\/.*\.py$/; + +// The oxlint engine parses plain JS as happily as TS, so the lint scripts and +// build tooling are held to the same rules as the app. +const TYPESCRIPT = /\.(tsx?|mts|cts|mjs|cjs|jsx?)$/; + +const FIXTURES_REL = "scripts/lint/fixtures/"; + +// oxlint rejects any path containing "..", so it is always run from the repo +// root and given repo-relative paths. Its config still lives under frontend/, +// which is what makes `oxlint` and the plugin resolvable from there. +const OXLINT_BIN = "frontend/node_modules/oxlint/bin/oxlint"; +const OXLINT_CONFIG = "frontend/oxlint.comments.config.ts"; + +// Windows caps a command line near 32k characters, which a whole-tree file list +// exceeds by a wide margin. Unbatched it dies with ENAMETOOLONG, and silently: +// oxlint exits non-zero normally, so the error reads as "no findings". +const ARGV_BUDGET = 24_000; + +// Every module constant lives in this block. The top-level run below starts +// before any function body is reached, so a `const` declared further down is +// still in its temporal dead zone when the first call touches it. +const baseComments = new Map(); + +// A Java char literal, which is the only thing a single quote can legitimately +// open: one character, or one escape. An apostrophe in prose never matches, so +// `/** The approver's team ... */` keeps its closing delimiter. Without this the +// apostrophe opened a literal that never closed, the `*/` was blanked away, and +// the scanner read the next 47 lines of code as one comment. +const CHAR_LITERAL = /^'(\\[btnfr'"\\0]|\\u[0-9a-fA-F]{4}|[^'\\])'/; + +// A docstring opens the line, optionally behind a string prefix. Anything with +// code in front of the quotes is a value, not documentation. +const DOCSTRING_OPEN = /^[rbuf]{0,2}("""|''')/; + +const argv = process.argv.slice(2); +const flags = new Set(argv.filter((a) => a.startsWith("--"))); +const positional = argv.filter((a) => !a.startsWith("--") && !isFlagValue(a)); + +if (flags.has("--selftest")) process.exit(runSelfTest()); +if (flags.has("--help")) { + process.stdout.write( + readFileSync(fileURLToPath(import.meta.url), "utf8") + .split("\n") + .slice(1, 22) + .join("\n") + .replace(/^\/\/ ?/gm, "") + "\n", + ); + process.exit(0); +} + +const scope = resolveScope(); +const findings = collect(scope); +process.exit(publish(findings, scope)); + +// A "scope" is the set of files to look at plus, when the run is diff-based, the +// set of lines that are new. Reporting a legacy finding in a file someone merely +// touched is how a gate like this gets switched off, so diff runs filter by line. + +function resolveScope() { + if (flags.has("--all")) return { mode: "all", files: trackedFiles(), added: null }; + + const paths = positional.map(toRepoPath); + + // Paths plus --since is how the editor hook asks about one file: lint it, but + // only the lines this session actually wrote. + if (paths.length > 0 && flags.has("--since")) { + const ref = mergeBase(flagValue("--since")); + return narrow(diffScope(["diff", "--unified=0", "--no-color", ref, "--", ...paths], ref, paths), paths); + } + if (paths.length > 0) return { mode: "paths", files: paths, added: null }; + + if (flags.has("--since")) { + const ref = mergeBase(flagValue("--since")); + return diffScope(["diff", "--unified=0", "--no-color", ref], ref); + } + + // Always a working-tree comparison, never `--cached`. Findings are read from + // the file on disk, so diffing the index instead would pair index line numbers + // with working-tree content and silently mismatch once the two differ. + // CI knows the target branch; a developer running this before a commit does not. + const base = process.env.GITHUB_BASE_REF; + const ref = mergeBase(base ? `origin/${base}` : "HEAD"); + return diffScope(["diff", "--unified=0", "--no-color", ref], ref); +} + +function mergeBase(ref) { + try { + return git(["merge-base", "HEAD", ref]).trim(); + } catch { + // A shallow clone or a missing remote ref: compare against the ref itself. + return ref; + } +} + +function diffScope(args, base, paths = null) { + let diff; + try { + diff = git(args); + } catch (error) { + // A shallow clone, a detached CI checkout, or a base branch that was never + // fetched. Degrading to report-only beats failing a build over plumbing. + warn(`could not resolve a diff (${firstLine(error.stderr ?? error.message)}), so nothing was checked.`); + return { mode: "all", files: [], added: null }; + } + + const added = new Map(); + let file = null; + for (const line of diff.split("\n")) { + if (line.startsWith("+++ ")) { + const path = line.slice(4).replace(/^b\//, "").trim(); + file = path === "/dev/null" ? null : path; + if (file) added.set(file, new Set()); + continue; + } + if (!file || !line.startsWith("@@")) continue; + const hunk = /\+(\d+)(?:,(\d+))?/.exec(line); + if (!hunk) continue; + const start = Number(hunk[1]); + const count = hunk[2] === undefined ? 1 : Number(hunk[2]); + for (let i = 0; i < count; i++) added.get(file).add(start + i); + } + + // git diff never mentions an untracked file, so a brand new one would be waved + // through entirely. Every line of one is new. Listing every untracked file in + // the repo costs about as much as the diff, so when the caller already named the + // paths, only those are asked about. + for (const file of untrackedFiles(paths)) { + if (added.has(file)) continue; + added.set(file, allLinesOf(file)); + } + + return { mode: "diff", files: [...added.keys()], added, base }; +} + +function untrackedFiles(paths = null) { + const args = ["ls-files", "--others", "--exclude-standard"]; + if (paths) args.push("--", ...paths); + return git(args).split("\n").filter(Boolean); +} + +function allLinesOf(file) { + const path = insideRepo(file); + if (!path || !existsSync(path)) return new Set(); + const total = readFileSync(path, "utf8").split(/\r?\n/).length; + return new Set(Array.from({ length: total }, (_, i) => i + 1)); +} + +function narrow(scope, paths) { + // A diff that could not be resolved already returned a degraded scope with no + // added map. Pass it straight through: an empty map here would read as a clean + // pass rather than as a run that checked nothing. + if (!scope.added) return scope; + + const wanted = new Set(paths); + const added = new Map([...scope.added].filter(([file]) => wanted.has(file))); + return { mode: "diff", files: [...added.keys()], added, base: scope.base }; +} + +function trackedFiles() { + return git(["ls-files"]).split("\n").filter(Boolean); +} + +function toRepoPath(path) { + return relative(REPO, resolve(process.cwd(), path)).replace(/\\/g, "/"); +} + +function collect(scope) { + const selected = scope.files.map((f) => f.replace(/\\/g, "/")).filter(isLintable); + + // A path named on the command line and then dropped has to be said out loud. + // Reporting "clean" for a file this never opened is the failure mode the rest + // of this script works to avoid. + if (scope.mode === "paths") { + for (const file of scope.files) { + if (!selected.includes(file.replace(/\\/g, "/"))) warn(`skipped ${file}: not a lintable file inside the repo.`); + } + } + const results = []; + + for (const file of selected.filter((f) => JAVA.test(f) || PYTHON.test(f))) { + results.push(...lintLineBased(file)); + } + + const ts = selected.filter((f) => TYPESCRIPT.test(f)); + if (ts.length > 0) results.push(...lintTypeScript(ts)); + + if (scope.added) { + const onAddedLine = results.filter((r) => scope.added.get(r.file)?.has(r.line)); + return onAddedLine.filter((r) => !existedAtBase(r, scope.base)); + } + return results; +} + +// git marks a reindented or moved line as added, so line membership alone reports +// comments nobody wrote. A finding only counts if its comment text is not already +// in the file at the base. +// +// Cost is one `git show` per file, memoised. It gets one case wrong: adding a +// further copy of an already-duplicated comment reads as pre-existing. That is the +// right way round for a blocking rule. + +function existedAtBase(finding, base) { + if (!base) return false; + // Findings from the oxlint plugin arrive without their comment text, because + // they cross a process boundary as a message string. Recover it from the file + // on disk at the reported line, which is the same text the rule judged. + const body = finding.body ?? currentLineBody(finding); + if (!body) return false; + const key = `${base}:${finding.file}`; + if (!baseComments.has(key)) { + let source = ""; + try { + source = git(["show", key]); + } catch { + // Not in the base at all, so the whole file is new. + } + baseComments.set(key, commentBodiesOf(source)); + } + return baseComments.get(key).has(body); +} + +function currentLineBody(finding) { + try { + const line = readFileSync(insideRepo(finding.file), "utf8").split(/\r?\n/)[finding.line - 1]; + return line === undefined ? "" : normaliseComment(line); + } catch { + return ""; + } +} + +// Everything this tool reads is named by git or by a developer on the command +// line, so a path outside the repo is a mistake rather than an attack. Resolving +// through here keeps the contract true: git show and git diff cannot answer for a +// path outside the work tree, so escaping it only produces confusing output. +function insideRepo(file) { + const target = resolve(REPO, file); + const rel = relative(REPO, target); + if (rel.length === 0 || rel.startsWith("..") || isAbsolute(rel)) return ""; + return target; +} + +function isLintable(file) { + if (!insideRepo(file)) return false; + if (isExcludedPath(file)) return false; + + // The corpus is deliberately full of findings. Only the selftest reads it, + // and it does so by path rather than through this filter. + if (file.startsWith(FIXTURES_REL)) return false; + if (!JAVA.test(file) && !PYTHON.test(file) && !TYPESCRIPT.test(file)) return false; + return existsSync(insideRepo(file)); +} + +function lintLineBased(file) { + const source = readFileSync(insideRepo(file), "utf8"); + if (isGenerated(source)) return []; + const lines = source.split(/\r?\n/); + const runs = readRuns(lines, PYTHON.test(file) ? "py" : "java"); + return analyse({ lines, runs, isTestFile: isTestPath(file) }).map((f) => ({ ...f, file })); +} + +// Groups comment lines into runs, the same shape the oxlint plugin builds from +// parser tokens. String literals are blanked first so a `//` inside one is not +// mistaken for a comment; without that, every URL in a string became a finding. +function readRuns(lines, language) { + const runs = []; + let current = null; + let inBlock = false; + let docstring = null; + + const push = (index, column, body, kind, trailing = false) => { + const line = index + 1; + if (!trailing && current && current.endLine === line - 1 && current.kind === kind && !current.trailing) { + current.lines.push({ line, column, body }); + current.endLine = line; + return; + } + current = { startLine: line, endLine: line, kind, trailing, lines: [{ line, column, body }] }; + runs.push(current); + if (trailing) current = null; + }; + + for (let i = 0; i < lines.length; i++) { + const raw = lines[i]; + const text = blankStrings(raw, language); + const trimmed = text.trim(); + const column = raw.length - raw.trimStart().length + 1; + + if (inBlock) { + push(i, column, stripDocPrefix(raw), "doc"); + if (trimmed.includes("*/")) inBlock = false; + continue; + } + if (trimmed.length === 0) { + current = null; + continue; + } + if (language === "py") { + // Every triple-quoted string is tracked, not just the documenting ones. + // A template assigned to a constant opens mid-line and so is not + // documentation, but its closing delimiter sits alone on a line and reads + // exactly like an opener. Ignoring those strings desynchronised the + // scanner for the rest of the file, and 35 lines of ordinary code were + // reported as commented-out. + if (docstring) { + if (docstring.isDoc) push(i, column, stripDocstringDelimiters(raw), "doc"); + else current = null; + if (raw.includes(docstring.delimiter)) docstring = null; + continue; + } + if (trimmed.startsWith("#")) { + push(i, column, raw.trim().replace(/^#+/, ""), "line"); + continue; + } + const hashAt = raw.indexOf("#"); + if (hashAt > 0 && !/["']/.test(raw.slice(0, hashAt))) { + const body = raw.slice(hashAt + 1).trim(); + if (body.length > 0) { + push(i, hashAt + 1, body, "line", true); + continue; + } + } + const quoted = tripleQuoted(trimmed); + if (quoted) { + if (quoted.isDoc) push(i, column, stripDocstringDelimiters(raw), "doc"); + else current = null; + if (!quoted.closes) docstring = quoted; + continue; + } + current = null; + continue; + } + if (trimmed.startsWith("/*")) { + push(i, column, stripDocPrefix(raw), trimmed.startsWith("/**") ? "doc" : "block"); + if (!trimmed.includes("*/")) inBlock = true; + continue; + } + if (trimmed.startsWith("//")) { + push(i, column, raw.trim().replace(/^\/\/+/, ""), "line"); + continue; + } + + // Code first, then a comment. blankStrings has already neutralised any `//` + // inside a string literal, so this index is a real comment marker. A block + // comment counts here only when it also closes on this line, matching the + // oxlint engine: one that runs on has its bulk on lines of its own. + const trailingLine = text.indexOf("//"); + const trailingBlock = text.indexOf("/*"); + const at = + trailingLine > 0 ? trailingLine : trailingBlock > 0 && text.includes("*/", trailingBlock) ? trailingBlock : -1; + if (at > 0) { + const body = raw + .slice(at + 2) + .replace(/\*\/.*$/, "") + .trim(); + if (body.length > 0) { + push(i, at + 1, body, "line", true); + continue; + } + } + current = null; + } + + return runs; +} + +// The prose inside a docstring line, with the triple quotes and any string +// prefix taken off so the rules see what a reader sees. +// Where a triple-quoted string starts on this line, and whether it counts as +// documentation. It documents when the quotes open the line, allowing a string +// prefix; a template assigned to a constant opens mid-line and is data, and +// reading JSON as prose would judge its keys as comments. An odd number of +// delimiters means the string continues onto the next line. +function tripleQuoted(trimmed) { + const found = /("""|''')/.exec(trimmed); + if (!found) return null; + const delimiter = found[1]; + const occurrences = trimmed.split(delimiter).length - 1; + return { + delimiter, + isDoc: DOCSTRING_OPEN.test(trimmed), + closes: occurrences % 2 === 0, + }; +} + +function stripDocstringDelimiters(raw) { + return raw + .trim() + .replace(/^[rbuf]{0,2}("""|''')/, "") + .replace(/("""|''')\s*$/, "") + .trim(); +} + +function stripDocPrefix(raw) { + return raw + .trim() + .replace(/^\/\*+/, "") + .replace(/\*+\/$/, "") + .replace(/^\*+/, "") + .trim(); +} + +// Replaces the contents of string and char literals with spaces, preserving +// length so columns stay correct. Escapes are honoured so "\"" does not end it. +function blankStrings(line, language) { + if (language === "py") return line; + let out = ""; + let quote = null; + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (quote) { + if (ch === "\\") { + out += " "; + i++; + continue; + } + out += ch === quote ? ch : " "; + if (ch === quote) quote = null; + continue; + } + if (ch === '"') { + quote = ch; + out += ch; + continue; + } + if (ch === "'") { + const literal = CHAR_LITERAL.exec(line.slice(i)); + if (!literal) { + // Prose, not a literal. Leave it alone. + out += ch; + continue; + } + out += `'${" ".repeat(literal[0].length - 2)}'`; + i += literal[0].length - 1; + continue; + } + if (ch === "/" && line[i + 1] === "/") return out + line.slice(i); + out += ch; + } + return out; +} + +function lintTypeScript(files) { + if (!existsSync(join(REPO, OXLINT_BIN))) { + warn("frontend/node_modules/oxlint is missing, so TS/TSX was skipped. Run `task frontend:install`."); + return []; + } + return batch(files, ARGV_BUDGET).flatMap(runOxlint); +} + +function batch(files, budget) { + const batches = []; + let current = []; + let size = 0; + for (const file of files) { + if (current.length > 0 && size + file.length + 1 > budget) { + batches.push(current); + current = []; + size = 0; + } + current.push(file); + size += file.length + 1; + } + if (current.length > 0) batches.push(current); + return batches; +} + +function runOxlint(files) { + let stdout; + try { + // Invoked as `node ` rather than through npx: spawning a .cmd shim on + // Windows fails with EINVAL unless a shell is used, and a shell would mean + // quoting every path. It must also be the npm package rather than the + // standalone release binary, which accepts a jsPlugins config, skips loading + // it, and still reports success (oxc-project/oxc#25203). + stdout = execFileSync(process.execPath, [OXLINT_BIN, "--config", OXLINT_CONFIG, "--format=json", ...files], { + cwd: REPO, + encoding: "utf8", + maxBuffer: 1 << 28, + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (error) { + // oxlint exits non-zero whenever it reports something, which is the normal case. + stdout = error.stdout ?? ""; + if (!stdout.trim()) { + die(`oxlint failed on ${files.length} file(s): ${firstLine(error.stderr ?? error.message)}`); + } + } + + return parseOxlint(stdout); +} + +function firstLine(value) { + return value.toString().trim().split(/\r?\n/)[0]; +} + +function parseOxlint(stdout) { + const start = stdout.indexOf("{"); + if (start < 0) die("oxlint produced no JSON report."); + let report; + try { + report = JSON.parse(stdout.slice(start)); + } catch { + die("could not parse oxlint JSON output."); + } + + // JS plugins are alpha, and their documented failure mode is being skipped + // silently while oxlint still reports success (oxc-project/oxc#25203). + // number_of_rules is the report saying whether the plugin's rule was actually + // registered. Without this check a dead plugin reads exactly like clean code. + if ((report.number_of_rules ?? 0) < 1) { + die("oxlint loaded no rules, so the comment plugin did not run. Refusing to report a pass."); + } + + return (report.diagnostics ?? []).flatMap((d) => { + const parsed = /^(CMT\d{3})\s+(\S+):\s*(.*)$/.exec(d.message); + if (!parsed) return []; + const [, rule, , detail] = parsed; + const span = d.labels?.[0]?.span; + return [ + { + file: d.filename.replace(/\\/g, "/"), + line: span?.line ?? 1, + column: span?.column ?? 1, + rule, + detail, + severity: RULES[rule].severity, + }, + ]; + }); +} + +function publish(findings, scope) { + if (flags.has("--json")) { + process.stdout.write(`${JSON.stringify({ mode: scope.mode, findings }, null, 2)}\n`); + return 0; + } + + if (findings.length === 0) { + process.stdout.write(`comment-lint: clean (${scope.files.length} file${scope.files.length === 1 ? "" : "s"} in scope)\n`); + return 0; + } + + const byFile = new Map(); + for (const f of findings) { + if (!byFile.has(f.file)) byFile.set(f.file, []); + byFile.get(f.file).push(f); + } + + for (const [file, group] of [...byFile.entries()].sort()) { + process.stdout.write(`\n${file}\n`); + for (const f of group.sort((a, b) => a.line - b.line)) { + process.stdout.write(` ${String(f.line).padStart(5)} ${ruleLabel(f.rule)} ${f.detail}\n`); + } + } + + process.stdout.write( + `\ncomment-lint: ${findings.length} finding${findings.length === 1 ? "" : "s"} across ${byFile.size} file${byFile.size === 1 ? "" : "s"}\n`, + ); + + if (scope.mode === "all") { + process.stdout.write("Report-only mode: --all never fails, so the standing backlog can be worked through in chunks.\n"); + return 0; + } + + process.stdout.write( + "\nThe standard is devGuide/CODE_COMMENTS.md. A comment must carry information the\n" + + "code cannot; if a reader could derive it from the code in front of them, delete it.\n" + + "If a finding is genuinely wrong, put `comment-lint-allow: CMT00X` on the line above.\n", + ); + return 1; +} + +function warn(message) { + process.stderr.write(`comment-lint: ${message}\n`); +} + +// A gate that cannot run must not report a pass. Reserved for the engine being +// broken, as opposed to absent: a missing oxlint install is handled by skipping +// with a warning, so the hook stays usable before `task frontend:install`. +function die(message) { + process.stderr.write(`comment-lint: ${message}\n`); + process.exit(2); +} + +// The fixture corpus is the contract between the two engines: the same rule set +// applied to .java/.py by the line scanner and to .ts/.tsx by oxlint, with +// fixtures/expected.json asserting what each file should produce. +// +// Expectations live outside the fixtures on purpose. An in-file marker would sit +// inside the very comment under test, changing its word count and its run +// length, so the fixture would stop being an example of the real thing. +// +// --selftest compare against expected.json +// --selftest --update rewrite expected.json from current behaviour +// +// A rule change is meant to show up as a reviewable diff in expected.json. + +function runSelfTest() { + const dir = join(HERE, "fixtures"); + const expectedPath = join(dir, "expected.json"); + const files = readdirSync(dir) + .filter((f) => /\.(java|py|ts|tsx)$/.test(f)) + .sort(); + if (files.length === 0) { + warn("no fixtures found"); + return 1; + } + + const asRepoPath = (name) => relative(REPO, join(dir, name)).replace(/\\/g, "/"); + const tsFixtures = files.filter((f) => TYPESCRIPT.test(f)); + const tsFindings = tsFixtures.length > 0 ? lintTypeScript(tsFixtures.map(asRepoPath)) : []; + + // A skipped engine looks exactly like a clean engine in the snapshot, so + // refuse to record or compare rather than baking in a false pass. + if (tsFixtures.length > 0 && tsFindings.length === 0) { + warn("the TS engine produced nothing, so it did not run. Install frontend deps first."); + return 1; + } + + const actual = {}; + for (const name of files) { + const found = TYPESCRIPT.test(name) + ? tsFindings.filter((f) => f.file.endsWith(`/${name}`)) + : lintLineBased(asRepoPath(name)); + actual[name] = found + .map((f) => `${f.line}:${f.rule}:${f.severity}`) + .sort((a, b) => Number(a.split(":")[0]) - Number(b.split(":")[0])); + } + + if (flags.has("--update")) { + writeFileSync(expectedPath, `${JSON.stringify(actual, null, 2)}\n`); + process.stdout.write( + `comment-lint selftest: recorded ${Object.keys(actual).length} fixtures to ${relative(REPO, expectedPath)}\n`, + ); + return 0; + } + + if (!existsSync(expectedPath)) { + warn("fixtures/expected.json is missing. Run --selftest --update to record it."); + return 1; + } + + // --quiet says nothing unless something is wrong. It is how the lint tasks run + // the corpus first without burying their own output under eleven ok lines. + const quiet = flags.has("--quiet"); + const expected = JSON.parse(readFileSync(expectedPath, "utf8")); + let failures = 0; + for (const name of files) { + const want = (expected[name] ?? []).join(" | "); + const got = actual[name].join(" | "); + if (want === got) { + if (!quiet) process.stdout.write(`ok ${name} (${actual[name].length})\n`); + continue; + } + failures++; + process.stdout.write(`FAIL ${name}\n expected: ${want || "(nothing)"}\n actual: ${got || "(nothing)"}\n`); + } + + const stale = Object.keys(expected).filter((n) => !files.includes(n)); + for (const name of stale) { + failures++; + process.stdout.write(`FAIL ${name} is in expected.json but the fixture is gone\n`); + } + + if (failures > 0) { + process.stdout.write( + `\ncomment-lint selftest: ${failures} fixture(s) differ. If intended, rerun with --update and review the diff.\n`, + ); + return 1; + } + if (!quiet) process.stdout.write("\ncomment-lint selftest: both engines match the corpus\n"); + return 0; +} + +function isFlagValue(arg) { + const index = argv.indexOf(arg); + return index > 0 && argv[index - 1] === "--since"; +} + +function flagValue(flag) { + const index = argv.indexOf(flag); + return argv[index + 1] ?? "origin/main"; +} + +function git(args) { + // stderr is captured rather than inherited so git's line-ending advice ("CRLF + // will be replaced by LF") does not print once per file on Windows. Real + // failures still surface: execFileSync throws, and the caller reads .stderr. + return execFileSync("git", args, { + cwd: REPO, + encoding: "utf8", + maxBuffer: 1 << 28, + stdio: ["ignore", "pipe", "pipe"], + }); +} diff --git a/scripts/lint/comment-rules.mjs b/scripts/lint/comment-rules.mjs new file mode 100644 index 0000000000..4211be9689 --- /dev/null +++ b/scripts/lint/comment-rules.mjs @@ -0,0 +1,500 @@ +// The comment-quality rule set, shared by both engines so a rule means the same +// thing everywhere: the oxlint JS plugin (which owns .ts/.tsx, and has real +// comment tokens and an AST) and comment-lint.mjs (which owns .java and .py, and +// has only lines). Neither engine ever scans the other's files, so the two can +// differ in precision without producing contradictory findings on one file. +// +// The standard these rules enforce is devGuide/CODE_COMMENTS.md. Changing a rule +// here without changing that document leaves the repo with two answers. +// +// Between them the engines read every comment form the repo writes: // and /* */, +// Javadoc and JSDoc, JSX comments, # and Python docstrings. + +export const SEVERITY = { ERROR: "error", WARN: "warn" }; + +// Every rule blocks. A rule that only warns is a rule nobody acts on, so a +// finding that turns out to be wrong is a bug in the rule: narrow it, or mark the +// line with comment-lint-allow and say why. Each rule below carries the readings +// it deliberately excludes, which is where to start when one misfires. +export const RULES = { + CMT001: { name: "restates-code", severity: SEVERITY.ERROR }, + CMT002: { name: "banner", severity: SEVERITY.ERROR }, + CMT003: { name: "step-narration", severity: SEVERITY.ERROR }, + CMT004: { name: "diff-narration", severity: SEVERITY.ERROR }, + CMT005: { name: "dead-code", severity: SEVERITY.ERROR }, + CMT006: { name: "block-too-long", severity: SEVERITY.ERROR }, + CMT007: { name: "doc-restates-signature", severity: SEVERITY.ERROR }, + CMT008: { name: "bad-allow", severity: SEVERITY.ERROR }, + CMT009: { name: "unowned-todo", severity: SEVERITY.ERROR }, +}; + +export const MAX_BLOCK_LINES = 12; + +// CMT001 compares a comment against the code it introduces. Both sides are +// reduced to the same shape first: lowercased, camel/snake/kebab split into +// words, stop words and short words dropped. What survives is the information +// each side actually carries, so "Handle drag start" and `handleDragStart` land +// on the same set and the comment is shown to add nothing. + +const STOP_WORDS = new Set( + ( + "a an the and or but if then else for to of in on at by with from into is are be was were this that these those it its as we our you your do" + + " does done use uses used using will would should can could may might not no yes new only also just so such via per each all any some more" + + " most other another same when while where which what who how why here there now next finally first second third let const var function" + + " return set get" + ).split(" "), +); + +const WORD_RE = /[a-z][a-z0-9]*/g; + +export function contentWords(text) { + return (text.toLowerCase().match(WORD_RE) ?? []).filter((w) => w.length > 2 && !STOP_WORDS.has(w)); +} + +export function identWords(text) { + const split = text.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[_\-.]/g, " "); + return contentWords(split); +} + +// Sentence punctuation marks prose, which is usually saying something the code +// does not. A single trailing full stop does not count. +const PROSE_PUNCT = /[.;:?!]/; +const MAX_RESTATE_WORDS = 6; + +// Arrange/Act/Assert and Given/When/Then label the shape of a test rather than +// describe the line beneath. Exempt only as a bare marker, so +// `// Assert the cap is clamped to the tier maximum` is prose and judged on its +// merits. +const TEST_STRUCTURE = /^(arrange|act|assert|given|when|then)\b/i; +const MAX_MARKER_WORDS = 4; + +export function restatesCode(body, codeText) { + if (TEST_STRUCTURE.test(body.trim()) && body.trim().split(/\s+/).length <= MAX_MARKER_WORDS) return false; + if (PROSE_PUNCT.test(body.replace(/\.$/, ""))) return false; + const comment = contentWords(body); + if (comment.length === 0 || comment.length > MAX_RESTATE_WORDS) return false; + const code = identWords(codeText); + if (code.length === 0) return false; + + // Prefix matching either way, so "config" covers "configuration" and vice versa. + return comment.every((w) => code.some((k) => k.startsWith(w) || w.startsWith(k))); +} + +const RULE_CHARS = /^[=~_*#+\-]{4,}|[─-╿]{4,}|[=~_*+]{4,}$/; +const SECTION_LABEL = new RegExp( + "^(imports?|exports?|types?|interfaces?|constants?|config|helpers?|utils?|utilities|state|handlers?|callbacks?|effects?" + + "|render|rendering|styles?|props?|hooks?|setup|teardown|cleanup|main|public|private|internal|api|queries|mutations" + + "|selectors?|actions?|reducers?|components?|fields?|getters?|setters?|lifecycle|boilerplate)" + + "\\s*(section|area|block)?\\s*$", + "i", +); + +export function isBanner(body) { + if (RULE_CHARS.test(body.trim())) return true; + + // A label wrapped in decoration is still a label: strip the decoration first. + const bare = body + .replace(/[=~_*#+\-─-╿]/g, " ") + .replace(/\s+/g, " ") + .trim(); + return bare.length > 0 && SECTION_LABEL.test(bare); +} + +// A bare "1." is not narration: numbered lists are how a doc block enumerates +// conditions or alternatives, and matching them buries the rule in false +// positives. Only the explicit step form and sequencing adverbs qualify, and the +// number needs a separator after it, so a wrapped line beginning "step 2 unmounts +// + remounts the panel" reads as the prose it is. +const STEP = /^(step\s*\d+(\.\d+)?\s*[:.)\-]|(then|next|finally|afterwards|lastly)\s*[,:]\s+\S)/i; + +export function isStepNarration(body) { + return STEP.test(body.trim()); +} + +// Only phrases that can be talking about the code's own past. Excluded because +// each has an innocent reading that fires constantly: +// "used to" alone - "Used to clamp the live line" means "is used to" +// "previously" alone - "re-show even if previously dismissed" is runtime state +// "was called" - collides with "verify getSession was called" +// "left over from" - "no cards left over from the unfiltered grid" +const DIFF_NARRATION = new RegExp( + "\\b((this|it|we|they|that) used to|used to (be|live|sit)" + + "|(this|it|that|which|the (class|method|field|code|file|module|palette|banner)) is no longer (needed|used)" + + "|renamed from|was (previously|formerly) (called|named|known)" + + "|instead of the old|has been (removed|replaced) )", + "i", +); +const REMOVAL_SUFFIX = /(^|\s)[-(—]\s*(removed|deleted|dropped|no longer needed)\s*\)?\s*$/i; + +export function isDiffNarration(body) { + const t = body.trim(); + return DIFF_NARRATION.test(t) || REMOVAL_SUFFIX.test(t); +} + +const CODE_KEYWORD = new RegExp( + "^(import|package|public|private|protected|static|final|abstract|class|interface|enum|record|extends|implements" + + "|def|async|await|const|let|var|function|export|return|if|else|elif|for|while|do|try|catch|finally|switch|case" + + "|throw|new|super|this|@[A-Za-z])\\b", +); +const STATEMENT_TAIL = /[;{}]\s*$/; +const CALL_ONLY = /^[\w.$]+\s*\([^)]*\)\s*;?\s*$/; +const ASSIGNMENT = /\S\s*=\s*\S/; + +export function looksLikeCode(line) { + const t = line.trim(); + if (t.length === 0) return false; + if (CODE_KEYWORD.test(t)) return true; + if (CALL_ONLY.test(t)) return true; + return STATEMENT_TAIL.test(t) && ASSIGNMENT.test(t); +} + +export const MIN_DEAD_CODE_RUN = 3; +const DEAD_CODE_SHARE = 2 / 3; + +export function isDeadCodeRun(bodies) { + if (bodies.length < MIN_DEAD_CODE_RUN) return false; + const codeish = bodies.filter(looksLikeCode).length; + return codeish / bodies.length >= DEAD_CODE_SHARE; +} + +// Every documented-parameter form this repo writes, so the rule is not quietly +// Javadoc-only: +// Javadoc / JSDoc @param blob The blob to download +// Sphinx :param blob: The blob to download +// Google docstring blob: The blob to download (under an Args: heading) +// NumPy style is deliberately absent: it splits the name and the description +// across two lines, and there is one instance of it in the tree. +const PARAM_TAG = /^@param\s+(?:\{[^}]*\}\s+)?([\w$.]+)\s*-?\s*(.+)$/; +const SPHINX_PARAM = /^:(?:param|arg|key)\s+(?:\S+\s+)?([\w.]+)\s*:\s*(.+)$/; +const GOOGLE_PARAM = /^([a-z_][\w]*)\s*(?:\([^)]*\))?\s*:\s*(.+)$/; + +const RETURN_TAG = /^@returns?\s+(.+)$/; +const SPHINX_RETURN = /^:returns?\s*:\s*(.+)$/; + +// Description adds nothing when every word in it already appears in the thing +// being described. `@param blob - The blob to download` is the canonical case. +// +// No native linter covers this. eslint-plugin-jsdoc's require-param-description, +// Checkstyle's NonEmptyAtclauseDescription and ruff's D-rules all check that a +// description exists, not whether it says anything. +export function docRestatesSignature(body, ownerName = "") { + const t = body.trim(); + + for (const pattern of [PARAM_TAG, SPHINX_PARAM, GOOGLE_PARAM]) { + const match = pattern.exec(t); + if (!match) continue; + const [, name, description] = match; + // Google form is just `name: description`, which also matches ordinary prose + // containing a colon. Require the description to be short and unpunctuated so + // "Note: the cap is clamped" is not read as a parameter called "note". + if (pattern === GOOGLE_PARAM && /[.;,]/.test(description)) return false; + return addsNothing(description, name, 5); + } + + const returns = RETURN_TAG.exec(t) ?? SPHINX_RETURN.exec(t); + if (returns && ownerName) return addsNothing(returns[1], ownerName, 4); + return false; +} + +function addsNothing(description, subject, limit) { + const words = contentWords(description); + if (words.length === 0 || words.length > limit) return false; + const known = identWords(subject); + return known.length > 0 && words.every((w) => known.some((k) => k.startsWith(w) || w.startsWith(k))); +} + +// A TODO with no reference has nothing that will ever close it. An owner is not +// accepted in its place: a username goes stale when someone leaves and means +// nothing to an outside contributor, while an issue outlives both. +// +// Anchored at the start, so this catches a comment that *is* a TODO rather than +// prose that mentions the word. +const TODO_MARKER = /^(TODO|FIXME|HACK|XXX)\b/; + +// What counts as something that will close it: an issue, a link, or a security +// advisory. Checked across the whole comment run, so the reference can sit on a +// continuation line. +const HAS_REFERENCE = /(#\d+|https?:\/\/|CVE-\d|GHSA-|[A-Z]{2,}-\d+)/; + +export function isUnownedTodo(body, runText = body) { + return TODO_MARKER.test(body) && !HAS_REFERENCE.test(runText); +} + +// A rule id is silenced by `comment-lint-allow: CMT002`, on the comment itself or +// on the line above it. There is deliberately no form that disables every rule. +// +// The whole comment must be the directive. Matching it anywhere in the text meant +// prose that merely mentions the syntax silenced a rule, which this file's own +// paragraph above did. +const DIRECTIVE = /^comment-lint-allow:\s*(.+?)\s*$/i; + +export function isDirective(body) { + return DIRECTIVE.test(body.trim()); +} + +// A directive that names nothing real, or that suppresses nothing, is dead +// configuration: it reads as a silenced rule while silencing nothing, and it +// blinds the line for whoever inherits it. Reported for the same reason ESLint +// has --report-unused-disable-directives and ruff has RUF100. +class Allowance { + constructor(directives) { + this.entries = []; + for (const directive of directives) { + for (const token of directiveTokens(directive.body)) { + this.entries.push({ token, directive, known: token in RULES, used: false }); + } + } + } + + // Called only once a rule has decided it would report, so a directive counts + // as used when it actually silenced something. Asking before the rule decided + // marked every consulted directive as used, which hid the unused ones. + suppresses(rule) { + let allowed = false; + for (const entry of this.entries) { + if (entry.token !== rule) continue; + entry.used = true; + allowed = true; + } + return allowed; + } + + reportUnused(report) { + for (const entry of this.entries) { + if (entry.used) continue; + const detail = entry.known ? `${entry.token} is allowed here but nothing reported it` : `${entry.token} is not a rule`; + report("CMT008", entry.directive.line, entry.directive.column, detail, entry.directive.body); + } + } +} + +// Every token a directive names, valid or not, so an unknown one is reported +// rather than quietly ignored. Matching only real ids would let `CMT999` through +// as a silent no-op: it looks like a rule and silences nothing. +export function directiveTokens(body) { + const match = DIRECTIVE.exec(body.trim()); + if (!match) return []; + return match[1] + .split(",") + .map((token) => token.trim().toUpperCase()) + .filter(Boolean); +} + +// Generated files carry whatever the generator emits, and editing them to +// satisfy a lint rule would be undone on the next regeneration. +const GENERATED_MARKER = /AUTO-?GENERATED|@generated|DO NOT EDIT|Code generated by/i; +const GENERATED_HEADER_LINES = 10; + +export function isGenerated(source) { + return GENERATED_MARKER.test(source.split("\n", GENERATED_HEADER_LINES).join("\n")); +} + +export const EXCLUDED_PATHS = [ + /(^|\/)node_modules\//, + /(^|\/)dist(-\w+)?\//, + /(^|\/)build\//, + /(^|\/)target\//, + /(^|\/)vendor\//, + /pdfjs/i, + /thirdParty/i, + /\.min\./, + /src-tauri\/gen\//, + /public\/locales\//, + /\.d\.ts$/, + /(^|\/)storybook-static\//, + /(^|\/)playwright-report\//, + /(^|\/)org\/apache\//, +]; + +export function isExcludedPath(file) { + const normalised = file.replace(/\\/g, "/"); + return EXCLUDED_PATHS.some((re) => re.test(normalised)); +} + +// Comment text reduced to what a reader would call "the same comment": trimmed, +// whitespace collapsed, comment markers and decoration stripped. Both sides of +// the pre-existing check normalise through here so indentation and marker style +// cannot make an unchanged comment look new. +export function normaliseComment(text) { + return String(text) + .replace(/^[\s{]*(\/\/+|\/\*+|#+|\*+)/gm, " ") + .replace(/\*+\/[\s}]*$/gm, " ") + .replace(/\s+/g, " ") + .trim() + .toLowerCase(); +} + +// Every comment in a source file, normalised. Deliberately permissive and +// language-agnostic: it only ever decides whether a finding is pre-existing, so +// over-matching suppresses a duplicate comment and under-matching just reports +// something the author can look at. +export function commentBodiesOf(source) { + const bodies = new Set(); + for (const raw of source.split(/\r?\n/)) { + const marker = /(\/\/+|\/\*+|^\s*\*+|#+)/.exec(raw); + if (!marker) continue; + const body = normaliseComment(raw.slice(marker.index)); + if (body.length > 0) bodies.add(body); + } + return bodies; +} + +export function ruleLabel(id) { + return `${id} ${RULES[id].name}`; +} + +// Both engines funnel into this. They differ only in how they build `runs`: the +// oxlint plugin reads real comment tokens, comment-lint.mjs scans lines. Keeping +// the rule application here is what stops the two drifting apart. +// +// A "run" is a group of comment lines with no code between them, which is the +// unit CMT005 and CMT006 judge. Shape: +// { startLine, kind: "line" | "block" | "doc", lines: [{ line, column, body }] } +// `line` is 1-based to match every editor and every diff. + +export function analyse({ lines, runs, isTestFile = false }) { + const findings = []; + // `body` is the comment's own text, kept alongside the formatted detail so the + // caller can ask whether this exact comment already existed before the change. + // That is what stops a reindent or a code move reporting comments nobody wrote. + const report = (rule, line, column, detail, body) => { + if (isTestFile && SUPPRESSED_IN_TESTS.has(rule)) return; + findings.push({ rule, line, column, detail, body: normaliseComment(body ?? detail), severity: RULES[rule].severity }); + }; + + for (const run of runs) { + // A directive is scaffolding, not content. Leaving it in the run made it two + // lines long, and CMT001 only judges a one-line run, so any directive + // silenced CMT001 whatever rule it named. + const directives = run.lines.filter((l) => isDirective(l.body)); + const content = run.lines.filter((l) => !isDirective(l.body)); + const allowed = new Allowance(directives); + + if (content.length === 0) { + allowed.reportUnused(report); + continue; + } + + const bodies = content.map((l) => l.body); + const runText = bodies.join("\n"); + const first = content[0]; + + // Only CMT004 and CMT009 judge a trailing comment. The others depend on the + // comment introducing the code below it, and a trailing comment sits beside + // it: `0x25 // "%PDF"` overlaps in words while adding the decoding, which is + // the kind of lower-altitude fact the standard asks for. + if (run.trailing) { + for (const entry of content) { + const body = entry.body.trim(); + if (body.length === 0) continue; + if (isDiffNarration(body) && !allowed.suppresses("CMT004")) { + report("CMT004", entry.line, entry.column, truncate(body), body); + continue; + } + if (isUnownedTodo(body, runText) && !allowed.suppresses("CMT009")) { + report("CMT009", entry.line, entry.column, truncate(body), body); + } + } + allowed.reportUnused(report); + continue; + } + + if (isDeadCodeRun(bodies) && !allowed.suppresses("CMT005")) { + report("CMT005", run.startLine, first.column, `${bodies.length} commented-out lines`, runText); + allowed.reportUnused(report); + continue; // Every other rule would pile onto the same block of dead code. + } + + // A doc block is exempt: the standard asks for thorough contracts, so capping + // their length would argue with itself. This judges runs of implementation + // comment, where an essay means the code needs restructuring. + const essay = run.kind !== "doc" && content.length > MAX_BLOCK_LINES && run.startLine > FILE_HEADER_LINES; + if (essay && !allowed.suppresses("CMT006")) { + report("CMT006", run.startLine, first.column, `${content.length} lines, limit ${MAX_BLOCK_LINES}`, runText); + } + + const owner = run.kind === "line" ? "" : nextCodeLine(lines, run); + + for (const entry of content) { + const body = entry.body.trim(); + if (body.length === 0) continue; + + if (isBanner(body) && !allowed.suppresses("CMT002")) { + report("CMT002", entry.line, entry.column, truncate(body), body); + continue; + } + if (isStepNarration(body) && !allowed.suppresses("CMT003")) { + report("CMT003", entry.line, entry.column, truncate(body), body); + continue; + } + if (isDiffNarration(body) && !allowed.suppresses("CMT004")) { + report("CMT004", entry.line, entry.column, truncate(body), body); + continue; + } + if (docRestatesSignature(body, owner) && !allowed.suppresses("CMT007")) { + report("CMT007", entry.line, entry.column, truncate(body), body); + continue; + } + if (isUnownedTodo(body, runText) && !allowed.suppresses("CMT009")) { + report("CMT009", entry.line, entry.column, truncate(body), body); + continue; + } + } + + // CMT001 judges a whole single-line run against the code it introduces, so + // a two-line comment that happens to echo one identifier is left alone. + // A one-line `/* … */` counts, which is how JSX `{/* Cap editor */}` above + // `` is caught. A doc block does not: it is a contract, and + // CMT007 is the rule that judges those. + if (run.kind !== "doc" && content.length === 1) { + const entry = first; + const body = entry.body.trim(); + const code = nextCodeLine(lines, run); + if (code && !isBanner(body) && restatesCode(body, code) && !allowed.suppresses("CMT001")) { + report("CMT001", entry.line, entry.column, `${truncate(body)} -> ${truncate(code)}`, body); + } + } + + allowed.reportUnused(report); + } + + return findings.sort((a, b) => a.line - b.line || a.column - b.column); +} + +// A file header is allowed to be as long as it needs to be. +const FILE_HEADER_LINES = 5; +const DETAIL_WIDTH = 58; + +// Both of these say something real in a test and nothing anywhere else. A +// regression test explains itself by describing the old behaviour, and the e2e +// specs number their comments to match a written manual test procedure. +const SUPPRESSED_IN_TESTS = new Set(["CMT003", "CMT004"]); + +function precedingLine(lines, startLine) { + return lines[startLine - 2] ?? ""; +} + +function nextCodeLine(lines, run) { + const commentLines = new Set(run.lines.map((l) => l.line)); + for (let i = run.startLine; i < lines.length; i++) { + const lineNumber = i + 1; + if (commentLines.has(lineNumber)) continue; + const text = lines[i]?.trim() ?? ""; + if (text.length === 0) continue; + if (text.startsWith("//") || text.startsWith("#") || text.startsWith("*") || text.startsWith("/*")) continue; + if (text === "}" || text === "};" || text === ")" || text === ");") return ""; + return text; + } + return ""; +} + +function truncate(text) { + const flat = text.replace(/\s+/g, " ").trim(); + return flat.length > DETAIL_WIDTH ? `${flat.slice(0, DETAIL_WIDTH - 1)}…` : flat; +} + +export const TEST_FILE = /\.(test|spec)\.[jt]sx?$|(^|\/)src\/test\/|Test\.java$|Tests\.java$|(^|\/)test_[^/]+\.py$|_test\.py$/; + +export function isTestPath(file) { + return TEST_FILE.test(file.replace(/\\/g, "/")); +} diff --git a/scripts/lint/fixtures/AaaTest.java b/scripts/lint/fixtures/AaaTest.java new file mode 100644 index 0000000000..1aac398327 --- /dev/null +++ b/scripts/lint/fixtures/AaaTest.java @@ -0,0 +1,19 @@ +package fixtures; + +class AaaTest { + + void clampsToTierMaximum() { + // Arrange + var wallet = walletAt(500); + + // Act + var result = clamp(wallet); + + // Assert + assertEquals(100, result.cap()); + + // Assert the cap is clamped rather than rejected, because the tier + // downgrade path relies on it. + assertTrue(result.clamped()); + } +} diff --git a/scripts/lint/fixtures/README.md b/scripts/lint/fixtures/README.md new file mode 100644 index 0000000000..ceb8f1044b --- /dev/null +++ b/scripts/lint/fixtures/README.md @@ -0,0 +1,17 @@ +# comment-lint fixtures + +The contract between the two engines. Each file is a small, realistic example of +what a rule fires on, or of something it must leave alone. `expected.json` records +what every fixture should produce, down to the line and the severity. + +Expectations live outside the fixtures deliberately: an in-file `EXPECT:` marker +would sit inside the comment under test, changing its word count and its run +length, so the fixture would stop being an example of the real thing. + +```bash +node scripts/lint/comment-lint.mjs --selftest # compare +node scripts/lint/comment-lint.mjs --selftest --update # re-record, then review the diff +``` + +Adding a rule means adding a fixture that fires it and a line in a `clean.*` +fixture that must not. A rule with no fixture is a rule nobody can safely change. diff --git a/scripts/lint/fixtures/allow.java b/scripts/lint/fixtures/allow.java new file mode 100644 index 0000000000..50fe34d21e --- /dev/null +++ b/scripts/lint/fixtures/allow.java @@ -0,0 +1,32 @@ +package fixtures; + +class Allow { + + void keptOnPurpose() { + // comment-lint-allow: CMT002 + // ---------- kept on purpose, this fixture proves the escape hatch ---------- + run(); + } + + void unknownToken(Session session) { + // comment-lint-allow: FAKE_RULE + session.close(); + } + + void looksLikeARuleButIsNot(Session session) { + // comment-lint-allow: CMT999 + session.close(); + } + + void allowedButNothingFires(Session session) { + // comment-lint-allow: CMT001 + // Closed once the signing round trip has settled, not before. + session.close(); + } + + void directiveMustNotHideOtherRules(Registry registry) { + // comment-lint-allow: CMT002 + // Clear the registry + registry.clear(); + } +} diff --git a/scripts/lint/fixtures/apostrophes.java b/scripts/lint/fixtures/apostrophes.java new file mode 100644 index 0000000000..25d0745fd8 --- /dev/null +++ b/scripts/lint/fixtures/apostrophes.java @@ -0,0 +1,23 @@ +package fixtures; + +class Apostrophes { + + /** The approver's team, which must match the one this server already belongs to. */ + Long teamId; + + // The caller's own retry budget applies here; this method does not retry. + void submit() {} + + void literals() { + char quote = '\''; + char newline = '\n'; + char slash = '/'; + String path = "a//b"; + } + + /** Kept last: if the scanner desynchronises above, this stops being seen. */ + void canary() { + // Build document + document = build(); + } +} diff --git a/scripts/lint/fixtures/clean.java b/scripts/lint/fixtures/clean.java new file mode 100644 index 0000000000..34f21c313c --- /dev/null +++ b/scripts/lint/fixtures/clean.java @@ -0,0 +1,26 @@ +package fixtures; + +/** + * Authority on which filesystem locations a policy may read or write. Fail-closed: + * denied entirely under the saas profile, then Stirling's own config directory is + * always rejected, then the path must resolve inside an allowed root. + * + *

Compared after normalisation so {@code ..} cannot escape a root. Symlink + * escape is not defended: an operator who roots an allowlist on a symlink to a + * sensitive location is trusted. + */ +class Clean { + + /** Returns the normalised absolute path; throws if not permitted. */ + Path check(Path candidate) { + // whenComplete runs on the worker thread after the run finishes, so the + // terminal event never races the step events. + return candidate.toAbsolutePath().normalize(); + } + + void sizes() { + // Bytes, not KiB: the API contract predates the unit change and callers + // still send bytes. + long limit = 5_242_880L; + } +} diff --git a/scripts/lint/fixtures/clean.ts b/scripts/lint/fixtures/clean.ts new file mode 100644 index 0000000000..cac6895417 --- /dev/null +++ b/scripts/lint/fixtures/clean.ts @@ -0,0 +1,17 @@ +/** + * Auth/session seam. saas keeps a Supabase web session; desktop keeps a JWT in + * the Tauri secure store, and cloud code reads the token through here instead. + * Default no-op; saas/ and desktop/ shadow it. + */ +export interface SessionSeam { + /** Bearer access token for authenticated API calls, or null when signed out. */ + getAccessToken(): Promise; +} + +export function createSeam(): SessionSeam { + return { + // Resolves null rather than throwing: a signed-out caller is an ordinary + // state here, and every consumer already branches on null. + getAccessToken: async () => null, + }; +} diff --git a/scripts/lint/fixtures/deadcode.java b/scripts/lint/fixtures/deadcode.java new file mode 100644 index 0000000000..e2ad5b887e --- /dev/null +++ b/scripts/lint/fixtures/deadcode.java @@ -0,0 +1,14 @@ +package fixtures; + +class DeadCode { + + // private void oldPath(PDDocument document) { + // PDPage page = document.getPage(0); + // page.setRotation(90); + // document.save(target); + // } + + void currentPath(PDDocument document) { + document.save(target); + } +} diff --git a/scripts/lint/fixtures/docs.java b/scripts/lint/fixtures/docs.java new file mode 100644 index 0000000000..f572dc545f --- /dev/null +++ b/scripts/lint/fixtures/docs.java @@ -0,0 +1,12 @@ +package fixtures; + +class Docs { + + /** + * @param blob the blob + * @param timeoutMs how long to wait before abandoning the read; the caller + * owns retrying, because only it knows whether the operation is + * idempotent + */ + void download(Blob blob, long timeoutMs) {} +} diff --git a/scripts/lint/fixtures/docstrings.py b/scripts/lint/fixtures/docstrings.py new file mode 100644 index 0000000000..b9c67023c7 --- /dev/null +++ b/scripts/lint/fixtures/docstrings.py @@ -0,0 +1,36 @@ +"""Module docstring, which the scanner must see as documentation.""" + + +def download(blob, timeout_ms): + """Fetch the blob. + + Args: + blob: The blob + timeout_ms: How long to wait before abandoning the read; the caller owns + retrying, because only it knows whether the operation is idempotent. + + Returns: + The fetched bytes. + """ + return read(blob) + + +def sphinx(blob): + """Fetch the blob. + + :param blob: The blob + :returns: the sphinx result + """ + return read(blob) + + +def payload(): + # Not documentation: a triple-quoted value, so its contents are data. + body = """{"key": "value", "note": "Types"}""" + return body + + +def canary(): + # Build document + document = build() + return document diff --git a/scripts/lint/fixtures/expected.json b/scripts/lint/fixtures/expected.json new file mode 100644 index 0000000000..34c89482a5 --- /dev/null +++ b/scripts/lint/fixtures/expected.json @@ -0,0 +1,63 @@ +{ + "AaaTest.java": [], + "allow.java": [ + "12:CMT008:error", + "17:CMT008:error", + "22:CMT008:error", + "28:CMT008:error", + "29:CMT001:error" + ], + "apostrophes.java": [ + "20:CMT001:error" + ], + "clean.java": [], + "clean.ts": [], + "deadcode.java": [ + "5:CMT005:error" + ], + "docs.java": [ + "6:CMT007:error" + ], + "docstrings.py": [ + "8:CMT007:error", + "21:CMT007:error", + "34:CMT001:error" + ], + "narration.java": [ + "6:CMT003:error", + "9:CMT003:error" + ], + "narration.tsx": [ + "2:CMT003:error", + "5:CMT004:error", + "13:CMT001:error" + ], + "restates.java": [ + "6:CMT001:error", + "9:CMT001:error", + "17:CMT002:error", + "19:CMT002:error" + ], + "restates.py": [ + "2:CMT001:error", + "5:CMT002:error" + ], + "restates.ts": [ + "2:CMT001:error", + "14:CMT002:error", + "18:CMT002:error" + ], + "strings.java": [], + "templates.py": [ + "22:CMT001:error" + ], + "todos.java": [ + "6:CMT009:error" + ], + "trailing.java": [ + "13:CMT009:error", + "14:CMT004:error", + "19:CMT009:error", + "20:CMT004:error" + ] +} diff --git a/scripts/lint/fixtures/narration.java b/scripts/lint/fixtures/narration.java new file mode 100644 index 0000000000..6dcb69b981 --- /dev/null +++ b/scripts/lint/fixtures/narration.java @@ -0,0 +1,22 @@ +package fixtures; + +class Narration { + + void export(Document document) { + // Step 1: collect the annotations + var annotations = document.annotations(); + + // Then, flatten them onto the page + document.flatten(annotations); + + // IMPORTANT: do not reorder these + document.save(); + + // No longer needed after the storage migration + legacyCleanup(); + + // Ordering matters: flatten() reads the annotation list that save() + // clears, so a save first loses every annotation. See #6865. + document.close(); + } +} diff --git a/scripts/lint/fixtures/narration.tsx b/scripts/lint/fixtures/narration.tsx new file mode 100644 index 0000000000..fa931ce925 --- /dev/null +++ b/scripts/lint/fixtures/narration.tsx @@ -0,0 +1,17 @@ +export function Panel() { + // Step 1: read the cap + const cap = useCap(); + + // This used to be initialised by the footer, which mounted after the banner. + useConsentBanner(); + + // CRITICAL: keep this above the early return + useLayoutEffect(() => sync(cap), [cap]); + + return ( +

+ {/* Cap editor */} + +
+ ); +} diff --git a/scripts/lint/fixtures/restates.java b/scripts/lint/fixtures/restates.java new file mode 100644 index 0000000000..47b2e004b8 --- /dev/null +++ b/scripts/lint/fixtures/restates.java @@ -0,0 +1,24 @@ +package fixtures; + +class Restates { + + void run(Registry registry, Job job) { + // Clear the registry + registry.clear(); + + // Sanitize filename + String safeFilename = sanitizeFilename(job.originalFilename()); + + // Wait for the worker to drain before clearing, or an in-flight job + // re-registers its temp file after the sweep. + registry.awaitQuiescence(); + } + + // ---------- Internal helpers ---------- + + // Types + private enum Mode { + FAST, + SAFE + } +} diff --git a/scripts/lint/fixtures/restates.py b/scripts/lint/fixtures/restates.py new file mode 100644 index 0000000000..b952a934c0 --- /dev/null +++ b/scripts/lint/fixtures/restates.py @@ -0,0 +1,9 @@ +def build(request): + # Get the current status + status = request.current_status() + + # ---- helpers ---- + + # Truncated to 200 chars because the audit column is varchar(200) and a + # longer value fails the insert rather than being trimmed. + return status[:200] diff --git a/scripts/lint/fixtures/restates.ts b/scripts/lint/fixtures/restates.ts new file mode 100644 index 0000000000..3b19eb1f10 --- /dev/null +++ b/scripts/lint/fixtures/restates.ts @@ -0,0 +1,20 @@ +export function useGrid() { + // Handle drag start + const handleDragStart = (event: DragStartEvent) => event.active.id; + + // Selection state + const selection = new Set(); + + // Debounced so a fast drag does not queue a layout pass per pointer move. + const updateLayout = debounce(() => measure(), 16); + + return { handleDragStart, selection, updateLayout }; +} + +// ─── Types ──────────────────────────────────────────────────────────────── + +export type Gate = "OFFSITE_PROCESSING" | "AUTOMATION"; + +// Helpers + +export function noop() {} diff --git a/scripts/lint/fixtures/strings.java b/scripts/lint/fixtures/strings.java new file mode 100644 index 0000000000..d3600ceb3d --- /dev/null +++ b/scripts/lint/fixtures/strings.java @@ -0,0 +1,11 @@ +package fixtures; + +class Strings { + + // A `//` inside a literal is not a comment, and neither is an escaped quote. + void urls() { + String docs = "https://example.com/guide"; + String quoted = "a \" then // not a comment"; + char slash = '/'; + } +} diff --git a/scripts/lint/fixtures/templates.py b/scripts/lint/fixtures/templates.py new file mode 100644 index 0000000000..86e11edea8 --- /dev/null +++ b/scripts/lint/fixtures/templates.py @@ -0,0 +1,24 @@ +"""Templates that open mid-line, whose closing delimiter starts a line.""" + +_TOOL_IO = ''' +TOOL_IO: dict[ToolEndpoint, ToolIOSpec] = {{ +{declarations} +}} +''' + +_HEADER = """ +# Header emitted into the output file. +# Types +""" + + +def resolve(schema): + if "$ref" in schema: + return lookup(schema["$ref"]) + return schema + + +def canary(): + # Build document + document = build() + return document diff --git a/scripts/lint/fixtures/todos.java b/scripts/lint/fixtures/todos.java new file mode 100644 index 0000000000..2287ca143e --- /dev/null +++ b/scripts/lint/fixtures/todos.java @@ -0,0 +1,25 @@ +package fixtures; + +class Todos { + + void bare() { + // TODO: re-enable once account syncing lands + skip(); + } + + void referenced() { + // TODO(#1234): re-enable once account syncing lands + skip(); + } + + void linked() { + // FIXME: the upstream fix is tracked at https://example.com/issues/9 + workaround(); + } + + void mentioned() { + // Image placeholders are not scored: their body text is a TODO marker + // rather than prose, so scoring it would reward the placeholder. + score(); + } +} diff --git a/scripts/lint/fixtures/trailing.java b/scripts/lint/fixtures/trailing.java new file mode 100644 index 0000000000..58077e4561 --- /dev/null +++ b/scripts/lint/fixtures/trailing.java @@ -0,0 +1,22 @@ +package fixtures; + +class Trailing { + + void decodings() { + byte[] header = {0x25, 0x50, 0x44, 0x46}; // "%PDF" + long maxSize = 50L * 1024 * 1024; // 50 MB + double buffer = 0.10; // 10% headroom + int mode = 2; // MB + } + + void stillJudged() { + boolean supportsSign = false; // TODO make Sign work + cleanup(); // this used to run before the flush + } + + void blockFormToo() { + int mode = 2; /* MB */ + boolean ready = false; /* TODO wire this up */ + reset(); /* this used to run before the flush */ + } +}