From def3cf79f6145120cff8571c4c23978c5e852ee8 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Fri, 26 Jun 2026 12:08:08 +0100 Subject: [PATCH 01/12] More desktop CI optimisations (#6786) # Description of Changes - Change the nightly build to not sign any of the desktop builds, since we just care about the compiled code. The restored code will still be signed dependent on the OS in the PR builds. - Change RPM Linux to use zstd for compression because the one it was using runs really slowly, and the Jar is already compressed so it makes basically no difference (arguably we shouldn't compress at all) - ~Switch to consistently use Depot for Docker caching to stop filling up the GHA cache and evicting the Rust cache~ Decided against switching to Depot because we're probably doing another PR to remove Depot altogether in the near future --- .github/workflows/nightly.yml | 1 + .github/workflows/tauri-build.yml | 52 ++++++++++++++--------- frontend/editor/src-tauri/tauri.conf.json | 3 +- 3 files changed, 35 insertions(+), 21 deletions(-) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 4a32a0e840..e0b38c6929 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -62,4 +62,5 @@ jobs: uses: ./.github/workflows/tauri-build.yml with: platform: all + sign: false secrets: inherit diff --git a/.github/workflows/tauri-build.yml b/.github/workflows/tauri-build.yml index 944940e973..36c2c598db 100644 --- a/.github/workflows/tauri-build.yml +++ b/.github/workflows/tauri-build.yml @@ -16,6 +16,11 @@ on: required: false type: string default: "all" + sign: + description: "Sign and notarize the bundles." + required: false + type: boolean + default: true workflow_dispatch: inputs: platform: @@ -28,6 +33,11 @@ on: - windows - macos - linux + sign: + description: "Sign and notarize the bundles." + required: false + default: true + type: boolean permissions: contents: read @@ -177,7 +187,7 @@ jobs: # DigiCert KeyLocker Setup (Cloud HSM) - name: Setup DigiCert KeyLocker id: digicert-setup - if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }} + if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }} uses: digicert/ssm-code-signing@1d820463733701cf1484c7eb5d7d24a15ca2c454 # v1.2.1 env: SM_API_KEY: ${{ secrets.SM_API_KEY }} @@ -187,7 +197,7 @@ jobs: SM_HOST: ${{ secrets.SM_HOST }} - name: Setup DigiCert KeyLocker Certificate - if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }} + if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }} shell: pwsh run: | Write-Host "Setting up DigiCert KeyLocker environment..." @@ -222,7 +232,7 @@ jobs: # Traditional PFX Certificate Import (fallback if KeyLocker not configured) - name: Import Windows Code Signing Certificate - if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY == '' && github.ref == 'refs/heads/main' }} + if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY == '' && github.ref == 'refs/heads/main' }} env: WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} @@ -253,7 +263,7 @@ jobs: } - name: Import Apple Developer Certificate - if: matrix.platform == 'macos-15' && env.APPLE_CERTIFICATE != '' + if: inputs.sign && matrix.platform == 'macos-15' && env.APPLE_CERTIFICATE != '' env: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} @@ -274,7 +284,7 @@ jobs: rm certificate.p12 - name: Verify Certificate - if: matrix.platform == 'macos-15' && env.APPLE_CERTIFICATE != '' + if: inputs.sign && matrix.platform == 'macos-15' && env.APPLE_CERTIFICATE != '' run: | echo "Verifying Apple Developer Certificate..." KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db @@ -297,7 +307,7 @@ jobs: ls -la /usr/bin/hd* || echo "No hd* tools found" - name: Preflight smctl - if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }} + if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }} shell: pwsh env: KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }} @@ -310,7 +320,7 @@ jobs: if ($LASTEXITCODE -ne 0) { Write-Host "[WARN] smctl windows certsync returned non-zero - continuing" } - name: Configure Windows code signing - if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }} + if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }} shell: bash env: KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }} @@ -329,7 +339,7 @@ jobs: EOF - name: Import release GPG signing key (Linux) - if: matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main' + if: inputs.sign && matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main' run: | echo "$RELEASE_GPG_PRIVATE_KEY" | gpg --batch --import gpg --list-secret-keys --keyid-format=long @@ -350,19 +360,21 @@ jobs: uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} - APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - APPLE_SIGNING_IDENTITY: ${{ env.APPLE_SIGNING_IDENTITY }} - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + # Apple signing/notarization env is blanked when sign is false so the + # cache warmer compiles an unsigned bundle and skips notarization. + APPLE_CERTIFICATE: ${{ inputs.sign && secrets.APPLE_CERTIFICATE || '' }} + APPLE_CERTIFICATE_PASSWORD: ${{ inputs.sign && secrets.APPLE_CERTIFICATE_PASSWORD || '' }} + APPLE_SIGNING_IDENTITY: ${{ inputs.sign && env.APPLE_SIGNING_IDENTITY || '' }} + APPLE_ID: ${{ inputs.sign && secrets.APPLE_ID || '' }} + APPLE_PASSWORD: ${{ inputs.sign && secrets.APPLE_ID_PASSWORD || '' }} + APPLE_TEAM_ID: ${{ inputs.sign && secrets.APPLE_TEAM_ID || '' }} # AppImage signing β€” three env vars work together: # SIGN=1 tells linuxdeploy-plugin-appimage to forward --sign to appimagetool # APPIMAGETOOL_SIGN_PASSPHRASE appimagetool uses this to unlock the GPG key non-interactively # SIGN_KEY appimagetool picks the key matching this fingerprint # Without SIGN=1, the other two are ignored and the AppImage is built unsigned even if a key is present. - # Mirror the Windows/macOS gate: only sign when secret is present AND ref is main (skips PRs from forks/Dependabot). - SIGN: ${{ (env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main') && '1' || '0' }} + # Mirror the Windows/macOS gate: only sign when enabled AND secret is present AND ref is main (skips PRs from forks/Dependabot and the cache warmer). + SIGN: ${{ (inputs.sign && env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main') && '1' || '0' }} APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.RELEASE_GPG_PASSPHRASE }} SIGN_KEY: ${{ vars.RELEASE_GPG_FINGERPRINT }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} @@ -388,7 +400,7 @@ jobs: uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - SIGN: ${{ (env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main') && '1' || '0' }} + SIGN: ${{ (inputs.sign && env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main') && '1' || '0' }} APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.RELEASE_GPG_PASSPHRASE }} SIGN_KEY: ${{ vars.RELEASE_GPG_FINGERPRINT }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} @@ -403,7 +415,7 @@ jobs: args: --bundles appimage - name: Clear release GPG key from runner keyring (Linux) - if: always() && matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main' + if: always() && inputs.sign && matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main' env: RELEASE_GPG_FINGERPRINT: ${{ vars.RELEASE_GPG_FINGERPRINT }} run: | @@ -413,7 +425,7 @@ jobs: fi - name: Verify notarization (macOS only) - if: matrix.platform == 'macos-15' + if: inputs.sign && matrix.platform == 'macos-15' run: | echo "πŸ” Verifying notarization status..." cd ./frontend/editor/src-tauri/target @@ -451,7 +463,7 @@ jobs: # Verify the MSI AND the inner exe extracted from it are signed. # The inner exe is what gets installed on users' machines and what AV scans. - name: Verify Windows Code Signature - if: matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' + if: inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' shell: pwsh run: | $allSigned = $true diff --git a/frontend/editor/src-tauri/tauri.conf.json b/frontend/editor/src-tauri/tauri.conf.json index 3eb545c231..abbdff32f2 100644 --- a/frontend/editor/src-tauri/tauri.conf.json +++ b/frontend/editor/src-tauri/tauri.conf.json @@ -51,7 +51,8 @@ "desktopTemplate": "stirling-pdf.desktop" }, "rpm": { - "desktopTemplate": "stirling-pdf.desktop" + "desktopTemplate": "stirling-pdf.desktop", + "compression": { "type": "zstd", "level": 3 } }, "appimage": { "bundleMediaFramework": false From 3f7e898c69ce481879138303a712777860b4a47f Mon Sep 17 00:00:00 2001 From: James Brunton Date: Fri, 26 Jun 2026 14:21:53 +0100 Subject: [PATCH 02/12] Add sources service and frontend (#6774) # Description of Changes Redesign policies backend to treat sources a lot closer to how the frontend imagined them working (they're persistent now and have an API). Then connect the portal to the sources when mocks are off to allow for source creation in the UI. It's not particularly useful to do that right now because there's no policies UI, but I've tested manually that sources set up in the UI are usable by policies created via the API. I had to change the portal so that when mocks are off, it doesn't just hard crash when attempting to connect to all the backend APIs that don't exist yet. It'll still log the errors, but just continues on rendering the UI now. I also changed all the policies backend APIs to be gated behind a flag instead of behind the SaaS profile. This is because we haven't yet got the payment model sorted, but we're going to need this stuff running self-hosted to be able to test it locally. --- .taskfiles/backend.yml | 6 +- .taskfiles/frontend.yml | 12 + Taskfile.yml | 4 + .../common/model/ApplicationProperties.java | 5 + .../policy/config/FolderAccessGuard.java | 18 +- .../policy/config/PolicyAccessGuard.java | 18 +- .../policy/controller/PolicyController.java | 40 +- .../policy/engine/PolicyEngine.java | 4 +- .../policy/engine/PolicyRunRegistry.java | 4 +- .../policy/engine/PolicyRunner.java | 38 +- .../policy/engine/PolicyValidator.java | 25 +- .../policy/input/FolderInputSource.java | 4 +- .../proprietary/policy/model/Policy.java | 13 +- .../policy/output/FolderOutputSink.java | 4 +- .../policy/output/InlineOutputSink.java | 4 +- .../policy/source/InProcessSourceStore.java | 58 ++ .../policy/source/JpaSourceStore.java | 81 +++ .../proprietary/policy/source/Source.java | 34 ++ .../policy/source/SourceAccessGuard.java | 63 ++ .../policy/source/SourceController.java | 191 ++++++ .../policy/source/SourceEntity.java | 50 ++ .../proprietary/policy/source/SourceKpi.java | 4 + .../policy/source/SourceOverviewService.java | 118 ++++ .../policy/source/SourceRepository.java | 22 + .../policy/source/SourceStore.java | 21 + .../proprietary/policy/source/SourceView.java | 25 + .../policy/source/SourcesResponse.java | 6 + .../policy/store/InProcessPolicyStore.java | 10 +- .../policy/store/JpaPolicyStore.java | 12 +- .../policy/store/PolicyEntity.java | 8 +- .../policy/store/PolicyRepository.java | 12 + .../proprietary/policy/store/PolicyStore.java | 3 + .../policy/trigger/FolderWatchTrigger.java | 26 +- .../policy/trigger/PolicyTrigger.java | 8 + .../policy/trigger/PolicyTriggerManager.java | 26 +- .../policy/trigger/ScheduleTrigger.java | 4 +- .../configuration/DatabaseConfig.java | 6 +- .../policy/config/FolderAccessGuardTest.java | 27 +- .../policy/config/PolicyAccessGuardTest.java | 25 +- .../controller/PolicyControllerTest.java | 16 +- .../policy/engine/PolicyRunnerTest.java | 17 +- .../policy/engine/PolicyValidatorTest.java | 25 +- .../policy/input/FolderInputSourceTest.java | 5 +- .../policy/output/FolderOutputSinkTest.java | 6 +- .../policy/source/JpaSourceStoreTest.java | 114 ++++ .../policy/source/SourceControllerTest.java | 123 ++++ .../source/SourceOverviewServiceTest.java | 187 ++++++ .../policy/store/JpaPolicyStoreTest.java | 44 +- .../trigger/FolderWatchTriggerTest.java | 50 +- .../resources/application-saas.properties | 4 + .../saas/V22__policy_engine_tables.sql | 35 ++ .../public/locales/en-US/translation.toml | 108 ++-- frontend/portal/src/App.tsx | 19 +- frontend/portal/src/api/http.ts | 18 + frontend/portal/src/api/sources.ts | 97 +++- .../src/components/ErrorBoundary.test.tsx | 64 ++ .../portal/src/components/ErrorBoundary.tsx | 68 +++ .../portal/src/components/PolicySummary.tsx | 4 +- .../components/sources/AgentPanel.stories.tsx | 44 -- .../src/components/sources/AgentPanel.tsx | 98 ---- .../sources/ApiClientPanel.stories.tsx | 52 -- .../src/components/sources/ApiClientPanel.tsx | 81 --- .../sources/ConnectWizard.stories.tsx | 2 +- .../components/sources/ConnectWizard.test.tsx | 125 ++++ .../src/components/sources/ConnectWizard.tsx | 305 +++++++--- .../components/sources/KpiStrip.stories.tsx | 24 +- .../src/components/sources/KpiStrip.tsx | 16 +- .../sources/SourceDetailCard.stories.tsx | 45 +- .../components/sources/SourceDetailCard.tsx | 57 +- .../sources/SourceDetailPanel.stories.tsx | 41 +- .../components/sources/SourceDetailPanel.tsx | 64 +- .../sources/SourcesTable.stories.tsx | 49 +- .../src/components/sources/SourcesTable.tsx | 63 +- .../sources/WebhookPanel.stories.tsx | 51 -- .../src/components/sources/WebhookPanel.tsx | 70 --- .../portal/src/components/sources/format.ts | 4 - .../src/components/sources/sourceTypes.ts | 95 +++ frontend/portal/src/mocks/handlers/sources.ts | 182 +++++- frontend/portal/src/mocks/sources.ts | 545 ------------------ frontend/portal/src/setupTests.ts | 64 ++ frontend/portal/src/views/Sources.test.tsx | 130 +++++ frontend/portal/src/views/Sources.tsx | 144 ++++- frontend/portal/vitest.config.ts | 38 ++ frontend/shared/i18n/translationAudit.ts | 7 +- 84 files changed, 3090 insertions(+), 1349 deletions(-) create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/source/InProcessSourceStore.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/source/JpaSourceStore.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/source/Source.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceAccessGuard.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceController.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceEntity.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceKpi.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceOverviewService.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceRepository.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceStore.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceView.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourcesResponse.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/policy/source/JpaSourceStoreTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceControllerTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceOverviewServiceTest.java create mode 100644 app/saas/src/main/resources/db/migration/saas/V22__policy_engine_tables.sql create mode 100644 frontend/portal/src/components/ErrorBoundary.test.tsx create mode 100644 frontend/portal/src/components/ErrorBoundary.tsx delete mode 100644 frontend/portal/src/components/sources/AgentPanel.stories.tsx delete mode 100644 frontend/portal/src/components/sources/AgentPanel.tsx delete mode 100644 frontend/portal/src/components/sources/ApiClientPanel.stories.tsx delete mode 100644 frontend/portal/src/components/sources/ApiClientPanel.tsx create mode 100644 frontend/portal/src/components/sources/ConnectWizard.test.tsx delete mode 100644 frontend/portal/src/components/sources/WebhookPanel.stories.tsx delete mode 100644 frontend/portal/src/components/sources/WebhookPanel.tsx delete mode 100644 frontend/portal/src/components/sources/format.ts create mode 100644 frontend/portal/src/components/sources/sourceTypes.ts delete mode 100644 frontend/portal/src/mocks/sources.ts create mode 100644 frontend/portal/src/setupTests.ts create mode 100644 frontend/portal/src/views/Sources.test.tsx create mode 100644 frontend/portal/vitest.config.ts diff --git a/.taskfiles/backend.yml b/.taskfiles/backend.yml index 51ae93dc07..2be287bd0f 100644 --- a/.taskfiles/backend.yml +++ b/.taskfiles/backend.yml @@ -26,6 +26,7 @@ tasks: AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}' AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}' SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN}}' + POLICIES_ENABLED: '{{.POLICIES_ENABLED}}' dev:proprietary: desc: "Start backend dev server in proprietary mode" @@ -36,12 +37,13 @@ tasks: AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}' AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}' SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN | default ""}}' + POLICIES_ENABLED: '{{.POLICIES_ENABLED | 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 .POLICIES_ENABLED}}POLICIES_ENABLED={{.POLICIES_ENABLED}} {{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 .POLICIES_ENABLED}}POLICIES_ENABLED={{.POLICIES_ENABLED}} {{end}}./gradlew :stirling-pdf:bootRun' platforms: [linux, darwin] dev:bundled: diff --git a/.taskfiles/frontend.yml b/.taskfiles/frontend.yml index bcc7c07362..1318baf803 100644 --- a/.taskfiles/frontend.yml +++ b/.taskfiles/frontend.yml @@ -404,10 +404,22 @@ tasks: test: desc: "Run tests" + cmds: + - task: test:editor + - task: test:portal + + test:editor: + desc: "Run editor tests" deps: [prepare] cmds: - npx vitest run --root editor + test:portal: + desc: "Run portal tests" + deps: [prepare] + cmds: + - npx vitest run --root portal + test:watch: desc: "Run tests in watch mode" deps: [prepare] diff --git a/Taskfile.yml b/Taskfile.yml index 2c776f7ad1..f04967740f 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -90,6 +90,7 @@ tasks: vars: PORT: '{{.BACKEND_PORT}}' SECURITY_ENABLELOGIN: "true" + POLICIES_ENABLED: "true" - task: frontend:dev:portal vars: PORT: '{{.PORTAL_PORT}}' @@ -109,6 +110,7 @@ tasks: vars: PORT: '{{.BACKEND_PORT}}' SECURITY_ENABLELOGIN: "true" + POLICIES_ENABLED: "true" - task: frontend:dev:portal vars: PORT: '{{.PORTAL_PORT}}' @@ -135,6 +137,7 @@ tasks: vars: PORT: '{{.BACKEND_PORT}}' SECURITY_ENABLELOGIN: "true" + POLICIES_ENABLED: "true" - task: frontend:dev:proprietary vars: PORT: '{{.EDITOR_PORT}}' @@ -210,6 +213,7 @@ tasks: vars: PORT: '{{.BACKEND_PORT}}' SECURITY_ENABLELOGIN: "true" + POLICIES_ENABLED: "true" - task: frontend:preview:portal:proxy vars: PORT: '{{.PROXY_PORT}}' diff --git a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java index 8f7e878aef..7da9d76c6c 100644 --- a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java +++ b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java @@ -206,6 +206,11 @@ public class ApplicationProperties { @Data public static class Policies { + /** + * Master switch for the policy + sources subsystem (the PAYG-metered automation surface). + */ + private boolean enabled = false; + /** * Absolute directories that policy folder input sources and output sinks may read from or * write to. Empty (the default) disables folder access entirely, so a policy can never be diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/FolderAccessGuard.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/FolderAccessGuard.java index 899302f5b0..d61a006cd9 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/FolderAccessGuard.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/FolderAccessGuard.java @@ -4,14 +4,16 @@ import java.nio.file.Path; import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Optional; -import org.springframework.context.annotation.Profile; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.core.env.Environment; import org.springframework.stereotype.Component; import stirling.software.common.configuration.InstallationPathConfig; import stirling.software.common.model.ApplicationProperties; import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.source.SourceStore; /** * Authority on which filesystem locations a policy may read/write. Checked at save time and again @@ -28,7 +30,7 @@ import stirling.software.proprietary.policy.model.Policy; * defended: an operator who roots an allowlist on a symlink to a sensitive location is trusted. */ @Component -@Profile("saas") +@ConditionalOnBooleanProperty(name = "policies.enabled") public class FolderAccessGuard { public static final String FOLDER_TYPE = "folder"; @@ -36,12 +38,17 @@ public class FolderAccessGuard { private final boolean saasActive; private final List allowedRoots; private final List protectedRoots; + private final SourceStore sourceStore; - public FolderAccessGuard(ApplicationProperties applicationProperties, Environment environment) { + public FolderAccessGuard( + ApplicationProperties applicationProperties, + Environment environment, + SourceStore sourceStore) { this.saasActive = Arrays.asList(environment.getActiveProfiles()).contains("saas"); this.allowedRoots = normalizeAll(applicationProperties.getPolicies().getAllowedFolderRoots()); this.protectedRoots = List.of(normalize(Path.of(InstallationPathConfig.getConfigPath()))); + this.sourceStore = sourceStore; } /** Returns the normalised absolute path; throws if not permitted. */ @@ -72,7 +79,10 @@ public class FolderAccessGuard { /** Whether this policy touches a folder source/sink, and so is subject to these rules. */ public boolean usesFolderAccess(Policy policy) { boolean readsFolder = - policy.sources().stream().anyMatch(spec -> FOLDER_TYPE.equals(spec.type())); + policy.sourceIds().stream() + .map(sourceStore::get) + .flatMap(Optional::stream) + .anyMatch(source -> FOLDER_TYPE.equals(source.type())); boolean writesFolder = policy.output() != null && FOLDER_TYPE.equals(policy.output().type()); return readsFolder || writesFolder; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/PolicyAccessGuard.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/PolicyAccessGuard.java index c7b10b941f..061c788052 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/PolicyAccessGuard.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/PolicyAccessGuard.java @@ -3,7 +3,7 @@ package stirling.software.proprietary.policy.config; import java.util.List; import java.util.Objects; -import org.springframework.context.annotation.Profile; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.stereotype.Component; import lombok.RequiredArgsConstructor; @@ -11,6 +11,7 @@ import lombok.RequiredArgsConstructor; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.service.UserServiceInterface; import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.store.PolicyStore; /** * Policies are scoped to a team: a user may view, run, edit, and delete only the policies belonging @@ -22,7 +23,7 @@ import stirling.software.proprietary.policy.model.Policy; */ @Component @RequiredArgsConstructor -@Profile("saas") +@ConditionalOnBooleanProperty(name = "policies.enabled") public class PolicyAccessGuard { private final UserServiceInterface userService; @@ -47,13 +48,16 @@ public class PolicyAccessGuard { return Objects.equals(policy.teamId(), policyManagementAuthority.currentUserTeamId()); } - /** The subset of {@code policies} scoped to the current user's team. */ - public List visible(List policies) { + /** + * The policies visible to the caller: their whole team's, loaded scoped rather than fetched + * globally and filtered, so on SaaS it never pulls another team's policies into memory. Login + * disabled (single-user) returns everything. + */ + public List visibleFrom(PolicyStore store) { if (!enforced()) { - return policies; + return store.all(); } - Long teamId = policyManagementAuthority.currentUserTeamId(); - return policies.stream().filter(policy -> Objects.equals(policy.teamId(), teamId)).toList(); + return store.findByTeam(policyManagementAuthority.currentUserTeamId()); } private boolean enforced() { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java index f2b751bca7..8a06582b58 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java @@ -6,7 +6,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import org.springframework.context.annotation.Profile; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.Resource; import org.springframework.http.HttpStatus; @@ -53,7 +53,10 @@ import stirling.software.proprietary.policy.model.PolicyRun; import stirling.software.proprietary.policy.model.PolicyRunStatus; import stirling.software.proprietary.policy.model.PolicyRunView; import stirling.software.proprietary.policy.progress.PolicyProgressListener; +import stirling.software.proprietary.policy.source.SourceAccessGuard; +import stirling.software.proprietary.policy.source.SourceStore; import stirling.software.proprietary.policy.store.PolicyStore; +import stirling.software.proprietary.policy.trigger.PolicyTriggerManager; /** * Policy CRUD plus pipeline runs (stored or ad-hoc). Runs are async: returns a run id, poll {@code @@ -65,15 +68,18 @@ import stirling.software.proprietary.policy.store.PolicyStore; @Hidden @RequiredArgsConstructor @Tag(name = "Policies", description = "Run tool pipelines on the backend") -@Profile("saas") +@ConditionalOnBooleanProperty(name = "policies.enabled") public class PolicyController { private final PolicyRunner policyRunner; private final PolicyRunRegistry runRegistry; private final PolicyStore policyStore; + private final SourceStore sourceStore; + private final SourceAccessGuard sourceAccessGuard; private final PolicyValidator policyValidator; private final PolicyAccessGuard policyAccessGuard; private final PolicyManagementAuthority policyManagementAuthority; + private final PolicyTriggerManager policyTriggerManager; private final ApplicationProperties applicationProperties; private final TempFileManager tempFileManager; private final JobOwnershipService jobOwnershipService; @@ -187,12 +193,33 @@ public class PolicyController { public ResponseEntity savePolicy(@RequestBody Policy policy) { requirePolicyEditingAllowed(); Policy owned = resolveOwnership(policy); + requireAccessibleSources(owned); try { policyValidator.validate(owned); } catch (IllegalArgumentException e) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage()); } - return ResponseEntity.ok(policyStore.save(owned)); + Policy saved = policyStore.save(owned); + // Re-sync trigger registrations now so a new/changed folder-watch policy starts being + // watched immediately instead of after the next reconcile sweep. + policyTriggerManager.notifyPoliciesChanged(); + return ResponseEntity.ok(saved); + } + + /** + * Every {@code sourceId} a policy references must resolve to a source in the caller's team, so + * a client can neither reference a non-existent source nor reach across teams to use another + * team's connection. A bad reference is a client error. + */ + private void requireAccessibleSources(Policy policy) { + for (String sourceId : policy.sourceIds()) { + boolean accessible = + sourceStore.get(sourceId).filter(sourceAccessGuard::canAccess).isPresent(); + if (!accessible) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "Unknown or inaccessible source: " + sourceId); + } + } } /** @@ -225,7 +252,7 @@ public class PolicyController { owner, policy.enabled(), policy.trigger(), - policy.sources(), + policy.sourceIds(), policy.steps(), policy.output(), teamId); @@ -257,7 +284,7 @@ public class PolicyController { summary = "List policies", description = "Lists the policies belonging to the caller's team.") public List listPolicies() { - return policyAccessGuard.visible(policyStore.all()); + return policyAccessGuard.visibleFrom(policyStore); } @GetMapping("/{policyId}") @@ -278,6 +305,9 @@ public class PolicyController { boolean accessible = policyStore.get(policyId).filter(policyAccessGuard::canAccess).isPresent(); if (accessible && policyStore.delete(policyId)) { + // Cancel any now-orphaned folder watch promptly rather than leaving the WatchKey open + // until the next reconcile sweep. + policyTriggerManager.notifyPoliciesChanged(); return ResponseEntity.noContent().build(); } return ResponseEntity.notFound().build(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java index 28951d52a2..ff5d73efb3 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java @@ -9,7 +9,7 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import org.slf4j.MDC; -import org.springframework.context.annotation.Profile; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.core.io.Resource; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; @@ -54,7 +54,7 @@ import stirling.software.proprietary.service.DownstreamEntitlementError; @Slf4j @Service @RequiredArgsConstructor -@Profile("saas") +@ConditionalOnBooleanProperty(name = "policies.enabled") public class PolicyEngine { // Admission weight for one run. Weighted heavy: a run chains many tools and holds intermediate diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunRegistry.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunRegistry.java index 1c88191396..f0a0a9d6b0 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunRegistry.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunRegistry.java @@ -9,7 +9,7 @@ import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; -import org.springframework.context.annotation.Profile; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.stereotype.Service; import jakarta.annotation.PreDestroy; @@ -29,7 +29,7 @@ import stirling.software.proprietary.policy.model.PolicyRun; */ @Slf4j @Service -@Profile("saas") +@ConditionalOnBooleanProperty(name = "policies.enabled") public class PolicyRunRegistry { private final Map runs = new ConcurrentHashMap<>(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java index 9aa7d284c3..27f43c74da 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java @@ -4,7 +4,7 @@ import java.io.IOException; import java.util.List; import java.util.function.Consumer; -import org.springframework.context.annotation.Profile; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.stereotype.Service; import lombok.RequiredArgsConstructor; @@ -19,33 +19,51 @@ import stirling.software.proprietary.policy.model.PolicyInputs; import stirling.software.proprietary.policy.model.PolicyRun; import stirling.software.proprietary.policy.model.PolicyRunStatus; import stirling.software.proprietary.policy.progress.PolicyProgressListener; +import stirling.software.proprietary.policy.source.Source; +import stirling.software.proprietary.policy.source.SourceStore; /** - * Turns a policy's configured {@link InputSpec sources} into runs. Triggers decide when - * and call {@link #run(Policy)}; the controller uses the supplied-input and ad-hoc entry points. + * Turns a policy's referenced sources into runs: each {@code sourceId} is resolved live to its + * persisted {@link Source}, then to an {@link InputSpec}. Triggers decide when and call + * {@link #run(Policy)}; the controller uses the supplied-input and ad-hoc entry points. */ @Slf4j @Service @RequiredArgsConstructor -@Profile("saas") +@ConditionalOnBooleanProperty(name = "policies.enabled") public class PolicyRunner { private final PolicyEngine policyEngine; private final List inputSources; + private final SourceStore sourceStore; /** - * Trigger entry point. Pulls every configured source; each yielded unit becomes its own run so + * Trigger entry point. Pulls every referenced source; each yielded unit becomes its own run so * one failure does not affect the others. No sources means one run with no input (generator - * pipeline). + * pipeline). Missing or disabled sources are skipped so one broken reference does not stop the + * rest. */ public void run(Policy policy) { - List sources = policy.sources(); - if (sources.isEmpty()) { + List sourceIds = policy.sourceIds(); + if (sourceIds.isEmpty()) { startRun(policy, PolicyInputs.of(List.of()), unused -> {}); return; } - for (InputSpec spec : sources) { - pullAndRun(policy, spec); + for (String sourceId : sourceIds) { + Source source = sourceStore.get(sourceId).orElse(null); + if (source == null) { + log.warn("Policy {} references missing source {}; skipping", policy.id(), sourceId); + continue; + } + if (!source.enabled()) { + log.debug( + "Source {} ({}) is disabled; skipping for policy {}", + sourceId, + source.name(), + policy.id()); + continue; + } + pullAndRun(policy, source.toInputSpec()); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyValidator.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyValidator.java index 6801ff38ce..c08d2dd857 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyValidator.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyValidator.java @@ -2,7 +2,7 @@ package stirling.software.proprietary.policy.engine; import java.util.List; -import org.springframework.context.annotation.Profile; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.stereotype.Service; import lombok.RequiredArgsConstructor; @@ -13,31 +13,44 @@ import stirling.software.proprietary.policy.model.OutputSpec; import stirling.software.proprietary.policy.model.Policy; import stirling.software.proprietary.policy.model.TriggerConfig; import stirling.software.proprietary.policy.output.PolicyOutputSink; +import stirling.software.proprietary.policy.source.Source; +import stirling.software.proprietary.policy.source.SourceStore; import stirling.software.proprietary.policy.trigger.PolicyTrigger; /** * Validates a policy at save time by delegating each facet (trigger, sources, output) to the bean * that handles its type, so a misconfiguration fails fast rather than at run time. A null trigger - * is a manual-only policy and skips trigger validation. + * is a manual-only policy and skips trigger validation. Each referenced {@code sourceId} must + * resolve to a persisted {@link Source} whose config its {@link InputSource} bean accepts. */ @Service @RequiredArgsConstructor -@Profile("saas") +@ConditionalOnBooleanProperty(name = "policies.enabled") public class PolicyValidator { private final List triggers; private final List inputSources; private final List outputSinks; + private final SourceStore sourceStore; /** - * @throws IllegalArgumentException if any facet's type is unknown or its config is invalid + * @throws IllegalArgumentException if any facet's type is unknown, a referenced source does not + * exist, or any config is invalid */ public void validate(Policy policy) { if (policy.trigger() != null) { triggerFor(policy.trigger()).validate(policy); } - for (InputSpec source : policy.sources()) { - inputSourceFor(source).validate(source); + for (String sourceId : policy.sourceIds()) { + Source source = + sourceStore + .get(sourceId) + .orElseThrow( + () -> + new IllegalArgumentException( + "unknown source: " + sourceId)); + InputSpec spec = source.toInputSpec(); + inputSourceFor(spec).validate(spec); } outputSinkFor(policy.output()).validate(policy.output()); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/FolderInputSource.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/FolderInputSource.java index 0990e83c18..c00f5b4a33 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/FolderInputSource.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/FolderInputSource.java @@ -9,7 +9,7 @@ import java.util.List; import java.util.Map; import java.util.stream.Stream; -import org.springframework.context.annotation.Profile; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.Resource; import org.springframework.stereotype.Service; @@ -34,7 +34,7 @@ import stirling.software.proprietary.policy.model.PolicyInputs; @Slf4j @Service @RequiredArgsConstructor -@Profile("saas") +@ConditionalOnBooleanProperty(name = "policies.enabled") public class FolderInputSource implements InputSource { private static final String TYPE = FolderAccessGuard.FOLDER_TYPE; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java index 6d5366eb47..51dcc4ac0c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java @@ -6,8 +6,9 @@ import java.util.List; * A stored automation: ordered tool steps, input sources, and an output destination. * *

Always runnable on demand. An optional {@link TriggerConfig} fires it automatically; a {@code - * null} trigger means manual-only. Trigger decides when, {@link InputSpec sources} decide where - * files come from; a run pulls from every source. + * null} trigger means manual-only. Trigger decides when; {@code sourceIds} reference the persisted + * {@code Source} connections (resolved live at run time) that decide where files come from; a run + * pulls from every referenced source. */ public record Policy( String id, @@ -15,13 +16,13 @@ public record Policy( String owner, boolean enabled, TriggerConfig trigger, - List sources, + List sourceIds, List steps, OutputSpec output, Long teamId) { public Policy { - sources = sources == null ? List.of() : List.copyOf(sources); + sourceIds = sourceIds == null ? List.of() : List.copyOf(sourceIds); steps = steps == null ? List.of() : steps; output = output == null ? OutputSpec.inline() : output; } @@ -36,10 +37,10 @@ public record Policy( String owner, boolean enabled, TriggerConfig trigger, - List sources, + List sourceIds, List steps, OutputSpec output) { - this(id, name, owner, enabled, trigger, sources, steps, output, null); + this(id, name, owner, enabled, trigger, sourceIds, steps, output, null); } /** A policy with no configured sources (a generator, or files supplied directly to a run). */ diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/FolderOutputSink.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/FolderOutputSink.java index 94a9d34a35..8821a2940b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/FolderOutputSink.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/FolderOutputSink.java @@ -9,7 +9,7 @@ import java.util.List; import java.util.UUID; import org.apache.commons.io.FilenameUtils; -import org.springframework.context.annotation.Profile; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.core.io.Resource; import org.springframework.http.MediaType; import org.springframework.http.MediaTypeFactory; @@ -31,7 +31,7 @@ import stirling.software.proprietary.policy.model.OutputSpec; @Slf4j @Service @RequiredArgsConstructor -@Profile("saas") +@ConditionalOnBooleanProperty(name = "policies.enabled") public class FolderOutputSink implements PolicyOutputSink { static final String TYPE = FolderAccessGuard.FOLDER_TYPE; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/InlineOutputSink.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/InlineOutputSink.java index 799072aeaa..0fcd7323ff 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/InlineOutputSink.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/InlineOutputSink.java @@ -5,7 +5,7 @@ import java.io.InputStream; import java.util.ArrayList; import java.util.List; -import org.springframework.context.annotation.Profile; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.core.io.Resource; import org.springframework.http.MediaType; import org.springframework.http.MediaTypeFactory; @@ -23,7 +23,7 @@ import stirling.software.proprietary.policy.model.OutputSpec; */ @Service @RequiredArgsConstructor -@Profile("saas") +@ConditionalOnBooleanProperty(name = "policies.enabled") public class InlineOutputSink implements PolicyOutputSink { private static final String TYPE = "inline"; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/InProcessSourceStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/InProcessSourceStore.java new file mode 100644 index 0000000000..0d3897c529 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/InProcessSourceStore.java @@ -0,0 +1,58 @@ +package stirling.software.proprietary.policy.source; + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * In-memory {@link SourceStore} for tests and any future no-database mode. {@link JpaSourceStore} + * is the runtime bean. + */ +public class InProcessSourceStore implements SourceStore { + + private final Map sources = new ConcurrentHashMap<>(); + + @Override + public Source save(Source source) { + String id = + source.id() == null || source.id().isBlank() + ? UUID.randomUUID().toString() + : source.id(); + Source stored = + new Source( + id, + source.name(), + source.type(), + source.options(), + source.enabled(), + source.owner(), + source.teamId()); + sources.put(id, stored); + return stored; + } + + @Override + public Optional get(String id) { + return Optional.ofNullable(sources.get(id)); + } + + @Override + public List all() { + return List.copyOf(sources.values()); + } + + @Override + public List findByTeam(Long teamId) { + return sources.values().stream() + .filter(source -> Objects.equals(source.teamId(), teamId)) + .toList(); + } + + @Override + public boolean delete(String id) { + return sources.remove(id) != null; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/JpaSourceStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/JpaSourceStore.java new file mode 100644 index 0000000000..18bbf5cbdc --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/JpaSourceStore.java @@ -0,0 +1,81 @@ +package stirling.software.proprietary.policy.source; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; + +import tools.jackson.databind.ObjectMapper; + +/** + * Durable {@link SourceStore} backed by JPA; the runtime store. Sources are persisted as JSON via + * {@link SourceEntity}, with scalar columns kept in sync for querying. + */ +@Service +@RequiredArgsConstructor +@ConditionalOnBooleanProperty(name = "policies.enabled") +public class JpaSourceStore implements SourceStore { + + private final SourceRepository repository; + private final ObjectMapper objectMapper; + + @Override + public Source save(Source source) { + String id = + source.id() == null || source.id().isBlank() + ? UUID.randomUUID().toString() + : source.id(); + Source stored = + new Source( + id, + source.name(), + source.type(), + source.options(), + source.enabled(), + source.owner(), + source.teamId()); + + SourceEntity entity = new SourceEntity(); + entity.setId(id); + entity.setName(stored.name()); + entity.setType(stored.type()); + entity.setOwner(stored.owner()); + entity.setTeamId(stored.teamId()); + entity.setEnabled(stored.enabled()); + entity.setSourceJson(objectMapper.writeValueAsString(stored)); + repository.save(entity); + return stored; + } + + @Override + public Optional get(String id) { + return repository.findById(id).map(this::toSource); + } + + @Override + public List all() { + return repository.findAll().stream().map(this::toSource).toList(); + } + + @Override + public List findByTeam(Long teamId) { + return repository.findByTeam(teamId).stream().map(this::toSource).toList(); + } + + @Override + public boolean delete(String id) { + if (!repository.existsById(id)) { + return false; + } + repository.deleteById(id); + return true; + } + + private Source toSource(SourceEntity entity) { + return objectMapper.readValue(entity.getSourceJson(), Source.class); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/Source.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/Source.java new file mode 100644 index 0000000000..ca80c1b46f --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/Source.java @@ -0,0 +1,34 @@ +package stirling.software.proprietary.policy.source; + +import java.util.Map; + +import stirling.software.proprietary.policy.model.InputSpec; + +/** + * A persisted, reusable input connection: the instantiation of a source definition. Policies + * reference sources by {@code id} rather than embedding their config, so one connection is + * configured once and can feed many policies. + * + *

{@code type} keys an {@link stirling.software.proprietary.policy.input.InputSource} bean, + * matching {@link InputSpec#type()}; {@code options} is that source's config. {@code owner} and + * {@code teamId} scope the source to a team, mirroring {@link + * stirling.software.proprietary.policy.model.Policy}. + */ +public record Source( + String id, + String name, + String type, + Map options, + boolean enabled, + String owner, + Long teamId) { + + public Source { + options = options == null ? Map.of() : options; + } + + /** The runtime form the policy engine resolves and runs against. */ + public InputSpec toInputSpec() { + return new InputSpec(type, options); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceAccessGuard.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceAccessGuard.java new file mode 100644 index 0000000000..1db5663fd2 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceAccessGuard.java @@ -0,0 +1,63 @@ +package stirling.software.proprietary.policy.source; + +import java.util.List; +import java.util.Objects; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.service.UserServiceInterface; +import stirling.software.proprietary.policy.config.PolicyManagementAuthority; + +/** + * Sources are scoped to a team exactly like policies: a user may view, edit, and delete only the + * sources belonging to their own team (the team a source is stamped with at creation). Enforced + * only when login is enabled; single-user deployments (login disabled) pass every check. Mirrors + * {@link stirling.software.proprietary.policy.config.PolicyAccessGuard}. + */ +@Component +@RequiredArgsConstructor +@ConditionalOnBooleanProperty(name = "policies.enabled") +public class SourceAccessGuard { + + private final UserServiceInterface userService; + private final ApplicationProperties applicationProperties; + private final PolicyManagementAuthority policyManagementAuthority; + + /** Owner for a new source: the current user, or {@code null} when login is disabled. */ + public String ownerForNewSource() { + return enforced() ? userService.getCurrentUsername() : null; + } + + /** Team a new source is stamped with: the creator's team. {@code null} when login disabled. */ + public Long teamForNewSource() { + return enforced() ? policyManagementAuthority.currentUserTeamId() : null; + } + + /** Whether the source belongs to the current user's team (so they may view/edit it). */ + public boolean canAccess(Source source) { + if (!enforced()) { + return true; + } + return Objects.equals(source.teamId(), policyManagementAuthority.currentUserTeamId()); + } + + /** + * The sources visible to the caller: their whole team's, loaded scoped rather than fetched + * globally and filtered, so on SaaS it never pulls another team's sources into memory. Login + * disabled (single-user) returns everything. + */ + public List visibleFrom(SourceStore store) { + if (!enforced()) { + return store.all(); + } + return store.findByTeam(policyManagementAuthority.currentUserTeamId()); + } + + private boolean enforced() { + return applicationProperties.getSecurity().isEnableLogin(); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceController.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceController.java new file mode 100644 index 0000000000..5755eb332f --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceController.java @@ -0,0 +1,191 @@ +package stirling.software.proprietary.policy.source; + +import java.util.List; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +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 org.springframework.web.server.ResponseStatusException; + +import io.swagger.v3.oas.annotations.Hidden; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +import lombok.RequiredArgsConstructor; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.policy.config.PolicyAccessGuard; +import stirling.software.proprietary.policy.config.PolicyManagementAuthority; +import stirling.software.proprietary.policy.input.InputSource; +import stirling.software.proprietary.policy.model.InputSpec; +import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.store.PolicyStore; +import stirling.software.proprietary.policy.trigger.PolicyTriggerManager; + +/** + * CRUD for persisted, reusable input connections plus the Sources overview for the admin portal. A + * source is configured once here and referenced by id from any number of policies; the overview + * reports how many reference each one. Editing follows the same team-leader rule as policies, and + * everything is scoped to the caller's team. + */ +@RestController +@RequestMapping("/api/v1/sources") +@Hidden +@RequiredArgsConstructor +@Tag(name = "Sources", description = "Reusable policy input connections") +@ConditionalOnBooleanProperty(name = "policies.enabled") +public class SourceController { + + private final SourceStore sourceStore; + private final SourceAccessGuard sourceAccessGuard; + private final SourceOverviewService overviewService; + private final PolicyStore policyStore; + private final PolicyAccessGuard policyAccessGuard; + private final PolicyManagementAuthority policyManagementAuthority; + private final PolicyTriggerManager policyTriggerManager; + private final ApplicationProperties applicationProperties; + private final List inputSources; + + @GetMapping + @Operation( + summary = "Sources overview", + description = + "Returns the KPI strip plus one row per source the caller's team owns, each with" + + " how many policies reference it and which.") + public SourcesResponse list() { + return overviewService.overview(); + } + + @GetMapping("/{sourceId}") + @Operation(summary = "Get a source by id") + public ResponseEntity get(@PathVariable String sourceId) { + return sourceStore + .get(sourceId) + .filter(sourceAccessGuard::canAccess) + .map(ResponseEntity::ok) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + + @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) + @Operation( + summary = "Create or update a source", + description = + "Stores an input connection (type + config). A blank id is assigned; owner and" + + " team are stamped server-side. The config is validated against the" + + " matching source type.") + public ResponseEntity save(@RequestBody Source source) { + requireSourceEditingAllowed(); + Source owned = resolveOwnership(source); + try { + validateConfig(owned); + } catch (IllegalArgumentException e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage()); + } + Source saved = sourceStore.save(owned); + // An edited folder source can change which directory needs watching, so re-sync trigger + // registrations now instead of waiting for the next reconcile. + policyTriggerManager.notifyPoliciesChanged(); + return ResponseEntity.ok(saved); + } + + @DeleteMapping("/{sourceId}") + @Operation( + summary = "Delete a source", + description = + "Removes a source that no policy references. A source still in use returns 409" + + " so the connection can't be pulled out from under a live policy.") + public ResponseEntity delete(@PathVariable String sourceId) { + requireSourceEditingAllowed(); + Source source = sourceStore.get(sourceId).filter(sourceAccessGuard::canAccess).orElse(null); + if (source == null) { + return ResponseEntity.notFound().build(); + } + List referencing = referencingPolicyNames(sourceId); + if (!referencing.isEmpty()) { + throw new ResponseStatusException( + HttpStatus.CONFLICT, + "Source is referenced by " + + referencing.size() + + " policy(ies): " + + String.join(", ", referencing)); + } + sourceStore.delete(sourceId); + return ResponseEntity.noContent().build(); + } + + /** + * Stamp owner + team server-side. Create stamps the current user and their team; update + * preserves the existing owner and team after verifying the source belongs to the caller's + * team, so the client can neither forge ownership on create nor reach across teams on update (a + * source in another team reads as not-found). + */ + private Source resolveOwnership(Source incoming) { + String id = incoming.id(); + if (id != null && !id.isBlank()) { + Source existing = sourceStore.get(id).orElse(null); + if (existing != null) { + if (!sourceAccessGuard.canAccess(existing)) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No source: " + id); + } + return withOwnerAndTeam(incoming, existing.owner(), existing.teamId()); + } + } + return withOwnerAndTeam( + incoming, + sourceAccessGuard.ownerForNewSource(), + sourceAccessGuard.teamForNewSource()); + } + + private static Source withOwnerAndTeam(Source source, String owner, Long teamId) { + return new Source( + source.id(), + source.name(), + source.type(), + source.options(), + source.enabled(), + owner, + teamId); + } + + /** Validate the config against the bean that handles the source's type, as the engine will. */ + private void validateConfig(Source source) { + InputSpec spec = source.toInputSpec(); + inputSources.stream() + .filter(inputSource -> inputSource.supports(spec)) + .findFirst() + .orElseThrow( + () -> new IllegalArgumentException("unknown source type: " + source.type())) + .validate(spec); + } + + /** + * Editing sources requires the editor role for the caller's team (a team leader on SaaS), the + * same rule as policies. Single-user deployments (login disabled) trust the local operator. + */ + private void requireSourceEditingAllowed() { + if (!applicationProperties.getSecurity().isEnableLogin()) { + return; + } + if (!policyManagementAuthority.canEditPolicies()) { + throw new ResponseStatusException( + HttpStatus.FORBIDDEN, + "Sources may only be created or modified by a team leader"); + } + } + + /** Names of the caller's visible policies that reference the given source. */ + private List referencingPolicyNames(String sourceId) { + return policyAccessGuard.visibleFrom(policyStore).stream() + .filter(policy -> policy.sourceIds().contains(sourceId)) + .map(Policy::name) + .toList(); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceEntity.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceEntity.java new file mode 100644 index 0000000000..412f862f89 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceEntity.java @@ -0,0 +1,50 @@ +package stirling.software.proprietary.policy.source; + +import java.io.Serializable; + +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; + +/** + * JPA row for a {@link Source}. The whole source lives as JSON in {@code sourceJson} (authoritative + * on read); the scalar columns are denormalized copies for querying. {@code owner} and {@code + * teamId} are plain values, not foreign keys, to stay decoupled from the security entities - + * matching {@link stirling.software.proprietary.policy.store.PolicyEntity}. + */ +@Entity +@Table(name = "policy_sources") +@NoArgsConstructor +@Getter +@Setter +public class SourceEntity implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id + @Column(name = "id") + private String id; + + @Column(name = "name") + private String name; + + @Column(name = "type") + private String type; + + @Column(name = "owner") + private String owner; + + @Column(name = "team_id") + private Long teamId; + + @Column(name = "enabled") + private boolean enabled; + + @Column(name = "source_json", columnDefinition = "text") + private String sourceJson; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceKpi.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceKpi.java new file mode 100644 index 0000000000..20f97757a2 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceKpi.java @@ -0,0 +1,4 @@ +package stirling.software.proprietary.policy.source; + +/** One headline figure in the Sources overview strip. */ +public record SourceKpi(long value, String description) {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceOverviewService.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceOverviewService.java new file mode 100644 index 0000000000..b9bc7c1cb2 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceOverviewService.java @@ -0,0 +1,118 @@ +package stirling.software.proprietary.policy.source; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; + +import stirling.software.proprietary.policy.config.PolicyAccessGuard; +import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.store.PolicyStore; + +/** + * Builds the Sources overview: every persisted source the caller's team owns, shown exactly once, + * each annotated with the policies that reference it. Reference counts are derived by scanning the + * team's policies in memory rather than persisted on the source - fine at admin-dashboard scale and + * always consistent with the live policy set. + */ +@Service +@RequiredArgsConstructor +@ConditionalOnBooleanProperty(name = "policies.enabled") +public class SourceOverviewService { + + private final SourceStore sourceStore; + private final PolicyStore policyStore; + private final SourceAccessGuard sourceAccessGuard; + private final PolicyAccessGuard policyAccessGuard; + + public SourcesResponse overview() { + List sources = sourceAccessGuard.visibleFrom(sourceStore); + List policies = policyAccessGuard.visibleFrom(policyStore); + + Map> referencesBySource = referencesBySource(policies); + + List views = + sources.stream() + .map( + source -> + toView( + source, + referencesBySource.getOrDefault( + source.id(), List.of()))) + .sorted( + Comparator.comparingInt(SourceView::referenceCount) + .reversed() + .thenComparing(SourceView::name)) + .toList(); + + return new SourcesResponse(buildKpis(views), views); + } + + /** Policies referencing each source id, across the caller's visible policies. */ + private static Map> referencesBySource(List policies) { + Map> bySource = new HashMap<>(); + for (Policy policy : policies) { + for (String sourceId : policy.sourceIds()) { + bySource.computeIfAbsent(sourceId, key -> new ArrayList<>()).add(policy); + } + } + return bySource; + } + + private static SourceView toView(Source source, List referencingPolicies) { + List refs = + referencingPolicies.stream() + .map(policy -> new SourceView.PolicyRef(policy.id(), policy.name())) + .toList(); + return new SourceView( + source.id(), + source.name(), + source.type(), + deriveStatus(source, refs.size()), + refs.size(), + refs, + configRows(source), + null); + } + + /** A disabled (paused) source reads as "disabled"; an unreferenced one reads as "unused". */ + private static String deriveStatus(Source source, int referenceCount) { + if (!source.enabled()) { + return "disabled"; + } + return referenceCount == 0 ? "unused" : "active"; + } + + /** Generic key/value view of the source's config - works for any source type. */ + private static List configRows(Source source) { + return source.options().entrySet().stream() + .map( + entry -> + new SourceView.DetailRow( + humanize(entry.getKey()), String.valueOf(entry.getValue()))) + .toList(); + } + + private static String humanize(String key) { + if (key == null || key.isBlank()) { + return key; + } + return Character.toUpperCase(key.charAt(0)) + key.substring(1); + } + + private static List buildKpis(List sources) { + long total = sources.size(); + long inUse = sources.stream().filter(source -> source.referenceCount() > 0).count(); + long orphaned = total - inUse; + return List.of( + new SourceKpi(total, "connections"), + new SourceKpi(inUse, "referenced by a policy"), + new SourceKpi(orphaned, "unused")); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceRepository.java new file mode 100644 index 0000000000..b8968fea1a --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceRepository.java @@ -0,0 +1,22 @@ +package stirling.software.proprietary.policy.source; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository +public interface SourceRepository extends JpaRepository { + + /** + * Sources belonging to a team, loaded without scanning every team's rows. A {@code null} teamId + * matches the rows with no team (login-disabled / pre-team data), mirroring the in-memory team + * filter rather than the empty result a plain {@code = null} would give. + */ + @Query( + "select s from SourceEntity s where (:teamId is null and s.teamId is null) or" + + " s.teamId = :teamId") + List findByTeam(@Param("teamId") Long teamId); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceStore.java new file mode 100644 index 0000000000..64d6fe5a6c --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceStore.java @@ -0,0 +1,21 @@ +package stirling.software.proprietary.policy.source; + +import java.util.List; +import java.util.Optional; + +/** Stores {@link Source} definitions (persisted, reusable input connections). */ +public interface SourceStore { + + /** Create or update; a blank/absent id is assigned. Returns the stored source. */ + Source save(Source source); + + Optional get(String id); + + List all(); + + /** Sources owned by the given team, loaded scoped rather than fetched globally. */ + List findByTeam(Long teamId); + + /** Returns whether the source existed. */ + boolean delete(String id); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceView.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceView.java new file mode 100644 index 0000000000..bb1fdbf9d0 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceView.java @@ -0,0 +1,25 @@ +package stirling.software.proprietary.policy.source; + +import java.util.List; + +/** + * One row in the Sources overview: a persisted input connection shown exactly once, with how many + * policies reference it (and which). {@code docsTotal} is {@code null} - per-source document volume + * is not tracked yet; the field is reserved so a later doc-accounting pass is additive. + */ +public record SourceView( + String id, + String name, + String type, + String status, + int referenceCount, + List referencingPolicies, + List config, + Long docsTotal) { + + /** A policy that references this source. */ + public record PolicyRef(String id, String name) {} + + /** A key/value line summarising the source's config for display. */ + public record DetailRow(String label, String value) {} +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourcesResponse.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourcesResponse.java new file mode 100644 index 0000000000..5f50877e9b --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourcesResponse.java @@ -0,0 +1,6 @@ +package stirling.software.proprietary.policy.source; + +import java.util.List; + +/** The Sources overview payload: a KPI strip plus one row per source. */ +public record SourcesResponse(List kpis, List sources) {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java index d4a1259d3d..b6d882bd6d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java @@ -2,6 +2,7 @@ package stirling.software.proprietary.policy.store; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; @@ -29,7 +30,7 @@ public class InProcessPolicyStore implements PolicyStore { policy.owner(), policy.enabled(), policy.trigger(), - policy.sources(), + policy.sourceIds(), policy.steps(), policy.output(), policy.teamId()); @@ -47,6 +48,13 @@ public class InProcessPolicyStore implements PolicyStore { return List.copyOf(policies.values()); } + @Override + public List findByTeam(Long teamId) { + return policies.values().stream() + .filter(policy -> Objects.equals(policy.teamId(), teamId)) + .toList(); + } + @Override public List findByTriggerType(String triggerType) { return policies.values().stream() diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java index 085a97b5a7..4de6ee2f3a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java @@ -4,7 +4,7 @@ import java.util.List; import java.util.Optional; import java.util.UUID; -import org.springframework.context.annotation.Profile; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.stereotype.Service; import lombok.RequiredArgsConstructor; @@ -19,7 +19,7 @@ import tools.jackson.databind.ObjectMapper; */ @Service @RequiredArgsConstructor -@Profile("saas") +@ConditionalOnBooleanProperty(name = "policies.enabled") public class JpaPolicyStore implements PolicyStore { private final PolicyRepository repository; @@ -38,7 +38,7 @@ public class JpaPolicyStore implements PolicyStore { policy.owner(), policy.enabled(), policy.trigger(), - policy.sources(), + policy.sourceIds(), policy.steps(), policy.output(), policy.teamId()); @@ -49,6 +49,7 @@ public class JpaPolicyStore implements PolicyStore { entity.setOwner(stored.owner()); entity.setEnabled(stored.enabled()); entity.setTriggerType(stored.trigger() == null ? null : stored.trigger().type()); + entity.setTeamId(stored.teamId()); entity.setPolicyJson(objectMapper.writeValueAsString(stored)); repository.save(entity); return stored; @@ -64,6 +65,11 @@ public class JpaPolicyStore implements PolicyStore { return repository.findAll().stream().map(this::toPolicy).toList(); } + @Override + public List findByTeam(Long teamId) { + return repository.findByTeam(teamId).stream().map(this::toPolicy).toList(); + } + @Override public List findByTriggerType(String triggerType) { return repository.findByTriggerTypeAndEnabledTrue(triggerType).stream() diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyEntity.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyEntity.java index 944b494611..4b99a90e6c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyEntity.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyEntity.java @@ -15,8 +15,9 @@ import lombok.Setter; * JPA row for a {@link stirling.software.proprietary.policy.model.Policy}. The whole policy lives * as JSON in {@code policyJson} (authoritative on read); the scalar columns are denormalized copies * for querying, notably {@code triggerType} + {@code enabled} so background triggers can fetch - * their policies. {@code owner} is a plain string, not a foreign key, to stay decoupled from the - * security entities. + * their policies, and {@code teamId} so the caller's team can be loaded without scanning every + * team's rows. {@code owner} and {@code teamId} are plain values, not foreign keys, to stay + * decoupled from the security entities. */ @Entity @Table(name = "policies") @@ -43,6 +44,9 @@ public class PolicyEntity implements Serializable { @Column(name = "trigger_type") private String triggerType; + @Column(name = "team_id") + private Long teamId; + @Column(name = "policy_json", columnDefinition = "text") private String policyJson; } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyRepository.java index ba6924f0f8..8cb36b73d8 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyRepository.java @@ -3,6 +3,8 @@ package stirling.software.proprietary.policy.store; import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @Repository @@ -10,4 +12,14 @@ public interface PolicyRepository extends JpaRepository { /** Enabled policies of a given trigger type, for background triggers to activate. */ List findByTriggerTypeAndEnabledTrue(String triggerType); + + /** + * Policies belonging to a team, loaded without scanning every team's rows. A {@code null} + * teamId matches the rows with no team (login-disabled / pre-team data), mirroring the + * in-memory team filter rather than the empty result a plain {@code = null} would give. + */ + @Query( + "select p from PolicyEntity p where (:teamId is null and p.teamId is null) or" + + " p.teamId = :teamId") + List findByTeam(@Param("teamId") Long teamId); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyStore.java index c9a2a0ecf7..67796d4a67 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyStore.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyStore.java @@ -15,6 +15,9 @@ public interface PolicyStore { List all(); + /** Policies owned by the given team, loaded scoped rather than fetched globally. */ + List findByTeam(Long teamId); + /** Enabled policies with the given trigger type, for background triggers. */ List findByTriggerType(String triggerType); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/FolderWatchTrigger.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/FolderWatchTrigger.java index 864160d4b6..35c530fad0 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/FolderWatchTrigger.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/FolderWatchTrigger.java @@ -20,7 +20,7 @@ import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; -import org.springframework.context.annotation.Profile; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.stereotype.Service; import lombok.RequiredArgsConstructor; @@ -31,6 +31,8 @@ import stirling.software.proprietary.policy.engine.PolicyRunner; import stirling.software.proprietary.policy.input.InputSource; import stirling.software.proprietary.policy.model.InputSpec; import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.source.Source; +import stirling.software.proprietary.policy.source.SourceStore; import stirling.software.proprietary.policy.store.PolicyStore; /** @@ -46,7 +48,7 @@ import stirling.software.proprietary.policy.store.PolicyStore; @Slf4j @Service @RequiredArgsConstructor -@Profile("saas") +@ConditionalOnBooleanProperty(name = "policies.enabled") public class FolderWatchTrigger implements PolicyTrigger { private static final String TYPE = "folder-watch"; @@ -54,6 +56,7 @@ public class FolderWatchTrigger implements PolicyTrigger { private final PolicyStore policyStore; private final PolicyRunner policyRunner; private final List inputSources; + private final SourceStore sourceStore; private final ApplicationProperties applicationProperties; private final Map keysByDir = new ConcurrentHashMap<>(); @@ -120,6 +123,14 @@ public class FolderWatchTrigger implements PolicyTrigger { dirByKey.clear(); } + @Override + public void onPoliciesChanged() { + // A created/updated/deleted policy may add or drop a watched directory: register/cancel now + // instead of waiting up to watchReconcileSeconds for the next reconcile. A no-op until the + // trigger is started (watchService null), where the first reconcile picks everything up. + syncRegistrations(); + } + private void watchLoop() { // Capture once: stop() may null the field; close() still wakes take()/poll() on this local. WatchService watcher = watchService; @@ -269,12 +280,17 @@ public class FolderWatchTrigger implements PolicyTrigger { // the path was configured. private List watchDirsOf(Policy policy) { List dirs = new ArrayList<>(); - for (InputSpec spec : policy.sources()) { - InputSource source = sourceFor(spec); + for (String sourceId : policy.sourceIds()) { + Source source = sourceStore.get(sourceId).orElse(null); if (source == null) { continue; } - for (Path dir : source.watchTargets(spec)) { + InputSpec spec = source.toInputSpec(); + InputSource inputSource = sourceFor(spec); + if (inputSource == null) { + continue; + } + for (Path dir : inputSource.watchTargets(spec)) { dirs.add(dir.toAbsolutePath().normalize()); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/PolicyTrigger.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/PolicyTrigger.java index a9cb363719..a97a9ac880 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/PolicyTrigger.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/PolicyTrigger.java @@ -20,4 +20,12 @@ public interface PolicyTrigger { default void start() {} default void stop() {} + + /** + * React to a policy being created, updated, or deleted. A trigger that caches per-policy state + * (folder-watch tracks which directories to watch) re-syncs it now, so a new policy is acted on + * immediately and a deleted one stops at once, rather than waiting for the next periodic sweep. + * Default no-op for triggers that read the store fresh on every fire. + */ + default void onPoliciesChanged() {} } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/PolicyTriggerManager.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/PolicyTriggerManager.java index c1175eeb92..7971e6b490 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/PolicyTriggerManager.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/PolicyTriggerManager.java @@ -2,8 +2,8 @@ package stirling.software.proprietary.policy.trigger; import java.util.List; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.context.SmartLifecycle; -import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; import lombok.RequiredArgsConstructor; @@ -13,7 +13,7 @@ import lombok.extern.slf4j.Slf4j; @Slf4j @Service @RequiredArgsConstructor -@Profile("saas") +@ConditionalOnBooleanProperty(name = "policies.enabled") public class PolicyTriggerManager implements SmartLifecycle { private final List triggers; @@ -48,4 +48,26 @@ public class PolicyTriggerManager implements SmartLifecycle { public boolean isRunning() { return running; } + + /** + * Tell every trigger that the policy set changed so cached registrations refresh promptly + * instead of waiting for the next periodic reconcile. Best-effort and idempotent: a failing + * trigger is logged and the rest still run; a no-op before the subsystem has started. + */ + public void notifyPoliciesChanged() { + if (!running) { + return; + } + for (PolicyTrigger trigger : triggers) { + try { + trigger.onPoliciesChanged(); + } catch (RuntimeException e) { + log.error( + "Failed to refresh trigger '{}' after policy change: {}", + trigger.type(), + e.getMessage(), + e); + } + } + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/ScheduleTrigger.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/ScheduleTrigger.java index 708ca8128b..3e747d7169 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/ScheduleTrigger.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/ScheduleTrigger.java @@ -10,7 +10,7 @@ import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; -import org.springframework.context.annotation.Profile; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.stereotype.Service; import lombok.RequiredArgsConstructor; @@ -32,7 +32,7 @@ import tools.jackson.databind.ObjectMapper; @Slf4j @Service @RequiredArgsConstructor -@Profile("saas") +@ConditionalOnBooleanProperty(name = "policies.enabled") public class ScheduleTrigger implements PolicyTrigger { private static final String TYPE = "schedule"; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java index 454df5f67e..3c713206de 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java @@ -32,14 +32,16 @@ import stirling.software.common.model.exception.UnsupportedProviderException; "stirling.software.proprietary.repository", "stirling.software.proprietary.storage.repository", "stirling.software.proprietary.workflow.repository", - "stirling.software.proprietary.policy.store" + "stirling.software.proprietary.policy.store", + "stirling.software.proprietary.policy.source" }) @EntityScan({ "stirling.software.proprietary.security.model", "stirling.software.proprietary.model", "stirling.software.proprietary.storage.model", "stirling.software.proprietary.workflow.model", - "stirling.software.proprietary.policy.store" + "stirling.software.proprietary.policy.store", + "stirling.software.proprietary.policy.source" }) public class DatabaseConfig { diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/FolderAccessGuardTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/FolderAccessGuardTest.java index ea33319a8b..4859954e71 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/FolderAccessGuardTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/FolderAccessGuardTest.java @@ -17,6 +17,9 @@ import stirling.software.common.model.ApplicationProperties; import stirling.software.proprietary.policy.model.InputSpec; import stirling.software.proprietary.policy.model.OutputSpec; import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.source.InProcessSourceStore; +import stirling.software.proprietary.policy.source.Source; +import stirling.software.proprietary.policy.source.SourceStore; /** * Tests for {@link FolderAccessGuard}: folder access is fail-closed, confined to the configured @@ -26,12 +29,14 @@ class FolderAccessGuardTest { @TempDir Path tempDir; + private final SourceStore sourceStore = new InProcessSourceStore(); + private FolderAccessGuard guard(List allowedRoots, String... activeProfiles) { ApplicationProperties properties = new ApplicationProperties(); properties.getPolicies().setAllowedFolderRoots(allowedRoots); StandardEnvironment environment = new StandardEnvironment(); environment.setActiveProfiles(activeProfiles); - return new FolderAccessGuard(properties, environment); + return new FolderAccessGuard(properties, environment, sourceStore); } @Test @@ -93,7 +98,23 @@ class FolderAccessGuardTest { assertFalse(guard.usesFolderAccess(policy(List.of(), OutputSpec.inline()))); } - private static Policy policy(List sources, OutputSpec output) { - return new Policy("p1", "p", "owner", true, null, sources, List.of(), output); + private Policy policy(List sources, OutputSpec output) { + List sourceIds = + sources.stream() + .map( + spec -> + sourceStore + .save( + new Source( + null, + "src", + spec.type(), + spec.options(), + true, + "owner", + null)) + .id()) + .toList(); + return new Policy("p1", "p", "owner", true, null, sourceIds, List.of(), output); } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/PolicyAccessGuardTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/PolicyAccessGuardTest.java index 9f86b87aec..35c4d2631e 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/PolicyAccessGuardTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/config/PolicyAccessGuardTest.java @@ -17,6 +17,8 @@ import stirling.software.common.model.ApplicationProperties; import stirling.software.common.service.UserServiceInterface; import stirling.software.proprietary.policy.model.OutputSpec; import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.store.InProcessPolicyStore; +import stirling.software.proprietary.policy.store.PolicyStore; /** * {@link PolicyAccessGuard}: policies are scoped to the caller's team. A user sees/accesses only @@ -36,18 +38,27 @@ class PolicyAccessGuardTest { } @Test - void visibleFiltersToTheCallersTeam() { + void visibleFromLoadsOnlyTheCallersTeam() { when(policyManagementAuthority.currentUserTeamId()).thenReturn(1L); - List all = List.of(inTeam(1L), inTeam(2L), inTeam(1L), inTeam(null)); - List visible = guard(true).visible(all); + PolicyStore store = new InProcessPolicyStore(); + store.save(inTeam(1L)); + store.save(inTeam(2L)); + store.save(inTeam(1L)); + store.save(inTeam(null)); + + List visible = guard(true).visibleFrom(store); + assertEquals(2, visible.size()); assertTrue(visible.stream().allMatch(p -> Long.valueOf(1L).equals(p.teamId()))); } @Test - void visibleReturnsEverythingWhenLoginDisabled() { - List all = List.of(inTeam(1L), inTeam(2L)); - assertEquals(all, guard(false).visible(all)); + void visibleFromReturnsEverythingWhenLoginDisabled() { + PolicyStore store = new InProcessPolicyStore(); + store.save(inTeam(1L)); + store.save(inTeam(2L)); + + assertEquals(2, guard(false).visibleFrom(store).size()); } @Test @@ -79,6 +90,6 @@ class PolicyAccessGuardTest { private static Policy inTeam(Long teamId) { return new Policy( - "p1", "p", "owner", true, null, List.of(), List.of(), OutputSpec.inline(), teamId); + null, "p", "owner", true, null, List.of(), List.of(), OutputSpec.inline(), teamId); } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java index 9325c7de51..98ed101246 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java @@ -40,6 +40,9 @@ import stirling.software.proprietary.policy.model.Policy; import stirling.software.proprietary.policy.model.PolicyRun; import stirling.software.proprietary.policy.model.PolicyRunView; import stirling.software.proprietary.policy.progress.PolicyProgressListener; +import stirling.software.proprietary.policy.source.SourceAccessGuard; +import stirling.software.proprietary.policy.source.SourceStore; +import stirling.software.proprietary.policy.trigger.PolicyTriggerManager; @ExtendWith(MockitoExtension.class) @DisplayName("PolicyController") @@ -48,9 +51,12 @@ class PolicyControllerTest { @Mock private PolicyRunner policyRunner; @Mock private PolicyRunRegistry runRegistry; @Mock private stirling.software.proprietary.policy.store.PolicyStore policyStore; + @Mock private SourceStore sourceStore; + @Mock private SourceAccessGuard sourceAccessGuard; @Mock private PolicyValidator policyValidator; @Mock private PolicyAccessGuard policyAccessGuard; @Mock private PolicyManagementAuthority policyManagementAuthority; + @Mock private PolicyTriggerManager policyTriggerManager; @Mock private TempFileManager tempFileManager; @Mock private JobOwnershipService jobOwnershipService; @@ -65,9 +71,12 @@ class PolicyControllerTest { policyRunner, runRegistry, policyStore, + sourceStore, + sourceAccessGuard, policyValidator, policyAccessGuard, policyManagementAuthority, + policyTriggerManager, applicationProperties, tempFileManager, jobOwnershipService); @@ -214,6 +223,7 @@ class PolicyControllerTest { assertThat(response.getBody().owner()).isEqualTo("alice"); assertThat(response.getBody().teamId()).isEqualTo(7L); verify(policyValidator).validate(any()); + verify(policyTriggerManager).notifyPoliciesChanged(); } @Test @@ -229,6 +239,7 @@ class PolicyControllerTest { assertThat(((ResponseStatusException) e).getStatusCode()) .isEqualTo(HttpStatus.FORBIDDEN)); verify(policyStore, never()).save(any()); + verify(policyTriggerManager, never()).notifyPoliciesChanged(); } @Test @@ -295,8 +306,7 @@ class PolicyControllerTest { @DisplayName("listPolicies returns team-visible policies") void listVisible() { List all = List.of(policy("a", 1L), policy("b", 1L)); - when(policyStore.all()).thenReturn(all); - when(policyAccessGuard.visible(all)).thenReturn(all); + when(policyAccessGuard.visibleFrom(policyStore)).thenReturn(all); List result = controller.listPolicies(); @@ -355,6 +365,7 @@ class PolicyControllerTest { ResponseEntity response = controller.deletePolicy("a"); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + verify(policyTriggerManager).notifyPoliciesChanged(); } @Test @@ -369,6 +380,7 @@ class PolicyControllerTest { assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); verify(policyStore, never()).delete(any()); + verify(policyTriggerManager, never()).notifyPoliciesChanged(); } @Test diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunnerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunnerTest.java index 2cefb1b27c..cff169581b 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunnerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunnerTest.java @@ -33,6 +33,9 @@ import stirling.software.proprietary.policy.model.PolicyInputs; import stirling.software.proprietary.policy.model.PolicyRun; import stirling.software.proprietary.policy.model.PolicyRunStatus; import stirling.software.proprietary.policy.progress.PolicyProgressListener; +import stirling.software.proprietary.policy.source.InProcessSourceStore; +import stirling.software.proprietary.policy.source.Source; +import stirling.software.proprietary.policy.source.SourceStore; /** * Tests for {@link PolicyRunner}: the one place that turns a policy's sources into runs. Verifies @@ -45,11 +48,12 @@ class PolicyRunnerTest { @Mock private PolicyEngine policyEngine; @Mock private InputSource folderSource; + private final SourceStore sourceStore = new InProcessSourceStore(); private PolicyRunner runner; @BeforeEach void setUp() { - runner = new PolicyRunner(policyEngine, List.of(folderSource)); + runner = new PolicyRunner(policyEngine, List.of(folderSource), sourceStore); } @Test @@ -145,15 +149,22 @@ class PolicyRunnerTest { verifyNoInteractions(folderSource); } - private static Policy policy(List sources) { + /** Persists each spec as a source and returns a policy referencing them by id. */ + private Policy policy(List sources) { + List sourceIds = + sources.stream().map(spec -> sourceStore.save(sourceFrom(spec)).id()).toList(); return new Policy( "p1", "p", "owner", true, null, - sources, + sourceIds, List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), OutputSpec.inline()); } + + private static Source sourceFrom(InputSpec spec) { + return new Source(null, "src", spec.type(), spec.options(), true, "owner", null); + } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyValidatorTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyValidatorTest.java index edaa586511..8cdb1b45a3 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyValidatorTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyValidatorTest.java @@ -23,6 +23,9 @@ import stirling.software.proprietary.policy.model.OutputSpec; import stirling.software.proprietary.policy.model.Policy; import stirling.software.proprietary.policy.model.TriggerConfig; import stirling.software.proprietary.policy.output.PolicyOutputSink; +import stirling.software.proprietary.policy.source.InProcessSourceStore; +import stirling.software.proprietary.policy.source.Source; +import stirling.software.proprietary.policy.source.SourceStore; import stirling.software.proprietary.policy.trigger.PolicyTrigger; /** Tests for {@link PolicyValidator}: routes each facet to its handler and surfaces failures. */ @@ -33,12 +36,14 @@ class PolicyValidatorTest { @Mock private InputSource inputSource; @Mock private PolicyOutputSink outputSink; + private final SourceStore sourceStore = new InProcessSourceStore(); private PolicyValidator validator; @BeforeEach void setUp() { validator = - new PolicyValidator(List.of(trigger), List.of(inputSource), List.of(outputSink)); + new PolicyValidator( + List.of(trigger), List.of(inputSource), List.of(outputSink), sourceStore); } @Test @@ -51,7 +56,7 @@ class PolicyValidatorTest { validator.validate(policy); verify(trigger).validate(policy); - verify(inputSource).validate(policy.sources().get(0)); + verify(inputSource).validate(InputSpec.folder("/in")); verify(outputSink).validate(policy.output()); } @@ -88,27 +93,35 @@ class PolicyValidatorTest { assertTrue(ex.getMessage().contains("unknown trigger type")); } - private static Policy policy(String triggerType) { + private Policy policy(String triggerType) { return new Policy( "p1", "p", "owner", true, new TriggerConfig(triggerType, Map.of()), - List.of(InputSpec.folder("/in")), + List.of(folderSourceId()), List.of(), OutputSpec.inline()); } - private static Policy manualOnly() { + private Policy manualOnly() { return new Policy( "p1", "p", "owner", true, null, - List.of(InputSpec.folder("/in")), + List.of(folderSourceId()), List.of(), OutputSpec.inline()); } + + /** Persists a folder source ("/in") and returns its id for a policy to reference. */ + private String folderSourceId() { + InputSpec spec = InputSpec.folder("/in"); + return sourceStore + .save(new Source(null, "src", spec.type(), spec.options(), true, "owner", null)) + .id(); + } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/FolderInputSourceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/FolderInputSourceTest.java index 3e9d34ccf0..62bfa3aabb 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/FolderInputSourceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/FolderInputSourceTest.java @@ -25,6 +25,7 @@ import stirling.software.common.model.ApplicationProperties; import stirling.software.common.util.FileReadinessChecker; import stirling.software.proprietary.policy.config.FolderAccessGuard; import stirling.software.proprietary.policy.model.InputSpec; +import stirling.software.proprietary.policy.source.InProcessSourceStore; /** Tests for {@link FolderInputSource}: consume (claim + route) and snapshot (read-only) modes. */ @ExtendWith(MockitoExtension.class) @@ -40,7 +41,9 @@ class FolderInputSourceTest { void setUp() { ApplicationProperties properties = new ApplicationProperties(); properties.getPolicies().setAllowedFolderRoots(List.of(tempDir.toString())); - FolderAccessGuard guard = new FolderAccessGuard(properties, new StandardEnvironment()); + FolderAccessGuard guard = + new FolderAccessGuard( + properties, new StandardEnvironment(), new InProcessSourceStore()); source = new FolderInputSource(readinessChecker, guard); // Lenient: the missing-dir / nonexistent-dir cases return before any readiness check. lenient().when(readinessChecker.isReady(any())).thenReturn(true); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/FolderOutputSinkTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/FolderOutputSinkTest.java index 0d714162d7..f1b8b599ac 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/FolderOutputSinkTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/FolderOutputSinkTest.java @@ -22,6 +22,7 @@ import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.job.ResultFile; import stirling.software.proprietary.policy.config.FolderAccessGuard; import stirling.software.proprietary.policy.model.OutputSpec; +import stirling.software.proprietary.policy.source.InProcessSourceStore; /** Tests for {@link FolderOutputSink}: outputs are written to the configured directory on disk. */ class FolderOutputSinkTest { @@ -34,7 +35,10 @@ class FolderOutputSinkTest { void setUp() { ApplicationProperties properties = new ApplicationProperties(); properties.getPolicies().setAllowedFolderRoots(List.of(tempDir.toString())); - sink = new FolderOutputSink(new FolderAccessGuard(properties, new StandardEnvironment())); + sink = + new FolderOutputSink( + new FolderAccessGuard( + properties, new StandardEnvironment(), new InProcessSourceStore())); } @Test diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/JpaSourceStoreTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/JpaSourceStoreTest.java new file mode 100644 index 0000000000..c5f8c9c936 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/JpaSourceStoreTest.java @@ -0,0 +1,114 @@ +package stirling.software.proprietary.policy.source; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; +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 tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; + +/** + * Tests for {@link JpaSourceStore}'s entity mapping. The repository is mocked; real Hibernate/H2 + * persistence is exercised at application boot, mirroring {@link + * stirling.software.proprietary.policy.store.JpaPolicyStore}'s test convention. + */ +@ExtendWith(MockitoExtension.class) +class JpaSourceStoreTest { + + @Mock private SourceRepository repository; + + private final ObjectMapper objectMapper = JsonMapper.builder().build(); + private JpaSourceStore store; + + @BeforeEach + void setUp() { + store = new JpaSourceStore(repository, objectMapper); + } + + @Test + void saveAssignsAnIdAndPersistsTheSourceAsJson() { + Source saved = + store.save( + new Source( + null, + "Claims intake", + "folder", + Map.of("directory", "/in/claims"), + true, + "alice", + 7L)); + + assertNotNull(saved.id()); + ArgumentCaptor captor = ArgumentCaptor.forClass(SourceEntity.class); + verify(repository).save(captor.capture()); + SourceEntity entity = captor.getValue(); + assertEquals(saved.id(), entity.getId()); + assertEquals("folder", entity.getType()); + assertEquals("alice", entity.getOwner()); + assertEquals(Long.valueOf(7L), entity.getTeamId()); + assertTrue(entity.isEnabled()); + // The stored JSON round-trips back to an equal source. + assertEquals(saved, objectMapper.readValue(entity.getSourceJson(), Source.class)); + } + + @Test + void getDeserializesTheSourceFromJson() { + Source source = + new Source("s1", "Claims", "folder", Map.of("directory", "/in"), true, "alice", 1L); + when(repository.findById("s1")).thenReturn(Optional.of(entityFor(source))); + + assertEquals(source, store.get("s1").orElseThrow()); + } + + @Test + void allDeserializesEverySource() { + Source a = new Source("a", "A", "folder", Map.of("directory", "/a"), true, "alice", 1L); + when(repository.findAll()).thenReturn(List.of(entityFor(a))); + + assertEquals(List.of(a), store.all()); + } + + @Test + void findByTeamDelegatesToTheScopedQuery() { + Source mine = new Source("a", "A", "folder", Map.of("directory", "/a"), true, "alice", 7L); + when(repository.findByTeam(7L)).thenReturn(List.of(entityFor(mine))); + + assertEquals(List.of(mine), store.findByTeam(7L)); + } + + @Test + void deleteReturnsWhetherTheSourceExisted() { + when(repository.existsById("s1")).thenReturn(true); + assertTrue(store.delete("s1")); + verify(repository).deleteById("s1"); + + when(repository.existsById("missing")).thenReturn(false); + assertFalse(store.delete("missing")); + } + + private SourceEntity entityFor(Source source) { + SourceEntity entity = new SourceEntity(); + entity.setId(source.id()); + entity.setName(source.name()); + entity.setType(source.type()); + entity.setOwner(source.owner()); + entity.setTeamId(source.teamId()); + entity.setEnabled(source.enabled()); + entity.setSourceJson(objectMapper.writeValueAsString(source)); + return entity; + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceControllerTest.java new file mode 100644 index 0000000000..c01df84a55 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceControllerTest.java @@ -0,0 +1,123 @@ +package stirling.software.proprietary.policy.source; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +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.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.ResponseEntity; +import org.springframework.web.server.ResponseStatusException; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.service.UserServiceInterface; +import stirling.software.proprietary.policy.config.PolicyAccessGuard; +import stirling.software.proprietary.policy.config.PolicyManagementAuthority; +import stirling.software.proprietary.policy.input.InputSource; +import stirling.software.proprietary.policy.model.OutputSpec; +import stirling.software.proprietary.policy.model.PipelineStep; +import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.store.InProcessPolicyStore; +import stirling.software.proprietary.policy.store.PolicyStore; +import stirling.software.proprietary.policy.trigger.PolicyTriggerManager; + +/** + * Tests for {@link SourceController}'s delete guard: a source still referenced by a policy is + * protected (409), while an unreferenced one is removed. Login is disabled, so editing and team + * scoping pass through and the reference check is exercised on its own. + */ +class SourceControllerTest { + + private final SourceStore sourceStore = new InProcessSourceStore(); + private final PolicyStore policyStore = new InProcessPolicyStore(); + private PolicyTriggerManager triggerManager; + private SourceController controller; + + @BeforeEach + void setUp() { + ApplicationProperties properties = new ApplicationProperties(); + properties.getSecurity().setEnableLogin(false); + UserServiceInterface userService = mock(UserServiceInterface.class); + PolicyManagementAuthority authority = mock(PolicyManagementAuthority.class); + SourceAccessGuard sourceGuard = new SourceAccessGuard(userService, properties, authority); + PolicyAccessGuard policyGuard = new PolicyAccessGuard(userService, properties, authority); + SourceOverviewService overviewService = + new SourceOverviewService(sourceStore, policyStore, sourceGuard, policyGuard); + triggerManager = mock(PolicyTriggerManager.class); + // A permissive input source so config validation passes and save can be exercised. + InputSource folderInput = mock(InputSource.class); + when(folderInput.supports(any())).thenReturn(true); + controller = + new SourceController( + sourceStore, + sourceGuard, + overviewService, + policyStore, + policyGuard, + authority, + triggerManager, + properties, + List.of(folderInput)); + } + + @Test + void deletingAReferencedSourceConflicts() { + Source source = sourceStore.save(folderSource()); + policyStore.save(policyReferencing("Redact incoming", source.id())); + + ResponseStatusException ex = + assertThrows(ResponseStatusException.class, () -> controller.delete(source.id())); + + assertEquals(409, ex.getStatusCode().value()); + assertTrue(sourceStore.get(source.id()).isPresent()); + } + + @Test + void deletingAnUnreferencedSourceSucceeds() { + Source source = sourceStore.save(folderSource()); + + ResponseEntity response = controller.delete(source.id()); + + assertEquals(204, response.getStatusCode().value()); + assertTrue(sourceStore.get(source.id()).isEmpty()); + // A deletable source is referenced by no policy, so no watch registration can change. + verify(triggerManager, never()).notifyPoliciesChanged(); + } + + @Test + void savingASourceReSyncsTriggerRegistrations() { + controller.save(folderSource()); + + verify(triggerManager).notifyPoliciesChanged(); + } + + @Test + void deletingAMissingSourceIsNotFound() { + assertEquals(404, controller.delete("nope").getStatusCode().value()); + } + + private static Source folderSource() { + return new Source( + null, "Claims intake", "folder", Map.of("directory", "/in"), true, "owner", null); + } + + private static Policy policyReferencing(String name, String sourceId) { + return new Policy( + null, + name, + "owner", + true, + null, + List.of(sourceId), + List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), + OutputSpec.inline()); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceOverviewServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceOverviewServiceTest.java new file mode 100644 index 0000000000..986fd8c636 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceOverviewServiceTest.java @@ -0,0 +1,187 @@ +package stirling.software.proprietary.policy.source; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.service.UserServiceInterface; +import stirling.software.proprietary.policy.config.PolicyAccessGuard; +import stirling.software.proprietary.policy.config.PolicyManagementAuthority; +import stirling.software.proprietary.policy.model.OutputSpec; +import stirling.software.proprietary.policy.model.PipelineStep; +import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.store.InProcessPolicyStore; +import stirling.software.proprietary.policy.store.PolicyStore; + +/** + * Tests for {@link SourceOverviewService}: each source appears exactly once, annotated with the + * policies that reference it. Login is disabled, so the team guards pass everything through and the + * reference counting is exercised directly. + */ +class SourceOverviewServiceTest { + + private final SourceStore sourceStore = new InProcessSourceStore(); + private final PolicyStore policyStore = new InProcessPolicyStore(); + private SourceOverviewService service; + + @BeforeEach + void setUp() { + ApplicationProperties properties = new ApplicationProperties(); + properties.getSecurity().setEnableLogin(false); + UserServiceInterface userService = mock(UserServiceInterface.class); + PolicyManagementAuthority authority = mock(PolicyManagementAuthority.class); + SourceAccessGuard sourceGuard = new SourceAccessGuard(userService, properties, authority); + PolicyAccessGuard policyGuard = new PolicyAccessGuard(userService, properties, authority); + service = new SourceOverviewService(sourceStore, policyStore, sourceGuard, policyGuard); + } + + @Test + void eachSourceAppearsOnceWithItsReferenceCount() { + Source a = source("A", "/a"); + Source b = source("B", "/b"); + Source c = source("C unused", "/c"); + policyReferencing("P1", a.id()); + policyReferencing("P2", a.id(), b.id()); + + SourcesResponse response = service.overview(); + + assertEquals(3, response.sources().size()); + // Sorted most-referenced first, so the shared source A leads. + assertEquals(a.id(), response.sources().get(0).id()); + + SourceView av = find(response, a.id()); + assertEquals(2, av.referenceCount()); + assertEquals("active", av.status()); + assertTrue( + av.referencingPolicies().stream() + .map(SourceView.PolicyRef::name) + .toList() + .containsAll(List.of("P1", "P2"))); + assertTrue( + av.config().stream() + .anyMatch( + row -> + row.label().equals("Directory") + && row.value().equals("/a"))); + + assertEquals(1, find(response, b.id()).referenceCount()); + + SourceView cv = find(response, c.id()); + assertEquals(0, cv.referenceCount()); + assertEquals("unused", cv.status()); + + // KPI strip: total, in-use, orphaned. + assertEquals(List.of(3L, 2L, 1L), response.kpis().stream().map(SourceKpi::value).toList()); + } + + @Test + void aDisabledSourceReadsAsDisabled() { + Source disabled = + sourceStore.save( + new Source( + null, + "Paused", + "folder", + Map.of("directory", "/d"), + false, + "owner", + null)); + + assertEquals("disabled", find(service.overview(), disabled.id()).status()); + } + + @Test + void overviewLoadsOnlyTheCallersTeam() { + // Login on, caller is on team 1. Another team's source and policy must be invisible, and a + // cross-team policy referencing our source must not inflate its reference count. + ApplicationProperties properties = new ApplicationProperties(); + properties.getSecurity().setEnableLogin(true); + UserServiceInterface userService = mock(UserServiceInterface.class); + PolicyManagementAuthority authority = mock(PolicyManagementAuthority.class); + when(authority.currentUserTeamId()).thenReturn(1L); + SourceAccessGuard sourceGuard = new SourceAccessGuard(userService, properties, authority); + PolicyAccessGuard policyGuard = new PolicyAccessGuard(userService, properties, authority); + SourceOverviewService scoped = + new SourceOverviewService(sourceStore, policyStore, sourceGuard, policyGuard); + + Source ours = teamSource("Ours", "/ours", 1L); + teamSource("Theirs", "/theirs", 2L); + teamPolicy("Our policy", 1L, ours.id()); + teamPolicy("Their policy", 2L, ours.id()); + + SourcesResponse response = scoped.overview(); + + assertEquals(1, response.sources().size()); + SourceView view = response.sources().get(0); + assertEquals(ours.id(), view.id()); + assertEquals(1, view.referenceCount()); + assertEquals(List.of(1L, 1L, 0L), response.kpis().stream().map(SourceKpi::value).toList()); + } + + @Test + void documentVolumeIsNotTrackedYet() { + Source a = source("A", "/a"); + assertNull(find(service.overview(), a.id()).docsTotal()); + } + + private Source source(String name, String directory) { + return sourceStore.save( + new Source( + null, name, "folder", Map.of("directory", directory), true, "owner", null)); + } + + private Source teamSource(String name, String directory, Long teamId) { + return sourceStore.save( + new Source( + null, + name, + "folder", + Map.of("directory", directory), + true, + "owner", + teamId)); + } + + private void policyReferencing(String name, String... sourceIds) { + policyStore.save( + new Policy( + null, + name, + "owner", + true, + null, + List.of(sourceIds), + List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), + OutputSpec.inline())); + } + + private void teamPolicy(String name, Long teamId, String... sourceIds) { + policyStore.save( + new Policy( + null, + name, + "owner", + true, + null, + List.of(sourceIds), + List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), + OutputSpec.inline(), + teamId)); + } + + private static SourceView find(SourcesResponse response, String id) { + return response.sources().stream() + .filter(view -> view.id().equals(id)) + .findFirst() + .orElseThrow(); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/store/JpaPolicyStoreTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/store/JpaPolicyStoreTest.java index 2bdbc08a79..e9667f2e8d 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/store/JpaPolicyStoreTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/store/JpaPolicyStoreTest.java @@ -18,7 +18,6 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import stirling.software.proprietary.policy.model.InputSpec; import stirling.software.proprietary.policy.model.OutputSpec; import stirling.software.proprietary.policy.model.PipelineStep; import stirling.software.proprietary.policy.model.Policy; @@ -55,7 +54,7 @@ class JpaPolicyStoreTest { "alice", true, new TriggerConfig("schedule", Map.of()), - List.of(InputSpec.folder("/in")), + List.of("src-in"), List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), OutputSpec.inline())); @@ -88,6 +87,46 @@ class JpaPolicyStoreTest { assertEquals(policy, store.get("p1").orElseThrow()); } + @Test + void saveDenormalizesTeamIdForScopedQueries() { + store.save( + new Policy( + "p1", + "scoped", + "alice", + true, + null, + List.of(), + List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), + OutputSpec.inline(), + 9L)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(PolicyEntity.class); + verify(repository).save(captor.capture()); + assertEquals(Long.valueOf(9L), captor.getValue().getTeamId()); + } + + @Test + void findByTeamDelegatesToTheScopedQuery() { + Policy policy = + new Policy( + "p1", + "ours", + "alice", + true, + null, + List.of(), + List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), + OutputSpec.inline(), + 9L); + when(repository.findByTeam(9L)).thenReturn(List.of(entityFor(policy))); + + List mine = store.findByTeam(9L); + + assertEquals(1, mine.size()); + assertEquals("p1", mine.get(0).id()); + } + @Test void findByTriggerTypeUsesTheEnabledQuery() { Policy policy = @@ -125,6 +164,7 @@ class JpaPolicyStoreTest { entity.setOwner(policy.owner()); entity.setEnabled(policy.enabled()); entity.setTriggerType(policy.trigger() == null ? null : policy.trigger().type()); + entity.setTeamId(policy.teamId()); entity.setPolicyJson(objectMapper.writeValueAsString(policy)); return entity; } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/trigger/FolderWatchTriggerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/trigger/FolderWatchTriggerTest.java index 9b0b9f1d2a..58c5123327 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/trigger/FolderWatchTriggerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/trigger/FolderWatchTriggerTest.java @@ -32,6 +32,9 @@ import stirling.software.proprietary.policy.model.OutputSpec; import stirling.software.proprietary.policy.model.PipelineStep; import stirling.software.proprietary.policy.model.Policy; import stirling.software.proprietary.policy.model.TriggerConfig; +import stirling.software.proprietary.policy.source.InProcessSourceStore; +import stirling.software.proprietary.policy.source.Source; +import stirling.software.proprietary.policy.source.SourceStore; import stirling.software.proprietary.policy.store.PolicyStore; /** @@ -50,6 +53,7 @@ class FolderWatchTriggerTest { @TempDir Path tempDir; + private final SourceStore sourceStore = new InProcessSourceStore(); private FolderWatchTrigger trigger; @BeforeEach @@ -59,6 +63,7 @@ class FolderWatchTriggerTest { policyStore, policyRunner, List.of(folderSource), + sourceStore, new ApplicationProperties()); lenient().when(folderSource.supports(any())).thenReturn(true); lenient() @@ -160,18 +165,59 @@ class FolderWatchTriggerTest { } } + @Test + void onPoliciesChangedSyncsRegistrationsImmediately() throws Exception { + Path dir = Files.createDirectories(tempDir.resolve("watched")); + Policy p = folderWatch("p", List.of(InputSpec.folder(dir.toString()))); + + WatchService service = FileSystems.getDefault().newWatchService(); + try { + trigger.watchService = service; + when(policyStore.findByTriggerType("folder-watch")).thenReturn(List.of(p)); + + // The mutation hook registers the new policy's directory without waiting for a + // reconcile. + trigger.onPoliciesChanged(); + assertEquals(Set.of(normalized(dir.toString())), trigger.watchedDirs()); + + // Once the policy is gone, the same hook cancels its registration. + when(policyStore.findByTriggerType("folder-watch")).thenReturn(List.of()); + trigger.onPoliciesChanged(); + assertEquals(Set.of(), trigger.watchedDirs()); + } finally { + service.close(); + } + } + private static Path normalized(String dir) { return Path.of(dir).toAbsolutePath().normalize(); } - private static Policy folderWatch(String id, List sources) { + /** Persists each spec as a source and returns a folder-watch policy referencing them by id. */ + private Policy folderWatch(String id, List sources) { + List sourceIds = + sources.stream() + .map( + spec -> + sourceStore + .save( + new Source( + null, + "src", + spec.type(), + spec.options(), + true, + "owner", + null)) + .id()) + .toList(); return new Policy( id, "watcher", "owner", true, new TriggerConfig("folder-watch", Map.of()), - sources, + sourceIds, List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), OutputSpec.inline()); } diff --git a/app/saas/src/main/resources/application-saas.properties b/app/saas/src/main/resources/application-saas.properties index 63ba016950..1984974fef 100644 --- a/app/saas/src/main/resources/application-saas.properties +++ b/app/saas/src/main/resources/application-saas.properties @@ -65,6 +65,10 @@ supabase.url=https://${app.supabase.project-ref}.supabase.co spring.security.oauth2.resourceserver.jwt.jwk-set-uri=https://${app.supabase.project-ref}.supabase.co/auth/v1/.well-known/jwks.json spring.security.oauth2.resourceserver.jwt.audiences=${app.supabase.expected-aud} +# ---------- Policies ---------- +# Exposes the /api/v1/policies and /api/v1/sources controllers, engine, stores, and triggers. +policies.enabled=true + # ---------- Multi-tenant scoping ---------- # Restrict the signing user picker to the caller's team; SaaS must be 'team' # or unrelated tenants leak emails to each other. diff --git a/app/saas/src/main/resources/db/migration/saas/V22__policy_engine_tables.sql b/app/saas/src/main/resources/db/migration/saas/V22__policy_engine_tables.sql new file mode 100644 index 0000000000..babdaef562 --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V22__policy_engine_tables.sql @@ -0,0 +1,35 @@ +-- Policy engine schema (gated by policies.enabled): persisted policies and the reusable input +-- connections ("sources") they reference by id. The whole policy/source lives as JSON in the +-- *_json column (authoritative on read); the scalar columns are denormalized copies for querying, +-- notably team_id so a caller's team can be loaded without scanning every team's rows. owner and +-- team_id are plain values, not foreign keys, to stay decoupled from the security entities (so this +-- subsystem can be enabled or disabled without touching them). Hibernate ddl-auto would also create +-- these, but this keeps the schema explicit for the Flyway-managed deployments. + +CREATE TABLE IF NOT EXISTS policies ( + id VARCHAR(255) PRIMARY KEY, + name VARCHAR(255), + owner VARCHAR(255), + enabled BOOLEAN NOT NULL DEFAULT FALSE, + trigger_type VARCHAR(255), + team_id BIGINT, + policy_json TEXT +); + +-- For deployments where Hibernate already created policies before this migration (pre-team_id). +ALTER TABLE policies ADD COLUMN IF NOT EXISTS team_id BIGINT; + +CREATE INDEX IF NOT EXISTS idx_policies_team ON policies (team_id); +CREATE INDEX IF NOT EXISTS idx_policies_trigger ON policies (trigger_type, enabled); + +CREATE TABLE IF NOT EXISTS policy_sources ( + id VARCHAR(255) PRIMARY KEY, + name VARCHAR(255), + type VARCHAR(255), + owner VARCHAR(255), + team_id BIGINT, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + source_json TEXT +); + +CREATE INDEX IF NOT EXISTS idx_policy_sources_team ON policy_sources (team_id); diff --git a/frontend/portal/public/locales/en-US/translation.toml b/frontend/portal/public/locales/en-US/translation.toml index 4488dd6726..1aa24bd89e 100644 --- a/frontend/portal/public/locales/en-US/translation.toml +++ b/frontend/portal/public/locales/en-US/translation.toml @@ -341,8 +341,8 @@ title = "No drift detected" description = "Every document in the last 24h matched its inferred schema." [sources] -title = "Sources & Agents" -subtitle = "Every place documents flow into Stirling β€” agents, API clients, webhooks, connectors and more. Click a row for type-specific detail." +title = "Sources" +subtitle = "Reusable input connections that feed documents into Stirling. Configure a connection once, then reference it from any number of policies. Click a row for its config and which policies use it." [sources.actions] agentBuilder = "Agent Builder" @@ -350,86 +350,75 @@ connectSource = "Connect source" [sources.empty] title = "No sources connected yet" -description = "Connect an agent, API client, webhook, connector or inbox to start feeding documents into your pipelines." +description = "Connect a folder (and, soon, cloud storage) so your policies have somewhere to pull documents from." [sources.kpi] -agentsActive = "Agents active" -scenarios = "Scenarios" -evalPassRate = "Eval pass rate (7d)" -docs24h = "Docs / 24h" +total = "Connections" +inUse = "In use" +unused = "Unused" + +[sources.status] +active = "Active" +unused = "Unused" +disabled = "Disabled" [sources.table] source = "Source" status = "Status" -docs24h = "Docs / 24h" -docs30d = "Docs / 30d" -lastEvent = "Last event" -owner = "Owner" +usedBy = "Policies" [sources.detail] -ownedBy = "{{type}} Β· owned by {{owner}}" +subtitle = "{{type}} Β· {{status}}" closeAriaLabel = "Close detail" +usedBy = "Used by" +notReferenced = "Not referenced by any policy, so it's safe to delete." +docsUntracked = "Per-source document volume isn't tracked yet." +edit = "Edit" +pause = "Pause" +resume = "Resume" +delete = "Delete source" -[sources.agent] -model = "Model" -calls24h = "Calls / 24h" -errorRate = "Error rate" -escalations24h = "Escalations / 24h" -meanConfidence = "Mean confidence" -meanOutputConfidence = "Mean output confidence" -assignedPipelines = "Assigned pipelines" -scopes = "Scopes" -viewEvalRuns = "View eval runs" -pauseAgent = "Pause agent" - -[sources.apiClient] -secretKey = "Secret key" -rateLimit = "Rate limit" -createdBy = "Created by" -lastRotated = "Last rotated" -rateLimitWindow = "Rate-limit window" -usedPct = "{{pct}} used" -rateLimitUsage = "Rate-limit usage" -topEndpoints = "Top endpoints" -callsPer24h = "{{count}} / 24h" -rotateKey = "Rotate key" -revoke = "Revoke" - -[sources.webhook] -endpointUrl = "Endpoint URL" -authType = "Auth type" -successRate = "Success rate" -retries24h = "Retries / 24h" -recentDeliveries = "Recent deliveries" -sendTestEvent = "Send test event" -viewSigningSecret = "View signing secret" +[sources.delete] +title = "Delete source?" +body = "Delete \"{{name}}\"? This can't be undone. Policies that reference it would need to be updated." +cancel = "Cancel" +confirm = "Delete" [sources.wizard] title = "Connect a source" +editTitle = "Edit source" subtitle = "Step {{current}} of {{total}} Β· {{label}}" cancel = "Cancel" back = "Back" continue = "Continue" -configureNote = "Scopes, rate limits and IP allowlists can be tuned after the source is connected." +save = "Save changes" +name = "Name" +namePlaceholder = "e.g. Claims intake" type = "Type" -defaultPipeline = "Default pipeline" -defaultPipelineValue = "Redact & Flatten" -initialState = "Initial state" -initialStateValue = "Paused" -region = "Region" [sources.wizard.steps] chooseType = "Choose type" configure = "Configure" review = "Review & connect" -[sources.wizard.configureLead] -before = "Configure your" -after = ". Point it at Stirling and attach a default pipeline β€” every document this source ingests runs through it automatically." +[sources.types.folder] +label = "Folder" +description = "Watch a directory on the server for new documents." -[sources.wizard.reviewLead] -before = "Ready to connect a new" -after = ". It starts paused so you can verify the first few documents before going live." +[sources.types.folder.fields.directory] +label = "Directory path" +placeholder = "/data/incoming" +helperText = "Absolute path Stirling watches for files to process." + +[sources.types.folder.fields.mode] +label = "Read mode" + +[sources.types.folder.fields.mode.options] +consume = "Consume: process each file once" +snapshot = "Snapshot: re-read the folder every run" + +[sources.types.unknown] +label = "Source" [usage] title = "Usage & Billing" @@ -1780,3 +1769,8 @@ or = "or" [auth] loading = "Loading" redirectingToEditor = "Redirecting to the editor..." + +[errorBoundary] +title = "Something went wrong on this page" +description = "This view hit an unexpected error. Try again, or pick another section from the sidebar." +retry = "Try again" diff --git a/frontend/portal/src/App.tsx b/frontend/portal/src/App.tsx index c40029d0dc..a94a5c304b 100644 --- a/frontend/portal/src/App.tsx +++ b/frontend/portal/src/App.tsx @@ -1,7 +1,8 @@ import { useEffect, type ReactNode } from "react"; -import { BrowserRouter } from "react-router-dom"; +import { BrowserRouter, useLocation } from "react-router-dom"; import { MantineProvider } from "@mantine/core"; import { AuthProvider } from "@shared/auth"; +import { ErrorBoundary } from "@portal/components/ErrorBoundary"; import { ThemeProvider, useTheme } from "@portal/contexts/ThemeContext"; import { TierProvider } from "@portal/contexts/TierContext"; import { UIProvider, useUI } from "@portal/contexts/UIContext"; @@ -61,6 +62,20 @@ function SettingsHost() { return ; } +/** + * The routed view, wrapped in an error boundary so a single view crashing can't + * white-screen the portal (the shell + nav stay alive). Keyed by route so + * navigating to another section clears any error from the previous one. + */ +function RoutedContent() { + const { pathname } = useLocation(); + return ( + + + + ); +} + export function App() { // Honour the Vite base path so the portal routes correctly when served under a // subpath (e.g. "/portal" behind the single-origin proxy). BASE_URL is "./" @@ -78,7 +93,7 @@ export function App() { - + diff --git a/frontend/portal/src/api/http.ts b/frontend/portal/src/api/http.ts index 29e99dde7c..9e1619fe38 100644 --- a/frontend/portal/src/api/http.ts +++ b/frontend/portal/src/api/http.ts @@ -31,6 +31,24 @@ export class HttpError extends Error { } } +/** + * Best-effort human-readable message from a thrown error: unwraps an + * {@link HttpError}'s ProblemDetail-ish body (`detail` / `message` / `error`) + * before falling back to the error's own message. Shared by views that surface + * a failed request inline. + */ +export function errorMessage(error: unknown): string { + if (error instanceof HttpError) { + const body = error.body as { + detail?: string; + message?: string; + error?: string; + } | null; + return body?.detail ?? body?.message ?? body?.error ?? error.message; + } + return error instanceof Error ? error.message : String(error); +} + function authHeader(): Record { const token = getStoredToken(); return token ? { Authorization: `Bearer ${token}` } : {}; diff --git a/frontend/portal/src/api/sources.ts b/frontend/portal/src/api/sources.ts index cbc2c7cba6..29988958b5 100644 --- a/frontend/portal/src/api/sources.ts +++ b/frontend/portal/src/api/sources.ts @@ -1,25 +1,80 @@ import { httpJson } from "@portal/api/http"; -import type { SourcesResponse } from "@portal/mocks/sources"; -import type { Tier } from "@portal/contexts/TierContext"; -export type { - AgentDetail, - ApiClientDetail, - BasicDetail, - Source, - SourceDetail, - SourceStatus, - SourceType, - SourceTypeMeta, - SourcesKpi, - SourcesResponse, - WebhookDetail, -} from "@portal/mocks/sources"; -export { SOURCE_STATUS_TONE, SOURCE_TYPE_META } from "@portal/mocks/sources"; +/** + * Sources service layer: the backend contract. + */ -/** GET /v1/sources?tier=… β€” KPI strip + the sources table for the tier. */ -export async function fetchSources(tier: Tier): Promise { - return httpJson( - `/v1/sources?tier=${encodeURIComponent(tier)}`, - ); +/** Overview row status: referenced and enabled, enabled-but-orphaned, or disabled. */ +export type SourceStatus = "active" | "unused" | "disabled"; + +/** A policy that references a source. */ +export interface SourcePolicyRef { + id: string; + name: string; +} + +/** One key/value line summarising a source's config for display. */ +export interface SourceDetailRow { + label: string; + value: string; +} + +/** One row in the Sources overview table. Mirrors the backend `SourceView`. */ +export interface SourceView { + id: string; + name: string; + type: string; + status: SourceStatus; + referenceCount: number; + referencingPolicies: SourcePolicyRef[]; + config: SourceDetailRow[]; + /** Per-source document volume: not tracked yet (always null for now). */ + docsTotal: number | null; +} + +export interface SourceKpi { + value: number; + description: string; +} + +export interface SourcesResponse { + kpis: SourceKpi[]; + sources: SourceView[]; +} + +/** + * The wire record for a single source: the create/update body (`id` omitted on + * create) and what the backend returns from POST/GET. Mirrors the backend + * `Source` record; `owner`/`teamId` are stamped server-side. + */ +export interface Source { + id?: string; + name: string; + type: string; + options: Record; + enabled: boolean; + owner?: string | null; + teamId?: number | null; +} + +/** GET /api/v1/sources: KPI strip + one row per source for the admin. */ +export async function fetchSources(): Promise { + return httpJson("/api/v1/sources"); +} + +/** GET /api/v1/sources/{id}: the raw source record (config options), for editing. */ +export async function fetchSource(id: string): Promise { + return httpJson(`/api/v1/sources/${encodeURIComponent(id)}`); +} + +/** POST /api/v1/sources: create (blank id) or update (matched id) a source. */ +export async function createSource(source: Source): Promise { + return httpJson("/api/v1/sources", { method: "POST", body: source }); +} + +/** DELETE /api/v1/sources/{id}: remove a source (409 if a policy references it). */ +export async function deleteSource(id: string): Promise { + await httpJson(`/api/v1/sources/${encodeURIComponent(id)}`, { + method: "DELETE", + }); } diff --git a/frontend/portal/src/components/ErrorBoundary.test.tsx b/frontend/portal/src/components/ErrorBoundary.test.tsx new file mode 100644 index 0000000000..91c22effca --- /dev/null +++ b/frontend/portal/src/components/ErrorBoundary.test.tsx @@ -0,0 +1,64 @@ +import { useState } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { ErrorBoundary } from "@portal/components/ErrorBoundary"; + +// Deterministic i18n: return the key so assertions don't depend on the async +// TOML backend ever loading. Mirrors the editor's test setup convention. +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { changeLanguage: vi.fn() }, + }), +})); + +function Boom(): never { + throw new Error("kaboom"); +} + +describe("ErrorBoundary", () => { + it("renders the default fallback when a child throws", () => { + // The boundary logs the caught error; silence it for a clean test run. + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + render( + + + , + ); + expect(screen.getByText("errorBoundary.title")).toBeInTheDocument(); + expect(screen.getByText("errorBoundary.description")).toBeInTheDocument(); + spy.mockRestore(); + }); + + it("recovers when retry is clicked and the child stops throwing", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + + // A child that throws once, then renders fine after an external toggle. + let shouldThrow = true; + function Toggleable() { + const [, force] = useState(0); + // Expose a way for the test to flip the flag and re-render is driven by + // ErrorBoundary remounting the subtree on reset. + if (shouldThrow) throw new Error("kaboom"); + void force; + return

recovered content
; + } + + render( + + + , + ); + + // Fallback is shown. + expect(screen.getByText("errorBoundary.title")).toBeInTheDocument(); + + // Stop throwing, then click retry to clear the boundary's error state. + shouldThrow = false; + fireEvent.click(screen.getByText("errorBoundary.retry")); + + expect(screen.getByText("recovered content")).toBeInTheDocument(); + expect(screen.queryByText("errorBoundary.title")).not.toBeInTheDocument(); + spy.mockRestore(); + }); +}); diff --git a/frontend/portal/src/components/ErrorBoundary.tsx b/frontend/portal/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000000..f97d208a31 --- /dev/null +++ b/frontend/portal/src/components/ErrorBoundary.tsx @@ -0,0 +1,68 @@ +import { Component, type ErrorInfo, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { Button, EmptyState } from "@shared/components"; + +/** + * Default fallback for {@link ErrorBoundary}. Split into a function component so + * it can read translations via the `useTranslation` hook, which the class-based + * boundary cannot call directly. + */ +function DefaultErrorFallback({ onRetry }: { onRetry: () => void }) { + const { t } = useTranslation(); + return ( + {t("errorBoundary.retry")}} + /> + ); +} + +interface ErrorBoundaryProps { + children: ReactNode; + /** Custom fallback; receives a reset fn to retry the subtree. */ + fallback?: (reset: () => void) => ReactNode; +} + +interface ErrorBoundaryState { + error: Error | null; +} + +/** + * Contains render/runtime crashes to its subtree so one failing view can never + * white-screen the whole portal. The app shell + navigation live OUTSIDE this + * boundary and stay interactive, so the user can always navigate away; the + * boundary is keyed by route in App, so moving to another view clears the error. + * + * This is what makes running against a real backend (mocks off) safe: a page + * that gets an unexpected/!missing response degrades to a contained error card + * instead of taking the app down. + */ +export class ErrorBoundary extends Component< + ErrorBoundaryProps, + ErrorBoundaryState +> { + state: ErrorBoundaryState = { error: null }; + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { error }; + } + + componentDidCatch(error: Error, info: ErrorInfo): void { + // Log for debugging; the UI itself stays contained by the fallback. + console.error("Portal view crashed:", error, info.componentStack); + } + + reset = (): void => this.setState({ error: null }); + + render(): ReactNode { + const { error } = this.state; + if (!error) return this.props.children; + if (this.props.fallback) return this.props.fallback(this.reset); + return ( +
+ +
+ ); + } +} diff --git a/frontend/portal/src/components/PolicySummary.tsx b/frontend/portal/src/components/PolicySummary.tsx index 971ef88e1d..57199bc8f7 100644 --- a/frontend/portal/src/components/PolicySummary.tsx +++ b/frontend/portal/src/components/PolicySummary.tsx @@ -125,7 +125,7 @@ export function PolicySummary() { }, ]; - const rows: PolicyRow[] = data?.catalogue.map(toRow) ?? []; + const rows: PolicyRow[] = data?.catalogue?.map(toRow) ?? []; return (
@@ -139,7 +139,7 @@ export function PolicySummary() { {t("policySummary.subtitle")}

- {data && ( + {data?.summary && ( {t("policySummary.activeSummary", { active: data.summary.active, diff --git a/frontend/portal/src/components/sources/AgentPanel.stories.tsx b/frontend/portal/src/components/sources/AgentPanel.stories.tsx deleted file mode 100644 index 5289d0c623..0000000000 --- a/frontend/portal/src/components/sources/AgentPanel.stories.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import type { AgentDetail } from "@portal/api/sources"; -import { AgentPanel } from "@portal/components/sources/AgentPanel"; - -const meta: Meta = { - title: "Portal/Sources/AgentPanel", - component: AgentPanel, - parameters: { layout: "padded" }, - decorators: [ - (S) => ( -
- -
- ), - ], -}; -export default meta; -type Story = StoryObj; - -const healthy: AgentDetail = { - kind: "agent", - model: "claude-sonnet-4.5", - calls24h: 1342, - errorRate: 0.004, - confidence: 0.962, - escalations24h: 11, - assignedPipelines: ["Invoice v3", "AP Routing"], - scopes: ["documents:read", "pipelines:invoke", "extract:write"], -}; - -export const Healthy: Story = { args: { d: healthy } }; - -/** Error rate over the 5% alarm threshold flips the badge and bar to danger/amber. */ -export const Degraded: Story = { - args: { - d: { - ...healthy, - model: "claude-opus-4.1", - errorRate: 0.071, - confidence: 0.883, - escalations24h: 34, - }, - }, -}; diff --git a/frontend/portal/src/components/sources/AgentPanel.tsx b/frontend/portal/src/components/sources/AgentPanel.tsx deleted file mode 100644 index a5a095bc9c..0000000000 --- a/frontend/portal/src/components/sources/AgentPanel.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { - Button, - Chip, - ProgressBar, - StatTile, - StatusBadge, -} from "@shared/components"; -import type { AgentDetail } from "@portal/api/sources"; -import { pct } from "@portal/components/sources/format"; -import "@portal/views/Sources.css"; - -export function AgentPanel({ d }: { d: AgentDetail }) { - const { t } = useTranslation(); - const errorTone = - d.errorRate >= 0.05 - ? "danger" - : d.errorRate >= 0.02 - ? "warning" - : "success"; - return ( -
-
- {d.model}} - /> - - - {pct(d.errorRate)} - - } - /> - -
- -
-
- {t("sources.agent.meanConfidence")} - {pct(d.confidence)} -
- = 0.93 ? "var(--color-green)" : "var(--color-amber)" - } - label={t("sources.agent.meanOutputConfidence")} - /> -
- -
- - {t("sources.agent.assignedPipelines")} - -
- {d.assignedPipelines.map((p) => ( - - {p} - - ))} -
-
- -
- - {t("sources.agent.scopes")} - -
- {d.scopes.map((s) => ( - - {s} - - ))} -
-
- - {/* TODO(backend): wire to GET /v1/sources/{id}/eval-runs and - POST /v1/sources/{id}/pause β€” currently inert demo controls. */} -
- - -
-
- ); -} diff --git a/frontend/portal/src/components/sources/ApiClientPanel.stories.tsx b/frontend/portal/src/components/sources/ApiClientPanel.stories.tsx deleted file mode 100644 index 7c67e90784..0000000000 --- a/frontend/portal/src/components/sources/ApiClientPanel.stories.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import type { ApiClientDetail } from "@portal/api/sources"; -import { ApiClientPanel } from "@portal/components/sources/ApiClientPanel"; - -const meta: Meta = { - title: "Portal/Sources/ApiClientPanel", - component: ApiClientPanel, - parameters: { layout: "padded" }, - decorators: [ - (S) => ( -
- -
- ), - ], -}; -export default meta; -type Story = StoryObj; - -const active: ApiClientDetail = { - kind: "apiclient", - maskedKey: "sk_live_β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’4f9a", - rateLimit: "600 req/min", - rateUsedPct: 0.42, - endpoints: [ - { method: "POST", path: "/v1/extract", calls24h: 1820 }, - { method: "POST", path: "/v1/redact", calls24h: 740 }, - { method: "GET", path: "/v1/documents/{id}", calls24h: 380 }, - ], - createdBy: "you@acme.com", - lastRotated: "23 days ago", -}; - -export const Active: Story = { args: { d: active } }; - -/** Rate window near its ceiling pushes the thresholded bar into the warning band. */ -export const NearLimit: Story = { - args: { d: { ...active, rateUsedPct: 0.93 } }, -}; - -/** A revoked key: never rotated, no traffic, masked label flags the state. */ -export const Revoked: Story = { - args: { - d: { - ...active, - maskedKey: "sk_live_β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’0000 (revoked)", - rateUsedPct: 0, - lastRotated: "never", - endpoints: active.endpoints.map((e) => ({ ...e, calls24h: 0 })), - }, - }, -}; diff --git a/frontend/portal/src/components/sources/ApiClientPanel.tsx b/frontend/portal/src/components/sources/ApiClientPanel.tsx deleted file mode 100644 index 332b18f949..0000000000 --- a/frontend/portal/src/components/sources/ApiClientPanel.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { Button, Chip, ProgressBar, StatTile } from "@shared/components"; -import type { ApiClientDetail } from "@portal/api/sources"; -import { pct } from "@portal/components/sources/format"; -import "@portal/views/Sources.css"; - -export function ApiClientPanel({ d }: { d: ApiClientDetail }) { - const { t } = useTranslation(); - return ( -
-
- {d.maskedKey}} - /> - - - -
- -
-
- {t("sources.apiClient.rateLimitWindow")} - - {t("sources.apiClient.usedPct", { pct: pct(d.rateUsedPct) })} - -
- -
- -
- - {t("sources.apiClient.topEndpoints")} - -
- {d.endpoints.map((e) => ( -
- - {e.method} - - {e.path} - - {t("sources.apiClient.callsPer24h", { - count: e.calls24h.toLocaleString(), - })} - -
- ))} -
-
- - {/* TODO(backend): wire to POST /v1/sources/{id}/rotate-key and - DELETE /v1/sources/{id} β€” currently inert demo controls. */} -
- - -
-
- ); -} diff --git a/frontend/portal/src/components/sources/ConnectWizard.stories.tsx b/frontend/portal/src/components/sources/ConnectWizard.stories.tsx index a78fb0ef15..db647689b9 100644 --- a/frontend/portal/src/components/sources/ConnectWizard.stories.tsx +++ b/frontend/portal/src/components/sources/ConnectWizard.stories.tsx @@ -5,7 +5,7 @@ const meta: Meta = { title: "Portal/Sources/ConnectWizard", component: ConnectWizard, parameters: { layout: "fullscreen" }, - args: { open: true, onClose: () => {} }, + args: { open: true, onClose: () => {}, onCreated: () => {} }, }; export default meta; type Story = StoryObj; diff --git a/frontend/portal/src/components/sources/ConnectWizard.test.tsx b/frontend/portal/src/components/sources/ConnectWizard.test.tsx new file mode 100644 index 0000000000..b16b6fcc66 --- /dev/null +++ b/frontend/portal/src/components/sources/ConnectWizard.test.tsx @@ -0,0 +1,125 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { HttpError } from "@portal/api/http"; +import { ConnectWizard } from "@portal/components/sources/ConnectWizard"; + +// Deterministic i18n: keys come back verbatim so the test never waits on the +// async TOML backend. +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { changeLanguage: vi.fn() }, + }), +})); + +// Mock the service layer so no real fetch is issued. +const createSource = vi.fn(); +vi.mock("@portal/api/sources", () => ({ + createSource: (...args: unknown[]) => createSource(...args), +})); + +/** Step through the folder-source flow: choose type -> configure -> review. */ +function stepToReview() { + // Step 0: folder is the default-selected type. Continue. + fireEvent.click(screen.getByText("sources.wizard.continue")); + + // Step 1: fill the required name + directory fields. + const inputs = screen.getAllByRole("textbox"); + // First textbox is the name field, second is the folder's directory field. + fireEvent.change(inputs[0], { target: { value: "Claims intake" } }); + fireEvent.change(inputs[1], { target: { value: "/data/incoming" } }); + fireEvent.click(screen.getByText("sources.wizard.continue")); +} + +describe("ConnectWizard", () => { + beforeEach(() => { + createSource.mockReset(); + }); + + it("creates a folder source with the configured options", async () => { + createSource.mockResolvedValue({ id: "src-1" }); + const onCreated = vi.fn(); + const onClose = vi.fn(); + + render(); + + stepToReview(); + + // Step 2: submit via the final "connect source" action. + fireEvent.click(screen.getByText("sources.actions.connectSource")); + + await waitFor(() => { + expect(createSource).toHaveBeenCalledTimes(1); + }); + expect(createSource).toHaveBeenCalledWith({ + name: "Claims intake", + type: "folder", + options: { directory: "/data/incoming", mode: "consume" }, + enabled: true, + }); + await waitFor(() => { + expect(onCreated).toHaveBeenCalledTimes(1); + }); + }); + + it("edits an existing source: prefilled, skips type, submits with its id", async () => { + createSource.mockResolvedValue({ id: "s1" }); + const onCreated = vi.fn(); + + render( + , + ); + + // Edit opens on the configure step with name + directory prefilled. + const inputs = screen.getAllByRole("textbox") as HTMLInputElement[]; + expect(inputs[0].value).toBe("James"); + expect(inputs[1].value).toBe("'/data/in'"); + + // Fix the stray quotes, continue to review, save. + fireEvent.change(inputs[1], { target: { value: "/data/in" } }); + fireEvent.click(screen.getByText("sources.wizard.continue")); + fireEvent.click(screen.getByText("sources.wizard.save")); + + await waitFor(() => { + expect(createSource).toHaveBeenCalledTimes(1); + }); + expect(createSource).toHaveBeenCalledWith({ + id: "s1", + name: "James", + type: "folder", + options: { directory: "/data/in", mode: "consume" }, + enabled: true, + }); + await waitFor(() => { + expect(onCreated).toHaveBeenCalledTimes(1); + }); + }); + + it("renders the inline error message when create fails", async () => { + createSource.mockRejectedValue( + new HttpError(400, "Bad Request", { + detail: "Directory is not readable", + }), + ); + + render(); + + stepToReview(); + fireEvent.click(screen.getByText("sources.actions.connectSource")); + + expect( + await screen.findByText("Directory is not readable"), + ).toBeInTheDocument(); + }); +}); diff --git a/frontend/portal/src/components/sources/ConnectWizard.tsx b/frontend/portal/src/components/sources/ConnectWizard.tsx index 8bf677f27d..e0c9a3a6b7 100644 --- a/frontend/portal/src/components/sources/ConnectWizard.tsx +++ b/frontend/portal/src/components/sources/ConnectWizard.tsx @@ -1,166 +1,283 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Button, CodeBlock, Modal, StatTile } from "@shared/components"; -import { type Source, SOURCE_TYPE_META } from "@portal/api/sources"; +import { + Banner, + Button, + FormField, + Input, + Modal, + Select, + StatTile, +} from "@shared/components"; +import { errorMessage } from "@portal/api/http"; +import { createSource, type Source } from "@portal/api/sources"; +import { + CREATABLE_SOURCE_TYPES, + defaultOptions, + sourceTypeMeta, + type CreatableSourceType, +} from "@portal/components/sources/sourceTypes"; import "@portal/views/Sources.css"; -const WIZARD_STEP_COUNT = 3; +const DEFAULT_TYPE = CREATABLE_SOURCE_TYPES[0]; -const CONNECT_SNIPPET = `curl https://api.stirlingpdf.com/v1/extract \\ - -H "Authorization: Bearer sk_live_β€’β€’β€’β€’" \\ - -F "file=@invoice.pdf" \\ - -F "pipeline=invoice-v3"`; +/** Wizard steps. Editing skips type selection (the type is fixed once created). */ +type StepId = "type" | "configure" | "review"; +const CREATE_STEPS: StepId[] = ["type", "configure", "review"]; +const EDIT_STEPS: StepId[] = ["configure", "review"]; interface ConnectWizardProps { open: boolean; onClose: () => void; + /** Called after a source is created or updated so the page can refetch. */ + onCreated: () => void; + /** When set, the wizard edits this existing source instead of creating one. */ + source?: Source; +} + +/** The creatable-type metadata for a source's stored type, falling back to folder. */ +function typeFor(type: string | undefined): CreatableSourceType { + return CREATABLE_SOURCE_TYPES.find((t) => t.type === type) ?? DEFAULT_TYPE; +} + +/** Source options coerced to strings for the form, defaulted from the type's fields. */ +function optionsFor( + type: CreatableSourceType, + options: Record | undefined, +): Record { + const out = defaultOptions(type); + for (const [key, value] of Object.entries(options ?? {})) { + out[key] = value == null ? "" : String(value); + } + return out; } /** - * Guided shell for connecting a new source. The final step is a demo stub that - * closes without provisioning β€” wiring it to the backend creates the source. + * Guided flow for connecting a source (choose type -> configure -> review) or + * editing an existing one (configure -> review, type fixed). On submit a blank + * id creates and a set id updates, matching the backend's POST contract. */ -export function ConnectWizard({ open, onClose }: ConnectWizardProps) { +export function ConnectWizard({ + open, + onClose, + onCreated, + source, +}: ConnectWizardProps) { const { t } = useTranslation(); - const [step, setStep] = useState(0); - const [type, setType] = useState("agent"); + const isEdit = source !== undefined; + const steps = isEdit ? EDIT_STEPS : CREATE_STEPS; - const wizardSteps = [ - t("sources.wizard.steps.chooseType"), - t("sources.wizard.steps.configure"), - t("sources.wizard.steps.review"), - ]; + const [stepIndex, setStepIndex] = useState(0); + const [type, setType] = useState(() => + typeFor(source?.type), + ); + const [name, setName] = useState(source?.name ?? ""); + const [options, setOptions] = useState>(() => + optionsFor(typeFor(source?.type), source?.options), + ); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); - function close() { - onClose(); - // Reset for the next open, after the close transition has finished. - setTimeout(() => { - setStep(0); - setType("agent"); - }, 200); + // Re-seed the form whenever the wizard opens (or its target source changes) so + // editing prefills the current config and a reopened create starts clean. + useEffect(() => { + if (!open) return; + const ct = typeFor(source?.type); + setStepIndex(0); + setType(ct); + setName(source?.name ?? ""); + setOptions(optionsFor(ct, source?.options)); + setSubmitting(false); + setError(null); + }, [open, source]); + + const stepId = steps[stepIndex]; + const isLast = stepIndex === steps.length - 1; + + function chooseType(next: CreatableSourceType) { + setType(next); + setOptions(defaultOptions(next)); } - const isLast = step === WIZARD_STEP_COUNT - 1; + const requiredFilled = type.fields.every( + (f) => !f.required || (options[f.key] ?? "").trim() !== "", + ); + const canContinue = + stepId === "configure" ? name.trim() !== "" && requiredFilled : true; - function advance() { - if (isLast) { - // TODO(backend): POST /v1/sources { type, pipeline, region } β€” provision - // the source, then close on success. - close(); - } else { - setStep((s) => s + 1); + async function advance() { + if (!isLast) { + setStepIndex((i) => i + 1); + return; + } + setSubmitting(true); + setError(null); + try { + const fields = { + name: name.trim(), + type: type.type, + options, + enabled: source?.enabled ?? true, + }; + await createSource(isEdit ? { ...fields, id: source.id } : fields); + onCreated(); + onClose(); + } catch (e) { + setError(errorMessage(e)); + } finally { + setSubmitting(false); } } + const stepLabels: Record = { + type: t("sources.wizard.steps.chooseType"), + configure: t("sources.wizard.steps.configure"), + review: t("sources.wizard.steps.review"), + }; + return ( } >
    - {wizardSteps.map((label, i) => ( + {steps.map((id, i) => (
  1. - {i < step ? "βœ“" : i + 1} + {i < stepIndex ? "βœ“" : i + 1} - {label} + {stepLabels[id]}
  2. ))}
- {step === 0 && ( + {stepId === "type" && (
- {(Object.keys(SOURCE_TYPE_META) as Source["type"][]).map((t) => { - const meta = SOURCE_TYPE_META[t]; - return ( - - ); - })} + {CREATABLE_SOURCE_TYPES.map((ct) => ( + + ))}
)} - {step === 1 && ( + {stepId === "configure" && (
-

- {t("sources.wizard.configureLead.before")}{" "} - {SOURCE_TYPE_META[type].label} - {t("sources.wizard.configureLead.after")} -

- -

- {t("sources.wizard.configureNote")} -

+ + setName(e.target.value)} + /> + + {type.fields.map((field) => ( + + {field.control === "select" ? ( + + setOptions((o) => ({ ...o, [field.key]: e.target.value })) + } + /> + )} + + ))}
)} - {step === 2 && ( + {stepId === "review" && (
-

- {t("sources.wizard.reviewLead.before")}{" "} - {SOURCE_TYPE_META[type].label} - {t("sources.wizard.reviewLead.after")} -

+ - - - + {type.fields.map((field) => ( + + ))}
+ {error && }
)}
diff --git a/frontend/portal/src/components/sources/KpiStrip.stories.tsx b/frontend/portal/src/components/sources/KpiStrip.stories.tsx index 36c290b50b..c2233dcb47 100644 --- a/frontend/portal/src/components/sources/KpiStrip.stories.tsx +++ b/frontend/portal/src/components/sources/KpiStrip.stories.tsx @@ -1,7 +1,16 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { buildSourcesResponse } from "@portal/mocks/sources"; +import type { SourcesResponse } from "@portal/api/sources"; import { KpiStrip } from "@portal/components/sources/KpiStrip"; +const RESPONSE: SourcesResponse = { + kpis: [ + { value: 4, description: "connections" }, + { value: 2, description: "referenced by a policy" }, + { value: 2, description: "unused" }, + ], + sources: [], +}; + const meta: Meta = { title: "Portal/Sources/KpiStrip", component: KpiStrip, @@ -10,20 +19,11 @@ const meta: Meta = { export default meta; type Story = StoryObj; -export const Pro: Story = { - args: { data: buildSourcesResponse("pro"), loading: false }, -}; - -export const Enterprise: Story = { - args: { data: buildSourcesResponse("enterprise"), loading: false }, +export const Ready: Story = { + args: { data: RESPONSE, loading: false }, }; /** Loading collapses every card to the placeholder dash. */ export const Loading: Story = { args: { data: null, loading: true }, }; - -/** Free tier reports zeros and a "connect a source" prompt. */ -export const Free: Story = { - args: { data: buildSourcesResponse("free"), loading: false }, -}; diff --git a/frontend/portal/src/components/sources/KpiStrip.tsx b/frontend/portal/src/components/sources/KpiStrip.tsx index 12f242bcad..8bdc511b57 100644 --- a/frontend/portal/src/components/sources/KpiStrip.tsx +++ b/frontend/portal/src/components/sources/KpiStrip.tsx @@ -3,15 +3,15 @@ import { MetricCard, MetricStrip } from "@shared/components"; import type { SourcesResponse } from "@portal/api/sources"; /** - * KPI labels are product copy β€” they describe what each metric IS, not its - * current value. They stay client-side so the strip's structure is stable - * across loading / empty / ready states; only values + deltas flow from the API. + * KPI labels are product copy: they describe what each metric IS, not its + * current value. They stay client-side so the strip's structure is stable across + * loading / empty / ready states; only values + descriptions flow from the API. + * Order matches SourceOverviewService.buildKpis: total, in-use, unused. */ const KPI_LABEL_KEYS = [ - "sources.kpi.agentsActive", - "sources.kpi.scenarios", - "sources.kpi.evalPassRate", - "sources.kpi.docs24h", + "sources.kpi.total", + "sources.kpi.inUse", + "sources.kpi.unused", ] as const; interface KpiStripProps { @@ -30,8 +30,6 @@ export function KpiStrip({ data, loading }: KpiStripProps) { key={labelKey} label={t(labelKey)} value={k?.value ?? "β€”"} - delta={k?.delta} - deltaDirection={k?.deltaDirection} description={k?.description} /> ); diff --git a/frontend/portal/src/components/sources/SourceDetailCard.stories.tsx b/frontend/portal/src/components/sources/SourceDetailCard.stories.tsx index 83f5171ecc..0f9ddee521 100644 --- a/frontend/portal/src/components/sources/SourceDetailCard.stories.tsx +++ b/frontend/portal/src/components/sources/SourceDetailCard.stories.tsx @@ -1,17 +1,45 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import type { Source } from "@portal/api/sources"; -import { sourcesFor } from "@portal/mocks/sources"; +import type { SourceView } from "@portal/api/sources"; import { SourceDetailCard } from "@portal/components/sources/SourceDetailCard"; -const ENTERPRISE = sourcesFor("enterprise"); -const byType = (type: Source["type"]) => - ENTERPRISE.find((s) => s.type === type)!; +const IN_USE: SourceView = { + id: "src-claims", + name: "Claims intake", + type: "folder", + status: "active", + referenceCount: 2, + referencingPolicies: [ + { id: "a", name: "Security Policy" }, + { id: "b", name: "Redaction Policy" }, + ], + config: [ + { label: "Directory", value: "/data/claims-intake" }, + { label: "Mode", value: "consume" }, + ], + docsTotal: null, +}; + +const ORPHANED: SourceView = { + id: "src-archive", + name: "Archive reprocess", + type: "folder", + status: "unused", + referenceCount: 0, + referencingPolicies: [], + config: [{ label: "Directory", value: "/data/archive" }], + docsTotal: null, +}; const meta: Meta = { title: "Portal/Sources/SourceDetailCard", component: SourceDetailCard, parameters: { layout: "padded" }, - args: { onClose: () => {} }, + args: { + onClose: () => {}, + onEdit: () => {}, + onTogglePause: () => {}, + onDelete: () => {}, + }, decorators: [ (S) => (
@@ -23,6 +51,5 @@ const meta: Meta = { export default meta; type Story = StoryObj; -export const Agent: Story = { args: { source: byType("agent") } }; -export const Webhook: Story = { args: { source: byType("webhook") } }; -export const Connector: Story = { args: { source: byType("connector") } }; +export const InUse: Story = { args: { source: IN_USE } }; +export const Orphaned: Story = { args: { source: ORPHANED } }; diff --git a/frontend/portal/src/components/sources/SourceDetailCard.tsx b/frontend/portal/src/components/sources/SourceDetailCard.tsx index e60f502760..6169116c0f 100644 --- a/frontend/portal/src/components/sources/SourceDetailCard.tsx +++ b/frontend/portal/src/components/sources/SourceDetailCard.tsx @@ -1,17 +1,32 @@ import { useTranslation } from "react-i18next"; -import { type Source, SOURCE_TYPE_META } from "@portal/api/sources"; +import { Button } from "@shared/components"; +import type { SourceView } from "@portal/api/sources"; import { SourceDetailPanel } from "@portal/components/sources/SourceDetailPanel"; +import { sourceTypeMeta } from "@portal/components/sources/sourceTypes"; import "@portal/views/Sources.css"; interface SourceDetailCardProps { - source: Source; + source: SourceView; onClose: () => void; + onEdit: (source: SourceView) => void; + onTogglePause: (source: SourceView) => void; + onDelete: (source: SourceView) => void; + /** Disables the actions while a mutation is in flight. */ + busy?: boolean; } -/** Expanded type-specific detail for the selected table row. */ -export function SourceDetailCard({ source, onClose }: SourceDetailCardProps) { +/** Expanded detail for the selected source row, with edit/pause/delete actions. */ +export function SourceDetailCard({ + source, + onClose, + onEdit, + onTogglePause, + onDelete, + busy = false, +}: SourceDetailCardProps) { const { t } = useTranslation(); - const meta = SOURCE_TYPE_META[source.type]; + const meta = sourceTypeMeta(source.type); + const paused = source.status === "disabled"; return (
@@ -24,9 +39,9 @@ export function SourceDetailCard({ source, onClose }: SourceDetailCardProps) {

{source.name}

- {t("sources.detail.ownedBy", { - type: meta.label, - owner: source.owner, + {t("sources.detail.subtitle", { + type: t(meta.labelKey), + status: t(`sources.status.${source.status}`), })}
@@ -39,7 +54,33 @@ export function SourceDetailCard({ source, onClose }: SourceDetailCardProps) { Γ—
+ + +
+ + + +
); } diff --git a/frontend/portal/src/components/sources/SourceDetailPanel.stories.tsx b/frontend/portal/src/components/sources/SourceDetailPanel.stories.tsx index 2581771ea3..79e8fe9c86 100644 --- a/frontend/portal/src/components/sources/SourceDetailPanel.stories.tsx +++ b/frontend/portal/src/components/sources/SourceDetailPanel.stories.tsx @@ -1,11 +1,34 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import type { Source } from "@portal/api/sources"; -import { sourcesFor } from "@portal/mocks/sources"; +import type { SourceView } from "@portal/api/sources"; import { SourceDetailPanel } from "@portal/components/sources/SourceDetailPanel"; -const ENTERPRISE = sourcesFor("enterprise"); -const byType = (type: Source["type"]) => - ENTERPRISE.find((s) => s.type === type)!; +const IN_USE: SourceView = { + id: "src-claims", + name: "Claims intake", + type: "folder", + status: "active", + referenceCount: 2, + referencingPolicies: [ + { id: "a", name: "Security Policy" }, + { id: "b", name: "Redaction Policy" }, + ], + config: [ + { label: "Directory", value: "/data/claims-intake" }, + { label: "Mode", value: "consume" }, + ], + docsTotal: null, +}; + +const ORPHANED: SourceView = { + id: "src-archive", + name: "Archive reprocess", + type: "folder", + status: "unused", + referenceCount: 0, + referencingPolicies: [], + config: [{ label: "Directory", value: "/data/archive" }], + docsTotal: null, +}; const meta: Meta = { title: "Portal/Sources/SourceDetailPanel", @@ -22,8 +45,6 @@ const meta: Meta = { export default meta; type Story = StoryObj; -export const Agent: Story = { args: { source: byType("agent") } }; -export const ApiClient: Story = { args: { source: byType("apiclient") } }; -export const Webhook: Story = { args: { source: byType("webhook") } }; -/** "basic" covers editor, connector, email, desktop and batch sources. */ -export const Basic: Story = { args: { source: byType("connector") } }; +export const InUse: Story = { args: { source: IN_USE } }; +/** A source no policy references is called out as safe to delete. */ +export const Orphaned: Story = { args: { source: ORPHANED } }; diff --git a/frontend/portal/src/components/sources/SourceDetailPanel.tsx b/frontend/portal/src/components/sources/SourceDetailPanel.tsx index 1938eb609b..8fa435bb6c 100644 --- a/frontend/portal/src/components/sources/SourceDetailPanel.tsx +++ b/frontend/portal/src/components/sources/SourceDetailPanel.tsx @@ -1,34 +1,46 @@ -import { StatTile } from "@shared/components"; -import type { BasicDetail, Source } from "@portal/api/sources"; -import { AgentPanel } from "@portal/components/sources/AgentPanel"; -import { ApiClientPanel } from "@portal/components/sources/ApiClientPanel"; -import { WebhookPanel } from "@portal/components/sources/WebhookPanel"; +import { useTranslation } from "react-i18next"; +import { Chip, StatTile } from "@shared/components"; +import type { SourceView } from "@portal/api/sources"; import "@portal/views/Sources.css"; -/** Generic key/value grid for the simpler source types (editor, connector, …). */ -function BasicPanel({ rows }: { rows: BasicDetail["rows"] }) { +/** + * Expanded detail for a source row: its config (key/value) plus which policies + * reference it. A 0-reference source is called out as safe to delete. + */ +export function SourceDetailPanel({ source }: { source: SourceView }) { + const { t } = useTranslation(); return (
-
- {rows.map((r) => ( - - ))} + {source.config.length > 0 && ( +
+ {source.config.map((row) => ( + + ))} +
+ )} + +
+ + {t("sources.detail.usedBy")} + + {source.referencingPolicies.length === 0 ? ( +

+ {t("sources.detail.notReferenced")} +

+ ) : ( +
+ {source.referencingPolicies.map((policy) => ( + + {policy.name} + + ))} +
+ )}
+ +

+ {t("sources.detail.docsUntracked")} +

); } - -/** Renders the detail payload for a source, dispatched on its discriminant. */ -export function SourceDetailPanel({ source }: { source: Source }) { - const { detail } = source; - switch (detail.kind) { - case "agent": - return ; - case "apiclient": - return ; - case "webhook": - return ; - case "basic": - return ; - } -} diff --git a/frontend/portal/src/components/sources/SourcesTable.stories.tsx b/frontend/portal/src/components/sources/SourcesTable.stories.tsx index 7d7ea6fdc8..30f1e5bc5f 100644 --- a/frontend/portal/src/components/sources/SourcesTable.stories.tsx +++ b/frontend/portal/src/components/sources/SourcesTable.stories.tsx @@ -1,14 +1,51 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { sourcesFor } from "@portal/mocks/sources"; +import type { SourceView } from "@portal/api/sources"; import { SourcesTable } from "@portal/components/sources/SourcesTable"; -const PRO = sourcesFor("pro"); +const SOURCES: SourceView[] = [ + { + id: "src-claims", + name: "Claims intake", + type: "folder", + status: "active", + referenceCount: 2, + referencingPolicies: [ + { id: "a", name: "Security Policy" }, + { id: "b", name: "Redaction Policy" }, + ], + config: [ + { label: "Directory", value: "/data/claims-intake" }, + { label: "Mode", value: "consume" }, + ], + docsTotal: null, + }, + { + id: "src-archive", + name: "Archive reprocess", + type: "folder", + status: "unused", + referenceCount: 0, + referencingPolicies: [], + config: [{ label: "Directory", value: "/data/archive" }], + docsTotal: null, + }, + { + id: "src-legacy", + name: "Legacy share (paused)", + type: "folder", + status: "disabled", + referenceCount: 0, + referencingPolicies: [], + config: [{ label: "Directory", value: "/mnt/legacy" }], + docsTotal: null, + }, +]; const meta: Meta = { title: "Portal/Sources/SourcesTable", component: SourcesTable, parameters: { layout: "padded" }, - args: { sources: PRO, expandedId: null, onRowClick: () => {} }, + args: { sources: SOURCES, expandedId: null, onRowClick: () => {} }, }; export default meta; type Story = StoryObj; @@ -17,9 +54,5 @@ export const Default: Story = {}; /** A row with an open detail panel rotates its caret. */ export const RowExpanded: Story = { - args: { expandedId: PRO[1].id }, -}; - -export const Enterprise: Story = { - args: { sources: sourcesFor("enterprise") }, + args: { expandedId: SOURCES[0].id }, }; diff --git a/frontend/portal/src/components/sources/SourcesTable.tsx b/frontend/portal/src/components/sources/SourcesTable.tsx index a0cb15f520..0f6c23589c 100644 --- a/frontend/portal/src/components/sources/SourcesTable.tsx +++ b/frontend/portal/src/components/sources/SourcesTable.tsx @@ -1,18 +1,27 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { Chip, StatusBadge, Table, type TableColumn } from "@shared/components"; import { - type Source, - SOURCE_STATUS_TONE, - SOURCE_TYPE_META, -} from "@portal/api/sources"; + Chip, + StatusBadge, + type StatusTone, + Table, + type TableColumn, +} from "@shared/components"; +import type { SourceStatus, SourceView } from "@portal/api/sources"; +import { sourceTypeMeta } from "@portal/components/sources/sourceTypes"; import "@portal/views/Sources.css"; +const STATUS_TONE: Record = { + active: "success", + unused: "neutral", + disabled: "warning", +}; + interface SourcesTableProps { - sources: Source[]; + sources: SourceView[]; /** Id of the row whose detail panel is open, drives the caret state. */ expandedId: string | null; - onRowClick: (source: Source) => void; + onRowClick: (source: SourceView) => void; } export function SourcesTable({ @@ -21,13 +30,13 @@ export function SourcesTable({ onRowClick, }: SourcesTableProps) { const { t } = useTranslation(); - const columns = useMemo[]>( + const columns = useMemo[]>( () => [ { key: "name", header: t("sources.table.source"), render: (s) => { - const meta = SOURCE_TYPE_META[s.type]; + const meta = sourceTypeMeta(s.type); return (
{s.name} - {meta.label} + {t(meta.labelKey)}
@@ -51,38 +60,28 @@ export function SourcesTable({ header: t("sources.table.status"), render: (s) => ( - {s.status} + {t(`sources.status.${s.status}`)} ), }, { - key: "docs24h", - header: t("sources.table.docs24h"), + key: "referenceCount", + header: t("sources.table.usedBy"), align: "right", - render: (s) => s.docs24h.toLocaleString(), - }, - { - key: "docs30d", - header: t("sources.table.docs30d"), - align: "right", - render: (s) => s.docs30d.toLocaleString(), - }, - { - key: "lastEvent", - header: t("sources.table.lastEvent"), render: (s) => ( - {s.lastEvent} + + {s.referenceCount} + ), }, - { - key: "owner", - header: t("sources.table.owner"), - render: (s) => {s.owner}, - }, { key: "expand", header: "", @@ -104,7 +103,7 @@ export function SourcesTable({ ); return ( - + className="portal-sources__table" columns={columns} rows={sources} diff --git a/frontend/portal/src/components/sources/WebhookPanel.stories.tsx b/frontend/portal/src/components/sources/WebhookPanel.stories.tsx deleted file mode 100644 index 673fc08a92..0000000000 --- a/frontend/portal/src/components/sources/WebhookPanel.stories.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import type { WebhookDetail } from "@portal/api/sources"; -import { WebhookPanel } from "@portal/components/sources/WebhookPanel"; - -const meta: Meta = { - title: "Portal/Sources/WebhookPanel", - component: WebhookPanel, - parameters: { layout: "padded" }, - decorators: [ - (S) => ( -
- -
- ), - ], -}; -export default meta; -type Story = StoryObj; - -const healthy: WebhookDetail = { - kind: "webhook", - url: "https://hooks.acme.com/stirling/ingest", - authType: "HMAC-SHA256", - successRate: 0.991, - retries24h: 3, - recentDeliveries: [ - { event: "document.processed", status: 200, time: "9m ago" }, - { event: "pipeline.completed", status: 200, time: "22m ago" }, - { event: "document.processed", status: 503, time: "1h ago" }, - ], -}; - -export const Healthy: Story = { args: { d: healthy } }; - -/** Below the 95% floor: success badge goes danger, retries spike, 5xx deliveries. */ -export const Failing: Story = { - args: { - d: { - ...healthy, - url: "https://erp.acme.com/inbound/stirling", - authType: "Basic", - successRate: 0.72, - retries24h: 58, - recentDeliveries: [ - { event: "document.processed", status: 502, time: "2m ago" }, - { event: "document.processed", status: 502, time: "5m ago" }, - { event: "pipeline.completed", status: 200, time: "9m ago" }, - ], - }, - }, -}; diff --git a/frontend/portal/src/components/sources/WebhookPanel.tsx b/frontend/portal/src/components/sources/WebhookPanel.tsx deleted file mode 100644 index 0edd58fd20..0000000000 --- a/frontend/portal/src/components/sources/WebhookPanel.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { Button, StatTile, StatusBadge } from "@shared/components"; -import type { WebhookDetail } from "@portal/api/sources"; -import { pct } from "@portal/components/sources/format"; -import "@portal/views/Sources.css"; - -export function WebhookPanel({ d }: { d: WebhookDetail }) { - const { t } = useTranslation(); - const rateTone = - d.successRate >= 0.99 - ? "success" - : d.successRate >= 0.95 - ? "warning" - : "danger"; - return ( -
-
- {d.url}} - /> - - - {pct(d.successRate)} - - } - /> - -
- -
- - {t("sources.webhook.recentDeliveries")} - -
- {d.recentDeliveries.map((r, i) => ( -
- - {r.status} - - {r.event} - {r.time} -
- ))} -
-
- - {/* TODO(backend): wire to POST /v1/sources/{id}/test-event and - GET /v1/sources/{id}/signing-secret β€” currently inert demo controls. */} -
- - -
-
- ); -} diff --git a/frontend/portal/src/components/sources/format.ts b/frontend/portal/src/components/sources/format.ts deleted file mode 100644 index 5ddbaaaaff..0000000000 --- a/frontend/portal/src/components/sources/format.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** Format a 0..1 ratio as a one-decimal percentage, e.g. 0.962 β†’ "96.2%". */ -export function pct(n: number): string { - return `${(n * 100).toFixed(1)}%`; -} diff --git a/frontend/portal/src/components/sources/sourceTypes.ts b/frontend/portal/src/components/sources/sourceTypes.ts new file mode 100644 index 0000000000..dd4b61470b --- /dev/null +++ b/frontend/portal/src/components/sources/sourceTypes.ts @@ -0,0 +1,95 @@ +import type { ChipTone } from "@shared/components"; + +/** + * Per-type presentation + create-form metadata. User-facing copy is stored as + * i18n keys (resolved by the rendering component via t()), not literals, so the + * table chip, type picker, and configure step stay translatable. The structure + * lives client-side so it stays stable regardless of which connections exist. + * The backend's source `type` string keys into here; unknown types fall back + * gracefully. + */ + +export interface SourceTypeMeta { + labelKey: string; + icon: string; + tone: ChipTone; +} + +const SOURCE_TYPE_META: Record = { + folder: { labelKey: "sources.types.folder.label", icon: "⛁", tone: "blue" }, +}; + +const UNKNOWN_TYPE_META: SourceTypeMeta = { + labelKey: "sources.types.unknown.label", + icon: "β—‡", + tone: "neutral", +}; + +export function sourceTypeMeta(type: string): SourceTypeMeta { + return SOURCE_TYPE_META[type] ?? UNKNOWN_TYPE_META; +} + +/** One configurable field for a creatable source type. */ +export interface SourceFieldDef { + key: string; + labelKey: string; + control: "text" | "select"; + required?: boolean; + placeholderKey?: string; + helperTextKey?: string; + options?: { value: string; labelKey: string }[]; + defaultValue?: string; +} + +/** A source type the wizard can create, with the fields its config needs. */ +export interface CreatableSourceType { + type: string; + labelKey: string; + descriptionKey: string; + fields: SourceFieldDef[]; +} + +export const CREATABLE_SOURCE_TYPES: CreatableSourceType[] = [ + { + type: "folder", + labelKey: "sources.types.folder.label", + descriptionKey: "sources.types.folder.description", + fields: [ + { + key: "directory", + labelKey: "sources.types.folder.fields.directory.label", + control: "text", + required: true, + placeholderKey: "sources.types.folder.fields.directory.placeholder", + helperTextKey: "sources.types.folder.fields.directory.helperText", + }, + { + key: "mode", + labelKey: "sources.types.folder.fields.mode.label", + control: "select", + defaultValue: "consume", + options: [ + { + value: "consume", + labelKey: "sources.types.folder.fields.mode.options.consume", + }, + { + value: "snapshot", + labelKey: "sources.types.folder.fields.mode.options.snapshot", + }, + ], + }, + ], + }, +]; + +/** Default option values for a type's create form. */ +export function defaultOptions( + type: CreatableSourceType, +): Record { + const out: Record = {}; + for (const field of type.fields) { + out[field.key] = field.defaultValue ?? ""; + } + return out; +} diff --git a/frontend/portal/src/mocks/handlers/sources.ts b/frontend/portal/src/mocks/handlers/sources.ts index d44322e992..90418caf56 100644 --- a/frontend/portal/src/mocks/handlers/sources.ts +++ b/frontend/portal/src/mocks/handlers/sources.ts @@ -1,12 +1,182 @@ import { http, HttpResponse, delay } from "msw"; -import type { Tier } from "@portal/contexts/TierContext"; -import { buildSourcesResponse } from "@portal/mocks/sources"; +import type { + Source, + SourceKpi, + SourcePolicyRef, + SourceStatus, + SourceView, + SourcesResponse, +} from "@portal/api/sources"; + +/** + * Stateful mock for the Sources surface so the portal works fully offline with + * mocks on. Mirrors the real backend shape (`/api/v1/sources`, SourceController + + * SourceOverviewService): create/delete mutate an in-memory store, and delete of + * a still-referenced source returns 409. With mocks OFF these calls fall through + * to the real backend instead, like any other `/api/v1/...` surface. + */ + +interface StoredSource extends Source { + id: string; +} + +function seedSources(): StoredSource[] { + return [ + { + id: "src-claims", + name: "Claims intake", + type: "folder", + options: { directory: "/data/claims-intake", mode: "consume" }, + enabled: true, + owner: "you@acme.com", + }, + { + id: "src-contracts", + name: "Contracts drop", + type: "folder", + options: { directory: "/data/contracts", mode: "snapshot" }, + enabled: true, + owner: "legal-ops@acme.com", + }, + { + id: "src-archive", + name: "Archive reprocess", + type: "folder", + options: { directory: "/data/archive", mode: "consume" }, + enabled: true, + owner: "data-eng@acme.com", + }, + { + id: "src-legacy", + name: "Legacy share (paused)", + type: "folder", + options: { directory: "/mnt/legacy" }, + enabled: false, + owner: "data-eng@acme.com", + }, + ]; +} + +/** Which seeded policies reference each seeded source (drives reference counts). */ +const references: Record = { + "src-claims": [ + { id: "pol_security", name: "Security Policy" }, + { id: "pol_redaction", name: "Redaction Policy" }, + ], + "src-contracts": [{ id: "pol_contract", name: "Contract Review" }], +}; + +let store: StoredSource[] = seedSources(); + +let idCounter = 0; +function nextId(): string { + idCounter += 1; + return `src_${Date.now().toString(36)}_${idCounter}`; +} + +function refsFor(id: string): SourcePolicyRef[] { + return references[id] ?? []; +} + +function configRows(options: Record) { + return Object.entries(options).map(([key, value]) => ({ + label: key.charAt(0).toUpperCase() + key.slice(1), + value: String(value), + })); +} + +function deriveStatus( + source: StoredSource, + referenceCount: number, +): SourceStatus { + if (!source.enabled) return "disabled"; + return referenceCount === 0 ? "unused" : "active"; +} + +function toSourceView( + source: StoredSource, + refs: SourcePolicyRef[], +): SourceView { + return { + id: source.id, + name: source.name, + type: source.type, + status: deriveStatus(source, refs.length), + referenceCount: refs.length, + referencingPolicies: refs, + config: configRows(source.options), + docsTotal: null, + }; +} + +function buildKpis(views: SourceView[]): SourceKpi[] { + const total = views.length; + const inUse = views.filter((v) => v.referenceCount > 0).length; + return [ + { value: total, description: "connections" }, + { value: inUse, description: "referenced by a policy" }, + { value: total - inUse, description: "unused" }, + ]; +} + +function buildOverview(): SourcesResponse { + const views = store + .map((s) => toSourceView(s, refsFor(s.id))) + .sort( + (a, b) => + b.referenceCount - a.referenceCount || a.name.localeCompare(b.name), + ); + return { kpis: buildKpis(views), sources: views }; +} export const sourcesHandlers = [ - http.get("/v1/sources", async ({ request }) => { + http.get("/api/v1/sources", async () => { await delay(120); - const url = new URL(request.url); - const tier = (url.searchParams.get("tier") ?? "pro") as Tier; - return HttpResponse.json(buildSourcesResponse(tier)); + return HttpResponse.json(buildOverview()); + }), + + http.get("/api/v1/sources/:id", async ({ params }) => { + await delay(120); + const source = store.find((s) => s.id === params.id); + if (!source) return new HttpResponse(null, { status: 404 }); + return HttpResponse.json(source); + }), + + http.post("/api/v1/sources", async ({ request }) => { + await delay(120); + const incoming = (await request.json()) as Source; + const existing = incoming.id + ? store.find((s) => s.id === incoming.id) + : undefined; + const id = existing?.id ?? nextId(); + const saved: StoredSource = { + ...incoming, + id, + owner: existing?.owner ?? "you@acme.com", + }; + store = existing + ? store.map((s) => (s.id === id ? saved : s)) + : [...store, saved]; + return HttpResponse.json(saved); + }), + + http.delete("/api/v1/sources/:id", async ({ params }) => { + await delay(120); + const id = String(params.id); + const source = store.find((s) => s.id === id); + if (!source) return new HttpResponse(null, { status: 404 }); + const refs = refsFor(id); + if (refs.length > 0) { + return HttpResponse.json( + { + detail: `Source is referenced by ${refs.length} policy(ies): ${refs + .map((r) => r.name) + .join(", ")}`, + }, + { status: 409 }, + ); + } + store = store.filter((s) => s.id !== id); + return new HttpResponse(null, { status: 204 }); }), ]; diff --git a/frontend/portal/src/mocks/sources.ts b/frontend/portal/src/mocks/sources.ts deleted file mode 100644 index d881043a33..0000000000 --- a/frontend/portal/src/mocks/sources.ts +++ /dev/null @@ -1,545 +0,0 @@ -/** - * Sources & Agents fixtures and the types api/sources.ts shares with them. - * - * A "source" is anything that feeds documents into Stirling β€” an interactive - * editor session, an autonomous agent, an API client, a webhook, a storage - * connector, an email inbox, a desktop app, or a batch job. Each carries a - * type-specific detail payload surfaced when its table row is expanded. - * - * api/sources.ts imports the types; the MSW handlers serve the fixture data - * over the intercepted httpJson() calls. Components never reach into this - * module directly. Once a real backend exists the handlers stop being - * registered and these fixtures can be deleted (or kept as test seeds). - */ - -import type { Tier } from "@portal/contexts/TierContext"; - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Source types */ -/* ──────────────────────────────────────────────────────────────────────── */ - -export type SourceType = - | "editor" - | "agent" - | "apiclient" - | "webhook" - | "connector" - | "email" - | "desktop" - | "batch"; - -export type SourceStatus = "active" | "idle" | "degraded" | "paused" | "error"; - -/** Type-specific detail payloads, discriminated by `kind`. */ - -export interface AgentDetail { - kind: "agent"; - model: string; - /** Calls over the trailing 24h. */ - calls24h: number; - errorRate: number; - /** Mean output confidence 0..1. */ - confidence: number; - escalations24h: number; - /** Pipelines this agent is allowed to invoke. */ - assignedPipelines: string[]; - scopes: string[]; -} - -export interface ApiClientDetail { - kind: "apiclient"; - /** Pre-masked for display β€” never carries the real secret. */ - maskedKey: string; - rateLimit: string; - /** Requests used against the current rate-limit window. */ - rateUsedPct: number; - endpoints: { method: string; path: string; calls24h: number }[]; - createdBy: string; - lastRotated: string; -} - -export interface WebhookDetail { - kind: "webhook"; - url: string; - authType: "HMAC-SHA256" | "Bearer token" | "Basic" | "None"; - successRate: number; - /** Recent deliveries, newest first. */ - recentDeliveries: { event: string; status: number; time: string }[]; - retries24h: number; -} - -/** Generic key/value detail for the simpler source types. */ -export interface BasicDetail { - kind: "basic"; - rows: { label: string; value: string }[]; -} - -export type SourceDetail = - | AgentDetail - | ApiClientDetail - | WebhookDetail - | BasicDetail; - -export interface Source { - id: string; - name: string; - type: SourceType; - status: SourceStatus; - docs24h: number; - docs30d: number; - /** Relative-time string, e.g. "2m ago". */ - lastEvent: string; - owner: string; - detail: SourceDetail; -} - -export interface SourcesKpi { - value: string | number; - delta?: number; - deltaDirection?: "up" | "down" | "flat"; - description?: string; -} - -export interface SourcesResponse { - kpis: SourcesKpi[]; - sources: Source[]; -} - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Per-type presentation metadata (icon + chip tone + label) */ -/* Lives client-side β€” it's product copy, not data. Re-exported for the */ -/* view via api/sources.ts. */ -/* ──────────────────────────────────────────────────────────────────────── */ - -export interface SourceTypeMeta { - label: string; - icon: string; - tone: "neutral" | "blue" | "purple" | "green" | "amber" | "red"; -} - -export const SOURCE_TYPE_META: Record = { - editor: { label: "Editor", icon: "✎", tone: "neutral" }, - agent: { label: "Agent", icon: "β—†", tone: "purple" }, - apiclient: { label: "API client", icon: "⌘", tone: "blue" }, - webhook: { label: "Webhook", icon: "⇄", tone: "green" }, - connector: { label: "Connector", icon: "⛁", tone: "amber" }, - email: { label: "Email inbox", icon: "βœ‰", tone: "blue" }, - desktop: { label: "Desktop", icon: "β–£", tone: "neutral" }, - batch: { label: "Batch job", icon: "≑", tone: "amber" }, -}; - -export const SOURCE_STATUS_TONE: Record< - SourceStatus, - "success" | "warning" | "danger" | "neutral" | "info" -> = { - active: "success", - idle: "neutral", - degraded: "warning", - paused: "info", - error: "danger", -}; - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Fixture builders */ -/* ──────────────────────────────────────────────────────────────────────── */ - -function agentDetail(over: Partial): AgentDetail { - return { - kind: "agent", - model: "claude-sonnet-4.5", - calls24h: 0, - errorRate: 0, - confidence: 0.95, - escalations24h: 0, - assignedPipelines: [], - scopes: [], - ...over, - }; -} - -const PRO_SOURCES: Source[] = [ - { - id: "src-editor-1", - name: "Web Editor β€” workspace", - type: "editor", - status: "active", - docs24h: 38, - docs30d: 642, - lastEvent: "4m ago", - owner: "you@acme.com", - detail: { - kind: "basic", - rows: [ - { label: "Session", value: "Browser Β· Chrome 126" }, - { label: "Active editors", value: "3 this week" }, - { label: "Default pipeline", value: "Redact & Flatten" }, - { label: "Region", value: "us-east-1" }, - ], - }, - }, - { - id: "src-agent-1", - name: "Invoice Extractor", - type: "agent", - status: "active", - docs24h: 1287, - docs30d: 31840, - lastEvent: "32s ago", - owner: "platform@acme.com", - detail: agentDetail({ - model: "claude-sonnet-4.5", - calls24h: 1342, - errorRate: 0.004, - confidence: 0.962, - escalations24h: 11, - assignedPipelines: ["Invoice v3", "AP Routing"], - scopes: ["documents:read", "pipelines:invoke", "extract:write"], - }), - }, - { - id: "src-agent-2", - name: "Contract Router", - type: "agent", - status: "degraded", - docs24h: 412, - docs30d: 9870, - lastEvent: "6m ago", - owner: "legal-ops@acme.com", - // Degraded: error rate over the 5% alarm threshold and confidence below the - // green band, so the panel renders danger/amber tones. - detail: agentDetail({ - model: "claude-opus-4.1", - calls24h: 455, - errorRate: 0.071, - confidence: 0.883, - escalations24h: 34, - assignedPipelines: ["Contract Review", "DPA Classifier"], - scopes: ["documents:read", "pipelines:invoke", "review:escalate"], - }), - }, - { - id: "src-agent-3", - name: "KYC Processor", - type: "agent", - status: "active", - docs24h: 768, - docs30d: 18420, - lastEvent: "1m ago", - owner: "risk@acme.com", - detail: agentDetail({ - model: "claude-sonnet-4.5", - calls24h: 802, - errorRate: 0.012, - confidence: 0.941, - escalations24h: 7, - assignedPipelines: ["KYC Onboarding"], - scopes: ["documents:read", "pipelines:invoke", "pii:read"], - }), - }, - { - id: "src-api-1", - name: "Acme Production", - type: "apiclient", - status: "active", - docs24h: 2940, - docs30d: 71200, - lastEvent: "11s ago", - owner: "platform@acme.com", - detail: { - kind: "apiclient", - maskedKey: "sk_live_β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’4f9a", - rateLimit: "600 req/min", - rateUsedPct: 0.42, - endpoints: [ - { method: "POST", path: "/v1/extract", calls24h: 1820 }, - { method: "POST", path: "/v1/redact", calls24h: 740 }, - { method: "GET", path: "/v1/documents/{id}", calls24h: 380 }, - ], - createdBy: "you@acme.com", - lastRotated: "23 days ago", - }, - }, - { - id: "src-webhook-1", - name: "Slack delivery hook", - type: "webhook", - status: "active", - docs24h: 96, - docs30d: 2310, - lastEvent: "9m ago", - owner: "platform@acme.com", - detail: { - kind: "webhook", - url: "https://hooks.acme.com/stirling/ingest", - authType: "HMAC-SHA256", - successRate: 0.991, - retries24h: 3, - recentDeliveries: [ - { event: "document.processed", status: 200, time: "9m ago" }, - { event: "pipeline.completed", status: 200, time: "22m ago" }, - { event: "document.processed", status: 503, time: "1h ago" }, - ], - }, - }, - { - id: "src-webhook-err", - name: "Legacy ERP callback", - type: "webhook", - status: "error", - docs24h: 41, - docs30d: 1180, - lastEvent: "2m ago", - owner: "integrations@acme.com", - // Endpoint is rejecting most deliveries β€” success rate below the 95% floor - // drives the danger tone and the retry count climbs. - detail: { - kind: "webhook", - url: "https://erp.acme.com/inbound/stirling", - authType: "Basic", - successRate: 0.72, - retries24h: 58, - recentDeliveries: [ - { event: "document.processed", status: 502, time: "2m ago" }, - { event: "document.processed", status: 502, time: "5m ago" }, - { event: "pipeline.completed", status: 200, time: "9m ago" }, - ], - }, - }, - { - id: "src-batch-1", - name: "Nightly archive reprocess", - type: "batch", - status: "idle", - docs24h: 0, - docs30d: 48600, - lastEvent: "8h ago", - owner: "data-eng@acme.com", - detail: { - kind: "basic", - rows: [ - { label: "Schedule", value: "Daily Β· 02:00 UTC" }, - { label: "Last run", value: "1,620 docs Β· 0 errors" }, - { label: "Source bucket", value: "s3://acme-archive/inbound" }, - { label: "Pipeline", value: "OCR & Index" }, - ], - }, - }, -]; - -const ENTERPRISE_EXTRA: Source[] = [ - { - id: "src-connector-1", - name: "SharePoint β€” Legal", - type: "connector", - status: "active", - docs24h: 1840, - docs30d: 44900, - lastEvent: "2m ago", - owner: "legal-ops@acme.com", - detail: { - kind: "basic", - rows: [ - { label: "Provider", value: "Microsoft SharePoint" }, - { label: "Sync", value: "Delta Β· every 5 min" }, - { label: "Watched library", value: "Contracts / Inbound" }, - { label: "Auth", value: "Azure AD app registration" }, - ], - }, - }, - { - id: "src-connector-2", - name: "S3 β€” claims-intake", - type: "connector", - status: "active", - docs24h: 5210, - docs30d: 128400, - lastEvent: "40s ago", - owner: "claims@acme.com", - detail: { - kind: "basic", - rows: [ - { label: "Provider", value: "AWS S3" }, - { label: "Bucket", value: "s3://acme-claims/intake" }, - { label: "Notification", value: "EventBridge β†’ SQS" }, - { label: "Region", value: "us-east-1" }, - ], - }, - }, - { - id: "src-email-1", - name: "invoices@acme.com", - type: "email", - status: "active", - docs24h: 318, - docs30d: 7640, - lastEvent: "14m ago", - owner: "ap@acme.com", - detail: { - kind: "basic", - rows: [ - { label: "Inbox", value: "invoices@acme.com" }, - { label: "Attachments", value: "PDF only Β· max 25 MB" }, - { label: "Pipeline", value: "Invoice v3" }, - { label: "Spam filter", value: "Enabled Β· DKIM verified" }, - ], - }, - }, - { - id: "src-desktop-1", - name: "Stirling Desktop β€” Reviewer pool", - type: "desktop", - status: "idle", - docs24h: 47, - docs30d: 1290, - lastEvent: "3h ago", - owner: "review-team@acme.com", - detail: { - kind: "basic", - rows: [ - { label: "App version", value: "Desktop 2.7.1" }, - { label: "Seats", value: "12 active devices" }, - { label: "Default pipeline", value: "Manual Review" }, - { label: "Offline queue", value: "Enabled" }, - ], - }, - }, - { - id: "src-agent-4", - name: "Compliance Sweep", - type: "agent", - status: "active", - docs24h: 2105, - docs30d: 52300, - lastEvent: "18s ago", - owner: "compliance@acme.com", - detail: agentDetail({ - model: "claude-opus-4.1", - calls24h: 2180, - errorRate: 0.008, - confidence: 0.957, - escalations24h: 19, - assignedPipelines: ["COI Compliance", "PII Sweep", "Retention Policy"], - scopes: ["documents:read", "pipelines:invoke", "pii:read", "audit:write"], - }), - }, - { - id: "src-webhook-2", - name: "Datadog events hook", - type: "webhook", - status: "active", - docs24h: 410, - docs30d: 9800, - lastEvent: "1m ago", - owner: "sre@acme.com", - detail: { - kind: "webhook", - url: "https://intake.datadoghq.com/stirling/events", - authType: "Bearer token", - successRate: 0.999, - retries24h: 0, - recentDeliveries: [ - { event: "pipeline.completed", status: 202, time: "1m ago" }, - { event: "agent.escalated", status: 202, time: "12m ago" }, - { event: "pipeline.failed", status: 202, time: "47m ago" }, - ], - }, - }, - { - id: "src-api-revoked", - name: "Legacy ETL (revoked)", - type: "apiclient", - status: "error", - docs24h: 0, - docs30d: 3120, - lastEvent: "5d ago", - owner: "data-eng@acme.com", - // Key was revoked after a leak; calls now 401 so 24h traffic is zero while - // the 30d window still shows the pre-revocation volume. - detail: { - kind: "apiclient", - maskedKey: "sk_live_β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’0000 (revoked)", - rateLimit: "300 req/min", - rateUsedPct: 0, - endpoints: [ - { method: "POST", path: "/v1/extract", calls24h: 0 }, - { method: "POST", path: "/v1/redact", calls24h: 0 }, - ], - createdBy: "former-admin@acme.com", - lastRotated: "never", - }, - }, - { - id: "src-api-2", - name: "Partner Integration (read-only)", - type: "apiclient", - status: "paused", - docs24h: 0, - docs30d: 14200, - lastEvent: "2d ago", - owner: "partnerships@acme.com", - detail: { - kind: "apiclient", - maskedKey: "sk_live_β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’91c2", - rateLimit: "120 req/min", - rateUsedPct: 0, - endpoints: [ - { method: "GET", path: "/v1/documents/{id}", calls24h: 0 }, - { method: "GET", path: "/v1/pipelines", calls24h: 0 }, - ], - createdBy: "you@acme.com", - lastRotated: "61 days ago", - }, - }, -]; - -/** Sources for a given tier. Free is intentionally empty. */ -export function sourcesFor(tier: Tier): Source[] { - if (tier === "free") return []; - if (tier === "enterprise") return [...PRO_SOURCES, ...ENTERPRISE_EXTRA]; - return PRO_SOURCES; -} - -/** - * KPI strip values. Pro sits below the eval pass-rate target, enterprise above β€” - * the delta/direction differences let a dev see tier variation at a glance. - */ -export function kpisFor(tier: Tier): SourcesKpi[] { - if (tier === "free") { - return [ - { value: 0, description: "Connect a source to begin" }, - { value: 0 }, - { value: "β€”" }, - { value: 0 }, - ]; - } - - const sources = sourcesFor(tier); - const agents = sources.filter((s) => s.type === "agent"); - const agentsActive = agents.filter((s) => s.status === "active").length; - const docs24h = sources.reduce((sum, s) => sum + s.docs24h, 0); - - if (tier === "enterprise") { - return [ - { - value: agentsActive, - delta: 0.25, - description: `${agents.length} total`, - }, - { value: 42, delta: 0.09, description: "Eval scenarios" }, - { value: "96.4%", deltaDirection: "up", delta: 0.012 }, - { value: docs24h.toLocaleString(), delta: 0.14 }, - ]; - } - - // pro - return [ - { value: agentsActive, delta: 0.5, description: `${agents.length} total` }, - { value: 18, delta: 0.2, description: "Eval scenarios" }, - { value: "91.2%", deltaDirection: "flat", delta: 0 }, - { value: docs24h.toLocaleString(), delta: 0.16 }, - ]; -} - -export function buildSourcesResponse(tier: Tier): SourcesResponse { - return { kpis: kpisFor(tier), sources: sourcesFor(tier) }; -} diff --git a/frontend/portal/src/setupTests.ts b/frontend/portal/src/setupTests.ts new file mode 100644 index 0000000000..9064c734ba --- /dev/null +++ b/frontend/portal/src/setupTests.ts @@ -0,0 +1,64 @@ +import "@testing-library/jest-dom"; +import { vi } from "vitest"; + +// Mirrors the editor's setup: jsdom lacks a handful of browser APIs that shared +// components (Mantine FocusTrap, responsive helpers) touch on render. + +class LocalStorageMock implements Storage { + private store: Record = {}; + + get length(): number { + return Object.keys(this.store).length; + } + + clear(): void { + this.store = {}; + } + + getItem(key: string): string | null { + return this.store[key] ?? null; + } + + key(index: number): string | null { + return Object.keys(this.store)[index] ?? null; + } + + removeItem(key: string): void { + delete this.store[key]; + } + + setItem(key: string, value: string): void { + this.store[key] = value; + } +} + +Object.defineProperty(window, "localStorage", { + value: new LocalStorageMock(), + writable: true, +}); + +global.ResizeObserver = vi.fn().mockImplementation(() => ({ + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), +})); + +global.IntersectionObserver = vi.fn().mockImplementation(() => ({ + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), +})) as unknown as typeof IntersectionObserver; + +Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), +}); diff --git a/frontend/portal/src/views/Sources.test.tsx b/frontend/portal/src/views/Sources.test.tsx new file mode 100644 index 0000000000..98ce7175e4 --- /dev/null +++ b/frontend/portal/src/views/Sources.test.tsx @@ -0,0 +1,130 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { HttpError } from "@portal/api/http"; +import type { SourcesResponse } from "@portal/api/sources"; +import { Sources } from "@portal/views/Sources"; + +// Deterministic i18n: keys returned verbatim, so assertions are stable without +// the async TOML backend. +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { changeLanguage: vi.fn() }, + }), +})); + +const fetchSources = vi.fn(); +const fetchSource = vi.fn(); +const createSource = vi.fn(); +const deleteSource = vi.fn(); +vi.mock("@portal/api/sources", () => ({ + fetchSources: () => fetchSources(), + fetchSource: (id: string) => fetchSource(id), + createSource: (source: unknown) => createSource(source), + deleteSource: (id: string) => deleteSource(id), +})); + +const RESPONSE: SourcesResponse = { + kpis: [ + { value: 2, description: "" }, + { value: 1, description: "" }, + { value: 1, description: "" }, + ], + sources: [ + { + id: "src-referenced", + name: "Claims intake", + type: "folder", + status: "active", + referenceCount: 2, + referencingPolicies: [ + { id: "pol-1", name: "Redaction" }, + { id: "pol-2", name: "Classification" }, + ], + config: [{ label: "Directory", value: "/data/incoming" }], + docsTotal: null, + }, + { + id: "src-orphan", + name: "Scratch folder", + type: "folder", + status: "unused", + referenceCount: 0, + referencingPolicies: [], + config: [{ label: "Directory", value: "/tmp/scratch" }], + docsTotal: null, + }, + ], +}; + +function renderView() { + return render( + + + , + ); +} + +describe("Sources view", () => { + beforeEach(() => { + fetchSources.mockReset(); + fetchSource.mockReset(); + createSource.mockReset(); + deleteSource.mockReset(); + }); + + it("surfaces the inline 409 message when deleting a referenced source", async () => { + fetchSources.mockResolvedValue(RESPONSE); + deleteSource.mockRejectedValue( + new HttpError(409, "Conflict", { + detail: "Source is referenced by 2 policies", + }), + ); + + renderView(); + + // Wait for the row to render after the async fetch resolves. + const row = await screen.findByText("Claims intake"); + fireEvent.click(row); + + // Detail card opens with its delete action. + fireEvent.click(await screen.findByText("sources.detail.delete")); + + // Confirm in the dialog. + fireEvent.click(await screen.findByText("sources.delete.confirm")); + + await waitFor(() => { + expect(deleteSource).toHaveBeenCalledWith("src-referenced"); + }); + + expect( + await screen.findByText("Source is referenced by 2 policies"), + ).toBeInTheDocument(); + }); + + it("pauses a source by re-saving it with enabled flipped off", async () => { + fetchSources.mockResolvedValue(RESPONSE); + fetchSource.mockResolvedValue({ + id: "src-referenced", + name: "Claims intake", + type: "folder", + options: { directory: "/data/incoming", mode: "consume" }, + enabled: true, + }); + createSource.mockResolvedValue({}); + + renderView(); + + fireEvent.click(await screen.findByText("Claims intake")); + fireEvent.click(await screen.findByText("sources.detail.pause")); + + await waitFor(() => { + expect(createSource).toHaveBeenCalledTimes(1); + }); + expect(fetchSource).toHaveBeenCalledWith("src-referenced"); + expect(createSource).toHaveBeenCalledWith( + expect.objectContaining({ id: "src-referenced", enabled: false }), + ); + }); +}); diff --git a/frontend/portal/src/views/Sources.tsx b/frontend/portal/src/views/Sources.tsx index 02c427fde1..230dc3abb7 100644 --- a/frontend/portal/src/views/Sources.tsx +++ b/frontend/portal/src/views/Sources.tsx @@ -1,10 +1,24 @@ -import { useState } from "react"; +import { useCallback, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Button, EmptyState, Skeleton } from "@shared/components"; -import { useTier } from "@portal/contexts/TierContext"; +import { + Banner, + Button, + EmptyState, + Modal, + Skeleton, +} from "@shared/components"; import { useView } from "@portal/contexts/ViewContext"; import { useAsync, useSectionFlags } from "@portal/hooks/useAsync"; -import { fetchSources, type SourcesResponse } from "@portal/api/sources"; +import { errorMessage } from "@portal/api/http"; +import { + createSource, + deleteSource, + fetchSource, + fetchSources, + type Source, + type SourcesResponse, + type SourceView, +} from "@portal/api/sources"; import { AgentBuilderIcon } from "@portal/components/icons"; import { KpiStrip } from "@portal/components/sources/KpiStrip"; import { SourcesTable } from "@portal/components/sources/SourcesTable"; @@ -14,18 +28,86 @@ import "@portal/views/Sources.css"; export function Sources() { const { t } = useTranslation(); - const { tier } = useTier(); const { setActiveView } = useView(); - const state = useAsync(() => fetchSources(tier), [tier]); + // Refetch after every mutation by bumping this counter, so the table reflects + // the in-memory store the handlers maintain (mirrors the Policies view). + const [version, setVersion] = useState(0); + const state = useAsync(() => fetchSources(), [version]); const { data, loading } = state; const { isLoading, isEmpty } = useSectionFlags(state); + const refetch = useCallback(() => setVersion((v) => v + 1), []); const [expandedId, setExpandedId] = useState(null); const [wizardOpen, setWizardOpen] = useState(false); + const [editingSource, setEditingSource] = useState(null); + const [mutating, setMutating] = useState(false); + const [pageError, setPageError] = useState(null); + const [pendingDelete, setPendingDelete] = useState(null); + const [deleting, setDeleting] = useState(false); + const [deleteError, setDeleteError] = useState(null); const sources = data?.sources ?? []; const expanded = sources.find((s) => s.id === expandedId) ?? null; + function openCreate() { + setEditingSource(null); + setWizardOpen(true); + } + + // Editing needs the raw source (config options), which the overview rows don't + // carry, so fetch it before opening the wizard prefilled. + async function openEdit(source: SourceView) { + if (mutating) return; + setPageError(null); + setMutating(true); + try { + setEditingSource(await fetchSource(source.id)); + setWizardOpen(true); + } catch (e) { + setPageError(errorMessage(e)); + } finally { + setMutating(false); + } + } + + // Pause/resume: re-save the source with enabled flipped (same POST contract as + // edit). Fetch the raw record first so the full config round-trips intact. + async function togglePause(source: SourceView) { + if (mutating) return; + setPageError(null); + setMutating(true); + try { + const raw = await fetchSource(source.id); + await createSource({ ...raw, enabled: !raw.enabled }); + refetch(); + } catch (e) { + setPageError(errorMessage(e)); + } finally { + setMutating(false); + } + } + + function requestDelete(source: SourceView) { + setDeleteError(null); + setPendingDelete(source); + } + + async function confirmDelete() { + if (!pendingDelete || deleting) return; + setDeleting(true); + setDeleteError(null); + try { + await deleteSource(pendingDelete.id); + setPendingDelete(null); + setExpandedId(null); + refetch(); + } catch (e) { + setDeleteError(errorMessage(e)); + } finally { + setDeleting(false); + } + } + return (
@@ -41,15 +123,14 @@ export function Sources() { > {t("sources.actions.agentBuilder")} -
+ {pageError && } + {isLoading && ( @@ -65,7 +146,7 @@ export function Sources() { title={t("sources.empty.title")} description={t("sources.empty.description")} actions={ - } @@ -86,10 +167,49 @@ export function Sources() { setExpandedId(null)} + onEdit={openEdit} + onTogglePause={togglePause} + onDelete={requestDelete} + busy={mutating} /> )} - setWizardOpen(false)} /> + setWizardOpen(false)} + onCreated={refetch} + /> + + !deleting && setPendingDelete(null)} + width="sm" + title={t("sources.delete.title")} + footer={ +
+ + +
+ } + > +

{t("sources.delete.body", { name: pendingDelete?.name ?? "" })}

+ {deleteError && } +
); } diff --git a/frontend/portal/vitest.config.ts b/frontend/portal/vitest.config.ts new file mode 100644 index 0000000000..a34f840b89 --- /dev/null +++ b/frontend/portal/vitest.config.ts @@ -0,0 +1,38 @@ +import { defineConfig } from "vitest/config"; +import react from "@vitejs/plugin-react-swc"; +import tsconfigPaths from "vite-tsconfig-paths"; +import { resolve } from "node:path"; + +// Standalone test config for the portal app, mirroring the editor's setup. +// Explicit resolve.alias for @portal and @shared so imports resolve even when +// they originate inside frontend/shared/ (outside the portal tsconfig scope), +// matching the resolve.alias block in the portal's vite.config.ts. +const portalDir = resolve(__dirname, "src"); +const sharedDir = resolve(__dirname, "..", "shared"); + +export default defineConfig({ + plugins: [ + react(), + tsconfigPaths({ + projects: [resolve(__dirname, "tsconfig.json")], + }), + ], + resolve: { + alias: { + "@portal": portalDir, + "@shared": sharedDir, + }, + }, + test: { + globals: true, + environment: "jsdom", + setupFiles: ["./src/setupTests.ts"], + css: false, + include: ["src/**/*.test.{ts,tsx}"], + testTimeout: 10000, + hookTimeout: 10000, + }, + esbuild: { + target: "es2020", + }, +}); diff --git a/frontend/shared/i18n/translationAudit.ts b/frontend/shared/i18n/translationAudit.ts index 0140a15699..50cd1b2c76 100644 --- a/frontend/shared/i18n/translationAudit.ts +++ b/frontend/shared/i18n/translationAudit.ts @@ -102,7 +102,12 @@ export const I18N_PROJECTS: TranslationProject[] = [ srcRoot: front("portal/src"), extraRoots: [SHARED_SRC], localeFile: front("portal/public/locales/en-US/translation.toml"), - ignoredKeyPatterns: [], + ignoredKeyPatterns: [ + // Source-type copy is referenced via metadata keys in + // components/sources/sourceTypes.ts (t(field.labelKey)), so the static + // scan can't see these as used. + /^sources\.types\./, + ], minUsedKeys: 20, minLocaleKeys: 20, }, From eea9696bd41958706159ab61c838d96a026a86f1 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Fri, 26 Jun 2026 15:53:51 +0100 Subject: [PATCH 03/12] Actually build the frontend for Playwright nightlies (#6817) # Description of Changes [Our nightlies have literally never passed before](https://github.com/Stirling-Tools/Stirling-PDF/actions/workflows/nightly.yml). As far as I can tell, that's because the frontend was never being built, so the Playwright tests would just never start up. I've forced a nightly run from this branch, and the Playwright tests still fail, but for legitimate failures now. It's a separate job to track down why they're actually failing, so I'm leaving that for followup work. --- .github/workflows/nightly.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index e0b38c6929..11a8ea2be5 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -41,6 +41,11 @@ jobs: - name: Install all Playwright browsers run: task e2e:install + - name: Build frontend (production bundle for vite preview) + env: + VITE_BUILD_FOR_PREVIEW: "1" + run: task frontend:build + - name: Run E2E tests (all browsers) run: task e2e:cross-browser From 013f14546257ebd97b42b09605763a5557058947 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Fri, 26 Jun 2026 16:05:07 +0100 Subject: [PATCH 04/12] Upgrade to TS7 for local type-checking (#6815) # Description of Changes We can't convert to TS7 completely yet because it lacks the TS API, so ESLint and some of our scripts don't work, but we can do [what the TS team suggest and run TS6 and TS7 side-by-side](https://devblogs.microsoft.com/typescript/progress-on-typescript-7-december-2025/#compiler). When we do that, we take the `task frontend:typecheck:all` job from ~76s to ~13s, and everything else continues to work as it did before. I've set it so that CI will still use TS6 for the time being and locally we use TS7 out of an abundance of caution because CI time doesn't really matter but local time does. I do think it was a bit pointless doing that since the TS team claim the type checking performs identically, but we might as well have it like that for now. If it happens to go badly locally for any devs, they can use `CI=true task frontend:typecheck` to revert to use TS6 trivially. --- .taskfiles/frontend.yml | 37 ++++-- frontend/package-lock.json | 256 ++++++++++++++++++++++++++++--------- frontend/package.json | 1 + 3 files changed, 227 insertions(+), 67 deletions(-) diff --git a/.taskfiles/frontend.yml b/.taskfiles/frontend.yml index 1318baf803..3ad0486759 100644 --- a/.taskfiles/frontend.yml +++ b/.taskfiles/frontend.yml @@ -295,17 +295,26 @@ tasks: cmds: - task: typecheck:proprietary + typecheck:_run: + internal: true + env: + CI: '{{ .CI | default "false" }}' + cmds: + - '{{ if eq .CI "true" }}npx tsc{{ else }}npx tsgo{{ end }} --noEmit --project {{.PROJECT}}' + typecheck:core: desc: "Typecheck core build variant" deps: [prepare] cmds: - - npx tsc --noEmit --project editor/src/core/tsconfig.json + - task: typecheck:_run + vars: { PROJECT: editor/src/core/tsconfig.json } typecheck:proprietary: desc: "Typecheck proprietary build variant" deps: [prepare] cmds: - - npx tsc --noEmit --project editor/src/proprietary/tsconfig.json + - task: typecheck:_run + vars: { PROJECT: editor/src/proprietary/tsconfig.json } typecheck:saas: desc: "Typecheck SaaS build variant" @@ -313,7 +322,8 @@ tasks: - task: prepare vars: { MODE: saas } cmds: - - npx tsc --noEmit --project editor/src/saas/tsconfig.json + - task: typecheck:_run + vars: { PROJECT: editor/src/saas/tsconfig.json } typecheck:desktop: desc: "Typecheck desktop build variant" @@ -321,38 +331,45 @@ tasks: - task: prepare vars: { MODE: desktop } cmds: - - npx tsc --noEmit --project editor/src/desktop/tsconfig.json + - task: typecheck:_run + vars: { PROJECT: editor/src/desktop/tsconfig.json } typecheck:cloud: desc: "Typecheck cloud shared layer (standalone)" deps: [prepare] cmds: - - npx tsc --noEmit --project editor/src/cloud/tsconfig.json + - task: typecheck:_run + vars: { PROJECT: editor/src/cloud/tsconfig.json } typecheck:scripts: desc: "Typecheck scripts" deps: [prepare] cmds: - - npx tsc --noEmit --project scripts/tsconfig.json - - npx tsc --noEmit --project editor/scripts/tsconfig.json + - task: typecheck:_run + vars: { PROJECT: scripts/tsconfig.json } + - task: typecheck:_run + vars: { PROJECT: editor/scripts/tsconfig.json } typecheck:prototypes: desc: "Typecheck prototypes build variant" deps: [prepare] cmds: - - npx tsc --noEmit --project editor/src/prototypes/tsconfig.json + - task: typecheck:_run + vars: { PROJECT: editor/src/prototypes/tsconfig.json } typecheck:portal: desc: "Typecheck developer portal build variant" deps: [install] cmds: - - npx tsc --noEmit --project portal/tsconfig.json + - task: typecheck:_run + vars: { PROJECT: portal/tsconfig.json } typecheck:shared: desc: "Typecheck the shared design system" deps: [install] cmds: - - npx tsc --noEmit --project shared/tsconfig.json + - task: typecheck:_run + vars: { PROJECT: shared/tsconfig.json } typecheck:all: desc: "Typecheck all build variants" diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1bb51a358b..3a9db8016c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -109,6 +109,7 @@ "@types/react-dom": "^19.1.9", "@typescript-eslint/eslint-plugin": "^8.61.1", "@typescript-eslint/parser": "^8.61.1", + "@typescript/native-preview": "7.0.0-dev.20260624.1", "@vitejs/plugin-react-swc": "^4.1.0", "@vitest/coverage-v8": "^3.2.4", "dotenv": "^16.4.7", @@ -6028,6 +6029,147 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@typescript/native-preview": { + "version": "7.0.0-dev.20260624.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview/-/native-preview-7.0.0-dev.20260624.1.tgz", + "integrity": "sha512-ogwfNo1xuAutOF8RbTCo3Ut0q/65u2ucOeHizi6O14q+3vnelNS+u8qVC2QWXubMcwtuN5E9cbfPslvGC4kdwA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsgo": "bin/tsgo.js" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260624.1", + "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260624.1", + "@typescript/native-preview-linux-arm": "7.0.0-dev.20260624.1", + "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260624.1", + "@typescript/native-preview-linux-x64": "7.0.0-dev.20260624.1", + "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260624.1", + "@typescript/native-preview-win32-x64": "7.0.0-dev.20260624.1" + } + }, + "node_modules/@typescript/native-preview-darwin-arm64": { + "version": "7.0.0-dev.20260624.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-arm64/-/native-preview-darwin-arm64-7.0.0-dev.20260624.1.tgz", + "integrity": "sha512-g8CqDkYCHTCYdhBHXs5cMraBurOS+KrcMFxE0SsaKZoI6Tnp+le1aWvxUBbzNKJYyThHJqb/1mLopzEJxJCuKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/native-preview-darwin-x64": { + "version": "7.0.0-dev.20260624.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-x64/-/native-preview-darwin-x64-7.0.0-dev.20260624.1.tgz", + "integrity": "sha512-P00JVvSV90eioYDuINAKmOSA8yhFTWLq6RvS5lrCfUuDlcgr2kSOgZAfFHIksHBVz6ZXpAXpa0dHPmc5SJ3Ymw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/native-preview-linux-arm": { + "version": "7.0.0-dev.20260624.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm/-/native-preview-linux-arm-7.0.0-dev.20260624.1.tgz", + "integrity": "sha512-eWHELvfQMkVRjafMd+3ATgM9p9yAergJaM4AOY8AekCNWnHFwUrp/ohh+ryyMUIqque5jjb/kuTiOiGj728I2Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/native-preview-linux-arm64": { + "version": "7.0.0-dev.20260624.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm64/-/native-preview-linux-arm64-7.0.0-dev.20260624.1.tgz", + "integrity": "sha512-cppM2yTZ/Gd1hOXy8NEJcUBxJ0O0zl9CU3OU1ZWZ/OHWWX/ukEzCCr94SUwJhjIWOylBCpIYkrvYoTwxNa94XQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/native-preview-linux-x64": { + "version": "7.0.0-dev.20260624.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-x64/-/native-preview-linux-x64-7.0.0-dev.20260624.1.tgz", + "integrity": "sha512-FaB8rS+rKYz4nDrEsHsF3b4cn7eCKCYroMJReA375OuQ6PHcmCNQ6QlVetA0dfFBxTTgejmoKyfw9xgAA5P4Yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/native-preview-win32-arm64": { + "version": "7.0.0-dev.20260624.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-arm64/-/native-preview-win32-arm64-7.0.0-dev.20260624.1.tgz", + "integrity": "sha512-BgkqbCmSHDb5UxqWaFlFFJ/DHNT3lEUO4W8627ap6+QthJZuXk2imiHAX3PgYXC6en9fLLyR6jjcseAa4CCshg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/native-preview-win32-x64": { + "version": "7.0.0-dev.20260624.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-x64/-/native-preview-win32-x64-7.0.0-dev.20260624.1.tgz", + "integrity": "sha512-WaZ+ue63NgB2j/lqjirfevh/TqcsCxSqnKhGGiRnlxHyYIBcoq+x7KngyEnyGIaywJE1PcFeXA+2EMSIPlSEiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", @@ -6207,14 +6349,14 @@ } }, "node_modules/@vue/compiler-core": { - "version": "3.5.38", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.38.tgz", - "integrity": "sha512-s99aGxWYig9ErHbct27KXEGhrBYlRI6c4MwAgXErOAbX9xiW37/uMa+XUDO69zLz83dng8UUZ70CTOJrLrYrEQ==", + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.39.tgz", + "integrity": "sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw==", "license": "MIT", "peer": true, "dependencies": { "@babel/parser": "^7.29.7", - "@vue/shared": "3.5.38", + "@vue/shared": "3.5.39", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" @@ -6241,28 +6383,28 @@ "peer": true }, "node_modules/@vue/compiler-dom": { - "version": "3.5.38", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.38.tgz", - "integrity": "sha512-JTqp25l8aFfJYF7/KmsXZjAxJz7T+SjmTJLoXVjHtc2BrSgSiW2n9Aem/cWq1OPe68A8JL06B3eVdhlP0H4TVw==", + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.39.tgz", + "integrity": "sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg==", "license": "MIT", "peer": true, "dependencies": { - "@vue/compiler-core": "3.5.38", - "@vue/shared": "3.5.38" + "@vue/compiler-core": "3.5.39", + "@vue/shared": "3.5.39" } }, "node_modules/@vue/compiler-sfc": { - "version": "3.5.38", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.38.tgz", - "integrity": "sha512-DuA2GiZawSEW442iw/9+Fkol8hTgb4Ke5KkhmSry65QA7YuyMbIdy8p0XZRMvNwJdgRz307W8g1CSzdvS4nuNg==", + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.39.tgz", + "integrity": "sha512-d0ki86iOyN8LoZPBmk5SJWNwHP19CnDDCfuo//+2WJa2g5Ke0Jay983PIBIcSSzldC68I8DrD5GrHV3OSDfodg==", "license": "MIT", "peer": true, "dependencies": { "@babel/parser": "^7.29.7", - "@vue/compiler-core": "3.5.38", - "@vue/compiler-dom": "3.5.38", - "@vue/compiler-ssr": "3.5.38", - "@vue/shared": "3.5.38", + "@vue/compiler-core": "3.5.39", + "@vue/compiler-dom": "3.5.39", + "@vue/compiler-ssr": "3.5.39", + "@vue/shared": "3.5.39", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.15", @@ -6277,68 +6419,68 @@ "peer": true }, "node_modules/@vue/compiler-ssr": { - "version": "3.5.38", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.38.tgz", - "integrity": "sha512-7s+W5Gc42FGxZMcuwl8H5B29T8BJPMdBT7KHFE+BbAuZ/iTEdTtv7z2XiMjiaUUw4w3ZcCEdHs36RuYJ2VA7bA==", + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.39.tgz", + "integrity": "sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw==", "license": "MIT", "peer": true, "dependencies": { - "@vue/compiler-dom": "3.5.38", - "@vue/shared": "3.5.38" + "@vue/compiler-dom": "3.5.39", + "@vue/shared": "3.5.39" } }, "node_modules/@vue/reactivity": { - "version": "3.5.38", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.38.tgz", - "integrity": "sha512-pG6LV/NDNRbKizcUjFFLAfjaL8mcv4DmR9avNcUw2gDHBzZneuS2TWCmp633ynzxz9YYKNeEPK2I8Wraqy2HUQ==", + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.39.tgz", + "integrity": "sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog==", "license": "MIT", "peer": true, "dependencies": { - "@vue/shared": "3.5.38" + "@vue/shared": "3.5.39" } }, "node_modules/@vue/runtime-core": { - "version": "3.5.38", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.38.tgz", - "integrity": "sha512-iyW8WVfF1CpCXxncZY5Ei6rSd6oZr5DgEom//fUjRBRl56AXPD+s9ATvukRt77ZFTuYlnVA1bxY+dJB94tWVYw==", + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.39.tgz", + "integrity": "sha512-9GLtNyRvPAUMbX+7ono0RC2j0guo2LXVi8LvcmAooImACUKm0oFf0jjwbX8/H0AE/t1nxhAkn8RSl9PMCzzxZw==", "license": "MIT", "peer": true, "dependencies": { - "@vue/reactivity": "3.5.38", - "@vue/shared": "3.5.38" + "@vue/reactivity": "3.5.39", + "@vue/shared": "3.5.39" } }, "node_modules/@vue/runtime-dom": { - "version": "3.5.38", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.38.tgz", - "integrity": "sha512-apX2wt9sdfDshS+a2xueFZLVpt0GkRJZSoPmrW/SA4yzXTznhfcMVW59gr7h4YQeY0vJhdJkk2rsIDwgfFgC5A==", + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.39.tgz", + "integrity": "sha512-7Y6aAGboKcXAZ3ECuUy7RrS5yy2r47dhTp2SKaJmYxjopImaVFaNa5Ne66NwGovsrxVAl5S5rwc7m22UG7Lmww==", "license": "MIT", "peer": true, "dependencies": { - "@vue/reactivity": "3.5.38", - "@vue/runtime-core": "3.5.38", - "@vue/shared": "3.5.38", + "@vue/reactivity": "3.5.39", + "@vue/runtime-core": "3.5.39", + "@vue/shared": "3.5.39", "csstype": "^3.2.3" } }, "node_modules/@vue/server-renderer": { - "version": "3.5.38", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.38.tgz", - "integrity": "sha512-vue8vbf2QlV4quHqzwmJy6dWfmRhP1J8l4wtZg60CL6VoKqcPY2oe7may3+1d9qfpedjK5PRLFqd5k3Isj9mUw==", + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.39.tgz", + "integrity": "sha512-yZSakiAGw85rZfG7UM8akMnIF+FmeiNk47uvHf2nVBBSe+dIKUhZuZq9+XgJhbV3nS5Z4ALH23/MpXofW+mbcw==", "license": "MIT", "peer": true, "dependencies": { - "@vue/compiler-ssr": "3.5.38", - "@vue/shared": "3.5.38" + "@vue/compiler-ssr": "3.5.39", + "@vue/shared": "3.5.39" }, "peerDependencies": { - "vue": "3.5.38" + "vue": "3.5.39" } }, "node_modules/@vue/shared": { - "version": "3.5.38", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.38.tgz", - "integrity": "sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug==", + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.39.tgz", + "integrity": "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==", "license": "MIT", "peer": true }, @@ -15611,9 +15753,9 @@ } }, "node_modules/svelte": { - "version": "5.56.3", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.3.tgz", - "integrity": "sha512-w7JvrM5IFl5cmfbY0TLik9o7mjRUJmRMhOR51tBPu708Gr/MjbGs7VnJnr/B0CaXeI4vtnOh7RKxDr0cwhMdDA==", + "version": "5.56.4", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.4.tgz", + "integrity": "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==", "license": "MIT", "peer": true, "dependencies": { @@ -15628,7 +15770,7 @@ "clsx": "^2.1.1", "devalue": "^5.8.1", "esm-env": "^1.2.1", - "esrap": "^2.2.11", + "esrap": "^2.2.12", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", @@ -16898,17 +17040,17 @@ } }, "node_modules/vue": { - "version": "3.5.38", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.38.tgz", - "integrity": "sha512-vAMKHfImQlYSy0C+PBue4s3ERZ2xGKfgZg5GXAsLInq1dyh2H78ILVP5sK0KPFPVW4kv+OGCIvBEondcjpZp7A==", + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.39.tgz", + "integrity": "sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA==", "license": "MIT", "peer": true, "dependencies": { - "@vue/compiler-dom": "3.5.38", - "@vue/compiler-sfc": "3.5.38", - "@vue/runtime-dom": "3.5.38", - "@vue/server-renderer": "3.5.38", - "@vue/shared": "3.5.38" + "@vue/compiler-dom": "3.5.39", + "@vue/compiler-sfc": "3.5.39", + "@vue/runtime-dom": "3.5.39", + "@vue/server-renderer": "3.5.39", + "@vue/shared": "3.5.39" }, "peerDependencies": { "typescript": "*" diff --git a/frontend/package.json b/frontend/package.json index 20edad9cfc..8d47a841e5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -129,6 +129,7 @@ "@types/react-dom": "^19.1.9", "@typescript-eslint/eslint-plugin": "^8.61.1", "@typescript-eslint/parser": "^8.61.1", + "@typescript/native-preview": "7.0.0-dev.20260624.1", "@vitejs/plugin-react-swc": "^4.1.0", "@vitest/coverage-v8": "^3.2.4", "dotenv": "^16.4.7", From 6ff910f26cf619ddf9c29f541fe2b12c042a3848 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Mon, 29 Jun 2026 09:32:30 +0100 Subject: [PATCH 05/12] Expand `any` type linting in frontend (#6808) # Description of Changes Continued effort to expand linting scope to ban the `any` type in our codebase. This PR pulls in a lot of subfolders into the linting scope, because the excluded list was getting short enough that it was feasible to move a layer down. I then fixed all the trivially fixable `any` type violations in the subfolders, which just required local changes to the one file. The aim of this PR is more to expand the scope to all the folders we can that already avoid `any` types, rather than actually fix violations. --- .../components/fileManager/FileListItem.tsx | 2 +- .../components/shared/signing/SignPopout.tsx | 8 +++-- .../addAttachments/AddAttachmentsSettings.tsx | 4 +-- .../AddPageNumbersAppearanceSettings.tsx | 12 +++++-- .../addPageNumbers/PageNumberPreview.tsx | 7 +++- .../AdjustContrastBasicSettings.tsx | 6 ++-- .../AdjustContrastColorSettings.tsx | 6 ++-- .../tools/compare/hooks/useComparePanZoom.ts | 8 ++--- .../extractImages/ExtractImagesSettings.tsx | 5 ++- .../viewer/hooks/useDocumentReady.ts | 2 +- .../hooks/tools/automate/useAutomationForm.ts | 2 +- .../hooks/tools/compare/operationUtils.ts | 2 +- .../hooks/tools/merge/useMergeOperation.ts | 4 +-- frontend/eslint.config.mjs | 36 ++++++++++++++----- 14 files changed, 69 insertions(+), 35 deletions(-) diff --git a/frontend/editor/src/core/components/fileManager/FileListItem.tsx b/frontend/editor/src/core/components/fileManager/FileListItem.tsx index 6cf1251d65..ae22ced51f 100644 --- a/frontend/editor/src/core/components/fileManager/FileListItem.tsx +++ b/frontend/editor/src/core/components/fileManager/FileListItem.tsx @@ -150,7 +150,7 @@ const FileListItem: React.FC = ({ shareLinks?: Array<{ token?: string }>; }>(`/api/v1/storage/files/${file.remoteStorageId}`, { suppressErrorToast: true, - } as any); + }); const links = response.data?.shareLinks ?? []; const token = links[links.length - 1]?.token; if (!token) { diff --git a/frontend/editor/src/core/components/shared/signing/SignPopout.tsx b/frontend/editor/src/core/components/shared/signing/SignPopout.tsx index aece0cb2da..a904c87246 100644 --- a/frontend/editor/src/core/components/shared/signing/SignPopout.tsx +++ b/frontend/editor/src/core/components/shared/signing/SignPopout.tsx @@ -536,8 +536,10 @@ const SignPopout = ({ pdfFile = new File([pdfResponse.data], session.documentName, { type: "application/pdf", }); - } catch (pdfError: any) { - if (pdfError?.response?.status === 404) { + } catch (pdfError: unknown) { + const status = (pdfError as { response?: { status?: number } }) + ?.response?.status; + if (status === 404) { // Finalized but signed PDF not available - backend issue alert({ alertType: "warning", @@ -760,7 +762,7 @@ const SignPopout = ({ // Update workbench data, preserving PDF and callbacks setCustomWorkbenchViewData( SESSION_DETAIL_WORKBENCH_ID, - (prevData: any) => ({ + (prevData: Record) => ({ ...prevData, session: response.data, }), diff --git a/frontend/editor/src/core/components/tools/addAttachments/AddAttachmentsSettings.tsx b/frontend/editor/src/core/components/tools/addAttachments/AddAttachmentsSettings.tsx index 9df438b429..4eaca5caa6 100644 --- a/frontend/editor/src/core/components/tools/addAttachments/AddAttachmentsSettings.tsx +++ b/frontend/editor/src/core/components/tools/addAttachments/AddAttachmentsSettings.tsx @@ -105,8 +105,8 @@ const AddAttachmentsSettings = ({ fontWeight: 400, lineHeight: 1.2, display: "-webkit-box", - WebkitLineClamp: 2 as any, - WebkitBoxOrient: "vertical" as any, + WebkitLineClamp: 2, + WebkitBoxOrient: "vertical", overflow: "hidden", whiteSpace: "normal", wordBreak: "break-word", diff --git a/frontend/editor/src/core/components/tools/addPageNumbers/AddPageNumbersAppearanceSettings.tsx b/frontend/editor/src/core/components/tools/addPageNumbers/AddPageNumbersAppearanceSettings.tsx index 79931d1295..627f7c1b9e 100644 --- a/frontend/editor/src/core/components/tools/addPageNumbers/AddPageNumbersAppearanceSettings.tsx +++ b/frontend/editor/src/core/components/tools/addPageNumbers/AddPageNumbersAppearanceSettings.tsx @@ -36,7 +36,10 @@ const AddPageNumbersAppearanceSettings = ({ label={t("addPageNumbers.selectText.2", "Margin")} value={parameters.customMargin} onChange={(v) => - onParameterChange("customMargin", (v as any) || "medium") + onParameterChange( + "customMargin", + (v as AddPageNumbersParameters["customMargin"]) || "medium", + ) } data={[ { value: "small", label: t("sizes.small", "Small") }, @@ -95,7 +98,12 @@ const AddPageNumbersAppearanceSettings = ({ onCustomInput(e.target.value)} - /> - - - - - {onSave && ( - - )} - - - {previewDocs != null && ( -
- -
-
- {t("payg.cap.docsEstimate", "β‰ˆ {{docs}} processed PDFs / month", { - docs: previewDocs.toLocaleString(), - })} -
-
- {t("payg.cap.docsRate", "at {{rate}} / PDF", { - rate: formatMinor(pricePerDocMinor ?? 0, currency), - })} -
-
-
- )} - - {isNoCap && ( -
- {t( - "payg.cap.noCapDesc", - "Usage is billed without an upper limit. You can re-enable a cap at any time.", - )} -
- )} - - {note &&
{note}
} - + + t("payg.cap.docsEstimate", "β‰ˆ {{docs}} processed PDFs / month", { + docs, + }), + docsRate: (rate) => + t("payg.cap.docsRate", "at {{rate}} / PDF", { rate }), + noCapDesc: t( + "payg.cap.noCapDesc", + "Usage is billed without an upper limit. You can re-enable a cap at any time.", + ), + }} + /> ); }; diff --git a/frontend/editor/src/cloud/components/shared/config/configSections/usageMeters.tsx b/frontend/editor/src/cloud/components/shared/config/configSections/usageMeters.tsx index f1012bd2dd..308fc97d32 100644 --- a/frontend/editor/src/cloud/components/shared/config/configSections/usageMeters.tsx +++ b/frontend/editor/src/cloud/components/shared/config/configSections/usageMeters.tsx @@ -7,36 +7,10 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { useWallet, type Wallet } from "@app/hooks/useWallet"; +import { currencySymbol, MeterBar, meterState } from "@shared/billing"; import "@app/components/shared/config/configSections/Payg.css"; import "@app/components/shared/config/configSections/PaygFree.css"; -export type MeterState = "FULL" | "WARNED" | "DEGRADED"; - -/** Warn/degrade band for a usage meter (mirrors the BE thresholds). */ -export function meterState( - used: number, - limit: number, -): { state: MeterState; pct: number } { - const pct = limit > 0 ? Math.min(100, (used / limit) * 100) : 100; - const state: MeterState = - pct >= 100 ? "DEGRADED" : pct >= 80 ? "WARNED" : "FULL"; - return { state, pct }; -} - -/** Currency symbol for compact inline use; falls back to the ISO code. */ -function currencySymbol(currency: string | null): string { - switch ((currency ?? "").toLowerCase()) { - case "usd": - return "$"; - case "eur": - return "€"; - case "gbp": - return "Β£"; - default: - return currency ? currency.toUpperCase() + " " : "$"; - } -} - // ─── One-time free grant meter ────────────────────────────────────────────── export interface FreeSnapshot { @@ -78,38 +52,20 @@ export function FreeMeterPanel({ snap }: { snap: FreeSnapshot }) { : t("payg.free.state.plentyLeft", "Plenty left"); return ( -
-
-
- - {snap.billableUsed.toLocaleString()} - - - {t("payg.free.hero.capSuffix", "/ {{limit}} free PDFs", { - limit: snap.billableLimit.toLocaleString(), - })} - -
- - - {stateLabel} - -
- -
-
-
- -
+ {t("payg.free.hero.metaCategories", "Automation Β· AI Β· API requests")} -
-
+ } + /> ); } @@ -158,45 +114,28 @@ export function SpendCapMeterPanel({ snap }: { snap: SpendCapSnapshot }) { const symbol = currencySymbol(snap.currency); return ( -
-
-
- - {symbol} - {snap.spent.toLocaleString()} + + + {t( + "payg.spendCapMeter.metaCategories", + "Automation Β· AI Β· API spend", + )} - - {t("payg.spendCapMeter.capSuffix", "/ {{amount}} cap", { - amount: `${symbol}${snap.cap.toLocaleString()}`, - })} + β€’ + + {t("payg.spendCapMeter.resets", "Resets each billing period")} -
- - - {stateLabel} - -
- -
-
-
- -
- - {t( - "payg.spendCapMeter.metaCategories", - "Automation Β· AI Β· API spend", - )} - - β€’ - - {t("payg.spendCapMeter.resets", "Resets each billing period")} - -
-
+ + } + /> ); } diff --git a/frontend/editor/src/cloud/hooks/useWallet.ts b/frontend/editor/src/cloud/hooks/useWallet.ts index 0222cc77c0..5a1b1d21cb 100644 --- a/frontend/editor/src/cloud/hooks/useWallet.ts +++ b/frontend/editor/src/cloud/hooks/useWallet.ts @@ -50,123 +50,26 @@ import apiClient from "@app/services/apiClient"; import { createPortalSession } from "@app/services/billing"; import { openExternal } from "@app/platform/openExternal"; import { getWalletDevPreview } from "@app/hooks/walletDevPreview"; +import type { + Wallet, + WalletStatus, + WalletRole, + WalletMember, + WalletCategoryBreakdown, + WalletActivityRow, +} from "@shared/billing"; // ─── Public types ─────────────────────────────────────────────────────── - -export type WalletStatus = "free" | "subscribed"; -export type WalletRole = "leader" | "member"; - -/** - * A single team member's billing-relevant info β€” name + email for the avatar - * row, {@code spendUnits} for their per-member usage display. Mirrors a row of - * the backend's {@code members} array on {@code WalletSnapshot} (joined with - * {@code team_memberships}). - */ -export interface WalletMember { - /** Supabase user id of the member. */ - userId: string; - name: string; - email: string; - /** Member's current-period billable spend. */ - spendUnits: number; -} - -/** - * Per-category breakdown of current-period spend in billable units. The - * categories mirror the {@code FeatureGate} buckets the backend tracks: - * server-side tool calls ({@code api}), AI-backed tools ({@code ai}), and - * pipeline / automation runs ({@code automation}). Numbers sum to {@code - * billableUsed} (modulo rounding in mock data). - */ -export interface WalletCategoryBreakdown { - api: number; - ai: number; - automation: number; -} - -/** Mirror of the backend's {@code WalletSnapshot} record (the JSON returned from {@code GET /api/v1/payg/wallet}). */ -export interface Wallet { - /** - * The caller's primary team_id. Needed when invoking Supabase edge functions - * (create-checkout-session, etc.) that run outside Spring Security and have - * no other way to resolve the caller's team. May be null on the synthetic - * empty snapshot returned to anonymous / team-less callers. - */ - teamId: number | null; - status: WalletStatus; - role: WalletRole; - /** - * ISO yyyy-mm-dd. The Stripe subscription's current period when subscribed; - * the calendar month for free teams. - */ - billingPeriodStart: string; - billingPeriodEnd: string; - /** - * For a free team: the one-time free documents used so far ({@code - * freeAllowance βˆ’ freeRemaining}). For a subscribed team: documents - * processed this month across automation + AI + API. - */ - billableUsed: number; - /** - * The team's document ceiling for the matching window: the one-time free - * grant ({@code freeAllowance}) for free teams; the monthly paid-doc cap - * {@code floor(cap / perDocRate)} for capped subscribed teams; null when - * subscribed with no cap (uncapped). - */ - billableLimit: number | null; - /** - * The team's one-time free document grant size β€” the "N" in "X of N free". - * A lifetime grant ({@code pricing_policy.free_tier_units}): it never resets - * and is not lost when the team subscribes. - */ - freeAllowance: number; - /** - * One-time free documents still available to the team - * ({@code payg_team_extensions.free_units_remaining}). 0 = grant exhausted. - * Survives subscribing β€” a subscribed team keeps any unused grant. - */ - freeRemaining: number; - /** - * Paid per-document rate in minor units of {@link Wallet#currency} (may be - * fractional); null when the rate can't be resolved β€” render "unknown", - * never substitute. - */ - pricePerDocMinor: number | null; - /** Lower-case ISO 4217 currency of the subscription's Stripe Price; null when unknown. */ - currency: string | null; - /** - * Estimated charges so far this period in minor units of currency: paid - * (Stripe-metered) documents this period Γ— rate. The free portion was - * already netted out at charge time. Informational β€” the Stripe invoice - * is authoritative. Null when the rate is unknown. - */ - estimatedBillMinor: number | null; - /** Monthly cap in major currency units when subscribed; null when noCap or status=='free'. */ - capUsd: number | null; - /** Only meaningful when status=='subscribed'. */ - noCap: boolean; - /** Stripe subscription id when subscribed; null when free. */ - stripeSubscriptionId: string | null; - /** Current-period spend in billable units. */ - spendUnitsThisPeriod: number; - /** Per-category spend breakdown (api / ai / automation). */ - categoryBreakdown: WalletCategoryBreakdown; - /** - * Team members, populated for the leader view; empty for members or - * single-seat tenants. Leader-vs-member is still resolved via {@link - * Wallet#role} β€” this field just carries the per-member rows the leader's - * sub-cap table needs. - */ - members: WalletMember[]; - /** - * Recent billable-activity rows. V1 returns {@code []} from the backend; - * the field exists so the Plan page can render an empty state without - * branching on undefined. Each entry is a {@code Record} - * because the activity-row shape is not yet finalised β€” when the meter- - * event surface lands, this widens to a real interface. - */ - recent: Array>; -} +// The wallet contract lives in @shared/billing (shared with the admin portal). +// Re-exported so existing `@app/hooks/useWallet` importers keep their imports. +export type { + Wallet, + WalletStatus, + WalletRole, + WalletMember, + WalletCategoryBreakdown, + WalletActivityRow, +}; export interface UseWalletResult { wallet: Wallet | null; diff --git a/frontend/editor/src/saas/routes/authShared/saas-auth.css b/frontend/editor/src/saas/routes/authShared/saas-auth.css index 505b29a14a..535d72d3dd 100644 --- a/frontend/editor/src/saas/routes/authShared/saas-auth.css +++ b/frontend/editor/src/saas/routes/authShared/saas-auth.css @@ -1,55 +1,3 @@ -/* SaaS-specific auth styles β€” imported alongside the base auth.css */ - -.oauth-container-fullwidth { - display: flex; - flex-direction: column; - gap: 0.75rem; /* 12px */ -} - -.oauth-button-fullwidth { - width: 100%; - display: flex; - align-items: center; - justify-content: center; - padding: 0.75rem 1rem; - border: 1px solid #d1d5db; - border-radius: 100px; - background-color: #ffffff; - font-size: 1rem; - font-weight: 600; - color: #000000; - cursor: pointer; - gap: 0.5rem; - box-shadow: 0 0.125rem 0.375rem rgba(0, 0, 0, 0.04); - transition: - background-color 150ms ease, - box-shadow 150ms ease, - border-color 150ms ease; -} - -.oauth-button-fullwidth:disabled { - cursor: not-allowed; - opacity: 0.6; -} - -.oauth-button-fullwidth:hover:not(:disabled) { - background-color: #fafafa; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); -} - -[data-mantine-color-scheme="dark"] .oauth-button-fullwidth { - background-color: var(--bg-surface); - color: var(--text-primary); - border-color: var(--border-default); - box-shadow: none; -} - -[data-mantine-color-scheme="dark"] - .oauth-button-fullwidth:hover:not(:disabled) { - background-color: var(--bg-raised); - box-shadow: none; -} - .auth-dropdown-wrapper { position: relative; } diff --git a/frontend/portal/.env b/frontend/portal/.env index 52374a4aa2..d4cae761ad 100644 --- a/frontend/portal/.env +++ b/frontend/portal/.env @@ -10,3 +10,22 @@ VITE_EDITOR_URL=/ # in production builds). The single-origin proxy task sets this to "false" so the # portal uses the real backend. VITE_PORTAL_MOCKS= + +# Hosted SaaS Supabase project for IN-APP account linking (both values are +# public). Set per deploy; absent β†’ the account-link UI shows a "configure" +# state. For local e2e, point these at the SaaS Supabase project the local +# backend links against (e.g. the V3 branch project). +VITE_SAAS_SUPABASE_URL= +VITE_SAAS_SUPABASE_ANON_KEY= + +# Hosted SaaS Java backend base URL (e.g. https://api.stirlingpdf.com). Used +# for ATTENDED portal -> SaaS reads (wallet, billing, plans, checkout) with the +# admin's Supabase JWT. Distinct from the local backend (which the portal +# reaches same-origin via the vite proxy). Absent β†’ wallet/billing surfaces +# stay on the MSW mock. +VITE_SAAS_API_URL= + +# Stripe publishable key (pk_live_… / pk_test_…) used by the embedded Checkout +# in the billing surface. Public by design. Empty β†’ the checkout modal shows a +# "configure" state instead of mounting Stripe. +VITE_STRIPE_PUBLISHABLE_KEY= diff --git a/frontend/portal/public/locales/en-US/translation.toml b/frontend/portal/public/locales/en-US/translation.toml index 1aa24bd89e..c0b3ccae91 100644 --- a/frontend/portal/public/locales/en-US/translation.toml +++ b/frontend/portal/public/locales/en-US/translation.toml @@ -49,8 +49,9 @@ appProcessor = "Processor" appEditor = "Editor" docsProcessed = "Docs processed" docsCount = "{{docs}} docs" -planPayAsYouGo = "Pay-as-you-go" -planEnterprise = "Enterprise Plan" +planProcessor = "Processor plan" +linkAccount = "Link Stirling account" +planEnterprise = "Enterprise plan" [search] ariaLabel = "Search" @@ -94,6 +95,7 @@ general = "General" authentication = "Authentication" sessions = "Active sessions" early-access = "Early access" +account-link = "Account link" [settings.profile] accountFallback = "Account" @@ -420,180 +422,6 @@ snapshot = "Snapshot: re-read the folder every run" [sources.types.unknown] label = "Source" -[usage] -title = "Usage & Billing" -subtitle = "Your last 30 days of processing, plan, and charges." - -[usage.chart.empty] -title = "No usage yet" -description = "Once documents are processed, your 30-day usage appears here." - -[usage.kpi.docsThisPeriod] -label = "Docs this period" -description = "of {{included}} included" - -[usage.kpi.costThisMonth] -label = "Cost this month" -description = "incl. {{fee}} platform" -freePlan = "free plan" - -[usage.kpi.nextBillingDate] -label = "Next billing date" -resetsMonthly = "resets monthly" -autoCharge = "auto-charge" - -[usage.kpi.remainingInPlan] -label = "Remaining in plan" -description = "docs before cap" - -[usage.kpi.commitUtilisation] -label = "Commit utilisation" -description = "of committed volume" - -[usage.kpi.overage] -label = "Overage (${{rate}}/doc)" -description_one = "{{docs}} doc past cap" -description_other = "{{docs}} docs past cap" - -[usage.currentPlan] -eyebrow = "Current plan" - -[usage.currentPlan.badge] -free = "Free" -pro = "Pay-as-you-go" -enterprise = "Committed" - -[usage.currentPlan.free] -progressLabel = "Free plan usage" - -[usage.currentPlan.free.capReached] -title = "You've hit your free plan cap" -body = "New documents are paused until next cycle. Upgrade to keep processing without interruption." - -[usage.currentPlan.free.approaching] -title = "Approaching your free plan cap" -body = "You're at {{pct}}% of 500 docs/month. Upgrade to pay-as-you-go to avoid a pause." - -[usage.currentPlan.pro] -platformFee = "Platform fee" -includedDocs = "Included docs" -overage = "Overage Β· {{docs}} docs @ ${{rate}}" -projected = "Projected this month" - -[usage.currentPlan.enterprise] -committedVolume = "Committed volume" -committedVolumeValue = "{{docs}} docs/mo" -drawnThisPeriod = "Drawn this period" -drawnThisPeriodValue = "{{docs}} docs" -effectiveRate = "Effective rate" -effectiveRateValue = "${{rate}} / doc" -monthlyDraw = "Monthly draw" - -[usage.currentPlan.actions] -upgrade = "Upgrade plan" -talkToSales = "Talk to sales" -adjustCommitment = "Adjust commitment" -downloadInvoices = "Download invoices" - -[usage.spendCap.free] -title = "Spend cap" -description = "The free plan can't accrue spend β€” your usage is hard-capped at 500 docs/month. Upgrade to pay-as-you-go to set a monthly spend cap." - -[usage.spendCap.enterprise] -title = "Spend controls" -description = "Spend is governed by your committed-volume contract. Overage terms and alert thresholds are managed with your account team." -badge = "Committed contract" -overage = "Overage billed at ${{rate}}/doc" - -[usage.spendCap.pro] -title = "Monthly spend cap" -subtitle = "Pause processing automatically when spend reaches your limit." -disable = "Disable cap" -enable = "Enable cap" -projected = "Projected {{projected}} of {{cap}} cap" -progressLabel = "Spend against cap" - -[usage.plans] -title = "Plans" -subtitle = "Move up or down at any time β€” changes take effect next cycle." - -[usage.planCard] -current = "Current" -yourPlan = "Your plan" -contactSales = "Contact sales" -choosePlan = "Choose plan" - -[usage.history] -title = "Billing history" -subtitle = "Line items from the current and prior billing cycles." -emptyRows = "No line items" - -[usage.history.columns] -date = "Date" -description = "Description" -docs = "Docs" -amount = "Amount" -status = "Status" - -[usage.history.status] -paid = "Paid" -due = "Due" -pending = "Pending" -refunded = "Refunded" - -[usage.history.empty] -title = "No billing history" -description = "Charges and credits appear here once your first cycle closes." - -[usage.upgrade] -notNow = "Not now" - -[usage.upgrade.free] -title = "Upgrade to keep processing" -subtitle = "Pay-as-you-go Β· $0.05 / doc" -body = "You're at the edge of the 500 doc/month free cap. Pay-as-you-go lifts the cap instantly β€” you only pay for what you process beyond the included 25,000 docs." -bullets = [ - "Lift the 500 doc/month cap immediately", - "25,000 docs included, then $0.05/doc", - "Unlimited pipelines, agents, and sources", - "Set a monthly spend cap to stay in control", -] -cta = "Switch to pay-as-you-go" - -[usage.upgrade.proToEnterprise] -title = "Move to a committed plan" -subtitle = "Enterprise Β· committed annual volume" -body = "Your overage is consistent month over month. A committed-volume contract lowers your effective per-doc rate and unlocks dedicated regions, SSO, and a named CSM." -bullets = [ - "Lower effective rate vs metered overage", - "Dedicated & on-prem region options", - "SSO, audit-log export, signed DPA", - "Named CSM and 99.99% SLA", -] -cta = "Talk to sales" - -[usage.upgrade.pro] -title = "You're already on pay-as-you-go" -subtitle = "Considering a committed plan?" -body = "Pay-as-you-go scales with usage. If your volume is steady, a committed-volume contract typically lowers your effective per-doc rate." -bullets = [ - "Predictable monthly spend", - "Lower effective per-doc rate at volume", - "Volume discounts kick in past 1M docs/mo", -] -cta = "Explore committed pricing" - -[usage.upgrade.enterprise] -title = "Adjust your commitment" -subtitle = "Enterprise Β· bespoke terms" -body = "Your plan is governed by a committed-volume contract. Changes to committed volume, regions, or terms are handled with your account team β€” they'll model the right shape with you." -bullets = [ - "Re-model committed volume up or down", - "Add dedicated or on-prem regions", - "Adjust SLA, DPA, and overage terms", -] -cta = "Contact your CSM" - [documents] title = "Documents" subtitle = "Review and approve documents moving through your pipelines." @@ -1774,3 +1602,252 @@ redirectingToEditor = "Redirecting to the editor..." title = "Something went wrong on this page" description = "This view hit an unexpected error. Try again, or pick another section from the sidebar." retry = "Try again" + +# ── Account link (combined-billing Mode A) ─────────────────────────────────── +[accountLink.state] +unlinked = "Not linked" +free = "Editor plan" +subscribed = "Processor plan" + +[accountLink.panel] +sub = "Link this self-hosted org to its Stirling account so unattended processing bills against your org wallet." +instancesTitle = "Linked instances" +instancesSub = "Every self-hosted instance registered to this org. Revoke a credential to immediately cut off its unattended access." +revokeError = "Couldn't revoke instance" + +[accountLink.panel.loadError] +title = "Couldn't load linked instances" +forbidden = "Only the team owner can view the org's linked instances." +generic = "Couldn't load the team's linked instances. Try again in a moment." + +[accountLink.card] +eyebrow = "Account link" +title = "Link this org to its Stirling account" +linked = "Linked" +notLinked = "Not linked" +unlink = "Unlink" +linkButton = "Link your Stirling account" +linkedAs = "Linked as {{name}}." +linkedGeneric = "This instance is linked." +billingNote = "Unattended processing bills against your org wallet." + +[accountLink.card.error] +title = "Couldn't link" + +[accountLink.card.loginNotConfigured] +title = "SaaS login not configured" +before = "Set" +after = "to enable account linking against the hosted Stirling account. In dev you can simulate sign-in from the link dialog." + +[accountLink.modal] +linkTitle = "Link your Stirling account" +reauthTitle = "Sign in again" +linkSubtitle = "Sign in to the account this server should bill against." +reauthSubtitle = "Your session expired β€” sign back in to your Stirling account. Your instance stays linked." +simulateSignIn = "Simulate sign-in (dev)" + +[accountLink.modal.loginNotConfigured] +title = "SaaS login not configured" +before = "Set" +and = "and" +after = "to enable in-app linking against the hosted Stirling account." + +[accountLink.gate] +title = "Link to unlock" +titleFeature = "Link to unlock {{feature}}" +description = "Link this org's Stirling account to use billable features." +action = "Link account" + +[accountLink.instances] +unnamed = "Unnamed instance" +revoked = "Revoked" +active = "Active" +revoke = "Revoke" + +[accountLink.instances.columns] +instance = "Instance" +status = "Status" +lastSeen = "Last seen" +linked = "Linked" + +[accountLink.instances.empty] +title = "No linked instances" +description = "Link this org's account, then register your self-hosted instances to see them here." + +[accountLink.instances.time] +never = "never" +justNow = "just now" +minutesAgo_one = "{{count}}m ago" +minutesAgo_other = "{{count}}m ago" +hoursAgo_one = "{{count}}h ago" +hoursAgo_other = "{{count}}h ago" +daysAgo_one = "{{count}}d ago" +daysAgo_other = "{{count}}d ago" + +# ── Billing surface (Usage & billing) ──────────────────────────────────────── +[billing.enterpriseUpsell] +eyebrow = "Volume discount Β· 1M+ PDFs" +title = "Stirling Enterprise" +description = "Committed volume discounts, air-gapped deployment, custom MSA and security reviews, and 3rd-party distributor partnerships." +cta = "Build your Enterprise quote" + +[billing.freeEditors] +title = "Free PDF Editors" +previewBadge = "Preview Β· sample data" +subtitle = "Deploy anywhere, for your whole team." +editorsDeployed = "Editors deployed" +activeThisMonth = "Active this month" +pdfsEdited = "PDFs edited" +cost = "Cost" +inviteTeammates = "Invite teammates" + +[billing.freePlan] +currentPlan = "Current plan" +planName = "Editor" +freeForever = "Free forever" +ssoIncluded = "SSO included" +unlimitedUsers = "Unlimited users" +switchOnProcessor = "Switch on the Processor β†’" +noTeamResolved = "No team is resolved on your wallet yet β€” refresh and try again." +checkoutErrorTitle = "Couldn't start checkout" +ownerOnly = "Only the team owner can switch on the Processor plan." + +[billing.linkPrompt] +title = "Link your Stirling account" +description = "Manual PDF editing β€” view, sign, merge, split, watermark, compress, convert, manual OCR β€” is always free, linked or not. Link to claim 500 free PDFs of metered processing (automation, AI, and the API); when you need more, turn on the Processor plan and only pay for what you use." +cta = "Link Stirling account" + +[billing.walletMeter] +eyebrow = "Processor trial" +sub = "Use the PDF Editor for free. Pay to process PDFs automatically." +title_one = "Process {{allowance}} PDFs free" +title_other = "Process {{allowance}} PDFs free" +titleWithRate_one = "Process {{allowance}} PDFs free, then {{rate}}/PDF" +titleWithRate_other = "Process {{allowance}} PDFs free, then {{rate}}/PDF" +capSuffix_one = "of {{allowance}} free PDFs used" +capSuffix_other = "of {{allowance}} free PDFs used" +statusLabel_one = "{{remaining}} left" +statusLabel_other = "{{remaining}} left" + +[billing.pdfsProcessed] +eyebrow = "PDFs processed this period" +unit = "metered PDFs" +segbarAriaLabel = "Metered PDFs split by category" +segmentApiLabel = "API" +segmentApiDesc = "Direct API requests" +segmentAgentsLabel = "Agents" +segmentAgentsDesc = "AI agent actions" +segmentAutomationLabel = "Automation" +segmentAutomationDesc = "Automations & pipelines" +legendValue_one = "{{formatted}} PDFs" +legendValue_other = "{{formatted}} PDFs" +emptyPeriod = "No metered processing yet this period." + +[billing.spendThisMonth] +eyebrow = "Spend this month" +processed_one = "{{formattedCount}} PDF processed." +processed_other = "{{formattedCount}} PDFs processed." +processedWithRate_one = "{{formattedCount}} PDF processed, at {{rate}} each." +processedWithRate_other = "{{formattedCount}} PDFs processed, at {{rate}} each." + +[billing.spendLimit] +eyebrow = "Spend limit" +editTitle = "Set your monthly ceiling" +capControlNote = "Changes apply immediately β€” raise or lower the ceiling any time." +useSuggested = "Use suggested Β· {{amount}} / month" +guardrailLabel = "Your guardrail:" +guardrailBody = "a hard ceiling β€” you're never billed past it. At the cap, metered processing pauses (unlimited PDF editing keeps working) until you raise it or the cycle resets. Nothing is lost." +saveError = "Couldn't save limit" +cancel = "Cancel" +save = "Save limit" +displaySub = "You're only billed for what you process automatically β€” never past the ceiling." +adjustLimit = "Adjust limit" +capSuffix = "/ month" +capSuffixWithDocs = "/ month Β· β‰ˆ {{documents}} documents" +noCap = "no cap" +pctUsed = "{{pct}}% used" +usedThisMonth = "{{amount}} used this month" +remaining = "{{amount}} remaining" +thisPeriodUncapped = "{{amount}} this period Β· uncapped" + +[billing.spendLimit.projection] +label = "Projected to exceed." +body_one = "At {{rate}}/day you reach the cap in ~{{count}} day (~{{monthEnd}} month-end). Suggested limit ~{{suggested}}." +body_other = "At {{rate}}/day you reach the cap in ~{{count}} days (~{{monthEnd}} month-end). Suggested limit ~{{suggested}}." + +[billing.invoices] +title = "Invoice history" +columnDate = "Date" +columnPdfsProcessed = "PDFs processed" +columnAmount = "Amount" +columnStatus = "Status" +columnDescription = "Description" +descriptionFallback = "Invoice" +viewLink = "View β†—" +viewAriaLabel = "View invoice {{number}} in Stripe" +pdfLink = "PDF ↓" +downloadAriaLabel = "Download invoice {{number}} as PDF" +loadError = "Couldn't load invoices: {{error}}" +emptyTitle = "No invoices yet" +emptyDescription = "Once your team subscribes and the first cycle closes, your invoices appear here." +showFewer_one = "Show fewer (top {{count}})" +showFewer_other = "Show fewer (top {{count}})" +showMostRecent_one = "Show {{count}} most recent" +showMostRecent_other = "Show {{count}} most recent" +showAll_one = "Show all {{count}}" +showAll_other = "Show all {{count}}" +fetchLimitNote_one = "Showing your {{count}} most recent invoices. Older invoices are in the Stripe portal." +fetchLimitNote_other = "Showing your {{count}} most recent invoices. Older invoices are in the Stripe portal." + +[billing.paymentMethod] +eyebrow = "Payment method" +cardEnding = "{{brand}} ending {{last4}}" +cardFallback = "Card" +expiresBilledMonthly = "Expires {{expiry}} Β· billed monthly" +billedMonthly = "Billed monthly" +managedTitle = "Managed in Stripe" +managedSub = "Your card and billing details are kept securely in Stripe's customer portal." +update = "Update" + +[billing.checkout] +title = "Turn on the Processor plan" +subtitle = "Add a card to keep going past your free Editor-plan grant. Stripe handles the rest." +noClientSecret = "Edge function returned no client_secret." + +[billing.checkout.notConfigured] +title = "Stripe not configured" +bodyBefore = "Set" +bodyAfter = "in the portal env to enable in-app checkout." + +[billing.checkout.error] +title = "Couldn't start checkout" + +[billing.subscribedPlan.capWarn] +reachedTitle = "Monthly spend limit reached" +approachingTitle = "You're at {{pct}}% of your monthly spend limit" +raiseLimit = "Raise limit" +reachedBody = "Metered processing is paused until you raise the limit or the cycle resets. Unlimited PDF editing keeps working." +approachingBody = "Raise it now so automated processing never pauses." + +[billing.subscribedPlan.portalError] +title = "Couldn't open Stripe portal" + +# ── Usage & billing view ───────────────────────────────────────────────────── +[usage] +title = "Usage & billing" +subtitle = "Consumption, invoices, and plan management for every PDF Stirling has billed, in one console." +managePayment = "Manage Payment" + +[usage.finalizing] +title = "Finalizing your subscription…" +body = "It can take a few seconds for your subscription to activate. This page updates automatically." + +[usage.sessionExpired] +title = "Session expired" +action = "Sign in again" +body = "Your Stirling account session has expired. Sign in again to view billing β€” your instance stays linked." + +[usage.error] +loadWallet = "Couldn't load wallet" +openStripePortal = "Couldn't open Stripe portal" +walletUnavailable = "Wallet unavailable: {{status}} {{statusText}}" diff --git a/frontend/portal/src/App.tsx b/frontend/portal/src/App.tsx index a94a5c304b..cd1195cde3 100644 --- a/frontend/portal/src/App.tsx +++ b/frontend/portal/src/App.tsx @@ -5,6 +5,8 @@ import { AuthProvider } from "@shared/auth"; import { ErrorBoundary } from "@portal/components/ErrorBoundary"; import { ThemeProvider, useTheme } from "@portal/contexts/ThemeContext"; import { TierProvider } from "@portal/contexts/TierContext"; +import { LinkProvider, useLink } from "@portal/contexts/LinkContext"; +import type { SupabaseLoginSession } from "@shared/auth/ui/useSupabaseLogin"; import { UIProvider, useUI } from "@portal/contexts/UIContext"; import { mantineTheme } from "@portal/theme/mantineTheme"; import { AppShell } from "@portal/components/AppShell"; @@ -13,6 +15,11 @@ import { AssistantButton } from "@portal/components/AssistantButton"; import { AssistantPanel } from "@portal/components/AssistantPanel"; import { SearchModal } from "@portal/components/SearchModal"; import { SettingsModal } from "@portal/components/SettingsModal"; +import { LinkAccountModal } from "@portal/components/account-link/LinkAccountModal"; +import { + AccountLinkProvider, + useAccountLinkContext, +} from "@portal/contexts/AccountLinkContext"; import { ViewRouter } from "@portal/ViewRouter"; /** @@ -58,8 +65,42 @@ function GlobalShortcuts() { /** Bridges the Settings modal's open/close props to UIContext state. */ function SettingsHost() { - const { settingsOpen, closeSettings } = useUI(); - return ; + const { settingsOpen, settingsInitialSection, closeSettings } = useUI(); + return ( + + ); +} + +/** + * The one and only account-link login modal. Mounted at the app root (never + * nested in another overlay) and driven by UIContext, so any "Link account" CTA + * β€” sidebar, billing prompt, feature gate, Settings panel β€” opens this exact + * instance. Linking is finished by the shared {@link useAccountLinkContext} + * orchestration. + */ +function LinkModalHost() { + const { linkModalOpen, linkModalMode, closeLinkModal } = useUI(); + const { markSaasSessionChanged } = useLink(); + const link = useAccountLinkContext(); + // "reauth" only refreshes the browser SaaS session for attended reads β€” the + // sign-in already applied it to the Supabase client, so we just signal a + // refetch. It must NOT call completeLink (that re-registers β†’ duplicate row). + const onLinked = + linkModalMode === "reauth" + ? () => markSaasSessionChanged() + : (session: SupabaseLoginSession) => link.completeLink(session); + return ( + + ); } /** @@ -87,22 +128,29 @@ export function App() { - - - - - - - - - - - - - - - - + + {/* TierProvider sits INSIDE LinkProvider so it can derive the tier + from the real link/subscription state when MSW mocks are off. */} + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/portal/src/ViewRouter.tsx b/frontend/portal/src/ViewRouter.tsx index 2efaabcc68..f56aa08bff 100644 --- a/frontend/portal/src/ViewRouter.tsx +++ b/frontend/portal/src/ViewRouter.tsx @@ -28,6 +28,8 @@ export function ViewRouter() { } /> } /> } /> + {/* Account-link is now a Settings panel; redirect legacy bookmarks home. */} + } /> {/* Settings is a modal overlay, not a route (see AppShell + UIContext). */} {/* Unknown paths land on Home. */} } /> diff --git a/frontend/portal/src/api/agents.ts b/frontend/portal/src/api/agents.ts index 842d5d398b..9956a3f66a 100644 --- a/frontend/portal/src/api/agents.ts +++ b/frontend/portal/src/api/agents.ts @@ -1,4 +1,4 @@ -import { httpJson } from "@portal/api/http"; +import { apiClient } from "@portal/api/http"; import type { AgentsResponse } from "@portal/mocks/agents"; import type { Tier } from "@portal/contexts/TierContext"; @@ -16,7 +16,7 @@ export { AGENT_STATUS_TONE, TOOL_CATALOGUE } from "@portal/mocks/agents"; /** GET /v1/agents?tier=… β€” fleet summary + every agent with its full builder state. */ export async function fetchAgents(tier: Tier): Promise { - return httpJson( + return apiClient.local.json( `/v1/agents?tier=${encodeURIComponent(tier)}`, ); } diff --git a/frontend/portal/src/api/assistant.ts b/frontend/portal/src/api/assistant.ts index a6f10597f5..05e3bc2198 100644 --- a/frontend/portal/src/api/assistant.ts +++ b/frontend/portal/src/api/assistant.ts @@ -1,15 +1,18 @@ -import { httpJson } from "@portal/api/http"; +import { apiClient } from "@portal/api/http"; /** GET /v1/assistant/suggestions */ export async function fetchAssistantSuggestions(): Promise { - return httpJson("/v1/assistant/suggestions"); + return apiClient.local.json("/v1/assistant/suggestions"); } /** POST /v1/assistant/messages */ export async function getAssistantReply(input: string): Promise { - const res = await httpJson<{ reply: string }>("/v1/assistant/messages", { - method: "POST", - body: { input }, - }); + const res = await apiClient.local.json<{ reply: string }>( + "/v1/assistant/messages", + { + method: "POST", + body: { input }, + }, + ); return res.reply; } diff --git a/frontend/portal/src/api/billing.ts b/frontend/portal/src/api/billing.ts new file mode 100644 index 0000000000..7c87bb5ca5 --- /dev/null +++ b/frontend/portal/src/api/billing.ts @@ -0,0 +1,79 @@ +import { apiClient } from "@portal/api/http"; +import type { Wallet } from "@shared/billing"; + +/** + * Real wallet + billing surface. All calls go to apiClient.saas β€” the hosted + * SaaS Java backend, authed by the admin's Supabase JWT. The wallet contract + * itself lives in {@code @shared/billing} (shared with the editor cloud surface). + */ + +// Re-export the shared contract so existing `@portal/api/billing` importers keep working. +export type { + Wallet, + WalletStatus, + WalletRole, + WalletMember, + WalletCategoryBreakdown, + WalletActivityRow, +} from "@shared/billing"; + +export async function fetchWallet(): Promise { + return apiClient.saas.json("/api/v1/payg/wallet"); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Cap β€” leader-only PATCH (real endpoint). +// ──────────────────────────────────────────────────────────────────────────── + +export async function updateCap(capUsd: number | null): Promise { + await apiClient.saas.json("/api/v1/payg/cap", { + method: "PATCH", + body: { capUsd: capUsd ?? 0, noCap: capUsd === null }, + }); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Invoices β€” backed by GET /api/v1/payg/invoices (reads stripe.invoices via +// the Sync Engine). Returns [] for free teams + missing schema. +// ──────────────────────────────────────────────────────────────────────────── + +export interface Invoice { + id: string; + number: string | null; + status: string; + totalMinor: number | null; + currency: string | null; + createdAt: string | null; + periodStart: string | null; + periodEnd: string | null; + hostedInvoiceUrl: string | null; + invoicePdf: string | null; + /** Product name from the subscription chain (e.g. "Stirling Processor Plan"). */ + description: string | null; + /** Billed units (PDFs) on this invoice; null when the line-item table isn't synced. */ + pdfsProcessed: number | null; +} + +export async function fetchInvoices(limit: number = 20): Promise { + return apiClient.saas.json( + `/api/v1/payg/invoices?limit=${encodeURIComponent(String(limit))}`, + ); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Payment method β€” GET /api/v1/payg/payment-method. Reads the default card off +// the Stripe mirror; `present: false` when the mirror doesn't carry one (table +// not synced / no card). Card edits happen in Stripe's portal, not here. +// ──────────────────────────────────────────────────────────────────────────── + +export interface PaymentMethod { + present: boolean; + brand: string | null; + last4: string | null; + expMonth: number | null; + expYear: number | null; +} + +export async function fetchPaymentMethod(): Promise { + return apiClient.saas.json("/api/v1/payg/payment-method"); +} diff --git a/frontend/portal/src/api/docs.ts b/frontend/portal/src/api/docs.ts index cc4a625016..0de1911d4f 100644 --- a/frontend/portal/src/api/docs.ts +++ b/frontend/portal/src/api/docs.ts @@ -1,4 +1,4 @@ -import { httpJson } from "@portal/api/http"; +import { apiClient } from "@portal/api/http"; import type { Tier } from "@portal/contexts/TierContext"; import type { DocsContent, DocsNavSection } from "@portal/mocks/docs"; @@ -18,10 +18,10 @@ export type { /** GET /v1/docs/nav β€” the docs nav tree. */ export async function fetchDocsNav(): Promise { - return httpJson("/v1/docs/nav"); + return apiClient.local.json("/v1/docs/nav"); } /** GET /v1/docs/content β€” the tier-scaled reference content. */ export async function fetchDocsContent(tier: Tier): Promise { - return httpJson(`/v1/docs/content?tier=${tier}`); + return apiClient.local.json(`/v1/docs/content?tier=${tier}`); } diff --git a/frontend/portal/src/api/documents.ts b/frontend/portal/src/api/documents.ts index c64fd1227b..3e8e4e1170 100644 --- a/frontend/portal/src/api/documents.ts +++ b/frontend/portal/src/api/documents.ts @@ -1,4 +1,4 @@ -import { httpJson } from "@portal/api/http"; +import { apiClient } from "@portal/api/http"; import type { DocumentsResponse } from "@portal/mocks/documents"; import type { Tier } from "@portal/contexts/TierContext"; @@ -20,7 +20,7 @@ export { /** GET /v1/documents?tier=… β€” summary strip + the review queue for the tier. */ export async function fetchDocuments(tier: Tier): Promise { - return httpJson( + return apiClient.local.json( `/v1/documents?tier=${encodeURIComponent(tier)}`, ); } diff --git a/frontend/portal/src/api/editorDeploy.ts b/frontend/portal/src/api/editorDeploy.ts index f565cb2955..38517c0bc9 100644 --- a/frontend/portal/src/api/editorDeploy.ts +++ b/frontend/portal/src/api/editorDeploy.ts @@ -1,4 +1,4 @@ -import { httpJson } from "@portal/api/http"; +import { apiClient } from "@portal/api/http"; import type { EditorDeploymentResponse } from "@portal/mocks/editorDeploy"; import type { Tier } from "@portal/contexts/TierContext"; @@ -29,7 +29,7 @@ export { export async function fetchEditorDeployment( tier: Tier, ): Promise { - return httpJson( + return apiClient.local.json( `/v1/editor/deployment?tier=${encodeURIComponent(tier)}`, ); } diff --git a/frontend/portal/src/api/home.ts b/frontend/portal/src/api/home.ts index 122e1ca644..e7ddfea910 100644 --- a/frontend/portal/src/api/home.ts +++ b/frontend/portal/src/api/home.ts @@ -1,4 +1,4 @@ -import { httpJson } from "@portal/api/http"; +import { apiClient } from "@portal/api/http"; import type { ActivityEvent, KpiEntry, @@ -23,25 +23,29 @@ export { PIPELINE_STAGES, PIPELINE_TEMPLATES } from "@portal/mocks/home"; /** GET /v1/analytics/usage?window=30d */ export async function fetchUsageSeries(): Promise { - return httpJson("/v1/analytics/usage?window=30d"); + return apiClient.local.json( + "/v1/analytics/usage?window=30d", + ); } /** GET /v1/activity?limit=8 */ export async function fetchRecentActivity(): Promise { - return httpJson("/v1/activity?limit=8"); + return apiClient.local.json("/v1/activity?limit=8"); } /** GET /v1/home/kpis?tier=… */ export async function fetchHomeKpis(tier: Tier): Promise { - return httpJson(`/v1/home/kpis?tier=${encodeURIComponent(tier)}`); + return apiClient.local.json( + `/v1/home/kpis?tier=${encodeURIComponent(tier)}`, + ); } /** GET /v1/regions/health (Enterprise) */ export async function fetchRegionHealth(): Promise { - return httpJson("/v1/regions/health"); + return apiClient.local.json("/v1/regions/health"); } /** GET /v1/onboarding (Free) */ export async function fetchOnboarding(): Promise { - return httpJson("/v1/onboarding"); + return apiClient.local.json("/v1/onboarding"); } diff --git a/frontend/portal/src/api/http.test.ts b/frontend/portal/src/api/http.test.ts new file mode 100644 index 0000000000..085948c77f --- /dev/null +++ b/frontend/portal/src/api/http.test.ts @@ -0,0 +1,131 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * api/http apiClient routing + error branches β€” the module exists specifically + * to make portalβ†’backend routing explicit after /v1/billing/wallet once fell + * through to the local backend. The happy-path routing (saas hits the absolute + * base with the Supabase bearer) is covered in api/link.test.ts; here we pin the + * error/edge branches that gate the billing UI's error surface. + */ +const { getSession, getStoredTokenMock } = vi.hoisted(() => ({ + getSession: vi.fn(), + getStoredTokenMock: vi.fn(), +})); + +vi.mock("@shared/auth", () => ({ getStoredToken: getStoredTokenMock })); +vi.mock("@shared/auth/supabase/supabaseClient", () => ({ + getSupabaseClient: () => ({ auth: { getSession } }), + configureSupabase: vi.fn(), +})); +vi.mock("@portal/auth/saasSupabase", () => ({ ensureSaasSupabase: vi.fn() })); + +import { + apiClient, + HttpError, + SaasNotLinkedError, + SaasUnconfiguredError, +} from "@portal/api/http"; + +const fetchMock = vi.fn(); + +beforeEach(() => { + vi.stubGlobal("fetch", fetchMock); + fetchMock.mockReset(); + getSession.mockReset(); + getStoredTokenMock.mockReset(); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); +}); + +function ok(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +describe("apiClient.saas", () => { + it("throws SaasUnconfiguredError when VITE_SAAS_API_URL is unset", async () => { + vi.stubEnv("VITE_SAAS_API_URL", ""); + await expect( + apiClient.saas.json("/api/v1/payg/wallet"), + ).rejects.toBeInstanceOf(SaasUnconfiguredError); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("throws SaasNotLinkedError when there is no SaaS session", async () => { + vi.stubEnv("VITE_SAAS_API_URL", "https://saas.test.local"); + getSession.mockResolvedValue({ data: { session: null } }); + await expect( + apiClient.saas.json("/api/v1/payg/wallet"), + ).rejects.toBeInstanceOf(SaasNotLinkedError); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("attaches the Supabase bearer and hits the absolute SaaS base", async () => { + vi.stubEnv("VITE_SAAS_API_URL", "https://saas.test.local"); + getSession.mockResolvedValue({ + data: { session: { access_token: "supabase_tok" } }, + }); + fetchMock.mockResolvedValue(ok({ status: "free" })); + + const body = await apiClient.saas.json<{ status: string }>( + "/api/v1/payg/wallet", + ); + + expect(body.status).toBe("free"); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe("https://saas.test.local/api/v1/payg/wallet"); + expect((init.headers as Record).Authorization).toBe( + "Bearer supabase_tok", + ); + }); +}); + +describe("apiClient.local", () => { + it("attaches the Spring admin bearer and stays same-origin", async () => { + getStoredTokenMock.mockReturnValue("spring_tok"); + fetchMock.mockResolvedValue(ok({ linked: false })); + + await apiClient.local.json("/api/v1/account-link/status"); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe("/api/v1/account-link/status"); + expect((init.headers as Record).Authorization).toBe( + "Bearer spring_tok", + ); + }); + + it("returns undefined for a 204 response", async () => { + getStoredTokenMock.mockReturnValue("spring_tok"); + fetchMock.mockResolvedValue(new Response(null, { status: 204 })); + + const result = await apiClient.local.json("/api/v1/account-link/unlink", { + method: "POST", + }); + + expect(result).toBeUndefined(); + }); + + it("throws HttpError with the status and parsed body on non-2xx", async () => { + getStoredTokenMock.mockReturnValue("spring_tok"); + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ error: "nope" }), { + status: 500, + statusText: "Internal Server Error", + headers: { "Content-Type": "application/json" }, + }), + ); + + const err = await apiClient.local + .json("/api/v1/account-link/status") + .catch((e: unknown) => e); + + expect(err).toBeInstanceOf(HttpError); + expect((err as HttpError).status).toBe(500); + expect((err as HttpError).body).toEqual({ error: "nope" }); + }); +}); diff --git a/frontend/portal/src/api/http.ts b/frontend/portal/src/api/http.ts index 9e1619fe38..de8d89babc 100644 --- a/frontend/portal/src/api/http.ts +++ b/frontend/portal/src/api/http.ts @@ -1,16 +1,54 @@ /** - * Shared HTTP plumbing for the portal's service layer. + * Portal API client β€” explicit per-backend, per-credential routing. * - * Every `api/*.ts` module calls {@link httpJson}, which issues a real `fetch`. - * In dev and Storybook those requests are intercepted by the MSW handlers in - * `mocks/` and answered with fixture data; pointing at a real backend is just - * a matter of not registering MSW. Consumers don't change either way. + * ## Domains * - * The shared `stirling_jwt` bearer token (set by the auth gate, and shared - * same-origin with the editor) is attached automatically so portal data calls - * are authenticated once real backend endpoints exist. + * apiClient.local Same-origin (vite proxy β†’ this instance's local + * Stirling backend on :8080). Spring admin bearer + * (`stirling_jwt` from @shared/auth) auto-attached. + * USE FOR: actions on this instance β€” + * /api/v1/account-link/{status,link,unlink}, etc. + * + * apiClient.saas VITE_SAAS_API_URL (hosted SaaS Java). The admin's + * Supabase JWT (from the account-link login, + * persisted + SDK-refreshed) is auto-attached. + * USE FOR: attended portalβ†’SaaS reads β€” + * /api/v1/payg/wallet, etc. + * Throws SaasUnconfiguredError when VITE_SAAS_API_URL + * is missing β€” callers surface a clear "configure" + * state rather than silently routing to the wrong + * domain. + * + * Endpoints that don't have a real backend yet still target their eventual + * domain (almost always `.local`): with Mocks=on the MSW handlers intercept; + * with Mocks=off they hit the real backend and 404 until the route ships, then + * self-heal β€” no call-site migration needed. + * + * ## Why this is split, not a single function + * + * The two backends speak two credentials and resolve different identities. A + * single generic fetch reading the path prefix to pick a domain is implicit + + * fragile (the bug we hit: /v1/billing/wallet fell through to the local + * backend on a real run). Forcing the call site to say `.local` / `.saas` + * keeps the routing intent reviewable in diffs. + * + * ## Device credential isn't here + * + * The instance↔SaaS device credential ({@code X-Device-Id}+{@code X-Device-Secret}) + * is a server-side credential the local backend uses for UNATTENDED metering / + * entitlement calls. It never enters the portal β€” the browser is the human + * admin and uses the Supabase JWT for SaaS reads. Don't add it here. */ import { getStoredToken } from "@shared/auth"; +import { getSupabaseClient } from "@shared/auth/supabase/supabaseClient"; +import { ensureSaasSupabase } from "@portal/auth/saasSupabase"; + +/** Read the SaaS base URL at call time so tests can stub it via vi.stubEnv. */ +function saasBaseUrl(): string | null { + const raw = import.meta.env.VITE_SAAS_API_URL; + if (!raw) return null; + return raw.replace(/\/+$/, ""); +} export interface HttpRequestOptions { method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; @@ -20,6 +58,7 @@ export interface HttpRequestOptions { signal?: AbortSignal; } +/** Thrown by any apiClient call on non-2xx response, with the parsed body. */ export class HttpError extends Error { constructor( public readonly status: number, @@ -31,6 +70,26 @@ export class HttpError extends Error { } } +/** Thrown by apiClient.saas.* when VITE_SAAS_API_URL isn't set. */ +export class SaasUnconfiguredError extends Error { + constructor() { + super( + "SaaS API not configured β€” set VITE_SAAS_API_URL to enable portalβ†’SaaS reads.", + ); + this.name = "SaasUnconfiguredError"; + } +} + +/** Thrown by apiClient.saas.* when the admin has no SaaS session yet. */ +export class SaasNotLinkedError extends Error { + constructor() { + super( + "No SaaS session β€” admin must link an account before attended SaaS reads.", + ); + this.name = "SaasNotLinkedError"; + } +} + /** * Best-effort human-readable message from a thrown error: unwraps an * {@link HttpError}'s ProblemDetail-ish body (`detail` / `message` / `error`) @@ -49,16 +108,38 @@ export function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } -function authHeader(): Record { +// ──────────────────────────────────────────────────────────────────────────── +// Shared response handler +// ──────────────────────────────────────────────────────────────────────────── + +async function unwrap(res: Response): Promise { + if (!res.ok) { + let body: unknown = null; + try { + body = await res.json(); + } catch { + // ignore β€” non-JSON error response + } + throw new HttpError(res.status, res.statusText, body); + } + // 204 / empty-body responses have nothing to parse. + if (res.status === 204 || res.headers.get("Content-Length") === "0") { + return undefined as T; + } + const text = await res.text(); + return (text ? JSON.parse(text) : undefined) as T; +} + +// ──────────────────────────────────────────────────────────────────────────── +// local β€” same-origin Stirling backend, Spring admin bearer +// ──────────────────────────────────────────────────────────────────────────── + +function localAuthHeader(): Record { const token = getStoredToken(); return token ? { Authorization: `Bearer ${token}` } : {}; } -/** - * Thin JSON fetch wrapper used by every api module. In dev/Storybook the - * request is served by MSW; against a real backend it hits the network. - */ -export async function httpJson( +async function localJson( path: string, options: HttpRequestOptions = {}, ): Promise { @@ -69,20 +150,64 @@ export async function httpJson( ...(options.body !== undefined ? { "Content-Type": "application/json" } : {}), - ...authHeader(), + ...localAuthHeader(), ...options.headers, }, body: options.body !== undefined ? JSON.stringify(options.body) : undefined, signal: options.signal, }); - if (!res.ok) { - let body: unknown = null; - try { - body = await res.json(); - } catch { - // ignore β€” non-JSON error response - } - throw new HttpError(res.status, res.statusText, body); - } - return (await res.json()) as T; + return unwrap(res); } + +// ──────────────────────────────────────────────────────────────────────────── +// saas β€” hosted SaaS Java, admin's Supabase JWT +// ──────────────────────────────────────────────────────────────────────────── + +async function getSaasAccessToken(): Promise { + ensureSaasSupabase(); + const supabase = getSupabaseClient(); + if (!supabase) return null; + const { data } = await supabase.auth.getSession(); + return data.session?.access_token ?? null; +} + +async function saasJson( + path: string, + options: HttpRequestOptions = {}, +): Promise { + const base = saasBaseUrl(); + if (!base) throw new SaasUnconfiguredError(); + const token = await getSaasAccessToken(); + if (!token) throw new SaasNotLinkedError(); + const res = await fetch(`${base}${path}`, { + method: options.method ?? "GET", + headers: { + Accept: "application/json", + Authorization: `Bearer ${token}`, + ...(options.body !== undefined + ? { "Content-Type": "application/json" } + : {}), + ...options.headers, + }, + body: options.body !== undefined ? JSON.stringify(options.body) : undefined, + signal: options.signal, + }); + return unwrap(res); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Exported API client +// ──────────────────────────────────────────────────────────────────────────── + +export const apiClient = { + /** Local backend (this instance). Spring admin bearer auto-attached. */ + local: { + json: localJson, + }, + /** Hosted SaaS Java. Admin's Supabase JWT auto-attached. */ + saas: { + json: saasJson, + /** True when VITE_SAAS_API_URL is set. Doesn't check session liveness. */ + isConfigured: (): boolean => Boolean(saasBaseUrl()), + }, +} as const; diff --git a/frontend/portal/src/api/infrastructure.ts b/frontend/portal/src/api/infrastructure.ts index 92255aece2..9d3023abd6 100644 --- a/frontend/portal/src/api/infrastructure.ts +++ b/frontend/portal/src/api/infrastructure.ts @@ -1,4 +1,4 @@ -import { httpJson } from "@portal/api/http"; +import { apiClient } from "@portal/api/http"; import type { Tier } from "@portal/contexts/TierContext"; import type { ApiKey, @@ -57,32 +57,42 @@ const q = (tier: Tier) => `?tier=${encodeURIComponent(tier)}`; export async function fetchDeployments( tier: Tier, ): Promise { - return httpJson( + return apiClient.local.json( `/v1/infrastructure/deployments${q(tier)}`, ); } /** GET /v1/infrastructure/api-keys?tier=… */ export async function fetchApiKeys(tier: Tier): Promise { - return httpJson(`/v1/infrastructure/api-keys${q(tier)}`); + return apiClient.local.json( + `/v1/infrastructure/api-keys${q(tier)}`, + ); } /** GET /v1/infrastructure/security?tier=… */ export async function fetchSecurity(tier: Tier): Promise { - return httpJson(`/v1/infrastructure/security${q(tier)}`); + return apiClient.local.json( + `/v1/infrastructure/security${q(tier)}`, + ); } /** GET /v1/infrastructure/models?tier=… */ export async function fetchModels(tier: Tier): Promise { - return httpJson(`/v1/infrastructure/models${q(tier)}`); + return apiClient.local.json( + `/v1/infrastructure/models${q(tier)}`, + ); } /** GET /v1/infrastructure/storage?tier=… */ export async function fetchStorage(tier: Tier): Promise { - return httpJson(`/v1/infrastructure/storage${q(tier)}`); + return apiClient.local.json( + `/v1/infrastructure/storage${q(tier)}`, + ); } /** GET /v1/infrastructure/audit-log?tier=… */ export async function fetchAuditLog(tier: Tier): Promise { - return httpJson(`/v1/infrastructure/audit-log${q(tier)}`); + return apiClient.local.json( + `/v1/infrastructure/audit-log${q(tier)}`, + ); } diff --git a/frontend/portal/src/api/link.test.ts b/frontend/portal/src/api/link.test.ts new file mode 100644 index 0000000000..e6d9b12703 --- /dev/null +++ b/frontend/portal/src/api/link.test.ts @@ -0,0 +1,123 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; +import { setupServer } from "msw/node"; +import { linkHandlers } from "@portal/mocks/handlers/link"; +import { resetLinkStore } from "@portal/mocks/link"; + +// Mock the shared Supabase client used by apiClient.saas. The team-wide +// /instances + /instances/:id/revoke calls go to SaaS now (auto-attached +// Bearer = current Supabase access token). Hoisted so vi.mock can see it. +const { getSession } = vi.hoisted(() => ({ + getSession: vi.fn().mockResolvedValue({ + data: { session: { access_token: "supabase_jwt_test" } }, + }), +})); +vi.mock("@shared/auth/supabase/supabaseClient", () => ({ + getSupabaseClient: () => ({ auth: { getSession } }), + configureSupabase: vi.fn(), +})); + +// Pretend the SaaS base URL is configured so apiClient.saas calls don't throw +// SaasUnconfiguredError. MSW's wildcard handlers (`*/...`) intercept the +// absolute URL the same way they do the relative one. +vi.stubEnv("VITE_SAAS_API_URL", "https://saas.test.local"); + +import { + fetchInstances, + fetchStatus, + linkInstance, + revokeInstance, + unlinkInstance, +} from "@portal/api/link"; + +const server = setupServer(...linkHandlers); + +beforeAll(() => server.listen({ onUnhandledRequest: "error" })); +afterEach(() => server.resetHandlers()); +afterAll(() => { + server.close(); + vi.unstubAllEnvs(); +}); +beforeEach(() => resetLinkStore()); + +describe("api/link β€” local backend (this instance)", () => { + it("starts not-linked", async () => { + const status = await fetchStatus(); + expect(status.linked).toBe(false); + }); + + it("links this instance via the local endpoint, never returning a secret", async () => { + const status = await linkInstance({ + supabaseJwt: "jwt_abc", + name: "node-1", + }); + expect(status.linked).toBe(true); + expect(status.name).toBe("node-1"); + // Contract: the device secret is stored server-side, never sent to the portal. + expect(status).not.toHaveProperty("deviceSecret"); + expect(status).not.toHaveProperty("deviceId"); + expect(await (await fetchStatus()).linked).toBe(true); + }); + + it("unlinks this instance", async () => { + await linkInstance({ supabaseJwt: "jwt_abc" }); + // unlink returns 204 (no body); the status is read back separately. + await unlinkInstance(); + expect((await fetchStatus()).linked).toBe(false); + }); + + it("forwards the SaaS JWT in the link body", async () => { + let seenBody: unknown = null; + server.events.on("request:start", async ({ request }) => { + if (request.method === "POST" && request.url.endsWith("/link")) { + seenBody = await request.clone().json(); + } + }); + await linkInstance({ supabaseJwt: "jwt_xyz", name: "n" }); + expect(seenBody).toMatchObject({ supabaseJwt: "jwt_xyz" }); + server.events.removeAllListeners(); + }); +}); + +describe("api/link β€” SaaS backend (team-wide)", () => { + it("fetches the instance list", async () => { + const rows = await fetchInstances(); + expect(rows.length).toBeGreaterThan(0); + expect(rows[0]).toHaveProperty("deviceId"); + }); + + it("revokes an instance", async () => { + const active = (await fetchInstances()).find((r) => !r.revoked)!; + await revokeInstance(active.instanceId); + const after = await fetchInstances(); + expect(after.find((r) => r.instanceId === active.instanceId)?.revoked).toBe( + true, + ); + }); + + it("hits the absolute SaaS URL with the Supabase JWT as Bearer", async () => { + let seenUrl: string | null = null; + let seenAuth: string | null = null; + const capture = ({ request }: { request: Request }) => { + if (request.url.includes("/account-link/instances")) { + seenUrl = request.url; + seenAuth = request.headers.get("authorization"); + } + }; + server.events.on("request:start", capture); + await fetchInstances(); + expect(seenUrl).toBe( + "https://saas.test.local/api/v1/account-link/instances", + ); + expect(seenAuth).toBe("Bearer supabase_jwt_test"); + server.events.removeAllListeners(); + }); +}); diff --git a/frontend/portal/src/api/link.ts b/frontend/portal/src/api/link.ts new file mode 100644 index 0000000000..c55978dffc --- /dev/null +++ b/frontend/portal/src/api/link.ts @@ -0,0 +1,85 @@ +import { apiClient } from "@portal/api/http"; +import type { + LinkInstanceRequest, + LinkStatus, + LinkedInstanceRow, +} from "@portal/mocks/link"; + +export type { + LinkInstanceRequest, + LinkStatus, + LinkedInstanceRow, +} from "@portal/mocks/link"; + +/** + * Account-link client (combined-billing "Mode A"). Two distinct surfaces: + * + * THIS instance β€” apiClient.local (Spring admin bearer auto-attached): + * - POST /api/v1/account-link/link β€” hand the local backend the admin's + * SaaS JWT in the body. It registers + * with SaaS + stores the device + * secret SERVER-SIDE; the portal + * NEVER receives or renders it. + * - GET /api/v1/account-link/status β€” Linked / Not-linked for this + * instance. + * - POST /api/v1/account-link/unlink β€” drop this instance's link (local + * backend best-effort tells SaaS). + * + * TEAM-WIDE management β€” apiClient.saas (admin's Supabase JWT auto-attached + * from the in-app account-link login): + * - GET /api/v1/account-link/instances β€” every linked instance + * - POST /api/v1/account-link/instances/{id}/revoke + * + * The team-wide endpoints are served by the hosted SaaS Java backend (the + * local backend has no such routes), so they go through apiClient.saas. They're + * MSW-intercepted in dev/Storybook via wildcard handlers that match both the + * local and absolute SaaS URLs. + */ + +const BASE = "/api/v1/account-link"; + +/** + * Link THIS instance. The local backend takes the SaaS JWT, registers with + * SaaS, and persists the device secret itself; the response carries only the + * resulting link status. No secret is returned. + */ +export async function linkInstance( + req: LinkInstanceRequest, +): Promise { + return apiClient.local.json(`${BASE}/link`, { + method: "POST", + body: req, + }); +} + +/** Linked / Not-linked for this instance. */ +export async function fetchStatus(): Promise { + return apiClient.local.json(`${BASE}/status`); +} + +/** + * Drop this instance's link. The local backend best-effort tells SaaS to + * revoke before clearing the credential locally, then returns 204 β€” there's no + * body, so the caller sets the known unlinked status itself. + */ +export async function unlinkInstance(): Promise { + await apiClient.local.json(`${BASE}/unlink`, { method: "POST" }); +} + +/** + * Every linked instance for the team β€” SaaS-direct call with the admin's + * Supabase JWT (no longer takes an accessToken parameter; the saas client + * resolves the live session itself). + */ +export async function fetchInstances(): Promise { + return apiClient.saas.json(`${BASE}/instances`); +} + +/** + * Revoke a linked instance β€” SaaS-direct call with the admin's Supabase JWT. + */ +export async function revokeInstance(instanceId: number): Promise { + await apiClient.saas.json(`${BASE}/instances/${instanceId}/revoke`, { + method: "POST", + }); +} diff --git a/frontend/portal/src/api/notifications.ts b/frontend/portal/src/api/notifications.ts index 21b6e33f66..ac0b2f85bc 100644 --- a/frontend/portal/src/api/notifications.ts +++ b/frontend/portal/src/api/notifications.ts @@ -1,4 +1,4 @@ -import { httpJson } from "@portal/api/http"; +import { apiClient } from "@portal/api/http"; import type { Notification, NotificationCategory, @@ -8,12 +8,12 @@ export type { Notification, NotificationCategory }; /** GET /v1/notifications */ export async function fetchNotifications(): Promise { - return httpJson("/v1/notifications"); + return apiClient.local.json("/v1/notifications"); } /** POST /v1/notifications/mark-all-read */ export async function markAllNotificationsRead(): Promise { - await httpJson<{ ok: true }>("/v1/notifications/mark-all-read", { + await apiClient.local.json<{ ok: true }>("/v1/notifications/mark-all-read", { method: "POST", }); } diff --git a/frontend/portal/src/api/ops.ts b/frontend/portal/src/api/ops.ts index e741793bf6..c8a564f307 100644 --- a/frontend/portal/src/api/ops.ts +++ b/frontend/portal/src/api/ops.ts @@ -1,11 +1,11 @@ -import { HttpError, httpJson } from "@portal/api/http"; +import { apiClient, HttpError } from "@portal/api/http"; import type { FeaturedOp, OpResultMap } from "@portal/mocks/ops"; export type { FeaturedOp, OpResultMap }; /** GET /v1/ops/featured */ export async function fetchFeaturedOps(): Promise { - return httpJson("/v1/ops/featured"); + return apiClient.local.json("/v1/ops/featured"); } export class UnknownOpError extends Error { @@ -21,13 +21,13 @@ export async function runSingleOp( sample: string, ): Promise<{ result: OpResultMap; durationMs: number }> { try { - return await httpJson<{ result: OpResultMap; durationMs: number }>( - `/v1/ops/${encodeURIComponent(opId)}/run`, - { - method: "POST", - body: { sample }, - }, - ); + return await apiClient.local.json<{ + result: OpResultMap; + durationMs: number; + }>(`/v1/ops/${encodeURIComponent(opId)}/run`, { + method: "POST", + body: { sample }, + }); } catch (err) { if (err instanceof HttpError && err.status === 404) { throw new UnknownOpError(opId); diff --git a/frontend/portal/src/api/pipelines.ts b/frontend/portal/src/api/pipelines.ts index e9c3ae2f2e..e7bb6222b6 100644 --- a/frontend/portal/src/api/pipelines.ts +++ b/frontend/portal/src/api/pipelines.ts @@ -1,4 +1,4 @@ -import { httpJson } from "@portal/api/http"; +import { apiClient } from "@portal/api/http"; import type { PipelinesResponse } from "@portal/mocks/pipelines"; import type { Tier } from "@portal/contexts/TierContext"; @@ -18,7 +18,7 @@ export type { /** GET /v1/pipelines?tier=… β€” the deployed fleet plus tier-specific extras. */ export async function fetchPipelines(tier: Tier): Promise { - return httpJson( + return apiClient.local.json( `/v1/pipelines?tier=${encodeURIComponent(tier)}`, ); } @@ -32,7 +32,7 @@ export async function fetchPipelines(tier: Tier): Promise { * handler resolves `{ ok: true }`; the UI treats a resolved promise as accepted. */ export async function promoteToPolicy(id: string): Promise<{ ok: true }> { - return httpJson<{ ok: true }>( + return apiClient.local.json<{ ok: true }>( `/v1/pipelines/${encodeURIComponent(id)}/promote-to-policy`, { method: "POST" }, ); diff --git a/frontend/portal/src/api/policies.ts b/frontend/portal/src/api/policies.ts index c17a5f0b11..fba5eda1eb 100644 --- a/frontend/portal/src/api/policies.ts +++ b/frontend/portal/src/api/policies.ts @@ -1,4 +1,4 @@ -import { httpJson } from "@portal/api/http"; +import { apiClient } from "@portal/api/http"; import type { PoliciesResponse, Policy } from "@portal/mocks/policies"; /** @@ -45,12 +45,14 @@ export { /** GET /api/v1/policies β€” the catalogue + every configured policy. */ export async function fetchPolicies(): Promise { - return httpJson("/api/v1/policies"); + return apiClient.local.json("/api/v1/policies"); } /** GET /api/v1/policies/{id} β€” one stored policy's raw record. */ export async function fetchPolicy(id: string): Promise { - return httpJson(`/api/v1/policies/${encodeURIComponent(id)}`); + return apiClient.local.json( + `/api/v1/policies/${encodeURIComponent(id)}`, + ); } /** @@ -58,14 +60,20 @@ export async function fetchPolicy(id: string): Promise { * assigns owner + team server-side and returns the stored policy with its id. */ export async function savePolicy(policy: Policy): Promise { - return httpJson("/api/v1/policies", { method: "POST", body: policy }); + return apiClient.local.json("/api/v1/policies", { + method: "POST", + body: policy, + }); } /** DELETE /api/v1/policies/{id} β€” remove a stored policy. */ export async function deletePolicy(id: string): Promise { - await httpJson(`/api/v1/policies/${encodeURIComponent(id)}`, { - method: "DELETE", - }); + await apiClient.local.json( + `/api/v1/policies/${encodeURIComponent(id)}`, + { + method: "DELETE", + }, + ); } /** The async run acknowledgement: a run id to poll for status. */ @@ -83,7 +91,7 @@ export interface PolicyRunResponse { * run id. Runs regardless of the policy's enabled flag. */ export async function runPolicy(id: string): Promise { - return httpJson( + return apiClient.local.json( `/api/v1/policies/${encodeURIComponent(id)}/run`, { method: "POST" }, ); diff --git a/frontend/portal/src/api/sdkComponents.ts b/frontend/portal/src/api/sdkComponents.ts index ed02afd9a1..3a43f158a3 100644 --- a/frontend/portal/src/api/sdkComponents.ts +++ b/frontend/portal/src/api/sdkComponents.ts @@ -1,4 +1,4 @@ -import { httpJson } from "@portal/api/http"; +import { apiClient } from "@portal/api/http"; import type { ComponentsResponse } from "@portal/mocks/sdkComponents"; import type { Tier } from "@portal/contexts/TierContext"; @@ -22,7 +22,7 @@ export { /** GET /v1/components?tier=… β€” summary strip + the embeddable SDK catalogue. */ export async function fetchComponents(tier: Tier): Promise { - return httpJson( + return apiClient.local.json( `/v1/components?tier=${encodeURIComponent(tier)}`, ); } diff --git a/frontend/portal/src/api/search.ts b/frontend/portal/src/api/search.ts index 6a7b600df8..2f085066c9 100644 --- a/frontend/portal/src/api/search.ts +++ b/frontend/portal/src/api/search.ts @@ -1,9 +1,9 @@ -import { httpJson } from "@portal/api/http"; +import { apiClient } from "@portal/api/http"; import type { QuickAction } from "@portal/mocks/search"; export type { QuickAction }; /** GET /v1/search/quick-actions */ export async function fetchQuickActions(): Promise { - return httpJson("/v1/search/quick-actions"); + return apiClient.local.json("/v1/search/quick-actions"); } diff --git a/frontend/portal/src/api/settings.ts b/frontend/portal/src/api/settings.ts index 8cba5d0cc1..1ec1cb45b3 100644 --- a/frontend/portal/src/api/settings.ts +++ b/frontend/portal/src/api/settings.ts @@ -1,4 +1,4 @@ -import { httpJson } from "@portal/api/http"; +import { apiClient } from "@portal/api/http"; import type { SettingsSnapshot } from "@portal/mocks/settings"; import type { Tier } from "@portal/contexts/TierContext"; @@ -13,7 +13,7 @@ export type { /** GET /v1/settings?tier=… β€” the account + workspace snapshot the modal edits. */ export async function fetchSettings(tier: Tier): Promise { - return httpJson( + return apiClient.local.json( `/v1/settings?tier=${encodeURIComponent(tier)}`, ); } diff --git a/frontend/portal/src/api/sources.ts b/frontend/portal/src/api/sources.ts index 29988958b5..9f9839d7a1 100644 --- a/frontend/portal/src/api/sources.ts +++ b/frontend/portal/src/api/sources.ts @@ -1,4 +1,4 @@ -import { httpJson } from "@portal/api/http"; +import { apiClient } from "@portal/api/http"; /** * Sources service layer: the backend contract. @@ -59,22 +59,30 @@ export interface Source { /** GET /api/v1/sources: KPI strip + one row per source for the admin. */ export async function fetchSources(): Promise { - return httpJson("/api/v1/sources"); + return apiClient.local.json("/api/v1/sources"); } /** GET /api/v1/sources/{id}: the raw source record (config options), for editing. */ export async function fetchSource(id: string): Promise { - return httpJson(`/api/v1/sources/${encodeURIComponent(id)}`); + return apiClient.local.json( + `/api/v1/sources/${encodeURIComponent(id)}`, + ); } /** POST /api/v1/sources: create (blank id) or update (matched id) a source. */ export async function createSource(source: Source): Promise { - return httpJson("/api/v1/sources", { method: "POST", body: source }); + return apiClient.local.json("/api/v1/sources", { + method: "POST", + body: source, + }); } /** DELETE /api/v1/sources/{id}: remove a source (409 if a policy references it). */ export async function deleteSource(id: string): Promise { - await httpJson(`/api/v1/sources/${encodeURIComponent(id)}`, { - method: "DELETE", - }); + await apiClient.local.json( + `/api/v1/sources/${encodeURIComponent(id)}`, + { + method: "DELETE", + }, + ); } diff --git a/frontend/portal/src/api/usage.ts b/frontend/portal/src/api/usage.ts deleted file mode 100644 index 6e80d8312c..0000000000 --- a/frontend/portal/src/api/usage.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { httpJson } from "@portal/api/http"; -import type { Tier } from "@portal/contexts/TierContext"; -import type { - BillingHistoryRow, - BillingSummary, - PlanOption, - UsageSeriesResponse, -} from "@portal/mocks/usage"; - -export type { - BillingHistoryRow, - BillingSummary, - InvoiceStatus, - PlanOption, - UsagePoint, - UsageSeriesResponse, -} from "@portal/mocks/usage"; -export { OVERAGE_RATE } from "@portal/mocks/usage"; - -/** GET /v1/billing/usage β€” 30-day docs-processed series. */ -export async function fetchBillingUsage(): Promise { - return httpJson("/v1/billing/usage"); -} - -/** GET /v1/billing/summary?tier=… β€” KPI strip + current-plan figures. */ -export async function fetchBillingSummary(tier: Tier): Promise { - return httpJson( - `/v1/billing/summary?tier=${encodeURIComponent(tier)}`, - ); -} - -/** GET /v1/billing/plans β€” available plan catalogue. */ -export async function fetchPlanOptions(): Promise { - return httpJson("/v1/billing/plans"); -} - -/** GET /v1/billing/history?tier=… β€” invoice / line-item history. */ -export async function fetchBillingHistory( - tier: Tier, -): Promise { - return httpJson( - `/v1/billing/history?tier=${encodeURIComponent(tier)}`, - ); -} diff --git a/frontend/portal/src/api/users.ts b/frontend/portal/src/api/users.ts index f9e7b11c08..4c0fa5dcb6 100644 --- a/frontend/portal/src/api/users.ts +++ b/frontend/portal/src/api/users.ts @@ -1,4 +1,4 @@ -import { httpJson } from "@portal/api/http"; +import { apiClient } from "@portal/api/http"; import type { UsersResponse } from "@portal/mocks/users"; import type { Tier } from "@portal/contexts/TierContext"; @@ -20,5 +20,7 @@ export { /** GET /v1/users?tier=… β€” summary strip, members table, role catalogue, access. */ export async function fetchUsers(tier: Tier): Promise { - return httpJson(`/v1/users?tier=${encodeURIComponent(tier)}`); + return apiClient.local.json( + `/v1/users?tier=${encodeURIComponent(tier)}`, + ); } diff --git a/frontend/portal/src/auth/saasSupabase.ts b/frontend/portal/src/auth/saasSupabase.ts new file mode 100644 index 0000000000..25a8a6ed6b --- /dev/null +++ b/frontend/portal/src/auth/saasSupabase.ts @@ -0,0 +1,42 @@ +import { + configureSupabase, + getSupabaseClient, +} from "@shared/auth/supabase/supabaseClient"; + +/** + * Configures the shared Supabase client against the hosted SaaS project so the + * portal can mint a SaaS JWT IN-APP for account linking (no popup). This is a + * separate, transient SaaS auth β€” the portal's own session stays Spring (the + * local instance admin); calls to the local backend still carry the Spring + * bearer, and the SaaS JWT is passed only in the link request body. + * + * Config: VITE_SAAS_SUPABASE_URL + VITE_SAAS_SUPABASE_ANON_KEY (both public). + * Absent β†’ {@link isSaasSupabaseConfigured} is false and the link UI degrades to + * a "configure the SaaS Supabase URL" state. + */ +const url = import.meta.env.VITE_SAAS_SUPABASE_URL; +const key = import.meta.env.VITE_SAAS_SUPABASE_ANON_KEY; + +export const isSaasSupabaseConfigured = Boolean(url && key); + +/** OAuth providers the hosted SaaS login offers (mirrors the SaaS editor login). */ +export const SAAS_OAUTH_PROVIDERS = ["google", "github", "apple", "azure"]; + +/** sessionStorage marker set before an SSO redirect so the return can finish the link. */ +export const PENDING_LINK_KEY = "stirling-account-link-pending"; + +let configured = false; + +/** + * Configure the shared Supabase client once (idempotent). Returns the client, or + * null when the SaaS Supabase env isn't set. `detectSessionInUrl` (on by default) + * means an SSO redirect back to the portal is picked up here. + */ +export function ensureSaasSupabase() { + if (!isSaasSupabaseConfigured) return null; + if (!configured) { + configureSupabase({ url: url as string, key: key as string }); + configured = true; + } + return getSupabaseClient(); +} diff --git a/frontend/portal/src/auth/saasSupabaseLogin.test.ts b/frontend/portal/src/auth/saasSupabaseLogin.test.ts new file mode 100644 index 0000000000..5517897ec9 --- /dev/null +++ b/frontend/portal/src/auth/saasSupabaseLogin.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; + +// Mock the shared Supabase client the in-app link login is wired to. Declared +// via vi.hoisted so the vi.mock factory can reference them. +const { signInWithPassword, signInWithOAuth } = vi.hoisted(() => ({ + signInWithPassword: vi.fn(), + signInWithOAuth: vi.fn(), +})); + +vi.mock("@shared/auth/supabase/supabaseClient", () => ({ + getSupabaseClient: () => ({ auth: { signInWithPassword, signInWithOAuth } }), +})); + +import { useSupabaseLogin } from "@shared/auth/ui/useSupabaseLogin"; + +describe("useSupabaseLogin (in-app account-link login)", () => { + beforeEach(() => { + signInWithPassword.mockReset(); + signInWithOAuth.mockReset(); + }); + + it("fires onSuccess with the access token on a successful email sign-in", async () => { + signInWithPassword.mockResolvedValue({ + data: { session: { access_token: "tok-123" } }, + error: null, + }); + const onSuccess = vi.fn(); + const { result } = renderHook(() => useSupabaseLogin({ onSuccess })); + + act(() => { + result.current.setEmail("admin@org.com"); + result.current.setPassword("pw"); + }); + await act(async () => { + await result.current.signInWithEmail(); + }); + + expect(signInWithPassword).toHaveBeenCalledWith({ + email: "admin@org.com", + password: "pw", + }); + expect(onSuccess).toHaveBeenCalledWith({ access_token: "tok-123" }); + expect(result.current.error).toBeNull(); + }); + + it("surfaces an email sign-in error and skips onSuccess", async () => { + signInWithPassword.mockResolvedValue({ + data: { session: null }, + error: { message: "Invalid login credentials" }, + }); + const onSuccess = vi.fn(); + const { result } = renderHook(() => useSupabaseLogin({ onSuccess })); + + act(() => { + result.current.setEmail("admin@org.com"); + result.current.setPassword("nope"); + }); + await act(async () => { + await result.current.signInWithEmail(); + }); + + expect(result.current.error).toBe("Invalid login credentials"); + expect(onSuccess).not.toHaveBeenCalled(); + }); + + it("kicks off OAuth with the provider, redirect, and pre-redirect hook", async () => { + signInWithOAuth.mockResolvedValue({ data: {}, error: null }); + const onBeforeOAuth = vi.fn(); + const { result } = renderHook(() => + useSupabaseLogin({ + providers: ["google", "github"], + redirectTo: "http://portal.local/account-link", + onBeforeOAuth, + }), + ); + + expect(result.current.hasProviders).toBe(true); + await act(async () => { + await result.current.signInWithProvider("google"); + }); + + expect(onBeforeOAuth).toHaveBeenCalledWith("google"); + expect(signInWithOAuth).toHaveBeenCalledWith({ + provider: "google", + options: { redirectTo: "http://portal.local/account-link" }, + }); + }); + + it("validates both fields before calling Supabase", async () => { + const { result } = renderHook(() => useSupabaseLogin()); + await act(async () => { + await result.current.signInWithEmail(); + }); + expect(signInWithPassword).not.toHaveBeenCalled(); + expect(result.current.error).toBeTruthy(); + }); +}); diff --git a/frontend/portal/src/billing/sharedBillingFormat.test.ts b/frontend/portal/src/billing/sharedBillingFormat.test.ts new file mode 100644 index 0000000000..8882df796b --- /dev/null +++ b/frontend/portal/src/billing/sharedBillingFormat.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; +import { + currencySymbol, + docCapForMoney, + formatMinor, + formatPeriodDate, + meterState, +} from "@shared/billing"; + +/** + * Unit tests for the @shared/billing money/meter helpers the portal billing + * surface (and the editor cloud surface) depend on. docCapForMoney mirrors the + * backend's capβ†’PDF conversion and meterState mirrors the BE warn/degrade bands, + * so these invariants matter beyond cosmetics. + */ +describe("docCapForMoney", () => { + it("returns null when there is no cap", () => { + expect(docCapForMoney(null, 2)).toBeNull(); + }); + + it("returns null when the rate is unresolved or non-positive", () => { + expect(docCapForMoney(1000, null)).toBeNull(); + expect(docCapForMoney(1000, 0)).toBeNull(); + expect(docCapForMoney(1000, -5)).toBeNull(); + }); + + it("floors capMinor / rate (the backend mirror)", () => { + // $1000 cap, 2 minor units / doc β†’ floor(100000 / 2) = 50000 PDFs. + expect(docCapForMoney(1000, 2)).toBe(50000); + // Sub-cent rate (0.5 minor) β†’ floor(100000 / 0.5) = 200000. + expect(docCapForMoney(1000, 0.5)).toBe(200000); + // Floors a partial PDF down. + expect(docCapForMoney(10, 3)).toBe(333); + }); + + it("treats a $0 cap as zero paid PDFs", () => { + expect(docCapForMoney(0, 2)).toBe(0); + }); +}); + +describe("meterState", () => { + it("is FULL below the warn band", () => { + expect(meterState(10, 100).state).toBe("FULL"); + expect(meterState(79, 100).state).toBe("FULL"); + }); + + it("is WARNED from 80% up to (not including) 100%", () => { + expect(meterState(80, 100).state).toBe("WARNED"); + expect(meterState(99, 100).state).toBe("WARNED"); + }); + + it("is DEGRADED at and above 100%, with pct clamped to 100", () => { + expect(meterState(100, 100)).toEqual({ state: "DEGRADED", pct: 100 }); + const over = meterState(500, 100); + expect(over.state).toBe("DEGRADED"); + expect(over.pct).toBe(100); + }); + + it("treats a non-positive limit as fully consumed", () => { + expect(meterState(0, 0)).toEqual({ state: "DEGRADED", pct: 100 }); + }); +}); + +describe("currencySymbol", () => { + it("maps known currencies and defaults empty/usd to $", () => { + expect(currencySymbol("usd")).toBe("$"); + expect(currencySymbol("")).toBe("$"); + expect(currencySymbol(null)).toBe("$"); + expect(currencySymbol("eur")).toBe("€"); + expect(currencySymbol("gbp")).toBe("Β£"); + }); + + it("falls back to the upper-cased code for anything unmapped", () => { + expect(currencySymbol("cad")).toBe("CAD "); + }); +}); + +describe("formatMinor", () => { + it("formats whole and fractional cents", () => { + expect(formatMinor(224, "usd")).toContain("2.24"); + expect(formatMinor(5, "usd")).toContain("0.05"); + }); + + it("keeps up to 3 fraction digits so sub-cent rates don't round to $0", () => { + expect(formatMinor(0.5, "usd")).toContain("0.005"); + }); +}); + +describe("formatPeriodDate", () => { + it("returns an empty string for null", () => { + expect(formatPeriodDate(null)).toBe(""); + }); + + it("formats the date part of an ISO string", () => { + const out = formatPeriodDate("2026-06-24"); + expect(out).toContain("Jun"); + expect(out).toContain("24"); + }); + + it("includes the year only when asked", () => { + expect(formatPeriodDate("2026-06-24")).not.toContain("2026"); + expect(formatPeriodDate("2026-06-24", { year: true })).toContain("2026"); + }); +}); diff --git a/frontend/portal/src/billing/stripe.test.ts b/frontend/portal/src/billing/stripe.test.ts new file mode 100644 index 0000000000..eeb9e16dcd --- /dev/null +++ b/frontend/portal/src/billing/stripe.test.ts @@ -0,0 +1,114 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * Branch coverage for the Stripe edge-function client: the embedded-checkout vs + * already-subscribed-redirect vs neither-secret-nor-url mapping, the mock flag, + * unconfigured Supabase, and the portal-session path. + */ +const { getClient, invoke } = vi.hoisted(() => ({ + getClient: vi.fn(), + invoke: vi.fn(), +})); + +vi.mock("@portal/auth/saasSupabase", () => ({ ensureSaasSupabase: vi.fn() })); +vi.mock("@shared/auth/supabase/supabaseClient", () => ({ + getSupabaseClient: () => getClient(), + configureSupabase: vi.fn(), +})); + +import { + createCheckoutSession, + createPortalSession, + StripeFunctionError, +} from "@portal/billing/stripe"; + +const req = { teamId: 1, successUrl: "s", cancelUrl: "c" } as const; + +beforeEach(() => { + invoke.mockReset(); + getClient.mockReset().mockReturnValue({ functions: { invoke } }); +}); +afterEach(() => vi.restoreAllMocks()); + +describe("createCheckoutSession", () => { + it("maps embedded checkout (client_secret)", async () => { + invoke.mockResolvedValue({ + data: { success: true, client_secret: "cs_123" }, + error: null, + }); + const s = await createCheckoutSession(req); + expect(s).toEqual({ + clientSecret: "cs_123", + redirectUrl: null, + alreadySubscribed: false, + mock: false, + }); + }); + + it("short-circuits already-subscribed to the portal URL (no client secret)", async () => { + invoke.mockResolvedValue({ + data: { + success: true, + already_subscribed: true, + portal_url: "https://portal", + }, + error: null, + }); + const s = await createCheckoutSession(req); + expect(s.alreadySubscribed).toBe(true); + expect(s.redirectUrl).toBe("https://portal"); + expect(s.clientSecret).toBeNull(); + }); + + it("flags a mock client secret", async () => { + invoke.mockResolvedValue({ + data: { success: true, client_secret: "cs_mock_abc" }, + error: null, + }); + expect((await createCheckoutSession(req)).mock).toBe(true); + }); + + it("throws when success is false", async () => { + invoke.mockResolvedValue({ + data: { success: false, error: "no team" }, + error: null, + }); + await expect(createCheckoutSession(req)).rejects.toBeInstanceOf( + StripeFunctionError, + ); + }); + + it("throws when neither client_secret nor url is returned", async () => { + invoke.mockResolvedValue({ data: { success: true }, error: null }); + await expect(createCheckoutSession(req)).rejects.toThrow(/neither/); + }); + + it("throws unconfigured when there is no Supabase client", async () => { + getClient.mockReturnValue(null); + const err = await createCheckoutSession(req).catch((e: unknown) => e); + expect(err).toBeInstanceOf(StripeFunctionError); + expect((err as StripeFunctionError).code).toBe("unconfigured"); + }); +}); + +describe("createPortalSession", () => { + it("returns the portal URL", async () => { + invoke.mockResolvedValue({ + data: { success: true, url: "https://billing" }, + error: null, + }); + expect(await createPortalSession({ teamId: 1, returnUrl: "r" })).toBe( + "https://billing", + ); + }); + + it("throws on a free team (no url)", async () => { + invoke.mockResolvedValue({ + data: { success: false, error: "team_not_subscribed" }, + error: null, + }); + await expect( + createPortalSession({ teamId: 1, returnUrl: "r" }), + ).rejects.toBeInstanceOf(StripeFunctionError); + }); +}); diff --git a/frontend/portal/src/billing/stripe.ts b/frontend/portal/src/billing/stripe.ts new file mode 100644 index 0000000000..068046ef37 --- /dev/null +++ b/frontend/portal/src/billing/stripe.ts @@ -0,0 +1,170 @@ +import { getSupabaseClient } from "@shared/auth/supabase/supabaseClient"; +import { ensureSaasSupabase } from "@portal/auth/saasSupabase"; + +/** + * Stripe checkout + portal sessions, minted via the SaaS Supabase edge + * functions (no new Java endpoints). Same pattern the SaaS web app uses for + * its Plan page β€” `supabase.functions.invoke` carries the admin's JWT + * automatically, and the edge functions resolve the team via the + * `payg_get_checkout_context` RPC. + */ + +export class StripeFunctionError extends Error { + constructor( + message: string, + public readonly code?: string, + ) { + super(message); + this.name = "StripeFunctionError"; + } +} + +/** Currencies the SaaS PAYG offering supports. Default for new checkouts is "usd". */ +export type SaasCurrency = "usd" | "eur" | "gbp"; + +interface CheckoutSessionRequest { + teamId: number; + /** Where Stripe redirects on success β€” typically the portal billing page. */ + successUrl: string; + /** Where Stripe redirects on cancel/close. */ + cancelUrl: string; + /** ISO 4217 lower-case. Defaults to "usd"; portal uses the wallet's currency when set. */ + currency?: SaasCurrency; + /** Optional prefill for the Stripe Checkout email field. */ + billingOwnerEmail?: string; +} + +interface PortalSessionRequest { + teamId: number; + returnUrl: string; +} + +/** + * Checkout response shape. The edge function defaults to embedded Stripe + * Checkout (returns {@code client_secret}); it can also return: + * - {@code portal_url} + {@code already_subscribed: true} when the team is + * already on PAYG (short-circuit so the click still does something useful) + * - {@code url} alongside or instead of {@code client_secret} for hosted / + * redirect-mode flows (rare; embedded is the default for the SaaS UX). + */ +interface CheckoutResponse { + success: boolean; + client_secret?: string; + url?: string; + portal_url?: string; + already_subscribed?: boolean; + mock?: boolean; + error?: string; +} + +interface PortalResponse { + success: boolean; + url?: string; + error?: string; +} + +async function invoke( + name: string, + body: Record, +): Promise { + ensureSaasSupabase(); + const supabase = getSupabaseClient(); + if (!supabase) { + throw new StripeFunctionError( + "SaaS Supabase not configured β€” set VITE_SAAS_SUPABASE_URL.", + "unconfigured", + ); + } + const { data, error } = await supabase.functions.invoke(name, { body }); + if (error) { + throw new StripeFunctionError( + error.message ?? `Edge function ${name} failed`, + ); + } + if (data == null) { + throw new StripeFunctionError(`Edge function ${name} returned no data`); + } + return data; +} + +/** + * Result of {@link createCheckoutSession}. Exactly ONE of {@code clientSecret} + * or {@code redirectUrl} is set: clientSecret drives embedded Stripe Checkout + * (the default UX, matching the SaaS web app); redirectUrl is used for the + * already-subscribed short-circuit (portal URL) or any hosted-mode fallback. + */ +export interface CheckoutSession { + clientSecret: string | null; + redirectUrl: string | null; + alreadySubscribed: boolean; + mock: boolean; +} + +/** + * Mint a Stripe Checkout session for PAYG subscription. Defaults to embedded + * Checkout (returns {@code clientSecret}) so the portal can mount + * <EmbeddedCheckoutProvider> inline. If the team is already subscribed the + * edge function short-circuits to a Customer Portal URL β€” surfaced as + * {@code redirectUrl} + {@code alreadySubscribed=true} so the caller can open it + * in a new tab instead of trying to mount a checkout iframe with no secret. + */ +export async function createCheckoutSession( + req: CheckoutSessionRequest, +): Promise { + const res = await invoke("create-checkout-session", { + team_id: req.teamId, + currency: req.currency ?? "usd", + success_url: req.successUrl, + cancel_url: req.cancelUrl, + ...(req.billingOwnerEmail + ? { billing_owner_email: req.billingOwnerEmail } + : {}), + }); + if (!res.success) { + throw new StripeFunctionError( + res.error ?? "create-checkout-session failed", + ); + } + const alreadySubscribed = Boolean(res.already_subscribed); + const redirectUrl = alreadySubscribed + ? (res.portal_url ?? null) + : (res.url ?? null); + const clientSecret = alreadySubscribed ? null : (res.client_secret ?? null); + if (!clientSecret && !redirectUrl) { + throw new StripeFunctionError( + "create-checkout-session returned neither client_secret nor URL", + ); + } + return { + clientSecret, + redirectUrl, + alreadySubscribed, + mock: Boolean(res.mock) || clientSecret?.startsWith("cs_mock_") === true, + }; +} + +/** {@code VITE_STRIPE_PUBLISHABLE_KEY} β€” the Stripe pk used by embedded Checkout. */ +export function getStripePublishableKey(): string { + return import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY; +} + +/** + * Mint a Stripe Customer Portal session. The admin can manage their card, + * view invoices, and cancel from Stripe's hosted UI. The edge function returns + * 404 with {@code team_not_subscribed} if called for a free team β€” surfaced + * here as a StripeFunctionError the caller can toast. + */ +export async function createPortalSession( + req: PortalSessionRequest, +): Promise { + const res = await invoke("create-customer-portal-session", { + team_id: req.teamId, + return_url: req.returnUrl, + }); + if (!res.success || !res.url) { + throw new StripeFunctionError( + res.error ?? "create-customer-portal-session failed", + ); + } + return res.url; +} diff --git a/frontend/portal/src/components/AppShell.css b/frontend/portal/src/components/AppShell.css index 1c7491d9a5..03702966e4 100644 --- a/frontend/portal/src/components/AppShell.css +++ b/frontend/portal/src/components/AppShell.css @@ -1,6 +1,14 @@ +/* Fixed-height shell so the MAIN COLUMN scrolls, not the document. With + min-height:100vh the shell grows with content and the document scrolls, which + means .portal-shell__view's overflow-y never engages and any `position:sticky` + inside it (e.g. a page header) rides away with the document. Pinning the shell + to the viewport (+ min-height:0 on the flex descendants so they can shrink + below content) makes .portal-shell__view the scroll container, so sticky + page headers stick under the global header. */ .portal-shell { display: flex; - min-height: 100vh; + height: 100vh; + overflow: hidden; background: var(--color-bg); color: var(--color-text-2); } @@ -10,10 +18,12 @@ display: flex; flex-direction: column; min-width: 0; /* prevent grid blowout on narrow content */ + min-height: 0; /* allow the column to bound its children */ } .portal-shell__view { flex: 1 1 auto; + min-height: 0; /* scroll instead of growing past the viewport */ overflow-y: auto; animation: fadeInUp var(--motion-enter) both; } diff --git a/frontend/portal/src/components/Header.tsx b/frontend/portal/src/components/Header.tsx index 59b1a72625..8783e9bafc 100644 --- a/frontend/portal/src/components/Header.tsx +++ b/frontend/portal/src/components/Header.tsx @@ -40,8 +40,12 @@ function ThemeToggle() { } function TierSwitcher() { - const { tier, setTier } = useTier(); + const { tier, setTier, isDerived } = useTier(); const info = TIER_INFO[tier]; + // When mocks are off, the tier is derived from the real link/wallet state β€” + // pair the dropdown with the mocks toggle (hidden in prod) so testing real + // billing flows can't be perturbed by accidentally flipping the mock tier. + if (isDerived) return null; return ( diff --git a/frontend/portal/src/components/SettingsModal.tsx b/frontend/portal/src/components/SettingsModal.tsx index 0c9ea1d7d7..7cb4ad8c44 100644 --- a/frontend/portal/src/components/SettingsModal.tsx +++ b/frontend/portal/src/components/SettingsModal.tsx @@ -31,7 +31,9 @@ import { PoliciesIcon, InfrastructureIcon, SparklesIcon, + LinkIcon, } from "@portal/components/icons"; +import { AccountLinkPanel } from "@portal/components/account-link/AccountLinkPanel"; import "@portal/components/SettingsModal.css"; type SettingsSection = @@ -41,7 +43,21 @@ type SettingsSection = | "general" | "authentication" | "sessions" - | "early-access"; + | "early-access" + | "account-link"; + +function isSettingsSection(value: string | null): value is SettingsSection { + return ( + value === "profile" || + value === "appearance" || + value === "notifications" || + value === "general" || + value === "authentication" || + value === "sessions" || + value === "early-access" || + value === "account-link" + ); +} /** Org-wide auth posture the Admin sections edit, mirrored into local state. */ interface SecurityForm { @@ -54,6 +70,12 @@ interface SecurityForm { interface SettingsModalProps { open: boolean; onClose: () => void; + /** + * Optional section to land on when opening. When `null`/unsupported the modal + * picks the default ("profile"). Set by callers like the sidebar's "Link + * account" affordance β†’ "account-link". + */ + initialSection?: string | null; } /** @@ -83,7 +105,11 @@ const SESSION_TIMEOUT_VALUES = ["60", "240", "480", "720", "1440"] as const; * state. Save is a no-op for the demo β€” it closes β€” but the theme control * writes straight through to ThemeProvider so the change is real and visible. */ -export function SettingsModal({ open, onClose }: SettingsModalProps) { +export function SettingsModal({ + open, + onClose, + initialSection, +}: SettingsModalProps) { const { t } = useTranslation(); const { tier } = useTier(); const { theme, setTheme } = useTheme(); @@ -124,6 +150,11 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) { { title: t("settings.groups.admin"), items: [ + { + key: "account-link", + label: t("settings.sections.account-link"), + icon: , + }, { key: "authentication", label: t("settings.sections.authentication"), @@ -189,8 +220,10 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) { }, [snapshot]); useEffect(() => { - if (open) setSection("profile"); - }, [open]); + if (!open) return; + const requested = initialSection ?? null; + setSection(isSettingsSection(requested) ? requested : "profile"); + }, [open, initialSection]); const regionOptions = useMemo(() => { if (!snapshot) return []; @@ -301,6 +334,8 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) { } /> )} + + {section === "account-link" && } ); diff --git a/frontend/portal/src/components/Sidebar.tsx b/frontend/portal/src/components/Sidebar.tsx index ac5b042b92..48e7ea0b04 100644 --- a/frontend/portal/src/components/Sidebar.tsx +++ b/frontend/portal/src/components/Sidebar.tsx @@ -4,6 +4,7 @@ import { useView, type ViewId } from "@portal/contexts/ViewContext"; import { useTier } from "@portal/contexts/TierContext"; import { useTheme } from "@portal/contexts/ThemeContext"; import { useUI } from "@portal/contexts/UIContext"; +import { useLink } from "@portal/contexts/LinkContext"; import { useAsync } from "@portal/hooks/useAsync"; import { fetchHomeKpis, type KpiEntry } from "@portal/api/home"; import { EDITOR_URL } from "@portal/auth/editorUrl"; @@ -19,6 +20,7 @@ import { ComponentsIcon, InfrastructureIcon, UsageIcon, + LinkIcon, DocsIcon, SettingsIcon, ChevronDownIcon, @@ -47,6 +49,27 @@ const GROUP_PLATFORM: NavEntry[] = [ { id: "docs", icon: }, ]; +/** + * Sidebar-footer link-account CTA. Only visible when the org is unlinked β€” once + * linked, the linked-instances row + plan badge already communicate the state, + * so a permanent footer button would be noise. Click β†’ opens the login modal + * directly. + */ +function LinkAccountFooterItem() { + const { t } = useTranslation(); + const { openLinkModal } = useUI(); + const { linkState } = useLink(); + if (linkState !== "unlinked") return null; + return ( + } + onClick={() => openLinkModal()} + /> + ); +} + function UsageFooter() { const { tier } = useTier(); const { t } = useTranslation(); @@ -90,8 +113,8 @@ function UsageFooter() { const planLabel = tier === "pro" - ? t("shell.sidebar.planPayAsYouGo") - : t("shell.sidebar.planEnterprise"); + ? t("shell.sidebar.planProcessor", "Processor plan") + : t("shell.sidebar.planEnterprise", "Enterprise plan"); return (
@@ -200,6 +223,7 @@ export function Sidebar() {
+ ( + () => (linked ? fetchInstances() : Promise.resolve([])), + [reloadKey, linked], + ); + + const [revokingId, setRevokingId] = useState(null); + const [revokeError, setRevokeError] = useState(null); + + const revoke = useCallback(async (instance: LinkedInstanceRow) => { + setRevokingId(instance.instanceId); + setRevokeError(null); + try { + await apiRevokeInstance(instance.instanceId); + setReloadKey((k) => k + 1); + } catch (e) { + setRevokeError(e instanceof Error ? e.message : String(e)); + } finally { + setRevokingId(null); + } + }, []); + + return ( +
+
+
+

{t("accountLink.panel.sub")}

+
+ + {t(LINK_INFO[linkState].labelKey)} + +
+ + + + {linked && ( +
+
+

+ {t("accountLink.panel.instancesTitle")} +

+

+ {t("accountLink.panel.instancesSub")} +

+
+ {instancesState.loading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ ) : instancesState.error ? ( + + {instancesState.error instanceof HttpError && + instancesState.error.status === 403 + ? t("accountLink.panel.loadError.forbidden") + : t("accountLink.panel.loadError.generic")} + + ) : ( + + )} + + {revokeError && ( + + {revokeError} + + )} +
+ )} +
+ ); +} diff --git a/frontend/portal/src/components/account-link/LinkAccountCard.stories.tsx b/frontend/portal/src/components/account-link/LinkAccountCard.stories.tsx new file mode 100644 index 0000000000..af949e9dc9 --- /dev/null +++ b/frontend/portal/src/components/account-link/LinkAccountCard.stories.tsx @@ -0,0 +1,55 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { UseAccountLink } from "@portal/hooks/useAccountLink"; +import { LinkAccountCard } from "@portal/components/account-link/LinkAccountCard"; +import "@portal/views/AccountLink.css"; + +// A no-op UseAccountLink for static stories; overridden per story. +const base: UseAccountLink = { + loginConfigured: true, + status: { linked: false, name: null }, + phase: "idle", + error: null, + completeLink: async () => {}, + unlink: async () => {}, +}; + +const meta: Meta = { + title: "Portal/AccountLink/LinkAccountCard", + component: LinkAccountCard, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +/** Not linked β€” the "Link your Stirling account" button opens the login modal. */ +export const NotLinked: Story = { + args: { link: base }, +}; + +/** Linking β€” login completed, button shows progress while registering. */ +export const Linking: Story = { + args: { link: { ...base, phase: "linking" } }, +}; + +/** Linked β€” status only; the device secret is never shown. */ +export const Linked: Story = { + args: { + link: { ...base, status: { linked: true, name: "prod-eu-gateway" } }, + }, +}; + +/** SaaS Supabase not configured β€” explains the in-app dev simulate fallback. */ +export const Unconfigured: Story = { + args: { link: { ...base, loginConfigured: false } }, +}; + +/** Link error surfaced inline. */ +export const Error: Story = { + args: { + link: { + ...base, + phase: "error", + error: "Couldn't register this instance with the SaaS backend.", + }, + }, +}; diff --git a/frontend/portal/src/components/account-link/LinkAccountCard.tsx b/frontend/portal/src/components/account-link/LinkAccountCard.tsx new file mode 100644 index 0000000000..7231fbbe28 --- /dev/null +++ b/frontend/portal/src/components/account-link/LinkAccountCard.tsx @@ -0,0 +1,81 @@ +import { useTranslation } from "react-i18next"; +import { Banner, Button, Card, StatusBadge } from "@shared/components"; +import type { UseAccountLink } from "@portal/hooks/useAccountLink"; +import { useUI } from "@portal/contexts/UIContext"; + +interface Props { + link: UseAccountLink; +} + +/** + * Status + actions for THIS instance's account link. The "Link" button opens + * the single top-level login modal (UIContext.openLinkModal) β€” never a nested + * modal. The portal posts the returned JWT to the local backend, which stores + * the device secret server-side; the secret is never received or rendered here. + */ +export function LinkAccountCard({ link }: Props) { + const { t } = useTranslation(); + const { openLinkModal } = useUI(); + const linking = link.phase === "linking"; + const linked = link.status?.linked ?? false; + + return ( + +
+
+ + {t("accountLink.card.eyebrow")} + +

{t("accountLink.card.title")}

+
+ + {linked + ? t("accountLink.card.linked") + : t("accountLink.card.notLinked")} + +
+ + {!link.loginConfigured && ( + + {t("accountLink.card.loginNotConfigured.before")}{" "} + VITE_SAAS_SUPABASE_URL{" "} + {t("accountLink.card.loginNotConfigured.after")} + + )} + + {link.error && ( + + {link.error} + + )} + + {linked ? ( +
+ + {link.status?.name + ? t("accountLink.card.linkedAs", { name: link.status.name }) + : t("accountLink.card.linkedGeneric")}{" "} + {t("accountLink.card.billingNote")} + + +
+ ) : ( +
+ +
+ )} +
+ ); +} diff --git a/frontend/portal/src/components/account-link/LinkAccountModal.tsx b/frontend/portal/src/components/account-link/LinkAccountModal.tsx new file mode 100644 index 0000000000..b0dafac4ec --- /dev/null +++ b/frontend/portal/src/components/account-link/LinkAccountModal.tsx @@ -0,0 +1,107 @@ +import { useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { Banner, Button, Modal } from "@shared/components"; +import SupabaseLoginForm from "@shared/auth/ui/SupabaseLoginForm"; +import { + useSupabaseLogin, + type SupabaseLoginSession, +} from "@shared/auth/ui/useSupabaseLogin"; +import "@shared/auth/ui/auth-theme.css"; +import { + ensureSaasSupabase, + isSaasSupabaseConfigured, + PENDING_LINK_KEY, + SAAS_OAUTH_PROVIDERS, +} from "@portal/auth/saasSupabase"; + +interface Props { + open: boolean; + onClose: () => void; + /** + * "link" registers this instance against the signed-in account; "reauth" only + * refreshes an expired SaaS session (the instance is already linked). The mode + * is persisted across the OAuth redirect so the SSO-return handler doesn't + * re-register on a reauth. + */ + mode?: "link" | "reauth"; + /** Called with the SaaS session after a successful sign-in. */ + onLinked: (session: SupabaseLoginSession) => void | Promise; +} + +/** + * In-app account-link login. Signs the admin in to their Stirling (SaaS) account + * via the shared Supabase login (SSO + email/password), then hands the resulting + * session to the caller to register this instance. No popup; the device secret + * never reaches the browser. SSO redirects away and is finished by useAccountLink + * on return. + */ +export function LinkAccountModal({ + open, + onClose, + mode = "link", + onLinked, +}: Props) { + const { t } = useTranslation(); + useEffect(() => { + if (open) ensureSaasSupabase(); + }, [open]); + + const reauth = mode === "reauth"; + const login = useSupabaseLogin({ + providers: SAAS_OAUTH_PROVIDERS, + // Return to the current page after SSO; the SSO-return handler in + // useAccountLink reads the persisted mode so it links vs. only refreshes. + redirectTo: window.location.href, + onBeforeOAuth: () => sessionStorage.setItem(PENDING_LINK_KEY, mode), + onSuccess: async (session) => { + await onLinked(session); + onClose(); + }, + }); + + return ( + + {isSaasSupabaseConfigured ? ( + + ) : ( +
+ + {t("accountLink.modal.loginNotConfigured.before")}{" "} + VITE_SAAS_SUPABASE_URL{" "} + {t("accountLink.modal.loginNotConfigured.and")}{" "} + VITE_SAAS_SUPABASE_ANON_KEY{" "} + {t("accountLink.modal.loginNotConfigured.after")} + + {import.meta.env.DEV && ( + + )} +
+ )} +
+ ); +} diff --git a/frontend/portal/src/components/account-link/LinkGate.stories.tsx b/frontend/portal/src/components/account-link/LinkGate.stories.tsx new file mode 100644 index 0000000000..af2ac5c96b --- /dev/null +++ b/frontend/portal/src/components/account-link/LinkGate.stories.tsx @@ -0,0 +1,24 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Card } from "@shared/components"; +import { LinkGate } from "@portal/components/account-link/LinkGate"; + +const meta: Meta = { + title: "Portal/AccountLink/LinkGate", + component: LinkGate, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +/** + * Gating follows the Link toolbar global: "Unlinked" shows the lock prompt, + * any linked state renders the feature. + */ +export const Default: Story = { + args: { + feature: "AI extraction", + children: ( + A billable feature, unlocked once linked. + ), + }, +}; diff --git a/frontend/portal/src/components/account-link/LinkGate.tsx b/frontend/portal/src/components/account-link/LinkGate.tsx new file mode 100644 index 0000000000..254f4739bc --- /dev/null +++ b/frontend/portal/src/components/account-link/LinkGate.tsx @@ -0,0 +1,43 @@ +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { Banner, Button } from "@shared/components"; +import { useLink } from "@portal/contexts/LinkContext"; +import { useUI } from "@portal/contexts/UIContext"; + +interface Props { + /** The billable feature β€” rendered only when the org is linked. */ + children: ReactNode; + /** Feature name for the lock copy, e.g. "AI extraction". */ + feature?: string; +} + +/** + * Gates billable features on the account-link state. When the org is unlinked it + * renders a "link to unlock" prompt instead of the feature; once linked (free or + * subscribed) the children render. Drop this around any surface that should only + * work against a linked SaaS wallet. + */ +export function LinkGate({ children, feature }: Props) { + const { t } = useTranslation(); + const { featuresUnlocked } = useLink(); + const { openLinkModal } = useUI(); + + if (featuresUnlocked) return <>{children}; + + return ( + openLinkModal()}> + {t("accountLink.gate.action")} + + } + /> + ); +} diff --git a/frontend/portal/src/components/account-link/LinkedInstancesTable.stories.tsx b/frontend/portal/src/components/account-link/LinkedInstancesTable.stories.tsx new file mode 100644 index 0000000000..d10d08bf27 --- /dev/null +++ b/frontend/portal/src/components/account-link/LinkedInstancesTable.stories.tsx @@ -0,0 +1,36 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { listInstances, type LinkedInstanceRow } from "@portal/mocks/link"; +import { LinkedInstancesTable } from "@portal/components/account-link/LinkedInstancesTable"; +import "@portal/views/AccountLink.css"; + +const meta: Meta = { + title: "Portal/AccountLink/LinkedInstancesTable", + component: LinkedInstancesTable, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +/** Active + revoked instances; revoke flips status locally. */ +export const Default: Story = { + render: () => { + const [rows, setRows] = useState(listInstances()); + return ( + + setRows((rs) => + rs.map((r) => + r.instanceId === i.instanceId ? { ...r, revoked: true } : r, + ), + ) + } + /> + ); + }, +}; + +export const Empty: Story = { + args: { instances: [], onRevoke: () => {} }, +}; diff --git a/frontend/portal/src/components/account-link/LinkedInstancesTable.tsx b/frontend/portal/src/components/account-link/LinkedInstancesTable.tsx new file mode 100644 index 0000000000..bfe7fd0258 --- /dev/null +++ b/frontend/portal/src/components/account-link/LinkedInstancesTable.tsx @@ -0,0 +1,123 @@ +import { useTranslation } from "react-i18next"; +import type { TFunction } from "i18next"; +import { + Button, + Card, + EmptyState, + StatusBadge, + Table, + type TableColumn, +} from "@shared/components"; +import type { LinkedInstanceRow } from "@portal/api/link"; + +interface Props { + instances: LinkedInstanceRow[]; + /** Called when the leader revokes a (non-revoked) instance. */ + onRevoke: (instance: LinkedInstanceRow) => void; + /** instanceId currently being revoked β€” disables its button + shows progress. */ + revokingId?: number | null; +} + +function relativeTime(iso: string | null, t: TFunction): string { + if (!iso) return t("accountLink.instances.time.never"); + const diffMs = Date.now() - new Date(iso).getTime(); + const mins = Math.round(diffMs / 60_000); + if (mins < 1) return t("accountLink.instances.time.justNow"); + if (mins < 60) + return t("accountLink.instances.time.minutesAgo", { count: mins }); + const hrs = Math.round(mins / 60); + if (hrs < 24) return t("accountLink.instances.time.hoursAgo", { count: hrs }); + return t("accountLink.instances.time.daysAgo", { + count: Math.round(hrs / 24), + }); +} + +/** List of linked self-hosted instances with a leader-only revoke action. */ +export function LinkedInstancesTable({ + instances, + onRevoke, + revokingId, +}: Props) { + const { t } = useTranslation(); + const cols: TableColumn[] = [ + { + key: "name", + header: t("accountLink.instances.columns.instance"), + render: (i) => ( +
+ + {i.name ?? t("accountLink.instances.unnamed")} + + {i.deviceId} +
+ ), + }, + { + key: "status", + header: t("accountLink.instances.columns.status"), + render: (i) => + i.revoked ? ( + + {t("accountLink.instances.revoked")} + + ) : ( + + {t("accountLink.instances.active")} + + ), + }, + { + key: "lastSeen", + header: t("accountLink.instances.columns.lastSeen"), + render: (i) => ( + + {relativeTime(i.lastSeenAt, t)} + + ), + }, + { + key: "created", + header: t("accountLink.instances.columns.linked"), + render: (i) => ( + + {relativeTime(i.createdAt, t)} + + ), + }, + { + key: "actions", + header: "", + align: "right", + render: (i) => + i.revoked ? null : ( + + ), + }, + ]; + + return ( + + {instances.length === 0 ? ( + + ) : ( + String(i.instanceId)} + /> + )} + + ); +} diff --git a/frontend/portal/src/components/billing/EnterpriseUpsell.stories.tsx b/frontend/portal/src/components/billing/EnterpriseUpsell.stories.tsx new file mode 100644 index 0000000000..1e2fb83045 --- /dev/null +++ b/frontend/portal/src/components/billing/EnterpriseUpsell.stories.tsx @@ -0,0 +1,17 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { EnterpriseUpsell } from "@portal/components/billing/EnterpriseUpsell"; +import "@portal/components/billing/billing.css"; + +const meta: Meta = { + title: "Portal/Billing/EnterpriseUpsell", + component: EnterpriseUpsell, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +/** Standalone card β€” used on the free and subscribed billing views. */ +export const Default: Story = {}; + +/** Bare variant β€” embeds in another card's column (no surface of its own). */ +export const Bare: Story = { args: { bare: true } }; diff --git a/frontend/portal/src/components/billing/EnterpriseUpsell.tsx b/frontend/portal/src/components/billing/EnterpriseUpsell.tsx new file mode 100644 index 0000000000..bff0e91d2c --- /dev/null +++ b/frontend/portal/src/components/billing/EnterpriseUpsell.tsx @@ -0,0 +1,39 @@ +import { useTranslation } from "react-i18next"; +import { Button, Card } from "@shared/components"; + +interface Props { + /** Render without the Card wrapper, to embed inside another card's column. */ + bare?: boolean; +} + +/** + * Volume-discount / Enterprise upsell, shared by the free and subscribed billing + * views. The CTA is intentionally inert until the sales/quote URL is confirmed. + */ +export function EnterpriseUpsell({ bare = false }: Props) { + const { t } = useTranslation(); + const body = ( + <> + + {t("billing.enterpriseUpsell.eyebrow")} + +
+
+

+ {t("billing.enterpriseUpsell.title")} +

+

+ {t("billing.enterpriseUpsell.description")} +

+
+ {/* Destination wired when the enterprise/sales URL is confirmed. */} + +
+ + ); + if (bare) + return
{body}
; + return {body}; +} diff --git a/frontend/portal/src/components/billing/FreePdfEditorsCard.stories.tsx b/frontend/portal/src/components/billing/FreePdfEditorsCard.stories.tsx new file mode 100644 index 0000000000..442274431a --- /dev/null +++ b/frontend/portal/src/components/billing/FreePdfEditorsCard.stories.tsx @@ -0,0 +1,18 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FreePdfEditorsCard } from "@portal/components/billing/FreePdfEditorsCard"; +import "@portal/components/billing/billing.css"; + +const meta: Meta = { + title: "Portal/Billing/FreePdfEditorsCard", + component: FreePdfEditorsCard, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +/** + * The team editor-fleet card. The metrics are SAMPLE data (flagged with the + * Preview badge) until the fleet-telemetry endpoint lands in a follow-up PR; + * "Invite teammates" is intentionally inert for now. + */ +export const Default: Story = {}; diff --git a/frontend/portal/src/components/billing/FreePdfEditorsCard.tsx b/frontend/portal/src/components/billing/FreePdfEditorsCard.tsx new file mode 100644 index 0000000000..e6eaa65d51 --- /dev/null +++ b/frontend/portal/src/components/billing/FreePdfEditorsCard.tsx @@ -0,0 +1,76 @@ +import { useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { + Button, + Card, + MetricCard, + MetricStrip, + StatusBadge, +} from "@shared/components"; +import GroupsIcon from "@mui/icons-material/GroupsRounded"; +import PersonAddIcon from "@mui/icons-material/PersonAddAltRounded"; + +/** + * "Free PDF Editors" team-fleet card. The editors-deployed / active-this-month / + * PDFs-edited figures come from a fleet-telemetry endpoint that does not exist + * yet (tracked for a follow-up PR), so they are SAMPLE data β€” flagged with a + * Preview badge β€” and "Invite teammates" is intentionally inert. The layout is + * built so the page matches the marketing design; swap the constants for live + * values when the endpoint lands. + */ +const SAMPLE = { + editorsDeployed: "6", + activeThisMonth: "4", + pdfsEdited: "1,240", +}; + +export function FreePdfEditorsCard() { + const navigate = useNavigate(); + const { t } = useTranslation(); + return ( + +
+
+ + + +
+

+ {t("billing.freeEditors.title")}{" "} + + {t("billing.freeEditors.previewBadge")} + +

+

+ {t("billing.freeEditors.subtitle")} +

+
+
+ + + + + + + {/* Opens the Users tab with its invite-member modal (via the ?invite param). */} + +
+
+ ); +} diff --git a/frontend/portal/src/components/billing/FreePlanView.stories.tsx b/frontend/portal/src/components/billing/FreePlanView.stories.tsx new file mode 100644 index 0000000000..e89219fd3c --- /dev/null +++ b/frontend/portal/src/components/billing/FreePlanView.stories.tsx @@ -0,0 +1,22 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FreePlanView } from "@portal/components/billing/FreePlanView"; +import { freeWallet } from "@portal/components/billing/walletFixtures"; +import "@portal/components/billing/billing.css"; + +const meta: Meta = { + title: "Portal/Billing/FreePlanView", + component: FreePlanView, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +/** Leader β€” free meter + the "Turn on Processor" CTA (opens embedded checkout on click). */ +export const Leader: Story = { + args: { wallet: freeWallet }, +}; + +/** Member β€” sees the explainer but not the enable CTA. */ +export const Member: Story = { + args: { wallet: { ...freeWallet, role: "member" } }, +}; diff --git a/frontend/portal/src/components/billing/FreePlanView.tsx b/frontend/portal/src/components/billing/FreePlanView.tsx new file mode 100644 index 0000000000..2d8526d805 --- /dev/null +++ b/frontend/portal/src/components/billing/FreePlanView.tsx @@ -0,0 +1,111 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Banner, Button, StatusBadge } from "@shared/components"; +import type { Wallet } from "@portal/api/billing"; +import type { SaasCurrency } from "@portal/billing/stripe"; +import { WalletMeter } from "@portal/components/billing/WalletMeter"; +import { FreePdfEditorsCard } from "@portal/components/billing/FreePdfEditorsCard"; +import { EnterpriseUpsell } from "@portal/components/billing/EnterpriseUpsell"; +import { StripeCheckoutModal } from "@portal/components/billing/StripeCheckoutModal"; + +interface Props { + wallet: Wallet; + /** Called after checkout completes so the parent refetches the wallet. */ + onSubscribed?: () => void; +} + +function isSaasCurrency(c: string | null): c is SaasCurrency { + return c === "usd" || c === "eur" || c === "gbp"; +} + +/** + * Linked, not yet subscribed β€” the "Editor" current plan. Shows the team's free + * editor fleet, the Processor trial meter (with the inline "Switch on the + * Processor" CTA β†’ embedded Stripe Checkout), and the Enterprise upsell. + */ +export function FreePlanView({ wallet, onSubscribed }: Props) { + const { t } = useTranslation(); + const [modalOpen, setModalOpen] = useState(false); + const [missingTeam, setMissingTeam] = useState(null); + + const isLeader = wallet.role === "leader"; + const currency: SaasCurrency = isSaasCurrency(wallet.currency) + ? wallet.currency + : "usd"; + + function openCheckout() { + if (wallet.teamId == null) { + setMissingTeam(t("billing.freePlan.noTeamResolved")); + return; + } + setMissingTeam(null); + setModalOpen(true); + } + + const switchOnAction = isLeader ? ( + + ) : null; + + return ( +
+ {/* Current plan */} +
+ + {t("billing.freePlan.currentPlan")} + +
+

+ {t("billing.freePlan.planName")} +

+ + {t("billing.freePlan.freeForever")} + + + {t("billing.freePlan.ssoIncluded")} + + + {t("billing.freePlan.unlimitedUsers")} + +
+
+ + + + {/* Processor trial β€” meter with the inline upgrade CTA */} + + + {missingTeam && ( + + {missingTeam} + + )} + {!isLeader && ( +

+ {t("billing.freePlan.ownerOnly")} +

+ )} + + {/* Volume discount / Enterprise */} + + + {wallet.teamId != null && ( + setModalOpen(false)} + teamId={wallet.teamId} + currency={currency} + onComplete={() => { + setModalOpen(false); + onSubscribed?.(); + }} + /> + )} +
+ ); +} diff --git a/frontend/portal/src/components/billing/InvoicesList.tsx b/frontend/portal/src/components/billing/InvoicesList.tsx new file mode 100644 index 0000000000..425bd10582 --- /dev/null +++ b/frontend/portal/src/components/billing/InvoicesList.tsx @@ -0,0 +1,223 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Button, + Card, + EmptyState, + Skeleton, + StatusBadge, + Table, + type TableColumn, +} from "@shared/components"; +import { formatMinor, formatPeriodDate } from "@shared/billing"; +import { fetchInvoices, type Invoice } from "@portal/api/billing"; + +const DEFAULT_VISIBLE = 5; +/** Fetch a few more than DEFAULT_VISIBLE so "Show more" actually has something to show. */ +const FETCH_LIMIT = 20; + +function statusTone( + status: string, +): "success" | "warning" | "danger" | "neutral" { + switch (status) { + case "paid": + return "success"; + case "open": + case "draft": + return "warning"; + case "uncollectible": + case "void": + return "danger"; + default: + return "neutral"; + } +} + +/** + * Recent Stripe invoices, sourced from GET /api/v1/payg/invoices (reads + * stripe.invoices via the Sync Engine). Backend orders newest first AND + * filters out drafts (matching Stripe's own customer portal behavior β€” + * drafts have no public hosted URL or PDF). We fetch FETCH_LIMIT rows and + * show DEFAULT_VISIBLE by default with a "Show all N" inline toggle. For + * history older than FETCH_LIMIT, the Stripe customer portal (button on the + * Subscription card above) is the authoritative archive. + * + * Each row links straight out to Stripe-hosted assets β€” the invoice page + * ({@code hostedInvoiceUrl}) and the PDF ({@code invoicePdf}). Rendered as + * actual {@code } anchors rather than {@code window.open} + * handlers so they're real user gestures (popup blockers don't trip). + */ +export function InvoicesList() { + const { t } = useTranslation(); + const [invoices, setInvoices] = useState(null); + const [error, setError] = useState(null); + const [showAll, setShowAll] = useState(false); + + useEffect(() => { + let cancelled = false; + setError(null); + fetchInvoices(FETCH_LIMIT) + .then((rows) => { + if (!cancelled) setInvoices(rows); + }) + .catch((e) => { + if (!cancelled) setError(e instanceof Error ? e.message : String(e)); + }); + return () => { + cancelled = true; + }; + }, []); + + const total = invoices?.length ?? 0; + const visible = showAll ? total : Math.min(DEFAULT_VISIBLE, total); + const visibleRows = invoices?.slice(0, visible) ?? []; + const hasMore = total > DEFAULT_VISIBLE; + // We only fetched FETCH_LIMIT rows β€” at the cap there may be older invoices we + // didn't load, so don't claim "all". + const atFetchLimit = total >= FETCH_LIMIT; + + // Column layout mirrors Stripe's own customer-portal "Invoice history" rows: + // Date Β· Amount Β· Status Β· Description (product name) Β· Actions + // The monospace invoice id is dropped β€” users care about "what was it for", + // not the internal id. + const columns: TableColumn[] = [ + { + key: "date", + header: t("billing.invoices.columnDate"), + render: (inv) => + inv.createdAt ? formatPeriodDate(inv.createdAt, { year: true }) : "β€”", + }, + { + key: "pdfs", + header: t("billing.invoices.columnPdfsProcessed"), + align: "right", + // Billed units on the invoice's metered line item; "β€”" when the + // line-item table isn't synced into the Stripe mirror. + render: (inv) => + inv.pdfsProcessed == null ? "β€”" : inv.pdfsProcessed.toLocaleString(), + }, + { + key: "amount", + header: t("billing.invoices.columnAmount"), + align: "right", + render: (inv) => + inv.totalMinor == null + ? "β€”" + : formatMinor(inv.totalMinor, inv.currency), + }, + { + key: "status", + header: t("billing.invoices.columnStatus"), + render: (inv) => ( + + {inv.status} + + ), + }, + { + key: "description", + header: t("billing.invoices.columnDescription"), + render: (inv) => ( + + {inv.description ?? t("billing.invoices.descriptionFallback")} + + ), + }, + { + key: "actions", + header: "", + align: "right", + render: (inv) => ( + + ), + }, + ]; + + return ( + +

+ {t("billing.invoices.title")} +

+ + {invoices === null && !error && ( +
+ + + +
+ )} + + {error && ( +

+ {t("billing.invoices.loadError", { error })} +

+ )} + + {invoices !== null && invoices.length === 0 && !error && ( + + )} + + {invoices !== null && invoices.length > 0 && ( + <> +
inv.id} + /> + {hasMore && ( +
+ + {showAll && atFetchLimit && ( + + {t("billing.invoices.fetchLimitNote", { count: FETCH_LIMIT })} + + )} +
+ )} + + )} + + ); +} diff --git a/frontend/portal/src/components/billing/LinkAccountPrompt.stories.tsx b/frontend/portal/src/components/billing/LinkAccountPrompt.stories.tsx new file mode 100644 index 0000000000..20a9ab2e80 --- /dev/null +++ b/frontend/portal/src/components/billing/LinkAccountPrompt.stories.tsx @@ -0,0 +1,14 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { LinkAccountPrompt } from "@portal/components/billing/LinkAccountPrompt"; +import "@portal/components/billing/billing.css"; + +const meta: Meta = { + title: "Portal/Billing/LinkAccountPrompt", + component: LinkAccountPrompt, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +/** Unlinked billing page β€” CTA opens the login modal (UIProvider from the preview decorator). */ +export const Default: Story = {}; diff --git a/frontend/portal/src/components/billing/LinkAccountPrompt.tsx b/frontend/portal/src/components/billing/LinkAccountPrompt.tsx new file mode 100644 index 0000000000..72fb366142 --- /dev/null +++ b/frontend/portal/src/components/billing/LinkAccountPrompt.tsx @@ -0,0 +1,27 @@ +import { useTranslation } from "react-i18next"; +import { Button, Card, EmptyState } from "@shared/components"; +import { useUI } from "@portal/contexts/UIContext"; + +/** + * Unlinked state β€” the billing page asks the admin to link their Stirling + * account to claim the 500-PDF free grant. The CTA opens the login modal + * directly (no detour through Settings). + */ +export function LinkAccountPrompt() { + const { t } = useTranslation(); + const { openLinkModal } = useUI(); + return ( + + openLinkModal()}> + {t("billing.linkPrompt.cta")} + + } + /> + + ); +} diff --git a/frontend/portal/src/components/billing/PaymentMethodCard.stories.tsx b/frontend/portal/src/components/billing/PaymentMethodCard.stories.tsx new file mode 100644 index 0000000000..63b492107e --- /dev/null +++ b/frontend/portal/src/components/billing/PaymentMethodCard.stories.tsx @@ -0,0 +1,51 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { http, HttpResponse } from "msw"; +import { PaymentMethodCard } from "@portal/components/billing/PaymentMethodCard"; +import "@portal/components/billing/billing.css"; + +const meta: Meta = { + title: "Portal/Billing/PaymentMethodCard", + component: PaymentMethodCard, + args: { onManage: () => {} }, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +/** Card present in the Stripe mirror. */ +export const WithCard: Story = { + parameters: { + msw: { + handlers: [ + http.get("*/api/v1/payg/payment-method", () => + HttpResponse.json({ + present: true, + brand: "visa", + last4: "4242", + expMonth: 8, + expYear: 2027, + }), + ), + ], + }, + }, +}; + +/** Mirror carries no card (table not synced / no card on file) β€” neutral fallback. */ +export const ManagedInStripe: Story = { + parameters: { + msw: { + handlers: [ + http.get("*/api/v1/payg/payment-method", () => + HttpResponse.json({ + present: false, + brand: null, + last4: null, + expMonth: null, + expYear: null, + }), + ), + ], + }, + }, +}; diff --git a/frontend/portal/src/components/billing/PaymentMethodCard.tsx b/frontend/portal/src/components/billing/PaymentMethodCard.tsx new file mode 100644 index 0000000000..9876a8a4f3 --- /dev/null +++ b/frontend/portal/src/components/billing/PaymentMethodCard.tsx @@ -0,0 +1,91 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Button, Card } from "@shared/components"; +import { fetchPaymentMethod, type PaymentMethod } from "@portal/api/billing"; + +interface Props { + /** Opens the Stripe customer portal (card changes live in Stripe, not here). */ + onManage: () => void; + managing?: boolean; +} + +function titleCase(s: string): string { + return s ? s.charAt(0).toUpperCase() + s.slice(1) : s; +} + +/** + * The team's default card, read from the Stripe mirror via + * {@code GET /api/v1/payg/payment-method}. When the mirror doesn't carry the + * card (table not synced, or no card on file) we don't invent one β€” we show a + * neutral "managed in Stripe" state. Editing always happens in Stripe's portal; + * the Update button just deep-links there. + */ +export function PaymentMethodCard({ onManage, managing }: Props) { + const { t } = useTranslation(); + // undefined = loading, null = none/unavailable, object = real card. + const [pm, setPm] = useState(undefined); + + useEffect(() => { + let cancelled = false; + fetchPaymentMethod() + .then((p) => { + if (!cancelled) setPm(p.present ? p : null); + }) + .catch(() => { + if (!cancelled) setPm(null); + }); + return () => { + cancelled = true; + }; + }, []); + + const hasCard = pm != null && pm.last4 != null; + + return ( + +
+
+ + {t("billing.paymentMethod.eyebrow")} + + {hasCard ? ( + <> +

+ {t("billing.paymentMethod.cardEnding", { + brand: titleCase( + pm.brand ?? t("billing.paymentMethod.cardFallback"), + ), + last4: pm.last4, + })} +

+

+ {pm.expMonth != null && pm.expYear != null + ? t("billing.paymentMethod.expiresBilledMonthly", { + expiry: `${String(pm.expMonth).padStart(2, "0")}/${pm.expYear}`, + }) + : t("billing.paymentMethod.billedMonthly")} +

+ + ) : ( + <> +

+ {t("billing.paymentMethod.managedTitle")} +

+

+ {t("billing.paymentMethod.managedSub")} +

+ + )} +
+ +
+
+ ); +} diff --git a/frontend/portal/src/components/billing/PdfsProcessedCard.stories.tsx b/frontend/portal/src/components/billing/PdfsProcessedCard.stories.tsx new file mode 100644 index 0000000000..eaf3172bec --- /dev/null +++ b/frontend/portal/src/components/billing/PdfsProcessedCard.stories.tsx @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { PdfsProcessedCard } from "@portal/components/billing/PdfsProcessedCard"; +import { subscribedWallet } from "@portal/components/billing/walletFixtures"; +import "@portal/components/billing/billing.css"; + +const meta: Meta = { + title: "Portal/Billing/PdfsProcessedCard", + component: PdfsProcessedCard, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +/** Metered PDFs split across API / Agents / Automation (real categoryBreakdown). */ +export const WithBreakdown: Story = { args: { wallet: subscribedWallet } }; + +/** Nothing metered yet this period β€” the split hides. */ +export const Empty: Story = { + args: { + wallet: { + ...subscribedWallet, + billableUsed: 0, + spendUnitsThisPeriod: 0, + categoryBreakdown: { api: 0, ai: 0, automation: 0 }, + }, + }, +}; diff --git a/frontend/portal/src/components/billing/PdfsProcessedCard.tsx b/frontend/portal/src/components/billing/PdfsProcessedCard.tsx new file mode 100644 index 0000000000..1718ea2a19 --- /dev/null +++ b/frontend/portal/src/components/billing/PdfsProcessedCard.tsx @@ -0,0 +1,104 @@ +import { useTranslation } from "react-i18next"; +import { Card } from "@shared/components"; +import type { Wallet, WalletCategoryBreakdown } from "@portal/api/billing"; + +/** + * "PDFs processed this period" headline + a stacked split of where the metered + * PDFs went. The split reuses the wallet's existing {@code categoryBreakdown} + * (API / Agents / Automation β€” the same buckets the entitlement service tracks; + * the "AI" bucket surfaces as "Agents" here). Real data only: the bar hides when + * nothing metered has run yet. + */ +const SEGMENTS: ReadonlyArray<{ + key: keyof WalletCategoryBreakdown; + labelKey: string; + descKey: string; + cls: string; +}> = [ + { + key: "api", + labelKey: "billing.pdfsProcessed.segmentApiLabel", + descKey: "billing.pdfsProcessed.segmentApiDesc", + cls: "blue", + }, + { + key: "ai", + labelKey: "billing.pdfsProcessed.segmentAgentsLabel", + descKey: "billing.pdfsProcessed.segmentAgentsDesc", + cls: "purple", + }, + { + key: "automation", + labelKey: "billing.pdfsProcessed.segmentAutomationLabel", + descKey: "billing.pdfsProcessed.segmentAutomationDesc", + cls: "teal", + }, +]; + +export function PdfsProcessedCard({ wallet }: { wallet: Wallet }) { + const { t } = useTranslation(); + const b = wallet.categoryBreakdown; + const total = b.api + b.ai + b.automation; + + return ( + + + {t("billing.pdfsProcessed.eyebrow")} + +
+ + {wallet.billableUsed.toLocaleString()} + + + {t("billing.pdfsProcessed.unit")} + +
+ + {total > 0 ? ( + <> +
+ {SEGMENTS.map((s) => + b[s.key] > 0 ? ( + + ) : null, + )} +
+
+ {SEGMENTS.map((s) => ( +
+ + + {t(s.labelKey)} + + + {t("billing.pdfsProcessed.legendValue", { + count: b[s.key], + formatted: b[s.key].toLocaleString(), + })} + + + {t(s.descKey)} + +
+ ))} +
+ + ) : ( +

+ {t("billing.pdfsProcessed.emptyPeriod")} +

+ )} +
+ ); +} diff --git a/frontend/portal/src/components/billing/SpendLimitCard.stories.tsx b/frontend/portal/src/components/billing/SpendLimitCard.stories.tsx new file mode 100644 index 0000000000..1356dc156a --- /dev/null +++ b/frontend/portal/src/components/billing/SpendLimitCard.stories.tsx @@ -0,0 +1,67 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { SpendLimitCard } from "@portal/components/billing/SpendLimitCard"; +import { subscribedWallet } from "@portal/components/billing/walletFixtures"; +import type { Wallet } from "@portal/api/billing"; +import "@portal/components/billing/billing.css"; + +const meta: Meta = { + title: "Portal/Billing/SpendLimitCard", + component: SpendLimitCard, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +/** Interactive wrapper so the in-place "Adjust limit" edit toggle works in-story. */ +function Demo({ wallet, open = false }: { wallet: Wallet; open?: boolean }) { + const [adjusting, setAdjusting] = useState(open); + return ( + + ); +} + +/** Comfortably within the cap. */ +export const WithinCap: Story = { + render: () => , +}; + +/** Approaching the cap β€” % used chip + run-rate projection. */ +export const ApproachingCap: Story = { + render: () => ( + + ), +}; + +/** The in-place editor β€” buckets + suggested shortcut + guardrail + Save. */ +export const Editing: Story = { + render: () => ( + + ), +}; + +/** Uncapped β€” no bar, no projection. */ +export const NoCap: Story = { + render: () => ( + + ), +}; diff --git a/frontend/portal/src/components/billing/SpendLimitCard.tsx b/frontend/portal/src/components/billing/SpendLimitCard.tsx new file mode 100644 index 0000000000..d4861dc2d1 --- /dev/null +++ b/frontend/portal/src/components/billing/SpendLimitCard.tsx @@ -0,0 +1,279 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Banner, Button, Card } from "@shared/components"; +import { + currencySymbol, + docCapForMoney, + formatMinor, + formatMoneyMajor, + MeterBar, + meterState, + SpendCapControl as SharedSpendCapControl, +} from "@shared/billing"; +import type { Wallet } from "@portal/api/billing"; +import { updateCap } from "@portal/api/billing"; + +interface Props { + wallet: Wallet; + onWalletChange?: () => void; + /** Controlled edit mode β€” lifted so the over-cap banner can open it. */ + adjusting: boolean; + onAdjustingChange: (open: boolean) => void; +} + +/** Local-date day count between two ISO yyyy-mm-dd strings; 0 if unparseable. */ +function daysBetween(aIso: string, bIso: string): number { + const a = Date.parse(`${aIso}T00:00:00`); + const b = Date.parse(`${bIso}T00:00:00`); + if (Number.isNaN(a) || Number.isNaN(b)) return 0; + return Math.max(0, Math.round((b - a) / 86_400_000)); +} + +interface Projection { + dailyRateMajor: number; + daysToCap: number; + projectedEndMajor: number; + suggestedMajor: number; +} + +/** + * Straight-line cap projection from real data: current spend over elapsed days + * gives a daily run-rate, extrapolated across the period. Returns null unless + * there's a live cap, a known rate, at least a day elapsed, and the trajectory + * actually overshoots β€” i.e. only when there's something to warn about. + */ +function projectOverspend(wallet: Wallet): Projection | null { + if (wallet.noCap || wallet.capUsd == null) return null; + if (wallet.estimatedBillMinor == null || wallet.estimatedBillMinor <= 0) { + return null; + } + const totalDays = daysBetween( + wallet.billingPeriodStart, + wallet.billingPeriodEnd, + ); + if (totalDays <= 0) return null; + const todayIso = new Date().toISOString().slice(0, 10); + const rawElapsed = daysBetween(wallet.billingPeriodStart, todayIso); + // Need at least a full day of data to extrapolate. On day 0 (or clock skew / a + // UTC-vs-local boundary) rawElapsed is <= 0; projecting then would treat the whole + // period's spend as one day's run-rate and falsely "project to exceed". + if (rawElapsed < 1) return null; + const elapsed = Math.min(totalDays, rawElapsed); + + const spentMajor = wallet.estimatedBillMinor / 100; + const dailyRateMajor = spentMajor / elapsed; + const projectedEndMajor = dailyRateMajor * totalDays; + if (projectedEndMajor <= wallet.capUsd) return null; + + const daysToCap = Math.max( + 1, + Math.ceil((wallet.capUsd - spentMajor) / dailyRateMajor), + ); + const suggestedMajor = Math.ceil((projectedEndMajor * 1.15) / 1000) * 1000; + return { dailyRateMajor, daysToCap, projectedEndMajor, suggestedMajor }; +} + +function persistedCapOf(wallet: Wallet): number | null { + return wallet.noCap ? null : (wallet.capUsd ?? null); +} + +/** + * The cap surface (right card of the spend row). Two in-place modes β€” display + * (flat spend-vs-cap meter via the shared {@link MeterBar}, % used, projection) + * and edit (the shared bucket {@link SharedSpendCapControl} + suggested-value + * shortcut + guardrail note + Cancel/Save) β€” swapping in place rather than + * revealing a second card. Leader-only edit; members see display only. + */ +export function SpendLimitCard({ + wallet, + onWalletChange, + adjusting, + onAdjustingChange, +}: Props) { + const { t } = useTranslation(); + const isLeader = wallet.role === "leader"; + const symbol = currencySymbol(wallet.currency); + const persistedCap = persistedCapOf(wallet); + const proj = projectOverspend(wallet); + + const [draftCap, setDraftCap] = useState(persistedCap); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + // Reseed the draft whenever the editor opens or the persisted value changes. + useEffect(() => { + if (adjusting) { + setDraftCap(persistedCap); + setError(null); + } + }, [adjusting, persistedCap]); + + async function save() { + setSaving(true); + setError(null); + try { + await updateCap(draftCap); + onAdjustingChange(false); + onWalletChange?.(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setSaving(false); + } + } + + // ── Edit mode ─────────────────────────────────────────────────────────── + if (isLeader && adjusting) { + return ( + + + {t("billing.spendLimit.eyebrow")} + +

+ {t("billing.spendLimit.editTitle")} +

+ + + + {proj && draftCap !== proj.suggestedMajor && ( + + )} + +
+ {t("billing.spendLimit.guardrailLabel")}{" "} + {t("billing.spendLimit.guardrailBody")} +
+ + {error && ( + + {error} + + )} + +
+ + +
+
+ ); + } + + // ── Display mode ──────────────────────────────────────────────────────── + const spentMinor = wallet.estimatedBillMinor ?? 0; + const cap = wallet.capUsd ?? 0; + const capActive = !wallet.noCap && wallet.capUsd != null; + const { state, pct } = meterState(spentMinor / 100, cap); + const remainingMinor = Math.max(0, Math.round(cap * 100) - spentMinor); + const docEstimate = docCapForMoney(wallet.capUsd, wallet.pricePerDocMinor); + const spentLabel = formatMinor(spentMinor, wallet.currency); + + return ( + +
+
+ + {t("billing.spendLimit.eyebrow")} + +

+ {t("billing.spendLimit.displaySub")} +

+
+ {isLeader && ( + + )} +
+ +
+ + + {t("billing.spendLimit.usedThisMonth", { + amount: spentLabel, + })} + + + {t("billing.spendLimit.remaining", { + amount: formatMinor(remainingMinor, wallet.currency), + })} + + + ) : ( + + {t("billing.spendLimit.thisPeriodUncapped", { + amount: spentLabel, + })} + + ) + } + /> +
+ + {proj && ( +

+ {t("billing.spendLimit.projection.label")}{" "} + {t("billing.spendLimit.projection.body", { + count: proj.daysToCap, + rate: `${symbol}${proj.dailyRateMajor.toLocaleString(undefined, { + maximumFractionDigits: 2, + })}`, + monthEnd: formatMoneyMajor( + Math.round(proj.projectedEndMajor), + wallet.currency, + ), + suggested: formatMoneyMajor(proj.suggestedMajor, wallet.currency), + })} +

+ )} +
+ ); +} diff --git a/frontend/portal/src/components/billing/SpendThisMonthCard.stories.tsx b/frontend/portal/src/components/billing/SpendThisMonthCard.stories.tsx new file mode 100644 index 0000000000..a47b3df65b --- /dev/null +++ b/frontend/portal/src/components/billing/SpendThisMonthCard.stories.tsx @@ -0,0 +1,15 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { SpendThisMonthCard } from "@portal/components/billing/SpendThisMonthCard"; +import { subscribedWallet } from "@portal/components/billing/walletFixtures"; +import "@portal/components/billing/billing.css"; + +const meta: Meta = { + title: "Portal/Billing/SpendThisMonthCard", + component: SpendThisMonthCard, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +/** Actual spend + the Enterprise upsell tacked onto the foot. */ +export const Default: Story = { args: { wallet: subscribedWallet } }; diff --git a/frontend/portal/src/components/billing/SpendThisMonthCard.tsx b/frontend/portal/src/components/billing/SpendThisMonthCard.tsx new file mode 100644 index 0000000000..1989224df9 --- /dev/null +++ b/frontend/portal/src/components/billing/SpendThisMonthCard.tsx @@ -0,0 +1,47 @@ +import { useTranslation } from "react-i18next"; +import { Card } from "@shared/components"; +import { formatMinor } from "@shared/billing"; +import type { Wallet } from "@portal/api/billing"; +import { EnterpriseUpsell } from "@portal/components/billing/EnterpriseUpsell"; + +/** + * Actual metered spend this period, with the Enterprise upsell tacked onto the + * foot (matching marketing β€” not a separate full-width row). Flex column so the + * upsell pins to the bottom and the card matches the spend-limit card's height. + */ +export function SpendThisMonthCard({ wallet }: { wallet: Wallet }) { + const { t } = useTranslation(); + const rateLabel = + wallet.pricePerDocMinor != null && wallet.pricePerDocMinor > 0 + ? formatMinor(wallet.pricePerDocMinor, wallet.currency) + : null; + + return ( + + + {t("billing.spendThisMonth.eyebrow")} + +
+ + {formatMinor(wallet.estimatedBillMinor ?? 0, wallet.currency)} + +
+

+ {rateLabel + ? t("billing.spendThisMonth.processedWithRate", { + count: wallet.billableUsed, + formattedCount: wallet.billableUsed.toLocaleString(), + rate: rateLabel, + }) + : t("billing.spendThisMonth.processed", { + count: wallet.billableUsed, + formattedCount: wallet.billableUsed.toLocaleString(), + })} +

+ +
+ +
+
+ ); +} diff --git a/frontend/portal/src/components/billing/StripeCheckoutModal.tsx b/frontend/portal/src/components/billing/StripeCheckoutModal.tsx new file mode 100644 index 0000000000..ac590adc71 --- /dev/null +++ b/frontend/portal/src/components/billing/StripeCheckoutModal.tsx @@ -0,0 +1,152 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Banner, Modal, Skeleton } from "@shared/components"; +import { + EmbeddedCheckout, + EmbeddedCheckoutProvider, +} from "@stripe/react-stripe-js"; +import type { Stripe } from "@stripe/stripe-js"; +import { + createCheckoutSession, + getStripePublishableKey, + type SaasCurrency, +} from "@portal/billing/stripe"; + +interface Props { + open: boolean; + onClose: () => void; + /** Caller's resolved team id. The edge function needs it to scope checkout. */ + teamId: number; + /** "usd" | "eur" | "gbp" β€” the SaaS PAYG offering's supported set. */ + currency: SaasCurrency; + /** Optional billing email prefill (Stripe locks the field when set). */ + billingOwnerEmail?: string; + /** + * Fired when Stripe (or the mock continue button) signals success. Caller + * refreshes the wallet so the linked-subscribed view takes over. + */ + onComplete: () => void; +} + +/** + * Embedded Stripe Checkout, matching the SaaS web app's PAYG sign-up UX. We + * fetch a {@code client_secret} from the SaaS Supabase edge function then + * mount <EmbeddedCheckoutProvider> inline β€” no full-page redirect, the + * admin stays in the portal. + * + * If the team is already subscribed the edge function short-circuits to a + * Stripe Customer Portal URL; we open it in a new tab and close the modal. + */ +let stripePromise: Promise | null = null; +function loadStripeOnce(pk: string): Promise { + if (stripePromise === null) { + stripePromise = import("@stripe/stripe-js").then((m) => m.loadStripe(pk)); + } + return stripePromise; +} + +export function StripeCheckoutModal({ + open, + onClose, + teamId, + currency, + billingOwnerEmail, + onComplete, +}: Props) { + const { t } = useTranslation(); + const [clientSecret, setClientSecret] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const publishableKey = getStripePublishableKey(); + + // Mint the checkout session whenever the modal opens for a fresh team/currency. + useEffect(() => { + if (!open) { + // Reset on close so re-opening fetches a fresh session. + setClientSecret(null); + setError(null); + setLoading(true); + return; + } + let cancelled = false; + setLoading(true); + setError(null); + createCheckoutSession({ + teamId, + currency, + successUrl: window.location.href, + cancelUrl: window.location.href, + billingOwnerEmail, + }) + .then((session) => { + if (cancelled) return; + if (session.alreadySubscribed && session.redirectUrl) { + // Team's already on PAYG β€” bounce them to the management portal + // instead of mounting a checkout iframe with no secret. + window.open(session.redirectUrl, "_blank", "noopener,noreferrer"); + onClose(); + return; + } + if (!session.clientSecret) { + setError(t("billing.checkout.noClientSecret")); + return; + } + setClientSecret(session.clientSecret); + }) + .catch((e) => { + if (!cancelled) { + setError(e instanceof Error ? e.message : String(e)); + } + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [open, teamId, currency, billingOwnerEmail, onClose, t]); + + const stripe = publishableKey ? loadStripeOnce(publishableKey) : null; + const canRender = Boolean(stripe && clientSecret); + + return ( + + {!publishableKey && ( + + {t("billing.checkout.notConfigured.bodyBefore")}{" "} + VITE_STRIPE_PUBLISHABLE_KEY{" "} + {t("billing.checkout.notConfigured.bodyAfter")} + + )} + {publishableKey && error && ( + + {error} + + )} + {publishableKey && loading && !error && ( +
+ + +
+ )} + {publishableKey && canRender && stripe && clientSecret && ( + + + + )} +
+ ); +} diff --git a/frontend/portal/src/components/billing/SubscribedPlanView.stories.tsx b/frontend/portal/src/components/billing/SubscribedPlanView.stories.tsx new file mode 100644 index 0000000000..4083c687f0 --- /dev/null +++ b/frontend/portal/src/components/billing/SubscribedPlanView.stories.tsx @@ -0,0 +1,85 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { http, HttpResponse } from "msw"; +import { SubscribedPlanView } from "@portal/components/billing/SubscribedPlanView"; +import { subscribedWallet } from "@portal/components/billing/walletFixtures"; +import "@portal/components/billing/billing.css"; + +const card = http.get("*/api/v1/payg/payment-method", () => + HttpResponse.json({ + present: true, + brand: "visa", + last4: "4242", + expMonth: 8, + expYear: 2027, + }), +); + +const invoices = http.get("*/api/v1/payg/invoices", () => + HttpResponse.json([ + { + id: "in_6", + number: "INV-2026-006", + status: "open", + totalMinor: 714235, + currency: "usd", + createdAt: "2026-06-01T00:00:00Z", + periodStart: "2026-06-01T00:00:00Z", + periodEnd: "2026-06-30T00:00:00Z", + hostedInvoiceUrl: "https://invoice.stripe.com/i/test_6", + invoicePdf: "https://invoice.stripe.com/i/test_6/pdf", + description: "Stirling Processor Plan", + pdfsProcessed: 142847, + }, + { + id: "in_5", + number: "INV-2026-005", + status: "paid", + totalMinor: 691085, + currency: "usd", + createdAt: "2026-05-01T00:00:00Z", + periodStart: "2026-05-01T00:00:00Z", + periodEnd: "2026-05-31T00:00:00Z", + hostedInvoiceUrl: "https://invoice.stripe.com/i/test_5", + invoicePdf: "https://invoice.stripe.com/i/test_5/pdf", + description: "Stirling Processor Plan", + pdfsProcessed: 138217, + }, + { + id: "in_4", + number: "INV-2026-004", + status: "paid", + totalMinor: 650515, + currency: "usd", + createdAt: "2026-04-01T00:00:00Z", + periodStart: "2026-04-01T00:00:00Z", + periodEnd: "2026-04-30T00:00:00Z", + hostedInvoiceUrl: "https://invoice.stripe.com/i/test_4", + invoicePdf: "https://invoice.stripe.com/i/test_4/pdf", + description: "Stirling Processor Plan", + pdfsProcessed: 130103, + }, + ]), +); + +const meta: Meta = { + title: "Portal/Billing/SubscribedPlanView", + component: SubscribedPlanView, + parameters: { layout: "padded", msw: { handlers: [card, invoices] } }, +}; +export default meta; +type Story = StoryObj; + +/** The full Processor-plan dashboard β€” leader, within cap. */ +export const Leader: Story = { args: { wallet: subscribedWallet } }; + +/** Approaching the cap β€” surfaces the over-cap warning banner + projection. */ +export const ApproachingCap: Story = { + args: { + wallet: { + ...subscribedWallet, + estimatedBillMinor: 85_000, + billableUsed: 42_500, + spendUnitsThisPeriod: 42_500, + }, + }, +}; diff --git a/frontend/portal/src/components/billing/SubscribedPlanView.tsx b/frontend/portal/src/components/billing/SubscribedPlanView.tsx new file mode 100644 index 0000000000..6d9d6bd6f9 --- /dev/null +++ b/frontend/portal/src/components/billing/SubscribedPlanView.tsx @@ -0,0 +1,106 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Banner, Button } from "@shared/components"; +import { meterState } from "@shared/billing"; +import type { Wallet } from "@portal/api/billing"; +import { useStripePortal } from "@portal/hooks/useStripePortal"; +import { FreePdfEditorsCard } from "@portal/components/billing/FreePdfEditorsCard"; +import { PdfsProcessedCard } from "@portal/components/billing/PdfsProcessedCard"; +import { SpendThisMonthCard } from "@portal/components/billing/SpendThisMonthCard"; +import { SpendLimitCard } from "@portal/components/billing/SpendLimitCard"; +import { PaymentMethodCard } from "@portal/components/billing/PaymentMethodCard"; +import { InvoicesList } from "@portal/components/billing/InvoicesList"; + +interface Props { + wallet: Wallet; + onWalletChange?: () => void; +} + +/** + * Linked + subscribed β€” the full Processor-plan dashboard, matching the + * marketing layout and reusing the free view's building blocks: + * - team editor fleet ({@link FreePdfEditorsCard}, shared with the free view) + * - PDFs processed + category split ({@link PdfsProcessedCard}) + * - spend-vs-cap meter, projection, and the leader-only cap editor + * ({@link SpendLimitCard} β†’ shared {@code SpendCapControl}) + * - Enterprise upsell ({@link EnterpriseUpsell}, shared with the free view) + * - per-member usage, Stripe invoices, and the default payment method + * + * Card / subscription management lives in Stripe's hosted portal β€” both the + * page-header "Manage Payment" action and the payment card's "Update" button + * deep-link there via {@link useStripePortal}. + */ +export function SubscribedPlanView({ wallet, onWalletChange }: Props) { + const { t } = useTranslation(); + const [adjusting, setAdjusting] = useState(false); + const portal = useStripePortal(wallet); + + const isLeader = wallet.role === "leader"; + const spent = + wallet.estimatedBillMinor != null ? wallet.estimatedBillMinor / 100 : 0; + const capActive = !wallet.noCap && wallet.capUsd != null; + const { state, pct } = meterState(spent, wallet.capUsd ?? 0); + const showCapWarn = capActive && state !== "FULL"; + + function raiseLimit() { + setAdjusting(true); + document + .getElementById("portal-spend-limit") + ?.scrollIntoView({ behavior: "smooth", block: "start" }); + } + + return ( +
+ {showCapWarn && ( + + {t("billing.subscribedPlan.capWarn.raiseLimit")} + + ) : undefined + } + > + {state === "DEGRADED" + ? t("billing.subscribedPlan.capWarn.reachedBody") + : t("billing.subscribedPlan.capWarn.approachingBody")} + + )} + + + + + +
+ + +
+ + + + + + {portal.error && ( + + {portal.error} + + )} +
+ ); +} diff --git a/frontend/portal/src/components/billing/WalletMeter.stories.tsx b/frontend/portal/src/components/billing/WalletMeter.stories.tsx new file mode 100644 index 0000000000..b43bcf8a07 --- /dev/null +++ b/frontend/portal/src/components/billing/WalletMeter.stories.tsx @@ -0,0 +1,28 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { WalletMeter } from "@portal/components/billing/WalletMeter"; +import { freeWallet } from "@portal/components/billing/walletFixtures"; +import "@portal/components/billing/billing.css"; + +const meta: Meta = { + title: "Portal/Billing/WalletMeter", + component: WalletMeter, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +/** Free grant, plenty left (< 80% used) β†’ FULL band. */ +export const FreePlentyLeft: Story = { + // The band keys off used/allowance β€” 120/500 = 24% β†’ FULL. + args: { wallet: { ...freeWallet, billableUsed: 120, freeRemaining: 380 } }, +}; + +/** Free grant approaching the limit (β‰₯ 80%) β†’ WARNED band. */ +export const FreeApproachingLimit: Story = { + args: { wallet: { ...freeWallet, billableUsed: 440, freeRemaining: 60 } }, +}; + +/** Free grant exhausted β†’ DEGRADED band. */ +export const FreeLimitReached: Story = { + args: { wallet: { ...freeWallet, billableUsed: 500, freeRemaining: 0 } }, +}; diff --git a/frontend/portal/src/components/billing/WalletMeter.tsx b/frontend/portal/src/components/billing/WalletMeter.tsx new file mode 100644 index 0000000000..506faffb8b --- /dev/null +++ b/frontend/portal/src/components/billing/WalletMeter.tsx @@ -0,0 +1,70 @@ +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { Card } from "@shared/components"; +import { formatMinor, MeterBar, meterState } from "@shared/billing"; +import type { Wallet } from "@portal/api/billing"; + +interface Props { + /** A linked-free wallet. */ + wallet: Wallet; + /** Optional top-right action (e.g. "Switch on the Processor"). */ + action?: ReactNode; +} + +/** + * The free Processor-trial meter β€” "X / N free PDFs used" against the one-time + * grant. Uses the shared {@link MeterBar} (same `paygf-meter` structure as the + * cloud plan page). The subscribed spend-vs-cap meter is a separate surface + * ({@code SpendLimitCard}); this card is only the free face. + */ +export function WalletMeter({ wallet, action }: Props) { + const { t } = useTranslation(); + const { state, pct } = meterState(wallet.billableUsed, wallet.freeAllowance); + const rate = + wallet.pricePerDocMinor != null && wallet.pricePerDocMinor > 0 + ? wallet.pricePerDocMinor + : null; + const title = + rate != null + ? t("billing.walletMeter.titleWithRate", { + count: wallet.freeAllowance, + allowance: wallet.freeAllowance.toLocaleString(), + rate: formatMinor(rate, wallet.currency), + }) + : t("billing.walletMeter.title", { + count: wallet.freeAllowance, + allowance: wallet.freeAllowance.toLocaleString(), + }); + + return ( + +
+
+ + {t("billing.walletMeter.eyebrow")} + +

{title}

+

+ {t("billing.walletMeter.sub")} +

+
+ {action} +
+
+ +
+
+ ); +} diff --git a/frontend/portal/src/components/billing/billing.css b/frontend/portal/src/components/billing/billing.css new file mode 100644 index 0000000000..ee1f593833 --- /dev/null +++ b/frontend/portal/src/components/billing/billing.css @@ -0,0 +1,999 @@ +/* Portal billing page β€” paired with views/Usage.tsx (.portal-billing root). */ + +.portal-billing__stack { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +/* Spend this month | Spend limit β€” two separate cards, side by side. */ +.portal-billing__spend-row { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr); + gap: 0.75rem; + align-items: stretch; +} +@media (max-width: 60rem) { + .portal-billing__spend-row { + grid-template-columns: 1fr; + } +} + +/* Left card is a flex column so the Enterprise upsell pins to the foot and the + card matches the spend-limit card's height. */ +.portal-billing__spend-this-month { + display: flex; + flex-direction: column; +} +.portal-billing__spend-foot { + margin-top: auto; + padding-top: 1rem; + border-top: 1px solid var(--color-border); +} +.portal-billing__spend-foot:not(:first-child) { + margin-top: 1.25rem; +} + +/* Cap editor (in-place, replaces the spend-limit display). */ +.portal-billing__suggested { + display: inline-flex; + align-items: center; + margin-top: 0.75rem; + padding: 0.3rem 0.7rem; + font-size: 0.8125rem; + font-weight: 500; + color: var(--color-blue); + background: var(--color-bg-subtle); + border: 1px solid var(--color-border); + border-radius: 999px; + cursor: pointer; +} +.portal-billing__suggested:hover { + border-color: var(--color-blue); +} +.portal-billing__guardrail { + margin-top: 0.85rem; + padding: 0.7rem 0.85rem; + font-size: 0.8125rem; + color: var(--color-text-3); + background: var(--color-bg-subtle); + border-radius: 0.6rem; +} +.portal-billing__guardrail strong { + color: var(--color-text-1); +} +.portal-billing__edit-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + margin-top: 1rem; +} +/* Tighten the billing cards to the marketing rhythm β€” less empty top/bottom and + slightly snugger sides than the default loose profile. */ +.portal-billing__stack .sui-card--pad-loose { + padding: 0.95rem 1.5rem; +} + +.portal-billing__row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.25rem; +} + +@media (max-width: 900px) { + .portal-billing__row { + grid-template-columns: 1fr; + } +} + +.portal-billing__row-actions { + display: flex; + align-items: center; + gap: 0.75rem; + margin-top: 1rem; +} + +.portal-billing__eyebrow { + display: inline-block; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--color-text-3); + margin-bottom: 0.25rem; +} + +.portal-billing__section-title { + font-size: 1.05rem; + font-weight: 600; + color: var(--color-text-1); + margin: 0 0 0.25rem; +} + +.portal-billing__section-sub { + font-size: 0.875rem; + color: var(--color-text-3); + margin: 0 0 1rem; +} + +.portal-billing__skeleton { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.portal-billing__error { + color: var(--color-red, #b91c1c); + font-size: 0.875rem; + margin: 0.5rem 0; +} + +/* ── Meter card ─────────────────────────────────────────────────────────── */ +.portal-billing__meter-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 1rem; +} + +.portal-billing__meter-title { + font-size: 1.25rem; + font-weight: 600; + margin: 0; + color: var(--color-text-1); +} + +.portal-billing__meter-figures { + display: flex; + flex-wrap: wrap; + gap: 1.5rem; + margin-bottom: 1rem; +} + +.portal-billing__meter-figure { + display: flex; + flex-direction: column; +} + +.portal-billing__meter-num { + font-size: 1.75rem; + font-weight: 600; + color: var(--color-text-1); +} + +.portal-billing__meter-num--muted { + color: var(--color-text-3); +} + +.portal-billing__meter-label { + font-size: 0.8125rem; + color: var(--color-text-3); +} + +.portal-billing__meter-track { + height: 0.5rem; + background: var(--color-bg-subtle, #e5e7eb); + border-radius: 999px; + overflow: hidden; + margin-bottom: 0.75rem; +} + +.portal-billing__meter-fill { + height: 100%; + background: linear-gradient( + 90deg, + var(--color-blue, #0a8bff), + var(--color-purple, #8b5cf6) + ); + border-radius: 999px; + transition: width 200ms ease; +} + +.portal-billing__meter-foot { + font-size: 0.8125rem; + color: var(--color-text-3); + margin: 0.75rem 0 0; +} + +/* ── SaaS-shared meter primitives ─────────────────────────────────────────── + Same class names + structure as + editor/src/cloud/components/shared/config/configSections/{Payg,PaygFree}.css + so the visual treatment matches the SaaS plan page. When the shared move + lands these can be deduped via a shared stylesheet. */ +/* Flat by default β€” the meter sits directly on its card, no inner panel box + (shared by the free trial meter and the subscribed spend meter). */ +.paygf-meter { + margin-top: 0.6rem; +} +.paygf-meter__top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} +.paygf-meter__figure { + display: flex; + align-items: baseline; + gap: 7px; +} +.paygf-meter__num { + font-size: 1.7rem; + font-weight: 750; + line-height: 1; + letter-spacing: -0.02em; + color: var(--color-text-1, #0f172a); + font-variant-numeric: tabular-nums; +} +.paygf-meter__cap { + font-size: 0.85rem; + color: var(--color-text-3, #64748b); + font-variant-numeric: tabular-nums; +} +.paygf-meter .payg-bar { + margin-top: 11px; +} +.paygf-meter__meta { + margin-top: 10px; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 10px; + font-size: 0.78rem; + color: var(--color-text-2, #475569); +} + +/* Status chip (Healthy / Approaching / Cap reached). Solid hex colours + intentionally β€” the chip palette is semantic, not theme-bound. */ +.payg-status { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 6px 13px; + border-radius: 999px; + font-size: 0.8125rem; + font-weight: 600; + white-space: nowrap; +} +.payg-status__dot { + width: 8px; + height: 8px; + border-radius: 999px; +} +.payg-status[data-state="FULL"] { + background: #dcfce7; + color: #15803d; +} +.payg-status[data-state="FULL"] .payg-status__dot { + background: #22c55e; + box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.18); +} +.payg-status[data-state="WARNED"] { + background: #fef3c7; + color: #a16207; +} +.payg-status[data-state="WARNED"] .payg-status__dot { + background: #eab308; + box-shadow: 0 0 0 3px rgba(234, 179, 8, 0.2); +} +.payg-status[data-state="DEGRADED"] { + background: #fee2e2; + color: #b91c1c; +} +.payg-status[data-state="DEGRADED"] .payg-status__dot { + background: #ef4444; + box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.2); +} + +/* Segmented usage bar */ +.payg-bar { + margin-top: 18px; + height: 10px; + border-radius: 999px; + background: var(--color-bg-subtle, #e5e7eb); + overflow: hidden; + position: relative; +} +.payg-bar__fill { + height: 100%; + border-radius: 999px; + transition: width 0.5s cubic-bezier(0.16, 1, 0.3, 1); +} +.payg-bar__fill[data-state="FULL"] { + background: linear-gradient(90deg, #0a8bff, #38bdf8); +} +.payg-bar__fill[data-state="WARNED"] { + background: linear-gradient(90deg, #f59e0b, #fbbf24); +} +.payg-bar__fill[data-state="DEGRADED"] { + background: linear-gradient(90deg, #dc2626, #f87171); +} + +/* ── Plan card (free state) ─────────────────────────────────────────────── */ +.portal-billing__plan-card { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.portal-billing__plan-title { + font-size: 1.25rem; + font-weight: 600; + margin: 0; + color: var(--color-text-1); +} + +.portal-billing__plan-sub { + font-size: 0.9375rem; + color: var(--color-text-2); + margin: 0 0 0.5rem; +} + +.portal-billing__plan-features { + list-style: none; + padding: 0; + margin: 0 0 1rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.portal-billing__plan-features li { + font-size: 0.875rem; + color: var(--color-text-2); + padding-left: 1.25rem; + position: relative; +} + +.portal-billing__plan-features li::before { + content: "βœ“"; + color: var(--color-green, #10b981); + position: absolute; + left: 0; + font-weight: 600; +} + +.portal-billing__plan-actions { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; +} + +.portal-billing__plan-reassure { + font-size: 0.75rem; + color: var(--color-text-3); +} + +.portal-billing__plan-readonly { + font-size: 0.875rem; + color: var(--color-text-3); + font-style: italic; + margin: 0; +} + +/* ── Category breakdown ─────────────────────────────────────────────────── */ +.portal-billing__breakdown { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.portal-billing__breakdown-row { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.portal-billing__breakdown-head { + display: flex; + justify-content: space-between; + font-size: 0.8125rem; + color: var(--color-text-2); +} + +.portal-billing__breakdown-value { + color: var(--color-text-3); +} + +.portal-billing__breakdown-track { + height: 0.5rem; + background: var(--color-bg-subtle, #e5e7eb); + border-radius: 999px; + overflow: hidden; +} + +.portal-billing__breakdown-fill { + height: 100%; + border-radius: 999px; + transition: width 200ms ease; +} + +.portal-billing__breakdown-fill--blue { + background: #0a8bff; +} + +.portal-billing__breakdown-fill--purple { + background: #8b5cf6; +} + +.portal-billing__breakdown-fill--teal { + background: #06b6d4; +} + +/* ── Cap control ────────────────────────────────────────────────────────── */ +.portal-billing__cap-row { + display: flex; + align-items: flex-end; + gap: 1rem; + flex-wrap: wrap; + margin-bottom: 0.75rem; +} + +.portal-billing__cap-field { + display: flex; + flex-direction: column; + gap: 0.25rem; + font-size: 0.8125rem; + color: var(--color-text-3); +} + +.portal-billing__cap-input { + width: 8rem; + padding: 0.5rem 0.75rem; + border: 1px solid var(--color-border, #d1d5db); + border-radius: 0.375rem; + font-size: 0.9375rem; + font-family: inherit; +} + +.portal-billing__cap-nocap { + display: flex; + align-items: center; + gap: 0.375rem; + font-size: 0.875rem; + color: var(--color-text-2); + cursor: pointer; +} + +.portal-billing__cap-actions { + display: flex; + justify-content: flex-end; +} + +/* ── Members + invoices ─────────────────────────────────────────────────── */ +.portal-billing__member-stack { + display: flex; + flex-direction: column; +} + +.portal-billing__member-name { + font-weight: 600; + color: var(--color-text-1); +} + +.portal-billing__member-email { + font-size: 0.8125rem; + color: var(--color-text-3); +} + +.portal-billing__invoice-num { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.875rem; +} + +.portal-billing__invoice-desc { + color: var(--color-text-1, #0f172a); + font-size: 0.9375rem; +} + +/* Tables that sit flush inside an already-surfaced Card: drop the table's + own border + background + radius so only the row dividers (kept by sui-table + defaults) show. Column headers are hidden too β€” small self-evident tables + read better without them. Used by the Invoice history + Per-member usage + tables. */ +.portal-billing__flush-table { + border: none; + background: transparent; + border-radius: 0; +} +.portal-billing__flush-table thead { + display: none; +} + +.portal-billing__subscription-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.portal-billing__subscription-head > div:first-child { + flex: 1 1 auto; + min-width: 0; +} + +.portal-billing__subscription-head + > div:first-child + .portal-billing__section-sub { + margin-bottom: 0; +} + +/* ── Spend-cap control β€” ported from the SaaS cloud SpendCapControl.css. + Same class names + structure so the visual is identical to the SaaS Plan + page; when the shared-component move lands, these dedupe to one stylesheet. */ +.scc { + --scc-accent: #0a8bff; + --scc-accent-text: #0a8bff; + --scc-accent-soft: rgba(10, 139, 255, 0.12); + --scc-accent-border: rgba(10, 139, 255, 0.25); + --scc-chip-bg: var(--color-bg-muted, #f8fafc); + --scc-chip-border: var(--color-border, #e2e8f0); + --scc-text-primary: var(--color-text-1, #0f172a); + --scc-text-secondary: var(--color-text-2, #475569); + --scc-text-muted: var(--color-text-3, #64748b); + --scc-border-strong: var(--color-text-4, #94a3b8); + display: flex; + flex-direction: column; + gap: 14px; + margin-top: 0.75rem; +} +[data-mantine-color-scheme="dark"] .scc { + --scc-accent-text: #66b8ff; + --scc-accent-soft: rgba(10, 139, 255, 0.16); + --scc-chip-bg: #272d35; + --scc-chip-border: #3d444e; +} + +.scc-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.scc-chip { + display: inline-flex; + align-items: center; + height: 34px; + padding: 0 16px; + border-radius: 999px; + border: 1px solid var(--scc-chip-border); + background: var(--scc-chip-bg); + color: var(--scc-text-secondary); + font: inherit; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + font-variant-numeric: tabular-nums; + transition: + color 0.12s ease, + border-color 0.12s ease, + background 0.12s ease; +} +.scc-chip:hover:not(:disabled) { + color: var(--scc-text-primary); + border-color: var(--scc-border-strong); +} +.scc-chip[data-selected="true"] { + background: var(--scc-accent-soft); + border-color: var(--scc-accent); + color: var(--scc-accent-text); +} +.scc-chip:disabled { + opacity: 0.45; + cursor: default; +} + +.scc-custom { + display: inline-flex; + align-items: center; + gap: 1px; + height: 34px; + padding: 0 14px; + border-radius: 999px; + border: 1px dashed var(--scc-chip-border); + background: transparent; + cursor: text; + transition: + border-color 0.12s ease, + background 0.12s ease; +} +.scc-custom:hover { + border-color: var(--scc-border-strong); +} +.scc-custom[data-active="true"] { + border-style: solid; + border-color: var(--scc-accent); + background: var(--scc-accent-soft); +} +.scc-custom__symbol { + color: var(--scc-text-muted); + font-size: 0.85rem; + font-weight: 600; +} +.scc-custom[data-active="true"] .scc-custom__symbol, +.scc-custom[data-active="true"] .scc-custom__input { + color: var(--scc-accent-text); +} +.scc-custom__input { + width: 70px; + border: none; + outline: none; + background: transparent; + color: var(--scc-text-primary); + font: inherit; + font-size: 0.85rem; + font-weight: 600; + font-variant-numeric: tabular-nums; +} +.scc-custom__input::placeholder { + color: var(--scc-text-muted); + font-weight: 600; +} + +.scc-row__spacer { + margin-left: auto; +} + +.scc-estimate { + display: flex; + align-items: center; + gap: 11px; + padding: 12px 14px; + border-radius: 10px; + background: var(--scc-accent-soft); + border: 1px solid var(--scc-accent-border); +} +.scc-estimate__icon { + color: var(--scc-accent-text); + display: flex; +} +.scc-estimate__main { + font-size: 0.875rem; + font-weight: 550; + color: var(--scc-text-primary); +} +.scc-estimate__sub { + font-size: 0.75rem; + color: var(--scc-text-muted); + margin-top: 1px; +} + +.scc-note { + font-size: 0.8125rem; + color: var(--scc-text-muted); + line-height: 1.45; +} + +/* ── Plan header (free-vs-metered split) β€” ported from SaaS payg-planhead ── */ +.portal-billing__planhead-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 14px; +} +.portal-billing__planhead-eyebrow { + font-size: 0.78rem; + color: var(--color-text-3); +} +.portal-billing__role-pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 12px; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 600; + white-space: nowrap; +} +.portal-billing__role-pill[data-leader="true"] { + background: rgba(10, 139, 255, 0.12); + color: #0a8bff; + border: 1px solid rgba(10, 139, 255, 0.25); +} +.portal-billing__role-pill[data-leader="false"] { + background: var(--color-bg-muted); + color: var(--color-text-3); + border: 1px solid var(--color-border); +} +.portal-billing__planhead-split { + display: grid; + grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr); +} +.portal-billing__planhead-col { + padding-right: 22px; +} +.portal-billing__planhead-col--meter { + padding-right: 0; + padding-left: 22px; + border-left: 1px solid var(--color-border); +} +.portal-billing__planhead-lbl { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + margin-bottom: 8px; +} +.portal-billing__planhead-lbl--free { + color: #10b981; +} +.portal-billing__planhead-lbl--meter { + color: #0a8bff; +} +.portal-billing__planhead-title { + margin: 0; + font-size: 1.05rem; + font-weight: 700; + color: var(--color-text-1); + letter-spacing: -0.01em; + line-height: 1.25; +} +.portal-billing__planhead-body { + margin: 5px 0 0; + font-size: 0.85rem; + color: var(--color-text-3); + line-height: 1.5; +} +/* The period meter merged into the plan-head card, divided from the split. */ +.portal-billing__planhead-meter { + margin-top: 18px; + padding-top: 18px; + border-top: 1px solid var(--color-border); +} +@media (max-width: 640px) { + .portal-billing__planhead-split { + grid-template-columns: 1fr; + gap: 16px; + } + .portal-billing__planhead-col { + padding-right: 0; + } + .portal-billing__planhead-col--meter { + padding-left: 0; + padding-top: 16px; + border-left: none; + border-top: 1px solid var(--color-border); + } +} + +.portal-billing__invoice-actions { + display: inline-flex; + align-items: center; + gap: 0.5rem; + justify-content: flex-end; +} + +.portal-billing__invoice-link { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.5rem; + border-radius: 0.375rem; + font-size: 0.8125rem; + font-weight: 500; + color: var(--color-text-1, #0f172a); + text-decoration: none; + border: 1px solid transparent; + transition: + background 120ms ease, + border-color 120ms ease; +} + +.portal-billing__invoice-link:hover { + background: var(--color-bg-muted, #f1f5f9); + border-color: var(--color-border, #e2e8f0); +} + +.portal-billing__invoice-link:focus-visible { + outline: none; + border-color: var(--color-blue, #0a8bff); + background: var(--color-bg-muted, #f1f5f9); +} + +.portal-billing__invoice-footer { + display: flex; + justify-content: center; + margin-top: 0.75rem; +} + +/* ── Current plan header ──────────────────────────────────────────────── */ +.portal-billing__current-plan { + display: flex; + flex-direction: column; + gap: 0.35rem; +} +.portal-billing__current-plan-row { + display: flex; + align-items: center; + gap: 0.55rem; + flex-wrap: wrap; +} +.portal-billing__current-plan-name { + margin: 0; + font-size: 1.5rem; + font-weight: 700; + color: var(--color-text-1); +} + +/* ── Free PDF Editors (fleet) card ───────────────────────────────────── */ +.portal-billing__editors-id { + display: flex; + align-items: flex-start; + gap: 0.75rem; +} +.portal-billing__editors-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.5rem; + height: 2.5rem; + flex-shrink: 0; + line-height: 0; + border-radius: 0.65rem; + background: var(--color-bg-subtle); + border: 1px solid var(--color-border); + color: var(--color-blue); +} +/* Identity, stats, and the "Invite teammates" action all sit on one line; the + metric cells render flat (no per-stat box) and are divided by hairlines, so + the card reads as a single strip rather than a box-in-a-box. */ +.portal-billing__fleet-row { + display: flex; + align-items: center; + gap: 2rem; +} +.portal-billing__fleet-row .portal-billing__editors-id { + align-items: center; + flex: 0 0 auto; +} +.portal-billing__fleet-metrics { + flex: 1; +} +.portal-billing__fleet-metrics .sui-metric { + background: transparent; + border: 0; + box-shadow: none; + border-left: 1px solid var(--color-border); + padding: 0 0 0 1.5rem; + min-width: 0; + gap: 0.3rem; +} +/* Uppercase stat labels to match the marketing treatment. */ +.portal-billing__fleet-metrics .sui-metric__label { + text-transform: uppercase; + letter-spacing: 0.05em; +} +@media (max-width: 60rem) { + .portal-billing__fleet-row { + flex-wrap: wrap; + gap: 1.25rem; + } + .portal-billing__fleet-metrics { + flex: 1 1 100%; + order: 3; + } +} + +/* "N left" reads as quiet gray text, like the "x of y" suffix β€” not a status pill. */ +.portal-billing__trial-meter .payg-status, +.portal-billing__spend-meter .payg-status { + background: none; + padding: 0; + border-radius: 0; + font-size: 0.85rem; + font-weight: 400; + color: var(--color-text-3, #64748b); +} +.portal-billing__trial-meter .payg-status__dot, +.portal-billing__spend-meter .payg-status__dot { + display: none; +} +/* Spend meter: anchor the "remaining" half of the meta line to the right edge. */ +.portal-billing__spend-meter .paygf-meter__meta { + justify-content: space-between; +} + +/* ── Enterprise / volume-discount card ───────────────────────────────── */ +.portal-billing__enterprise-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; +} +/* Bare variant β€” embeds in another card's column without its own surface. */ +.portal-billing__enterprise-bare { + display: block; +} + +/* ── PDFs processed + category split (subscribed) ────────────────────── */ +.portal-billing__bignum-row { + display: flex; + align-items: baseline; + gap: 0.5rem; + margin: 0.35rem 0 0.85rem; +} +.portal-billing__bignum { + font-size: 2rem; + font-weight: 750; + line-height: 1; + letter-spacing: -0.02em; + color: var(--color-text-1); + font-variant-numeric: tabular-nums; +} +.portal-billing__bignum-unit { + font-size: 0.85rem; + color: var(--color-text-3); +} +.portal-billing__segbar { + display: flex; + height: 0.55rem; + border-radius: 999px; + overflow: hidden; + background: var(--color-bg-muted, #f1f5f9); +} +.portal-billing__segbar-seg { + height: 100%; +} +.portal-billing__segbar-seg--blue { + background: #0a8bff; +} +.portal-billing__segbar-seg--purple { + background: #8b5cf6; +} +.portal-billing__segbar-seg--teal { + background: #06b6d4; +} +.portal-billing__seglegend { + display: flex; + flex-wrap: wrap; + gap: 0.6rem 1.75rem; + margin-top: 0.85rem; +} +.portal-billing__seglegend-row { + display: flex; + align-items: baseline; + gap: 0.5rem; + font-size: 0.85rem; +} +.portal-billing__dot { + width: 0.55rem; + height: 0.55rem; + border-radius: 999px; + align-self: center; + flex-shrink: 0; +} +.portal-billing__dot--blue { + background: #0a8bff; +} +.portal-billing__dot--purple { + background: #8b5cf6; +} +.portal-billing__dot--teal { + background: #06b6d4; +} +.portal-billing__seglegend-label { + font-weight: 600; + color: var(--color-text-1); +} +.portal-billing__seglegend-val { + color: var(--color-text-2); + font-variant-numeric: tabular-nums; +} +.portal-billing__seglegend-desc { + color: var(--color-text-4); +} + +/* ── Spend-limit projection line ─────────────────────────────────────── */ +.portal-billing__projection { + margin: 0.85rem 0 0; + font-size: 0.85rem; + color: var(--color-text-3); +} +.portal-billing__projection strong { + color: var(--color-amber, #d97706); +} diff --git a/frontend/portal/src/components/billing/walletFixtures.ts b/frontend/portal/src/components/billing/walletFixtures.ts new file mode 100644 index 0000000000..c751f1269a --- /dev/null +++ b/frontend/portal/src/components/billing/walletFixtures.ts @@ -0,0 +1,60 @@ +import type { Wallet } from "@portal/api/billing"; + +/** Linked, on the one-time free grant β€” leader view. Override per story. */ +export const freeWallet: Wallet = { + teamId: 42, + status: "free", + role: "leader", + billingPeriodStart: "2026-06-01", + billingPeriodEnd: "2026-06-30", + billableUsed: 120, + billableLimit: 500, + freeAllowance: 500, + freeRemaining: 380, + pricePerDocMinor: 2, + currency: "usd", + estimatedBillMinor: null, + capUsd: null, + noCap: false, + stripeSubscriptionId: null, + spendUnitsThisPeriod: 120, + categoryBreakdown: { api: 40, ai: 30, automation: 50 }, + members: [], + recent: [], +}; + +/** Linked + subscribed (Processor plan), capped, leader view with members. */ +export const subscribedWallet: Wallet = { + teamId: 42, + status: "subscribed", + role: "leader", + billingPeriodStart: "2026-06-01", + billingPeriodEnd: "2026-06-30", + billableUsed: 2250, + billableLimit: 50000, + freeAllowance: 500, + freeRemaining: 0, + pricePerDocMinor: 2, + currency: "usd", + estimatedBillMinor: 4500, + capUsd: 1000, + noCap: false, + stripeSubscriptionId: "sub_123", + spendUnitsThisPeriod: 2250, + categoryBreakdown: { api: 900, ai: 600, automation: 750 }, + members: [ + { + userId: "u1", + name: "Ada Lovelace", + email: "ada@acme.test", + spendUnits: 1400, + }, + { + userId: "u2", + name: "Alan Turing", + email: "alan@acme.test", + spendUnits: 850, + }, + ], + recent: [], +}; diff --git a/frontend/portal/src/components/icons.tsx b/frontend/portal/src/components/icons.tsx index 67051cab1f..84d66c9cb3 100644 --- a/frontend/portal/src/components/icons.tsx +++ b/frontend/portal/src/components/icons.tsx @@ -243,3 +243,12 @@ export function AgentBuilderIcon(props: IconProps) { ); } + +export function LinkIcon(props: IconProps) { + return ( + + + + + ); +} diff --git a/frontend/portal/src/components/usage/AvailablePlans.stories.tsx b/frontend/portal/src/components/usage/AvailablePlans.stories.tsx deleted file mode 100644 index 4b172d0ad7..0000000000 --- a/frontend/portal/src/components/usage/AvailablePlans.stories.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { AvailablePlans } from "@portal/components/usage/AvailablePlans"; -import { PLAN_OPTIONS } from "@portal/mocks/usage"; - -const meta: Meta = { - title: "Portal/Usage/AvailablePlans", - component: AvailablePlans, - args: { plans: PLAN_OPTIONS, onSelect: () => {} }, -}; -export default meta; -type Story = StoryObj; - -export const OnFree: Story = { args: { current: "free" } }; - -export const OnPro: Story = { args: { current: "pro" } }; - -export const OnEnterprise: Story = { args: { current: "enterprise" } }; diff --git a/frontend/portal/src/components/usage/AvailablePlans.tsx b/frontend/portal/src/components/usage/AvailablePlans.tsx deleted file mode 100644 index f82285f2ee..0000000000 --- a/frontend/portal/src/components/usage/AvailablePlans.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { useTranslation } from "react-i18next"; -import type { Tier } from "@portal/contexts/TierContext"; -import type { PlanOption } from "@portal/api/usage"; -import { PlanCard } from "@portal/components/usage/PlanCard"; -import "@portal/views/Usage.css"; - -/** The plan-catalogue grid, marking the caller's current tier. */ -export function AvailablePlans({ - plans, - current, - onSelect, -}: { - plans: PlanOption[]; - current: Tier; - onSelect: (plan: PlanOption) => void; -}) { - const { t } = useTranslation(); - return ( -
-
-

- {t("usage.plans.title")} -

-

{t("usage.plans.subtitle")}

-
-
- {plans.map((plan) => ( - onSelect(plan)} - /> - ))} -
-
- ); -} diff --git a/frontend/portal/src/components/usage/BillingHistoryTable.stories.tsx b/frontend/portal/src/components/usage/BillingHistoryTable.stories.tsx deleted file mode 100644 index c5144d62ef..0000000000 --- a/frontend/portal/src/components/usage/BillingHistoryTable.stories.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { http, HttpResponse, delay } from "msw"; -import { BillingHistoryTable } from "@portal/components/usage/BillingHistoryTable"; -import { buildBillingHistory } from "@portal/mocks/usage"; - -const meta: Meta = { - title: "Portal/Usage/BillingHistoryTable", - component: BillingHistoryTable, -}; -export default meta; -type Story = StoryObj; - -// Free: usage tally lines, no charges. -export const Free: Story = { globals: { tier: "free" } }; - -// Pro: platform fee + metered overage rows across cycles. -export const Pro: Story = { globals: { tier: "pro" } }; - -// Enterprise: committed draws plus a goodwill credit (negative amount). -export const Enterprise: Story = { globals: { tier: "enterprise" } }; - -export const Loading: Story = { - parameters: { - msw: { - handlers: [ - http.get("/v1/billing/history", async () => { - await delay("infinite"); - return HttpResponse.json(buildBillingHistory("pro")); - }), - ], - }, - }, -}; - -export const Empty: Story = { - parameters: { - msw: { - handlers: [http.get("/v1/billing/history", () => HttpResponse.json([]))], - }, - }, -}; diff --git a/frontend/portal/src/components/usage/BillingHistoryTable.tsx b/frontend/portal/src/components/usage/BillingHistoryTable.tsx deleted file mode 100644 index eb5ec8a04b..0000000000 --- a/frontend/portal/src/components/usage/BillingHistoryTable.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { - Card, - EmptyState, - Skeleton, - StatusBadge, - Table, - type StatusTone, - type TableColumn, -} from "@shared/components"; -import { useTier } from "@portal/contexts/TierContext"; -import { useAsync, useSectionFlags } from "@portal/hooks/useAsync"; -import { - fetchBillingHistory, - type BillingHistoryRow, - type InvoiceStatus, -} from "@portal/api/usage"; -import { USD, formatBillingDate } from "@portal/components/usage/format"; -import "@portal/views/Usage.css"; - -const STATUS_TONE: Record = { - paid: "success", - due: "warning", - pending: "info", - refunded: "neutral", -}; - -/** Invoice / line-item history for the current and prior billing cycles. */ -export function BillingHistoryTable() { - const { t } = useTranslation(); - const { tier } = useTier(); - - const statusLabel: Record = { - paid: t("usage.history.status.paid"), - due: t("usage.history.status.due"), - pending: t("usage.history.status.pending"), - refunded: t("usage.history.status.refunded"), - }; - const state = useAsync( - () => fetchBillingHistory(tier), - [tier], - ); - const { data: rows } = state; - const { isLoading, isEmpty } = useSectionFlags(state); - - const columns: TableColumn[] = [ - { - key: "date", - header: t("usage.history.columns.date"), - render: (r) => ( - - {formatBillingDate(r.date)} - - ), - width: "9rem", - }, - { - key: "description", - header: t("usage.history.columns.description"), - render: (r) => r.description, - }, - { - key: "docs", - header: t("usage.history.columns.docs"), - align: "right", - render: (r) => (r.docs > 0 ? r.docs.toLocaleString() : "β€”"), - width: "8rem", - }, - { - key: "amount", - header: t("usage.history.columns.amount"), - align: "right", - render: (r) => ( - - {r.amount < 0 - ? `βˆ’${USD.format(Math.abs(r.amount))}` - : USD.format(r.amount)} - - ), - width: "8rem", - }, - { - key: "status", - header: t("usage.history.columns.status"), - align: "right", - render: (r) => ( - - {statusLabel[r.status]} - - ), - width: "8rem", - }, - ]; - - return ( -
-
-

- {t("usage.history.title")} -

-

- {t("usage.history.subtitle")} -

-
- - {isLoading && ( -
- {Array.from({ length: 4 }).map((_, i) => ( - - ))} -
- )} - - {isEmpty && ( - - )} - - {rows && rows.length > 0 && ( - -
r.id} - empty={t("usage.history.emptyRows")} - /> - - )} - - ); -} diff --git a/frontend/portal/src/components/usage/BillingKpiStrip.stories.tsx b/frontend/portal/src/components/usage/BillingKpiStrip.stories.tsx deleted file mode 100644 index 84f50d95aa..0000000000 --- a/frontend/portal/src/components/usage/BillingKpiStrip.stories.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { BillingKpiStrip } from "@portal/components/usage/BillingKpiStrip"; -import { buildBillingSummary } from "@portal/mocks/usage"; - -const meta: Meta = { - title: "Portal/Usage/BillingKpiStrip", - component: BillingKpiStrip, -}; -export default meta; -type Story = StoryObj; - -// The "remaining in plan" KPI replaces overage on free. -export const Free: Story = { - args: { summary: buildBillingSummary("free") }, - globals: { tier: "free" }, -}; - -// Pro surfaces metered overage cost + docs past cap. -export const Pro: Story = { - args: { summary: buildBillingSummary("pro") }, - globals: { tier: "pro" }, -}; - -// Enterprise swaps overage for commit utilisation. -export const Enterprise: Story = { - args: { summary: buildBillingSummary("enterprise") }, - globals: { tier: "enterprise" }, -}; - -// Summary still loading β€” every metric falls back to an em dash. -export const Loading: Story = { - args: { summary: null }, - globals: { tier: "pro" }, -}; diff --git a/frontend/portal/src/components/usage/BillingKpiStrip.tsx b/frontend/portal/src/components/usage/BillingKpiStrip.tsx deleted file mode 100644 index 9ed9e6024a..0000000000 --- a/frontend/portal/src/components/usage/BillingKpiStrip.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { MetricCard, MetricStrip } from "@shared/components"; -import { useTier } from "@portal/contexts/TierContext"; -import { OVERAGE_RATE, type BillingSummary } from "@portal/api/usage"; -import { USD, formatBillingDate } from "@portal/components/usage/format"; -import "@portal/views/Usage.css"; - -/** Headline billing KPIs: docs processed, cost, the tier-relevant cap figure, and renewal. */ -export function BillingKpiStrip({ - summary, -}: { - summary: BillingSummary | null; -}) { - const { t } = useTranslation(); - const { tier } = useTier(); - - // Overage is meaningless on free (gated) / enterprise (committed) β€” surface - // the more relevant headline figure for those tiers instead. - const overageCard = - tier === "free" - ? { - label: t("usage.kpi.remainingInPlan.label"), - value: summary - ? `${(summary.includedDocs - summary.docsThisPeriod).toLocaleString()}` - : "β€”", - description: t("usage.kpi.remainingInPlan.description"), - } - : tier === "enterprise" - ? { - label: t("usage.kpi.commitUtilisation.label"), - value: summary - ? `${Math.round((summary.docsThisPeriod / summary.includedDocs) * 100)}%` - : "β€”", - description: t("usage.kpi.commitUtilisation.description"), - } - : { - label: t("usage.kpi.overage.label", { - rate: OVERAGE_RATE.toFixed(2), - }), - value: summary ? USD.format(summary.overageCost) : "β€”", - description: summary - ? t("usage.kpi.overage.description", { - count: summary.overageDocs, - docs: summary.overageDocs.toLocaleString(), - }) - : undefined, - }; - - return ( - - - 0 - ? t("usage.kpi.costThisMonth.description", { - fee: USD.format(summary.monthlyFee), - }) - : tier === "free" - ? t("usage.kpi.costThisMonth.freePlan") - : undefined - } - /> - - - - ); -} diff --git a/frontend/portal/src/components/usage/CurrentPlanCard.stories.tsx b/frontend/portal/src/components/usage/CurrentPlanCard.stories.tsx deleted file mode 100644 index 1c75228913..0000000000 --- a/frontend/portal/src/components/usage/CurrentPlanCard.stories.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { CurrentPlanCard } from "@portal/components/usage/CurrentPlanCard"; -import { buildBillingSummary } from "@portal/mocks/usage"; - -const meta: Meta = { - title: "Portal/Usage/CurrentPlanCard", - component: CurrentPlanCard, - args: { onUpgrade: () => {} }, - decorators: [ - (S) => ( -
- -
- ), - ], -}; -export default meta; -type Story = StoryObj; - -// Free, approaching the cap β€” warning banner + cap meter. -export const FreeApproachingCap: Story = { - args: { summary: buildBillingSummary("free") }, - globals: { tier: "free" }, -}; - -// Free, cap reached β€” danger banner, processing paused. -export const FreeCapReached: Story = { - args: { - summary: { - ...buildBillingSummary("free"), - docsThisPeriod: 500, - capReached: true, - }, - }, - globals: { tier: "free" }, -}; - -// Pro pay-as-you-go breakdown with metered overage. -export const Pro: Story = { - args: { summary: buildBillingSummary("pro") }, - globals: { tier: "pro" }, -}; - -// Enterprise committed-volume breakdown. -export const Enterprise: Story = { - args: { summary: buildBillingSummary("enterprise") }, - globals: { tier: "enterprise" }, -}; diff --git a/frontend/portal/src/components/usage/CurrentPlanCard.tsx b/frontend/portal/src/components/usage/CurrentPlanCard.tsx deleted file mode 100644 index 4dde3a06c1..0000000000 --- a/frontend/portal/src/components/usage/CurrentPlanCard.tsx +++ /dev/null @@ -1,194 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { - Banner, - Button, - Card, - ProgressBar, - StatusBadge, -} from "@shared/components"; -import { useTier } from "@portal/contexts/TierContext"; -import { OVERAGE_RATE, type BillingSummary } from "@portal/api/usage"; -import { USD } from "@portal/components/usage/format"; -import "@portal/views/Usage.css"; - -function BreakdownRow({ - label, - value, - emphasis, -}: { - label: string; - value: string; - emphasis?: boolean; -}) { - return ( -
- {label} - {value} -
- ); -} - -/** - * Current-plan summary card. The body adapts per tier: a cap meter + nudge on - * free, a metered pay-as-you-go breakdown on pro, a committed-volume breakdown - * on enterprise. - */ -export function CurrentPlanCard({ - summary, - onUpgrade, -}: { - summary: BillingSummary; - onUpgrade: () => void; -}) { - const { t } = useTranslation(); - const { tier } = useTier(); - const usedRatio = summary.docsThisPeriod / summary.includedDocs; - - return ( - -
-
- - {t("usage.currentPlan.eyebrow")} - -

{summary.planName}

-
- - {tier === "free" - ? t("usage.currentPlan.badge.free") - : tier === "pro" - ? t("usage.currentPlan.badge.pro") - : t("usage.currentPlan.badge.enterprise")} - -
- - {tier === "free" && ( - <> -
-
- - {summary.docsThisPeriod.toLocaleString()} /{" "} - {summary.includedDocs.toLocaleString()} docs - - - {Math.round(usedRatio * 100)}% - -
- -
- {summary.capReached ? ( - - {t("usage.currentPlan.free.capReached.body")} - - ) : ( - - {t("usage.currentPlan.free.approaching.body", { - pct: Math.round(usedRatio * 100), - })} - - )} - - )} - - {tier === "pro" && ( -
- - - - -
- )} - - {tier === "enterprise" && ( -
- - - - -
- )} - -
- {tier !== "enterprise" ? ( - - ) : ( - - )} - {/* TODO(backend): GET /v1/billing/invoices?format=pdf β€” bundle + download invoice PDFs. */} - -
-
- ); -} diff --git a/frontend/portal/src/components/usage/PlanCard.stories.tsx b/frontend/portal/src/components/usage/PlanCard.stories.tsx deleted file mode 100644 index d27d94cc33..0000000000 --- a/frontend/portal/src/components/usage/PlanCard.stories.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { PlanCard } from "@portal/components/usage/PlanCard"; -import { PLAN_OPTIONS } from "@portal/mocks/usage"; - -const [free, pro, enterprise] = PLAN_OPTIONS; - -const meta: Meta = { - title: "Portal/Usage/PlanCard", - component: PlanCard, - args: { onSelect: () => {} }, - decorators: [ - (S) => ( -
- -
- ), - ], -}; -export default meta; -type Story = StoryObj; - -export const Free: Story = { args: { plan: free, isCurrent: false } }; - -export const Pro: Story = { args: { plan: pro, isCurrent: false } }; - -export const Enterprise: Story = { - args: { plan: enterprise, isCurrent: false }, -}; - -// The active plan is outlined and its CTA disabled. -export const Current: Story = { args: { plan: pro, isCurrent: true } }; diff --git a/frontend/portal/src/components/usage/PlanCard.tsx b/frontend/portal/src/components/usage/PlanCard.tsx deleted file mode 100644 index 2e5d182a12..0000000000 --- a/frontend/portal/src/components/usage/PlanCard.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { Button, Card, StatusBadge } from "@shared/components"; -import type { PlanOption } from "@portal/api/usage"; -import "@portal/views/Usage.css"; - -/** A single plan in the catalogue grid; highlighted when it's the active plan. */ -export function PlanCard({ - plan, - isCurrent, - onSelect, -}: { - plan: PlanOption; - isCurrent: boolean; - onSelect: () => void; -}) { - const { t } = useTranslation(); - const accent = plan.tier === "enterprise" ? "purple" : "blue"; - return ( - -
-

{plan.name}

- {isCurrent && ( - - {t("usage.planCard.current")} - - )} -
-
- {plan.price} - - {plan.priceCadence} - -
-

{plan.blurb}

-
    - {plan.features.map((f) => ( -
  • - - βœ“ - - {f} -
  • - ))} -
- -
- ); -} diff --git a/frontend/portal/src/components/usage/SpendCapControl.stories.tsx b/frontend/portal/src/components/usage/SpendCapControl.stories.tsx deleted file mode 100644 index 0a426166ea..0000000000 --- a/frontend/portal/src/components/usage/SpendCapControl.stories.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { SpendCapControl } from "@portal/components/usage/SpendCapControl"; -import { buildBillingSummary } from "@portal/mocks/usage"; - -const meta: Meta = { - title: "Portal/Usage/SpendCapControl", - component: SpendCapControl, - decorators: [ - (S) => ( -
- -
- ), - ], -}; -export default meta; -type Story = StoryObj; - -// Free can't accrue spend β€” explanatory card, no slider. -export const Free: Story = { - args: { summary: buildBillingSummary("free") }, - globals: { tier: "free" }, -}; - -// Pro with a cap already set β€” interactive slider + projection meter. -export const ProCapEnabled: Story = { - args: { summary: buildBillingSummary("pro") }, - globals: { tier: "pro" }, -}; - -// Pro with no cap set β€” starts collapsed behind "Enable cap". -export const ProCapDisabled: Story = { - args: { summary: { ...buildBillingSummary("pro"), spendCap: null } }, - globals: { tier: "pro" }, -}; - -// Enterprise spend is contract-governed β€” read-only card. -export const Enterprise: Story = { - args: { summary: buildBillingSummary("enterprise") }, - globals: { tier: "enterprise" }, -}; diff --git a/frontend/portal/src/components/usage/SpendCapControl.tsx b/frontend/portal/src/components/usage/SpendCapControl.tsx deleted file mode 100644 index c82992d383..0000000000 --- a/frontend/portal/src/components/usage/SpendCapControl.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import { useState } from "react"; -import { useTranslation } from "react-i18next"; -import { - Button, - Card, - ProgressBar, - Slider, - StatusBadge, -} from "@shared/components"; -import { useTier } from "@portal/contexts/TierContext"; -import type { BillingSummary } from "@portal/api/usage"; -import { USD } from "@portal/components/usage/format"; -import "@portal/views/Usage.css"; - -/** - * Monthly spend-cap control. Only pay-as-you-go can accrue spend, so free and - * enterprise render explanatory cards instead of the interactive slider. - */ -export function SpendCapControl({ summary }: { summary: BillingSummary }) { - const { t } = useTranslation(); - const { tier } = useTier(); - const [enabled, setEnabled] = useState(summary.spendCap !== null); - const [cap, setCap] = useState(summary.spendCap ?? 1_000); - - if (tier === "free") { - return ( - -

- {t("usage.spendCap.free.title")} -

-

- {t("usage.spendCap.free.description")} -

-
- ); - } - - if (tier === "enterprise") { - return ( - -

- {t("usage.spendCap.enterprise.title")} -

-

- {t("usage.spendCap.enterprise.description")} -

-
- - {t("usage.spendCap.enterprise.badge")} - - - {t("usage.spendCap.enterprise.overage", { - rate: summary.overageRate.toFixed(3), - })} - -
-
- ); - } - - const projected = summary.costThisMonth; - const capRatio = enabled ? Math.min(projected / cap, 1) : 0; - - // TODO(backend): PUT /v1/billing/spend-cap { enabled, cap } β€” persist the cap - // so processing pauses server-side when projected spend reaches the limit. - return ( - -
-
-

- {t("usage.spendCap.pro.title")} -

-

- {t("usage.spendCap.pro.subtitle")} -

-
- -
- - {enabled && ( - <> -
- USD.format(v)} - /> -
-
- - {t("usage.spendCap.pro.projected", { - projected: USD.format(projected), - cap: USD.format(cap), - })} - - - {Math.round(capRatio * 100)}% - -
- - - )} -
- ); -} diff --git a/frontend/portal/src/components/usage/UpgradeModal.stories.tsx b/frontend/portal/src/components/usage/UpgradeModal.stories.tsx deleted file mode 100644 index 511006d30a..0000000000 --- a/frontend/portal/src/components/usage/UpgradeModal.stories.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { UpgradeModal } from "@portal/components/usage/UpgradeModal"; -import { PLAN_OPTIONS } from "@portal/mocks/usage"; - -const enterprisePlan = - PLAN_OPTIONS.find((p) => p.tier === "enterprise") ?? null; - -const meta: Meta = { - title: "Portal/Usage/UpgradeModal", - component: UpgradeModal, - args: { open: true, onClose: () => {}, target: null }, -}; -export default meta; -type Story = StoryObj; - -// Free user pushed to pay-as-you-go. -export const FromFree: Story = { args: { currentTier: "free" } }; - -// Pro user with no specific target β€” generic committed-pricing nudge. -export const FromPro: Story = { args: { currentTier: "pro" } }; - -// Pro user selecting the enterprise plan β€” sales-conversation copy. -export const ProToEnterprise: Story = { - args: { currentTier: "pro", target: enterprisePlan }, -}; - -// Enterprise user routed to their account team. -export const FromEnterprise: Story = { args: { currentTier: "enterprise" } }; diff --git a/frontend/portal/src/components/usage/UpgradeModal.tsx b/frontend/portal/src/components/usage/UpgradeModal.tsx deleted file mode 100644 index 2dc989d6c7..0000000000 --- a/frontend/portal/src/components/usage/UpgradeModal.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import { useTranslation } from "react-i18next"; -import type { TFunction } from "i18next"; -import { Button, Modal } from "@shared/components"; -import type { Tier } from "@portal/contexts/TierContext"; -import type { PlanOption } from "@portal/api/usage"; -import "@portal/views/Usage.css"; - -interface UpgradeCopy { - title: string; - subtitle: string; - body: string; - bullets: string[]; - cta: string; - ctaAccent: "blue" | "purple"; -} - -/** - * Modal copy is intent-driven, not just plan-driven: a free user who has hit - * the cap sees urgency; a pro user is nudged toward a committed plan; an - * enterprise user is routed to their account team for bespoke terms. - */ -function upgradeCopy( - t: TFunction, - currentTier: Tier, - target: PlanOption | null, -): UpgradeCopy { - // Cap-reached: free user pushed to pay-as-you-go. - if (currentTier === "free") { - return { - title: t("usage.upgrade.free.title"), - subtitle: t("usage.upgrade.free.subtitle"), - body: t("usage.upgrade.free.body"), - bullets: [ - t("usage.upgrade.free.bullets.0"), - t("usage.upgrade.free.bullets.1"), - t("usage.upgrade.free.bullets.2"), - t("usage.upgrade.free.bullets.3"), - ], - cta: t("usage.upgrade.free.cta"), - ctaAccent: "blue", - }; - } - - // Commit-recommend: pro user with overage nudged to a committed plan. - if (currentTier === "pro") { - if (target?.tier === "enterprise") { - return { - title: t("usage.upgrade.proToEnterprise.title"), - subtitle: t("usage.upgrade.proToEnterprise.subtitle"), - body: t("usage.upgrade.proToEnterprise.body"), - bullets: [ - t("usage.upgrade.proToEnterprise.bullets.0"), - t("usage.upgrade.proToEnterprise.bullets.1"), - t("usage.upgrade.proToEnterprise.bullets.2"), - t("usage.upgrade.proToEnterprise.bullets.3"), - ], - cta: t("usage.upgrade.proToEnterprise.cta"), - ctaAccent: "purple", - }; - } - return { - title: t("usage.upgrade.pro.title"), - subtitle: t("usage.upgrade.pro.subtitle"), - body: t("usage.upgrade.pro.body"), - bullets: [ - t("usage.upgrade.pro.bullets.0"), - t("usage.upgrade.pro.bullets.1"), - t("usage.upgrade.pro.bullets.2"), - ], - cta: t("usage.upgrade.pro.cta"), - ctaAccent: "purple", - }; - } - - // Bespoke-enterprise: route to account team. - return { - title: t("usage.upgrade.enterprise.title"), - subtitle: t("usage.upgrade.enterprise.subtitle"), - body: t("usage.upgrade.enterprise.body"), - bullets: [ - t("usage.upgrade.enterprise.bullets.0"), - t("usage.upgrade.enterprise.bullets.1"), - t("usage.upgrade.enterprise.bullets.2"), - ], - cta: t("usage.upgrade.enterprise.cta"), - ctaAccent: "purple", - }; -} - -/** Intent-aware plan-change / sales-conversation modal. */ -export function UpgradeModal({ - open, - onClose, - currentTier, - target, -}: { - open: boolean; - onClose: () => void; - currentTier: Tier; - target: PlanOption | null; -}) { - const { t } = useTranslation(); - const copy = upgradeCopy(t, currentTier, target); - return ( - - - {/* TODO(backend): POST /v1/billing/plan-change { tier } (or hand off to - sales) β€” for now the CTA just dismisses the modal. */} - - - } - > -

{copy.body}

-
    - {copy.bullets.map((b) => ( -
  • - - βœ“ - - {b} -
  • - ))} -
-
- ); -} diff --git a/frontend/portal/src/components/usage/UsageChart.stories.tsx b/frontend/portal/src/components/usage/UsageChart.stories.tsx deleted file mode 100644 index 51327a0db4..0000000000 --- a/frontend/portal/src/components/usage/UsageChart.stories.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { http, HttpResponse, delay } from "msw"; -import { UsageChart } from "@portal/components/usage/UsageChart"; -import { buildUsagePayload } from "@portal/mocks/usage"; - -const meta: Meta = { - title: "Portal/Usage/UsageChart", - component: UsageChart, - parameters: { layout: "padded" }, - decorators: [ - (S) => ( -
- -
- ), - ], -}; -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Loading: Story = { - parameters: { - msw: { - handlers: [ - http.get("/v1/billing/usage", async () => { - await delay("infinite"); - return HttpResponse.json(buildUsagePayload()); - }), - ], - }, - }, -}; - -export const Empty: Story = { - parameters: { - msw: { - handlers: [ - http.get("/v1/billing/usage", () => - HttpResponse.json({ points: [], priorTotal: 0 }), - ), - ], - }, - }, -}; diff --git a/frontend/portal/src/components/usage/UsageChart.tsx b/frontend/portal/src/components/usage/UsageChart.tsx deleted file mode 100644 index a705f08132..0000000000 --- a/frontend/portal/src/components/usage/UsageChart.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { useMemo } from "react"; -import { useTranslation } from "react-i18next"; -import { EmptyState, Skeleton } from "@shared/components"; -import { useAsync, useSectionFlags } from "@portal/hooks/useAsync"; -import { fetchBillingUsage, type UsageSeriesResponse } from "@portal/api/usage"; -import { UsageAreaChart } from "@portal/components/UsageAreaChart"; -import "@portal/components/UsageAreaChart.css"; - -/** 30-day docs-processed area chart, with the period total and prior-period delta. */ -export function UsageChart() { - const { t } = useTranslation(); - const state = useAsync(() => fetchBillingUsage(), []); - const { data: usage } = state; - const { isLoading } = useSectionFlags(state); - - const docs30d = useMemo( - () => usage?.points.reduce((sum, p) => sum + p.value, 0) ?? 0, - [usage], - ); - const deltaPct = useMemo(() => { - if (!usage || usage.priorTotal <= 0) return undefined; - return (docs30d - usage.priorTotal) / usage.priorTotal; - }, [usage, docs30d]); - - if (isLoading) { - return ( -
- - - -
- ); - } - - if (!usage || usage.points.length === 0) { - return ( - - ); - } - - return ( - - ); -} diff --git a/frontend/portal/src/components/usage/format.ts b/frontend/portal/src/components/usage/format.ts deleted file mode 100644 index 25497473d0..0000000000 --- a/frontend/portal/src/components/usage/format.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** Shared currency / date formatting for the Usage & Billing surface. */ - -export const USD = new Intl.NumberFormat(undefined, { - style: "currency", - currency: "USD", - maximumFractionDigits: 2, -}); - -export function formatBillingDate(iso: string): string { - return new Date(iso).toLocaleDateString(undefined, { - month: "short", - day: "numeric", - year: "numeric", - }); -} diff --git a/frontend/portal/src/contexts/AccountLinkContext.tsx b/frontend/portal/src/contexts/AccountLinkContext.tsx new file mode 100644 index 0000000000..d3f4bf3678 --- /dev/null +++ b/frontend/portal/src/contexts/AccountLinkContext.tsx @@ -0,0 +1,37 @@ +import { createContext, useContext, type ReactNode } from "react"; +import { + useAccountLink, + type UseAccountLink, +} from "@portal/hooks/useAccountLink"; + +/** + * Single app-wide {@link useAccountLink} instance. The link flow is orchestrated + * in exactly one place so that: + * - status is fetched once on mount (not per consumer), and + * - the SSO-return effect fires once β€” two instances would both call + * {@link UseAccountLink.completeLink} on return and re-register the device + * credential, leaving a duplicate linked_instance row. + * + * Consumers (the top-level link modal host, the Settings account-link panel, + * the link card) read this shared instance instead of calling the hook again. + */ +const AccountLinkContext = createContext(null); + +export function AccountLinkProvider({ children }: { children: ReactNode }) { + const link = useAccountLink(); + return ( + + {children} + + ); +} + +export function useAccountLinkContext(): UseAccountLink { + const v = useContext(AccountLinkContext); + if (!v) { + throw new Error( + "useAccountLinkContext must be used inside ", + ); + } + return v; +} diff --git a/frontend/portal/src/contexts/LinkContext.test.tsx b/frontend/portal/src/contexts/LinkContext.test.tsx new file mode 100644 index 0000000000..be13ede65d --- /dev/null +++ b/frontend/portal/src/contexts/LinkContext.test.tsx @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { render, screen, act } from "@testing-library/react"; +import { + deriveLinkState, + LINK_INFO, + LinkProvider, + useApplyLinkFacts, + useLink, +} from "@portal/contexts/LinkContext"; + +describe("deriveLinkState", () => { + it("maps raw facts to the three link states", () => { + expect(deriveLinkState(false, false)).toBe("unlinked"); + expect(deriveLinkState(false, true)).toBe("unlinked"); + expect(deriveLinkState(true, false)).toBe("linked-free"); + expect(deriveLinkState(true, true)).toBe("linked-subscribed"); + }); +}); + +describe("LINK_INFO", () => { + it("only unlocks features once linked", () => { + expect(LINK_INFO.unlinked.unlocked).toBe(false); + expect(LINK_INFO["linked-free"].unlocked).toBe(true); + expect(LINK_INFO["linked-subscribed"].unlocked).toBe(true); + }); +}); + +function Probe() { + const { linkState, isLinked, featuresUnlocked } = useLink(); + const apply = useApplyLinkFacts(); + return ( +
+ {linkState} + {String(isLinked)} + {String(featuresUnlocked)} + +
+ ); +} + +describe("LinkProvider", () => { + it("defaults to unlinked and locks features", () => { + render( + + + , + ); + expect(screen.getByTestId("state").textContent).toBe("unlinked"); + expect(screen.getByTestId("linked").textContent).toBe("false"); + expect(screen.getByTestId("unlocked").textContent).toBe("false"); + }); + + it("applies link facts to update the derived state", () => { + render( + + + , + ); + act(() => screen.getByText("subscribe").click()); + expect(screen.getByTestId("state").textContent).toBe("linked-subscribed"); + expect(screen.getByTestId("unlocked").textContent).toBe("true"); + }); + + it("throws when useLink is used outside the provider", () => { + function Bare() { + useLink(); + return null; + } + expect(() => render()).toThrow(/useLink must be used/); + }); +}); diff --git a/frontend/portal/src/contexts/LinkContext.tsx b/frontend/portal/src/contexts/LinkContext.tsx new file mode 100644 index 0000000000..daeb1cd71a --- /dev/null +++ b/frontend/portal/src/contexts/LinkContext.tsx @@ -0,0 +1,119 @@ +import { + createContext, + useCallback, + useContext, + useMemo, + useState, + type ReactNode, +} from "react"; + +/** + * The "linked" dimension of the account-link surface (combined-billing "Mode A"), + * a sibling to TierContext. It answers one question the rest of the portal asks: + * has this self-hosted org linked its SaaS account, and if so, is it on the free + * grant or actively subscribed? + * + * - `unlinked` β€” no SaaS account linked. Billable features render a + * "link to unlock" affordance. + * - `linked-free` β€” linked, running on the one-time free grant (500 PDFs). + * - `linked-subscribed` β€” linked with a live PAYG subscription. + * + * The portal admin establishes the link by signing in to the SaaS Supabase + * project in-app (auth/saasSupabase.ts + the shared Supabase login) and + * registering the instance (api/link.ts); the subscribed-vs-free distinction + * comes from the wallet (api/billing.ts Wallet.status). + */ +export type LinkState = "unlinked" | "linked-free" | "linked-subscribed"; + +export interface LinkInfo { + /** i18n key for the badge label; resolve with `t()` at the call site. */ + labelKey: string; + /** Whether billable features are unlocked (any linked state). */ + unlocked: boolean; +} + +export const LINK_INFO: Record = { + unlinked: { labelKey: "accountLink.state.unlinked", unlocked: false }, + "linked-free": { labelKey: "accountLink.state.free", unlocked: true }, + "linked-subscribed": { + labelKey: "accountLink.state.subscribed", + unlocked: true, + }, +}; + +interface LinkContextValue { + linkState: LinkState; + setLinkState: (state: LinkState) => void; + /** True for any linked state β€” gates "link to unlock" prompts. */ + isLinked: boolean; + /** Convenience for `LINK_INFO[linkState].unlocked` β€” billable features usable. */ + featuresUnlocked: boolean; + /** + * Bumps whenever the browser's SaaS session changes (e.g. a re-sign-in after + * expiry). Attended SaaS reads (the wallet) key off this to refetch with the + * fresh token without re-establishing the instance link. + */ + saasSessionNonce: number; + markSaasSessionChanged: () => void; +} + +const LinkContext = createContext(null); + +export function LinkProvider({ + children, + initialState = "unlinked", +}: { + children: ReactNode; + initialState?: LinkState; +}) { + const [linkState, setLinkState] = useState(initialState); + const [saasSessionNonce, setSaasSessionNonce] = useState(0); + const markSaasSessionChanged = useCallback( + () => setSaasSessionNonce((n) => n + 1), + [], + ); + const value = useMemo(() => { + const unlocked = LINK_INFO[linkState].unlocked; + return { + linkState, + setLinkState, + isLinked: linkState !== "unlinked", + featuresUnlocked: unlocked, + saasSessionNonce, + markSaasSessionChanged, + }; + }, [linkState, saasSessionNonce, markSaasSessionChanged]); + return {children}; +} + +export function useLink(): LinkContextValue { + const v = useContext(LinkContext); + if (!v) throw new Error("useLink must be used inside "); + return v; +} + +/** + * Derives the linked state from raw facts: whether the org has linked its SaaS + * account and whether it carries a live subscription. Keeps the unlinked / + * linked-free / linked-subscribed mapping in one place. + */ +export function deriveLinkState( + linked: boolean, + subscribed: boolean, +): LinkState { + if (!linked) return "unlinked"; + return subscribed ? "linked-subscribed" : "linked-free"; +} + +/** Hook returning a setter that maps raw link/subscription facts to LinkState. */ +export function useApplyLinkFacts(): ( + linked: boolean, + subscribed: boolean, +) => void { + const { setLinkState } = useLink(); + return useCallback( + (linked: boolean, subscribed: boolean) => + setLinkState(deriveLinkState(linked, subscribed)), + [setLinkState], + ); +} diff --git a/frontend/portal/src/contexts/TierContext.tsx b/frontend/portal/src/contexts/TierContext.tsx index 4b69419a6c..5f83159758 100644 --- a/frontend/portal/src/contexts/TierContext.tsx +++ b/frontend/portal/src/contexts/TierContext.tsx @@ -1,10 +1,13 @@ import { createContext, useContext, + useEffect, useMemo, useState, type ReactNode, } from "react"; +import { readMocksPreference } from "@portal/mocks/preference"; +import { useLink, type LinkState } from "@portal/contexts/LinkContext"; export type Tier = "free" | "pro" | "enterprise"; @@ -14,18 +17,34 @@ export interface TierInfo { } export const TIER_INFO: Record = { - free: { label: "Free Plan", dotColor: "var(--color-text-4)" }, - pro: { label: "Pay-as-you-go", dotColor: "var(--color-blue)" }, - enterprise: { label: "Enterprise Plan", dotColor: "var(--color-purple)" }, + // Matches SaaS branding (editor/cloud Payg + PaygFree): the always-free + // manual-tools tier is "Editor plan"; the metered tier is "Processor plan". + free: { label: "Editor plan", dotColor: "var(--color-text-4)" }, + pro: { label: "Processor plan", dotColor: "var(--color-blue)" }, + enterprise: { label: "Enterprise plan", dotColor: "var(--color-purple)" }, }; interface TierContextValue { tier: Tier; + /** No-op when MSW mocks are off (tier is derived from real link state). */ setTier: (tier: Tier) => void; + /** True when the tier value is derived from the real wallet/link, not the dropdown. */ + isDerived: boolean; } const TierContext = createContext(null); +/** Maps the real link/subscription state onto the tier the rest of the portal reads. */ +function tierFromLinkState(linkState: LinkState): Tier { + switch (linkState) { + case "linked-subscribed": + return "pro"; + case "linked-free": + case "unlinked": + return "free"; + } +} + export function TierProvider({ children, initialTier = "pro", @@ -33,8 +52,33 @@ export function TierProvider({ children: ReactNode; initialTier?: Tier; }) { - const [tier, setTier] = useState(initialTier); - const value = useMemo(() => ({ tier, setTier }), [tier]); + // Mocks toggling reloads the page (see MocksToggle), so a single read at mount + // is correct β€” the preference can't change without us remounting. + const mocksOn = useMemo(() => readMocksPreference(), []); + const { linkState } = useLink(); + + const [mockTier, setMockTier] = useState(initialTier); + + // When mocks are off, mirror the real link state into the tier so any + // component still keyed on `tier` (sidebar plan badge, gated panels) stays + // consistent with the wallet. When mocks are on, the dropdown wins. + useEffect(() => { + if (!mocksOn) { + setMockTier(tierFromLinkState(linkState)); + } + }, [mocksOn, linkState]); + + const value = useMemo( + () => ({ + tier: mocksOn ? mockTier : tierFromLinkState(linkState), + // Setter is a no-op when mocks are off β€” UI controls can disable themselves + // via `isDerived`, but even if one slips through, it has no effect. + setTier: mocksOn ? setMockTier : () => {}, + isDerived: !mocksOn, + }), + [mocksOn, mockTier, linkState], + ); + return {children}; } diff --git a/frontend/portal/src/contexts/UIContext.tsx b/frontend/portal/src/contexts/UIContext.tsx index 8d0400e18d..ba2ccbd344 100644 --- a/frontend/portal/src/contexts/UIContext.tsx +++ b/frontend/portal/src/contexts/UIContext.tsx @@ -19,8 +19,29 @@ interface UIContextValue { /** Settings is a modal overlay, not a route. */ settingsOpen: boolean; - openSettings: () => void; + /** + * The section the Settings modal should land on when opened. `null` lets the + * modal pick its own default. Cleared back to `null` on close. + */ + settingsInitialSection: string | null; + openSettings: (section?: string) => void; closeSettings: () => void; + + /** + * The account-link login modal. A single top-level instance β€” never nested in + * another overlay. Opening it from within Settings closes Settings first (no + * modal-in-modal) and reopens Settings on the account-link section once the + * login modal closes, so the admin returns to where they were. + */ + linkModalOpen: boolean; + /** + * "link" registers this instance (the normal first-time flow); "reauth" only + * refreshes an expired SaaS session for attended reads β€” it must NOT re-register + * (that would mint a duplicate device credential). + */ + linkModalMode: "link" | "reauth"; + openLinkModal: (mode?: "link" | "reauth") => void; + closeLinkModal: () => void; } const UIContext = createContext(null); @@ -29,6 +50,16 @@ export function UIProvider({ children }: { children: ReactNode }) { const [searchOpen, setSearchOpen] = useState(false); const [assistantOpen, setAssistantOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); + const [settingsInitialSection, setSettingsInitialSection] = useState< + string | null + >(null); + const [linkModalOpen, setLinkModalOpen] = useState(false); + const [linkModalMode, setLinkModalMode] = useState<"link" | "reauth">("link"); + // When the link modal is opened from inside Settings, remember the section to + // restore so closing the modal returns the admin to where they were. + const [reopenSettingsAfterLink, setReopenSettingsAfterLink] = useState< + string | null + >(null); const value = useMemo( () => ({ @@ -43,10 +74,48 @@ export function UIProvider({ children }: { children: ReactNode }) { toggleAssistant: () => setAssistantOpen((o) => !o), settingsOpen, - openSettings: () => setSettingsOpen(true), - closeSettings: () => setSettingsOpen(false), + settingsInitialSection, + openSettings: (section?: string) => { + setSettingsInitialSection(section ?? null); + setSettingsOpen(true); + }, + closeSettings: () => { + setSettingsOpen(false); + setSettingsInitialSection(null); + }, + + linkModalOpen, + linkModalMode, + openLinkModal: (mode: "link" | "reauth" = "link") => { + setLinkModalMode(mode); + // Never stack on Settings: close it first, and remember to reopen it on + // the account-link section once the login modal closes. + if (settingsOpen) { + setReopenSettingsAfterLink("account-link"); + setSettingsOpen(false); + setSettingsInitialSection(null); + } + setLinkModalOpen(true); + }, + closeLinkModal: () => { + setLinkModalOpen(false); + setLinkModalMode("link"); + if (reopenSettingsAfterLink) { + setSettingsInitialSection(reopenSettingsAfterLink); + setSettingsOpen(true); + setReopenSettingsAfterLink(null); + } + }, }), - [searchOpen, assistantOpen, settingsOpen], + [ + searchOpen, + assistantOpen, + settingsOpen, + settingsInitialSection, + linkModalOpen, + linkModalMode, + reopenSettingsAfterLink, + ], ); return {children}; diff --git a/frontend/portal/src/hooks/useAccountLink.test.tsx b/frontend/portal/src/hooks/useAccountLink.test.tsx new file mode 100644 index 0000000000..b327322778 --- /dev/null +++ b/frontend/portal/src/hooks/useAccountLink.test.tsx @@ -0,0 +1,75 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, render, waitFor } from "@testing-library/react"; +import { LinkProvider } from "@portal/contexts/LinkContext"; + +/** + * The SSO-return path is mode-aware: a "reauth" return must only refresh the + * session, NOT re-register the instance (re-registering mints a duplicate device + * credential). This is the exact regression that slipped through once, so it gets + * a dedicated guard. + */ +const { linkInstance, fetchStatus, unlinkInstance, getSession } = vi.hoisted( + () => ({ + linkInstance: vi.fn(), + fetchStatus: vi.fn(), + unlinkInstance: vi.fn(), + getSession: vi.fn(), + }), +); + +vi.mock("@portal/api/link", () => ({ + linkInstance, + fetchStatus, + unlinkInstance, +})); +vi.mock("@portal/auth/saasSupabase", () => ({ + PENDING_LINK_KEY: "stirling_pending_link", + isSaasSupabaseConfigured: true, + SAAS_OAUTH_PROVIDERS: [], + ensureSaasSupabase: () => ({ auth: { getSession } }), +})); + +import { useAccountLink } from "@portal/hooks/useAccountLink"; +import { PENDING_LINK_KEY } from "@portal/auth/saasSupabase"; + +function Probe() { + useAccountLink(); + return null; +} + +const renderHook = () => + render( + + + , + ); + +beforeEach(() => { + linkInstance.mockReset().mockResolvedValue({ linked: true, name: null }); + fetchStatus.mockReset().mockResolvedValue({ linked: true, name: null }); + unlinkInstance.mockReset(); + getSession.mockReset().mockResolvedValue({ + data: { session: { access_token: "tok" } }, + }); + sessionStorage.clear(); +}); +afterEach(() => sessionStorage.clear()); + +describe("useAccountLink β€” SSO return", () => { + it("reauth mode refreshes the session WITHOUT re-registering", async () => { + sessionStorage.setItem(PENDING_LINK_KEY, "reauth"); + renderHook(); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(linkInstance).not.toHaveBeenCalled(); + }); + + it("link mode registers the instance with the returned token", async () => { + sessionStorage.setItem(PENDING_LINK_KEY, "link"); + renderHook(); + await waitFor(() => expect(linkInstance).toHaveBeenCalledTimes(1)); + expect(linkInstance.mock.calls[0][0].supabaseJwt).toBe("tok"); + }); +}); diff --git a/frontend/portal/src/hooks/useAccountLink.ts b/frontend/portal/src/hooks/useAccountLink.ts new file mode 100644 index 0000000000..8c7158dd7b --- /dev/null +++ b/frontend/portal/src/hooks/useAccountLink.ts @@ -0,0 +1,142 @@ +import { useCallback, useEffect, useState } from "react"; +import type { SupabaseLoginSession } from "@shared/auth/ui/useSupabaseLogin"; +import { + ensureSaasSupabase, + isSaasSupabaseConfigured, + PENDING_LINK_KEY, +} from "@portal/auth/saasSupabase"; +import { + fetchStatus, + linkInstance, + unlinkInstance, + type LinkStatus, +} from "@portal/api/link"; +import { useApplyLinkFacts, useLink } from "@portal/contexts/LinkContext"; + +/** + * Orchestrates the account-link flow for THIS instance: + * + * 1. The admin signs in to their Stirling account IN-APP (LinkAccountModal β†’ + * shared Supabase login), minting a short-term SaaS JWT. + * 2. {@link completeLink} POSTs that JWT to the LOCAL backend (api/link.ts), + * which registers with SaaS and stores the device secret server-side. + * 3. The resulting Linked / Not-linked status is read back. + * + * Email/password resolves inline (the modal calls completeLink). SSO redirects + * the browser to the provider and back; the returned session is finished here on + * mount (see the pending-link effect). The device secret is never received or + * rendered. Subscription state is resolved separately from the wallet, so a fresh + * link marks the org linked-free. + */ + +export type LinkPhase = "idle" | "linking" | "error"; + +export interface UseAccountLink { + /** Whether the SaaS Supabase project is configured (false β†’ link UI shows a configure state). */ + loginConfigured: boolean; + /** Linked / Not-linked status for this instance; null while first loading. */ + status: LinkStatus | null; + phase: LinkPhase; + error: string | null; + /** Finish linking THIS instance with a SaaS session minted by the login modal. */ + completeLink: (session: SupabaseLoginSession, name?: string) => Promise; + /** Unlink this instance. */ + unlink: () => Promise; +} + +export function useAccountLink(): UseAccountLink { + const applyLinkFacts = useApplyLinkFacts(); + const { markSaasSessionChanged } = useLink(); + const [status, setStatus] = useState(null); + const [phase, setPhase] = useState("idle"); + const [error, setError] = useState(null); + + const completeLink = useCallback( + async (session: SupabaseLoginSession, name?: string) => { + setPhase("linking"); + setError(null); + try { + const next = await linkInstance({ + supabaseJwt: session.access_token, + name, + }); + setStatus(next); + setPhase("idle"); + if (next.linked) applyLinkFacts(true, false); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + setPhase("error"); + } + }, + [applyLinkFacts], + ); + + // Read the current link status on mount. + useEffect(() => { + let cancelled = false; + void fetchStatus() + .then((s) => { + if (!cancelled) { + setStatus(s); + // A linked instance is at least linked-free; subscription comes from the wallet. + if (s.linked) applyLinkFacts(true, false); + } + }) + .catch(() => { + // Status endpoint absent (flag off) / unreachable β†’ leave status null, + // which renders as "Not linked". Don't surface an error or leak an + // unhandled rejection for the expected flag-off case. + if (!cancelled) setStatus({ linked: false, name: null }); + }); + return () => { + cancelled = true; + }; + }, [applyLinkFacts]); + + // SSO return: an SSO sign-in we kicked off has redirected back and the SaaS + // session is now in the shared Supabase client. The pending marker carries the + // mode: "reauth" only refreshes attended reads (the instance is already linked + // β€” re-registering would mint a duplicate credential); anything else links. + useEffect(() => { + const supabase = ensureSaasSupabase(); + const pending = sessionStorage.getItem(PENDING_LINK_KEY); + if (!supabase || pending === null) return; + let cancelled = false; + void supabase.auth.getSession().then(({ data }) => { + sessionStorage.removeItem(PENDING_LINK_KEY); + const token = data.session?.access_token; + if (!token || cancelled) return; + if (pending === "reauth") { + markSaasSessionChanged(); + } else { + void completeLink({ access_token: token }); + } + }); + return () => { + cancelled = true; + }; + }, [completeLink, markSaasSessionChanged]); + + const unlink = useCallback(async () => { + setPhase("linking"); + setError(null); + try { + await unlinkInstance(); + setStatus({ linked: false, name: null }); + setPhase("idle"); + applyLinkFacts(false, false); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + setPhase("error"); + } + }, [applyLinkFacts]); + + return { + loginConfigured: isSaasSupabaseConfigured, + status, + phase, + error, + completeLink, + unlink, + }; +} diff --git a/frontend/portal/src/hooks/useStripePortal.ts b/frontend/portal/src/hooks/useStripePortal.ts new file mode 100644 index 0000000000..c438dabcca --- /dev/null +++ b/frontend/portal/src/hooks/useStripePortal.ts @@ -0,0 +1,34 @@ +import { useCallback, useState } from "react"; +import type { Wallet } from "@portal/api/billing"; +import { createPortalSession } from "@portal/billing/stripe"; + +/** + * Opens the Stripe customer portal for the wallet's team in a new tab. Card, + * invoice, and cancellation changes all live in Stripe's hosted portal β€” both + * the header "Manage Payment" action and the payment-method card's "Update" + * button route through here. + */ +export function useStripePortal(wallet: Wallet | null) { + const [opening, setOpening] = useState(false); + const [error, setError] = useState(null); + + const open = useCallback(async () => { + const teamId = wallet?.teamId; + if (teamId == null) return; + setOpening(true); + setError(null); + try { + const url = await createPortalSession({ + teamId, + returnUrl: window.location.href, + }); + window.open(url, "_blank", "noopener,noreferrer"); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setOpening(false); + } + }, [wallet?.teamId]); + + return { open, opening, error }; +} diff --git a/frontend/portal/src/mocks/agents.ts b/frontend/portal/src/mocks/agents.ts index 60a10ebb74..e8523d11c7 100644 --- a/frontend/portal/src/mocks/agents.ts +++ b/frontend/portal/src/mocks/agents.ts @@ -6,7 +6,7 @@ * cases), tool-access governance, an eval / golden set, and version history. * * api/agents.ts imports the types; the MSW handlers serve this fixture data - * over the intercepted httpJson() calls. Components never reach into this + * over the intercepted apiClient.local.json() calls. Components never reach into this * module directly. Once a real backend exists the handlers stop being * registered and these fixtures can be deleted (or kept as test seeds). */ diff --git a/frontend/portal/src/mocks/docs.ts b/frontend/portal/src/mocks/docs.ts index 59e5e91d64..6aae3fe45b 100644 --- a/frontend/portal/src/mocks/docs.ts +++ b/frontend/portal/src/mocks/docs.ts @@ -1,7 +1,7 @@ /** * Developer Docs fixtures and the types api/docs.ts shares with them. * api/docs.ts imports the types; the MSW handlers in mocks/handlers/ serve the - * fixture data over the intercepted httpJson() calls. Components never reach + * fixture data over the intercepted apiClient.local.json() calls. Components never reach * into this module directly. * * Two payloads back the surface: diff --git a/frontend/portal/src/mocks/documents.ts b/frontend/portal/src/mocks/documents.ts index 49b524887c..6e1cc973e3 100644 --- a/frontend/portal/src/mocks/documents.ts +++ b/frontend/portal/src/mocks/documents.ts @@ -7,7 +7,7 @@ * shown directly or behind a zero-standing-access elevation request. * * api/documents.ts imports the types; the MSW handlers serve the fixture data - * over the intercepted httpJson() calls. Components never reach into this + * over the intercepted apiClient.local.json() calls. Components never reach into this * module directly. Once a real backend exists the handlers stop being * registered and these fixtures can be deleted (or kept as test seeds). */ diff --git a/frontend/portal/src/mocks/editorDeploy.ts b/frontend/portal/src/mocks/editorDeploy.ts index d4bbbac678..398ef69470 100644 --- a/frontend/portal/src/mocks/editorDeploy.ts +++ b/frontend/portal/src/mocks/editorDeploy.ts @@ -7,7 +7,7 @@ * instance, and the service credential / offline-activation lifecycle. * * api/editorDeploy.ts imports the types; the MSW handlers serve this fixture - * data over the intercepted httpJson() calls. Components never reach into this + * data over the intercepted apiClient.local.json() calls. Components never reach into this * module directly. Once a real backend exists the handlers stop being registered * and these fixtures can be deleted (or kept as test seeds). */ diff --git a/frontend/portal/src/mocks/handlers/index.ts b/frontend/portal/src/mocks/handlers/index.ts index 2d263cc4d0..130d1a7b35 100644 --- a/frontend/portal/src/mocks/handlers/index.ts +++ b/frontend/portal/src/mocks/handlers/index.ts @@ -7,7 +7,6 @@ import { searchHandlers } from "@portal/mocks/handlers/search"; import { pipelinesHandlers } from "@portal/mocks/handlers/pipelines"; import { sourcesHandlers } from "@portal/mocks/handlers/sources"; import { infrastructureHandlers } from "@portal/mocks/handlers/infrastructure"; -import { usageHandlers } from "@portal/mocks/handlers/usage"; import { docsHandlers } from "@portal/mocks/handlers/docs"; import { settingsHandlers } from "@portal/mocks/handlers/settings"; import { usersHandlers } from "@portal/mocks/handlers/users"; @@ -16,6 +15,7 @@ import { policiesHandlers } from "@portal/mocks/handlers/policies"; import { documentsHandlers } from "@portal/mocks/handlers/documents"; import { sdkComponentsHandlers } from "@portal/mocks/handlers/sdkComponents"; import { editorDeployHandlers } from "@portal/mocks/handlers/editorDeploy"; +import { linkHandlers } from "@portal/mocks/handlers/link"; export const handlers = [ ...authHandlers, @@ -27,7 +27,6 @@ export const handlers = [ ...pipelinesHandlers, ...sourcesHandlers, ...infrastructureHandlers, - ...usageHandlers, ...docsHandlers, ...settingsHandlers, ...usersHandlers, @@ -36,6 +35,7 @@ export const handlers = [ ...documentsHandlers, ...sdkComponentsHandlers, ...editorDeployHandlers, + ...linkHandlers, ]; export { resetNotificationsStore } from "@portal/mocks/handlers/notifications"; diff --git a/frontend/portal/src/mocks/handlers/link.ts b/frontend/portal/src/mocks/handlers/link.ts new file mode 100644 index 0000000000..2be110a794 --- /dev/null +++ b/frontend/portal/src/mocks/handlers/link.ts @@ -0,0 +1,65 @@ +import { http, HttpResponse, delay } from "msw"; +import { + getLocalStatus, + linkLocal, + listInstances, + revokeInstance, + unlinkLocal, + type LinkInstanceRequest, +} from "@portal/mocks/link"; + +/** + * Account-link MSW handlers. Two surfaces: + * + * - LOCAL backend (this instance): link / status / unlink. `link` mutates the + * in-memory store and flips local status so the surface behaves like a real + * backend within a session. The device secret stays server-side β€” never + * returned over the wire, matching the real contract. + * - SaaS backend (team-wide): instances / revoke. + * + * Mirrors the real AccountLinkController paths so MSW can be dropped with no code + * change. + */ +export const linkHandlers = [ + http.get("/api/v1/account-link/status", async () => { + await delay(120); + return HttpResponse.json(getLocalStatus()); + }), + + http.post("/api/v1/account-link/link", async ({ request }) => { + await delay(120); + let name: string | undefined; + try { + name = ((await request.json()) as LinkInstanceRequest)?.name; + } catch { + // empty body β€” name stays undefined + } + return HttpResponse.json(linkLocal(name), { status: 201 }); + }), + + http.post("/api/v1/account-link/unlink", async () => { + await delay(120); + // Clear local link state, then 204 (no body) to match the real backend. + unlinkLocal(); + return new HttpResponse(null, { status: 204 }); + }), + + // Team-wide list/revoke are SaaS-direct now (apiClient.saas calls the + // absolute VITE_SAAS_API_URL). Wildcard so the same handlers intercept both + // the relative pattern (legacy / direct-MSW usage) and any absolute SaaS + // base URL configured in dev/test. + http.get("*/api/v1/account-link/instances", async () => { + await delay(120); + return HttpResponse.json(listInstances()); + }), + + http.post( + "*/api/v1/account-link/instances/:instanceId/revoke", + async ({ params }) => { + await delay(120); + const ok = revokeInstance(Number(params.instanceId)); + if (!ok) return new HttpResponse(null, { status: 404 }); + return new HttpResponse(null, { status: 204 }); + }, + ), +]; diff --git a/frontend/portal/src/mocks/handlers/usage.ts b/frontend/portal/src/mocks/handlers/usage.ts deleted file mode 100644 index 82859349bc..0000000000 --- a/frontend/portal/src/mocks/handlers/usage.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { http, HttpResponse, delay } from "msw"; -import type { Tier } from "@portal/contexts/TierContext"; -import { - buildBillingHistory, - buildBillingSummary, - buildUsagePayload, - PLAN_OPTIONS, -} from "@portal/mocks/usage"; - -function tierFrom(request: Request): Tier { - const url = new URL(request.url); - return (url.searchParams.get("tier") ?? "pro") as Tier; -} - -export const usageHandlers = [ - http.get("/v1/billing/usage", async () => { - await delay(120); - return HttpResponse.json(buildUsagePayload()); - }), - - http.get("/v1/billing/summary", async ({ request }) => { - await delay(120); - return HttpResponse.json(buildBillingSummary(tierFrom(request))); - }), - - http.get("/v1/billing/plans", async () => { - await delay(120); - return HttpResponse.json(PLAN_OPTIONS); - }), - - http.get("/v1/billing/history", async ({ request }) => { - await delay(120); - return HttpResponse.json(buildBillingHistory(tierFrom(request))); - }), -]; diff --git a/frontend/portal/src/mocks/home.ts b/frontend/portal/src/mocks/home.ts index 229da46fa2..125a25d336 100644 --- a/frontend/portal/src/mocks/home.ts +++ b/frontend/portal/src/mocks/home.ts @@ -1,7 +1,7 @@ /** * Home dashboard fixtures and the types api/home.ts shares with them. * api/home.ts imports the types; the MSW handlers in mocks/handlers/ serve the - * fixture data over the intercepted httpJson() calls. Components never reach + * fixture data over the intercepted apiClient.local.json() calls. Components never reach * into this module directly. * * Once a real backend exists, the MSW handlers stop being registered and these diff --git a/frontend/portal/src/mocks/infrastructure.ts b/frontend/portal/src/mocks/infrastructure.ts index 98e06994a7..47c0c664ab 100644 --- a/frontend/portal/src/mocks/infrastructure.ts +++ b/frontend/portal/src/mocks/infrastructure.ts @@ -1,7 +1,7 @@ /** * Infrastructure surface fixtures and the types api/infrastructure.ts shares * with them. api/infrastructure.ts imports the types; the MSW handlers in - * mocks/handlers/ serve the fixture data over the intercepted httpJson() calls. + * mocks/handlers/ serve the fixture data over the intercepted apiClient.local.json() calls. * Components never reach into this module directly. * * Everything here is tier-scaled deterministically: free sees a single region diff --git a/frontend/portal/src/mocks/link.test.ts b/frontend/portal/src/mocks/link.test.ts new file mode 100644 index 0000000000..bd1cd505ef --- /dev/null +++ b/frontend/portal/src/mocks/link.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + getLocalStatus, + linkLocal, + listInstances, + resetLinkStore, + revokeInstance, + unlinkLocal, +} from "@portal/mocks/link"; + +describe("mocks/link store", () => { + beforeEach(() => resetLinkStore()); + + it("starts not-linked locally", () => { + expect(getLocalStatus().linked).toBe(false); + }); + + it("lists seed instances newest-first", () => { + const rows = listInstances(); + expect(rows.length).toBeGreaterThan(0); + for (let i = 1; i < rows.length; i++) { + expect(rows[i - 1].instanceId).toBeGreaterThan(rows[i].instanceId); + } + }); + + it("links locally and adds an active instance β€” without surfacing any secret", () => { + const before = listInstances().length; + const status = linkLocal("new-node"); + expect(status.linked).toBe(true); + expect(status.name).toBe("new-node"); + expect(status).not.toHaveProperty("deviceSecret"); + + const rows = listInstances(); + expect(rows.length).toBe(before + 1); + const added = rows.find((r) => r.name === "new-node"); + expect(added).toBeDefined(); + expect(added?.revoked).toBe(false); + expect(added?.lastSeenAt).toBeNull(); + }); + + it("links without a name as null", () => { + expect(linkLocal().name).toBeNull(); + }); + + it("unlinks locally", () => { + linkLocal("temp"); + expect(getLocalStatus().linked).toBe(true); + expect(unlinkLocal().linked).toBe(false); + }); + + it("revokes an instance and is idempotent", () => { + const id = listInstances().find((r) => !r.revoked)!.instanceId; + expect(revokeInstance(id)).toBe(true); + expect(listInstances().find((r) => r.instanceId === id)?.revoked).toBe( + true, + ); + // Idempotent β€” revoking again still returns true. + expect(revokeInstance(id)).toBe(true); + }); + + it("returns false revoking an unknown instance", () => { + expect(revokeInstance(999_999)).toBe(false); + }); + + it("resets to seed state", () => { + linkLocal("temp"); + const grown = listInstances().length; + resetLinkStore(); + expect(listInstances().length).toBeLessThan(grown); + expect(getLocalStatus().linked).toBe(false); + }); +}); diff --git a/frontend/portal/src/mocks/link.ts b/frontend/portal/src/mocks/link.ts new file mode 100644 index 0000000000..f540f85177 --- /dev/null +++ b/frontend/portal/src/mocks/link.ts @@ -0,0 +1,147 @@ +/** + * Account-link fixtures and the types api/link.ts shares with them. + * + * "Mode A" combined billing: a self-hosted instance links the org's SaaS account + * so its unattended calls bill against the org wallet. Two surfaces: + * + * - THIS instance: the local backend (`POST /api/v1/account-link/link`, + * `GET /status`, `POST /unlink`). Linking hands the local backend the admin's + * SaaS JWT; it registers with SaaS and stores the device secret SERVER-SIDE. + * The portal only ever sees a Linked / Not-linked status β€” never the secret. + * - TEAM-WIDE management: the SaaS backend (`GET /instances`, + * `POST /instances/{id}/revoke`), called with the admin's JWT. + * + * api/link.ts imports the types; the MSW handlers in mocks/handlers/link.ts serve + * this fixture data over the intercepted apiClient.local.json() calls. Components never reach + * into this module directly. Once the real backend is wired the handlers stop + * being registered and these fixtures can be deleted (or kept as test seeds). + */ + +/* ──────────────────────────────────────────────────────────────────────── */ +/* Local backend β€” link / status / unlink (this instance) */ +/* ──────────────────────────────────────────────────────────────────────── */ + +/** Body for POST /api/v1/account-link/link β€” the SaaS JWT + optional name. */ +export interface LinkInstanceRequest { + /** Admin's SaaS session JWT, obtained via the hosted-login popup. */ + supabaseJwt: string; + /** Optional label for this instance. */ + name?: string; +} + +/** Link status for this instance (GET /api/v1/account-link/status). */ +export interface LinkStatus { + linked: boolean; + /** Display name the local backend stored at link time; null when unset. */ + name: string | null; +} + +/* ──────────────────────────────────────────────────────────────────────── */ +/* SaaS backend β€” team-wide instance management */ +/* ──────────────────────────────────────────────────────────────────────── */ + +/** A linked instance row (GET /api/v1/account-link/instances). */ +export interface LinkedInstanceRow { + instanceId: number; + deviceId: string; + name: string | null; + /** ISO timestamp the instance was registered. */ + createdAt: string | null; + /** ISO timestamp the instance last presented its credential; null if never. */ + lastSeenAt: string | null; + revoked: boolean; +} + +/* ──────────────────────────────────────────────────────────────────────── */ +/* Mock store β€” link/unlink/revoke mutate this so the surface feels live */ +/* ──────────────────────────────────────────────────────────────────────── */ + +function seedInstances(): LinkedInstanceRow[] { + return [ + { + instanceId: 1001, + deviceId: "8f2c1d4a-6b3e-4a9f-9c10-2d5e7f1a0b34", + name: "prod-eu-gateway", + createdAt: daysAgo(28), + lastSeenAt: minutesAgo(3), + revoked: false, + }, + { + instanceId: 1002, + deviceId: "1a9b8c7d-2e3f-4051-8a6b-9c0d1e2f3a4b", + name: "staging-docker", + createdAt: daysAgo(11), + lastSeenAt: minutesAgo(140), + revoked: false, + }, + { + instanceId: 1003, + deviceId: "5d4c3b2a-1f0e-4d9c-8b7a-6e5f4d3c2b1a", + name: "retired-poc", + createdAt: daysAgo(96), + lastSeenAt: daysAgo(40), + revoked: true, + }, + ]; +} + +let store: LinkedInstanceRow[] = seedInstances(); +let nextId = 1004; +let localStatus: LinkStatus = { linked: false, name: null }; + +/** Resets the mock store + local link status to seed state (Storybook / tests). */ +export function resetLinkStore(): void { + store = seedInstances(); + nextId = 1004; + localStatus = { linked: false, name: null }; +} + +/** Current local link status for this instance. */ +export function getLocalStatus(): LinkStatus { + return { ...localStatus }; +} + +/** + * Links this instance: the local backend would register with SaaS and persist + * the device secret itself. The mock just appends a row and flips local status β€” + * no secret is ever surfaced. + */ +export function linkLocal(name?: string): LinkStatus { + const instanceId = nextId++; + store.push({ + instanceId, + deviceId: crypto.randomUUID(), + name: name ?? null, + createdAt: new Date().toISOString(), + lastSeenAt: null, + revoked: false, + }); + localStatus = { linked: true, name: name ?? null }; + return getLocalStatus(); +} + +/** Unlinks this instance locally. */ +export function unlinkLocal(): LinkStatus { + localStatus = { linked: false, name: null }; + return getLocalStatus(); +} + +/** All instances for the org, newest first (includes revoked). */ +export function listInstances(): LinkedInstanceRow[] { + return [...store].sort((a, b) => b.instanceId - a.instanceId); +} + +/** Revokes an instance by id. Returns false if not found. Idempotent. */ +export function revokeInstance(instanceId: number): boolean { + const row = store.find((i) => i.instanceId === instanceId); + if (!row) return false; + row.revoked = true; + return true; +} + +function daysAgo(n: number): string { + return new Date(Date.now() - n * 86_400_000).toISOString(); +} +function minutesAgo(n: number): string { + return new Date(Date.now() - n * 60_000).toISOString(); +} diff --git a/frontend/portal/src/mocks/pipelines.ts b/frontend/portal/src/mocks/pipelines.ts index 3183581f53..d39e7b7083 100644 --- a/frontend/portal/src/mocks/pipelines.ts +++ b/frontend/portal/src/mocks/pipelines.ts @@ -1,7 +1,7 @@ /** * Pipelines fixtures and the types api/pipelines.ts shares with them. * api/pipelines.ts imports the types; the MSW handlers in mocks/handlers/ - * serve the fixture data over the intercepted httpJson() calls. Components + * serve the fixture data over the intercepted apiClient.local.json() calls. Components * never reach into this module directly. * * Fixtures are tier-shaped: diff --git a/frontend/portal/src/mocks/policies.ts b/frontend/portal/src/mocks/policies.ts index ccf28345ec..76339e5acd 100644 --- a/frontend/portal/src/mocks/policies.ts +++ b/frontend/portal/src/mocks/policies.ts @@ -16,7 +16,7 @@ * ReactNode icons replaced by string icon keys (the portal renders its own). * * api/policies.ts re-exports these types; the MSW handlers serve the fixture - * data over intercepted httpJson() calls. Components never reach in here. + * data over intercepted apiClient.local.json() calls. Components never reach in here. */ /* ──────────────────────────────────────────────────────────────────────── */ diff --git a/frontend/portal/src/mocks/sdkComponents.ts b/frontend/portal/src/mocks/sdkComponents.ts index b5c5d0c060..aed16d2028 100644 --- a/frontend/portal/src/mocks/sdkComponents.ts +++ b/frontend/portal/src/mocks/sdkComponents.ts @@ -8,7 +8,7 @@ * price, an install/usage snippet, and its key props. * * api/sdkComponents.ts imports the types; the MSW handlers serve the fixture - * data over the intercepted httpJson() calls. Components never reach into this + * data over the intercepted apiClient.local.json() calls. Components never reach into this * module directly. Once a real backend exists the handlers stop being * registered and these fixtures can be deleted (or kept as test seeds). */ diff --git a/frontend/portal/src/mocks/settings.ts b/frontend/portal/src/mocks/settings.ts index be98a3fb07..99bcf313cb 100644 --- a/frontend/portal/src/mocks/settings.ts +++ b/frontend/portal/src/mocks/settings.ts @@ -1,7 +1,7 @@ /** * Account-settings fixtures and the types api/settings.ts shares with them. * api/settings.ts imports the types; the MSW handlers in mocks/handlers/ serve - * the fixture data over the intercepted httpJson() call. Components never reach + * the fixture data over the intercepted apiClient.local.json() call. Components never reach * into this module directly. * * The shape is tier-aware: the workspace plan label, available regions, and @@ -97,9 +97,9 @@ const REGIONS: RegionOption[] = [ ]; const PLAN_LABEL: Record = { - free: "Free Plan", - pro: "Pay-as-you-go", - enterprise: "Enterprise Plan", + free: "Editor plan", + pro: "Processor plan", + enterprise: "Enterprise plan", }; const SEATS: Record = { diff --git a/frontend/portal/src/mocks/usage.ts b/frontend/portal/src/mocks/usage.ts deleted file mode 100644 index 8fb6068549..0000000000 --- a/frontend/portal/src/mocks/usage.ts +++ /dev/null @@ -1,341 +0,0 @@ -/** - * Usage & Billing fixtures and the types api/usage.ts shares with them. - * api/usage.ts imports the types; the MSW handlers in mocks/handlers/usage.ts - * serve this fixture data over the intercepted httpJson() calls. Components - * never reach into this module directly. - * - * Once a real billing backend exists, the MSW handlers stop being registered - * and these fixtures can be deleted (or kept as test seeds). - */ - -import type { Tier } from "@portal/contexts/TierContext"; -import { - buildUsageSeries, - buildUsageSeriesResponse, - type UsageSeriesResponse, -} from "@portal/mocks/home"; - -export type { UsagePoint, UsageSeriesResponse } from "@portal/mocks/home"; - -/** Per-doc rate charged once the free cap is exceeded (pay-as-you-go). */ -export const OVERAGE_RATE = 0.05; - -/** Documents included before overage / cap kicks in, per tier. */ -export const TIER_DOC_CAP: Record = { - free: 500, - pro: 25_000, - enterprise: 2_000_000, -}; - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Billing summary (KPI strip + plan card) */ -/* ──────────────────────────────────────────────────────────────────────── */ - -export interface BillingSummary { - tier: Tier; - /** Human plan name shown on the current-plan card. */ - planName: string; - /** Docs processed in the current billing period. */ - docsThisPeriod: number; - /** Included docs before overage / cap. */ - includedDocs: number; - /** Cost accrued this month, in USD. */ - costThisMonth: number; - /** Overage docs past the included cap (0 when under). */ - overageDocs: number; - /** Overage cost this month, in USD. */ - overageCost: number; - /** Per-doc overage rate, in USD. */ - overageRate: number; - /** Fixed monthly platform fee, in USD (0 for free / usage-only). */ - monthlyFee: number; - /** ISO date the next invoice closes. */ - nextBillingDate: string; - /** Optional user-set hard spend cap for the month, in USD. */ - spendCap: number | null; - /** True when the free plan's doc cap has been reached. */ - capReached: boolean; -} - -/** Builds a deterministic billing summary for a tier from the usage series. */ -export function buildBillingSummary(tier: Tier): BillingSummary { - const docs30d = buildUsageSeries().reduce((sum, p) => sum + p.value, 0); - const included = TIER_DOC_CAP[tier]; - const nextBillingDate = nextMonthFirst(); - - if (tier === "free") { - // Free is gated, not metered β€” show progress toward the hard cap. 463/500 - // sits in the "approaching cap" band that drives the upgrade nudge. - const docs = 463; - return { - tier, - planName: "Free", - docsThisPeriod: docs, - includedDocs: included, - costThisMonth: 0, - overageDocs: 0, - overageCost: 0, - overageRate: OVERAGE_RATE, - monthlyFee: 0, - nextBillingDate, - spendCap: null, - capReached: docs >= included, - }; - } - - if (tier === "enterprise") { - // Committed-volume contract β€” usage sits comfortably inside the commit. - return { - tier, - planName: "Enterprise (committed)", - docsThisPeriod: docs30d, - includedDocs: included, - costThisMonth: 18_000, - overageDocs: 0, - overageCost: 0, - overageRate: 0.018, - monthlyFee: 18_000, - nextBillingDate, - spendCap: null, - capReached: false, - }; - } - - // Pro: pay-as-you-go with a small platform fee + metered overage. - const monthlyFee = 49; - const overageDocs = Math.max(0, docs30d - included); - const overageCost = +(overageDocs * OVERAGE_RATE).toFixed(2); - return { - tier, - planName: "Pay-as-you-go", - docsThisPeriod: docs30d, - includedDocs: included, - costThisMonth: +(monthlyFee + overageCost).toFixed(2), - overageDocs, - overageCost, - overageRate: OVERAGE_RATE, - monthlyFee, - nextBillingDate, - spendCap: 2_500, - capReached: false, - }; -} - -/** First day of next month, ISO (YYYY-MM-DD) β€” the next invoice close date. */ -function nextMonthFirst(): string { - const now = new Date(); - const next = new Date(now.getFullYear(), now.getMonth() + 1, 1); - return next.toISOString().slice(0, 10); -} - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Plan catalogue (current + available plan cards) */ -/* ──────────────────────────────────────────────────────────────────────── */ - -export interface PlanOption { - tier: Tier; - name: string; - /** Headline price line, e.g. "$0" or "$0.05 / doc". */ - price: string; - /** Cadence sub-line under the price, e.g. "forever" or "+ $49/mo platform". */ - priceCadence: string; - /** One-line positioning blurb. */ - blurb: string; - /** Bullet feature list. */ - features: string[]; -} - -export const PLAN_OPTIONS: PlanOption[] = [ - { - tier: "free", - name: "Free", - price: "$0", - priceCadence: "forever", - blurb: "Kick the tyres on a single project.", - features: [ - "500 docs / month", - "All single operations", - "1 pipeline Β· 1 agent", - "Community support", - ], - }, - { - tier: "pro", - name: "Pay-as-you-go", - price: "$0.05", - priceCadence: "per doc Β· + $49/mo platform", - blurb: "Scale with usage, only pay for what you process.", - features: [ - "25,000 docs included", - "Unlimited pipelines & agents", - "Overage at $0.05 / doc", - "Email support Β· 99.9% SLA", - ], - }, - { - tier: "enterprise", - name: "Enterprise", - price: "Custom", - priceCadence: "committed annual volume", - blurb: "Committed volume, bespoke terms, dedicated regions.", - features: [ - "Committed-volume pricing", - "Dedicated & on-prem regions", - "SSO Β· audit log export Β· DPA", - "Named CSM Β· 99.99% SLA", - ], - }, -]; - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Billing history table */ -/* ──────────────────────────────────────────────────────────────────────── */ - -export type InvoiceStatus = "paid" | "due" | "pending" | "refunded"; - -export interface BillingHistoryRow { - id: string; - /** ISO date the line item posted. */ - date: string; - description: string; - /** Docs attributed to the line item (0 for flat fees / credits). */ - docs: number; - /** Amount in USD; negative for credits / refunds. */ - amount: number; - status: InvoiceStatus; -} - -export function buildBillingHistory(tier: Tier): BillingHistoryRow[] { - if (tier === "free") { - // Free has no charges β€” just the running tally line. - return [ - { - id: "bh-free-1", - date: monthsAgo(0), - description: "Free plan usage Β· 463 / 500 docs", - docs: 463, - amount: 0, - status: "pending", - }, - { - id: "bh-free-2", - date: monthsAgo(1), - description: "Free plan usage Β· 500 / 500 docs (capped)", - docs: 500, - amount: 0, - status: "paid", - }, - ]; - } - - if (tier === "enterprise") { - return [ - { - id: "bh-ent-1", - date: monthsAgo(0), - description: "Committed volume Β· annual contract (monthly draw)", - docs: 1_240_511, - amount: 18_000, - status: "pending", - }, - { - id: "bh-ent-2", - date: monthsAgo(1), - description: "Committed volume Β· annual contract (monthly draw)", - docs: 1_188_204, - amount: 18_000, - status: "paid", - }, - { - id: "bh-ent-3", - date: monthsAgo(1), - description: "Dedicated region Β· ap-southeast-1 provisioning", - docs: 0, - amount: 4_500, - status: "paid", - }, - { - id: "bh-ent-4", - date: monthsAgo(2), - description: "Committed volume Β· annual contract (monthly draw)", - docs: 1_092_876, - amount: 18_000, - status: "paid", - }, - { - id: "bh-ent-5", - date: monthsAgo(3), - description: "Overage credit Β· region migration goodwill", - docs: 0, - amount: -1_200, - status: "refunded", - }, - ]; - } - - // Pro: platform fee + metered overage each cycle. - const docs30d = buildUsageSeries().reduce((sum, p) => sum + p.value, 0); - const overage = Math.max(0, docs30d - TIER_DOC_CAP.pro); - return [ - { - id: "bh-pro-1", - date: monthsAgo(0), - description: "Platform fee Β· current cycle", - docs: 0, - amount: 49, - status: "due", - }, - { - id: "bh-pro-2", - date: monthsAgo(0), - description: `Document overage Β· ${overage.toLocaleString()} docs @ $0.05`, - docs: overage, - amount: +(overage * OVERAGE_RATE).toFixed(2), - status: "due", - }, - { - id: "bh-pro-3", - date: monthsAgo(1), - description: "Platform fee Β· last cycle", - docs: 0, - amount: 49, - status: "paid", - }, - { - id: "bh-pro-4", - date: monthsAgo(1), - description: "Document overage Β· 31,402 docs @ $0.05", - docs: 31_402, - amount: 1_570.1, - status: "paid", - }, - { - id: "bh-pro-5", - date: monthsAgo(2), - description: "Platform fee Β· prior cycle", - docs: 0, - amount: 49, - status: "paid", - }, - { - id: "bh-pro-6", - date: monthsAgo(2), - description: "Document overage Β· 18,945 docs @ $0.05", - docs: 18_945, - amount: 947.25, - status: "paid", - }, - ]; -} - -/** N whole months back from today, on the 1st, ISO (YYYY-MM-DD). */ -function monthsAgo(n: number): string { - const now = new Date(); - const d = new Date(now.getFullYear(), now.getMonth() - n, 1); - return d.toISOString().slice(0, 10); -} - -/** Full usage payload for the 30-day chart β€” the same series Home charts. */ -export function buildUsagePayload(): UsageSeriesResponse { - return buildUsageSeriesResponse(); -} diff --git a/frontend/portal/src/mocks/users.ts b/frontend/portal/src/mocks/users.ts index 2c42cc04c7..136a9b07bb 100644 --- a/frontend/portal/src/mocks/users.ts +++ b/frontend/portal/src/mocks/users.ts @@ -7,7 +7,7 @@ * tier-scoped access controls (seat limits, MFA, sessions, SSO/SCIM). * * api/users.ts imports the types; the MSW handlers serve the fixture data over - * the intercepted httpJson() calls. Components never reach into this module + * the intercepted apiClient.local.json() calls. Components never reach into this module * directly. Once a real backend exists the handlers stop being registered and * these fixtures can be deleted (or kept as test seeds). */ diff --git a/frontend/portal/src/views/AccountLink.css b/frontend/portal/src/views/AccountLink.css new file mode 100644 index 0000000000..07b49a03f1 --- /dev/null +++ b/frontend/portal/src/views/AccountLink.css @@ -0,0 +1,125 @@ +.portal-link { + display: flex; + flex-direction: column; + gap: 1.25rem; + padding: 1.5rem; + max-width: 72rem; + margin: 0 auto; +} + +/* Header */ +.portal-link__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.portal-link__page-title { + margin: 0; + font-size: 1.375rem; + font-weight: 600; + color: var(--color-text-1); +} + +.portal-link__page-sub { + margin: 0.25rem 0 0; + font-size: 0.8125rem; + color: var(--color-text-4); + max-width: 44rem; +} + +/* Link account card */ +.portal-link__card { + display: flex; + flex-direction: column; + gap: 0.875rem; +} + +.portal-link__card-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem; +} + +.portal-link__eyebrow { + display: block; + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-text-5); +} + +.portal-link__title { + margin: 0.125rem 0 0; + font-size: 1.125rem; + font-weight: 600; + color: var(--color-text-1); +} + +.portal-link__form, +.portal-link__register { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.portal-link__form-actions, +.portal-link__actions { + display: flex; + align-items: center; + gap: 0.625rem; + flex-wrap: wrap; +} + +/* Instances section */ +.portal-link__instances { + display: flex; + flex-direction: column; + gap: 0.875rem; +} + +.portal-link__section-title { + margin: 0; + font-size: 0.9375rem; + font-weight: 600; + color: var(--color-text-1); +} + +.portal-link__section-sub { + margin: 0.25rem 0 0; + font-size: 0.75rem; + color: var(--color-text-4); + max-width: 44rem; +} + +.portal-link__skeleton { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +/* Table cells */ +.portal-link__cell-stack { + display: flex; + flex-direction: column; + gap: 0.125rem; +} + +.portal-link__cell-strong { + font-weight: 600; + color: var(--color-text-1); +} + +.portal-link__device-id { + font-size: 0.6875rem; + color: var(--color-text-5); + font-variant-numeric: tabular-nums; +} + +.portal-link__muted { + color: var(--color-text-4); + font-size: 0.8125rem; +} diff --git a/frontend/portal/src/views/Usage.css b/frontend/portal/src/views/Usage.css index bf39aeb105..c03e790680 100644 --- a/frontend/portal/src/views/Usage.css +++ b/frontend/portal/src/views/Usage.css @@ -1,18 +1,34 @@ .portal-usage { display: flex; flex-direction: column; - gap: 1.25rem; - padding: 1.5rem; - max-width: 84rem; - margin: 0 auto; } -/* Header */ +/* Sticky page header: a full-bleed bar (sidebar tint + bottom border). The + scroll container is .portal-shell__view (overflow-y:auto), whose top already + sits just below the global .portal-header β€” so this pins at top:0 of the view, + flush under the global bar. */ .portal-usage__header { + position: sticky; + top: 0; + z-index: 5; + background: var(--color-sidebar-bg); + border-bottom: 1px solid var(--color-border); +} +.portal-usage__header-inner { display: flex; - align-items: flex-start; + align-items: center; justify-content: space-between; gap: 1rem; + padding: 1rem 1.5rem; +} + +/* Body is full-bleed (just padding) β€” the billing surface runs edge-to-edge to + match marketing, same as the free view. */ +.portal-usage__body { + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1.5rem; } .portal-usage__title { @@ -90,6 +106,48 @@ color: var(--color-text-1); } +/* Wallet contract card */ +.portal-usage__wallet { + display: flex; + flex-direction: column; + gap: 0.875rem; +} + +.portal-usage__wallet-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem; +} + +.portal-usage__wallet-badges { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +/* Wallet hero metric β€” mirrors the SaaS plan-card price block */ +.portal-usage__wallet-hero { + display: flex; + align-items: baseline; + gap: 0.5rem; + flex-wrap: wrap; +} + +.portal-usage__wallet-hero-value { + font-size: 2.25rem; + font-weight: 600; + line-height: 1; + color: var(--color-text-1); + font-variant-numeric: tabular-nums; +} + +.portal-usage__wallet-hero-unit { + font-size: 0.875rem; + color: var(--color-text-4); +} + /* Cap progress (free) */ .portal-usage__cap { display: flex; diff --git a/frontend/portal/src/views/Usage.tsx b/frontend/portal/src/views/Usage.tsx index 149b3872ae..172b88c3cf 100644 --- a/frontend/portal/src/views/Usage.tsx +++ b/frontend/portal/src/views/Usage.tsx @@ -1,105 +1,211 @@ -import { useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Card, Skeleton, StatusBadge } from "@shared/components"; -import { useTier } from "@portal/contexts/TierContext"; -import { useAsync } from "@portal/hooks/useAsync"; +import { Banner, Button, Skeleton } from "@shared/components"; +import { useLink } from "@portal/contexts/LinkContext"; +import { useUI } from "@portal/contexts/UIContext"; +import { fetchWallet, type Wallet } from "@portal/api/billing"; +import { useStripePortal } from "@portal/hooks/useStripePortal"; +import { LinkAccountPrompt } from "@portal/components/billing/LinkAccountPrompt"; +import { FreePlanView } from "@portal/components/billing/FreePlanView"; +import { SubscribedPlanView } from "@portal/components/billing/SubscribedPlanView"; import { - fetchBillingSummary, - fetchPlanOptions, - type BillingSummary, - type PlanOption, -} from "@portal/api/usage"; -import { UsageChart } from "@portal/components/usage/UsageChart"; -import { BillingKpiStrip } from "@portal/components/usage/BillingKpiStrip"; -import { CurrentPlanCard } from "@portal/components/usage/CurrentPlanCard"; -import { SpendCapControl } from "@portal/components/usage/SpendCapControl"; -import { AvailablePlans } from "@portal/components/usage/AvailablePlans"; -import { BillingHistoryTable } from "@portal/components/usage/BillingHistoryTable"; -import { UpgradeModal } from "@portal/components/usage/UpgradeModal"; + HttpError, + SaasNotLinkedError, + SaasUnconfiguredError, +} from "@portal/api/http"; import "@portal/views/Usage.css"; +import "@portal/components/billing/billing.css"; +/** + * Billing & usage page. State-driven by the link/subscription dimension β€” + * NOT by the legacy {@code tier} prop: + * + * unlinked β†’ LinkAccountPrompt + * linked-free β†’ FreePlanView (free meter + PAYG explainer) + * linked-subscribed β†’ SubscribedPlanView (period meter, cap, members, + * invoices, Stripe portal) + * + * Wallet comes from {@code GET /api/v1/payg/wallet} (apiClient.saas). After a + * subscription flip via Stripe checkout / cancel via the portal, the + * onWalletChange refresh re-reads and the view re-dispatches on the new + * status. + */ export function Usage() { const { t } = useTranslation(); - const { tier } = useTier(); - const [modalOpen, setModalOpen] = useState(false); - const [modalTarget, setModalTarget] = useState(null); + const { isLinked, setLinkState, saasSessionNonce } = useLink(); + const { openLinkModal } = useUI(); + const [wallet, setWallet] = useState(null); + const [loading, setLoading] = useState(isLinked); + const [error, setError] = useState(null); + // The instance is linked but the browser's SaaS session has lapsed β€” needs a + // re-sign-in, NOT a re-link. + const [needsReauth, setNeedsReauth] = useState(false); + // Briefly polling the wallet after a successful checkout until the webhook flips + // it to subscribed. + const [finalizing, setFinalizing] = useState(false); + const [refreshKey, setRefreshKey] = useState(0); + // Stripe customer portal β€” the subscribed header's "Manage Payment" action. + const portal = useStripePortal(wallet); + // Guards the post-checkout poll loop from setState after unmount. + const mounted = useRef(true); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); - const summaryState = useAsync( - () => fetchBillingSummary(tier), - [tier], - ); - const summary = summaryState.loading ? null : summaryState.data; + useEffect(() => { + // Only fetch the wallet when the instance is linked. Unlinked β†’ render the + // link prompt; no SaaS call needed. + if (!isLinked) { + setWallet(null); + setLoading(false); + setError(null); + setNeedsReauth(false); + return; + } + let cancelled = false; + setLoading(true); + setError(null); + setNeedsReauth(false); + fetchWallet() + .then((w) => { + if (cancelled) return; + setWallet(w); + // Derive the linked-free / linked-subscribed dimension from the live + // wallet. Only refines a `linked-*` state; never flips unlinked β†’ linked. + setLinkState( + w.status === "subscribed" ? "linked-subscribed" : "linked-free", + ); + }) + .catch((e) => { + if (cancelled) return; + if (e instanceof SaasNotLinkedError) { + // Reached only when the instance IS linked (we don't fetch otherwise), + // so this means the attended SaaS session expired β€” prompt re-sign-in. + setNeedsReauth(true); + } else if (e instanceof SaasUnconfiguredError) { + setError(e.message); + } else if (e instanceof HttpError) { + setError( + t("usage.error.walletUnavailable", { + status: e.status, + statusText: e.statusText, + }), + ); + } else { + setError(e instanceof Error ? e.message : String(e)); + } + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [isLinked, refreshKey, saasSessionNonce, setLinkState]); - const plansState = useAsync(() => fetchPlanOptions(), []); - const { data: plans } = plansState; + const refresh = useCallback(() => setRefreshKey((k) => k + 1), []); - function openUpgrade(target: PlanOption | null) { - setModalTarget(target); - setModalOpen(true); - } + const confirmSubscription = useCallback(async () => { + // Stripe's onComplete fires before the subscription webhook lands, so poll the + // wallet briefly until it flips to subscribed rather than dropping the + // just-paid admin back on the free CTA. + setFinalizing(true); + for (let i = 0; i < 10; i++) { + try { + const w = await fetchWallet(); + if (!mounted.current) return; + if (w.status === "subscribed") { + setWallet(w); + setLinkState("linked-subscribed"); + setFinalizing(false); + return; + } + } catch { + // Transient read failure β€” keep polling. + } + await new Promise((r) => setTimeout(r, 2000)); + if (!mounted.current) return; + } + // Webhook still hasn't landed after ~20s: stop blocking and refresh. The page + // self-heals on the next load once provisioning completes. + setFinalizing(false); + setRefreshKey((k) => k + 1); + }, [setLinkState]); return ( -
+
-
-

{t("usage.title")}

-

{t("usage.subtitle")}

+
+
+

{t("usage.title")}

+

{t("usage.subtitle")}

+
+ {wallet?.status === "subscribed" && ( + + )}
- - {summary?.planName ?? "β€”"} -
- +
+ {!isLinked && } - - -
- {summary ? ( - openUpgrade(null)} - /> - ) : ( - - - - + {isLinked && loading && ( +
+ + +
)} - {summary ? ( - - ) : ( - - - - + + {isLinked && finalizing && ( + + {t("usage.finalizing.body")} + + )} + + {isLinked && needsReauth && ( + openLinkModal("reauth")}> + {t("usage.sessionExpired.action")} + + } + > + {t("usage.sessionExpired.body")} + + )} + + {isLinked && error && ( + + {error} + + )} + + {isLinked && portal.error && ( + + {portal.error} + + )} + + {isLinked && !finalizing && wallet && wallet.status === "free" && ( + + )} + + {isLinked && wallet && wallet.status === "subscribed" && ( + )}
- - {plans && plans.length > 0 && ( - // Any plan selection routes through the intent-aware upgrade modal; the - // target plan drives whether the copy is an upgrade pitch or a - // downgrade / sales conversation. - - )} - - - - setModalOpen(false)} - currentTier={tier} - target={modalTarget} - />
); } diff --git a/frontend/portal/src/views/Users.tsx b/frontend/portal/src/views/Users.tsx index 5e985fdc01..c7ffac50df 100644 --- a/frontend/portal/src/views/Users.tsx +++ b/frontend/portal/src/views/Users.tsx @@ -1,4 +1,5 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; +import { useSearchParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { Button, EmptyState, Skeleton } from "@shared/components"; import { useTier } from "@portal/contexts/TierContext"; @@ -25,6 +26,17 @@ export function Users() { const [inviteOpen, setInviteOpen] = useState(false); + // Deep-link from elsewhere (e.g. billing's "Invite teammates"): ?invite opens + // the modal, then the param is cleared so a refresh/back doesn't re-open it. + const [searchParams, setSearchParams] = useSearchParams(); + useEffect(() => { + if (searchParams.get("invite") === null) return; + setInviteOpen(true); + const next = new URLSearchParams(searchParams); + next.delete("invite"); + setSearchParams(next, { replace: true }); + }, [searchParams, setSearchParams]); + const members = data?.members ?? []; // Row actions are non-functional shells until the backend exists; each logs diff --git a/frontend/portal/src/vite-env.d.ts b/frontend/portal/src/vite-env.d.ts index 6da12835bf..73919fcfe8 100644 --- a/frontend/portal/src/vite-env.d.ts +++ b/frontend/portal/src/vite-env.d.ts @@ -1,6 +1,14 @@ /// interface ImportMetaEnv { + /** Hosted SaaS Supabase project URL β€” in-app account-link login. Empty β†’ link UI shows a configure state. */ + readonly VITE_SAAS_SUPABASE_URL: string; + /** Hosted SaaS Supabase anon/publishable key (public). */ + readonly VITE_SAAS_SUPABASE_ANON_KEY: string; + /** Hosted SaaS Java backend base URL β€” attended portalβ†’SaaS reads (wallet, invoices, …) via apiClient.saas with the admin's JWT. */ + readonly VITE_SAAS_API_URL: string; + /** Stripe publishable key (pk_live_… / pk_test_…) used by embedded Checkout. */ + readonly VITE_STRIPE_PUBLISHABLE_KEY: string; /** URL of the editor app (app switcher + non-admin redirect). See portal/.env. */ readonly VITE_EDITOR_URL: string; /** Force MSW mocks on/off ("true"/"false"); empty falls back to dev default. */ diff --git a/frontend/shared/auth/ui/OAuthButtons.tsx b/frontend/shared/auth/ui/OAuthButtons.tsx index 4eb2810c4d..2651e14c36 100644 --- a/frontend/shared/auth/ui/OAuthButtons.tsx +++ b/frontend/shared/auth/ui/OAuthButtons.tsx @@ -31,7 +31,11 @@ export const oauthProviderConfig: Record< interface OAuthButtonsProps { onProviderClick: (provider: OAuthProvider) => void; isSubmitting: boolean; - layout?: "vertical" | "grid" | "icons"; + /** + * `fullwidth` is the SaaS-login canonical look β€” rounded pill buttons stacked + * vertically. `vertical` (default) uses the Spring/Mantine button shape. + */ + layout?: "vertical" | "grid" | "icons" | "fullwidth"; enabledProviders?: OAuthProvider[]; // List of full auth paths from backend (e.g., '/oauth2/authorization/google', '/saml2/authenticate/stirling') ctaPrefix?: string; styleVariant?: "neutral" | "tinted" | "outline" | "light"; @@ -125,6 +129,39 @@ export default function OAuthButtons({ ); } + if (layout === "fullwidth") { + // Mirrors the SaaS editor login: rounded pill buttons stacked vertically, + // each with the provider's icon + "Sign in with X" label. + return ( +
+ {providers.map((p) => ( + + ))} +
+ ); + } + if (layout === "grid") { return (
diff --git a/frontend/shared/auth/ui/SupabaseLoginForm.tsx b/frontend/shared/auth/ui/SupabaseLoginForm.tsx new file mode 100644 index 0000000000..7550bc88a5 --- /dev/null +++ b/frontend/shared/auth/ui/SupabaseLoginForm.tsx @@ -0,0 +1,89 @@ +import { useTranslation } from "react-i18next"; +import ErrorMessage from "@shared/auth/ui/ErrorMessage"; +import EmailPasswordForm from "@shared/auth/ui/EmailPasswordForm"; +import OAuthButtons from "@shared/auth/ui/OAuthButtons"; +import type { SupabaseLoginState } from "@shared/auth/ui/useSupabaseLogin"; +import "@shared/auth/ui/auth.css"; + +interface SupabaseLoginFormProps { + /** Login state + handlers, from useSupabaseLogin. */ + state: SupabaseLoginState; + /** Optional logo rendered above the form. */ + logoSrc?: string; + logoAlt?: string; +} + +/** + * Supabase counterpart to {@link SpringLoginForm}: the shared login body (error, + * SSO buttons, divider, email/password) wired to {@link useSupabaseLogin}. Reuses + * the same presentational pieces as the Spring form so it matches the SaaS login. + */ +export default function SupabaseLoginForm({ + state, + logoSrc, + logoAlt = "Stirling PDF", +}: SupabaseLoginFormProps) { + const { t } = useTranslation(); + const { + error, + providers, + hasProviders, + isSubmitting, + email, + password, + setEmail, + setPassword, + signInWithEmail, + signInWithProvider, + } = state; + + return ( + <> + {logoSrc && ( +
+ {logoAlt} +
+ )} + + + + {hasProviders && ( + + )} + + {hasProviders && ( +
+ + {t("signup.or", "or")} + +
+ )} + +
+ +
+ + ); +} diff --git a/frontend/shared/auth/ui/auth.css b/frontend/shared/auth/ui/auth.css index ce3d663a64..3fce26f7ce 100644 --- a/frontend/shared/auth/ui/auth.css +++ b/frontend/shared/auth/ui/auth.css @@ -729,3 +729,55 @@ text-align: center; flex-shrink: 0; } + +/* ── Fullwidth OAuth (SaaS-screen canonical look β€” shared so the portal link + modal matches the SaaS editor login exactly) ──────────────────────────── */ +.oauth-container-fullwidth { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.oauth-button-fullwidth { + width: 100%; + display: flex; + align-items: center; + justify-content: center; + padding: 0.75rem 1rem; + border: 1px solid #d1d5db; + border-radius: 100px; + background-color: #ffffff; + font-size: 1rem; + font-weight: 600; + color: #000000; + cursor: pointer; + gap: 0.5rem; + box-shadow: 0 0.125rem 0.375rem rgba(0, 0, 0, 0.04); + transition: + background-color 150ms ease, + box-shadow 150ms ease, + border-color 150ms ease; +} + +.oauth-button-fullwidth:disabled { + cursor: not-allowed; + opacity: 0.6; +} + +.oauth-button-fullwidth:hover:not(:disabled) { + background-color: #fafafa; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); +} + +[data-mantine-color-scheme="dark"] .oauth-button-fullwidth { + background-color: var(--bg-surface); + color: var(--text-primary); + border-color: var(--border-default); + box-shadow: none; +} + +[data-mantine-color-scheme="dark"] + .oauth-button-fullwidth:hover:not(:disabled) { + background-color: var(--bg-raised); + box-shadow: none; +} diff --git a/frontend/shared/auth/ui/useSupabaseLogin.ts b/frontend/shared/auth/ui/useSupabaseLogin.ts new file mode 100644 index 0000000000..a443ff22f0 --- /dev/null +++ b/frontend/shared/auth/ui/useSupabaseLogin.ts @@ -0,0 +1,134 @@ +import { useCallback, useState } from "react"; +import { useTranslation } from "react-i18next"; +import type { Provider } from "@supabase/supabase-js"; +import { getSupabaseClient } from "@shared/auth/supabase/supabaseClient"; + +/** + * Supabase counterpart to {@link useSpringLogin}: owns a login form's state and + * sign-in handlers, wired to the shared Supabase client ({@link configureSupabase}). + * Kept out of the `@shared/auth` barrel (like the rest of the Supabase path) so + * Spring-only consumers don't pull in `@supabase/supabase-js`; import via the + * subpath. + * + * Email/password resolves inline and fires {@link UseSupabaseLoginOptions.onSuccess}. + * OAuth triggers a full-page redirect to the provider; the session arrives on + * return (the host completes the flow from the Supabase auth-state change). + */ + +export interface SupabaseLoginSession { + /** Supabase access token (JWT) the caller hands to its backend. */ + access_token: string; +} + +export interface UseSupabaseLoginOptions { + /** OAuth provider ids to surface (e.g. "google", "github", "apple", "azure"). */ + providers?: string[]; + /** OAuth return URL. The Supabase project must allow-list it. */ + redirectTo?: string; + /** Run just before an OAuth redirect (e.g. stash a pending-link marker). */ + onBeforeOAuth?: (provider: string) => void; + /** Receives the session after a successful email/password sign-in. */ + onSuccess?: (session: SupabaseLoginSession) => void | Promise; +} + +export interface SupabaseLoginState { + email: string; + setEmail: (value: string) => void; + password: string; + setPassword: (value: string) => void; + error: string | null; + setError: (value: string | null) => void; + isSubmitting: boolean; + providers: string[]; + hasProviders: boolean; + signInWithEmail: () => Promise; + signInWithProvider: (provider: string) => Promise; +} + +const NOT_CONFIGURED_KEY = "auth.supabaseUnconfigured"; +const NOT_CONFIGURED_FALLBACK = + "Account login is unavailable β€” Supabase is not configured."; + +export function useSupabaseLogin( + options: UseSupabaseLoginOptions = {}, +): SupabaseLoginState { + const { providers = [], redirectTo, onBeforeOAuth, onSuccess } = options; + const { t } = useTranslation(); + + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + const signInWithEmail = useCallback(async () => { + if (!email || !password) { + setError( + t("login.pleaseEnterBoth", "Please enter both email and password"), + ); + return; + } + const supabase = getSupabaseClient(); + if (!supabase) { + setError(t(NOT_CONFIGURED_KEY, NOT_CONFIGURED_FALLBACK)); + return; + } + try { + setIsSubmitting(true); + setError(null); + const { data, error: signInError } = + await supabase.auth.signInWithPassword({ + email: email.trim(), + password, + }); + if (signInError) { + setError(signInError.message); + } else if (data.session) { + await onSuccess?.({ access_token: data.session.access_token }); + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setIsSubmitting(false); + } + }, [email, password, onSuccess, t]); + + const signInWithProvider = useCallback( + async (provider: string) => { + const supabase = getSupabaseClient(); + if (!supabase) { + setError(t(NOT_CONFIGURED_KEY, NOT_CONFIGURED_FALLBACK)); + return; + } + try { + setIsSubmitting(true); + setError(null); + onBeforeOAuth?.(provider); + const { error: oauthError } = await supabase.auth.signInWithOAuth({ + provider: provider as Provider, + options: redirectTo ? { redirectTo } : undefined, + }); + if (oauthError) setError(oauthError.message); + // Success path redirects the browser; the session arrives on return. + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setIsSubmitting(false); + } + }, + [onBeforeOAuth, redirectTo, t], + ); + + return { + email, + setEmail, + password, + setPassword, + error, + setError, + isSubmitting, + providers, + hasProviders: providers.length > 0, + signInWithEmail, + signInWithProvider, + }; +} diff --git a/frontend/shared/billing/MeterBar.tsx b/frontend/shared/billing/MeterBar.tsx new file mode 100644 index 0000000000..5fcfa605b5 --- /dev/null +++ b/frontend/shared/billing/MeterBar.tsx @@ -0,0 +1,68 @@ +import type { ReactNode } from "react"; +import type { MeterState } from "@shared/billing/format"; + +interface MeterBarProps { + state: MeterState; + /** Fill percentage 0–100. */ + pct: number; + /** The big figure β€” a used count or a spend amount ("120", "$45"). */ + figure: ReactNode; + /** Suffix after the figure ("/ 500 free PDFs", "/ $1,000 cap", "no cap"). */ + capSuffix: ReactNode; + /** Status-chip content; null/undefined hides the chip. */ + statusLabel?: ReactNode; + /** Footer meta line; null/undefined hides the line entirely. */ + meta?: ReactNode; + /** Hide the fill bar (e.g. uncapped). Shown by default. */ + showBar?: boolean; +} + +/** + * The usage-meter bar (the {@code paygf-meter} block) shared by the editor cloud + * surface and the admin portal. Callers own the copy β€” the editor passes i18n + * strings, the portal passes literals β€” so this carries no i18n dependency. + * Styling comes from each app's own {@code paygf-meter}/{@code payg-bar}/{@code + * payg-status} CSS. + */ +export function MeterBar({ + state, + pct, + figure, + capSuffix, + statusLabel, + meta, + showBar = true, +}: MeterBarProps) { + return ( +
+
+
+ {figure} + {capSuffix} +
+ {statusLabel != null && ( + + + {statusLabel} + + )} +
+ {showBar && ( +
+
+
+ )} + {meta != null &&
{meta}
} +
+ ); +} diff --git a/frontend/shared/billing/SpendCapControl.tsx b/frontend/shared/billing/SpendCapControl.tsx new file mode 100644 index 0000000000..c81e2c0ecb --- /dev/null +++ b/frontend/shared/billing/SpendCapControl.tsx @@ -0,0 +1,203 @@ +import React, { useEffect, useState } from "react"; +import DescriptionIcon from "@mui/icons-material/DescriptionOutlined"; +import { Button } from "@shared/components"; +import { + DEFAULT_CAP_PRESETS, + currencySymbol, + docCapForMoney, + formatMinor, +} from "@shared/billing/format"; + +/** Copy the control renders. The editor passes i18n strings; the portal uses the defaults. */ +export interface SpendCapControlLabels { + custom: string; + amountAria: string; + noCap: string; + save: string; + docsEstimate: (docs: string) => string; + docsRate: (rate: string) => string; + noCapDesc: string; +} + +const DEFAULT_LABELS: SpendCapControlLabels = { + custom: "Custom", + amountAria: "Cap amount", + noCap: "No cap", + save: "Update cap", + docsEstimate: (docs) => `β‰ˆ ${docs} processed PDFs / month`, + docsRate: (rate) => `at ${rate} / PDF`, + noCapDesc: + "Usage is billed without an upper limit. You can re-enable a cap at any time.", +}; + +export interface SpendCapControlProps { + /** Current cap in major currency units; null = no cap, 0 = a real $0 cap. Controlled. */ + capUsd: number | null; + onChange: (capUsd: number | null) => void; + /** Per-document rate in minor units; null/0 hides the estimate. */ + pricePerDocMinor?: number | null; + currency?: string | null; + presets?: readonly number[]; + /** When provided, renders the inline Save button. */ + onSave?: (capUsd: number | null) => Promise | void; + /** Persisted value to diff against for the dirty check (with {@link onSave}). */ + savedCapUsd?: number | null; + /** Disable all inputs (e.g. while a parent operation is in flight). */ + disabled?: boolean; + /** Quiet helper line under the estimate. */ + note?: React.ReactNode; + labels?: Partial; +} + +/** + * Monthly spend-cap control shared by the editor cloud surface and the admin + * portal: preset chips, a custom-entry pill, a no-cap chip, an optional Save + * button, and a live capβ†’PDF estimate. Fully controlled (capUsd + onChange). + * Styling comes from each app's own {@code scc-*} CSS; copy is injected via + * {@link labels} so this carries no i18n dependency. + */ +export function SpendCapControl({ + capUsd, + onChange, + pricePerDocMinor, + currency, + presets = DEFAULT_CAP_PRESETS, + onSave, + savedCapUsd, + disabled, + note, + labels, +}: SpendCapControlProps) { + const L = { ...DEFAULT_LABELS, ...labels }; + const [saving, setSaving] = useState(false); + + const sym = currencySymbol(currency); + const isNoCap = capUsd === null; + const customActive = capUsd != null && !presets.includes(capUsd); + // Local mirror of the custom field's text so partial entry isn't clobbered by + // the controlled value. Parents that need it to resync (e.g. after a save) + // remount the control via a key. + const [customText, setCustomText] = useState( + customActive ? String(capUsd) : "", + ); + // Resync the field to an externally-loaded custom cap β€” e.g. the wallet arrives + // after first render (capUsd null/preset -> 1234), which would otherwise leave the + // field blank since customText only seeds once at mount. Gated on !focused so it + // never clobbers what the user is actively typing. + const [focused, setFocused] = useState(false); + useEffect(() => { + if (!focused && customActive && String(capUsd) !== customText) { + setCustomText(String(capUsd)); + } + }, [capUsd, customActive, focused, customText]); + const previewDocs = docCapForMoney(capUsd, pricePerDocMinor); + const dirty = onSave != null && capUsd !== (savedCapUsd ?? null); + const busy = saving || disabled; + + const selectPreset = (preset: number) => { + setCustomText(""); + onChange(preset); + }; + const selectNoCap = () => { + setCustomText(""); + onChange(null); + }; + const onCustomInput = (raw: string) => { + const cleaned = raw.replace(/[^0-9]/g, ""); + setCustomText(cleaned); + const v = cleaned === "" ? 0 : parseInt(cleaned, 10); + onChange(Number.isNaN(v) ? 0 : v); + }; + + const handleSave = async () => { + if (!onSave) return; + setSaving(true); + try { + await onSave(isNoCap ? null : Math.round(capUsd ?? 0)); + } finally { + setSaving(false); + } + }; + + return ( +
+
+ {presets.map((preset) => ( + + ))} + + + + + + {onSave && ( +
+ +
+ )} +
+ + {previewDocs != null && ( +
+ +
+
+ {L.docsEstimate(previewDocs.toLocaleString())} +
+
+ {L.docsRate(formatMinor(pricePerDocMinor ?? 0, currency))} +
+
+
+ )} + + {isNoCap &&
{L.noCapDesc}
} + {note &&
{note}
} +
+ ); +} diff --git a/frontend/shared/billing/format.ts b/frontend/shared/billing/format.ts new file mode 100644 index 0000000000..194a9bd686 --- /dev/null +++ b/frontend/shared/billing/format.ts @@ -0,0 +1,102 @@ +/** + * Pure money/meter helpers shared by the editor cloud surface and the admin + * portal. These carry backend-coupled invariants (the capβ†’PDF estimate mirrors + * the server's {@code docCapForMoney}; the meter bands mirror the BE warn/degrade + * thresholds), so keeping one copy is what stops the FE estimate silently + * diverging from the backend when a rate encoding changes. + */ + +/** Quick-amount cap presets (major currency units) offered everywhere. */ +export const DEFAULT_CAP_PRESETS = [500, 1000, 2500, 5000] as const; + +/** Compact currency symbol; falls back to the ISO code for anything unmapped. */ +export function currencySymbol(currency: string | null | undefined): string { + switch ((currency ?? "").toLowerCase()) { + case "usd": + case "": + return "$"; + case "eur": + return "€"; + case "gbp": + return "Β£"; + default: + return (currency ?? "").toUpperCase() + " "; + } +} + +/** + * Format minor units as a compact-symbol amount ("$2.24", "Β£0.40"). Uses the + * short symbol (not Intl's "US$" currency display) and allows up to 3 fraction + * digits so sub-cent per-document rates don't round to $0. + */ +export function formatMinor( + minor: number, + currency: string | null | undefined, +): string { + const num = new Intl.NumberFormat(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 3, + }).format(minor / 100); + return `${currencySymbol(currency)}${num}`; +} + +/** Format a major-unit amount with the compact symbol ("$1,000", "€500"). */ +export function formatMoneyMajor( + major: number, + currency: string | null | undefined, +): string { + return `${currencySymbol(currency)}${major.toLocaleString()}`; +} + +/** + * Paid PDFs a monthly cap buys β€” mirror of the backend's {@code docCapForMoney}: + * floor(capMinor / rate). The one-time free grant is a separate lifetime pool and + * is NOT added here. Returns null when there's no cap or no resolvable rate (the + * caller hides the estimate). + */ +export function docCapForMoney( + capUsdMajor: number | null, + pricePerDocMinor: number | null | undefined, +): number | null { + if (capUsdMajor == null) return null; + const rate = + pricePerDocMinor != null && pricePerDocMinor > 0 ? pricePerDocMinor : null; + return rate != null ? Math.floor((capUsdMajor * 100) / rate) : null; +} + +/** + * Short date for billing labels: "24 Jun" (period meters) or "24 Jun 2026" with + * {@code year}. Parses the date part of an ISO string as a local date. + */ +export function formatPeriodDate( + iso: string | null, + opts?: { year?: boolean }, +): string { + if (!iso) return ""; + const datePart = iso.split("T")[0]; + if (!datePart) return ""; + const [y, m, d] = datePart.split("-").map(Number); + if (!y || !m || !d) return datePart; + try { + return new Intl.DateTimeFormat(undefined, { + day: "numeric", + month: "short", + ...(opts?.year ? { year: "numeric" } : {}), + }).format(new Date(y, m - 1, d)); + } catch { + return datePart; + } +} + +export type MeterState = "FULL" | "WARNED" | "DEGRADED"; + +/** Warn (β‰₯80%) / degrade (β‰₯100%) band for a usage meter; mirrors the BE thresholds. */ +export function meterState( + used: number, + limit: number, +): { state: MeterState; pct: number } { + const pct = limit > 0 ? Math.min(100, (used / limit) * 100) : 100; + const state: MeterState = + pct >= 100 ? "DEGRADED" : pct >= 80 ? "WARNED" : "FULL"; + return { state, pct }; +} diff --git a/frontend/shared/billing/index.ts b/frontend/shared/billing/index.ts new file mode 100644 index 0000000000..76ab3b29d6 --- /dev/null +++ b/frontend/shared/billing/index.ts @@ -0,0 +1,24 @@ +export type { + Wallet, + WalletStatus, + WalletRole, + WalletMember, + WalletCategoryBreakdown, + WalletActivityRow, +} from "@shared/billing/types"; +export { + DEFAULT_CAP_PRESETS, + currencySymbol, + formatMinor, + formatMoneyMajor, + docCapForMoney, + formatPeriodDate, + meterState, + type MeterState, +} from "@shared/billing/format"; +export { MeterBar } from "@shared/billing/MeterBar"; +export { + SpendCapControl, + type SpendCapControlProps, + type SpendCapControlLabels, +} from "@shared/billing/SpendCapControl"; diff --git a/frontend/shared/billing/types.ts b/frontend/shared/billing/types.ts new file mode 100644 index 0000000000..83d0c66518 --- /dev/null +++ b/frontend/shared/billing/types.ts @@ -0,0 +1,67 @@ +/** + * The PAYG wallet contract β€” the single front-end mirror of the SaaS backend's + * {@code WalletSnapshotResponse} ({@code GET /api/v1/payg/wallet}). Both the + * editor cloud surface and the admin portal consume this, so a backend field + * change is a one-line update here instead of three diverging copies. + */ + +export type WalletStatus = "free" | "subscribed"; +export type WalletRole = "leader" | "member"; + +/** One team member's billing-relevant row (name/email + their period spend). */ +export interface WalletMember { + userId: string; + name: string; + email: string; + spendUnits: number; +} + +/** Current-period spend split across the billable feature buckets. */ +export interface WalletCategoryBreakdown { + api: number; + ai: number; + automation: number; +} + +/** A billable-activity row. The backend returns `[]` until the meter-event surface lands. */ +export interface WalletActivityRow { + id: number; + kind: string; + label: string; + ts: string; + docUnits: number; +} + +export interface Wallet { + /** Caller's primary team_id; null on the synthetic empty snapshot for team-less callers. */ + teamId: number | null; + status: WalletStatus; + role: WalletRole; + /** ISO yyyy-mm-dd. Stripe period when subscribed; calendar month when free. */ + billingPeriodStart: string; + billingPeriodEnd: string; + /** Free grant used (free teams) or documents processed this period (subscribed). */ + billableUsed: number; + /** Document ceiling for the window; null when subscribed-uncapped. */ + billableLimit: number | null; + /** One-time free grant size β€” a lifetime pool that survives subscribing. */ + freeAllowance: number; + /** Free grant still available; 0 = exhausted. */ + freeRemaining: number; + /** Paid per-document rate in minor units (may be fractional); null = unknown (render "unknown", never substitute). */ + pricePerDocMinor: number | null; + /** Lower-case ISO 4217; null when unknown. */ + currency: string | null; + /** Estimated charges so far this period in minor units; null when the rate is unknown. The Stripe invoice is authoritative. */ + estimatedBillMinor: number | null; + /** Monthly cap in major units when subscribed; null when noCap or free. */ + capUsd: number | null; + /** Only meaningful when subscribed. */ + noCap: boolean; + stripeSubscriptionId: string | null; + spendUnitsThisPeriod: number; + categoryBreakdown: WalletCategoryBreakdown; + /** Populated for the leader view; empty for members / single-seat tenants. */ + members: WalletMember[]; + recent: WalletActivityRow[]; +} From 5e97746721deaad03964d92e4a045aae6526b20c Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:05:58 +0100 Subject: [PATCH 10/12] UX improvement for side menu bookmark, comments and attachments (#6552) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Inline "Add bookmark" form in the bookmark sidebar (title + page, defaults to current page) - saves via /api/v1/general/edit-table-of-contents without leaving the viewer - Persistent "+ Add" rows above the list in Bookmarks, Attachments, Comments and Files sidebars (was only in empty state) - Close (X) button in every viewer sidebar header (Bookmarks, Attachments, Comments, Layers, Thumbnails) - "Add comment" button morphs into "Click a page to place… (cancel)" while textComment is armed, ESC to cancel - "Add attachment" auto-closes the attachment sidebar so you don't end up with two stacked panels - Footer link in bookmark sidebar to the full Edit Table of Contents tool for nesting/reordering - Fix: bookmark/attachment sidebars getting stuck on "Loading…" after a file swap (cache no longer caches `loading`, retry treats null bridge as not-ready) - Fix: Save silently routing to the editor tool on a fresh /read upload when `activeFileId` is still null - New Playwright tests (stubbed + live) covering Add buttons, Save flow with PDF round-trip, and close buttons 06-thumbnails 01-bookmarks 02-bookmarks-empty 03-attachments 04-comments 05-layers --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- .../public/locales/en-GB/translation.toml | 4 + .../public/locales/en-US/translation.toml | 4 + .../core/components/shared/FileSidebar.tsx | 29 + .../components/viewer/AttachmentSidebar.tsx | 106 +- .../components/viewer/BookmarkSidebar.tsx | 409 ++++++-- .../components/viewer/CommentsSidebar.tsx | 921 ++++++++++-------- .../core/components/viewer/LayerSidebar.tsx | 63 +- .../components/viewer/ThumbnailSidebar.tsx | 39 +- .../src/core/contexts/AnnotationContext.tsx | 29 +- .../live/viewer-sidebar-add-buttons.spec.ts | 218 +++++ .../viewer-sidebar-add-buttons.spec.ts | 198 ++++ frontend/editor/src/core/tools/Annotate.tsx | 19 +- 12 files changed, 1528 insertions(+), 511 deletions(-) create mode 100644 frontend/editor/src/core/tests/live/viewer-sidebar-add-buttons.spec.ts create mode 100644 frontend/editor/src/core/tests/stubbed/viewer-sidebar-add-buttons.spec.ts diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml index dba8fa41cc..67779cc07d 100644 --- a/frontend/editor/public/locales/en-GB/translation.toml +++ b/frontend/editor/public/locales/en-GB/translation.toml @@ -8003,6 +8003,8 @@ zoomIn = "Zoom In" zoomOut = "Zoom Out" [viewer.attachments] +addAttachment = "Add attachment" +close = "Close attachments" empty = "No attachments in this document" loading = "Loading attachments..." noDocument = "Open a PDF to view its attachments." @@ -8016,6 +8018,7 @@ addComment = "Add comment" addCommentPlaceholder = "Add comment..." addLink = "Add link" addReplyPlaceholder = "Add reply..." +close = "Close comments" deleteAnnotationAndComment = "Delete annotation & comment" deleteDescription = "This annotation has a comment attached. You can remove just the comment from the sidebar while keeping the annotation, or delete everything." deleteTitle = "Remove annotation from comments?" @@ -8026,6 +8029,7 @@ moreActions = "More actions" nComments_one = "{{count}} comment" nComments_other = "{{count}} comments" pageLabel = "Page {{page}}" +placingHint = "Click a page to place… (cancel)" removeCommentOnly = "Remove comment only" saveReply = "Save reply" title = "Comments" diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 5440077934..c549a6f9a1 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -8027,6 +8027,8 @@ zoomIn = "Zoom In" zoomOut = "Zoom Out" [viewer.attachments] +addAttachment = "Add attachment" +close = "Close attachments" empty = "No attachments in this document" loading = "Loading attachments..." noDocument = "Open a PDF to view its attachments." @@ -8044,6 +8046,7 @@ cancelClearAll = "Cancel" clearAll = "Clear all comments" clearAllDescription = "This removes comments and replies from the sidebar while keeping any attached annotations in the document." clearAllTitle = "Clear all comments?" +close = "Close comments" deleteAnnotationAndComment = "Delete annotation & comment" deleteDescription = "This annotation has a comment attached. You can remove just the comment from the sidebar while keeping the annotation, or delete everything." deleteTitle = "Remove annotation from comments?" @@ -8054,6 +8057,7 @@ moreActions = "More actions" nComments_one = "{{count}} comment" nComments_other = "{{count}} comments" pageLabel = "Page {{page}}" +placingHint = "Click a page to place… (cancel)" removeCommentOnly = "Remove comment only" saveReply = "Save reply" title = "Comments" diff --git a/frontend/editor/src/core/components/shared/FileSidebar.tsx b/frontend/editor/src/core/components/shared/FileSidebar.tsx index 3a14b0fb59..1eb51b759b 100644 --- a/frontend/editor/src/core/components/shared/FileSidebar.tsx +++ b/frontend/editor/src/core/components/shared/FileSidebar.tsx @@ -989,6 +989,35 @@ const FileSidebar = forwardRef(
) : filteredFileStubs.length > 0 ? (
+ {filteredFileStubs.map((stub) => { const workbenchFileId = state.files.ids.find( (id) => (id as string) === (stub.id as string), diff --git a/frontend/editor/src/core/components/viewer/AttachmentSidebar.tsx b/frontend/editor/src/core/components/viewer/AttachmentSidebar.tsx index 29f9806342..a858498bb6 100644 --- a/frontend/editor/src/core/components/viewer/AttachmentSidebar.tsx +++ b/frontend/editor/src/core/components/viewer/AttachmentSidebar.tsx @@ -4,12 +4,14 @@ import { ScrollArea, Text, ActionIcon, + Button, Loader, Stack, TextInput, } from "@mantine/core"; import LocalIcon from "@app/components/shared/LocalIcon"; import { useViewer } from "@app/contexts/ViewerContext"; +import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; import { PdfAttachmentObject } from "@embedpdf/models"; import AttachmentIcon from "@mui/icons-material/AttachmentRounded"; import DownloadIcon from "@mui/icons-material/DownloadRounded"; @@ -52,7 +54,9 @@ export const AttachmentSidebar = ({ preloadCacheKeys = [], }: AttachmentSidebarProps) => { const { t } = useTranslation(); - const { attachmentActions, hasAttachmentSupport } = useViewer(); + const { attachmentActions, hasAttachmentSupport, toggleAttachmentSidebar } = + useViewer(); + const { handleToolSelectForced } = useToolWorkflow(); const [searchTerm, setSearchTerm] = useState(""); const [attachmentSupport, setAttachmentSupport] = useState(() => hasAttachmentSupport(), @@ -139,16 +143,28 @@ export const AttachmentSidebar = ({ const key = documentCacheKey; const cached = cacheRef.current.get(key); - if ( - cached && - (cached.status === "loading" || cached.status === "success") - ) { + // Only short-circuit on a finalised success cache. Skipping when + // cached.status === "loading" caused the sidebar to get stuck: if + // the previous fetch was cancelled (by a parent re-render that + // changed the attachmentActions reference - createViewerActions + // builds a new object every viewer render), the cache still says + // "loading" but no live fetch is in flight. On the re-run we'd + // early-return and never refetch, so the UI would sit on the + // "Loading attachments..." state forever. Same change applied in + // BookmarkSidebar. + if (cached && cached.status === "success") { return; } let cancelled = false; + // Don't write "loading" into the cache - keep the cache for + // terminal states (success/error) only, so a cancelled run can + // never leave a stale "loading" entry behind. The visible + // sidebar state still goes through setActiveEntry below. const updateEntry = (entry: AttachmentCacheEntry) => { - cacheRef.current.set(key, entry); + if (entry.status === "success" || entry.status === "error") { + cacheRef.current.set(key, entry); + } if (!cancelled && currentKeyRef.current === key) { setActiveEntry(entry); } @@ -163,10 +179,20 @@ export const AttachmentSidebar = ({ ); const fetchWithRetry = async () => { - const maxAttempts = 10; + // See BookmarkSidebar - matching change. After a file swap the + // attachment bridge briefly unregisters and the action returns + // null until the new document is loaded; without retrying on + // null we'd cache an empty success and miss freshly-added + // attachments. + const maxAttempts = 30; for (let attempt = 0; attempt < maxAttempts; attempt++) { try { const result = await attachmentActions.getAttachments(); + if (result === null) { + if (attempt === maxAttempts - 1) return []; + await new Promise((resolve) => setTimeout(resolve, 50)); + continue; + } return Array.isArray(result) ? result : []; } catch (error: any) { const message = @@ -239,6 +265,14 @@ export const AttachmentSidebar = ({ attachmentActions.downloadAttachment(attachment); }; + const handleAddAttachment = useCallback(() => { + // Close the attachment sidebar before opening the tool so the user + // doesn't end up looking at two stacked side panels (the sidebar on + // the right + the tool's settings on the left). + toggleAttachmentSidebar(); + handleToolSelectForced("addAttachments"); + }, [handleToolSelectForced, toggleAttachmentSidebar]); + const filteredAttachments = useMemo(() => { const attachments = Array.isArray(activeEntry.attachments) ? activeEntry.attachments @@ -352,6 +386,18 @@ export const AttachmentSidebar = ({ {t("viewer.attachments.title", "Attachments")}
+ + + + +
+ + {t( "viewer.attachments.empty", "No attachments in this document", )} -
+ + )} {showAttachmentList && ( -
- {renderAttachments(filteredAttachments)} -
+ <> + +
+ {renderAttachments(filteredAttachments)} +
+ )} {showSearchEmpty && ( diff --git a/frontend/editor/src/core/components/viewer/BookmarkSidebar.tsx b/frontend/editor/src/core/components/viewer/BookmarkSidebar.tsx index c87c783bdf..40bd9d3045 100644 --- a/frontend/editor/src/core/components/viewer/BookmarkSidebar.tsx +++ b/frontend/editor/src/core/components/viewer/BookmarkSidebar.tsx @@ -7,10 +7,18 @@ import { Loader, Stack, TextInput, + NumberInput, Button, + Group, + UnstyledButton, } from "@mantine/core"; import LocalIcon from "@app/components/shared/LocalIcon"; import { useViewer } from "@app/contexts/ViewerContext"; +import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; +import { useFileContext } from "@app/contexts/FileContext"; +import { isStirlingFile, type FileId } from "@app/types/fileContext"; +import { createStirlingFilesAndStubs } from "@app/services/fileStubHelpers"; +import apiClient from "@app/services/apiClient"; import { PdfBookmarkObject, PdfActionType } from "@embedpdf/models"; import BookmarksIcon from "@mui/icons-material/BookmarksRounded"; import "@app/components/viewer/SidebarBase.css"; @@ -75,9 +83,25 @@ export const BookmarkSidebar = ({ documentCacheKey, preloadCacheKeys = [], }: BookmarkSidebarProps) => { - const { bookmarkActions, scrollActions, hasBookmarkSupport } = useViewer(); + const { + bookmarkActions, + scrollActions, + hasBookmarkSupport, + activeFileId, + activeFileIndex, + setActiveFileId, + getScrollState, + toggleBookmarkSidebar, + } = useViewer(); + const { handleToolSelectForced } = useToolWorkflow(); + const { selectors, actions: fileActions } = useFileContext(); const [expanded, setExpanded] = useState>({}); const [searchTerm, setSearchTerm] = useState(""); + const [isAddingBookmark, setIsAddingBookmark] = useState(false); + const [newBookmarkTitle, setNewBookmarkTitle] = useState(""); + const [newBookmarkPage, setNewBookmarkPage] = useState(1); + const [isSavingBookmark, setIsSavingBookmark] = useState(false); + const [addBookmarkError, setAddBookmarkError] = useState(null); const [bookmarkSupport, setBookmarkSupport] = useState(() => hasBookmarkSupport(), ); @@ -164,16 +188,23 @@ export const BookmarkSidebar = ({ const key = documentCacheKey; const cached = cacheRef.current.get(key); - if ( - cached && - (cached.status === "loading" || cached.status === "success") - ) { + // Only short-circuit on a finalised success cache. Skipping when + // cached.status === "loading" causes the sidebar to get stuck if + // the previous fetch was cancelled by a parent re-render (the + // bookmarkActions reference changes every viewer render because + // createViewerActions rebuilds the object). See matching change + // in AttachmentSidebar. + if (cached && cached.status === "success") { return; } let cancelled = false; + // Don't write "loading" into the cache - cache only terminal + // states so a cancelled run can't poison the cache. const updateEntry = (entry: BookmarkCacheEntry) => { - cacheRef.current.set(key, entry); + if (entry.status === "success" || entry.status === "error") { + cacheRef.current.set(key, entry); + } if (!cancelled && currentKeyRef.current === key) { setActiveEntry(entry); } @@ -188,10 +219,24 @@ export const BookmarkSidebar = ({ ); const fetchWithRetry = async () => { - const maxAttempts = 10; + // 30 Γ— 50ms = 1.5s window. After consumeFiles swaps the file the + // embedpdf bookmark plugin tears down for the old document and + // re-registers for the new one; until the bridge is back the + // action returns null. Without retrying on null we'd cache an + // empty "success" and the just-added bookmark would never show + // up in the sidebar. + const maxAttempts = 30; for (let attempt = 0; attempt < maxAttempts; attempt++) { try { const result = await bookmarkActions.fetchBookmarks(); + if (result === null) { + // Bridge not registered yet (document still loading). Wait + // and retry instead of caching this as a successful empty + // list. + if (attempt === maxAttempts - 1) return []; + await new Promise((resolve) => setTimeout(resolve, 50)); + continue; + } return Array.isArray(result) ? result : []; } catch (error: any) { const message = @@ -256,6 +301,143 @@ export const BookmarkSidebar = ({ setFetchNonce((value) => value + 1); }, [documentCacheKey, bookmarkActions]); + const handleOpenAddBookmark = useCallback(() => { + setAddBookmarkError(null); + setNewBookmarkTitle(""); + // Default the new bookmark's target page to whatever page the user is + // currently viewing - matches Acrobat / Foxit behaviour. + const currentPage = getScrollState?.()?.currentPage ?? 1; + setNewBookmarkPage(currentPage); + setIsAddingBookmark(true); + }, [getScrollState]); + + const handleCancelAddBookmark = useCallback(() => { + setIsAddingBookmark(false); + setAddBookmarkError(null); + setNewBookmarkTitle(""); + }, []); + + // Fallback: open the full Edit Table of Contents tool when inline add is + // not viable (e.g. the active file is a preview / unmanaged file we + // cannot consume + replace via FileContext). + const handleFallbackToTool = useCallback(() => { + handleToolSelectForced("editTableOfContents"); + }, [handleToolSelectForced]); + + const handleSubmitAddBookmark = useCallback(async () => { + const title = newBookmarkTitle.trim(); + if (!title) { + setAddBookmarkError("Bookmark title is required"); + return; + } + // Resolve the file the viewer is currently displaying. activeFileId + // is only set explicitly (user clicked a thumbnail / a tool ran); + // on a fresh /read upload it stays null and the viewer falls back + // to activeFileIndex - so we mirror that here. Without this, Save + // would silently route to the full editor every time on a fresh + // upload. + const allFiles = selectors.getFiles(); + const resolvedFile = activeFileId + ? allFiles.find((f) => isStirlingFile(f) && f.fileId === activeFileId) + : (allFiles[activeFileIndex] ?? allFiles[0]); + const resolvedFileId = + resolvedFile && isStirlingFile(resolvedFile) + ? (resolvedFile.fileId as FileId) + : null; + if (!resolvedFileId) { + handleFallbackToTool(); + return; + } + const fileId = resolvedFileId; + const file = selectors.getFile(fileId); + const parentStub = selectors.getStirlingFileStub(fileId); + if (!file || !parentStub) { + handleFallbackToTool(); + return; + } + + setIsSavingBookmark(true); + setAddBookmarkError(null); + try { + // Convert existing PDF bookmarks (from embedpdf) to the backend's + // payload shape, then append the new one. + const toPayload = ( + b: PdfBookmarkObject, + ): { + title: string; + pageNumber: number; + children: any[]; + } => ({ + title: b.title ?? "", + pageNumber: resolvePageNumber(b) ?? 1, + children: (b.children ?? []).map(toPayload), + }); + const existing = (activeEntry.bookmarks ?? []).map(toPayload); + const bookmarkData = [ + ...existing, + { title, pageNumber: newBookmarkPage, children: [] }, + ]; + + const formData = new FormData(); + formData.append("fileInput", file); + formData.append("replaceExisting", "true"); + formData.append("bookmarkData", JSON.stringify(bookmarkData)); + + const response = await apiClient.post( + "/api/v1/general/edit-table-of-contents", + formData, + { responseType: "blob" }, + ); + + const newFile = new File([response.data as Blob], file.name, { + type: "application/pdf", + }); + const { stirlingFiles, stubs } = await createStirlingFilesAndStubs( + [newFile], + parentStub, + "editTableOfContents", + ); + const outputFileIds = await fileActions.consumeFiles( + [fileId], + stirlingFiles, + stubs, + ); + + // Point the viewer at the new file. Without this the viewer's + // activeFileId-removed effect nulls activeFileId (old file is + // gone) and the activeFileIndex falls back to 0, which races + // against the embedpdf plugin reloading - the bookmark / + // attachment bridges can end up stuck in a "loading" state. + // useToolOperation does the same thing after consumeFiles. + if (outputFileIds.length === 1) { + setActiveFileId(outputFileIds[0]); + } + + // Reset form. The cache is keyed by documentCacheKey (== fileId); + // the new fileId triggers our document-switch effect, which + // resets state and re-fetches once the embedpdf bookmark + // capability has the new document loaded. + setIsAddingBookmark(false); + setNewBookmarkTitle(""); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to save bookmark"; + setAddBookmarkError(message); + } finally { + setIsSavingBookmark(false); + } + }, [ + newBookmarkTitle, + newBookmarkPage, + activeFileId, + activeFileIndex, + selectors, + fileActions, + setActiveFileId, + activeEntry.bookmarks, + handleFallbackToTool, + ]); + const bookmarksWithIds = useMemo(() => { const assignIds = ( nodes: PdfBookmarkObject[], @@ -293,34 +475,6 @@ export const BookmarkSidebar = ({ })); }; - const expandAll = useCallback(() => { - const allExpanded: Record = {}; - const expandRecursive = (nodes: BookmarkNode[]) => { - nodes.forEach((node) => { - if (node.children && node.children.length > 0) { - allExpanded[node.id] = true; - expandRecursive(node.children as BookmarkNode[]); - } - }); - }; - expandRecursive(bookmarksWithIds); - setExpanded(allExpanded); - }, [bookmarksWithIds]); - - const collapseAll = useCallback(() => { - const allCollapsed: Record = {}; - const collapseRecursive = (nodes: BookmarkNode[]) => { - nodes.forEach((node) => { - if (node.children && node.children.length > 0) { - allCollapsed[node.id] = false; - collapseRecursive(node.children as BookmarkNode[]); - } - }); - }; - collapseRecursive(bookmarksWithIds); - setExpanded(allCollapsed); - }, [bookmarksWithIds]); - const handleBookmarkClick = ( bookmark: PdfBookmarkObject, event: React.MouseEvent, @@ -499,31 +653,18 @@ export const BookmarkSidebar = ({ Bookmarks
- {bookmarkSupport && bookmarksWithIds.length > 0 && ( - <> - {Object.values(expanded).some((val) => val === false) ? ( - - - - ) : ( - - - - )} - - )} + + + + + )} - {showEmptyState && ( -
+ {showEmptyState && !isAddingBookmark && ( + + No bookmarks in this document -
+ + + )} + + {isAddingBookmark && ( + + + + Add bookmark + + setNewBookmarkTitle(e.currentTarget.value)} + autoFocus + disabled={isSavingBookmark} + /> + + setNewBookmarkPage(typeof v === "number" ? v : 1) + } + disabled={isSavingBookmark} + /> + {addBookmarkError && ( + + {addBookmarkError} + + )} + + + + + + )} {showBookmarkList && ( -
- {renderBookmarks(filteredBookmarks)} -
+ <> + {!isAddingBookmark && ( + + )} +
+ {renderBookmarks(filteredBookmarks)} +
+ )} {showSearchEmpty && ( @@ -609,6 +851,41 @@ export const BookmarkSidebar = ({ )}
+ + {bookmarkSupport && documentCacheKey && ( + + + + + + Need to reorder or nest? Open the Bookmark Editor + + + + + )} ); }; diff --git a/frontend/editor/src/core/components/viewer/CommentsSidebar.tsx b/frontend/editor/src/core/components/viewer/CommentsSidebar.tsx index f74ad806c6..cc470b3ea6 100644 --- a/frontend/editor/src/core/components/viewer/CommentsSidebar.tsx +++ b/frontend/editor/src/core/components/viewer/CommentsSidebar.tsx @@ -311,11 +311,17 @@ export function CommentsSidebar({ clearHighlightCommentRequest, scrollActions, getZoomState, + toggleCommentsSidebar, } = useViewer() ?? {}; const scrollViewportRef = useRef(null); const { state, provides } = useAnnotation(documentId); const { handleToolSelectForced } = useToolWorkflow(); - const { activateAnnotationToolRef } = useAnnotationContext(); + const { + activateAnnotationToolRef, + activeAnnotationToolId, + setActiveAnnotationToolId, + } = useAnnotationContext(); + const isPlacingComment = activeAnnotationToolId === TEXT_COMMENT_TOOL_ID; const [draftContents, setDraftContents] = useState>( {}, ); @@ -639,12 +645,34 @@ export function CommentsSidebar({ ); const handleAddComment = useCallback(() => { + // Keep the sidebar open this time - the button morphs into a + // "Click on a page... cancel" hint so the user can see exactly + // what state the viewer is in. handleToolSelectForced(ANNOTATE_PANEL_ID); requestAnimationFrame(() => { activateAnnotationToolRef.current?.(TEXT_COMMENT_TOOL_ID); }); }, [handleToolSelectForced, activateAnnotationToolRef]); + const handleCancelPlacingComment = useCallback(() => { + // De-arm the textComment tool. The panel's activateAnnotationTool + // takes the AnnotationToolId "select" to reset to no-tool state. + activateAnnotationToolRef.current?.("select" as never); + setActiveAnnotationToolId(null); + }, [activateAnnotationToolRef, setActiveAnnotationToolId]); + + // ESC cancels placement mode while the sidebar is open. + useEffect(() => { + if (!visible || !isPlacingComment) return; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + handleCancelPlacingComment(); + } + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [visible, isPlacingComment, handleCancelPlacingComment]); + if (!visible) return null; return ( @@ -716,6 +744,18 @@ export function CommentsSidebar({ )} + {toggleCommentsSidebar && ( + + + + )} @@ -733,434 +773,517 @@ export function CommentsSidebar({ "Place comments with the Comment, Insert Text, or Replace Text tools. They will appear here by page.", )} - + {isPlacingComment ? ( + + ) : ( + + )} ) : ( - pageNumbers.map((pageIndex) => { - const entries = byPage[pageIndex] ?? []; - const pageNum = pageIndex + 1; - return ( - - - {t("viewer.comments.pageLabel", "Page {{page}}", { - page: pageNum, - })} - - - {t("viewer.comments.nComments", "{{count}} comment(s)", { - count: entries.length, - })} - - - - {entries.map((entry) => { - const ann = entry.annotation?.object; - const id = ann?.id; - if (!id) return null; - const key = `${pageIndex}_${id}`; - const replyKey = `${pageIndex}_${id}_reply`; - const displayContent = getCommentDisplayContent(entry); - const draft = - draftContents[key] !== undefined - ? draftContents[key] - : displayContent; - const replyDraft = replyDrafts[replyKey] ?? ""; - const authorName = getAuthorName(ann, displayName); - /** Only treat as "comment posted" when annotation actually has content (user clicked Send), not on every keystroke. */ - const hasMainContent = - (displayContent ?? "").trim().length > 0; - const isEditingMain = editingMainKey === key; + <> + {isPlacingComment ? ( + + ) : ( + + )} + {pageNumbers.map((pageIndex) => { + const entries = byPage[pageIndex] ?? []; + const pageNum = pageIndex + 1; + return ( + + + {t("viewer.comments.pageLabel", "Page {{page}}", { + page: pageNum, + })} + + + {t("viewer.comments.nComments", "{{count}} comments", { + count: entries.length, + })} + + + + {entries.map((entry) => { + const ann = entry.annotation?.object; + const id = ann?.id; + if (!id) return null; + const key = `${pageIndex}_${id}`; + const replyKey = `${pageIndex}_${id}_reply`; + const displayContent = getCommentDisplayContent(entry); + const draft = + draftContents[key] !== undefined + ? draftContents[key] + : displayContent; + const replyDraft = replyDrafts[replyKey] ?? ""; + const authorName = getAuthorName(ann, displayName); + /** Only treat as "comment posted" when annotation actually has content (user clicked Send), not on every keystroke. */ + const hasMainContent = + (displayContent ?? "").trim().length > 0; + const isEditingMain = editingMainKey === key; - const mainTimestamp = formatCommentDate(ann); - const typeLabel = getAnnotationTypeLabel(ann, t); + const mainTimestamp = formatCommentDate(ann); + const typeLabel = getAnnotationTypeLabel(ann, t); - return ( - - - - - - {authorName} - - - {typeLabel} - {mainTimestamp ? ` Β· ${mainTimestamp}` : ""} - - - - - + + + + {authorName} + + + {typeLabel} + {mainTimestamp ? ` Β· ${mainTimestamp}` : ""} + + + + - - handleLocateAnnotation(pageIndex, ann) - } - > - - - - - - - - - - - - - - } - onClick={() => setEditingMainKey(key)} - > - {t("annotation.editText", "Edit")} - - - } - color="red" - onClick={() => - handleDeleteClick(pageIndex, id, ann) - } - > - {t("annotation.delete", "Delete")} - - - - - - - {!hasMainContent || isEditingMain ? ( - <> -