mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Merge branch 'main' into feat/auto-form-detection-server-only
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -67,6 +67,7 @@ labels:
|
||||
- 'frontend/**'
|
||||
- 'frontend/.*'
|
||||
- 'frontend/**/.*'
|
||||
- '.taskfiles/frontend.yml'
|
||||
|
||||
- label: 'Tauri'
|
||||
files:
|
||||
|
||||
@@ -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@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.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@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.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
|
||||
+62
-3
@@ -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 <base>/link, so a subpath build
|
||||
# serves it at <base>/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).
|
||||
|
||||
+14
-4
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
+32
-7
@@ -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|<csv of linux-x64,linux-arm64,darwin-x64,darwin-arm64,windows-x64>
|
||||
// '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|<csv of linux-x64,linux-arm64,linux-musl-x64,linux-musl-arm64,darwin-x64,darwin-arm64,windows-x64> (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 ->
|
||||
|
||||
@@ -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/<folder-uuid> are FileManagerView routes - they
|
||||
|
||||
@@ -106,6 +106,7 @@ SwaggerDoc.json
|
||||
|
||||
# Log file
|
||||
*.log
|
||||
*.log.gz
|
||||
|
||||
# BlueJ files
|
||||
*.ctxt
|
||||
|
||||
+14
-2
@@ -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',
|
||||
|
||||
@@ -114,6 +114,7 @@ public class OCRController {
|
||||
List<String> 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<String> 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");
|
||||
}
|
||||
|
||||
+4
@@ -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();
|
||||
|
||||
|
||||
+4
@@ -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())) {
|
||||
|
||||
+5
@@ -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;
|
||||
|
||||
|
||||
@@ -16,8 +16,9 @@
|
||||
<pattern>%d %p %c{1} [%thread] %m%n</pattern>
|
||||
</encoder>
|
||||
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
|
||||
<fileNamePattern>${LOG_PATH}/auth-%d{yyyy-MM-dd}.log</fileNamePattern>
|
||||
<maxHistory>1</maxHistory>
|
||||
<fileNamePattern>${LOG_PATH}/auth-%d{yyyy-MM-dd}.log.gz</fileNamePattern>
|
||||
<maxHistory>7</maxHistory>
|
||||
<totalSizeCap>64MB</totalSizeCap>
|
||||
</rollingPolicy>
|
||||
</appender>
|
||||
|
||||
@@ -28,8 +29,9 @@
|
||||
<pattern>%d %p %c{1} [%thread] %m%n</pattern>
|
||||
</encoder>
|
||||
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
|
||||
<fileNamePattern>${LOG_PATH}/info-%d{yyyy-MM-dd}.log</fileNamePattern>
|
||||
<maxHistory>1</maxHistory>
|
||||
<fileNamePattern>${LOG_PATH}/info-%d{yyyy-MM-dd}.log.gz</fileNamePattern>
|
||||
<maxHistory>7</maxHistory>
|
||||
<totalSizeCap>256MB</totalSizeCap>
|
||||
</rollingPolicy>
|
||||
</appender>
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
+139
-67
@@ -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").
|
||||
*
|
||||
* <p>Calls:
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@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.
|
||||
* <li>{@link #fetchEntitlement} — authenticates with the stored device credential against {@code
|
||||
* GET /api/v1/instance/entitlement}; what the local gate consults.
|
||||
* <li>{@link #reportUsage} — daily usage sync ({@code POST /api/v1/instance/sync}); reports
|
||||
* cumulative units and returns the refreshed entitlement.
|
||||
* <li>{@link #revokeSelf} — self-revokes the credential on local unlink ({@code POST
|
||||
* /api/v1/instance/revoke-self}).
|
||||
* </ul>
|
||||
*
|
||||
* <p>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<String> response = send(request);
|
||||
HttpResponse<String> 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<String> 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:
|
||||
*
|
||||
* <ul>
|
||||
* <li>2xx → the parsed snapshot.
|
||||
* <li>401/403 → {@link RevokedException} (authoritative deny — revoked/invalid credential);
|
||||
* the caller must BLOCK, not fail open.
|
||||
* <li>transport failure, other non-2xx (e.g. 5xx), or a malformed body → {@code null}
|
||||
* ("unknown" — the caller fails open).
|
||||
* </ul>
|
||||
*/
|
||||
/** Fetches the current entitlement using the stored device credential. */
|
||||
public InstanceEntitlement fetchEntitlement(String deviceId, String deviceSecret) {
|
||||
HttpResponse<String> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+88
-44
@@ -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").
|
||||
*
|
||||
* <p>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).
|
||||
*
|
||||
* <p>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<UsageSyncService> syncServiceProvider;
|
||||
|
||||
public AccountLinkController(
|
||||
AccountLinkService service,
|
||||
ConnectService connectService,
|
||||
LocalUsageService localUsageService,
|
||||
ObjectProvider<UsageSyncService> 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<ConnectService.ConnectStatus> 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<AccountLinkService.LinkStatus> 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<Void> syncNow() {
|
||||
UsageSyncService sync = syncServiceProvider.getIfAvailable();
|
||||
|
||||
+8
-27
@@ -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).
|
||||
*
|
||||
* <p>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
|
||||
* <b>off by default</b> and <b>dark</b> — 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).
|
||||
*
|
||||
* <p>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, <b>separate</b> 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);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-24
@@ -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").
|
||||
*
|
||||
* <p>{@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
|
||||
|
||||
+1
-1
@@ -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).
|
||||
*
|
||||
* <p>{@link #lastSyncSeq} is reserved (incremented + persisted) <em>before</em> each report so it
|
||||
* is strictly monotonic across restarts and partial failures — SaaS dedups replays by comparing it,
|
||||
|
||||
+1
-1
@@ -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<AccountLinkSyncState, Long> {}
|
||||
|
||||
+276
@@ -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<ConnectState> 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<DeviceCredential> credential = credentialStore.get();
|
||||
if (credential.isPresent()) {
|
||||
return new ConnectStatus(Phase.LINKED, null, null, credential.get().getTeamId());
|
||||
}
|
||||
Optional<ConnectState> 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));
|
||||
}
|
||||
}
|
||||
+60
@@ -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);
|
||||
}
|
||||
}
|
||||
+6
@@ -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<ConnectState, Long> {}
|
||||
+2
-2
@@ -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.
|
||||
*
|
||||
* <p>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
|
||||
|
||||
+1
-1
@@ -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.
|
||||
*
|
||||
* <p>Rules (in order):
|
||||
*
|
||||
|
||||
+2
-2
@@ -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.
|
||||
*
|
||||
* <p>Blocking responds {@code 402} with a machine-readable body the FE maps to a "link to activate"
|
||||
|
||||
+5
-5
@@ -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 <b>workflow
|
||||
* window</b>: 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 <b>workflow window</b>: 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.
|
||||
*
|
||||
* <p>{@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
|
||||
|
||||
+1
-1
@@ -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<MeteredInputSignature, Long> {
|
||||
|
||||
|
||||
+4
-4
@@ -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
|
||||
|
||||
+1
-1
@@ -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<UsageCounter, Long> {
|
||||
|
||||
/**
|
||||
|
||||
+2
-2
@@ -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.
|
||||
*
|
||||
* <p>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
|
||||
|
||||
+3
-3
@@ -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 <em>identical</em> 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 <em>identical</em> 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.
|
||||
*
|
||||
* <p>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}).
|
||||
|
||||
+3
-3
@@ -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()
|
||||
|
||||
+67
-27
@@ -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<String> resp =
|
||||
response(201, "{\"deviceId\":\"dev-1\",\"deviceSecret\":\"sec-1\",\"teamId\":42}");
|
||||
ArgumentCaptor<HttpRequest> 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<String> resp = response(401, "{\"error\":\"unauthorized\"}");
|
||||
void connectRequestParsesTheAuthorizeUrlItIsGiven() throws Exception {
|
||||
HttpResponse<String> resp =
|
||||
response(
|
||||
201,
|
||||
"{\"requestId\":\"req-1\",\"expiresIn\":900,"
|
||||
+ "\"authorizeUrl\":\"https://app.example.com/link?request=req-1\"}");
|
||||
ArgumentCaptor<HttpRequest> 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<String> 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<String> resp = response(status, body);
|
||||
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp);
|
||||
return client.connectClaim("req-1", "secret").outcome();
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+40
-33
@@ -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<UsageSyncService> 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
|
||||
|
||||
+6
-16
@@ -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());
|
||||
|
||||
+408
@@ -0,0 +1,408 @@
|
||||
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<String> 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<String> nonce = ArgumentCaptor.forClass(String.class);
|
||||
ArgumentCaptor<String> secret = ArgumentCaptor.forClass(String.class);
|
||||
verify(client).connectRequest(any(), anyString(), nonce.capture(), secret.capture(), any());
|
||||
|
||||
ArgumentCaptor<ConnectState> 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;
|
||||
}
|
||||
}
|
||||
+7
-85
@@ -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").
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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<RegisterResponse> 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<InstanceRow>> 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<Void> 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<TeamMembership> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,16 +18,7 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Account-link instance registration + lifecycle (combined-billing "Mode A").
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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<LinkedInstance> 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<LinkedInstance> 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");
|
||||
|
||||
@@ -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<ViewResponse> 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<ConnectRequestService.ConnectView> 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<Void> 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.
|
||||
*
|
||||
* <p>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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+47
@@ -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.
|
||||
*
|
||||
* <p>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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
@@ -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<ConnectRequest, Long> {
|
||||
|
||||
Optional<ConnectRequest> 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<ConnectRequest> 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);
|
||||
}
|
||||
@@ -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<URI> 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<ConnectView> 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<ConnectRequest> 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<ConnectRequest> 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<ConnectRequest> 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<URI> 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);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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).
|
||||
*
|
||||
* <p>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
|
||||
|
||||
@@ -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 <b>device
|
||||
* credential</b> — 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 <b>device credential</b> — not a
|
||||
* user JWT. Separate path prefix ({@code /api/v1/instance/**}) so the device credential is scoped
|
||||
* here and nowhere else.
|
||||
*
|
||||
* <p>{@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
|
||||
|
||||
@@ -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<TeamMembership> 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);
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
*
|
||||
* <p>Created by {@code POST /api/v1/account-link/register}, authenticated with the admin's
|
||||
|
||||
+1
-1
@@ -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).
|
||||
*
|
||||
* <p>Deliberately <em>not</em> 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
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package stirling.software.saas.accountlink;
|
||||
|
||||
/**
|
||||
* Origin formatting shared by the connect handshake.
|
||||
*
|
||||
* <p>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;
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,7 @@ public final class SaasSchemaOwnership {
|
||||
*/
|
||||
public static final Set<String> 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<String> HIBERNATE_MANAGED =
|
||||
Set.of(
|
||||
"account_link_connect_state",
|
||||
"account_link_device_credential",
|
||||
"account_link_metered_signature",
|
||||
"account_link_sync_state",
|
||||
|
||||
+6
-6
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<String> 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<String> 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,"
|
||||
|
||||
@@ -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 <em>last</em> 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.
|
||||
*
|
||||
* <p>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.
|
||||
|
||||
+23
-23
@@ -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<RegisterResponse> resp =
|
||||
controller.register(new RegisterRequest("host"), auth);
|
||||
ResponseEntity<List<InstanceRow>> 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<RegisterResponse> resp = controller.register(null, auth);
|
||||
ResponseEntity<List<InstanceRow>> 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<RegisterResponse> resp = controller.register(null, auth);
|
||||
ResponseEntity<List<InstanceRow>> 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<RegisterResponse> resp =
|
||||
controller.register(new RegisterRequest("host"), auth);
|
||||
ResponseEntity<List<InstanceRow>> 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
+358
@@ -0,0 +1,358 @@
|
||||
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<ConnectRequest> 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<ConnectRequest> 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<ConnectRequest> 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<ConnectRequest> 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;
|
||||
}
|
||||
}
|
||||
+53
-1
@@ -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() {
|
||||
|
||||
+9
-3
@@ -42,7 +42,7 @@ ext {
|
||||
bucket4jVersion = "8.19.0"
|
||||
archunitVersion = "1.4.2"
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3088,6 +3088,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"
|
||||
|
||||
@@ -3327,7 +3614,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]
|
||||
@@ -3438,6 +3725,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"
|
||||
@@ -3908,7 +4228,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"
|
||||
@@ -4009,9 +4328,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"
|
||||
@@ -4054,7 +4373,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"
|
||||
@@ -4106,7 +4425,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."
|
||||
@@ -6580,6 +6898,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."
|
||||
@@ -6613,17 +6959,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."
|
||||
@@ -8911,7 +9264,6 @@ appEditor = "Editor"
|
||||
appProcessor = "Processor"
|
||||
linkAccount = "Link Stirling account"
|
||||
primaryNav = "Primary navigation"
|
||||
switchApp = "Switch app"
|
||||
|
||||
[portal.shell.topbar]
|
||||
closeNav = "Close navigation"
|
||||
@@ -9390,6 +9742,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"
|
||||
|
||||
@@ -11890,6 +12252,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"
|
||||
|
||||
@@ -360,11 +360,9 @@ const TeamSection: React.FC = () => {
|
||||
verticalSpacing="sm"
|
||||
withRowBorders
|
||||
highlightOnHover
|
||||
style={
|
||||
{
|
||||
"--table-border-color": "var(--mantine-color-gray-3)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
style={{
|
||||
"--table-border-color": "var(--mantine-color-gray-3)",
|
||||
}}
|
||||
>
|
||||
<Table.Thead>
|
||||
<Table.Tr
|
||||
|
||||
@@ -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 */}
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<AppProviders>
|
||||
<AppLayout>
|
||||
<HomePage />
|
||||
<Onboarding />
|
||||
</AppLayout>
|
||||
</AppProviders>
|
||||
}
|
||||
/>
|
||||
{/* The app, under a shared frame so the rail renders once outside it. */}
|
||||
<Route element={<AppFrame />}>
|
||||
{/* All other routes need AppProviders for backend integration */}
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<AppProviders>
|
||||
<AppLayout>
|
||||
<HomePage />
|
||||
<Onboarding />
|
||||
</AppLayout>
|
||||
</AppProviders>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -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({
|
||||
<TourOrchestrationProvider>
|
||||
<AdminTourOrchestrationProvider>
|
||||
<FolderFileContextProvider>
|
||||
<WorkbenchSessionPersistence />
|
||||
{children}
|
||||
</FolderFileContextProvider>
|
||||
</AdminTourOrchestrationProvider>
|
||||
|
||||
@@ -336,7 +336,7 @@ const FileEditor = ({
|
||||
(fileId: FileId) => {
|
||||
const index = stubsRef.current.findIndex((r) => r.id === fileId);
|
||||
if (index !== -1) {
|
||||
setActiveFileId(fileId as string);
|
||||
setActiveFileId(fileId);
|
||||
setActiveFileIndex(index);
|
||||
navActions.setWorkbench("viewer");
|
||||
}
|
||||
@@ -410,10 +410,7 @@ const FileEditor = ({
|
||||
onUnzipFile={handleUnzipFile}
|
||||
toolMode={toolMode}
|
||||
isSupported={isFileSupported(record.name)}
|
||||
policies={
|
||||
policyFileBadges.get(record.id as string) ??
|
||||
EMPTY_POLICIES
|
||||
}
|
||||
policies={policyFileBadges.get(record.id) ?? EMPTY_POLICIES}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -173,7 +173,7 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
|
||||
mb="xs"
|
||||
style={{ paddingLeft: "1rem" }}
|
||||
>
|
||||
{t("fileManager.myFiles", "My Files")}
|
||||
{t("fileSidebar.myFiles", "File library")}
|
||||
</Text>
|
||||
{buttons}
|
||||
</Stack>
|
||||
|
||||
@@ -140,7 +140,7 @@ export function FileDetailsPanel({
|
||||
return null;
|
||||
}
|
||||
|
||||
const single = files.length === 1 ? files[0]! : null;
|
||||
const single = files.length === 1 ? files[0] : null;
|
||||
const totalSize = files.reduce((sum, f) => sum + f.size, 0);
|
||||
const ext = single ? (single.name.split(".").pop() ?? "").toUpperCase() : "";
|
||||
// Files still needing a server upload; drives Save-to-server visibility.
|
||||
|
||||
@@ -422,7 +422,7 @@ function GridView(props: FileGridProps) {
|
||||
parentPath={entry.parentPath}
|
||||
isSelected={selectedFileIds.has(entry.file.id)}
|
||||
isInWorkspace={
|
||||
activeWorkspaceFileIds?.has(entry.file.id as string) ?? false
|
||||
activeWorkspaceFileIds?.has(entry.file.id) ?? false
|
||||
}
|
||||
selectedFileIds={selectedFileIds}
|
||||
multiSelectActive={selectedFileIds.size >= 2}
|
||||
@@ -938,7 +938,7 @@ function FileCard({
|
||||
shiftKey: false,
|
||||
ctrlKey: true,
|
||||
metaKey: true,
|
||||
} as unknown as React.MouseEvent);
|
||||
});
|
||||
}}
|
||||
onChange={() => {
|
||||
/* handled by onClick */
|
||||
@@ -982,7 +982,7 @@ function FileCard({
|
||||
·
|
||||
</span>
|
||||
<span>{fileDate}</span>
|
||||
<PolicyBadges fileId={file.id as string} />
|
||||
<PolicyBadges fileId={file.id} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="files-page-card-actions">
|
||||
@@ -1137,7 +1137,7 @@ function ListView(
|
||||
parentPath={entry.parentPath}
|
||||
isSelected={selectedFileIds.has(entry.file.id)}
|
||||
isInWorkspace={
|
||||
activeWorkspaceFileIds?.has(entry.file.id as string) ?? false
|
||||
activeWorkspaceFileIds?.has(entry.file.id) ?? false
|
||||
}
|
||||
selectedFileIds={selectedFileIds}
|
||||
multiSelectActive={selectedFileIds.size >= 2}
|
||||
@@ -1424,7 +1424,7 @@ function FileRow({
|
||||
shiftKey: false,
|
||||
ctrlKey: true,
|
||||
metaKey: true,
|
||||
} as unknown as React.MouseEvent);
|
||||
});
|
||||
}}
|
||||
onChange={() => {
|
||||
/* handled by onClick */
|
||||
@@ -1491,7 +1491,7 @@ function FileRow({
|
||||
)}
|
||||
</span>
|
||||
<FileOriginBadge origin={getFileOrigin(file)} compact />
|
||||
<PolicyBadges fileId={file.id as string} />
|
||||
<PolicyBadges fileId={file.id} />
|
||||
{isInWorkspace && (
|
||||
<span className="files-page-row-open-pill">
|
||||
<span className="files-page-card-open-dot" />
|
||||
|
||||
@@ -474,7 +474,7 @@ export default function FileManagerView() {
|
||||
if (idx >= 0 && lastIdx >= 0) {
|
||||
const [a, b] = idx < lastIdx ? [idx, lastIdx] : [lastIdx, idx];
|
||||
for (let i = a; i <= b; i += 1) {
|
||||
next.add(visibleFiles[i]!.id);
|
||||
next.add(visibleFiles[i].id);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
@@ -593,7 +593,7 @@ export default function FileManagerView() {
|
||||
});
|
||||
// Branch on requested stubs so already-active files still activate.
|
||||
if (materialized.length === 1) {
|
||||
setActiveFileId(materialized[0]!.id);
|
||||
setActiveFileId(materialized[0].id);
|
||||
navActions.setWorkbench("viewer");
|
||||
} else if (materialized.length > 1) {
|
||||
navActions.setWorkbench("fileEditor");
|
||||
@@ -1172,7 +1172,7 @@ export default function FileManagerView() {
|
||||
else if (e.key === "End") next = TAB_DEFS.length - 1;
|
||||
else return;
|
||||
e.preventDefault();
|
||||
const target = TAB_DEFS[next]!;
|
||||
const target = TAB_DEFS[next];
|
||||
setCurrentTab(target.id);
|
||||
focusTab(target.id);
|
||||
}}
|
||||
@@ -1602,7 +1602,7 @@ export default function FileManagerView() {
|
||||
)
|
||||
)
|
||||
return;
|
||||
setViewMode(v as (typeof FILES_PAGE_VIEW_MODES)[number]);
|
||||
setViewMode(v);
|
||||
}}
|
||||
aria-label={t("filesPage.viewMode.label", "View mode")}
|
||||
options={[
|
||||
|
||||
@@ -121,7 +121,7 @@ export function FolderTreePanel({ active }: FolderTreePanelProps) {
|
||||
<div className="folder-tree-panel-inner">
|
||||
<div className="folder-tree-panel-header">
|
||||
<span className="folder-tree-panel-title">
|
||||
{t("filesPage.myFiles", "My Files")}
|
||||
{t("fileSidebar.myFiles", "File library")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -120,14 +120,14 @@ export function VersionTimeline({
|
||||
};
|
||||
const rows: Row[] = useMemo(() => {
|
||||
if (!collapsible || showAllCollapsed) {
|
||||
return ordered.map((v) => ({ kind: "version", version: v }) as Row);
|
||||
return ordered.map<Row>((v) => ({ kind: "version", version: v }));
|
||||
}
|
||||
const head = ordered
|
||||
.slice(0, 3)
|
||||
.map((v) => ({ kind: "version", version: v }) as Row);
|
||||
.map<Row>((v) => ({ kind: "version", version: v }));
|
||||
const tail = ordered
|
||||
.slice(-2)
|
||||
.map((v) => ({ kind: "version", version: v }) as Row);
|
||||
.map<Row>((v) => ({ kind: "version", version: v }));
|
||||
const hidden = ordered.length - 5;
|
||||
return [...head, { kind: "ellipsis", hidden }, ...tail];
|
||||
}, [collapsible, showAllCollapsed, ordered]);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -27,7 +27,7 @@ function depthOf(
|
||||
let cursor: FolderRecord | undefined = folder;
|
||||
while (cursor && cursor.parentFolderId) {
|
||||
depth += 1;
|
||||
cursor = byId.get(cursor.parentFolderId as string);
|
||||
cursor = byId.get(cursor.parentFolderId);
|
||||
if (depth > 50) break;
|
||||
}
|
||||
return depth;
|
||||
|
||||
@@ -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. */
|
||||
@@ -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 (
|
||||
<QuickNavHostProvider>
|
||||
<div className="app-frame">
|
||||
<QuickNavRailHost />
|
||||
<div className="app-frame__content">
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</QuickNavHostProvider>
|
||||
);
|
||||
}
|
||||
@@ -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 <Outlet />;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 <FileManagerView />;
|
||||
@@ -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 && (
|
||||
<div style={{ position: "absolute", top: 12, right: 12, zIndex: 20 }}>
|
||||
<NotificationBell />
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -139,9 +139,9 @@ export const MobileDrawCanvas = forwardRef<
|
||||
// where the per-frame synthetic event alone would drop curvature.
|
||||
const events =
|
||||
"getCoalescedEvents" in e.nativeEvent
|
||||
? (e.nativeEvent as PointerEvent).getCoalescedEvents()
|
||||
? e.nativeEvent.getCoalescedEvents()
|
||||
: [e.nativeEvent as PointerEvent];
|
||||
const rect = (e.currentTarget as HTMLCanvasElement).getBoundingClientRect();
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
for (const ev of events) {
|
||||
stroke.points.push({
|
||||
x: ev.clientX - rect.left,
|
||||
|
||||
@@ -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;
|
||||
@@ -48,6 +48,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;
|
||||
|
||||
@@ -1,27 +1,15 @@
|
||||
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 { useNotifications } from "@app/hooks/useNotifications";
|
||||
import { useNotificationActions } from "@app/components/notifications/notificationActions";
|
||||
import { NotificationItem } from "@app/components/notifications/NotificationItem";
|
||||
import { NotificationPanel } from "@app/components/notifications/NotificationPanel";
|
||||
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 <MountedNotificationBell />;
|
||||
@@ -29,14 +17,10 @@ export function NotificationBell() {
|
||||
|
||||
function MountedNotificationBell() {
|
||||
const { t } = useTranslation();
|
||||
const { notifications, unreadCount, documentStateFor, markAllSeen } =
|
||||
useNotifications();
|
||||
const { unreadCount } = useNotifications();
|
||||
const registry = useNotificationActions();
|
||||
const [open, setOpen] = useState(false);
|
||||
const container = useRef<HTMLDivElement>(null);
|
||||
const headingId = useId();
|
||||
// Where the new ones stop, frozen when the panel opens (opening marks everything read).
|
||||
const [firstSeenId, setFirstSeenId] = useState<string | null>(null);
|
||||
// Viewport-fixed, because the workbench bar clips its own overflow.
|
||||
const [anchor, setAnchor] = useState<{ top: number; right: number } | null>(
|
||||
null,
|
||||
@@ -61,54 +45,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)) 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 (
|
||||
<div className="notification-bell" ref={container}>
|
||||
<Button
|
||||
variant="quiet"
|
||||
size="md"
|
||||
shape="circle"
|
||||
className="notification-bell__trigger"
|
||||
// Read by the panel's outside-click handler.
|
||||
data-notifications-trigger
|
||||
aria-label={t("notifications.open", "Notifications")}
|
||||
aria-expanded={open}
|
||||
onClick={toggle}
|
||||
onClick={() => setOpen((wasOpen) => !wasOpen)}
|
||||
>
|
||||
<BellIcon />
|
||||
{unreadCount > 0 && (
|
||||
@@ -119,53 +66,11 @@ function MountedNotificationBell() {
|
||||
</Button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className="notification-bell__panel"
|
||||
role="dialog"
|
||||
// Named by its own heading: a dialog with no accessible name is announced as just "dialog".
|
||||
aria-labelledby={headingId}
|
||||
<NotificationPanel
|
||||
onClose={() => setOpen(false)}
|
||||
registry={registry}
|
||||
style={anchor ? { top: anchor.top, right: anchor.right } : undefined}
|
||||
>
|
||||
<h2 className="notification-bell__heading" id={headingId}>
|
||||
{t("notifications.title", "Notifications")}
|
||||
</h2>
|
||||
|
||||
{notifications.length === 0 ? (
|
||||
<p className="notification-bell__empty">
|
||||
{t("notifications.empty", "Nothing to report.")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="notification-bell__list">
|
||||
{notifications.map((notification, index) => (
|
||||
<Fragment key={notification.id}>
|
||||
{index === 0 && dividedAt > 0 && (
|
||||
<li aria-hidden>
|
||||
<DividerWithText
|
||||
text={t("notifications.section.new", "New")}
|
||||
/>
|
||||
</li>
|
||||
)}
|
||||
{/* Only with something on both sides: a lone "Earlier" over everything says
|
||||
nothing the empty badge has not. */}
|
||||
{index === dividedAt && dividedAt > 0 && (
|
||||
<li aria-hidden>
|
||||
<DividerWithText
|
||||
text={t("notifications.section.earlier", "Earlier")}
|
||||
/>
|
||||
</li>
|
||||
)}
|
||||
<NotificationItem
|
||||
notification={notification}
|
||||
unread={index < dividedAt}
|
||||
documentState={documentStateFor(notification)}
|
||||
registry={registry}
|
||||
onDismissPanel={() => setOpen(false)}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
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 } 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;
|
||||
}
|
||||
|
||||
/** Mounted only while open, since mounting is what marks everything read. */
|
||||
export function NotificationPanel({
|
||||
onClose,
|
||||
registry,
|
||||
id,
|
||||
style,
|
||||
className,
|
||||
}: NotificationPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const { notifications, unreadCount, documentStateFor, markAllSeen } =
|
||||
useNotifications();
|
||||
const panel = useRef<HTMLDivElement>(null);
|
||||
const headingId = useId();
|
||||
// Frozen on open, since opening marks them all read.
|
||||
const [firstSeenId, setFirstSeenId] = useState<string | null>(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;
|
||||
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 (
|
||||
<div
|
||||
ref={panel}
|
||||
className={
|
||||
className
|
||||
? `notification-bell__panel ${className}`
|
||||
: "notification-bell__panel"
|
||||
}
|
||||
id={id}
|
||||
role="dialog"
|
||||
tabIndex={-1}
|
||||
aria-labelledby={headingId}
|
||||
style={style}
|
||||
>
|
||||
<h2 className="notification-bell__heading" id={headingId}>
|
||||
{t("notifications.title", "Notifications")}
|
||||
</h2>
|
||||
|
||||
{notifications.length === 0 ? (
|
||||
<p className="notification-bell__empty">
|
||||
{t("notifications.empty", "Nothing to report.")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="notification-bell__list">
|
||||
{notifications.map((notification, index) => (
|
||||
<Fragment key={notification.id}>
|
||||
{index === 0 && dividedAt > 0 && (
|
||||
<li aria-hidden>
|
||||
<DividerWithText
|
||||
text={t("notifications.section.new", "New")}
|
||||
/>
|
||||
</li>
|
||||
)}
|
||||
{/* Only with unread rows above it. */}
|
||||
{index === dividedAt && dividedAt > 0 && (
|
||||
<li aria-hidden>
|
||||
<DividerWithText
|
||||
text={t("notifications.section.earlier", "Earlier")}
|
||||
/>
|
||||
</li>
|
||||
)}
|
||||
<NotificationItem
|
||||
notification={notification}
|
||||
unread={index < dividedAt}
|
||||
documentState={documentStateFor(notification)}
|
||||
registry={registry}
|
||||
onDismissPanel={onClose}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
import { useOnboardingDownload } from "@app/components/onboarding/useOnboardingDownload";
|
||||
import {
|
||||
SLIDE_DEFINITIONS,
|
||||
type SlideId,
|
||||
type ButtonAction,
|
||||
} from "@app/components/onboarding/onboardingFlowConfig";
|
||||
import ToolPanelModePrompt from "@app/components/tools/ToolPanelModePrompt";
|
||||
@@ -322,7 +321,7 @@ export default function Onboarding() {
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return SLIDE_DEFINITIONS[currentStep.slideId as SlideId];
|
||||
return SLIDE_DEFINITIONS[currentStep.slideId];
|
||||
}, [currentStep]);
|
||||
|
||||
const currentSlideContent = useMemo(() => {
|
||||
|
||||
@@ -244,7 +244,7 @@ export class ReorderPagesCommand extends DOMCommand {
|
||||
.map((pageNum) =>
|
||||
currentDoc.pages.find((p) => p.pageNumber === pageNum),
|
||||
)
|
||||
.filter((page) => page !== undefined) as PDFPage[];
|
||||
.filter((page) => page !== undefined);
|
||||
|
||||
const remainingPages = currentDoc.pages.filter(
|
||||
(page) => !this.selectedPages!.includes(page.pageNumber),
|
||||
|
||||
@@ -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<typeof makeStore>) {
|
||||
return render(
|
||||
<FileStoreContext.Provider value={store as never}>
|
||||
<FileActionsContext.Provider
|
||||
value={{ actions, dispatch: vi.fn() } as never}
|
||||
>
|
||||
<WorkbenchSessionPersistence />
|
||||
</FileActionsContext.Provider>
|
||||
</FileStoreContext.Provider>,
|
||||
);
|
||||
}
|
||||
|
||||
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<typeof mount>,
|
||||
store: ReturnType<typeof makeStore>,
|
||||
) =>
|
||||
view.rerender(
|
||||
<FileStoreContext.Provider value={store as never}>
|
||||
<FileActionsContext.Provider
|
||||
value={{ actions, dispatch: vi.fn() } as never}
|
||||
>
|
||||
<WorkbenchSessionPersistence />
|
||||
</FileActionsContext.Provider>
|
||||
</FileStoreContext.Provider>,
|
||||
);
|
||||
|
||||
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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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<string, StirlingFileStub> {
|
||||
const map = new Map<string, StirlingFileStub>();
|
||||
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<typeof setTimeout>;
|
||||
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<string | null>(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<typeof setTimeout> | 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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 <BrandMark>, 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,
|
||||
|
||||
@@ -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 (
|
||||
<Logo
|
||||
variant={collapsed ? "iconOnly" : "iconAndText"}
|
||||
iconHeight="1.6rem"
|
||||
textHeight="1.3rem"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { BrandSwitcher } from "@app/components/shared/BrandSwitcher";
|
||||
|
||||
const meta: Meta<typeof BrandSwitcher> = {
|
||||
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<typeof BrandSwitcher>;
|
||||
|
||||
export const Playground: Story = {};
|
||||
@@ -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 (
|
||||
<div className={`sui-brand-switcher${className ? ` ${className}` : ""}`}>
|
||||
<Dropdown.Root align="start" open={open} onOpenChange={setOpen}>
|
||||
<Dropdown.Trigger>
|
||||
<Button
|
||||
variant="quiet"
|
||||
data-brandmark-morph
|
||||
className={`sui-brand-switcher__trigger${open ? " is-open" : ""}`}
|
||||
aria-label={t("portal.shell.sidebar.switchApp", "Switch app")}
|
||||
leftSection={<BrandMark height="1.6rem" />}
|
||||
>
|
||||
{!collapsed && <Logo variant="textOnly" textHeight="1.3rem" />}
|
||||
</Button>
|
||||
</Dropdown.Trigger>
|
||||
<Dropdown.Menu width="11rem">
|
||||
<AppSwitchMenuItems current={current} onSwitch={onSwitch} />
|
||||
</Dropdown.Menu>
|
||||
</Dropdown.Root>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 256 256"
|
||||
fill="none"
|
||||
style={size ? { width: size, height: size } : undefined}
|
||||
aria-hidden
|
||||
>
|
||||
<rect width="256" height="256" rx="58" fill="var(--c-brand-mark)" />
|
||||
<path
|
||||
d="M39.2638 127.834L155.374 32L155.375 121.499L39.2638 217.333L39.2638 127.834Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M159 124.5L159 88.5L216.728 38.4472L216.728 128.052L100.479 224L100.479 172L159 124.5Z"
|
||||
fill="white"
|
||||
fillOpacity="0.6"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<void>;
|
||||
@@ -155,11 +156,12 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
collapsed = false,
|
||||
onToggleCollapse,
|
||||
onOpenSettings,
|
||||
accountHoisted = false,
|
||||
toggleAriaLabel,
|
||||
toggleIcon,
|
||||
onUploadFiles,
|
||||
onPickGoogleDriveFiles,
|
||||
extraAction,
|
||||
toggleAriaLabel,
|
||||
toggleIcon,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
@@ -249,7 +251,6 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
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<HTMLDivElement, FileSidebarProps>(
|
||||
</div>
|
||||
)}
|
||||
<div className="file-sidebar-inner">
|
||||
<div className="file-sidebar-brand">
|
||||
<AppSwitcher collapsed={collapsed} />
|
||||
{onToggleCollapse && (
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
size="md"
|
||||
className="file-sidebar-collapse-toggle"
|
||||
onClick={() => onToggleCollapse()}
|
||||
aria-label={
|
||||
toggleAriaLabel ??
|
||||
(collapsed
|
||||
? t("fileSidebar.expand", "Expand sidebar")
|
||||
: t("fileSidebar.collapse", "Collapse sidebar"))
|
||||
}
|
||||
>
|
||||
{toggleIcon ?? <SidebarToggleIcon size={18} />}
|
||||
</ActionIcon>
|
||||
)}
|
||||
</div>
|
||||
<SidebarHeader
|
||||
collapsed={collapsed}
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
toggleAriaLabel={toggleAriaLabel}
|
||||
toggleIcon={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<HTMLDivElement, FileSidebarProps>(
|
||||
{/* 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. */}
|
||||
<Tooltip
|
||||
label={t("fileSidebar.openFromComputer", "Open from computer")}
|
||||
@@ -1003,7 +991,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
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<HTMLDivElement, FileSidebarProps>(
|
||||
)}
|
||||
|
||||
<Tooltip
|
||||
label={t("fileSidebar.myFiles", "My Files")}
|
||||
label={t("fileSidebar.myFiles", "File library")}
|
||||
position="right"
|
||||
withinPortal
|
||||
disabled={!collapsed}
|
||||
@@ -1094,7 +1082,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
}}
|
||||
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<HTMLDivElement, FileSidebarProps>(
|
||||
<FolderOpenIcon className="file-sidebar-action-icon" />
|
||||
{!collapsed && (
|
||||
<span className="file-sidebar-action-label sidebar-content-fade">
|
||||
{t("fileSidebar.myFiles", "My Files")}
|
||||
{t("fileSidebar.myFiles", "File library")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -1370,15 +1358,15 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
{/* Getting-started checklist, floating above the footer (SaaS only). */}
|
||||
<SidebarChecklistSlot collapsed={collapsed} />
|
||||
|
||||
{/* Box 3 — the shared footer: credits, app switch, account row. */}
|
||||
{/* Box 3 — the shared footer: credits, plan, and the account row unless hoisted. */}
|
||||
<NavFooter
|
||||
className="file-sidebar-footer-box"
|
||||
displayName={displayName}
|
||||
profilePictureUrl={profilePictureUrl}
|
||||
onOpenSettings={onOpenSettings}
|
||||
showAccount={!accountHoisted}
|
||||
credits={credits}
|
||||
onOpenPlan={openPlan ?? undefined}
|
||||
otherApp={otherApp}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<div className={`file-sidebar-header${className ? ` ${className}` : ""}`}>
|
||||
{!collapsed && <Logo variant="textOnly" textHeight="1.3rem" />}
|
||||
{onToggleCollapse && (
|
||||
<SidebarToggleButton
|
||||
collapsed={collapsed}
|
||||
onToggle={onToggleCollapse}
|
||||
ariaLabel={toggleAriaLabel}
|
||||
icon={toggleIcon}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user