diff --git a/.claude/skills/pr-quiz/SKILL.md b/.claude/skills/pr-quiz/SKILL.md new file mode 100644 index 0000000000..89d2a43d51 --- /dev/null +++ b/.claude/skills/pr-quiz/SKILL.md @@ -0,0 +1,137 @@ +--- +name: pr-quiz +description: >- + Quiz the PR author on their own branch before they request review, to prove they + actually understand the change - especially code an AI wrote for them. Scopes the + branch diff vs its base, reads the changed code, then asks graded questions about + what changed, why, how it works, what it could break, and which edge cases it must + handle. Presents all questions first, waits for the author's answers, then grades + each honestly against the real code (Correct / Partial / Incorrect with the true + answer and file:line), scores it, and gives a readiness verdict that names the + areas to re-study before asking humans to review. Use when asked to quiz me on my + PR/branch, "test my understanding before review", a self-check gate before opening + a PR, or before requesting reviewers. Administered as an interactive + multiple-choice quiz (clickable options) by default; pass --free-text for + written answers, --questions N to set count, --save to write a scorecard. +argument-hint: "[branch-or-base-ref] [--questions N] [--free-text] [--save]" +allowed-tools: Bash, Read, Grep, Glob, Write, AskUserQuestion +--- + +# PR Quiz + +Test whether the **author** genuinely understands their own branch before they ask +other people to spend time reviewing it. This is a self-check gate: the point is to +catch changes - often AI-written - that the author would not be able to explain or +defend in review. Be a fair but honest examiner, not a pushover. + +`$ARGUMENTS` may name a base ref or branch to diff against; default is this branch +vs where it forked from the main line. Flags: +- `--questions N` - target N questions (else scale to diff size, see below). +- `--free-text` - administer as a written numbered list instead of the default + interactive multiple-choice. +- `--save` - also write a scorecard file after grading. + +## Integrity rules (read first - the whole skill depends on these) + +1. **Present every question before revealing any answer.** Ask, then wait. Never + show the answer key alongside the questions. +2. **Do not give hints or the answer while the quiz is open.** If the author asks + "what's the answer?" or "is it X?" before committing, decline warmly and tell + them to give their best answer first - guessing is part of the signal. +3. **Grade truthfully.** Vague, hand-wavy, or "the AI did it" non-answers are + Partial or Incorrect, not Correct. Do not inflate the score to be nice; a false + pass defeats the entire purpose. +4. **Ground everything in code you actually read.** Every question and every model + answer must trace to a real line in the diff. Cite `path:line`. No trivia + ("how many lines?"), no invented behavior. +5. **Credit real understanding.** If the author explains it correctly in their own + words, mark it Correct even if worded differently than your key. + +## Process + +### 1. Scope the change (silently) +- Find the base. Prefer the fork point off the main line so the quiz covers only + this branch's work: + ```bash + git fetch -q origin 2>/dev/null; \ + BASE=$(git merge-base HEAD origin/main 2>/dev/null || git merge-base HEAD main); \ + git diff --stat "$BASE"...HEAD + ``` + If `$ARGUMENTS` names a ref, diff against that instead. +- If the diff is empty, stop and say there's nothing to quiz on. +- Read commit messages / PR description for the *stated* intent, but verify it + against the actual diff - a mismatch is itself a good question. + +### 2. Understand the code well enough to examine on it +Read the full diff plus enough surrounding context and related files to answer +every question you plan to ask. You cannot grade understanding you don't have. +Note the non-obvious parts: the design decisions, the risky lines, the edge cases, +the cross-file ripples, and anything that violates or upholds repo conventions +(for this repo e.g. `@app/*` import layering, all file ops via FileContext, +Jackson 3 / Spring Boot 4 APIs, engine typed-contract boundaries). + +### 3. Build the question set +Scale count to the change unless `--questions N` is given: +small (< ~50 changed lines) 3-4, medium 5-8, large 9-12. Cap at 12. +Draw from these categories - weight toward the ones the diff actually exercises: +- **Intent** - what problem this solves; why it was needed now. +- **Mechanism** - how a specific non-trivial piece actually works ("walk me + through what `foo()` does when called with X"). +- **Decisions & alternatives** - why this approach over an obvious alternative; + what a reviewer would reasonably push back on. +- **Blast radius** - what else this touches or could break; what you'd retest. +- **Edge cases** - inputs/states the change must handle (null, empty, large, + concurrent, error paths). +- **Conventions & correctness** - does it follow the repo's rules; is there a + latent bug the author should be able to spot. +Prefer questions the author can only answer if they read and understood the code. +Keep a private answer key with `path:line` for each - do **not** show it yet. + +### 4. Administer the quiz +- **Default (multiple choice):** use the `AskUserQuestion` tool. Per question write + 3-4 options where **every** option is independently plausible - each distractor a + real-but-wrong reading of the code, not filler. Two hard rules so the answer + can't be spotted by shape rather than knowledge: + - **Randomise the correct option's position** across questions - never default + it to first. Spread it roughly evenly over the slots. + - **Keep all options the same depth and length.** Do not describe the correct + one more fully than the distractors - a longer or more-detailed option is a + dead giveaway. Trim the right answer or flesh out the wrong ones until a + reader can't tell them apart by size. + The tool caps a call at 4 questions, so ask in batches of 4 - but run them as + one continuous flow: fire the next batch immediately after the previous + returns, with no narration ("Round 2 of 3") and no grading between batches. + The author always has an "Other" free-text escape, which is fine. +- **`--free-text`:** present all questions in one numbered list, then say + "Answer in one reply; number your answers. I won't grade until you're done." + Wait for the author's answers. +- Do not proceed to grading until every answer is in. + +### 5. Grade +For each question, in order: +- Verdict: **Correct** / **Partial** / **Incorrect**. +- The model answer in one or two sentences, citing the real `path:line`. +- One line on the gap when Partial/Incorrect - what they missed and where to look. +Then a **Score** (e.g. 6/8, counting Partial as half) and a one-line summary of +the pattern (e.g. "solid on intent, shaky on the error paths"). + +### 6. Readiness verdict +End with a clear call: +- **Ready for review** - understanding is sound; note anything to mention to + reviewers proactively. +- **Study first** - list the specific files/concepts to re-read before requesting + review, each as a clickable `path:line`. Be concrete: "re-read the null handling + in X before you send this out." +Keep it honest - if they'd get grilled in review on something, say so now. + +### 7. If `--save` +Write `pr-quiz/-scorecard.md`: the questions, their answers, your grades +and model answers, the score, and the verdict. Don't commit it unless asked. + +## Principles +- **The author is the examinee, not the collaborator.** During the quiz you withhold + answers; you're measuring them, not helping them pass. +- **A failed quiz is a successful outcome** - it caught a gap before a human's time + was spent. Frame it that way, not as a scolding. +- **True to the code.** Every question, answer, and grade traces to a line you read. +- **Terse and direct** in chat - the questions and the verdict, minimal preamble. diff --git a/.github/aur/stirling-pdf-desktop/PKGBUILD b/.github/aur/stirling-pdf-desktop/PKGBUILD index 5d92425b19..fb6a99cfca 100644 --- a/.github/aur/stirling-pdf-desktop/PKGBUILD +++ b/.github/aur/stirling-pdf-desktop/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: Stirling PDF Inc pkgname=stirling-pdf-desktop -pkgver=2.14.1 +pkgver=2.14.2 pkgrel=1 pkgdesc="Locally hosted, web-based PDF manipulation tool (Tauri desktop app, official Stirling PDF Inc build)" arch=('x86_64') diff --git a/.github/aur/stirling-pdf-server-bin/PKGBUILD b/.github/aur/stirling-pdf-server-bin/PKGBUILD index f5a2bf3c6c..70bcee0423 100644 --- a/.github/aur/stirling-pdf-server-bin/PKGBUILD +++ b/.github/aur/stirling-pdf-server-bin/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: Stirling PDF Inc pkgname=stirling-pdf-server-bin -pkgver=2.14.1 +pkgver=2.14.2 pkgrel=1 pkgdesc="Locally hosted, web-based PDF manipulation tool (server JAR, prebuilt)" arch=('any') diff --git a/.github/config/.files.yaml b/.github/config/.files.yaml index 905f2feed9..a9d4d3550e 100644 --- a/.github/config/.files.yaml +++ b/.github/config/.files.yaml @@ -1,16 +1,35 @@ +# CI routing infra. Editing the top-level router (build.yml) or this filter +# config re-runs every area's jobs, so every job-gating filter below includes +# *ci. That makes a change to how jobs are dispatched actually exercise those +# jobs (self-testing), instead of a router edit only matching the project filter. +ci: &ci + - .github/workflows/build.yml + - .github/config/.files.yaml + build: &build + - *ci - build.gradle + - gradle/spotless.gradle - app/(common|core|proprietary|saas)/build.gradle - Taskfile.yml - .taskfiles/backend.yml + - .github/workflows/check-licence.yml openapi: &openapi + - *ci - *build - app/(common|core|proprietary|saas)/src/main/java/** + - .github/workflows/check-openapi.yml docker-base: &docker-base - docker/base/Dockerfile +# Dockerfiles only (base + embedded + unoserver). Gates the slow multi-arch +# (arm64) leg of the PR docker test build: arm64 is only rebuilt when a +# Dockerfile itself changes, not on every code PR. +dockerfiles: &dockerfiles + - docker/**/Dockerfile* + docker: &docker - docker/embedded/Dockerfile - docker/embedded/Dockerfile.fat @@ -23,13 +42,11 @@ docker: &docker - *docker-base project: &project + - *ci - app/(common|core|proprietary|saas)/src/(main|test)/java/** - *build - "app/(common|core|proprietary|saas)/src/(main|test)/resources/**/!(messages_*.properties|*.md)*" - exampleYmlFiles/** - - gradle/** - - libs/** - - "testing/**/!(requirements*.txt|requirements*.in)*" - *docker - *docker-base - gradle.properties @@ -45,8 +62,11 @@ project: &project - .taskfiles/docker.yml - scripts/db-migration/** - .github/workflows/db-migration-test.yml + - .github/workflows/docker-compose-tests.yml + - .github/workflows/test-build-docker.yml frontend: &frontend + - *ci - frontend/** - .github/workflows/testdriver.yml - testing/** @@ -63,10 +83,15 @@ frontend: &frontend - Taskfile.yml - .taskfiles/frontend.yml - .taskfiles/e2e.yml + - .github/workflows/frontend-validation.yml + - .github/workflows/frontend-a11y.yml + - .github/workflows/e2e-stubbed.yml + - .github/workflows/e2e-live.yml # Files that affect the Tauri desktop bundle. Gate the multi-OS Tauri build # job on changes to any of these. tauri: &tauri + - *ci - frontend/editor/src-tauri/** - frontend/editor/src/desktop/** - frontend/editor/tsconfig.desktop.vite.json @@ -81,6 +106,7 @@ tauri: &tauri # the engine validation job on changes to engine sources or to the Java # tool surfaces it generates models from. engine: &engine + - *ci - engine/** - app/(common|core|proprietary|saas)/src/main/java/** - .github/workflows/ai-engine.yml @@ -93,6 +119,7 @@ engine: &engine # tasks that drive generation. Deliberately excludes the broad frontend/docker/ # testing globs, so a CSS-only PR does not boot the backend to rebuild the spec. generated-models: &generated-models + - *ci - *openapi - frontend/editor/scripts/generate-tool-api-types.mts - frontend/editor/src/core/types/toolApiTypes.ts @@ -115,6 +142,7 @@ licenses-backend: &licenses-backend # Files that can affect premium / enterprise behaviour. Gate the enterprise # Playwright job on changes to any of these on PRs. proprietary: &proprietary + - *ci - app/proprietary/** - frontend/editor/src/proprietary/** - frontend/editor/src/core/tests/enterprise/** @@ -129,4 +157,5 @@ proprietary: &proprietary - configs/settings.yml.template - build.gradle - app/proprietary/build.gradle + - gradle/spotless.gradle - .github/workflows/build-enterprise.yml diff --git a/.github/labeler-config-srvaroa.yml b/.github/labeler-config-srvaroa.yml index ad3b431e57..bd1947d649 100644 --- a/.github/labeler-config-srvaroa.yml +++ b/.github/labeler-config-srvaroa.yml @@ -163,7 +163,7 @@ labels: - '.github/workflows/scorecards.yml' - 'exampleYmlFiles/test_cicd.yml' - - label: 'Github' + - label: 'GitHub' files: - '.github/.*' diff --git a/.github/labels.yml b/.github/labels.yml index a6888e7795..65861c0d4d 100644 --- a/.github/labels.yml +++ b/.github/labels.yml @@ -5,6 +5,7 @@ # the GitHub Action https://github.com/marketplace/actions/github-labeler. - name: "Licenses" color: "EDEDED" + description: "Issues or pull requests related to licenses" from_name: "licenses" - name: "Back End" color: "20CE6C" @@ -146,21 +147,21 @@ description: "Changes that do not affect the meaning of the code (formatting, etc.)" - name: "admin" color: "195055" -- name: "codex" - color: "ededed" - description: null -- name: "Github" +- name: "GitHub" color: "0052CC" -- name: "github_actions" - color: "000000" - description: "Pull requests that update GitHub Actions code" + description: "Issues or pull requests related to GitHub configuration and integrations" + from_name: "Github" - name: "needs-changes" color: "A65A86" + description: "Pull requests that require changes before they can be merged" - name: "on-hold" color: "2526F9" - name: "python" color: "2b67c6" description: "Pull requests that update Python code" +- name: "engine" + color: "2b67c6" + description: "Issues or pull requests related to the engine" - name: "size:L" color: "eb9500" description: "This PR changes 100-499 lines ignoring generated files." @@ -201,3 +202,6 @@ - name: "license-review-required" color: "EDEDED" description: "This PR requires a license review" +- name: "has conflicts" + color: "D93F0B" + description: "Pull request has merge conflicts with the base branch" diff --git a/.github/scripts/requirements_dev.txt b/.github/scripts/requirements_dev.txt index 0c10203efa..d4b9f19132 100644 --- a/.github/scripts/requirements_dev.txt +++ b/.github/scripts/requirements_dev.txt @@ -1,101 +1,116 @@ # -# This file is autogenerated by pip-compile with Python 3.12 +# This file is autogenerated by pip-compile with Python 3.13 # by the following command: # # pip-compile --allow-unsafe --generate-hashes --output-file='.github\scripts\requirements_dev.txt' --strip-extras '.github\scripts\requirements_dev.in' # # WARNING: pip install will require the following package to be hashed. # Consider using a hashable URL like https://github.com/jazzband/pip-tools/archive/SOMECOMMIT.zip -# CVE-2025-6176 mitigation: pin brotli to a specific commit brotli @ git+https://github.com/google/brotli.git@028fb5a23661f123017c060daa546b55cf4bde29 # via # -r .github/scripts/requirements_dev.in # fonttools -cffi==2.0.0 \ - --hash=sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb \ - --hash=sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b \ - --hash=sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f \ - --hash=sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9 \ - --hash=sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44 \ - --hash=sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2 \ - --hash=sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c \ - --hash=sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75 \ - --hash=sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65 \ - --hash=sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e \ - --hash=sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a \ - --hash=sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e \ - --hash=sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25 \ - --hash=sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a \ - --hash=sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe \ - --hash=sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b \ - --hash=sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91 \ - --hash=sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592 \ - --hash=sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187 \ - --hash=sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c \ - --hash=sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1 \ - --hash=sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94 \ - --hash=sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba \ - --hash=sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb \ - --hash=sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165 \ - --hash=sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529 \ - --hash=sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca \ - --hash=sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c \ - --hash=sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6 \ - --hash=sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c \ - --hash=sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0 \ - --hash=sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743 \ - --hash=sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63 \ - --hash=sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5 \ - --hash=sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5 \ - --hash=sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4 \ - --hash=sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d \ - --hash=sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b \ - --hash=sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93 \ - --hash=sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205 \ - --hash=sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27 \ - --hash=sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512 \ - --hash=sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d \ - --hash=sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c \ - --hash=sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037 \ - --hash=sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26 \ - --hash=sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322 \ - --hash=sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb \ - --hash=sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c \ - --hash=sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8 \ - --hash=sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4 \ - --hash=sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414 \ - --hash=sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9 \ - --hash=sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664 \ - --hash=sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9 \ - --hash=sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775 \ - --hash=sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739 \ - --hash=sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc \ - --hash=sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062 \ - --hash=sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe \ - --hash=sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9 \ - --hash=sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92 \ - --hash=sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5 \ - --hash=sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13 \ - --hash=sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d \ - --hash=sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26 \ - --hash=sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f \ - --hash=sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495 \ - --hash=sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b \ - --hash=sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6 \ - --hash=sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c \ - --hash=sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef \ - --hash=sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5 \ - --hash=sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18 \ - --hash=sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad \ - --hash=sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3 \ - --hash=sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7 \ - --hash=sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5 \ - --hash=sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534 \ - --hash=sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49 \ - --hash=sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2 \ - --hash=sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5 \ - --hash=sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453 \ - --hash=sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf +cffi==2.1.0 \ + --hash=sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc \ + --hash=sha256:03e9810d18c646077e501f661b682fbf5dee4676048527ca3cffe66faa9960dd \ + --hash=sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d \ + --hash=sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5 \ + --hash=sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f \ + --hash=sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6 \ + --hash=sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c \ + --hash=sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda \ + --hash=sha256:11b3fb55f4f8ad92274ed26705f65d8f91457de71f5380061eb6d125a768fecd \ + --hash=sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a \ + --hash=sha256:164bff1657b2a74f0b6d54e11c9b375bc97b931f2ca9c43fcf875838da1570dd \ + --hash=sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd \ + --hash=sha256:19c54ac121cad98450b4896fa9a43ee0180d57bc4bc911a33db6cab1efab6cd3 \ + --hash=sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb \ + --hash=sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66 \ + --hash=sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d \ + --hash=sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f \ + --hash=sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6 \ + --hash=sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0 \ + --hash=sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c \ + --hash=sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93 \ + --hash=sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d \ + --hash=sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d \ + --hash=sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8 \ + --hash=sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b \ + --hash=sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001 \ + --hash=sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d \ + --hash=sha256:3d7f118b5adbfdfead90c25822690b02bc8074fba949bb7858bec4ebd55adb43 \ + --hash=sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b \ + --hash=sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0 \ + --hash=sha256:4d433a51f1870e43a13b6732f92aaf540ff77c2015097c78556f75a2d6c030e0 \ + --hash=sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458 \ + --hash=sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8 \ + --hash=sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d \ + --hash=sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94 \ + --hash=sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022 \ + --hash=sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db \ + --hash=sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479 \ + --hash=sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376 \ + --hash=sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d \ + --hash=sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6 \ + --hash=sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3 \ + --hash=sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea \ + --hash=sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd \ + --hash=sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02 \ + --hash=sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde \ + --hash=sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224 \ + --hash=sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76 \ + --hash=sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804 \ + --hash=sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1 \ + --hash=sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913 \ + --hash=sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714 \ + --hash=sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc \ + --hash=sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2 \ + --hash=sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e \ + --hash=sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda \ + --hash=sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512 \ + --hash=sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28 \ + --hash=sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699 \ + --hash=sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3 \ + --hash=sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c \ + --hash=sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe \ + --hash=sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a \ + --hash=sha256:9d72af0cf10a76a600a9690078fe31c63b9588c8e86bf9fd353f713c84b5db0f \ + --hash=sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c \ + --hash=sha256:a016194dbe13d14ee9556e734b772d8d67b947092b268d757fd4290e3ba2dfc2 \ + --hash=sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f \ + --hash=sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b \ + --hash=sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565 \ + --hash=sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056 \ + --hash=sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629 \ + --hash=sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7 \ + --hash=sha256:b65f590ef2a44640f9a05dbb548a429b4ade77913ce683ac8b1480777658a6c0 \ + --hash=sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9 \ + --hash=sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853 \ + --hash=sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13 \ + --hash=sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a \ + --hash=sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4 \ + --hash=sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce \ + --hash=sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac \ + --hash=sha256:c5f5df567f6eb216de69be06ce55c8b714090fae02b18a3b40da8163b8c5fa9c \ + --hash=sha256:c941bb58d5a6e1c3892d86e42927ed6c180302f07e6d395d08c416e594b98b46 \ + --hash=sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384 \ + --hash=sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b \ + --hash=sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210 \ + --hash=sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc \ + --hash=sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a \ + --hash=sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5 \ + --hash=sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7 \ + --hash=sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2 \ + --hash=sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326 \ + --hash=sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f \ + --hash=sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca \ + --hash=sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98 \ + --hash=sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9 \ + --hash=sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5 \ + --hash=sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7 \ + --hash=sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc \ + --hash=sha256:fb62edb5bb52cca65fab91a63afa7561607120d26090a7e8fda6fb9f064726da \ + --hash=sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f # via weasyprint cfgv==3.5.0 \ --hash=sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0 \ @@ -105,67 +120,67 @@ cssselect2==0.9.0 \ --hash=sha256:6a99e5f91f9a016a304dd929b0966ca464bcfda15177b6fb4a118fc0fb5d9563 \ --hash=sha256:759aa22c216326356f65e62e791d66160a0f9c91d1424e8d8adc5e74dddfc6fb # via weasyprint -distlib==0.4.0 \ - --hash=sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16 \ - --hash=sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d +distlib==0.4.3 \ + --hash=sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b \ + --hash=sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed # via virtualenv -filelock==3.29.0 \ - --hash=sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90 \ - --hash=sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258 +filelock==3.30.0 \ + --hash=sha256:1774e682dbe443bd60f9609162fc596e2c80dc84ffc2957068953406d0520090 \ + --hash=sha256:40632998f0772e64183bb819f086a1b9def6be1090cf1dcb9d45f46806ef279b # via # python-discovery # virtualenv -fonttools==4.62.1 \ - --hash=sha256:0aa72c43a601cfa9273bb1ae0518f1acadc01ee181a6fc60cd758d7fdadffc04 \ - --hash=sha256:0b3ae47e8636156a9accff64c02c0924cbebad62854c4a6dbdc110cd5b4b341a \ - --hash=sha256:12859ff0b47dd20f110804c3e0d0970f7b832f561630cd879969011541a464a9 \ - --hash=sha256:149f7d84afca659d1a97e39a4778794a2f83bf344c5ee5134e09995086cc2392 \ - --hash=sha256:1596aeaddf7f78e21e68293c011316a25267b3effdaccaf4d59bc9159d681b82 \ - --hash=sha256:19177c8d96c7c36359266e571c5173bcee9157b59cfc8cb0153c5673dc5a3a7d \ - --hash=sha256:1c5c25671ce8805e0d080e2ffdeca7f1e86778c5cbfbeae86d7f866d8830517b \ - --hash=sha256:1eecc128c86c552fb963fe846ca4e011b1be053728f798185a1687502f6d398e \ - --hash=sha256:268abb1cb221e66c014acc234e872b7870d8b5d4657a83a8f4205094c32d2416 \ - --hash=sha256:2d850f66830a27b0d498ee05adb13a3781637b1826982cd7e2b3789ef0cc71ae \ - --hash=sha256:2e7abd2b1e11736f58c1de27819e1955a53267c21732e78243fa2fa2e5c1e069 \ - --hash=sha256:403d28ce06ebfc547fbcb0cb8b7f7cc2f7a2d3e1a67ba9a34b14632df9e080f9 \ - --hash=sha256:40975849bac44fb0b9253d77420c6d8b523ac4dcdcefeff6e4d706838a5b80f7 \ - --hash=sha256:486f32c8047ccd05652aba17e4a8819a3a9d78570eb8a0e3b4503142947880ed \ - --hash=sha256:49a445d2f544ce4a69338694cad575ba97b9a75fff02720da0882d1a73f12800 \ - --hash=sha256:59b372b4f0e113d3746b88985f1c796e7bf830dd54b28374cd85c2b8acd7583e \ - --hash=sha256:5a648bde915fba9da05ae98856987ca91ba832949a9e2888b48c47ef8b96c5a9 \ - --hash=sha256:5f37df1cac61d906e7b836abe356bc2f34c99d4477467755c216b72aa3dc748b \ - --hash=sha256:6706d1cb1d5e6251a97ad3c1b9347505c5615c112e66047abbef0f8545fa30d1 \ - --hash=sha256:68959f5fc58ed4599b44aad161c2837477d7f35f5f79402d97439974faebfebe \ - --hash=sha256:6acb4109f8bee00fec985c8c7afb02299e35e9c94b57287f3ea542f28bd0b0a7 \ - --hash=sha256:7487782e2113861f4ddcc07c3436450659e3caa5e470b27dc2177cade2d8e7fd \ - --hash=sha256:7aa21ff53e28a9c2157acbc44e5b401149d3c9178107130e82d74ceb500e5056 \ - --hash=sha256:7bca7a1c1faf235ffe25d4f2e555246b4750220b38de8261d94ebc5ce8a23c23 \ - --hash=sha256:8d337fdd49a79b0d51c4da87bc38169d21c3abbf0c1aa9367eff5c6656fb6dae \ - --hash=sha256:8f8fca95d3bb3208f59626a4b0ea6e526ee51f5a8ad5d91821c165903e8d9260 \ - --hash=sha256:90365821debbd7db678809c7491ca4acd1e0779b9624cdc6ddaf1f31992bf974 \ - --hash=sha256:92bb00a947e666169c99b43753c4305fc95a890a60ef3aeb2a6963e07902cc87 \ - --hash=sha256:93c316e0f5301b2adbe6a5f658634307c096fd5aae60a5b3412e4f3e1728ab24 \ - --hash=sha256:942b03094d7edbb99bdf1ae7e9090898cad7bf9030b3d21f33d7072dbcb51a53 \ - --hash=sha256:9c125ffa00c3d9003cdaaf7f2c79e6e535628093e14b5de1dccb08859b680936 \ - --hash=sha256:9dde91633f77fa576879a0c76b1d89de373cae751a98ddf0109d54e173b40f14 \ - --hash=sha256:9e7863e10b3de72376280b515d35b14f5eeed639d1aa7824f4cf06779ec65e42 \ - --hash=sha256:a24decd24d60744ee8b4679d38e88b8303d86772053afc29b19d23bb8207803c \ - --hash=sha256:a5d8825e1140f04e6c99bb7d37a9e31c172f3bc208afbe02175339e699c710e1 \ - --hash=sha256:aa69d10ed420d8121118e628ad47d86e4caa79ba37f968597b958f6cceab7eca \ - --hash=sha256:ad5cca75776cd453b1b035b530e943334957ae152a36a88a320e779d61fc980c \ - --hash=sha256:b4e0fcf265ad26e487c56cb12a42dffe7162de708762db951e1b3f755319507d \ - --hash=sha256:b820fcb92d4655513d8402d5b219f94481c4443d825b4372c75a2072aa4b357a \ - --hash=sha256:bd13b7999d59c5eb1c2b442eb2d0c427cb517a0b7a1f5798fc5c9e003f5ff782 \ - --hash=sha256:bdfe592802ef939a0e33106ea4a318eeb17822c7ee168c290273cbd5fabd746c \ - --hash=sha256:c05557a78f8fa514da0f869556eeda40887a8abc77c76ee3f74cf241778afd5a \ - --hash=sha256:c22b1014017111c401469e3acc5433e6acf6ebcc6aa9efb538a533c800971c79 \ - --hash=sha256:c9b9e288b4da2f64fd6180644221749de651703e8d0c16bd4b719533a3a7d6e3 \ - --hash=sha256:d241cdc4a67b5431c6d7f115fdf63335222414995e3a1df1a41e1182acd4bcc7 \ - --hash=sha256:e54c75fd6041f1122476776880f7c3c3295ffa31962dc6ebe2543c00dca58b5d \ - --hash=sha256:e8514f4924375f77084e81467e63238b095abda5107620f49421c368a6017ed2 \ - --hash=sha256:ee91628c08e76f77b533d65feb3fbe6d9dad699f95be51cf0d022db94089cdc4 \ - --hash=sha256:ef46db46c9447103b8f3ff91e8ba009d5fe181b1920a83757a5762551e32bb68 \ - --hash=sha256:fa1d16210b6b10a826d71bed68dd9ec24a9e218d5a5e2797f37c573e7ec215ca +fonttools==4.63.0 \ + --hash=sha256:032038247a96c1690f9f31e377c389383c902531b085aa4e4dabd6f57f870e69 \ + --hash=sha256:063e08bd17bd5a90127a14123de0d6a952dbc847695fd98b63c043d58057f90c \ + --hash=sha256:0c18358a155d75034911c5ee397a5b44cd19dd325dbb8b35fb60bf421d6a72ac \ + --hash=sha256:0eac00b9118c3c2f87d272e45341871c5b3066baa3c86897fa634a7c3fb59096 \ + --hash=sha256:1e874792a8212b44583ea02189d9e693906b2f78b261f372f95d6c563210ac1d \ + --hash=sha256:22135da48a348785c5e2d5d2d9d6bec5ed44adacbaeb9db12d9493bf6c6bfa68 \ + --hash=sha256:22693918177bd9ceabec4736d338045f357769416fc6b0b2508eefef75b08616 \ + --hash=sha256:27fdc65af8da6f88b9c6121c47a464cbe359fcfff7ff6fc2d37a1f395d755b78 \ + --hash=sha256:2b8ae05d9eacf6081414d759c0a352769ac28ce31280d6bb8e77b03f9e3c449f \ + --hash=sha256:2c14b4fd138c4bafcca294765c547914e1aa431ae1ca94ab99d8db08c958bd3b \ + --hash=sha256:308f957cdeaf8abe4e5f2f124902ef405448af92c90f80e302a3b771c2e6116b \ + --hash=sha256:37dd23e621e3b0aef1baa70a303b80aaf38449632cfc8fd2a55fb285bbccfc02 \ + --hash=sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d \ + --hash=sha256:51394295f1a51de8b5f30bdb1e1b9a4231536c7064ef5c6e211eec19fa36036f \ + --hash=sha256:58dc6bb86a78d782f00f9190ca02c119cf5bbe2807536e361e18d42019f877d8 \ + --hash=sha256:59ac449f8cca9b4ffa08d2e7bbadad87ce710d69d1eda5c3c1ce579baa987272 \ + --hash=sha256:6b2248c5decb223562f7902ff6325077a073f608ee8e33e88ad88db734eb9f49 \ + --hash=sha256:6d4741eb179121cab9eea4cb2393d24492373a260d7945006358c08cfbf45419 \ + --hash=sha256:6db5140a60a5d731d21ec076745b40a310607731b0a565b50776393188649001 \ + --hash=sha256:6e528da43bc3791085f8cb6141b1d13e459226790240340fcbb4625649238b03 \ + --hash=sha256:796f27556dbe094c4824f75ca85267e4df776c79036c8441469a4df37038c196 \ + --hash=sha256:79cdc9f567aec74a72918fd060283911406750cbc9fd28c1316023deb6ce31a9 \ + --hash=sha256:7d76edbff9014094dbf03bd2d074709dfa6ec7aba13d838c937a2b33d2d6a86e \ + --hash=sha256:7d782fac32985914c351556f68ac0855391572bcd87de50e05970d3cd4c96fc5 \ + --hash=sha256:7dd683fef0663e9f0f45cf541d788d24caa3ec9db50796b588e1757d8b3bc007 \ + --hash=sha256:85be818f5506e8a7753153def2c9550178f0ecae6a47b5e0e8dbb23f7cc90380 \ + --hash=sha256:948428a275741f0b64b113c955425a953314f4b9ab9997f73a72c83e68e569c8 \ + --hash=sha256:9ced0bd02ac751dd6319b0da88aaef24414e3b0dbc32bb4f24944821a3741a27 \ + --hash=sha256:9e12f105d2b6342c559c298afb674006bb2893afc7102dcf8a1b55b0486b4e40 \ + --hash=sha256:a8b33a82979e0a6a34ff435cc81317be1f95ec1ebb7a3a2d1c8a6a54f02ae44e \ + --hash=sha256:a9faff9e0c1f76f9fd55899d2ce785832efebab37eb8ae13995853aef178bef0 \ + --hash=sha256:af2fd1664d00a397d75f806985ddb36282091c2131a73a6485c23b4a34722263 \ + --hash=sha256:afefc1ed0a59785a7fb06ea7e1678e849c193e1e387db783579bc7b3056fcfcb \ + --hash=sha256:b1cd75a03ad8cb5bc40c90bfde68c0c47de423aa19e5c0f362b43520645eea94 \ + --hash=sha256:ba04cb5891d4c0c21b6da95eda8d7b090021508a294fff33464fc7d241e0856b \ + --hash=sha256:bf00f21eb5fb721dbaf73d1e9da6d02a1af7768f2ebcf9798be98beab8ba90f6 \ + --hash=sha256:c0425b277a59cff3d80ca42162a8de360f318438a2ac83570842a678d826d579 \ + --hash=sha256:c1aaa4b9c75798400ac043ce04d74e7830376c85095a5a6ed7cba2f17a266bf4 \ + --hash=sha256:c2a2a42198b696a6f48fad91709afb55176e66a5e566131219dba372fb7f8c59 \ + --hash=sha256:caeb583deeb5168e694b65cda8b4ee62abedfa66cf88488734466f2366b9c4e0 \ + --hash=sha256:cb014d58140a38135f16064c74c652ed57aa0b75cbf8bb59cac821f7edb5334e \ + --hash=sha256:ccf41f2efdf56994d22d73bef4ced1052161958169428d06ba9724ea9e9a64be \ + --hash=sha256:cd7e9857e5e63738b9d9fd707bc1f59c8b09e5177726d23664db393c59bb08bd \ + --hash=sha256:d76ac49f929aecaf82d83250b8347e099d7aecba0f4726c1d9b6df3b8bb5fe18 \ + --hash=sha256:d7e5c9973aa04c95650c96e5f5ad865fbf42d62079163ecfab1e01cbc2504c22 \ + --hash=sha256:dcf076a4474fe0d7367e5bbf5b052c7284fa1feca729c04176ce513521afd8a0 \ + --hash=sha256:e3297a6a4059b4acc3a1e9a8b04741f240a80044eef08ebd32e8b5bcdddce75b \ + --hash=sha256:ee08ebfa58f6e1aeff5697ab9582105bb620008c1caafb681e4c557e7483027b \ + --hash=sha256:ef3048ef05dbb552b89817713d9cac912e00d0fde4a3105c00d29e52e10c89af \ + --hash=sha256:fd1e3094f42d806d3d7c79162fc59e5910fcbe3a7360c385b8da969bc4493745 # via weasyprint identify==2.6.19 \ --hash=sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a \ @@ -175,193 +190,190 @@ nodeenv==1.10.0 \ --hash=sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827 \ --hash=sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb # via pre-commit -numpy==2.4.4 \ - --hash=sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed \ - --hash=sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50 \ - --hash=sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959 \ - --hash=sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827 \ - --hash=sha256:0d4e437e295f18ec29bc79daf55e8a47a9113df44d66f702f02a293d93a2d6dd \ - --hash=sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233 \ - --hash=sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc \ - --hash=sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b \ - --hash=sha256:19710a9ca9992d7174e9c52f643d4272dcd1558c5f7af7f6f8190f633bd651a7 \ - --hash=sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e \ - --hash=sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a \ - --hash=sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d \ - --hash=sha256:28a650663f7314afc3e6ec620f44f333c386aad9f6fc472030865dc0ebb26ee3 \ - --hash=sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e \ - --hash=sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb \ - --hash=sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a \ - --hash=sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0 \ - --hash=sha256:30caa73029a225b2d40d9fae193e008e24b2026b7ee1a867b7ee8d96ca1a448e \ - --hash=sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113 \ - --hash=sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103 \ - --hash=sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93 \ - --hash=sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af \ - --hash=sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5 \ - --hash=sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7 \ - --hash=sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392 \ - --hash=sha256:51fc224f7ca4d92656d5a5eb315f12eb5fe2c97a66249aa7b5f562528a3be38c \ - --hash=sha256:58c8b5929fcb8287cbd6f0a3fae19c6e03a5c48402ae792962ac465224a629a4 \ - --hash=sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40 \ - --hash=sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf \ - --hash=sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44 \ - --hash=sha256:6a246d5914aa1c820c9443ddcee9c02bec3e203b0c080349533fae17727dfd1b \ - --hash=sha256:6aa3236c78803afbcb255045fbef97a9e25a1f6c9888357d205ddc42f4d6eba5 \ - --hash=sha256:6bbe4eb67390b0a0265a2c25458f6b90a409d5d069f1041e6aff1e27e3d9a79e \ - --hash=sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74 \ - --hash=sha256:72944b19f2324114e9dc86a159787333b77874143efcf89a5167ef83cfee8af0 \ - --hash=sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e \ - --hash=sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec \ - --hash=sha256:86b6f55f5a352b48d7fbfd2dbc3d5b780b2d79f4d3c121f33eb6efb22e9a2015 \ - --hash=sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d \ - --hash=sha256:8a87ec22c87be071b6bdbd27920b129b94f2fc964358ce38f3822635a3e2e03d \ - --hash=sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842 \ - --hash=sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150 \ - --hash=sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8 \ - --hash=sha256:989824e9faf85f96ec9c7761cd8d29c531ad857bfa1daa930cba85baaecf1a9a \ - --hash=sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed \ - --hash=sha256:9b2aec6af35c113b05695ebb5749a787acd63cafc83086a05771d1e1cd1e555f \ - --hash=sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008 \ - --hash=sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e \ - --hash=sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0 \ - --hash=sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e \ - --hash=sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f \ - --hash=sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a \ - --hash=sha256:ba1f4fc670ed79f876f70082eff4f9583c15fb9a4b89d6188412de4d18ae2f40 \ - --hash=sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7 \ - --hash=sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83 \ - --hash=sha256:c9b39d38a9bd2ae1becd7eac1303d031c5c110ad31f2b319c6e7d98b135c934d \ - --hash=sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c \ - --hash=sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871 \ - --hash=sha256:df3775294accfdd75f32c74ae39fcba920c9a378a2fc18a12b6820aa8c1fb502 \ - --hash=sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252 \ - --hash=sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8 \ - --hash=sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115 \ - --hash=sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f \ - --hash=sha256:eea7ac5d2dce4189771cedb559c738a71512768210dc4e4753b107a2048b3d0e \ - --hash=sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d \ - --hash=sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0 \ - --hash=sha256:f2cf083b324a467e1ab358c105f6cad5ea950f50524668a80c486ff1db24e119 \ - --hash=sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e \ - --hash=sha256:f983334aea213c99992053ede6168500e5f086ce74fbc4acc3f2b00f5762e9db \ - --hash=sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121 \ - --hash=sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d \ - --hash=sha256:fcfe2045fd2e8f3cb0ce9d4ba6dba6333b8fa05bb8a4939c908cd43322d14c7e +numpy==2.4.6 \ + --hash=sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1 \ + --hash=sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4 \ + --hash=sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f \ + --hash=sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079 \ + --hash=sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096 \ + --hash=sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47 \ + --hash=sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66 \ + --hash=sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d \ + --hash=sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1 \ + --hash=sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e \ + --hash=sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147 \ + --hash=sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd \ + --hash=sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75 \ + --hash=sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063 \ + --hash=sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73 \ + --hash=sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab \ + --hash=sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4 \ + --hash=sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41 \ + --hash=sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402 \ + --hash=sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698 \ + --hash=sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7 \ + --hash=sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8 \ + --hash=sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b \ + --hash=sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8 \ + --hash=sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0 \ + --hash=sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662 \ + --hash=sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91 \ + --hash=sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0 \ + --hash=sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f \ + --hash=sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3 \ + --hash=sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f \ + --hash=sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67 \ + --hash=sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6 \ + --hash=sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997 \ + --hash=sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b \ + --hash=sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e \ + --hash=sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538 \ + --hash=sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627 \ + --hash=sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93 \ + --hash=sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02 \ + --hash=sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853 \ + --hash=sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c \ + --hash=sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43 \ + --hash=sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd \ + --hash=sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8 \ + --hash=sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089 \ + --hash=sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778 \ + --hash=sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1 \ + --hash=sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb \ + --hash=sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261 \ + --hash=sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb \ + --hash=sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a \ + --hash=sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8 \ + --hash=sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359 \ + --hash=sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5 \ + --hash=sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7 \ + --hash=sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751 \ + --hash=sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8 \ + --hash=sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605 \ + --hash=sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e \ + --hash=sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45 \ + --hash=sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2 \ + --hash=sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895 \ + --hash=sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe \ + --hash=sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb \ + --hash=sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a \ + --hash=sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577 \ + --hash=sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d \ + --hash=sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a \ + --hash=sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda \ + --hash=sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6 \ + --hash=sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20 # via opencv-python-headless -opencv-python-headless==4.13.0.92 \ - --hash=sha256:0525a3d2c0b46c611e2130b5fdebc94cf404845d8fa64d2f3a3b679572a5bd22 \ - --hash=sha256:0bd48544f77c68b2941392fcdf9bcd2b9cdf00e98cb8c29b2455d194763cf99e \ - --hash=sha256:1a7d040ac656c11b8c38677cc8cccdc149f98535089dbe5b081e80a4e5903209 \ - --hash=sha256:3e0a6f0a37994ec6ce5f59e936be21d5d6384a4556f2d2da9c2f9c5dc948394c \ - --hash=sha256:5c8cfc8e87ed452b5cecb9419473ee5560a989859fe1d10d1ce11ae87b09a2cb \ - --hash=sha256:77a82fe35ddcec0f62c15f2ba8a12ecc2ed4207c17b0902c7a3151ae29f37fb6 \ - --hash=sha256:a7cf08e5b191f4ebb530791acc0825a7986e0d0dee2a3c491184bd8599848a4b \ - --hash=sha256:eb60e36b237b1ebd40a912da5384b348df8ed534f6f644d8e0b4f103e272ba7d +opencv-python-headless==5.0.0.93 \ + --hash=sha256:030ca5e0837a2963ab36ef896baa9767eb8d2b83353fb28af5a521e40dd8756f \ + --hash=sha256:09a872a157c1376ab922a69bbf22f9a95bcc7b658a9d8b436a60212b02b2eeb4 \ + --hash=sha256:10818d91510e05c04568ae12b5cd120779c70c01bf897b001a6221fe430df80f \ + --hash=sha256:1e55af3abfb462eeeabe5c775f12bdb36216d8a93a3583d69e6bd6e1d6ba7d00 \ + --hash=sha256:829717b6a95554f273e49e357cee3b3a2a26b6f4842fbc1bed2b45bdd8f87e0e \ + --hash=sha256:840bd717c21e5c11cadadc022a823315ea417f961213d06b4df010e019eb16f4 \ + --hash=sha256:b82f9831daab90b725c7c1ee1b36cb5732c367096ac76d119e64e14eb70d5f3c \ + --hash=sha256:c6bcd96b185975ea240d22cfdb15a1f6d080cc95264cfbe2621f21bb144d89b9 \ + --hash=sha256:ed709fdf9aa0bd1f2ed8549e71d19449b03a675bb581eb292285f6861953be37 # via -r .github/scripts/requirements_dev.in pdf2image==1.17.0 \ --hash=sha256:eaa959bc116b420dd7ec415fcae49b98100dda3dd18cd2fdfa86d09f112f6d57 \ --hash=sha256:ecdd58d7afb810dffe21ef2b1bbc057ef434dabbac6c33778a38a3f7744a27e2 # via -r .github/scripts/requirements_dev.in -pillow==12.2.0 \ - --hash=sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9 \ - --hash=sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5 \ - --hash=sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987 \ - --hash=sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9 \ - --hash=sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b \ - --hash=sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f \ - --hash=sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd \ - --hash=sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e \ - --hash=sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e \ - --hash=sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe \ - --hash=sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795 \ - --hash=sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601 \ - --hash=sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1 \ - --hash=sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed \ - --hash=sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea \ - --hash=sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5 \ - --hash=sha256:2e589959f10d9824d39b350472b92f0ce3b443c0a3442ebf41c40cb8361c5b97 \ - --hash=sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453 \ - --hash=sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98 \ - --hash=sha256:34c0d99ecccea270c04882cb3b86e7b57296079c9a4aff88cb3b33563d95afaa \ - --hash=sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b \ - --hash=sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d \ - --hash=sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705 \ - --hash=sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8 \ - --hash=sha256:3e080565d8d7c671db5802eedfb438e5565ffa40115216eabb8cd52d0ecce024 \ - --hash=sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0 \ - --hash=sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286 \ - --hash=sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150 \ - --hash=sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2 \ - --hash=sha256:51c4167c34b0d8ba05b547a3bb23578d0ba17b80a5593f93bd8ecb123dd336a3 \ - --hash=sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b \ - --hash=sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f \ - --hash=sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463 \ - --hash=sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940 \ - --hash=sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166 \ - --hash=sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed \ - --hash=sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f \ - --hash=sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795 \ - --hash=sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780 \ - --hash=sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7 \ - --hash=sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1 \ - --hash=sha256:673aa32138f3e7531ccdbca7b3901dba9b70940a19ccecc6a37c77d5fdeb05b5 \ - --hash=sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295 \ - --hash=sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b \ - --hash=sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354 \ - --hash=sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60 \ - --hash=sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65 \ - --hash=sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005 \ - --hash=sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c \ - --hash=sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be \ - --hash=sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5 \ - --hash=sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06 \ - --hash=sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae \ - --hash=sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c \ - --hash=sha256:88d387ff40b3ff7c274947ed3125dedf5262ec6919d83946753b5f3d7c67ea4c \ - --hash=sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612 \ - --hash=sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e \ - --hash=sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab \ - --hash=sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808 \ - --hash=sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f \ - --hash=sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e \ - --hash=sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909 \ - --hash=sha256:975385f4776fafde056abb318f612ef6285b10a1f12b8570f3647ad0d74b48ec \ - --hash=sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe \ - --hash=sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50 \ - --hash=sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4 \ - --hash=sha256:a4e8f36e677d3336f35089648c8955c51c6d386a13cf6ee9c189c5f5bd713a9f \ - --hash=sha256:a52edc8bfff4429aaabdf4d9ee0daadbbf8562364f940937b941f87a4290f5ff \ - --hash=sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5 \ - --hash=sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb \ - --hash=sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414 \ - --hash=sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1 \ - --hash=sha256:b85f66ae9eb53e860a873b858b789217ba505e5e405a24b85c0464822fe88032 \ - --hash=sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76 \ - --hash=sha256:bd9c0c7a0c681a347b3194c500cb1e6ca9cab053ea4d82a5cf45b6b754560136 \ - --hash=sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e \ - --hash=sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c \ - --hash=sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3 \ - --hash=sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea \ - --hash=sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f \ - --hash=sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104 \ - --hash=sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176 \ - --hash=sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24 \ - --hash=sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3 \ - --hash=sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4 \ - --hash=sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed \ - --hash=sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43 \ - --hash=sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421 \ - --hash=sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7 \ - --hash=sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06 \ - --hash=sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5 +pillow==12.3.0 \ + --hash=sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756 \ + --hash=sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a \ + --hash=sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59 \ + --hash=sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45 \ + --hash=sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3 \ + --hash=sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df \ + --hash=sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139 \ + --hash=sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b \ + --hash=sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39 \ + --hash=sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e \ + --hash=sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8 \ + --hash=sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1 \ + --hash=sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8 \ + --hash=sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89 \ + --hash=sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5 \ + --hash=sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130 \ + --hash=sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd \ + --hash=sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d \ + --hash=sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b \ + --hash=sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed \ + --hash=sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace \ + --hash=sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb \ + --hash=sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931 \ + --hash=sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510 \ + --hash=sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6 \ + --hash=sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1 \ + --hash=sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce \ + --hash=sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385 \ + --hash=sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e \ + --hash=sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c \ + --hash=sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7 \ + --hash=sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace \ + --hash=sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c \ + --hash=sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f \ + --hash=sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64 \ + --hash=sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f \ + --hash=sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a \ + --hash=sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827 \ + --hash=sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17 \ + --hash=sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4 \ + --hash=sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a \ + --hash=sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701 \ + --hash=sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e \ + --hash=sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91 \ + --hash=sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66 \ + --hash=sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468 \ + --hash=sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217 \ + --hash=sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658 \ + --hash=sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418 \ + --hash=sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a \ + --hash=sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c \ + --hash=sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330 \ + --hash=sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402 \ + --hash=sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09 \ + --hash=sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930 \ + --hash=sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f \ + --hash=sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec \ + --hash=sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a \ + --hash=sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94 \ + --hash=sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468 \ + --hash=sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b \ + --hash=sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965 \ + --hash=sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8 \ + --hash=sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd \ + --hash=sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7 \ + --hash=sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c \ + --hash=sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777 \ + --hash=sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35 \ + --hash=sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9 \ + --hash=sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f \ + --hash=sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f \ + --hash=sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0 \ + --hash=sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c \ + --hash=sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71 \ + --hash=sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3 \ + --hash=sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838 \ + --hash=sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf \ + --hash=sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321 \ + --hash=sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26 \ + --hash=sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec \ + --hash=sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9 \ + --hash=sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65 \ + --hash=sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5 \ + --hash=sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e \ + --hash=sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d \ + --hash=sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198 \ + --hash=sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7 # via # -r .github/scripts/requirements_dev.in # pdf2image # weasyprint -platformdirs==4.9.6 \ - --hash=sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a \ - --hash=sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917 +platformdirs==4.10.0 \ + --hash=sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7 \ + --hash=sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a # via # python-discovery # virtualenv @@ -381,9 +393,9 @@ pyphen==0.17.2 \ --hash=sha256:3a07fb017cb2341e1d9ff31b8634efb1ae4dc4b130468c7c39dd3d32e7c3affd \ --hash=sha256:f60647a9c9b30ec6c59910097af82bc5dd2d36576b918e44148d8b07ef3b4aa3 # via weasyprint -python-discovery==1.2.2 \ - --hash=sha256:876e9c57139eb757cb5878cbdd9ae5379e5d96266c99ef731119e04fffe533bb \ - --hash=sha256:e1ae95d9af875e78f15e19aed0c6137ab1bb49c200f21f5061786490c9585c7a +python-discovery==1.4.4 \ + --hash=sha256:5cad33982d412c1f3ffb8f9ca4ea292c9680bca3942451d30b69c37fce53a4a3 \ + --hash=sha256:abebe9120b43453b68c908acfb1e72a19d1a959ed2cb620ad38fc57d08056dbe # via virtualenv pyyaml==6.0.3 \ --hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \ @@ -470,17 +482,17 @@ tinyhtml5==2.1.0 \ --hash=sha256:60a50ec3d938a37e491efa01af895853060943dcebb5627de5b10d188b338a67 \ --hash=sha256:6e11cfff38515834268daf89d5f85bbde0b6dd02e8d9e212d1385c2289b89f0a # via weasyprint -unoserver==3.6 \ - --hash=sha256:25c360fa194396a89cb79b4edd2735f8e4f0fd8531e59db3952114585bd7df05 \ - --hash=sha256:e446bcb3638c51880f002aaeecab1cf74dfa9df81035f027f7ff2e081b6d7015 +unoserver==3.7 \ + --hash=sha256:b05f9578506ac7374ae1b314c3a79528636c542ac78220a9ce99110584ca424b \ + --hash=sha256:fc44e6808071c9d2957e705ecf1742cea8a582aa5d5cc23babf36bb332ec6e8e # via -r .github/scripts/requirements_dev.in -virtualenv==21.2.4 \ - --hash=sha256:29d21e941795206138d0f22f4e45ff7050e5da6c6472299fb7103318763861ac \ - --hash=sha256:b294ef68192638004d72524ce7ef303e9d0cf5a44c95ce2e54a7500a6381cada +virtualenv==21.6.1 \ + --hash=sha256:15f978b7cd329f24855ff4a0c4b4899cc7678589f49adbdcbbb4d3232e641128 \ + --hash=sha256:afe991df855715a2b2f60edfcc0107ef95a79fdfd8cb4cdaa71603d1c12e463b # via pre-commit -weasyprint==68.1 \ - --hash=sha256:4dc3ba63c68bbbce3e9617cb2226251c372f5ee90a8a484503b1c099da9cf5be \ - --hash=sha256:d3b752049b453a5c95edb27ce78d69e9319af5a34f257fa0f4c738c701b4184e +weasyprint==69.0 \ + --hash=sha256:475951cfd917014de6d4d005caff48c6aa867e7e42b80cd5b16a0484a1609ee6 \ + --hash=sha256:a7a32f39ca16bd82ef11de99c92ea4b5f14951c9033af035e451ce4f4ee0a88c # via -r .github/scripts/requirements_dev.in webencodings==0.5.1 \ --hash=sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78 \ @@ -489,27 +501,28 @@ webencodings==0.5.1 \ # cssselect2 # tinycss2 # tinyhtml5 -zopfli==0.4.1 \ - --hash=sha256:02086247dd12fda929f9bfe8b3962b6bcdbfc8c82e99255aebcf367867cf0760 \ - --hash=sha256:07a5cdc5d1aaa6c288c5d9f5a5383042ba743641abf8e2fd898dcad622d8a38e \ - --hash=sha256:27823dc1161a4031d1c25925fd45d9868ec0cbc7692341830a7dcfa25063662c \ - --hash=sha256:2f992ac7d83cbddd889e1813ace576cbc91a05d5d7a0a21b366e2e5f492e7707 \ - --hash=sha256:4238d4d746d1095e29c9125490985e0c12ffd3654f54a24af551e2391e936d54 \ - --hash=sha256:5a4c22b6161f47f5bd34637dbaee6735abd287cd64e0d1ce28ef1871bf625f4b \ - --hash=sha256:84a31ba9edc921b1d3a4449929394a993888f32d70de3a3617800c428a947b9b \ - --hash=sha256:a899eca405662a23ae75054affa3517a060362eae1185d3d791c86a50153c4dd \ - --hash=sha256:a93c2ecafff372de6c0aa2212eff18a75f6c71a100372fee7b4b129cc0b6f9a7 \ - --hash=sha256:cb136a74d14a4ecfae29cb0fdecece58a6c115abc9a74c12bc6ac62e80f229d7 \ - --hash=sha256:d7bcee1b189d64ec33d1e05cfa1b6a1268c29329c382f6ca1bd6245b04925c57 \ - --hash=sha256:fdfb7ce9f5de37a5b2f75dd2642fd7717956ef2a72e0387302a36d382440db07 +zopfli==0.4.3 \ + --hash=sha256:0087c9a6f0c8a052be0f6d1a9bb71b6caffdd3e10201d6d6166e28d482cebe6d \ + --hash=sha256:47604eee5c6704bdf0e94d8391fe3b74ddb2abd84128fbcfdc3ee0fc265feaef \ + --hash=sha256:62248dbf8dbcbd588ee194b210e5be9fa80bce29641f55599d6d394bd2a9d8a3 \ + --hash=sha256:628c3e941752880b3491db8d44163d0aedb221944e22a17187ff7fc549b050f6 \ + --hash=sha256:769875152d0625c46707bcca57d4b2233fe653482067acd55fbf6ec525cb9bdc \ + --hash=sha256:7e9703ca6e7ef66c8d05e0826b6f558b680c9db8206f84f05a3ee93430a12e42 \ + --hash=sha256:7fa3c35193475290e3f007bbcdebdbae64ba2f012d75c632da0d727e1da50d5e \ + --hash=sha256:88f4fbe429aad72bc206275d81fab11a097e0f951a5848d1f51083c37ea73073 \ + --hash=sha256:921c2c9907f4364963848da5ad194b46d68865e07fdb975d04fd09bc42d47357 \ + --hash=sha256:d3a50f91a13cea9bafe025de8fd87a005eb26de02a4f0c193127ddbf23ac8ebe \ + --hash=sha256:d4f51dd1ab5312e837e2091284e0d9f1a138188f2e65812f9a5799dc02c45f94 \ + --hash=sha256:eb0c9c1d40a8cb1d58762d7e57290ccb753e0828c4d01be8acb59aae5d0ca206 \ + --hash=sha256:f2e0adcf7d36c6fd0dd36cc771ef7f0c5803a05666feafcd90d7170174a4148e # via fonttools # The following packages are considered to be unsafe in a requirements file: -pip==26.0.1 \ - --hash=sha256:bdb1b08f4274833d62c1aa29e20907365a2ceb950410df15fc9521bad440122b \ - --hash=sha256:c4037d8a277c89b320abe636d59f91e6d0922d08a05b60e85e53b296613346d8 +pip==26.1.2 \ + --hash=sha256:382ff9f685ee3bc25864f820aa50505825f10f5458ffff07e30a6d96e5715cab \ + --hash=sha256:f49cd134c61cf2fd75e0ce2676db03e4054504a5a4986d00f8299ae632dc4605 # via -r .github/scripts/requirements_dev.in -setuptools==82.0.1 \ - --hash=sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9 \ - --hash=sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb +setuptools==83.0.0 \ + --hash=sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef \ + --hash=sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3 # via -r .github/scripts/requirements_dev.in diff --git a/.github/scripts/requirements_sync_readme.txt b/.github/scripts/requirements_sync_readme.txt index b68152361d..d812da2025 100644 --- a/.github/scripts/requirements_sync_readme.txt +++ b/.github/scripts/requirements_sync_readme.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with Python 3.12 +# This file is autogenerated by pip-compile with Python 3.13 # by the following command: # # pip-compile --generate-hashes --output-file='.github\scripts\requirements_sync_readme.txt' --strip-extras '.github\scripts\requirements_sync_readme.in' @@ -8,7 +8,7 @@ tomli-w==1.2.0 \ --hash=sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90 \ --hash=sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021 # via -r .github/scripts/requirements_sync_readme.in -tomlkit==0.14.0 \ - --hash=sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680 \ - --hash=sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064 +tomlkit==0.15.0 \ + --hash=sha256:4dbc8f0fc024412b57ced8757ac7461305126a648ff8c2c807fcb8e133a78738 \ + --hash=sha256:7d1a9ecba3086638211b13814ea79c90dd54dd11993564376f3aa92271f5c7a3 # via -r .github/scripts/requirements_sync_readme.in diff --git a/.github/workflows/PR-Auto-Deploy-V2.yml b/.github/workflows/PR-Auto-Deploy-V2.yml index a3573a49df..a19a616735 100644 --- a/.github/workflows/PR-Auto-Deploy-V2.yml +++ b/.github/workflows/PR-Auto-Deploy-V2.yml @@ -23,13 +23,9 @@ permissions: pull-requests: write jobs: - pick: - uses: ./.github/workflows/_runner-pick.yml - check-pr: if: (github.event_name == 'pull_request' && github.event.action != 'closed') || github.event_name == 'workflow_dispatch' - needs: pick - runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }} + runs-on: ubuntu-latest outputs: should_deploy: ${{ steps.decide.outputs.should_deploy }} is_fork: ${{ steps.resolve.outputs.is_fork }} @@ -101,8 +97,8 @@ jobs: echo "allow_fork=${allow_fork:-false}" >> $GITHUB_OUTPUT deploy-v2-pr: - needs: [pick, check-pr] - runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }} + needs: check-pr + runs-on: ubuntu-latest if: needs.check-pr.outputs.should_deploy == 'true' && (needs.check-pr.outputs.is_fork == 'false' || needs.check-pr.outputs.allow_fork == 'true') # Concurrency control - only one deployment per PR at a time concurrency: @@ -112,10 +108,7 @@ jobs: contents: read issues: write pull-requests: write - id-token: write env: - USE_DEPOT: ${{ needs.pick.outputs.is_fork != 'true' }} - DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }} # Single source of truth for whether this preview embeds the admin portal: # drives the image build-arg and the deployment comment. BUILD_PORTAL: "true" @@ -190,12 +183,7 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} fetch-depth: 0 # Fetch full history for commit hash detection - - name: Set up Depot CLI - if: env.USE_DEPOT == 'true' - uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.0.0 - - name: Set up Docker Buildx - if: env.USE_DEPOT != 'true' uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - name: Get version number @@ -240,22 +228,8 @@ jobs: echo "Image needs to be built" fi - - name: Build and push V2 image (Depot) - if: env.USE_DEPOT == 'true' && steps.check-image.outputs.exists == 'false' - uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.16.0 - with: - project: ${{ vars.DEPOT_PROJECT_ID }} - context: . - file: ./docker/embedded/Dockerfile - push: true - tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-${{ steps.commit-hash.outputs.app_short }} - build-args: | - VERSION_TAG=v2-alpha - BUILD_PORTAL=${{ env.BUILD_PORTAL }} - platforms: linux/amd64 - - - name: Build and push V2 image (Docker fork fallback) - if: env.USE_DEPOT != 'true' && steps.check-image.outputs.exists == 'false' + - name: Build and push V2 image + if: steps.check-image.outputs.exists == 'false' uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: . @@ -297,7 +271,6 @@ jobs: - /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/storage:/storage:rw environment: DISABLE_ADDITIONAL_FEATURES: "false" - POLICIES_ENABLED: "true" STIRLING_BILLING_ACCOUNT_LINK_ENABLED: "true" SECURITY_ENABLELOGIN: "true" SECURITY_INITIALLOGIN_USERNAME: "${{ secrets.TEST_LOGIN_USERNAME }}" @@ -475,8 +448,7 @@ jobs: cleanup-v2-deployment: if: github.event.action == 'closed' - needs: pick - runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }} + runs-on: ubuntu-latest permissions: contents: read issues: write diff --git a/.github/workflows/PR-Demo-Comment-with-react.yml b/.github/workflows/PR-Demo-Comment-with-react.yml index 5a4ea8deef..2b81b8c777 100644 --- a/.github/workflows/PR-Demo-Comment-with-react.yml +++ b/.github/workflows/PR-Demo-Comment-with-react.yml @@ -34,12 +34,8 @@ permissions: pull-requests: read jobs: - pick: - uses: ./.github/workflows/_runner-pick.yml - check-comment: - needs: pick - runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }} + runs-on: ubuntu-latest permissions: issues: write if: | @@ -179,15 +175,11 @@ jobs: } deploy-pr: - needs: [pick, check-comment] - runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }} + needs: check-comment + runs-on: ubuntu-latest permissions: issues: write pull-requests: write - id-token: write - env: - USE_DEPOT: ${{ needs.pick.outputs.is_fork != 'true' }} - DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }} steps: - name: Harden Runner @@ -220,9 +212,9 @@ jobs: distribution: "temurin" - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 with: - gradle-version: 9.6.0 + gradle-version: 9.6.1 - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 @@ -240,12 +232,7 @@ jobs: MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }} STIRLING_PDF_DESKTOP_UI: false - - name: Set up Depot CLI - if: env.USE_DEPOT == 'true' - uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.0.0 - - name: Set up Docker Buildx - if: env.USE_DEPOT != 'true' uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - name: Login to Docker Hub @@ -254,22 +241,7 @@ jobs: username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_API }} - - name: Build and push PR-specific image (Depot) - if: env.USE_DEPOT == 'true' - uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.16.0 - with: - project: ${{ vars.DEPOT_PROJECT_ID }} - context: . - file: ./docker/embedded/Dockerfile - push: true - tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:pr-${{ needs.check-comment.outputs.pr_number }} - build-args: | - VERSION_TAG=alpha - PROTOTYPES_BUILD=${{ needs.check-comment.outputs.enable_prototypes }} - platforms: linux/amd64 - - - name: Build and push PR-specific image (Docker fork fallback) - if: env.USE_DEPOT != 'true' + - name: Build and push PR-specific image uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: . @@ -283,19 +255,8 @@ jobs: PROTOTYPES_BUILD=${{ needs.check-comment.outputs.enable_prototypes }} platforms: linux/amd64 - - name: Build and push engine image (Depot) - if: env.USE_DEPOT == 'true' && needs.check-comment.outputs.enable_prototypes == 'true' - uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.16.0 - with: - project: ${{ vars.DEPOT_PROJECT_ID }} - context: ./engine - file: ./engine/Dockerfile - push: true - tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:engine-pr-${{ needs.check-comment.outputs.pr_number }} - platforms: linux/amd64 - - - name: Build and push engine image (Docker fork fallback) - if: env.USE_DEPOT != 'true' && needs.check-comment.outputs.enable_prototypes == 'true' + - name: Build and push engine image + if: needs.check-comment.outputs.enable_prototypes == 'true' uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: ./engine @@ -510,8 +471,7 @@ jobs: handle-label-commands: if: ${{ github.event.issue.pull_request != null }} - needs: pick - runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }} + runs-on: ubuntu-latest steps: - name: Harden Runner uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 diff --git a/.github/workflows/_runner-pick.yml b/.github/workflows/_runner-pick.yml index 023325d383..0f32d79b95 100644 --- a/.github/workflows/_runner-pick.yml +++ b/.github/workflows/_runner-pick.yml @@ -2,8 +2,8 @@ name: _runner-pick # Tiny reusable workflow that classifies the trigger as either a "fork PR # from an untrusted contributor" or a "trusted commit" so downstream jobs -# can pick a runner class without each one duplicating the 200-char gate -# expression in their own `runs-on:`. +# can trust-gate (skip secret-dependent jobs on forks) without each one +# duplicating the gate expression. # # Caller pattern: # @@ -13,12 +13,12 @@ name: _runner-pick # # real-work: # needs: pick -# runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-8' }} +# if: needs.pick.outputs.is_fork != 'true' # steps: [...] # -# Output: -# is_fork: "true" when the trigger is a pull_request from a fork or an -# untrusted author_association, "false" otherwise. +# Outputs: +# is_fork: "true" when the trigger is a pull_request from a fork or an +# untrusted author_association, "false" otherwise. on: workflow_call: @@ -50,21 +50,18 @@ jobs: AUTHOR_ASSOC: ${{ github.event.pull_request.author_association }} run: | set -eu + if [ -z "${PR_NUMBER:-}" ]; then # Not a pull_request event at all (push, schedule, workflow_dispatch, # workflow_call from a non-PR trigger) -> trusted by default. - echo "is_fork=false" >> "$GITHUB_OUTPUT" - exit 0 + is_fork=false + elif [ "${HEAD_REPO_FORK}" = "true" ]; then + is_fork=true + else + case "${AUTHOR_ASSOC}" in + OWNER|MEMBER|COLLABORATOR) is_fork=false ;; + *) is_fork=true ;; + esac fi - if [ "${HEAD_REPO_FORK}" = "true" ]; then - echo "is_fork=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - case "${AUTHOR_ASSOC}" in - OWNER|MEMBER|COLLABORATOR) - echo "is_fork=false" >> "$GITHUB_OUTPUT" - ;; - *) - echo "is_fork=true" >> "$GITHUB_OUTPUT" - ;; - esac + + echo "is_fork=${is_fork}" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/ai-engine.yml b/.github/workflows/ai-engine.yml index 554100f535..fbc9848eaf 100644 --- a/.github/workflows/ai-engine.yml +++ b/.github/workflows/ai-engine.yml @@ -18,8 +18,6 @@ jobs: permissions: contents: read pull-requests: write - env: - DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }} steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 @@ -33,6 +31,7 @@ jobs: uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: enable-cache: true + cache-suffix: ai-engine - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 diff --git a/.github/workflows/backend-build.yml b/.github/workflows/backend-build.yml index ef0dd20aba..0479f91f0e 100644 --- a/.github/workflows/backend-build.yml +++ b/.github/workflows/backend-build.yml @@ -19,14 +19,8 @@ permissions: pull-requests: write jobs: - pick: - uses: ./.github/workflows/_runner-pick.yml - build: - needs: pick - runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-8' }} - env: - DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }} + runs-on: ubuntu-latest strategy: fail-fast: false matrix: @@ -56,9 +50,9 @@ jobs: key: gradle-deps-${{ runner.os }}-jdk-${{ matrix.jdk-version }}-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 with: - gradle-version: 9.6.0 + gradle-version: 9.6.1 cache-disabled: true - name: Install Task @@ -247,7 +241,7 @@ jobs: # so skip it for merge_group runs and workflow_dispatch. if: github.event_name == 'pull_request' id: jacoco - uses: madrapps/jacoco-report@50d3aff4548aa991e6753342d9ba291084e63848 # v1.7.2 + uses: madrapps/jacoco-report@e51ce1f46f7f8b5331593f935e59cbaf44b84920 # v1.8.0 with: paths: | ${{ github.workspace }}/**/build/reports/jacoco/test/jacocoTestReport.xml diff --git a/.github/workflows/build-enterprise.yml b/.github/workflows/build-enterprise.yml index 9e1a4efb83..8c06175a5d 100644 --- a/.github/workflows/build-enterprise.yml +++ b/.github/workflows/build-enterprise.yml @@ -15,23 +15,11 @@ name: Enterprise E2E (Playwright) on: workflow_call: - inputs: - depot_cores: - description: "Depot runner vCPU count (used in runs-on). Override for benchmarking." - required: false - type: string - default: "8" push: branches: ["main"] schedule: - cron: "0 4 * * *" workflow_dispatch: - inputs: - depot_cores: - description: "Depot runner vCPU count (used in runs-on). Override for benchmarking." - required: false - type: string - default: "8" # No `concurrency:` block here on purpose. When this workflow is called via # workflow_call from build.yml, ${{ github.workflow }}/event_name/pr_number @@ -50,17 +38,16 @@ jobs: playwright-e2e-enterprise: needs: pick - # Skip on fork PRs / untrusted authors: they have no PREMIUM_KEY_ENTERPRISE - # (nor DEPOT_TOKEN), so the suite can't boot premium and would fail. See the - # header comment. GitHub reports the skipped reusable workflow as success. + # Skip on fork PRs / untrusted authors: they have no PREMIUM_KEY_ENTERPRISE, + # so the suite can't boot premium and would fail. See the header comment. + # GitHub reports the skipped reusable workflow as success. if: needs.pick.outputs.is_fork != 'true' - runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') }} + runs-on: ubuntu-latest timeout-minutes: 45 env: PREMIUM_KEY: ${{ secrets.PREMIUM_KEY_ENTERPRISE }} PREMIUM_ENABLED: "true" SYSTEM_ENABLEANALYTICS: "false" - DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }} steps: - name: Harden Runner uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8142c37fca..c8275c023b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -41,6 +41,7 @@ jobs: openapi: ${{ steps.changes.outputs.openapi }} frontend: ${{ steps.changes.outputs.frontend }} docker-base: ${{ steps.changes.outputs.docker-base }} + dockerfiles: ${{ steps.changes.outputs.dockerfiles }} tauri: ${{ steps.changes.outputs.tauri }} engine: ${{ steps.changes.outputs.engine }} generated-models: ${{ steps.changes.outputs.generated-models }} @@ -98,6 +99,17 @@ jobs: uses: ./.github/workflows/frontend-validation.yml secrets: inherit + # Advisory: deliberately NOT in all-checks-passed. It reports on the stories a + # branch touches so a regression is visible in review, but a browser scan is + # too new here to block merges on. Promote it once its pass/fail proves stable. + frontend-a11y: + if: needs.files-changed.outputs.frontend == 'true' + needs: [files-changed] + permissions: + contents: read + uses: ./.github/workflows/frontend-a11y.yml + secrets: inherit + playwright-e2e: if: needs.files-changed.outputs.frontend == 'true' needs: [files-changed] @@ -148,11 +160,11 @@ jobs: permissions: contents: read packages: read - id-token: write uses: ./.github/workflows/test-build-docker.yml secrets: inherit with: docker-base-changed: ${{ needs.files-changed.outputs.docker-base }} + dockerfiles-changed: ${{ needs.files-changed.outputs.dockerfiles }} tauri-build: if: needs.files-changed.outputs.tauri == 'true' @@ -162,6 +174,13 @@ jobs: pull-requests: write uses: ./.github/workflows/tauri-build.yml secrets: inherit + # PR smoke build: Linux only (fastest + cheapest to compile), unsigned, + # deb-only, no AppImage. The full signed multi-OS matrix runs on release; + # nightly still warms the Rust cache with all-OS defaults. + with: + platform: linux + sign: false + minimal: true ai-engine: if: needs.files-changed.outputs.engine == 'true' diff --git a/.github/workflows/check-generated-models.yml b/.github/workflows/check-generated-models.yml index db9c49fba2..eef119cd02 100644 --- a/.github/workflows/check-generated-models.yml +++ b/.github/workflows/check-generated-models.yml @@ -21,8 +21,6 @@ jobs: permissions: contents: read pull-requests: write - env: - DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }} steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 @@ -36,6 +34,7 @@ jobs: uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: enable-cache: true + cache-suffix: generated-models - name: Set up JDK 25 uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 @@ -44,7 +43,7 @@ jobs: distribution: "temurin" - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 with: gradle-version: 9.6.0 diff --git a/.github/workflows/check-licence.yml b/.github/workflows/check-licence.yml index dcd8c032df..9f6cfb39a0 100644 --- a/.github/workflows/check-licence.yml +++ b/.github/workflows/check-licence.yml @@ -11,8 +11,6 @@ permissions: jobs: check-licence: runs-on: ubuntu-latest - env: - DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }} steps: - name: Harden Runner uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 @@ -38,9 +36,9 @@ jobs: key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 with: - gradle-version: 9.6.0 + gradle-version: 9.6.1 cache-disabled: true - name: Install Task diff --git a/.github/workflows/check-openapi.yml b/.github/workflows/check-openapi.yml index a27ea4ec02..d853afa86f 100644 --- a/.github/workflows/check-openapi.yml +++ b/.github/workflows/check-openapi.yml @@ -10,14 +10,8 @@ permissions: contents: read jobs: - pick: - uses: ./.github/workflows/_runner-pick.yml - check-generate-openapi-docs: - needs: pick - runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }} - env: - DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }} + runs-on: ubuntu-latest steps: - name: Harden Runner uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 @@ -43,9 +37,9 @@ jobs: key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 with: - gradle-version: 9.6.0 + gradle-version: 9.6.1 cache-disabled: true - name: Install Task diff --git a/.github/workflows/check_toml.yml b/.github/workflows/check_toml.yml index b7a277f873..279e57f95d 100644 --- a/.github/workflows/check_toml.yml +++ b/.github/workflows/check_toml.yml @@ -196,7 +196,7 @@ jobs: core.exportVariable("REFERENCE_FILE", referenceFilePath); - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" diff --git a/.github/workflows/coverage-aggregate.yml b/.github/workflows/coverage-aggregate.yml index 18772fe819..42aa971971 100644 --- a/.github/workflows/coverage-aggregate.yml +++ b/.github/workflows/coverage-aggregate.yml @@ -29,12 +29,8 @@ permissions: contents: read jobs: - pick: - uses: ./.github/workflows/_runner-pick.yml - aggregate: - needs: pick - runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }} + runs-on: ubuntu-latest timeout-minutes: 15 steps: - name: Harden Runner @@ -60,13 +56,13 @@ jobs: key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 with: - gradle-version: 9.6.0 + gradle-version: 9.6.1 cache-disabled: true - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" diff --git a/.github/workflows/db-migration-test.yml b/.github/workflows/db-migration-test.yml index 9e8eeb8c8c..c6ebc481a7 100644 --- a/.github/workflows/db-migration-test.yml +++ b/.github/workflows/db-migration-test.yml @@ -12,15 +12,9 @@ permissions: contents: read jobs: - pick: - uses: ./.github/workflows/_runner-pick.yml - migration-test: - needs: pick - runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-8' }} + runs-on: ubuntu-latest timeout-minutes: 30 - env: - DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }} steps: - name: Harden Runner uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 @@ -46,9 +40,9 @@ jobs: key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 with: - gradle-version: 9.6.0 + gradle-version: 9.6.1 cache-disabled: true # No `-PnoSpotless` here yet because the upstream cache layer matches the diff --git a/.github/workflows/deploy-on-v2-commit.yml b/.github/workflows/deploy-on-v2-commit.yml index 0a4f502166..2dcbe2ae65 100644 --- a/.github/workflows/deploy-on-v2-commit.yml +++ b/.github/workflows/deploy-on-v2-commit.yml @@ -10,21 +10,11 @@ permissions: contents: read jobs: - pick: - uses: ./.github/workflows/_runner-pick.yml - deploy-v2-on-push: - needs: pick - runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }} + runs-on: ubuntu-latest concurrency: group: deploy-v2-push-V2 cancel-in-progress: true - permissions: - contents: read - id-token: write - env: - USE_DEPOT: ${{ needs.pick.outputs.is_fork != 'true' }} - DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }} steps: - name: Harden Runner @@ -35,12 +25,7 @@ jobs: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Set up Depot CLI - if: env.USE_DEPOT == 'true' - uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.0.0 - - name: Set up Docker Buildx - if: env.USE_DEPOT != 'true' uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - name: Get commit hashes for frontend and backend @@ -105,22 +90,8 @@ jobs: username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_API }} - - name: Build and push frontend image (Depot) - if: env.USE_DEPOT == 'true' && steps.check-frontend.outputs.exists == 'false' - uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.16.0 - with: - project: ${{ vars.DEPOT_PROJECT_ID }} - context: . - file: ./docker/frontend/Dockerfile - push: true - tags: | - ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }} - ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-latest - build-args: VERSION_TAG=v2-alpha - platforms: linux/amd64 - - - name: Build and push frontend image (Docker fork fallback) - if: env.USE_DEPOT != 'true' && steps.check-frontend.outputs.exists == 'false' + - name: Build and push frontend image + if: steps.check-frontend.outputs.exists == 'false' uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: . @@ -134,22 +105,8 @@ jobs: build-args: VERSION_TAG=v2-alpha platforms: linux/amd64 - - name: Build and push backend image (Depot) - if: env.USE_DEPOT == 'true' && steps.check-backend.outputs.exists == 'false' - uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.16.0 - with: - project: ${{ vars.DEPOT_PROJECT_ID }} - context: . - file: ./docker/backend/Dockerfile - push: true - tags: | - ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }} - ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-latest - build-args: VERSION_TAG=v2-alpha - platforms: linux/amd64 - - - name: Build and push backend image (Docker fork fallback) - if: env.USE_DEPOT != 'true' && steps.check-backend.outputs.exists == 'false' + - name: Build and push backend image + if: steps.check-backend.outputs.exists == 'false' uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: . diff --git a/.github/workflows/docker-compose-tests.yml b/.github/workflows/docker-compose-tests.yml index 945f4883cb..68bc1722be 100644 --- a/.github/workflows/docker-compose-tests.yml +++ b/.github/workflows/docker-compose-tests.yml @@ -11,28 +11,17 @@ on: required: false type: string default: "false" - depot_cores: - description: "Depot runner vCPU count (used in runs-on). Override for benchmarking. Tuned to 4 because bench showed 16 was within noise of 4." - required: false - type: string - default: "4" permissions: contents: read jobs: - pick: - uses: ./.github/workflows/_runner-pick.yml - docker-compose-tests: - needs: pick - runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '4') }} + runs-on: ubuntu-latest permissions: actions: write contents: read checks: write - env: - DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }} steps: - name: Harden Runner @@ -59,9 +48,9 @@ jobs: key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 with: - gradle-version: 9.6.0 + gradle-version: 9.6.1 cache-disabled: true # When the PR changes the base image, test.sh builds it locally @@ -85,7 +74,7 @@ jobs: sudo chmod +x /usr/local/bin/docker-compose - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" cache: "pip" # caching pip dependencies diff --git a/.github/workflows/e2e-live.yml b/.github/workflows/e2e-live.yml index 57a4357dad..a082f89bf5 100644 --- a/.github/workflows/e2e-live.yml +++ b/.github/workflows/e2e-live.yml @@ -5,23 +5,13 @@ name: Playwright E2E (live backend) # server. on: workflow_call: - inputs: - depot_cores: - description: "Depot runner vCPU count (used in runs-on). Override for benchmarking." - required: false - type: string - default: "8" permissions: contents: read jobs: - pick: - uses: ./.github/workflows/_runner-pick.yml - playwright-e2e-live: - needs: pick - runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') }} + runs-on: ubuntu-latest timeout-minutes: 30 steps: - name: Harden Runner @@ -94,7 +84,7 @@ jobs: fi - name: Set up Python for coverage summary if: always() && steps.live-coverage.outputs.report == 'true' - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" - name: Install defusedxml for coverage summary @@ -134,7 +124,7 @@ jobs: # a summary even on backend failure, as long as some Playwright # tests ran far enough to dump V8 coverage. if: always() - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" diff --git a/.github/workflows/e2e-stubbed.yml b/.github/workflows/e2e-stubbed.yml index ccfdf0052f..214a7188df 100644 --- a/.github/workflows/e2e-stubbed.yml +++ b/.github/workflows/e2e-stubbed.yml @@ -5,23 +5,13 @@ name: Playwright E2E (stubbed) # mocks API responses in the browser. on: workflow_call: - inputs: - depot_cores: - description: "Depot runner vCPU count (used in runs-on). Override for benchmarking. Tuned to 8 to match the other playwright workflows; bench showed flat scaling above 8." - required: false - type: string - default: "8" permissions: contents: read jobs: - pick: - uses: ./.github/workflows/_runner-pick.yml - playwright-e2e: - needs: pick - runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') }} + runs-on: ubuntu-latest steps: - name: Harden Runner uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 diff --git a/.github/workflows/frontend-a11y.yml b/.github/workflows/frontend-a11y.yml new file mode 100644 index 0000000000..f4d3e3b93a --- /dev/null +++ b/.github/workflows/frontend-a11y.yml @@ -0,0 +1,60 @@ +name: Frontend a11y regression gate + +# Reusable workflow called from build.yml when frontend sources change. +# +# Scans the stories this branch touches in real Chromium and runs axe against +# each. Existing violations are grandfathered in .storybook/a11y-baseline.json; +# the check fails on a NEW violation — a story breaking a rule it wasn't already +# breaking — or on a story that fails to render at all. +# +# Only changed stories, because a full sweep is ~30 minutes: far too slow to sit +# in front of every merge. The whole suite is scanned nightly instead +# (nightly.yml), which catches anything a branch didn't touch. +# +# Advisory for now: this is not in build.yml's all-checks-passed list, so a +# failure reports without blocking. Promote it once a few weeks of runs show the +# pass/fail is stable. +on: + workflow_call: + +permissions: + contents: read + +jobs: + frontend-a11y: + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - name: Harden Runner + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 + with: + egress-policy: audit + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + # Need the base branch too, to diff against it. + fetch-depth: 0 + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "22" + cache: "npm" + cache-dependency-path: frontend/package-lock.json + - name: Install Task + uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + - name: a11y gate (changed stories) + run: task frontend:storybook:a11y:changed -- origin/${{ github.base_ref || 'main' }} + - name: Upload scan reports + # The reports carry the offending selector and help text for each + # violation; without them a red run can only be understood by + # reproducing the whole scan locally. + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: a11y-scan-${{ github.run_id }} + path: frontend/.a11y-scan/ + retention-days: 7 + if-no-files-found: ignore + # The reports live in a dot-directory, which upload-artifact treats as + # hidden and silently skips by default. + include-hidden-files: true diff --git a/.github/workflows/frontend-backend-licenses-update.yml b/.github/workflows/frontend-backend-licenses-update.yml index cc9aa023e3..fcf6e18d0e 100644 --- a/.github/workflows/frontend-backend-licenses-update.yml +++ b/.github/workflows/frontend-backend-licenses-update.yml @@ -19,13 +19,9 @@ permissions: contents: read jobs: - pick: - uses: ./.github/workflows/_runner-pick.yml - files-changed: name: detect what files changed - needs: pick - runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }} + runs-on: ubuntu-latest timeout-minutes: 3 outputs: licenses-frontend: ${{ steps.changes.outputs.licenses-frontend }} @@ -48,8 +44,8 @@ jobs: generate-frontend-license-report: if: needs.files-changed.outputs.licenses-frontend == 'true' name: Generate Frontend License Report - needs: [pick, files-changed] - runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }} + needs: files-changed + runs-on: ubuntu-latest permissions: contents: write pull-requests: write @@ -299,7 +295,10 @@ jobs: base: main title: "Update Frontend 3rd Party Licenses" body: ${{ env.PR_BODY }} - labels: Licenses,github-actions,frontend + labels: | + Licenses + github-actions + Front End draft: false delete-branch: true sign-commits: true @@ -318,15 +317,13 @@ jobs: generate-backend-license-report: if: needs.files-changed.outputs.licenses-backend == 'true' - needs: [pick, files-changed] + needs: files-changed name: Generate Backend License Report - runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }} + runs-on: ubuntu-latest permissions: contents: write pull-requests: write repository-projects: write # Required for enabling automerge - env: - DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }} steps: - name: Harden Runner uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 @@ -354,9 +351,9 @@ jobs: distribution: "temurin" - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 with: - gradle-version: 9.6.0 + gradle-version: 9.6.1 - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 @@ -520,7 +517,10 @@ jobs: base: main title: "Update Backend 3rd Party Licenses" body: ${{ env.PR_BODY }} - labels: Licenses,github-actions,backend + labels: | + Licenses + github-actions + Back End delete-branch: true sign-commits: true diff --git a/.github/workflows/frontend-validation.yml b/.github/workflows/frontend-validation.yml index 70187df412..0e0a35a25e 100644 --- a/.github/workflows/frontend-validation.yml +++ b/.github/workflows/frontend-validation.yml @@ -11,12 +11,8 @@ permissions: pull-requests: write jobs: - pick: - uses: ./.github/workflows/_runner-pick.yml - frontend-validation: - needs: pick - runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }} + runs-on: ubuntu-latest steps: - name: Harden Runner uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 @@ -121,7 +117,7 @@ jobs: run: task frontend:test:coverage - name: Set up Python for coverage summary if: always() - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" - name: Install defusedxml for coverage summary diff --git a/.github/workflows/multiOSReleases.yml b/.github/workflows/multiOSReleases.yml index 9e8a65f02c..41ddeb7a13 100644 --- a/.github/workflows/multiOSReleases.yml +++ b/.github/workflows/multiOSReleases.yml @@ -36,13 +36,9 @@ permissions: contents: read jobs: - pick: - uses: ./.github/workflows/_runner-pick.yml - determine-matrix: if: ${{ vars.CI_PROFILE != 'lite' }} - needs: pick - runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }} + runs-on: ubuntu-latest outputs: matrix: ${{ steps.set-matrix.outputs.matrix }} version: ${{ steps.versionNumber.outputs.versionNumber }} @@ -71,9 +67,9 @@ jobs: gradle-${{ runner.os }}- - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 with: - gradle-version: 9.6.0 + gradle-version: 9.6.1 - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 @@ -112,10 +108,8 @@ jobs: fi build-jars: - needs: [pick, determine-matrix] - runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }} - env: - DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }} + needs: determine-matrix + runs-on: ubuntu-latest strategy: matrix: variant: @@ -146,9 +140,9 @@ jobs: distribution: "temurin" - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 with: - gradle-version: 9.6.0 + gradle-version: 9.6.1 - name: Setup Node.js if: matrix.variant.build_frontend == true @@ -195,7 +189,6 @@ jobs: SM_API_KEY: ${{ secrets.SM_API_KEY }} WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} RELEASE_GPG_PRIVATE_KEY: ${{ secrets.RELEASE_GPG_PRIVATE_KEY }} - DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }} steps: - name: Harden Runner uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 @@ -250,9 +243,9 @@ jobs: distribution: "temurin" - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 with: - gradle-version: 9.6.0 + gradle-version: 9.6.1 - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 @@ -510,6 +503,7 @@ jobs: # cargo output unsigned, so checking it produces false negatives. - name: Verify Windows Code Signature if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }} + timeout-minutes: 15 shell: pwsh run: | $allSigned = $true @@ -531,11 +525,26 @@ jobs: # Extract MSI and verify the inner exe (the file that actually gets installed). # This is the critical check - AV flags the installed exe at runtime. + # Use lessmsi, not `msiexec /a`: msiexec serializes on the global + # _MSIExecute mutex and hangs forever on hosted runners when another + # installer is busy. lessmsi reads MSI tables directly - no mutex, no service. $msi = $msiFiles[0].FullName $extractDir = Join-Path $env:RUNNER_TEMP "msi-verify" if (Test-Path $extractDir) { Remove-Item $extractDir -Recurse -Force } - $proc = Start-Process msiexec.exe -ArgumentList '/a', $msi, '/qn', "TARGETDIR=$extractDir" -Wait -PassThru -NoNewWindow - if ($proc.ExitCode -eq 0) { + New-Item -ItemType Directory -Force -Path $extractDir | Out-Null + + choco install lessmsi -y --no-progress --limit-output | Out-Null + + # Bound the extraction and kill on hang (defence in depth over timeout-minutes). + $proc = Start-Process lessmsi -ArgumentList 'x', "`"$msi`"", "`"$extractDir\`"" -PassThru -NoNewWindow + if (-not $proc.WaitForExit(120000)) { + try { $proc.Kill() } catch {} + Write-Host "[ERROR] MSI extraction timed out after 120s" + $allSigned = $false + } elseif ($proc.ExitCode -ne 0) { + Write-Host "[ERROR] Failed to extract MSI for verification (exit code: $($proc.ExitCode))" + $allSigned = $false + } else { $innerExe = Get-ChildItem -Path $extractDir -Filter "stirling-pdf.exe" -Recurse -File | Select-Object -First 1 if ($innerExe) { $sig = Get-AuthenticodeSignature -FilePath $innerExe.FullName @@ -548,9 +557,6 @@ jobs: Write-Host "[ERROR] Could not find stirling-pdf.exe inside MSI" $allSigned = $false } - } else { - Write-Host "[ERROR] Failed to extract MSI for verification (exit code: $($proc.ExitCode))" - $allSigned = $false } if (-not $allSigned) { @@ -625,8 +631,8 @@ jobs: retention-days: 1 collect-and-release: - needs: [pick, determine-matrix, build, build-jars] - runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }} + needs: [determine-matrix, build, build-jars] + runs-on: ubuntu-latest permissions: contents: write steps: @@ -800,7 +806,11 @@ jobs: uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 with: tag_name: v${{ needs.determine-matrix.outputs.version }} - generate_release_notes: true + # Don't regenerate/append notes on re-runs, and don't force this into the + # "Latest" slot - leave the release body and latest marker as they are. + generate_release_notes: false + append_body: false + make_latest: false fail_on_unmatched_files: true # Installers + updater payloads + manifest. .sig contents are embedded # in latest.json so the .sig files themselves are not uploaded. diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 1801876bcd..50346a6a39 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -13,13 +13,9 @@ permissions: contents: read jobs: - pick: - uses: ./.github/workflows/_runner-pick.yml - playwright-all-browsers: name: Playwright (chromium + firefox + webkit) - needs: pick - runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }} + runs-on: ubuntu-latest steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 @@ -57,6 +53,49 @@ jobs: path: frontend/playwright-report/ retention-days: 14 + # Whole-suite accessibility sweep. Pull requests only scan the stories they + # touch (frontend-a11y.yml) because a full pass takes ~30 minutes; this covers + # everything else, so a violation introduced by a change somewhere other than + # the story itself — a shared component, a theme token — still surfaces within + # a day. + a11y-all-stories: + name: a11y (every story) + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 + with: + egress-policy: audit + + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "22" + cache: "npm" + cache-dependency-path: frontend/package-lock.json + + - name: Install Task + uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + + - name: a11y gate (every story) + run: task frontend:storybook:a11y + + - name: Upload scan reports + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: a11y-scan-nightly-${{ github.run_id }} + path: frontend/.a11y-scan/ + retention-days: 14 + if-no-files-found: ignore + # The reports live in a dot-directory, which upload-artifact treats as + # hidden and silently skips by default. + include-hidden-files: true + # Builds all desktop platforms on a schedule so the Rust dependency cache is # written on main, where PR and merge-queue tauri builds can restore it. warm-tauri-cache: diff --git a/.github/workflows/pr-conflict-labeler.yml b/.github/workflows/pr-conflict-labeler.yml new file mode 100644 index 0000000000..f6e1016a4c --- /dev/null +++ b/.github/workflows/pr-conflict-labeler.yml @@ -0,0 +1,159 @@ +name: PR conflict labeler + +on: + pull_request_target: + types: + - opened + - reopened + - synchronize + - edited + - ready_for_review + schedule: + - cron: "17 */6 * * *" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: pr-conflict-labeler-${{ github.event.pull_request.number || 'all-open-prs' }} + cancel-in-progress: false + +env: + CONFLICT_LABEL: "has conflicts" + +jobs: + label-conflicts: + name: Label conflicted PRs + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + pull-requests: read + steps: + - name: Harden Runner + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 + with: + egress-policy: audit + + - name: Check out the repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up stirling-bot token + id: setup-bot + uses: ./.github/actions/setup-bot + with: + app-id: ${{ secrets.GH_APP_ID }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + + - name: Apply conflict label + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + github-token: ${{ steps.setup-bot.outputs.token }} + script: | + const conflictLabel = process.env.CONFLICT_LABEL; + const owner = context.repo.owner; + const repo = context.repo.repo; + const eventPullRequest = context.payload.pull_request; + + async function sleep(ms) { + await new Promise((resolve) => setTimeout(resolve, ms)); + } + + async function getPullRequestWithMergeableState(pullNumber) { + for (let attempt = 1; attempt <= 6; attempt += 1) { + const { data: pull } = await github.rest.pulls.get({ + owner, + repo, + pull_number: pullNumber, + }); + + if (pull.mergeable !== null) { + return pull; + } + + core.info(`PR #${pullNumber}: mergeable is not ready yet (attempt ${attempt}/6).`); + await sleep(5000); + } + + const { data: pull } = await github.rest.pulls.get({ + owner, + repo, + pull_number: pullNumber, + }); + return pull; + } + + async function ensureConflictLabel() { + try { + await github.rest.issues.getLabel({ + owner, + repo, + name: conflictLabel, + }); + } catch (error) { + if (error.status !== 404) { + throw error; + } + + await github.rest.issues.createLabel({ + owner, + repo, + name: conflictLabel, + color: 'D93F0B', + description: 'Pull request has merge conflicts with the base branch', + }); + core.info(`Created '${conflictLabel}' label.`); + } + } + + async function labelPullRequest(pull) { + const existingLabels = pull.labels.map((label) => label.name); + const hasConflictLabel = existingLabels.includes(conflictLabel); + const hasConflicts = pull.mergeable === false && pull.mergeable_state === 'dirty'; + + if (hasConflicts && !hasConflictLabel) { + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: pull.number, + labels: [conflictLabel], + }); + core.info(`Added '${conflictLabel}' to PR #${pull.number}.`); + return; + } + + if (!hasConflicts && hasConflictLabel) { + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number: pull.number, + name: conflictLabel, + }); + core.info(`Removed '${conflictLabel}' from PR #${pull.number}.`); + return; + } + + core.info(`PR #${pull.number}: no label change needed (mergeable=${pull.mergeable}, mergeable_state=${pull.mergeable_state}).`); + } + + await ensureConflictLabel(); + + let pullNumbers; + if (eventPullRequest) { + pullNumbers = [eventPullRequest.number]; + } else { + const pulls = await github.paginate(github.rest.pulls.list, { + owner, + repo, + state: 'open', + per_page: 100, + }); + pullNumbers = pulls.map((pull) => pull.number); + core.info(`Checking ${pullNumbers.length} open PR(s).`); + } + + for (const pullNumber of pullNumbers) { + const pull = await getPullRequestWithMergeableState(pullNumber); + await labelPullRequest(pull); + } diff --git a/.github/workflows/pre_commit.yml b/.github/workflows/pre_commit.yml index 37c31b7be3..3feca530d1 100644 --- a/.github/workflows/pre_commit.yml +++ b/.github/workflows/pre_commit.yml @@ -28,6 +28,7 @@ jobs: uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: enable-cache: true + cache-suffix: pre-commit - name: Install Task uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0 diff --git a/.github/workflows/push-docker.yml b/.github/workflows/push-docker.yml index eac1fa49fc..a87f6d68b5 100644 --- a/.github/workflows/push-docker.yml +++ b/.github/workflows/push-docker.yml @@ -76,9 +76,9 @@ jobs: gradle-${{ runner.os }}- - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 with: - gradle-version: 9.6.0 + gradle-version: 9.6.1 - name: Set up Docker Buildx id: buildx @@ -139,7 +139,6 @@ jobs: tags: | type=raw,value=${{ steps.versionNumber.outputs.versionNumber }},enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }} type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }} - type=raw,value=alpha,enable=${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/testMain' }} - name: Build and push Unified Dockerfile (latest variant) id: build-push-latest diff --git a/.github/workflows/swagger.yml b/.github/workflows/swagger.yml index 9696aa419c..90c5984693 100644 --- a/.github/workflows/swagger.yml +++ b/.github/workflows/swagger.yml @@ -22,15 +22,9 @@ permissions: contents: read jobs: - pick: - uses: ./.github/workflows/_runner-pick.yml - push: if: ${{ vars.CI_PROFILE != 'lite' }} - needs: pick - runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }} - env: - DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }} + runs-on: ubuntu-latest steps: - name: Harden Runner uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 @@ -46,9 +40,9 @@ jobs: distribution: "temurin" - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 with: - gradle-version: 9.6.0 + gradle-version: 9.6.1 - name: Generate Swagger documentation run: ./gradlew :stirling-pdf:generateOpenApiDocs diff --git a/.github/workflows/sync-portal-docs.yml b/.github/workflows/sync-portal-docs.yml new file mode 100644 index 0000000000..54ee262239 --- /dev/null +++ b/.github/workflows/sync-portal-docs.yml @@ -0,0 +1,95 @@ +name: Sync Portal Docs + +# Regenerates the portal Developer Docs manifest from the Stirling docs repo and +# opens a PR when it changes. Runs weekly, on manual dispatch, or when the docs +# repo fires a `docs-updated` repository_dispatch. +on: + schedule: + - cron: "0 6 * * 1" + workflow_dispatch: + inputs: + ref: + description: "Docs repo ref (branch or tag) to sync from" + required: false + default: "main" + repository_dispatch: + types: [docs-updated] + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + sync: + name: Sync docs manifest + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + pull-requests: write + steps: + - name: Harden Runner + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 + with: + egress-policy: audit + + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Setup GitHub App Bot + id: setup-bot + uses: ./.github/actions/setup-bot + with: + app-id: ${{ secrets.GH_APP_ID }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "22" + cache: "npm" + cache-dependency-path: frontend/package-lock.json + + - name: Install frontend dependencies + working-directory: frontend + env: + NPM_CONFIG_IGNORE_SCRIPTS: "true" + run: npm ci --ignore-scripts --audit=false --fund=false + + - name: Regenerate docs manifest + working-directory: frontend + env: + DOCS_REF: ${{ github.event.inputs.ref || github.event.client_payload.ref || 'main' }} + GITHUB_TOKEN: ${{ steps.setup-bot.outputs.token }} + run: npm run docs:sync + + - name: Create Pull Request + id: cpr + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 + with: + token: ${{ steps.setup-bot.outputs.token }} + commit-message: "Sync portal docs from docs repo" + committer: ${{ steps.setup-bot.outputs.committer }} + author: ${{ steps.setup-bot.outputs.committer }} + signoff: true + branch: sync-portal-docs + base: main + title: "Sync portal docs from docs repo" + body: | + Auto-generated by ${{ steps.setup-bot.outputs.app-slug }}[bot]. + + Regenerates `frontend/editor/src/portal/generated/docsManifest.json` + from the Stirling docs repo via `npm run docs:sync`. + labels: | + Documentation + github-actions + Front End + add-paths: frontend/editor/src/portal/generated/docsManifest.json + delete-branch: true + sign-commits: true diff --git a/.github/workflows/sync_files_v2.yml b/.github/workflows/sync_files_v2.yml index 956453192b..656c636e48 100644 --- a/.github/workflows/sync_files_v2.yml +++ b/.github/workflows/sync_files_v2.yml @@ -10,6 +10,7 @@ on: - "app/common/build.gradle" - "app/core/build.gradle" - "app/proprietary/build.gradle" + - "gradle/spotless.gradle" - "README.md" - "frontend/editor/public/locales/*/translation.toml" - "app/core/src/main/resources/static/3rdPartyLicenses.json" @@ -51,7 +52,7 @@ jobs: private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" cache: "pip" # caching pip dependencies @@ -64,6 +65,7 @@ jobs: uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: enable-cache: true + cache-suffix: sync-files - name: Install Task uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0 diff --git a/.github/workflows/tauri-build.yml b/.github/workflows/tauri-build.yml index 0a017d6273..c97ae4eaef 100644 --- a/.github/workflows/tauri-build.yml +++ b/.github/workflows/tauri-build.yml @@ -21,6 +21,11 @@ on: required: false type: boolean default: true + minimal: + description: "Fast smoke build: Linux deb only, skip rpm and the flaky AppImage pass. Used by PR builds." + required: false + type: boolean + default: false workflow_dispatch: inputs: platform: @@ -38,6 +43,11 @@ on: required: false default: true type: boolean + minimal: + description: "Fast smoke build: Linux deb only, skip rpm and the flaky AppImage pass." + required: false + default: false + type: boolean permissions: contents: read @@ -96,7 +106,6 @@ jobs: WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} RELEASE_GPG_PRIVATE_KEY: ${{ secrets.RELEASE_GPG_PRIVATE_KEY }} - DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }} steps: - name: Harden Runner uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 @@ -158,9 +167,9 @@ jobs: distribution: "temurin" - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 with: - gradle-version: 9.6.0 + gradle-version: 9.6.1 - name: Setup Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 @@ -386,10 +395,10 @@ jobs: with: projectPath: ./frontend/editor tauriScript: npx tauri - # Linux: build deb+rpm only here. AppImage runs in its own - # continue-on-error step below so its persistent linuxdeploy - # failure (#6127 onwards) does not tank deb/rpm uploads. - args: ${{ matrix.platform == 'ubuntu-22.04' && '--bundles deb,rpm' || matrix.args }} + # Linux: build deb+rpm only here (deb-only on minimal smoke builds). + # AppImage runs in its own continue-on-error step below so its + # persistent linuxdeploy failure (#6127 onwards) does not tank uploads. + args: ${{ matrix.platform == 'ubuntu-22.04' && (inputs.minimal && '--bundles deb' || '--bundles deb,rpm') || matrix.args }} - name: Build Tauri app (unsigned) if: ${{ !inputs.sign }} @@ -406,15 +415,16 @@ jobs: with: projectPath: ./frontend/editor tauriScript: npx tauri - # Linux: build deb+rpm only here. AppImage runs in its own - # continue-on-error step below so its persistent linuxdeploy - # failure (#6127 onwards) does not tank deb/rpm uploads. - args: ${{ matrix.platform == 'ubuntu-22.04' && '--bundles deb,rpm' || matrix.args }} + # Linux: build deb+rpm only here (deb-only on minimal smoke builds). + # AppImage runs in its own continue-on-error step below so its + # persistent linuxdeploy failure (#6127 onwards) does not tank uploads. + args: ${{ matrix.platform == 'ubuntu-22.04' && (inputs.minimal && '--bundles deb' || '--bundles deb,rpm') || matrix.args }} # AppImage is decoupled so its linuxdeploy run gets a fresh process # (rpm scratch state torn down) and its failure can't tank deb/rpm. + # Skipped on minimal smoke builds (flaky + slow, deb is enough to verify). - name: Build Tauri app (Linux AppImage) - if: matrix.platform == 'ubuntu-22.04' + if: matrix.platform == 'ubuntu-22.04' && !inputs.minimal continue-on-error: true uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2 env: diff --git a/.github/workflows/test-build-docker.yml b/.github/workflows/test-build-docker.yml index c91ea0435a..f8344da116 100644 --- a/.github/workflows/test-build-docker.yml +++ b/.github/workflows/test-build-docker.yml @@ -12,19 +12,16 @@ on: required: false type: string default: "false" - depot_cores: - description: "Depot runner vCPU count (used in runs-on). Override for benchmarking." + dockerfiles-changed: + description: "Whether any Dockerfile changed (forwarded from files-changed). Gates the slow arm64 build leg." required: false type: string - default: "8" + default: "false" permissions: contents: read jobs: - pick: - uses: ./.github/workflows/_runner-pick.yml - # TODO: extract a pre-matrix `prepare` job that runs once and produces # shared artifacts for the three matrix entries below to consume: # 1. `task backend:build` — currently runs 3× in parallel with @@ -40,14 +37,7 @@ jobs: # spring-security=true matrix entry if `task backend:build` and # `task backend:build:ci` produce equivalent JARs (verify before wiring). test-build-docker-images: - needs: pick - runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') }} - permissions: - contents: read - id-token: write - env: - USE_DEPOT: ${{ needs.pick.outputs.is_fork != 'true' && inputs.docker-base-changed != 'true' }} - DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }} + runs-on: ubuntu-latest strategy: fail-fast: false matrix: @@ -104,9 +94,9 @@ jobs: key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 with: - gradle-version: 9.6.0 + gradle-version: 9.6.1 cache-disabled: true - name: Install Task @@ -120,16 +110,10 @@ jobs: DISABLE_ADDITIONAL_FEATURES: true STIRLING_PDF_DESKTOP_UI: false - - name: Set up Depot CLI - if: env.USE_DEPOT == 'true' - uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.0.0 - - name: Set up QEMU - if: env.USE_DEPOT != 'true' uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 - name: Set up Docker Buildx - if: env.USE_DEPOT != 'true' id: buildx uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 @@ -146,13 +130,22 @@ jobs: # GITHUB_EVENT_NAME is already provided by the runner. env: DOCKER_BASE_CHANGED: ${{ inputs.docker-base-changed }} + DOCKERFILES_CHANGED: ${{ inputs.dockerfiles-changed }} run: | if [ "$GITHUB_EVENT_NAME" = "pull_request" ] && [ "$DOCKER_BASE_CHANGED" = "true" ]; then + # Base Dockerfile changed: build against the locally-built base, + # which only exists for amd64. echo "base_image=stirling-pdf-base:pr-test" >> "$GITHUB_OUTPUT" echo "platforms=linux/amd64" >> "$GITHUB_OUTPUT" - else + elif [ "$DOCKERFILES_CHANGED" = "true" ]; then + # A Dockerfile changed: also verify the arm64 build (slow QEMU leg). echo "base_image=stirlingtools/stirling-pdf-base:latest" >> "$GITHUB_OUTPUT" echo "platforms=linux/amd64,linux/arm64/v8" >> "$GITHUB_OUTPUT" + else + # No Dockerfile change: amd64 only. arm64 is exercised on the base + # image publish and on release, not on every code PR. + echo "base_image=stirlingtools/stirling-pdf-base:latest" >> "$GITHUB_OUTPUT" + echo "platforms=linux/amd64" >> "$GITHUB_OUTPUT" fi # Base-changed PRs build the embedded image with the local docker driver @@ -168,24 +161,10 @@ jobs: --tag stirling-pdf-embedded:pr-test \ . - - name: Build ${{ matrix.docker-rev }} (Depot) - if: env.USE_DEPOT == 'true' - uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.16.0 - with: - project: ${{ vars.DEPOT_PROJECT_ID }} - context: . - file: ./${{ matrix.docker-rev }} - push: false - platforms: ${{ steps.build-params.outputs.platforms }} - build-args: | - BASE_IMAGE=${{ steps.build-params.outputs.base_image }} - provenance: true - sbom: true - - # Fork PRs that did NOT change the base use the buildx container builder + # PRs that did NOT change the base use the buildx container builder # (multi-platform + gha cache) against the published base image. - - name: Build ${{ matrix.docker-rev }} (Docker fork fallback) - if: env.USE_DEPOT != 'true' && inputs.docker-base-changed != 'true' + - name: Build ${{ matrix.docker-rev }} + if: inputs.docker-base-changed != 'true' uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: builder: ${{ steps.buildx.outputs.name }} @@ -213,14 +192,7 @@ jobs: if-no-files-found: warn test-build-unoserver-image: - needs: pick - runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') }} - permissions: - contents: read - id-token: write - env: - USE_DEPOT: ${{ needs.pick.outputs.is_fork != 'true' && inputs.docker-base-changed != 'true' }} - DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }} + runs-on: ubuntu-latest steps: - name: Harden Runner uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 @@ -230,35 +202,14 @@ jobs: - name: Checkout Repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Set up Depot CLI - if: env.USE_DEPOT == 'true' - uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.0.0 - - name: Set up QEMU - if: env.USE_DEPOT != 'true' uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 - name: Set up Docker Buildx - if: env.USE_DEPOT != 'true' id: buildx uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - - name: Build docker/unoserver/Dockerfile (Depot) - if: env.USE_DEPOT == 'true' - uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.16.0 - with: - project: ${{ vars.DEPOT_PROJECT_ID }} - context: . - file: ./docker/unoserver/Dockerfile - push: false - load: true - platforms: linux/amd64 - tags: stirling-unoserver:pr-test - provenance: false - sbom: false - - - name: Build docker/unoserver/Dockerfile (Docker fork fallback) - if: env.USE_DEPOT != 'true' + - name: Build docker/unoserver/Dockerfile uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: builder: ${{ steps.buildx.outputs.name }} diff --git a/.github/workflows/testdriver.yml b/.github/workflows/testdriver.yml index ec9b658326..f86191656f 100644 --- a/.github/workflows/testdriver.yml +++ b/.github/workflows/testdriver.yml @@ -20,19 +20,9 @@ permissions: contents: read jobs: - pick: - uses: ./.github/workflows/_runner-pick.yml - deploy: if: ${{ vars.CI_PROFILE != 'lite' }} - needs: pick - runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }} - permissions: - contents: read - id-token: write - env: - USE_DEPOT: ${{ needs.pick.outputs.is_fork != 'true' }} - DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }} + runs-on: ubuntu-latest steps: - name: Harden Runner uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 @@ -49,9 +39,9 @@ jobs: distribution: "temurin" - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 with: - gradle-version: 9.6.0 + gradle-version: 9.6.1 - name: Build with Gradle run: ./gradlew build @@ -61,12 +51,7 @@ jobs: MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }} DISABLE_ADDITIONAL_FEATURES: true - - name: Set up Depot CLI - if: env.USE_DEPOT == 'true' - uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.0.0 - - name: Set up Docker Buildx - if: env.USE_DEPOT != 'true' uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - name: Get version number @@ -81,20 +66,7 @@ jobs: username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_API }} - - name: Build and push test image (Depot) - if: env.USE_DEPOT == 'true' - uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.16.0 - with: - project: ${{ vars.DEPOT_PROJECT_ID }} - context: . - file: ./docker/embedded/Dockerfile - push: true - tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:test-${{ github.sha }} - build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }} - platforms: linux/amd64 - - - name: Build and push test image (Docker fork fallback) - if: env.USE_DEPOT != 'true' + - name: Build and push test image uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: . @@ -153,8 +125,7 @@ jobs: files-changed: if: always() name: detect what files changed - needs: pick - runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }} + runs-on: ubuntu-latest timeout-minutes: 3 outputs: frontend: ${{ steps.changes.outputs.frontend }} @@ -174,8 +145,8 @@ jobs: test: if: needs.files-changed.outputs.frontend == 'true' - needs: [pick, deploy, files-changed] - runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }} + needs: [deploy, files-changed] + runs-on: ubuntu-latest steps: - name: Harden Runner uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 @@ -208,8 +179,8 @@ jobs: FORCE_COLOR: "3" cleanup: - needs: [pick, deploy, test] - runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }} + needs: [deploy, test] + runs-on: ubuntu-latest if: always() steps: diff --git a/.taskfiles/backend.yml b/.taskfiles/backend.yml index 77c9645476..dde75433a8 100644 --- a/.taskfiles/backend.yml +++ b/.taskfiles/backend.yml @@ -26,7 +26,6 @@ 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" @@ -41,13 +40,12 @@ 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}}{{if .POLICIES_ENABLED}}POLICIES_ENABLED={{.POLICIES_ENABLED}} {{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}}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}}{{if .POLICIES_ENABLED}}POLICIES_ENABLED={{.POLICIES_ENABLED}} {{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}}./gradlew :stirling-pdf:bootRun' platforms: [linux, darwin] dev:bundled: diff --git a/.taskfiles/frontend.yml b/.taskfiles/frontend.yml index b402df7d1b..3c2113bf96 100644 --- a/.taskfiles/frontend.yml +++ b/.taskfiles/frontend.yml @@ -85,7 +85,8 @@ tasks: # full path, hostname, or user. Consumed at dev-serve time by vite.config # and dropped from production builds. STIRLING_DEV_LABEL: - sh: basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)" + sh: >- + {{if eq OS "windows"}}powershell -NoProfile -Command '$root = git rev-parse --show-toplevel 2>$null; if (-not $root) { $root = (Get-Location).Path }; Split-Path -Leaf $root'{{else}}basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)"{{end}} cmds: - npx vite editor --mode {{.MODE}} --port {{.PORT}}{{if .OPEN}} --open{{end}} @@ -183,16 +184,74 @@ tasks: storybook: desc: "Start Storybook dev server" - deps: [install] + deps: [prepare] cmds: - npx storybook dev -p 6006 {{.CLI_ARGS}} storybook:build: desc: "Build static Storybook" - deps: [install] + deps: [prepare] cmds: - npx storybook build {{.CLI_ARGS}} + storybook:browser: + internal: true + desc: "Install the Chromium build the story scan runs in" + run: once + deps: [install] + cmds: + - npx playwright install chromium + + storybook:test: + desc: "Scan every story in real Chromium: it must render and pass axe" + deps: [prepare, storybook:browser] + cmds: + # Runs each story as a browser test. Pass a filter through, e.g. + # task frontend:storybook:test -- Button + - npx vitest run --config .storybook/vitest.config.ts {{.CLI_ARGS}} + + storybook:a11y: + desc: "a11y regression gate over every story: fail only on NEW axe violations" + deps: [prepare, storybook:browser] + cmds: + - node .storybook/a11y-scan.mjs + - node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt + + storybook:a11y:changed: + desc: "a11y gate over the stories this branch affects (default base origin/main)" + summary: | + Scans the stories a branch affects, which is what pull requests run — a + full scan takes ~30 minutes, far too long to sit in front of every merge. + A story is affected if its file changed, or if a same-named sibling + source file changed (editing Button.tsx or Button.css re-scans + Button.stories.tsx — the story renders the live component, so a component + edit changes what the story shows without touching the story file). + Changes that ripple further than a component's own stories are covered by + the nightly full sweep. + + Pass a base ref through CLI_ARGS, e.g. + task frontend:storybook:a11y:changed -- origin/release + deps: [prepare, storybook:browser] + vars: + BASE: '{{.CLI_ARGS | default "origin/main"}}' + CHANGED: + sh: node .storybook/a11y-changed.mjs {{.CLI_ARGS | default "origin/main"}} + cmds: + - cmd: | + if [ -z '{{.CHANGED}}' ]; then + echo "a11y: no story files affected vs {{.BASE}} — nothing to check" + exit 0 + fi + node .storybook/a11y-scan.mjs {{.CHANGED}} + node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt + + storybook:a11y:record: + desc: "Re-record the a11y baseline (run after intentionally fixing/adding violations)" + deps: [prepare, storybook:browser] + cmds: + - node .storybook/a11y-scan.mjs + - node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt --record + # ============================================================ # Code quality # ============================================================ @@ -203,6 +262,23 @@ tasks: cmds: - task: lint:eslint - task: lint:dpdm + - task: lint:colors + + lint:colors: + desc: "Enforce theme tokens — no hardcoded colours or raw primitives in components" + aliases: [lint:colours] + deps: [install] + cmds: + - node editor/scripts/lint/theme-lint.mjs + - node editor/scripts/lint/theme-lint.mjs css-colors + - node editor/scripts/lint/theme-lint.mjs code-colors + - node editor/scripts/lint/theme-lint.mjs no-primitives + + contrast: + desc: "Report low-contrast theme token pairs (warning only, never blocks)" + deps: [install] + cmds: + - node editor/scripts/lint/theme-lint.mjs contrast lint:eslint: desc: "Run ESLint linting" @@ -249,10 +325,8 @@ tasks: typecheck:_run: internal: true - env: - CI: '{{ .CI | default "false" }}' cmds: - - '{{ if eq .CI "true" }}npx tsc{{ else }}npx tsgo{{ end }} --noEmit --project {{.PROJECT}}' + - 'npx tsc --noEmit --project {{.PROJECT}}' typecheck:core: desc: "Typecheck core build variant" diff --git a/.taskfiles/pre-commit.yml b/.taskfiles/pre-commit.yml index 136f852a5b..f2e488e815 100644 --- a/.taskfiles/pre-commit.yml +++ b/.taskfiles/pre-commit.yml @@ -73,7 +73,7 @@ tasks: - task: gitleaks install: - desc: "Install the pinned pre-commit Python tools (ruff, codespell, toml-sort)" + desc: "Install the pinned pre-commit Python tools" run: once cmds: - uv sync --project scripts/pre-commit --locked @@ -112,7 +112,7 @@ tasks: toml-sort: deps: [install] cmds: - - uv run --project scripts/pre-commit --no-sync toml-sort --all --ignore-case {{if .FIX}}--in-place{{else}}--check{{end}} {{.LOCALE_TOML}} + - uv run --project scripts/pre-commit --no-sync python scripts/pre-commit/sort_locale_toml.py {{if .FIX}}--fix {{end}}{{.LOCALE_TOML}} whitespace: cmds: diff --git a/AGENTS.md b/AGENTS.md index 7e529e4be9..259729bc04 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -155,6 +155,8 @@ The project structure is defined in `engine/pyproject.toml`. Any new dependencie For a broader explanation of the frontend layering and override architecture, read @frontend/editor/DeveloperGuide.md +Before touching colours or theming (tokens, dark mode, accent colours), read @frontend/editor/src/core/theme/README.md — it explains the palette/`--c-*` token system and the rule that literal colours live only in `primitives.css`. + ```typescript // ✅ CORRECT - Use @app/* for all imports import { AppLayout } from "@app/components/AppLayout"; diff --git a/LICENSE b/LICENSE index 971c9d0b16..5579823030 100644 --- a/LICENSE +++ b/LICENSE @@ -22,6 +22,8 @@ if that directory exists, is licensed under the license defined in "frontend/edi if that directory exists, is licensed under the license defined in "frontend/editor/src/prototypes/LICENSE". * All content that resides under the "frontend/editor/src/portal/" directory of this repository, if that directory exists, is licensed under the license defined in "frontend/editor/src/portal/LICENSE". +* All content that resides under the "frontend/editor/src/portal-saas/" directory of this repository, +if that directory exists, is licensed under the license defined in "frontend/editor/src/portal-saas/LICENSE". * Content outside of the above mentioned directories or restrictions above is available under the MIT License as defined below. diff --git a/Taskfile.yml b/Taskfile.yml index 705ad4a1db..75183bd4f9 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -90,7 +90,6 @@ tasks: vars: PORT: '{{.BACKEND_PORT}}' SECURITY_ENABLELOGIN: "true" - POLICIES_ENABLED: "true" - task: frontend:dev:proprietary vars: PORT: '{{.EDITOR_PORT}}' diff --git a/app/allowed-licenses.json b/app/allowed-licenses.json index 9f1ff96359..88ed8ba4d5 100644 --- a/app/allowed-licenses.json +++ b/app/allowed-licenses.json @@ -208,6 +208,18 @@ "moduleName": ".*", "moduleLicense": "The W3C License" }, + { + "moduleName": "com.google.re2j:re2j", + "moduleLicense": "Go License" + }, + { + "moduleName": "com.hubspot:algebra", + "moduleLicense": null + }, + { + "moduleName": "com.hubspot.immutables:immutables-exceptions", + "moduleLicense": null + }, { "moduleName": ".*", "moduleLicense": "UnRar License" diff --git a/app/common/build.gradle b/app/common/build.gradle index 516edd4897..71ccbf236f 100644 --- a/app/common/build.gradle +++ b/app/common/build.gradle @@ -2,32 +2,6 @@ bootRun { enabled = false } -spotless { - java { - target 'src/**/java/**/*.java' - targetExclude 'src/main/java/org/apache/**' - googleJavaFormat(googleJavaFormatVersion).aosp().reorderImports(false) - // google-java-format 1.28.0 bundles Guava 32.x which crashes Spotless lint on JDK 24/25 - suppressLintsFor { setStep('google-java-format') } - - importOrder("java", "javax", "org", "com", "net", "io", "jakarta", "lombok", "me", "stirling") - trimTrailingWhitespace() - leadingTabsToSpaces() - endWithNewline() - } - yaml { - target '**/*.yml', '**/*.yaml' - trimTrailingWhitespace() - leadingTabsToSpaces() - endWithNewline() - } - format 'gradle', { - target '**/gradle/*.gradle', '**/*.gradle' - trimTrailingWhitespace() - leadingTabsToSpaces() - endWithNewline() - } -} dependencies { api "com.google.guava:guava:${guavaVersion}" api 'org.springframework.boot:spring-boot-starter-webmvc' @@ -42,7 +16,7 @@ dependencies { api "org.apache.pdfbox:pdfbox-io:$pdfboxVersion" api "org.apache.pdfbox:xmpbox:$pdfboxVersion" api "org.apache.pdfbox:preflight:$pdfboxVersion" - api 'com.github.junrar:junrar:7.5.10' // RAR archive support for CBR files + api 'com.github.junrar:junrar:7.6.0' // RAR archive support for CBR files api 'jakarta.servlet:jakarta.servlet-api:6.1.0' api 'org.snakeyaml:snakeyaml-engine:3.0.1' api "org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3" 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 d2f0472510..43f27eec2d 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,16 +206,12 @@ 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 - * pointed at an arbitrary server path. Stirling's own config directory is always - * off-limits, and folder access is always disabled in SaaS mode regardless of this list. + * write to. Empty (the default) disables folder access except to implicitly defined + * folders, such as server storage folders (if enabled) and the pipeline watched folders. + * Stirling's own config directory is always off-limits, and folder access is always + * disabled in SaaS mode regardless of this list. */ private List allowedFolderRoots = new java.util.ArrayList<>(); @@ -254,6 +250,29 @@ public class ApplicationProperties { * in-network object store. */ private boolean allowPrivateS3Endpoints = false; + + /** + * Whether an API/Purview/ConsignO integration's base URL may resolve to a loopback, + * link-local, or private address. Off by default: unlike S3 connections, any user may + * create one of these, so without this gate a user could point a connection at the cloud + * metadata address and have the server fetch it for them. Enable only when integrations + * genuinely live inside the network (e.g. an on-prem ConsignO or an internal API gateway). + */ + private boolean allowPrivateApiEndpoints = false; + + /** + * Whether administrators may define their own API integrations - a free-form base URL, + * path, body and headers - as opposed to only using the built-in vendor presets (Purview, + * ConsignO, S3). On by default, and admin-only regardless: a custom integration can point + * the server at any host, so it is authoring power, not self-serve. + * + *

Turning this off stops new custom integrations being created or edited. Ones that + * already exist keep running, because a policy that silently stopped calling out would be a + * worse surprise than one that keeps working; disable the connection itself to stop it. + */ + private boolean allowCustomApiIntegrations = true; + + private long webhookMaxBytes = 104857600L; } @Data @@ -308,6 +327,102 @@ public class ApplicationProperties { * explicitly requests it via {@code AiEngineClient.postWithTimeout}. */ private int longRunningTimeoutSeconds = 600; + + /** Timeout (seconds) for the SSE stream held open by long-running orchestrator runs. */ + private int streamTimeoutSeconds = 1800; + + /** + * Whether the processor pushes settings-derived AI config to the engine on startup/save. + * Pin false for env-driven deployments (SaaS) to keep the engine env-controlled. + */ + private boolean pushConfigToEngine = true; + + /** Model + provider selection, forwarded to the engine per-request. */ + private Models models = new Models(); + + /** Retrieval-augmented-generation (RAG) knobs, forwarded to the engine per-request. */ + private Rag rag = new Rag(); + + /** Request size / cost guardrails. */ + private Limits limits = new Limits(); + + /** Per-capability on/off switches so an admin can disable individual AI tools. */ + private Features features = new Features(); + + @Data + public static class Models { + /** Provider driving the model strings: 'anthropic', 'openai', 'ollama', or 'custom'. */ + private String provider = "anthropic"; + + /** High-quality tier model name (without provider prefix), e.g. 'claude-haiku-4-5'. */ + private String smartModel = "claude-haiku-4-5"; + + /** Cheap/fast tier model name (without provider prefix). */ + private String fastModel = "claude-haiku-4-5"; + + private int smartMaxTokens = 8192; + private int fastMaxTokens = 2048; + + /** + * API key for the selected provider (secret; masked). Empty means the engine uses its + * own env credential (e.g. ANTHROPIC_API_KEY). + */ + private String apiKey = ""; + + /** + * OpenAI-compatible base URL for 'ollama' / 'custom' providers (e.g. + * http://ollama:11434/v1). Ignored for anthropic/openai. SSRF-sensitive - admin only. + */ + private String baseUrl = ""; + } + + @Data + public static class Rag { + /** + * Embedding provider: 'voyageai', 'openai', 'ollama', or 'custom' (OpenAI-compatible). + */ + private String embeddingProvider = "voyageai"; + + /** Embedding model name (without provider prefix), e.g. 'voyage-4'. */ + private String embeddingModel = "voyage-4"; + + /** + * Secret API key for the embedding provider; masked + env-overridable like + * models.apiKey. + */ + private String embeddingApiKey = ""; + + /** + * OpenAI-compatible base URL for 'ollama' / 'custom' embedding providers (e.g. + * http://ollama:11434/v1). Ignored for voyageai/openai. SSRF-sensitive - admin only. + */ + private String embeddingBaseUrl = ""; + + /** How many chunks retrieval returns per search. */ + private int topK = 20; + + /** Per-run cap on knowledge-search tool calls before the agent must answer. */ + private int maxSearches = 5; + } + + @Data + public static class Limits { + private int maxPages = 200; + private int maxCharacters = 200000; + + /** Process-wide cap on concurrent model API calls (engine restart to apply). */ + private int modelMaxConcurrency = 32; + } + + @Data + public static class Features { + private boolean chat = true; + private boolean documentQuestions = true; + private boolean createPdf = true; + private boolean mathAuditor = true; + private boolean pdfComment = true; + private boolean classify = true; + } } /** diff --git a/app/common/src/main/java/stirling/software/common/model/job/JobResult.java b/app/common/src/main/java/stirling/software/common/model/job/JobResult.java index 52c0826e2b..aa43431a15 100644 --- a/app/common/src/main/java/stirling/software/common/model/job/JobResult.java +++ b/app/common/src/main/java/stirling/software/common/model/job/JobResult.java @@ -4,6 +4,8 @@ import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; import com.fasterxml.jackson.annotation.JsonIgnore; @@ -47,6 +49,9 @@ public class JobResult { */ private final List notes = new CopyOnWriteArrayList<>(); + /** Key/value metadata that survives the write-through into the shared job store. */ + private final Map metadata = new ConcurrentHashMap<>(); + /** * Create a new JobResult with the given job ID * @@ -161,4 +166,16 @@ public class JobResult { public List getNotes() { return Collections.unmodifiableList(notes); } + + /** Attach a metadata value, e.g. a policy id so cluster peers can identify a policy run. */ + public void putMetadata(String key, String value) { + if (key != null && value != null) { + this.metadata.put(key, value); + } + } + + /** An unmodifiable view of this job's metadata. */ + public Map getMetadata() { + return Collections.unmodifiableMap(metadata); + } } diff --git a/app/common/src/main/java/stirling/software/common/service/InternalApiClient.java b/app/common/src/main/java/stirling/software/common/service/InternalApiClient.java index 63d577ca85..dd49ab3f51 100644 --- a/app/common/src/main/java/stirling/software/common/service/InternalApiClient.java +++ b/app/common/src/main/java/stirling/software/common/service/InternalApiClient.java @@ -46,9 +46,14 @@ public class InternalApiClient { // The second alternation carves out `/api/v1/ai/tools/*` specifically — AI tools are // dispatchable, but the broader `/api/v1/ai/` surface (orchestrate, health, etc.) is // intentionally NOT permitted to avoid plan steps re-entering the orchestrator. + // + // `/api/v1/integration/*` holds third-party steps (external API call, Purview labelling, + // ConsignO). They reach outside the JVM, so the namespace is deliberately kept to tools that + // dereference an admin-owned connection rather than a caller-supplied host — see + // ApiConnectionResolver. private static final Pattern ALLOWED_ENDPOINT_PATH = Pattern.compile( - "^/api/v1/(general|misc|security|convert|filter)(/[A-Za-z0-9_-]+)+$" + "^/api/v1/(general|misc|security|convert|filter|integration)(/[A-Za-z0-9_-]+)+$" + "|^/api/v1/ai/tools(/[A-Za-z0-9_-]+)+$"); /** diff --git a/app/common/src/main/java/stirling/software/common/service/TaskManager.java b/app/common/src/main/java/stirling/software/common/service/TaskManager.java index 790e0626ac..3fd9ac3fe4 100644 --- a/app/common/src/main/java/stirling/software/common/service/TaskManager.java +++ b/app/common/src/main/java/stirling/software/common/service/TaskManager.java @@ -230,6 +230,18 @@ public class TaskManager { return false; } + /** Attach metadata to a job and write it through to the shared store for cluster peers. */ + public boolean putMetadata(String jobId, String key, String value) { + JobResult jobResult = jobResults.get(jobId); + if (jobResult != null) { + jobResult.putMetadata(key, value); + writeThrough(jobId, jobResult); + return true; + } + log.warn("Attempted to set metadata on non-existent job ID: {}", jobId); + return false; + } + /** * Get statistics about all jobs in the system * @@ -378,7 +390,7 @@ public class TaskManager { fileIds.add(rf.getFileId()); } } - Map meta = new HashMap<>(); + Map meta = new HashMap<>(result.getMetadata()); if (result.getNotes() != null && !result.getNotes().isEmpty()) { meta.put("notesCount", Integer.toString(result.getNotes().size())); } diff --git a/app/common/src/main/java/stirling/software/common/util/PdfTextLocator.java b/app/common/src/main/java/stirling/software/common/util/PdfTextLocator.java index 60aa65f74b..ec2a5da708 100644 --- a/app/common/src/main/java/stirling/software/common/util/PdfTextLocator.java +++ b/app/common/src/main/java/stirling/software/common/util/PdfTextLocator.java @@ -5,6 +5,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Locale; import java.util.Optional; +import java.util.regex.Pattern; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.common.PDRectangle; @@ -28,6 +29,8 @@ import lombok.extern.slf4j.Slf4j; @Component public class PdfTextLocator { + private static final Pattern NON_ALPHANUMERIC_PATTERN = Pattern.compile("[^A-Za-z0-9]"); + /** One found line of text with its user-space bounding box. */ public record MatchedBox(float x, float y, float width, float height) {} @@ -82,7 +85,7 @@ public class PdfTextLocator { /** Strip everything non-alphanumeric and lowercase for tolerant matching. */ private static String normalize(String s) { - return s.replaceAll("[^A-Za-z0-9]", "").toLowerCase(Locale.ROOT); + return NON_ALPHANUMERIC_PATTERN.matcher(s).replaceAll("").toLowerCase(Locale.ROOT); } private static final class CapturedLine { diff --git a/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java b/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java index 4db9f118ec..a653ae6c0b 100644 --- a/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java @@ -1,7 +1,11 @@ package stirling.software.common.util; +import java.util.regex.Pattern; + public class RequestUriUtils { + private static final Pattern SHARE_LINK_PATTERN = Pattern.compile("^/share/[^/]+/?$"); + public static boolean isStaticResource(String requestURI) { return isStaticResource("", requestURI); } @@ -198,11 +202,12 @@ public class RequestUriUtils { || trimmedUri.startsWith("/readiness") || trimmedUri.startsWith( "/api/v1/mobile-scanner/") // Mobile scanner endpoints (no auth) + || trimmedUri.startsWith("/api/v1/webhooks/") || trimmedUri.startsWith("/v1/api-docs") // Workflow participant endpoints - access controlled by share tokens, not login || trimmedUri.startsWith("/api/v1/workflow/participant/") // Share-link SPA bootstrap; data APIs remain protected - || trimmedUri.matches("^/share/[^/]+/?$"); + || SHARE_LINK_PATTERN.matcher(trimmedUri).matches(); } private static String stripContextPath(String contextPath, String requestURI) { diff --git a/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java b/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java index a6fefd1091..1912f3808b 100644 --- a/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java +++ b/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java @@ -176,6 +176,13 @@ class RequestUriUtilsTest { assertFalse(RequestUriUtils.isPublicAuthEndpoint("/api/v1/convert", "")); } + @Test + void testIsPublicAuthEndpoint_webhookReceiver() { + // The webhook source receiver authenticates each delivery by HMAC signature, not a session. + assertTrue(RequestUriUtils.isPublicAuthEndpoint("/api/v1/webhooks/whk_abc123", "")); + assertTrue(RequestUriUtils.isPublicAuthEndpoint("/app/api/v1/webhooks/whk_abc123", "/app")); + } + @Test void testIsPublicAuthEndpoint_withContextPath() { assertTrue(RequestUriUtils.isPublicAuthEndpoint("/app/login", "/app")); diff --git a/app/core/build.gradle b/app/core/build.gradle index 6a55d89304..578ace75cc 100644 --- a/app/core/build.gradle +++ b/app/core/build.gradle @@ -9,35 +9,6 @@ configurations { } } -spotless { - java { - target 'src/**/java/**/*.java' - targetExclude 'src/main/resources/static/**', 'src/main/java/org/apache/**' - googleJavaFormat(googleJavaFormatVersion).aosp().reorderImports(false) - // google-java-format 1.28.0 bundles Guava 32.x which crashes Spotless lint on JDK 24/25 - suppressLintsFor { setStep('google-java-format') } - - importOrder("java", "javax", "org", "com", "net", "io", "jakarta", "lombok", "me", "stirling") - trimTrailingWhitespace() - leadingTabsToSpaces() - endWithNewline() - } - yaml { - target '**/*.yml', '**/*.yaml' - targetExclude 'src/main/resources/static/**' - trimTrailingWhitespace() - leadingTabsToSpaces() - endWithNewline() - } - format 'gradle', { - target '**/gradle/*.gradle', '**/*.gradle' - targetExclude 'src/main/resources/static/**' - trimTrailingWhitespace() - leadingTabsToSpaces() - endWithNewline() - } -} - dependencies { if (!gradle.ext.disableAdditional) { implementation project(':proprietary') diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormPayloadParser.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormPayloadParser.java index 6c0c40ef0a..6f82c7546e 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormPayloadParser.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormPayloadParser.java @@ -117,8 +117,8 @@ final class FormPayloadParser { names.add(single); } } - } else if (root.isTextual()) { - final String single = trimToNull(root.asText("")); + } else if (root.isString()) { + final String single = trimToNull(root.asString("")); if (single != null) { names.add(single); } @@ -197,8 +197,8 @@ final class FormPayloadParser { if (node == null || node.isNull()) { return null; } - if (node.isTextual()) { - return trimToEmpty(node.asText("")); + if (node.isString()) { + return trimToEmpty(node.asString("")); } if (node.isNumber()) { return node.numberValue().toString(); @@ -207,7 +207,7 @@ final class FormPayloadParser { return Boolean.toString(node.booleanValue()); } // Fallback for other scalar-like nodes - return trimToEmpty(node.asText("")); + return trimToEmpty(node.asString("")); } private static void collectNames(JsonNode arrayNode, Set sink) { @@ -227,8 +227,8 @@ final class FormPayloadParser { return null; } - if (node.isTextual()) { - return trimToNull(node.asText("")); + if (node.isString()) { + return trimToNull(node.asString("")); } if (node.isObject()) { @@ -269,7 +269,7 @@ final class FormPayloadParser { final JsonNode v = objectNode.get(key); if (v == null || v.isNull()) { result.put(key, null); - } else if (v.isTextual() || v.isNumber() || v.isBoolean()) { + } else if (v.isString() || v.isNumber() || v.isBoolean()) { result.put(key, coerceScalarToString(v)); } else { result.put(key, v.toString()); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java index 27477f9c2a..8044050025 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java @@ -336,7 +336,19 @@ public class ConfigController { configData.put("premiumEnabled", applicationProperties.getPremium().isEnabled()); // AI Engine settings - configData.put("aiEngineEnabled", applicationProperties.getAiEngine().isEnabled()); + ApplicationProperties.AiEngine aiEngineConfig = applicationProperties.getAiEngine(); + configData.put("aiEngineEnabled", aiEngineConfig.isEnabled()); + // Per-capability flags let the UI hide individual AI tools an admin has turned off. + ApplicationProperties.AiEngine.Features aiFeatures = aiEngineConfig.getFeatures(); + configData.put( + "aiFeatures", + Map.ofEntries( + Map.entry("chat", aiFeatures.isChat()), + Map.entry("documentQuestions", aiFeatures.isDocumentQuestions()), + Map.entry("createPdf", aiFeatures.isCreatePdf()), + Map.entry("mathAuditor", aiFeatures.isMathAuditor()), + Map.entry("pdfComment", aiFeatures.isPdfComment()), + Map.entry("classify", aiFeatures.isClassify()))); // Timestamp TSA settings — single source of truth for presets + admin URLs ApplicationProperties.Security.Timestamp tsConfig = diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/ValidateSignatureController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/ValidateSignatureController.java index ecfc0ad2db..47fce44876 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/ValidateSignatureController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/ValidateSignatureController.java @@ -23,6 +23,9 @@ import org.bouncycastle.cms.CMSSignedData; import org.bouncycastle.cms.SignerInformation; import org.bouncycastle.cms.SignerInformationStore; import org.bouncycastle.cms.jcajce.JcaSimpleSignerInfoVerifierBuilder; +import org.bouncycastle.operator.jcajce.JcaDigestCalculatorProviderBuilder; +import org.bouncycastle.tsp.TimeStampToken; +import org.bouncycastle.tsp.TimeStampTokenInfo; import org.bouncycastle.util.Store; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -54,6 +57,9 @@ public class ValidateSignatureController { private final CustomPDFDocumentFactory pdfDocumentFactory; private final CertificateValidationService certValidationService; + /** PDF sub-filter identifying an RFC 3161 document timestamp (PAdES-LTV). */ + private static final String SUBFILTER_RFC3161 = "ETSI.RFC3161"; + @InitBinder public void initBinder(WebDataBinder binder) { binder.registerCustomEditor( @@ -128,8 +134,35 @@ public class ValidateSignatureController { byte[] signedContent = sig.getSignedContent(file.getInputStream()); byte[] signatureBytes = sig.getContents(file.getInputStream()); - CMSProcessable content = new CMSProcessableByteArray(signedContent); - CMSSignedData signedData = new CMSSignedData(content, signatureBytes); + // An RFC 3161 document timestamp (PAdES-LTV) carries its signed content + // *inside* the CMS - a TSTInfo - rather than being detached over the document. + // Building it as detached digests the ByteRange against an attribute that + // covers the TSTInfo, which can never match. + boolean isDocTimeStamp = SUBFILTER_RFC3161.equals(sig.getSubFilter()); + CMSSignedData signedData; + if (isDocTimeStamp) { + signedData = new CMSSignedData(signatureBytes); + } else { + CMSProcessable content = new CMSProcessableByteArray(signedContent); + signedData = new CMSSignedData(content, signatureBytes); + } + + // What actually binds a timestamp to this document: the TSTInfo's message + // imprint must equal the digest of the signed byte range. Without this check a + // valid timestamp token for some *other* document would verify happily here. + Date timeStampGenTime = null; + if (isDocTimeStamp) { + TimeStampToken token = new TimeStampToken(signedData); + TimeStampTokenInfo info = token.getTimeStampInfo(); + timeStampGenTime = info.getGenTime(); + if (!timestampCoversContent(info, signedContent)) { + result.setValid(false); + result.setErrorMessage( + "Timestamp message imprint does not match the document"); + results.add(result); + continue; + } + } Store certStore = signedData.getCertificates(); SignerInformationStore signerStore = signedData.getSignerInfos(); @@ -162,7 +195,15 @@ public class ValidateSignatureController { CertificateValidationService.ValidationTime validationTimeResult = certValidationService.extractValidationTime(signerInfo); Date validationTime; - if (validationTimeResult == null) { + if (timeStampGenTime != null) { + // The TSA's own asserted time is the authoritative one here, and is + // exactly what makes the signature verifiable after the cert expires. + validationTime = timeStampGenTime; + // Distinct from "timestamp", which CertificateValidationService already + // uses for a signature countersigned by a TSA. Both are RFC 3161, but + // one attests a signature and the other attests the whole document. + result.setValidationTimeSource("document-timestamp"); + } else if (validationTimeResult == null) { validationTime = new Date(); result.setValidationTimeSource("current"); } else { @@ -235,10 +276,13 @@ public class ValidateSignatureController { // Set basic signature info result.setSignerName(sig.getName()); + // A DocTimeStamp has no /M entry; its date is the TSA's genTime. result.setSignatureDate( - sig.getSignDate() != null - ? sig.getSignDate().getTime().toString() - : null); + timeStampGenTime != null + ? timeStampGenTime.toString() + : sig.getSignDate() != null + ? sig.getSignDate().getTime().toString() + : null); result.setReason(sig.getReason()); result.setLocation(sig.getLocation()); @@ -301,4 +345,20 @@ public class ValidateSignatureController { return ResponseEntity.ok(results); } + + /** + * True when the timestamp token was issued over exactly these bytes. + * + *

The digest algorithm is taken from the token rather than assumed, because a TSA chooses it + * - assuming SHA-256 would silently fail against any TSA that uses something else. + */ + private static boolean timestampCoversContent(TimeStampTokenInfo info, byte[] signedContent) + throws Exception { + org.bouncycastle.operator.DigestCalculator digest = + new JcaDigestCalculatorProviderBuilder().build().get(info.getHashAlgorithm()); + try (java.io.OutputStream out = digest.getOutputStream()) { + out.write(signedContent); + } + return java.util.Arrays.equals(digest.getDigest(), info.getMessageImprintDigest()); + } } diff --git a/app/core/src/main/java/stirling/software/SPDF/model/ApiEndpoint.java b/app/core/src/main/java/stirling/software/SPDF/model/ApiEndpoint.java index 04d2bb1fa1..e25b0dde8e 100644 --- a/app/core/src/main/java/stirling/software/SPDF/model/ApiEndpoint.java +++ b/app/core/src/main/java/stirling/software/SPDF/model/ApiEndpoint.java @@ -18,10 +18,10 @@ public class ApiEndpoint { postNode.path("parameters") .forEach( paramNode -> { - String paramName = paramNode.path("name").asText(""); + String paramName = paramNode.path("name").asString(""); parameters.put(paramName, paramNode); }); - this.description = postNode.path("description").asText(""); + this.description = postNode.path("description").asString(""); } public boolean areParametersValid(Map providedParams) { diff --git a/app/core/src/main/resources/application.properties b/app/core/src/main/resources/application.properties index da564e454b..c05f2dcbea 100644 --- a/app/core/src/main/resources/application.properties +++ b/app/core/src/main/resources/application.properties @@ -72,6 +72,8 @@ spring.datasource.username=sa spring.datasource.password= spring.h2.console.enabled=false spring.jpa.hibernate.ddl-auto=update +# Batch associations into IN() loads so list endpoints don't N+1 as tables grow. +spring.jpa.properties.hibernate.default_batch_fetch_size=100 # Defer datasource initialization to ensure that the database is fully set up # before Hibernate attempts to access it. This is particularly useful when # using database initialization scripts or tools. @@ -96,7 +98,8 @@ spring.main.allow-bean-definition-overriding=true # spring-data-redis is on the classpath only for the optional Valkey backplane (which wires its own # factory); exclude Spring Boot's stock Redis auto-config so a default install doesn't create a dead # localhost:6379 factory that flips /actuator/health to DOWN. -spring.autoconfigure.exclude=org.springframework.boot.data.redis.autoconfigure.DataRedisAutoConfiguration,org.springframework.boot.data.redis.autoconfigure.DataRedisReactiveAutoConfiguration +# Also exclude the repositories auto-config: in cluster mode it needs a redisTemplate bean we don't define. +spring.autoconfigure.exclude=org.springframework.boot.data.redis.autoconfigure.DataRedisAutoConfiguration,org.springframework.boot.data.redis.autoconfigure.DataRedisReactiveAutoConfiguration,org.springframework.boot.data.redis.autoconfigure.DataRedisRepositoriesAutoConfiguration # Set up a consistent temporary directory location java.io.tmpdir=${stirling.tempfiles.directory:${java.io.tmpdir}/stirling-pdf} diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index 85d97158b3..fd9cc13c13 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -366,12 +366,43 @@ aiEngine: enabled: false # Set to 'true' to enable the AI engine integration url: http://localhost:5001 # URL of the Python AI engine timeoutSeconds: 120 # Timeout in seconds for AI engine requests + longRunningTimeoutSeconds: 600 # Timeout (seconds) for heavy operations like RAG ingestion of large documents + streamTimeoutSeconds: 1800 # SSE stream timeout (seconds) for long-running orchestrator runs + pushConfigToEngine: true # Push admin AI config to the engine on startup + save; false = engine stays fully env-controlled + models: + provider: anthropic # Model provider: 'anthropic', 'openai', 'ollama', or 'custom' (OpenAI-compatible) + smartModel: claude-haiku-4-5 # High-quality tier model name (no provider prefix) + fastModel: claude-haiku-4-5 # Cheap/fast tier model name (no provider prefix) + smartMaxTokens: 8192 # Max output tokens for the smart tier + fastMaxTokens: 2048 # Max output tokens for the fast tier + apiKey: "" # API key for the selected provider (secret). Empty = engine uses its native env credentials (e.g. ANTHROPIC_API_KEY) + baseUrl: "" # OpenAI-compatible base URL for 'ollama'/'custom' providers (e.g. http://ollama:11434/v1). Ignored for anthropic/openai + rag: + embeddingProvider: voyageai # Embedding provider: 'voyageai', 'openai', 'ollama', or 'custom' (OpenAI-compatible) + embeddingModel: voyage-4 # Embedding model name (no provider prefix) + embeddingApiKey: "" # Secret API key for the embedding provider. Empty = engine uses its native env credentials (e.g. VOYAGE_API_KEY) + embeddingBaseUrl: "" # OpenAI-compatible base URL for 'ollama'/'custom' embedding providers (e.g. http://ollama:11434/v1). Ignored for voyageai/openai + topK: 20 # Number of chunks retrieval returns per search + maxSearches: 5 # Per-run cap on knowledge-search tool calls before the agent must answer + limits: + maxPages: 200 # Upper bound on PDF pages the engine will process per request + maxCharacters: 200000 # Upper bound on characters of extracted text per request + modelMaxConcurrency: 32 # Process-wide cap on concurrent model API calls (engine restart to apply) + features: # Per-capability switches; turn an individual AI tool off without disabling the whole engine + chat: true # Assistant chat + documentQuestions: true # Ask-questions-about-a-PDF + createPdf: true # Generate a PDF from a natural-language spec + mathAuditor: true # Numerical/formula contradiction auditing + pdfComment: true # AI-authored PDF comments/annotations + classify: true # Automatic document classification/labelling policies: # Folder automations can read from and write to the directories you allow here, so treat this as a - # security boundary. Leave allowedFolderRoots empty (default) to disable folder sources/outputs - # entirely; list absolute directories to permit folder access only within them. Stirling's own - # config directory is always off-limits, and folder access is always disabled in SaaS mode. + # security boundary. Leave allowedFolderRoots empty (default) to disable folder sources/outputs, + # other than from directories that are always permitted like server file-storage and watched folders. + # List absolute directories to permit folder access within them. + # Stirling's own config directory is always off-limits, and folder access is always + # disabled in SaaS mode. allowedFolderRoots: [] # e.g. ["/data/inbox", "/data/outbox"] scheduleSweepSeconds: 60 # How often (seconds) scheduled policies are checked for being due watchReconcileSeconds: 300 # How often (seconds) folder-watch re-syncs watches and re-runs as a safety net for missed events @@ -385,6 +416,8 @@ policies: mcp: enabled: false # Master switch. 'false' (default) means no /mcp endpoint, no metadata, no beans wired. scopesEnabled: true # Enforce mcp.tools.read / mcp.tools.write scopes derived from operation category + maxRequestBytes: 10485760 # Max size (bytes) of an incoming MCP tool request payload (default 10 MB) + maxInlineResponseBytes: 10485760 # Max size (bytes) of an MCP tool response returned inline before it is rejected (default 10 MB) allowedOperations: [] # Tool allow-list (operation ids, e.g. ['compress-pdf']). Empty = all. When set, ONLY these are exposed over MCP. blockedOperations: [] # Tool deny-list (operation ids). Always removed from MCP even if otherwise allowed. auth: diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/DocumentTimestampValidationTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/DocumentTimestampValidationTest.java new file mode 100644 index 0000000000..6e8f1676a6 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/DocumentTimestampValidationTest.java @@ -0,0 +1,91 @@ +package stirling.software.SPDF.controller.api.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.io.InputStream; +import java.util.List; + +import org.apache.pdfbox.Loader; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; +import org.springframework.mock.web.MockMultipartFile; + +import stirling.software.SPDF.model.api.security.SignatureValidationRequest; +import stirling.software.SPDF.model.api.security.SignatureValidationResult; +import stirling.software.SPDF.service.CertificateValidationService; +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.service.CustomPDFDocumentFactory; + +/** + * Validation of RFC 3161 document timestamps (PAdES-LTV). + * + *

These fixtures are a real PDF stamped by a real public TSA (freetsa.org). Before this was + * handled explicitly, every such timestamp was reported invalid: a DocTimeStamp's CMS encapsulates + * a TSTInfo rather than being detached over the document, so digesting the byte range compared + * against the wrong thing and always mismatched. That made the timestamp feature look broken to + * anyone who checked their own output with our validator. + */ +class DocumentTimestampValidationTest { + + private ValidateSignatureController controller; + + @BeforeEach + void setUp() throws Exception { + CertificateValidationService certValidationService = + new CertificateValidationService(null, new ApplicationProperties()); + CustomPDFDocumentFactory factory = org.mockito.Mockito.mock(CustomPDFDocumentFactory.class); + // Delegate to the real loader so the signature dictionary is parsed as in production. + when(factory.load(any(InputStream.class))) + .thenAnswer( + invocation -> + Loader.loadPDF( + ((InputStream) invocation.getArgument(0)).readAllBytes())); + controller = new ValidateSignatureController(factory, certValidationService); + } + + @Test + void aGenuineDocumentTimestampValidates() throws Exception { + SignatureValidationResult result = validate("timestamp/doc-timestamped.pdf"); + + assertThat(result.isValid()).isTrue(); + assertThat(result.getErrorMessage()).isNull(); + // The TSA's asserted time is what keeps the signature verifiable once the signing + // certificate expires, so it must be the time we validate against. + // Deliberately not "timestamp" - that value already means "signature countersigned by a + // TSA", which is a different assertion about a different thing. + assertThat(result.getValidationTimeSource()).isEqualTo("document-timestamp"); + assertThat(result.getSignatureDate()).isNotNull(); + assertThat(result.getSubjectDN()).contains("freetsa.org"); + assertThat(result.isCoversEntireDocument()).isTrue(); + } + + @Test + void aTamperedDocumentFailsTheMessageImprintCheck() throws Exception { + // Same file with a single byte flipped inside the signed range. Without the imprint check + // the CMS signature over the TSTInfo would still verify happily - the token is untouched - + // and a modified document would be reported as validly timestamped. + SignatureValidationResult result = validate("timestamp/doc-timestamped-tampered.pdf"); + + assertThat(result.isValid()).isFalse(); + assertThat(result.getErrorMessage()) + .isEqualTo("Timestamp message imprint does not match the document"); + } + + private SignatureValidationResult validate(String resource) throws IOException { + byte[] bytes; + try (InputStream in = new ClassPathResource(resource).getInputStream()) { + bytes = in.readAllBytes(); + } + SignatureValidationRequest request = new SignatureValidationRequest(); + request.setFileInput( + new MockMultipartFile("fileInput", "doc.pdf", "application/pdf", bytes)); + + List results = controller.validateSignature(request).getBody(); + assertThat(results).hasSize(1); + return results.get(0); + } +} diff --git a/app/core/src/test/resources/timestamp/doc-timestamped-tampered.pdf b/app/core/src/test/resources/timestamp/doc-timestamped-tampered.pdf new file mode 100644 index 0000000000..3616a4cb13 Binary files /dev/null and b/app/core/src/test/resources/timestamp/doc-timestamped-tampered.pdf differ diff --git a/app/core/src/test/resources/timestamp/doc-timestamped.pdf b/app/core/src/test/resources/timestamp/doc-timestamped.pdf new file mode 100644 index 0000000000..020a1b9dc4 Binary files /dev/null and b/app/core/src/test/resources/timestamp/doc-timestamped.pdf differ diff --git a/app/proprietary/build.gradle b/app/proprietary/build.gradle index fb21ed0d0c..d2028f7859 100644 --- a/app/proprietary/build.gradle +++ b/app/proprietary/build.gradle @@ -6,33 +6,6 @@ repositories { bootRun { enabled = false } - -spotless { - java { - target 'src/**/java/**/*.java' - targetExclude 'src/main/java/org/apache/**' - googleJavaFormat(googleJavaFormatVersion).aosp().reorderImports(false) - // google-java-format 1.28.0 bundles Guava 32.x which crashes Spotless lint on JDK 24/25 - suppressLintsFor { setStep('google-java-format') } - - importOrder("java", "javax", "org", "com", "net", "io", "jakarta", "lombok", "me", "stirling") - trimTrailingWhitespace() - leadingTabsToSpaces() - endWithNewline() - } - yaml { - target '**/*.yml', '**/*.yaml' - trimTrailingWhitespace() - leadingTabsToSpaces() - endWithNewline() - } - format 'gradle', { - target '**/gradle/*.gradle', '**/*.gradle' - trimTrailingWhitespace() - leadingTabsToSpaces() - endWithNewline() - } -} dependencies { implementation project(':common') api "com.google.guava:guava:${guavaVersion}" @@ -66,6 +39,20 @@ dependencies { implementation "com.google.code.gson:gson:${gsonVersion}" + // jinjava/jjwt transitively request older Jackson 2 versions; declare the current + // version directly so it is selected consistently (root build.gradle pins are the fallback). + runtimeOnly "com.fasterxml.jackson.core:jackson-core:${jackson2Version}" + runtimeOnly "com.fasterxml.jackson.core:jackson-databind:${jackson2Version}" + + implementation("com.hubspot.jinjava:jinjava:${jinjavaVersion}") { + // Compile-time-only annotation artifacts (class-retention annotations, not needed at + // runtime) whose declared licences (LGPL / none) fail the licence compatibility check. + exclude group: 'com.google.code.findbugs', module: 'annotations' + exclude group: 'org.derive4j', module: 'derive4j-annotation' + exclude group: 'com.hubspot.immutables', module: 'hubspot-style' + exclude group: 'com.hubspot.immutables', module: 'immutable-collection-encodings' + } + api 'io.micrometer:micrometer-registry-prometheus' api "io.jsonwebtoken:jjwt-api:${jwtVersion}" @@ -89,6 +76,7 @@ dependencies { testImplementation "org.testcontainers:testcontainers:${testcontainersMinioVersion}" testImplementation "org.testcontainers:minio:${testcontainersMinioVersion}" testImplementation "org.testcontainers:localstack:${testcontainersMinioVersion}" + testImplementation "org.testcontainers:postgresql:${testcontainersMinioVersion}" testImplementation "org.testcontainers:junit-jupiter:${testcontainersMinioVersion}" } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/service/ResourceAccessService.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/service/ResourceAccessService.java index 72159829a4..7642dded85 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/access/service/ResourceAccessService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/service/ResourceAccessService.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.access.service; +import java.util.Collection; import java.util.HashSet; import java.util.List; import java.util.Set; @@ -42,6 +43,52 @@ public class ResourceAccessService { return canUseResource(ResourceType.PORTAL, "", null, portalDefaultPolicy, user); } + /** + * Portal access for a roster (admin, grant, or default policy). {@code activeTeamLeaderUserIds} + * must hold ids of users who lead their own active team — the set the ADMINS_AND_TEAM_LEADS + * default admits, matching {@link #canAccessPortal}. + */ + public Set usersWithPortalAccess( + Collection users, Set activeTeamLeaderUserIds) { + Set grantedPrincipals = new HashSet<>(); + for (ResourceGrant g : + grantRepository.findByResourceTypeAndResourceId(ResourceType.PORTAL, "")) { + if (permissionSatisfies(g.getPermission(), AccessPermission.USE)) { + grantedPrincipals.add(new PrincipalRef(g.getPrincipalType(), g.getPrincipalId())); + } + } + Set leaderIds = activeTeamLeaderUserIds == null ? Set.of() : activeTeamLeaderUserIds; + Set allowed = new HashSet<>(); + for (User user : users) { + if (user != null + && user.getId() != null + && hasPortalAccess(user, grantedPrincipals, leaderIds)) { + allowed.add(user.getId()); + } + } + return allowed; + } + + private boolean hasPortalAccess( + User user, Set grantedPrincipals, Set leaderIds) { + if (isAdmin(user)) { + return true; + } + for (PrincipalRef principal : principalResolver.principalsOf(user)) { + if (grantedPrincipals.contains(principal)) { + return true; + } + } + if (portalDefaultPolicy == null) { + return false; + } + return switch (portalDefaultPolicy) { + case ORG_ALL -> principalResolver.allowsDeploymentWideAccess(); + case ADMINS_AND_TEAM_LEADS -> leaderIds.contains(user.getId()); + case EXPLICIT_ONLY -> false; + }; + } + /** Whether the user may use a resource, falling back to its default policy. */ public boolean canUseResource( ResourceType type, @@ -172,11 +219,13 @@ public class ResourceAccessService { }; } - // Portal (no owner) admits any team lead; a team-owned resource admits only that team's - // leads; a user-owned resource admits no extra leads. + // Portal (no owner) admits the leader of the user's active team; a team-owned resource + // admits only that team's leads; a user-owned resource admits no extra leads. private boolean matchesTeamLeadDefault(PrincipalRef owner, User user) { if (owner == null) { - return teamLeadLookup.isAnyTeamLeader(user); + return user.getTeam() != null + && user.getTeam().getId() != null + && teamLeadLookup.isLeaderOfTeam(user, user.getTeam().getId()); } return owner.type() == PrincipalType.TEAM && owner.id() != null diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/service/SecretMasker.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/service/SecretMasker.java index f160b92759..4be76e174e 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/access/service/SecretMasker.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/service/SecretMasker.java @@ -39,6 +39,11 @@ public class SecretMasker { "bearer", "signature"); + // Keys whose nested map holds secrets under arbitrary, caller-named keys - a free-form HTTP + // headers map is the case in point: the secret can sit under any header name (X-API-Key, + // Ocp-Apim-Subscription-Key), so the name is no signal. Mask every value in these outright. + private static final Set SENSITIVE_VALUE_CONTAINERS = Set.of("headers"); + /** Replace sensitive values with the mask (recursively) for safe display. */ public Map mask(Map config) { return mask(config, 0); @@ -73,6 +78,12 @@ public class SecretMasker { if (isSensitive(e.getKey()) && isRedacted(e.getValue(), depth)) { continue; } + if (isSensitiveContainer(e.getKey()) + && e.getValue() instanceof Map m + && depth < MAX_DEPTH) { + out.put(e.getKey(), sanitizeAllValues(castMap(m), depth + 1)); + continue; + } out.put( e.getKey(), e.getValue() instanceof Map m && depth < MAX_DEPTH @@ -100,6 +111,14 @@ public class SecretMasker { } continue; } + if (isSensitiveContainer(key) + && depth < MAX_DEPTH + && stored.get(key) instanceof Map s + && value instanceof Map i) { + // Every value here is a secret, so restore a redacted one from stored per-entry. + out.put(key, mergeAllValues(castMap(s), castMap(i), depth + 1)); + continue; + } if (depth < MAX_DEPTH && stored.get(key) instanceof Map s && value instanceof Map i) { @@ -119,6 +138,9 @@ public class SecretMasker { } return MASK; } + if (isSensitiveContainer(key) && value instanceof Map m && depth < MAX_DEPTH) { + return maskAllValues(castMap(m), depth + 1); + } if (depth >= MAX_DEPTH) { // Too deep to descend; mask containers rather than risk leaking an unmasked secret. return value instanceof Map || value instanceof List ? MASK : value; @@ -141,6 +163,53 @@ public class SecretMasker { return SENSITIVE_HINTS.stream().anyMatch(lower::contains); } + private boolean isSensitiveContainer(String key) { + return SENSITIVE_VALUE_CONTAINERS.contains(key.toLowerCase(Locale.ROOT)); + } + + /** Mask every value in a container map, whatever its keys are named. */ + private Map maskAllValues(Map map, int depth) { + Map out = new LinkedHashMap<>(); + for (Map.Entry e : map.entrySet()) { + Object v = e.getValue(); + if (v == null || (v instanceof String s && s.isBlank())) { + out.put(e.getKey(), v); + } else if (v instanceof Map m && depth < MAX_DEPTH) { + out.put(e.getKey(), maskAllValues(castMap(m), depth + 1)); + } else { + out.put(e.getKey(), MASK); + } + } + return out; + } + + /** Merge a container map treating every entry as a secret, restoring redacted from stored. */ + private Map mergeAllValues( + Map stored, Map incoming, int depth) { + Map out = new LinkedHashMap<>(); + for (Map.Entry e : incoming.entrySet()) { + if (isRedacted(e.getValue(), depth)) { + if (stored.containsKey(e.getKey())) { + out.put(e.getKey(), stored.get(e.getKey())); + } + } else { + out.put(e.getKey(), e.getValue()); + } + } + return out; + } + + /** Drop redacted entries from a container map on create, whatever their keys are named. */ + private Map sanitizeAllValues(Map map, int depth) { + Map out = new LinkedHashMap<>(); + for (Map.Entry e : map.entrySet()) { + if (!isRedacted(e.getValue(), depth)) { + out.put(e.getKey(), e.getValue()); + } + } + return out; + } + /** Blank, the mask placeholder, or any structure that still contains the mask. */ private boolean isRedacted(Object value, int depth) { if (value == null) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptor.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptor.java index 285943ee49..0cd71c9bda 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptor.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptor.java @@ -35,6 +35,7 @@ import stirling.software.proprietary.billing.ContentHasher; import stirling.software.proprietary.billing.DocumentUnitCalculator; import stirling.software.proprietary.billing.DocumentUnitCalculator.FileSize; import stirling.software.proprietary.billing.UnitCalcPolicy; +import stirling.software.proprietary.policy.controller.PolicyRunRoutes; import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken; /** @@ -84,7 +85,13 @@ public class InstanceEntitlementInterceptor implements HandlerInterceptor { instanceof ApiKeyAuthenticationToken; BillingCategory category = BillableOperationClassifier.categorize(request, apiKey); request.setAttribute(ATTR_CATEGORY, category); - decision = gate.evaluate(category != BillingCategory.BYPASSED); + // A policy run kicks off billable automation, so block it up front when unentitled + // rather than after its first tool. It carries no automation header itself (category + // BYPASSED), so it's gated here but metered only via its dispatched sub-steps - keeping + // the BYPASSED meter category avoids double-counting. + boolean billable = + category != BillingCategory.BYPASSED || PolicyRunRoutes.matches(request); + decision = gate.evaluate(billable); } catch (RuntimeException e) { // Fail open: an inability to resolve entitlement (e.g. a DB or SaaS blip) must never // turn into a hard block on billable work. diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/classification/ClassificationLabelProvider.java b/app/proprietary/src/main/java/stirling/software/proprietary/classification/ClassificationLabelProvider.java new file mode 100644 index 0000000000..480eaecf76 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/classification/ClassificationLabelProvider.java @@ -0,0 +1,61 @@ +package stirling.software.proprietary.classification; + +import java.io.InputStream; +import java.util.List; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Component; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.classification.model.ClassificationLabel; +import stirling.software.proprietary.classification.model.ClassificationLabels; + +import tools.jackson.databind.ObjectMapper; + +/** + * Supplies the classification vocabulary the classify tool sends to the AI engine. The set is a + * fixed, built-in list bundled with the application ({@code + * classification/classification-labels.json}) and shared by everyone — there is no per-team + * customization or database. Loaded once at startup. + */ +@Slf4j +@Component +public class ClassificationLabelProvider { + + private static final String RESOURCE = "classification/classification-labels.json"; + + private final List labels; + + // Explicit @Autowired: the class has a second (private) constructor for tests, so Spring + // can't infer which to use without it. + @Autowired + public ClassificationLabelProvider(ObjectMapper objectMapper) { + this(load(objectMapper)); + } + + private ClassificationLabelProvider(List labels) { + this.labels = List.copyOf(labels); + } + + /** Build a provider with an explicit label set (tests). */ + public static ClassificationLabelProvider withLabels(List labels) { + return new ClassificationLabelProvider(labels); + } + + /** The built-in vocabulary, in file order. */ + public List labels() { + return labels; + } + + private static List load(ObjectMapper objectMapper) { + try (InputStream in = new ClassPathResource(RESOURCE).getInputStream()) { + ClassificationLabels parsed = objectMapper.readValue(in, ClassificationLabels.class); + return parsed.labels(); + } catch (Exception e) { + log.error("Failed to load classification labels from {}", RESOURCE, e); + return List.of(); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/classification/ClassificationLabelsController.java b/app/proprietary/src/main/java/stirling/software/proprietary/classification/ClassificationLabelsController.java deleted file mode 100644 index 6dcd19e724..0000000000 --- a/app/proprietary/src/main/java/stirling/software/proprietary/classification/ClassificationLabelsController.java +++ /dev/null @@ -1,136 +0,0 @@ -package stirling.software.proprietary.classification; - -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.PutMapping; -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.common.service.UserServiceInterface; -import stirling.software.proprietary.classification.model.ClassificationLabels; -import stirling.software.proprietary.classification.model.LabelsValidator; -import stirling.software.proprietary.classification.store.ClassificationLabelStore; -import stirling.software.proprietary.classification.store.TeamLabelsEntity; -import stirling.software.proprietary.policy.config.PolicyManagementAuthority; - -/** - * Read/write the team's classification label set — the flat vocabulary the document classifier runs - * against. Shared and team-scoped exactly like policies: every user reads their own team's labels, - * and only a user who may edit policies (a team leader on SaaS, the global admin self-hosted; see - * {@link PolicyManagementAuthority}) may change it — gated only when login is enabled, since - * single-user deployments trust the local operator. A team with no stored labels reads as {@code - * 204}; that team has no vocabulary, so its documents are not classified (there is no built-in - * default on the backend or the engine — the label data lives only in the frontend). - */ -@RestController -@RequestMapping("/api/v1/classification/labels") -@Hidden -@RequiredArgsConstructor -@Tag(name = "Classification", description = "Team-scoped document-classification labels") -@ConditionalOnBooleanProperty(name = "policies.enabled") -public class ClassificationLabelsController { - - private final ClassificationLabelStore labelStore; - private final PolicyManagementAuthority policyManagementAuthority; - private final ApplicationProperties applicationProperties; - private final UserServiceInterface userService; - - @GetMapping - @Operation( - summary = "Get the team's classification labels", - description = - "Returns the caller's team label set, or 204 when the team has none (its" - + " documents are then not classified).") - public ResponseEntity getTeamLabels() { - return labelStore - .findByTeam(currentTeamId()) - .map(ResponseEntity::ok) - .orElseGet(() -> ResponseEntity.noContent().build()); - } - - @PutMapping(consumes = MediaType.APPLICATION_JSON_VALUE) - @Operation( - summary = "Save the team's classification labels", - description = - "Validates and stores the label set for the caller's team, shared by everyone" - + " on the team. Requires the policy-editor role for the team.") - public ResponseEntity saveTeamLabels( - @RequestBody ClassificationLabels labels) { - requireEditingAllowed(); - validate(labels); - ClassificationLabels saved = labelStore.save(currentTeamId(), labels, currentUsername()); - return ResponseEntity.ok(saved); - } - - @DeleteMapping - @Operation( - summary = "Reset the team's classification labels", - description = - "Removes the team's stored label set; its documents are then not classified" - + " until labels are saved again. Requires the policy-editor role for the" - + " team.") - public ResponseEntity resetTeamLabels() { - requireEditingAllowed(); - labelStore.deleteByTeam(currentTeamId()); - return ResponseEntity.noContent().build(); - } - - private static void validate(ClassificationLabels labels) { - try { - LabelsValidator.validate(labels); - } catch (IllegalArgumentException e) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage()); - } - } - - /** - * Editing the team labels requires the editor role for the caller's team — the same gate - * policies use (team leader on SaaS, global admin self-hosted). Single-user deployments (login - * disabled) have no such role, so they trust the local operator. - */ - private void requireEditingAllowed() { - if (!applicationProperties.getSecurity().isEnableLogin()) { - return; - } - if (!policyManagementAuthority.canEditPolicies()) { - throw new ResponseStatusException( - HttpStatus.FORBIDDEN, - "The team classification labels may only be changed by a team leader"); - } - } - - /** - * The caller's team key. With login disabled the single operator owns the {@link - * TeamLabelsEntity#NO_TEAM} sentinel row; with login enabled a caller with no resolvable team - * is an error rather than being dropped into the shared sentinel bucket (which would let - * unteamed users read and overwrite each other's "team" labels). - */ - private Long currentTeamId() { - Long teamId = policyManagementAuthority.currentUserTeamId(); - if (teamId != null) { - return teamId; - } - if (!applicationProperties.getSecurity().isEnableLogin()) { - return TeamLabelsEntity.NO_TEAM; - } - throw new ResponseStatusException( - HttpStatus.UNAUTHORIZED, "Could not resolve the current user's team"); - } - - private String currentUsername() { - return userService == null ? null : userService.getCurrentUsername(); - } -} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/classification/ClassificationRunBiller.java b/app/proprietary/src/main/java/stirling/software/proprietary/classification/ClassificationRunBiller.java new file mode 100644 index 0000000000..dd0467c9ca --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/classification/ClassificationRunBiller.java @@ -0,0 +1,8 @@ +package stirling.software.proprietary.classification; + +/** Meters a client-side classification run; SaaS charges PAYG, other flavors have no bean. */ +public interface ClassificationRunBiller { + + /** Charge one classification policy run covering {@code documentCount} documents. */ + void recordClassificationRun(int documentCount); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/classification/model/ClassificationLabels.java b/app/proprietary/src/main/java/stirling/software/proprietary/classification/model/ClassificationLabels.java index ea7a1269f0..7ac73c92de 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/classification/model/ClassificationLabels.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/classification/model/ClassificationLabels.java @@ -3,10 +3,10 @@ package stirling.software.proprietary.classification.model; import java.util.List; /** - * A flat multi-label classification vocabulary — the set of labels a document may be assigned. - * Stored per team (admin-edited, shared by everyone on the team); the classifier runs against these - * label names. A team with no stored set has no vocabulary, so its documents are not classified — - * neither the backend nor the engine holds a default of its own. + * A flat multi-label classification vocabulary — the set of labels a document may be assigned. The + * classifier runs against these label names. The vocabulary is a fixed, built-in set shared by + * everyone (see {@link stirling.software.proprietary.classification.ClassificationLabelProvider}); + * this record is the JSON parse target for that bundled resource. */ public record ClassificationLabels(List labels) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/classification/model/LabelsValidator.java b/app/proprietary/src/main/java/stirling/software/proprietary/classification/model/LabelsValidator.java deleted file mode 100644 index 722e8b417b..0000000000 --- a/app/proprietary/src/main/java/stirling/software/proprietary/classification/model/LabelsValidator.java +++ /dev/null @@ -1,71 +0,0 @@ -package stirling.software.proprietary.classification.model; - -import java.util.HashSet; -import java.util.Locale; -import java.util.Set; -import java.util.regex.Pattern; - -/** - * Structural validation for a user- or admin-supplied label set, run before it is stored so a - * malformed vocabulary can never reach the classifier. Mirrors the invariants the engine relies on: - * non-blank ids and names, each unique within the set (ids exactly, names case-insensitively). - */ -public final class LabelsValidator { - - private LabelsValidator() {} - - // Generous upper bounds so a legitimate label set is never blocked, but a single team or user - // can't store an unbounded blob that would bloat the row, balloon the classifier prompt, or - // exhaust memory on deserialize. - static final int MAX_LABELS = 500; - static final int MAX_TEXT_LENGTH = 128; - - // Icon is a Material Symbols key (lowercase, digits, hyphens). Enforce the SHAPE server-side — - // the exact allowlist lives in the frontend — so a client bypassing the UI can't store - // arbitrary - // text that would render as garbage (or worse) in every teammate's sidebar. - private static final Pattern ICON_KEY = Pattern.compile("^[a-z0-9-]+$"); - - /** - * @throws IllegalArgumentException with a human-readable message when the label set is invalid. - */ - public static void validate(ClassificationLabels labels) { - if (labels == null || labels.labels() == null) { - throw new IllegalArgumentException("Labels are required"); - } - if (labels.labels().size() > MAX_LABELS) { - throw new IllegalArgumentException("Too many labels (max " + MAX_LABELS + ")"); - } - Set ids = new HashSet<>(); - Set names = new HashSet<>(); - for (ClassificationLabel label : labels.labels()) { - requireText(label.id(), "Label id"); - requireText(label.name(), "Label name"); - if (label.icon() != null && !label.icon().isEmpty()) { - if (label.icon().length() > MAX_TEXT_LENGTH) { - throw new IllegalArgumentException( - "Label icon is too long (max " + MAX_TEXT_LENGTH + " characters)"); - } - if (!ICON_KEY.matcher(label.icon()).matches()) { - throw new IllegalArgumentException("Invalid label icon: " + label.icon()); - } - } - if (!ids.add(label.id().trim())) { - throw new IllegalArgumentException("Duplicate label id: " + label.id()); - } - if (!names.add(label.name().trim().toLowerCase(Locale.ROOT))) { - throw new IllegalArgumentException("Duplicate label name: " + label.name()); - } - } - } - - private static void requireText(String value, String field) { - if (value == null || value.isBlank()) { - throw new IllegalArgumentException(field + " must not be blank"); - } - if (value.trim().length() > MAX_TEXT_LENGTH) { - throw new IllegalArgumentException( - field + " is too long (max " + MAX_TEXT_LENGTH + " characters)"); - } - } -} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/classification/store/ClassificationLabelStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/classification/store/ClassificationLabelStore.java deleted file mode 100644 index a604854c60..0000000000 --- a/app/proprietary/src/main/java/stirling/software/proprietary/classification/store/ClassificationLabelStore.java +++ /dev/null @@ -1,22 +0,0 @@ -package stirling.software.proprietary.classification.store; - -import java.util.Optional; - -import stirling.software.proprietary.classification.model.ClassificationLabels; - -/** - * Stores one {@link ClassificationLabels} set per team. A {@code null} teamId addresses the - * unteamed set (login disabled / no resolvable team), mirroring how the policy store treats a null - * team. - */ -public interface ClassificationLabelStore { - - /** The team's stored labels, or empty when it has none (callers then skip classification). */ - Optional findByTeam(Long teamId); - - /** Create or replace the team's labels. Returns the stored value. */ - ClassificationLabels save(Long teamId, ClassificationLabels labels, String updatedBy); - - /** Remove the team's labels (reset to default). Returns whether a set existed. */ - boolean deleteByTeam(Long teamId); -} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/classification/store/InProcessClassificationLabelStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/classification/store/InProcessClassificationLabelStore.java deleted file mode 100644 index 6846902eab..0000000000 --- a/app/proprietary/src/main/java/stirling/software/proprietary/classification/store/InProcessClassificationLabelStore.java +++ /dev/null @@ -1,36 +0,0 @@ -package stirling.software.proprietary.classification.store; - -import java.util.Map; -import java.util.Optional; -import java.util.concurrent.ConcurrentHashMap; - -import stirling.software.proprietary.classification.model.ClassificationLabels; - -/** - * In-memory {@link ClassificationLabelStore} for tests and any future no-database mode. {@link - * JpaClassificationLabelStore} is the runtime bean. - */ -public class InProcessClassificationLabelStore implements ClassificationLabelStore { - - private final Map byTeam = new ConcurrentHashMap<>(); - - @Override - public Optional findByTeam(Long teamId) { - return Optional.ofNullable(byTeam.get(key(teamId))); - } - - @Override - public ClassificationLabels save(Long teamId, ClassificationLabels labels, String updatedBy) { - byTeam.put(key(teamId), labels); - return labels; - } - - @Override - public boolean deleteByTeam(Long teamId) { - return byTeam.remove(key(teamId)) != null; - } - - private static long key(Long teamId) { - return teamId == null ? TeamLabelsEntity.NO_TEAM : teamId; - } -} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/classification/store/JpaClassificationLabelStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/classification/store/JpaClassificationLabelStore.java deleted file mode 100644 index 724e335fc4..0000000000 --- a/app/proprietary/src/main/java/stirling/software/proprietary/classification/store/JpaClassificationLabelStore.java +++ /dev/null @@ -1,76 +0,0 @@ -package stirling.software.proprietary.classification.store; - -import java.time.Instant; -import java.util.Optional; - -import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; -import org.springframework.stereotype.Service; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -import stirling.software.proprietary.classification.model.ClassificationLabels; - -import tools.jackson.core.JacksonException; -import tools.jackson.databind.ObjectMapper; - -/** - * Durable {@link ClassificationLabelStore} backed by JPA; the runtime store. Gated on {@code - * policies.enabled} — stored labels only matter when the Classification policy can run — so it - * shares the policy subsystem's on/off switch. Each label set is persisted as JSON via {@link - * TeamLabelsEntity}. - */ -@Slf4j -@Service -@RequiredArgsConstructor -@ConditionalOnBooleanProperty(name = "policies.enabled") -public class JpaClassificationLabelStore implements ClassificationLabelStore { - - private final TeamLabelsRepository teamRepository; - private final ObjectMapper objectMapper; - - @Override - public Optional findByTeam(Long teamId) { - return teamRepository - .findById(key(teamId)) - .flatMap(entity -> parse(entity.getLabelsJson(), "team " + teamId)); - } - - @Override - public ClassificationLabels save(Long teamId, ClassificationLabels labels, String updatedBy) { - TeamLabelsEntity entity = new TeamLabelsEntity(); - entity.setTeamId(key(teamId)); - entity.setLabelsJson(objectMapper.writeValueAsString(labels)); - entity.setUpdatedAt(Instant.now()); - entity.setUpdatedBy(updatedBy); - teamRepository.save(entity); - return labels; - } - - @Override - public boolean deleteByTeam(Long teamId) { - long id = key(teamId); - if (!teamRepository.existsById(id)) { - return false; - } - teamRepository.deleteById(id); - return true; - } - - private Optional parse(String json, String owner) { - try { - return Optional.of(objectMapper.readValue(json, ClassificationLabels.class)); - } catch (JacksonException e) { - // A stored label set that no longer parses (corruption / manual DB edit) must not break - // classification: drop it so the caller treats the team as having no labels (and skips - // classification) rather than surfacing a 500 on every upload. - log.warn("Discarding unparseable stored labels for {}: {}", owner, e.getMessage()); - return Optional.empty(); - } - } - - /** Map the nullable team id onto the entity's non-null key (sentinel for the unteamed case). */ - private static long key(Long teamId) { - return teamId == null ? TeamLabelsEntity.NO_TEAM : teamId; - } -} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/classification/store/TeamLabelsEntity.java b/app/proprietary/src/main/java/stirling/software/proprietary/classification/store/TeamLabelsEntity.java deleted file mode 100644 index 25f2e5b259..0000000000 --- a/app/proprietary/src/main/java/stirling/software/proprietary/classification/store/TeamLabelsEntity.java +++ /dev/null @@ -1,47 +0,0 @@ -package stirling.software.proprietary.classification.store; - -import java.io.Serializable; -import java.time.Instant; - -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 team's classification labels — one row per team. The label set lives as JSON in - * {@code labelsJson} (authoritative on read). {@code teamId} is the natural key; the sentinel - * {@link #NO_TEAM} stands in for the unteamed (login-disabled / self-hosted single-team) case, - * since a primary key can't be null (policies store a nullable {@code team_id}, but this table is - * keyed one-per-team). Kept decoupled from the security entities — {@code teamId} is a plain value, - * not a foreign key — so classification can be enabled or disabled without touching them. - */ -@Entity -@Table(name = "classification_labels") -@NoArgsConstructor -@Getter -@Setter -public class TeamLabelsEntity implements Serializable { - - private static final long serialVersionUID = 1L; - - /** Sentinel key for the unteamed label set (login disabled / no resolvable team). */ - public static final long NO_TEAM = 0L; - - @Id - @Column(name = "team_id") - private long teamId; - - @Column(name = "labels_json", columnDefinition = "text") - private String labelsJson; - - @Column(name = "updated_at") - private Instant updatedAt; - - @Column(name = "updated_by") - private String updatedBy; -} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/classification/store/TeamLabelsRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/classification/store/TeamLabelsRepository.java deleted file mode 100644 index 7fc9589c39..0000000000 --- a/app/proprietary/src/main/java/stirling/software/proprietary/classification/store/TeamLabelsRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package stirling.software.proprietary.classification.store; - -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -@Repository -public interface TeamLabelsRepository extends JpaRepository {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/config/CustomAuditEventRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/config/CustomAuditEventRepository.java index f83bf0afdc..53596ebdb4 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/config/CustomAuditEventRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/config/CustomAuditEventRepository.java @@ -54,10 +54,21 @@ public class CustomAuditEventRepository implements AuditEventRepository { return; } String rid = MDC.get("requestId"); + String apiKeyLabel = + MDC.get( + stirling.software.proprietary.security.service + .ApiKeyAuthenticationService.AUDIT_LABEL_MDC_KEY); - if (rid != null) { + if (rid != null || apiKeyLabel != null) { clean = new java.util.HashMap<>(clean); - clean.put("requestId", rid); + if (rid != null) { + clean.put("requestId", rid); + } + // Named key that made the request; surfaces as the doc source in the processor + // feed. + if (apiKeyLabel != null) { + clean.put("__apiKeyLabel", apiKeyLabel); + } } String source = MDC.get("auditSource"); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AiEngineController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AiEngineController.java index a96b3d1240..f50d53e542 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AiEngineController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AiEngineController.java @@ -7,7 +7,6 @@ import java.util.concurrent.Executor; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -28,6 +27,7 @@ import jakarta.validation.Valid; import lombok.extern.slf4j.Slf4j; +import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.job.ResultFile; import stirling.software.common.service.JobOwnershipService; import stirling.software.common.service.TaskManager; @@ -38,6 +38,7 @@ import stirling.software.proprietary.model.api.ai.AiWorkflowResponse; import stirling.software.proprietary.model.api.ai.AiWorkflowResultFile; import stirling.software.proprietary.service.AiEngineClient; import stirling.software.proprietary.service.AiEngineEndpointResolver; +import stirling.software.proprietary.service.AiFeatureGate; import stirling.software.proprietary.service.AiWorkflowService; import tools.jackson.core.JacksonException; @@ -60,15 +61,14 @@ public class AiEngineController { private final TaskManager taskManager; private final JobOwnershipService jobOwnershipService; private final AiEngineEndpointResolver endpointResolver; + private final AiFeatureGate aiFeatureGate; private final UserServiceInterface userService; /** - * SSE emitter timeout. Long enough to accommodate multi-gigabyte PDF workflows (OCR on a - * 1000-page scan, splitting a huge PDF, etc.) without the emitter completing out from under the - * executor. Configurable via {@code stirling.ai.streamTimeoutMs}. + * SSE emitter timeout (ms), long enough for multi-gigabyte PDF workflows without completing out + * from under the executor. Derived from {@code aiEngine.streamTimeoutSeconds}. */ - @Value("${stirling.ai.streamTimeoutMs:1800000}") - private long streamTimeoutMs; + private final long streamTimeoutMs; public AiEngineController( AiEngineClient aiEngineClient, @@ -78,6 +78,8 @@ public class AiEngineController { TaskManager taskManager, JobOwnershipService jobOwnershipService, AiEngineEndpointResolver endpointResolver, + AiFeatureGate aiFeatureGate, + ApplicationProperties applicationProperties, @Autowired(required = false) UserServiceInterface userService) { this.aiEngineClient = aiEngineClient; this.aiWorkflowService = aiWorkflowService; @@ -86,7 +88,10 @@ public class AiEngineController { this.taskManager = taskManager; this.jobOwnershipService = jobOwnershipService; this.endpointResolver = endpointResolver; + this.aiFeatureGate = aiFeatureGate; this.userService = userService; + this.streamTimeoutMs = + applicationProperties.getAiEngine().getStreamTimeoutSeconds() * 1000L; } private String currentUserId() { @@ -111,6 +116,7 @@ public class AiEngineController { + " system and downloadable via GET /api/v1/general/files/{fileId}.") public AiWorkflowResponse orchestrate(@Valid @ModelAttribute AiWorkflowRequest request) throws IOException { + aiFeatureGate.requireConversationalWorkflow(); AiWorkflowResponse result = aiWorkflowService.orchestrate(request); registerFileResultAsJob(result); return result; @@ -123,6 +129,7 @@ public class AiEngineController { "Accepts a PDF upload and a user message, returns SSE events with progress" + " updates followed by the final AI workflow result") public SseEmitter orchestrateStream(@Valid @ModelAttribute AiWorkflowRequest request) { + aiFeatureGate.requireConversationalWorkflow(); SseEmitter emitter = new SseEmitter(streamTimeoutMs); emitter.onTimeout( @@ -246,6 +253,8 @@ public class AiEngineController { "Sends a user message to the PDF edit agent which returns a structured plan" + " of tool operations to perform") public ResponseEntity pdfEdit(@RequestBody String requestBody) throws IOException { + // Same gate as /orchestrate: edit agent is a model call on the same conversational surface. + aiFeatureGate.requireConversationalWorkflow(); JsonNode parsed = parseJson(requestBody); if (!parsed.isObject()) { throw new ResponseStatusException( diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ClassifyLabelController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ClassifyLabelController.java index 629a377cf3..798de71df9 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ClassifyLabelController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ClassifyLabelController.java @@ -31,11 +31,11 @@ import stirling.software.common.service.PdfMetadataService; import stirling.software.common.service.UserServiceInterface; import stirling.software.common.util.TempFileManager; import stirling.software.common.util.WebResponseUtils; +import stirling.software.proprietary.classification.ClassificationLabelProvider; import stirling.software.proprietary.classification.model.ClassificationLabel; -import stirling.software.proprietary.classification.store.ClassificationLabelStore; import stirling.software.proprietary.model.api.ai.AiPageText; -import stirling.software.proprietary.policy.config.PolicyManagementAuthority; import stirling.software.proprietary.service.AiEngineClient; +import stirling.software.proprietary.service.AiFeatureGate; import stirling.software.proprietary.service.PdfContentExtractor; import tools.jackson.databind.JsonNode; @@ -46,7 +46,7 @@ import tools.jackson.databind.node.ObjectNode; * Dispatchable tool that classifies a PDF and writes the result into its metadata. * *

Runs as a Classification-policy pipeline step: it reads a bounded page window, asks the AI - * engine to classify the document against the caller's team label set, and stores the engine's JSON + * engine to classify the document against the built-in label set, and stores the engine's JSON * answer — minus the transport-only {@code outcome} field — in the custom Info-dictionary key * {@link PdfMetadataService#CLASSIFICATION_KEY}. Returns the labelled PDF. Not intended for direct * client use. @@ -68,17 +68,14 @@ public class ClassifyLabelController { private final PdfContentExtractor pdfContentExtractor; private final PdfMetadataService pdfMetadataService; private final AiEngineClient aiEngineClient; + private final AiFeatureGate aiFeatureGate; private final ObjectMapper objectMapper; private final UserServiceInterface userService; /** - * Present only when the policy subsystem is enabled ({@code policies.enabled}); the store and - * team authority are gated on it. Null otherwise, in which case there are no team labels to - * classify against and the document is passed through unlabelled. + * The fixed, built-in vocabulary shared by everyone — see {@link ClassificationLabelProvider}. */ - private final ClassificationLabelStore labelStore; - - private final PolicyManagementAuthority policyManagementAuthority; + private final ClassificationLabelProvider labelProvider; public ClassifyLabelController( CustomPDFDocumentFactory pdfDocumentFactory, @@ -86,19 +83,19 @@ public class ClassifyLabelController { PdfContentExtractor pdfContentExtractor, PdfMetadataService pdfMetadataService, AiEngineClient aiEngineClient, + AiFeatureGate aiFeatureGate, ObjectMapper objectMapper, - @Autowired(required = false) UserServiceInterface userService, - @Autowired(required = false) ClassificationLabelStore labelStore, - @Autowired(required = false) PolicyManagementAuthority policyManagementAuthority) { + ClassificationLabelProvider labelProvider, + @Autowired(required = false) UserServiceInterface userService) { this.pdfDocumentFactory = pdfDocumentFactory; this.tempFileManager = tempFileManager; this.pdfContentExtractor = pdfContentExtractor; this.pdfMetadataService = pdfMetadataService; this.aiEngineClient = aiEngineClient; + this.aiFeatureGate = aiFeatureGate; this.objectMapper = objectMapper; + this.labelProvider = labelProvider; this.userService = userService; - this.labelStore = labelStore; - this.policyManagementAuthority = policyManagementAuthority; } @PostMapping(value = "/classify-and-label", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @@ -111,14 +108,15 @@ public class ClassifyLabelController { + " intended for direct client use.") public ResponseEntity classifyAndLabel( @RequestParam("fileInput") MultipartFile fileInput) throws IOException { + aiFeatureGate.requireClassify(); try (PDDocument document = pdfDocumentFactory.load(fileInput, true)) { String fileName = safeFileName(fileInput.getOriginalFilename()); List allowed = resolveAllowedLabels(); if (allowed.isEmpty()) { - // No vocabulary to classify against (the team stored no labels): pass the file - // through unlabelled rather than ask the engine to classify against nothing. - log.debug("[classify-and-label] {} has no team labels; skipping", fileName); + // No vocabulary to classify against: pass the file through unlabelled rather than + // ask the engine to classify against nothing. + log.debug("[classify-and-label] {} has no labels; skipping", fileName); return WebResponseUtils.pdfDocToWebResponse(document, fileName, tempFileManager); } @@ -175,24 +173,13 @@ public class ClassifyLabelController { } /** - * The allowed labels for the caller's team as {@code {id, name}} pairs, de-duplicated by id. - * The engine shows the model the names and returns the ids (icons are presentational and never - * sent). Returns an empty list — the caller then skips classification — when the policy - * subsystem is disabled (no store) or the team has no stored labels. The engine holds no - * default vocabulary of its own, so a team's stored labels are the only source. + * The built-in vocabulary as {@code {id, name}} pairs, de-duplicated by id. The engine shows + * the model the names and returns the ids (icons are presentational and never sent). The engine + * holds no default vocabulary of its own, so this bundled set is the only source. */ private List resolveAllowedLabels() { - if (labelStore == null) { - return List.of(); - } - Long teamId = - policyManagementAuthority == null - ? null - : policyManagementAuthority.currentUserTeamId(); - Map byId = new LinkedHashMap<>(); - labelStore.findByTeam(teamId).ifPresent(labels -> collectLabels(labels.labels(), byId)); - + collectLabels(labelProvider.labels(), byId); return List.copyOf(byId.values()); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/CreatePdfAgentController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/CreatePdfAgentController.java index 15b7982dec..0a41c552aa 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/CreatePdfAgentController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/CreatePdfAgentController.java @@ -8,12 +8,14 @@ import java.util.List; import org.apache.pdfbox.pdmodel.PDDocument; import org.springframework.core.io.Resource; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; import io.github.pixee.security.Filenames; import io.swagger.v3.oas.annotations.Hidden; @@ -24,18 +26,25 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.common.configuration.RuntimePathConfig; +import stirling.software.common.model.ApplicationProperties; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.ProcessExecutor; import stirling.software.common.util.TempFile; import stirling.software.common.util.TempFileManager; import stirling.software.common.util.WebResponseUtils; +import stirling.software.proprietary.model.api.ai.create.AiDocument; +import stirling.software.proprietary.service.AiDocumentHtmlRenderer; +import stirling.software.proprietary.service.AiFeatureGate; + +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; /** - * Dispatchable tool that converts an AI-generated HTML string to a PDF via WeasyPrint. + * Dispatchable tool that converts an AI-generated document model to a PDF via WeasyPrint. * *

Called by {@link stirling.software.proprietary.service.AiWorkflowService} when the engine - * emits a {@code CREATE_PDF_FROM_HTML_AGENT} plan step. The HTML comes from a trusted Jinja - * template so sanitization is intentionally skipped. + * emits a {@code CREATE_PDF_FROM_HTML_AGENT} plan step. The engine supplies the document as + * structured fields; the HTML is built here from a fixed template. */ @Slf4j @Hidden @@ -48,6 +57,10 @@ public class CreatePdfAgentController { private final TempFileManager tempFileManager; private final CustomPDFDocumentFactory pdfDocumentFactory; private final RuntimePathConfig runtimePathConfig; + private final ApplicationProperties applicationProperties; + private final ObjectMapper objectMapper; + private final AiDocumentHtmlRenderer htmlRenderer; + private final AiFeatureGate aiFeatureGate; /** * Returns true only when WeasyPrint is definitively unavailable — either the binary could not @@ -74,32 +87,42 @@ public class CreatePdfAgentController { value = "/create-pdf-from-html-agent", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @Operation( - summary = "Convert AI-generated HTML to a PDF", + summary = "Convert an AI-generated document to a PDF", description = - "Accepts an HTML document as a plain-text parameter and returns a PDF." - + " This endpoint is dispatched by the AI workflow orchestrator as a" - + " plan step; it is not intended for direct client use.") - public ResponseEntity createPdfFromHtml( - @RequestParam("htmlContent") String htmlContent, - @RequestParam("filename") String filename) + "Accepts a structured document as a JSON parameter and returns a PDF. This" + + " endpoint is dispatched by the AI workflow orchestrator as a plan" + + " step; it is not intended for direct client use.") + public ResponseEntity createPdf( + @RequestParam("document") String document, @RequestParam("filename") String filename) throws Exception { + if (!applicationProperties.getAiEngine().isEnabled()) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND); + } + aiFeatureGate.requireCreatePdf(); + + AiDocument model; + try { + model = objectMapper.readValue(document, AiDocument.class); + } catch (JacksonException e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST); + } + + String html = htmlRenderer.render(model); log.info( - "[create-pdf-agent] converting HTML to PDF via WeasyPrint — html_bytes={}", - htmlContent.length()); + "[create-pdf-agent] converting document to PDF via WeasyPrint — html_bytes={}", + html.length()); try (TempFile htmlFile = tempFileManager.createManagedTempFile(".html"); TempFile pdfFile = tempFileManager.createManagedTempFile(".pdf")) { - Files.writeString(htmlFile.getPath(), htmlContent, StandardCharsets.UTF_8); + Files.writeString(htmlFile.getPath(), html, StandardCharsets.UTF_8); List command = new ArrayList<>(); command.add(runtimePathConfig.getWeasyPrintPath()); command.add("-e"); command.add("utf-8"); command.add("-v"); - // SSRF: the HTML is self-contained and the engine validates style colours, so no - // external url() reaches WeasyPrint. For full isolation, run it network-isolated. command.add(htmlFile.getAbsolutePath()); command.add(pdfFile.getAbsolutePath()); @@ -126,8 +149,8 @@ public class CreatePdfAgentController { // avoids materialising the whole document as a byte[] twice (read-all + re-serialise), // which matters for large generated documents. TempFile tempOut = tempFileManager.createManagedTempFile(".pdf"); - try (PDDocument document = pdfDocumentFactory.load(pdfFile.getPath())) { - document.save(tempOut.getPath().toFile()); + try (PDDocument pdDocument = pdfDocumentFactory.load(pdfFile.getPath())) { + pdDocument.save(tempOut.getPath().toFile()); } catch (Exception e) { tempOut.close(); throw e; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/MathAuditorAgentController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/MathAuditorAgentController.java index 20ca5c109f..8beddd5e94 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/MathAuditorAgentController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/MathAuditorAgentController.java @@ -2,6 +2,7 @@ package stirling.software.proprietary.controller.api; import java.io.IOException; import java.math.BigDecimal; +import java.util.regex.Pattern; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; @@ -20,6 +21,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.proprietary.model.api.ai.Verdict; +import stirling.software.proprietary.service.AiFeatureGate; import stirling.software.proprietary.service.AiToolInputValidator; import stirling.software.proprietary.service.MathAuditorOrchestrator; @@ -46,7 +48,9 @@ import stirling.software.proprietary.service.MathAuditorOrchestrator; @Tag(name = "AI Tools", description = "Dispatchable AI-backed tools.") public class MathAuditorAgentController { + private static final Pattern NEWLINE_PATTERN = Pattern.compile("[\\r\\n]"); private final MathAuditorOrchestrator orchestrator; + private final AiFeatureGate aiFeatureGate; @PostMapping(value = "/math-auditor-agent", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @Operation( @@ -77,15 +81,17 @@ public class MathAuditorAgentController { + " ignored (default: 0.01)") @RequestParam(value = "tolerance", defaultValue = "0.01") BigDecimal tolerance) { + aiFeatureGate.requireMathAuditor(); AiToolInputValidator.validatePdfUpload(fileInput); if (tolerance.compareTo(BigDecimal.ZERO) < 0) { return ResponseEntity.badRequest().build(); } + String originalFilename = fileInput.getOriginalFilename(); String safeName = - fileInput.getOriginalFilename() != null - ? fileInput.getOriginalFilename().replaceAll("[\\r\\n]", "_") + originalFilename != null + ? NEWLINE_PATTERN.matcher(originalFilename).replaceAll("_") : ""; log.info("[math-auditor-agent] request file={} tolerance={}", safeName, tolerance); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PdfCommentAgentController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PdfCommentAgentController.java index ef0ceaba25..fadfacf03a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PdfCommentAgentController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PdfCommentAgentController.java @@ -1,6 +1,7 @@ package stirling.software.proprietary.controller.api; import java.io.IOException; +import java.util.regex.Pattern; import org.springframework.core.io.ByteArrayResource; import org.springframework.core.io.Resource; @@ -20,6 +21,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import stirling.software.proprietary.service.AiFeatureGate; import stirling.software.proprietary.service.AiToolResponseHeaders; import stirling.software.proprietary.service.PdfCommentAgentOrchestrator; import stirling.software.proprietary.service.PdfCommentAgentOrchestrator.AnnotatedPdf; @@ -45,8 +47,10 @@ import tools.jackson.databind.node.ObjectNode; @Tag(name = "AI Tools", description = "Dispatchable AI-backed tools.") public class PdfCommentAgentController { + private static final Pattern NEWLINE_PATTERN = Pattern.compile("[\\r\\n]"); private final PdfCommentAgentOrchestrator orchestrator; private final ObjectMapper objectMapper; + private final AiFeatureGate aiFeatureGate; @PostMapping( value = "/pdf-comment-agent", @@ -77,10 +81,12 @@ public class PdfCommentAgentController { @RequestParam("prompt") String prompt) throws IOException { + aiFeatureGate.requirePdfComment(); + String originalFilename = fileInput.getOriginalFilename(); String safeName = - fileInput.getOriginalFilename() != null - ? fileInput.getOriginalFilename().replaceAll("[\\r\\n]", "_") + originalFilename != null + ? NEWLINE_PATTERN.matcher(originalFilename).replaceAll("_") : ""; log.info( "[pdf-comment-agent] request file={} promptLen={}", diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PortalApiKeysController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PortalApiKeysController.java new file mode 100644 index 0000000000..af0f4d055a --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PortalApiKeysController.java @@ -0,0 +1,54 @@ +package stirling.software.proprietary.controller.api; + +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.RequestParam; + +import io.swagger.v3.oas.annotations.Operation; + +import lombok.RequiredArgsConstructor; + +import stirling.software.common.annotations.api.ProprietaryUiDataApi; +import stirling.software.proprietary.model.api.apikey.CreateApiKeyRequest; +import stirling.software.proprietary.model.api.apikey.CreatedApiKeyDto; +import stirling.software.proprietary.model.api.apikey.PortalApiKeysResponse; +import stirling.software.proprietary.security.service.ApiKeyManagementService; + +/** + * Real backing for the portal Infrastructure → API Keys tab: list/create/revoke named, personal API + * keys. Replaces the former portal-only mock endpoint. Not gated behind an Enterprise license - API + * keys are a core auth feature available on every self-hosted instance. + */ +@ProprietaryUiDataApi +@RequiredArgsConstructor +public class PortalApiKeysController { + + private final ApiKeyManagementService apiKeyManagementService; + + // tier accepted for endpoint symmetry with the other infra tabs; ignored here. + @GetMapping("/infrastructure/api-keys") + @Operation(summary = "List API keys", description = "The caller's personal API keys.") + public ResponseEntity list( + @RequestParam(value = "tier", required = false) String tier) { + return ResponseEntity.ok(apiKeyManagementService.listVisibleKeys()); + } + + @PostMapping("/infrastructure/api-keys") + @Operation( + summary = "Create an API key", + description = "Mints a personal key and returns its one-time secret.") + public ResponseEntity create(@RequestBody CreateApiKeyRequest request) { + return ResponseEntity.ok(apiKeyManagementService.createKey(request)); + } + + @DeleteMapping("/infrastructure/api-keys/{id}") + @Operation(summary = "Revoke an API key", description = "Disables a key the caller owns.") + public ResponseEntity revoke(@PathVariable("id") Long id) { + apiKeyManagementService.revokeKey(id); + return ResponseEntity.noContent().build(); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java index 8db5f48174..02fa1e0b25 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java @@ -3,7 +3,6 @@ package stirling.software.proprietary.controller.api; import static stirling.software.common.util.ProviderUtils.validateProvider; import java.time.Instant; -import java.time.temporal.ChronoUnit; import java.util.*; import java.util.stream.Collectors; @@ -45,7 +44,6 @@ import stirling.software.proprietary.security.config.EnterpriseEndpoint; import stirling.software.proprietary.security.database.repository.SessionRepository; import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.model.Authority; -import stirling.software.proprietary.security.model.SessionEntity; import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.model.dto.AdminUserSummary; import stirling.software.proprietary.security.repository.TeamMembershipRepository; @@ -169,16 +167,8 @@ public class ProprietaryUIDataController { boolean isFirstTimeSetup = false; boolean showDefaultCredentials = false; - List allUsers = userRepository.findAll(); - List realUsers = - allUsers.stream() - .filter( - user -> - !Role.INTERNAL_API_USER - .getRoleId() - .equals(user.getUsername())) - .toList(); - long userCount = realUsers.size(); + // Count real users, excluding the internal API user. + long userCount = userRepository.countByUsernameNot(Role.INTERNAL_API_USER.getRoleId()); if (userCount == 0) { isFirstTimeSetup = true; @@ -265,92 +255,67 @@ public class ProprietaryUIDataController { @PreAuthorize("hasRole('ADMIN')") @Operation(summary = "Get admin settings data") public ResponseEntity getAdminSettingsData(Authentication authentication) { - List allUsers = userRepository.findAllWithTeam(); - Iterator iterator = allUsers.iterator(); + List allUsers = userRepository.findAllWithTeamAndAuthorities(); Map roleDetails = Role.getAllRoleDetails(); + // Drop the internal API user and internal-team members; the roster never shows them. + boolean hasInternalApiUser = false; + List visibleUsers = new ArrayList<>(allUsers.size()); + for (User user : allUsers) { + if (user == null) { + continue; + } + if (isInternalApiUser(user)) { + hasInternalApiUser = true; + continue; + } + if (user.getTeam() != null + && TeamService.INTERNAL_TEAM_NAME.equals(user.getTeam().getName())) { + continue; + } + visibleUsers.add(user); + } + if (hasInternalApiUser) { + roleDetails.remove(Role.INTERNAL_API_USER.getRoleId()); + } + + // All users' settings in one query (mfaSecret masked). + Map> settingsByUserId = + loadSettingsByUserId(visibleUsers.stream().map(User::getId).toList()); + + // Active = any non-expired session within the inactivity window; expiry is left to + // SessionScheduled. + int maxInactiveInterval = sessionPersistentRegistry.getMaxInactiveInterval(); + Instant activeCutoff = Instant.now().minusSeconds(maxInactiveInterval); + Map lastRequestByPrincipal = new HashMap<>(); + for (Object[] row : sessionRepository.findLatestRequestPerPrincipal()) { + if (row[0] != null) { + lastRequestByPrincipal.put((String) row[0], (Instant) row[1]); + } + } + Set activePrincipals = + new HashSet<>(sessionRepository.findActivePrincipalsSince(activeCutoff)); + Map userSessions = new HashMap<>(); Map userLastRequest = new HashMap<>(); Map> userSettings = new HashMap<>(); int activeUsers = 0; int disabledUsers = 0; - - while (iterator.hasNext()) { - User user = iterator.next(); - if (user != null) { - String username = user.getUsername(); - boolean shouldRemove = false; - - // Check if user is an INTERNAL_API_USER - for (Authority authority : user.getAuthorities()) { - if (authority.getAuthority().equals(Role.INTERNAL_API_USER.getRoleId())) { - shouldRemove = true; - roleDetails.remove(Role.INTERNAL_API_USER.getRoleId()); - break; - } - } - - // Check if user is part of the Internal team - if (user.getTeam() != null - && TeamService.INTERNAL_TEAM_NAME.equals(user.getTeam().getName())) { - shouldRemove = true; - } - - if (shouldRemove) { - iterator.remove(); - continue; - } - - // Session status and last request time - int maxInactiveInterval = sessionPersistentRegistry.getMaxInactiveInterval(); - boolean hasActiveSession = false; - Date lastRequest = null; - Optional latestSession = - sessionPersistentRegistry.findLatestSession(username); - - if (latestSession.isPresent()) { - SessionEntity sessionEntity = latestSession.get(); - Instant lastAccessedTime = - Optional.ofNullable(sessionEntity.getLastRequest()) - .orElse(Instant.EPOCH); - Instant now = Instant.now(); - Instant expirationTime = - lastAccessedTime.plus(maxInactiveInterval, ChronoUnit.SECONDS); - - if (now.isAfter(expirationTime)) { - sessionPersistentRegistry.expireSession(sessionEntity.getSessionId()); - } else { - hasActiveSession = !sessionEntity.isExpired(); - } - lastRequest = Date.from(lastAccessedTime); - } else { - lastRequest = new Date(0); - } - - User userWithSettings = - userRepository.findByIdWithSettings(user.getId()).orElse(user); - - // Mask mfaSecret if present in settings - Map originalSettings = userWithSettings.getSettings(); - Map settingsCopy = - originalSettings != null - ? new HashMap<>(originalSettings) - : new HashMap<>(); - if (settingsCopy.containsKey("mfaSecret")) { - settingsCopy.put("mfaSecret", "********"); - } - userSettings.put(username, settingsCopy); - userSessions.put(username, hasActiveSession); - userLastRequest.put(username, lastRequest); - - if (hasActiveSession) activeUsers++; - if (!user.isEnabled()) disabledUsers++; - } + for (User user : visibleUsers) { + String username = user.getUsername(); + boolean hasActiveSession = activePrincipals.contains(username); + Instant lastRequest = lastRequestByPrincipal.get(username); + userSessions.put(username, hasActiveSession); + userLastRequest.put( + username, lastRequest != null ? Date.from(lastRequest) : new Date(0)); + userSettings.put(username, maskSecrets(settingsByUserId.get(user.getId()))); + if (hasActiveSession) activeUsers++; + if (!user.isEnabled()) disabledUsers++; } // Sort users by active status and last request date List sortedUsers = - allUsers.stream() + visibleUsers.stream() .sorted( (u1, u2) -> { boolean u1Active = userSessions.get(u1.getUsername()); @@ -380,11 +345,30 @@ public class ProprietaryUIDataController { int licenseMaxUsers = licenseSettingsService.getSettings().getLicenseMaxUsers(); boolean premiumEnabled = applicationProperties.getPremium().isEnabled(); - // Convert User entities to AdminUserSummary DTOs to exclude sensitive fields - Set leaderUserIds = leaderUserIds(); + // Resolve portal access for the whole roster. The teamLead display flag counts a + // LEADER membership on any team (mirrors /me), but the portal default policy only + // admits leaders of their own active team, so the bulk check gets the narrower set. + List leaderMemberships = + teamMembershipRepository.findByRoleFetchingUserAndTeam(TeamRole.LEADER); + Set leaderUserIds = + leaderMemberships.stream() + .map(row -> row.getUser().getId()) + .collect(Collectors.toSet()); + Set activeTeamLeaderUserIds = + leaderMemberships.stream() + .filter( + row -> + row.getUser().getTeam() != null + && row.getTeam() + .getId() + .equals(row.getUser().getTeam().getId())) + .map(row -> row.getUser().getId()) + .collect(Collectors.toSet()); + Set portalAccessUserIds = + resourceAccessService.usersWithPortalAccess(sortedUsers, activeTeamLeaderUserIds); List userSummaries = sortedUsers.stream() - .map(user -> convertUserToSummary(user, leaderUserIds)) + .map(user -> convertUserToSummary(user, leaderUserIds, portalAccessUserIds)) .toList(); AdminSettingsData data = new AdminSettingsData(); @@ -393,7 +377,7 @@ public class ProprietaryUIDataController { data.setRoleDetails(roleDetails); data.setUserSessions(userSessions); data.setUserLastRequest(userLastRequest); - data.setTotalUsers(allUsers.size()); + data.setTotalUsers(visibleUsers.size()); data.setActiveUsers(activeUsers); data.setDisabledUsers(disabledUsers); data.setTeams(allTeams); @@ -516,7 +500,8 @@ public class ProprietaryUIDataController { } List teamUsers = userRepository.findAllByTeamId(id); - List allUsers = userRepository.findAllWithTeam(); + // Fetch authorities + team for the available-users list. + List allUsers = userRepository.findAllWithTeamAndAuthorities(); List availableUsers = allUsers.stream() .filter( @@ -568,24 +553,48 @@ public class ProprietaryUIDataController { return ResponseEntity.ok(data); } - /** User ids holding a LEADER membership on any team. */ - private Set leaderUserIds() { - return teamMembershipRepository.findByRoleFetchingUserAndTeam(TeamRole.LEADER).stream() - .map(row -> row.getUser().getId()) - .collect(Collectors.toSet()); + /** Whether the user holds the internal-API authority (never shown in the roster). */ + private boolean isInternalApiUser(User user) { + for (Authority authority : user.getAuthorities()) { + if (Role.INTERNAL_API_USER.getRoleId().equals(authority.getAuthority())) { + return true; + } + } + return false; + } + + /** Assemble per-user settings maps from the flat (id, key, value) rows of one bulk query. */ + private Map> loadSettingsByUserId(List userIds) { + Map> byUser = new HashMap<>(); + if (userIds.isEmpty()) { + return byUser; + } + for (Object[] row : userRepository.findSettingsByUserIds(userIds)) { + byUser.computeIfAbsent((Long) row[0], id -> new HashMap<>()) + .put((String) row[1], (String) row[2]); + } + return byUser; + } + + /** Copy a settings map with mfaSecret masked; null-safe. */ + private Map maskSecrets(Map settings) { + Map copy = settings != null ? new HashMap<>(settings) : new HashMap<>(); + if (copy.containsKey("mfaSecret")) { + copy.put("mfaSecret", "********"); + } + return copy; } /** - * Convert User entity to AdminUserSummary DTO, excluding sensitive fields like password and - * apiKey. + * Convert a User to AdminUserSummary (excludes sensitive fields); portal access is passed in. */ - private AdminUserSummary convertUserToSummary(User user, Set leaderUserIds) { + private AdminUserSummary convertUserToSummary( + User user, Set leaderUserIds, Set portalAccessUserIds) { AdminUserSummary summary = new AdminUserSummary(); summary.setId(user.getId()); summary.setTeamLead(leaderUserIds.contains(user.getId())); - // Authoritative portal access, same call /me uses, so the roster honors the configured - // policy instead of the frontend guessing from role/team-leadership. - summary.setPortalAccess(resourceAccessService.canAccessPortal(user)); + // Portal access (same policy /me uses). + summary.setPortalAccess(portalAccessUserIds.contains(user.getId())); summary.setUsername(user.getUsername()); summary.setEmail(user.getUsername()); // Use username as email for consistency summary.setRoleName(user.getRoleName()); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ApiAuthType.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ApiAuthType.java new file mode 100644 index 0000000000..0eef442523 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ApiAuthType.java @@ -0,0 +1,25 @@ +package stirling.software.proprietary.integration.api; + +/** + * How an {@link stirling.software.proprietary.integration.model.IntegrationType#API} connection + * authenticates. + */ +public enum ApiAuthType { + /** No credentials; the endpoint is open or authorises by network position. */ + NONE, + /** {@code Authorization: Bearer }. */ + BEARER, + /** {@code Authorization: Basic base64(username:password)}. */ + BASIC, + /** The token in a caller-named header, e.g. {@code X-API-Key: }. */ + HEADER, + /** + * The connection logs in first and reuses the short-lived token it gets back. + * + *

For the large class of enterprise APIs - ConsignO Cloud, OAuth2 client-credentials, and + * others - where credentials buy a token rather than authenticating a call directly. Without + * this a step could not reach them at all: each call needs a token, and a stateless step has + * nowhere to obtain or keep one. See {@link ApiTokenLogin}. + */ + TOKEN_LOGIN +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ApiConnectionResolver.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ApiConnectionResolver.java new file mode 100644 index 0000000000..e29435ff14 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ApiConnectionResolver.java @@ -0,0 +1,130 @@ +package stirling.software.proprietary.integration.api; + +import java.util.LinkedHashMap; +import java.util.Map; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; + +import stirling.software.proprietary.access.model.ResourceType; +import stirling.software.proprietary.access.service.OwnershipService; +import stirling.software.proprietary.integration.model.IntegrationConfig; +import stirling.software.proprietary.integration.model.IntegrationType; +import stirling.software.proprietary.integration.repository.IntegrationConfigRepository; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.service.UserService; + +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.ObjectMapper; + +/** + * Dereferences a step's {@code connectionId} to a stored integration config. + * + *

Mirrors {@code S3ConnectionResolver}. When an authenticated caller is present the connection + * must be usable by them; a background worker thread carries no {@code SecurityContext} and skips + * that check, relying on the step having been access-checked when the policy was saved or when an + * ad-hoc run was dispatched - see {@link IntegrationStepValidator}, which is what makes that + * assumption true rather than merely hoped for. + */ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ApiConnectionResolver { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private final IntegrationConfigRepository connections; + private final OwnershipService ownership; + private final UserService userService; + + /** The raw config map for a connection of the given type. */ + public Map resolveConfig(Long connectionId, IntegrationType type) { + IntegrationConfig connection = + connections + .findById(connectionId) + .filter(cfg -> cfg.getIntegrationType() == type) + .filter(this::usableByCurrentUser) + // Existence and access collapse into one error so a caller cannot tell + // "no such connection" from "someone else's connection" and enumerate ids. + .orElseThrow( + () -> + new IllegalArgumentException( + "unknown or inaccessible " + + type.name().toLowerCase() + + " connection")); + if (!connection.isEnabled()) { + throw new IllegalArgumentException( + type.name().toLowerCase() + " connection is disabled"); + } + return configOf(connection); + } + + /** The settings for a generic {@code API} connection. */ + public ApiConnectionSettings resolve(Long connectionId) { + return ApiConnectionSettings.from(resolveConfig(connectionId, IntegrationType.API)); + } + + /** Parse a {@code connectionId} step parameter; null when absent. */ + public static Long connectionId(Object reference) { + if (reference == null || (reference instanceof String s && s.isBlank())) { + return null; + } + if (reference instanceof Number number) { + return number.longValue(); + } + try { + return Long.valueOf(reference.toString().trim()); + } catch (NumberFormatException e) { + throw new IllegalArgumentException( + "'connectionId' is not a valid connection reference: " + reference); + } + } + + /** + * Whether the current caller may use this connection. A missing principal means a worker + * thread, where access was established earlier; it must never be the only thing standing + * between a caller and a connection, or the check becomes a confused deputy. + */ + private boolean usableByCurrentUser(IntegrationConfig connection) { + User user = currentUser(); + return user == null || ownership.canUse(ResourceType.INTEGRATION_CONFIG, connection, user); + } + + // Mirrors ResourceAccessSecurity's principal resolution; null when unauthenticated. + private User currentUser() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null || !auth.isAuthenticated()) { + return null; + } + Object principal = auth.getPrincipal(); + if (principal instanceof User user) { + return user; + } + if (principal instanceof UserDetails userDetails) { + return userService.findByUsername(userDetails.getUsername()).orElse(null); + } + if (principal instanceof String username && !"anonymousUser".equals(username)) { + return userService.findByUsername(username).orElse(null); + } + return null; + } + + private static Map configOf(IntegrationConfig connection) { + String json = connection.getConfig(); + if (json == null || json.isBlank()) { + return Map.of(); + } + try { + return OBJECT_MAPPER.readValue( + json, new TypeReference>() {}); + } catch (Exception e) { + throw new IllegalArgumentException( + "connection '" + connection.getName() + "' has unreadable config", e); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ApiConnectionSettings.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ApiConnectionSettings.java new file mode 100644 index 0000000000..a1bb29cf84 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ApiConnectionSettings.java @@ -0,0 +1,282 @@ +package stirling.software.proprietary.integration.api; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * A resolved {@code API} connection: where to call, and how to authenticate. + * + *

{@code baseUrl} is the security anchor of the whole feature. It is set by whoever can manage + * the connection (an admin or team owner) and is the only thing that decides which host is + * contacted. A pipeline step supplies a relative path only, resolved under this base by + * {@link ExternalApiPaths}, so a step author can never pivot the call to a host of their choosing. + * Widening that - letting a step pass a full URL - would turn every policy into an SSRF primitive. + * + *

Whether the base URL may resolve to a private address is deliberately not a field + * here. Any user may create an API connection (unlike S3, which {@code IntegrationConfigService} + * restricts to admins), so a per-connection opt-in would let a user grant themselves a fetch of the + * cloud metadata service. It is an operator property instead - {@code + * policies.allowPrivateApiEndpoints} - checked by {@link ApiIntegrationValidator}. + */ +public record ApiConnectionSettings( + String baseUrl, + ApiAuthType authType, + String headerName, + String headerPrefix, + String token, + String username, + String password, + Map headers, + ApiTokenLogin tokenLogin, + Set resultUrlHosts, + int timeoutSeconds) { + + static final String BASE_URL_OPTION = "baseUrl"; + static final String AUTH_TYPE_OPTION = "authType"; + static final String HEADER_NAME_OPTION = "headerName"; + static final String HEADER_PREFIX_OPTION = "headerPrefix"; + // "token"/"password" contain SecretMasker hints, so they mask on read and merge on update. + static final String TOKEN_OPTION = "token"; + static final String USERNAME_OPTION = "username"; + static final String PASSWORD_OPTION = "password"; + static final String HEADERS_OPTION = "headers"; + static final String RESULT_URL_HOSTS_OPTION = "resultUrlHosts"; + static final String TIMEOUT_SECONDS_OPTION = "timeoutSeconds"; + + static final int DEFAULT_TIMEOUT_SECONDS = 60; + private static final int MAX_TIMEOUT_SECONDS = 600; + + public ApiConnectionSettings { + headers = headers == null ? Map.of() : Map.copyOf(headers); + resultUrlHosts = resultUrlHosts == null ? Set.of() : Set.copyOf(resultUrlHosts); + } + + /** + * @throws IllegalArgumentException if the config is unusable; the message is surfaced to the + * operator editing the connection, so it names the offending option. + */ + public static ApiConnectionSettings from(Map options) { + String baseUrl = trimmed(options.get(BASE_URL_OPTION)); + if (baseUrl == null) { + throw new IllegalArgumentException("api config requires a 'baseUrl' option"); + } + URI uri = parseHttpUrl(baseUrl); + if (uri.getQuery() != null || uri.getFragment() != null) { + throw new IllegalArgumentException( + "api config 'baseUrl' must not carry a query string or fragment"); + } + + ApiAuthType authType = parseAuthType(trimmed(options.get(AUTH_TYPE_OPTION))); + String headerName = trimmed(options.get(HEADER_NAME_OPTION)); + // Many APIs want a scheme before the token ("Authorization: Token abc", + // "Authorization: DeepL-Auth-Key abc"). Without this a preset would have to make the + // operator paste the scheme into the secret itself, which reads as a typo waiting to + // happen. + String headerPrefix = trimmed(options.get(HEADER_PREFIX_OPTION)); + String token = trimmed(options.get(TOKEN_OPTION)); + String username = trimmed(options.get(USERNAME_OPTION)); + String password = trimmed(options.get(PASSWORD_OPTION)); + + switch (authType) { + case BEARER -> require(token, "api config authType 'BEARER' requires a 'token'"); + case HEADER -> { + require(token, "api config authType 'HEADER' requires a 'token'"); + require(headerName, "api config authType 'HEADER' requires a 'headerName'"); + if (!ExternalApiHeaders.isValidName(headerName)) { + throw new IllegalArgumentException( + "api config 'headerName' is not a valid HTTP header name: " + + headerName); + } + } + case BASIC -> { + require(username, "api config authType 'BASIC' requires a 'username'"); + require(password, "api config authType 'BASIC' requires a 'password'"); + } + case TOKEN_LOGIN -> { + /* validated by ApiTokenLogin.from below */ + } + case NONE -> { + /* nothing to check */ + } + } + + return new ApiConnectionSettings( + stripTrailingSlash(baseUrl), + authType, + headerName, + headerPrefix, + token, + username, + password, + parseHeaders(options.get(HEADERS_OPTION)), + authType == ApiAuthType.TOKEN_LOGIN ? ApiTokenLogin.from(options) : null, + parseResultUrlHosts(options.get(RESULT_URL_HOSTS_OPTION)), + parseTimeout(options.get(TIMEOUT_SECONDS_OPTION))); + } + + /** The configured base as a URI; callers resolve step paths under it. */ + public URI baseUri() { + return URI.create(baseUrl); + } + + /** + * Identity of this connection's login for token-cache purposes. Includes the credentials, so + * editing a password evicts the token cached under the old one rather than reusing it until it + * expires. + */ + String tokenCacheKey() { + return baseUrl + "|" + Objects.hash(tokenLogin); + } + + private static URI parseHttpUrl(String value) { + URI uri; + try { + uri = new URI(value); + } catch (URISyntaxException e) { + throw new IllegalArgumentException("api config 'baseUrl' is not a valid URL", e); + } + String scheme = uri.getScheme() == null ? null : uri.getScheme().toLowerCase(Locale.ROOT); + if (!"http".equals(scheme) && !"https".equals(scheme)) { + throw new IllegalArgumentException( + "api config 'baseUrl' must be an http(s) URL, e.g. https://api.example.com"); + } + if (uri.getHost() == null || uri.getHost().isBlank()) { + throw new IllegalArgumentException("api config 'baseUrl' must include a host"); + } + return uri; + } + + private static ApiAuthType parseAuthType(String value) { + if (value == null) { + return ApiAuthType.NONE; + } + try { + return ApiAuthType.valueOf(value.toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException( + "api config 'authType' must be one of NONE, BEARER, BASIC, HEADER; got " + + value); + } + } + + /** Static headers sent on every call. Rejects anything auth-bearing to keep one auth path. */ + private static Map parseHeaders(Object value) { + if (value == null) { + return Map.of(); + } + if (!(value instanceof Map raw)) { + throw new IllegalArgumentException("api config 'headers' must be an object"); + } + Map headers = new LinkedHashMap<>(); + for (Map.Entry entry : raw.entrySet()) { + String name = trimmed(entry.getKey()); + String headerValue = entry.getValue() == null ? null : entry.getValue().toString(); + if (name == null) { + continue; + } + if (!ExternalApiHeaders.isValidName(name)) { + throw new IllegalArgumentException( + "api config 'headers' has an invalid header name: " + name); + } + if (ExternalApiHeaders.isReserved(name)) { + throw new IllegalArgumentException( + "api config 'headers' must not set '" + + name + + "'; use 'authType' and 'token' instead"); + } + if (headerValue == null || !ExternalApiHeaders.isValidValue(headerValue)) { + throw new IllegalArgumentException( + "api config 'headers' has an invalid value for '" + name + "'"); + } + headers.put(name, headerValue); + } + return headers; + } + + /** + * Hosts a result may be fetched from, beyond the connection's own. Declared by the operator + * because the alternative - trusting the host named in the API's response - is an SSRF. + */ + private static Set parseResultUrlHosts(Object value) { + if (value == null) { + return Set.of(); + } + if (!(value instanceof java.util.List list)) { + throw new IllegalArgumentException( + "api config 'resultUrlHosts' must be a list of hostnames"); + } + Set out = new java.util.LinkedHashSet<>(); + for (Object entry : list) { + String host = trimmed(entry); + if (host == null) { + continue; + } + if (host.contains("/") || host.contains(":") || host.contains("*")) { + // A URL, port or wildcard here would read as broader than it is; subdomains are + // already covered by the "endsWith('.' + host)" rule at match time. + throw new IllegalArgumentException( + "api config 'resultUrlHosts' takes bare hostnames, e.g." + + " cdn.vendor.com; got " + + host); + } + out.add(host.toLowerCase(Locale.ROOT)); + } + return out; + } + + private static int parseTimeout(Object value) { + if (value == null) { + return DEFAULT_TIMEOUT_SECONDS; + } + int seconds; + try { + seconds = Integer.parseInt(value.toString().trim()); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("api config 'timeoutSeconds' must be a number"); + } + if (seconds < 1 || seconds > MAX_TIMEOUT_SECONDS) { + throw new IllegalArgumentException( + "api config 'timeoutSeconds' must be between 1 and " + MAX_TIMEOUT_SECONDS); + } + return seconds; + } + + private static void require(String value, String message) { + if (value == null) { + throw new IllegalArgumentException(message); + } + } + + private static String stripTrailingSlash(String value) { + String out = value; + while (out.endsWith("/")) { + out = out.substring(0, out.length() - 1); + } + return out; + } + + private static String trimmed(Object value) { + if (value == null) { + return null; + } + String text = value.toString().trim(); + return text.isEmpty() ? null : text; + } + + /** Never prints the credentials, so an accidental log line cannot leak them. */ + @Override + public String toString() { + return "ApiConnectionSettings[baseUrl=" + + baseUrl + + ", authType=" + + authType + + ", timeoutSeconds=" + + timeoutSeconds + + "]"; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ApiIntegrationValidator.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ApiIntegrationValidator.java new file mode 100644 index 0000000000..014bacb4a5 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ApiIntegrationValidator.java @@ -0,0 +1,109 @@ +package stirling.software.proprietary.integration.api; + +import java.util.Map; + +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.cluster.s3.S3Clients; +import stirling.software.proprietary.integration.model.IntegrationType; +import stirling.software.proprietary.integration.service.IntegrationConfigValidator; + +/** + * The {@code API} connection schema, enforced when the config is saved: an http(s) base URL, a + * coherent auth block, and a host that must not reach private addresses without the operator + * opt-in. + * + *

The host check runs here so a bad connection fails in the form rather than mid-run. It is not + * the only check - {@link ExternalApiCaller} re-checks before dispatch, because DNS can be + * re-pointed at a private address long after save time (a check-then-use gap this validator alone + * cannot close). + */ +@Component +@RequiredArgsConstructor +public class ApiIntegrationValidator implements IntegrationConfigValidator { + + private final ApplicationProperties applicationProperties; + + @Override + public IntegrationType type() { + return IntegrationType.API; + } + + @Override + public void validate(Map config) { + ApiConnectionSettings settings = ApiConnectionSettings.from(config); + requirePublicHost(settings, applicationProperties, "API connection base URL"); + } + + /** + * Shared by every integration type that dials an operator-supplied host, so they cannot drift + * apart on what counts as reachable. + */ + static void requirePublicHost( + ApiConnectionSettings settings, + ApplicationProperties applicationProperties, + String settingName) { + // Block the cloud metadata service unconditionally - before the opt-in check. The private- + // endpoint opt-in exists for on-prem services (RFC1918, an internal gateway), but the + // metadata endpoint is never a real integration and reaching it is the highest-value SSRF: + // it hands out the instance's own IAM credentials. So it stays blocked even when the + // operator has allowed private endpoints. + denyCloudMetadata(settings.baseUri(), settingName); + try { + S3Clients.validateEndpointHost( + settings.baseUri(), + applicationProperties.getPolicies().isAllowPrivateApiEndpoints(), + settingName, + "set policies.allowPrivateApiEndpoints=true to opt in (e.g. for an on-prem" + + " integration)."); + } catch (IllegalStateException e) { + throw new IllegalArgumentException(e.getMessage(), e); + } + } + + /** AWS/GCP/Azure, Oracle and IBM metadata addresses; mirrors {@code SsrfProtectionService}. */ + private static final java.util.Set CLOUD_METADATA_IPS = + java.util.Set.of( + "169.254.169.254", "169.254.169.253", "169.254.169.250", "fd00:ec2::254"); + + private static void denyCloudMetadata(java.net.URI uri, String settingName) { + String host = uri.getHost(); + if (host == null || host.isBlank()) { + return; // a missing host is S3Clients' error to report, with its own message + } + java.net.InetAddress[] addresses; + try { + addresses = java.net.InetAddress.getAllByName(host); + } catch (java.net.UnknownHostException e) { + return; // an unresolvable host is likewise left to S3Clients to reject + } + for (java.net.InetAddress address : addresses) { + String ip = normalise(address.getHostAddress()); + if (CLOUD_METADATA_IPS.stream().anyMatch(ip::startsWith)) { + throw new IllegalArgumentException( + settingName + + " host '" + + host + + "' resolves to the cloud metadata service (" + + ip + + "), which is never a valid integration target."); + } + } + } + + /** Strip an IPv4-mapped-IPv6 prefix and any zone id so the compare sees a bare address. */ + private static String normalise(String ip) { + String out = ip; + int zone = out.indexOf('%'); + if (zone >= 0) { + out = out.substring(0, zone); + } + if (out.startsWith("::ffff:")) { + out = out.substring(7); + } + return out; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ApiTokenCache.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ApiTokenCache.java new file mode 100644 index 0000000000..3610f77ab0 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ApiTokenCache.java @@ -0,0 +1,149 @@ +package stirling.software.proprietary.integration.api; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import org.springframework.http.MediaType; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; + +import lombok.extern.slf4j.Slf4j; + +import tools.jackson.databind.ObjectMapper; + +/** + * Obtains and caches the short-lived tokens of {@link ApiAuthType#TOKEN_LOGIN} connections. + * + *

The step that uses a token is stateless and runs once per document, so without a cache a + * hundred-document policy would perform a hundred logins - which many vendors rate-limit, and some + * treat as suspicious. The cache is keyed on the connection's login identity (credentials included) + * so that editing a password does not keep reusing the token bought with the old one. + * + *

Entries expire well inside the vendor's stated lifetime, and a 401 additionally evicts and + * retries once ({@link ExternalApiCaller}), so a token that expires early - or is revoked - costs + * one retry rather than a failed run. + */ +@Slf4j +public class ApiTokenCache { + + /** Bounded so a deployment with many connections cannot grow this without limit. */ + private static final int MAX_ENTRIES = 500; + + private final Cache tokens; + private final HttpClient httpClient; + private final ObjectMapper objectMapper; + + ApiTokenCache(HttpClient httpClient, ObjectMapper objectMapper) { + this.httpClient = httpClient; + this.objectMapper = objectMapper; + this.tokens = + Caffeine.newBuilder() + .maximumSize(MAX_ENTRIES) + // Per-entry, because each connection states its own lifetime. + .expireAfter( + new com.github.benmanes.caffeine.cache.Expiry() { + @Override + public long expireAfterCreate( + String key, String value, long currentTime) { + return ttlNanos(key); + } + + @Override + public long expireAfterUpdate( + String key, + String value, + long currentTime, + long currentDuration) { + return ttlNanos(key); + } + + @Override + public long expireAfterRead( + String key, + String value, + long currentTime, + long currentDuration) { + // Reading must not extend a token's life: the vendor's + // clock is running regardless of how often we use it. + return currentDuration; + } + }) + .build(); + } + + // The TTL travels in the key so the Expiry callbacks can see it without a second lookup. + private static long ttlNanos(String key) { + int seconds = Integer.parseInt(key.substring(key.lastIndexOf('#') + 1)); + return TimeUnit.SECONDS.toNanos(seconds); + } + + /** + * The connection's current token, logging in if there is not a live one. + * + * @throws IOException if the login call fails or returns no token + */ + String token(ApiConnectionSettings settings) throws IOException { + String key = cacheKey(settings); + String cached = tokens.getIfPresent(key); + if (cached != null) { + return cached; + } + String token = login(settings); + tokens.put(key, token); + return token; + } + + /** Drop the cached token, e.g. after a 401 says it is no longer accepted. */ + void invalidate(ApiConnectionSettings settings) { + tokens.invalidate(cacheKey(settings)); + } + + private static String cacheKey(ApiConnectionSettings settings) { + return settings.tokenCacheKey() + "#" + settings.tokenLogin().tokenTtlSeconds(); + } + + private String login(ApiConnectionSettings settings) throws IOException { + ApiTokenLogin login = settings.tokenLogin(); + URI target = ExternalApiPaths.resolve(settings.baseUri(), login.loginPath()); + + HttpRequest.Builder request = + HttpRequest.newBuilder(target) + .timeout(Duration.ofSeconds(settings.timeoutSeconds())) + .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .POST( + HttpRequest.BodyPublishers.ofByteArray( + objectMapper.writeValueAsBytes(login.loginBody()))); + login.loginHeaders().forEach(request::header); + + ExternalApiCaller.Response response = + ExternalApiCaller.send(httpClient, request.build(), target); + if (!response.isSuccess()) { + // Deliberately does not echo the body: a login failure response can repeat the + // credentials back, and this message reaches the run log. + throw new IOException( + "Login to " + + target.getHost() + + login.loginPath() + + " returned HTTP " + + response.status()); + } + try { + String token = login.extractToken(response, objectMapper); + log.debug("[external-api] obtained a token from {}", target.getHost()); + return token; + } catch (IllegalStateException e) { + throw new IOException(e.getMessage(), e); + } + } + + /** The auth header for an authenticated call. */ + Map.Entry authHeader(ApiConnectionSettings settings) throws IOException { + return settings.tokenLogin().authHeader(token(settings)); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ApiTokenLogin.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ApiTokenLogin.java new file mode 100644 index 0000000000..a7c02d786f --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ApiTokenLogin.java @@ -0,0 +1,209 @@ +package stirling.software.proprietary.integration.api; + +import java.util.LinkedHashMap; +import java.util.Map; + +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; + +/** + * How a connection turns credentials into a short-lived token. + * + *

Modelled on what real APIs actually do rather than on one vendor. The two axes that vary are + * where the token comes back ({@code tokenResponseHeader} or {@code tokenResponseJsonPath}) and how + * it is then presented ({@code tokenHeaderName} + {@code tokenPrefix}). That covers both ends of + * the spectrum: + * + *

    + *
  • ConsignO Cloud - {@code POST /auth/login} with {@code X-Client-Id}/{@code X-Client-Secret} + * headers and a {@code {username, password, tenantId}} body, returning the token in the + * {@code X-Auth-Token} response header, which is then sent back as {@code + * X-Auth-Token}. + *
  • OAuth2 client-credentials - a form or JSON post returning {@code {"access_token": ...}} in + * the body, sent back as {@code Authorization: Bearer ...}. + *
+ * + *

{@code loginBody} and {@code loginHeaders} are stored as nested maps rather than a + * pre-rendered JSON string so {@code SecretMasker} can recurse and mask the {@code password} / + * {@code X-Client-Secret} entries inside them. A flat string would sail past it and hand the + * password back in plaintext on every read of the connection. + */ +record ApiTokenLogin( + String loginPath, + Map loginBody, + Map loginHeaders, + String tokenResponseHeader, + String tokenResponseJsonPath, + String tokenHeaderName, + String tokenPrefix, + int tokenTtlSeconds) { + + static final String LOGIN_PATH_OPTION = "loginPath"; + static final String LOGIN_BODY_OPTION = "loginBody"; + static final String LOGIN_HEADERS_OPTION = "loginHeaders"; + static final String TOKEN_RESPONSE_HEADER_OPTION = "tokenResponseHeader"; + static final String TOKEN_RESPONSE_JSON_PATH_OPTION = "tokenResponseJsonPath"; + static final String TOKEN_HEADER_NAME_OPTION = "tokenHeaderName"; + static final String TOKEN_PREFIX_OPTION = "tokenPrefix"; + static final String TOKEN_TTL_SECONDS_OPTION = "tokenTtlSeconds"; + + /** + * Conservative default. ConsignO's token lasts 30 minutes; caching for 25 leaves room for a + * slow call to finish on a token that was still valid when it started. A cache that expired + * exactly on the vendor's boundary would fail intermittently and look like a network fault. + */ + static final int DEFAULT_TOKEN_TTL_SECONDS = 1500; + + private static final int MAX_TOKEN_TTL_SECONDS = 86400; + + ApiTokenLogin { + loginBody = loginBody == null ? Map.of() : Map.copyOf(loginBody); + loginHeaders = loginHeaders == null ? Map.of() : Map.copyOf(loginHeaders); + } + + static ApiTokenLogin from(Map options) { + String loginPath = trimmed(options.get(LOGIN_PATH_OPTION)); + if (loginPath == null) { + throw new IllegalArgumentException( + "api config authType 'TOKEN_LOGIN' requires a 'loginPath', e.g. /auth/login"); + } + String responseHeader = trimmed(options.get(TOKEN_RESPONSE_HEADER_OPTION)); + String responseJsonPath = trimmed(options.get(TOKEN_RESPONSE_JSON_PATH_OPTION)); + if ((responseHeader == null) == (responseJsonPath == null)) { + throw new IllegalArgumentException( + "api config authType 'TOKEN_LOGIN' needs exactly one of" + + " 'tokenResponseHeader' (e.g. X-Auth-Token) or" + + " 'tokenResponseJsonPath' (e.g. access_token) to say where the token" + + " comes back"); + } + String tokenHeaderName = trimmed(options.get(TOKEN_HEADER_NAME_OPTION)); + if (tokenHeaderName == null) { + throw new IllegalArgumentException( + "api config authType 'TOKEN_LOGIN' requires a 'tokenHeaderName' saying which" + + " header carries the token back, e.g. X-Auth-Token or Authorization"); + } + if (!ExternalApiHeaders.isValidName(tokenHeaderName)) { + throw new IllegalArgumentException( + "api config 'tokenHeaderName' is not a valid HTTP header name: " + + tokenHeaderName); + } + if (responseHeader != null && !ExternalApiHeaders.isValidName(responseHeader)) { + throw new IllegalArgumentException( + "api config 'tokenResponseHeader' is not a valid HTTP header name: " + + responseHeader); + } + + return new ApiTokenLogin( + loginPath, + nestedObject(options.get(LOGIN_BODY_OPTION), LOGIN_BODY_OPTION), + loginHeaders(options.get(LOGIN_HEADERS_OPTION)), + responseHeader, + responseJsonPath, + tokenHeaderName, + trimmed(options.get(TOKEN_PREFIX_OPTION)) == null + ? "" + : trimmed(options.get(TOKEN_PREFIX_OPTION)) + " ", + ttl(options.get(TOKEN_TTL_SECONDS_OPTION))); + } + + /** Pull the token out of a login response. */ + String extractToken(ExternalApiCaller.Response response, ObjectMapper objectMapper) { + if (tokenResponseHeader != null) { + String value = response.header(tokenResponseHeader); + if (value == null || value.isBlank()) { + throw new IllegalStateException( + "Login succeeded but returned no '" + + tokenResponseHeader + + "' response header"); + } + return value; + } + JsonNode node = response.bodyAsJson(objectMapper); + for (String segment : tokenResponseJsonPath.split("\\.")) { + if (node == null) { + break; + } + node = node.get(segment); + } + if (node == null || !node.isValueNode() || node.asString().isBlank()) { + throw new IllegalStateException( + "Login succeeded but its body had no token at '" + tokenResponseJsonPath + "'"); + } + return node.asString(); + } + + /** The header to send on an authenticated call. */ + Map.Entry authHeader(String token) { + return Map.entry(tokenHeaderName, tokenPrefix + token); + } + + private static Map nestedObject(Object value, String option) { + if (value == null) { + return Map.of(); + } + if (!(value instanceof Map raw)) { + throw new IllegalArgumentException("api config '" + option + "' must be an object"); + } + Map out = new LinkedHashMap<>(); + raw.forEach((key, entry) -> out.put(String.valueOf(key), entry)); + return out; + } + + private static Map loginHeaders(Object value) { + Map out = new LinkedHashMap<>(); + nestedObject(value, LOGIN_HEADERS_OPTION) + .forEach( + (name, entry) -> { + String headerValue = entry == null ? null : entry.toString(); + if (!ExternalApiHeaders.isValidName(name)) { + throw new IllegalArgumentException( + "api config 'loginHeaders' has an invalid header name: " + + name); + } + if (headerValue == null + || !ExternalApiHeaders.isValidValue(headerValue)) { + throw new IllegalArgumentException( + "api config 'loginHeaders' has an invalid value for '" + + name + + "'"); + } + out.put(name, headerValue); + }); + return out; + } + + private static int ttl(Object value) { + if (value == null) { + return DEFAULT_TOKEN_TTL_SECONDS; + } + int seconds; + try { + seconds = Integer.parseInt(value.toString().trim()); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("api config 'tokenTtlSeconds' must be a number"); + } + if (seconds < 1 || seconds > MAX_TOKEN_TTL_SECONDS) { + throw new IllegalArgumentException( + "api config 'tokenTtlSeconds' must be between 1 and " + MAX_TOKEN_TTL_SECONDS); + } + return seconds; + } + + private static String trimmed(Object value) { + if (value == null) { + return null; + } + String text = value.toString().trim(); + return text.isEmpty() ? null : text; + } + + /** Never prints the login body or headers: both carry the credentials. */ + @Override + public String toString() { + return "ApiTokenLogin[loginPath=" + + loginPath + + ", tokenTtlSeconds=" + + tokenTtlSeconds + + "]"; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/DocumentContext.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/DocumentContext.java new file mode 100644 index 0000000000..044d24c0c5 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/DocumentContext.java @@ -0,0 +1,180 @@ +package stirling.software.proprietary.integration.api; + +import java.io.IOException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.util.Base64; +import java.util.Calendar; +import java.util.HexFormat; +import java.util.List; +import java.util.Locale; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDDocumentInformation; +import org.springframework.web.multipart.MultipartFile; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.service.PdfMetadataService; +import stirling.software.proprietary.integration.purview.PdfSensitivityLabels; +import stirling.software.proprietary.integration.purview.SensitivityLabel; + +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ObjectNode; + +/** + * Everything Stirling already knows about the document and the run, as one JSON object. + * + *

An external API almost always wants more than the bytes: what the file is, what it was called, + * whether it is already classified or labelled, and which policy sent it. All of that is in hand at + * the moment of the call, so it is offered rather than left for the operator to re-derive - most + * usefully the Purview label and the classifier's verdict, which turn a call-out into something the + * receiving system can make a decision with. + * + *

The shape is also the namespace for placeholders (see {@link Placeholders}), so {@code + * {{document.sha256}}} or {@code {{sensitivityLabel.name}}} in a field, path, or header resolves + * against exactly what is documented here: + * + *

+ * document.filename | .extension | .contentType | .sizeBytes | .sha256 | .base64
+ *         .pageCount | .encrypted | .title | .author | .subject | .keywords
+ *         .creator | .producer | .created | .modified
+ * classification.*         the classifier policy's verdict, when it has run
+ * sensitivityLabel.labelId | .name | .siteId | .method | .protected
+ * run.policyName | .runId | .timestamp
+ * 
+ * + *

Every field is best-effort: a non-PDF, an unparseable PDF, or an ad-hoc run with no policy + * simply omits what it cannot know. Building the context must never be the reason a step fails. + */ +@Slf4j +final class DocumentContext { + + private DocumentContext() {} + + static ObjectNode build( + MultipartFile file, + byte[] content, + String policyName, + String runId, + ObjectMapper objectMapper) { + ObjectNode root = objectMapper.createObjectNode(); + ObjectNode document = root.putObject("document"); + + String filename = file.getOriginalFilename(); + document.put("filename", filename); + document.put("extension", extensionOf(filename)); + document.put("contentType", file.getContentType()); + document.put("sizeBytes", content.length); + document.put("sha256", sha256(content)); + // The bytes themselves, for steps that carry the document inside a JSON body + // (an attachment field, a signing payload) rather than as multipart. + document.put("base64", Base64.getEncoder().encodeToString(content)); + + if (looksLikePdf(content)) { + addPdfFacts(document, root, content, objectMapper); + } + + ObjectNode run = root.putObject("run"); + run.put("policyName", policyName); + run.put("runId", runId); + run.put("timestamp", Instant.now().toString()); + return root; + } + + /** PDF-only facts. A document we cannot parse still gets the basics above. */ + private static void addPdfFacts( + ObjectNode document, ObjectNode root, byte[] content, ObjectMapper objectMapper) { + try (PDDocument pdf = Loader.loadPDF(content)) { + document.put("pageCount", pdf.getNumberOfPages()); + document.put("encrypted", pdf.isEncrypted()); + + PDDocumentInformation info = pdf.getDocumentInformation(); + document.put("title", info.getTitle()); + document.put("author", info.getAuthor()); + document.put("subject", info.getSubject()); + document.put("keywords", info.getKeywords()); + document.put("creator", info.getCreator()); + document.put("producer", info.getProducer()); + document.put("created", toIso(info.getCreationDate())); + document.put("modified", toIso(info.getModificationDate())); + + addClassification(root, info, objectMapper); + addSensitivityLabel(root, pdf); + } catch (IOException | RuntimeException e) { + // An encrypted or malformed PDF is a normal thing to send to an external API; the + // extra facts are a convenience, not a precondition. + log.debug("Could not read PDF facts for the step context: {}", e.getMessage()); + } + } + + /** The classifier policy's verdict, so a call-out can act on it without re-classifying. */ + private static void addClassification( + ObjectNode root, PDDocumentInformation info, ObjectMapper objectMapper) { + String raw = info.getCustomMetadataValue(PdfMetadataService.CLASSIFICATION_KEY); + if (raw == null || raw.isBlank()) { + return; + } + try { + JsonNode parsed = objectMapper.readTree(raw); + root.set("classification", parsed); + } catch (RuntimeException e) { + // Written by another tool; if it is not JSON, pass it through as text rather than drop + // it - the receiving system may still recognise it. + root.put("classification", raw); + } + } + + /** The Purview label already on the document, if any. */ + private static void addSensitivityLabel(ObjectNode root, PDDocument pdf) { + List labels = PdfSensitivityLabels.readAll(pdf); + if (labels.isEmpty()) { + return; + } + SensitivityLabel label = labels.get(0); + ObjectNode node = root.putObject("sensitivityLabel"); + node.put("labelId", label.labelId()); + node.put("name", label.name()); + node.put("siteId", label.siteId()); + node.put("method", label.method() == null ? null : label.method().name()); + node.put("protected", label.isProtected()); + } + + /** Cheap check so a non-PDF never pays for a parse attempt. */ + private static boolean looksLikePdf(byte[] content) { + return content.length > 4 + && content[0] == '%' + && content[1] == 'P' + && content[2] == 'D' + && content[3] == 'F'; + } + + /** + * A content hash is the field external systems most often key on - dedupe, chain-of-custody, + * "have I already scanned this" - and they cannot compute it without the bytes we are sending. + */ + private static String sha256(byte[] content) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(content)); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 is required by the Java platform", e); + } + } + + private static String toIso(Calendar calendar) { + return calendar == null ? null : calendar.toInstant().toString(); + } + + private static String extensionOf(String filename) { + if (filename == null) { + return null; + } + int dot = filename.lastIndexOf('.'); + return dot < 0 || dot == filename.length() - 1 + ? null + : filename.substring(dot + 1).toLowerCase(Locale.ROOT); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ExternalApiCallController.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ExternalApiCallController.java new file mode 100644 index 0000000000..499ab90fdb --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ExternalApiCallController.java @@ -0,0 +1,552 @@ +package stirling.software.proprietary.integration.api; + +import java.io.IOException; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import io.github.pixee.security.Filenames; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.service.AutomationRunContext; +import stirling.software.common.service.InternalApiClient; +import stirling.software.common.util.TempFileManager; +import stirling.software.proprietary.service.AiToolResponseHeaders; + +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ObjectNode; + +/** + * Posts the document flowing through a policy to a third-party HTTP API and folds the answer back + * into the run. + * + *

This is the generic integration step: rather than a bespoke connector per vendor, an operator + * defines an {@code API} connection (base URL + credentials) once and any policy can call a path + * under it. The connection owns the host and the credentials; the step owns only the path and the + * form fields, so a policy author can never aim the call somewhere else or read the secret. + * + *

Response handling is explicit rather than inferred, because the two useful behaviours destroy + * different things when guessed wrong: + * + *

    + *
  • {@code report} (default) - the document continues untouched and the API's answer rides + * along in {@link AiToolResponseHeaders#TOOL_REPORT}. For call-outs that inspect or notify. A + * {@code requireTrue} field turns the answer into a gate: the named JSON verdict must be true + * or the step fails, so a scanner's "not clean" actually stops the run. + *
  • {@code replace} - the response body becomes the document. For call-outs that + * transform. Fails loudly if the API returns JSON or an empty body, instead of silently + * dropping the document from the pipeline. + *
+ */ +@Slf4j +@RestController +@RequestMapping("/api/v1/integration") +@RequiredArgsConstructor +@Tag(name = "Integrations", description = "Third-party integration steps.") +public class ExternalApiCallController { + + static final String MODE_REPORT = "report"; + static final String MODE_REPLACE = "replace"; + + /** + * The report travels as an HTTP header, and Jetty caps a response header at 8KB by default. A + * body larger than this is summarised rather than risking a header the container refuses to + * write - which would fail the whole step over a merely verbose API. + */ + static final int MAX_REPORT_BODY_CHARS = 4096; + + static final String BODY_MULTIPART = "multipart"; + static final String BODY_JSON = "json"; + static final String BODY_BINARY = "binary"; + + /** Field (multipart) and property (json) the auto-populated context is offered under. */ + static final String CONTEXT_FIELD = "stirlingContext"; + + private final ApiConnectionResolver connectionResolver; + private final ExternalApiCaller caller; + private final ObjectMapper objectMapper; + private final TempFileManager tempFileManager; + private final ApplicationProperties applicationProperties; + + @PostMapping(value = "/external-api-call", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation( + summary = "Send the document to an external API", + description = + "Sends the document to a path under a stored API connection's base URL and" + + " either records the response as a step report or replaces the" + + " document with it. Fields, path and headers may reference" + + " {{document.*}}, {{classification.*}}, {{sensitivityLabel.*}} and" + + " {{run.*}}. Type:SISO") + public ResponseEntity call( + @RequestParam("fileInput") MultipartFile fileInput, + @RequestParam("connectionId") String connectionId, + @RequestParam(value = "path", required = false) String path, + @RequestParam(value = "method", defaultValue = "POST") String method, + @RequestParam(value = "bodyMode", defaultValue = BODY_MULTIPART) String bodyMode, + @RequestParam(value = "fileFieldName", defaultValue = "file") String fileFieldName, + @RequestParam(value = "responseMode", defaultValue = MODE_REPORT) String responseMode, + @RequestParam(value = "resultUrlPath", required = false) String resultUrlPath, + @RequestParam(value = "resultUrlHeader", required = false) String resultUrlHeader, + @RequestParam(value = "responseSelect", required = false) String responseSelect, + @RequestParam(value = "requireTrue", required = false) String requireTrue, + @RequestParam(value = "fields", required = false) String fields, + @RequestParam(value = "bodyTemplate", required = false) String bodyTemplate, + @RequestParam(value = "headers", required = false) String headers, + @RequestParam(value = "includeContext", defaultValue = "false") boolean includeContext, + @RequestParam(value = "includeFile", defaultValue = "true") boolean includeFile, + @RequestHeader(value = InternalApiClient.POLICY_NAME_HEADER, required = false) + String policyName, + @RequestHeader(value = AutomationRunContext.RUN_ID_HEADER, required = false) + String runId) + throws IOException { + + String mode = normalise(responseMode, MODE_REPORT, MODE_REPORT, MODE_REPLACE); + String body = normalise(bodyMode, BODY_MULTIPART, BODY_MULTIPART, BODY_JSON, BODY_BINARY); + String verb = parseMethod(method); + + Long id = ApiConnectionResolver.connectionId(connectionId); + if (id == null) { + throw new IllegalArgumentException("'connectionId' is required"); + } + ApiConnectionSettings settings = connectionResolver.resolve(id); + + String filename = safeFileName(fileInput.getOriginalFilename()); + String contentType = + fileInput.getContentType() == null + ? MediaType.APPLICATION_OCTET_STREAM_VALUE + : fileInput.getContentType(); + byte[] content = fileInput.getBytes(); + + ObjectNode context = + DocumentContext.build(fileInput, content, policyName, runId, objectMapper); + + ExternalApiCaller.Response response = + caller.dispatch( + settings, + verb, + Placeholders.resolve(path, context, Placeholders.Escaping.URL_PATH), + buildBody( + body, + bodyTemplate, + includeFile, + includeContext, + context, + fileFieldName, + filename, + contentType, + content, + resolveAll(parseJsonObject(fields, "fields"), context)), + validatedHeaders(resolveAll(parseJsonObject(headers, "headers"), context))); + + if (!response.isSuccess()) { + // Fail the step: a policy that silently continued past a rejected call-out would + // deliver documents the external system believes it never approved. + throw new IOException( + "External API returned HTTP " + response.status() + summarise(response)); + } + + enforceVerdict(response, requireTrue); + + return MODE_REPLACE.equals(mode) + ? replaceDocument( + settings, + response, + filename, + resultUrlPath, + resultUrlHeader, + responseSelect) + : reportOnly(fileInput, filename, contentType, response); + } + + /** + * Assemble the outbound body. + * + *
    + *
  • {@code multipart} - the file plus form fields, what most upload APIs expect. + *
  • {@code json} - a JSON object of the fields, with the context merged in and the file + * base64'd under {@code content}. For APIs that take a document as JSON, and for + * notify-style call-outs (with {@code includeFile=false}) that want the facts only. + *
  • {@code binary} - the raw bytes as the body. For APIs that want the file and nothing + * else; fields would have nowhere to go, so they are refused rather than dropped. + *
+ */ + private ExternalApiCaller.Body buildBody( + String bodyMode, + String bodyTemplate, + boolean includeFile, + boolean includeContext, + ObjectNode context, + String fileFieldName, + String filename, + String contentType, + byte[] content, + Map fields) + throws IOException { + + if (bodyTemplate != null && !bodyTemplate.isBlank()) { + return templatedBody(bodyTemplate, context, filename, contentType, content); + } + switch (bodyMode) { + case BODY_BINARY -> { + if (!fields.isEmpty()) { + throw new IllegalArgumentException( + "bodyMode 'binary' sends only the document, so 'fields' cannot be" + + " sent; use 'headers' instead, or bodyMode 'multipart'."); + } + if (!includeFile) { + throw new IllegalArgumentException( + "bodyMode 'binary' with includeFile=false would send an empty body"); + } + return ExternalApiCaller.raw(contentType, content); + } + case BODY_JSON -> { + ObjectNode json = objectMapper.createObjectNode(); + fields.forEach(json::put); + if (includeContext) { + json.setAll(context); + } + if (includeFile) { + json.put("filename", filename); + json.put("contentType", contentType); + json.put("content", Base64.getEncoder().encodeToString(content)); + } + return ExternalApiCaller.raw( + MediaType.APPLICATION_JSON_VALUE, objectMapper.writeValueAsBytes(json)); + } + default -> { + Map all = new LinkedHashMap<>(fields); + if (includeContext) { + all.put(CONTEXT_FIELD, objectMapper.writeValueAsString(context)); + } + if (!includeFile) { + // Fields-only multipart: a notify-style call-out that wants the facts, not + // the document. + MultipartBody body = new MultipartBody(); + body.addFields(all); + return new ExternalApiCaller.Body(body.contentType(), body.build()); + } + return ExternalApiCaller.multipart( + fileFieldName, filename, contentType, content, all); + } + } + } + + /** + * A caller-shaped JSON body: the template is resolved against the context, so an arbitrary + * vendor payload can be expressed as config. {@code {{document.base64}}} carries the file + * itself, which is how APIs that take a document nested inside a JSON document are reached. + * + *

The base64 is added to a copy of the context rather than the context proper: it is the + * size of the file, and {@code stirlingContext} must not silently grow by a whole document. + */ + private ExternalApiCaller.Body templatedBody( + String bodyTemplate, + ObjectNode context, + String filename, + String contentType, + byte[] content) + throws IOException { + JsonNode template; + try { + template = objectMapper.readTree(bodyTemplate); + } catch (Exception e) { + throw new IllegalArgumentException("api step 'bodyTemplate' must be valid JSON", e); + } + ObjectNode withFile = context.deepCopy(); + ObjectNode document = (ObjectNode) withFile.get("document"); + if (document != null) { + document.put("base64", Base64.getEncoder().encodeToString(content)); + document.put("safeFilename", filename); + document.put("resolvedContentType", contentType); + } + JsonNode resolved = Placeholders.resolveTree(template, withFile); + return ExternalApiCaller.raw( + MediaType.APPLICATION_JSON_VALUE, objectMapper.writeValueAsBytes(resolved)); + } + + /** Resolve every value's placeholders against the context. */ + private Map resolveAll(Map values, ObjectNode context) { + Map out = new LinkedHashMap<>(); + values.forEach( + (key, value) -> + out.put( + key, + Placeholders.resolve(value, context, Placeholders.Escaping.NONE))); + return out; + } + + /** Per-step headers, held to the same rules as a connection's static headers. */ + private Map validatedHeaders(Map headers) { + headers.forEach( + (name, value) -> { + if (!ExternalApiHeaders.isValidName(name)) { + throw new IllegalArgumentException( + "api step 'headers' has an invalid header name: " + name); + } + if (ExternalApiHeaders.isReserved(name)) { + throw new IllegalArgumentException( + "api step 'headers' must not set '" + + name + + "'; it is set by the connection or the client"); + } + if (!ExternalApiHeaders.isValidValue(value)) { + // A resolved placeholder could carry a newline out of document metadata. + throw new IllegalArgumentException( + "api step 'headers' has an invalid value for '" + name + "'"); + } + }); + return headers; + } + + private static String parseMethod(String method) { + String verb = method == null ? "POST" : method.trim().toUpperCase(Locale.ROOT); + // Only the verbs that carry a body; GET/DELETE would silently drop the document. + if (!List.of("POST", "PUT", "PATCH").contains(verb)) { + throw new IllegalArgumentException( + "'method' must be POST, PUT or PATCH; got " + method); + } + return verb; + } + + private static String normalise(String value, String fallback, String... allowed) { + String out = + value == null || value.isBlank() ? fallback : value.trim().toLowerCase(Locale.ROOT); + if (!List.of(allowed).contains(out)) { + throw new IllegalArgumentException( + "must be one of " + String.join(", ", allowed) + "; got " + value); + } + return out; + } + + /** + * Turn the response into the document that continues down the pipeline. + * + *

Three shapes of answer are accepted, because real APIs use all three: the document inline, + * a URL to fetch it from, or an archive to pick it out of. Anything else fails the step rather + * than putting a non-document into the pipeline for a later step to trip over. + */ + private ResponseEntity replaceDocument( + ApiConnectionSettings settings, + ExternalApiCaller.Response response, + String requestFilename, + String resultUrlPath, + String resultUrlHeader, + String responseSelect) + throws IOException { + + ExternalApiCaller.Response payload = response; + boolean followed = false; + String url = resultUrl(response, resultUrlPath, resultUrlHeader); + if (url != null) { + // The URL came out of the response, so ResultUrls decides whether it may be fetched. + payload = + caller.getResult( + settings, ResultUrls.validate(settings, url, applicationProperties)); + followed = true; + if (!payload.isSuccess()) { + throw new IOException( + "Fetching the API's result URL returned HTTP " + payload.status()); + } + } + + if (payload.body().length == 0) { + throw new IOException( + "External API returned an empty body, so there is no document to replace with;" + + " use responseMode=report to keep the original."); + } + if (payload.isJson() && !followed) { + throw new IOException( + "External API returned JSON, which cannot replace the document. Use" + + " responseMode=report to keep the original and record the answer, or" + + " set resultUrlPath if the JSON points at the document."); + } + + String filename = ResultFiles.nameFor(payload, requestFilename); + Resource result = ResultFiles.asResource(payload.body(), filename); + + if (ResultFiles.isArchive(result)) { + if (responseSelect == null || responseSelect.isBlank()) { + // Handing a .zip to a step that expects a PDF fails later and more obscurely. + throw new IOException( + "External API returned an archive; set 'responseSelect' (e.g. *.pdf, or an" + + " index) to say which entry becomes the document."); + } + result = ResultFiles.selectFromArchive(result, responseSelect, tempFileManager); + filename = result.getFilename(); + } else if (responseSelect != null && !responseSelect.isBlank()) { + throw new IOException( + "'responseSelect' was set but the API returned a single file, not an archive"); + } + + MediaType type = + payload.contentType() == null || ResultFiles.isArchiveName(filename) + ? MediaType.APPLICATION_OCTET_STREAM + : MediaType.parseMediaType(payload.contentType().split(";")[0].trim()); + return ResponseEntity.ok() + .contentType(type) + .header( + HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=\"" + filename + "\"") + .body(result); + } + + /** The result URL the API pointed at, from the body or a header; null when neither is set. */ + private String resultUrl( + ExternalApiCaller.Response response, String resultUrlPath, String resultUrlHeader) { + if (resultUrlHeader != null && !resultUrlHeader.isBlank()) { + String value = response.header(resultUrlHeader.trim()); + if (value == null || value.isBlank()) { + throw new IllegalArgumentException( + "'resultUrlHeader' names '" + + resultUrlHeader + + "' but the response had no such header"); + } + return value; + } + if (resultUrlPath == null || resultUrlPath.isBlank()) { + return null; + } + JsonNode node = response.bodyAsJson(objectMapper); + for (String segment : resultUrlPath.trim().split("\\.")) { + if (node == null) { + break; + } + node = node.get(segment); + } + if (node == null || !node.isValueNode() || node.asString().isBlank()) { + throw new IllegalArgumentException( + "'resultUrlPath' found no URL at '" + resultUrlPath + "' in the response"); + } + return node.asString(); + } + + /** + * Gate the run on a boolean verdict in the API's JSON answer (e.g. Cloudmersive's {@code + * CleanResult}). When {@code requireTrue} names a field - dotted for a nested one - that field + * must be JSON {@code true}, or the step fails so the document is parked rather than delivered. + * Fail-closed: a missing field, a non-boolean, a false, or a non-JSON body all stop the run. + * This is what makes a scanner's "not clean" actually stop the pipeline. + */ + private void enforceVerdict(ExternalApiCaller.Response response, String requireTrue) + throws IOException { + if (requireTrue == null || requireTrue.isBlank()) { + return; + } + JsonNode node = response.isJson() ? response.bodyAsJson(objectMapper) : null; + for (String segment : requireTrue.trim().split("\\.")) { + if (node == null) { + break; + } + node = node.get(segment); + } + if (node == null || !node.asBoolean(false)) { + throw new IOException( + "External API verdict '" + + requireTrue.trim() + + "' was not true" + + summarise(response) + + "; the document was not approved, so the run was stopped."); + } + } + + /** The document passes through; the API's answer rides in the report header. */ + private ResponseEntity reportOnly( + MultipartFile fileInput, + String filename, + String contentType, + ExternalApiCaller.Response response) + throws IOException { + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType(contentType)) + .header( + HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=\"" + filename + "\"") + .header(AiToolResponseHeaders.TOOL_REPORT, buildReport(response)) + .body(new ByteArrayResource(fileInput.getBytes())); + } + + /** A JSON object describing the call, small enough to survive as a header. */ + private String buildReport(ExternalApiCaller.Response response) { + ObjectNode report = objectMapper.createObjectNode(); + report.put("status", response.status()); + report.put("contentType", response.contentType()); + if (response.isJson()) { + try { + JsonNode parsed = objectMapper.readTree(response.bodyAsText()); + String rendered = objectMapper.writeValueAsString(parsed); + if (rendered.length() <= MAX_REPORT_BODY_CHARS) { + report.set("body", parsed); + } else { + report.put("bodyTruncated", true); + report.put("body", rendered.substring(0, MAX_REPORT_BODY_CHARS)); + } + } catch (Exception e) { + // Content-Type said JSON but the body is not; keep the step alive and say so. + report.put("bodyParseError", e.getMessage()); + report.put("body", truncate(response.bodyAsText())); + } + } else { + report.put("bodyBytes", response.body().length); + } + return objectMapper.writeValueAsString(report); + } + + /** A JSON object of string values, e.g. {@code {"policy":"strict"}}. */ + private Map parseJsonObject(String json, String what) { + if (json == null || json.isBlank()) { + return Map.of(); + } + Map raw; + try { + raw = + objectMapper.readValue( + json, new TypeReference>() {}); + } catch (Exception e) { + throw new IllegalArgumentException("api step '" + what + "' must be a JSON object", e); + } + Map out = new LinkedHashMap<>(); + raw.forEach((key, value) -> out.put(key, value == null ? "" : value.toString())); + return out; + } + + private String summarise(ExternalApiCaller.Response response) { + String text = truncate(response.bodyAsText()); + return text.isBlank() ? "" : ": " + text; + } + + private static String truncate(String text) { + if (text == null) { + return ""; + } + String oneLine = text.replaceAll("\\s+", " ").trim(); + return oneLine.length() <= MAX_REPORT_BODY_CHARS + ? oneLine + : oneLine.substring(0, MAX_REPORT_BODY_CHARS) + "…"; + } + + private static String safeFileName(String originalFilename) { + String name = Filenames.toSimpleFileName(originalFilename); + return (name == null || name.isBlank()) ? "document" : name; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ExternalApiCaller.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ExternalApiCaller.java new file mode 100644 index 0000000000..67171bb5f5 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ExternalApiCaller.java @@ -0,0 +1,299 @@ +package stirling.software.proprietary.integration.api; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.model.ApplicationProperties; + +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; + +/** + * Performs the outbound call for an {@code API} connection. + * + *

Follows the established self-hosted outbound pattern (JDK {@link HttpClient}; see {@code + * AccountLinkClient}): the client is injectable so tests can drive a real local server without + * reaching the network. + */ +@Slf4j +@Service +public class ExternalApiCaller { + + /** + * Cap on a response we will read into memory. An external API returning something enormous is a + * misconfiguration, and without a cap it would be a trivial way to OOM the server. + */ + static final int MAX_RESPONSE_BYTES = 64 * 1024 * 1024; + + private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(10); + + private final HttpClient httpClient; + private final ApplicationProperties applicationProperties; + private final ApiTokenCache tokenCache; + + @Autowired + public ExternalApiCaller( + ApplicationProperties applicationProperties, ObjectMapper objectMapper) { + this( + HttpClient.newBuilder() + .connectTimeout(CONNECT_TIMEOUT) + // Following a redirect would re-target the request at a host the base URL + // never authorised, undoing ExternalApiPaths. Let the caller see the 3xx. + .followRedirects(HttpClient.Redirect.NEVER) + .build(), + applicationProperties, + objectMapper); + } + + ExternalApiCaller( + HttpClient httpClient, + ApplicationProperties applicationProperties, + ObjectMapper objectMapper) { + this.httpClient = httpClient; + this.applicationProperties = applicationProperties; + this.tokenCache = new ApiTokenCache(httpClient, objectMapper); + } + + /** What the external API sent back, before the step decides what to do with it. */ + public record Response( + int status, String contentType, byte[] body, Map headers) { + + public Response { + headers = headers == null ? Map.of() : Map.copyOf(headers); + } + + /** A response header by name, case-insensitively; null when absent. */ + public String header(String name) { + for (Map.Entry entry : headers.entrySet()) { + if (entry.getKey().equalsIgnoreCase(name)) { + return entry.getValue(); + } + } + return null; + } + + JsonNode bodyAsJson(ObjectMapper objectMapper) { + try { + return objectMapper.readTree(bodyAsText()); + } catch (RuntimeException e) { + return null; + } + } + + public boolean isSuccess() { + return status >= 200 && status < 300; + } + + public boolean isJson() { + return contentType != null && contentType.toLowerCase().contains("json"); + } + + public String bodyAsText() { + return new String(body, StandardCharsets.UTF_8); + } + } + + /** + * POST a document to {@code path} under the connection's base URL as multipart/form-data. + * + * @throws IOException on transport failure or an oversized response + */ + public Response postFile( + ApiConnectionSettings settings, + String path, + String fileFieldName, + String filename, + String fileContentType, + byte[] content, + Map fields) + throws IOException { + return dispatch( + settings, + "POST", + path, + multipart(fileFieldName, filename, fileContentType, content, fields), + Map.of()); + } + + /** A request body plus the Content-Type that describes it. */ + record Body(String contentType, HttpRequest.BodyPublisher publisher) {} + + static Body multipart( + String fileFieldName, + String filename, + String fileContentType, + byte[] content, + Map fields) + throws IOException { + MultipartBody body = new MultipartBody(); + body.addFields(fields); + body.addFile(fileFieldName, filename, fileContentType, content); + return new Body(body.contentType(), body.build()); + } + + /** A body of caller-built bytes, e.g. a JSON document or the raw file. */ + static Body raw(String contentType, byte[] content) { + return new Body(contentType, HttpRequest.BodyPublishers.ofByteArray(content)); + } + + /** + * Send {@code body} to {@code path} under the connection's base URL. + * + * @param method POST, PUT or PATCH - the verbs that carry a body + * @param extraHeaders per-step headers, already validated by the caller + */ + public Response dispatch( + ApiConnectionSettings settings, + String method, + String path, + Body body, + Map extraHeaders) + throws IOException { + + URI target = ExternalApiPaths.resolve(settings.baseUri(), path); + // Re-check at dispatch: save-time validation cannot see a DNS record re-pointed at a + // private address afterwards. + ApiIntegrationValidator.requirePublicHost( + settings, applicationProperties, "API connection base URL"); + + Response response = attempt(settings, method, target, body, extraHeaders); + if (response.status() == 401 && settings.authType() == ApiAuthType.TOKEN_LOGIN) { + // The cached token was rejected - expired early, or revoked. One fresh login and + // one retry; if that also 401s the credentials are wrong and the step says so. + log.debug("[external-api] token rejected by {}; re-authenticating", target.getHost()); + tokenCache.invalidate(settings); + response = attempt(settings, method, target, body, extraHeaders); + } + return response; + } + + private Response attempt( + ApiConnectionSettings settings, + String method, + URI target, + Body body, + Map extraHeaders) + throws IOException { + HttpRequest.Builder request = + HttpRequest.newBuilder(target) + .timeout(Duration.ofSeconds(settings.timeoutSeconds())) + .header("Content-Type", body.contentType()) + .method(method, body.publisher()); + applyHeaders(request, settings); + // Step headers last so a step can override a connection default, but never the auth + // header: ExternalApiHeaders rejects reserved names before we get here. + extraHeaders.forEach(request::header); + return send(httpClient, request.build(), target); + } + + /** + * GET an absolute result URL the API pointed us at. + * + *

Takes a {@link URI} rather than a string so it cannot be called with something unchecked: + * the only way to obtain one is {@link ResultUrls#validate}, which is where the host allowlist + * lives. Credentials are deliberately not sent - the URL is usually a presigned link on another + * host, and forwarding the connection's token there would leak it to a third party. + */ + public Response getResult(ApiConnectionSettings settings, URI target) throws IOException { + HttpRequest request = + HttpRequest.newBuilder(target) + .timeout(Duration.ofSeconds(settings.timeoutSeconds())) + .GET() + .build(); + return send(httpClient, request, target); + } + + /** GET {@code path} under the connection's base URL. */ + public Response get(ApiConnectionSettings settings, String path) throws IOException { + URI target = ExternalApiPaths.resolve(settings.baseUri(), path); + ApiIntegrationValidator.requirePublicHost( + settings, applicationProperties, "API connection base URL"); + + HttpRequest.Builder request = + HttpRequest.newBuilder(target) + .timeout(Duration.ofSeconds(settings.timeoutSeconds())) + .GET(); + applyHeaders(request, settings); + return send(httpClient, request.build(), target); + } + + static Response send(HttpClient httpClient, HttpRequest request, URI target) + throws IOException { + HttpResponse response; + try { + response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted calling " + safeTarget(target), e); + } catch (IOException e) { + // The message can carry the host but never the credentials, which live in headers. + throw new IOException( + "Failed to call " + safeTarget(target) + ": " + e.getMessage(), e); + } + byte[] body = response.body() == null ? new byte[0] : response.body(); + if (body.length > MAX_RESPONSE_BYTES) { + throw new IOException( + "Response from " + + safeTarget(target) + + " exceeds the " + + MAX_RESPONSE_BYTES + + " byte limit"); + } + String contentType = response.headers().firstValue("content-type").orElse(null); + Map headers = new LinkedHashMap<>(); + response.headers() + .map() + .forEach((name, values) -> headers.put(name, String.join(", ", values))); + log.debug("[external-api] {} -> HTTP {}", safeTarget(target), response.statusCode()); + return new Response(response.statusCode(), contentType, body, headers); + } + + private void applyHeaders(HttpRequest.Builder request, ApiConnectionSettings settings) + throws IOException { + settings.headers().forEach(request::header); + switch (settings.authType()) { + case BEARER -> request.header("Authorization", "Bearer " + settings.token()); + case HEADER -> + request.header( + settings.headerName(), + settings.headerPrefix() == null + ? settings.token() + : settings.headerPrefix() + " " + settings.token()); + case BASIC -> + request.header( + "Authorization", + "Basic " + + Base64.getEncoder() + .encodeToString( + (settings.username() + + ":" + + settings.password()) + .getBytes(StandardCharsets.UTF_8))); + case TOKEN_LOGIN -> { + Map.Entry auth = tokenCache.authHeader(settings); + request.header(auth.getKey(), auth.getValue()); + } + case NONE -> { + /* no credentials */ + } + } + } + + /** Scheme, host and path only: a query string could carry a token an operator put there. */ + private static String safeTarget(URI target) { + return target.getScheme() + "://" + target.getAuthority() + target.getPath(); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ExternalApiHeaders.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ExternalApiHeaders.java new file mode 100644 index 0000000000..055101e381 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ExternalApiHeaders.java @@ -0,0 +1,72 @@ +package stirling.software.proprietary.integration.api; + +import java.util.Locale; +import java.util.Set; + +/** + * Validation for operator-supplied HTTP header names and values. + * + *

Header values reach the wire verbatim, so a value carrying CR/LF could splice extra headers - + * or a whole second request - into the stream. Names and values are therefore checked against the + * RFC 7230 grammar rather than trusted. + */ +public final class ExternalApiHeaders { + + /** + * Headers a connection may not set as a static header. Authentication has exactly one path + * ({@code authType} + {@code token}) so credentials cannot be smuggled in as a "static" header + * that bypasses the auth validation; the rest are framing headers owned by the HTTP client, + * where a caller-set value would contradict the body actually sent. + */ + private static final Set RESERVED = + Set.of( + "authorization", + "proxy-authorization", + "host", + "content-length", + "transfer-encoding", + "connection", + "upgrade", + "expect"); + + private ExternalApiHeaders() {} + + /** RFC 7230 {@code token}: the only characters legal in a header name. */ + public static boolean isValidName(String name) { + if (name == null || name.isEmpty()) { + return false; + } + for (int i = 0; i < name.length(); i++) { + if (!isTokenChar(name.charAt(i))) { + return false; + } + } + return true; + } + + /** Visible ASCII, space and horizontal tab. Excludes CR/LF and NUL, which would inject. */ + public static boolean isValidValue(String value) { + if (value == null) { + return false; + } + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + boolean printable = c >= 0x20 && c <= 0x7E; + if (!printable && c != '\t') { + return false; + } + } + return true; + } + + public static boolean isReserved(String name) { + return name != null && RESERVED.contains(name.toLowerCase(Locale.ROOT)); + } + + private static boolean isTokenChar(char c) { + return (c >= 'a' && c <= 'z') + || (c >= 'A' && c <= 'Z') + || (c >= '0' && c <= '9') + || "!#$%&'*+-.^_`|~".indexOf(c) >= 0; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ExternalApiPaths.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ExternalApiPaths.java new file mode 100644 index 0000000000..36ca00816c --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ExternalApiPaths.java @@ -0,0 +1,120 @@ +package stirling.software.proprietary.integration.api; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Locale; + +/** + * Resolves a step-supplied relative path under a connection's operator-set base URL. + * + *

This is the control that keeps the external-API step from being an SSRF primitive. The base + * URL comes from an {@code IntegrationConfig} only someone with manage rights can edit; the path + * comes from a pipeline step, which is a far weaker trust boundary. Everything here exists to + * guarantee that a path can address a resource under the base and nothing else. + * + *

{@link URI#resolve} is deliberately not used: resolving the protocol-relative {@code + * //evil.example} against {@code https://api.example.com/v1} yields {@code https://evil.example}, + * silently changing host. Instead the path is screened, appended textually, normalised, and then + * the result is re-checked against the base - so a miss in the screen is still caught by the check. + */ +public final class ExternalApiPaths { + + private ExternalApiPaths() {} + + /** + * @param base the connection's base URL, already validated as http(s) with a host + * @param path a relative path, optionally with a query string; blank means the base itself + * @throws IllegalArgumentException if the path is absolute, escapes the base, or carries + * characters that could split the request line + */ + public static URI resolve(URI base, String path) { + if (path == null || path.isBlank()) { + return base; + } + String candidate = path.trim(); + screen(candidate); + + if (!candidate.startsWith("/")) { + candidate = "/" + candidate; + } + + URI resolved; + try { + resolved = new URI(base + candidate).normalize(); + } catch (URISyntaxException e) { + throw new IllegalArgumentException( + "api step 'path' is not a valid URL path: " + path, e); + } + requireSameOrigin(base, resolved, path); + requireUnderBasePath(base, resolved, path); + return resolved; + } + + /** Reject the shapes that could retarget the request before it is even assembled. */ + private static void screen(String path) { + if (path.contains("://") || path.startsWith("//")) { + throw new IllegalArgumentException( + "api step 'path' must be relative to the connection's base URL, not an" + + " absolute or protocol-relative URL: " + + path); + } + for (int i = 0; i < path.length(); i++) { + char c = path.charAt(i); + // Control characters and spaces can split the request line; a backslash is normalised + // to '/' by some servers and would sidestep the traversal check below. + if (c <= 0x20 || c == 0x7F || c == '\\') { + throw new IllegalArgumentException( + "api step 'path' contains an illegal character: " + path); + } + } + if (path.indexOf('#') >= 0) { + throw new IllegalArgumentException( + "api step 'path' must not contain a fragment: " + path); + } + // Percent-encoded dots would survive the normalise() below and be decoded by the target, so + // a traversal must not be smuggled past us in encoded form. + // + // Only dots are rejected. An encoded slash or backslash is legitimate: Placeholders encodes + // substituted values, so a filename containing '/' arrives here as %2F, where it is data + // inside one segment rather than structure. Rejecting those would refuse ordinary filenames + // while doing nothing for traversal, which needs the dots. + String lower = path.toLowerCase(Locale.ROOT); + if (lower.contains("%2e")) { + throw new IllegalArgumentException( + "api step 'path' must not percent-encode dots: " + path); + } + } + + private static void requireSameOrigin(URI base, URI resolved, String original) { + boolean sameOrigin = + equalsIgnoreCase(base.getScheme(), resolved.getScheme()) + && equalsIgnoreCase(base.getHost(), resolved.getHost()) + && base.getPort() == resolved.getPort() + && resolved.getUserInfo() == null; + if (!sameOrigin) { + throw new IllegalArgumentException( + "api step 'path' would change the target host; it must stay under the" + + " connection's base URL: " + + original); + } + } + + private static void requireUnderBasePath(URI base, URI resolved, String original) { + String basePath = base.getPath() == null ? "" : base.getPath(); + String resolvedPath = resolved.getPath() == null ? "" : resolved.getPath(); + // The base URL has its trailing slash stripped at parse time, so a base path of "/v1" + // must match "/v1" exactly or be followed by a separator - never "/v1betray". + boolean under = + basePath.isEmpty() + || resolvedPath.equals(basePath) + || resolvedPath.startsWith(basePath + "/"); + if (!under) { + throw new IllegalArgumentException( + "api step 'path' escapes the connection's base path: " + original); + } + } + + private static boolean equalsIgnoreCase(String a, String b) { + return a == null ? b == null : a.equalsIgnoreCase(b); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/IntegrationStepValidator.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/IntegrationStepValidator.java new file mode 100644 index 0000000000..6118f3b642 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/IntegrationStepValidator.java @@ -0,0 +1,68 @@ +package stirling.software.proprietary.integration.api; + +import java.util.Map; + +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; + +import stirling.software.proprietary.integration.model.IntegrationType; +import stirling.software.proprietary.policy.engine.PipelineStepValidator; +import stirling.software.proprietary.policy.model.PipelineStep; + +/** + * Authorization-checks the {@code connectionId} of any integration step, on the request thread. + * + *

This is what stops an integration step being a confused deputy. A step names a connection by + * id, and the worker thread that runs it has no principal - so {@link ApiConnectionResolver} lets + * the lookup through unchecked there, exactly as the S3 resolver does. Without this validator a + * caller could put any id in a step and have the server dial that tenant's endpoint with that + * tenant's stored credentials. Resolving here, while the caller is still on the thread, forces the + * ownership check to run. + * + *

Registered as a {@link PipelineStepValidator} so both entry points cover it: save-time + * validation of a stored policy, and {@code PolicyController}'s ad-hoc gate. + */ +@Component +@RequiredArgsConstructor +public class IntegrationStepValidator implements PipelineStepValidator { + + static final String CONNECTION_ID_PARAM = "connectionId"; + private static final String INTEGRATION_PREFIX = "/api/v1/integration/"; + + /** + * Which connection type each integration step dereferences. A step under {@link + * #INTEGRATION_PREFIX} that is absent here is rejected rather than waved through, so a new + * endpoint cannot quietly skip this check by forgetting to register. + */ + private static final Map STEP_CONNECTION_TYPES = + Map.of( + "/api/v1/integration/external-api-call", IntegrationType.API, + "/api/v1/integration/purview-apply-label", IntegrationType.PURVIEW, + "/api/v1/integration/purview-read-label", IntegrationType.PURVIEW, + "/api/v1/integration/consigno-submit", IntegrationType.CONSIGNO, + "/api/v1/integration/consigno-fetch-signed", IntegrationType.CONSIGNO); + + private final ApiConnectionResolver connectionResolver; + + @Override + public void validate(PipelineStep step) { + String operation = step.operation(); + if (operation == null || !operation.startsWith(INTEGRATION_PREFIX)) { + return; + } + IntegrationType type = STEP_CONNECTION_TYPES.get(operation); + if (type == null) { + throw new IllegalArgumentException("unknown integration step: " + operation); + } + Long connectionId = + ApiConnectionResolver.connectionId(step.parameters().get(CONNECTION_ID_PARAM)); + if (connectionId == null) { + throw new IllegalArgumentException( + operation + " requires a '" + CONNECTION_ID_PARAM + "' parameter"); + } + // Throws if the connection is missing, the wrong type, disabled, or not usable by the + // caller. The parsed settings are discarded: this call is the check. + connectionResolver.resolveConfig(connectionId, type); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/MultipartBody.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/MultipartBody.java new file mode 100644 index 0000000000..a848f8599c --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/MultipartBody.java @@ -0,0 +1,101 @@ +package stirling.software.proprietary.integration.api; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.http.HttpRequest; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.Map; + +/** + * Builds a {@code multipart/form-data} body for the JDK HTTP client, which has no multipart + * publisher of its own. + * + *

The body is assembled in memory. Callers bound the document size before getting here; the + * external-API step is for API-shaped payloads, not bulk transfer. + */ +final class MultipartBody { + + private final String boundary; + private final ByteArrayOutputStream out = new ByteArrayOutputStream(); + + MultipartBody() { + byte[] random = new byte[16]; + new SecureRandom().nextBytes(random); + this.boundary = + "StirlingBoundary" + Base64.getUrlEncoder().withoutPadding().encodeToString(random); + } + + String contentType() { + return "multipart/form-data; boundary=" + boundary; + } + + /** + * @throws IllegalArgumentException if the name could break out of its part header; + * names come from step parameters, so they are checked rather than trusted + */ + MultipartBody addField(String name, String value) throws IOException { + requireSafe(name, "field name"); + writeAscii("--" + boundary + "\r\n"); + writeAscii("Content-Disposition: form-data; name=\"" + name + "\"\r\n\r\n"); + // The value is body, not header: quotes, newlines and backslashes are ordinary data here + // and must survive untouched. Checking it like a header rejected every JSON value - which + // is most of them, the auto-populated context included. + out.write(value.getBytes(StandardCharsets.UTF_8)); + writeAscii("\r\n"); + return this; + } + + MultipartBody addFile(String name, String filename, String contentType, byte[] content) + throws IOException { + requireSafe(name, "file field name"); + requireSafe(filename, "filename"); + writeAscii("--" + boundary + "\r\n"); + writeAscii( + "Content-Disposition: form-data; name=\"" + + name + + "\"; filename=\"" + + filename + + "\"\r\n"); + writeAscii("Content-Type: " + contentType + "\r\n\r\n"); + out.write(content); + writeAscii("\r\n"); + return this; + } + + HttpRequest.BodyPublisher build() throws IOException { + writeAscii("--" + boundary + "--\r\n"); + return HttpRequest.BodyPublishers.ofByteArray(out.toByteArray()); + } + + MultipartBody addFields(Map fields) throws IOException { + for (Map.Entry entry : fields.entrySet()) { + addField(entry.getKey(), entry.getValue()); + } + return this; + } + + /** + * A quote, CR, LF or backslash in a part header - a field name or filename - would let + * it close the quoted string and forge headers of its own. Values are not checked: they are + * body, and the boundary that delimits them is 16 random bytes minted per request, so a value + * cannot end its own part. + */ + private static void requireSafe(String value, String what) { + if (value == null) { + throw new IllegalArgumentException("api step " + what + " must not be null"); + } + if (value.indexOf('"') >= 0 + || value.indexOf('\r') >= 0 + || value.indexOf('\n') >= 0 + || value.indexOf('\\') >= 0) { + throw new IllegalArgumentException( + "api step " + what + " contains an illegal character: " + value); + } + } + + private void writeAscii(String text) throws IOException { + out.write(text.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/Placeholders.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/Placeholders.java new file mode 100644 index 0000000000..7a8a3fade3 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/Placeholders.java @@ -0,0 +1,153 @@ +package stirling.software.proprietary.integration.api; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.node.ArrayNode; +import tools.jackson.databind.node.ObjectNode; +import tools.jackson.databind.node.StringNode; + +/** + * Substitutes {@code {{dotted.path}}} references against the {@link DocumentContext}. + * + *

This is what lets one step satisfy APIs that disagree about payload shape. Rather than a + * connector per vendor, an operator writes the field names the vendor expects and fills them from + * context - {@code {"sha256": "{{document.sha256}}", "class": "{{sensitivityLabel.name}}"}}. + * + *

Deliberately not a template language: dotted lookup and nothing else. No expressions, no + * control flow, no method calls - a step definition is lower-trust than server config, and the + * whole point of a template engine (evaluating what it is given) is the thing to avoid here. + */ +final class Placeholders { + + private static final Pattern PLACEHOLDER = Pattern.compile("\\{\\{\\s*([\\w.]+)\\s*}}"); + + /** How a resolved value is escaped for the position it lands in. */ + enum Escaping { + /** Verbatim: form fields and header values, which are validated separately. */ + NONE, + /** Percent-encoded: a path segment, where a stray slash would change the target. */ + URL_PATH + } + + private Placeholders() {} + + /** + * @param template text that may contain {@code {{...}}} references; null passes through + * @param context the object to resolve against + * @throws IllegalArgumentException if a reference names something the context does not hold, so + * a typo surfaces as an error instead of silently sending an empty value + */ + static String resolve(String template, JsonNode context, Escaping escaping) { + if (template == null || template.isEmpty()) { + return template; + } + Matcher matcher = PLACEHOLDER.matcher(template); + StringBuilder out = new StringBuilder(); + while (matcher.find()) { + String path = matcher.group(1); + JsonNode value = lookup(context, path); + if (value == null || value.isMissingNode()) { + throw new IllegalArgumentException( + "unknown placeholder '{{" + + path + + "}}'; available: document.*, classification.*," + + " sensitivityLabel.*, run.*"); + } + matcher.appendReplacement(out, Matcher.quoteReplacement(render(value, escaping))); + } + matcher.appendTail(out); + return out.toString(); + } + + /** + * Resolve every string in a JSON tree, in place, leaving structure and non-strings alone. + * + *

This is what lets one step post an arbitrary vendor-shaped body - a nested {@code + * documents[0].data} as readily as a flat field - without a connector per vendor. + */ + static JsonNode resolveTree(JsonNode node, JsonNode context) { + if (node instanceof ObjectNode object) { + for (String name : new java.util.ArrayList<>(object.propertyNames())) { + object.set(name, resolveTree(object.get(name), context)); + } + return object; + } + if (node instanceof ArrayNode array) { + for (int i = 0; i < array.size(); i++) { + array.set(i, resolveTree(array.get(i), context)); + } + return array; + } + if (node != null && node.isString()) { + return StringNode.valueOf(resolve(node.asString(), context, Escaping.NONE)); + } + return node; + } + + /** Whether the text references anything at all, so callers can skip resolving. */ + static boolean hasPlaceholder(String text) { + return text != null && PLACEHOLDER.matcher(text).find(); + } + + private static JsonNode lookup(JsonNode context, String path) { + JsonNode node = context; + for (String segment : path.split("\\.")) { + if (node == null || !node.isObject()) { + return null; + } + node = node.get(segment); + } + return node; + } + + /** + * A null in context renders empty rather than the literal "null": absent metadata is a normal + * state, and "null" in a vendor's field would be a value, not an absence. + */ + private static String render(JsonNode value, Escaping escaping) { + String text; + if (value.isNull()) { + text = ""; + } else if (value.isValueNode()) { + text = value.asString(); + } else { + // An object or array (e.g. {{classification}}) renders as its JSON. + text = value.toString(); + } + return escaping == Escaping.URL_PATH ? urlEncodePathSegment(text) : text; + } + + /** + * Encode for a path segment: a filename is the likeliest value to land in a path and may carry + * a slash, which would otherwise read as structure rather than data. + * + *

Dots are left alone even though a traversal is made of them. Encoding them would be worse: + * {@code %2E%2E} survives {@link java.net.URI#normalize()} and gets decoded by the target, so + * the traversal would arrive intact and unexamined. Left raw, {@code ..} normalises here and is + * caught by {@code ExternalApiPaths}' under-the-base check - the one place that can actually + * see it. + */ + private static String urlEncodePathSegment(String text) { + StringBuilder out = new StringBuilder(text.length()); + for (byte b : text.getBytes(java.nio.charset.StandardCharsets.UTF_8)) { + char c = (char) (b & 0xFF); + // RFC 3986 unreserved. + boolean unreserved = + (c >= 'a' && c <= 'z') + || (c >= 'A' && c <= 'Z') + || (c >= '0' && c <= '9') + || c == '-' + || c == '.' + || c == '_' + || c == '~'; + if (unreserved) { + out.append(c); + } else { + out.append('%').append(String.format("%02X", b & 0xFF)); + } + } + return out.toString(); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ResultFiles.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ResultFiles.java new file mode 100644 index 0000000000..f2ceb7988f --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ResultFiles.java @@ -0,0 +1,215 @@ +package stirling.software.proprietary.integration.api; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.Resource; + +import stirling.software.common.util.TempFileManager; +import stirling.software.common.util.ZipExtractionUtils; + +/** + * Works out which bytes, and under which name, a response should contribute to the pipeline. + * + *

Three things go wrong if this is left implicit: + * + *

    + *
  • The name. A step that replaces the document must name it for what came back, not for + * what went out. Keeping the inbound name means a PDF-to-DOCX call-out yields a DOCX called + * {@code .pdf}, and the next step's type check either waves it through or rejects it for the + * wrong reason. The response's own {@code Content-Disposition} or {@code Content-Type} is the + * only honest source. + *
  • Archives. Plenty of APIs answer with a ZIP even when one file was sent - ConsignO + * returns "PDF (single) or ZIP (multiple)". Handing a {@code .zip} to a step expecting a PDF + * is a confusing failure, so a step can select what it wanted out of the archive. + *
  • Nothing useful at all. An empty body or an error page is not a document, and saying + * so beats letting it flow onward as one. + *
+ */ +final class ResultFiles { + + /** Extensions we can name from a content type; anything else keeps the server's filename. */ + private static final Map EXTENSION_BY_TYPE = + Map.ofEntries( + Map.entry("application/pdf", "pdf"), + Map.entry("application/zip", "zip"), + Map.entry("application/json", "json"), + Map.entry("text/plain", "txt"), + Map.entry("text/html", "html"), + Map.entry("image/png", "png"), + Map.entry("image/jpeg", "jpg"), + Map.entry("image/tiff", "tiff"), + Map.entry("application/msword", "doc"), + Map.entry( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "docx"), + Map.entry("application/vnd.ms-excel", "xls"), + Map.entry( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "xlsx")); + + private ResultFiles() {} + + /** + * The filename to give the returned bytes. + * + *

Prefers what the server said ({@code Content-Disposition}), then the base name of the + * request with an extension derived from {@code Content-Type}, and only then the original name + * unchanged. + */ + static String nameFor(ExternalApiCaller.Response response, String requestFilename) { + String disposition = response.header("content-disposition"); + String fromServer = filenameFromDisposition(disposition); + if (fromServer != null) { + return fromServer; + } + String extension = extensionFor(response.contentType()); + if (extension == null) { + return requestFilename; + } + return baseName(requestFilename) + "." + extension; + } + + /** + * Pick the file a step asked for out of an archive. + * + * @param select a glob such as {@code *.pdf}, or a 0-based index such as {@code 1} + * @throws IOException if nothing in the archive matches, naming what was there - a silent pick + * of the wrong file would be worse than a failed step + */ + static Resource selectFromArchive( + Resource archive, String select, TempFileManager tempFileManager) throws IOException { + List entries = ZipExtractionUtils.extractZip(archive, tempFileManager); + if (entries.isEmpty()) { + throw new IOException("The API returned an empty archive"); + } + Integer index = asIndex(select); + if (index != null) { + if (index < 0 || index >= entries.size()) { + throw new IOException( + "'responseSelect' asked for entry " + + index + + " but the archive has " + + entries.size() + + ": " + + names(entries)); + } + return entries.get(index); + } + List matches = new ArrayList<>(); + for (Resource entry : entries) { + if (matchesGlob(entry.getFilename(), select)) { + matches.add(entry); + } + } + if (matches.isEmpty()) { + throw new IOException( + "'responseSelect' matched nothing in the archive; it holds " + names(entries)); + } + if (matches.size() > 1) { + // Taking the first would be a coin toss the operator did not ask for. + throw new IOException( + "'responseSelect' matched " + + matches.size() + + " entries (" + + names(matches) + + "); narrow it, or use an index"); + } + return matches.get(0); + } + + /** Whether the chosen name is itself an archive, so its content type is not the entry's. */ + static boolean isArchiveName(String filename) { + return filename != null && filename.toLowerCase(Locale.ROOT).endsWith(".zip"); + } + + static boolean isArchive(Resource resource) throws IOException { + return ZipExtractionUtils.isZip(resource); + } + + static Resource asResource(byte[] content, String filename) { + return new ByteArrayResource(content) { + @Override + public String getFilename() { + return filename; + } + }; + } + + /** Only {@code *} is supported, and only against the entry's own name. */ + private static boolean matchesGlob(String filename, String glob) { + if (filename == null) { + return false; + } + String name = filename.toLowerCase(Locale.ROOT); + String pattern = glob.trim().toLowerCase(Locale.ROOT); + String regex = + java.util.Arrays.stream(pattern.split("\\*", -1)) + .map(java.util.regex.Pattern::quote) + .reduce((a, b) -> a + ".*" + b) + .orElse(""); + return name.matches(regex); + } + + private static Integer asIndex(String select) { + try { + return Integer.valueOf(select.trim()); + } catch (NumberFormatException e) { + return null; + } + } + + private static String names(List entries) { + return entries.stream().map(Resource::getFilename).toList().toString(); + } + + /** {@code attachment; filename="signed.pdf"} or its RFC 5987 {@code filename*} form. */ + private static String filenameFromDisposition(String disposition) { + if (disposition == null) { + return null; + } + for (String part : disposition.split(";")) { + String token = part.trim(); + String value = null; + if (token.regionMatches(true, 0, "filename=", 0, 9)) { + value = token.substring(9).trim(); + } else if (token.regionMatches(true, 0, "filename*=", 0, 10)) { + value = token.substring(10).trim(); + int tick = value.lastIndexOf('\''); + if (tick >= 0) { + value = value.substring(tick + 1); + } + } + if (value == null) { + continue; + } + if (value.length() >= 2 && value.startsWith("\"") && value.endsWith("\"")) { + value = value.substring(1, value.length() - 1); + } + // The name comes from the remote server, so it is treated as data: strip any path it + // tries to bring with it rather than letting it steer where anything is written. + String simple = io.github.pixee.security.Filenames.toSimpleFileName(value); + if (simple != null && !simple.isBlank()) { + return simple; + } + } + return null; + } + + private static String extensionFor(String contentType) { + if (contentType == null) { + return null; + } + String type = contentType.split(";")[0].trim().toLowerCase(Locale.ROOT); + return EXTENSION_BY_TYPE.get(type); + } + + private static String baseName(String filename) { + int dot = filename.lastIndexOf('.'); + return dot <= 0 ? filename : filename.substring(0, dot); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ResultUrls.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ResultUrls.java new file mode 100644 index 0000000000..53d382f3ef --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ResultUrls.java @@ -0,0 +1,108 @@ +package stirling.software.proprietary.integration.api; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Locale; +import java.util.Set; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.cluster.s3.S3Clients; + +/** + * Validates a result URL an external API asked us to fetch. + * + *

This is the most dangerous input in the whole feature and deserves saying plainly: unlike a + * step's {@code path}, which an operator wrote, this URL is chosen by the remote service at run + * time. Fetching whatever it names would hand any integration - or anything that has + * compromised, spoofed, or MITM'd one - a server-side GET of its choosing, i.e. the cloud metadata + * service. {@link ExternalApiPaths} cannot help here: the whole point of a result URL is that it + * usually lives on a different host (a CDN or presigned object store), so "must be under the base + * URL" would reject the normal case. + * + *

The rule is therefore an operator-declared allowlist: a result may come from the + * connection's own host, or from a host named in the connection's {@code resultUrlHosts}. The + * decision of which hosts are legitimate stays with whoever configured the connection, and never + * with the response. + */ +final class ResultUrls { + + private ResultUrls() {} + + /** + * @param url exactly as the API returned it + * @return the URL to fetch + * @throws IllegalArgumentException if the response named a host the operator did not authorise + */ + static URI validate( + ApiConnectionSettings settings, + String url, + ApplicationProperties applicationProperties) { + URI uri; + try { + uri = new URI(url.trim()); + } catch (URISyntaxException e) { + throw new IllegalArgumentException( + "The API returned a result URL that is not a valid URL: " + url, e); + } + String scheme = uri.getScheme() == null ? "" : uri.getScheme().toLowerCase(Locale.ROOT); + if (!"http".equals(scheme) && !"https".equals(scheme)) { + // file:, gopher:, jar: and friends are how a URL fetch becomes a local file read. + throw new IllegalArgumentException( + "The API returned a result URL that is not http(s): " + url); + } + String host = uri.getHost(); + if (host == null || host.isBlank()) { + throw new IllegalArgumentException( + "The API returned a result URL with no host: " + url); + } + if (uri.getUserInfo() != null) { + // Credentials in a URL are also the classic way to make a host look like another one. + throw new IllegalArgumentException( + "The API returned a result URL carrying credentials, which is not accepted"); + } + + if (!isAllowedHost(settings, host)) { + throw new IllegalArgumentException( + "The API returned a result URL on '" + + host + + "', which this connection does not allow. Add it to the connection's" + + " 'resultUrlHosts' if results are meant to come from there."); + } + // Even an allowlisted name must not resolve somewhere internal: a hostile or compromised + // DNS record for cdn.vendor.example pointing at 169.254.169.254 would otherwise be obeyed. + try { + S3Clients.validateEndpointHost( + uri, + applicationProperties.getPolicies().isAllowPrivateApiEndpoints(), + "API result URL", + "set policies.allowPrivateApiEndpoints=true to opt in (e.g. for an on-prem" + + " integration)."); + } catch (IllegalStateException e) { + throw new IllegalArgumentException(e.getMessage(), e); + } + return uri; + } + + /** + * The connection's own host is implicitly allowed; anything else must be declared. + * + *

Package-private so the matching rule can be tested without a DNS lookup: {@link #validate} + * additionally resolves the host, which fails closed and so cannot run against example hosts. + */ + static boolean isAllowedHost(ApiConnectionSettings settings, String host) { + String candidate = host.toLowerCase(Locale.ROOT); + if (candidate.equalsIgnoreCase(settings.baseUri().getHost())) { + return true; + } + Set allowed = settings.resultUrlHosts(); + for (String entry : allowed) { + String allowedHost = entry.toLowerCase(Locale.ROOT); + // An exact host, or a subdomain of it. Not a bare suffix match: "evilvendor.com" + // must not be admitted by an entry of "vendor.com". + if (candidate.equals(allowedHost) || candidate.endsWith("." + allowedHost)) { + return true; + } + } + return false; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/controller/IntegrationConfigController.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/controller/IntegrationConfigController.java index 4e79fc5e7a..2c92883aee 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/integration/controller/IntegrationConfigController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/controller/IntegrationConfigController.java @@ -52,6 +52,25 @@ public class IntegrationConfigController { return ResponseEntity.ok(service.toResponse(service.create(request, user), user)); } + /** + * What this caller may set up, so the UI offers the vendor presets and the free-form "custom + * API" option only to those who can actually use them. The answer is computed here rather than + * inferred client-side: hiding a button is presentation, and the service still refuses the call + * regardless of what the client believed. + */ + @GetMapping("/capabilities") + public ResponseEntity capabilities( + @AuthenticationPrincipal User user) { + requireUser(user); + return ResponseEntity.ok( + new IntegrationCapabilitiesResponse(service.canAuthorCustomApi(user))); + } + + /** + * @param customApi whether the caller may author a free-form API integration + */ + public record IntegrationCapabilitiesResponse(boolean customApi) {} + @GetMapping("/{id}") public ResponseEntity get( @PathVariable Long id, @AuthenticationPrincipal User user) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/crypto/CredentialEncryption.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/crypto/CredentialEncryption.java index 96e20f55cb..8ac0ddda96 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/integration/crypto/CredentialEncryption.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/crypto/CredentialEncryption.java @@ -44,10 +44,13 @@ public class CredentialEncryption { private static volatile SecretKey key; private final String configuredKey; + private final boolean clusterEnabled; public CredentialEncryption( - @Value("${stirling.security.credentialEncryptionKey:}") String configuredKey) { + @Value("${stirling.security.credentialEncryptionKey:}") String configuredKey, + @Value("${cluster.enabled:false}") boolean clusterEnabled) { this.configuredKey = configuredKey; + this.clusterEnabled = clusterEnabled; } @PostConstruct @@ -64,6 +67,14 @@ public class CredentialEncryption { if (configured != null && !configured.isBlank()) { return new SecretKeySpec(Base64.getDecoder().decode(configured.trim()), ALGORITHM); } + // Cluster nodes must share this key, so fail fast rather than generate a node-local one. + if (clusterEnabled) { + throw new IllegalStateException( + "cluster.enabled=true requires a shared credential encryption key. Set" + + " STIRLING_CREDENTIAL_ENCRYPTION_KEY (or" + + " stirling.security.credentialEncryptionKey) to the same value on every" + + " node."); + } return loadOrCreateKeyFile(); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/crypto/LegacyDecryptStringConverter.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/crypto/LegacyDecryptStringConverter.java new file mode 100644 index 0000000000..118608b925 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/crypto/LegacyDecryptStringConverter.java @@ -0,0 +1,35 @@ +package stirling.software.proprietary.integration.crypto; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +/** + * For columns that were once whole-blob encrypted but no longer hold secrets. + * + *

Writes plaintext, so the value is readable by any instance regardless of the per-installation + * encryption key. Any value that isn't our ciphertext (already-plaintext JSON, or ciphertext from a + * key we don't hold) is returned as-is; the latter is the caller's to reject. + */ +@Converter +public class LegacyDecryptStringConverter implements AttributeConverter { + + @Override + public String convertToDatabaseColumn(String attribute) { + return attribute; + } + + @Override + public String convertToEntityAttribute(String dbData) { + // Plaintext JSON can never be our Base64 ciphertext ('{' is not in the Base64 alphabet), so + // skip the decrypt attempt for it. + if (dbData == null || dbData.stripLeading().startsWith("{")) { + return dbData; + } + try { + return CredentialEncryption.decrypt(dbData); + } catch (IllegalArgumentException | IllegalStateException e) { + // Legacy ciphertext we can't read (key we don't hold) - the caller's to reject. + return dbData; + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/crypto/LenientEncryptedStringConverter.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/crypto/LenientEncryptedStringConverter.java deleted file mode 100644 index 7bb6190220..0000000000 --- a/app/proprietary/src/main/java/stirling/software/proprietary/integration/crypto/LenientEncryptedStringConverter.java +++ /dev/null @@ -1,31 +0,0 @@ -package stirling.software.proprietary.integration.crypto; - -import jakarta.persistence.AttributeConverter; -import jakarta.persistence.Converter; - -/** - * {@link EncryptedStringConverter} for columns that held plaintext before encryption shipped: - * writes are always encrypted, but a stored value that is not valid ciphertext is returned as-is, - * so pre-encryption rows keep loading and become encrypted on their next save. The discrimination - * is exact for JSON payloads, which can never be mistaken for ciphertext ('{' is not in the Base64 - * alphabet). The trade-off is that a genuinely corrupted ciphertext surfaces as garbage to the - * caller's parser instead of failing here. - */ -@Converter -public class LenientEncryptedStringConverter implements AttributeConverter { - - @Override - public String convertToDatabaseColumn(String attribute) { - return CredentialEncryption.encrypt(attribute); - } - - @Override - public String convertToEntityAttribute(String dbData) { - try { - return CredentialEncryption.decrypt(dbData); - } catch (IllegalArgumentException | IllegalStateException e) { - // Not ciphertext: legacy plaintext from before encryption shipped. - return dbData; - } - } -} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/model/IntegrationType.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/model/IntegrationType.java index 1e5bc0c7d4..375b7f4604 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/integration/model/IntegrationType.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/model/IntegrationType.java @@ -4,5 +4,10 @@ package stirling.software.proprietary.integration.model; public enum IntegrationType { S3, MCP, - API + /** A generic outbound HTTP endpoint a pipeline step can post a document to. */ + API, + /** Microsoft Purview Information Protection: sensitivity-label taxonomy via Graph. */ + PURVIEW, + /** ConsignO Cloud (Notarius) e-signature and notarization. */ + CONSIGNO } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/purview/PdfSensitivityLabels.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/purview/PdfSensitivityLabels.java new file mode 100644 index 0000000000..28ed8de8f3 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/purview/PdfSensitivityLabels.java @@ -0,0 +1,323 @@ +package stirling.software.proprietary.integration.purview; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Predicate; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDDocumentCatalog; +import org.apache.pdfbox.pdmodel.PDDocumentInformation; +import org.apache.pdfbox.pdmodel.common.PDMetadata; + +import lombok.extern.slf4j.Slf4j; + +/** + * Reads and writes Microsoft Purview sensitivity labels on a PDF. + * + *

Microsoft documents what a label is - the {@code MSIP_Label__} + * key/value set - but not where it lives inside a PDF; that detail sits inside the MIP + * SDK, which is C++/.NET only and has no Java binding. This class therefore treats the two places a + * PDF can hold such pairs as equally valid: + * + *

    + *
  • the Document Information dictionary, whose custom entries are literally a key/value map; + *
  • the XMP packet, where the same keys appear as properties. + *
+ * + *

Reading is deliberately tolerant - it scans both and takes whichever yields a label - so a + * document labelled by Acrobat, the MIP client, or another vendor is still understood. Writing + * populates both, because a downstream reader may only look at one. + * + *

Scope: this applies the label metadata. It does not encrypt, and cannot: protection + * is enforced by the Azure Rights Management service through the MIP SDK. A label whose policy + * demands encryption will be marked here but not protected, which {@link #apply} refuses to do + * silently. + */ +@Slf4j +public final class PdfSensitivityLabels { + + /** Captures the GUID and the attribute name out of {@code MSIP_Label__}. */ + private static final Pattern LABEL_KEY = + Pattern.compile("^MSIP_Label_([0-9a-fA-F-]{36})_(\\w+)$"); + + /** Finds the same keys inside a raw XMP packet, whatever schema wraps them. */ + private static final Pattern XMP_LABEL_ENTRY = + Pattern.compile( + "<([\\w-]+:)?(MSIP_Label_[0-9a-fA-F-]{36}_\\w+)>([^<]*)", + Pattern.CASE_INSENSITIVE); + + /** + * Adobe's extension schema for carrying arbitrary Document Info entries in XMP. Using it keeps + * the XMP copy standards-shaped instead of inventing a namespace. + */ + private static final String PDFX_NAMESPACE = "http://ns.adobe.com/pdfx/1.3/"; + + private static final int MAX_XMP_BYTES = 8 * 1024 * 1024; + + private PdfSensitivityLabels() {} + + /** + * The label on this document, if any. + * + *

A document carries at most one label per organisation, but may carry labels from several. + * When more than one is present the first found is returned - callers that care about a + * specific tenant should compare {@link SensitivityLabel#siteId()}. + */ + public static Optional read(PDDocument document) { + List all = readAll(document); + return all.isEmpty() ? Optional.empty() : Optional.of(all.get(0)); + } + + /** Every label on the document, across both metadata surfaces, de-duplicated by GUID. */ + public static List readAll(PDDocument document) { + Map> byLabelId = new LinkedHashMap<>(); + collect(infoPairs(document), byLabelId); + collect(xmpPairs(document), byLabelId); + + List labels = new ArrayList<>(); + byLabelId.forEach( + (labelId, attributes) -> { + SensitivityLabel label = SensitivityLabel.fromAttributes(labelId, attributes); + if (label != null) { + labels.add(label); + } + }); + return labels; + } + + /** + * Apply a label, replacing any the same tenant already set. + * + * @throws IllegalArgumentException if the label claims encryption, which this cannot honour + */ + public static void apply(PDDocument document, SensitivityLabel label) throws IOException { + if (label.isProtected()) { + // Writing ContentBits=ENCRYPT onto an unencrypted file would tell every downstream + // reader the content is protected when it is plaintext. Refuse rather than lie. + throw new IllegalArgumentException( + "This label requires encryption, which needs the Microsoft Purview client or" + + " MIP SDK; Stirling can apply the label metadata but cannot protect" + + " the content."); + } + // "An object can only have one label from the same organization." Replace this tenant's + // labels on both surfaces, but leave other tenants' labels untouched on both. + Set replaced = labelIdsOfTenant(document, label.siteId()); + replaced.add(label.labelId()); + Map pairs = label.toMetadata(); + removeInfoLabels(document, replaced::contains); + writeInfo(document, pairs); + writeXmp(document, pairs, replaced::contains); + } + + /** Strip every label, e.g. before re-labelling or when downgrading a document. */ + public static void clear(PDDocument document) throws IOException { + removeInfoLabels(document, labelId -> true); + writeXmp(document, Map.of(), labelId -> true); + } + + /** The GUIDs of labels this tenant already set, so both surfaces can drop exactly those. */ + private static Set labelIdsOfTenant(PDDocument document, String siteId) { + Set ids = new LinkedHashSet<>(); + for (SensitivityLabel existing : readAll(document)) { + if (siteId.equalsIgnoreCase(existing.siteId())) { + ids.add(existing.labelId()); + } + } + return ids; + } + + /** Drop info-dictionary label entries whose GUID the predicate selects. */ + private static void removeInfoLabels(PDDocument document, Predicate removeLabelId) { + PDDocumentInformation info = document.getDocumentInformation(); + for (String key : new ArrayList<>(info.getMetadataKeys())) { + Matcher matcher = LABEL_KEY.matcher(key); + if (matcher.matches() && removeLabelId.test(matcher.group(1))) { + info.setCustomMetadataValue(key, null); + } + } + } + + private static void writeInfo(PDDocument document, Map pairs) { + PDDocumentInformation info = document.getDocumentInformation(); + pairs.forEach(info::setCustomMetadataValue); + } + + /** + * Rewrite the XMP packet's label properties, leaving the rest of the packet untouched. + * + *

The packet is edited textually rather than re-serialised through xmpbox: a document's XMP + * may carry schemas xmpbox does not model, and a round-trip through it would silently drop + * them. + */ + private static void writeXmp( + PDDocument document, Map pairs, Predicate removeLabelId) + throws IOException { + PDDocumentCatalog catalog = document.getDocumentCatalog(); + String existing = readXmpString(catalog); + if (existing == null) { + if (pairs.isEmpty()) { + return; + } + existing = emptyPacket(); + } + String stripped = stripLabels(existing, removeLabelId); + String updated = insertLabelProperties(stripped, pairs); + if (updated == null) { + log.debug("XMP packet has no rdf:Description to hold the label; info dictionary only"); + return; + } + PDMetadata metadata = new PDMetadata(document); + metadata.importXMPMetadata(updated.getBytes(StandardCharsets.UTF_8)); + catalog.setMetadata(metadata); + } + + /** Remove only the XMP label entries whose GUID the predicate selects, keeping the rest. */ + private static String stripLabels(String packet, Predicate removeLabelId) { + Matcher matcher = XMP_LABEL_ENTRY.matcher(packet); + StringBuilder out = new StringBuilder(); + while (matcher.find()) { + Matcher key = LABEL_KEY.matcher(matcher.group(2)); + boolean remove = key.matches() && removeLabelId.test(key.group(1)); + matcher.appendReplacement(out, Matcher.quoteReplacement(remove ? "" : matcher.group())); + } + matcher.appendTail(out); + return out.toString(); + } + + /** Splice the properties into the first {@code rdf:Description}; null when there is none. */ + private static String insertLabelProperties(String packet, Map pairs) { + if (pairs.isEmpty()) { + return packet; + } + Matcher description = Pattern.compile("]*>").matcher(packet); + if (!description.find()) { + return null; + } + StringBuilder properties = new StringBuilder(); + pairs.forEach( + (key, value) -> + properties + .append("\n ') + .append(escapeXml(value)) + .append("')); + String opening = description.group(); + String withNamespace = + opening.contains("xmlns:pdfx=") + ? opening + : opening.substring(0, opening.length() - 1) + + " xmlns:pdfx=\"" + + PDFX_NAMESPACE + + "\">"; + return packet.substring(0, description.start()) + + withNamespace + + properties + + packet.substring(description.end()); + } + + private static Map infoPairs(PDDocument document) { + Map pairs = new LinkedHashMap<>(); + PDDocumentInformation info = document.getDocumentInformation(); + for (String key : info.getMetadataKeys()) { + String value = info.getCustomMetadataValue(key); + if (value != null) { + pairs.put(key, value); + } + } + return pairs; + } + + private static Map xmpPairs(PDDocument document) { + Map pairs = new LinkedHashMap<>(); + String packet; + try { + packet = readXmpString(document.getDocumentCatalog()); + } catch (IOException e) { + log.debug( + "Unreadable XMP packet; falling back to the info dictionary: {}", + e.getMessage()); + return pairs; + } + if (packet == null) { + return pairs; + } + Matcher matcher = XMP_LABEL_ENTRY.matcher(packet); + while (matcher.find()) { + pairs.put(matcher.group(2), unescapeXml(matcher.group(3).trim())); + } + return pairs; + } + + /** Group raw pairs by label GUID, keeping the attribute name as the key. */ + private static void collect(Map pairs, Map> into) { + pairs.forEach( + (key, value) -> { + Matcher matcher = LABEL_KEY.matcher(key); + if (!matcher.matches()) { + return; + } + into.computeIfAbsent(matcher.group(1), id -> new LinkedHashMap<>()) + // Info-dictionary pairs are collected first and win: a stale XMP copy + // must not override the value the labelling client wrote. + .putIfAbsent(matcher.group(2), value); + }); + } + + private static String readXmpString(PDDocumentCatalog catalog) throws IOException { + PDMetadata metadata = catalog.getMetadata(); + if (metadata == null) { + return null; + } + try (InputStream is = metadata.exportXMPMetadata()) { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + byte[] chunk = new byte[8192]; + int read; + int total = 0; + while ((read = is.read(chunk)) != -1) { + total += read; + if (total > MAX_XMP_BYTES) { + // A hostile document could otherwise hand us an unbounded packet to hold. + throw new IOException("XMP packet exceeds " + MAX_XMP_BYTES + " bytes"); + } + buffer.write(chunk, 0, read); + } + return buffer.toString(StandardCharsets.UTF_8); + } + } + + private static String emptyPacket() { + return "" + + "" + + "" + + "" + + ""; + } + + private static String escapeXml(String value) { + return value.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """); + } + + private static String unescapeXml(String value) { + return value.replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("&", "&"); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/purview/PurviewConnectionSettings.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/purview/PurviewConnectionSettings.java new file mode 100644 index 0000000000..cc40872293 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/purview/PurviewConnectionSettings.java @@ -0,0 +1,94 @@ +package stirling.software.proprietary.integration.purview; + +import java.util.Locale; +import java.util.Map; +import java.util.regex.Pattern; + +/** + * A Microsoft Purview tenant connection. + * + *

Only {@code tenantId} is required, because labelling a document needs nothing else: a label is + * a set of key/value pairs and the tenant id is the {@code SiteId} among them. No call to Microsoft + * is involved, so the step works with no network and no app registration. + * + *

The app-registration fields are optional and buy exactly one thing: reading the tenant's label + * taxonomy from Graph, so the UI can offer a list of labels instead of asking someone to paste a + * GUID. They are not needed to apply or read a label. Graph cannot apply labels for an application + * anyway - "application permissions are not supported when updating assignedLabels" - which is why + * labelling here goes through the published metadata contract instead. + */ +public record PurviewConnectionSettings( + String tenantId, + String clientId, + String clientSecret, + String graphBaseUrl, + String loginBaseUrl) { + + static final String TENANT_ID_OPTION = "tenantId"; + static final String CLIENT_ID_OPTION = "clientId"; + // Contains a SecretMasker hint, so it masks on read and merges on update. + static final String CLIENT_SECRET_OPTION = "clientSecret"; + static final String GRAPH_BASE_URL_OPTION = "graphBaseUrl"; + static final String LOGIN_BASE_URL_OPTION = "loginBaseUrl"; + + public static final String DEFAULT_GRAPH_BASE_URL = "https://graph.microsoft.com"; + public static final String DEFAULT_LOGIN_BASE_URL = "https://login.microsoftonline.com"; + + /** Entra tenant ids are GUIDs; the value ends up in document metadata, so it is checked. */ + private static final Pattern GUID = + Pattern.compile("^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$"); + + public static PurviewConnectionSettings from(Map options) { + String tenantId = trimmed(options.get(TENANT_ID_OPTION)); + if (tenantId == null) { + throw new IllegalArgumentException("purview config requires a 'tenantId'"); + } + if (!GUID.matcher(tenantId).matches()) { + throw new IllegalArgumentException( + "purview config 'tenantId' must be a GUID, e.g." + + " cb46c030-1825-4e81-a295-151c039dbf02"); + } + String clientId = trimmed(options.get(CLIENT_ID_OPTION)); + String clientSecret = trimmed(options.get(CLIENT_SECRET_OPTION)); + // Half an app registration would fail only when someone opened the label picker, which is + // a confusing place to discover it. + if ((clientId == null) != (clientSecret == null)) { + throw new IllegalArgumentException( + "purview config needs both 'clientId' and 'clientSecret' to read the label" + + " list, or neither"); + } + return new PurviewConnectionSettings( + tenantId.toLowerCase(Locale.ROOT), + clientId, + clientSecret, + orDefault(trimmed(options.get(GRAPH_BASE_URL_OPTION)), DEFAULT_GRAPH_BASE_URL), + orDefault(trimmed(options.get(LOGIN_BASE_URL_OPTION)), DEFAULT_LOGIN_BASE_URL)); + } + + /** Whether this connection can read the tenant's label taxonomy from Graph. */ + public boolean canListLabels() { + return clientId != null && clientSecret != null; + } + + private static String orDefault(String value, String fallback) { + return value == null ? fallback : value; + } + + private static String trimmed(Object value) { + if (value == null) { + return null; + } + String text = value.toString().trim(); + return text.isEmpty() ? null : text; + } + + /** Never prints the client secret, so an accidental log line cannot leak it. */ + @Override + public String toString() { + return "PurviewConnectionSettings[tenantId=" + + tenantId + + ", canListLabels=" + + canListLabels() + + "]"; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/purview/PurviewIntegrationValidator.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/purview/PurviewIntegrationValidator.java new file mode 100644 index 0000000000..485345e5cd --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/purview/PurviewIntegrationValidator.java @@ -0,0 +1,23 @@ +package stirling.software.proprietary.integration.purview; + +import java.util.Map; + +import org.springframework.stereotype.Component; + +import stirling.software.proprietary.integration.model.IntegrationType; +import stirling.software.proprietary.integration.service.IntegrationConfigValidator; + +/** The Purview connection schema, enforced when the config is saved. */ +@Component +public class PurviewIntegrationValidator implements IntegrationConfigValidator { + + @Override + public IntegrationType type() { + return IntegrationType.PURVIEW; + } + + @Override + public void validate(Map config) { + PurviewConnectionSettings.from(config); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/purview/PurviewLabelController.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/purview/PurviewLabelController.java new file mode 100644 index 0000000000..65555077c3 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/purview/PurviewLabelController.java @@ -0,0 +1,184 @@ +package stirling.software.proprietary.integration.purview; + +import java.io.IOException; +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import io.github.pixee.security.Filenames; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.util.TempFileManager; +import stirling.software.common.util.WebResponseUtils; +import stirling.software.proprietary.integration.api.ApiConnectionResolver; +import stirling.software.proprietary.integration.model.IntegrationType; +import stirling.software.proprietary.integration.purview.SensitivityLabel.AssignmentMethod; +import stirling.software.proprietary.service.AiToolResponseHeaders; + +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ArrayNode; +import tools.jackson.databind.node.ObjectNode; + +/** + * Purview sensitivity labelling as policy steps. + * + *

Both steps are local: a label is metadata, so applying and reading one involves no call to + * Microsoft. The connection supplies the tenant id that becomes the label's {@code SiteId}. + * + *

{@code purview-read-label} exists to make labels actionable: it reports what a + * document already carries, so a policy can branch on it - the case Purview itself does not cover, + * since it labels documents but does not process them. + */ +@Slf4j +@RestController +@RequestMapping("/api/v1/integration") +@RequiredArgsConstructor +@Tag(name = "Integrations", description = "Third-party integration steps.") +public class PurviewLabelController { + + private final ApiConnectionResolver connectionResolver; + private final CustomPDFDocumentFactory pdfDocumentFactory; + private final TempFileManager tempFileManager; + private final ObjectMapper objectMapper; + + @PostMapping(value = "/purview-apply-label", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation( + summary = "Apply a Microsoft Purview sensitivity label", + description = + "Writes the Purview label metadata (MSIP_Label__*) onto the PDF, so" + + " Purview-aware tools recognise the label. Applies the label only;" + + " it cannot encrypt, which requires the Microsoft client." + + " Input:PDF Output:PDF Type:SISO") + public ResponseEntity applyLabel( + @RequestParam("fileInput") MultipartFile fileInput, + @RequestParam("connectionId") String connectionId, + @RequestParam("labelId") String labelId, + @RequestParam(value = "labelName", required = false) String labelName, + @RequestParam(value = "method", defaultValue = "STANDARD") String method, + @RequestParam(value = "contentBits", required = false) Integer contentBits) + throws IOException { + + PurviewConnectionSettings settings = settings(connectionId); + AssignmentMethod assignment = parseMethod(method); + + try (PDDocument document = pdfDocumentFactory.load(fileInput, true)) { + String fileName = safeFileName(fileInput.getOriginalFilename()); + SensitivityLabel label = + new SensitivityLabel( + labelId.trim(), + labelName, + settings.tenantId(), + assignment, + Instant.now(), + contentBits); + PdfSensitivityLabels.apply(document, label); + log.debug("[purview-apply-label] labelled {} as {}", fileName, labelId); + return WebResponseUtils.pdfDocToWebResponse(document, fileName, tempFileManager); + } + } + + @PostMapping(value = "/purview-read-label", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation( + summary = "Read the Microsoft Purview sensitivity label on a PDF", + description = + "Reports the Purview labels a PDF already carries so a policy can act on" + + " them. The document passes through unchanged." + + " Input:PDF Output:PDF Type:SISO") + public ResponseEntity readLabel( + @RequestParam("fileInput") MultipartFile fileInput, + @RequestParam("connectionId") String connectionId) + throws IOException { + + PurviewConnectionSettings settings = settings(connectionId); + + List labels; + try (PDDocument document = pdfDocumentFactory.load(fileInput, true)) { + labels = PdfSensitivityLabels.readAll(document); + } + // The document is returned byte-for-byte rather than re-saved: a read must not perturb the + // file it inspected, and a PDFBox round-trip would rewrite its structure. + byte[] bytes = fileInput.getBytes(); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_PDF); + headers.setContentDispositionFormData( + "attachment", safeFileName(fileInput.getOriginalFilename())); + headers.setContentLength(bytes.length); + headers.set(AiToolResponseHeaders.TOOL_REPORT, buildReport(labels, settings)); + return ResponseEntity.ok().headers(headers).body(new ByteArrayResource(bytes)); + } + + /** + * The labels found, and which of them is this tenant's - a document can carry labels from + * several organisations, and only the matching one reflects this tenant's policy. + */ + private String buildReport(List labels, PurviewConnectionSettings settings) { + Optional own = + labels.stream() + .filter(label -> settings.tenantId().equalsIgnoreCase(label.siteId())) + .findFirst(); + ObjectNode report = objectMapper.createObjectNode(); + report.put("labelled", own.isPresent()); + own.ifPresent( + label -> { + report.put("labelId", label.labelId()); + report.put("labelName", label.name()); + report.put("method", label.method() == null ? null : label.method().name()); + report.put( + "setDate", label.setDate() == null ? null : label.setDate().toString()); + report.put("contentBits", label.contentBits()); + report.put("protected", label.isProtected()); + }); + ArrayNode others = report.putArray("otherTenantLabels"); + labels.stream() + .filter(label -> !settings.tenantId().equalsIgnoreCase(label.siteId())) + .forEach( + label -> { + ObjectNode node = others.addObject(); + node.put("labelId", label.labelId()); + node.put("siteId", label.siteId()); + }); + return objectMapper.writeValueAsString(report); + } + + private PurviewConnectionSettings settings(String connectionId) { + Long id = ApiConnectionResolver.connectionId(connectionId); + if (id == null) { + throw new IllegalArgumentException("'connectionId' is required"); + } + return PurviewConnectionSettings.from( + connectionResolver.resolveConfig(id, IntegrationType.PURVIEW)); + } + + private static AssignmentMethod parseMethod(String method) { + AssignmentMethod parsed = AssignmentMethod.parse(method); + if (parsed == null) { + throw new IllegalArgumentException( + "'method' must be STANDARD (applied automatically) or PRIVILEGED (chosen by a" + + " person); got " + + method); + } + return parsed; + } + + private static String safeFileName(String originalFilename) { + String name = Filenames.toSimpleFileName(originalFilename); + return (name == null || name.isBlank()) ? "labelled.pdf" : name; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/purview/SensitivityLabel.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/purview/SensitivityLabel.java new file mode 100644 index 0000000000..5f6e97452e --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/purview/SensitivityLabel.java @@ -0,0 +1,186 @@ +package stirling.software.proprietary.integration.purview; + +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Pattern; + +/** + * One Microsoft Purview Information Protection label as it is written to a document. + * + *

Microsoft persists a label as a flat set of key/value pairs named {@code + * MSIP_Label__}, and documents that contract publicly so third-party software can + * read a label and act on it. That published contract - not the MIP SDK, which has no Java binding + * - is what this type implements. See Label + * metadata in the MIP SDK. + * + *

Only {@code Enabled} and {@code SiteId} are mandatory in that contract; the rest are optional + * and may be absent on a label written by an older client, so readers here tolerate their absence. + */ +public record SensitivityLabel( + String labelId, + String name, + String siteId, + AssignmentMethod method, + Instant setDate, + Integer contentBits) { + + /** How the label came to be applied. */ + public enum AssignmentMethod { + /** Applied by default or automatically - e.g. by a policy like this one. */ + STANDARD, + /** Chosen deliberately by a person. */ + PRIVILEGED; + + String wireValue() { + return name().charAt(0) + name().substring(1).toLowerCase(Locale.ROOT); + } + + static AssignmentMethod parse(String value) { + if (value == null) { + return null; + } + try { + return valueOf(value.trim().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException e) { + return null; + } + } + } + + public static final String KEY_PREFIX = "MSIP_Label_"; + + /** Content marks the labelling application applied; a bitmask, per the MIP contract. */ + public static final int CONTENT_BITS_HEADER = 0x1; + + public static final int CONTENT_BITS_FOOTER = 0x2; + public static final int CONTENT_BITS_WATERMARK = 0x4; + public static final int CONTENT_BITS_ENCRYPT = 0x8; + + /** + * Extended ISO 8601, matching the {@code 2018-11-08T21:13:16-0800} form Microsoft documents. + */ + private static final DateTimeFormatter SET_DATE = + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssZ", Locale.ROOT) + .withZone(ZoneOffset.UTC); + + /** + * Microsoft caps each key and value at 255 characters "to maintain compatibility across common + * applications". + */ + static final int MAX_VALUE_LENGTH = 255; + + /** The GUID shape a labelId must take, matching what the read path accepts from a document. */ + private static final Pattern LABEL_ID = Pattern.compile("^[0-9a-fA-F-]{36}$"); + + public SensitivityLabel { + if (labelId == null || labelId.isBlank()) { + throw new IllegalArgumentException("a sensitivity label needs a labelId"); + } + if (!LABEL_ID.matcher(labelId).matches()) { + // labelId is spliced verbatim into XMP/info key names; a non-GUID would let a stray + // character (a space, or <, >, &) corrupt or inject the metadata it is written into. + throw new IllegalArgumentException("a sensitivity label needs a GUID labelId"); + } + if (siteId == null || siteId.isBlank()) { + throw new IllegalArgumentException("a sensitivity label needs a siteId (tenant id)"); + } + } + + /** The {@code MSIP_Label__} prefix this label's keys share. */ + public String keyPrefix() { + return KEY_PREFIX + labelId + "_"; + } + + /** + * This label as the key/value pairs to persist. Optional attributes are omitted when unset + * rather than written empty, so a reader cannot mistake "not recorded" for "recorded as blank". + */ + public Map toMetadata() { + Map out = new LinkedHashMap<>(); + String prefix = keyPrefix(); + out.put(prefix + "Enabled", "true"); + out.put(prefix + "SiteId", siteId); + if (method != null) { + out.put(prefix + "Method", method.wireValue()); + } + if (setDate != null) { + out.put(prefix + "SetDate", SET_DATE.format(setDate)); + } + if (name != null && !name.isBlank()) { + out.put(prefix + "Name", truncate(name)); + } + if (contentBits != null) { + out.put(prefix + "ContentBits", String.valueOf(contentBits)); + } + return out; + } + + /** + * Rebuild a label from the pairs found on a document. + * + * @param labelId the GUID between the prefix and the attribute name + * @param attributes attribute name (e.g. {@code Name}) to value, for that GUID only + * @return null when the pairs do not describe an enabled label + */ + static SensitivityLabel fromAttributes(String labelId, Map attributes) { + // "DLP products typically validate the existence of this key to identify the + // classification label" - an absent or false Enabled means there is no label here. + if (!"true".equalsIgnoreCase(attributes.get("Enabled"))) { + return null; + } + String siteId = attributes.get("SiteId"); + if (siteId == null || siteId.isBlank()) { + // SiteId is mandatory in the contract, but a label written by something non-compliant + // is still a label; keep it readable rather than throwing on someone else's file. + siteId = "unknown"; + } + return new SensitivityLabel( + labelId, + attributes.get("Name"), + siteId, + AssignmentMethod.parse(attributes.get("Method")), + parseDate(attributes.get("SetDate")), + parseInt(attributes.get("ContentBits"))); + } + + private static Instant parseDate(String value) { + if (value == null || value.isBlank()) { + return null; + } + try { + return SET_DATE.parse(value.trim(), Instant::from); + } catch (RuntimeException e) { + try { + // Tolerate the plain ISO form some writers use instead. + return Instant.parse(value.trim()); + } catch (RuntimeException ignored) { + return null; + } + } + } + + private static Integer parseInt(String value) { + if (value == null || value.isBlank()) { + return null; + } + try { + return Integer.valueOf(value.trim()); + } catch (NumberFormatException e) { + return null; + } + } + + private static String truncate(String value) { + return value.length() <= MAX_VALUE_LENGTH ? value : value.substring(0, MAX_VALUE_LENGTH); + } + + /** Whether the labelling application encrypted the content. */ + public boolean isProtected() { + return contentBits != null && (contentBits & CONTENT_BITS_ENCRYPT) != 0; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/service/IntegrationConfigService.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/service/IntegrationConfigService.java index 9e44b35523..a6650569a8 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/integration/service/IntegrationConfigService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/service/IntegrationConfigService.java @@ -13,6 +13,7 @@ import org.springframework.web.server.ResponseStatusException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import stirling.software.common.model.ApplicationProperties; import stirling.software.proprietary.access.model.DefaultAccessPolicy; import stirling.software.proprietary.access.model.OwnerScope; import stirling.software.proprietary.access.model.ResourceType; @@ -43,6 +44,11 @@ public class IntegrationConfigService { private final OwnershipService ownership; private final SecretMasker secretMasker; private final ResourceGrantRepository grantRepository; + private final ApplicationProperties applicationProperties; + // Bean-discovered extension points: features that understand a type contribute its config + // schema and report what still references a config, without this module depending on them. + private final List validators; + private final List usageChecks; // ---- commands ---- @@ -58,6 +64,7 @@ public class IntegrationConfigService { && !ownership.isAdmin(currentUser)) { throw forbidden("S3 connections can only be created by administrators or team owners"); } + requireCustomApiAllowed(cfg.getIntegrationType(), currentUser); cfg.setName(require(request.name(), "name")); cfg.setEnabled(request.enabled() == null || request.enabled()); cfg.setLocked(request.locked() != null && request.locked()); @@ -66,13 +73,21 @@ public class IntegrationConfigService { ? DefaultAccessPolicy.EXPLICIT_ONLY : request.defaultAccess()); + // TEAM scope may omit the team id: default to the caller's own team so clients (the + // portal) need not know it. assignOwnership still enforces admin-or-leader of that team. + Long ownerTeamId = request.ownerTeamId(); + if (ownerTeamId == null && scope == OwnerScope.TEAM && currentUser.getTeam() != null) { + ownerTeamId = currentUser.getTeam().getId(); + } ownership.assignOwnership( cfg, scope, - request.ownerTeamId(), + ownerTeamId, currentUser, () -> lockedServerExists(cfg.getIntegrationType())); - cfg.setConfig(writeJson(secretMasker.sanitize(request.config()))); + Map config = secretMasker.sanitize(request.config()); + validateConfig(cfg.getIntegrationType(), config); + cfg.setConfig(writeJson(config)); return repository.save(cfg); } @@ -101,18 +116,58 @@ public class IntegrationConfigService { cfg.setDefaultAccess(request.defaultAccess()); } if (request.config() != null) { - cfg.setConfig( - writeJson(secretMasker.merge(readJson(cfg.getConfig()), request.config()))); + // Editing the config of a custom integration is the same authoring power as creating + // one - it is where the base URL and body live - so it is gated identically. + requireCustomApiAllowed(cfg.getIntegrationType(), currentUser); + Map merged = + secretMasker.merge(readJson(cfg.getConfig()), request.config()); + validateConfig(cfg.getIntegrationType(), merged); + cfg.setConfig(writeJson(merged)); } return repository.save(cfg); } + /** + * A custom API integration names its own host, path and body, so it can point the server + * anywhere. That is authoring power rather than self-serve configuration: admins only, and the + * operator can withdraw it entirely. The vendor presets are not gated here - they carry a fixed + * shape, so the worst a user can do is misconfigure their own connection. + */ + private void requireCustomApiAllowed(IntegrationType type, User currentUser) { + if (type != IntegrationType.API) { + return; + } + if (!applicationProperties.getPolicies().isAllowCustomApiIntegrations()) { + throw forbidden( + "Custom API integrations are disabled on this server" + + " (policies.allowCustomApiIntegrations)"); + } + if (!ownership.isAdmin(currentUser)) { + throw forbidden("Custom API integrations can only be created by administrators"); + } + } + + /** Whether this caller may author custom API integrations, for the UI to offer or hide it. */ + public boolean canAuthorCustomApi(User currentUser) { + return applicationProperties.getPolicies().isAllowCustomApiIntegrations() + && ownership.isAdmin(currentUser); + } + @Transactional public void delete(Long id, User currentUser) { IntegrationConfig cfg = load(id); if (!ownership.canManage(TYPE, cfg, currentUser)) { throw forbidden("You cannot manage this integration"); } + // Refuse to pull a connection out from under whatever still references it. + List usages = + usageChecks.stream() + .flatMap(check -> check.usagesOf(cfg.getId()).stream()) + .toList(); + if (!usages.isEmpty()) { + throw new ResponseStatusException( + HttpStatus.CONFLICT, "Integration is in use by: " + String.join(", ", usages)); + } // Drop grants sharing this config so they do not dangle as dead rows. grantRepository.deleteByResourceTypeAndResourceId(TYPE, String.valueOf(cfg.getId())); repository.delete(cfg); @@ -188,6 +243,19 @@ public class IntegrationConfigService { // ---- integration-specific glue ---- + /** Runs every registered validator for the type; unknown types save free-form. */ + private void validateConfig(IntegrationType type, Map config) { + for (IntegrationConfigValidator validator : validators) { + if (validator.type() == type) { + try { + validator.validate(config == null ? Map.of() : config); + } catch (IllegalArgumentException e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage()); + } + } + } + } + /** A non-admin can't create a personal config of a type an admin has locked at server scope. */ private boolean lockedServerExists(IntegrationType type) { return repository.findByScope(OwnerScope.SERVER).stream() diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/service/IntegrationConfigUsageCheck.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/service/IntegrationConfigUsageCheck.java new file mode 100644 index 0000000000..6d703baeda --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/service/IntegrationConfigUsageCheck.java @@ -0,0 +1,15 @@ +package stirling.software.proprietary.integration.service; + +import java.util.List; + +/** + * Reports what still references an integration config, so deletion can be refused instead of + * pulling a connection out from under a live consumer. Implementations are beans discovered by + * {@link IntegrationConfigService} (e.g. the policy subsystem reporting sources and pipelines that + * reference a connection). + */ +public interface IntegrationConfigUsageCheck { + + /** Human-readable labels of everything still using the config; empty when unreferenced. */ + List usagesOf(long configId); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/service/IntegrationConfigValidator.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/service/IntegrationConfigValidator.java new file mode 100644 index 0000000000..05857d2714 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/service/IntegrationConfigValidator.java @@ -0,0 +1,23 @@ +package stirling.software.proprietary.integration.service; + +import java.util.Map; + +import stirling.software.proprietary.integration.model.IntegrationType; + +/** + * Validates one integration type's config map at save time. Implementations are beans discovered by + * {@link IntegrationConfigService}, so the feature that understands a type (e.g. the policy S3 + * backend) owns its schema without the integration module depending on it. Types with no registered + * validator save free-form. + */ +public interface IntegrationConfigValidator { + + /** The type this validator understands. */ + IntegrationType type(); + + /** + * Validates the config as it will be stored (secrets already sanitized/merged, so values are + * real, never the redaction mask). Throws {@link IllegalArgumentException} on bad config. + */ + void validate(Map config); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpApiKeyAuthFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpApiKeyAuthFilter.java index e45dadb0c0..9c76978ccb 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpApiKeyAuthFilter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpApiKeyAuthFilter.java @@ -24,8 +24,8 @@ import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.service.UserService; /** - * API-key auth for the MCP endpoint: validates a Stirling per-user API key and binds the request to - * that user with the MCP scopes. + * API-key auth for the MCP endpoint: validates a Stirling API key and binds the request to that + * user with the MCP scopes. */ @Slf4j public class McpApiKeyAuthFilter extends OncePerRequestFilter { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/Team.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/Team.java index 119c909557..a54959b0ff 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/model/Team.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/Team.java @@ -14,6 +14,7 @@ import stirling.software.proprietary.security.model.User; @Entity @Table(name = "teams") +@EntityListeners(TeamEntityListener.class) @NoArgsConstructor @Getter @Setter @@ -28,7 +29,9 @@ public class Team implements Serializable { @Column(name = "team_id") private Long id; - @Column(name = "name", unique = true, nullable = false) + // Not unique: SaaS personal teams all share the name "My Team". TeamController enforces + // uniqueness for admin-created teams. + @Column(name = "name", nullable = false) private String name; @OneToMany(mappedBy = "team", cascade = CascadeType.ALL, orphanRemoval = true) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/TeamCreatedEvent.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/TeamCreatedEvent.java new file mode 100644 index 0000000000..3f632fe1d8 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/TeamCreatedEvent.java @@ -0,0 +1,4 @@ +package stirling.software.proprietary.model; + +/** Published once a new {@link Team} row is inserted, so listeners can seed per-team defaults. */ +public record TeamCreatedEvent(Long teamId, String teamName) {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/TeamEntityListener.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/TeamEntityListener.java new file mode 100644 index 0000000000..6a4725a142 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/TeamEntityListener.java @@ -0,0 +1,26 @@ +package stirling.software.proprietary.model; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; + +import jakarta.persistence.PostPersist; + +/** Publishes {@link TeamCreatedEvent} on insert; Spring bridges the publisher via a static. */ +@Component +public class TeamEntityListener { + + private static ApplicationEventPublisher publisher; + + @Autowired + void setPublisher(ApplicationEventPublisher applicationEventPublisher) { + TeamEntityListener.publisher = applicationEventPublisher; + } + + @PostPersist + public void onCreate(Team team) { + if (publisher != null) { + publisher.publishEvent(new TeamCreatedEvent(team.getId(), team.getName())); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/TeamMembership.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/TeamMembership.java index de121939bb..27370870c4 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/model/TeamMembership.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/TeamMembership.java @@ -24,7 +24,17 @@ import stirling.software.proprietary.security.model.User; @Entity @Table( name = "team_memberships", - uniqueConstraints = {@UniqueConstraint(columnNames = {"team_id", "user_id"})}) + uniqueConstraints = {@UniqueConstraint(columnNames = {"team_id", "user_id"})}, + // Match the saas migration names so ddl-auto skips them on saas (index already there) and + // only creates them on self-hosted, which has no migrations. + indexes = { + @Index( + name = "idx_team_memberships_user_role", + columnList = "user_id, role"), // leader-set lookups + @Index( + name = "idx_team_memberships_team_role", + columnList = "team_id, role") // per-team member lists + }) @NoArgsConstructor @Getter @Setter @@ -50,7 +60,7 @@ public class TeamMembership implements Serializable { private User user; @Enumerated(EnumType.STRING) - @Column(name = "role", nullable = false) + @Column(name = "role", nullable = false, length = 50) @ToString.Include private TeamRole role = TeamRole.MEMBER; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/create/AiDocument.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/create/AiDocument.java new file mode 100644 index 0000000000..6fd95728c2 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/create/AiDocument.java @@ -0,0 +1,35 @@ +package stirling.software.proprietary.model.api.ai.create; + +import java.util.List; + +import lombok.Data; + +@Data +public class AiDocument { + + private String title; + private String subtitle; + private String referenceNumber; + private Style style; + private List

sections; + + @Data + public static class Style { + private String primaryColor; + private String backgroundColor; + private String bodyTextColor; + } + + @Data + public static class Section { + private String type; + private String heading; + private String body; + private List> pairs; + private List columns; + private List> rows; + private List totalRow; + private List items; + private List signatories; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/apikey/CreateApiKeyRequest.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/apikey/CreateApiKeyRequest.java new file mode 100644 index 0000000000..14aa43093c --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/apikey/CreateApiKeyRequest.java @@ -0,0 +1,4 @@ +package stirling.software.proprietary.model.api.apikey; + +/** Create-key request body from the portal: just a display name for the new personal key. */ +public record CreateApiKeyRequest(String name) {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/apikey/CreatedApiKeyDto.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/apikey/CreatedApiKeyDto.java new file mode 100644 index 0000000000..aa285344c8 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/apikey/CreatedApiKeyDto.java @@ -0,0 +1,7 @@ +package stirling.software.proprietary.model.api.apikey; + +import lombok.Builder; + +/** Returned once when a key is created: the row plus the plaintext secret, never persisted. */ +@Builder +public record CreatedApiKeyDto(PortalApiKeyDto key, String secret) {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/apikey/PortalApiKeyDto.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/apikey/PortalApiKeyDto.java new file mode 100644 index 0000000000..062c1e0a9f --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/apikey/PortalApiKeyDto.java @@ -0,0 +1,21 @@ +package stirling.software.proprietary.model.api.apikey; + +import lombok.Builder; + +/** + * One API key as shown in the portal Infrastructure → API Keys tab. Never carries the secret; that + * is returned once from {@link CreatedApiKeyDto} at creation time. + */ +@Builder +public record PortalApiKeyDto( + String id, + String name, + String prefix, + String created, + String lastUsed, + /** "active" | "revoked". */ + String status, + long usageToday, + long usageMonth, + /** Lifetime request count for the key. */ + long usageTotal) {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/apikey/PortalApiKeysResponse.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/apikey/PortalApiKeysResponse.java new file mode 100644 index 0000000000..dd21af7226 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/apikey/PortalApiKeysResponse.java @@ -0,0 +1,9 @@ +package stirling.software.proprietary.model.api.apikey; + +import java.util.List; + +import lombok.Builder; + +/** Payload for the API Keys tab: the personal keys the caller owns. */ +@Builder +public record PortalApiKeysResponse(List keys) {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/FolderAccessDeniedException.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/FolderAccessDeniedException.java new file mode 100644 index 0000000000..12b4ab0855 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/config/FolderAccessDeniedException.java @@ -0,0 +1,14 @@ +package stirling.software.proprietary.policy.config; + +/** + * A folder path was rejected only because it falls outside the configured/implied allowed roots - a + * condition an admin can resolve by adding the root under the Folder Access settings. Distinct from + * the guard's other rejections (SaaS mode, the protected config dir), which editing the allowlist + * cannot fix, so callers can offer a "go to settings" affordance for this case alone. + */ +public class FolderAccessDeniedException extends IllegalArgumentException { + + public FolderAccessDeniedException(String message) { + super(message); + } +} 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 d61a006cd9..92ab91e99c 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 @@ -6,11 +6,11 @@ import java.util.Arrays; import java.util.List; import java.util.Optional; -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.configuration.RuntimePathConfig; import stirling.software.common.model.ApplicationProperties; import stirling.software.proprietary.policy.model.Policy; import stirling.software.proprietary.policy.source.SourceStore; @@ -23,6 +23,9 @@ import stirling.software.proprietary.policy.source.SourceStore; *
  • denied entirely under the {@code saas} profile; *
  • Stirling's own config dir always rejected, even if an allowed root were misconfigured to * contain it; + *
  • Stirling-owned "implied" roots are always permitted (even with none configured): the local + * server file-storage directory when that storage provider is enabled, and the pipeline + * watched-folder directories, so automations use them without the admin listing them; *
  • must resolve within {@code policies.allowedFolderRoots}; none configured means all denied. * * @@ -30,23 +33,33 @@ import stirling.software.proprietary.policy.source.SourceStore; * defended: an operator who roots an allowlist on a symlink to a sensitive location is trusted. */ @Component -@ConditionalOnBooleanProperty(name = "policies.enabled") public class FolderAccessGuard { public static final String FOLDER_TYPE = "folder"; + /** Reason keys for an implied root, surfaced to the admin UI so it can label each one. */ + public static final String IMPLIED_SERVER_STORAGE = "serverStorage"; + + public static final String IMPLIED_WATCHED_FOLDER = "watchedFolder"; + + /** A directory implicitly permitted regardless of {@code allowedFolderRoots}, and why. */ + public record ImpliedRoot(Path path, String reason) {} + private final boolean saasActive; private final List allowedRoots; + private final List impliedRoots; private final List protectedRoots; private final SourceStore sourceStore; public FolderAccessGuard( ApplicationProperties applicationProperties, + RuntimePathConfig runtimePathConfig, Environment environment, SourceStore sourceStore) { this.saasActive = Arrays.asList(environment.getActiveProfiles()).contains("saas"); this.allowedRoots = normalizeAll(applicationProperties.getPolicies().getAllowedFolderRoots()); + this.impliedRoots = impliedRoots(applicationProperties.getStorage(), runtimePathConfig); this.protectedRoots = List.of(normalize(Path.of(InstallationPathConfig.getConfigPath()))); this.sourceStore = sourceStore; } @@ -64,18 +77,28 @@ public class FolderAccessGuard { "folder may not point inside a protected Stirling directory"); } } + // Stirling-owned implied roots are always permitted, even with no configured roots, so + // automations work against them out of the box. + if (impliedRoots.stream().anyMatch(root -> normalized.startsWith(root.path()))) { + return normalized; + } if (allowedRoots.isEmpty()) { - throw new IllegalArgumentException( + throw new FolderAccessDeniedException( "folder access is disabled; set policies.allowedFolderRoots to permit it"); } boolean within = allowedRoots.stream().anyMatch(normalized::startsWith); if (!within) { - throw new IllegalArgumentException( + throw new FolderAccessDeniedException( "folder '" + normalized + "' is outside the allowed folder roots"); } return normalized; } + /** The Stirling-owned directories always permitted, with a reason key for each (read-only). */ + public List impliedRoots() { + return impliedRoots; + } + /** Whether this policy touches a folder source/sink, and so is subject to these rules. */ public boolean usesFolderAccess(Policy policy) { boolean readsFolder = @@ -88,6 +111,34 @@ public class FolderAccessGuard { return readsFolder || writesFolder; } + /** + * Stirling-owned directories always permitted regardless of {@code allowedFolderRoots}, so + * folder automations work against them out of the box. + */ + private static List impliedRoots( + ApplicationProperties.Storage storage, RuntimePathConfig runtimePathConfig) { + List roots = new ArrayList<>(); + for (Path path : serverStorageRoots(storage)) { + roots.add(new ImpliedRoot(path, IMPLIED_SERVER_STORAGE)); + } + for (Path path : normalizeAll(runtimePathConfig.getPipelineWatchedFoldersPaths())) { + roots.add(new ImpliedRoot(path, IMPLIED_WATCHED_FOLDER)); + } + return List.copyOf(roots); + } + + /** The local server file-storage directory, when that storage provider is enabled. */ + private static List serverStorageRoots(ApplicationProperties.Storage storage) { + if (!storage.isEnabled() || !"local".equalsIgnoreCase(storage.getProvider())) { + return List.of(); + } + String basePath = storage.getLocal().getBasePath(); + if (basePath == null || basePath.isBlank()) { + return List.of(); + } + return List.of(normalize(Path.of(basePath))); + } + private static List normalizeAll(List roots) { List result = new ArrayList<>(); for (String root : roots) { 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 061c788052..4fa90cc04b 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,6 @@ package stirling.software.proprietary.policy.config; import java.util.List; import java.util.Objects; -import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.stereotype.Component; import lombok.RequiredArgsConstructor; @@ -23,7 +22,6 @@ import stirling.software.proprietary.policy.store.PolicyStore; */ @Component @RequiredArgsConstructor -@ConditionalOnBooleanProperty(name = "policies.enabled") public class PolicyAccessGuard { private final UserServiceInterface userService; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/ClassificationMeterController.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/ClassificationMeterController.java new file mode 100644 index 0000000000..dc978648b6 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/ClassificationMeterController.java @@ -0,0 +1,81 @@ +package stirling.software.proprietary.policy.controller; + +import java.util.List; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.Hidden; +import io.swagger.v3.oas.annotations.Operation; + +import jakarta.servlet.http.HttpServletRequest; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.audit.AuditContext; +import stirling.software.proprietary.classification.ClassificationRunBiller; + +/** + * Meters + audits a client-side (non-AI) classification run so both classify paths bill + * identically. Side-effect only; does no classification itself. + */ +@Slf4j +@Hidden +@RestController +@RequestMapping("/api/v1/policies") +public class ClassificationMeterController { + + /** Audit step label mirrors the AI classify tool so both paths read alike in the trail. */ + private static final String CLASSIFY_STEP = "/api/v1/ai/tools/classify-and-label"; + + /** Client-supplied count cap: the frontend meters one document per call. */ + private static final int MAX_DOCUMENTS = 10_000; + + private final ObjectProvider biller; + + public ClassificationMeterController(ObjectProvider biller) { + this.biller = biller; + } + + @PostMapping("/classify/meter") + @Operation( + summary = "Meter a client-side classification run", + description = + "Records billing + audit for a non-AI classification performed in the browser." + + " Does no classification itself. Dispatched by the frontend, not for" + + " direct use.") + public ResponseEntity meterClassification( + @RequestBody(required = false) ClassifyMeterRequest body, HttpServletRequest request) { + int documents = body != null && body.documentCount() != null ? body.documentCount() : 1; + if (documents < 1) documents = 1; + if (documents > MAX_DOCUMENTS) documents = MAX_DOCUMENTS; + String policyName = + body != null && body.policyName() != null && !body.policyName().isBlank() + ? body.policyName() + : "Classification"; + + // Stamp the run so ControllerAuditAspect records it as a policy run, like the AI path. + request.setAttribute(AuditContext.REQ_ATTR_POLICY_NAME, policyName); + request.setAttribute(AuditContext.REQ_ATTR_POLICY_STEPS, List.of(CLASSIFY_STEP)); + + ClassificationRunBiller runBiller = biller.getIfAvailable(); + if (runBiller != null) { + try { + runBiller.recordClassificationRun(documents); + } catch (RuntimeException e) { + log.warn( + "[classify meter] billing failed; classification proceeds unbilled: {}", + e.getMessage()); + } + } + return ResponseEntity.accepted().build(); + } + + /** Frontend payload: documents classified, plus the policy name for the audit label. */ + public record ClassifyMeterRequest( + String policyName, Integer documentCount, List labels) {} +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/FolderAccessSettingsController.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/FolderAccessSettingsController.java new file mode 100644 index 0000000000..1b39aee106 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/FolderAccessSettingsController.java @@ -0,0 +1,42 @@ +package stirling.software.proprietary.policy.controller; + +import java.util.List; + +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; + +import io.swagger.v3.oas.annotations.Operation; + +import lombok.RequiredArgsConstructor; + +import stirling.software.common.annotations.api.AdminApi; +import stirling.software.proprietary.policy.config.FolderAccessGuard; + +/** + * Read-only admin view of the folder roots that are always permitted for folder automations, + * regardless of {@code policies.allowedFolderRoots} (server storage, pipeline watched folders). The + * Folder Access settings section renders these so an admin can see what is implicitly allowed and + * why, without them being editable. The editable roots themselves live under the {@code policies} + * settings section. + */ +@AdminApi +@PreAuthorize("hasRole('ADMIN')") +@RequiredArgsConstructor +public class FolderAccessSettingsController { + + private final FolderAccessGuard folderAccessGuard; + + @GetMapping("/policies/implied-folder-roots") + @Operation( + summary = "Implied folder roots", + description = + "Stirling-managed directories always permitted for folder automations" + + " regardless of policies.allowedFolderRoots. Read-only.") + public List impliedFolderRoots() { + return folderAccessGuard.impliedRoots().stream() + .map(root -> new ImpliedFolderRoot(root.path().toString(), root.reason())) + .toList(); + } + + public record ImpliedFolderRoot(String path, String reason) {} +} 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 e2c89f6a54..a34c392597 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,8 +6,8 @@ import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Optional; -import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.Resource; import org.springframework.http.HttpStatus; @@ -40,6 +40,8 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import stirling.software.common.cluster.JobStore; +import stirling.software.common.cluster.JobStoreEntry; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.job.JobResponse; import stirling.software.common.service.JobOwnershipService; @@ -66,6 +68,7 @@ import stirling.software.proprietary.policy.overview.PoliciesOverviewResponse; import stirling.software.proprietary.policy.overview.PolicyOverviewService; import stirling.software.proprietary.policy.progress.PolicyProgressListener; import stirling.software.proprietary.policy.source.EditorSource; +import stirling.software.proprietary.policy.source.Source; import stirling.software.proprietary.policy.source.SourceAccessGuard; import stirling.software.proprietary.policy.source.SourceDocCounter; import stirling.software.proprietary.policy.source.SourceStore; @@ -85,7 +88,6 @@ import stirling.software.proprietary.util.SecretMasker; @Hidden @RequiredArgsConstructor @Tag(name = "Policies", description = "Run tool pipelines on the backend") -@ConditionalOnBooleanProperty(name = "policies.enabled") public class PolicyController { private final PolicyRunner policyRunner; @@ -104,6 +106,8 @@ public class PolicyController { private final ApplicationProperties applicationProperties; private final TempFileManager tempFileManager; private final JobOwnershipService jobOwnershipService; + // Shared job store: lets the run endpoints see runs that executed on other nodes. + private final JobStore jobStore; @PostMapping(value = "/run", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @Operation( @@ -120,6 +124,7 @@ public class PolicyController { throws IOException { stampPolicyAudit(definition); requireRunnable(definition); + validateAdHocRun(definition); PolicyInputs inputs = toInputs(files); PolicyRunHandle handle = policyRunner.runAdHoc(definition, inputs, PolicyProgressListener.NOOP); @@ -140,6 +145,7 @@ public class PolicyController { throws IOException { stampPolicyAudit(definition); requireRunnable(definition); + validateAdHocRun(definition); PolicyInputs inputs = toInputs(files); SseEmitter emitter = @@ -172,10 +178,19 @@ public class PolicyController { description = "Returns the current status, step cursor, and output files of a run.") public ResponseEntity status(@PathVariable String runId) { PolicyRun run = runRegistry.get(runId); - if (run == null) { - return ResponseEntity.notFound().build(); + if (run != null) { + return ResponseEntity.ok(PolicyRunView.of(run)); } - return ResponseEntity.ok(PolicyRunView.of(run)); + // Not local: read the run's shared projection so any node can serve its status. + if (ownedByCurrentUser(runId)) { + Optional entry = jobStore.get(runId); + if (entry.isPresent() + && entry.get().resultMeta() != null + && entry.get().resultMeta().containsKey("policyId")) { + return ResponseEntity.ok(PolicyRunView.ofEntry(entry.get())); + } + } + return ResponseEntity.notFound().build(); } @GetMapping("/runs") @@ -188,11 +203,26 @@ public class PolicyController { + " collected, rather than orphaned on the backend. Ad-hoc runs (no" + " policy id) are excluded.") public List listRuns() { - return runRegistry.all().stream() + // Local runs first (they carry live step state); keyed by runId to dedupe shared entries. + Map byRunId = new LinkedHashMap<>(); + runRegistry.all().stream() .filter(run -> run.getPolicyId() != null) .filter(run -> ownedByCurrentUser(run.getRunId())) - .map(PolicyRunView::of) - .toList(); + .forEach(run -> byRunId.put(run.getRunId(), PolicyRunView.of(run))); + // Then runs from other nodes, read from the shared job store. + for (JobStoreEntry entry : jobStore.all()) { + if (byRunId.containsKey(entry.jobId())) { + continue; + } + Map meta = entry.resultMeta(); + if (meta == null || !meta.containsKey("policyId")) { + continue; // ad-hoc job, not a stored-policy run + } + if (ownedByCurrentUser(entry.jobId())) { + byRunId.put(entry.jobId(), PolicyRunView.ofEntry(entry)); + } + } + return List.copyOf(byRunId.values()); } /** @@ -219,6 +249,7 @@ public class PolicyController { requirePolicyEditingAllowed(); Policy owned = withStoredOutputSecrets(resolveOwnership(policy)); requireAccessibleSources(owned); + requireAccessibleOutput(owned); try { policyValidator.validate(owned); } catch (IllegalArgumentException e) { @@ -261,6 +292,40 @@ public class PolicyController { } } + /** + * A policy's output destination is a {@link Source} used as a write target: it must resolve to + * a source in the caller's team, so a client can neither reference a non-existent location nor + * reach across teams to write to another team's. The editor is virtual and has no writable + * location, so it can't be a destination. The config is then validated on this (request) thread + * so an S3 destination's connection is authorization-checked against the caller - the async + * delivery worker has no principal. A policy with no reference (inline / editor / one-off) has + * nothing to check. + */ + private void requireAccessibleOutput(Policy policy) { + for (String outputId : policy.outputIds()) { + Source destination = + sourceStore + .get(outputId) + .filter(sourceAccessGuard::canAccess) + .orElseThrow( + () -> + new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Unknown or inaccessible output source: " + + outputId)); + if (EditorSource.TYPE.equals(destination.type())) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "The editor can't be used as an output destination"); + } + try { + policyValidator.validateOutput(destination.toOutputSpec()); + } catch (IllegalArgumentException e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage()); + } + } + } + /** * Assign owner + owning team server-side. Create stamps the current user and their team; update * preserves the existing owner and team after verifying the policy belongs to the caller's team @@ -294,6 +359,7 @@ public class PolicyController { policy.sourceIds(), policy.steps(), policy.output(), + policy.outputIds(), teamId); } @@ -329,16 +395,7 @@ public class PolicyController { } private static Policy withOutput(Policy policy, OutputSpec output) { - return new Policy( - policy.id(), - policy.name(), - policy.owner(), - policy.enabled(), - policy.trigger(), - policy.sourceIds(), - policy.steps(), - output, - policy.teamId()); + return policy.withOutput(output); } /** @@ -530,6 +587,29 @@ public class PolicyController { } } + /** + * Authorization-check an ad-hoc run's steps and output while the caller's principal is present + * (this request thread). The worker thread that later runs and delivers carries no security + * context, so a connection-access check would be skipped there; without this gate a caller + * could reference another tenant's connection by id and write to it, or make the server call it + * with its stored credentials (confused deputy). Stored policies are covered by save-time + * {@link PolicyValidator#validate} instead. + */ + private void validateAdHocRun(PipelineDefinition definition) { + try { + // Steps get the same treatment as the output, and for the same reason: an integration + // step dereferences its connection by id on a principal-less worker thread, so this + // request thread is the only place that reference can be checked against the caller. + policyValidator.validateSteps(definition.steps()); + // Every destination is checked; an ad-hoc run with no destinations validates nothing. + for (OutputSpec output : definition.outputs()) { + policyValidator.validateOutput(output); + } + } catch (IllegalArgumentException e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage()); + } + } + /** * Ad-hoc runs (AI / one-off pipelines) are still editor activity, so their supplied documents * feed the same virtual editor source as stored editor policies, counted against the caller's diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyRunRoutes.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyRunRoutes.java new file mode 100644 index 0000000000..4c8b5f40a5 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyRunRoutes.java @@ -0,0 +1,85 @@ +package stirling.software.proprietary.policy.controller; + +import org.springframework.web.servlet.HandlerMapping; + +import jakarta.servlet.http.HttpServletRequest; + +/** + * Policy execute-route namespace under {@code /api/v1/policies} - the paths that actually run an + * automation ({@code /run}, {@code /run/stream}, {@code /{id}/run}, {@code /{id}/trigger}). + * + *

    Single source of truth for both PAYG entitlement gates, so a caller without billing is blocked + * at the start of a run rather than partway through: the saas {@code EntitlementGuard} gates these + * on {@code FeatureGate.AUTOMATION}, and the self-hosted account-link {@code + * InstanceEntitlementInterceptor} treats them as billable. Read/list policy endpoints are + * deliberately excluded so the UI can still show policies and prompt on use. + * + *

    This is the sole gate between an unentitled caller and a billable run, so the match is exact + * (not a loose suffix) and segment-anchored. {@code PolicyRunRoutesTest} asserts it against every + * mapping on {@code PolicyController}, so a new execute route that isn't classified here fails the + * build rather than silently running for free. + */ +public final class PolicyRunRoutes { + + private static final String BASE = "/api/v1/policies"; + + private PolicyRunRoutes() {} + + /** + * True when the request resolved to a policy execute endpoint. Prefers the matched route + * pattern (context-path independent, set by Spring MVC) and falls back to the raw request URI. + */ + public static boolean matches(HttpServletRequest request) { + Object pattern = request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE); + String path = pattern instanceof String s ? s : request.getRequestURI(); + String rel = relativeToBase(path); + return rel != null && isExecuteRoute(rel); + } + + /** + * The path relative to {@code /api/v1/policies}, or null if the request isn't under that base. + * Segment-anchored (the char after the base must be {@code /} or end-of-string) so a sibling + * like {@code /api/v1/policies-x/...} never matches; tolerates a leading context path. + */ + private static String relativeToBase(String path) { + if (path == null) { + return null; + } + int base = path.indexOf(BASE); + if (base < 0) { + return null; + } + int end = base + BASE.length(); + if (end < path.length() && path.charAt(end) != '/') { + return null; + } + return path.substring(end); + } + + /** + * The execute routes only: {@code /run}, {@code /run/stream}, and the single-segment {@code + * /{id}/run} / {@code /{id}/trigger} (template or concrete id). Read/list/CRUD routes - {@code + * /run/{runId}}, {@code /runs}, {@code /overview}, {@code /triggers}, {@code /{id}}, {@code + * /order}, {@code /{id}/processed-history}, the base list/create - are all excluded. + */ + private static boolean isExecuteRoute(String rel) { + return rel.equals("/run") + || rel.equals("/run/stream") + || isSingleIdRoute(rel, "run") + || isSingleIdRoute(rel, "trigger"); + } + + /** + * True for exactly {@code /{oneSegment}/} (the id being a template or a concrete value). + */ + private static boolean isSingleIdRoute(String rel, String verb) { + String suffix = "/" + verb; + if (!rel.endsWith(suffix)) { + return false; + } + String idSegment = rel.substring(0, rel.length() - suffix.length()); + return idSegment.length() > 1 + && idSegment.charAt(0) == '/' + && idSegment.indexOf('/', 1) < 0; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PipelineStepValidator.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PipelineStepValidator.java new file mode 100644 index 0000000000..fc440609a0 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PipelineStepValidator.java @@ -0,0 +1,22 @@ +package stirling.software.proprietary.policy.engine; + +import stirling.software.proprietary.policy.model.PipelineStep; + +/** + * Validates one step's parameters before a run is admitted. Implementations are beans discovered by + * {@link PolicyValidator}, so the feature that understands a step's parameters owns their rules + * without the engine depending on it. + * + *

    Steps run on a worker thread with no {@code SecurityContext}, so anything a step dereferences + * by id - an integration connection, say - cannot be authorization-checked at run time. A validator + * that resolves such a reference must therefore be called while the caller's principal is still + * present, which is what {@link PolicyValidator#validateSteps} guarantees. + */ +public interface PipelineStepValidator { + + /** + * @throws IllegalArgumentException if the step is misconfigured or references something the + * current caller may not use + */ + void validate(PipelineStep step); +} 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 d54caedd14..e6dce0ee7b 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,6 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import org.slf4j.MDC; -import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.core.io.Resource; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; @@ -38,6 +37,7 @@ import stirling.software.proprietary.policy.model.PolicyInputs; import stirling.software.proprietary.policy.model.PolicyRun; import stirling.software.proprietary.policy.model.WaitState; import stirling.software.proprietary.policy.output.OutputDelivery; +import stirling.software.proprietary.policy.output.PolicyOutputResolver; import stirling.software.proprietary.policy.output.PolicyOutputSink; import stirling.software.proprietary.policy.progress.PolicyProgressListener; import stirling.software.proprietary.service.DownstreamEntitlementError; @@ -57,7 +57,6 @@ import stirling.software.proprietary.service.DownstreamEntitlementError; @Slf4j @Service @RequiredArgsConstructor -@ConditionalOnBooleanProperty(name = "policies.enabled") public class PolicyEngine { // Admission weight for one run. Weighted heavy: a run chains many tools and holds intermediate @@ -74,6 +73,7 @@ public class PolicyEngine { private final FileStorage fileStorage; private final JobOwnershipService jobOwnershipService; private final List outputSinks; + private final PolicyOutputResolver outputResolver; private final ResourceMonitor resourceMonitor; private final JobQueue jobQueue; @@ -121,8 +121,14 @@ public class PolicyEngine { // the owner owns those outputs. String triggeringUser = currentActingPrincipal(); String fileOwner = triggeringUser != null ? triggeringUser : policy.owner(); + // Resolve the referenced output destinations live (like sourceIds), so a stored policy + // delivers to each of its saved Source destinations. Unreferenced policies fall back to + // their inline output. + PipelineDefinition definition = + new PipelineDefinition( + policy.name(), policy.steps(), outputResolver.resolve(policy)); return submitForPrincipal( - policy.owner(), fileOwner, policy.id(), policy.toDefinition(), inputs, listener); + policy.owner(), fileOwner, policy.id(), definition, inputs, listener); } private PolicyRunHandle submitForPrincipal( @@ -136,6 +142,10 @@ public class PolicyEngine { // ownership check passes. No-op when security is off. String runId = jobOwnershipService.createScopedJobKey(UUID.randomUUID().toString()); taskManager.createTask(runId); + // Tag the shared job entry with the policy id so peers can list it as a policy run. + if (policyId != null) { + taskManager.putMetadata(runId, "policyId", policyId); + } PolicyRun run = new PolicyRun(runId, policyId, definition); registry.register(run); CompletableFuture completion = new CompletableFuture<>(); @@ -210,13 +220,21 @@ public class PolicyEngine { run.markRunning(); PolicyExecutionResult result = stepExecutor.execute(run.getDefinition(), inputs, listener); - OutputSpec output = run.getDefinition().output(); - List outputs = - sinkFor(output) - .deliver( - new OutputDelivery(runId, run.getPolicyId()), - result.files(), - output); + // Deliver the run's files to every destination; no destinations means inline + // delivery (results stored/returned to the caller), preserving ad-hoc/AI behaviour. + List destinations = run.getDefinition().outputs(); + if (destinations.isEmpty()) { + destinations = List.of(OutputSpec.inline()); + } + List outputs = new ArrayList<>(); + for (OutputSpec destination : destinations) { + outputs.addAll( + sinkFor(destination) + .deliver( + new OutputDelivery(runId, run.getPolicyId()), + result.files(), + destination)); + } taskManager.setMultipleFileResults(runId, outputs); taskManager.setComplete(runId); run.complete(outputs); 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 f0a0a9d6b0..edc906461e 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,6 @@ import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; -import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.stereotype.Service; import jakarta.annotation.PreDestroy; @@ -29,7 +28,6 @@ import stirling.software.proprietary.policy.model.PolicyRun; */ @Slf4j @Service -@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 8575e95e61..866fe0910d 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 @@ -5,7 +5,6 @@ import java.util.ArrayList; import java.util.List; import java.util.function.Consumer; -import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.stereotype.Service; import lombok.RequiredArgsConstructor; @@ -35,7 +34,6 @@ import stirling.software.proprietary.policy.source.SourceStore; @Slf4j @Service @RequiredArgsConstructor -@ConditionalOnBooleanProperty(name = "policies.enabled") public class PolicyRunner { private final PolicyEngine policyEngine; 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 c08d2dd857..c2d1357889 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,6 @@ package stirling.software.proprietary.policy.engine; import java.util.List; -import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.stereotype.Service; import lombok.RequiredArgsConstructor; @@ -10,6 +9,7 @@ import lombok.RequiredArgsConstructor; import stirling.software.proprietary.policy.input.InputSource; 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; import stirling.software.proprietary.policy.model.TriggerConfig; import stirling.software.proprietary.policy.output.PolicyOutputSink; @@ -18,19 +18,19 @@ 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. Each referenced {@code sourceId} must - * resolve to a persisted {@link Source} whose config its {@link InputSource} bean accepts. + * Validates a policy at save time by delegating each facet (trigger, sources, steps, 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. Each referenced {@code sourceId} + * must resolve to a persisted {@link Source} whose config its {@link InputSource} bean accepts. */ @Service @RequiredArgsConstructor -@ConditionalOnBooleanProperty(name = "policies.enabled") public class PolicyValidator { private final List triggers; private final List inputSources; private final List outputSinks; + private final List stepValidators; private final SourceStore sourceStore; /** @@ -52,7 +52,37 @@ public class PolicyValidator { InputSpec spec = source.toInputSpec(); inputSourceFor(spec).validate(spec); } - outputSinkFor(policy.output()).validate(policy.output()); + validateSteps(policy.steps()); + validateOutput(policy.output()); + } + + /** + * Validate each step against every registered {@link PipelineStepValidator}. Must be called on + * a request thread (caller's principal present) for the same reason as {@link + * #validateOutput(OutputSpec)}: a step that dereferences an integration connection by id is + * access-checked here or nowhere, since the worker thread that later runs it has no principal. + * + * @throws IllegalArgumentException if any step is invalid or references an inaccessible + * resource + */ + public void validateSteps(List steps) { + for (PipelineStep step : steps) { + for (PipelineStepValidator validator : stepValidators) { + validator.validate(step); + } + } + } + + /** + * Validate an output spec against its sink. Must be called on a request thread (caller's + * principal present) so an S3 output's connection is authorization-checked against the caller - + * ad-hoc runs are never persisted and so never hit {@link #validate(Policy)}, and the worker + * thread that later delivers has no principal, so this is their only access gate. + * + * @throws IllegalArgumentException if the type is unknown or the config is invalid/inaccessible + */ + public void validateOutput(OutputSpec output) { + outputSinkFor(output).validate(output); } private PolicyTrigger triggerFor(TriggerConfig config) { 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 9270cc0c1b..4f02d13c82 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 @@ -14,7 +14,6 @@ import java.util.Map; import java.util.function.Supplier; import java.util.stream.Stream; -import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.Resource; import org.springframework.stereotype.Service; @@ -42,7 +41,6 @@ import stirling.software.proprietary.policy.model.PolicyInputs; @Slf4j @Service @RequiredArgsConstructor -@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/input/InputSource.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/InputSource.java index d32c2fc546..15fb0d84f2 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/InputSource.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/InputSource.java @@ -3,6 +3,7 @@ package stirling.software.proprietary.policy.input; import java.io.IOException; import java.nio.file.Path; import java.util.List; +import java.util.Map; import stirling.software.proprietary.policy.model.InputSpec; @@ -22,6 +23,11 @@ public interface InputSource { /** Throws {@link IllegalArgumentException} on bad config. Called on save to fail fast. */ default void validate(InputSpec spec) {} + default Map prepareOptionsForSave( + Map options, boolean isCreate) { + return options; + } + /** * Resolve the spec into zero or more units of work, each carrying one run's files and a * completion hook. Empty list means nothing to run right now. Discovery is read-only - files diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/S3InputSource.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/S3InputSource.java index 99e189f326..34576b47b9 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/S3InputSource.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/S3InputSource.java @@ -6,7 +6,6 @@ import java.io.InputStream; import java.util.ArrayList; import java.util.List; -import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.core.io.AbstractResource; import org.springframework.core.io.Resource; import org.springframework.stereotype.Service; @@ -18,6 +17,7 @@ import stirling.software.proprietary.policy.model.InputSpec; import stirling.software.proprietary.policy.model.PolicyInputs; import stirling.software.proprietary.policy.s3.S3Config; import stirling.software.proprietary.policy.s3.S3ConnectionPool; +import stirling.software.proprietary.policy.s3.S3ConnectionResolver; import stirling.software.proprietary.policy.s3.S3Identities; import software.amazon.awssdk.core.exception.SdkException; @@ -36,25 +36,24 @@ import software.amazon.awssdk.services.s3.model.S3Object; * Reads input files from an Amazon S3 (or S3-compatible) bucket; each listed object is its own unit * of work, claimed through the {@link ResolveContext} ledger and tracked in place. Identity and * version gate come from {@link S3Identities}, so the steady-state sweep never downloads content. - * Options (see {@link S3Config}): "bucket" (required), "region" (default us-east-1), "prefix" (only - * keys starting with it are read), "endpoint" (S3-compatible stores such as MinIO; path-style - * addressing is used automatically), "accessKeyId" and "secretAccessKey" (required; requests are - * never signed with the server's own AWS identity), and "mode" which is "consume" (default: a - * processed object is deleted once every policy that claimed it has settled successfully and it is - * still the version that ran; failures stay in place and are not retried until they change) or - * "snapshot" (stateless, every run sees the full set). Keys ending in "/" (folder placeholders) and - * keys with a dot-prefixed path segment are never picked up, mirroring the folder source's - * hidden-file rule. + * Options: "connectionId" references the stored S3 connection (an {@code IntegrationConfig} owning + * bucket, region, endpoint, and credentials - resolved by {@link S3ConnectionResolver}); "prefix" + * (only keys starting with it are read) and "mode" are per-source, where mode is "consume" + * (default: a processed object is deleted once every policy that claimed it has settled + * successfully and it is still the version that ran; failures stay in place and are not retried + * until they change) or "snapshot" (stateless, every run sees the full set). Keys ending in "/" + * (folder placeholders) and keys with a dot-prefixed path segment are never picked up, mirroring + * the folder source's hidden-file rule. */ @Slf4j @Service @RequiredArgsConstructor -@ConditionalOnBooleanProperty(name = "policies.enabled") public class S3InputSource implements InputSource { private static final String TYPE = "s3"; private final S3ConnectionPool connectionPool; + private final S3ConnectionResolver connectionResolver; @Override public String type() { @@ -67,12 +66,12 @@ public class S3InputSource implements InputSource { } /** - * Fails fast at save time: bad config shape, a private endpoint without the operator opt-in, or - * a bucket the supplied credentials cannot list. + * Fails fast at save time: an unknown/disabled/unusable connection, bad config shape, a private + * endpoint without the operator opt-in, or a bucket the connection cannot list. */ @Override public void validate(InputSpec spec) { - S3Config config = S3Config.from(spec.options()); + S3Config config = connectionResolver.resolve(spec.options()); try { connectionPool.clientFor(config).listObjectsV2(listRequest(config).maxKeys(1).build()); } catch (SdkException e) { @@ -89,7 +88,7 @@ public class S3InputSource implements InputSource { @Override public List resolve(InputSpec spec, ResolveContext ctx) throws IOException { - S3Config config = S3Config.from(spec.options()); + S3Config config = connectionResolver.resolve(spec.options()); S3Client client = connectionPool.clientFor(config); // A listing failure propagates so the sweep reads it as "could not list" (which vetoes // presence cleanup), never as "verifiably no objects". diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/WebhookInputSource.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/WebhookInputSource.java new file mode 100644 index 0000000000..49cb3c70c4 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/WebhookInputSource.java @@ -0,0 +1,145 @@ +package stirling.software.proprietary.policy.input; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.Resource; +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.util.FileReadinessChecker; +import stirling.software.proprietary.policy.ledger.FolderIdentities; +import stirling.software.proprietary.policy.model.InputSpec; +import stirling.software.proprietary.policy.model.PolicyInputs; +import stirling.software.proprietary.policy.webhook.WebhookConfig; +import stirling.software.proprietary.policy.webhook.WebhookIds; +import stirling.software.proprietary.policy.webhook.WebhookSpool; + +@Slf4j +@Service +@RequiredArgsConstructor +public class WebhookInputSource implements InputSource { + + static final String TYPE = "webhook"; + + private final WebhookSpool spool; + private final FileReadinessChecker readinessChecker; + + @Override + public String type() { + return TYPE; + } + + @Override + public boolean supports(InputSpec spec) { + return spec != null && TYPE.equals(spec.type()); + } + + @Override + public void validate(InputSpec spec) { + WebhookConfig.from(spec.options()); + } + + @Override + public Map prepareOptionsForSave( + Map options, boolean isCreate) { + boolean hasId = + options.get(WebhookConfig.WEBHOOK_ID_OPTION) != null + && !options.get(WebhookConfig.WEBHOOK_ID_OPTION).toString().isBlank(); + if (!isCreate && hasId) { + return options; + } + Map prepared = new LinkedHashMap<>(options); + prepared.put(WebhookConfig.WEBHOOK_ID_OPTION, WebhookIds.newWebhookId()); + prepared.put(WebhookConfig.SIGNING_SECRET_OPTION, WebhookIds.newSigningSecret()); + return prepared; + } + + @Override + public List resolve(InputSpec spec, ResolveContext ctx) throws IOException { + WebhookConfig config = WebhookConfig.from(spec.options()); + Path dir = spool.dirFor(config.webhookId()); + if (!Files.isDirectory(dir)) { + ctx.reportPresent(List.of()); + return List.of(); + } + Path canonicalDir = FolderIdentities.canonicalDir(dir); + List present = listFiles(dir); + + ctx.reportPresent( + present.stream() + .map(file -> FolderIdentities.identity(canonicalDir, dir, file)) + .toList()); + + List work = new ArrayList<>(); + for (Path file : present) { + if (!readinessChecker.isReady(file)) { + continue; + } + String identity = FolderIdentities.identity(canonicalDir, dir, file); + String gate; + boolean claimed; + try { + gate = FolderIdentities.statGate(file); + claimed = ctx.claim(identity, gate, null); + } catch (IOException | UncheckedIOException e) { + log.debug("Could not read {} for its version: {}", file, e.getMessage()); + continue; + } + if (!claimed) { + continue; + } + work.add( + new ResolvedInput( + PolicyInputs.of(List.of(fileResource(file))), + success -> completeConsumed(ctx, identity, file, gate, success))); + } + return work; + } + + private static void completeConsumed( + ResolveContext ctx, String identity, Path file, String claimGate, boolean success) { + ctx.settle(identity, claimGate, null, success); + if (!success) { + return; + } + try { + if (FolderIdentities.statGate(file).equals(claimGate) && ctx.allSettledDone(identity)) { + Files.deleteIfExists(file); + } + } catch (java.nio.file.NoSuchFileException alreadyGone) { + } catch (IOException e) { + log.warn("Could not remove consumed webhook delivery {}: {}", file, e.getMessage()); + } + } + + private static List listFiles(Path dir) throws IOException { + List files = new ArrayList<>(); + try (Stream entries = Files.list(dir)) { + entries.filter(Files::isRegularFile) + .filter(file -> !file.getFileName().toString().startsWith(".")) + .forEach(files::add); + } + return files; + } + + private static Resource fileResource(Path path) { + String name = WebhookSpool.displayName(path.getFileName().toString()); + return new FileSystemResource(path.toFile()) { + @Override + public String getFilename() { + return name; + } + }; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/JpaProcessedLedger.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/JpaProcessedLedger.java index 43f0e6795d..02fce08763 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/JpaProcessedLedger.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/JpaProcessedLedger.java @@ -7,7 +7,6 @@ import java.util.Map; import java.util.function.Supplier; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.context.event.EventListener; import org.springframework.dao.DataIntegrityViolationException; @@ -24,7 +23,6 @@ import lombok.extern.slf4j.Slf4j; */ @Slf4j @Service -@ConditionalOnBooleanProperty(name = "policies.enabled") public class JpaProcessedLedger implements ProcessedLedger { private static final int STAMP_CHUNK = 500; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/migration/CompletedMigration.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/migration/CompletedMigration.java new file mode 100644 index 0000000000..9837cd4f45 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/migration/CompletedMigration.java @@ -0,0 +1,40 @@ +package stirling.software.proprietary.policy.migration; + +import java.io.Serializable; +import java.time.Instant; + +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; + +/** + * A one-time policy-subsystem migration that has finished, keyed by a stable migration id. Its + * presence lets a migration skip its (otherwise every-boot) scan once it has run, instead of + * re-scanning and finding nothing to do forever. + */ +@Entity +@Table(name = "policy_completed_migrations") +@NoArgsConstructor +@Getter +@Setter +public class CompletedMigration implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id + @Column(name = "id") + private String id; + + @Column(name = "applied_at") + private Instant appliedAt; + + public CompletedMigration(String id, Instant appliedAt) { + this.id = id; + this.appliedAt = appliedAt; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/migration/CompletedMigrationRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/migration/CompletedMigrationRepository.java new file mode 100644 index 0000000000..23dec902c5 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/migration/CompletedMigrationRepository.java @@ -0,0 +1,7 @@ +package stirling.software.proprietary.policy.migration; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface CompletedMigrationRepository extends JpaRepository {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/migration/CompletedMigrations.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/migration/CompletedMigrations.java new file mode 100644 index 0000000000..f03defa92b --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/migration/CompletedMigrations.java @@ -0,0 +1,18 @@ +package stirling.software.proprietary.policy.migration; + +/** + * Tracks which one-time policy-subsystem migrations have finished, so a migration can skip its + * every-boot scan once done. {@link JpaCompletedMigrations} is the runtime bean; {@link + * InProcessCompletedMigrations} backs tests. + */ +public interface CompletedMigrations { + + /** Whether the migration with this id has already been recorded as complete. */ + boolean isDone(String id); + + /** + * Record the migration as complete. Safe to call concurrently: a race on first boot leaves the + * marker recorded exactly once and never propagates a failure to the caller. + */ + void markDone(String id); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/migration/InProcessCompletedMigrations.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/migration/InProcessCompletedMigrations.java new file mode 100644 index 0000000000..0744c85965 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/migration/InProcessCompletedMigrations.java @@ -0,0 +1,23 @@ +package stirling.software.proprietary.policy.migration; + +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * In-memory {@link CompletedMigrations} for tests and any future no-database mode. {@link + * JpaCompletedMigrations} is the runtime bean. + */ +public class InProcessCompletedMigrations implements CompletedMigrations { + + private final Set done = ConcurrentHashMap.newKeySet(); + + @Override + public boolean isDone(String id) { + return done.contains(id); + } + + @Override + public void markDone(String id) { + done.add(id); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/migration/JpaCompletedMigrations.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/migration/JpaCompletedMigrations.java new file mode 100644 index 0000000000..853e18be51 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/migration/JpaCompletedMigrations.java @@ -0,0 +1,38 @@ +package stirling.software.proprietary.policy.migration; + +import java.time.Instant; + +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Durable {@link CompletedMigrations} backed by JPA; the runtime bean. {@code markDone} relies on + * the primary-key uniqueness of {@link CompletedMigration#getId()} to stay safe under a concurrent + * first boot: whichever node inserts first wins, and the loser's duplicate insert is swallowed + * rather than propagated, so it never disturbs the migration that called it. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class JpaCompletedMigrations implements CompletedMigrations { + + private final CompletedMigrationRepository repository; + + @Override + public boolean isDone(String id) { + return repository.existsById(id); + } + + @Override + public void markDone(String id) { + try { + repository.save(new CompletedMigration(id, Instant.now())); + } catch (DataIntegrityViolationException alreadyRecorded) { + // A concurrent boot recorded the same marker first; the row exists, so we are done. + log.debug("Completion marker '{}' was already recorded concurrently", id); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PipelineDefinition.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PipelineDefinition.java index 146424b0fb..209756c67f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PipelineDefinition.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PipelineDefinition.java @@ -3,13 +3,20 @@ package stirling.software.proprietary.policy.model; import java.util.List; /** - * An ordered chain of tool steps plus an output destination; the unit the engine executes. + * An ordered chain of tool steps plus its output destinations; the unit the engine executes. * - *

    {@code output} may be null for callers that handle result files themselves (e.g. the AI - * workflow, which builds its own response payload). + *

    {@code outputs} may be empty for callers that handle result files themselves (e.g. the AI + * workflow, which builds its own response payload) - the engine then falls back to inline delivery. + * A run's files are delivered to every destination in the list. */ -public record PipelineDefinition(String name, List steps, OutputSpec output) { +public record PipelineDefinition(String name, List steps, List outputs) { public PipelineDefinition { steps = steps == null ? List.of() : steps; + outputs = outputs == null ? List.of() : List.copyOf(outputs); + } + + /** Convenience for the common single-destination (or inline) case. A null output is empty. */ + public PipelineDefinition(String name, List steps, OutputSpec output) { + this(name, steps, output == null ? List.of() : List.of(output)); } } 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 51dcc4ac0c..ae9fedc46a 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 @@ -3,12 +3,14 @@ package stirling.software.proprietary.policy.model; import java.util.List; /** - * A stored automation: ordered tool steps, input sources, and an output destination. + * A stored automation: ordered tool steps, input sources, and output destinations. * *

    Always runnable on demand. An optional {@link TriggerConfig} fires it automatically; a {@code * 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. + * {@code Source} locations (resolved live at run time) files come from; a run pulls from every + * referenced source. {@code outputIds} reference the {@code Source} locations (resolved live) a + * run's files are delivered to - a run is delivered to every one; when empty the inline {@link + * #output} is used (results returned to the caller), the case for editor and one-off policies. */ public record Policy( String id, @@ -19,12 +21,32 @@ public record Policy( List sourceIds, List steps, OutputSpec output, + List outputIds, Long teamId) { public Policy { sourceIds = sourceIds == null ? List.of() : List.copyOf(sourceIds); steps = steps == null ? List.of() : steps; output = output == null ? OutputSpec.inline() : output; + outputIds = outputIds == null ? List.of() : List.copyOf(outputIds); + } + + /** + * Without output references: the inline output is used as-is. Kept for the engine, migrations, + * and tests, and for editor/one-off policies that return results to the caller rather than a + * stored destination. + */ + public Policy( + String id, + String name, + String owner, + boolean enabled, + TriggerConfig trigger, + List sourceIds, + List steps, + OutputSpec output, + Long teamId) { + this(id, name, owner, enabled, trigger, sourceIds, steps, output, List.of(), teamId); } /** @@ -40,7 +62,7 @@ public record Policy( List sourceIds, List steps, OutputSpec output) { - this(id, name, owner, enabled, trigger, sourceIds, steps, output, null); + this(id, name, owner, enabled, trigger, sourceIds, steps, output, List.of(), null); } /** A policy with no configured sources (a generator, or files supplied directly to a run). */ @@ -52,10 +74,25 @@ public record Policy( TriggerConfig trigger, List steps, OutputSpec output) { - this(id, name, owner, enabled, trigger, List.of(), steps, output, null); + this(id, name, owner, enabled, trigger, List.of(), steps, output, List.of(), null); } - /** This policy's pipeline as the engine sees it. */ + /** A copy with the inline output replaced (e.g. resolved for the engine, or migrated). */ + public Policy withOutput(OutputSpec resolved) { + return new Policy( + id, name, owner, enabled, trigger, sourceIds, steps, resolved, outputIds, teamId); + } + + /** A copy referencing the given saved output destinations. */ + public Policy withOutputIds(List newOutputIds) { + return new Policy( + id, name, owner, enabled, trigger, sourceIds, steps, output, newOutputIds, teamId); + } + + /** + * This policy's pipeline as the engine sees it (inline output; destinations resolved + * elsewhere). + */ public PipelineDefinition toDefinition() { return new PipelineDefinition(name, steps, output); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyRunView.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyRunView.java index 04a2707077..6d98e273fc 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyRunView.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyRunView.java @@ -1,7 +1,9 @@ package stirling.software.proprietary.policy.model; import java.util.List; +import java.util.Map; +import stirling.software.common.cluster.JobStoreEntry; import stirling.software.common.model.job.ResultFile; /** @@ -34,4 +36,33 @@ public record PolicyRunView( run.getOutputs(), run.getCreatedAt().toEpochMilli()); } + + /** Cross-node view from a shared job-store entry; step cursor is node-local so it reads 0. */ + public static PolicyRunView ofEntry(JobStoreEntry entry) { + Map meta = entry.resultMeta() == null ? Map.of() : entry.resultMeta(); + PolicyRunStatus status = + switch (entry.state()) { + case COMPLETE -> PolicyRunStatus.COMPLETED; + case FAILED -> PolicyRunStatus.FAILED; + case RUNNING, PENDING -> PolicyRunStatus.RUNNING; + }; + List outputs = + entry.fileIds() == null + ? List.of() + : entry.fileIds().stream() + .map(id -> ResultFile.builder().fileId(id).build()) + .toList(); + long createdAt = entry.createdAt() == null ? 0L : entry.createdAt().toEpochMilli(); + return new PolicyRunView( + entry.jobId(), + meta.get("policyId"), + status, + 0, + 0, + entry.error(), + null, + null, + outputs, + createdAt); + } } 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 52da21208a..c06f18a846 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 @@ -15,7 +15,6 @@ import java.util.List; import java.util.UUID; import java.util.stream.Stream; -import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.core.io.Resource; import org.springframework.http.MediaType; import org.springframework.http.MediaTypeFactory; @@ -41,7 +40,6 @@ import stirling.software.proprietary.policy.model.OutputSpec; @Slf4j @Service @RequiredArgsConstructor -@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 904eb20523..78531eaa9c 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,6 @@ import java.io.InputStream; import java.util.ArrayList; import java.util.List; -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 +22,6 @@ import stirling.software.proprietary.policy.model.OutputSpec; */ @Service @RequiredArgsConstructor -@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/output/PolicyInlineOutputMigration.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/PolicyInlineOutputMigration.java new file mode 100644 index 0000000000..a93b60d000 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/PolicyInlineOutputMigration.java @@ -0,0 +1,144 @@ +package stirling.software.proprietary.policy.output; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.policy.migration.CompletedMigrations; +import stirling.software.proprietary.policy.model.OutputSpec; +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; + +/** + * One-time, idempotent migration of policies' inline output destinations onto stored {@link Source} + * references: policies written before a destination was a saved location carry their folder/S3 + * destination inline; this points each at a {@link Source} (reusing one at the same location, or + * creating it) so the destination becomes a managed location like any other source. Policies with + * an inline "return to caller" output have no location to store and are left as-is. + * + *

    Idempotent by construction: a policy that already carries an {@code outputId} is skipped, so a + * sequential re-run finds nothing to do. Matches are keyed by the write-relevant config within a + * team, so an output to a folder/prefix an input source already covers links to that same source - + * unifying the "output of A is the input of B" case onto one location. A concurrent multi-node boot + * can at worst create a redundant (unreferenced) source row, never corrupt a policy. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class PolicyInlineOutputMigration { + + /** Completion-marker id: once recorded, later boots skip the scan entirely. */ + private static final String MIGRATION_ID = "policy-inline-output"; + + // Destination types worth persisting as a location; "inline" has nothing to store. + private static final List DESTINATION_TYPES = List.of("folder", "s3"); + // The options that actually address a write destination, per type. Read-only options (e.g. a + // folder's consume mode) are excluded so an output matches an existing input source at the same + // place regardless of how that source reads. + private static final Map> ADDRESS_OPTIONS = + Map.of("folder", List.of("directory"), "s3", List.of("connectionId", "prefix")); + // Field separator for the dedup key: a unit-separator control char that cannot appear in a + // directory/prefix/connection id, so distinct field sets can never collide. + private static final char DELIMITER = '\u001f'; + + private final PolicyStore policyStore; + private final SourceStore sourceStore; + private final CompletedMigrations completedMigrations; + + // Runs after EmbeddedS3CredentialMigration (@Order(1)) so any legacy S3 output has already had + // its embedded credentials extracted into a connection; the Source created here then references + // that connection rather than copying credentials into source_json. Not wrapped in a single + // transaction: each store write is its own (idempotent) commit, so a crash mid-run just re-runs + // next boot, and the marker below is written only once the whole pass succeeds. + @Order(2) + @EventListener(ApplicationReadyEvent.class) + public void migrate() { + if (completedMigrations.isDone(MIGRATION_ID)) { + return; + } + Map byAddress = indexExistingSources(); + int migrated = 0; + for (Policy policy : policyStore.all()) { + if (!policy.outputIds().isEmpty()) { + continue; // already references one or more locations + } + OutputSpec output = policy.output(); + if (output == null || !DESTINATION_TYPES.contains(output.type())) { + continue; // inline / editor / no location to migrate + } + Source destination = destinationFor(policy, output, byAddress); + policyStore.save(policy.withOutputIds(List.of(destination.id()))); + migrated++; + } + if (migrated > 0) { + log.info("Linked {} policy output(s) to stored source locations", migrated); + } + completedMigrations.markDone(MIGRATION_ID); + } + + /** Reuses an existing team source at the same address, else creates a minimal one. */ + private Source destinationFor(Policy policy, OutputSpec spec, Map byAddress) { + String key = addressKey(policy.teamId(), spec.type(), spec.options()); + Source existing = byAddress.get(key); + if (existing != null) { + return existing; + } + Source created = + sourceStore.save( + new Source( + null, + destinationName(spec), + spec.type(), + spec.options(), + true, + policy.owner(), + policy.teamId())); + byAddress.put(key, created); + return created; + } + + private Map indexExistingSources() { + Map byKey = new LinkedHashMap<>(); + for (Source source : sourceStore.all()) { + if (!DESTINATION_TYPES.contains(source.type())) { + continue; + } + byKey.putIfAbsent(addressKey(source.teamId(), source.type(), source.options()), source); + } + return byKey; + } + + private static String addressKey(Long teamId, String type, Map options) { + StringBuilder key = new StringBuilder(); + key.append(teamId == null ? "" : teamId).append(DELIMITER); + key.append(type == null ? "" : type).append(DELIMITER); + for (String option : ADDRESS_OPTIONS.getOrDefault(type, List.of())) { + Object value = options.get(option); + key.append(value == null ? "" : value.toString()).append(DELIMITER); + } + return key.toString(); + } + + /** A readable default name derived from the destination; the user can rename it later. */ + private static String destinationName(OutputSpec spec) { + if ("folder".equals(spec.type())) { + Object directory = spec.options().get("directory"); + return directory == null ? "Folder" : "Folder: " + directory; + } + if ("s3".equals(spec.type())) { + Object prefix = spec.options().get("prefix"); + return prefix == null || prefix.toString().isBlank() ? "S3 bucket" : "S3: " + prefix; + } + return spec.type(); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/PolicyOutputResolver.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/PolicyOutputResolver.java new file mode 100644 index 0000000000..3e9d804683 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/PolicyOutputResolver.java @@ -0,0 +1,53 @@ +package stirling.software.proprietary.policy.output; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.policy.model.OutputSpec; +import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.source.Source; +import stirling.software.proprietary.policy.source.SourceStore; + +/** + * Resolves a policy's effective output destinations at run time: each {@code outputId} references a + * {@link Source} used as a destination, looked up live (so editing a location updates every policy + * that writes to it), exactly as input {@code sourceIds} are resolved. A run is delivered to every + * resolved destination. A policy with no references keeps its inline output (results returned to + * the caller) - the case for editor and one-off policies. A reference that no longer resolves + * (location deleted out from under a live policy - normally blocked by the source delete guard) is + * skipped; if none resolve, delivery falls back to inline so the run still completes. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class PolicyOutputResolver { + + private final SourceStore sourceStore; + + public List resolve(Policy policy) { + List outputIds = policy.outputIds(); + if (outputIds.isEmpty()) { + return List.of(policy.output()); + } + List resolved = new ArrayList<>(); + for (String outputId : outputIds) { + sourceStore + .get(outputId) + .map(Source::toOutputSpec) + .ifPresentOrElse( + resolved::add, + () -> + log.warn( + "Policy {} references missing output source {}; skipping" + + " that destination", + policy.id(), + outputId)); + } + return resolved.isEmpty() ? List.of(policy.output()) : resolved; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/S3OutputSink.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/S3OutputSink.java index c7d740868a..b0bd210e10 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/S3OutputSink.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/S3OutputSink.java @@ -13,7 +13,6 @@ import java.util.HexFormat; import java.util.List; import java.util.UUID; -import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.core.io.Resource; import org.springframework.http.MediaType; import org.springframework.http.MediaTypeFactory; @@ -27,6 +26,7 @@ import stirling.software.proprietary.policy.ledger.ProcessedLedger; import stirling.software.proprietary.policy.model.OutputSpec; import stirling.software.proprietary.policy.s3.S3Config; import stirling.software.proprietary.policy.s3.S3ConnectionPool; +import stirling.software.proprietary.policy.s3.S3ConnectionResolver; import stirling.software.proprietary.policy.s3.S3Identities; import software.amazon.awssdk.core.exception.SdkException; @@ -53,12 +53,12 @@ import software.amazon.awssdk.services.s3.model.S3Exception; @Slf4j @Service @RequiredArgsConstructor -@ConditionalOnBooleanProperty(name = "policies.enabled") public class S3OutputSink implements PolicyOutputSink { private static final String TYPE = "s3"; private final S3ConnectionPool connectionPool; + private final S3ConnectionResolver connectionResolver; private final ProcessedLedger processedLedger; @Override @@ -72,19 +72,19 @@ public class S3OutputSink implements PolicyOutputSink { } /** - * Config shape and endpoint guard only - no network probe, since write-only credentials - * (s3:PutObject without s3:ListBucket) are a legitimate setup for an output bucket and a - * listing probe would wrongly reject them. + * Connection resolution (including the saving user's right to use it) and endpoint guard only - + * no network probe, since write-only credentials (s3:PutObject without s3:ListBucket) are a + * legitimate setup for an output bucket and a listing probe would wrongly reject them. */ @Override public void validate(OutputSpec spec) { - connectionPool.clientFor(S3Config.from(spec.options())); + connectionPool.clientFor(connectionResolver.resolve(spec.options())); } @Override public List deliver( OutputDelivery delivery, List outputs, OutputSpec spec) throws IOException { - S3Config config = S3Config.from(spec.options()); + S3Config config = connectionResolver.resolve(spec.options()); S3Client client = connectionPool.clientFor(config); List results = new ArrayList<>(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java index 5ba7856606..c927ec199c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java @@ -4,8 +4,8 @@ import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; -import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.stereotype.Service; import lombok.RequiredArgsConstructor; @@ -29,7 +29,6 @@ import stirling.software.proprietary.policy.store.PolicyStore; */ @Service @RequiredArgsConstructor -@ConditionalOnBooleanProperty(name = "policies.enabled") public class PolicyOverviewService { private final PolicyStore policyStore; @@ -77,10 +76,25 @@ public class PolicyOverviewService { triggerSummary(policy.trigger()), sources, steps, - outputSummary(policy.output()), + outputSummary(policy, sourceNames), policy.owner()); } + /** + * A policy that delivers to sources shows those locations' display names, comma-joined (each + * falling back to its id if it's since been deleted or isn't visible); otherwise the inline + * output's type. + */ + private static String outputSummary(Policy policy, Map sourceNames) { + List outputIds = policy.outputIds(); + if (!outputIds.isEmpty()) { + return outputIds.stream() + .map(id -> sourceNames.getOrDefault(id, id)) + .collect(Collectors.joining(", ")); + } + return outputSummary(policy.output()); + } + /** A null trigger is a manual-only policy; otherwise the trigger's type keys the summary. */ private static String triggerSummary(TriggerConfig trigger) { return trigger == null ? "manual" : trigger.type(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/EmbeddedS3CredentialMigration.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/EmbeddedS3CredentialMigration.java new file mode 100644 index 0000000000..bbbb9cb8b1 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/EmbeddedS3CredentialMigration.java @@ -0,0 +1,215 @@ +package stirling.software.proprietary.policy.s3; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.access.model.DefaultAccessPolicy; +import stirling.software.proprietary.access.model.OwnerScope; +import stirling.software.proprietary.integration.model.IntegrationConfig; +import stirling.software.proprietary.integration.model.IntegrationType; +import stirling.software.proprietary.integration.repository.IntegrationConfigRepository; +import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.policy.migration.CompletedMigrations; +import stirling.software.proprietary.policy.model.OutputSpec; +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; +import stirling.software.proprietary.security.repository.TeamRepository; + +import tools.jackson.databind.ObjectMapper; + +/** + * One-time, idempotent extraction of legacy embedded S3 credentials into stored connections: + * sources and policy outputs written before connections shipped carry bucket/credentials in their + * own options; this rewrites each to reference a (deduplicated) S3 {@link IntegrationConfig} and + * keeps only per-use options (prefix, mode). MUST be programmatic - it parses and rewrites the + * option JSON (and decrypts any legacy ciphertext row on read), which no SQL migration can do. + * + *

    Idempotent by construction: rewritten rows no longer embed credentials, so re-runs find + * nothing to do. Connections are deduplicated against both this run's extractions and existing S3 + * connections; a concurrent multi-node boot can at worst create a redundant connection row, never + * corrupt a source. Ownership follows the owning row: team-scoped when the source/policy has a + * team, server-scoped otherwise (single-operator self-hosted). + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class EmbeddedS3CredentialMigration { + + /** Completion-marker id: once recorded, later boots skip the scan entirely. */ + private static final String MIGRATION_ID = "embedded-s3-credentials"; + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final List CONNECTION_OPTIONS = + List.of("bucket", "region", "endpoint", "accessKeyId", "secretAccessKey"); + // Field separator for the dedup key: a unit-separator control char that cannot appear in a + // bucket/region/endpoint/credential, so distinct field sets can never collide. + private static final char DELIMITER = '\u001f'; + + private final SourceStore sourceStore; + private final PolicyStore policyStore; + private final IntegrationConfigRepository connections; + private final TeamRepository teamRepository; + private final CompletedMigrations completedMigrations; + + // Must run before PolicyInlineOutputMigration: that migration copies a policy's inline output + // options into a Source, so embedded S3 credentials have to be extracted into a connection here + // first, or they would be copied verbatim (plaintext) into the new source row. Each rewrite is + // its own idempotent commit (dedup by credential key), so a crash mid-run just re-runs next + // boot; the completion marker below is written only once the whole pass succeeds. + @Order(1) + @EventListener(ApplicationReadyEvent.class) + public void migrate() { + if (completedMigrations.isDone(MIGRATION_ID)) { + return; + } + Map byCredentialKey = indexExistingConnections(); + int migrated = 0; + for (Source source : sourceStore.all()) { + if (!"s3".equals(source.type()) || !embedsCredentials(source.options())) { + continue; + } + IntegrationConfig connection = + connectionFor(source.options(), source.teamId(), byCredentialKey); + sourceStore.save(withOptions(source, referencing(connection, source.options(), true))); + migrated++; + } + for (Policy policy : policyStore.all()) { + OutputSpec output = policy.output(); + if (!"s3".equals(output.type()) || !embedsCredentials(output.options())) { + continue; + } + IntegrationConfig connection = + connectionFor(output.options(), policy.teamId(), byCredentialKey); + policyStore.save( + withOutput( + policy, + new OutputSpec( + output.type(), + referencing(connection, output.options(), false)))); + migrated++; + } + if (migrated > 0) { + log.info("Extracted embedded S3 credentials from {} row(s) into connections", migrated); + } + completedMigrations.markDone(MIGRATION_ID); + } + + private static boolean embedsCredentials(Map options) { + return options.get("accessKeyId") != null; + } + + /** Reuses an existing connection with identical coordinates+credentials, else creates one. */ + private IntegrationConfig connectionFor( + Map options, Long teamId, Map byKey) { + String key = credentialKey(options); + IntegrationConfig existing = byKey.get(key); + if (existing != null) { + return existing; + } + IntegrationConfig connection = new IntegrationConfig(); + connection.setIntegrationType(IntegrationType.S3); + connection.setName(connectionName(options, byKey)); + connection.setEnabled(true); + connection.setLocked(false); + connection.setDefaultAccess(DefaultAccessPolicy.EXPLICIT_ONLY); + Team team = teamId == null ? null : teamRepository.findById(teamId).orElse(null); + if (team != null) { + connection.setScope(OwnerScope.TEAM); + connection.setOwnerTeam(team); + } else { + // No team (teamless self-hosted, or a source whose team was since deleted): server + // scope, i.e. admin-owned. An orphaned-team source's non-admin editor would then need + // an admin to re-share the connection - acceptable for the narrow orphaned case. + connection.setScope(OwnerScope.SERVER); + } + Map config = new LinkedHashMap<>(); + for (String option : CONNECTION_OPTIONS) { + Object value = options.get(option); + if (value != null && !value.toString().isBlank()) { + config.put(option, value); + } + } + connection.setConfig(OBJECT_MAPPER.writeValueAsString(config)); + IntegrationConfig saved = connections.save(connection); + byKey.put(key, saved); + return saved; + } + + /** The rewritten options: the connection reference plus per-use settings only. */ + private static Map referencing( + IntegrationConfig connection, Map legacy, boolean keepMode) { + Map options = new LinkedHashMap<>(); + options.put(S3ConnectionResolver.CONNECTION_ID_OPTION, connection.getId()); + Object prefix = legacy.get("prefix"); + if (prefix != null && !prefix.toString().isBlank()) { + options.put("prefix", prefix); + } + Object mode = legacy.get("mode"); + if (keepMode && mode != null && !mode.toString().isBlank()) { + options.put("mode", mode); + } + return options; + } + + private Map indexExistingConnections() { + Map byKey = new LinkedHashMap<>(); + for (IntegrationConfig connection : connections.findAll()) { + if (connection.getIntegrationType() != IntegrationType.S3) { + continue; + } + try { + Map config = + OBJECT_MAPPER.readValue(connection.getConfig(), Map.class); + byKey.putIfAbsent(credentialKey(config), connection); + } catch (Exception e) { + log.debug( + "Skipping unreadable S3 connection {} while indexing: {}", + connection.getId(), + e.getMessage()); + } + } + return byKey; + } + + private static String credentialKey(Map options) { + StringBuilder key = new StringBuilder(); + for (String option : CONNECTION_OPTIONS) { + Object value = options.get(option); + key.append(value == null ? "" : value.toString().trim()).append(DELIMITER); + } + return key.toString(); + } + + private static String connectionName( + Map options, Map byKey) { + String base = "S3: " + options.getOrDefault("bucket", "bucket"); + long sameName = byKey.values().stream().filter(c -> c.getName().startsWith(base)).count(); + return sameName == 0 ? base : base + " (" + (sameName + 1) + ")"; + } + + private static Source withOptions(Source source, Map options) { + return new Source( + source.id(), + source.name(), + source.type(), + options, + source.enabled(), + source.owner(), + source.teamId()); + } + + private static Policy withOutput(Policy policy, OutputSpec output) { + return policy.withOutput(output); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/PolicyS3ConnectionUsageCheck.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/PolicyS3ConnectionUsageCheck.java new file mode 100644 index 0000000000..276ca1b002 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/PolicyS3ConnectionUsageCheck.java @@ -0,0 +1,54 @@ +package stirling.software.proprietary.policy.s3; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; + +import stirling.software.proprietary.integration.service.IntegrationConfigUsageCheck; +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; + +/** + * Reports the policy sources and pipeline outputs referencing an S3 connection, so the connection + * cannot be deleted out from under them (mirrors {@code SourceController}'s referenced-source + * delete guard). Scans in memory - fine at admin-dashboard scale, always consistent with the live + * stores. + */ +@Component +@RequiredArgsConstructor +public class PolicyS3ConnectionUsageCheck implements IntegrationConfigUsageCheck { + + private final SourceStore sourceStore; + private final PolicyStore policyStore; + + @Override + public List usagesOf(long configId) { + List usages = new ArrayList<>(); + for (Source source : sourceStore.all()) { + if (references(source.options(), configId)) { + usages.add("source '" + source.name() + "'"); + } + } + for (Policy policy : policyStore.all()) { + if (references(policy.output().options(), configId)) { + usages.add("pipeline '" + policy.name() + "'"); + } + } + return usages; + } + + private static boolean references(Map options, long configId) { + try { + Long reference = S3ConnectionResolver.connectionId(options); + return reference != null && reference == configId; + } catch (IllegalArgumentException unparseable) { + return false; + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3Config.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3Config.java index 152a6de4cf..c9d3eabd83 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3Config.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3Config.java @@ -5,10 +5,12 @@ import java.net.URISyntaxException; import java.util.Map; /** - * Connection settings shared by the S3 input source and output sink, parsed from a spec's options - * map. Credentials are required: there is deliberately no fallback to the server's own AWS - * credential chain, so user-supplied config can never borrow the host's identity. {@code snapshot} - * is input-only and ignored by the sink. + * The fully resolved connection settings the S3 input source and output sink run with - normally + * produced by {@link S3ConnectionResolver} merging a stored connection (bucket, region, endpoint, + * credentials) with per-use options (prefix, mode), or parsed directly from legacy options that + * still embed credentials. Credentials are required: there is deliberately no fallback to the + * server's own AWS credential chain, so user-supplied config can never borrow the host's identity. + * {@code snapshot} is input-only and ignored by the sink. */ public record S3Config( String bucket, diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3ConnectionPool.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3ConnectionPool.java index 4118557dbd..145295d462 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3ConnectionPool.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3ConnectionPool.java @@ -6,7 +6,6 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.function.Function; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.stereotype.Service; import jakarta.annotation.PreDestroy; @@ -32,7 +31,6 @@ import software.amazon.awssdk.services.s3.S3Configuration; * users rather than the operator. */ @Service -@ConditionalOnBooleanProperty(name = "policies.enabled") public class S3ConnectionPool { private final ApplicationProperties applicationProperties; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3ConnectionResolver.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3ConnectionResolver.java new file mode 100644 index 0000000000..f8b39b6e36 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3ConnectionResolver.java @@ -0,0 +1,149 @@ +package stirling.software.proprietary.policy.s3; + +import java.util.LinkedHashMap; +import java.util.Map; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.access.model.ResourceType; +import stirling.software.proprietary.access.service.OwnershipService; +import stirling.software.proprietary.integration.model.IntegrationConfig; +import stirling.software.proprietary.integration.model.IntegrationType; +import stirling.software.proprietary.integration.repository.IntegrationConfigRepository; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.service.UserService; + +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.ObjectMapper; + +/** + * Turns a source's or output's options into a full {@link S3Config} by dereferencing its {@code + * connectionId} to a stored S3 {@link IntegrationConfig} (the connection owns bucket, region, + * endpoint, and credentials; the options own per-use settings such as prefix and mode). Options + * with no {@code connectionId} fall back to legacy embedded credentials, so rows written before + * connections shipped keep working until {@link EmbeddedS3CredentialMigration} rewrites them. + * + *

    When an authenticated caller is present (save-time validation), they must be allowed to use + * the connection. Background sweeps and deliveries run with no caller and skip that check: the + * referencing source or policy was access-checked when it was saved. + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class S3ConnectionResolver { + + static final String CONNECTION_ID_OPTION = "connectionId"; + private static final String PREFIX_OPTION = "prefix"; + private static final String MODE_OPTION = "mode"; + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private final IntegrationConfigRepository connections; + private final OwnershipService ownership; + private final UserService userService; + + public S3Config resolve(Map options) { + Long connectionId = connectionId(options); + if (connectionId == null) { + // Legacy embedded credentials, pending migration. + return S3Config.from(options); + } + IntegrationConfig connection = + connections + .findById(connectionId) + .filter(cfg -> cfg.getIntegrationType() == IntegrationType.S3) + .filter(this::usableByCurrentUser) + // Existence and access collapse into one error: a caller must not be able + // to tell "no such connection" from "someone else's connection" and + // enumerate ids. The id/name are never echoed. + .orElseThrow( + () -> + new IllegalArgumentException( + "unknown or inaccessible s3 connection")); + if (!connection.isEnabled()) { + throw new IllegalArgumentException("s3 connection is disabled"); + } + Map merged = new LinkedHashMap<>(connectionConfig(connection)); + copyPerUseOption(options, merged, PREFIX_OPTION); + copyPerUseOption(options, merged, MODE_OPTION); + return S3Config.from(merged); + } + + /** The {@code connectionId} option as a long, or null when the options are legacy-embedded. */ + static Long connectionId(Map options) { + Object reference = options.get(CONNECTION_ID_OPTION); + if (reference == null || (reference instanceof String s && s.isBlank())) { + return null; + } + if (reference instanceof Number number) { + return number.longValue(); + } + try { + return Long.valueOf(reference.toString().trim()); + } catch (NumberFormatException e) { + throw new IllegalArgumentException( + "s3 'connectionId' is not a valid connection reference: " + reference); + } + } + + /** + * Whether the current caller may use this connection. With no principal - a background sweep or + * delivery on a worker thread that carries no {@code SecurityContext} - access is treated as + * already established: stored policies are validated with the caller present at save time, and + * ad-hoc runs are validated on the request thread before dispatch (see {@code + * PolicyValidator#validateOutput}). A missing principal must therefore never be the ONLY thing + * standing between a caller and a connection, or the check becomes a confused deputy. + */ + private boolean usableByCurrentUser(IntegrationConfig connection) { + User user = currentUser(); + return user == null || ownership.canUse(ResourceType.INTEGRATION_CONFIG, connection, user); + } + + // Mirrors ResourceAccessSecurity's principal resolution; null when unauthenticated. + private User currentUser() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null || !auth.isAuthenticated()) { + return null; + } + Object principal = auth.getPrincipal(); + if (principal instanceof User user) { + return user; + } + if (principal instanceof UserDetails userDetails) { + return userService.findByUsername(userDetails.getUsername()).orElse(null); + } + if (principal instanceof String username && !"anonymousUser".equals(username)) { + return userService.findByUsername(username).orElse(null); + } + return null; + } + + private static Map connectionConfig(IntegrationConfig connection) { + String json = connection.getConfig(); + if (json == null || json.isBlank()) { + return Map.of(); + } + try { + return OBJECT_MAPPER.readValue( + json, new TypeReference>() {}); + } catch (Exception e) { + throw new IllegalArgumentException( + "s3 connection '" + connection.getName() + "' has unreadable config", e); + } + } + + private static void copyPerUseOption( + Map options, Map merged, String key) { + Object value = options.get(key); + if (value != null && !value.toString().isBlank()) { + merged.put(key, value); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3IntegrationValidator.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3IntegrationValidator.java new file mode 100644 index 0000000000..026c3f2377 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3IntegrationValidator.java @@ -0,0 +1,49 @@ +package stirling.software.proprietary.policy.s3; + +import java.net.URI; +import java.util.Map; + +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.cluster.s3.S3Clients; +import stirling.software.proprietary.integration.model.IntegrationType; +import stirling.software.proprietary.integration.service.IntegrationConfigValidator; + +/** + * The S3 connection schema, enforced when an S3 {@link IntegrationType} config is saved: bucket and + * credentials required, endpoint an http(s) URL that must not reach private addresses without the + * operator opt-in - the same rules {@link S3ConnectionPool} enforces before signing, moved to save + * time so a bad connection fails in the form rather than in a sweep. + */ +@Component +@RequiredArgsConstructor +public class S3IntegrationValidator implements IntegrationConfigValidator { + + private final ApplicationProperties applicationProperties; + + @Override + public IntegrationType type() { + return IntegrationType.S3; + } + + @Override + public void validate(Map config) { + S3Config parsed = S3Config.from(config); + if (parsed.endpoint() == null) { + return; + } + try { + S3Clients.validateEndpointHost( + URI.create(parsed.endpoint()), + applicationProperties.getPolicies().isAllowPrivateS3Endpoints(), + "S3 connection endpoint", + "set policies.allowPrivateS3Endpoints=true to opt in (e.g. for a local" + + " MinIO)."); + } catch (IllegalStateException e) { + throw new IllegalArgumentException(e.getMessage(), e); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeeder.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeeder.java new file mode 100644 index 0000000000..4150dc2f31 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeeder.java @@ -0,0 +1,96 @@ +package stirling.software.proprietary.policy.seed; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.model.TeamCreatedEvent; +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.PolicyStore; +import stirling.software.proprietary.security.repository.TeamRepository; +import stirling.software.proprietary.security.service.TeamService; + +/** + * Seeds an enabled Classification policy per team so classification is on by default. Idempotent; + * skips the internal team. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class DefaultClassificationPolicySeeder { + + static final String CATEGORY = "classification"; + private static final String CLASSIFY_ENDPOINT = "/api/v1/ai/tools/classify-and-label"; + private static final String POLICY_NAME = "Classification Policy"; + + private final PolicyStore policyStore; + private final TeamRepository teamRepository; + + // The default team is created during startup, before the entity event listener is guaranteed + // wired, so ensure it once the context is fully ready (self-hosted first boot). + @EventListener(ApplicationReadyEvent.class) + public void seedDefaultTeamOnStartup() { + teamRepository + .findByName(TeamService.DEFAULT_TEAM_NAME) + .ifPresent(team -> seedIfMissing(team.getId(), team.getName())); + } + + // Any team created at runtime (admin-created, SaaS sign-ups). Seeds inside the team's own + // transaction: rollback still leaves no policy behind, and the store's pessimistic lock needs a + // live transaction, which AFTER_COMMIT cannot offer. + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + public void onTeamCreated(TeamCreatedEvent event) { + seedIfMissing(event.teamId(), event.teamName()); + } + + private void seedIfMissing(Long teamId, String teamName) { + if (teamId == null || TeamService.INTERNAL_TEAM_NAME.equals(teamName)) { + return; + } + boolean alreadySeeded = + policyStore.findByTeam(teamId).stream() + .anyMatch(DefaultClassificationPolicySeeder::isClassification); + if (alreadySeeded) { + return; + } + policyStore.save(defaultPolicy(teamId)); + log.info("Seeded default Classification policy for team {}", teamId); + } + + private static boolean isClassification(Policy policy) { + return policy.output() != null + && CATEGORY.equals(policy.output().options().get("categoryId")); + } + + /** The default Classification policy: classify each upload, versioning the file in place. */ + static Policy defaultPolicy(Long teamId) { + Map options = new HashMap<>(); + options.put("categoryId", CATEGORY); + options.put("runOn", "upload"); + options.put("mode", "new_version"); + options.put("sources", List.of("editor")); + options.put("scopeTypes", List.of()); + options.put("reviewerEmail", ""); + return new Policy( + null, + POLICY_NAME, + "system", + true, + null, + List.of(), + List.of(new PipelineStep(CLASSIFY_ENDPOINT, Map.of())), + new OutputSpec("inline", options), + teamId); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/JpaSourceDocCounter.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/JpaSourceDocCounter.java index af67953d77..11a82187c9 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/JpaSourceDocCounter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/JpaSourceDocCounter.java @@ -10,7 +10,6 @@ import java.util.function.IntSupplier; import java.util.function.Supplier; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; @@ -24,7 +23,6 @@ import org.springframework.stereotype.Service; * table stays bounded (~one row per source per active hour, for at most 30 days). */ @Service -@ConditionalOnBooleanProperty(name = "policies.enabled") public class JpaSourceDocCounter implements SourceDocCounter { private final SourceDocCountRepository countRepository; 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 index 18bbf5cbdc..259159a9db 100644 --- 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 @@ -4,10 +4,10 @@ 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 lombok.extern.slf4j.Slf4j; import tools.jackson.databind.ObjectMapper; @@ -15,9 +15,9 @@ 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. */ +@Slf4j @Service @RequiredArgsConstructor -@ConditionalOnBooleanProperty(name = "policies.enabled") public class JpaSourceStore implements SourceStore { private final SourceRepository repository; @@ -53,17 +53,20 @@ public class JpaSourceStore implements SourceStore { @Override public Optional get(String id) { - return repository.findById(id).map(this::toSource); + return repository.findById(id).flatMap(this::toSource); } @Override public List all() { - return repository.findAll().stream().map(this::toSource).toList(); + return repository.findAll().stream().map(this::toSource).flatMap(Optional::stream).toList(); } @Override public List findByTeam(Long teamId) { - return repository.findByTeam(teamId).stream().map(this::toSource).toList(); + return repository.findByTeam(teamId).stream() + .map(this::toSource) + .flatMap(Optional::stream) + .toList(); } @Override @@ -75,7 +78,20 @@ public class JpaSourceStore implements SourceStore { return true; } - private Source toSource(SourceEntity entity) { - return objectMapper.readValue(entity.getSourceJson(), Source.class); + // Skip (don't fail) rows whose JSON can't be read - e.g. written by another app version/key. + // One unreadable row must never abort a bulk read or crash startup. + private Optional toSource(SourceEntity entity) { + try { + return Optional.of(objectMapper.readValue(entity.getSourceJson(), Source.class)); + } catch (Exception e) { + log.error( + "Skipping unreadable policy source id={} name={}: stored JSON could not be" + + " parsed ({}). Likely written by a different app version or" + + " encryption key.", + entity.getId(), + entity.getName(), + e.getMessage()); + return Optional.empty(); + } } } 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 index ca80c1b46f..c064a33d9a 100644 --- 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 @@ -3,14 +3,18 @@ package stirling.software.proprietary.policy.source; import java.util.Map; import stirling.software.proprietary.policy.model.InputSpec; +import stirling.software.proprietary.policy.model.OutputSpec; /** - * 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. + * A persisted, reusable storage location: the instantiation of a source definition. Policies + * reference sources by {@code id} rather than embedding their config, so one location is configured + * once and can be used by many policies - as an input (files come from it) and/or as an output (a + * run's files are delivered to it), which is how a folder or bucket can be both the output of one + * pipeline and the input of the next. * - *

    {@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 type} keys an {@link stirling.software.proprietary.policy.input.InputSource} bean (and, + * for writable types, a {@link stirling.software.proprietary.policy.output.PolicyOutputSink}), + * matching {@link InputSpec#type()}; {@code options} is that location's config. {@code owner} and * {@code teamId} scope the source to a team, mirroring {@link * stirling.software.proprietary.policy.model.Policy}. */ @@ -27,8 +31,17 @@ public record Source( options = options == null ? Map.of() : options; } - /** The runtime form the policy engine resolves and runs against. */ + /** The runtime form the policy engine resolves and reads inputs from. */ public InputSpec toInputSpec() { return new InputSpec(type, options); } + + /** + * The runtime form the policy engine delivers a run's outputs to, when this source is used as a + * policy's destination. Read-only options (e.g. a folder's consume mode) are simply ignored by + * the output sink. + */ + public OutputSpec toOutputSpec() { + return new OutputSpec(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 index f5a14282c9..72ff59e914 100644 --- 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 @@ -3,7 +3,6 @@ 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; @@ -20,7 +19,6 @@ import stirling.software.proprietary.policy.config.PolicyManagementAuthority; */ @Component @RequiredArgsConstructor -@ConditionalOnBooleanProperty(name = "policies.enabled") public class SourceAccessGuard { private final UserServiceInterface userService; 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 index 524b8c369c..73622738f0 100644 --- 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 @@ -2,12 +2,14 @@ package stirling.software.proprietary.policy.source; import java.util.List; import java.util.Map; +import java.util.Optional; -import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import org.springframework.http.ProblemDetail; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -23,6 +25,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.policy.config.FolderAccessDeniedException; import stirling.software.proprietary.policy.config.PolicyAccessGuard; import stirling.software.proprietary.policy.config.PolicyManagementAuthority; import stirling.software.proprietary.policy.input.InputSource; @@ -43,9 +46,17 @@ import stirling.software.proprietary.util.SecretMasker; @Hidden @RequiredArgsConstructor @Tag(name = "Sources", description = "Reusable policy input connections") -@ConditionalOnBooleanProperty(name = "policies.enabled") public class SourceController { + /** + * Machine-readable marker on the error body when a folder source is rejected for pointing + * outside the allowed roots. The admin portal keys off this to offer a link straight to the + * Folder Access settings rather than only showing the message. + */ + public static final String FOLDER_ACCESS_DENIED_CODE = "folderAccessDenied"; + + private static final String WEBHOOK_TYPE = "webhook"; + private final SourceStore sourceStore; private final SourceAccessGuard sourceAccessGuard; private final SourceOverviewService overviewService; @@ -109,9 +120,14 @@ public class SourceController { public ResponseEntity save(@RequestBody Source source) { requireSourceEditingAllowed(); requireNotEditor(source.id(), source.type()); - Source owned = withStoredSecrets(resolveOwnership(source)); + boolean isCreate = source.id() == null || source.id().isBlank(); + Source owned = withPreparedOptions(withStoredSecrets(resolveOwnership(source)), isCreate); try { validateConfig(owned); + } catch (FolderAccessDeniedException e) { + // Surfaced with a machine-readable code by handleFolderAccessDenied so the portal can + // link to the Folder Access settings; don't flatten it into a plain 400 here. + throw e; } catch (IllegalArgumentException e) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage()); } @@ -119,7 +135,7 @@ public class SourceController { // 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(withMaskedSecrets(saved)); + return ResponseEntity.ok(revealOnCreate(saved, isCreate)); } @DeleteMapping("/{sourceId}") @@ -148,6 +164,22 @@ public class SourceController { return ResponseEntity.noContent().build(); } + /** + * A folder source was rejected for pointing outside the allowed roots. Return a 400 carrying + * {@link #FOLDER_ACCESS_DENIED_CODE} so the portal can offer a link to the Folder Access + * settings, while other guard rejections (SaaS mode, the protected config dir) fall through to + * the global handler as plain 400s the admin can't fix by editing the allowlist. + */ + @ExceptionHandler(FolderAccessDeniedException.class) + public ResponseEntity handleFolderAccessDenied(FolderAccessDeniedException ex) { + ProblemDetail problem = + ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage()); + problem.setProperty("code", FOLDER_ACCESS_DENIED_CODE); + return ResponseEntity.badRequest() + .contentType(MediaType.APPLICATION_PROBLEM_JSON) + .body(problem); + } + /** * 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 @@ -221,14 +253,33 @@ public class SourceController { /** 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() + inputSourceFor(spec) .orElseThrow( () -> new IllegalArgumentException("unknown source type: " + source.type())) .validate(spec); } + private Source withPreparedOptions(Source source, boolean isCreate) { + InputSpec spec = source.toInputSpec(); + InputSource input = inputSourceFor(spec).orElse(null); + if (input == null) { + return source; + } + Map prepared = input.prepareOptionsForSave(source.options(), isCreate); + return prepared == null ? source : withOptions(source, prepared); + } + + private static Source revealOnCreate(Source saved, boolean isCreate) { + if (isCreate && WEBHOOK_TYPE.equals(saved.type())) { + return saved; + } + return withMaskedSecrets(saved); + } + + private Optional inputSourceFor(InputSpec spec) { + return inputSources.stream().filter(input -> input.supports(spec)).findFirst(); + } + /** * 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. @@ -256,10 +307,17 @@ public class SourceController { } } - /** Names of the caller's visible policies that reference the given source. */ + /** + * Names of the caller's visible policies that reference the given source - as an input ({@code + * sourceIds}) or as their output destination ({@code outputId}), so a location in use either + * way is protected from deletion. + */ private List referencingPolicyNames(String sourceId) { return policyAccessGuard.visibleFrom(policyStore).stream() - .filter(policy -> policy.sourceIds().contains(sourceId)) + .filter( + policy -> + policy.sourceIds().contains(sourceId) + || policy.outputIds().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 index c94e4cb079..728612742e 100644 --- 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 @@ -12,7 +12,7 @@ import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; -import stirling.software.proprietary.integration.crypto.LenientEncryptedStringConverter; +import stirling.software.proprietary.integration.crypto.LegacyDecryptStringConverter; /** * JPA row for a {@link Source}. The whole source lives as JSON in {@code sourceJson} (authoritative @@ -48,9 +48,9 @@ public class SourceEntity implements Serializable { @Column(name = "enabled") private boolean enabled; - // Encrypted at rest: source options carry user-supplied credentials (e.g. an S3 secret - // access key). Lenient so rows written before encryption shipped still load. - @Convert(converter = LenientEncryptedStringConverter.class) + // Plaintext at rest: the S3 credentials that used to live here now sit in a referenced + // IntegrationConfig connection (still encrypted). Decrypts legacy ciphertext on read. + @Convert(converter = LegacyDecryptStringConverter.class) @Column(name = "source_json", columnDefinition = "text") private String sourceJson; } 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 index f202a30e23..0f9df21440 100644 --- 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 @@ -3,10 +3,11 @@ package stirling.software.proprietary.policy.source; import java.util.ArrayList; import java.util.Comparator; import java.util.HashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Set; -import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.stereotype.Service; import lombok.RequiredArgsConstructor; @@ -24,7 +25,6 @@ import stirling.software.proprietary.util.SecretMasker; */ @Service @RequiredArgsConstructor -@ConditionalOnBooleanProperty(name = "policies.enabled") public class SourceOverviewService { private final SourceStore sourceStore; @@ -102,7 +102,8 @@ public class SourceOverviewService { List.of(), docs.total(), docs.last24h(), - docs.last30d()); + docs.last30d(), + null); } /** @@ -116,11 +117,17 @@ public class SourceOverviewService { return sources instanceof List list && list.contains(EditorSource.ID); } - /** Policies referencing each source id, across the caller's visible policies. */ + /** + * Policies referencing each source id, across the caller's visible policies. A source counts + * whether a policy reads from it ({@code sourceIds}) or writes to it ({@code outputId}); a + * policy that does both counts once. + */ private static Map> referencesBySource(List policies) { Map> bySource = new HashMap<>(); for (Policy policy : policies) { - for (String sourceId : policy.sourceIds()) { + Set referenced = new LinkedHashSet<>(policy.sourceIds()); + referenced.addAll(policy.outputIds()); + for (String sourceId : referenced) { bySource.computeIfAbsent(sourceId, key -> new ArrayList<>()).add(policy); } } @@ -143,7 +150,16 @@ public class SourceOverviewService { configRows(source), docs.total(), docs.last24h(), - docs.last30d()); + docs.last30d(), + webhookPath(source)); + } + + private static String webhookPath(Source source) { + if (!"webhook".equals(source.type())) { + return null; + } + Object webhookId = source.options().get("webhookId"); + return webhookId == null ? null : "/api/v1/webhooks/" + webhookId; } /** A disabled (paused) source reads as "disabled"; an unreferenced one reads as "unused". */ 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 index 6c6a9c4c6d..077edf62d8 100644 --- 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 @@ -17,7 +17,8 @@ public record SourceView( List config, long docsTotal, long docs24h, - long docs30d) { + long docs30d, + String webhookPath) { /** A policy that references this source. */ public record PolicyRef(String id, String name) {} 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 685ddb21db..6498e7e28a 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 @@ -36,6 +36,7 @@ public class InProcessPolicyStore implements PolicyStore { policy.sourceIds(), policy.steps(), policy.output(), + policy.outputIds(), policy.teamId()); policies.put(id, stored); // Existing policy keeps its position; a new one appends to the end of its team's queue. 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 5089b8d419..1b598b88c1 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 @@ -5,11 +5,11 @@ import java.util.Objects; import java.util.Optional; import java.util.UUID; -import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import stirling.software.proprietary.policy.model.Policy; @@ -19,9 +19,9 @@ import tools.jackson.databind.ObjectMapper; * Durable {@link PolicyStore} backed by JPA; the runtime store. Policies are persisted as JSON via * {@link PolicyEntity}, with scalar columns kept in sync for querying. */ +@Slf4j @Service @RequiredArgsConstructor -@ConditionalOnBooleanProperty(name = "policies.enabled") public class JpaPolicyStore implements PolicyStore { private final PolicyRepository repository; @@ -44,6 +44,7 @@ public class JpaPolicyStore implements PolicyStore { policy.sourceIds(), policy.steps(), policy.output(), + policy.outputIds(), policy.teamId()); PolicyEntity entity = new PolicyEntity(); @@ -98,23 +99,30 @@ public class JpaPolicyStore implements PolicyStore { @Override public Optional get(String id) { - return repository.findById(id).map(this::toPolicy); + return repository.findById(id).flatMap(this::toPolicy); } @Override public List all() { - return repository.findAllOrdered().stream().map(this::toPolicy).toList(); + return repository.findAllOrdered().stream() + .map(this::toPolicy) + .flatMap(Optional::stream) + .toList(); } @Override public List findByTeam(Long teamId) { - return repository.findByTeam(teamId).stream().map(this::toPolicy).toList(); + return repository.findByTeam(teamId).stream() + .map(this::toPolicy) + .flatMap(Optional::stream) + .toList(); } @Override public List findByTriggerType(String triggerType) { return repository.findByTriggerTypeAndEnabledTrue(triggerType).stream() .map(this::toPolicy) + .flatMap(Optional::stream) .toList(); } @@ -127,7 +135,19 @@ public class JpaPolicyStore implements PolicyStore { return true; } - private Policy toPolicy(PolicyEntity entity) { - return objectMapper.readValue(entity.getPolicyJson(), Policy.class); + // Skip (don't fail) rows whose JSON can't be read - e.g. written by another app version/key. + // One unreadable row must never abort a bulk read or crash startup. + private Optional toPolicy(PolicyEntity entity) { + try { + return Optional.of(objectMapper.readValue(entity.getPolicyJson(), Policy.class)); + } catch (Exception e) { + log.error( + "Skipping unreadable policy id={} name={}: stored JSON could not be parsed" + + " ({}). Likely written by a different app version or encryption key.", + entity.getId(), + entity.getName(), + e.getMessage()); + return Optional.empty(); + } } } 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 e393e47833..c871267bc4 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 @@ -12,7 +12,7 @@ import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; -import stirling.software.proprietary.integration.crypto.LenientEncryptedStringConverter; +import stirling.software.proprietary.integration.crypto.LegacyDecryptStringConverter; /** * JPA row for a {@link stirling.software.proprietary.policy.model.Policy}. The whole policy lives @@ -58,9 +58,9 @@ public class PolicyEntity implements Serializable { @Column(name = "sort_order") private Integer sortOrder; - // Encrypted at rest: output options carry user-supplied credentials (e.g. an S3 secret - // access key). Lenient so rows written before encryption shipped still load. - @Convert(converter = LenientEncryptedStringConverter.class) + // Plaintext at rest: the S3 credentials that used to live here now sit in a referenced + // IntegrationConfig connection (still encrypted). Decrypts legacy ciphertext on read. + @Convert(converter = LegacyDecryptStringConverter.class) @Column(name = "policy_json", columnDefinition = "text") private String policyJson; } 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 dee78e8488..a4470a418b 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,6 @@ import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; -import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.stereotype.Service; import lombok.RequiredArgsConstructor; @@ -50,7 +49,6 @@ import stirling.software.proprietary.policy.store.PolicyStore; @Slf4j @Service @RequiredArgsConstructor -@ConditionalOnBooleanProperty(name = "policies.enabled") public class FolderWatchTrigger implements PolicyTrigger { private static final String TYPE = "folder-watch"; 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 7971e6b490..cf63853be9 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,7 +2,6 @@ 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.stereotype.Service; @@ -13,7 +12,6 @@ import lombok.extern.slf4j.Slf4j; @Slf4j @Service @RequiredArgsConstructor -@ConditionalOnBooleanProperty(name = "policies.enabled") public class PolicyTriggerManager implements SmartLifecycle { private final List triggers; 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 32ba4ec77d..2018ffd126 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,6 @@ import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; -import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.stereotype.Service; import lombok.RequiredArgsConstructor; @@ -32,7 +31,6 @@ import tools.jackson.databind.ObjectMapper; @Slf4j @Service @RequiredArgsConstructor -@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/policy/trigger/WebhookTrigger.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/WebhookTrigger.java new file mode 100644 index 0000000000..816f510bba --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/WebhookTrigger.java @@ -0,0 +1,130 @@ +package stirling.software.proprietary.policy.trigger; + +import java.util.Set; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.policy.engine.PolicyRunner; +import stirling.software.proprietary.policy.engine.SweepKind; +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; +import stirling.software.proprietary.policy.webhook.WebhookConfig; + +@Slf4j +@Service +@RequiredArgsConstructor +public class WebhookTrigger implements PolicyTrigger { + + static final String TYPE = "webhook"; + private static final String WEBHOOK_SOURCE_TYPE = "webhook"; + + private final PolicyStore policyStore; + private final PolicyRunner policyRunner; + private final SourceStore sourceStore; + private final ApplicationProperties applicationProperties; + + private volatile ScheduledExecutorService reconciler; + + @Override + public String type() { + return TYPE; + } + + @Override + public boolean requiresSource() { + return true; + } + + @Override + public Set supportedSourceTypes() { + return Set.of(WEBHOOK_SOURCE_TYPE); + } + + @Override + public void validate(Policy policy) { + boolean hasWebhookSource = + policy.sourceIds().stream() + .map(sourceStore::get) + .flatMap(java.util.Optional::stream) + .anyMatch(source -> WEBHOOK_SOURCE_TYPE.equals(source.type())); + if (!hasWebhookSource) { + throw new IllegalArgumentException( + "webhook trigger requires at least one webhook input source"); + } + } + + @Override + public synchronized void start() { + if (reconciler != null) { + return; + } + long reconcileSeconds = applicationProperties.getPolicies().getWatchReconcileSeconds(); + reconciler = + Executors.newSingleThreadScheduledExecutor( + Thread.ofVirtual().name("policy-webhook-reconcile-", 0).factory()); + reconciler.scheduleAtFixedRate(this::safeReconcile, 0, reconcileSeconds, TimeUnit.SECONDS); + log.info("Webhook trigger started (reconcile every {}s)", reconcileSeconds); + } + + @Override + public synchronized void stop() { + if (reconciler != null) { + reconciler.shutdownNow(); + reconciler = null; + } + } + + public void fireForWebhook(String webhookId) { + for (Policy policy : policyStore.findByTriggerType(TYPE)) { + if (!referencesWebhook(policy, webhookId)) { + continue; + } + try { + log.debug("Webhook policy {} ({}) saw a delivery", policy.id(), policy.name()); + policyRunner.run(policy, SweepKind.LIGHT); + } catch (RuntimeException e) { + log.warn("Webhook run failed for policy {}: {}", policy.id(), e.getMessage()); + } + } + } + + private void safeReconcile() { + try { + for (Policy policy : policyStore.findByTriggerType(TYPE)) { + try { + policyRunner.run(policy); + } catch (RuntimeException e) { + log.warn( + "Webhook reconcile run failed for policy {}: {}", + policy.id(), + e.getMessage()); + } + } + } catch (RuntimeException e) { + log.error("Webhook reconcile failed: {}", e.getMessage(), e); + } + } + + private boolean referencesWebhook(Policy policy, String webhookId) { + for (String sourceId : policy.sourceIds()) { + Source source = sourceStore.get(sourceId).orElse(null); + if (source == null || !WEBHOOK_SOURCE_TYPE.equals(source.type())) { + continue; + } + Object configured = source.options().get(WebhookConfig.WEBHOOK_ID_OPTION); + if (configured != null && configured.toString().equals(webhookId)) { + return true; + } + } + return false; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/webhook/WebhookConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/webhook/WebhookConfig.java new file mode 100644 index 0000000000..4757513314 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/webhook/WebhookConfig.java @@ -0,0 +1,37 @@ +package stirling.software.proprietary.policy.webhook; + +import java.util.Map; + +public record WebhookConfig(String webhookId, String signingSecret) { + + public static final String WEBHOOK_ID_OPTION = "webhookId"; + public static final String SIGNING_SECRET_OPTION = "signingSecret"; + + public static WebhookConfig from(Map options) { + String webhookId = trimmed(options.get(WEBHOOK_ID_OPTION)); + if (webhookId == null) { + throw new IllegalArgumentException("webhook config requires a 'webhookId' option"); + } + if (!WebhookIds.isValidId(webhookId)) { + throw new IllegalArgumentException("webhook config 'webhookId' has an invalid format"); + } + String signingSecret = trimmed(options.get(SIGNING_SECRET_OPTION)); + if (signingSecret == null) { + throw new IllegalArgumentException("webhook config requires a 'signingSecret' option"); + } + return new WebhookConfig(webhookId, signingSecret); + } + + private static String trimmed(Object value) { + if (value == null) { + return null; + } + String text = value.toString().trim(); + return text.isEmpty() ? null : text; + } + + @Override + public String toString() { + return "WebhookConfig[webhookId=" + webhookId + "]"; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/webhook/WebhookIds.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/webhook/WebhookIds.java new file mode 100644 index 0000000000..098e7d2972 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/webhook/WebhookIds.java @@ -0,0 +1,33 @@ +package stirling.software.proprietary.policy.webhook; + +import java.security.SecureRandom; +import java.util.Base64; +import java.util.regex.Pattern; + +public final class WebhookIds { + + private static final Pattern VALID_ID = Pattern.compile("^[A-Za-z0-9_-]{16,128}$"); + + private static final SecureRandom RANDOM = new SecureRandom(); + private static final Base64.Encoder ENCODER = Base64.getUrlEncoder().withoutPadding(); + + private WebhookIds() {} + + public static String newWebhookId() { + return randomToken(18); + } + + public static String newSigningSecret() { + return randomToken(32); + } + + public static boolean isValidId(String id) { + return id != null && VALID_ID.matcher(id).matches(); + } + + private static String randomToken(int bytes) { + byte[] buffer = new byte[bytes]; + RANDOM.nextBytes(buffer); + return ENCODER.encodeToString(buffer); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/webhook/WebhookReceiverController.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/webhook/WebhookReceiverController.java new file mode 100644 index 0000000000..509c6c7fd3 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/webhook/WebhookReceiverController.java @@ -0,0 +1,150 @@ +package stirling.software.proprietary.policy.webhook; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Arrays; + +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestHeader; +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 jakarta.servlet.http.HttpServletRequest; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.policy.source.Source; +import stirling.software.proprietary.policy.source.SourceStore; +import stirling.software.proprietary.policy.trigger.WebhookTrigger; + +@Slf4j +@RestController +@RequestMapping("/api/v1/webhooks") +@Hidden +@RequiredArgsConstructor +@Tag(name = "Webhooks", description = "Inbound webhook source receiver") +public class WebhookReceiverController { + + static final String SIGNATURE_HEADER = "X-Stirling-Signature"; + static final String FILENAME_HEADER = "X-Stirling-Filename"; + private static final String WEBHOOK_TYPE = "webhook"; + + private final SourceStore sourceStore; + private final WebhookSpool spool; + private final WebhookTrigger webhookTrigger; + private final ApplicationProperties applicationProperties; + + @PostMapping("/{webhookId}") + @Operation( + summary = "Deliver a document to a webhook source", + description = + "The body is the raw document; sign it with the source's secret and present" + + " 'sha256=' in the X-Stirling-Signature header. Returns 202 once" + + " the document is spooled for the referencing policies.") + public ResponseEntity receive( + @PathVariable String webhookId, + @RequestHeader(value = SIGNATURE_HEADER, required = false) String signature, + @RequestHeader(value = FILENAME_HEADER, required = false) String filename, + HttpServletRequest request) { + if (!WebhookIds.isValidId(webhookId)) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No such webhook"); + } + Source source = findWebhookSource(webhookId); + if (source == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No such webhook"); + } + + WebhookConfig config = WebhookConfig.from(source.options()); + byte[] body = readBoundedBody(request); + if (!WebhookSignatures.verify(config.signingSecret(), body, signature)) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid signature"); + } + if (!source.enabled()) { + throw new ResponseStatusException( + HttpStatus.FORBIDDEN, "Webhook source is paused; deliveries are not accepted"); + } + if (body.length == 0) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Empty request body"); + } + + String storedName = stageToSpool(webhookId, filename, body); + + webhookTrigger.fireForWebhook(webhookId); + log.info( + "Accepted webhook delivery '{}' ({} bytes) for {}", + storedName, + body.length, + webhookId); + return ResponseEntity.accepted() + .contentType(MediaType.APPLICATION_JSON) + .body(new WebhookDeliveryResponse(true, storedName, body.length)); + } + + private Source findWebhookSource(String webhookId) { + for (Source source : sourceStore.all()) { + if (!WEBHOOK_TYPE.equals(source.type())) { + continue; + } + Object configured = source.options().get(WebhookConfig.WEBHOOK_ID_OPTION); + if (configured != null && configured.toString().equals(webhookId)) { + return source; + } + } + return null; + } + + private String stageToSpool(String webhookId, String filename, byte[] body) { + try { + return WebhookSpool.displayName( + spool.store(webhookId, filename, body).getFileName().toString()); + } catch (IOException e) { + log.error("Could not spool webhook delivery for {}: {}", webhookId, e.getMessage()); + throw new ResponseStatusException( + HttpStatus.INTERNAL_SERVER_ERROR, "Could not store delivery"); + } + } + + private byte[] readBoundedBody(HttpServletRequest request) { + long maxBytes = applicationProperties.getPolicies().getWebhookMaxBytes(); + long declared = request.getContentLengthLong(); + if (declared < 0) { + throw new ResponseStatusException( + HttpStatus.LENGTH_REQUIRED, "A Content-Length header is required"); + } + if (declared > maxBytes) { + throw new ResponseStatusException( + HttpStatus.PAYLOAD_TOO_LARGE, + "Delivery exceeds the " + maxBytes + "-byte limit"); + } + byte[] body = new byte[(int) declared]; + int total = 0; + try (InputStream in = request.getInputStream()) { + int read; + while (total < body.length + && (read = in.read(body, total, body.length - total)) != -1) { + total += read; + } + if (total == body.length && in.read() != -1) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "Body exceeds the declared Content-Length"); + } + } catch (IOException e) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "Could not read request body"); + } + return total == body.length ? body : Arrays.copyOf(body, total); + } + + public record WebhookDeliveryResponse(boolean accepted, String filename, int bytes) {} +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/webhook/WebhookSignatures.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/webhook/WebhookSignatures.java new file mode 100644 index 0000000000..3373bc7503 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/webhook/WebhookSignatures.java @@ -0,0 +1,49 @@ +package stirling.software.proprietary.policy.webhook; + +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +public final class WebhookSignatures { + + private static final String ALGORITHM = "HmacSHA256"; + private static final String PREFIX = "sha256="; + + private WebhookSignatures() {} + + public static String sign(String signingSecret, byte[] body) { + return PREFIX + HexFormat.of().formatHex(hmac(signingSecret, body)); + } + + public static boolean verify(String signingSecret, byte[] body, String presented) { + if (signingSecret == null || presented == null || body == null) { + return false; + } + String hex = presented.trim(); + if (hex.regionMatches(true, 0, PREFIX, 0, PREFIX.length())) { + hex = hex.substring(PREFIX.length()); + } + byte[] presentedBytes; + try { + presentedBytes = HexFormat.of().parseHex(hex); + } catch (IllegalArgumentException notHex) { + return false; + } + return MessageDigest.isEqual(hmac(signingSecret, body), presentedBytes); + } + + private static byte[] hmac(String signingSecret, byte[] body) { + try { + Mac mac = Mac.getInstance(ALGORITHM); + mac.init(new SecretKeySpec(signingSecret.getBytes(StandardCharsets.UTF_8), ALGORITHM)); + return mac.doFinal(body); + } catch (NoSuchAlgorithmException | InvalidKeyException e) { + throw new IllegalStateException("HMAC-SHA256 unavailable", e); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/webhook/WebhookSpool.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/webhook/WebhookSpool.java new file mode 100644 index 0000000000..6a8377c67a --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/webhook/WebhookSpool.java @@ -0,0 +1,87 @@ +package stirling.software.proprietary.policy.webhook; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.UUID; + +import org.springframework.stereotype.Component; + +import stirling.software.common.configuration.InstallationPathConfig; + +@Component +public class WebhookSpool { + + private static final String SPOOL_DIR = "policy-webhook-spool"; + private static final String TEMP_SUFFIX = ".part"; + private static final String DEFAULT_NAME = "document.pdf"; + private static final int UNIQUE_LEN = 32; + + private final Path spoolRoot; + + public WebhookSpool() { + this(Path.of(InstallationPathConfig.getPath(), SPOOL_DIR)); + } + + public WebhookSpool(Path spoolRoot) { + this.spoolRoot = spoolRoot.toAbsolutePath().normalize(); + } + + public Path dirFor(String webhookId) { + if (!WebhookIds.isValidId(webhookId)) { + throw new IllegalArgumentException("invalid webhookId"); + } + Path dir = spoolRoot.resolve(webhookId).normalize(); + if (!dir.getParent().equals(spoolRoot)) { + throw new IllegalArgumentException("invalid webhookId"); + } + return dir; + } + + public Path store(String webhookId, String filename, byte[] content) throws IOException { + Path dir = dirFor(webhookId); + Files.createDirectories(dir); + String finalName = spoolName(filename); + Path target = dir.resolve(finalName).normalize(); + Path temp = dir.resolve("." + finalName + TEMP_SUFFIX).normalize(); + if (!target.startsWith(dir) || !temp.startsWith(dir)) { + throw new IllegalArgumentException("invalid delivery name"); + } + Files.write(temp, content); + try { + Files.move(temp, target, StandardCopyOption.ATOMIC_MOVE); + } catch (IOException atomicUnsupported) { + Files.move(temp, target, StandardCopyOption.REPLACE_EXISTING); + } + return target; + } + + static String spoolName(String filename) { + return UUID.randomUUID().toString().replace("-", "") + "-" + sanitize(filename); + } + + public static String displayName(String spoolFileName) { + int dash = spoolFileName.indexOf('-'); + if (dash == UNIQUE_LEN && dash + 1 < spoolFileName.length()) { + return spoolFileName.substring(dash + 1); + } + return spoolFileName; + } + + private static String sanitize(String filename) { + if (filename == null) { + return DEFAULT_NAME; + } + String base = filename.replace('\\', '/'); + int slash = base.lastIndexOf('/'); + if (slash >= 0) { + base = base.substring(slash + 1); + } + base = base.replaceAll("[^A-Za-z0-9._-]", "_").trim(); + while (base.startsWith(".")) { + base = base.substring(1); + } + return base.isEmpty() ? DEFAULT_NAME : base; + } +} 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 2e9c4d3e5b..a5bb305e06 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 @@ -34,11 +34,11 @@ import stirling.software.common.model.exception.UnsupportedProviderException; "stirling.software.proprietary.workflow.repository", "stirling.software.proprietary.policy.store", "stirling.software.proprietary.policy.source", + "stirling.software.proprietary.policy.migration", "stirling.software.proprietary.policy.ledger", "stirling.software.proprietary.accountlink", "stirling.software.proprietary.access.repository", - "stirling.software.proprietary.integration.repository", - "stirling.software.proprietary.classification.store" + "stirling.software.proprietary.integration.repository" }) @EntityScan({ "stirling.software.proprietary.security.model", @@ -47,11 +47,11 @@ import stirling.software.common.model.exception.UnsupportedProviderException; "stirling.software.proprietary.workflow.model", "stirling.software.proprietary.policy.store", "stirling.software.proprietary.policy.source", + "stirling.software.proprietary.policy.migration", "stirling.software.proprietary.policy.ledger", "stirling.software.proprietary.accountlink", "stirling.software.proprietary.access.model", - "stirling.software.proprietary.integration.model", - "stirling.software.proprietary.classification.store" + "stirling.software.proprietary.integration.model" }) public class DatabaseConfig { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java index 20a9cb2628..eab9fc6fd0 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java @@ -57,6 +57,7 @@ import stirling.software.proprietary.security.oauth2.TauriAuthorizationRequestRe import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticationFailureHandler; import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticationSuccessHandler; import stirling.software.proprietary.security.saml2.CustomSaml2ResponseAuthenticationConverter; +import stirling.software.proprietary.security.service.ApiKeyAuthenticationService; import stirling.software.proprietary.security.service.CustomOAuth2UserService; import stirling.software.proprietary.security.service.CustomUserDetailsService; import stirling.software.proprietary.security.service.JwtServiceInterface; @@ -484,12 +485,14 @@ public class SecurityConfiguration { } @Bean - public JwtAuthenticationFilter jwtAuthenticationFilter() { + public JwtAuthenticationFilter jwtAuthenticationFilter( + ApiKeyAuthenticationService apiKeyAuthenticationService) { return new JwtAuthenticationFilter( jwtService, userService, userDetailsService, jwtAuthenticationEntryPoint, - securityProperties); + securityProperties, + apiKeyAuthenticationService); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/KeygenLicenseVerifier.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/KeygenLicenseVerifier.java index 1680aa9285..bc68a64f6e 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/KeygenLicenseVerifier.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/KeygenLicenseVerifier.java @@ -134,9 +134,9 @@ public class KeygenLicenseVerifier { try { JsonNode attrs = objectMapper.readTree(payload); - encryptedData = attrs.path("enc").asText(""); - encodedSignature = attrs.path("sig").asText(""); - algorithm = attrs.path("alg").asText(""); + encryptedData = attrs.path("enc").asString(""); + encodedSignature = attrs.path("sig").asString(""); + algorithm = attrs.path("alg").asString(""); } catch (Exception e) { log.error("Failed to parse license file: {}", e.getMessage()); return false; @@ -219,11 +219,11 @@ public class KeygenLicenseVerifier { String issuedStr = metaObj.path("issued").isNull() ? null - : metaObj.path("issued").asText(null); + : metaObj.path("issued").asString(null); String expiryStr = metaObj.path("expiry").isNull() ? null - : metaObj.path("expiry").asText(null); + : metaObj.path("expiry").asString(null); if (issuedStr != null && expiryStr != null) { java.time.Instant issued = java.time.Instant.parse(issuedStr); @@ -287,7 +287,7 @@ public class KeygenLicenseVerifier { } // Check license status if available - String status = attributesObj.path("status").asText(null); + String status = attributesObj.path("status").asString(null); if (status != null && !"ACTIVE".equals(status) && !"EXPIRING".equals(status)) { // Accept "EXPIRING" status as valid @@ -381,7 +381,7 @@ public class KeygenLicenseVerifier { JsonNode licenseObj = licenseData.path("license"); if (licenseObj.isMissingNode() || !licenseObj.isObject()) { - String id = licenseData.path("id").asText(null); + String id = licenseData.path("id").asString(null); if (id != null) { log.info("Found license ID: {}", id); licenseObj = licenseData; // Use the root object as the license object @@ -391,7 +391,7 @@ public class KeygenLicenseVerifier { } } - String licenseId = licenseObj.path("id").asText("unknown"); + String licenseId = licenseObj.path("id").asString("unknown"); log.info("Processing license with ID: {}", licenseId); // Check for floating license in license object @@ -402,7 +402,7 @@ public class KeygenLicenseVerifier { } // Check expiry date - String expiryStr = licenseObj.path("expiry").asText(null); + String expiryStr = licenseObj.path("expiry").asString(null); if (expiryStr != null && !"null".equals(expiryStr)) { java.time.Instant expiry = java.time.Instant.parse(expiryStr); java.time.Instant now = java.time.Instant.now(); @@ -420,7 +420,7 @@ public class KeygenLicenseVerifier { // Extract account, product, policy info JsonNode accountObj = licenseData.path("account"); if (!accountObj.isMissingNode() && accountObj.isObject()) { - String accountId = accountObj.path("id").asText("unknown"); + String accountId = accountObj.path("id").asString("unknown"); log.info("License belongs to account: {}", accountId); // Verify this matches your expected account ID @@ -433,7 +433,7 @@ public class KeygenLicenseVerifier { // Extract policy information if available JsonNode policyObj = licenseData.path("policy"); if (!policyObj.isMissingNode() && policyObj.isObject()) { - String policyId = policyObj.path("id").asText("unknown"); + String policyId = policyObj.path("id").asString("unknown"); log.info("License uses policy: {}", policyId); // Check for floating license in policy @@ -503,9 +503,9 @@ public class KeygenLicenseVerifier { validateLicense(licenseKey, machineFingerprint, context); if (validationResponse != null) { boolean isValid = validationResponse.path("meta").path("valid").asBoolean(); - String licenseId = validationResponse.path("data").path("id").asText(""); + String licenseId = validationResponse.path("data").path("id").asString(""); if (!isValid) { - String code = validationResponse.path("meta").path("code").asText(""); + String code = validationResponse.path("meta").path("code").asString(""); log.info(code); if ("NO_MACHINE".equals(code) || "NO_MACHINES".equals(code) @@ -589,8 +589,8 @@ public class KeygenLicenseVerifier { JsonNode metaNode = jsonResponse.path("meta"); boolean isValid = metaNode.path("valid").asBoolean(); - String detail = metaNode.path("detail").asText(""); - String code = metaNode.path("code").asText(""); + String detail = metaNode.path("detail").asString(""); + String code = metaNode.path("code").asString(""); log.info("License validity: {}", isValid); log.info("Validation detail: {}", detail); @@ -614,7 +614,7 @@ public class KeygenLicenseVerifier { if (includedNode.isArray()) { for (JsonNode node : includedNode) { - if ("policies".equals(node.path("type").asText(""))) { + if ("policies".equals(node.path("type").asString(""))) { policyNode = node; break; } @@ -700,9 +700,9 @@ public class KeygenLicenseVerifier { for (JsonNode machine : machines) { if (machineFingerprint.equals( - machine.path("attributes").path("fingerprint").asText(""))) { + machine.path("attributes").path("fingerprint").asString(""))) { isCurrentMachineActivated = true; - currentMachineId = machine.path("id").asText(""); + currentMachineId = machine.path("id").asString(""); log.info( "Current machine is already activated with ID: {}", currentMachineId); @@ -729,14 +729,14 @@ public class KeygenLicenseVerifier { for (JsonNode machine : machines) { String createdStr = - machine.path("attributes").path("created").asText(null); + machine.path("attributes").path("created").asString(null); if (createdStr != null && !createdStr.isEmpty()) { try { java.time.Instant createdTime = java.time.Instant.parse(createdStr); if (oldestTime == null || createdTime.isBefore(oldestTime)) { oldestTime = createdTime; - oldestMachineId = machine.path("id").asText(""); + oldestMachineId = machine.path("id").asString(""); } } catch (Exception e) { log.warn( @@ -750,7 +750,7 @@ public class KeygenLicenseVerifier { if (oldestMachineId == null) { log.warn( "Could not determine oldest machine by timestamp, using first machine in list"); - oldestMachineId = machines.path(0).path("id").asText(""); + oldestMachineId = machines.path(0).path("id").asString(""); } log.info("Deregistering machine with ID: {}", oldestMachineId); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java index 14ddde602a..650acd968b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java @@ -8,6 +8,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; @@ -45,6 +46,7 @@ import stirling.software.common.util.RegexPatternUtils; import stirling.software.proprietary.security.model.api.admin.SettingValueResponse; import stirling.software.proprietary.security.model.api.admin.UpdateSettingValueRequest; import stirling.software.proprietary.security.model.api.admin.UpdateSettingsRequest; +import stirling.software.proprietary.service.AiEngineConfigSync; import tools.jackson.core.type.TypeReference; import tools.jackson.databind.ObjectMapper; @@ -58,6 +60,7 @@ public class AdminSettingsController { private final ApplicationProperties applicationProperties; private final ObjectMapper objectMapper; private final ApplicationContext applicationContext; + private final AiEngineConfigSync aiEngineConfigSync; // Track settings that have been modified but not yet applied (require restart) private static final ConcurrentHashMap pendingChanges = @@ -172,6 +175,26 @@ public class AdminSettingsController { .body(Map.of("error", "No settings provided to update")); } + // Mutable copy so we can drop masked "********" values: a UI round-trip must not + // overwrite a real secret (e.g. an API key) with the placeholder from the GET. + settings = new LinkedHashMap<>(settings); + settings.entrySet() + .removeIf( + e -> { + if (!"********".equals(e.getValue())) { + return false; + } + String key = e.getKey(); + String leaf = + key.contains(".") + ? key.substring(key.lastIndexOf('.') + 1) + : key; + return isSensitiveFieldWithPath(leaf, key); + }); + if (settings.isEmpty()) { + return ResponseEntity.ok(Map.of("message", "No changed settings to update.")); + } + // Validate all settings first before applying any changes for (Map.Entry entry : settings.entrySet()) { String key = entry.getKey(); @@ -188,6 +211,9 @@ public class AdminSettingsController { // Validate pipeline path settings String validationError = validatePipelinePathSetting(key, value); + if (validationError == null) { + validationError = validateAiEngineNumericSetting(key, value); + } if (validationError != null) { return ResponseEntity.badRequest() .body(Map.of("error", HtmlUtils.htmlEscape(validationError))); @@ -202,10 +228,13 @@ public class AdminSettingsController { for (Map.Entry entry : settings.entrySet()) { String key = entry.getKey(); Object value = entry.getValue(); - log.info("Admin updating setting: {} = {}", key, value); + log.info("Admin updating setting: {} = {}", key, logSafeValue(key, value)); pendingChanges.put(key, value != null ? value : ""); } + // Push changed AI settings live so model/RAG/limit changes skip the restart. + maybePushAiEngineLive(settings); + return ResponseEntity.ok( Map.of( "message", @@ -350,7 +379,10 @@ public class AdminSettingsController { + HtmlUtils.htmlEscape(fullKey))); } - log.info("Admin updating section setting: {} = {}", fullKey, value); + log.info( + "Admin updating section setting: {} = {}", + fullKey, + logSafeValue(fullKey, value)); GeneralUtils.saveKeyToSettings(fullKey, value); // Track this as a pending change @@ -469,7 +501,7 @@ public class AdminSettingsController { } } - log.info("Admin updating single setting: {} = {}", key, value); + log.info("Admin updating single setting: {} = {}", key, logSafeValue(key, value)); GeneralUtils.saveKeyToSettings(key, value); // Track this as a pending change @@ -600,6 +632,27 @@ public class AdminSettingsController { } } + /** + * Forward pending {@code aiEngine.*} changes to the engine after a save. Sends all accumulated + * pending changes, not just this save's: the running bean doesn't reflect unrestarted values. + */ + private void maybePushAiEngineLive(Map changedSettings) { + boolean aiChangedNow = + changedSettings.keySet().stream().anyMatch(k -> k.startsWith("aiEngine.")); + if (!aiChangedNow) { + return; + } + Map aiEnginePending = new HashMap<>(); + for (Map.Entry entry : pendingChanges.entrySet()) { + if (entry.getKey().startsWith("aiEngine.")) { + aiEnginePending.put(entry.getKey(), entry.getValue()); + } + } + if (!aiEnginePending.isEmpty()) { + aiEngineConfigSync.pushLiveAfterSave(aiEnginePending); + } + } + private Object getSectionData(String sectionName) { if (sectionName == null || sectionName.trim().isEmpty()) { return null; @@ -620,6 +673,7 @@ public class AdminSettingsController { case "telegram" -> applicationProperties.getTelegram(); case "aiengine", "aiEngine" -> applicationProperties.getAiEngine(); case "mcp" -> applicationProperties.getMcp(); + case "policies" -> applicationProperties.getPolicies(); default -> null; }; } @@ -646,7 +700,8 @@ public class AdminSettingsController { "telegram", "aiEngine", "aiengine", - "mcp"); + "mcp", + "policies"); // Pattern to validate safe property paths - only alphanumeric, dots, and underscores private static final Pattern SAFE_KEY_PATTERN = @@ -684,6 +739,38 @@ public class AdminSettingsController { return true; } + /** + * Minimum accepted value per bounded {@code aiEngine.*} numeric. A saved out-of-range value + * would make the engine reject every later push, including the one that fixes it. + */ + private static final Map AI_ENGINE_NUMERIC_MINIMUMS = + Map.of( + "aiEngine.models.smartMaxTokens", 1, + "aiEngine.models.fastMaxTokens", 1, + "aiEngine.rag.topK", 1, + "aiEngine.rag.maxSearches", 0, + "aiEngine.limits.maxPages", 1, + "aiEngine.limits.maxCharacters", 1, + "aiEngine.limits.modelMaxConcurrency", 1); + + private String validateAiEngineNumericSetting(String key, Object value) { + Integer min = AI_ENGINE_NUMERIC_MINIMUMS.get(key); + if (min == null || value == null) { + return null; + } + long parsed; + if (value instanceof Number number) { + parsed = number.longValue(); + } else { + try { + parsed = Long.parseLong(value.toString().trim()); + } catch (NumberFormatException e) { + return key + " must be a whole number"; + } + } + return parsed < min ? key + " must be at least " + min : null; + } + private String validatePipelinePathSetting(String key, Object value) { // Validate pipeline path settings if (key.startsWith("system.customPaths.pipeline.watchedFoldersDirs") @@ -828,6 +915,15 @@ public class AdminSettingsController { return masked; } + /** + * Value to log for a settings key, with secrets redacted: API keys, client secrets and mail + * passwords travel this path and must not land in the log in cleartext. + */ + private Object logSafeValue(String key, Object value) { + String leaf = key.contains(".") ? key.substring(key.lastIndexOf('.') + 1) : key; + return isSensitiveFieldWithPath(leaf, key) ? "" : value; + } + /** Check if a field name indicates sensitive data with full path context */ private boolean isSensitiveFieldWithPath(String fieldName, String fullPath) { String lowerField = fieldName.toLowerCase(); @@ -843,8 +939,12 @@ public class AdminSettingsController { return true; } - // Check for fields containing 'password' or 'secret' - return lowerField.contains("password") || lowerField.contains("secret"); + // Match secret-bearing names (apikey covers provider creds). "token" is a suffix + // match only, so it doesn't swallow numeric fields like smartMaxTokens. + return lowerField.contains("password") + || lowerField.contains("secret") + || lowerField.contains("apikey") + || lowerField.endsWith("token"); } /** Create a masked representation for sensitive fields */ diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UIDataTessdataController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UIDataTessdataController.java index 8212dcfb29..3aba9d0eb9 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UIDataTessdataController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UIDataTessdataController.java @@ -3,6 +3,7 @@ package stirling.software.proprietary.security.controller.api; import java.io.IOException; import java.io.InputStream; import java.net.HttpURLConnection; +import java.net.URI; import java.net.URL; import java.nio.file.Files; import java.nio.file.Path; @@ -150,7 +151,7 @@ public class UIDataTessdataController { protected boolean downloadLanguageFile(String safeLang, Path targetFile, String downloadUrl) { HttpURLConnection connection = null; try { - URL url = new URL(downloadUrl); + URL url = URI.create(downloadUrl).toURL(); connection = (HttpURLConnection) url.openConnection(); connection.setRequestMethod("GET"); connection.setRequestProperty("User-Agent", "Stirling-PDF-App"); @@ -199,7 +200,7 @@ public class UIDataTessdataController { String apiUrl = "https://api.github.com/repos/tesseract-ocr/tessdata/contents"; HttpURLConnection connection = null; try { - URL url = new URL(apiUrl); + URL url = URI.create(apiUrl).toURL(); connection = (HttpURLConnection) url.openConnection(); connection.setRequestMethod("GET"); connection.setRequestProperty("User-Agent", "Stirling-PDF-App"); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/SessionRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/SessionRepository.java index db5d5a9b33..af35516cdb 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/SessionRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/SessionRepository.java @@ -45,4 +45,29 @@ public interface SessionRepository extends JpaRepository + "WHERE u.team.id = :teamId " + "GROUP BY u.username") List findLatestSessionByTeamId(@Param("teamId") Long teamId); + + /** Latest request instant per principal. */ + @Query( + "SELECT s.principalName, MAX(s.lastRequest) FROM SessionEntity s GROUP BY s.principalName") + List findLatestRequestPerPrincipal(); + + /** Principals with a live (non-expired, within-window) session. */ + @Query( + "SELECT DISTINCT s.principalName FROM SessionEntity s " + + "WHERE s.expired = false AND s.lastRequest > :cutoff") + List findActivePrincipalsSince(@Param("cutoff") Instant cutoff); + + /** Flag timed-out sessions as expired. */ + @Modifying + @Transactional + @Query( + "UPDATE SessionEntity s SET s.expired = true " + + "WHERE s.expired = false AND s.lastRequest < :cutoff") + int expireOlderThan(@Param("cutoff") Instant cutoff); + + /** Purge long-expired sessions to bound table growth. */ + @Modifying + @Transactional + @Query("DELETE FROM SessionEntity s WHERE s.expired = true AND s.lastRequest < :cutoff") + int deleteExpiredOlderThan(@Param("cutoff") Instant cutoff); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/UserRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/UserRepository.java index acae6044de..c7aa508e31 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/UserRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/UserRepository.java @@ -1,11 +1,13 @@ package stirling.software.proprietary.security.database.repository; import java.time.LocalDateTime; +import java.util.Collection; import java.util.List; import java.util.Optional; import java.util.UUID; import java.util.stream.Stream; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -43,6 +45,15 @@ public interface UserRepository extends JpaRepository { @Query(value = "SELECT u FROM User u LEFT JOIN FETCH u.team") List findAllWithTeam(); + /** All users with team + authorities fetched (DISTINCT dedupes the collection join). */ + @EntityGraph(attributePaths = {"team", "authorities"}) + @Query("SELECT DISTINCT u FROM User u") + List findAllWithTeamAndAuthorities(); + + /** (userId, key, value) settings rows for the given users. */ + @Query("SELECT u.id, KEY(s), VALUE(s) FROM User u JOIN u.settings s WHERE u.id IN :ids") + List findSettingsByUserIds(@Param("ids") Collection ids); + @Query( "SELECT u FROM User u JOIN FETCH u.authorities JOIN FETCH u.team WHERE u.team.id = :teamId") List findAllByTeamId(@Param("teamId") Long teamId); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java index 92bbcab89c..ed8001df5a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java @@ -11,6 +11,7 @@ import java.sql.SQLException; import java.util.Map; import java.util.Optional; +import org.slf4j.MDC; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; @@ -33,8 +34,9 @@ import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.exception.UnsupportedProviderException; import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken; import stirling.software.proprietary.security.model.AuthenticationType; -import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.model.exception.AuthenticationFailureException; +import stirling.software.proprietary.security.service.ApiKeyAuthenticationService; +import stirling.software.proprietary.security.service.ApiKeyAuthenticationService.ApiKeyAuthentication; import stirling.software.proprietary.security.service.CustomUserDetailsService; import stirling.software.proprietary.security.service.JwtServiceInterface; import stirling.software.proprietary.security.service.UserService; @@ -48,11 +50,15 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final CustomUserDetailsService userDetailsService; private final AuthenticationEntryPoint authenticationEntryPoint; private final ApplicationProperties.Security securityProperties; + private final ApiKeyAuthenticationService apiKeyAuthenticationService; @Override protected void doFilterInternal( HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + // Start clean so a pooled thread can't inherit a prior request's key label. This filter + // runs before UserAuthenticationFilter, so in JWT mode it owns the API-key label lifecycle. + MDC.remove(ApiKeyAuthenticationService.AUDIT_LABEL_MDC_KEY); if (!jwtService.isJwtEnabled()) { filterChain.doFilter(request, response); return; @@ -131,9 +137,14 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { if (apiKey != null && !apiKey.isBlank()) { try { - Optional user = userService.getUserByApiKey(apiKey); + // Resolve through the shared service so the multi-key table (then the legacy + // per-user key) is consulted and per-key usage is recorded; the key runs as its + // owner. It also yields a per-key label for the processor's document + // attribution. + Optional resolved = + apiKeyAuthenticationService.authenticate(apiKey); - if (user.isEmpty()) { + if (resolved.isEmpty()) { handleAuthenticationFailure( request, response, @@ -143,8 +154,13 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { authentication = new ApiKeyAuthenticationToken( - user.get(), apiKey, user.get().getAuthorities()); + resolved.get().user(), apiKey, resolved.get().authorities()); SecurityContextHolder.getContext().setAuthentication(authentication); + if (resolved.get().auditLabel() != null) { + MDC.put( + ApiKeyAuthenticationService.AUDIT_LABEL_MDC_KEY, + resolved.get().auditLabel()); + } return true; } catch (AuthenticationException e) { handleAuthenticationFailure( diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java index 5777b093e8..8fe351ccde 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java @@ -6,6 +6,7 @@ import java.io.IOException; import java.util.List; import java.util.Optional; +import org.slf4j.MDC; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.annotation.Lazy; import org.springframework.context.annotation.Profile; @@ -33,6 +34,8 @@ import stirling.software.common.util.RequestUriUtils; import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken; import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal; +import stirling.software.proprietary.security.service.ApiKeyAuthenticationService; +import stirling.software.proprietary.security.service.ApiKeyAuthenticationService.ApiKeyAuthentication; import stirling.software.proprietary.security.service.UserService; import stirling.software.proprietary.security.session.SessionPersistentRegistry; @@ -41,18 +44,24 @@ import stirling.software.proprietary.security.session.SessionPersistentRegistry; @Profile("!saas") public class UserAuthenticationFilter extends OncePerRequestFilter { + /** MDC key carrying the resolved key's label into audit events for the processor feed. */ + public static final String API_KEY_LABEL_MDC = ApiKeyAuthenticationService.AUDIT_LABEL_MDC_KEY; + private final ApplicationProperties.Security securityProp; private final UserService userService; + private final ApiKeyAuthenticationService apiKeyAuthenticationService; private final SessionPersistentRegistry sessionPersistentRegistry; private final boolean loginEnabledValue; public UserAuthenticationFilter( @Lazy ApplicationProperties.Security securityProp, @Lazy UserService userService, + ApiKeyAuthenticationService apiKeyAuthenticationService, SessionPersistentRegistry sessionPersistentRegistry, @Qualifier("loginEnabled") boolean loginEnabledValue) { this.securityProp = securityProp; this.userService = userService; + this.apiKeyAuthenticationService = apiKeyAuthenticationService; this.sessionPersistentRegistry = sessionPersistentRegistry; this.loginEnabledValue = loginEnabledValue; } @@ -62,6 +71,14 @@ public class UserAuthenticationFilter extends OncePerRequestFilter { HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + // Start each request clean so a pooled thread can't inherit a prior request's key label - + // but keep a label an upstream filter (JwtAuthenticationFilter) already set for a request + // it API-key-authenticated, otherwise per-key attribution is lost on the JWT path. + if (!(SecurityContextHolder.getContext().getAuthentication() + instanceof ApiKeyAuthenticationToken)) { + MDC.remove(API_KEY_LABEL_MDC); + } + if (!loginEnabledValue) { // If login is not enabled, just pass all requests without authentication filterChain.doFilter(request, response); @@ -89,18 +106,23 @@ public class UserAuthenticationFilter extends OncePerRequestFilter { String apiKey = request.getHeader("X-API-KEY"); if (apiKey != null && !apiKey.trim().isEmpty()) { try { - // Use API key to authenticate. This requires you to have an authentication - // provider for API keys. - Optional user = userService.getUserByApiKey(apiKey); - if (user.isEmpty()) { + // Resolves the multi-key table then the legacy key, records usage, and yields a + // per-key label for the processor's document-source attribution. + Optional resolved = + apiKeyAuthenticationService.authenticate(apiKey); + if (resolved.isEmpty()) { response.setStatus(HttpStatus.UNAUTHORIZED.value()); response.getWriter().write("Invalid API Key."); return; } + User user = resolved.get().user(); authentication = new ApiKeyAuthenticationToken( - user.get(), apiKey, user.get().getAuthorities()); + user, apiKey, resolved.get().authorities()); SecurityContextHolder.getContext().setAuthentication(authentication); + if (resolved.get().auditLabel() != null) { + MDC.put(API_KEY_LABEL_MDC, resolved.get().auditLabel()); + } } catch (AuthenticationException e) { // If API key authentication fails, deny the request response.setStatus(HttpStatus.UNAUTHORIZED.value()); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserBasedRateLimitingFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserBasedRateLimitingFilter.java index e4a15ae7b4..a07b5c9a97 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserBasedRateLimitingFilter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserBasedRateLimitingFilter.java @@ -11,7 +11,6 @@ import org.springframework.http.HttpStatus; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.core.userdetails.UserDetails; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; @@ -58,22 +57,24 @@ public class UserBasedRateLimitingFilter extends OncePerRequestFilter { filterChain.doFilter(request, response); return; } + // Bucket by the resolved user (the auth filter runs first and populates the context, even + // for X-API-KEY requests), so all of a user's API keys share ONE per-user quota - minting + // extra keys can't multiply the daily limit. Fall back to the raw key / IP only when the + // request is unauthenticated. String identifier = null; - // Check for API key in the request headers - String apiKey = request.getHeader("X-API-KEY"); - if (apiKey != null && !apiKey.trim().isEmpty()) { - identifier = // Prefix to distinguish between API keys and usernames - "API_KEY_" + apiKey; - } else { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - if (authentication != null && authentication.isAuthenticated()) { - UserDetails userDetails = (UserDetails) authentication.getPrincipal(); - identifier = userDetails.getUsername(); - } + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication != null + && authentication.isAuthenticated() + && !"anonymousUser".equals(authentication.getName())) { + identifier = authentication.getName(); } - // If neither API key nor an authenticated user is present, use IP address if (identifier == null) { - identifier = request.getRemoteAddr(); + String apiKey = request.getHeader("X-API-KEY"); + if (apiKey != null && !apiKey.trim().isEmpty()) { + identifier = "API_KEY_" + apiKey; + } else { + identifier = request.getRemoteAddr(); + } } Role userRole = getRoleFromAuthentication(SecurityContextHolder.getContext().getAuthentication()); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/ApiKey.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/ApiKey.java new file mode 100644 index 0000000000..fea66144d2 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/ApiKey.java @@ -0,0 +1,72 @@ +package stirling.software.proprietary.security.model; + +import java.io.Serializable; +import java.time.Instant; + +import jakarta.persistence.*; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * A named, personal API key belonging to a user. The raw secret is shown once at creation and never + * stored; only its SHA-256 hash is persisted, so a leaked database row cannot be replayed. Distinct + * from the legacy single {@code users.apiKey} column, which stays a per-user key for backward + * compatibility and is lazily represented here. + */ +@Entity +@Table( + name = "api_keys", + indexes = { + @Index(name = "idx_api_key_hash", columnList = "key_hash", unique = true), + @Index(name = "idx_api_key_owner", columnList = "owner_user_id") + }) +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ApiKey implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "name", nullable = false, length = 100) + private String name; + + /** SHA-256 hex of the raw key; the raw value is never persisted. */ + @Column(name = "key_hash", nullable = false, unique = true, length = 64) + private String keyHash; + + /** Non-secret leading fragment of the raw key, shown in listings (e.g. {@code sk_a1b2c3d4}). */ + @Column(name = "prefix", nullable = false, length = 32) + private String prefix; + + /** The user who created and owns the key; the key authenticates as this user. */ + @Column(name = "owner_user_id", nullable = false) + private Long ownerUserId; + + @Column(name = "enabled", nullable = false) + private boolean enabled; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @Column(name = "last_used_at") + private Instant lastUsedAt; + + @Column(name = "revoked_at") + private Instant revokedAt; + + /** Active = enabled and not revoked; only active keys authenticate. */ + public boolean isActive() { + return enabled && revokedAt == null; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/ApiKeyAuthenticationToken.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/ApiKeyAuthenticationToken.java index c969704bad..b09ba20aab 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/ApiKeyAuthenticationToken.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/ApiKeyAuthenticationToken.java @@ -5,6 +5,7 @@ import java.util.Collection; import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.core.GrantedAuthority; +/** Authentication produced from an {@code X-API-KEY} header; runs as the key's owner. */ public class ApiKeyAuthenticationToken extends AbstractAuthenticationToken { private final Object principal; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/ApiKeyDailyUsage.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/ApiKeyDailyUsage.java new file mode 100644 index 0000000000..48055fc7d6 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/ApiKeyDailyUsage.java @@ -0,0 +1,45 @@ +package stirling.software.proprietary.security.model; + +import java.io.Serializable; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.Table; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * One UTC day's request tally for an API key. Rolling "today"/"this month" usage is summed from + * these rows, keeping the table at one row per key per active day rather than one per request. + */ +@Entity +@Table(name = "api_key_daily_usage") +@IdClass(ApiKeyDailyUsageId.class) +@Getter +@Setter +@NoArgsConstructor +public class ApiKeyDailyUsage implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id + @Column(name = "api_key_id") + private Long apiKeyId; + + @Id + @Column(name = "epoch_day") + private long epochDay; + + @Column(name = "count") + private long count; + + public ApiKeyDailyUsage(Long apiKeyId, long epochDay, long count) { + this.apiKeyId = apiKeyId; + this.epochDay = epochDay; + this.count = count; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/ApiKeyDailyUsageId.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/ApiKeyDailyUsageId.java new file mode 100644 index 0000000000..77bbbf43f1 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/ApiKeyDailyUsageId.java @@ -0,0 +1,36 @@ +package stirling.software.proprietary.security.model; + +import java.io.Serializable; +import java.util.Objects; + +/** Composite key for {@link ApiKeyDailyUsage}: one row per key per UTC day. */ +public class ApiKeyDailyUsageId implements Serializable { + + private static final long serialVersionUID = 1L; + + private Long apiKeyId; + private long epochDay; + + public ApiKeyDailyUsageId() {} + + public ApiKeyDailyUsageId(Long apiKeyId, long epochDay) { + this.apiKeyId = apiKeyId; + this.epochDay = epochDay; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof ApiKeyDailyUsageId other)) { + return false; + } + return epochDay == other.epochDay && Objects.equals(apiKeyId, other.apiKeyId); + } + + @Override + public int hashCode() { + return Objects.hash(apiKeyId, epochDay); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/Authority.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/Authority.java index 9adcce1ad3..659f7691bd 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/Authority.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/Authority.java @@ -11,6 +11,7 @@ import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.Index; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; @@ -19,7 +20,10 @@ import lombok.Getter; import lombok.Setter; @Entity -@Table(name = "authorities") +@Table( + name = "authorities", + // index the FK: authorities load by user_id + indexes = @Index(name = "idx_authorities_user_id", columnList = "user_id")) @Getter @Setter public class Authority implements GrantedAuthority, Serializable { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/JwtSigningKeyEntity.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/JwtSigningKeyEntity.java new file mode 100644 index 0000000000..23cf42502d --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/JwtSigningKeyEntity.java @@ -0,0 +1,52 @@ +package stirling.software.proprietary.security.model; + +import java.io.Serializable; +import java.time.LocalDateTime; + +import org.hibernate.annotations.CreationTimestamp; + +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import stirling.software.proprietary.integration.crypto.EncryptedStringConverter; + +/** A JWT signing keypair in the shared DB; the private key is encrypted at rest. */ +@Entity +@Table(name = "jwt_signing_keys") +@NoArgsConstructor +@Getter +@Setter +public class JwtSigningKeyEntity implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id + @Column(name = "key_id", length = 128) + private String keyId; + + // Base64 X.509 public key (non-secret). + @Column(name = "verifying_key", columnDefinition = "text", nullable = false) + private String verifyingKey; + + // Base64 PKCS#8 private key, encrypted at rest. + @Convert(converter = EncryptedStringConverter.class) + @Column(name = "signing_key", columnDefinition = "text", nullable = false) + private String signingKey; + + @CreationTimestamp + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + + public JwtSigningKeyEntity(String keyId, String verifyingKey, String signingKey) { + this.keyId = keyId; + this.verifyingKey = verifyingKey; + this.signingKey = signingKey; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/SessionEntity.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/SessionEntity.java index 7e9da2cb7d..552d97d022 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/SessionEntity.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/SessionEntity.java @@ -5,13 +5,23 @@ import java.time.Instant; import jakarta.persistence.Entity; import jakarta.persistence.Id; +import jakarta.persistence.Index; import jakarta.persistence.Table; import lombok.Data; @Entity @Data -@Table(name = "sessions") +@Table( + name = "sessions", + indexes = { + // per-principal session/activity lookups + @Index( + name = "idx_sessions_principal_last", + columnList = "principal_name, last_request"), + // scheduled expiry/purge scan + @Index(name = "idx_sessions_expired", columnList = "expired") + }) public class SessionEntity implements Serializable { @Id private String sessionId; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java index 2b2d22cfc7..32733f5fc5 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java @@ -28,7 +28,10 @@ import stirling.software.common.model.enumeration.Role; import stirling.software.proprietary.model.Team; @Entity -@Table(name = "users") +@Table( + name = "users", + // team_id backs Team.users joins, the admin roster fetch, and per-team user counts. + indexes = @Index(name = "idx_users_team_id", columnList = "team_id")) @NoArgsConstructor @Getter @Setter @@ -103,7 +106,6 @@ public class User implements UserDetails, Serializable { @ElementCollection @MapKeyColumn(name = "setting_key") - @Lob @Column(name = "setting_value", columnDefinition = "text") @CollectionTable(name = "user_settings", joinColumns = @JoinColumn(name = "user_id")) @JsonIgnore diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/ApiKeyDailyUsageRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/ApiKeyDailyUsageRepository.java new file mode 100644 index 0000000000..4db1dd34b8 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/ApiKeyDailyUsageRepository.java @@ -0,0 +1,55 @@ +package stirling.software.proprietary.security.repository; + +import java.util.Collection; +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import stirling.software.proprietary.security.model.ApiKeyDailyUsage; +import stirling.software.proprietary.security.model.ApiKeyDailyUsageId; + +@Repository +public interface ApiKeyDailyUsageRepository + extends JpaRepository { + + /** Atomically bump today's tally; returns 0 when no row exists yet (caller then inserts). */ + @Modifying + @Query( + "UPDATE ApiKeyDailyUsage u SET u.count = u.count + 1 " + + "WHERE u.apiKeyId = :apiKeyId AND u.epochDay = :epochDay") + int incrementIfPresent(@Param("apiKeyId") Long apiKeyId, @Param("epochDay") long epochDay); + + @Query( + "SELECT COALESCE(SUM(u.count), 0) FROM ApiKeyDailyUsage u " + + "WHERE u.apiKeyId = :apiKeyId AND u.epochDay >= :fromDayInclusive") + long sumSince( + @Param("apiKeyId") Long apiKeyId, @Param("fromDayInclusive") long fromDayInclusive); + + @Query( + "SELECT u.count FROM ApiKeyDailyUsage u " + + "WHERE u.apiKeyId = :apiKeyId AND u.epochDay = :epochDay") + Long countForDay(@Param("apiKeyId") Long apiKeyId, @Param("epochDay") long epochDay); + + /** Batched today-count for many keys in one query (avoids N+1 when listing keys). */ + @Query( + "SELECT u.apiKeyId AS apiKeyId, u.count AS total FROM ApiKeyDailyUsage u " + + "WHERE u.apiKeyId IN :ids AND u.epochDay = :epochDay") + List countForDayByIds( + @Param("ids") Collection ids, @Param("epochDay") long epochDay); + + /** Batched trailing-window sum for many keys in one query. */ + @Query( + "SELECT u.apiKeyId AS apiKeyId, SUM(u.count) AS total FROM ApiKeyDailyUsage u " + + "WHERE u.apiKeyId IN :ids AND u.epochDay >= :fromDayInclusive " + + "GROUP BY u.apiKeyId") + List sumSinceByIds( + @Param("ids") Collection ids, @Param("fromDayInclusive") long fromDayInclusive); + + void deleteByApiKeyId(Long apiKeyId); + + List findByApiKeyId(Long apiKeyId); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/ApiKeyRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/ApiKeyRepository.java new file mode 100644 index 0000000000..62e2cc1408 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/ApiKeyRepository.java @@ -0,0 +1,19 @@ +package stirling.software.proprietary.security.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import stirling.software.proprietary.security.model.ApiKey; + +@Repository +public interface ApiKeyRepository extends JpaRepository { + + Optional findByKeyHash(String keyHash); + + boolean existsByKeyHash(String keyHash); + + List findByOwnerUserIdOrderByCreatedAtDesc(Long ownerUserId); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/ApiKeyUsageSum.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/ApiKeyUsageSum.java new file mode 100644 index 0000000000..bff5b9304c --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/ApiKeyUsageSum.java @@ -0,0 +1,8 @@ +package stirling.software.proprietary.security.repository; + +/** Projection: a key id and a usage total, for batching per-key usage into one query. */ +public interface ApiKeyUsageSum { + Long getApiKeyId(); + + Long getTotal(); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/JwtSigningKeyRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/JwtSigningKeyRepository.java new file mode 100644 index 0000000000..d34f45593b --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/JwtSigningKeyRepository.java @@ -0,0 +1,26 @@ +package stirling.software.proprietary.security.repository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import stirling.software.proprietary.security.model.JwtSigningKeyEntity; + +/** Shared-DB store of JWT signing keys - the source of truth every cluster node reads from. */ +@Repository +public interface JwtSigningKeyRepository extends JpaRepository { + + /** Newest first, so the most recently created key is the active signing key. */ + List findAllByOrderByCreatedAtDesc(); + + /** + * The current active signing key: the single newest row. Used for cheap cluster convergence. + */ + Optional findFirstByOrderByCreatedAtDesc(); + + /** Keys created before the cutoff, eligible for rotation cleanup. */ + List findByCreatedAtBefore(LocalDateTime cutoff); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticatedPrincipal.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticatedPrincipal.java index a39a390927..c58d2dcc73 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticatedPrincipal.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticatedPrincipal.java @@ -5,15 +5,17 @@ import java.util.List; import java.util.Map; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticatedPrincipal; +import org.springframework.security.core.AuthenticatedPrincipal; +import org.springframework.security.saml2.provider.service.authentication.Saml2ResponseAssertionAccessor; @ConditionalOnProperty(name = "security.saml2.enabled", havingValue = "true") public record CustomSaml2AuthenticatedPrincipal( String name, Map> attributes, String nameId, - List sessionIndexes) - implements Saml2AuthenticatedPrincipal, Serializable { + List sessionIndexes, + String responseValue) + implements Saml2ResponseAssertionAccessor, AuthenticatedPrincipal, Serializable { @Override public String getName() { @@ -24,4 +26,33 @@ public record CustomSaml2AuthenticatedPrincipal( public Map> getAttributes() { return this.attributes; } + + @Override + public String getNameId() { + return this.nameId; + } + + @Override + public List getSessionIndexes() { + return this.sessionIndexes; + } + + @Override + public String getResponseValue() { + return this.responseValue; + } + + @Override + @SuppressWarnings("unchecked") + public List getAttribute(String name) { + List values = this.attributes.get(name); + return values != null ? (List) values : null; + } + + @Override + @SuppressWarnings("unchecked") + public A getFirstAttribute(String name) { + List values = this.attributes.get(name); + return values != null && !values.isEmpty() ? (A) values.get(0) : null; + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2ResponseAuthenticationConverter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2ResponseAuthenticationConverter.java index f95e2cbc25..0acc98ee55 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2ResponseAuthenticationConverter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2ResponseAuthenticationConverter.java @@ -99,7 +99,11 @@ public class CustomSaml2ResponseAuthenticationConverter CustomSaml2AuthenticatedPrincipal principal = new CustomSaml2AuthenticatedPrincipal( - userIdentifier, attributes, userIdentifier, sessionIndexes); + userIdentifier, + attributes, + userIdentifier, + sessionIndexes, + responseToken.getToken().getSaml2Response()); return new Saml2Authentication( principal, diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyAuthenticationService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyAuthenticationService.java new file mode 100644 index 0000000000..ab223f929b --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyAuthenticationService.java @@ -0,0 +1,107 @@ +package stirling.software.proprietary.security.service; + +import java.time.Instant; +import java.util.Collection; +import java.util.Optional; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; + +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.model.ApiKey; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.ApiKeyRepository; + +/** + * Resolves an incoming {@code X-API-KEY} to its owning user and records per-key usage. Depends only + * on repositories (never {@code UserService}) so {@code UserService} can delegate here without a + * bean cycle. + * + *

    Resolution order: the multi-key {@code api_keys} table first (by hash), then the legacy + * per-user {@code users.apiKey} column. Legacy keys therefore keep working unchanged. Every key is + * personal and authenticates as its owner with the owner's authorities. + */ +@Service +@RequiredArgsConstructor +public class ApiKeyAuthenticationService { + + /** + * MDC key that carries the resolved key's label into audit events so the processor's Documents + * feed can attribute a document to the specific key. Set by the auth filters (both flavors), + * read by {@code CustomAuditEventRepository}. + */ + public static final String AUDIT_LABEL_MDC_KEY = "apiKeyLabel"; + + private final ApiKeyRepository apiKeyRepository; + private final ApiKeyUsageRecorder usageRecorder; + private final UserRepository userRepository; + + /** The user a raw key authenticates as, or empty if it matches no active key. */ + public Optional resolveUser(String rawKey) { + return authenticate(rawKey).map(ApiKeyAuthentication::user); + } + + /** + * Resolve a raw key, recording usage as a side effect. Returns the owning user, a display label + * for the resolved key ({@code null} for the legacy per-user key), and the owner's authorities. + */ + public Optional authenticate(String rawKey) { + if (rawKey == null || rawKey.isBlank()) { + return Optional.empty(); + } + + ApiKey key = apiKeyRepository.findByKeyHash(ApiKeyHasher.hash(rawKey)).orElse(null); + if (key != null) { + if (!key.isActive()) { + return Optional.empty(); + } + User owner = userRepository.findById(key.getOwnerUserId()).orElse(null); + if (owner == null || !owner.isEnabled()) { + return Optional.empty(); + } + usageRecorder.record(key.getId()); + return Optional.of( + new ApiKeyAuthentication(owner, auditLabel(key), owner.getAuthorities())); + } + + // Legacy single per-user key: keep working, always a personal key for its user. + return userRepository + .findByApiKey(rawKey) + .filter(User::isEnabled) + .map(user -> new ApiKeyAuthentication(user, null, user.getAuthorities())); + } + + /** "Production ingest (sk_a1b2c3d4)" - shown against API-sourced docs in the processor feed. */ + private static String auditLabel(ApiKey key) { + return key.getName() + " (" + key.getPrefix() + ")"; + } + + /** + * Revoke the {@code api_keys} row that mirrors a given raw key, if any. Called when the legacy + * per-user key is rotated so the migrated shadow row can't keep authenticating the old secret. + */ + @Transactional + public void revokeMigratedKey(String rawKey) { + if (rawKey == null || rawKey.isBlank()) { + return; + } + apiKeyRepository + .findByKeyHash(ApiKeyHasher.hash(rawKey)) + .filter(ApiKey::isActive) + .ifPresent( + k -> { + k.setEnabled(false); + k.setRevokedAt(Instant.now()); + apiKeyRepository.save(k); + }); + } + + /** + * A resolved key: the user, an optional processor-feed label, and the authorities to run as. + */ + public record ApiKeyAuthentication( + User user, String auditLabel, Collection authorities) {} +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyHasher.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyHasher.java new file mode 100644 index 0000000000..3807781036 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyHasher.java @@ -0,0 +1,50 @@ +package stirling.software.proprietary.security.service; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.HexFormat; + +/** Generates opaque API-key secrets and hashes them for storage/lookup. */ +public final class ApiKeyHasher { + + /** Human-recognisable prefix so a leaked string is identifiable as a Stirling API key. */ + public static final String KEY_PREFIX = "sk_"; + + /** Chars of the raw key kept for non-secret display (includes the {@code sk_} prefix). */ + private static final int DISPLAY_PREFIX_LENGTH = 11; + + private static final SecureRandom RANDOM = new SecureRandom(); + + private ApiKeyHasher() {} + + /** A fresh opaque secret: {@code sk_} followed by 40 hex chars of cryptographic randomness. */ + public static String generateRawKey() { + byte[] bytes = new byte[20]; + RANDOM.nextBytes(bytes); + return KEY_PREFIX + HexFormat.of().formatHex(bytes); + } + + /** SHA-256 hex of a raw key; the value stored and looked up, never the raw key. */ + public static String hash(String rawKey) { + try { + byte[] digest = + MessageDigest.getInstance("SHA-256") + .digest(rawKey.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(digest); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 unavailable", e); + } + } + + /** Leading, non-secret fragment shown in listings (e.g. {@code sk_a1b2c3d4}). */ + public static String displayPrefix(String rawKey) { + if (rawKey == null) { + return ""; + } + return rawKey.length() <= DISPLAY_PREFIX_LENGTH + ? rawKey + : rawKey.substring(0, DISPLAY_PREFIX_LENGTH); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyLegacyMigrator.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyLegacyMigrator.java new file mode 100644 index 0000000000..413c0c0d72 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyLegacyMigrator.java @@ -0,0 +1,31 @@ +package stirling.software.proprietary.security.service; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; + +import stirling.software.proprietary.security.model.ApiKey; +import stirling.software.proprietary.security.repository.ApiKeyRepository; + +/** + * Inserts the shadow {@code api_keys} row that mirrors a user's legacy {@code users.apiKey} in its + * OWN ({@code REQUIRES_NEW}) transaction. Kept a separate bean so the write is isolated from the + * caller's listing transaction: when two concurrent first-loads race to insert the same hash, the + * loser's unique-key clash rolls back only this insert instead of poisoning the caller's + * transaction (on Postgres a failed statement aborts the whole transaction). The {@code + * DataIntegrityViolationException} is left to propagate so the caller can treat it as "already + * migrated". + */ +@Component +@RequiredArgsConstructor +class ApiKeyLegacyMigrator { + + private final ApiKeyRepository apiKeyRepository; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void insertMigratedKey(ApiKey key) { + apiKeyRepository.saveAndFlush(key); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyManagementService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyManagementService.java new file mode 100644 index 0000000000..e30bce7845 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyManagementService.java @@ -0,0 +1,235 @@ +package stirling.software.proprietary.security.service; + +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.model.api.apikey.CreateApiKeyRequest; +import stirling.software.proprietary.model.api.apikey.CreatedApiKeyDto; +import stirling.software.proprietary.model.api.apikey.PortalApiKeyDto; +import stirling.software.proprietary.model.api.apikey.PortalApiKeysResponse; +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.model.ApiKey; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.ApiKeyDailyUsageRepository; +import stirling.software.proprietary.security.repository.ApiKeyRepository; + +/** + * Portal-facing CRUD for named, personal API keys: lists, creates, and revokes the caller's own + * keys. Every key belongs to exactly one user and authenticates as that user; there is no sharing. + * + *

    Every pre-existing single {@code users.apiKey} is lazily represented as a key owned by that + * user, so historic keys list uniformly. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ApiKeyManagementService { + + private static final DateTimeFormatter CREATED_FORMAT = + DateTimeFormatter.ofPattern("yyyy-MM-dd").withZone(ZoneOffset.UTC); + private static final DateTimeFormatter LAST_USED_FORMAT = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm").withZone(ZoneOffset.UTC); + private static final int MONTH_WINDOW_DAYS = 30; + + /** Bounds a key name so it can't bloat storage or the audit/processor feed. */ + private static final int MAX_NAME_LENGTH = 100; + + /** Caps active keys per user so key creation can't be used to multiply rate-limit budget. */ + private static final int MAX_ACTIVE_KEYS_PER_USER = 50; + + private final ApiKeyRepository apiKeyRepository; + private final ApiKeyDailyUsageRepository usageRepository; + private final UserRepository userRepository; + private final UserService userService; + private final ApiKeyLegacyMigrator legacyMigrator; + + /** All keys the caller owns. */ + @Transactional + public PortalApiKeysResponse listVisibleKeys() { + User caller = requireCaller(); + migrateLegacyKey(caller); + + List visible = + apiKeyRepository.findByOwnerUserIdOrderByCreatedAtDesc(caller.getId()); + + // Batch usage for all keys into three queries rather than two-per-key (avoids N+1). + long today = Instant.now().atZone(ZoneOffset.UTC).toLocalDate().toEpochDay(); + List ids = visible.stream().map(ApiKey::getId).toList(); + Map todayById = new HashMap<>(); + Map monthById = new HashMap<>(); + Map totalById = new HashMap<>(); + if (!ids.isEmpty()) { + usageRepository + .countForDayByIds(ids, today) + .forEach(r -> todayById.put(r.getApiKeyId(), r.getTotal())); + usageRepository + .sumSinceByIds(ids, today - (MONTH_WINDOW_DAYS - 1)) + .forEach(r -> monthById.put(r.getApiKeyId(), r.getTotal())); + usageRepository + .sumSinceByIds(ids, Long.MIN_VALUE) + .forEach(r -> totalById.put(r.getApiKeyId(), r.getTotal())); + } + + List keys = + visible.stream() + .map( + k -> + toDto( + k, + zeroIfNull(todayById.get(k.getId())), + zeroIfNull(monthById.get(k.getId())), + zeroIfNull(totalById.get(k.getId())))) + .toList(); + return PortalApiKeysResponse.builder().keys(keys).build(); + } + + private static long zeroIfNull(Long value) { + return value == null ? 0L : value; + } + + /** Create a personal key and return its one-time secret. */ + @Transactional + public CreatedApiKeyDto createKey(CreateApiKeyRequest request) { + User caller = requireCaller(); + String name = request == null ? null : request.name(); + if (name == null || name.isBlank()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Key name is required"); + } + if (name.trim().length() > MAX_NAME_LENGTH) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Key name must be " + MAX_NAME_LENGTH + " characters or fewer"); + } + long activeOwned = + apiKeyRepository.findByOwnerUserIdOrderByCreatedAtDesc(caller.getId()).stream() + .filter(ApiKey::isActive) + .count(); + if (activeOwned >= MAX_ACTIVE_KEYS_PER_USER) { + throw new ResponseStatusException( + HttpStatus.TOO_MANY_REQUESTS, + "You have reached the maximum of " + + MAX_ACTIVE_KEYS_PER_USER + + " active API keys; revoke one before creating another"); + } + + String rawKey = ApiKeyHasher.generateRawKey(); + ApiKey saved = + apiKeyRepository.save( + ApiKey.builder() + .name(name.trim()) + .keyHash(ApiKeyHasher.hash(rawKey)) + .prefix(ApiKeyHasher.displayPrefix(rawKey)) + .ownerUserId(caller.getId()) + .enabled(true) + .createdAt(Instant.now()) + .build()); + + return CreatedApiKeyDto.builder().key(toDto(saved, 0L, 0L, 0L)).secret(rawKey).build(); + } + + /** Soft-revoke a key the caller owns; also clears the legacy column if it is that key. */ + @Transactional + public void revokeKey(Long id) { + User caller = requireCaller(); + ApiKey key = + apiKeyRepository + .findById(id) + .orElseThrow( + () -> new ResponseStatusException(HttpStatus.NOT_FOUND, "No key")); + if (!key.getOwnerUserId().equals(caller.getId())) { + // Not-found rather than forbidden so a caller can't probe other users' key ids. + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No key"); + } + key.setEnabled(false); + key.setRevokedAt(Instant.now()); + apiKeyRepository.save(key); + clearLegacyColumnIfMatches(key); + } + + /** Represent a user's pre-existing single key as a row so it lists uniformly. */ + private void migrateLegacyKey(User user) { + String legacy = user.getApiKey(); + if (legacy == null || legacy.isBlank()) { + return; + } + String hash = ApiKeyHasher.hash(legacy); + if (apiKeyRepository.existsByKeyHash(hash)) { + return; + } + try { + // Insert in its own transaction so a concurrent-insert clash can't poison this + // listing transaction (see ApiKeyLegacyMigrator). + legacyMigrator.insertMigratedKey( + ApiKey.builder() + .name("Default key") + .keyHash(hash) + .prefix(ApiKeyHasher.displayPrefix(legacy)) + .ownerUserId(user.getId()) + .enabled(true) + .createdAt(Instant.now()) + .build()); + } catch (DataIntegrityViolationException alreadyMigrated) { + // A concurrent first-load won the race and inserted the same hash; that's fine. + log.debug("Legacy key already migrated concurrently for user {}", user.getId()); + } + } + + /** + * If a revoked key is the owner's legacy {@code users.apiKey}, null it so it stops resolving. + */ + private void clearLegacyColumnIfMatches(ApiKey key) { + userRepository + .findById(key.getOwnerUserId()) + .ifPresent( + owner -> { + String legacy = owner.getApiKey(); + if (legacy != null + && ApiKeyHasher.hash(legacy).equals(key.getKeyHash())) { + owner.setApiKey(null); + userRepository.save(owner); + } + }); + } + + private PortalApiKeyDto toDto(ApiKey key, long usageToday, long usageMonth, long usageTotal) { + return PortalApiKeyDto.builder() + .id(String.valueOf(key.getId())) + .name(key.getName()) + .prefix(key.getPrefix()) + .created( + key.getCreatedAt() == null ? "" : CREATED_FORMAT.format(key.getCreatedAt())) + .lastUsed( + key.getLastUsedAt() == null + ? "Never" + : LAST_USED_FORMAT.format(key.getLastUsedAt())) + .status(key.isActive() ? "active" : "revoked") + .usageToday(usageToday) + .usageMonth(usageMonth) + .usageTotal(usageTotal) + .build(); + } + + private User requireCaller() { + String username = userService.getCurrentUsername(); + if (username == null || username.isBlank() || "anonymousUser".equalsIgnoreCase(username)) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Not authenticated"); + } + return userService + .findByUsernameIgnoreCase(username) + .orElseThrow( + () -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Unknown user")); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyUsageRecorder.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyUsageRecorder.java new file mode 100644 index 0000000000..5f36678e22 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyUsageRecorder.java @@ -0,0 +1,60 @@ +package stirling.software.proprietary.security.service; + +import java.time.Instant; +import java.time.ZoneOffset; + +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Records per-key usage off the request thread. Kept a separate bean so the {@code @Async} proxy is + * honoured (a self-invocation from the resolver would run inline). Best-effort: never fails a + * request. The actual writes go through {@link ApiKeyUsageWriter} so each step commits in its own + * transaction and a first-write race can't drop a count. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ApiKeyUsageRecorder { + + private final ApiKeyUsageWriter writer; + + /** Bump today's tally for the key and stamp last-used. */ + @Async("auditExecutor") + public void record(Long apiKeyId) { + if (apiKeyId == null) { + return; + } + try { + long epochDay = Instant.now().atZone(ZoneOffset.UTC).toLocalDate().toEpochDay(); + // First writer of the day inserts the row; everyone else (and the loser of an insert + // race) increments. Separate transactions mean a unique-key clash never rolls back an + // already-counted request. + if (writer.increment(apiKeyId, epochDay) == 0 + && !firstUseInserted(apiKeyId, epochDay)) { + writer.increment(apiKeyId, epochDay); + } + writer.stampLastUsed(apiKeyId); + } catch (Exception e) { + log.debug("Failed to record API key usage for id={}", apiKeyId, e); + } + } + + /** + * Whether we inserted the day's first row. A lost insert race can surface either as a {@code + * false} return or - when the failed flush marked the REQUIRES_NEW transaction rollback-only, + * so its commit throws - as an exception; both mean "someone else inserted", so we treat any + * failure as not-inserted and let the caller fall back to an increment rather than dropping the + * count. + */ + private boolean firstUseInserted(Long apiKeyId, long epochDay) { + try { + return writer.tryInsertFirstUse(apiKeyId, epochDay); + } catch (RuntimeException raced) { + return false; + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyUsageWriter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyUsageWriter.java new file mode 100644 index 0000000000..94bec676d7 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/ApiKeyUsageWriter.java @@ -0,0 +1,59 @@ +package stirling.software.proprietary.security.service; + +import java.time.Instant; + +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; + +import stirling.software.proprietary.security.model.ApiKeyDailyUsage; +import stirling.software.proprietary.security.repository.ApiKeyDailyUsageRepository; +import stirling.software.proprietary.security.repository.ApiKeyRepository; + +/** + * Per-step transactional writes for {@link ApiKeyUsageRecorder}. Each method runs in its own + * ({@code REQUIRES_NEW}) transaction so a unique-key clash when two requests race to insert the + * day's first row rolls back only that failed insert - never an already-counted request or the + * last-used stamp. + */ +@Component +@RequiredArgsConstructor +class ApiKeyUsageWriter { + + private final ApiKeyRepository apiKeyRepository; + private final ApiKeyDailyUsageRepository usageRepository; + + /** Bump today's tally if the row already exists; returns rows updated (0 if none yet). */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public int increment(Long apiKeyId, long epochDay) { + return usageRepository.incrementIfPresent(apiKeyId, epochDay); + } + + /** + * Insert today's row with a count of 1. Flushes so a concurrent first-write's unique-key clash + * surfaces here (returning false) instead of at commit; the caller then increments instead. + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public boolean tryInsertFirstUse(Long apiKeyId, long epochDay) { + try { + usageRepository.saveAndFlush(new ApiKeyDailyUsage(apiKeyId, epochDay, 1)); + return true; + } catch (DataIntegrityViolationException raced) { + return false; + } + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void stampLastUsed(Long apiKeyId) { + apiKeyRepository + .findById(apiKeyId) + .ifPresent( + key -> { + key.setLastUsedAt(Instant.now()); + apiKeyRepository.save(key); + }); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/DatabaseService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/DatabaseService.java index ffd9b5cb17..f18f6bd160 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/DatabaseService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/DatabaseService.java @@ -46,6 +46,9 @@ public class DatabaseService implements DatabaseServiceInterface { public static final String BACKUP_PREFIX = "backup_"; public static final String SQL_SUFFIX = ".sql"; + private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s+"); + private static final Pattern LINE_COMMENT_PATTERN = Pattern.compile("--[^\r\n]*"); + private static final Pattern BLOCK_COMMENT_PATTERN = Pattern.compile("/\\*[\\s\\S]*?\\*/"); private final Path BACKUP_DIR; // Whitelist of allowed SQL patterns for H2 database backups (generated by SCRIPT command) @@ -520,7 +523,7 @@ public class DatabaseService implements DatabaseServiceInterface { private void validateSqlContent(Path scriptPath) { try { String content = Files.readString(scriptPath); - String normalizedContent = normalizeSqlContent(content); + String normalizedContent = sanitizeSql(content); String codeOnly = stripStringLiterals(normalizedContent); for (Pattern deniedPattern : DENIED_PATTERNS) { @@ -568,19 +571,21 @@ public class DatabaseService implements DatabaseServiceInterface { } /** - * Normalizes SQL content by removing comments to prevent bypass attacks. + * Sanitize SQL content by removing comments to prevent bypass attacks. * - * @param sql the SQL content to normalize - * @return normalized SQL without comments + * @param sql the SQL content to sanitize + * @return sanitize SQL without comments */ - private String normalizeSqlContent(String sql) { + private String sanitizeSql(String sql) { // Remove block comments (/* ... */) - sql = sql.replaceAll("/\\*[\\s\\S]*?\\*/", " "); + // TODO: I feel like this should re-evaluated. + // Passing around SQL like this, smells a bit when we have Hibernate/Critaria API. + String intermediateSql = BLOCK_COMMENT_PATTERN.matcher(sql).replaceAll(" "); // Remove line comments (--....) - sql = sql.replaceAll("--[^\r\n]*", " "); + intermediateSql = LINE_COMMENT_PATTERN.matcher(intermediateSql).replaceAll(" "); // Collapse multiple whitespaces - sql = sql.replaceAll("\\s+", " "); - return sql.trim(); + intermediateSql = WHITESPACE_PATTERN.matcher(intermediateSql).replaceAll(" "); + return intermediateSql.trim(); } private String stripStringLiterals(String sql) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeyPairCleanupService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeyPairCleanupService.java index 8745f505fd..ea7245aa75 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeyPairCleanupService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeyPairCleanupService.java @@ -3,8 +3,10 @@ package stirling.software.proprietary.security.service; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.time.Duration; import java.time.LocalDateTime; import java.util.List; +import java.util.Optional; import java.util.concurrent.TimeUnit; import org.springframework.beans.factory.annotation.Autowired; @@ -17,6 +19,8 @@ import jakarta.annotation.PostConstruct; import lombok.extern.slf4j.Slf4j; +import stirling.software.common.cluster.DistributedLock; +import stirling.software.common.cluster.DistributedLock.LockHandle; import stirling.software.common.configuration.InstallationPathConfig; import stirling.software.common.model.ApplicationProperties; import stirling.software.proprietary.security.model.JwtVerificationKey; @@ -26,15 +30,23 @@ import stirling.software.proprietary.security.model.JwtVerificationKey; @ConditionalOnBooleanProperty("v2") public class KeyPairCleanupService { + // Cluster-wide single-writer: keys live in the shared DB, so only one node may prune + rotate + // per cycle. Otherwise every node runs this and they race to delete each other's keys. + private static final String CLEANUP_LOCK = "jwt-key-cleanup"; + private static final Duration LOCK_LEASE = Duration.ofMinutes(5); + private final KeyPersistenceService keyPersistenceService; private final ApplicationProperties.Security.Jwt jwtProperties; + private final DistributedLock distributedLock; @Autowired public KeyPairCleanupService( KeyPersistenceService keyPersistenceService, - ApplicationProperties applicationProperties) { + ApplicationProperties applicationProperties, + DistributedLock distributedLock) { this.keyPersistenceService = keyPersistenceService; this.jwtProperties = applicationProperties.getSecurity().getJwt(); + this.distributedLock = distributedLock; } @Transactional @@ -44,7 +56,28 @@ public class KeyPairCleanupService { if (!jwtProperties.isEnableKeyCleanup() || !keyPersistenceService.isKeystoreEnabled()) { return; } + // A lock-backend error must never fail this @PostConstruct/scheduled run: degrade to + // "skip this cycle" so a transient Valkey blip can't stop a node from booting. + Optional lock; + try { + lock = distributedLock.tryAcquire(CLEANUP_LOCK, LOCK_LEASE); + } catch (RuntimeException e) { + log.warn( + "Could not acquire the JWT key-cleanup lock ({}); skipping this cycle", + e.getMessage()); + return; + } + // No lock means another node is already pruning; skip until the next tick. + if (lock.isEmpty()) { + log.debug("Another node holds the JWT key-cleanup lock; skipping this cycle"); + return; + } + try (LockHandle held = lock.get()) { + runCleanup(); + } + } + private void runCleanup() { LocalDateTime cutoffDate = LocalDateTime.now().minusDays(jwtProperties.getKeyRetentionDays()); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeyPersistenceService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeyPersistenceService.java index 49e66e5c53..adb11936ff 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeyPersistenceService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeyPersistenceService.java @@ -18,15 +18,17 @@ import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.Base64; import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; -import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.cache.Cache; import org.springframework.cache.CacheManager; -import org.springframework.cache.annotation.CacheEvict; -import org.springframework.cache.caffeine.CaffeineCache; +import org.springframework.context.annotation.DependsOn; +import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; import jakarta.annotation.PostConstruct; @@ -34,27 +36,39 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.common.configuration.InstallationPathConfig; import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.security.model.JwtSigningKeyEntity; import stirling.software.proprietary.security.model.JwtVerificationKey; +import stirling.software.proprietary.security.repository.JwtSigningKeyRepository; +/** Persists JWT signing keys in the shared DB so all nodes sign/verify with the same key. */ @Slf4j @Service +// CredentialEncryption must init first: startup persists an encrypted private key. +@DependsOn("credentialEncryption") public class KeyPersistenceService implements KeyPersistenceServiceInterface { public static final String KEY_SUFFIX = ".key"; public static final String PUB_KEY_SUFFIX = ".pub"; private final ApplicationProperties.Security.Jwt jwtProperties; - private final CacheManager cacheManager; private final Cache verifyingKeyCache; + private final JwtSigningKeyRepository keyRepository; + private final boolean clusterEnabled; + + // kid -> KeyPair; safe to cache since key material is immutable. + private final Map keyPairCache = new ConcurrentHashMap<>(); private volatile JwtVerificationKey activeKey; - @Autowired public KeyPersistenceService( - ApplicationProperties applicationProperties, CacheManager cacheManager) { + ApplicationProperties applicationProperties, + CacheManager cacheManager, + JwtSigningKeyRepository keyRepository, + @Value("${cluster.enabled:false}") boolean clusterEnabled) { this.jwtProperties = applicationProperties.getSecurity().getJwt(); - this.cacheManager = cacheManager; this.verifyingKeyCache = cacheManager.getCache("verifyingKeys"); + this.keyRepository = keyRepository; + this.clusterEnabled = clusterEnabled; } @PostConstruct @@ -63,138 +77,86 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface { log.info("JWT keystore is disabled - keys will be generated in memory"); return; } - try { - ensurePrivateKeyDirectoryExists(); - loadExistingKeysFromDisk(); + importLegacyDiskKeysIfPresent(); + loadKeysFromDb(); } catch (Exception e) { - log.error("Failed to initialize keystore, using in-memory generation", e); - } - } - - /** - * Load all existing JWT keys from disk into memory on startup. - * - *

    This ensures tokens signed with previous keys remain valid after server restart. If no - * keys exist on disk, generates a new keypair. - */ - private void loadExistingKeysFromDisk() { - try { - Path keyDirectory = Path.of(InstallationPathConfig.getPrivateKeyPath()); - - if (!Files.exists(keyDirectory)) { - log.info("No existing keys found, generating new keypair"); - generateAndStoreKeypair(); - return; - } - - List keyFiles; - try (var stream = Files.list(keyDirectory)) { - keyFiles = - stream.filter(path -> path.toString().endsWith(KEY_SUFFIX)) - .sorted( - (a, b) -> - b.getFileName().compareTo(a.getFileName())) // Most - // recent - // first - .toList(); - } - - if (keyFiles.isEmpty()) { - log.info("No existing keys found in directory, generating new keypair"); - generateAndStoreKeypair(); - return; - } - - log.info("Loading {} existing JWT keys from disk", keyFiles.size()); - int loadedCount = 0; - - for (Path keyFile : keyFiles) { - try { - String keyId = keyFile.getFileName().toString().replace(KEY_SUFFIX, ""); - - // Load private key first - PrivateKey privateKey = loadPrivateKey(keyId); - - // Try to load public key, or generate it from private key if missing - // (migration) - String encodedPublicKey; - try { - encodedPublicKey = loadPublicKey(keyId); - } catch (IOException e) { - // Public key file doesn't exist - generate it from private key (migration) - log.info("Migrating legacy key: generating public key file for {}", keyId); - KeyPair keyPair = reconstructKeyPair(privateKey); - - // Save the public key file - Path publicKeyFile = keyDirectory.resolve(keyId + PUB_KEY_SUFFIX); - encodedPublicKey = encodePublicKey(keyPair.getPublic()); - Files.writeString(publicKeyFile, encodedPublicKey); - publicKeyFile.toFile().setReadable(true, true); - publicKeyFile.toFile().setWritable(true, true); - publicKeyFile.toFile().setExecutable(false, false); - - log.info("Successfully migrated key: {}", keyId); - } - - // Create verification key and add to cache - JwtVerificationKey verifyingKey = - new JwtVerificationKey(keyId, encodedPublicKey); - verifyingKeyCache.put(keyId, verifyingKey); - loadedCount++; - - // Set the most recent key as active (first in sorted list) - if (activeKey == null) { - activeKey = verifyingKey; - log.info("Set active JWT signing key: {}", keyId); - } else { - log.debug( - "Loaded historical JWT key: {} (created: {})", - keyId, - verifyingKey.getCreatedAt()); - } - } catch (Exception e) { - log.warn( - "Failed to load key: {}, skipping. Error: {}", - keyFile.getFileName(), - e.getMessage()); - } - } - - if (loadedCount == 0) { - log.warn("No valid keys could be loaded from disk, generating new keypair"); - generateAndStoreKeypair(); - } else { - log.info( - "Successfully loaded {} JWT keys, active key: {}", - loadedCount, - activeKey.getKeyId()); - } - - } catch (IOException e) { - log.error("Failed to load keys from disk, generating new keypair", e); + log.error("Failed to initialize keystore, generating a fresh keypair", e); generateAndStoreKeypair(); } } - @Transactional - private JwtVerificationKey generateAndStoreKeypair() { - JwtVerificationKey verifyingKey = null; + /** + * Cluster convergence: adopt the newest signing key in the shared DB as this node's active key. + * Runs on every node so a key a peer just minted becomes the shared active signer within one + * interval, keeping cluster rotation equivalent to single-node. Cluster-only: a single node + * always holds its own newest key, so this is skipped entirely off-cluster. + */ + @Scheduled(fixedDelayString = "${stirling.security.jwt.activeKeyReloadMs:300000}") + public void reloadActiveKeyFromDb() { + if (!clusterEnabled || !isKeystoreEnabled()) { + return; + } + try { + Optional newestOpt = + keyRepository.findFirstByOrderByCreatedAtDesc(); + if (newestOpt.isEmpty()) { + return; + } + JwtSigningKeyEntity newest = newestOpt.get(); + JwtVerificationKey current = activeKey; + if (current != null && newest.getKeyId().equals(current.getKeyId())) { + return; + } + JwtVerificationKey adopted = + new JwtVerificationKey(newest.getKeyId(), newest.getVerifyingKey()); + verifyingKeyCache.put(newest.getKeyId(), adopted); + activeKey = adopted; + log.info( + "Adopted newest JWT signing key {} from the shared DB as active", + newest.getKeyId()); + } catch (Exception e) { + log.warn("Could not reload active JWT key from the shared DB: {}", e.getMessage()); + } + } + /** Load every signing key from the shared DB into the caches; most recent becomes active. */ + private void loadKeysFromDb() { + List keys = keyRepository.findAllByOrderByCreatedAtDesc(); + if (keys.isEmpty()) { + log.info("No JWT keys in the database, generating a new keypair"); + generateAndStoreKeypair(); + return; + } + for (JwtSigningKeyEntity key : keys) { + verifyingKeyCache.put( + key.getKeyId(), new JwtVerificationKey(key.getKeyId(), key.getVerifyingKey())); + } + activeKey = new JwtVerificationKey(keys.get(0).getKeyId(), keys.get(0).getVerifyingKey()); + log.info("Loaded {} JWT key(s) from DB, active key: {}", keys.size(), activeKey.getKeyId()); + } + + private JwtVerificationKey generateAndStoreKeypair() { try { KeyPair keyPair = generateRSAKeypair(); String keyId = generateKeyId(); + String verifyingKey = encodePublicKey(keyPair.getPublic()); + String signingKey = + Base64.getEncoder().encodeToString(keyPair.getPrivate().getEncoded()); - storeKeyPair(keyId, keyPair); - verifyingKey = new JwtVerificationKey(keyId, encodePublicKey(keyPair.getPublic())); - verifyingKeyCache.put(keyId, verifyingKey); - activeKey = verifyingKey; + // Converter encrypts the private key at rest with the shared credential-encryption key. + keyRepository.save(new JwtSigningKeyEntity(keyId, verifyingKey, signingKey)); + + keyPairCache.put(keyId, keyPair); + JwtVerificationKey verificationKey = new JwtVerificationKey(keyId, verifyingKey); + verifyingKeyCache.put(keyId, verificationKey); + activeKey = verificationKey; log.info("Generated and stored new JWT keypair: {}", keyId); - } catch (IOException e) { + return verificationKey; + } catch (RuntimeException e) { log.error("Failed to generate and store keypair", e); + return null; } - - return verifyingKey; } @Override @@ -207,25 +169,29 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface { @Override public Optional getKeyPair(String keyId) { - if (!isKeystoreEnabled()) { + if (!isKeystoreEnabled() || keyId == null) { return Optional.empty(); } - + KeyPair cached = keyPairCache.get(keyId); + if (cached != null) { + return Optional.of(cached); + } + Optional entityOpt = keyRepository.findById(keyId); + if (entityOpt.isEmpty()) { + log.warn("No signing key found in DB for keyId: {}", keyId); + return Optional.empty(); + } + JwtSigningKeyEntity entity = entityOpt.get(); try { - JwtVerificationKey verifyingKey = - verifyingKeyCache.get(keyId, JwtVerificationKey.class); - - if (verifyingKey == null) { - log.warn("No signing key found in database for keyId: {}", keyId); - return Optional.empty(); - } - - PrivateKey privateKey = loadPrivateKey(keyId); - PublicKey publicKey = decodePublicKey(verifyingKey.getVerifyingKey()); - - return Optional.of(new KeyPair(publicKey, privateKey)); - } catch (Exception e) { - log.error("Failed to load keypair for keyId: {}", keyId, e); + KeyPair keyPair = + new KeyPair( + decodePublicKey(entity.getVerifyingKey()), + decodePrivateKey(entity.getSigningKey())); + keyPairCache.put(keyId, keyPair); + verifyingKeyCache.put(keyId, new JwtVerificationKey(keyId, entity.getVerifyingKey())); + return Optional.of(keyPair); + } catch (NoSuchAlgorithmException | InvalidKeySpecException e) { + log.error("Failed to decode keypair for keyId: {}", keyId, e); return Optional.empty(); } } @@ -241,44 +207,69 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface { } @Override - @CacheEvict( - value = {"verifyingKeys"}, - key = "#keyId", - condition = "#root.target.isKeystoreEnabled()") public void removeKey(String keyId) { + keyRepository.deleteById(keyId); verifyingKeyCache.evict(keyId); + keyPairCache.remove(keyId); } @Override public List getKeysEligibleForCleanup(LocalDateTime cutoffDate) { - CaffeineCache caffeineCache = (CaffeineCache) verifyingKeyCache; - com.github.benmanes.caffeine.cache.Cache nativeCache = - caffeineCache.getNativeCache(); - - log.debug( - "Cache size: {}, Checking {} keys for cleanup", - nativeCache.estimatedSize(), - nativeCache.asMap().size()); - - return nativeCache.asMap().values().stream() - .filter(value -> value instanceof JwtVerificationKey) - .map(value -> (JwtVerificationKey) value) - .filter( - key -> { - boolean eligible = key.getCreatedAt().isBefore(cutoffDate); - log.debug( - "Key {} created at {}, eligible for cleanup: {}", - key.getKeyId(), - key.getCreatedAt(), - eligible); - return eligible; - }) + return keyRepository.findByCreatedAtBefore(cutoffDate).stream() + .map(e -> new JwtVerificationKey(e.getKeyId(), e.getVerifyingKey())) .toList(); } + /** Import any pre-existing on-disk keys into the DB once, so upgrades keep sessions valid. */ + private void importLegacyDiskKeysIfPresent() { + if (keyRepository.count() > 0) { + return; + } + Path keyDirectory = Path.of(InstallationPathConfig.getPrivateKeyPath()); + if (!Files.exists(keyDirectory)) { + return; + } + List keyFiles; + try (var stream = Files.list(keyDirectory)) { + keyFiles = stream.filter(p -> p.toString().endsWith(KEY_SUFFIX)).toList(); + } catch (IOException e) { + log.warn("Could not list legacy key directory {}: {}", keyDirectory, e.getMessage()); + return; + } + int imported = 0; + for (Path keyFile : keyFiles) { + String keyId = keyFile.getFileName().toString().replace(KEY_SUFFIX, ""); + try { + PrivateKey privateKey = loadPrivateKey(keyId); + String verifyingKey = resolveLegacyPublicKey(keyId, privateKey); + String signingKey = Base64.getEncoder().encodeToString(privateKey.getEncoded()); + keyRepository.save(new JwtSigningKeyEntity(keyId, verifyingKey, signingKey)); + imported++; + } catch (Exception e) { + log.warn("Skipping legacy key {}: {}", keyId, e.getMessage()); + } + } + if (imported > 0) { + log.info("Imported {} legacy JWT key(s) from disk into the shared DB", imported); + } + } + + private String resolveLegacyPublicKey(String keyId, PrivateKey privateKey) + throws NoSuchAlgorithmException, InvalidKeySpecException { + try { + return loadPublicKey(keyId); + } catch (IOException e) { + // No .pub file: derive the public key from the RSA private key. + return encodePublicKey(reconstructKeyPair(privateKey).getPublic()); + } + } + + // UUID suffix so two nodes booting the same second don't collide on keyId. private String generateKeyId() { return "jwt-key-" - + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd-HHmmss")); + + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd-HHmmss")) + + "-" + + UUID.randomUUID().toString().substring(0, 8); } private KeyPair generateRSAKeypair() { @@ -291,125 +282,49 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface { } } - private void ensurePrivateKeyDirectoryExists() throws IOException { - Path keyPath = Path.of(InstallationPathConfig.getPrivateKeyPath()); - - if (!Files.exists(keyPath)) { - Files.createDirectories(keyPath); - } - } - - /** - * Store both private and public keys to disk. - * - *

    Private key stored as: keyId.key - * - *

    Public key stored as: keyId.pub - */ - private void storeKeyPair(String keyId, KeyPair keyPair) throws IOException { - Path keyDirectory = Path.of(InstallationPathConfig.getPrivateKeyPath()); - - // Store private key - Path privateKeyFile = keyDirectory.resolve(keyId + KEY_SUFFIX); - String encodedPrivateKey = - Base64.getEncoder().encodeToString(keyPair.getPrivate().getEncoded()); - Files.writeString(privateKeyFile, encodedPrivateKey); - - // Set read/write to only the owner (security) - privateKeyFile.toFile().setReadable(true, true); - privateKeyFile.toFile().setWritable(true, true); - privateKeyFile.toFile().setExecutable(false, false); - - // Store public key - Path publicKeyFile = keyDirectory.resolve(keyId + PUB_KEY_SUFFIX); - String encodedPublicKey = - Base64.getEncoder().encodeToString(keyPair.getPublic().getEncoded()); - Files.writeString(publicKeyFile, encodedPublicKey); - - // Public key can be more permissive but still restrict to owner - publicKeyFile.toFile().setReadable(true, true); - publicKeyFile.toFile().setWritable(true, true); - publicKeyFile.toFile().setExecutable(false, false); - - log.debug( - "Stored keypair to disk: {} (private: {}, public: {})", - keyId, - privateKeyFile.getFileName(), - publicKeyFile.getFileName()); - } - private PrivateKey loadPrivateKey(String keyId) throws IOException, NoSuchAlgorithmException, InvalidKeySpecException { Path keyFile = Path.of(InstallationPathConfig.getPrivateKeyPath()).resolve(keyId + KEY_SUFFIX); - if (!Files.exists(keyFile)) { throw new IOException("Private key not found: " + keyFile); } - - String encodedKey = Files.readString(keyFile); - byte[] keyBytes = Base64.getDecoder().decode(encodedKey); - PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes); - KeyFactory keyFactory = KeyFactory.getInstance("RSA"); - - return keyFactory.generatePrivate(keySpec); + return decodePrivateKey(Files.readString(keyFile)); } - /** - * Load public key from disk. - * - * @param keyId the key identifier - * @return Base64-encoded public key string - * @throws IOException if the public key file is not found - */ private String loadPublicKey(String keyId) throws IOException { Path publicKeyFile = Path.of(InstallationPathConfig.getPrivateKeyPath()).resolve(keyId + PUB_KEY_SUFFIX); - if (!Files.exists(publicKeyFile)) { throw new IOException("Public key not found: " + publicKeyFile); } - return Files.readString(publicKeyFile).trim(); } - /** - * Reconstruct a KeyPair from a PrivateKey. - * - *

    For RSA keys, derives the public key from the private key. - * - * @param privateKey the RSA private key - * @return reconstructed KeyPair - * @throws NoSuchAlgorithmException if RSA algorithm is not available - * @throws InvalidKeySpecException if the key specification is invalid - */ private KeyPair reconstructKeyPair(PrivateKey privateKey) throws NoSuchAlgorithmException, InvalidKeySpecException { - // For RSA, we can derive the public key from the private key KeyFactory keyFactory = KeyFactory.getInstance("RSA"); - - // Get the private key spec RSAPrivateCrtKey rsaPrivateKey = (RSAPrivateCrtKey) privateKey; - - // Create public key spec from private key parameters RSAPublicKeySpec publicKeySpec = new RSAPublicKeySpec(rsaPrivateKey.getModulus(), rsaPrivateKey.getPublicExponent()); - - // Generate public key - PublicKey publicKey = keyFactory.generatePublic(publicKeySpec); - - return new KeyPair(publicKey, privateKey); + return new KeyPair(keyFactory.generatePublic(publicKeySpec), privateKey); } private String encodePublicKey(PublicKey publicKey) { return Base64.getEncoder().encodeToString(publicKey.getEncoded()); } + @Override public PublicKey decodePublicKey(String encodedKey) throws NoSuchAlgorithmException, InvalidKeySpecException { - byte[] keyBytes = Base64.getDecoder().decode(encodedKey); - X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes); - KeyFactory keyFactory = KeyFactory.getInstance("RSA"); - return keyFactory.generatePublic(keySpec); + X509EncodedKeySpec keySpec = new X509EncodedKeySpec(Base64.getDecoder().decode(encodedKey)); + return KeyFactory.getInstance("RSA").generatePublic(keySpec); + } + + private PrivateKey decodePrivateKey(String encodedKey) + throws NoSuchAlgorithmException, InvalidKeySpecException { + PKCS8EncodedKeySpec keySpec = + new PKCS8EncodedKeySpec(Base64.getDecoder().decode(encodedKey)); + return KeyFactory.getInstance("RSA").generatePrivate(keySpec); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java index 45532978f3..0cb4653ef1 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java @@ -95,6 +95,7 @@ public class UserService implements UserServiceInterface { private final ResourceGrantRepository resourceGrantRepository; private final IntegrationConfigRepository integrationConfigRepository; private final TeamMembershipService teamMembershipService; + private final ApiKeyAuthenticationService apiKeyAuthenticationService; @Transactional public void processSSOPostLogin( @@ -147,15 +148,16 @@ public class UserService implements UserServiceInterface { } public Authentication getAuthentication(String apiKey) { - Optional user = getUserByApiKey(apiKey); - if (user.isEmpty()) { - throw new UsernameNotFoundException("API key is not valid"); - } - // Convert the user into an Authentication object - return new UsernamePasswordAuthenticationToken( // principal (typically the user) - user, // credentials (we don't expose the password or API key here) - null, // user's authorities (roles/permissions) - getAuthorities(user.get())); + // Resolve through the shared service (multi-key table, then the legacy per-user column). + // The key runs as its owner with the owner's authorities. + var resolved = + apiKeyAuthenticationService + .authenticate(apiKey) + .orElseThrow(() -> new UsernameNotFoundException("API key is not valid")); + return new UsernamePasswordAuthenticationToken( + resolved.user(), // principal + null, // credentials (we don't expose the password or API key here) + resolved.authorities()); // the owner's authorities } private Collection getAuthorities(User user) { @@ -173,6 +175,9 @@ public class UserService implements UserServiceInterface { public User addApiKeyToUser(String username) { Optional userOpt = findByUsernameIgnoreCase(username); + // Rotating/regenerating the legacy key must also revoke its migrated api_keys shadow row, + // otherwise the old secret keeps authenticating (it resolves from api_keys first). + userOpt.map(User::getApiKey).ifPresent(apiKeyAuthenticationService::revokeMigratedKey); User user = saveUser(userOpt, generateApiKey()); try { databaseService.exportDatabase(); @@ -220,7 +225,8 @@ public class UserService implements UserServiceInterface { } public Optional getUserByApiKey(String apiKey) { - return userRepository.findByApiKey(apiKey); + // Resolves the multi-key api_keys table first, then the legacy per-user column. + return apiKeyAuthenticationService.resolveUser(apiKey); } public Optional loadUserByApiKey(String apiKey) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/session/SessionPersistentRegistry.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/session/SessionPersistentRegistry.java index 3961107870..19adb9a970 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/session/SessionPersistentRegistry.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/session/SessionPersistentRegistry.java @@ -146,6 +146,16 @@ public class SessionPersistentRegistry implements SessionRegistry { return sessionRepository.findAll(); } + // Flag every session idle past the timeout. + public int expireStaleSessions() { + return sessionRepository.expireOlderThan(Instant.now().minus(defaultMaxInactiveInterval)); + } + + // Purge sessions expired longer than the retention window. + public int purgeExpiredSessions(Duration retention) { + return sessionRepository.deleteExpiredOlderThan(Instant.now().minus(retention)); + } + // Mark a session as expired public void expireSession(String sessionId) { Optional sessionEntityOpt = sessionRepository.findById(sessionId); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/session/SessionScheduled.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/session/SessionScheduled.java index 1f491bf4d6..8afde4959d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/session/SessionScheduled.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/session/SessionScheduled.java @@ -1,12 +1,8 @@ package stirling.software.proprietary.security.session; -import java.time.Instant; -import java.time.temporal.ChronoUnit; -import java.util.Date; -import java.util.List; +import java.time.Duration; import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.security.core.session.SessionInformation; import org.springframework.stereotype.Component; import lombok.RequiredArgsConstructor; @@ -15,23 +11,15 @@ import lombok.RequiredArgsConstructor; @RequiredArgsConstructor public class SessionScheduled { + // Retention before an expired session is purged. + private static final Duration EXPIRED_SESSION_RETENTION = Duration.ofDays(30); + private final SessionPersistentRegistry sessionPersistentRegistry; @Scheduled(cron = "0 0/5 * * * ?") public void expireSessions() { - Instant now = Instant.now(); - for (Object principal : sessionPersistentRegistry.getAllPrincipals()) { - List sessionInformations = - sessionPersistentRegistry.getAllSessions(principal, false); - for (SessionInformation sessionInformation : sessionInformations) { - Date lastRequest = sessionInformation.getLastRequest(); - int maxInactiveInterval = sessionPersistentRegistry.getMaxInactiveInterval(); - Instant expirationTime = - lastRequest.toInstant().plus(maxInactiveInterval, ChronoUnit.SECONDS); - if (now.isAfter(expirationTime)) { - sessionPersistentRegistry.expireSession(sessionInformation.getSessionId()); - } - } - } + // Flag timed-out sessions, then purge long-dead ones. + sessionPersistentRegistry.expireStaleSessions(); + sessionPersistentRegistry.purgeExpiredSessions(EXPIRED_SESSION_RETENTION); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/AiDocumentHtmlRenderer.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiDocumentHtmlRenderer.java new file mode 100644 index 0000000000..26eff3113c --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiDocumentHtmlRenderer.java @@ -0,0 +1,135 @@ +package stirling.software.proprietary.service; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; + +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Component; + +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.JinjavaConfig; + +import stirling.software.proprietary.model.api.ai.create.AiDocument; + +/** Renders an {@link AiDocument} to HTML using a Jinja template loaded from the classpath. */ +@Component +public class AiDocumentHtmlRenderer { + + private static final String TEMPLATE_PATH = "templates/ai/create/document.html.jinja2"; + + private static final Pattern SAFE_COLOR = Pattern.compile("^#[0-9a-fA-F]{6}$"); + + private final Jinjava jinjava; + private final String template; + + public AiDocumentHtmlRenderer() { + JinjavaConfig config = + JinjavaConfig.newBuilder().withNestedInterpretationEnabled(false).build(); + this.jinjava = new Jinjava(config); + this.template = loadTemplate(); + } + + public String render(AiDocument doc) { + return jinjava.render(template, buildContext(doc)); + } + + private static Map buildContext(AiDocument doc) { + Map context = new LinkedHashMap<>(); + context.put("title", doc.getTitle()); + context.put("subtitle", doc.getSubtitle()); + context.put("reference_number", doc.getReferenceNumber()); + + AiDocument.Style style = doc.getStyle(); + if (style != null) { + context.put("style_primary", safeColor(style.getPrimaryColor())); + context.put("style_background", safeColor(style.getBackgroundColor())); + context.put("style_body", safeColor(style.getBodyTextColor())); + } + + List> sections = new ArrayList<>(); + if (doc.getSections() != null) { + for (AiDocument.Section section : doc.getSections()) { + if (section != null && section.getType() != null) { + sections.add(buildSection(section)); + } + } + } + context.put("sections", sections); + return context; + } + + private static Map buildSection(AiDocument.Section section) { + Map node = new LinkedHashMap<>(); + node.put("type", section.getType()); + node.put("heading", section.getHeading()); + switch (section.getType()) { + case "text" -> node.put("paragraphs", paragraphs(section.getBody())); + case "key_value" -> node.put("pairs", pairs(section.getPairs())); + case "line_items" -> { + node.put("columns", orEmpty(section.getColumns())); + node.put("rows", orEmptyRows(section.getRows())); + node.put("total_row", emptyToNull(section.getTotalRow())); + } + case "bullet_list" -> node.put("items", orEmpty(section.getItems())); + case "signature" -> node.put("signatories", orEmpty(section.getSignatories())); + default -> {} + } + return node; + } + + private static List paragraphs(String body) { + String text = body == null ? "" : body; + List out = new ArrayList<>(); + for (String paragraph : text.split("\n\n")) { + out.add(paragraph.replace("\n", " ")); + } + return out; + } + + private static List> pairs(List> pairs) { + List> out = new ArrayList<>(); + if (pairs != null) { + for (List pair : pairs) { + Map node = new LinkedHashMap<>(); + node.put("label", pair.isEmpty() ? "" : pair.get(0)); + node.put("value", pair.size() < 2 ? "" : pair.get(1)); + out.add(node); + } + } + return out; + } + + private static List orEmpty(List values) { + return values == null ? List.of() : values; + } + + private static List> orEmptyRows(List> rows) { + return rows == null ? List.of() : rows; + } + + private static List emptyToNull(List values) { + return values == null || values.isEmpty() ? null : values; + } + + private static String safeColor(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return SAFE_COLOR.matcher(trimmed).matches() ? trimmed : null; + } + + private static String loadTemplate() { + try { + return new ClassPathResource(TEMPLATE_PATH).getContentAsString(StandardCharsets.UTF_8); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/AiEngineConfigSync.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiEngineConfigSync.java new file mode 100644 index 0000000000..99f5f025ef --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiEngineConfigSync.java @@ -0,0 +1,282 @@ +package stirling.software.proprietary.service; + +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.stream.Collectors; + +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Service; + +import jakarta.annotation.PreDestroy; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.model.ApplicationProperties.AiEngine; + +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ObjectNode; + +/** + * Pushes admin-configured AI settings to the engine on startup and after each save; non-blocking + * and best-effort. Disabled via {@code aiEngine.pushConfigToEngine} for env-driven deployments. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AiEngineConfigSync { + + private static final int MAX_ATTEMPTS = 5; + private static final long RETRY_DELAY_MS = 3000L; + + private final ApplicationProperties applicationProperties; + private final AiEngineClient aiEngineClient; + private final ObjectMapper objectMapper; + + // Single worker keeps pushes strictly ordered; virtual (daemon) thread never blocks shutdown. + private final ExecutorService pushExecutor = + Executors.newSingleThreadExecutor( + Thread.ofVirtual().name("ai-engine-config-sync").factory()); + + @PreDestroy + void shutdown() { + pushExecutor.shutdownNow(); + } + + @EventListener(ApplicationReadyEvent.class) + public void pushConfigOnStartup() { + AiEngine cfg = applicationProperties.getAiEngine(); + if (!cfg.isEnabled()) { + return; + } + if (!cfg.isPushConfigToEngine()) { + log.debug( + "Skipping AI engine config push: aiEngine.pushConfigToEngine is disabled" + + " (the engine is configured from its own environment)"); + return; + } + // Engine may still be booting; push off-thread with retries so startup never blocks. + submit(() -> pushWithRetries(cfg)); + } + + /** + * Push AI settings to the engine after an admin save so changes apply without a restart. No-op + * unless AI is enabled and an engine-relevant {@code aiEngine.*} key changed. + */ + public void pushLiveAfterSave(Map pendingAiEngine) { + // Save already persisted; a build/dispatch failure must not fail the save. + try { + // Gate on the running bean: the client refuses calls while disabled, so a pending + // enable would always fail here; the post-restart startup push covers first enablement. + AiEngine cfg = applicationProperties.getAiEngine(); + if (pendingAiEngine == null + || pendingAiEngine.isEmpty() + || !cfg.isPushConfigToEngine() + || !cfg.isEnabled()) { + return; + } + Set engineKeys = + pendingAiEngine.keySet().stream() + .filter(AiEngineConfigSync::isEngineRelevantKey) + .collect(Collectors.toSet()); + if (engineKeys.isEmpty()) { + return; + } + ObjectNode node = buildConfigNode(cfg); + pendingAiEngine.forEach((k, v) -> overlayIfEngineRelevant(node, k, v)); + keepEnvForUnconfiguredIdentity(node, engineKeys); + String body = node.toString(); + submit(() -> pushOnce(body)); + } catch (Exception e) { + log.warn( + "Could not build the live AI engine config push: {} (settings were saved; the" + + " engine will re-sync on the next restart)", + e.getMessage(), + e); + } + } + + /** + * Run pushes on the single-threaded executor so they stay serialised: each carries the full + * config and the engine keeps whatever lands last, so overlapping pushes could leave it stale. + */ + private void submit(Runnable task) { + pushExecutor.execute(task); + } + + private void pushOnce(String body) { + try { + aiEngineClient.post("/api/v1/config", body, null); + log.info("Pushed AI engine configuration after settings change"); + } catch (Exception e) { + log.error( + "Live AI engine config push failed: {}. The engine keeps running its previous" + + " configuration; if the engine is not on localhost, set" + + " STIRLING_ENGINE_SHARED_SECRET on both the engine and the processor" + + " so it accepts the push.", + e.getMessage()); + } + } + + // Only models/rag/limits reach the engine; the rest is processor-side. + private static boolean isEngineRelevantKey(String key) { + return key.startsWith("aiEngine.models.") + || key.startsWith("aiEngine.rag.") + || key.startsWith("aiEngine.limits."); + } + + private void overlayIfEngineRelevant(ObjectNode node, String key, Object value) { + if (!isEngineRelevantKey(key)) { + return; + } + String[] parts = key.substring("aiEngine.".length()).split("\\."); + if (parts.length < 2) { + // No leaf here; writing at parts[0] would overwrite the whole section with a scalar. + return; + } + ObjectNode parent = node; + for (int i = 0; i < parts.length - 1; i++) { + JsonNode child = parent.get(parts[i]); + parent = (child instanceof ObjectNode on) ? on : parent.putObject(parts[i]); + } + parent.set(parts[parts.length - 1], objectMapper.valueToTree(value)); + } + + private void pushWithRetries(AiEngine cfg) { + ObjectNode node = buildConfigNode(cfg); + keepEnvForUnconfiguredIdentity(node, Set.of()); + String body = node.toString(); + for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + try { + aiEngineClient.post("/api/v1/config", body, null); + log.info("Pushed AI engine configuration on startup (attempt {})", attempt); + return; + } catch (Exception e) { + log.warn( + "AI engine config push failed (attempt {}/{}): {}", + attempt, + MAX_ATTEMPTS, + e.getMessage()); + if (attempt < MAX_ATTEMPTS) { + try { + Thread.sleep(RETRY_DELAY_MS); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + return; + } + } + } + } + log.warn( + "Giving up pushing AI engine configuration after {} attempts; the engine will use" + + " its own environment configuration until the next restart.", + MAX_ATTEMPTS); + } + + private ObjectNode buildConfigNode(AiEngine cfg) { + AiEngine.Models m = cfg.getModels(); + AiEngine.Rag r = cfg.getRag(); + AiEngine.Limits l = cfg.getLimits(); + + ObjectNode root = objectMapper.createObjectNode(); + + ObjectNode models = root.putObject("models"); + models.put("provider", m.getProvider()); + models.put("smartModel", m.getSmartModel()); + models.put("fastModel", m.getFastModel()); + models.put("smartMaxTokens", m.getSmartMaxTokens()); + models.put("fastMaxTokens", m.getFastMaxTokens()); + models.put("apiKey", m.getApiKey()); + models.put("baseUrl", m.getBaseUrl()); + + ObjectNode rag = root.putObject("rag"); + rag.put("embeddingProvider", r.getEmbeddingProvider()); + rag.put("embeddingModel", r.getEmbeddingModel()); + rag.put("embeddingApiKey", r.getEmbeddingApiKey()); + rag.put("embeddingBaseUrl", r.getEmbeddingBaseUrl()); + rag.put("topK", r.getTopK()); + rag.put("maxSearches", r.getMaxSearches()); + + ObjectNode limits = root.putObject("limits"); + limits.put("maxPages", l.getMaxPages()); + limits.put("maxCharacters", l.getMaxCharacters()); + limits.put("modelMaxConcurrency", l.getModelMaxConcurrency()); + + return root; + } + + // Defaults used to detect whether a section was configured or left at built-in values. + private static final AiEngine.Models DEFAULT_MODELS = new AiEngine.Models(); + private static final AiEngine.Rag DEFAULT_RAG = new AiEngine.Rag(); + + private static boolean isBlank(String s) { + return s == null || s.isBlank(); + } + + private static String text(JsonNode section, String field) { + return section.path(field).asText(""); + } + + /** + * Blank the identity (provider/model/credentials) of unconfigured sections so the push keeps + * the engine's env values; edited sections are sent as-is so a cleared key really clears. + */ + private void keepEnvForUnconfiguredIdentity(ObjectNode root, Set touchedKeys) { + if (root.get("models") instanceof ObjectNode models) { + boolean configured = + touchedIdentity(touchedKeys, MODEL_IDENTITY_KEYS) + || !isBlank(text(models, "apiKey")) + || !isBlank(text(models, "baseUrl")) + || !DEFAULT_MODELS.getProvider().equals(text(models, "provider")) + || !DEFAULT_MODELS.getSmartModel().equals(text(models, "smartModel")) + || !DEFAULT_MODELS.getFastModel().equals(text(models, "fastModel")); + if (!configured) { + models.put("provider", ""); + models.put("smartModel", ""); + models.put("fastModel", ""); + models.put("apiKey", ""); + models.put("baseUrl", ""); + } + } + if (root.get("rag") instanceof ObjectNode rag) { + boolean configured = + touchedIdentity(touchedKeys, RAG_IDENTITY_KEYS) + || !isBlank(text(rag, "embeddingApiKey")) + || !isBlank(text(rag, "embeddingBaseUrl")) + || !DEFAULT_RAG + .getEmbeddingProvider() + .equals(text(rag, "embeddingProvider")) + || !DEFAULT_RAG.getEmbeddingModel().equals(text(rag, "embeddingModel")); + if (!configured) { + rag.put("embeddingProvider", ""); + rag.put("embeddingModel", ""); + rag.put("embeddingApiKey", ""); + rag.put("embeddingBaseUrl", ""); + } + } + } + + private static final Set MODEL_IDENTITY_KEYS = + Set.of( + "aiEngine.models.provider", + "aiEngine.models.smartModel", + "aiEngine.models.fastModel", + "aiEngine.models.apiKey", + "aiEngine.models.baseUrl"); + + private static final Set RAG_IDENTITY_KEYS = + Set.of( + "aiEngine.rag.embeddingProvider", + "aiEngine.rag.embeddingModel", + "aiEngine.rag.embeddingApiKey", + "aiEngine.rag.embeddingBaseUrl"); + + private static boolean touchedIdentity(Set touchedKeys, Set identityKeys) { + return touchedKeys.stream().anyMatch(identityKeys::contains); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/AiFeatureGate.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiFeatureGate.java new file mode 100644 index 0000000000..ffce8a1eb3 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiFeatureGate.java @@ -0,0 +1,56 @@ +package stirling.software.proprietary.service; + +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ResponseStatusException; + +import lombok.RequiredArgsConstructor; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.model.ApplicationProperties.AiEngine.Features; + +/** + * Central gate for the AI feature switches ({@code aiEngine.features.*}); each {@code require*} + * throws 503 when the engine is disabled or the capability is off. + */ +@Component +@RequiredArgsConstructor +public class AiFeatureGate { + + private final ApplicationProperties applicationProperties; + + private Features features() { + return applicationProperties.getAiEngine().getFeatures(); + } + + private void require(boolean featureEnabled, String feature) { + if (!applicationProperties.getAiEngine().isEnabled() || !featureEnabled) { + throw new ResponseStatusException( + HttpStatus.SERVICE_UNAVAILABLE, "AI feature '" + feature + "' is disabled"); + } + } + + /** + * Shared entry point for chat and document questions; open while either is enabled, since a + * request can't be attributed to just one. No per-capability gate exists for the same reason. + */ + public void requireConversationalWorkflow() { + require(features().isChat() || features().isDocumentQuestions(), "conversation"); + } + + public void requireCreatePdf() { + require(features().isCreatePdf(), "createPdf"); + } + + public void requireMathAuditor() { + require(features().isMathAuditor(), "mathAuditor"); + } + + public void requirePdfComment() { + require(features().isPdfComment(), "pdfComment"); + } + + public void requireClassify() { + require(features().isClassify(), "classify"); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/AiToolInputValidator.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiToolInputValidator.java index 260d69fb22..b2d389be3e 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/AiToolInputValidator.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiToolInputValidator.java @@ -39,7 +39,7 @@ public final class AiToolInputValidator { } if (file.getSize() > MAX_INPUT_FILE_BYTES) { throw new ResponseStatusException( - HttpStatus.PAYLOAD_TOO_LARGE, + HttpStatus.CONTENT_TOO_LARGE, "PDF exceeds maximum size of " + (MAX_INPUT_FILE_BYTES / (1024 * 1024)) + " MB for AI tools"); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java index 74e68d4602..b9e3488324 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java @@ -774,7 +774,7 @@ public class AiWorkflowService { String[] errorHolder) { try { JsonNode node = objectMapper.readTree(line); - String event = node.path("event").asText(); + String event = node.path("event").asString(); switch (event) { case "progress" -> { AiEngineProgressDetail detail = @@ -785,7 +785,7 @@ public class AiWorkflowService { JsonNode response = node.path("response"); resultHolder[0] = objectMapper.treeToValue(response, AiWorkflowResponse.class); } - case "error" -> errorHolder[0] = node.path("message").asText("unknown error"); + case "error" -> errorHolder[0] = node.path("message").asString("unknown error"); case "heartbeat" -> listener.onHeartbeat(); default -> log.warn("Ignoring unknown engine stream event: {}", event); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/PdfCommentAgentOrchestrator.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/PdfCommentAgentOrchestrator.java index 9fbcda00f8..57b5699e62 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/PdfCommentAgentOrchestrator.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/PdfCommentAgentOrchestrator.java @@ -7,6 +7,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.UUID; +import java.util.regex.Pattern; import org.apache.commons.io.FilenameUtils; import org.apache.pdfbox.pdmodel.PDDocument; @@ -62,6 +63,8 @@ public class PdfCommentAgentOrchestrator { /** Filename used when the uploaded PDF has no usable original filename. */ private static final String FALLBACK_OUTPUT_NAME = "document-commented.pdf"; + private static final Pattern NEWLINE_PATTERN = Pattern.compile("[\\r\\n]"); + /** * Small value record returned to the controller: the annotated PDF bytes, the suggested * download filename (used in the {@code Content-Disposition} header), and metadata the @@ -263,7 +266,7 @@ public class PdfCommentAgentOrchestrator { private static String safeName(String originalFilename) { return originalFilename != null - ? originalFilename.replaceAll("[\\r\\n]", "_") + ? NEWLINE_PATTERN.matcher(originalFilename).replaceAll("_") : ""; } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/PortalDocumentsService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/PortalDocumentsService.java index fcae36f61b..3e519da5ed 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/PortalDocumentsService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/PortalDocumentsService.java @@ -65,12 +65,13 @@ public class PortalDocumentsService { // "API". The automation marker distinguishes a policy-run step from real API traffic. boolean automation = isAutomation(data); String policyName = asString(data.get("policyName")); + String origin = asString(data.get("__origin")); String source = automation ? (policyName != null && !policyName.isBlank() ? "Policy: " + policyName : "Policy automation") - : sourceLabel(asString(data.get("__origin"))); + : sourceLabel(origin, asString(data.get("__apiKeyLabel"))); String product = automation ? "Automation" : productLabel(source); String action = prettyTool(path); boolean failed = isFailure(data); @@ -173,9 +174,12 @@ public class PortalDocumentsService { return code instanceof Number n && n.intValue() >= 400; } - private static String sourceLabel(String origin) { + private static String sourceLabel(String origin, String apiKeyLabel) { if ("API".equals(origin)) { - return "API integration"; + // Attribute to the specific named key when known, else the generic API channel. + return apiKeyLabel != null && !apiKeyLabel.isBlank() + ? "API key · " + apiKeyLabel + : "API integration"; } if ("SYSTEM".equals(origin)) { return "System"; @@ -184,7 +188,8 @@ public class PortalDocumentsService { } private static String productLabel(String source) { - return "API integration".equals(source) ? "API" : "Editor"; + // Covers both the generic "API integration" and per-key "API key ·

    -
    {{ doc.title }}
    - {%- if doc.subtitle %} -
    {{ doc.subtitle }}
    +
    {{ title }}
    + {%- if subtitle %} +
    {{ subtitle }}
    {%- endif %} - {%- if doc.reference_number %} -
    {{ doc.reference_number }}
    + {%- if reference_number %} +
    {{ reference_number }}
    {%- endif %}
    -{%- for section in doc.sections %} +{%- for section in sections %} {%- if section.type == "text" %}
    @@ -212,8 +213,8 @@

    {{ section.heading }}

    {%- endif %}
    - {%- for para in section.body.split('\n\n') %} -

    {{ para | replace('\n', ' ') }}

    + {%- for para in section.paragraphs %} +

    {{ para }}

    {%- endfor %}
    @@ -225,10 +226,10 @@ {%- endif %} - {%- for label, value in section.pairs %} + {%- for pair in section.pairs %} - - + + {%- endfor %} @@ -299,3 +300,4 @@ +{%- endautoescape %} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/access/service/ResourceAccessPortalBulkParityTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/access/service/ResourceAccessPortalBulkParityTest.java new file mode 100644 index 0000000000..ba73fd2149 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/access/service/ResourceAccessPortalBulkParityTest.java @@ -0,0 +1,152 @@ +package stirling.software.proprietary.access.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.lenient; + +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import stirling.software.common.model.enumeration.Role; +import stirling.software.proprietary.access.model.AccessPermission; +import stirling.software.proprietary.access.model.DefaultAccessPolicy; +import stirling.software.proprietary.access.model.PrincipalRef; +import stirling.software.proprietary.access.model.PrincipalType; +import stirling.software.proprietary.access.model.ResourceGrant; +import stirling.software.proprietary.access.model.ResourceType; +import stirling.software.proprietary.access.repository.ResourceGrantRepository; +import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.security.model.Authority; +import stirling.software.proprietary.security.model.User; + +/** Bulk portal-access must match per-user canAccessPortal for every policy. */ +@ExtendWith(MockitoExtension.class) +class ResourceAccessPortalBulkParityTest { + + @Mock private ResourceGrantRepository grantRepository; + @Mock private TeamLeadLookup teamLeadLookup; + + private ResourceAccessService service; + + private User admin; + private User leader; + private User userGrantHolder; + private User teamGrantMember; + private User plainMember; + private List everyone; + private Set leaderUserIds; + + void setUp(DefaultAccessPolicy policy) { + service = + new ResourceAccessService( + grantRepository, teamLeadLookup, new DefaultPrincipalResolver()); + ReflectionTestUtils.setField(service, "portalDefaultPolicy", policy); + + admin = user(1L, null, Role.ADMIN.getRoleId()); + leader = user(2L, 10L, Role.USER.getRoleId()); + userGrantHolder = user(3L, null, Role.USER.getRoleId()); + teamGrantMember = user(4L, 20L, Role.USER.getRoleId()); + plainMember = user(5L, 10L, Role.USER.getRoleId()); + everyone = List.of(admin, leader, userGrantHolder, teamGrantMember, plainMember); + + // Grants: a USER grant to #3 and a TEAM grant to team 20 (which #4 belongs to). + lenient() + .when(grantRepository.findByResourceTypeAndResourceId(ResourceType.PORTAL, "")) + .thenReturn( + List.of( + grant(PrincipalType.USER, 3L, AccessPermission.USE), + grant(PrincipalType.TEAM, 20L, AccessPermission.USE))); + + // Only #2 leads their active team; leaderUserIds is what the controller passes to the + // bulk method (the active-team-leader set). + lenient().when(teamLeadLookup.isLeaderOfTeam(leader, 10L)).thenReturn(true); + leaderUserIds = Set.of(2L); + } + + @Test + void bulkMatchesPerUserForAdminsAndTeamLeadsPolicy() { + assertParity(DefaultAccessPolicy.ADMINS_AND_TEAM_LEADS); + } + + @Test + void bulkMatchesPerUserForOrgAllPolicy() { + assertParity(DefaultAccessPolicy.ORG_ALL); + } + + @Test + void bulkMatchesPerUserForExplicitOnlyPolicy() { + assertParity(DefaultAccessPolicy.EXPLICIT_ONLY); + } + + /** SaaS no-leak: ORG_ALL grants nobody deployment-wide when the resolver forbids it. */ + @Test + void orgAllDoesNotLeakDeploymentWideWhenResolverForbidsIt() { + DefaultPrincipalResolver base = new DefaultPrincipalResolver(); + PrincipalResolver saasLikeResolver = + new PrincipalResolver() { + @Override + public Set principalsOf(User user) { + return base.principalsOf(user); + } + // allowsDeploymentWideAccess() inherits the interface default (false) = SaaS. + }; + service = new ResourceAccessService(grantRepository, teamLeadLookup, saasLikeResolver); + ReflectionTestUtils.setField(service, "portalDefaultPolicy", DefaultAccessPolicy.ORG_ALL); + lenient() + .when(grantRepository.findByResourceTypeAndResourceId(ResourceType.PORTAL, "")) + .thenReturn(List.of()); + + User adminUser = user(1L, null, Role.ADMIN.getRoleId()); + User plainMember = user(5L, 10L, Role.USER.getRoleId()); + + Set bulk = service.usersWithPortalAccess(List.of(adminUser, plainMember), Set.of()); + + assertThat(bulk).contains(1L).doesNotContain(5L); + assertThat(service.canAccessPortal(plainMember)) + .as("ORG_ALL must not grant a plain member deployment-wide on a SaaS-like resolver") + .isFalse(); + assertThat(service.canAccessPortal(adminUser)).isTrue(); + } + + private void assertParity(DefaultAccessPolicy policy) { + setUp(policy); + Set bulk = service.usersWithPortalAccess(everyone, leaderUserIds); + for (User user : everyone) { + boolean authoritative = service.canAccessPortal(user); + assertThat(bulk.contains(user.getId())) + .as( + "policy=%s user=%d bulk should equal canAccessPortal(%s)", + policy, user.getId(), authoritative) + .isEqualTo(authoritative); + } + } + + private User user(Long id, Long teamId, String authority) { + User user = new User(); + user.setId(id); + user.setUsername("user-" + id); + new Authority(authority, user); + if (teamId != null) { + Team team = new Team(); + team.setId(teamId); + team.setName("team-" + teamId); + user.setTeam(team); + } + return user; + } + + private ResourceGrant grant(PrincipalType type, Long principalId, AccessPermission permission) { + ResourceGrant grant = new ResourceGrant(); + grant.setResourceType(ResourceType.PORTAL); + grant.setResourceId(""); + grant.setPrincipalType(type); + grant.setPrincipalId(principalId); + grant.setPermission(permission); + return grant; + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/access/service/ResourceAccessServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/access/service/ResourceAccessServiceTest.java index c63e6b8e47..90019c8cbb 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/access/service/ResourceAccessServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/access/service/ResourceAccessServiceTest.java @@ -239,10 +239,10 @@ class ResourceAccessServiceTest { } @Test - void teamLeadDefaultAllowsLeaderButNotRegularUser() { + void teamLeadDefaultAllowsActiveTeamLeaderButNotRegularUser() { stubGrants(); - User leader = user(5); - when(teamLeadLookup.isAnyTeamLeader(leader)).thenReturn(true); + User leader = userInTeam(5, 7L); + when(teamLeadLookup.isLeaderOfTeam(leader, 7L)).thenReturn(true); assertThat( service.canUseResource( TYPE, RID, null, DefaultAccessPolicy.ADMINS_AND_TEAM_LEADS, leader)) @@ -291,6 +291,27 @@ class ResourceAccessServiceTest { assertThat(service.canAccessPortal(user(5))).isFalse(); } + @Test + void portalAllowedToLeaderOfActiveTeam() { + when(grantRepository.findByResourceTypeAndResourceId(ResourceType.PORTAL, "")) + .thenReturn(List.of()); + User leader = userInTeam(10, 21L); + when(teamLeadLookup.isLeaderOfTeam(leader, 21L)).thenReturn(true); + assertThat(service.canAccessPortal(leader)).isTrue(); + } + + @Test + void portalDeniedToMemberWhoseActiveTeamTheyDoNotLead() { + // Durable home teams: a user still leads their dormant home team, but their ACTIVE + // team is one they only belong to -> no portal access (this is the bug-3 guard that + // active-team leadership preserves once home teams stop being deleted on join). + when(grantRepository.findByResourceTypeAndResourceId(ResourceType.PORTAL, "")) + .thenReturn(List.of()); + User member = userInTeam(11, 22L); + // isLeaderOfTeam(member, 22L) left unstubbed -> false: member of active team. + assertThat(service.canAccessPortal(member)).isFalse(); + } + // ---- helpers ---- private void stubGrants(ResourceGrant... grants) { diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/access/service/SecretMaskerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/access/service/SecretMaskerTest.java index 4f76b06b5c..dbd8c02131 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/access/service/SecretMaskerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/access/service/SecretMaskerTest.java @@ -77,6 +77,48 @@ class SecretMaskerTest { assertThat(clean).containsEntry("bucket", "b").doesNotContainKey("secretKey"); } + @Test + void maskRedactsEveryHeaderValueRegardlessOfName() { + Map headers = new LinkedHashMap<>(); + headers.put("X-API-Key", "real-secret"); // name carries no secret hint + headers.put("Ocp-Apim-Subscription-Key", "abc123"); + headers.put("Content-Type", "application/json"); + Map config = new LinkedHashMap<>(); + config.put("baseUrl", "https://api.example"); + config.put("headers", headers); + + Map masked = masker.mask(config); + + assertThat(masked.get("baseUrl")).isEqualTo("https://api.example"); + @SuppressWarnings("unchecked") + Map maskedHeaders = (Map) masked.get("headers"); + assertThat(maskedHeaders.get("X-API-Key")).isEqualTo(SecretMasker.MASK); + assertThat(maskedHeaders.get("Ocp-Apim-Subscription-Key")).isEqualTo(SecretMasker.MASK); + assertThat(maskedHeaders.get("Content-Type")).isEqualTo(SecretMasker.MASK); + } + + @Test + void mergeRestoresRedactedHeaderValuesFromStored() { + Map storedHeaders = new LinkedHashMap<>(); + storedHeaders.put("X-API-Key", "REAL"); + storedHeaders.put("Content-Type", "application/json"); + Map stored = new LinkedHashMap<>(); + stored.put("headers", storedHeaders); + + Map incomingHeaders = new LinkedHashMap<>(); + incomingHeaders.put("X-API-Key", SecretMasker.MASK); // untouched secret comes back masked + incomingHeaders.put("Content-Type", "text/plain"); // genuinely edited + Map incoming = new LinkedHashMap<>(); + incoming.put("headers", incomingHeaders); + + Map merged = masker.merge(stored, incoming); + + @SuppressWarnings("unchecked") + Map mergedHeaders = (Map) merged.get("headers"); + assertThat(mergedHeaders.get("X-API-Key")).isEqualTo("REAL"); // restored, not "********" + assertThat(mergedHeaders.get("Content-Type")).isEqualTo("text/plain"); // updated + } + @Test void deeplyNestedInputIsBoundedNotOverflowing() { // Build a structure far deeper than the recursion cap. diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptorTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptorTest.java index 13a2606105..082da85fd7 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptorTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptorTest.java @@ -155,6 +155,43 @@ class InstanceEntitlementInterceptorTest { verifyNoInteractions(entitlementCache); } + @Test + void gatesPolicyRunUpFrontEvenWithoutAutomationHeader() throws Exception { + // The policy /run call carries no automation header, but must be blocked up front (not + // after its first tool) when the instance is unlinked. + when(gate.evaluate(anyBoolean())) + .thenReturn(GateDecision.block(GateDecision.Reason.NOT_LINKED)); + + InstanceEntitlementInterceptor interceptor = interceptor(); + MockHttpServletRequest req = + new MockHttpServletRequest("POST", "/api/v1/policies/pol-1/run"); + MockHttpServletResponse resp = new MockHttpServletResponse(); + + assertFalse(interceptor.preHandle(req, resp, new Object())); + assertEquals(HttpStatus.PAYMENT_REQUIRED.value(), resp.getStatus()); + assertTrue(resp.getContentAsString().contains("ACCOUNT_LINK_REQUIRED")); + verify(gate).evaluate(true); // gated as billable despite no automation header + } + + @Test + void doesNotMeterThePolicyRunEndpointItself() throws Exception { + // Gated up front, but metered only via its dispatched tool sub-steps (category BYPASSED + // here), so the /run request itself never accrues usage. + when(gate.evaluate(anyBoolean())) + .thenReturn(GateDecision.allow(GateDecision.Reason.ENTITLED)); + UsageMeterService meter = mock(UsageMeterService.class); + when(meterProvider.getIfAvailable()).thenReturn(meter); + + InstanceEntitlementInterceptor interceptor = interceptor(); + MockHttpServletRequest req = + new MockHttpServletRequest("POST", "/api/v1/policies/pol-1/run"); + MockHttpServletResponse resp = new MockHttpServletResponse(); + interceptor.preHandle(req, resp, new Object()); + interceptor.afterCompletion(req, resp, new Object(), null); + + verifyNoInteractions(meter); + } + private static InstanceEntitlement entitled(UnitCalcPolicy policy, LocalDateTime period) { return new InstanceEntitlement( true, 0, 0, 100L, EntitlementState.OK, policy, period, period.plusMonths(1)); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/classification/ClassificationLabelsControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/classification/ClassificationLabelsControllerTest.java deleted file mode 100644 index 221d567c31..0000000000 --- a/app/proprietary/src/test/java/stirling/software/proprietary/classification/ClassificationLabelsControllerTest.java +++ /dev/null @@ -1,133 +0,0 @@ -package stirling.software.proprietary.classification; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.Mockito.when; - -import java.util.List; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.http.HttpStatus; -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.classification.model.ClassificationLabel; -import stirling.software.proprietary.classification.model.ClassificationLabels; -import stirling.software.proprietary.classification.store.ClassificationLabelStore; -import stirling.software.proprietary.classification.store.InProcessClassificationLabelStore; -import stirling.software.proprietary.policy.config.PolicyManagementAuthority; - -@ExtendWith(MockitoExtension.class) -@DisplayName("ClassificationLabelsController") -class ClassificationLabelsControllerTest { - - private static final Long TEAM = 7L; - - @Mock private PolicyManagementAuthority policyManagementAuthority; - @Mock private UserServiceInterface userService; - - private ClassificationLabelStore store; - private ApplicationProperties applicationProperties; - private ClassificationLabelsController controller; - - @BeforeEach - void setUp() { - store = new InProcessClassificationLabelStore(); - applicationProperties = new ApplicationProperties(); - controller = - new ClassificationLabelsController( - store, policyManagementAuthority, applicationProperties, userService); - } - - private static ClassificationLabels sample() { - return new ClassificationLabels( - List.of( - new ClassificationLabel("invoice", "Invoice", "receipt-long"), - new ClassificationLabel("contract", "Contract", null))); - } - - private void loginEnabled(boolean enabled) { - applicationProperties.getSecurity().setEnableLogin(enabled); - } - - @Test - @DisplayName("GET returns 204 when the team has no labels") - void getEmpty() { - when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM); - ResponseEntity response = controller.getTeamLabels(); - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); - } - - @Test - @DisplayName("PUT then GET round-trips the team's labels (login disabled)") - void saveThenGet() { - loginEnabled(false); - when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM); - - controller.saveTeamLabels(sample()); - ResponseEntity got = controller.getTeamLabels(); - - assertThat(got.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(got.getBody()).isNotNull(); - assertThat(got.getBody().labels()).hasSize(2); - assertThat(got.getBody().labels().getFirst().name()).isEqualTo("Invoice"); - assertThat(got.getBody().labels().getFirst().icon()).isEqualTo("receipt-long"); - } - - @Test - @DisplayName("PUT is scoped per team") - void perTeam() { - loginEnabled(false); - when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM); - controller.saveTeamLabels(sample()); - - when(policyManagementAuthority.currentUserTeamId()).thenReturn(99L); - assertThat(controller.getTeamLabels().getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); - } - - @Test - @DisplayName("PUT is rejected for a non-editor when login is enabled") - void putForbiddenForNonEditor() { - loginEnabled(true); - when(policyManagementAuthority.canEditPolicies()).thenReturn(false); - - assertThatThrownBy(() -> controller.saveTeamLabels(sample())) - .isInstanceOf(ResponseStatusException.class) - .hasFieldOrPropertyWithValue("statusCode", HttpStatus.FORBIDDEN); - } - - @Test - @DisplayName("PUT rejects an invalid label set with 400") - void putInvalid() { - loginEnabled(false); - ClassificationLabels duplicate = - new ClassificationLabels( - List.of( - new ClassificationLabel("invoice", "Invoice", null), - new ClassificationLabel("invoice", "Invoice", null))); - - assertThatThrownBy(() -> controller.saveTeamLabels(duplicate)) - .isInstanceOf(ResponseStatusException.class) - .hasFieldOrPropertyWithValue("statusCode", HttpStatus.BAD_REQUEST); - } - - @Test - @DisplayName("DELETE resets the team back to no stored labels") - void deleteResets() { - loginEnabled(false); - when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM); - controller.saveTeamLabels(sample()); - - ResponseEntity response = controller.resetTeamLabels(); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); - assertThat(controller.getTeamLabels().getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); - } -} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/classification/model/LabelsValidatorTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/classification/model/LabelsValidatorTest.java deleted file mode 100644 index b9730370c7..0000000000 --- a/app/proprietary/src/test/java/stirling/software/proprietary/classification/model/LabelsValidatorTest.java +++ /dev/null @@ -1,146 +0,0 @@ -package stirling.software.proprietary.classification.model; - -import static org.assertj.core.api.Assertions.assertThatCode; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import java.util.List; -import java.util.Locale; -import java.util.stream.IntStream; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -@DisplayName("LabelsValidator") -class LabelsValidatorTest { - - private static ClassificationLabels labels(ClassificationLabel... labels) { - return new ClassificationLabels(List.of(labels)); - } - - private static ClassificationLabel label(String name) { - return new ClassificationLabel(slug(name), name, null); - } - - private static String slug(String name) { - return name.trim() - .toLowerCase(Locale.ROOT) - .replaceAll("[^a-z0-9]+", "-") - .replaceAll("(^-|-$)", ""); - } - - @Test - @DisplayName("accepts a well-formed label set") - void acceptsValid() { - ClassificationLabels set = - labels( - new ClassificationLabel("invoice", "Invoice", "receipt-long"), - label("Contract")); - assertThatCode(() -> LabelsValidator.validate(set)).doesNotThrowAnyException(); - } - - @Test - @DisplayName("accepts an empty label set (reads as: use the default)") - void acceptsEmpty() { - assertThatCode(() -> LabelsValidator.validate(labels())).doesNotThrowAnyException(); - } - - @Test - @DisplayName("rejects a null label set") - void rejectsNull() { - assertThatThrownBy(() -> LabelsValidator.validate(null)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("Labels are required"); - } - - @Test - @DisplayName("rejects duplicate names (distinct ids)") - void rejectsDuplicateNames() { - ClassificationLabels set = - labels( - new ClassificationLabel("invoice-a", "Invoice", null), - new ClassificationLabel("invoice-b", "Invoice", null)); - assertThatThrownBy(() -> LabelsValidator.validate(set)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("Duplicate label name"); - } - - @Test - @DisplayName("rejects duplicate names differing only by case") - void rejectsDuplicateNamesCaseInsensitive() { - ClassificationLabels set = - labels( - new ClassificationLabel("invoice-a", "Invoice", null), - new ClassificationLabel("invoice-b", "INVOICE", null)); - assertThatThrownBy(() -> LabelsValidator.validate(set)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("Duplicate label name"); - } - - @Test - @DisplayName("rejects duplicate ids") - void rejectsDuplicateIds() { - ClassificationLabels set = - labels( - new ClassificationLabel("invoice", "Invoice", null), - new ClassificationLabel("invoice", "Sales invoice", null)); - assertThatThrownBy(() -> LabelsValidator.validate(set)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("Duplicate label id"); - } - - @Test - @DisplayName("rejects a blank name") - void rejectsBlankName() { - ClassificationLabels set = labels(new ClassificationLabel("blank", " ", null)); - assertThatThrownBy(() -> LabelsValidator.validate(set)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("Label name must not be blank"); - } - - @Test - @DisplayName("rejects a blank id") - void rejectsBlankId() { - ClassificationLabels set = labels(new ClassificationLabel(" ", "Invoice", null)); - assertThatThrownBy(() -> LabelsValidator.validate(set)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("Label id must not be blank"); - } - - @Test - @DisplayName("rejects an over-long name") - void rejectsOverLongName() { - ClassificationLabels set = - labels( - new ClassificationLabel( - "x", "x".repeat(LabelsValidator.MAX_TEXT_LENGTH + 1), null)); - assertThatThrownBy(() -> LabelsValidator.validate(set)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("too long"); - } - - @Test - @DisplayName("rejects an over-long icon (a null icon is fine)") - void rejectsOverLongIcon() { - ClassificationLabels set = - labels( - new ClassificationLabel( - "invoice", - "Invoice", - "x".repeat(LabelsValidator.MAX_TEXT_LENGTH + 1))); - assertThatThrownBy(() -> LabelsValidator.validate(set)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("icon is too long"); - } - - @Test - @DisplayName("rejects more labels than the cap") - void rejectsTooManyLabels() { - List tooMany = - IntStream.rangeClosed(0, LabelsValidator.MAX_LABELS) - .mapToObj(i -> label("label" + i)) - .toList(); - assertThatThrownBy(() -> LabelsValidator.validate(new ClassificationLabels(tooMany))) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("Too many labels"); - } -} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/AdminSettingsPerfHarness.java b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/AdminSettingsPerfHarness.java new file mode 100644 index 0000000000..35b9e354de --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/AdminSettingsPerfHarness.java @@ -0,0 +1,255 @@ +package stirling.software.proprietary.controller.api; + +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; + +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.hibernate.SessionFactory; +import org.hibernate.stat.Statistics; +import org.springframework.security.core.Authentication; +import org.springframework.test.util.ReflectionTestUtils; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.model.enumeration.Role; +import stirling.software.common.model.enumeration.TeamRole; +import stirling.software.proprietary.access.model.DefaultAccessPolicy; +import stirling.software.proprietary.access.repository.ResourceGrantRepository; +import stirling.software.proprietary.access.service.DefaultPrincipalResolver; +import stirling.software.proprietary.access.service.MembershipTeamLeadLookup; +import stirling.software.proprietary.access.service.ResourceAccessService; +import stirling.software.proprietary.config.AuditConfigurationProperties; +import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.model.TeamMembership; +import stirling.software.proprietary.model.UserLicenseSettings; +import stirling.software.proprietary.repository.PersistentAuditEventRepository; +import stirling.software.proprietary.security.database.repository.SessionRepository; +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.model.Authority; +import stirling.software.proprietary.security.model.SessionEntity; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; +import stirling.software.proprietary.security.repository.TeamRepository; +import stirling.software.proprietary.security.service.DatabaseServiceInterface; +import stirling.software.proprietary.security.service.LoginAttemptService; +import stirling.software.proprietary.security.service.MfaService; +import stirling.software.proprietary.security.session.SessionPersistentRegistry; +import stirling.software.proprietary.service.UserLicenseSettingsService; + +import tools.jackson.databind.ObjectMapper; + +/** Shared seeding, wiring, and statement-count measurement for the admin-roster query tests. */ +class AdminSettingsPerfHarness { + + static final Duration SESSION_TIMEOUT = Duration.ofMinutes(30); + private static final Instant STALE = Instant.now().minus(Duration.ofHours(2)); + private static final Instant FRESH = Instant.now().minus(Duration.ofMinutes(2)); + + record Measure(int users, long statements, long updates, long inserts, long millis) {} + + private final UserRepository userRepository; + private final SessionRepository sessionRepository; + private final TeamRepository teamRepository; + private final TeamMembershipRepository teamMembershipRepository; + private final ResourceGrantRepository resourceGrantRepository; + private final EntityManager em; + private final EntityManagerFactory emf; + + AdminSettingsPerfHarness( + UserRepository userRepository, + SessionRepository sessionRepository, + TeamRepository teamRepository, + TeamMembershipRepository teamMembershipRepository, + ResourceGrantRepository resourceGrantRepository, + EntityManager em, + EntityManagerFactory emf) { + this.userRepository = userRepository; + this.sessionRepository = sessionRepository; + this.teamRepository = teamRepository; + this.teamMembershipRepository = teamMembershipRepository; + this.resourceGrantRepository = resourceGrantRepository; + this.em = em; + this.emf = emf; + } + + Measure seedAndMeasure( + ProprietaryUIDataController controller, Authentication auth, int userCount) { + wipe(); + seed(userCount); + em.flush(); + em.clear(); + + Statistics stats = emf.unwrap(SessionFactory.class).getStatistics(); + stats.setStatisticsEnabled(true); + stats.clear(); + + long t0 = System.nanoTime(); + var response = controller.getAdminSettingsData(auth); + int users = response.getBody().getUsers().size(); + em.flush(); // materialise any writes the GET issued so they are counted + long millis = (System.nanoTime() - t0) / 1_000_000; + + return new Measure( + users, + stats.getPrepareStatementCount(), + stats.getEntityUpdateCount(), + stats.getEntityInsertCount(), + millis); + } + + void wipe() { + // Detach anything a prior measure left managed, then delete children before parents. + em.clear(); + teamMembershipRepository.deleteAllInBatch(); + sessionRepository.deleteAllInBatch(); + resourceGrantRepository.deleteAllInBatch(); + // Not deleteAllInBatch: a bulk DELETE bypasses the User->authorities/user_settings cascade. + userRepository.deleteAll(); + em.flush(); + teamRepository.deleteAllInBatch(); + em.flush(); + em.clear(); + } + + void seed(int userCount) { + int teamCount = Math.max(1, userCount / 40); + List teams = new ArrayList<>(teamCount); + for (int i = 0; i < teamCount; i++) { + Team team = new Team(); + team.setName("team-" + i); + teams.add(team); + } + List savedTeams = teamRepository.saveAll(teams); + em.flush(); + + List users = new ArrayList<>(userCount); + List sessions = new ArrayList<>(userCount); + for (int i = 0; i < userCount; i++) { + User user = new User(); + String username = "user-" + i; + user.setUsername(username); + user.setEnabled(true); + user.setTeam(savedTeams.get(i % teamCount)); + new Authority(i == 0 ? Role.ADMIN.getRoleId() : Role.USER.getRoleId(), user); + Map settings = new HashMap<>(); + settings.put("language", "en-GB"); + if (i % 5 == 0) { + settings.put("mfaSecret", "SECRET-" + i); + } + user.setSettings(settings); + users.add(user); + + SessionEntity session = new SessionEntity(); + session.setSessionId(UUID.randomUUID().toString()); + session.setPrincipalName(username); + // ~30% of sessions are past the timeout. + session.setLastRequest(i % 10 < 3 ? STALE : FRESH); + session.setExpired(false); + sessions.add(session); + } + List savedUsers = userRepository.saveAll(users); + sessionRepository.saveAll(sessions); + em.flush(); + + List memberships = new ArrayList<>(); + for (int i = 0; i < userCount; i++) { + if (i % 10 == 0) { + TeamMembership membership = new TeamMembership(); + membership.setTeam(savedUsers.get(i).getTeam()); + membership.setUser(savedUsers.get(i)); + membership.setRole(TeamRole.LEADER); + membership.setInvitedAt(LocalDateTime.now()); + memberships.add(membership); + } + } + teamMembershipRepository.saveAll(memberships); + em.flush(); + } + + ProprietaryUIDataController buildController() { + ApplicationProperties applicationProperties = + mock(ApplicationProperties.class, RETURNS_DEEP_STUBS); + + SessionPersistentRegistry sessionRegistry = + new SessionPersistentRegistry(sessionRepository); + ReflectionTestUtils.setField( + sessionRegistry, "defaultMaxInactiveInterval", SESSION_TIMEOUT); + + ResourceAccessService resourceAccessService = + new ResourceAccessService( + resourceGrantRepository, + new MembershipTeamLeadLookup(teamMembershipRepository), + new DefaultPrincipalResolver()); + ReflectionTestUtils.setField( + resourceAccessService, + "portalDefaultPolicy", + DefaultAccessPolicy.ADMINS_AND_TEAM_LEADS); + + UserLicenseSettingsService licenseSettingsService = mock(UserLicenseSettingsService.class); + UserLicenseSettings licenseSettings = mock(UserLicenseSettings.class); + lenient().when(licenseSettings.getLicenseMaxUsers()).thenReturn(0); + lenient().when(licenseSettingsService.getSettings()).thenReturn(licenseSettings); + lenient().when(licenseSettingsService.calculateMaxAllowedUsers()).thenReturn(100_000); + lenient().when(licenseSettingsService.getAvailableUserSlots()).thenReturn(100_000L); + lenient().when(licenseSettingsService.getDisplayGrandfatheredCount()).thenReturn(0); + + LoginAttemptService loginAttemptService = mock(LoginAttemptService.class); + lenient().when(loginAttemptService.getAllBlockedUsers()).thenReturn(new ArrayList<>()); + + return new ProprietaryUIDataController( + applicationProperties, + mock(AuditConfigurationProperties.class), + sessionRegistry, + userRepository, + teamRepository, + teamMembershipRepository, + sessionRepository, + mock(DatabaseServiceInterface.class), + mock(ObjectMapper.class), + false, + licenseSettingsService, + mock(PersistentAuditEventRepository.class), + mock(MfaService.class), + loginAttemptService, + resourceAccessService); + } + + Authentication adminAuth() { + Authentication auth = mock(Authentication.class); + lenient().when(auth.getName()).thenReturn("user-0"); + return auth; + } + + User mkUser(String username, Team team, String authority, Map settings) { + User user = new User(); + user.setUsername(username); + user.setEnabled(true); + user.setTeam(team); + new Authority(authority, user); + user.setSettings(new HashMap<>(settings)); + return user; + } + + TeamRepository teams() { + return teamRepository; + } + + UserRepository users() { + return userRepository; + } + + EntityManager em() { + return em; + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/AdminSettingsQueryPerfTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/AdminSettingsQueryPerfTest.java new file mode 100644 index 0000000000..92ec752610 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/AdminSettingsQueryPerfTest.java @@ -0,0 +1,194 @@ +package stirling.software.proprietary.controller.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.boot.persistence.autoconfigure.EntityScan; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.security.core.Authentication; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import jakarta.persistence.PersistenceContext; + +import stirling.software.common.model.enumeration.Role; +import stirling.software.proprietary.access.repository.ResourceGrantRepository; +import stirling.software.proprietary.controller.api.AdminSettingsPerfHarness.Measure; +import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.security.database.repository.SessionRepository; +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.model.dto.AdminUserSummary; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; +import stirling.software.proprietary.security.repository.TeamRepository; +import stirling.software.proprietary.security.service.TeamService; + +/** Admin roster issues a constant query count regardless of size (H2). */ +@DataJpaTest +class AdminSettingsQueryPerfTest { + + @Autowired private UserRepository userRepository; + @Autowired private SessionRepository sessionRepository; + @Autowired private TeamRepository teamRepository; + @Autowired private TeamMembershipRepository teamMembershipRepository; + @Autowired private ResourceGrantRepository resourceGrantRepository; + @Autowired private EntityManagerFactory emf; + + @PersistenceContext private EntityManager em; + + private AdminSettingsPerfHarness harness() { + return new AdminSettingsPerfHarness( + userRepository, + sessionRepository, + teamRepository, + teamMembershipRepository, + resourceGrantRepository, + em, + emf); + } + + @Test + void queryCountDoesNotScaleWithUsers() { + AdminSettingsPerfHarness harness = harness(); + ProprietaryUIDataController controller = harness.buildController(); + Authentication admin = harness.adminAuth(); + + Measure small = harness.seedAndMeasure(controller, admin, 150); + Measure large = harness.seedAndMeasure(controller, admin, 750); + + System.out.printf( + "%n[admin-settings scaling] N=%d -> %d statements, %d updates, %d ms%n", + small.users(), small.statements(), small.updates(), small.millis()); + System.out.printf( + "[admin-settings scaling] N=%d -> %d statements, %d updates, %d ms%n", + large.users(), large.statements(), large.updates(), large.millis()); + long delta = large.statements() - small.statements(); + System.out.printf( + "[admin-settings scaling] +%d users cost +%d statements%n", + large.users() - small.users(), delta); + + assertTrue( + delta <= 40, + "admin-settings issues per-user queries: adding " + + (large.users() - small.users()) + + " users added " + + delta + + " SQL statements (expected <= 40). The roster endpoint still scales O(N)."); + assertEquals( + 0, + large.updates(), + "admin-settings performed " + large.updates() + " row UPDATEs during a read (GET)"); + } + + @Test + void headlineBenchmark() { + int n = Integer.getInteger("adminBenchUsers", 2000); + AdminSettingsPerfHarness harness = harness(); + ProprietaryUIDataController controller = harness.buildController(); + + Measure m = harness.seedAndMeasure(controller, harness.adminAuth(), n); + System.out.printf( + "%n==== admin-settings headline (N=%d users) ====%n" + + " SQL statements : %d%n" + + " row UPDATEs : %d%n" + + " row INSERTs : %d%n" + + " wall-clock : %d ms%n" + + " statements/user: %.2f%n" + + "==============================================%n", + m.users(), + m.statements(), + m.updates(), + m.inserts(), + m.millis(), + (double) m.statements() / n); + + assertEquals(n, m.users(), "roster should return every seeded (non-internal) user"); + } + + @Test + void rosterExcludesInternalAccountsAndMasksSecrets() { + AdminSettingsPerfHarness harness = harness(); + ProprietaryUIDataController controller = harness.buildController(); + harness.wipe(); + + Team acme = new Team(); + acme.setName("acme"); + Team internal = new Team(); + internal.setName(TeamService.INTERNAL_TEAM_NAME); + List savedTeams = harness.teams().saveAll(List.of(acme, internal)); + harness.em().flush(); + + User adminUser = + harness.mkUser("admin", savedTeams.get(0), Role.ADMIN.getRoleId(), Map.of()); + User mfaUser = + harness.mkUser( + "mfa-user", + savedTeams.get(0), + Role.USER.getRoleId(), + Map.of("mfaSecret", "TOPSECRET", "language", "fr")); + User apiUser = + harness.mkUser( + "internal-api", + savedTeams.get(0), + Role.INTERNAL_API_USER.getRoleId(), + Map.of()); + User internalTeamUser = + harness.mkUser("internal-team", savedTeams.get(1), Role.USER.getRoleId(), Map.of()); + harness.users().saveAll(List.of(adminUser, mfaUser, apiUser, internalTeamUser)); + harness.em().flush(); + harness.em().clear(); + + Authentication auth = mock(Authentication.class); + lenient().when(auth.getName()).thenReturn("admin"); + ProprietaryUIDataController.AdminSettingsData data = + controller.getAdminSettingsData(auth).getBody(); + + Set usernames = + data.getUsers().stream() + .map(AdminUserSummary::getUsername) + .collect(Collectors.toSet()); + assertTrue(usernames.contains("admin")); + assertTrue(usernames.contains("mfa-user")); + assertFalse(usernames.contains("internal-api"), "internal-api user must be excluded"); + assertFalse(usernames.contains("internal-team"), "internal-team user must be excluded"); + assertEquals(2, data.getTotalUsers()); + + Map mfaSettings = data.getUserSettings().get("mfa-user"); + assertEquals("********", mfaSettings.get("mfaSecret"), "mfaSecret must be masked"); + assertEquals("fr", mfaSettings.get("language"), "non-secret settings preserved"); + + AdminUserSummary adminSummary = + data.getUsers().stream() + .filter(u -> "admin".equals(u.getUsername())) + .findFirst() + .orElseThrow(); + assertTrue(adminSummary.isPortalAccess(), "admin should have portal access"); + } + + @SpringBootConfiguration + @EntityScan( + basePackages = { + "stirling.software.proprietary.security.model", + "stirling.software.proprietary.model", + "stirling.software.proprietary.access.model" + }) + @EnableJpaRepositories( + basePackages = { + "stirling.software.proprietary.security.database.repository", + "stirling.software.proprietary.security.repository", + "stirling.software.proprietary.access.repository" + }) + static class TestApp {} +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/AdminSettingsQueryPostgresTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/AdminSettingsQueryPostgresTest.java new file mode 100644 index 0000000000..11d3e7184c --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/AdminSettingsQueryPostgresTest.java @@ -0,0 +1,109 @@ +package stirling.software.proprietary.controller.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; +import org.springframework.boot.persistence.autoconfigure.EntityScan; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.security.core.Authentication; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import jakarta.persistence.PersistenceContext; + +import stirling.software.proprietary.access.repository.ResourceGrantRepository; +import stirling.software.proprietary.controller.api.AdminSettingsPerfHarness.Measure; +import stirling.software.proprietary.security.database.repository.SessionRepository; +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; +import stirling.software.proprietary.security.repository.TeamRepository; + +/** Admin-roster queries + index DDL on real Postgres (skipped without Docker). */ +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Testcontainers(disabledWithoutDocker = true) +class AdminSettingsQueryPostgresTest { + + @Container + static PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine"); + + @DynamicPropertySource + static void datasource(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", POSTGRES::getJdbcUrl); + registry.add("spring.datasource.username", POSTGRES::getUsername); + registry.add("spring.datasource.password", POSTGRES::getPassword); + registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver"); + registry.add("spring.jpa.hibernate.ddl-auto", () -> "create-drop"); + registry.add("spring.jpa.properties.hibernate.default_batch_fetch_size", () -> "100"); + } + + @Autowired private UserRepository userRepository; + @Autowired private SessionRepository sessionRepository; + @Autowired private TeamRepository teamRepository; + @Autowired private TeamMembershipRepository teamMembershipRepository; + @Autowired private ResourceGrantRepository resourceGrantRepository; + @Autowired private EntityManagerFactory emf; + + @PersistenceContext private EntityManager em; + + private AdminSettingsPerfHarness harness() { + return new AdminSettingsPerfHarness( + userRepository, + sessionRepository, + teamRepository, + teamMembershipRepository, + resourceGrantRepository, + em, + emf); + } + + @Test + void newRosterQueriesRunOnPostgresWithConstantScaling() { + AdminSettingsPerfHarness harness = harness(); + ProprietaryUIDataController controller = harness.buildController(); + Authentication admin = harness.adminAuth(); + + Measure small = harness.seedAndMeasure(controller, admin, 100); + Measure large = harness.seedAndMeasure(controller, admin, 400); + + System.out.printf( + "%n[admin-settings postgres] N=%d -> %d statements, %d updates, %d ms%n", + small.users(), small.statements(), small.updates(), small.millis()); + System.out.printf( + "[admin-settings postgres] N=%d -> %d statements, %d updates, %d ms%n", + large.users(), large.statements(), large.updates(), large.millis()); + + assertEquals(400, large.users(), "roster returns every seeded user on Postgres"); + assertTrue( + large.statements() - small.statements() <= 40, + "roster must not scale per-user on Postgres (delta=" + + (large.statements() - small.statements()) + + ")"); + assertEquals(0, large.updates(), "no writes during the GET on Postgres"); + } + + @SpringBootConfiguration + @EntityScan( + basePackages = { + "stirling.software.proprietary.security.model", + "stirling.software.proprietary.model", + "stirling.software.proprietary.access.model" + }) + @EnableJpaRepositories( + basePackages = { + "stirling.software.proprietary.security.database.repository", + "stirling.software.proprietary.security.repository", + "stirling.software.proprietary.access.repository" + }) + static class TestApp {} +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ClassifyLabelControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ClassifyLabelControllerTest.java index c5ee39be14..74749289a7 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ClassifyLabelControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ClassifyLabelControllerTest.java @@ -14,7 +14,6 @@ import static org.mockito.Mockito.when; import java.util.List; import org.apache.pdfbox.pdmodel.PDDocument; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; @@ -27,11 +26,10 @@ import org.springframework.web.multipart.MultipartFile; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.service.PdfMetadataService; import stirling.software.common.util.TempFileManager; +import stirling.software.proprietary.classification.ClassificationLabelProvider; import stirling.software.proprietary.classification.model.ClassificationLabel; -import stirling.software.proprietary.classification.model.ClassificationLabels; -import stirling.software.proprietary.classification.store.InProcessClassificationLabelStore; -import stirling.software.proprietary.policy.config.PolicyManagementAuthority; import stirling.software.proprietary.service.AiEngineClient; +import stirling.software.proprietary.service.AiFeatureGate; import stirling.software.proprietary.service.PdfContentExtractor; import tools.jackson.databind.JsonNode; @@ -42,22 +40,17 @@ import tools.jackson.databind.json.JsonMapper; @MockitoSettings(strictness = Strictness.LENIENT) class ClassifyLabelControllerTest { - private static final Long TEAM = 7L; - @Mock private CustomPDFDocumentFactory pdfDocumentFactory; @Mock private TempFileManager tempFileManager; @Mock private PdfContentExtractor pdfContentExtractor; @Mock private PdfMetadataService pdfMetadataService; @Mock private AiEngineClient aiEngineClient; - @Mock private PolicyManagementAuthority policyManagementAuthority; + @Mock private AiFeatureGate aiFeatureGate; private final ObjectMapper objectMapper = JsonMapper.builder().build(); - private InProcessClassificationLabelStore labelStore; private ClassifyLabelController controller; - @BeforeEach - void setUp() { - labelStore = new InProcessClassificationLabelStore(); + private void withLabels(List labels) { controller = new ClassifyLabelController( pdfDocumentFactory, @@ -65,10 +58,10 @@ class ClassifyLabelControllerTest { pdfContentExtractor, pdfMetadataService, aiEngineClient, + aiFeatureGate, objectMapper, - null, - labelStore, - policyManagementAuthority); + ClassificationLabelProvider.withLabels(labels), + null); } private void stubSinglePageDocument() throws Exception { @@ -98,12 +91,7 @@ class ClassifyLabelControllerTest { @Test void classifyAndLabel_writesClassificationWithoutOutcome() throws Exception { - when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM); - labelStore.save( - TEAM, - new ClassificationLabels( - List.of(new ClassificationLabel("invoice", "Invoice", null))), - "admin"); + withLabels(List.of(new ClassificationLabel("invoice", "Invoice", null))); stubSinglePageDocument(); @@ -118,16 +106,12 @@ class ClassifyLabelControllerTest { } @Test - void classifyAndLabel_sendsTeamLabelIdsAndNames() throws Exception { - when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM); - labelStore.save( - TEAM, - new ClassificationLabels( - List.of( - new ClassificationLabel("invoice", "Invoice", "receipt-long"), - new ClassificationLabel("contract", "Contract", null), - new ClassificationLabel("timesheet", "Timesheet", null))), - "admin"); + void classifyAndLabel_sendsLabelIdsAndNames() throws Exception { + withLabels( + List.of( + new ClassificationLabel("invoice", "Invoice", "receipt-long"), + new ClassificationLabel("contract", "Contract", null), + new ClassificationLabel("timesheet", "Timesheet", null))); stubSinglePageDocument(); @@ -152,13 +136,13 @@ class ClassifyLabelControllerTest { } @Test - void classifyAndLabel_skipsClassificationWhenNothingStored() throws Exception { - when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM); + void classifyAndLabel_skipsClassificationWhenNoLabels() throws Exception { + withLabels(List.of()); stubSinglePageDocument(); - // No team labels stored, and the engine holds no default of its own, so the file is passed - // through unlabelled: neither the engine nor the metadata write is invoked. + // No vocabulary, and the engine holds no default of its own, so the file is passed through + // unlabelled: neither the engine nor the metadata write is invoked. verify(aiEngineClient, never()).post(anyString(), anyString(), any()); verify(pdfMetadataService, never()) .setClassificationMetadata(any(PDDocument.class), anyString()); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/PdfCommentAgentControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/PdfCommentAgentControllerTest.java index 42edace946..6be89e99cd 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/PdfCommentAgentControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/PdfCommentAgentControllerTest.java @@ -3,6 +3,7 @@ package stirling.software.proprietary.controller.api; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -26,6 +27,7 @@ import org.springframework.web.server.ResponseStatusException; import org.springframework.web.servlet.mvc.annotation.ResponseStatusExceptionResolver; import org.springframework.web.servlet.mvc.support.DefaultHandlerExceptionResolver; +import stirling.software.proprietary.service.AiFeatureGate; import stirling.software.proprietary.service.PdfCommentAgentOrchestrator; import stirling.software.proprietary.service.PdfCommentAgentOrchestrator.AnnotatedPdf; @@ -39,13 +41,15 @@ import tools.jackson.databind.json.JsonMapper; class PdfCommentAgentControllerTest { @Mock private PdfCommentAgentOrchestrator orchestrator; + @Mock private AiFeatureGate aiFeatureGate; private MockMvc mockMvc; @BeforeEach void setUp() { PdfCommentAgentController controller = - new PdfCommentAgentController(orchestrator, JsonMapper.builder().build()); + new PdfCommentAgentController( + orchestrator, JsonMapper.builder().build(), aiFeatureGate); mockMvc = MockMvcBuilders.standaloneSetup(controller) // standaloneSetup's defaults don't handle ResponseStatusException; wire up @@ -117,6 +121,27 @@ class PdfCommentAgentControllerTest { verify(orchestrator, never()).applyComments(any(), anyString()); } + @Test + void returnsServiceUnavailableWhenPdfCommentFeatureDisabled() throws Exception { + doThrow(new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE)) + .when(aiFeatureGate) + .requirePdfComment(); + MockMultipartFile pdfFile = + new MockMultipartFile( + "fileInput", + "input.pdf", + MediaType.APPLICATION_PDF_VALUE, + "%PDF-1.4\n%%EOF".getBytes()); + + mockMvc.perform( + multipart("/api/v1/ai/tools/pdf-comment-agent") + .file(pdfFile) + .param("prompt", "flag dates")) + .andExpect(status().isServiceUnavailable()); + + verify(orchestrator, never()).applyComments(any(), anyString()); + } + @Test void rejectsMissingPromptParameter() throws Exception { MockMultipartFile pdfFile = diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ProprietaryUIDataControllerMoreTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ProprietaryUIDataControllerMoreTest.java index 585edc6e66..6b469f7ba2 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ProprietaryUIDataControllerMoreTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ProprietaryUIDataControllerMoreTest.java @@ -141,7 +141,8 @@ class ProprietaryUIDataControllerMoreTest { void singleAdminFirstLogin() { User admin = normalUser(1L, "admin"); admin.setFirstLogin(true); - when(userRepository.findAll()).thenReturn(List.of(admin)); + when(userRepository.countByUsernameNot(Role.INTERNAL_API_USER.getRoleId())) + .thenReturn(1L); when(userRepository.findByUsernameIgnoreCase("admin")).thenReturn(Optional.of(admin)); ResponseEntity response = controller.getLoginData(); @@ -154,7 +155,8 @@ class ProprietaryUIDataControllerMoreTest { @Test @DisplayName("does not flag setup when a normal user exists") void normalUserNoSetup() { - when(userRepository.findAll()).thenReturn(List.of(normalUser(1L, "bob"))); + when(userRepository.countByUsernameNot(Role.INTERNAL_API_USER.getRoleId())) + .thenReturn(1L); ResponseEntity response = controller.getLoginData(); @@ -233,7 +235,7 @@ class ProprietaryUIDataControllerMoreTest { CustomSaml2AuthenticatedPrincipal principal = new CustomSaml2AuthenticatedPrincipal( - "samluser", Map.of(), "nameId", List.of()); + "samluser", Map.of(), "nameId", List.of(), "response"); Authentication auth = new UsernamePasswordAuthenticationToken(principal, null, List.of()); @@ -252,11 +254,9 @@ class ProprietaryUIDataControllerMoreTest { @DisplayName("aggregates users, teams and license limits") void aggregates() { User user = normalUser(1L, "bob"); - when(userRepository.findAllWithTeam()) + when(userRepository.findAllWithTeamAndAuthorities()) .thenReturn(new java.util.ArrayList<>(List.of(user))); when(sessionPersistentRegistry.getMaxInactiveInterval()).thenReturn(3600); - when(sessionPersistentRegistry.findLatestSession("bob")).thenReturn(Optional.empty()); - when(userRepository.findByIdWithSettings(1L)).thenReturn(Optional.of(user)); when(teamRepository.findAll()).thenReturn(List.of()); when(licenseSettingsService.calculateMaxAllowedUsers()).thenReturn(10); @@ -310,7 +310,7 @@ class ProprietaryUIDataControllerMoreTest { team.setName("Engineering"); when(teamRepository.findById(5L)).thenReturn(Optional.of(team)); when(userRepository.findAllByTeamId(5L)).thenReturn(List.of()); - when(userRepository.findAllWithTeam()).thenReturn(List.of()); + when(userRepository.findAllWithTeamAndAuthorities()).thenReturn(List.of()); when(sessionRepository.findLatestSessionByTeamId(5L)) .thenReturn(Collections.emptyList()); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ProprietaryUIDataControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ProprietaryUIDataControllerTest.java index 0b01c9bcf5..297d83583d 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ProprietaryUIDataControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ProprietaryUIDataControllerTest.java @@ -3,7 +3,6 @@ package stirling.software.proprietary.controller.api; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.when; -import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Optional; @@ -93,7 +92,7 @@ class ProprietaryUIDataControllerTest { @Test void loginDataFlagsFirstTimeSetupWhenNoUsers() { - when(userRepository.findAll()).thenReturn(Collections.emptyList()); + when(userRepository.countByUsernameNot(Role.INTERNAL_API_USER.getRoleId())).thenReturn(0L); ResponseEntity response = controller.getLoginData(); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/integration/api/ApiIntegrationValidatorTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/integration/api/ApiIntegrationValidatorTest.java new file mode 100644 index 0000000000..e767aae34d --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/integration/api/ApiIntegrationValidatorTest.java @@ -0,0 +1,76 @@ +package stirling.software.proprietary.integration.api; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.LinkedHashMap; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import stirling.software.common.model.ApplicationProperties; + +/** + * The private-endpoint opt-in is coarse by design (it lets an on-prem integration reach RFC1918), + * but it must never open the cloud metadata service - the one internal address whose only use is + * stealing the instance's credentials. + */ +class ApiIntegrationValidatorTest { + + private final ApiIntegrationValidator validator = + new ApiIntegrationValidator(properties(false)); + + private static ApplicationProperties properties(boolean allowPrivate) { + ApplicationProperties p = new ApplicationProperties(); + p.getPolicies().setAllowPrivateApiEndpoints(allowPrivate); + return p; + } + + private static Map config(String baseUrl) { + Map c = new LinkedHashMap<>(); + c.put("baseUrl", baseUrl); + return c; + } + + @Test + void acceptsAnOrdinaryPublicHost() { + // A public IP literal, so the check needs no network DNS (an unresolvable name would fail + // closed at the resolve step, which is correct but not what this test is about). + assertThatCode(() -> validator.validate(config("https://1.1.1.1/v1"))) + .doesNotThrowAnyException(); + } + + @Test + void rejectsAPrivateHostByDefault() { + assertThatThrownBy(() -> validator.validate(config("http://10.0.0.5/x"))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void rejectsTheCloudMetadataAddressEvenWithThePrivateOptInOn() { + ApiIntegrationValidator opted = new ApiIntegrationValidator(properties(true)); + + // The on-prem opt-in allows RFC1918, but the metadata endpoint stays blocked. + assertThatThrownBy(() -> opted.validate(config("http://169.254.169.254/latest/meta-data/"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("metadata service"); + } + + @Test + void aPrivateOnPremHostIsAllowedWhenOptedIn() { + ApiIntegrationValidator opted = new ApiIntegrationValidator(properties(true)); + + assertThatCode(() -> opted.validate(config("http://10.10.0.20:8080/api"))) + .doesNotThrowAnyException(); + } + + @Test + void theMetadataBlockRunsBeforeTheOptInSoItCannotBeBypassed() { + // Also covers the Oracle/IBM variants that share the 169.254.169.x range. + ApiIntegrationValidator opted = new ApiIntegrationValidator(properties(true)); + + assertThatThrownBy(() -> opted.validate(config("http://169.254.169.253/"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("metadata service"); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/integration/api/DocumentContextTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/integration/api/DocumentContextTest.java new file mode 100644 index 0000000000..4576584b58 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/integration/api/DocumentContextTest.java @@ -0,0 +1,177 @@ +package stirling.software.proprietary.integration.api; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.time.Instant; +import java.util.Base64; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; + +import stirling.software.common.service.PdfMetadataService; +import stirling.software.proprietary.integration.purview.PdfSensitivityLabels; +import stirling.software.proprietary.integration.purview.SensitivityLabel; +import stirling.software.proprietary.integration.purview.SensitivityLabel.AssignmentMethod; + +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ObjectNode; + +/** + * The context is what an external API gets told about the document, so it is asserted concretely. + */ +class DocumentContextTest { + + private static final String TENANT = "cb46c030-1825-4e81-a295-151c039dbf02"; + private final ObjectMapper objectMapper = new ObjectMapper(); + + private static byte[] pdfBytes(java.util.function.Consumer customise) + throws IOException { + try (PDDocument document = new PDDocument()) { + document.addPage(new PDPage()); + document.addPage(new PDPage()); + customise.accept(document); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + document.save(out); + return out.toByteArray(); + } + } + + private ObjectNode contextOf(byte[] content, String filename, String policyName, String runId) { + MockMultipartFile file = + new MockMultipartFile("fileInput", filename, "application/pdf", content); + return DocumentContext.build(file, content, policyName, runId, objectMapper); + } + + @Test + void describesThePdfAndTheRun() throws IOException { + byte[] content = + pdfBytes( + document -> { + document.getDocumentInformation().setTitle("Q3 Invoice"); + document.getDocumentInformation().setAuthor("Anthony"); + }); + + ObjectNode context = contextOf(content, "invoice.pdf", "Outbound review", "run-42"); + + assertThat(context.at("/document/filename").asString()).isEqualTo("invoice.pdf"); + assertThat(context.at("/document/extension").asString()).isEqualTo("pdf"); + assertThat(context.at("/document/contentType").asString()).isEqualTo("application/pdf"); + assertThat(context.at("/document/sizeBytes").asInt()).isEqualTo(content.length); + assertThat(context.at("/document/pageCount").asInt()).isEqualTo(2); + assertThat(context.at("/document/encrypted").asBoolean()).isFalse(); + assertThat(context.at("/document/title").asString()).isEqualTo("Q3 Invoice"); + assertThat(context.at("/document/author").asString()).isEqualTo("Anthony"); + assertThat(context.at("/run/policyName").asString()).isEqualTo("Outbound review"); + assertThat(context.at("/run/runId").asString()).isEqualTo("run-42"); + assertThat(Instant.parse(context.at("/run/timestamp").asString())).isNotNull(); + } + + @Test + void hashesTheContentTheApiWillReceive() throws IOException { + byte[] content = pdfBytes(document -> {}); + + String sha = contextOf(content, "a.pdf", null, null).at("/document/sha256").asString(); + + assertThat(sha).hasSize(64).matches("[0-9a-f]{64}"); + // Same bytes, same hash: external systems key on this for dedupe and chain-of-custody. + assertThat(contextOf(content, "renamed.pdf", null, null).at("/document/sha256").asString()) + .isEqualTo(sha); + } + + @Test + void carriesTheBytesAsBase64ForBodyPayloads() throws IOException { + // Presets that attach or sign the document reference {{document.base64}}; without this the + // placeholder is unknown and the whole step fails at resolution time. + byte[] content = pdfBytes(document -> {}); + + String base64 = contextOf(content, "a.pdf", null, null).at("/document/base64").asString(); + + assertThat(Base64.getDecoder().decode(base64)).isEqualTo(content); + } + + @Test + void surfacesAnExistingPurviewLabel() throws IOException { + byte[] content = + pdfBytes( + document -> { + try { + PdfSensitivityLabels.apply( + document, + new SensitivityLabel( + "2096f6a2-d2f7-48be-b329-b73aaa526e5d", + "Confidential", + TENANT, + AssignmentMethod.PRIVILEGED, + null, + null)); + } catch (IOException e) { + throw new java.io.UncheckedIOException(e); + } + }); + + ObjectNode context = contextOf(content, "secret.pdf", null, null); + + assertThat(context.at("/sensitivityLabel/name").asString()).isEqualTo("Confidential"); + assertThat(context.at("/sensitivityLabel/siteId").asString()).isEqualTo(TENANT); + assertThat(context.at("/sensitivityLabel/method").asString()).isEqualTo("PRIVILEGED"); + assertThat(context.at("/sensitivityLabel/protected").asBoolean()).isFalse(); + } + + @Test + void surfacesTheClassifierVerdictAsJson() throws IOException { + byte[] content = + pdfBytes( + document -> + document.getDocumentInformation() + .setCustomMetadataValue( + PdfMetadataService.CLASSIFICATION_KEY, + "{\"label\":\"invoice\",\"confidence\":0.91}")); + + ObjectNode context = contextOf(content, "a.pdf", null, null); + + // Nested, not a JSON string, so {{classification.label}} resolves. + assertThat(context.at("/classification/label").asString()).isEqualTo("invoice"); + assertThat(context.at("/classification/confidence").asDouble()).isEqualTo(0.91); + } + + @Test + void omitsWhatIsAbsentRatherThanInventingIt() throws IOException { + ObjectNode context = contextOf(pdfBytes(document -> {}), "a.pdf", null, null); + + assertThat(context.has("sensitivityLabel")).isFalse(); + assertThat(context.has("classification")).isFalse(); + assertThat(context.at("/run/policyName").isNull()).isTrue(); + } + + @Test + void aNonPdfStillGetsTheBasics() { + byte[] content = "just text".getBytes(); + MockMultipartFile file = + new MockMultipartFile("fileInput", "notes.txt", "text/plain", content); + + ObjectNode context = DocumentContext.build(file, content, null, null, objectMapper); + + assertThat(context.at("/document/filename").asString()).isEqualTo("notes.txt"); + assertThat(context.at("/document/extension").asString()).isEqualTo("txt"); + assertThat(context.at("/document/sizeBytes").asInt()).isEqualTo(content.length); + assertThat(context.at("/document/sha256").asString()).hasSize(64); + // No PDF facts, and no exception either. + assertThat(context.at("/document/pageCount").isMissingNode()).isTrue(); + } + + @Test + void unparseableBytesClaimingToBeAPdfDoNotFailTheStep() { + byte[] content = "%PDF-1.7 but truncated".getBytes(); + MockMultipartFile file = + new MockMultipartFile("fileInput", "broken.pdf", "application/pdf", content); + + ObjectNode context = DocumentContext.build(file, content, null, null, objectMapper); + + assertThat(context.at("/document/sha256").asString()).hasSize(64); + assertThat(context.at("/document/pageCount").isMissingNode()).isTrue(); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/integration/api/ExternalApiCallControllerLiveTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/integration/api/ExternalApiCallControllerLiveTest.java new file mode 100644 index 0000000000..462c1a348f --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/integration/api/ExternalApiCallControllerLiveTest.java @@ -0,0 +1,615 @@ +package stirling.software.proprietary.integration.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.http.HttpClient; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.core.io.Resource; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockMultipartFile; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.service.PdfMetadataService; +import stirling.software.common.util.TempFileManager; +import stirling.software.common.util.TempFileRegistry; +import stirling.software.proprietary.integration.model.IntegrationType; +import stirling.software.proprietary.integration.purview.PdfSensitivityLabels; +import stirling.software.proprietary.integration.purview.SensitivityLabel; +import stirling.software.proprietary.integration.purview.SensitivityLabel.AssignmentMethod; +import stirling.software.proprietary.service.AiToolResponseHeaders; + +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; + +/** + * Drives the real {@link ExternalApiCallController} against a real HTTP server on loopback. + * + *

    Everything below the connection lookup is genuine: a real PDF, real context extraction, real + * placeholder resolution, a real JDK HTTP client, and a real receiver that records exactly what + * arrived. Only {@link ApiConnectionResolver} is stubbed - resolving a connection means a database + * and an authorization check, which belong to their own tests. + * + *

    The receiver is the point. Asserting what a third party actually received is the only way to + * know the document and its context left in the shape an integration expects; asserting our own + * intentions would pass just as happily with the bytes never leaving. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class ExternalApiCallControllerLiveTest { + + @Mock private ApiConnectionResolver connectionResolver; + + private HttpServer server; + private String baseUrl; + private ExternalApiCallController controller; + private final ObjectMapper objectMapper = new ObjectMapper(); + private final ApplicationProperties properties = new ApplicationProperties(); + + /** What the receiver saw, so assertions are about the wire rather than our intentions. */ + private volatile String receivedBody; + + private volatile String receivedContentType; + private volatile String receivedMethod; + private final Map receivedHeaders = new LinkedHashMap<>(); + + @BeforeEach + void startReceiver() throws IOException { + // Loopback is exactly what the host guard blocks by default; an operator opts in for an + // on-prem integration, which is what a local receiver stands in for. + properties.getPolicies().setAllowPrivateApiEndpoints(true); + + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + + // Records, then answers with a verdict - the DLP/scanner shape. + server.createContext( + "/v1/scan", + exchange -> { + capture(exchange); + respond( + exchange, + 200, + "application/json", + "{\"verdict\":\"clean\",\"score\":0.02}" + .getBytes(StandardCharsets.UTF_8)); + }); + + // Answers with a different document - the converter shape. + server.createContext( + "/v1/convert", + exchange -> { + capture(exchange); + exchange.getResponseHeaders() + .add("Content-Disposition", "attachment; filename=\"converted.docx\""); + respond( + exchange, + 200, + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "DOCX-BYTES".getBytes(StandardCharsets.UTF_8)); + }); + + // Answers with a link to the result - the async/large-file shape. + server.createContext( + "/v1/deferred", + exchange -> { + capture(exchange); + respond( + exchange, + 200, + "application/json", + ("{\"status\":\"done\",\"data\":{\"downloadUrl\":\"" + + baseUrl + + "/files/result.pdf\"}}") + .getBytes(StandardCharsets.UTF_8)); + }); + + // Answers with a link on a host the connection never authorised. + server.createContext( + "/v1/evil", + exchange -> { + capture(exchange); + respond( + exchange, + 200, + "application/json", + "{\"data\":{\"downloadUrl\":\"http://169.254.169.254/latest/meta-data/\"}}" + .getBytes(StandardCharsets.UTF_8)); + }); + + server.createContext( + "/files/result.pdf", + exchange -> + respond( + exchange, + 200, + "application/pdf", + "%PDF-1.7 fetched".getBytes(StandardCharsets.UTF_8))); + + // Answers with an archive - ConsignO's "PDF (single) or ZIP (multiple)" shape. + server.createContext( + "/v1/bundle", + exchange -> { + capture(exchange); + respond(exchange, 200, "application/zip", zip()); + }); + + server.createContext( + "/v1/reject", + exchange -> { + capture(exchange); + respond( + exchange, + 422, + "application/json", + "{\"error\":\"policy violation\"}".getBytes(StandardCharsets.UTF_8)); + }); + + // Cloudmersive's scan shape: HTTP 200 with the verdict in the body, clean or not. + server.createContext( + "/v1/clean", + exchange -> { + capture(exchange); + respond( + exchange, + 200, + "application/json", + "{\"CleanResult\":true}".getBytes(StandardCharsets.UTF_8)); + }); + server.createContext( + "/v1/infected", + exchange -> { + capture(exchange); + respond( + exchange, + 200, + "application/json", + "{\"CleanResult\":false,\"FoundViruses\":[{\"VirusName\":\"EICAR\"}]}" + .getBytes(StandardCharsets.UTF_8)); + }); + + server.start(); + baseUrl = "http://127.0.0.1:" + server.getAddress().getPort(); + + ExternalApiCaller caller = + new ExternalApiCaller( + HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NEVER).build(), + properties, + objectMapper); + controller = + new ExternalApiCallController( + connectionResolver, + caller, + objectMapper, + new TempFileManager(new TempFileRegistry(), properties), + properties); + } + + @AfterEach + void stopReceiver() { + server.stop(0); + } + + private void capture(HttpExchange exchange) throws IOException { + receivedMethod = exchange.getRequestMethod(); + receivedBody = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + receivedContentType = exchange.getRequestHeaders().getFirst("Content-Type"); + exchange.getRequestHeaders() + .forEach((name, values) -> receivedHeaders.put(name, values.get(0))); + } + + private static void respond(HttpExchange exchange, int status, String contentType, byte[] body) + throws IOException { + exchange.getResponseHeaders().add("Content-Type", contentType); + exchange.sendResponseHeaders(status, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + } + + private static byte[] zip() throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(out)) { + zip.putNextEntry(new ZipEntry("audit-trail.txt")); + zip.write("who signed what".getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + zip.putNextEntry(new ZipEntry("signed.pdf")); + zip.write("%PDF-1.7 signed".getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + } + return out.toByteArray(); + } + + /** A labelled, classified PDF, so the context has something real to carry. */ + private static MockMultipartFile pdf() throws IOException { + try (PDDocument document = new PDDocument()) { + document.addPage(new PDPage()); + document.addPage(new PDPage()); + document.getDocumentInformation().setTitle("Q3 Claim"); + document.getDocumentInformation() + .setCustomMetadataValue( + PdfMetadataService.CLASSIFICATION_KEY, + "{\"label\":\"invoice\",\"confidence\":0.91}"); + PdfSensitivityLabels.apply( + document, + new SensitivityLabel( + "2096f6a2-d2f7-48be-b329-b73aaa526e5d", + "Confidential", + "cb46c030-1825-4e81-a295-151c039dbf02", + AssignmentMethod.PRIVILEGED, + null, + null)); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + document.save(out); + return new MockMultipartFile( + "fileInput", "claim.pdf", "application/pdf", out.toByteArray()); + } + } + + private void connection(Map extra) { + Map config = new LinkedHashMap<>(); + config.put("baseUrl", baseUrl); + config.putAll(extra); + when(connectionResolver.resolve(eq(7L))).thenReturn(ApiConnectionSettings.from(config)); + when(connectionResolver.resolveConfig(eq(7L), any(IntegrationType.class))) + .thenReturn(config); + } + + /** + * The step's parameters, named. The controller takes seventeen positional arguments, which is + * unreadable and easy to mis-order at a call site; this lets each test state only what it + * varies. + */ + private final class Step { + private String path = "/v1/scan"; + private String method = "POST"; + private String bodyMode = "multipart"; + private String fileFieldName = "file"; + private String responseMode = "report"; + private String resultUrlPath; + private String resultUrlHeader; + private String responseSelect; + private String requireTrue; + private String fields; + private String bodyTemplate; + private String headers; + private boolean includeContext; + private boolean includeFile = true; + private String policyName; + private String runId; + + Step path(String v) { + path = v; + return this; + } + + Step method(String v) { + method = v; + return this; + } + + Step bodyMode(String v) { + bodyMode = v; + return this; + } + + Step responseMode(String v) { + responseMode = v; + return this; + } + + Step resultUrlPath(String v) { + resultUrlPath = v; + return this; + } + + Step responseSelect(String v) { + responseSelect = v; + return this; + } + + Step requireTrue(String v) { + requireTrue = v; + return this; + } + + Step fields(String v) { + fields = v; + return this; + } + + Step bodyTemplate(String v) { + bodyTemplate = v; + return this; + } + + Step headers(String v) { + headers = v; + return this; + } + + Step includeContext(boolean v) { + includeContext = v; + return this; + } + + Step run(String policy, String id) { + policyName = policy; + runId = id; + return this; + } + + ResponseEntity go() throws IOException { + return controller.call( + pdf(), + "7", + path, + method, + bodyMode, + fileFieldName, + responseMode, + resultUrlPath, + resultUrlHeader, + responseSelect, + requireTrue, + fields, + bodyTemplate, + headers, + includeContext, + includeFile, + policyName, + runId); + } + } + + private Step step() { + return new Step(); + } + + @Test + void sendsTheDocumentAndWhatWeKnowAboutItToTheReceiver() throws IOException { + connection(Map.of()); + + ResponseEntity response = + step().path("/v1/scan") + .fields( + "{\"sha256\":\"{{document.sha256}}\",\"label\":\"{{sensitivityLabel.name}}\"," + + "\"class\":\"{{classification.label}}\",\"pages\":\"{{document.pageCount}}\"}") + .includeContext(true) + .run("Outbound review", "run-42") + .go(); + + assertThat(receivedMethod).isEqualTo("POST"); + assertThat(receivedContentType).startsWith("multipart/form-data"); + // Fields the vendor asked for, filled from what Stirling already knew - no extra calls. + assertThat(receivedBody).contains("name=\"label\"").contains("Confidential"); + assertThat(receivedBody).contains("name=\"class\"").contains("invoice"); + assertThat(receivedBody).contains("name=\"pages\"").contains("2"); + assertThat(receivedBody).containsPattern("name=\"sha256\"[\\s\\S]{0,24}[0-9a-f]{64}"); + // The document itself, under the field name the vendor expects. + assertThat(receivedBody).contains("name=\"file\"; filename=\"claim.pdf\"").contains("%PDF"); + // The context, including which policy and run sent it. + assertThat(receivedBody) + .contains("stirlingContext") + .contains("Outbound review") + .contains("run-42"); + + JsonNode report = + objectMapper.readTree( + response.getHeaders().getFirst(AiToolResponseHeaders.TOOL_REPORT)); + assertThat(report.at("/status").asInt()).isEqualTo(200); + assertThat(report.at("/body/verdict").asString()).isEqualTo("clean"); + } + + @Test + void reportModeReturnsTheDocumentUntouched() throws IOException { + connection(Map.of()); + + ResponseEntity response = step().path("/v1/scan").go(); + + // Byte-for-byte: an inspecting call-out must not perturb what it inspected. + assertThat(response.getBody().getInputStream().readAllBytes()) + .startsWith("%PDF".getBytes()); + assertThat(response.getHeaders().getFirst("Content-Disposition")).contains("claim.pdf"); + } + + @Test + void replaceModeAdoptsTheReturnedDocumentAndItsRealName() throws IOException { + connection(Map.of()); + + ResponseEntity response = + step().path("/v1/convert").bodyMode("binary").responseMode("replace").go(); + + assertThat(receivedContentType).isEqualTo("application/pdf"); + assertThat(receivedBody).startsWith("%PDF"); + assertThat(response.getBody().getInputStream().readAllBytes()) + .isEqualTo("DOCX-BYTES".getBytes(StandardCharsets.UTF_8)); + // Named for what came back, not what went out: a DOCX must not be called .pdf. + assertThat(response.getHeaders().getFirst("Content-Disposition")) + .contains("converted.docx"); + } + + @Test + void followsAResultUrlOnTheConnectionsOwnHost() throws IOException { + connection(Map.of()); + + ResponseEntity response = + step().path("/v1/deferred") + .responseMode("replace") + .resultUrlPath("data.downloadUrl") + .go(); + + assertThat(response.getBody().getInputStream().readAllBytes()) + .isEqualTo("%PDF-1.7 fetched".getBytes(StandardCharsets.UTF_8)); + } + + @Test + void refusesAResultUrlTheConnectionNeverAuthorised() { + connection(Map.of()); + + // The URL is chosen by the remote service at run time; obeying it would be an SSRF. + assertThatThrownBy( + () -> + step().path("/v1/evil") + .responseMode("replace") + .resultUrlPath("data.downloadUrl") + .go()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("does not allow"); + } + + @Test + void picksTheWantedFileOutOfAReturnedArchive() throws IOException { + connection(Map.of()); + + ResponseEntity response = + step().path("/v1/bundle").responseMode("replace").responseSelect("*.pdf").go(); + + assertThat(response.getBody().getInputStream().readAllBytes()) + .isEqualTo("%PDF-1.7 signed".getBytes(StandardCharsets.UTF_8)); + } + + @Test + void anUnselectedArchiveFailsRatherThanBecomingTheDocument() { + connection(Map.of()); + + // Handing a .zip to a step that expects a PDF fails later and more obscurely. + assertThatThrownBy(() -> step().path("/v1/bundle").responseMode("replace").go()) + .isInstanceOf(IOException.class) + .hasMessageContaining("responseSelect"); + } + + @Test + void aRejectedCallOutFailsTheStep() { + connection(Map.of()); + + // A policy that continued past a rejection would deliver documents the external system + // believes it never approved. + assertThatThrownBy(() -> step().path("/v1/reject").go()) + .isInstanceOf(IOException.class) + .hasMessageContaining("HTTP 422") + .hasMessageContaining("policy violation"); + } + + @Test + void aCleanVerdictLetsTheDocumentThrough() throws IOException { + connection(Map.of()); + + // Cloudmersive answers HTTP 200 whether clean or not; the verdict is in the body. A clean + // result must pass the document through untouched. + ResponseEntity response = + step().path("/v1/clean").requireTrue("CleanResult").go(); + + assertThat(response.getBody().getInputStream().readAllBytes()) + .startsWith("%PDF".getBytes()); + } + + @Test + void anInfectedVerdictStopsTheRunEvenOnHttp200() { + connection(Map.of()); + + // The whole security proposition: HTTP 200 with CleanResult=false must NOT sail through. + assertThatThrownBy(() -> step().path("/v1/infected").requireTrue("CleanResult").go()) + .isInstanceOf(IOException.class) + .hasMessageContaining("CleanResult") + .hasMessageContaining("not true"); + } + + @Test + void aMissingVerdictFieldFailsClosed() { + connection(Map.of()); + + // /v1/scan answers {"verdict":"clean"} - it has no CleanResult field at all. A gate that + // cannot find its verdict must stop the run, not wave the document through. + assertThatThrownBy(() -> step().path("/v1/scan").requireTrue("CleanResult").go()) + .isInstanceOf(IOException.class) + .hasMessageContaining("not true"); + } + + @Test + void sendsAVendorShapedJsonBodyWithTheDocumentNestedInside() throws IOException { + connection(Map.of()); + + // ConsignO's submit shape: the PDF base64'd into documents[0].data. + step().path("/v1/scan") + .bodyMode("json") + .bodyTemplate( + "{\"name\":\"{{document.filename}}\",\"status\":1," + + "\"documents\":[{\"name\":\"{{document.filename}}\",\"data\":\"{{document.base64}}\"}]," + + "\"actions\":[{\"mode\":\"remote\",\"signer\":{\"type\":\"certifio\"}}]}") + .go(); + + assertThat(receivedContentType).isEqualTo("application/json"); + JsonNode sent = objectMapper.readTree(receivedBody); + assertThat(sent.at("/name").asString()).isEqualTo("claim.pdf"); + // Numbers keep their type; only strings are substituted. + assertThat(sent.at("/status").isNumber()).isTrue(); + assertThat(sent.at("/actions/0/signer/type").asString()).isEqualTo("certifio"); + assertThat(Base64.getDecoder().decode(sent.at("/documents/0/data").asString())) + .startsWith("%PDF".getBytes(StandardCharsets.UTF_8)); + } + + @Test + void appliesTheConnectionsCredentialAndTheStepsHeadersAndVerb() throws IOException { + connection(Map.of("authType", "BEARER", "token", "s3cr3t-token")); + + step().path("/v1/scan") + .method("PUT") + .headers("{\"X-Case-Id\":\"{{run.runId}}\"}") + .run(null, "run-99") + .go(); + + assertThat(receivedMethod).isEqualTo("PUT"); + assertThat(receivedHeaders.get("X-case-id")).isEqualTo("run-99"); + // The connection's credential, which the step never supplies or sees. + assertThat(receivedHeaders.get("Authorization")).isEqualTo("Bearer s3cr3t-token"); + } + + @Test + void aStepCannotAimTheCallAtAnotherHost() { + connection(Map.of()); + + assertThatThrownBy(() -> step().path("//evil.example/x").go()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("must be relative"); + } + + @Test + void notifyStyleCallOutSendsTheFactsWithoutTheDocument() throws IOException { + connection(Map.of()); + + Step notify = step().path("/v1/scan").bodyMode("json").includeContext(true); + notify.includeFile = false; + notify.run("Outbound review", "run-7").go(); + + JsonNode sent = objectMapper.readTree(receivedBody); + assertThat(sent.at("/document/filename").asString()).isEqualTo("claim.pdf"); + assertThat(sent.at("/run/policyName").asString()).isEqualTo("Outbound review"); + // No document: the point of a notification is the facts, not the bytes. + assertThat(sent.has("content")).isFalse(); + assertThat(receivedBody).doesNotContain("%PDF"); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/integration/api/ExternalApiCallerAuthHeaderTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/integration/api/ExternalApiCallerAuthHeaderTest.java new file mode 100644 index 0000000000..47aed03ae2 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/integration/api/ExternalApiCallerAuthHeaderTest.java @@ -0,0 +1,145 @@ +package stirling.software.proprietary.integration.api; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.http.HttpClient; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.sun.net.httpserver.HttpServer; + +import stirling.software.common.model.ApplicationProperties; + +import tools.jackson.databind.ObjectMapper; + +/** + * Asserts the credential header {@link ExternalApiCaller} actually puts on the wire for each auth + * shape, against a real local server. + * + *

    The interesting case is {@code headerPrefix}. Vendors disagree about the scheme in front of a + * token - PandaDoc wants {@code API-Key}, Rossum {@code token}, DeepL {@code DeepL-Auth-Key} - and + * without it every one of those presets would have to make the operator paste the scheme into the + * secret field, where a missing space silently becomes a 401. + */ +class ExternalApiCallerAuthHeaderTest { + + private HttpServer server; + private String baseUrl; + private final Map seen = new ConcurrentHashMap<>(); + private final ObjectMapper objectMapper = new ObjectMapper(); + + @BeforeEach + void startServer() throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext( + "/ingest", + exchange -> { + seen.clear(); + exchange.getRequestHeaders() + .forEach( + (name, values) -> + seen.put( + name.toLowerCase(java.util.Locale.ROOT), + String.join(", ", values))); + exchange.getRequestBody().readAllBytes(); + byte[] body = "{\"ok\":true}".getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + server.start(); + baseUrl = "http://127.0.0.1:" + server.getAddress().getPort(); + } + + @AfterEach + void stopServer() { + server.stop(0); + } + + @Test + void headerAuthWithPrefixSendsSchemeAndToken() throws IOException { + post( + connection( + Map.of( + "authType", + "HEADER", + "headerName", + "Authorization", + "headerPrefix", + "API-Key", + "token", + "pd-secret"))); + + assertThat(seen).containsEntry("authorization", "API-Key pd-secret"); + } + + @Test + void headerAuthWithoutPrefixSendsTheBareToken() throws IOException { + post( + connection( + Map.of( + "authType", + "HEADER", + "headerName", + "x-api-key", + "token", + "sk-ant-secret"))); + + // No scheme invented: a vendor that wants the raw key must receive exactly that. + assertThat(seen).containsEntry("x-api-key", "sk-ant-secret"); + assertThat(seen).doesNotContainKey("authorization"); + } + + @Test + void bearerAuthIsUnaffectedByAPrefix() throws IOException { + // headerPrefix belongs to HEADER auth; BEARER must keep its own scheme regardless. + post( + connection( + Map.of( + "authType", + "BEARER", + "headerPrefix", + "API-Key", + "token", + "sk-secret"))); + + assertThat(seen).containsEntry("authorization", "Bearer sk-secret"); + } + + private void post(ApiConnectionSettings settings) throws IOException { + ExternalApiCaller.Response response = + caller().dispatch( + settings, + "POST", + "/ingest", + ExternalApiCaller.raw( + "application/json", "{}".getBytes(StandardCharsets.UTF_8)), + Map.of()); + assertThat(response.isSuccess()).isTrue(); + } + + private ApiConnectionSettings connection(Map options) { + Map config = new LinkedHashMap<>(options); + config.put("baseUrl", baseUrl); + return ApiConnectionSettings.from(config); + } + + private ExternalApiCaller caller() { + ApplicationProperties properties = new ApplicationProperties(); + // The server is on loopback, which is exactly what the guard blocks by default. + properties.getPolicies().setAllowPrivateApiEndpoints(true); + return new ExternalApiCaller( + HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NEVER).build(), + properties, + objectMapper); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/integration/api/ExternalApiCallerTokenLoginTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/integration/api/ExternalApiCallerTokenLoginTest.java new file mode 100644 index 0000000000..7cfbb8cbe4 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/integration/api/ExternalApiCallerTokenLoginTest.java @@ -0,0 +1,296 @@ +package stirling.software.proprietary.integration.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.http.HttpClient; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; + +import stirling.software.common.model.ApplicationProperties; + +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ObjectNode; + +/** + * Drives {@link ExternalApiCaller} against a real local HTTP server shaped like ConsignO Cloud's + * auth: credentials in headers plus a JSON body, and the token handed back only in the {@code + * X-Auth-Token} response header. + * + *

    A real server rather than a mock, because what is being tested is the wire behaviour - that + * the token is found in a header, reused rather than re-fetched, and re-obtained on a 401. + */ +class ExternalApiCallerTokenLoginTest { + + private HttpServer server; + private String baseUrl; + private final AtomicInteger logins = new AtomicInteger(); + private final List> callHeaders = new ArrayList<>(); + private final ObjectMapper objectMapper = new ObjectMapper(); + private volatile String issuedToken = "token-1"; + private volatile boolean rejectToken; + private volatile String workflowBody; + + @BeforeEach + void startServer() throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + + server.createContext( + "/api/v1/auth/login", + exchange -> { + logins.incrementAndGet(); + String body = + new String( + exchange.getRequestBody().readAllBytes(), + StandardCharsets.UTF_8); + // The vendor authenticates the app by header and the user by body. + if (!"client-abc".equals(exchange.getRequestHeaders().getFirst("X-Client-Id")) + || !"client-xyz" + .equals( + exchange.getRequestHeaders() + .getFirst("X-Client-Secret")) + || !body.contains("\"password\":\"s3cr3t\"") + || !body.contains("\"tenantId\":\"acme\"")) { + respond(exchange, 401, "{}"); + return; + } + exchange.getResponseHeaders().add("X-Auth-Token", issuedToken); + respond(exchange, 200, "{\"msg\":\"ok\"}"); + }); + + server.createContext( + "/api/v1/documents", + exchange -> { + Map headers = new LinkedHashMap<>(); + exchange.getRequestHeaders() + .forEach((name, values) -> headers.put(name, values.get(0))); + callHeaders.add(headers); + String token = exchange.getRequestHeaders().getFirst("X-Auth-Token"); + if (rejectToken || token == null || !token.equals(issuedToken)) { + respond(exchange, 401, "{\"msg\":\"expired\"}"); + return; + } + respond( + exchange, + 201, + "{\"response\":{\"metadata\":{\"documentId\":\"doc-9\"}}}"); + }); + + server.createContext( + "/api/v1/workflows", + exchange -> { + if (!issuedToken.equals( + exchange.getRequestHeaders().getFirst("X-Auth-Token"))) { + respond(exchange, 401, "{\"msg\":\"expired\"}"); + return; + } + workflowBody = + new String( + exchange.getRequestBody().readAllBytes(), + StandardCharsets.UTF_8); + respond(exchange, 201, "{\"response\":{\"id\":\"wf-7\",\"status\":1}}"); + }); + + server.start(); + baseUrl = "http://127.0.0.1:" + server.getAddress().getPort() + "/api/v1"; + } + + @AfterEach + void stopServer() { + server.stop(0); + } + + private static void respond(HttpExchange exchange, int status, String body) throws IOException { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(status, bytes.length); + exchange.getResponseBody().write(bytes); + exchange.close(); + } + + private ExternalApiCaller caller() { + ApplicationProperties properties = new ApplicationProperties(); + // The server is on loopback, which is exactly what the guard blocks by default. + properties.getPolicies().setAllowPrivateApiEndpoints(true); + return new ExternalApiCaller( + HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NEVER).build(), + properties, + objectMapper); + } + + /** The ConsignO connection an operator would configure. */ + private ApiConnectionSettings consignoConnection() { + Map config = new LinkedHashMap<>(); + config.put("baseUrl", baseUrl); + config.put("authType", "TOKEN_LOGIN"); + config.put("loginPath", "/auth/login"); + config.put( + "loginBody", + Map.of("username", "api@acme.test", "password", "s3cr3t", "tenantId", "acme")); + config.put( + "loginHeaders", + Map.of("X-Client-Id", "client-abc", "X-Client-Secret", "client-xyz")); + config.put("tokenResponseHeader", "X-Auth-Token"); + config.put("tokenHeaderName", "X-Auth-Token"); + return ApiConnectionSettings.from(config); + } + + private ExternalApiCaller.Response upload(ExternalApiCaller caller) throws IOException { + return caller.postFile( + consignoConnection(), + "/documents", + "file", + "contract.pdf", + "application/pdf", + "%PDF-1.7".getBytes(StandardCharsets.UTF_8), + Map.of()); + } + + @Test + void logsInAndSendsTheTokenFromTheResponseHeader() throws IOException { + ExternalApiCaller.Response response = upload(caller()); + + assertThat(response.status()).isEqualTo(201); + assertThat(response.bodyAsText()).contains("doc-9"); + assertThat(logins.get()).isEqualTo(1); + // The token was found in a response header and presented on the next call. + assertThat(callHeaders) + .singleElement() + .extracting(h -> h.get("X-auth-token")) + .isEqualTo("token-1"); + } + + @Test + void reusesTheTokenAcrossCallsRatherThanLoggingInPerDocument() throws IOException { + ExternalApiCaller caller = caller(); + upload(caller); + upload(caller); + upload(caller); + + // A 100-document policy must not perform 100 logins. + assertThat(logins.get()).isEqualTo(1); + assertThat(callHeaders).hasSize(3); + } + + @Test + void reAuthenticatesOnceWhenTheTokenIsRejected() throws IOException { + ExternalApiCaller caller = caller(); + upload(caller); + assertThat(logins.get()).isEqualTo(1); + + // The vendor expires the token early and starts issuing a new one. + issuedToken = "token-2"; + ExternalApiCaller.Response response = upload(caller); + + assertThat(response.status()).isEqualTo(201); + assertThat(logins.get()).isEqualTo(2); + assertThat(callHeaders).last().extracting(h -> h.get("X-auth-token")).isEqualTo("token-2"); + } + + @Test + void aPersistent401SurfacesRatherThanLoopingForever() throws IOException { + rejectToken = true; + + ExternalApiCaller.Response response = upload(caller()); + + assertThat(response.status()).isEqualTo(401); + // Exactly one retry: the initial login plus one re-auth, then give up. + assertThat(logins.get()).isEqualTo(2); + } + + /** + * The whole ConsignO submit, as an operator would configure it: log in, then post their real + * workflow shape with the PDF base64'd into {@code documents[0].data}. Their API takes the + * document inline, so {@code POST /documents} is not needed and the submit is a single call - + * which is what brings it within reach of the generic step. + */ + @Test + void submitsAConsignoSignatureWorkflowEndToEnd() throws IOException { + byte[] pdf = "%PDF-1.7 contract".getBytes(StandardCharsets.UTF_8); + ObjectNode context = objectMapper.createObjectNode(); + ObjectNode document = context.putObject("document"); + document.put("filename", "contract.pdf"); + document.put("base64", Base64.getEncoder().encodeToString(pdf)); + + String template = + """ + { + "name": "{{document.filename}}", + "status": 1, + "documents": [ + {"name": "{{document.filename}}", "data": "{{document.base64}}"} + ], + "actions": [ + {"mode":"remote","ref":"1", + "signer":{"type":"certifio","email":"notary@example.test","lang":"en"}} + ] + } + """; + JsonNode body = Placeholders.resolveTree(objectMapper.readTree(template), context); + + ExternalApiCaller.Response response = + caller().dispatch( + consignoConnection(), + "POST", + "/workflows", + ExternalApiCaller.raw( + "application/json", objectMapper.writeValueAsBytes(body)), + Map.of()); + + assertThat(response.status()).isEqualTo(201); + // The workflow id the vendor hands back - the thing a later fetch would need, and which a + // step currently has no way to carry to the next step. + assertThat(objectMapper.readTree(response.bodyAsText()).at("/response/id").asString()) + .isEqualTo("wf-7"); + assertThat(logins.get()).isEqualTo(1); + + // The document really arrived, nested where ConsignO expects it. + JsonNode received = objectMapper.readTree(workflowBody); + assertThat(received.at("/actions/0/signer/type").asString()).isEqualTo("certifio"); + assertThat(Base64.getDecoder().decode(received.at("/documents/0/data").asString())) + .isEqualTo(pdf); + } + + @Test + void badCredentialsFailTheStepWithoutEchoingThem() { + Map config = new LinkedHashMap<>(); + config.put("baseUrl", baseUrl); + config.put("authType", "TOKEN_LOGIN"); + config.put("loginPath", "/auth/login"); + config.put("loginBody", Map.of("username", "api@acme.test", "password", "wrong")); + config.put("loginHeaders", Map.of("X-Client-Id", "client-abc", "X-Client-Secret", "nope")); + config.put("tokenResponseHeader", "X-Auth-Token"); + config.put("tokenHeaderName", "X-Auth-Token"); + ApiConnectionSettings settings = ApiConnectionSettings.from(config); + + assertThatThrownBy( + () -> + caller().postFile( + settings, + "/documents", + "file", + "c.pdf", + "application/pdf", + "%PDF".getBytes(StandardCharsets.UTF_8), + Map.of())) + .isInstanceOf(IOException.class) + .hasMessageContaining("returned HTTP 401") + // The login body is echoed by some vendors; the message must not carry it onward. + .hasMessageNotContaining("wrong"); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/integration/api/ExternalApiPathsTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/integration/api/ExternalApiPathsTest.java new file mode 100644 index 0000000000..e03b330095 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/integration/api/ExternalApiPathsTest.java @@ -0,0 +1,135 @@ +package stirling.software.proprietary.integration.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.net.URI; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +/** + * {@link ExternalApiPaths} is the control that stops the external-API step becoming an SSRF + * primitive, so these lean on the ways a step author might try to leave the connection's base URL. + */ +class ExternalApiPathsTest { + + private static final URI BASE = URI.create("https://api.example.com/v1"); + + @Nested + @DisplayName("resolves paths under the base") + class Resolves { + + @Test + void appendsARelativePath() { + assertThat(ExternalApiPaths.resolve(BASE, "/scan")) + .isEqualTo(URI.create("https://api.example.com/v1/scan")); + } + + @Test + void addsTheLeadingSlashWhenOmitted() { + assertThat(ExternalApiPaths.resolve(BASE, "scan")) + .isEqualTo(URI.create("https://api.example.com/v1/scan")); + } + + @Test + void blankPathIsTheBaseItself() { + assertThat(ExternalApiPaths.resolve(BASE, " ")).isEqualTo(BASE); + assertThat(ExternalApiPaths.resolve(BASE, null)).isEqualTo(BASE); + } + + @Test + void keepsAQueryString() { + assertThat(ExternalApiPaths.resolve(BASE, "/scan?mode=strict")) + .isEqualTo(URI.create("https://api.example.com/v1/scan?mode=strict")); + } + + @Test + void allowsATraversalThatStaysUnderTheBase() { + // "/v1/a/../b" normalises to "/v1/b", which is still under the base. + assertThat(ExternalApiPaths.resolve(BASE, "/a/../b")) + .isEqualTo(URI.create("https://api.example.com/v1/b")); + } + + @Test + void baseWithNoPathAcceptsAnyPath() { + assertThat(ExternalApiPaths.resolve(URI.create("https://api.example.com"), "/scan")) + .isEqualTo(URI.create("https://api.example.com/scan")); + } + + @Test + void keepsAnEncodedSlashFromASubstitutedValue() { + // Placeholders percent-encodes what it substitutes, so a filename containing '/' + // arrives as %2F. That is data inside one segment and must survive. + assertThat(ExternalApiPaths.resolve(BASE, "/docs/my%2Ffile.pdf")) + .isEqualTo(URI.create("https://api.example.com/v1/docs/my%2Ffile.pdf")); + } + } + + @Nested + @DisplayName("refuses to leave the base") + class Refuses { + + @Test + void protocolRelativeUrlCannotChangeHost() { + // The reason URI.resolve is not used: it would yield https://evil.example/x here. + assertThatThrownBy(() -> ExternalApiPaths.resolve(BASE, "//evil.example/x")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("must be relative"); + } + + @ParameterizedTest + @ValueSource( + strings = { + "https://evil.example/x", + "http://evil.example/x", + "HTTPS://evil.example/x", + "file:///etc/passwd" + }) + void absoluteUrlIsRejected(String path) { + assertThatThrownBy(() -> ExternalApiPaths.resolve(BASE, path)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void traversalAboveTheBasePathIsRejected() { + assertThatThrownBy(() -> ExternalApiPaths.resolve(BASE, "/../admin")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("escapes"); + } + + @Test + void percentEncodedTraversalIsRejected() { + // normalize() would not decode these, so the target server would do the escaping. + assertThatThrownBy(() -> ExternalApiPaths.resolve(BASE, "/%2e%2e/admin")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("percent-encode"); + } + + @ParameterizedTest + @ValueSource(strings = {"/scan\r\nX-Injected: 1", "/scan\nfoo", "/sc an", "/scan\\..\\x"}) + void requestSplittingCharactersAreRejected(String path) { + assertThatThrownBy(() -> ExternalApiPaths.resolve(BASE, path)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("illegal character"); + } + + @Test + void siblingPathThatMerelySharesAPrefixIsRejected() { + // "/v1betray" starts with "/v1" textually but is a different resource tree. + assertThatThrownBy(() -> ExternalApiPaths.resolve(BASE, "/../v1betray/x")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("escapes"); + } + + @Test + void fragmentIsRejected() { + assertThatThrownBy(() -> ExternalApiPaths.resolve(BASE, "/scan#frag")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("fragment"); + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/integration/api/MultipartBodyTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/integration/api/MultipartBodyTest.java new file mode 100644 index 0000000000..c059de14d4 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/integration/api/MultipartBodyTest.java @@ -0,0 +1,111 @@ +package stirling.software.proprietary.integration.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +/** + * The name/value asymmetry here is easy to get wrong in either direction: too lax on names is + * header injection, too strict on values silently rejects ordinary JSON. + */ +class MultipartBodyTest { + + private static String render(MultipartBody body) throws IOException { + // The publisher is what actually goes on the wire. + java.net.http.HttpRequest.BodyPublisher publisher = body.build(); + StringBuilder out = new StringBuilder(); + publisher.subscribe( + new java.util.concurrent.Flow.Subscriber<>() { + @Override + public void onSubscribe(java.util.concurrent.Flow.Subscription s) { + s.request(Long.MAX_VALUE); + } + + @Override + public void onNext(java.nio.ByteBuffer item) { + byte[] bytes = new byte[item.remaining()]; + item.get(bytes); + out.append(new String(bytes, StandardCharsets.UTF_8)); + } + + @Override + public void onError(Throwable t) {} + + @Override + public void onComplete() {} + }); + return out.toString(); + } + + @Test + void carriesAJsonValueThroughUntouched() throws IOException { + // Regression: values were once checked like headers, which rejected every JSON value — + // including the auto-populated context, so includeContext could never be sent. + String json = "{\"document\":{\"title\":\"Q3 \\\"final\\\"\"},\"n\":2}"; + MultipartBody body = new MultipartBody(); + body.addField("stirlingContext", json); + + assertThat(render(body)).contains(json); + } + + @Test + void carriesAValueWithNewlinesAndBackslashes() throws IOException { + MultipartBody body = new MultipartBody(); + body.addField("notes", "line one\nline two\\end"); + + assertThat(render(body)).contains("line one\nline two\\end"); + } + + @Test + void writesTheDocumentUnderItsFieldNameAndFilename() throws IOException { + MultipartBody body = new MultipartBody(); + body.addFields(Map.of("policy", "strict")); + body.addFile( + "file", + "claim.pdf", + "application/pdf", + "%PDF-1.7".getBytes(StandardCharsets.UTF_8)); + + String rendered = render(body); + assertThat(rendered).contains("name=\"policy\"").contains("strict"); + assertThat(rendered) + .contains("name=\"file\"; filename=\"claim.pdf\"") + .contains("Content-Type: application/pdf") + .contains("%PDF-1.7"); + assertThat(body.contentType()).startsWith("multipart/form-data; boundary=StirlingBoundary"); + } + + @ParameterizedTest + @ValueSource(strings = {"na\"me", "na\rme", "na\nme", "na\\me"}) + void refusesAFieldNameThatCouldForgeItsOwnHeaders(String name) { + MultipartBody body = new MultipartBody(); + + assertThatThrownBy(() -> body.addField(name, "v")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("illegal character"); + } + + @ParameterizedTest + @ValueSource(strings = {"a\".pdf", "a\r.pdf", "a\n.pdf"}) + void refusesAFilenameThatCouldForgeItsOwnHeaders(String filename) { + MultipartBody body = new MultipartBody(); + + assertThatThrownBy(() -> body.addFile("file", filename, "application/pdf", new byte[] {1})) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("illegal character"); + } + + @Test + void eachBodyGetsItsOwnBoundary() { + // A value cannot end its own part because it cannot know the boundary in advance. + assertThat(new MultipartBody().contentType()) + .isNotEqualTo(new MultipartBody().contentType()); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/integration/api/PlaceholdersTemplateTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/integration/api/PlaceholdersTemplateTest.java new file mode 100644 index 0000000000..3d91d0230f --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/integration/api/PlaceholdersTemplateTest.java @@ -0,0 +1,110 @@ +package stirling.software.proprietary.integration.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Base64; + +import org.junit.jupiter.api.Test; + +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ObjectNode; + +/** + * Body templating is what decides whether a vendor with a nested payload needs bespoke code, so the + * headline case here is ConsignO Cloud's real {@code POST /workflows} shape. + */ +class PlaceholdersTemplateTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private ObjectNode context() { + ObjectNode root = objectMapper.createObjectNode(); + ObjectNode document = root.putObject("document"); + document.put("filename", "contract.pdf"); + document.put("base64", Base64.getEncoder().encodeToString("%PDF-1.7".getBytes())); + document.put("pageCount", 4); + root.putObject("run").put("policyName", "Signature run"); + root.putObject("sensitivityLabel").put("name", "Confidential"); + return root; + } + + private JsonNode resolve(String template) { + return Placeholders.resolveTree(objectMapper.readTree(template), context()); + } + + @Test + void buildsConsignOsWorkflowPayload() { + // Lifted from the ConsignO Cloud API reference: the document rides base64 in + // documents[0].data, and `certifio` is the Notarius professional-certificate signer. + String template = + """ + { + "name": "{{document.filename}}", + "status": 1, + "documents": [ + {"name": "{{document.filename}}", "data": "{{document.base64}}"} + ], + "actions": [ + { + "mode": "remote", + "ref": "1", + "signer": { + "type": "certifio", + "email": "notary@example.test", + "lang": "en" + } + } + ] + } + """; + + JsonNode body = resolve(template); + + assertThat(body.at("/name").asString()).isEqualTo("contract.pdf"); + // Numbers and booleans keep their type; only strings are substituted. + assertThat(body.at("/status").isNumber()).isTrue(); + assertThat(body.at("/status").asInt()).isEqualTo(1); + assertThat(body.at("/documents/0/name").asString()).isEqualTo("contract.pdf"); + assertThat(new String(Base64.getDecoder().decode(body.at("/documents/0/data").asString()))) + .isEqualTo("%PDF-1.7"); + assertThat(body.at("/actions/0/signer/type").asString()).isEqualTo("certifio"); + assertThat(body.at("/actions/0/ref").asString()).isEqualTo("1"); + } + + @Test + void resolvesInsideNestedObjectsAndArrays() { + JsonNode body = + resolve( + "{\"a\":{\"b\":[{\"c\":\"{{document.filename}}\"}," + + "\"{{run.policyName}}\"]}}"); + + assertThat(body.at("/a/b/0/c").asString()).isEqualTo("contract.pdf"); + assertThat(body.at("/a/b/1").asString()).isEqualTo("Signature run"); + } + + @Test + void leavesNonStringsAlone() { + JsonNode body = resolve("{\"n\":3,\"b\":true,\"z\":null,\"arr\":[1,2]}"); + + assertThat(body.at("/n").asInt()).isEqualTo(3); + assertThat(body.at("/b").asBoolean()).isTrue(); + assertThat(body.at("/z").isNull()).isTrue(); + assertThat(body.at("/arr/1").asInt()).isEqualTo(2); + } + + @Test + void substitutesWithinSurroundingText() { + JsonNode body = resolve("{\"subject\":\"{{run.policyName}}: {{document.filename}}\"}"); + + assertThat(body.at("/subject").asString()).isEqualTo("Signature run: contract.pdf"); + } + + @Test + void aTypoInATemplateIsAnErrorNotASilentlyEmptyPayload() { + assertThatThrownBy(() -> resolve("{\"x\":\"{{document.flename}}\"}")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("unknown placeholder"); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/integration/api/PlaceholdersTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/integration/api/PlaceholdersTest.java new file mode 100644 index 0000000000..cd82e7997f --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/integration/api/PlaceholdersTest.java @@ -0,0 +1,127 @@ +package stirling.software.proprietary.integration.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.net.URI; + +import org.junit.jupiter.api.Test; + +import stirling.software.proprietary.integration.api.Placeholders.Escaping; + +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ObjectNode; + +class PlaceholdersTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private ObjectNode context() { + ObjectNode root = objectMapper.createObjectNode(); + ObjectNode document = root.putObject("document"); + document.put("filename", "invoice.pdf"); + document.put("sha256", "abc123"); + document.put("pageCount", 3); + document.putNull("title"); + ObjectNode label = root.putObject("sensitivityLabel"); + label.put("name", "Confidential"); + root.putObject("run").put("policyName", "Outbound review"); + return root; + } + + @Test + void substitutesADottedPath() { + assertThat(Placeholders.resolve("{{document.filename}}", context(), Escaping.NONE)) + .isEqualTo("invoice.pdf"); + } + + @Test + void substitutesSeveralWithSurroundingText() { + assertThat( + Placeholders.resolve( + "{{document.filename}} ({{document.pageCount}}p) is" + + " {{sensitivityLabel.name}}", + context(), + Escaping.NONE)) + .isEqualTo("invoice.pdf (3p) is Confidential"); + } + + @Test + void toleratesWhitespaceInsideBraces() { + assertThat(Placeholders.resolve("{{ document.sha256 }}", context(), Escaping.NONE)) + .isEqualTo("abc123"); + } + + @Test + void aNullValueRendersEmptyNotTheWordNull() { + // "null" in a vendor's field would read as a value rather than an absence. + assertThat(Placeholders.resolve("[{{document.title}}]", context(), Escaping.NONE)) + .isEqualTo("[]"); + } + + @Test + void textWithNoPlaceholderIsUntouched() { + assertThat(Placeholders.resolve("/scan", context(), Escaping.NONE)).isEqualTo("/scan"); + assertThat(Placeholders.resolve(null, context(), Escaping.NONE)).isNull(); + } + + @Test + void anUnknownPathIsAnErrorRatherThanAnEmptyValue() { + // A typo that silently sent "" could mean an external system files a document wrongly. + assertThatThrownBy( + () -> Placeholders.resolve("{{document.nope}}", context(), Escaping.NONE)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("unknown placeholder"); + assertThatThrownBy(() -> Placeholders.resolve("{{nope.at.all}}", context(), Escaping.NONE)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void anObjectRendersAsJson() { + assertThat(Placeholders.resolve("{{sensitivityLabel}}", context(), Escaping.NONE)) + .isEqualTo("{\"name\":\"Confidential\"}"); + } + + @Test + void pathEscapingEncodesSeparatorsButNotDots() { + ObjectNode context = context(); + context.putObject("x").put("weird", "a/b c.pdf"); + + // The dot survives (it is unreserved); the slash and space cannot pass as structure. + assertThat(Placeholders.resolve("{{x.weird}}", context, Escaping.URL_PATH)) + .isEqualTo("a%2Fb%20c.pdf"); + } + + @Test + void aTraversalInAValueIsNeutralisedRatherThanObeyed() { + ObjectNode context = context(); + context.putObject("x").put("nasty", "../../admin"); + + // Encoding the separators leaves one inert segment, so there is no traversal left to + // normalise: the request stays under the base and the value arrives as data. + String resolved = Placeholders.resolve("/docs/{{x.nasty}}", context, Escaping.URL_PATH); + assertThat(resolved).isEqualTo("/docs/..%2F..%2Fadmin"); + + assertThat(ExternalApiPaths.resolve(URI.create("https://api.example.com/v1"), resolved)) + .isEqualTo(URI.create("https://api.example.com/v1/docs/..%2F..%2Fadmin")); + } + + @Test + void aTraversalWrittenIntoTheTemplateItselfIsStillRejected() { + // The operator's own text is not encoded, so a literal ".." normalises and the base check + // sees it. This is why dots are deliberately left unencoded above. + assertThatThrownBy( + () -> + ExternalApiPaths.resolve( + URI.create("https://api.example.com/v1"), "/docs/../../x")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("escapes"); + } + + @Test + void detectsWhetherTextReferencesAnything() { + assertThat(Placeholders.hasPlaceholder("{{a.b}}")).isTrue(); + assertThat(Placeholders.hasPlaceholder("plain")).isFalse(); + assertThat(Placeholders.hasPlaceholder(null)).isFalse(); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/integration/api/ResultUrlsTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/integration/api/ResultUrlsTest.java new file mode 100644 index 0000000000..a46cd115a7 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/integration/api/ResultUrlsTest.java @@ -0,0 +1,193 @@ +package stirling.software.proprietary.integration.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.net.URI; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import stirling.software.common.model.ApplicationProperties; + +/** + * A result URL is picked by the remote service at run time, so these lean on the ways a hostile or + * compromised integration might use that to aim a server-side fetch somewhere it should not go. + */ +class ResultUrlsTest { + + private final ApplicationProperties properties = new ApplicationProperties(); + + private ApiConnectionSettings connection(List resultUrlHosts) { + Map config = new LinkedHashMap<>(); + config.put("baseUrl", "https://api.vendor.example/v1"); + if (resultUrlHosts != null) { + config.put("resultUrlHosts", resultUrlHosts); + } + return ApiConnectionSettings.from(config); + } + + // The host-matching rule is asserted directly: validate() also resolves the host, which fails + // closed, so it cannot be exercised against reserved .example names without real DNS. + + @Test + void allowsTheConnectionsOwnHostWithoutBeingDeclared() { + assertThat(ResultUrls.isAllowedHost(connection(null), "api.vendor.example")).isTrue(); + } + + @Test + void allowsADeclaredResultHost() { + // The common real case: the API answers on one host, the file lives on a CDN. + assertThat( + ResultUrls.isAllowedHost( + connection(List.of("cdn.vendor.example")), "cdn.vendor.example")) + .isTrue(); + } + + @Test + void allowsASubdomainOfADeclaredHost() { + assertThat( + ResultUrls.isAllowedHost( + connection(List.of("vendor.example")), "files.eu.vendor.example")) + .isTrue(); + } + + @Test + void hostMatchingIsCaseInsensitive() { + assertThat( + ResultUrls.isAllowedHost( + connection(List.of("CDN.Vendor.Example")), "cdn.vendor.example")) + .isTrue(); + } + + @Test + void anUnresolvableHostIsRefusedRatherThanAssumedSafe() { + // Fail closed: if we cannot see where a name points, we cannot say it is not internal. + assertThatThrownBy( + () -> + ResultUrls.validate( + connection(null), + "https://api.vendor.example/files/signed.pdf", + properties)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Unable to resolve"); + } + + @Test + void fetchesFromAnAllowedHostThatResolves() { + properties.getPolicies().setAllowPrivateApiEndpoints(true); + Map config = new LinkedHashMap<>(); + config.put("baseUrl", "http://127.0.0.1:9000/v1"); + ApiConnectionSettings settings = ApiConnectionSettings.from(config); + + assertThat( + ResultUrls.validate( + settings, "http://127.0.0.1:9000/files/signed.pdf", properties)) + .isEqualTo(URI.create("http://127.0.0.1:9000/files/signed.pdf")); + } + + @Test + void refusesAnUndeclaredHost() { + assertThatThrownBy( + () -> + ResultUrls.validate( + connection(null), "https://evil.example/x.pdf", properties)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("does not allow"); + } + + @Test + void refusesAHostThatMerelyEndsWithADeclaredOne() { + // "evilvendor.example" must not be admitted by an entry of "vendor.example". + assertThatThrownBy( + () -> + ResultUrls.validate( + connection(List.of("vendor.example")), + "https://evilvendor.example/x.pdf", + properties)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("does not allow"); + } + + @Test + void refusesTheCloudMetadataServiceEvenIfDeclared() { + // The headline SSRF: an integration answering with the metadata address. + assertThatThrownBy( + () -> + ResultUrls.validate( + connection(List.of("169.254.169.254")), + "http://169.254.169.254/latest/meta-data/iam/", + properties)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("private/link-local"); + } + + @Test + void refusesLoopbackEvenIfDeclared() { + assertThatThrownBy( + () -> + ResultUrls.validate( + connection(List.of("localhost")), + "http://localhost:8080/admin", + properties)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void anOperatorCanOptInToPrivateResultHostsForOnPrem() { + properties.getPolicies().setAllowPrivateApiEndpoints(true); + + assertThat( + ResultUrls.validate( + connection(List.of("localhost")), + "http://localhost:8080/files/signed.pdf", + properties)) + .hasHost("localhost"); + } + + @ParameterizedTest + @ValueSource( + strings = { + "file:///etc/passwd", + "jar:file:///tmp/x.jar!/y", + "gopher://evil.example/x", + "ftp://evil.example/x" + }) + void refusesNonHttpSchemes(String url) { + // A URL fetch that accepts file: is a local file read. + assertThatThrownBy(() -> ResultUrls.validate(connection(null), url, properties)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void refusesCredentialsEmbeddedInTheUrl() { + assertThatThrownBy( + () -> + ResultUrls.validate( + connection(List.of("cdn.vendor.example")), + "https://user:pw@cdn.vendor.example/x.pdf", + properties)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("credentials"); + } + + @Test + void refusesGarbage() { + assertThatThrownBy(() -> ResultUrls.validate(connection(null), "not a url", properties)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void resultUrlHostsMustBeBareHostnames() { + // A URL or wildcard here reads as broader than it is. + assertThatThrownBy(() -> connection(List.of("https://cdn.vendor.example/x"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("bare hostnames"); + assertThatThrownBy(() -> connection(List.of("*.vendor.example"))) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/integration/crypto/LegacyDecryptStringConverterTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/integration/crypto/LegacyDecryptStringConverterTest.java new file mode 100644 index 0000000000..11b913e9d7 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/integration/crypto/LegacyDecryptStringConverterTest.java @@ -0,0 +1,51 @@ +package stirling.software.proprietary.integration.crypto; + +import static org.assertj.core.api.Assertions.assertThat; + +import javax.crypto.KeyGenerator; +import javax.crypto.SecretKey; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class LegacyDecryptStringConverterTest { + + private final LegacyDecryptStringConverter converter = new LegacyDecryptStringConverter(); + + @BeforeAll + static void initKey() throws Exception { + KeyGenerator generator = KeyGenerator.getInstance("AES"); + generator.init(256); + SecretKey key = generator.generateKey(); + CredentialEncryption.initialiseForTesting(key); + } + + @Test + void writesPlaintext() { + String json = "{\"bucket\":\"inbox\",\"mode\":\"consume\"}"; + + assertThat(converter.convertToDatabaseColumn(json)).isEqualTo(json); + } + + @Test + void decryptsLegacyCiphertextOnRead() { + String json = "{\"bucket\":\"inbox\"}"; + String legacyCiphertext = CredentialEncryption.encrypt(json); + + assertThat(legacyCiphertext).isNotEqualTo(json); + assertThat(converter.convertToEntityAttribute(legacyCiphertext)).isEqualTo(json); + } + + @Test + void passesPlaintextThroughOnRead() { + String json = "{\"bucket\":\"inbox\",\"mode\":\"consume\"}"; + + assertThat(converter.convertToEntityAttribute(json)).isEqualTo(json); + } + + @Test + void nullsPassThrough() { + assertThat(converter.convertToDatabaseColumn(null)).isNull(); + assertThat(converter.convertToEntityAttribute(null)).isNull(); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/integration/crypto/LenientEncryptedStringConverterTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/integration/crypto/LenientEncryptedStringConverterTest.java deleted file mode 100644 index b61ded9708..0000000000 --- a/app/proprietary/src/test/java/stirling/software/proprietary/integration/crypto/LenientEncryptedStringConverterTest.java +++ /dev/null @@ -1,45 +0,0 @@ -package stirling.software.proprietary.integration.crypto; - -import static org.assertj.core.api.Assertions.assertThat; - -import javax.crypto.KeyGenerator; -import javax.crypto.SecretKey; - -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; - -class LenientEncryptedStringConverterTest { - - private final LenientEncryptedStringConverter converter = new LenientEncryptedStringConverter(); - - @BeforeAll - static void initKey() throws Exception { - KeyGenerator generator = KeyGenerator.getInstance("AES"); - generator.init(256); - SecretKey key = generator.generateKey(); - CredentialEncryption.initialiseForTesting(key); - } - - @Test - void roundTripsThroughCiphertext() { - String json = "{\"bucket\":\"inbox\",\"secretAccessKey\":\"shh\"}"; - - String stored = converter.convertToDatabaseColumn(json); - - assertThat(stored).isNotEqualTo(json).doesNotContain("shh"); - assertThat(converter.convertToEntityAttribute(stored)).isEqualTo(json); - } - - @Test - void legacyPlaintextRowsPassThroughOnRead() { - String legacy = "{\"bucket\":\"inbox\",\"mode\":\"consume\"}"; - - assertThat(converter.convertToEntityAttribute(legacy)).isEqualTo(legacy); - } - - @Test - void nullsPassThrough() { - assertThat(converter.convertToDatabaseColumn(null)).isNull(); - assertThat(converter.convertToEntityAttribute(null)).isNull(); - } -} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/integration/purview/PdfSensitivityLabelsTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/integration/purview/PdfSensitivityLabelsTest.java new file mode 100644 index 0000000000..7e563c371a --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/integration/purview/PdfSensitivityLabelsTest.java @@ -0,0 +1,282 @@ +package stirling.software.proprietary.integration.purview; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.time.Instant; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.common.PDMetadata; +import org.junit.jupiter.api.Test; + +import stirling.software.proprietary.integration.purview.SensitivityLabel.AssignmentMethod; + +/** + * Exercises the label round-trip against real PDFBox documents, including a save/reload so the + * assertions reflect what actually lands on disk rather than in-memory state. + */ +class PdfSensitivityLabelsTest { + + private static final String LABEL_ID = "2096f6a2-d2f7-48be-b329-b73aaa526e5d"; + private static final String TENANT = "cb46c030-1825-4e81-a295-151c039dbf02"; + + private static PDDocument newDocument() { + PDDocument document = new PDDocument(); + document.addPage(new PDPage()); + return document; + } + + private static PDDocument saveAndReload(PDDocument document) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + document.save(out); + document.close(); + return Loader.loadPDF(new ByteArrayInputStream(out.toByteArray()).readAllBytes()); + } + + private static String xmpString(PDDocument document) throws IOException { + PDMetadata metadata = document.getDocumentCatalog().getMetadata(); + if (metadata == null) { + return ""; + } + try (InputStream is = metadata.exportXMPMetadata()) { + return new String(is.readAllBytes(), StandardCharsets.UTF_8); + } + } + + private static SensitivityLabel confidential() { + return new SensitivityLabel( + LABEL_ID, + "Confidential", + TENANT, + AssignmentMethod.STANDARD, + Instant.parse("2026-07-17T10:15:30Z"), + SensitivityLabel.CONTENT_BITS_FOOTER); + } + + @Test + void appliedLabelSurvivesSaveAndReload() throws IOException { + PDDocument document = newDocument(); + PdfSensitivityLabels.apply(document, confidential()); + + try (PDDocument reloaded = saveAndReload(document)) { + SensitivityLabel read = PdfSensitivityLabels.read(reloaded).orElseThrow(); + assertThat(read.labelId()).isEqualTo(LABEL_ID); + assertThat(read.name()).isEqualTo("Confidential"); + assertThat(read.siteId()).isEqualTo(TENANT); + assertThat(read.method()).isEqualTo(AssignmentMethod.STANDARD); + assertThat(read.setDate()).isEqualTo(Instant.parse("2026-07-17T10:15:30Z")); + assertThat(read.contentBits()).isEqualTo(SensitivityLabel.CONTENT_BITS_FOOTER); + } + } + + @Test + void writesTheDocumentedKeyNamesIntoTheInfoDictionary() throws IOException { + try (PDDocument document = newDocument()) { + PdfSensitivityLabels.apply(document, confidential()); + + var info = document.getDocumentInformation(); + String prefix = "MSIP_Label_" + LABEL_ID + "_"; + assertThat(info.getCustomMetadataValue(prefix + "Enabled")).isEqualTo("true"); + assertThat(info.getCustomMetadataValue(prefix + "SiteId")).isEqualTo(TENANT); + assertThat(info.getCustomMetadataValue(prefix + "Method")).isEqualTo("Standard"); + assertThat(info.getCustomMetadataValue(prefix + "Name")).isEqualTo("Confidential"); + assertThat(info.getCustomMetadataValue(prefix + "ContentBits")).isEqualTo("2"); + // Extended ISO 8601, as the MIP contract specifies. + assertThat(info.getCustomMetadataValue(prefix + "SetDate")) + .isEqualTo("2026-07-17T10:15:30+0000"); + } + } + + @Test + void readsALabelPresentOnlyInTheInfoDictionary() throws IOException { + // What a third-party labeller may leave behind: no XMP copy at all. + try (PDDocument document = newDocument()) { + var info = document.getDocumentInformation(); + String prefix = "MSIP_Label_" + LABEL_ID + "_"; + info.setCustomMetadataValue(prefix + "Enabled", "true"); + info.setCustomMetadataValue(prefix + "SiteId", TENANT); + info.setCustomMetadataValue(prefix + "Name", "Secret"); + + SensitivityLabel read = PdfSensitivityLabels.read(document).orElseThrow(); + assertThat(read.name()).isEqualTo("Secret"); + assertThat(read.method()).isNull(); + } + } + + @Test + void unlabelledDocumentReadsAsEmpty() throws IOException { + try (PDDocument document = newDocument()) { + assertThat(PdfSensitivityLabels.read(document)).isEmpty(); + } + } + + @Test + void enabledFalseIsNotALabel() throws IOException { + try (PDDocument document = newDocument()) { + var info = document.getDocumentInformation(); + info.setCustomMetadataValue("MSIP_Label_" + LABEL_ID + "_Enabled", "false"); + info.setCustomMetadataValue("MSIP_Label_" + LABEL_ID + "_SiteId", TENANT); + + assertThat(PdfSensitivityLabels.read(document)).isEmpty(); + } + } + + @Test + void relabellingReplacesTheSameTenantsLabel() throws IOException { + // "An object can only have one label from the same organization." + String otherLabel = "11111111-2222-3333-4444-555555555555"; + try (PDDocument document = newDocument()) { + PdfSensitivityLabels.apply(document, confidential()); + PdfSensitivityLabels.apply( + document, + new SensitivityLabel( + otherLabel, "Public", TENANT, AssignmentMethod.PRIVILEGED, null, null)); + + assertThat(PdfSensitivityLabels.readAll(document)) + .singleElement() + .satisfies( + label -> { + assertThat(label.labelId()).isEqualTo(otherLabel); + assertThat(label.name()).isEqualTo("Public"); + }); + } + } + + @Test + void aDifferentTenantsLabelIsLeftAlone() throws IOException { + String foreignTenant = "99999999-8888-7777-6666-555555555555"; + try (PDDocument document = newDocument()) { + PdfSensitivityLabels.apply( + document, + new SensitivityLabel( + "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "Foreign", + foreignTenant, + null, + null, + null)); + PdfSensitivityLabels.apply(document, confidential()); + + assertThat(PdfSensitivityLabels.readAll(document)) + .hasSize(2) + .extracting(SensitivityLabel::siteId) + .containsExactlyInAnyOrder(foreignTenant, TENANT); + } + } + + @Test + void aDifferentTenantsLabelStaysInTheXmpSurfaceToo() throws IOException { + String foreignLabel = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; + PDDocument document = newDocument(); + PdfSensitivityLabels.apply( + document, + new SensitivityLabel( + foreignLabel, + "Foreign", + "99999999-8888-7777-6666-555555555555", + null, + null, + null)); + PdfSensitivityLabels.apply(document, confidential()); + + try (PDDocument reloaded = saveAndReload(document)) { + // The foreign label must survive on the XMP copy, not only in the info dictionary: + // re-labelling replaces this tenant's labels, not everyone else's. + String xmp = xmpString(reloaded); + assertThat(xmp).contains("MSIP_Label_" + foreignLabel + "_"); + assertThat(xmp).contains("MSIP_Label_" + LABEL_ID + "_"); + } + } + + @Test + void clearRemovesEveryLabel() throws IOException { + PDDocument document = newDocument(); + PdfSensitivityLabels.apply(document, confidential()); + PdfSensitivityLabels.clear(document); + + try (PDDocument reloaded = saveAndReload(document)) { + assertThat(PdfSensitivityLabels.readAll(reloaded)).isEmpty(); + } + } + + @Test + void refusesALabelThatClaimsEncryption() throws IOException { + try (PDDocument document = newDocument()) { + SensitivityLabel encrypting = + new SensitivityLabel( + LABEL_ID, + "Highly Confidential", + TENANT, + AssignmentMethod.STANDARD, + null, + SensitivityLabel.CONTENT_BITS_ENCRYPT); + + // Marking content as protected without protecting it would mislead every reader. + assertThatThrownBy(() -> PdfSensitivityLabels.apply(document, encrypting)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cannot protect"); + } + } + + @Test + void preservesUnrelatedXmpAndInfoMetadata() throws IOException { + PDDocument document = newDocument(); + document.getDocumentInformation().setAuthor("Anthony"); + document.getDocumentInformation().setCustomMetadataValue("StirlingPDFClassification", "{}"); + PdfSensitivityLabels.apply(document, confidential()); + + try (PDDocument reloaded = saveAndReload(document)) { + assertThat(reloaded.getDocumentInformation().getAuthor()).isEqualTo("Anthony"); + assertThat( + reloaded.getDocumentInformation() + .getCustomMetadataValue("StirlingPDFClassification")) + .isEqualTo("{}"); + assertThat(PdfSensitivityLabels.read(reloaded)).isPresent(); + } + } + + @Test + void labelValuesAreCappedAtTheDocumentedLength() { + SensitivityLabel longName = + new SensitivityLabel(LABEL_ID, "x".repeat(400), TENANT, null, null, null); + assertThat(longName.toMetadata().get("MSIP_Label_" + LABEL_ID + "_Name")) + .hasSize(SensitivityLabel.MAX_VALUE_LENGTH); + } + + @Test + void labelRequiresIdAndTenant() { + assertThatThrownBy(() -> new SensitivityLabel(null, "n", TENANT, null, null, null)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new SensitivityLabel(LABEL_ID, "n", " ", null, null, null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void rejectsALabelIdThatIsNotAGuid() { + // labelId is written into XMP/info key names verbatim; a space or markup char must not + // pass, + // or it would corrupt or inject the metadata packet it lands in. + assertThatThrownBy( + () -> + new SensitivityLabel( + "not a guid", "Public", TENANT, null, null, null)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy( + () -> + new SensitivityLabel( + "-2222-3333-4444-5555555555", + "Public", + TENANT, + null, + null, + null)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/integration/service/IntegrationConfigServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/integration/service/IntegrationConfigServiceTest.java index 8e0ef200bb..90842612d1 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/integration/service/IntegrationConfigServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/integration/service/IntegrationConfigServiceTest.java @@ -13,9 +13,9 @@ import java.util.Map; import java.util.Optional; import java.util.Set; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.HttpStatus; @@ -52,7 +52,148 @@ class IntegrationConfigServiceTest { @Mock private stirling.software.proprietary.access.repository.ResourceGrantRepository grantRepository; - @InjectMocks private IntegrationConfigService service; + @Mock private IntegrationConfigValidator validator; + @Mock private IntegrationConfigUsageCheck usageCheck; + + private final stirling.software.common.model.ApplicationProperties applicationProperties = + new stirling.software.common.model.ApplicationProperties(); + + private IntegrationConfigService service; + + @BeforeEach + void setUp() { + service = + new IntegrationConfigService( + repository, + ownership, + secretMasker, + grantRepository, + applicationProperties, + List.of(validator), + List.of(usageCheck)); + } + + @Test + void createRejectsAConfigItsTypeValidatorRefuses() { + when(secretMasker.sanitize(any())).thenReturn(Map.of()); + when(validator.type()).thenReturn(IntegrationType.MCP); + org.mockito.Mockito.doThrow(new IllegalArgumentException("mcp config needs a 'url'")) + .when(validator) + .validate(any()); + + assertThatThrownBy( + () -> + service.create( + request(IntegrationType.MCP, OwnerScope.USER, null), + user(7))) + .isInstanceOf(ResponseStatusException.class) + .satisfies( + e -> + assertThat(((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.BAD_REQUEST)); + } + + @Test + void anAdminCanAuthorACustomApiIntegration() { + when(secretMasker.sanitize(any())).thenReturn(Map.of()); + when(repository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(ownership.isAdmin(any())).thenReturn(true); + + IntegrationConfig created = + service.create(request(IntegrationType.API, OwnerScope.USER, null), user(7)); + + assertThat(created.getIntegrationType()).isEqualTo(IntegrationType.API); + } + + @Test + void aNonAdminCannotAuthorACustomApiIntegration() { + // A custom integration names its own host and body, so it can aim the server anywhere; + // that is admin authoring power, not self-serve config like a vendor preset. + when(ownership.isAdmin(any())).thenReturn(false); + + assertThatThrownBy( + () -> + service.create( + request(IntegrationType.API, OwnerScope.USER, null), + user(7))) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + void theOperatorCanWithdrawCustomApiAuthoringEntirely() { + applicationProperties.getPolicies().setAllowCustomApiIntegrations(false); + + // Off for everyone, admins included. + assertThatThrownBy( + () -> + service.create( + request(IntegrationType.API, OwnerScope.USER, null), + user(7))) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.FORBIDDEN); + assertThat(service.canAuthorCustomApi(user(7))).isFalse(); + } + + @Test + void vendorPresetsAreNotGatedByTheCustomApiFlag() { + // Purview/ConsignO carry a fixed shape: the worst a user can do is misconfigure their own + // connection, so they stay self-serve even with custom authoring switched off. + applicationProperties.getPolicies().setAllowCustomApiIntegrations(false); + when(secretMasker.sanitize(any())).thenReturn(Map.of()); + when(repository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + IntegrationConfig created = + service.create(request(IntegrationType.PURVIEW, OwnerScope.USER, null), user(7)); + + assertThat(created.getIntegrationType()).isEqualTo(IntegrationType.PURVIEW); + } + + @Test + void editingACustomApisConfigNeedsTheSameRightsAsCreatingIt() { + // Otherwise the base URL and body could be rewritten by someone who could never have + // authored them. + IntegrationConfig cfg = config(5L); + cfg.setIntegrationType(IntegrationType.API); + when(repository.findById(5L)).thenReturn(Optional.of(cfg)); + when(ownership.canManage(any(), eq(cfg), any())).thenReturn(true); + when(ownership.isAdmin(any())).thenReturn(false); + + assertThatThrownBy( + () -> + service.update( + 5L, + request(IntegrationType.API, OwnerScope.USER, null), + user(7))) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + void customApiAuthoringIsOnByDefaultForAdmins() { + when(ownership.isAdmin(any())).thenReturn(true); + + assertThat(service.canAuthorCustomApi(user(7))).isTrue(); + } + + @Test + void deleteRefusedWhileAnythingStillReferencesTheConfig() { + IntegrationConfig cfg = config(9L); + when(repository.findById(9L)).thenReturn(Optional.of(cfg)); + when(ownership.canManage(any(), eq(cfg), any())).thenReturn(true); + when(usageCheck.usagesOf(9L)).thenReturn(List.of("source 'Claims intake'")); + + assertThatThrownBy(() -> service.delete(9L, user(7))) + .isInstanceOf(ResponseStatusException.class) + .satisfies( + e -> + assertThat(((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.CONFLICT)); + verify(repository, org.mockito.Mockito.never()).delete(any(IntegrationConfig.class)); + } @Test void createDelegatesOwnershipAndSanitizesConfig() { @@ -61,9 +202,9 @@ class IntegrationConfigServiceTest { User user = user(7); IntegrationConfig created = - service.create(request(IntegrationType.API, OwnerScope.USER, null), user); + service.create(request(IntegrationType.MCP, OwnerScope.USER, null), user); - assertThat(created.getIntegrationType()).isEqualTo(IntegrationType.API); + assertThat(created.getIntegrationType()).isEqualTo(IntegrationType.MCP); assertThat(created.getName()).isEqualTo("name"); verify(ownership) .assignOwnership(eq(created), eq(OwnerScope.USER), isNull(), eq(user), any()); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/model/TeamEntityListenerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/model/TeamEntityListenerTest.java new file mode 100644 index 0000000000..6807aa8687 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/model/TeamEntityListenerTest.java @@ -0,0 +1,46 @@ +package stirling.software.proprietary.model; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; + +@ExtendWith(MockitoExtension.class) +class TeamEntityListenerTest { + + @Mock private ApplicationEventPublisher publisher; + + private static Team team(Long id, String name) { + Team team = new Team(); + team.setId(id); + team.setName(name); + return team; + } + + @Test + void publishesTeamCreatedEventOnPersist() { + TeamEntityListener listener = new TeamEntityListener(); + listener.setPublisher(publisher); + + listener.onCreate(team(5L, "Acme")); + + ArgumentCaptor event = ArgumentCaptor.forClass(TeamCreatedEvent.class); + org.mockito.Mockito.verify(publisher).publishEvent(event.capture()); + assertThat(event.getValue().teamId()).isEqualTo(5L); + assertThat(event.getValue().teamName()).isEqualTo("Acme"); + } + + @Test + void doesNotThrowWhenNoPublisherIsSet() { + // JPA can build the listener before Spring wires the publisher; must be a safe no-op. + TeamEntityListener listener = new TeamEntityListener(); + listener.setPublisher(null); + + assertThatCode(() -> listener.onCreate(team(1L, "X"))).doesNotThrowAnyException(); + } +} 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 4859954e71..351dcd1d2f 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 @@ -13,6 +13,7 @@ import org.junit.jupiter.api.io.TempDir; import org.springframework.core.env.StandardEnvironment; import stirling.software.common.configuration.InstallationPathConfig; +import stirling.software.common.configuration.RuntimePathConfig; import stirling.software.common.model.ApplicationProperties; import stirling.software.proprietary.policy.model.InputSpec; import stirling.software.proprietary.policy.model.OutputSpec; @@ -36,7 +37,37 @@ class FolderAccessGuardTest { properties.getPolicies().setAllowedFolderRoots(allowedRoots); StandardEnvironment environment = new StandardEnvironment(); environment.setActiveProfiles(activeProfiles); - return new FolderAccessGuard(properties, environment, sourceStore); + return new FolderAccessGuard( + properties, new RuntimePathConfig(properties), environment, sourceStore); + } + + private FolderAccessGuard guardWithStorage( + List allowedRoots, boolean storageEnabled, String provider, String basePath) { + ApplicationProperties properties = new ApplicationProperties(); + properties.getPolicies().setAllowedFolderRoots(allowedRoots); + ApplicationProperties.Storage storage = properties.getStorage(); + storage.setEnabled(storageEnabled); + storage.setProvider(provider); + storage.getLocal().setBasePath(basePath); + return new FolderAccessGuard( + properties, + new RuntimePathConfig(properties), + new StandardEnvironment(), + sourceStore); + } + + private FolderAccessGuard guardWithWatchedFolder(String watchedDir) { + ApplicationProperties properties = new ApplicationProperties(); + properties + .getSystem() + .getCustomPaths() + .getPipeline() + .setWatchedFoldersDirs(List.of(watchedDir)); + return new FolderAccessGuard( + properties, + new RuntimePathConfig(properties), + new StandardEnvironment(), + sourceStore); } @Test @@ -50,8 +81,9 @@ class FolderAccessGuardTest { @Test void rejectsADirectoryOutsideEveryAllowedRoot() { FolderAccessGuard guard = guard(List.of(tempDir.toString())); + // FolderAccessDeniedException (not the base type): the admin can fix this in settings. assertThrows( - IllegalArgumentException.class, + FolderAccessDeniedException.class, () -> guard.requirePermitted(tempDir.resolveSibling("elsewhere"))); } @@ -59,14 +91,50 @@ class FolderAccessGuardTest { void rejectsTraversalThatWalksOutOfAnAllowedRoot() { FolderAccessGuard guard = guard(List.of(tempDir.toString())); assertThrows( - IllegalArgumentException.class, + FolderAccessDeniedException.class, () -> guard.requirePermitted(tempDir.resolve("..").resolve("escaped"))); } @Test void rejectsEverythingWhenNoRootsAreConfigured() { FolderAccessGuard guard = guard(List.of()); - assertThrows(IllegalArgumentException.class, () -> guard.requirePermitted(tempDir)); + assertThrows(FolderAccessDeniedException.class, () -> guard.requirePermitted(tempDir)); + } + + @Test + void permitsTheLocalServerStorageDirectoryEvenWithNoConfiguredRoots() { + Path storageBase = tempDir.resolve("storage"); + FolderAccessGuard guard = + guardWithStorage(List.of(), true, "local", storageBase.toString()); + Path within = storageBase.resolve("inbox"); + + assertEquals(within.toAbsolutePath().normalize(), guard.requirePermitted(within)); + } + + @Test + void ignoresServerStorageWhenTheStorageFeatureIsDisabled() { + Path storageBase = tempDir.resolve("storage"); + FolderAccessGuard guard = + guardWithStorage(List.of(), false, "local", storageBase.toString()); + + assertThrows(IllegalArgumentException.class, () -> guard.requirePermitted(storageBase)); + } + + @Test + void ignoresServerStorageWhenTheProviderIsNotLocal() { + Path storageBase = tempDir.resolve("storage"); + FolderAccessGuard guard = guardWithStorage(List.of(), true, "s3", storageBase.toString()); + + assertThrows(IllegalArgumentException.class, () -> guard.requirePermitted(storageBase)); + } + + @Test + void permitsPipelineWatchedFoldersEvenWithNoConfiguredRoots() { + Path watched = tempDir.resolve("watched"); + FolderAccessGuard guard = guardWithWatchedFolder(watched.toString()); + Path within = watched.resolve("inbox"); + + assertEquals(within.toAbsolutePath().normalize(), guard.requirePermitted(within)); } @Test @@ -76,15 +144,21 @@ class FolderAccessGuardTest { // Allow the config dir's parent, so only the protected-path rule can reject it. FolderAccessGuard guard = guard(List.of(configDir.getParent().toString())); - assertThrows( - IllegalArgumentException.class, - () -> guard.requirePermitted(configDir.resolve("settings.yml"))); + // Not a FolderAccessDeniedException: editing the allowlist can't unprotect the config dir. + IllegalArgumentException ex = + assertThrows( + IllegalArgumentException.class, + () -> guard.requirePermitted(configDir.resolve("settings.yml"))); + assertFalse(ex instanceof FolderAccessDeniedException); } @Test void refusesAllFolderAccessUnderTheSaasProfile() { FolderAccessGuard guard = guard(List.of(tempDir.toString()), "saas"); - assertThrows(IllegalArgumentException.class, () -> guard.requirePermitted(tempDir)); + // Not a FolderAccessDeniedException: SaaS has no folder allowlist to point the admin at. + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> guard.requirePermitted(tempDir)); + assertFalse(ex instanceof FolderAccessDeniedException); } @Test 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 c02945fdad..351eecd581 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 @@ -4,6 +4,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -26,6 +27,8 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.server.ResponseStatusException; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import stirling.software.common.cluster.JobStore; +import stirling.software.common.cluster.inprocess.InProcessJobStore; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.job.JobResponse; import stirling.software.common.service.JobOwnershipService; @@ -77,6 +80,7 @@ class PolicyControllerTest { @Mock private JobOwnershipService jobOwnershipService; private ApplicationProperties applicationProperties; + private final JobStore jobStore = new InProcessJobStore(); private PolicyController controller; private final java.util.List @@ -105,7 +109,8 @@ class PolicyControllerTest { policyTriggers, applicationProperties, tempFileManager, - jobOwnershipService); + jobOwnershipService, + jobStore); } private static stirling.software.proprietary.policy.trigger.PolicyTrigger trigger( @@ -130,7 +135,7 @@ class PolicyControllerTest { private static PipelineDefinition definitionWithStep() { return new PipelineDefinition( - "pipe", List.of(new PipelineStep("/api/v1/misc/compress-pdf", null)), null); + "pipe", List.of(new PipelineStep("/api/v1/misc/compress-pdf", null)), List.of()); } private static Policy policy(String id, Long teamId) { @@ -185,7 +190,7 @@ class PolicyControllerTest { @Test @DisplayName("rejects a pipeline with no steps") void rejectsEmptyPipeline() { - PipelineDefinition empty = new PipelineDefinition("pipe", List.of(), null); + PipelineDefinition empty = new PipelineDefinition("pipe", List.of(), List.of()); assertThatThrownBy(() -> controller.run(empty, new PolicyRunFiles())) .isInstanceOf(ResponseStatusException.class) @@ -194,6 +199,29 @@ class PolicyControllerTest { assertThat(((ResponseStatusException) e).getStatusCode()) .isEqualTo(HttpStatus.BAD_REQUEST)); } + + @Test + @DisplayName("rejects an ad-hoc output the caller cannot use, on the request thread") + void rejectsUnauthorizedAdHocOutput() { + // The confused-deputy guard: an S3 output referencing a connection the caller may not + // use is validated here (principal present) and refused before any worker dispatch. + PipelineDefinition definition = + new PipelineDefinition( + "pipe", + List.of(new PipelineStep("/api/v1/misc/compress-pdf", null)), + new OutputSpec("s3", Map.of("connectionId", 999))); + doThrow(new IllegalArgumentException("unknown or inaccessible s3 connection")) + .when(policyValidator) + .validateOutput(any()); + + assertThatThrownBy(() -> controller.run(definition, new PolicyRunFiles())) + .isInstanceOf(ResponseStatusException.class) + .satisfies( + e -> + assertThat(((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.BAD_REQUEST)); + verify(policyRunner, never()).runAdHoc(any(), any(), any()); + } } @Nested @@ -213,7 +241,7 @@ class PolicyControllerTest { @Test @DisplayName("rejects a pipeline with no steps") void rejectsEmpty() { - PipelineDefinition empty = new PipelineDefinition("pipe", List.of(), null); + PipelineDefinition empty = new PipelineDefinition("pipe", List.of(), List.of()); assertThatThrownBy(() -> controller.runStream(empty, new PolicyRunFiles())) .isInstanceOf(ResponseStatusException.class); @@ -240,6 +268,8 @@ class PolicyControllerTest { @DisplayName("returns 404 when run is unknown") void notFound() { when(runRegistry.get("missing")).thenReturn(null); + when(jobOwnershipService.extractJobId("missing")).thenReturn("missing"); + when(jobOwnershipService.createScopedJobKey("missing")).thenReturn("missing"); ResponseEntity response = controller.status("missing"); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyRunRoutesTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyRunRoutesTest.java new file mode 100644 index 0000000000..f1be12ae1d --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyRunRoutesTest.java @@ -0,0 +1,127 @@ +package stirling.software.proprietary.policy.controller; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Arrays; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.servlet.HandlerMapping; + +class PolicyRunRoutesTest { + + private static boolean matchesUri(String uri) { + MockHttpServletRequest req = new MockHttpServletRequest(); + req.setRequestURI(uri); + return PolicyRunRoutes.matches(req); + } + + private static boolean matchesPattern(String pattern) { + MockHttpServletRequest req = new MockHttpServletRequest(); + req.setAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, pattern); + return PolicyRunRoutes.matches(req); + } + + @Test + void matchesTheFourExecuteRoutes() { + assertThat(matchesUri("/api/v1/policies/run")).isTrue(); + assertThat(matchesUri("/api/v1/policies/run/stream")).isTrue(); + assertThat(matchesUri("/api/v1/policies/pol-123/run")).isTrue(); + assertThat(matchesUri("/api/v1/policies/pol-123/trigger")).isTrue(); + } + + @Test + void excludesReadListAndCrudRoutes() { + assertThat(matchesUri("/api/v1/policies")).isFalse(); // list + create + assertThat(matchesUri("/api/v1/policies/runs")).isFalse(); + assertThat(matchesUri("/api/v1/policies/run/abc-run-id")).isFalse(); // GET /run/{runId} + assertThat(matchesUri("/api/v1/policies/overview")).isFalse(); + assertThat(matchesUri("/api/v1/policies/triggers")).isFalse(); // NB: not "/trigger" + assertThat(matchesUri("/api/v1/policies/order")).isFalse(); + assertThat(matchesUri("/api/v1/policies/pol-123")).isFalse(); + assertThat(matchesUri("/api/v1/policies/pol-123/processed-history")).isFalse(); + } + + @Test + void isSegmentAnchoredAndContextPathTolerant() { + assertThat(matchesUri("/stirling/api/v1/policies/pol-123/run")).isTrue(); + assertThat(matchesUri("/api/v1/policies-x/pol-123/run")) + .isFalse(); // sibling, not the segment + assertThat(matchesUri("/api/v1/sources/pol/run")).isFalse(); + assertThat(matchesUri("/api/v1/misc/compress-pdf")).isFalse(); + } + + /** Every request mapping on PolicyController, and whether it executes an automation. */ + private static final Map EXPECTED = + Map.of( + "/api/v1/policies", false, // base: list (GET) + create (POST) + "/api/v1/policies/run", true, + "/api/v1/policies/run/stream", true, + "/api/v1/policies/run/{runId}", false, + "/api/v1/policies/runs", false, + "/api/v1/policies/order", false, + "/api/v1/policies/overview", false, + "/api/v1/policies/triggers", false, + "/api/v1/policies/{policyId}", false, // GET + DELETE + "/api/v1/policies/{policyId}/processed-history", false); + + // Split out because Map.of caps at 10 entries; the execute {id} routes live here. + private static final Map EXPECTED_ID_EXECUTES = + Map.of( + "/api/v1/policies/{policyId}/run", true, + "/api/v1/policies/{policyId}/trigger", true); + + /** + * Fail-safe: this matcher is the sole billing gate, so an unmatched execute route would run + * automations for free. Reconstruct every mapping on PolicyController and assert its + * classification is declared above - a new/renamed route lands as "unclassified" and fails the + * build until someone decides whether it executes an automation. + */ + @Test + void everyControllerMappingIsClassified() { + String base = classMapping(); + Arrays.stream(PolicyController.class.getDeclaredMethods()) + .filter(m -> AnnotatedElementUtils.hasAnnotation(m, RequestMapping.class)) + .forEach( + m -> { + String pattern = base + methodMapping(m); + Boolean expected = expectedFor(pattern); + assertThat(expected) + .as( + "unclassified PolicyController route %s - add it to" + + " PolicyRunRoutesTest.EXPECTED", + pattern) + .isNotNull(); + assertThat(matchesPattern(pattern)) + .as("PolicyRunRoutes classification of %s", pattern) + .isEqualTo(expected); + }); + } + + private static Boolean expectedFor(String pattern) { + if (EXPECTED.containsKey(pattern)) { + return EXPECTED.get(pattern); + } + return EXPECTED_ID_EXECUTES.get(pattern); + } + + private static String classMapping() { + RequestMapping rm = + AnnotatedElementUtils.getMergedAnnotation( + PolicyController.class, RequestMapping.class); + return rm == null ? "" : firstOrEmpty(rm); + } + + private static String methodMapping(java.lang.reflect.Method m) { + RequestMapping rm = AnnotatedElementUtils.getMergedAnnotation(m, RequestMapping.class); + return rm == null ? "" : firstOrEmpty(rm); + } + + private static String firstOrEmpty(RequestMapping rm) { + String[] paths = rm.path().length > 0 ? rm.path() : rm.value(); + return paths.length > 0 ? paths[0] : ""; + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyEngineTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyEngineTest.java index 78348fa5c4..e69f0f5476 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyEngineTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyEngineTest.java @@ -15,8 +15,10 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.io.IOException; import java.io.InputStream; import java.nio.file.Path; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; @@ -37,6 +39,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.client.HttpClientErrorException; import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.model.job.ResultFile; import stirling.software.common.service.FileStorage; import stirling.software.common.service.FileStorage.StoredFile; import stirling.software.common.service.InternalApiClient; @@ -55,7 +58,11 @@ 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.output.InlineOutputSink; +import stirling.software.proprietary.policy.output.OutputDelivery; +import stirling.software.proprietary.policy.output.PolicyOutputResolver; +import stirling.software.proprietary.policy.output.PolicyOutputSink; import stirling.software.proprietary.policy.progress.PolicyProgressListener; +import stirling.software.proprietary.policy.source.InProcessSourceStore; import tools.jackson.databind.json.JsonMapper; @@ -81,6 +88,7 @@ class PolicyEngineTest { @TempDir Path tempDir; + private final RecordingSink recordingSink = new RecordingSink(); private PolicyRunRegistry registry; private PolicyEngine engine; @@ -98,6 +106,7 @@ class PolicyEngineTest { JsonMapper.builder().build()); registry = new PolicyRunRegistry(new ApplicationProperties()); InlineOutputSink sink = new InlineOutputSink(fileStorage); + PolicyOutputResolver outputResolver = new PolicyOutputResolver(new InProcessSourceStore()); engine = new PolicyEngine( executor, @@ -105,7 +114,8 @@ class PolicyEngineTest { registry, fileStorage, jobOwnershipService, - List.of(sink), + List.of(sink, recordingSink), + outputResolver, resourceMonitor, jobQueue); @@ -156,6 +166,36 @@ class PolicyEngineTest { verify(taskManager, atLeastOnce()).addNote(eq(runId), anyString()); } + @Test + void deliversTheRunsFilesToEveryDestination() throws Exception { + when(toolMetadataService.isMultiInput(anyString())).thenReturn(false); + when(toolMetadataService.shouldUnpackZipResponse(anyString())).thenReturn(false); + stubEndpoint(COMPRESS, pdf("compressed", "out.pdf")); + + // Two destinations of a recording sink; each fully reads the (shared) result file, so this + // also proves the result Resources are re-readable across more than one delivery. + PipelineDefinition definition = + new PipelineDefinition( + "multi", + List.of(new PipelineStep(COMPRESS, Map.of())), + List.of( + new OutputSpec("record", Map.of("dest", "a")), + new OutputSpec("record", Map.of("dest", "b")))); + + PolicyRun run = + engine.submit( + definition, + PolicyInputs.of(List.of(pdf("input", "input.pdf"))), + PolicyProgressListener.NOOP) + .completion() + .get(10, TimeUnit.SECONDS); + + assertEquals(PolicyRunStatus.COMPLETED, run.getStatus()); + // One result file per destination, and each destination read the same output content. + assertEquals(2, run.getOutputs().size()); + assertEquals(List.of("a:compressed", "b:compressed"), recordingSink.deliveries()); + } + @Test void submitFailsRunWhenAToolErrors() throws Exception { when(toolMetadataService.isMultiInput(ROTATE)).thenReturn(false); @@ -385,4 +425,50 @@ class PolicyEngineTest { } }; } + + /** + * A test output sink (type "record") that fully reads each delivered file and records + * "{dest}:{content}" per file, so a test can assert the run was delivered to every destination. + */ + private static final class RecordingSink implements PolicyOutputSink { + + private final List deliveries = new ArrayList<>(); + + List deliveries() { + return deliveries; + } + + @Override + public String type() { + return "record"; + } + + @Override + public boolean supports(OutputSpec spec) { + return spec != null && "record".equals(spec.type()); + } + + @Override + public List deliver( + OutputDelivery delivery, List outputs, OutputSpec spec) + throws IOException { + String dest = String.valueOf(spec.options().get("dest")); + List results = new ArrayList<>(); + for (Resource file : outputs) { + byte[] bytes; + try (InputStream is = file.getInputStream()) { + bytes = is.readAllBytes(); + } + deliveries.add(dest + ":" + new String(bytes)); + results.add( + ResultFile.builder() + .fileId("rec-" + dest) + .fileName(dest + "/" + file.getFilename()) + .contentType("application/pdf") + .fileSize(bytes.length) + .build()); + } + return results; + } + } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyExecutorTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyExecutorTest.java index 9b10d9090e..4d9ea1deb7 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyExecutorTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyExecutorTest.java @@ -166,8 +166,8 @@ class PolicyExecutorTest { new PipelineStep( createPdf, Map.of( - "htmlContent", - "

    hi

    ", + "document", + "{\"title\":\"PO\",\"sections\":[]}", "filename", "purchase-order.pdf"))), PolicyInputs.of(List.of()), diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunRegistryTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunRegistryTest.java index 1549ba62ac..0d1361c5d9 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunRegistryTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunRegistryTest.java @@ -95,7 +95,8 @@ class PolicyRunRegistryTest { } private PolicyRun register(String runId) { - PolicyRun run = new PolicyRun(runId, null, new PipelineDefinition(runId, List.of(), null)); + PolicyRun run = + new PolicyRun(runId, null, new PipelineDefinition(runId, List.of(), List.of())); registry.register(run); return run; } 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 8cdb1b45a3..2e9613a024 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 @@ -35,6 +35,7 @@ class PolicyValidatorTest { @Mock private PolicyTrigger trigger; @Mock private InputSource inputSource; @Mock private PolicyOutputSink outputSink; + @Mock private PipelineStepValidator stepValidator; private final SourceStore sourceStore = new InProcessSourceStore(); private PolicyValidator validator; @@ -43,7 +44,11 @@ class PolicyValidatorTest { void setUp() { validator = new PolicyValidator( - List.of(trigger), List.of(inputSource), List.of(outputSink), sourceStore); + List.of(trigger), + List.of(inputSource), + List.of(outputSink), + List.of(stepValidator), + sourceStore); } @Test @@ -82,6 +87,28 @@ class PolicyValidatorTest { assertTrue(ex.getMessage().contains("schedule")); } + @Test + void validateOutputDelegatesToTheSink() { + when(outputSink.supports(any())).thenReturn(true); + OutputSpec output = new OutputSpec("s3", Map.of("connectionId", 1)); + + validator.validateOutput(output); + + verify(outputSink).validate(output); + } + + @Test + void validateOutputSurfacesAnInaccessibleConnection() { + when(outputSink.supports(any())).thenReturn(true); + doThrow(new IllegalArgumentException("unknown or inaccessible s3 connection")) + .when(outputSink) + .validate(any()); + + assertThrows( + IllegalArgumentException.class, + () -> validator.validateOutput(new OutputSpec("s3", Map.of("connectionId", 1)))); + } + @Test void rejectsAnUnknownTriggerType() { when(trigger.type()).thenReturn("schedule"); 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 64178ce9bb..bcb968ca8d 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 @@ -27,6 +27,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.core.env.StandardEnvironment; +import stirling.software.common.configuration.RuntimePathConfig; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.util.FileReadinessChecker; import stirling.software.proprietary.policy.config.FolderAccessGuard; @@ -57,7 +58,10 @@ class FolderInputSourceTest { properties.getPolicies().setAllowedFolderRoots(List.of(tempDir.toString())); FolderAccessGuard guard = new FolderAccessGuard( - properties, new StandardEnvironment(), new InProcessSourceStore()); + properties, + new RuntimePathConfig(properties), + new StandardEnvironment(), + new InProcessSourceStore()); source = new FolderInputSource(readinessChecker, guard); ledger = new InProcessProcessedLedger(); ctx = new RecordingContext(); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/S3InputSourceMinioTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/S3InputSourceMinioTest.java index 4e1e1fc305..47de8f6092 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/S3InputSourceMinioTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/S3InputSourceMinioTest.java @@ -23,6 +23,7 @@ import stirling.software.common.model.ApplicationProperties; import stirling.software.proprietary.policy.ledger.InProcessProcessedLedger; import stirling.software.proprietary.policy.model.InputSpec; import stirling.software.proprietary.policy.s3.S3ConnectionPool; +import stirling.software.proprietary.policy.s3.S3TestConnections; import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; @@ -82,7 +83,9 @@ class S3InputSourceMinioTest { // The MinIO endpoint resolves to loopback, so the operator opt-in must be on. ApplicationProperties properties = new ApplicationProperties(); properties.getPolicies().setAllowPrivateS3Endpoints(true); - source = new S3InputSource(new S3ConnectionPool(properties)); + source = + new S3InputSource( + new S3ConnectionPool(properties), S3TestConnections.legacyResolver()); ledger = new InProcessProcessedLedger(); ctx = new RecordingContext(); } @@ -161,7 +164,9 @@ class S3InputSourceMinioTest { @Test void aPrivateEndpointIsRejectedWithoutTheOperatorOptIn() { S3InputSource guarded = - new S3InputSource(new S3ConnectionPool(new ApplicationProperties())); + new S3InputSource( + new S3ConnectionPool(new ApplicationProperties()), + S3TestConnections.legacyResolver()); assertThatThrownBy(() -> guarded.validate(spec(Map.of()))) .isInstanceOf(IllegalArgumentException.class) diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/S3InputSourceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/S3InputSourceTest.java index 73995248dd..9054b4f304 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/S3InputSourceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/S3InputSourceTest.java @@ -29,6 +29,7 @@ import stirling.software.common.model.ApplicationProperties; import stirling.software.proprietary.policy.ledger.InProcessProcessedLedger; import stirling.software.proprietary.policy.model.InputSpec; import stirling.software.proprietary.policy.s3.S3ConnectionPool; +import stirling.software.proprietary.policy.s3.S3TestConnections; import software.amazon.awssdk.core.ResponseInputStream; import software.amazon.awssdk.core.exception.SdkClientException; @@ -64,7 +65,8 @@ class S3InputSourceTest { void setUp() { source = new S3InputSource( - new S3ConnectionPool(new ApplicationProperties(), config -> s3Client)); + new S3ConnectionPool(new ApplicationProperties(), config -> s3Client), + S3TestConnections.legacyResolver()); ledger = new InProcessProcessedLedger(); ctx = new RecordingContext(); } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/WebhookInputSourceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/WebhookInputSourceTest.java new file mode 100644 index 0000000000..9255e82d19 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/WebhookInputSourceTest.java @@ -0,0 +1,165 @@ +package stirling.software.proprietary.policy.input; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +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.lenient; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import stirling.software.common.util.FileReadinessChecker; +import stirling.software.proprietary.policy.ledger.InProcessProcessedLedger; +import stirling.software.proprietary.policy.model.InputSpec; +import stirling.software.proprietary.policy.webhook.WebhookConfig; +import stirling.software.proprietary.policy.webhook.WebhookSpool; + +@ExtendWith(MockitoExtension.class) +class WebhookInputSourceTest { + + private static final String POLICY = "p1"; + private static final String WEBHOOK_ID = "testwebhookid1234"; + + @Mock private FileReadinessChecker readinessChecker; + + @TempDir Path tempDir; + + private WebhookSpool spool; + private WebhookInputSource source; + private InProcessProcessedLedger ledger; + private RecordingContext ctx; + + @BeforeEach + void setUp() { + spool = new WebhookSpool(tempDir.resolve("spool")); + source = new WebhookInputSource(spool, readinessChecker); + ledger = new InProcessProcessedLedger(); + ctx = new RecordingContext(); + lenient().when(readinessChecker.isReady(any())).thenReturn(true); + } + + private static InputSpec spec(String mode) { + return new InputSpec( + "webhook", + Map.of("webhookId", WEBHOOK_ID, "signingSecret", "secret", "mode", mode)); + } + + @Test + void consumeRemovesTheDeliveryOnceProcessed() throws IOException { + Path delivered = spool.store(WEBHOOK_ID, "doc.pdf", "data".getBytes()); + + List work = source.resolve(spec("consume"), ctx); + + assertEquals(1, work.size()); + assertEquals("doc.pdf", work.get(0).inputs().primary().get(0).getFilename()); + assertTrue(Files.exists(delivered)); + assertTrue(source.resolve(spec("consume"), ctx).isEmpty()); + + work.get(0).onComplete().accept(true); + assertTrue(Files.notExists(delivered)); + assertTrue(source.resolve(spec("consume"), ctx).isEmpty()); + } + + @Test + void aFailedRunLeavesTheDeliveryInPlace() throws IOException { + Path delivered = spool.store(WEBHOOK_ID, "doc.pdf", "data".getBytes()); + + List work = source.resolve(spec("consume"), ctx); + work.get(0).onComplete().accept(false); + + assertTrue(Files.exists(delivered)); + } + + @Test + void nothingDeliveredIsAnEmptySourceNotAnError() throws IOException { + List work = source.resolve(spec("consume"), ctx); + assertTrue(work.isEmpty()); + assertTrue(ctx.present.isEmpty()); + } + + @Test + void validateRejectsMissingIdOrSecret() { + assertThrows( + IllegalArgumentException.class, + () -> source.validate(new InputSpec("webhook", Map.of("signingSecret", "s")))); + assertThrows( + IllegalArgumentException.class, + () -> source.validate(new InputSpec("webhook", Map.of("webhookId", WEBHOOK_ID)))); + } + + @Test + void prepareMintsIdAndSecretOnCreate() { + Map prepared = + source.prepareOptionsForSave(Map.of("mode", "consume"), true); + + String id = prepared.get(WebhookConfig.WEBHOOK_ID_OPTION).toString(); + String secret = prepared.get(WebhookConfig.SIGNING_SECRET_OPTION).toString(); + assertFalse(id.isBlank()); + assertFalse(secret.isBlank()); + assertEquals("consume", prepared.get("mode")); + Map other = source.prepareOptionsForSave(Map.of(), true); + assertNotEquals(id, other.get(WebhookConfig.WEBHOOK_ID_OPTION).toString()); + } + + @Test + void prepareLeavesAnExistingWebhookUntouchedOnEdit() { + Map existing = Map.of("webhookId", WEBHOOK_ID, "signingSecret", "keepme"); + + Map prepared = source.prepareOptionsForSave(existing, false); + + assertEquals(WEBHOOK_ID, prepared.get("webhookId")); + assertEquals("keepme", prepared.get("signingSecret")); + } + + @Test + void prepareIgnoresClientSuppliedIdAndSecretOnCreate() { + Map prepared = + source.prepareOptionsForSave( + Map.of("webhookId", "client-chosen-id", "signingSecret", "weak"), true); + + assertNotEquals("client-chosen-id", prepared.get(WebhookConfig.WEBHOOK_ID_OPTION)); + assertNotEquals("weak", prepared.get(WebhookConfig.SIGNING_SECRET_OPTION)); + } + + private class RecordingContext implements ResolveContext { + + private final List present = new ArrayList<>(); + + @Override + public boolean claim(String identity, String gate, Supplier contentHash) { + return ledger.claim(POLICY, identity, gate, contentHash); + } + + @Override + public void settle( + String identity, String finalGate, String finalContentHash, boolean success) { + ledger.settle(POLICY, identity, finalGate, finalContentHash, success); + } + + @Override + public boolean allSettledDone(String identity) { + return ledger.allSettledDone(identity); + } + + @Override + public void reportPresent(Collection identities) { + present.addAll(identities); + } + } +} 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 f9e1c92403..7f9acf4fae 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 @@ -19,6 +19,7 @@ import org.springframework.core.env.StandardEnvironment; import org.springframework.core.io.ByteArrayResource; import org.springframework.core.io.Resource; +import stirling.software.common.configuration.RuntimePathConfig; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.job.ResultFile; import stirling.software.proprietary.policy.config.FolderAccessGuard; @@ -49,7 +50,10 @@ class FolderOutputSinkTest { sink = new FolderOutputSink( new FolderAccessGuard( - properties, new StandardEnvironment(), new InProcessSourceStore()), + properties, + new RuntimePathConfig(properties), + new StandardEnvironment(), + new InProcessSourceStore()), ledger); } @@ -113,7 +117,10 @@ class FolderOutputSinkTest { FolderOutputSink orderedSink = new FolderOutputSink( new FolderAccessGuard( - properties, new StandardEnvironment(), new InProcessSourceStore()), + properties, + new RuntimePathConfig(properties), + new StandardEnvironment(), + new InProcessSourceStore()), orderedLedger); orderedSink.deliver( diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/PolicyInlineOutputMigrationTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/PolicyInlineOutputMigrationTest.java new file mode 100644 index 0000000000..082ca8cd42 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/PolicyInlineOutputMigrationTest.java @@ -0,0 +1,149 @@ +package stirling.software.proprietary.policy.output; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import stirling.software.proprietary.policy.migration.InProcessCompletedMigrations; +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.source.InProcessSourceStore; +import stirling.software.proprietary.policy.source.Source; +import stirling.software.proprietary.policy.source.SourceStore; +import stirling.software.proprietary.policy.store.InProcessPolicyStore; +import stirling.software.proprietary.policy.store.PolicyStore; + +/** + * Tests for {@link PolicyInlineOutputMigration}: policies carrying a folder/S3 destination inline + * are rewritten to reference a {@link Source} location by id; inline (return-to-caller) policies + * are left untouched; the pass is idempotent; two policies sharing a destination in one team share + * one source; and an output at a location an input source already covers reuses that source. + */ +class PolicyInlineOutputMigrationTest { + + private final PolicyStore policyStore = new InProcessPolicyStore(); + private final SourceStore sourceStore = new InProcessSourceStore(); + private final PolicyInlineOutputMigration migration = + new PolicyInlineOutputMigration( + policyStore, sourceStore, new InProcessCompletedMigrations()); + + @Test + void migratesAFolderPolicyToAStoredSource() { + Policy saved = policyStore.save(folderPolicy("Archive", "/out")); + + migration.migrate(); + + Policy migrated = policyStore.get(saved.id()).orElseThrow(); + assertEquals(1, migrated.outputIds().size()); + Source destination = sourceStore.get(migrated.outputIds().get(0)).orElseThrow(); + assertEquals("folder", destination.type()); + assertEquals("/out", destination.options().get("directory")); + } + + @Test + void leavesInlinePoliciesUntouched() { + Policy saved = + policyStore.save( + new Policy( + null, + "Editor run", + "owner", + true, + null, + List.of(), + List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), + OutputSpec.inline())); + + migration.migrate(); + + assertTrue(policyStore.get(saved.id()).orElseThrow().outputIds().isEmpty()); + assertTrue(sourceStore.all().isEmpty()); + } + + @Test + void isIdempotent() { + policyStore.save(folderPolicy("Archive", "/out")); + + migration.migrate(); + int afterFirst = sourceStore.all().size(); + migration.migrate(); + + assertEquals(afterFirst, sourceStore.all().size()); + } + + @Test + void skipsTheScanOnceComplete() { + // First pass records the completion marker (even with nothing to migrate). + migration.migrate(); + + // A migratable folder policy created afterwards is left untouched: the marker means the + // migration never scans again, rather than re-scanning and finding it every boot. + Policy later = policyStore.save(folderPolicy("Late", "/late")); + migration.migrate(); + + assertTrue(policyStore.get(later.id()).orElseThrow().outputIds().isEmpty()); + assertTrue(sourceStore.all().isEmpty()); + } + + @Test + void dedupesASharedDestinationWithinATeam() { + policyStore.save(teamFolderPolicy("A", "/shared", 7L)); + policyStore.save(teamFolderPolicy("B", "/shared", 7L)); + + migration.migrate(); + + assertEquals(1, sourceStore.all().size()); + } + + @Test + void reusesAnExistingInputSourceAtTheSameLocation() { + // An input source already reads /shared (with consume mode); a policy that outputs there + // should link to that same source, not mint a duplicate. + Source existing = + sourceStore.save( + new Source( + null, + "Shared", + "folder", + Map.of("directory", "/shared", "mode", "consume"), + true, + "owner", + 7L)); + Policy saved = policyStore.save(teamFolderPolicy("Writer", "/shared", 7L)); + + migration.migrate(); + + assertEquals(1, sourceStore.all().size()); + assertEquals(List.of(existing.id()), policyStore.get(saved.id()).orElseThrow().outputIds()); + } + + private static Policy folderPolicy(String name, String directory) { + return new Policy( + null, + name, + "owner", + true, + null, + List.of(), + List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), + OutputSpec.folder(directory)); + } + + private static Policy teamFolderPolicy(String name, String directory, Long teamId) { + return new Policy( + null, + name, + "owner", + true, + null, + List.of(), + List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), + OutputSpec.folder(directory), + teamId); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/PolicyOutputResolverTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/PolicyOutputResolverTest.java new file mode 100644 index 0000000000..d65c5b24f2 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/PolicyOutputResolverTest.java @@ -0,0 +1,73 @@ +package stirling.software.proprietary.policy.output; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +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.source.InProcessSourceStore; +import stirling.software.proprietary.policy.source.Source; +import stirling.software.proprietary.policy.source.SourceStore; + +/** + * Tests for {@link PolicyOutputResolver}: a policy's {@code outputIds} resolve live to the stored + * sources used as destinations (one spec each), an unreferenced policy keeps its inline output, and + * a dangling reference falls back to inline delivery rather than failing the run. + */ +class PolicyOutputResolverTest { + + private final SourceStore sourceStore = new InProcessSourceStore(); + private final PolicyOutputResolver resolver = new PolicyOutputResolver(sourceStore); + + @Test + void resolvesEachOutputIdToItsStoredSource() { + Source archive = sourceStore.save(folder("Archive", "/out")); + Source backup = sourceStore.save(folder("Backup", "/backup")); + + List specs = + resolver.resolve(policy().withOutputIds(List.of(archive.id(), backup.id()))); + + assertEquals(2, specs.size()); + assertEquals("/out", specs.get(0).options().get("directory")); + assertEquals("/backup", specs.get(1).options().get("directory")); + } + + @Test + void anUnreferencedPolicyKeepsItsInlineOutput() { + List specs = resolver.resolve(policy()); + + assertEquals(1, specs.size()); + assertEquals("inline", specs.get(0).type()); + } + + @Test + void whenNoReferencesResolveItFallsBackToInline() { + List specs = + resolver.resolve(policy().withOutputIds(List.of("does-not-exist"))); + + assertEquals(1, specs.size()); + assertEquals("inline", specs.get(0).type()); + } + + private static Source folder(String name, String directory) { + return new Source( + null, name, "folder", Map.of("directory", directory), true, "owner", null); + } + + private static Policy policy() { + return new Policy( + "p1", + "Pipeline", + "owner", + true, + null, + List.of(), + 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/output/S3OutputSinkMinioTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/S3OutputSinkMinioTest.java index a2a5a41ca0..153e6e1508 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/S3OutputSinkMinioTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/S3OutputSinkMinioTest.java @@ -28,6 +28,7 @@ import stirling.software.proprietary.policy.ledger.InProcessProcessedLedger; import stirling.software.proprietary.policy.model.InputSpec; import stirling.software.proprietary.policy.model.OutputSpec; import stirling.software.proprietary.policy.s3.S3ConnectionPool; +import stirling.software.proprietary.policy.s3.S3TestConnections; import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; @@ -90,8 +91,8 @@ class S3OutputSinkMinioTest { properties.getPolicies().setAllowPrivateS3Endpoints(true); S3ConnectionPool pool = new S3ConnectionPool(properties); ledger = new InProcessProcessedLedger(); - sink = new S3OutputSink(pool, ledger); - source = new S3InputSource(pool); + sink = new S3OutputSink(pool, S3TestConnections.legacyResolver(), ledger); + source = new S3InputSource(pool, S3TestConnections.legacyResolver()); } @Test diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/S3OutputSinkTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/S3OutputSinkTest.java index 8240bcc4e1..a5a3327c51 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/S3OutputSinkTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/S3OutputSinkTest.java @@ -32,6 +32,7 @@ import stirling.software.proprietary.policy.ledger.InProcessProcessedLedger; import stirling.software.proprietary.policy.ledger.ProcessedFileStatus; import stirling.software.proprietary.policy.model.OutputSpec; import stirling.software.proprietary.policy.s3.S3ConnectionPool; +import stirling.software.proprietary.policy.s3.S3TestConnections; import software.amazon.awssdk.awscore.exception.AwsServiceException; import software.amazon.awssdk.core.exception.SdkClientException; @@ -66,6 +67,7 @@ class S3OutputSinkTest { sink = new S3OutputSink( new S3ConnectionPool(new ApplicationProperties(), config -> s3Client), + S3TestConnections.legacyResolver(), ledger); } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/EmbeddedS3CredentialMigrationTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/EmbeddedS3CredentialMigrationTest.java new file mode 100644 index 0000000000..a794ee104e --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/EmbeddedS3CredentialMigrationTest.java @@ -0,0 +1,217 @@ +package stirling.software.proprietary.policy.s3; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.times; +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 java.util.concurrent.atomic.AtomicLong; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import stirling.software.proprietary.access.model.OwnerScope; +import stirling.software.proprietary.integration.model.IntegrationConfig; +import stirling.software.proprietary.integration.repository.IntegrationConfigRepository; +import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.policy.migration.InProcessCompletedMigrations; +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.source.InProcessSourceStore; +import stirling.software.proprietary.policy.source.Source; +import stirling.software.proprietary.policy.store.InProcessPolicyStore; +import stirling.software.proprietary.security.repository.TeamRepository; + +/** + * Tests for {@link EmbeddedS3CredentialMigration}: legacy embedded credentials become deduplicated + * team-scoped connections, rewritten rows keep only per-use options, and re-runs are no-ops. + */ +@ExtendWith(MockitoExtension.class) +class EmbeddedS3CredentialMigrationTest { + + @Mock private IntegrationConfigRepository connections; + @Mock private TeamRepository teamRepository; + + private final InProcessSourceStore sourceStore = new InProcessSourceStore(); + private final InProcessPolicyStore policyStore = new InProcessPolicyStore(); + private EmbeddedS3CredentialMigration migration; + + @BeforeEach + void setUp() { + migration = + new EmbeddedS3CredentialMigration( + sourceStore, + policyStore, + connections, + teamRepository, + new InProcessCompletedMigrations()); + AtomicLong ids = new AtomicLong(100); + // Lenient: the nothing-to-migrate cases never create a connection. + lenient().when(connections.findAll()).thenReturn(List.of()); + lenient() + .when(connections.save(any())) + .thenAnswer( + invocation -> { + IntegrationConfig saved = invocation.getArgument(0); + if (saved.getId() == null) { + saved.setId(ids.incrementAndGet()); + } + return saved; + }); + } + + @Test + void extractsSharedCredentialsIntoOneTeamScopedConnection() { + Team team = new Team(); + team.setId(7L); + when(teamRepository.findById(7L)).thenReturn(Optional.of(team)); + Source source = + sourceStore.save( + new Source( + null, + "Claims intake", + "s3", + Map.of( + "bucket", "inbox", + "prefix", "incoming/", + "mode", "snapshot", + "accessKeyId", "AKIAEXAMPLE", + "secretAccessKey", "shh"), + true, + "alice", + 7L)); + Policy policy = + policyStore.save( + new Policy( + null, + "Rotate", + "alice", + true, + null, + List.of(source.id()), + List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), + new OutputSpec( + "s3", + Map.of( + "bucket", "inbox", + "prefix", "processed/", + "accessKeyId", "AKIAEXAMPLE", + "secretAccessKey", "shh")), + 7L)); + + migration.migrate(); + + // Same bucket + credentials on both rows: exactly one connection extracted. + verify(connections, times(1)).save(any()); + Map sourceOptions = sourceStore.get(source.id()).orElseThrow().options(); + assertEquals(101L, sourceOptions.get("connectionId")); + assertEquals("incoming/", sourceOptions.get("prefix")); + assertEquals("snapshot", sourceOptions.get("mode")); + assertNull(sourceOptions.get("accessKeyId")); + assertNull(sourceOptions.get("secretAccessKey")); + assertNull(sourceOptions.get("bucket")); + + Map outputOptions = + policyStore.get(policy.id()).orElseThrow().output().options(); + assertEquals(101L, outputOptions.get("connectionId")); + assertEquals("processed/", outputOptions.get("prefix")); + assertNull(outputOptions.get("secretAccessKey")); + } + + @Test + void connectionOwnershipFollowsTheSourceTeam() { + Team team = new Team(); + team.setId(7L); + when(teamRepository.findById(7L)).thenReturn(Optional.of(team)); + sourceStore.save(s3Source("teamed", 7L)); + + migration.migrate(); + + verify(connections) + .save( + org.mockito.ArgumentMatchers.argThat( + connection -> + connection.getScope() == OwnerScope.TEAM + && connection.getOwnerTeam() == team)); + } + + @Test + void teamlessRowsBecomeServerScopedConnections() { + sourceStore.save(s3Source("solo", null)); + + migration.migrate(); + + verify(connections) + .save( + org.mockito.ArgumentMatchers.argThat( + connection -> connection.getScope() == OwnerScope.SERVER)); + } + + @Test + void aSecondRunFindsNothingToDo() { + sourceStore.save(s3Source("once", null)); + + migration.migrate(); + migration.migrate(); + + // One connection from the first run; the rewritten source no longer embeds credentials. + verify(connections, times(1)).save(any()); + } + + @Test + void nonS3AndAlreadyMigratedRowsAreUntouched() { + Source folder = + sourceStore.save( + new Source( + null, + "Folder", + "folder", + Map.of("directory", "/in"), + true, + "alice", + null)); + Source migrated = + sourceStore.save( + new Source( + null, + "Done already", + "s3", + Map.of("connectionId", 55L, "prefix", "in/"), + true, + "alice", + null)); + + migration.migrate(); + + verify(connections, times(0)).save(any()); + assertEquals( + Map.of("directory", "/in"), sourceStore.get(folder.id()).orElseThrow().options()); + assertEquals( + Map.of("connectionId", 55L, "prefix", "in/"), + sourceStore.get(migrated.id()).orElseThrow().options()); + } + + private static Source s3Source(String name, Long teamId) { + return new Source( + null, + name, + "s3", + Map.of( + "bucket", "inbox", + "accessKeyId", "AKIAEXAMPLE", + "secretAccessKey", "shh"), + true, + "alice", + teamId); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/PolicyS3ConnectionUsageCheckTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/PolicyS3ConnectionUsageCheckTest.java new file mode 100644 index 0000000000..847a553ba6 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/PolicyS3ConnectionUsageCheckTest.java @@ -0,0 +1,52 @@ +package stirling.software.proprietary.policy.s3; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +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.source.InProcessSourceStore; +import stirling.software.proprietary.policy.source.Source; +import stirling.software.proprietary.policy.store.InProcessPolicyStore; + +/** Tests for {@link PolicyS3ConnectionUsageCheck}'s reference scan across sources and outputs. */ +class PolicyS3ConnectionUsageCheckTest { + + private final InProcessSourceStore sourceStore = new InProcessSourceStore(); + private final InProcessPolicyStore policyStore = new InProcessPolicyStore(); + private final PolicyS3ConnectionUsageCheck check = + new PolicyS3ConnectionUsageCheck(sourceStore, policyStore); + + @Test + void reportsSourcesAndOutputsReferencingTheConnection() { + sourceStore.save( + new Source( + null, + "Claims intake", + "s3", + Map.of("connectionId", 5L, "prefix", "in/"), + true, + "alice", + null)); + policyStore.save( + new Policy( + null, + "Rotate", + "alice", + true, + null, + List.of(), + List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), + new OutputSpec("s3", Map.of("connectionId", "5")), + null)); + + assertThat(check.usagesOf(5)) + .containsExactlyInAnyOrder("source 'Claims intake'", "pipeline 'Rotate'"); + assertThat(check.usagesOf(6)).isEmpty(); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/S3ConnectionResolverTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/S3ConnectionResolverTest.java new file mode 100644 index 0000000000..6a480842c9 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/S3ConnectionResolverTest.java @@ -0,0 +1,149 @@ +package stirling.software.proprietary.policy.s3; + +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.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import java.util.Map; +import java.util.Optional; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import stirling.software.proprietary.access.service.OwnershipService; +import stirling.software.proprietary.integration.model.IntegrationConfig; +import stirling.software.proprietary.integration.model.IntegrationType; +import stirling.software.proprietary.integration.repository.IntegrationConfigRepository; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.service.UserService; + +/** + * Tests for {@link S3ConnectionResolver}: connection dereferencing with per-use overrides, the + * legacy embedded fallback, and the save-time access check that background sweeps skip. + */ +@ExtendWith(MockitoExtension.class) +class S3ConnectionResolverTest { + + @Mock private IntegrationConfigRepository connections; + @Mock private OwnershipService ownership; + @Mock private UserService userService; + + @AfterEach + void clearSecurityContext() { + SecurityContextHolder.clearContext(); + } + + @Test + void resolvesAConnectionAndMergesPerUseOptions() { + when(connections.findById(9L)).thenReturn(Optional.of(s3Connection(9L, true))); + + S3Config config = + resolver() + .resolve( + Map.of( + "connectionId", 9L, + "prefix", "incoming/", + "mode", "snapshot")); + + assertEquals("inbox", config.bucket()); + assertEquals("AKIAEXAMPLE", config.accessKeyId()); + assertEquals("incoming/", config.prefix()); + assertTrue(config.snapshot()); + } + + @Test + void acceptsAStringConnectionReference() { + when(connections.findById(9L)).thenReturn(Optional.of(s3Connection(9L, true))); + + assertEquals("inbox", resolver().resolve(Map.of("connectionId", "9")).bucket()); + } + + @Test + void fallsBackToLegacyEmbeddedCredentials() { + S3Config config = + resolver() + .resolve( + Map.of( + "bucket", "legacy", + "accessKeyId", "AKIAEXAMPLE", + "secretAccessKey", "shh")); + + assertEquals("legacy", config.bucket()); + } + + @Test + void rejectsUnknownDisabledOrWrongTypeConnections() { + when(connections.findById(1L)).thenReturn(Optional.empty()); + assertThrows( + IllegalArgumentException.class, + () -> resolver().resolve(Map.of("connectionId", 1L))); + + when(connections.findById(2L)).thenReturn(Optional.of(s3Connection(2L, false))); + assertThrows( + IllegalArgumentException.class, + () -> resolver().resolve(Map.of("connectionId", 2L))); + + IntegrationConfig mcp = s3Connection(3L, true); + mcp.setIntegrationType(IntegrationType.MCP); + when(connections.findById(3L)).thenReturn(Optional.of(mcp)); + assertThrows( + IllegalArgumentException.class, + () -> resolver().resolve(Map.of("connectionId", 3L))); + } + + @Test + void anAuthenticatedSaverMustBeAllowedToUseTheConnection() { + when(connections.findById(9L)).thenReturn(Optional.of(s3Connection(9L, true))); + User saver = new User(); + saver.setUsername("alice"); + SecurityContextHolder.getContext() + .setAuthentication( + new UsernamePasswordAuthenticationToken(saver, null, java.util.List.of())); + when(ownership.canUse(any(), any(IntegrationConfig.class), eq(saver))).thenReturn(false); + + // Denied reads the same as unknown and never echoes the connection name, so ids can't be + // enumerated by probing. + assertThrows( + IllegalArgumentException.class, + () -> resolver().resolve(Map.of("connectionId", 9L))); + try { + resolver().resolve(Map.of("connectionId", 9L)); + } catch (IllegalArgumentException e) { + org.junit.jupiter.api.Assertions.assertFalse( + e.getMessage().contains("Claims bucket"), + "access-denied error must not leak the connection name"); + } + } + + @Test + void backgroundSweepsWithNoUserSkipTheAccessCheck() { + when(connections.findById(9L)).thenReturn(Optional.of(s3Connection(9L, true))); + + // No authentication in the context: resolution succeeds without consulting ownership. + assertEquals("inbox", resolver().resolve(Map.of("connectionId", 9L)).bucket()); + } + + private S3ConnectionResolver resolver() { + return new S3ConnectionResolver(connections, ownership, userService); + } + + private static IntegrationConfig s3Connection(long id, boolean enabled) { + IntegrationConfig connection = new IntegrationConfig(); + connection.setId(id); + connection.setIntegrationType(IntegrationType.S3); + connection.setName("Claims bucket"); + connection.setEnabled(enabled); + connection.setConfig( + "{\"bucket\":\"inbox\",\"accessKeyId\":\"AKIAEXAMPLE\"," + + "\"secretAccessKey\":\"shh\"}"); + return connection; + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/S3IntegrationValidatorTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/S3IntegrationValidatorTest.java new file mode 100644 index 0000000000..4c07968807 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/S3IntegrationValidatorTest.java @@ -0,0 +1,71 @@ +package stirling.software.proprietary.policy.s3; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.integration.model.IntegrationType; + +/** + * Tests for {@link S3IntegrationValidator}: the S3 connection schema fails at save time - missing + * credentials, bad endpoints, and private endpoints without the operator opt-in. + */ +class S3IntegrationValidatorTest { + + @Test + void acceptsACompleteConnection() { + assertThatCode( + () -> + validator(false) + .validate( + Map.of( + "bucket", "inbox", + "accessKeyId", "AKIAEXAMPLE", + "secretAccessKey", "shh"))) + .doesNotThrowAnyException(); + } + + @Test + void rejectsMissingCredentialsOrBucket() { + assertThatThrownBy(() -> validator(false).validate(Map.of("bucket", "inbox"))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy( + () -> + validator(false) + .validate( + Map.of( + "accessKeyId", "AKIAEXAMPLE", + "secretAccessKey", "shh"))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void rejectsAPrivateEndpointWithoutTheOperatorOptIn() { + Map config = + Map.of( + "bucket", "inbox", + "accessKeyId", "AKIAEXAMPLE", + "secretAccessKey", "shh", + "endpoint", "http://localhost:9000"); + + assertThatThrownBy(() -> validator(false).validate(config)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("allowPrivateS3Endpoints"); + assertThatCode(() -> validator(true).validate(config)).doesNotThrowAnyException(); + } + + @Test + void itOnlyClaimsTheS3Type() { + org.junit.jupiter.api.Assertions.assertEquals(IntegrationType.S3, validator(false).type()); + } + + private static S3IntegrationValidator validator(boolean allowPrivateEndpoints) { + ApplicationProperties properties = new ApplicationProperties(); + properties.getPolicies().setAllowPrivateS3Endpoints(allowPrivateEndpoints); + return new S3IntegrationValidator(properties); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/S3TestConnections.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/S3TestConnections.java new file mode 100644 index 0000000000..7649831d2a --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/S3TestConnections.java @@ -0,0 +1,24 @@ +package stirling.software.proprietary.policy.s3; + +import static org.mockito.Mockito.mock; + +import stirling.software.proprietary.access.service.OwnershipService; +import stirling.software.proprietary.integration.repository.IntegrationConfigRepository; +import stirling.software.proprietary.security.service.UserService; + +/** Test fixtures for S3 connection plumbing shared across the policy S3 tests. */ +public final class S3TestConnections { + + private S3TestConnections() {} + + /** + * A resolver for tests whose options embed credentials directly (the legacy pass-through path), + * so its collaborators are never touched. + */ + public static S3ConnectionResolver legacyResolver() { + return new S3ConnectionResolver( + mock(IntegrationConfigRepository.class), + mock(OwnershipService.class), + mock(UserService.class)); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeederTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeederTest.java new file mode 100644 index 0000000000..2f270afddb --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeederTest.java @@ -0,0 +1,118 @@ +package stirling.software.proprietary.policy.seed; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +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 java.util.Optional; + +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 stirling.software.proprietary.model.Team; +import stirling.software.proprietary.model.TeamCreatedEvent; +import stirling.software.proprietary.policy.model.OutputSpec; +import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.store.PolicyStore; +import stirling.software.proprietary.security.repository.TeamRepository; +import stirling.software.proprietary.security.service.TeamService; + +@ExtendWith(MockitoExtension.class) +class DefaultClassificationPolicySeederTest { + + @Mock private PolicyStore policyStore; + @Mock private TeamRepository teamRepository; + + private DefaultClassificationPolicySeeder seeder() { + return new DefaultClassificationPolicySeeder(policyStore, teamRepository); + } + + private static Policy classificationPolicy(Long teamId) { + return new Policy( + "p1", + "Classification Policy", + "system", + true, + null, + List.of(), + List.of(), + new OutputSpec("inline", Map.of("categoryId", "classification")), + teamId); + } + + @Test + void seedsAnEnabledClassificationPolicyWhenTheTeamHasNone() { + when(policyStore.findByTeam(7L)).thenReturn(List.of()); + + seeder().onTeamCreated(new TeamCreatedEvent(7L, "Acme")); + + ArgumentCaptor saved = ArgumentCaptor.forClass(Policy.class); + verify(policyStore).save(saved.capture()); + Policy policy = saved.getValue(); + assertThat(policy.enabled()).isTrue(); + assertThat(policy.teamId()).isEqualTo(7L); + assertThat(policy.output().type()).isEqualTo("inline"); + assertThat(policy.output().options().get("categoryId")).isEqualTo("classification"); + assertThat(policy.output().options().get("runOn")).isEqualTo("upload"); + assertThat(policy.output().options().get("mode")).isEqualTo("new_version"); + assertThat(policy.output().options().get("sources")).isEqualTo(List.of("editor")); + assertThat(policy.steps()).hasSize(1); + assertThat(policy.steps().get(0).operation()) + .isEqualTo("/api/v1/ai/tools/classify-and-label"); + } + + @Test + void doesNotSeedWhenAClassificationPolicyAlreadyExists() { + when(policyStore.findByTeam(7L)).thenReturn(List.of(classificationPolicy(7L))); + + seeder().onTeamCreated(new TeamCreatedEvent(7L, "Acme")); + + verify(policyStore, never()).save(any()); + } + + @Test + void doesNotSeedForTheInternalTeam() { + seeder().onTeamCreated(new TeamCreatedEvent(2L, "Internal")); + + verify(policyStore, never()).findByTeam(anyLong()); + verify(policyStore, never()).save(any()); + } + + @Test + void doesNotSeedWhenTeamIdIsNull() { + seeder().onTeamCreated(new TeamCreatedEvent(null, "Acme")); + + verify(policyStore, never()).save(any()); + } + + @Test + void seedsTheDefaultTeamOnStartupWhenItExistsAndHasNoPolicy() { + Team defaultTeam = new Team(); + defaultTeam.setId(1L); + defaultTeam.setName(TeamService.DEFAULT_TEAM_NAME); + when(teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME)) + .thenReturn(Optional.of(defaultTeam)); + when(policyStore.findByTeam(1L)).thenReturn(List.of()); + + seeder().seedDefaultTeamOnStartup(); + + verify(policyStore).save(any()); + } + + @Test + void doesNotSeedOnStartupWhenThereIsNoDefaultTeam() { + when(teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME)).thenReturn(Optional.empty()); + + seeder().seedDefaultTeamOnStartup(); + + verify(policyStore, never()).save(any()); + } +} 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 index 295b4785b8..545ea8e50f 100644 --- 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 @@ -1,33 +1,41 @@ 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.assertNotEquals; 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.ArgumentMatchers.anyBoolean; 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.nio.file.Path; import java.util.List; import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; 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.common.util.FileReadinessChecker; 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.input.WebhookInputSource; 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; +import stirling.software.proprietary.policy.webhook.WebhookSpool; import stirling.software.proprietary.util.SecretMasker; /** @@ -41,6 +49,9 @@ class SourceControllerTest { private final PolicyStore policyStore = new InProcessPolicyStore(); private PolicyTriggerManager triggerManager; private SourceController controller; + private SourceController webhookController; + + @TempDir Path tempDir; @BeforeEach void setUp() { @@ -61,6 +72,8 @@ class SourceControllerTest { // A permissive input source so config validation passes and save can be exercised. InputSource folderInput = mock(InputSource.class); when(folderInput.supports(any())).thenReturn(true); + when(folderInput.prepareOptionsForSave(any(), anyBoolean())) + .thenAnswer(invocation -> invocation.getArgument(0)); controller = new SourceController( sourceStore, @@ -72,6 +85,45 @@ class SourceControllerTest { triggerManager, properties, List.of(folderInput)); + WebhookInputSource webhookInput = + new WebhookInputSource(new WebhookSpool(tempDir), mock(FileReadinessChecker.class)); + webhookController = + new SourceController( + sourceStore, + sourceGuard, + overviewService, + policyStore, + policyGuard, + authority, + triggerManager, + properties, + List.of(webhookInput)); + } + + @Test + void creatingAWebhookRevealsItsSecretOnceThenMasks() { + Source created = + webhookController + .save( + new Source( + null, + "Partner uploads", + "webhook", + Map.of("mode", "consume"), + true, + null, + null)) + .getBody(); + + String secret = String.valueOf(created.options().get("signingSecret")); + String webhookId = String.valueOf(created.options().get("webhookId")); + assertNotEquals(SecretMasker.REDACTED, secret); + assertFalse(secret.isBlank()); + assertFalse(webhookId.isBlank()); + + Source read = webhookController.get(created.id()).getBody(); + assertEquals(SecretMasker.REDACTED, read.options().get("signingSecret")); + assertEquals(webhookId, read.options().get("webhookId")); } @Test diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/trigger/WebhookTriggerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/trigger/WebhookTriggerTest.java new file mode 100644 index 0000000000..63b721dd6d --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/trigger/WebhookTriggerTest.java @@ -0,0 +1,115 @@ +package stirling.software.proprietary.policy.trigger; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +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.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.policy.engine.PolicyRunner; +import stirling.software.proprietary.policy.engine.SweepKind; +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; + +@ExtendWith(MockitoExtension.class) +class WebhookTriggerTest { + + private static final String TYPE = "webhook"; + + @Mock private PolicyStore policyStore; + @Mock private PolicyRunner policyRunner; + + private final SourceStore sourceStore = new InProcessSourceStore(); + private WebhookTrigger trigger; + + @BeforeEach + void setUp() { + trigger = + new WebhookTrigger( + policyStore, policyRunner, sourceStore, new ApplicationProperties()); + } + + @Test + void firesOnlyPoliciesReferencingTheDeliveredWebhook() { + Policy matching = webhookPolicy("a", "whkA"); + Policy other = webhookPolicy("b", "whkB"); + when(policyStore.findByTriggerType(TYPE)).thenReturn(List.of(matching, other)); + + trigger.fireForWebhook("whkA"); + + verify(policyRunner).run(matching, SweepKind.LIGHT); + verify(policyRunner, never()).run(other, SweepKind.LIGHT); + } + + @Test + void ignoresADeliveryForAnUnknownWebhookId() { + Policy policy = webhookPolicy("a", "whkA"); + when(policyStore.findByTriggerType(TYPE)).thenReturn(List.of(policy)); + + trigger.fireForWebhook("whkZ"); + + verify(policyRunner, never()).run(any(), any(SweepKind.class)); + } + + @Test + void validateRequiresAWebhookSource() { + assertThrows( + IllegalArgumentException.class, + () -> trigger.validate(policy("p", webhookTriggerConfig(), List.of()))); + trigger.validate(webhookPolicy("p", "whkA")); + } + + private static TriggerConfig webhookTriggerConfig() { + return new TriggerConfig(TYPE, Map.of()); + } + + private Policy webhookPolicy(String id, String webhookId) { + String sourceId = + sourceStore + .save( + new Source( + null, + "hook", + "webhook", + Map.of( + "webhookId", + webhookId, + "signingSecret", + "s", + "mode", + "consume"), + true, + "owner", + null)) + .id(); + return policy(id, webhookTriggerConfig(), List.of(sourceId)); + } + + private static Policy policy(String id, TriggerConfig trigger, List sourceIds) { + return new Policy( + id, + "hook", + "owner", + true, + trigger, + sourceIds, + 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/webhook/WebhookLocalDeliveryE2eTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/webhook/WebhookLocalDeliveryE2eTest.java new file mode 100644 index 0000000000..774dc8e8bc --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/webhook/WebhookLocalDeliveryE2eTest.java @@ -0,0 +1,137 @@ +package stirling.software.proprietary.policy.webhook; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.mock.web.MockHttpServletRequest; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.util.FileReadinessChecker; +import stirling.software.proprietary.policy.input.ResolveContext; +import stirling.software.proprietary.policy.input.ResolvedInput; +import stirling.software.proprietary.policy.input.WebhookInputSource; +import stirling.software.proprietary.policy.ledger.InProcessProcessedLedger; +import stirling.software.proprietary.policy.model.InputSpec; +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.WebhookTrigger; + +class WebhookLocalDeliveryE2eTest { + + private static final String POLICY = "p1"; + private static final String WEBHOOK_ID = "localwebhookid12"; + private static final String SECRET = "topsecret"; + + @TempDir Path tempDir; + + private WebhookReceiverController receiver; + private WebhookInputSource inputSource; + private WebhookTrigger trigger; + private InProcessProcessedLedger ledger; + private RecordingContext ctx; + + @BeforeEach + void setUp() { + WebhookSpool spool = new WebhookSpool(tempDir.resolve("spool")); + SourceStore sourceStore = new InProcessSourceStore(); + sourceStore.save( + new Source( + "s1", + "Partner uploads", + "webhook", + Map.of("webhookId", WEBHOOK_ID, "signingSecret", SECRET, "mode", "consume"), + true, + "owner", + null)); + trigger = mock(WebhookTrigger.class); + FileReadinessChecker readiness = mock(FileReadinessChecker.class); + when(readiness.isReady(any())).thenReturn(true); + receiver = + new WebhookReceiverController( + sourceStore, spool, trigger, new ApplicationProperties()); + inputSource = new WebhookInputSource(spool, readiness); + ledger = new InProcessProcessedLedger(); + ctx = new RecordingContext(); + } + + @Test + void aDeliveryIsSpooledFiresTheTriggerThenIsReadAndConsumed() throws IOException { + byte[] body = "a pdf".getBytes(StandardCharsets.UTF_8); + String signature = WebhookSignatures.sign(SECRET, body); + + var response = receiver.receive(WEBHOOK_ID, signature, "invoice.pdf", request(body)); + assertThat(response.getStatusCode().value()).isEqualTo(202); + verify(trigger).fireForWebhook(WEBHOOK_ID); + + List work = inputSource.resolve(spec(), ctx); + assertThat(work).hasSize(1); + assertThat(work.get(0).inputs().primary().get(0).getFilename()).isEqualTo("invoice.pdf"); + assertThat(read(work.get(0))).isEqualTo("a pdf"); + assertThat(inputSource.resolve(spec(), ctx)).isEmpty(); + + work.get(0).onComplete().accept(true); + assertThat(inputSource.resolve(spec(), ctx)).isEmpty(); + } + + private static InputSpec spec() { + return new InputSpec( + "webhook", + Map.of("webhookId", WEBHOOK_ID, "signingSecret", SECRET, "mode", "consume")); + } + + private static MockHttpServletRequest request(byte[] body) { + MockHttpServletRequest req = + new MockHttpServletRequest("POST", "/api/v1/webhooks/" + WEBHOOK_ID); + req.setContent(body); + return req; + } + + private static String read(ResolvedInput unit) throws IOException { + try (InputStream stream = unit.inputs().primary().get(0).getInputStream()) { + return new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } + } + + private class RecordingContext implements ResolveContext { + + private final List present = new ArrayList<>(); + + @Override + public boolean claim(String identity, String gate, Supplier contentHash) { + return ledger.claim(POLICY, identity, gate, contentHash); + } + + @Override + public void settle( + String identity, String finalGate, String finalContentHash, boolean success) { + ledger.settle(POLICY, identity, finalGate, finalContentHash, success); + } + + @Override + public boolean allSettledDone(String identity) { + return ledger.allSettledDone(identity); + } + + @Override + public void reportPresent(Collection identities) { + present.addAll(identities); + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/webhook/WebhookReceiverControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/webhook/WebhookReceiverControllerTest.java new file mode 100644 index 0000000000..4a9a94b5ce --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/webhook/WebhookReceiverControllerTest.java @@ -0,0 +1,193 @@ +package stirling.software.proprietary.policy.webhook; + +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.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.web.server.ResponseStatusException; + +import stirling.software.common.model.ApplicationProperties; +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.WebhookTrigger; +import stirling.software.proprietary.policy.webhook.WebhookReceiverController.WebhookDeliveryResponse; + +class WebhookReceiverControllerTest { + + private static final String WEBHOOK_ID = "receivertestid12"; + private static final String SECRET = "topsecret"; + private static final byte[] BODY = "a pdf".getBytes(StandardCharsets.UTF_8); + + @TempDir Path tempDir; + + private SourceStore sourceStore; + private WebhookSpool spool; + private WebhookTrigger trigger; + private ApplicationProperties properties; + private WebhookReceiverController controller; + + @BeforeEach + void setUp() { + sourceStore = new InProcessSourceStore(); + sourceStore.save(webhookSource(true)); + spool = new WebhookSpool(tempDir.resolve("spool")); + trigger = mock(WebhookTrigger.class); + properties = new ApplicationProperties(); + controller = new WebhookReceiverController(sourceStore, spool, trigger, properties); + } + + private static Source webhookSource(boolean enabled) { + return new Source( + "s1", + "Partner uploads", + "webhook", + Map.of("webhookId", WEBHOOK_ID, "signingSecret", SECRET, "mode", "consume"), + enabled, + "owner", + null); + } + + private static MockHttpServletRequest request(byte[] body) { + MockHttpServletRequest req = + new MockHttpServletRequest("POST", "/api/v1/webhooks/" + WEBHOOK_ID); + req.setContent(body); + return req; + } + + @Test + void aValidDeliveryIsSpooledAndFiresTheTrigger() throws IOException { + String signature = WebhookSignatures.sign(SECRET, BODY); + + ResponseEntity response = + controller.receive(WEBHOOK_ID, signature, "invoice.pdf", request(BODY)); + + assertEquals(202, response.getStatusCode().value()); + assertTrue(response.getBody().accepted()); + assertEquals("invoice.pdf", response.getBody().filename()); + assertEquals(1, spooledFiles().size()); + verify(trigger).fireForWebhook(WEBHOOK_ID); + } + + @Test + void aWrongSignatureIsRejectedAndStoresNothing() { + ResponseStatusException ex = + assertThrows( + ResponseStatusException.class, + () -> + controller.receive( + WEBHOOK_ID, "sha256=deadbeef", "x.pdf", request(BODY))); + + assertEquals(401, ex.getStatusCode().value()); + assertTrue(spooledFiles().isEmpty()); + verify(trigger, never()).fireForWebhook(WEBHOOK_ID); + } + + @Test + void anUnknownWebhookIsNotFound() { + ResponseStatusException ex = + assertThrows( + ResponseStatusException.class, + () -> + controller.receive( + "unknownwebhookid", + WebhookSignatures.sign(SECRET, BODY), + "x.pdf", + request(BODY))); + + assertEquals(404, ex.getStatusCode().value()); + } + + @Test + void aPausedSourceRejectsDeliveries() { + sourceStore.save(webhookSource(false)); + String signature = WebhookSignatures.sign(SECRET, BODY); + + ResponseStatusException ex = + assertThrows( + ResponseStatusException.class, + () -> controller.receive(WEBHOOK_ID, signature, "x.pdf", request(BODY))); + + assertEquals(403, ex.getStatusCode().value()); + assertTrue(spooledFiles().isEmpty()); + } + + @Test + void anEmptyBodyIsRejected() { + byte[] empty = new byte[0]; + String signature = WebhookSignatures.sign(SECRET, empty); + + ResponseStatusException ex = + assertThrows( + ResponseStatusException.class, + () -> controller.receive(WEBHOOK_ID, signature, null, request(empty))); + + assertEquals(400, ex.getStatusCode().value()); + } + + @Test + void anOversizeDeliveryIsRejectedBeforeStoring() { + properties.getPolicies().setWebhookMaxBytes(2); + + ResponseStatusException ex = + assertThrows( + ResponseStatusException.class, + () -> + controller.receive( + WEBHOOK_ID, + WebhookSignatures.sign(SECRET, BODY), + "x.pdf", + request(BODY))); + + assertEquals(413, ex.getStatusCode().value()); + assertTrue(spooledFiles().isEmpty()); + } + + @Test + void aDeliveryWithoutAContentLengthIsRejected() { + MockHttpServletRequest req = + new MockHttpServletRequest("POST", "/api/v1/webhooks/" + WEBHOOK_ID); + ResponseStatusException ex = + assertThrows( + ResponseStatusException.class, + () -> + controller.receive( + WEBHOOK_ID, + WebhookSignatures.sign(SECRET, BODY), + "x.pdf", + req)); + + assertEquals(411, ex.getStatusCode().value()); + assertTrue(spooledFiles().isEmpty()); + } + + private List spooledFiles() { + Path dir = spool.dirFor(WEBHOOK_ID); + if (!Files.isDirectory(dir)) { + return List.of(); + } + try (Stream entries = Files.list(dir)) { + return entries.filter(Files::isRegularFile) + .filter(p -> !p.getFileName().toString().startsWith(".")) + .toList(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/webhook/WebhookSignaturesTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/webhook/WebhookSignaturesTest.java new file mode 100644 index 0000000000..6b2e45094d --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/webhook/WebhookSignaturesTest.java @@ -0,0 +1,48 @@ +package stirling.software.proprietary.policy.webhook; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.Test; + +class WebhookSignaturesTest { + + private static final String SECRET = "whsec_test_secret"; + private static final byte[] BODY = "the document bytes".getBytes(StandardCharsets.UTF_8); + + @Test + void aFreshlySignedBodyVerifies() { + String header = WebhookSignatures.sign(SECRET, BODY); + assertTrue(header.startsWith("sha256=")); + assertTrue(WebhookSignatures.verify(SECRET, BODY, header)); + } + + @Test + void abarehexSignatureVerifiesToo() { + String header = WebhookSignatures.sign(SECRET, BODY); + String bareHex = header.substring("sha256=".length()); + assertTrue(WebhookSignatures.verify(SECRET, BODY, bareHex)); + } + + @Test + void aWrongSecretDoesNotVerify() { + String header = WebhookSignatures.sign(SECRET, BODY); + assertFalse(WebhookSignatures.verify("other-secret", BODY, header)); + } + + @Test + void atamperedBodyDoesNotVerify() { + String header = WebhookSignatures.sign(SECRET, BODY); + byte[] tampered = "the document byteS".getBytes(StandardCharsets.UTF_8); + assertFalse(WebhookSignatures.verify(SECRET, tampered, header)); + } + + @Test + void aMissingOrMalformedHeaderIsFalseNotAnError() { + assertFalse(WebhookSignatures.verify(SECRET, BODY, null)); + assertFalse(WebhookSignatures.verify(SECRET, BODY, "sha256=not-hex")); + assertFalse(WebhookSignatures.verify(SECRET, BODY, "")); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/configuration/SecurityConfigurationTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/configuration/SecurityConfigurationTest.java index 50d522c056..fe2bf8e948 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/configuration/SecurityConfigurationTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/configuration/SecurityConfigurationTest.java @@ -29,6 +29,7 @@ import stirling.software.proprietary.security.database.repository.PersistentLogi import stirling.software.proprietary.security.filter.IPRateLimitingFilter; import stirling.software.proprietary.security.filter.JwtAuthenticationFilter; import stirling.software.proprietary.security.filter.UserAuthenticationFilter; +import stirling.software.proprietary.security.service.ApiKeyAuthenticationService; import stirling.software.proprietary.security.service.CustomUserDetailsService; import stirling.software.proprietary.security.service.JwtServiceInterface; import stirling.software.proprietary.security.service.LoginAttemptService; @@ -160,7 +161,9 @@ class SecurityConfigurationTest { @Test @DisplayName("jwtAuthenticationFilter is created") void jwtAuthenticationFilter() { - JwtAuthenticationFilter filter = newConfig(true).jwtAuthenticationFilter(); + JwtAuthenticationFilter filter = + newConfig(true) + .jwtAuthenticationFilter(mock(ApiKeyAuthenticationService.class)); assertThat(filter).isNotNull(); } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AdminSettingsControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AdminSettingsControllerTest.java index 5dc0e92963..4a4e297c0b 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AdminSettingsControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AdminSettingsControllerTest.java @@ -1,10 +1,13 @@ package stirling.software.proprietary.security.controller.api; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; import java.io.IOException; import java.lang.reflect.Field; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -26,6 +29,7 @@ import stirling.software.common.util.GeneralUtils; import stirling.software.proprietary.security.model.api.admin.SettingValueResponse; import stirling.software.proprietary.security.model.api.admin.UpdateSettingValueRequest; import stirling.software.proprietary.security.model.api.admin.UpdateSettingsRequest; +import stirling.software.proprietary.service.AiEngineConfigSync; import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.json.JsonMapper; @@ -37,6 +41,7 @@ class AdminSettingsControllerTest { private ApplicationProperties applicationProperties; private ObjectMapper objectMapper; private ApplicationContext applicationContext; + private AiEngineConfigSync aiEngineConfigSync; private AdminSettingsController controller; @@ -45,9 +50,13 @@ class AdminSettingsControllerTest { applicationProperties = new ApplicationProperties(); objectMapper = JsonMapper.builder().build(); applicationContext = org.mockito.Mockito.mock(ApplicationContext.class); + aiEngineConfigSync = org.mockito.Mockito.mock(AiEngineConfigSync.class); controller = new AdminSettingsController( - applicationProperties, objectMapper, applicationContext); + applicationProperties, + objectMapper, + applicationContext, + aiEngineConfigSync); clearPendingChanges(); } @@ -189,6 +198,33 @@ class AdminSettingsControllerTest { assertThat(response.getBody().get("error").toString()).contains("Invalid setting key"); } + @Test + @DisplayName("rejects an out-of-range aiEngine numeric with 400") + void rejectsOutOfRangeAiEngineNumeric() { + // An out-of-range value would make the engine reject every later push, including the + // one that fixes it. + UpdateSettingsRequest request = new UpdateSettingsRequest(); + request.setSettings(Map.of("aiEngine.limits.modelMaxConcurrency", 0)); + + ResponseEntity> response = controller.updateSettings(request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody().get("error").toString()).contains("at least 1"); + } + + @Test + @DisplayName("accepts zero maxSearches, which legitimately means no retrieval") + void acceptsZeroMaxSearches() { + UpdateSettingsRequest request = new UpdateSettingsRequest(); + request.setSettings(Map.of("aiEngine.rag.maxSearches", 0)); + + try (MockedStatic mocked = mockStatic(GeneralUtils.class)) { + ResponseEntity> response = controller.updateSettings(request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + } + @Test @DisplayName("rejects unknown section prefix with 400") void rejectsUnknownSection() { @@ -277,6 +313,51 @@ class AdminSettingsControllerTest { assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); } } + + @Test + @DisplayName("drops a masked ******** secret so a UI round-trip can't overwrite a real key") + void dropsMaskedSecretValue() { + UpdateSettingsRequest request = new UpdateSettingsRequest(); + Map settings = new HashMap<>(); + settings.put("aiEngine.models.apiKey", "********"); + settings.put("ui.appName", "My App"); + request.setSettings(settings); + + try (MockedStatic mocked = mockStatic(GeneralUtils.class)) { + ResponseEntity> response = controller.updateSettings(request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + // The masked secret is stripped; only the real change is persisted. + mocked.verify( + () -> + GeneralUtils.updateSettingsTransactional( + argThat( + (Map m) -> + !m.containsKey("aiEngine.models.apiKey") + && m.containsKey("ui.appName")))); + } + } + + @Test + @DisplayName("forwards only aiEngine.* pending keys to the engine live-push") + void forwardsOnlyAiEngineKeysToLivePush() { + UpdateSettingsRequest request = new UpdateSettingsRequest(); + Map settings = new HashMap<>(); + settings.put("aiEngine.models.provider", "ollama"); + settings.put("ui.appName", "My App"); + request.setSettings(settings); + + try (MockedStatic mocked = mockStatic(GeneralUtils.class)) { + controller.updateSettings(request); + + verify(aiEngineConfigSync) + .pushLiveAfterSave( + argThat( + (Map m) -> + m.containsKey("aiEngine.models.provider") + && !m.containsKey("ui.appName"))); + } + } } @Nested @@ -430,6 +511,25 @@ class AdminSettingsControllerTest { SettingValueResponse body = (SettingValueResponse) response.getBody(); assertThat(body.getValue()).isEqualTo("********"); } + + @Test + @DisplayName("masks aiEngine apiKey but NOT the maxTokens numeric fields") + void masksApiKeyButNotMaxTokens() { + applicationProperties.getAiEngine().getModels().setApiKey("sk-real-key"); + applicationProperties.getAiEngine().getModels().setSmartMaxTokens(8192); + + // "token" as a substring of maxTokens must not trigger masking (would break the UI + // and flip the integer to a "********" string). + ResponseEntity tokensResp = + controller.getSettingValue("aiEngine.models.smartMaxTokens"); + SettingValueResponse tokens = (SettingValueResponse) tokensResp.getBody(); + assertThat(tokens.getValue()).isEqualTo(8192); + + // The real credential is still masked. + ResponseEntity keyResp = controller.getSettingValue("aiEngine.models.apiKey"); + SettingValueResponse key = (SettingValueResponse) keyResp.getBody(); + assertThat(key.getValue()).isEqualTo("********"); + } } @Nested diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/filter/UserAuthenticationFilterTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/filter/UserAuthenticationFilterTest.java index c1900ce908..df87c30e1b 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/filter/UserAuthenticationFilterTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/filter/UserAuthenticationFilterTest.java @@ -29,6 +29,8 @@ import org.springframework.security.core.session.SessionInformation; import stirling.software.common.model.ApplicationProperties; import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken; import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.service.ApiKeyAuthenticationService; +import stirling.software.proprietary.security.service.ApiKeyAuthenticationService.ApiKeyAuthentication; import stirling.software.proprietary.security.service.UserService; import stirling.software.proprietary.security.session.SessionPersistentRegistry; @@ -37,6 +39,7 @@ import stirling.software.proprietary.security.session.SessionPersistentRegistry; class UserAuthenticationFilterTest { @Mock private UserService userService; + @Mock private ApiKeyAuthenticationService apiKeyAuthenticationService; @Mock private SessionPersistentRegistry sessionPersistentRegistry; private ApplicationProperties.Security securityProp; @@ -60,7 +63,11 @@ class UserAuthenticationFilterTest { private UserAuthenticationFilter filter(boolean loginEnabled) { return new UserAuthenticationFilter( - securityProp, userService, sessionPersistentRegistry, loginEnabled); + securityProp, + userService, + apiKeyAuthenticationService, + sessionPersistentRegistry, + loginEnabled); } private static User enabledUser(String username) { @@ -99,7 +106,11 @@ class UserAuthenticationFilterTest { User user = enabledUser("api-user"); user.addAuthority( new stirling.software.proprietary.security.model.Authority("ROLE_USER", user)); - when(userService.getUserByApiKey("good-key")).thenReturn(Optional.of(user)); + when(apiKeyAuthenticationService.authenticate("good-key")) + .thenReturn( + Optional.of( + new ApiKeyAuthentication( + user, "Prod (sk_demo0000)", user.getAuthorities()))); when(userService.usernameExistsIgnoreCase("api-user")).thenReturn(true); when(userService.isUserDisabled("api-user")).thenReturn(false); when(sessionPersistentRegistry.getAllSessions(any(), anyBoolean())) @@ -117,7 +128,7 @@ class UserAuthenticationFilterTest { void invalidApiKeyRejected() throws Exception { request.setRequestURI("/api/v1/some/protected"); request.addHeader("X-API-KEY", "bad-key"); - when(userService.getUserByApiKey("bad-key")).thenReturn(Optional.empty()); + when(apiKeyAuthenticationService.authenticate("bad-key")).thenReturn(Optional.empty()); filter(true).doFilter(request, response, filterChain); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/filter/UserBasedRateLimitingFilterTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/filter/UserBasedRateLimitingFilterTest.java new file mode 100644 index 0000000000..69bc910701 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/filter/UserBasedRateLimitingFilterTest.java @@ -0,0 +1,95 @@ +package stirling.software.proprietary.security.filter; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; + +import stirling.software.common.model.enumeration.Role; +import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken; +import stirling.software.proprietary.security.model.User; + +@ExtendWith(MockitoExtension.class) +@DisplayName("UserBasedRateLimitingFilter") +class UserBasedRateLimitingFilterTest { + + @AfterEach + void clear() { + SecurityContextHolder.clearContext(); + } + + private void authenticateAs(String username) { + User u = new User(); + u.setUsername(username); + u.setEnabled(true); + SecurityContextHolder.getContext() + .setAuthentication( + new ApiKeyAuthenticationToken( + u, + "irrelevant", + List.of(new SimpleGrantedAuthority(Role.USER.getRoleId())))); + } + + private long remainingAfterApiPost(UserBasedRateLimitingFilter filter, String apiKey) + throws Exception { + MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/general/x"); + req.addHeader("X-API-KEY", apiKey); + MockHttpServletResponse res = new MockHttpServletResponse(); + filter.doFilter(req, res, new MockFilterChain()); + return Long.parseLong(res.getHeader("X-Rate-Limit-Remaining")); + } + + @Test + @DisplayName("all of a user's keys share ONE bucket - minting keys can't multiply the quota") + void keysShareOnePerUserBucket() throws Exception { + UserBasedRateLimitingFilter filter = new UserBasedRateLimitingFilter(true); + authenticateAs("alice"); + + long afterKeyA = remainingAfterApiPost(filter, "key-A"); + long afterKeyB = remainingAfterApiPost(filter, "key-B"); // different key, same user + + // The second (different) key drew from the SAME per-user bucket, so remaining fell by one. + // If it were keyed per-API-key, both would report the same remaining. + assertThat(afterKeyB).isEqualTo(afterKeyA - 1); + } + + @Test + @DisplayName("non-POST requests are not rate limited") + void nonPostPassesThrough() throws Exception { + UserBasedRateLimitingFilter filter = new UserBasedRateLimitingFilter(true); + authenticateAs("alice"); + MockHttpServletRequest req = new MockHttpServletRequest("GET", "/api/v1/general/x"); + req.addHeader("X-API-KEY", "key-A"); + MockHttpServletResponse res = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(req, res, chain); + + assertThat(res.getHeader("X-Rate-Limit-Remaining")).isNull(); + assertThat(chain.getRequest()).isNotNull(); // passed down the chain + } + + @Test + @DisplayName("rate limiting disabled: passes through untouched") + void disabledPassesThrough() throws Exception { + UserBasedRateLimitingFilter filter = new UserBasedRateLimitingFilter(false); + authenticateAs("alice"); + MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/general/x"); + req.addHeader("X-API-KEY", "key-A"); + MockHttpServletResponse res = new MockHttpServletResponse(); + + filter.doFilter(req, res, new MockFilterChain()); + + assertThat(res.getHeader("X-Rate-Limit-Remaining")).isNull(); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/ApiKeyAuthenticationServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/ApiKeyAuthenticationServiceTest.java new file mode 100644 index 0000000000..5a7a5d910b --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/ApiKeyAuthenticationServiceTest.java @@ -0,0 +1,148 @@ +package stirling.software.proprietary.security.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.core.GrantedAuthority; + +import stirling.software.common.model.enumeration.Role; +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.model.ApiKey; +import stirling.software.proprietary.security.model.Authority; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.ApiKeyRepository; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ApiKeyAuthenticationService") +class ApiKeyAuthenticationServiceTest { + + @Mock private ApiKeyRepository apiKeyRepository; + @Mock private ApiKeyUsageRecorder usageRecorder; + @Mock private UserRepository userRepository; + @InjectMocks private ApiKeyAuthenticationService service; + + private User user(long id, boolean enabled) { + User u = new User(); + u.setId(id); + u.setUsername("user" + id); + u.setEnabled(enabled); + return u; + } + + private ApiKey key(long id, long ownerId, boolean enabled, Instant revoked) { + return ApiKey.builder() + .id(id) + .name("Production ingest") + .keyHash(ApiKeyHasher.hash("raw-" + id)) + .prefix("sk_demo0000") + .ownerUserId(ownerId) + .enabled(enabled) + .revokedAt(revoked) + .createdAt(Instant.now()) + .build(); + } + + @Test + @DisplayName("resolves an active multi-key to its owner and records usage") + void resolvesActiveKey() { + String raw = "raw-1"; + when(apiKeyRepository.findByKeyHash(ApiKeyHasher.hash(raw))) + .thenReturn(Optional.of(key(1, 7, true, null))); + when(userRepository.findById(7L)).thenReturn(Optional.of(user(7, true))); + + var result = service.authenticate(raw); + + assertThat(result).isPresent(); + assertThat(result.get().user().getId()).isEqualTo(7L); + assertThat(result.get().auditLabel()).isEqualTo("Production ingest (sk_demo0000)"); + verify(usageRecorder).record(1L); + } + + @Test + @DisplayName("rejects a revoked key without recording usage") + void rejectsRevokedKey() { + String raw = "raw-2"; + when(apiKeyRepository.findByKeyHash(ApiKeyHasher.hash(raw))) + .thenReturn(Optional.of(key(2, 7, true, Instant.now()))); + + assertThat(service.authenticate(raw)).isEmpty(); + verifyNoInteractions(usageRecorder); + } + + @Test + @DisplayName("rejects a key whose owner is disabled") + void rejectsDisabledOwner() { + String raw = "raw-3"; + when(apiKeyRepository.findByKeyHash(ApiKeyHasher.hash(raw))) + .thenReturn(Optional.of(key(3, 8, true, null))); + when(userRepository.findById(8L)).thenReturn(Optional.of(user(8, false))); + + assertThat(service.authenticate(raw)).isEmpty(); + } + + @Test + @DisplayName("falls back to the legacy per-user column, with no per-key label") + void legacyFallback() { + String raw = "legacy-key"; + when(apiKeyRepository.findByKeyHash(ApiKeyHasher.hash(raw))).thenReturn(Optional.empty()); + when(userRepository.findByApiKey(raw)).thenReturn(Optional.of(user(9, true))); + + var result = service.authenticate(raw); + + assertThat(result).isPresent(); + assertThat(result.get().user().getId()).isEqualTo(9L); + assertThat(result.get().auditLabel()).isNull(); + verifyNoInteractions(usageRecorder); + } + + @Test + @DisplayName("blank keys resolve to nothing") + void blankKey() { + assertThat(service.authenticate(" ")).isEmpty(); + assertThat(service.resolveUser(null)).isEmpty(); + } + + @Test + @DisplayName("a key authenticates with its owner's authorities (owner acts as self)") + void keyKeepsOwnerAuthorities() { + String raw = "raw-6"; + User owner = user(8, true); + owner.addAuthority(new Authority(Role.ADMIN.getRoleId(), owner)); + when(apiKeyRepository.findByKeyHash(ApiKeyHasher.hash(raw))) + .thenReturn(Optional.of(key(6, 8, true, null))); + when(userRepository.findById(8L)).thenReturn(Optional.of(owner)); + + var result = service.authenticate(raw); + + List auths = + result.get().authorities().stream().map(GrantedAuthority::getAuthority).toList(); + assertThat(auths).contains(Role.ADMIN.getRoleId()); + } + + @Test + @DisplayName("revokeMigratedKey disables the shadow row so a rotated legacy key stops working") + void revokeMigratedKeyRevokesRow() { + String raw = "raw-9"; + ApiKey shadow = key(9, 1, true, null); + when(apiKeyRepository.findByKeyHash(ApiKeyHasher.hash(raw))) + .thenReturn(Optional.of(shadow)); + + service.revokeMigratedKey(raw); + + assertThat(shadow.isEnabled()).isFalse(); + assertThat(shadow.getRevokedAt()).isNotNull(); + verify(apiKeyRepository).save(shadow); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/ApiKeyHasherTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/ApiKeyHasherTest.java new file mode 100644 index 0000000000..ec7836a8af --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/ApiKeyHasherTest.java @@ -0,0 +1,38 @@ +package stirling.software.proprietary.security.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("ApiKeyHasher") +class ApiKeyHasherTest { + + @Test + @DisplayName("generated keys are unique, prefixed, and hash deterministically") + void generateAndHash() { + String a = ApiKeyHasher.generateRawKey(); + String b = ApiKeyHasher.generateRawKey(); + + assertThat(a).startsWith("sk_").isNotEqualTo(b); + // Same input hashes the same; SHA-256 hex is 64 chars. + assertThat(ApiKeyHasher.hash(a)).isEqualTo(ApiKeyHasher.hash(a)).hasSize(64); + assertThat(ApiKeyHasher.hash(a)).isNotEqualTo(ApiKeyHasher.hash(b)); + } + + @Test + @DisplayName("hash never returns the raw key") + void hashHidesRaw() { + String raw = ApiKeyHasher.generateRawKey(); + assertThat(ApiKeyHasher.hash(raw)).isNotEqualTo(raw); + } + + @Test + @DisplayName("display prefix is a short non-secret leading fragment") + void displayPrefix() { + String raw = ApiKeyHasher.generateRawKey(); + String prefix = ApiKeyHasher.displayPrefix(raw); + assertThat(prefix).hasSize(11).startsWith("sk_"); + assertThat(raw).startsWith(prefix); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/ApiKeyManagementServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/ApiKeyManagementServiceTest.java new file mode 100644 index 0000000000..e74452b114 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/ApiKeyManagementServiceTest.java @@ -0,0 +1,198 @@ +package stirling.software.proprietary.security.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.server.ResponseStatusException; + +import stirling.software.proprietary.model.api.apikey.CreateApiKeyRequest; +import stirling.software.proprietary.model.api.apikey.CreatedApiKeyDto; +import stirling.software.proprietary.model.api.apikey.PortalApiKeysResponse; +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.model.ApiKey; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.ApiKeyDailyUsageRepository; +import stirling.software.proprietary.security.repository.ApiKeyRepository; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ApiKeyManagementService") +class ApiKeyManagementServiceTest { + + @Mock private ApiKeyRepository apiKeyRepository; + @Mock private ApiKeyDailyUsageRepository usageRepository; + @Mock private UserRepository userRepository; + @Mock private UserService userService; + @Mock private ApiKeyLegacyMigrator legacyMigrator; + @InjectMocks private ApiKeyManagementService service; + + private User caller; + + @BeforeEach + void setUp() { + caller = new User(); + caller.setId(1L); + caller.setUsername("alice"); + lenient().when(userService.getCurrentUsername()).thenReturn("alice"); + lenient() + .when(userService.findByUsernameIgnoreCase("alice")) + .thenReturn(Optional.of(caller)); + lenient().when(apiKeyRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + lenient().when(usageRepository.countForDayByIds(any(), anyLong())).thenReturn(List.of()); + lenient().when(usageRepository.sumSinceByIds(any(), anyLong())).thenReturn(List.of()); + } + + private ApiKey personalKey(long id, long ownerId) { + return ApiKey.builder() + .id(id) + .name("Key " + id) + .keyHash("hash" + id) + .prefix("sk_demo0000") + .ownerUserId(ownerId) + .enabled(true) + .createdAt(Instant.now()) + .build(); + } + + // ---- migration safety --------------------------------------------------- + + @Test + @DisplayName("an existing legacy key migrates to an owner-only row") + void legacyKeyMigratesAsPersonal() { + caller.setApiKey("legacy-raw-key"); + when(apiKeyRepository.existsByKeyHash(ApiKeyHasher.hash("legacy-raw-key"))) + .thenReturn(false); + when(apiKeyRepository.findByOwnerUserIdOrderByCreatedAtDesc(1L)).thenReturn(List.of()); + + service.listVisibleKeys(); + + // Migration insert is isolated in its own transaction (ApiKeyLegacyMigrator). + ArgumentCaptor saved = ArgumentCaptor.forClass(ApiKey.class); + verify(legacyMigrator).insertMigratedKey(saved.capture()); + ApiKey migrated = saved.getValue(); + assertThat(migrated.getOwnerUserId()).isEqualTo(1L); + } + + @Test + @DisplayName("migration is idempotent - an already-migrated legacy key is not re-saved") + void legacyKeyMigrationIdempotent() { + caller.setApiKey("legacy-raw-key"); + when(apiKeyRepository.existsByKeyHash(ApiKeyHasher.hash("legacy-raw-key"))) + .thenReturn(true); + when(apiKeyRepository.findByOwnerUserIdOrderByCreatedAtDesc(1L)).thenReturn(List.of()); + + service.listVisibleKeys(); + + verify(legacyMigrator, never()).insertMigratedKey(any()); + } + + // ---- personal isolation ------------------------------------------------- + + @Test + @DisplayName("listing scopes keys to the caller by owner id") + void personalKeysScopedToOwner() { + when(apiKeyRepository.findByOwnerUserIdOrderByCreatedAtDesc(1L)) + .thenReturn(List.of(personalKey(10, 1L))); + + PortalApiKeysResponse res = service.listVisibleKeys(); + + assertThat(res.keys()).singleElement().satisfies(k -> assertThat(k.id()).isEqualTo("10")); + // Isolation: the query is keyed by the caller's id, never a broad scan. + verify(apiKeyRepository).findByOwnerUserIdOrderByCreatedAtDesc(1L); + } + + // ---- creation ----------------------------------------------------------- + + @Test + @DisplayName("a user creates a personal key and gets a one-time secret") + void createPersonalKey() { + CreatedApiKeyDto created = service.createKey(new CreateApiKeyRequest("My key")); + + assertThat(created.secret()).startsWith("sk_"); + ArgumentCaptor saved = ArgumentCaptor.forClass(ApiKey.class); + verify(apiKeyRepository).save(saved.capture()); + assertThat(saved.getValue().getOwnerUserId()).isEqualTo(1L); + assertThat(saved.getValue().getName()).isEqualTo("My key"); + } + + @Test + @DisplayName("rejects an over-long key name") + void rejectsLongName() { + String longName = "a".repeat(101); + assertThatThrownBy(() -> service.createKey(new CreateApiKeyRequest(longName))) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("characters or fewer"); + verify(apiKeyRepository, never()).save(any()); + } + + @Test + @DisplayName("rejects a blank key name") + void rejectsBlankName() { + assertThatThrownBy(() -> service.createKey(new CreateApiKeyRequest(" "))) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("required"); + verify(apiKeyRepository, never()).save(any()); + } + + @Test + @DisplayName("rejects creating a key past the per-user active-key cap") + void rejectsPastActiveKeyCap() { + when(apiKeyRepository.findByOwnerUserIdOrderByCreatedAtDesc(1L)) + .thenReturn(java.util.Collections.nCopies(50, personalKey(100, 1L))); + + assertThatThrownBy(() -> service.createKey(new CreateApiKeyRequest("One too many"))) + .isInstanceOf(ResponseStatusException.class); + verify(apiKeyRepository, never()).save(any()); + } + + // ---- revocation --------------------------------------------------------- + + @Test + @DisplayName("owner revokes their key and the legacy column is cleared") + void revokePersonalClearsLegacy() { + caller.setApiKey("legacy-raw-key"); + ApiKey legacyRow = personalKey(30, 1L); + legacyRow.setKeyHash(ApiKeyHasher.hash("legacy-raw-key")); + when(apiKeyRepository.findById(30L)).thenReturn(Optional.of(legacyRow)); + when(userRepository.findById(1L)).thenReturn(Optional.of(caller)); + + service.revokeKey(30L); + + assertThat(legacyRow.isEnabled()).isFalse(); + assertThat(legacyRow.getRevokedAt()).isNotNull(); + assertThat(caller.getApiKey()).isNull(); + verify(userRepository).save(caller); + } + + @Test + @DisplayName( + "a non-owner cannot revoke someone else's key (404, not 403, so ids can't be probed)") + void revokeForeignKeyForbidden() { + when(apiKeyRepository.findById(31L)).thenReturn(Optional.of(personalKey(31, 999L))); + + assertThatThrownBy(() -> service.revokeKey(31L)) + .isInstanceOf(ResponseStatusException.class) + .satisfies( + e -> + assertThat(((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(404)); + verify(apiKeyRepository, never()).save(any()); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/ApiKeyUsageRecorderTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/ApiKeyUsageRecorderTest.java new file mode 100644 index 0000000000..6a03414585 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/ApiKeyUsageRecorderTest.java @@ -0,0 +1,88 @@ +package stirling.software.proprietary.security.service; + +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +/** + * Unit tests for the increment/insert/increment race protocol. {@code @Async} has no proxy in a + * plain Mockito test, so {@code record()} runs inline and is directly testable. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("ApiKeyUsageRecorder") +class ApiKeyUsageRecorderTest { + + private static final long KEY = 7L; + + @Mock private ApiKeyUsageWriter writer; + @InjectMocks private ApiKeyUsageRecorder recorder; + + @Test + @DisplayName("a null key id is a no-op") + void nullIdIsNoOp() { + recorder.record(null); + verifyNoInteractions(writer); + } + + @Test + @DisplayName("row already exists: one increment, never inserts") + void rowExistsFastPath() { + when(writer.increment(eq(KEY), anyLong())).thenReturn(1); + + recorder.record(KEY); + + verify(writer, times(1)).increment(eq(KEY), anyLong()); + verify(writer, never()).tryInsertFirstUse(anyLong(), anyLong()); + verify(writer).stampLastUsed(KEY); + } + + @Test + @DisplayName("first writer of the day: increment misses, insert wins, no second increment") + void firstWriterInserts() { + when(writer.increment(eq(KEY), anyLong())).thenReturn(0); + when(writer.tryInsertFirstUse(eq(KEY), anyLong())).thenReturn(true); + + recorder.record(KEY); + + verify(writer, times(1)).increment(eq(KEY), anyLong()); + verify(writer).tryInsertFirstUse(eq(KEY), anyLong()); + verify(writer).stampLastUsed(KEY); + } + + @Test + @DisplayName( + "lost the insert race: falls back to a second increment so the count is not dropped") + void lostInsertRaceReincrements() { + when(writer.increment(eq(KEY), anyLong())).thenReturn(0); + when(writer.tryInsertFirstUse(eq(KEY), anyLong())).thenReturn(false); + + recorder.record(KEY); + + verify(writer, times(2)).increment(eq(KEY), anyLong()); + verify(writer).stampLastUsed(KEY); + } + + @Test + @DisplayName("insert throws (rollback-only commit): still re-increments, count not dropped") + void insertThrowsStillReincrements() { + when(writer.increment(eq(KEY), anyLong())).thenReturn(0); + when(writer.tryInsertFirstUse(eq(KEY), anyLong())) + .thenThrow(new RuntimeException("UnexpectedRollbackException")); + + recorder.record(KEY); + + verify(writer, times(2)).increment(eq(KEY), anyLong()); + verify(writer).stampLastUsed(KEY); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/KeyPairCleanupServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/KeyPairCleanupServiceTest.java new file mode 100644 index 0000000000..c3cb0fb1ba --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/KeyPairCleanupServiceTest.java @@ -0,0 +1,102 @@ +package stirling.software.proprietary.security.service; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +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.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import stirling.software.common.cluster.DistributedLock; +import stirling.software.common.cluster.DistributedLock.LockHandle; +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.security.model.JwtVerificationKey; + +/** Cluster safety: pruning JWT keys is single-writer, gated on the shared cleanup lock. */ +@ExtendWith(MockitoExtension.class) +class KeyPairCleanupServiceTest { + + @Mock private KeyPersistenceService keyPersistenceService; + @Mock private ApplicationProperties applicationProperties; + @Mock private ApplicationProperties.Security security; + @Mock private ApplicationProperties.Security.Jwt jwtProperties; + @Mock private DistributedLock distributedLock; + @Mock private LockHandle lockHandle; + + private KeyPairCleanupService cleanupService; + + @BeforeEach + void setUp() { + lenient().when(applicationProperties.getSecurity()).thenReturn(security); + lenient().when(security.getJwt()).thenReturn(jwtProperties); + lenient().when(jwtProperties.isEnableKeyCleanup()).thenReturn(true); + lenient().when(keyPersistenceService.isKeystoreEnabled()).thenReturn(true); + cleanupService = + new KeyPairCleanupService( + keyPersistenceService, applicationProperties, distributedLock); + } + + @Test + void skipsPruningWhenAnotherNodeHoldsTheLock() { + when(distributedLock.tryAcquire(any(), any())).thenReturn(Optional.empty()); + + cleanupService.cleanup(); + + // No node-local pruning happened; the lock holder owns this cycle. + verify(keyPersistenceService, never()).getKeysEligibleForCleanup(any()); + verify(keyPersistenceService, never()).refreshActiveKeyPair(); + } + + @Test + void prunesAndRotatesWhenLockAcquiredThenReleasesIt() { + when(distributedLock.tryAcquire(any(), any())).thenReturn(Optional.of(lockHandle)); + when(keyPersistenceService.getKeysEligibleForCleanup(any())) + .thenReturn(List.of(new JwtVerificationKey("old-key", "cHVi"))); + + cleanupService.cleanup(); + + verify(keyPersistenceService).removeKey("old-key"); + verify(keyPersistenceService).refreshActiveKeyPair(); + verify(lockHandle).close(); + } + + @Test + void releasesTheLockEvenWhenNoKeysAreEligible() { + when(distributedLock.tryAcquire(any(), any())).thenReturn(Optional.of(lockHandle)); + when(keyPersistenceService.getKeysEligibleForCleanup(any())).thenReturn(List.of()); + + cleanupService.cleanup(); + + verify(keyPersistenceService, never()).refreshActiveKeyPair(); + verify(lockHandle).close(); + } + + @Test + void skipsPruningWhenTheLockBackendErrors() { + // A Valkey blip at boot must not fail startup: tryAcquire throwing degrades to skip. + when(distributedLock.tryAcquire(any(), any())) + .thenThrow(new RuntimeException("valkey unreachable")); + + cleanupService.cleanup(); + + verify(keyPersistenceService, never()).getKeysEligibleForCleanup(any()); + verify(keyPersistenceService, never()).refreshActiveKeyPair(); + } + + @Test + void doesNothingWhenCleanupDisabled() { + when(jwtProperties.isEnableKeyCleanup()).thenReturn(false); + + cleanupService.cleanup(); + + verify(distributedLock, never()).tryAcquire(any(), any()); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/KeyPersistenceServiceInterfaceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/KeyPersistenceServiceInterfaceTest.java index 33b971e5ac..f16117fdb9 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/KeyPersistenceServiceInterfaceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/KeyPersistenceServiceInterfaceTest.java @@ -4,45 +4,41 @@ 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.ArgumentMatchers.any; import static org.mockito.Mockito.lenient; -import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; import java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.NoSuchAlgorithmException; import java.util.Base64; +import java.util.List; import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; import org.mockito.Mock; -import org.mockito.MockedStatic; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.cache.CacheManager; import org.springframework.cache.concurrent.ConcurrentMapCacheManager; -import stirling.software.common.configuration.InstallationPathConfig; import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.security.model.JwtSigningKeyEntity; import stirling.software.proprietary.security.model.JwtVerificationKey; +import stirling.software.proprietary.security.repository.JwtSigningKeyRepository; +/** DB-backed keystore: a key present only in the shared DB still resolves (the cross-node case). */ @ExtendWith(MockitoExtension.class) class KeyPersistenceServiceInterfaceTest { @Mock private ApplicationProperties applicationProperties; - @Mock private ApplicationProperties.Security security; - @Mock private ApplicationProperties.Security.Jwt jwtConfig; - - @TempDir Path tempDir; + @Mock private JwtSigningKeyRepository keyRepository; private KeyPersistenceService keyPersistenceService; private KeyPair testKeyPair; @@ -58,175 +54,128 @@ class KeyPersistenceServiceInterfaceTest { lenient().when(applicationProperties.getSecurity()).thenReturn(security); lenient().when(security.getJwt()).thenReturn(jwtConfig); - lenient().when(jwtConfig.isEnableKeystore()).thenReturn(true); // Default value + lenient().when(jwtConfig.isEnableKeystore()).thenReturn(true); + lenient().when(keyRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + // clusterEnabled=true so the convergence-reload path is exercised. + keyPersistenceService = + new KeyPersistenceService(applicationProperties, cacheManager, keyRepository, true); + } + + private JwtSigningKeyEntity entityFrom(String keyId) { + return new JwtSigningKeyEntity( + keyId, + Base64.getEncoder().encodeToString(testKeyPair.getPublic().getEncoded()), + Base64.getEncoder().encodeToString(testKeyPair.getPrivate().getEncoded())); } @ParameterizedTest @ValueSource(booleans = {true, false}) void testKeystoreEnabled(boolean keystoreEnabled) { when(jwtConfig.isEnableKeystore()).thenReturn(keystoreEnabled); - - try (MockedStatic mockedStatic = - mockStatic(InstallationPathConfig.class)) { - mockedStatic - .when(InstallationPathConfig::getPrivateKeyPath) - .thenReturn(tempDir.toString()); - keyPersistenceService = new KeyPersistenceService(applicationProperties, cacheManager); - - assertEquals(keystoreEnabled, keyPersistenceService.isKeystoreEnabled()); - } + assertEquals(keystoreEnabled, keyPersistenceService.isKeystoreEnabled()); } @Test - void testGetActiveKeypairWhenNoActiveKeyExists() { - try (MockedStatic mockedStatic = - mockStatic(InstallationPathConfig.class)) { - mockedStatic - .when(InstallationPathConfig::getPrivateKeyPath) - .thenReturn(tempDir.toString()); - keyPersistenceService = new KeyPersistenceService(applicationProperties, cacheManager); - keyPersistenceService.initializeKeystore(); + void generatesAndPersistsAKeyWhenNoneIsActive() { + // getActiveKey with no active key mints one and persists it - no disk involved. + JwtVerificationKey active = keyPersistenceService.getActiveKey(); - JwtVerificationKey result = keyPersistenceService.getActiveKey(); - - assertNotNull(result); - assertNotNull(result.getKeyId()); - assertNotNull(result.getVerifyingKey()); - } + assertNotNull(active); + assertNotNull(active.getKeyId()); + assertNotNull(active.getVerifyingKey()); + verify(keyRepository).save(any(JwtSigningKeyEntity.class)); } @Test - void testGetActiveKeyPairWithExistingKey() throws Exception { - String keyId = "test-key-2024-01-01-120000"; - String publicKeyBase64 = - Base64.getEncoder().encodeToString(testKeyPair.getPublic().getEncoded()); - String privateKeyBase64 = - Base64.getEncoder().encodeToString(testKeyPair.getPrivate().getEncoded()); + void loadsTheMostRecentExistingKeyAsActive() { + when(keyRepository.count()).thenReturn(1L); + when(keyRepository.findAllByOrderByCreatedAtDesc()) + .thenReturn(List.of(entityFrom("jwt-key-2026-07-13-000000-abcd1234"))); - JwtVerificationKey existingKey = new JwtVerificationKey(keyId, publicKeyBase64); + keyPersistenceService.initializeKeystore(); + JwtVerificationKey active = keyPersistenceService.getActiveKey(); - Path keyFile = tempDir.resolve(keyId + ".key"); - Files.writeString(keyFile, privateKeyBase64); - - try (MockedStatic mockedStatic = - mockStatic(InstallationPathConfig.class)) { - mockedStatic - .when(InstallationPathConfig::getPrivateKeyPath) - .thenReturn(tempDir.toString()); - keyPersistenceService = new KeyPersistenceService(applicationProperties, cacheManager); - keyPersistenceService.initializeKeystore(); - - JwtVerificationKey result = keyPersistenceService.getActiveKey(); - - assertNotNull(result); - assertNotNull(result.getKeyId()); - } + assertEquals("jwt-key-2026-07-13-000000-abcd1234", active.getKeyId()); } @Test - void testGetKeyPair() throws Exception { - String keyId = "test-key-123"; - String publicKeyBase64 = - Base64.getEncoder().encodeToString(testKeyPair.getPublic().getEncoded()); - String privateKeyBase64 = - Base64.getEncoder().encodeToString(testKeyPair.getPrivate().getEncoded()); + void getKeyPairResolvesAKeyPresentOnlyInTheSharedDb() { + // Never initialised locally: the key lives only in the DB, as if another node minted it. + String keyId = "jwt-key-from-another-node"; + when(keyRepository.findById(keyId)).thenReturn(Optional.of(entityFrom(keyId))); - JwtVerificationKey signingKey = new JwtVerificationKey(keyId, publicKeyBase64); + Optional result = keyPersistenceService.getKeyPair(keyId); - Path keyFile = tempDir.resolve(keyId + ".key"); - Files.writeString(keyFile, privateKeyBase64); - - try (MockedStatic mockedStatic = - mockStatic(InstallationPathConfig.class)) { - mockedStatic - .when(InstallationPathConfig::getPrivateKeyPath) - .thenReturn(tempDir.toString()); - keyPersistenceService = new KeyPersistenceService(applicationProperties, cacheManager); - - keyPersistenceService - .getClass() - .getDeclaredField("verifyingKeyCache") - .setAccessible(true); - var cache = cacheManager.getCache("verifyingKeys"); - cache.put(keyId, signingKey); - - Optional result = keyPersistenceService.getKeyPair(keyId); - - assertTrue(result.isPresent()); - assertNotNull(result.get().getPublic()); - assertNotNull(result.get().getPrivate()); - } + assertTrue(result.isPresent()); + assertNotNull(result.get().getPublic()); + assertNotNull(result.get().getPrivate()); } @Test - void testGetKeyPairNotFound() { - String keyId = "non-existent-key"; - - try (MockedStatic mockedStatic = - mockStatic(InstallationPathConfig.class)) { - mockedStatic - .when(InstallationPathConfig::getPrivateKeyPath) - .thenReturn(tempDir.toString()); - keyPersistenceService = new KeyPersistenceService(applicationProperties, cacheManager); - - Optional result = keyPersistenceService.getKeyPair(keyId); - - assertFalse(result.isPresent()); - } + void getKeyPairIsEmptyWhenTheKeyIsUnknown() { + when(keyRepository.findById("nope")).thenReturn(Optional.empty()); + assertFalse(keyPersistenceService.getKeyPair("nope").isPresent()); } @Test - void testGetKeyPairWhenKeystoreDisabled() { + void getKeyPairIsEmptyWhenKeystoreDisabled() { when(jwtConfig.isEnableKeystore()).thenReturn(false); - - try (MockedStatic mockedStatic = - mockStatic(InstallationPathConfig.class)) { - mockedStatic - .when(InstallationPathConfig::getPrivateKeyPath) - .thenReturn(tempDir.toString()); - keyPersistenceService = new KeyPersistenceService(applicationProperties, cacheManager); - - Optional result = keyPersistenceService.getKeyPair("any-key"); - - assertFalse(result.isPresent()); - } + assertFalse(keyPersistenceService.getKeyPair("any-key").isPresent()); } @Test - void testInitializeKeystoreCreatesDirectory() throws IOException { - try (MockedStatic mockedStatic = - mockStatic(InstallationPathConfig.class)) { - mockedStatic - .when(InstallationPathConfig::getPrivateKeyPath) - .thenReturn(tempDir.toString()); - keyPersistenceService = new KeyPersistenceService(applicationProperties, cacheManager); - keyPersistenceService.initializeKeystore(); + void eligibleForCleanupIsSourcedFromTheDb() { + when(keyRepository.findByCreatedAtBefore(any())).thenReturn(List.of(entityFrom("old-key"))); - assertTrue(Files.exists(tempDir)); - assertTrue(Files.isDirectory(tempDir)); - } + List stale = + keyPersistenceService.getKeysEligibleForCleanup(java.time.LocalDateTime.now()); + + assertEquals(1, stale.size()); + assertEquals("old-key", stale.get(0).getKeyId()); } @Test - void testLoadExistingKeypairWithMissingPrivateKeyFile() throws Exception { - String keyId = "test-key-missing-file"; - String publicKeyBase64 = - Base64.getEncoder().encodeToString(testKeyPair.getPublic().getEncoded()); + void reloadAdoptsTheNewestKeyAPeerMinted() { + // Boot with our own key active, then a peer mints a newer one in the shared DB. + when(keyRepository.count()).thenReturn(1L); + when(keyRepository.findAllByOrderByCreatedAtDesc()) + .thenReturn(List.of(entityFrom("jwt-key-local-old"))); + keyPersistenceService.initializeKeystore(); + assertEquals("jwt-key-local-old", keyPersistenceService.getActiveKey().getKeyId()); - JwtVerificationKey existingKey = new JwtVerificationKey(keyId, publicKeyBase64); + when(keyRepository.findFirstByOrderByCreatedAtDesc()) + .thenReturn(Optional.of(entityFrom("jwt-key-peer-new"))); - try (MockedStatic mockedStatic = - mockStatic(InstallationPathConfig.class)) { - mockedStatic - .when(InstallationPathConfig::getPrivateKeyPath) - .thenReturn(tempDir.toString()); - keyPersistenceService = new KeyPersistenceService(applicationProperties, cacheManager); - keyPersistenceService.initializeKeystore(); + keyPersistenceService.reloadActiveKeyFromDb(); - JwtVerificationKey result = keyPersistenceService.getActiveKey(); - assertNotNull(result); - assertNotNull(result.getKeyId()); - assertNotNull(result.getVerifyingKey()); - } + // Converged: this node now signs with the peer's newer key. + assertEquals("jwt-key-peer-new", keyPersistenceService.getActiveKey().getKeyId()); + } + + @Test + void reloadDoesNothingOffCluster() { + KeyPersistenceService singleNode = + new KeyPersistenceService( + applicationProperties, cacheManager, keyRepository, false); + + singleNode.reloadActiveKeyFromDb(); + + // Off-cluster the DB is never consulted for convergence. + verify(keyRepository, org.mockito.Mockito.never()).findFirstByOrderByCreatedAtDesc(); + } + + @Test + void reloadIsANoOpWhenAlreadyHoldingTheNewestKey() { + when(keyRepository.count()).thenReturn(1L); + when(keyRepository.findAllByOrderByCreatedAtDesc()) + .thenReturn(List.of(entityFrom("jwt-key-current"))); + keyPersistenceService.initializeKeystore(); + + when(keyRepository.findFirstByOrderByCreatedAtDesc()) + .thenReturn(Optional.of(entityFrom("jwt-key-current"))); + + keyPersistenceService.reloadActiveKeyFromDb(); + + assertEquals("jwt-key-current", keyPersistenceService.getActiveKey().getKeyId()); } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/UserServiceMoreTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/UserServiceMoreTest.java index bc516172a6..6e2b942b80 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/UserServiceMoreTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/UserServiceMoreTest.java @@ -41,6 +41,7 @@ import stirling.software.proprietary.security.model.AuthenticationType; import stirling.software.proprietary.security.model.Authority; import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.repository.TeamRepository; +import stirling.software.proprietary.security.service.ApiKeyAuthenticationService.ApiKeyAuthentication; import stirling.software.proprietary.security.session.SessionPersistentRegistry; import stirling.software.proprietary.storage.repository.FileShareAccessRepository; import stirling.software.proprietary.storage.repository.FileShareRepository; @@ -76,6 +77,7 @@ class UserServiceMoreTest { integrationConfigRepository; @Mock private TeamMembershipService teamMembershipService; + @Mock private ApiKeyAuthenticationService apiKeyAuthenticationService; @InjectMocks private UserService userService; @@ -99,7 +101,8 @@ class UserServiceMoreTest { void getAuthenticationValid() { User u = user("api"); u.addAuthority(new Authority("ROLE_USER", u)); - when(userRepository.findByApiKey("k")).thenReturn(Optional.of(u)); + when(apiKeyAuthenticationService.authenticate("k")) + .thenReturn(Optional.of(new ApiKeyAuthentication(u, null, u.getAuthorities()))); assertThat(userService.getAuthentication("k")).isNotNull(); } @@ -107,7 +110,7 @@ class UserServiceMoreTest { @Test @DisplayName("getAuthentication throws when key is unknown") void getAuthenticationInvalid() { - when(userRepository.findByApiKey("bad")).thenReturn(Optional.empty()); + when(apiKeyAuthenticationService.authenticate("bad")).thenReturn(Optional.empty()); assertThatThrownBy(() -> userService.getAuthentication("bad")) .isInstanceOf(UsernameNotFoundException.class); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/UserServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/UserServiceTest.java index 95ec72d962..4b49b4c4d2 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/UserServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/UserServiceTest.java @@ -71,6 +71,7 @@ class UserServiceTest { integrationConfigRepository; @Mock private TeamMembershipService teamMembershipService; + @Mock private ApiKeyAuthenticationService apiKeyAuthenticationService; @Spy @InjectMocks private UserService userService; @@ -196,6 +197,27 @@ class UserServiceTest { verify(userRepository).save(user); } + @Test + void addApiKeyToUserRevokesOldMigratedShadowRow() { + User user = new User(); + user.setUsername("user"); + user.setApiKey("old-secret"); + when(userRepository.findByUsernameIgnoreCase("user")).thenReturn(Optional.of(user)); + when(userRepository.findByApiKey(any())).thenReturn(Optional.empty()); + when(userRepository.save(any(User.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + User updated = userService.addApiKeyToUser("user"); + + // Rotating a legacy key must revoke its migrated api_keys shadow row with the OLD secret, + // and do so before the new key is generated - otherwise the old secret keeps + // authenticating. + org.mockito.InOrder inOrder = inOrder(apiKeyAuthenticationService, userRepository); + inOrder.verify(apiKeyAuthenticationService).revokeMigratedKey("old-secret"); + inOrder.verify(userRepository).save(user); + assertNotEquals("old-secret", updated.getApiKey()); + } + @Test void getApiKeyForUserCreatesWhenMissing() { User user = new User(); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/service/AiDocumentHtmlRendererTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/service/AiDocumentHtmlRendererTest.java new file mode 100644 index 0000000000..7b491c3539 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/AiDocumentHtmlRendererTest.java @@ -0,0 +1,139 @@ +package stirling.software.proprietary.service; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import stirling.software.proprietary.model.api.ai.create.AiDocument; + +class AiDocumentHtmlRendererTest { + + private final AiDocumentHtmlRenderer renderer = new AiDocumentHtmlRenderer(); + + private static AiDocument.Section section(String type) { + AiDocument.Section s = new AiDocument.Section(); + s.setType(type); + return s; + } + + private static AiDocument document(String title, List sections) { + AiDocument doc = new AiDocument(); + doc.setTitle(title); + doc.setSections(sections); + return doc; + } + + @Test + void rendersAllSectionTypes() { + AiDocument.Section text = section("text"); + text.setBody("Some prose text."); + AiDocument.Section kv = section("key_value"); + kv.setPairs(List.of(List.of("Key", "Value"))); + AiDocument.Section items = section("line_items"); + items.setColumns(List.of("A", "B")); + items.setRows(List.of(List.of("1", "2"))); + AiDocument.Section bullets = section("bullet_list"); + bullets.setItems(List.of("item one")); + AiDocument.Section sign = section("signature"); + sign.setSignatories(List.of("Alice")); + + String html = renderer.render(document("All", List.of(text, kv, items, bullets, sign))); + + assertTrue(html.contains("")); + assertTrue(html.contains("Some prose text.")); + assertTrue(html.contains("Key") && html.contains("Value")); + assertTrue(html.contains("
    ")); + } + + @Test + void totalRowAbsentWhenNotProvided() { + AiDocument.Section items = section("line_items"); + items.setColumns(List.of("Item")); + items.setRows(List.of(List.of("Widget"))); + + assertFalse( + renderer.render(document("Table", List.of(items))) + .contains("")); + } + + @Test + void rendersSubtitleAndReference() { + AiDocument doc = document("My Doc", List.of()); + doc.setSubtitle("Subtitle Here"); + doc.setReferenceNumber("REF-42"); + + String html = renderer.render(doc); + + assertTrue(html.contains("Subtitle Here")); + assertTrue(html.contains("REF-42")); + } + + @Test + void appliesHexColourOverride() { + AiDocument doc = document("Styled", List.of()); + AiDocument.Style style = new AiDocument.Style(); + style.setPrimaryColor("#ff00ff"); + style.setBackgroundColor("#111111"); + doc.setStyle(style); + + String html = renderer.render(doc); + + assertTrue(html.contains("--color-primary: #ff00ff")); + assertTrue(html.contains("--color-bg: #111111")); + } + + @Test + void ignoresColourWithDisallowedCharacters() { + AiDocument doc = document("Styled", List.of()); + AiDocument.Style style = new AiDocument.Style(); + style.setPrimaryColor("rgb(255, 0, 0)"); + doc.setStyle(style); + + String html = renderer.render(doc); + + assertFalse(html.contains("rgb(")); + assertTrue(html.contains("")); + } + + @Test + void ignoresNonHexColour() { + AiDocument doc = document("Styled", List.of()); + AiDocument.Style style = new AiDocument.Style(); + style.setPrimaryColor("magenta"); + style.setBackgroundColor("#fff"); + doc.setStyle(style); + + String html = renderer.render(doc); + + assertFalse(html.contains("--color-primary: magenta")); + assertFalse(html.contains("--color-bg: #fff;")); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/service/AiEngineConfigSyncTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/service/AiEngineConfigSyncTest.java new file mode 100644 index 0000000000..41f35c9daf --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/AiEngineConfigSyncTest.java @@ -0,0 +1,251 @@ +package stirling.software.proprietary.service; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.timeout; +import static org.mockito.Mockito.verify; + +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.model.ApplicationProperties.AiEngine; + +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; + +/** + * Config-push bridge is self-hosted-only. These lock in that the processor stays silent when {@code + * aiEngine.pushConfigToEngine} is off (env-driven/SaaS) and pushes when it is on. + */ +class AiEngineConfigSyncTest { + + private ApplicationProperties applicationProperties; + private AiEngineClient aiEngineClient; + private AiEngineConfigSync sync; + + @BeforeEach + void setUp() { + applicationProperties = new ApplicationProperties(); + applicationProperties.getAiEngine().setEnabled(true); + applicationProperties.getAiEngine().setPushConfigToEngine(true); + aiEngineClient = mock(AiEngineClient.class); + ObjectMapper objectMapper = JsonMapper.builder().build(); + sync = new AiEngineConfigSync(applicationProperties, aiEngineClient, objectMapper); + } + + @Test + void startupPushSkippedWhenPushDisabled() throws Exception { + applicationProperties.getAiEngine().setPushConfigToEngine(false); + + sync.pushConfigOnStartup(); + + // Returns synchronously before spawning the push thread, so no interaction ever happens. + verify(aiEngineClient, never()).post(anyString(), anyString(), isNull()); + } + + @Test + void startupPushSkippedWhenDisabled() throws Exception { + applicationProperties.getAiEngine().setEnabled(false); + + sync.pushConfigOnStartup(); + + verify(aiEngineClient, never()).post(anyString(), anyString(), isNull()); + } + + @Test + void startupPushSentWhenEnabledAndPushOn() throws Exception { + sync.pushConfigOnStartup(); + + // Push runs on a virtual thread; wait for the single POST to /api/v1/config. + verify(aiEngineClient, timeout(3000)).post(eq("/api/v1/config"), anyString(), isNull()); + } + + @Test + void livePushSkippedWhenPushDisabled() throws Exception { + applicationProperties.getAiEngine().setPushConfigToEngine(false); + + sync.pushLiveAfterSave(Map.of("aiEngine.models.provider", "ollama")); + + verify(aiEngineClient, never()).post(anyString(), anyString(), isNull()); + } + + @Test + void livePushSentForEngineRelevantChangeWhenPushOn() throws Exception { + sync.pushLiveAfterSave(Map.of("aiEngine.models.provider", "ollama")); + + verify(aiEngineClient, timeout(3000)).post(eq("/api/v1/config"), anyString(), isNull()); + } + + @Test + void startupPushSerialisesTheEngineWireContract() throws Exception { + // Distinct values so a dropped/renamed field is detectable. Keep in sync with + // engine/tests/fixtures/processor_config_push.json (the engine validates the same shape). + AiEngine ai = applicationProperties.getAiEngine(); + ai.getModels().setProvider("ollama"); + ai.getModels().setSmartModel("smart-model-x"); + ai.getModels().setFastModel("fast-model-x"); + ai.getModels().setSmartMaxTokens(1111); + ai.getModels().setFastMaxTokens(2222); + ai.getModels().setApiKey("provider-key-abc"); + ai.getModels().setBaseUrl("http://engine.example/v1"); + ai.getRag().setEmbeddingProvider("custom"); + ai.getRag().setEmbeddingModel("embed-model-x"); + ai.getRag().setEmbeddingApiKey("embed-key-abc"); + ai.getRag().setEmbeddingBaseUrl("http://embed.example/v1"); + ai.getRag().setTopK(33); + ai.getRag().setMaxSearches(7); + ai.getLimits().setMaxPages(111); + ai.getLimits().setMaxCharacters(222222); + ai.getLimits().setModelMaxConcurrency(9); + + ArgumentCaptor body = ArgumentCaptor.forClass(String.class); + sync.pushConfigOnStartup(); + verify(aiEngineClient, timeout(3000)).post(eq("/api/v1/config"), body.capture(), isNull()); + + JsonNode root = JsonMapper.builder().build().readTree(body.getValue()); + + JsonNode models = root.get("models"); + assertEquals("ollama", models.get("provider").asText()); + assertEquals("smart-model-x", models.get("smartModel").asText()); + assertEquals("fast-model-x", models.get("fastModel").asText()); + assertEquals(1111, models.get("smartMaxTokens").asInt()); + assertEquals(2222, models.get("fastMaxTokens").asInt()); + assertEquals("provider-key-abc", models.get("apiKey").asText()); + assertEquals("http://engine.example/v1", models.get("baseUrl").asText()); + + JsonNode rag = root.get("rag"); + assertEquals("custom", rag.get("embeddingProvider").asText()); + assertEquals("embed-model-x", rag.get("embeddingModel").asText()); + assertEquals("embed-key-abc", rag.get("embeddingApiKey").asText()); + assertEquals("http://embed.example/v1", rag.get("embeddingBaseUrl").asText()); + assertEquals(33, rag.get("topK").asInt()); + assertEquals(7, rag.get("maxSearches").asInt()); + + JsonNode limits = root.get("limits"); + assertEquals(111, limits.get("maxPages").asInt()); + assertEquals(222222, limits.get("maxCharacters").asInt()); + assertEquals(9, limits.get("modelMaxConcurrency").asInt()); + } + + @Test + void livePushSkippedForNonEngineRelevantChange() throws Exception { + // features.* is processor-side only; no engine push is warranted. + sync.pushLiveAfterSave(Map.of("aiEngine.features.chat", false)); + + verify(aiEngineClient, never()).post(anyString(), anyString(), isNull()); + } + + @Test + void startupPushKeepsEnvWhenSectionsUnconfigured() throws Exception { + // All defaults, no credentials: the push must NOT override the engine's env-configured + // provider/model/embedder, so the identity fields are blanked ("keep env" on the engine). + ArgumentCaptor body = ArgumentCaptor.forClass(String.class); + sync.pushConfigOnStartup(); + verify(aiEngineClient, timeout(3000)).post(eq("/api/v1/config"), body.capture(), isNull()); + + JsonNode root = JsonMapper.builder().build().readTree(body.getValue()); + assertEquals("", root.get("models").get("provider").asText()); + assertEquals("", root.get("models").get("smartModel").asText()); + assertEquals("", root.get("models").get("fastModel").asText()); + assertEquals("", root.get("rag").get("embeddingProvider").asText()); + assertEquals("", root.get("rag").get("embeddingModel").asText()); + } + + @Test + void startupPushSendsProviderWhenChangedFromDefault() throws Exception { + // Admin selected a non-default provider (relying on the engine's env key): send it so the + // engine actually switches provider, even though no API key was entered in the UI. + applicationProperties.getAiEngine().getModels().setProvider("openai"); + + ArgumentCaptor body = ArgumentCaptor.forClass(String.class); + sync.pushConfigOnStartup(); + verify(aiEngineClient, timeout(3000)).post(eq("/api/v1/config"), body.capture(), isNull()); + + JsonNode models = JsonMapper.builder().build().readTree(body.getValue()).get("models"); + assertEquals("openai", models.get("provider").asText()); + } + + @Test + void startupPushSendsModelsWhenApiKeyConfigured() throws Exception { + // A configured key means the admin is driving models from the UI: send the full section. + applicationProperties.getAiEngine().getModels().setApiKey("sk-real-key"); + + ArgumentCaptor body = ArgumentCaptor.forClass(String.class); + sync.pushConfigOnStartup(); + verify(aiEngineClient, timeout(3000)).post(eq("/api/v1/config"), body.capture(), isNull()); + + JsonNode models = JsonMapper.builder().build().readTree(body.getValue()).get("models"); + assertEquals("anthropic", models.get("provider").asText()); + assertEquals("sk-real-key", models.get("apiKey").asText()); + } + + @Test + void livePushSendsAnExplicitlyClearedApiKeyRatherThanKeepEnv() throws Exception { + // Clearing a leaked key must reach the engine as a real clear; blanking it as "keep env" + // would leave the revoked key live in the engine's cache indefinitely. + ArgumentCaptor body = ArgumentCaptor.forClass(String.class); + sync.pushLiveAfterSave(mapOf("aiEngine.models.apiKey", "")); + verify(aiEngineClient, timeout(3000)).post(eq("/api/v1/config"), body.capture(), isNull()); + + JsonNode models = JsonMapper.builder().build().readTree(body.getValue()).get("models"); + assertEquals("", models.get("apiKey").asText()); + // Identity was NOT blanked wholesale: the provider still travels so the engine applies + // the cleared credential against the right provider. + assertEquals("anthropic", models.get("provider").asText()); + } + + @Test + void livePushKeepsEnvWhenOnlyANumericKnobChanged() throws Exception { + // The admin touched a limit, not the identity, so the engine's env-configured + // provider/model must be preserved. + ArgumentCaptor body = ArgumentCaptor.forClass(String.class); + sync.pushLiveAfterSave(Map.of("aiEngine.limits.maxPages", 42)); + verify(aiEngineClient, timeout(3000)).post(eq("/api/v1/config"), body.capture(), isNull()); + + JsonNode root = JsonMapper.builder().build().readTree(body.getValue()); + assertEquals("", root.get("models").get("provider").asText()); + assertEquals("", root.get("models").get("apiKey").asText()); + assertEquals(42, root.get("limits").get("maxPages").asInt()); + } + + @Test + void livePushIgnoresAMalformedKeyInsteadOfClobberingTheSection() throws Exception { + // "aiEngine.models." has no leaf; writing at the section name would replace the whole + // models object with a scalar and produce an unparseable push. + ArgumentCaptor body = ArgumentCaptor.forClass(String.class); + sync.pushLiveAfterSave(mapOf("aiEngine.models.", "junk")); + verify(aiEngineClient, timeout(3000)).post(eq("/api/v1/config"), body.capture(), isNull()); + + JsonNode models = JsonMapper.builder().build().readTree(body.getValue()).get("models"); + assertTrue(models.isObject(), "models must still be an object"); + } + + @Test + void livePushNeverThrowsIntoTheCaller() throws Exception { + // The caller has already persisted settings.yml, so a push-building failure must not + // surface as a failed save. A null value inside the map is enough to break naive code. + Map pending = new HashMap<>(); + pending.put("aiEngine.models.provider", null); + + assertDoesNotThrow(() -> sync.pushLiveAfterSave(pending)); + } + + /** {@link Map#of} rejects nulls and we need entries with empty/odd values. */ + private static Map mapOf(String key, Object value) { + Map map = new HashMap<>(); + map.put(key, value); + return map; + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/service/AiFeatureGateTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/service/AiFeatureGateTest.java new file mode 100644 index 0000000000..932a56296e --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/AiFeatureGateTest.java @@ -0,0 +1,77 @@ +package stirling.software.proprietary.service; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +import stirling.software.common.model.ApplicationProperties; + +/** + * Lock in fail-closed gating: a 503 when the engine is disabled or the feature flag is off, and a + * clean pass only when both are on. + */ +class AiFeatureGateTest { + + private ApplicationProperties props; + private AiFeatureGate gate; + + @BeforeEach + void setUp() { + props = new ApplicationProperties(); + props.getAiEngine().setEnabled(true); // features default all-on + gate = new AiFeatureGate(props); + } + + @Test + void passesWhenEngineEnabledAndFeatureOn() { + assertDoesNotThrow(() -> gate.requireClassify()); + assertDoesNotThrow(() -> gate.requireConversationalWorkflow()); + } + + @Test + void throws503WhenFeatureFlagOff() { + props.getAiEngine().getFeatures().setClassify(false); + + ResponseStatusException ex = + assertThrows(ResponseStatusException.class, () -> gate.requireClassify()); + assertEquals(HttpStatus.SERVICE_UNAVAILABLE, ex.getStatusCode()); + } + + @Test + void throws503WhenEngineDisabledEvenIfFeatureOn() { + props.getAiEngine().setEnabled(false); // feature flag still true + + ResponseStatusException ex = + assertThrows( + ResponseStatusException.class, () -> gate.requireConversationalWorkflow()); + assertEquals(HttpStatus.SERVICE_UNAVAILABLE, ex.getStatusCode()); + } + + @Test + void conversationalWorkflowAllowedWhileEitherChatOrDocumentQuestionsOn() { + // The orchestrate endpoint serves both, so it stays open while either is enabled. + props.getAiEngine().getFeatures().setChat(false); + props.getAiEngine().getFeatures().setDocumentQuestions(true); + assertDoesNotThrow(() -> gate.requireConversationalWorkflow()); + + props.getAiEngine().getFeatures().setChat(true); + props.getAiEngine().getFeatures().setDocumentQuestions(false); + assertDoesNotThrow(() -> gate.requireConversationalWorkflow()); + } + + @Test + void conversationalWorkflowThrows503WhenBothChatAndDocumentQuestionsOff() { + props.getAiEngine().getFeatures().setChat(false); + props.getAiEngine().getFeatures().setDocumentQuestions(false); + + ResponseStatusException ex = + assertThrows( + ResponseStatusException.class, () -> gate.requireConversationalWorkflow()); + assertEquals(HttpStatus.SERVICE_UNAVAILABLE, ex.getStatusCode()); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/service/AiToolInputValidatorTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/service/AiToolInputValidatorTest.java index 5f8c906fce..2c4a1fe815 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/service/AiToolInputValidatorTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/AiToolInputValidatorTest.java @@ -75,6 +75,6 @@ class AiToolInputValidatorTest { assertThrows( ResponseStatusException.class, () -> AiToolInputValidator.validatePdfUpload(file)); - assertEquals(HttpStatus.PAYLOAD_TOO_LARGE, ex.getStatusCode()); + assertEquals(HttpStatus.CONTENT_TOO_LARGE, ex.getStatusCode()); } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/service/PortalDocumentsServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/service/PortalDocumentsServiceTest.java index 1afc0af4a4..f02d919ba3 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/service/PortalDocumentsServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/PortalDocumentsServiceTest.java @@ -70,4 +70,18 @@ class PortalDocumentsServiceTest { assertThat(doc.getProduct()).isEqualTo("API"); assertThat(doc.getSource()).isEqualTo("API integration"); } + + @Test + void apiDocumentIsAttributedToItsNamedKey() { + PortalReviewDocumentDto doc = + onlyDoc( + "{\"path\":\"/api/v1/misc/compress-pdf\",\"__origin\":\"API\"," + + "\"__apiKeyLabel\":\"Production ingest (sk_demo0000)\"," + + "\"files\":[{\"name\":\"a.pdf\",\"type\":\"application/pdf\"}]," + + "\"statusCode\":200}"); + + // The specific key label surfaces as the source; product stays "API". + assertThat(doc.getProduct()).isEqualTo("API"); + assertThat(doc.getSource()).isEqualTo("API key · Production ingest (sk_demo0000)"); + } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/util/SecretMaskerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/util/SecretMaskerTest.java index 415117a446..86d30cebe9 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/util/SecretMaskerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/util/SecretMaskerTest.java @@ -103,6 +103,17 @@ class SecretMaskerTest { assertEquals("AKIAEXAMPLE", result.get("accessKeyId")); } + @Test + @DisplayName("should mask camelCase signingSecret despite no word boundary") + void shouldMaskCamelCaseSigningSecret() { + Map input = Map.of("signingSecret", "shh", "webhookId", "whk_abc"); + + Map result = SecretMasker.mask(input); + + assertEquals(SecretMasker.REDACTED, result.get("signingSecret")); + assertEquals("whk_abc", result.get("webhookId")); + } + @Test @DisplayName("should mask nested map sensitive keys") void shouldMaskNestedMapSensitiveKeys() { diff --git a/app/saas/build.gradle b/app/saas/build.gradle index d6ae305def..495f583a74 100644 --- a/app/saas/build.gradle +++ b/app/saas/build.gradle @@ -2,32 +2,6 @@ bootRun { enabled = false } -spotless { - java { - target 'src/**/java/**/*.java' - targetExclude 'src/main/java/org/apache/**' - googleJavaFormat(googleJavaFormatVersion).aosp().reorderImports(false) - suppressLintsFor { setStep('google-java-format') } - - importOrder("java", "javax", "org", "com", "net", "io", "jakarta", "lombok", "me", "stirling") - trimTrailingWhitespace() - leadingTabsToSpaces() - endWithNewline() - } - yaml { - target '**/*.yml', '**/*.yaml' - trimTrailingWhitespace() - leadingTabsToSpaces() - endWithNewline() - } - format 'gradle', { - target '**/gradle/*.gradle', '**/*.gradle' - trimTrailingWhitespace() - leadingTabsToSpaces() - endWithNewline() - } -} - dependencies { implementation project(':common') implementation project(':proprietary') @@ -38,8 +12,5 @@ dependencies { api 'org.springframework.boot:spring-boot-starter-webmvc' api 'org.springframework.boot:spring-boot-starter-aspectj' - api 'org.flywaydb:flyway-core' - runtimeOnly 'org.flywaydb:flyway-database-postgresql' - testImplementation "com.tngtech.archunit:archunit-junit5:${archunitVersion}" } diff --git a/app/saas/src/main/java/stirling/software/saas/config/SaasJpaConfig.java b/app/saas/src/main/java/stirling/software/saas/config/SaasJpaConfig.java index 53a5f34c15..cd991cc44e 100644 --- a/app/saas/src/main/java/stirling/software/saas/config/SaasJpaConfig.java +++ b/app/saas/src/main/java/stirling/software/saas/config/SaasJpaConfig.java @@ -19,6 +19,7 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories; "stirling.software.saas.billing.repository", "stirling.software.saas.ai.repository", "stirling.software.saas.payg.repository", + "stirling.software.saas.payg.bundle", "stirling.software.saas.procurement.repository" }) @EntityScan({ diff --git a/app/saas/src/main/java/stirling/software/saas/model/SaasUserExtensions.java b/app/saas/src/main/java/stirling/software/saas/model/SaasUserExtensions.java index d483f848de..8d7dda1726 100644 --- a/app/saas/src/main/java/stirling/software/saas/model/SaasUserExtensions.java +++ b/app/saas/src/main/java/stirling/software/saas/model/SaasUserExtensions.java @@ -7,6 +7,7 @@ import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.OnDelete; import org.hibernate.annotations.OnDeleteAction; import org.hibernate.annotations.UpdateTimestamp; +import org.springframework.data.domain.Persistable; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -16,6 +17,7 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.MapsId; import jakarta.persistence.OneToOne; import jakarta.persistence.Table; +import jakarta.persistence.Transient; import lombok.Getter; import lombok.NoArgsConstructor; @@ -37,7 +39,7 @@ import stirling.software.proprietary.security.model.User; @NoArgsConstructor @Getter @Setter -public class SaasUserExtensions implements Serializable { +public class SaasUserExtensions implements Serializable, Persistable { private static final long serialVersionUID = 1L; @@ -57,6 +59,11 @@ public class SaasUserExtensions implements Serializable { @Column(name = "api_key_first_used_at") private LocalDateTime apiKeyFirstUsedAt; + // Durable "home team" the user returns to when leaving a joined team; distinct from the + // active users.team_id. Plain id (not a @ManyToOne) to avoid an eager Team load. Nullable. + @Column(name = "home_team_id") + private Long homeTeamId; + @CreationTimestamp @Column(name = "created_at", updatable = false) private LocalDateTime createdAt; @@ -75,4 +82,17 @@ public class SaasUserExtensions implements Serializable { public boolean isMeteredBillingEnabled() { return Boolean.TRUE.equals(hasMeteredBillingEnabled); } + + @Override + public Long getId() { + return userId; + } + + // Decided on the timestamp, not the id: the constructor pre-sets the @MapsId id, so an + // id-based check would route a new row to merge() and fail with "null identifier". + @Override + @Transient + public boolean isNew() { + return createdAt == null; + } } diff --git a/app/saas/src/main/java/stirling/software/saas/payg/api/PaygWalletController.java b/app/saas/src/main/java/stirling/software/saas/payg/api/PaygWalletController.java index 18da81ae2b..0f18f13284 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/api/PaygWalletController.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/api/PaygWalletController.java @@ -1,5 +1,6 @@ package stirling.software.saas.payg.api; +import java.math.BigDecimal; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; @@ -41,6 +42,7 @@ import stirling.software.saas.payg.api.WalletSnapshotResponse.CategoryBreakdown; import stirling.software.saas.payg.api.WalletSnapshotResponse.MemberRow; import stirling.software.saas.payg.billing.TeamBillingContext; import stirling.software.saas.payg.billing.TeamBillingService; +import stirling.software.saas.payg.bundle.PrepaidBundleService; import stirling.software.saas.payg.entitlement.EntitlementService; import stirling.software.saas.payg.entitlement.EntitlementSnapshot; import stirling.software.saas.payg.model.BillingCategory; @@ -86,6 +88,8 @@ public class PaygWalletController { static final String STATUS_SUBSCRIBED = "subscribed"; static final String ROLE_LEADER = "leader"; static final String ROLE_MEMBER = "member"; + static final String BILLING_MODE_PREPAID = "prepaid"; + static final String BILLING_MODE_PAYG = "payg"; /** * Placeholder ceiling for the team-less empty snapshot only (authenticated caller without a @@ -104,6 +108,7 @@ public class PaygWalletController { private final WalletLedgerRepository ledgerRepo; private final PaygShadowChargeRepository shadowRepo; private final UserRepository userRepository; + private final PrepaidBundleService prepaidBundleService; public PaygWalletController( EntitlementService entitlementService, @@ -113,7 +118,8 @@ public class PaygWalletController { WalletPolicyRepository policyRepo, WalletLedgerRepository ledgerRepo, PaygShadowChargeRepository shadowRepo, - UserRepository userRepository) { + UserRepository userRepository, + PrepaidBundleService prepaidBundleService) { this.entitlementService = Objects.requireNonNull(entitlementService, "entitlementService"); this.billingService = Objects.requireNonNull(billingService, "billingService"); this.memberRepo = Objects.requireNonNull(memberRepo, "memberRepo"); @@ -122,6 +128,8 @@ public class PaygWalletController { this.ledgerRepo = Objects.requireNonNull(ledgerRepo, "ledgerRepo"); this.shadowRepo = Objects.requireNonNull(shadowRepo, "shadowRepo"); this.userRepository = Objects.requireNonNull(userRepository, "userRepository"); + this.prepaidBundleService = + Objects.requireNonNull(prepaidBundleService, "prepaidBundleService"); } // --------------------------------------------------------------------------------------- @@ -187,6 +195,24 @@ public class PaygWalletController { ? buildMemberRows(teamId, snap.periodStart(), snap.periodEnd()) : List.of(); + // Prepaid bundles, aggregated across the team's in-term pools. Drawn ahead of the meter and + // kept out of the spend cap, so they're a separate dimension from the metered spend above. + PrepaidBundleService.PrepaidSummary prepaid = prepaidBundleService.summarize(teamId); + long prepaidRemaining = prepaid == null ? 0L : prepaid.unitsRemaining(); + long prepaidTotal = prepaid == null ? 0L : prepaid.unitsTotal(); + String prepaidExpiresAt = + prepaid == null || prepaid.expiresAt() == null + ? null + : ISO_DATE.format(prepaid.expiresAt().toLocalDate()); + // Prepaid while pools still have units to draw; once exhausted the meter is live again. + String billingMode = prepaidRemaining > 0 ? BILLING_MODE_PREPAID : BILLING_MODE_PAYG; + + // Per-credit rate for the bundle calculator — the bundle:processor price, NOT the metered + // per-doc rate. Resolved in the team's currency (USD fallback), null when the price is + // unsynced. + BigDecimal bundleRatePerCreditMinor = + billingService.resolveBundleRatePerCreditMinor(billing.currency()); + WalletSnapshotResponse body = new WalletSnapshotResponse( teamId, @@ -211,7 +237,12 @@ public class PaygWalletController { breakdowns.docs(), analytics.docsProcessed(), analytics.uniquePdfs(), - analytics.sizeMultiplierPdfs()); + analytics.sizeMultiplierPdfs(), + prepaidRemaining, + prepaidTotal, + prepaidExpiresAt, + billingMode, + bundleRatePerCreditMinor); return ResponseEntity.ok(body); } @@ -485,6 +516,11 @@ public class PaygWalletController { new CategoryBreakdown(0, 0, 0), 0, 0, - 0); + 0, + 0L, + 0L, + null, + BILLING_MODE_PAYG, + null); } } diff --git a/app/saas/src/main/java/stirling/software/saas/payg/api/WalletSnapshotResponse.java b/app/saas/src/main/java/stirling/software/saas/payg/api/WalletSnapshotResponse.java index 8147bd030c..8bf887a0ea 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/api/WalletSnapshotResponse.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/api/WalletSnapshotResponse.java @@ -55,6 +55,11 @@ import java.util.List; * @param members leader-only roster of team members + their per-member sub-caps. Empty for member * callers. * @param recent latest wallet-ledger entries (newest first) for the activity feed. + * @param bundleRatePerCreditMinor per-credit rate of the prepaid-bundle Stripe Price (lookup key + * {@code bundle:processor}) in minor units of {@code currency} (may be fractional); {@code + * null} when unresolved. The in-app bundle calculator multiplies its pool by this so its + * estimate matches the checkout edge fn's charge. Distinct from {@code pricePerDocMinor} (the + * metered per-document rate) — the two must not be conflated. */ public record WalletSnapshotResponse( Long teamId, @@ -79,7 +84,21 @@ public record WalletSnapshotResponse( CategoryBreakdown categoryDocs, int docsProcessedThisPeriod, int uniquePdfsThisPeriod, - int sizeMultiplierPdfsThisPeriod) { + int sizeMultiplierPdfsThisPeriod, + long prepaidUnitsRemaining, + long prepaidUnitsTotal, + String prepaidExpiresAt, + String billingMode, + BigDecimal bundleRatePerCreditMinor) { + + // Prepaid usage bundles, aggregated across the team's in-term pools (drawn ahead of the meter, + // outside the spend cap): + // prepaidUnitsRemaining — Σ units left across active pools (0 when exhausted / none) + // prepaidUnitsTotal — Σ capacity of in-term pools (the "X of Y used" denominator; 0 = no + // bundle this term, so the FE hides the prepaid card) + // prepaidExpiresAt — soonest term end (ISO date) for the countdown; null when no bundle + // billingMode — "prepaid" while prepaid units remain, else "payg" (the meter is + // live) // The count dimension, kept distinct from units (which now scale with file size): // categoryDocs — per-category INPUT-file counts (parallel to diff --git a/app/saas/src/main/java/stirling/software/saas/payg/billing/TeamBillingService.java b/app/saas/src/main/java/stirling/software/saas/payg/billing/TeamBillingService.java index 260efbd9ab..8bca25758a 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/billing/TeamBillingService.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/billing/TeamBillingService.java @@ -68,6 +68,14 @@ public class TeamBillingService { */ private static final String PAYG_LOOKUP_KEY = "plan:processor"; + /** + * Stripe Price {@code lookup_key} for the prepaid-bundle price — the per-credit rate the bundle + * calculator prices its pool at. A DIFFERENT price from {@link #PAYG_LOOKUP_KEY} (the metered + * per-document rate); the two must not be conflated, or the in-app estimate diverges from the + * amount the checkout edge fn actually charges (which bills against this same price). + */ + private static final String BUNDLE_LOOKUP_KEY = "bundle:processor"; + private final PaygTeamExtensionsRepository extensionsRepository; private final WalletPolicyRepository walletPolicyRepository; private final PricingPolicyService pricingPolicyService; @@ -254,6 +262,23 @@ public class TeamBillingService { .longValue()); } + /** + * Per-credit rate of the prepaid-bundle Stripe Price (lookup key {@code bundle:processor}) in + * {@code currency} (USD fallback) — the rate the in-app bundle calculator multiplies its pool + * by, so its estimate matches the amount the checkout edge fn charges (which bills the pool + * against this same price). Distinct from the metered {@code perDocMinor}; a bundle credit is + * one size-scaled run, priced per {@code unit_amount} of the bundle price. {@code null} when + * the rate can't be resolved (stripe schema absent, price unsynced) — the calculator then hides + * the figure and defers to the server total. + */ + public BigDecimal resolveBundleRatePerCreditMinor(String currency) { + return subscriptionDao + .findRateByLookupKey( + BUNDLE_LOOKUP_KEY, currency != null ? currency : DISPLAY_CURRENCY) + .map(StripeSubscriptionDao.PriceRate::perDocMinor) + .orElse(null); + } + /** * Inclusive-start / exclusive-end window for the calendar month — the monthly billing window * used when there's no Stripe subscription period to anchor on. diff --git a/app/saas/src/main/java/stirling/software/saas/payg/bundle/PrepaidBundle.java b/app/saas/src/main/java/stirling/software/saas/payg/bundle/PrepaidBundle.java new file mode 100644 index 0000000000..e2dff403a7 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/bundle/PrepaidBundle.java @@ -0,0 +1,105 @@ +package stirling.software.saas.payg.bundle; + +import java.io.Serializable; +import java.time.LocalDateTime; + +import org.hibernate.annotations.CreationTimestamp; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * A prepaid, expiring pool of PDF-process units bought up-front at a discount ("12 months for the + * price of 10"). Consumed after the team's free grant and before the meter (free -> prepaid -> + * metered); draws are booked to the {@code BOUGHT} ledger bucket, so a bundle never counts toward + * the spend cap or the Stripe meter. + * + *

    Carries only capacity + term + the Stripe link. The one-time amount and currency live on the + * Stripe Checkout Session / PaymentIntent referenced by {@link #stripeRef}; how many units a PDF + * costs comes from the team's pricing policy at charge time, not from the bundle. Status is + * derived, never stored (see {@link #isDrawable}). + * + *

    A team may hold several pools at once (top-ups); they are drawn FIFO by soonest {@link + * #expiresAt}. Unused units forfeit at expiry (no roll-over). + */ +@Entity +@Table( + name = "payg_prepaid_bundle", + // Declared here for ddl-auto (fresh schemas) and to document intent. The authoritative creator + // in production is the Supabase CLI migration 20260720000000_payg_prepaid_bundle, which builds + // the partial forms (WHERE units_remaining > 0 / WHERE stripe_ref IS NOT NULL). Flyway was + // retired for SaaS (#7100), so there is no migration twin — names match the CLI migration. + indexes = { + // Hot-path FIFO draw lookup — findDrawableForUpdate runs a locked read on every billable + // charge past the free grant; without it that degrades to a locked scan as the table grows. + @Index( + name = "idx_payg_prepaid_bundle_team_expiry", + columnList = "team_id, expires_at"), + // One pool per Stripe payment — the idempotency guard so a redelivered invoice.paid can't + // credit the same purchase twice. + @Index( + name = "uq_payg_prepaid_bundle_stripe_ref", + columnList = "stripe_ref", + unique = true), + }) +@NoArgsConstructor +@Getter +@Setter +public class PrepaidBundle implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "bundle_id") + private Long id; + + @Column(name = "team_id", nullable = false) + private Long teamId; + + /** Capacity granted at purchase — the denominator of the "X of Y used" meter. */ + @Column(name = "units_total", nullable = false) + private long unitsTotal; + + /** Live balance; pessimistic-locked on draw. */ + @Column(name = "units_remaining", nullable = false) + private long unitsRemaining; + + @Column(name = "purchased_at", nullable = false) + private LocalDateTime purchasedAt; + + /** {@code purchasedAt + 12 months}. Unused units forfeit after this instant. */ + @Column(name = "expires_at", nullable = false) + private LocalDateTime expiresAt; + + /** + * Stripe Checkout Session / PaymentIntent id for the one-time payment that created this pool. + * The amount + currency + receipt live on that object; a unique index makes the webhook credit + * idempotent. {@code null} only for pools seeded outside the purchase flow (tests/backfill). + */ + @Column(name = "stripe_ref", length = 128) + private String stripeRef; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + /** In-term (not yet expired) as of {@code now} — regardless of remaining balance. */ + public boolean isInTerm(LocalDateTime now) { + return expiresAt.isAfter(now); + } + + /** Has units left AND is still in term — i.e. a charge may draw from it. */ + public boolean isDrawable(LocalDateTime now) { + return unitsRemaining > 0 && isInTerm(now); + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/bundle/PrepaidBundleRepository.java b/app/saas/src/main/java/stirling/software/saas/payg/bundle/PrepaidBundleRepository.java new file mode 100644 index 0000000000..48d29517d6 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/bundle/PrepaidBundleRepository.java @@ -0,0 +1,55 @@ +package stirling.software.saas.payg.bundle; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import jakarta.persistence.LockModeType; + +@Repository +public interface PrepaidBundleRepository extends JpaRepository { + + /** + * A team's still-drawable pools (units left, not expired), soonest-expiring first, locked for + * the draw transaction. Mirrors {@code PaygTeamExtensionsRepository.findByIdForUpdate}: the + * PESSIMISTIC_WRITE lock serialises concurrent charges for the same team so two jobs can't both + * draw the same remaining unit, keeping the per-job {@code bundle_units_consumed} split exact. + * Drawn FIFO — the caller depletes the earliest-expiring pool first so capacity is used before + * it lapses. Filters on {@code expires_at} so an expired pool is never drawn even if the expiry + * sweep hasn't run (lazy expiry). + */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query( + "SELECT b FROM PrepaidBundle b WHERE b.teamId = :teamId AND b.unitsRemaining > 0" + + " AND b.expiresAt > :now ORDER BY b.expiresAt ASC") + List findDrawableForUpdate( + @Param("teamId") Long teamId, @Param("now") LocalDateTime now); + + /** + * A team's in-term pools (not yet expired), soonest-expiring first — read-only, for the wallet + * snapshot. Includes exhausted-but-in-term pools so the "X of Y used" meter keeps the right + * denominator for the current term. Small per team; the service aggregates in Java. + */ + @Query( + "SELECT b FROM PrepaidBundle b WHERE b.teamId = :teamId AND b.expiresAt > :now" + + " ORDER BY b.expiresAt ASC") + List findInTerm(@Param("teamId") Long teamId, @Param("now") LocalDateTime now); + + /** + * A team's in-term pools (not yet expired), soonest-expiring first, locked — for the refund + * restore path. Unlike {@link #findDrawableForUpdate} this includes pools already drawn to zero + * (that's exactly where a just-drawn charge's units go back), capped at {@code units_total} by + * the caller. + */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query( + "SELECT b FROM PrepaidBundle b WHERE b.teamId = :teamId AND b.expiresAt > :now" + + " ORDER BY b.expiresAt ASC") + List findInTermForUpdate( + @Param("teamId") Long teamId, @Param("now") LocalDateTime now); +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/bundle/PrepaidBundleService.java b/app/saas/src/main/java/stirling/software/saas/payg/bundle/PrepaidBundleService.java new file mode 100644 index 0000000000..1faefc8510 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/bundle/PrepaidBundleService.java @@ -0,0 +1,148 @@ +package stirling.software.saas.payg.bundle; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Draws down and restores prepaid unit pools ({@link PrepaidBundle}). Sits between the free grant + * and the meter in the charge pipeline (free → prepaid → metered): a charge first spends the team's + * free grant, then this service spends prepaid pools FIFO by soonest expiry, and only the remainder + * meters to Stripe / counts against the cap. + * + *

    Methods participate in the caller's transaction (default REQUIRED propagation), so the draw's + * pessimistic write lock is held for the whole {@code openProcess} transaction — the same + * discipline as the free-grant deduction, so concurrent same-team charges can't both spend the last + * unit. + */ +@Service +@Profile("saas") +@RequiredArgsConstructor +@Slf4j +public class PrepaidBundleService { + + private final PrepaidBundleRepository bundleRepository; + + /** + * Spend up to {@code units} from the team's drawable pools, earliest-expiring first, returning + * how many were actually drawn (0..{@code units}). Pools past their term are skipped (lazy + * expiry — the {@code expires_at} filter means an expired pool is never drawn even before the + * expiry sweep runs). A partial draw is normal: the remainder meters. + */ + @Transactional + public int draw(Long teamId, int units) { + if (teamId == null || units <= 0) { + return 0; + } + List pools = + bundleRepository.findDrawableForUpdate(teamId, LocalDateTime.now()); + int remaining = units; + int drawn = 0; + for (PrepaidBundle pool : pools) { + if (remaining <= 0) { + break; + } + long take = Math.min(remaining, pool.getUnitsRemaining()); + if (take <= 0) { + continue; + } + pool.setUnitsRemaining(pool.getUnitsRemaining() - take); + drawn += (int) take; + remaining -= (int) take; + } + if (drawn > 0) { + bundleRepository.saveAll(pools); + } + return drawn; + } + + /** + * Return {@code units} to the team's in-term pools on a refund, earliest-expiring first, + * capping each pool at its original {@code units_total}. Best-effort: units that can't be + * placed (all in-term pools already at capacity, or every pool expired in the tiny window since + * the draw) are dropped with a debug log — first-step-failure refunds are effectively + * immediate, so in practice the drawn-from pools are still open and take the units straight + * back. + */ + @Transactional + public int restore(Long teamId, int units) { + if (teamId == null || units <= 0) { + return 0; + } + List pools = + bundleRepository.findInTermForUpdate(teamId, LocalDateTime.now()); + int remaining = units; + for (PrepaidBundle pool : pools) { + if (remaining <= 0) { + break; + } + long headroom = pool.getUnitsTotal() - pool.getUnitsRemaining(); + if (headroom <= 0) { + continue; + } + long give = Math.min(remaining, headroom); + pool.setUnitsRemaining(pool.getUnitsRemaining() + give); + remaining -= (int) give; + } + int restored = units - remaining; + if (restored > 0) { + bundleRepository.saveAll(pools); + } + if (remaining > 0) { + log.debug( + "restore: {} of {} prepaid units couldn't be placed for team {} (no in-term" + + " headroom)", + remaining, + units, + teamId); + } + return restored; + } + + /** + * Aggregate a team's in-term pools for the wallet snapshot: total remaining + total capacity + + * soonest expiry. Includes exhausted-but-in-term pools so the "X of Y used" meter keeps the + * right denominator for the term. Returns {@code null} when the team has no in-term bundle. + */ + @Transactional(readOnly = true) + public PrepaidSummary summarize(Long teamId) { + if (teamId == null) { + return null; + } + List pools = bundleRepository.findInTerm(teamId, LocalDateTime.now()); + if (pools.isEmpty()) { + return null; + } + long remaining = 0L; + long total = 0L; + LocalDateTime soonest = null; + for (PrepaidBundle pool : pools) { + remaining += pool.getUnitsRemaining(); + total += pool.getUnitsTotal(); + if (soonest == null || pool.getExpiresAt().isBefore(soonest)) { + soonest = pool.getExpiresAt(); + } + } + return new PrepaidSummary(remaining, total, soonest); + } + + /** + * Total prepaid units a team can still draw right now (in-term pools only); 0 when it has none. + * The entitlement gate uses this so a team with a live prepaid pool stays entitled even without + * a metered subscription — paid-for capacity is usable on its own merit. + */ + @Transactional(readOnly = true) + public long prepaidRemainingUnits(Long teamId) { + PrepaidSummary summary = summarize(teamId); + return summary == null ? 0L : summary.unitsRemaining(); + } + + /** Aggregated prepaid balance for a team's in-term pools. */ + public record PrepaidSummary(long unitsRemaining, long unitsTotal, LocalDateTime expiresAt) {} +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/charge/JobChargeService.java b/app/saas/src/main/java/stirling/software/saas/payg/charge/JobChargeService.java index b71caddbee..2fc175f224 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/charge/JobChargeService.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/charge/JobChargeService.java @@ -17,6 +17,7 @@ import org.springframework.web.multipart.MultipartFile; import lombok.extern.slf4j.Slf4j; +import stirling.software.saas.payg.bundle.PrepaidBundleService; import stirling.software.saas.payg.docs.DocumentClassifier; import stirling.software.saas.payg.docs.DocumentMetrics; import stirling.software.saas.payg.job.JobContext; @@ -68,6 +69,7 @@ public class JobChargeService { private final PaygTeamExtensionsRepository teamExtensionsRepository; private final PaygMeterReportingService meterReportingService; private final WalletLedgerRepository ledgerRepository; + private final PrepaidBundleService prepaidBundleService; public JobChargeService( JobService jobService, @@ -77,7 +79,8 @@ public class JobChargeService { ProcessingJobRepository jobRepository, PaygTeamExtensionsRepository teamExtensionsRepository, PaygMeterReportingService meterReportingService, - WalletLedgerRepository ledgerRepository) { + WalletLedgerRepository ledgerRepository, + PrepaidBundleService prepaidBundleService) { this.jobService = Objects.requireNonNull(jobService, "jobService"); this.policyService = Objects.requireNonNull(policyService, "policyService"); this.classifier = Objects.requireNonNull(classifier, "classifier"); @@ -88,6 +91,8 @@ public class JobChargeService { this.meterReportingService = Objects.requireNonNull(meterReportingService, "meterReportingService"); this.ledgerRepository = Objects.requireNonNull(ledgerRepository, "ledgerRepository"); + this.prepaidBundleService = + Objects.requireNonNull(prepaidBundleService, "prepaidBundleService"); } /** @@ -128,14 +133,20 @@ public class JobChargeService { job.setDocUnits(units); int freeUsed = consumeFreeGrant(ctx, units); - recordShadowRow(ctx, job.getId(), policy.getId(), units, freeUsed); + // Prepaid bundle is the tier between the free grant and the meter (free → prepaid → + // metered); draw only what the free grant didn't cover. + int bundleUsed = drawBundle(ctx, units - freeUsed); + recordShadowRow(ctx, job.getId(), policy.getId(), units, freeUsed, bundleUsed); // doc_count + fingerprint were set on the fresh job by JobService.openFresh; carry them - // onto the ledger DEBIT so usage analytics query one table. + // onto the ledger DEBIT so usage analytics query one table. Bundle-drawn units are netted + // out of the ledger amount so they never count against the spend cap (they're prepaid, not + // metered CYCLE spend). recordLedgerDebit( ctx, job.getId(), policy.getId(), units, + bundleUsed, job.getDocCount(), job.getDocumentFingerprint()); @@ -179,12 +190,14 @@ public class JobChargeService { ProcessingJob job = jobService.open(jobCtx, chargeUnits); int freeUsed = consumeFreeGrant(ctx, chargeUnits); - recordShadowRow(ctx, job.getId(), policy.getId(), chargeUnits, freeUsed); + int bundleUsed = drawBundle(ctx, chargeUnits - freeUsed); + recordShadowRow(ctx, job.getId(), policy.getId(), chargeUnits, freeUsed, bundleUsed); recordLedgerDebit( ctx, job.getId(), policy.getId(), chargeUnits, + bundleUsed, job.getDocCount(), job.getDocumentFingerprint()); @@ -223,6 +236,24 @@ public class JobChargeService { return freeUsed; } + /** + * Draw this job's prepaid portion from the team's bundles — the tier after the free grant and + * before the meter — returning the units taken (0..{@code units}). Same guard as {@link + * #consumeFreeGrant}; {@link PrepaidBundleService#draw} holds a pessimistic pool lock inside + * this {@code openProcess} transaction so concurrent same-team charges split the pools exactly. + * Any remainder is the metered paid portion. + */ + private int drawBundle(ChargeContext ctx, int units) { + BillingCategory category = ctx.billingCategory(); + if (category == null || category == BillingCategory.BYPASSED || ctx.ownerTeamId() == null) { + return 0; + } + if (units <= 0) { + return 0; + } + return prepaidBundleService.draw(ctx.ownerTeamId(), units); + } + /** * The live spend record. Everything the customer-facing side reads — the wallet endpoint's * {@code spendUnitsThisPeriod}, the per-category breakdown ({@code wallet_category_summary} @@ -237,18 +268,27 @@ public class JobChargeService { java.util.UUID jobId, Long policyId, int units, + int bundleUnitsConsumed, int docCount, String documentFingerprint) { BillingCategory category = ctx.billingCategory(); if (category == null || category == BillingCategory.BYPASSED) { return; } + // Prepaid (bundle) units are netted out of the metered CYCLE spend, so the cap + the Stripe + // meter don't charge again for capacity already bought up front. (Free-grant units are NOT + // netted here — they still count toward CYCLE/cap; only the bundle split is removed.) + // doc_count stays the full count so the PDF is still counted once by usage analytics (which + // sum doc_count over DEBIT rows, bucket-agnostic). A fully-prepaid charge therefore writes + // a + // 0-unit DEBIT that still counts toward "PDFs processed". + int meteredUnits = units - bundleUnitsConsumed; WalletLedgerEntry entry = new WalletLedgerEntry(); entry.setTeamId(ctx.ownerTeamId()); entry.setActorUserId(ctx.ownerUserId()); entry.setEntryType(LedgerEntryType.DEBIT); entry.setBucket(LedgerBucket.CYCLE); - entry.setAmountUnits(-units); + entry.setAmountUnits(-meteredUnits); entry.setReferenceType(ReferenceType.JOB); entry.setReferenceId(jobId.toString()); entry.setPolicyId(policyId); @@ -297,15 +337,17 @@ public class JobChargeService { java.util.UUID jobId, Long policyId, int units, - int freeUnitsConsumed) { + int freeUnitsConsumed, + int bundleUnitsConsumed) { PaygShadowCharge row = new PaygShadowCharge(); row.setTeamId(ctx.ownerTeamId()); row.setJobId(jobId); row.setPolicyId(policyId); row.setPaygUnits(units); - // Free-vs-paid split fixed at charge time: paid (metered) = paygUnits - freeUnitsConsumed, - // and a refund restores freeUnitsConsumed to the team's grant. + // Free/prepaid/paid split fixed at charge time: metered = paygUnits - freeUnitsConsumed - + // bundleUnitsConsumed. A refund restores each portion to its source (grant / pools). row.setFreeUnitsConsumed(freeUnitsConsumed); + row.setBundleUnitsConsumed(bundleUnitsConsumed); // No legacy comparison: the legacy credit engine has been removed, so diff stays at 0. row.setLegacyCreditsCharged(0); row.setDiffPct(0); @@ -349,11 +391,15 @@ public class JobChargeService { // on the CHARGED→REFUNDED transition) prevents double-credits on re-invocation. BillingCategory category = row.getBillingCategory(); if (category != null && category != BillingCategory.BYPASSED) { + int bundleConsumed = + row.getBundleUnitsConsumed() == null ? 0 : row.getBundleUnitsConsumed(); WalletLedgerEntry refund = new WalletLedgerEntry(); refund.setTeamId(row.getTeamId()); refund.setEntryType(LedgerEntryType.REFUND); refund.setBucket(LedgerBucket.CYCLE); - refund.setAmountUnits(row.getPaygUnits()); + // Mirror the reduced DEBIT: prepaid units never entered the CYCLE spend, so the + // compensating credit is the metered portion only (paygUnits − bundle). + refund.setAmountUnits(row.getPaygUnits() - bundleConsumed); refund.setReferenceType(ReferenceType.JOB); refund.setReferenceId(jobId.toString()); refund.setPolicyId(row.getPolicyId()); @@ -367,6 +413,11 @@ public class JobChargeService { if (freeConsumed > 0 && row.getTeamId() != null) { teamExtensionsRepository.restoreFreeUnits(row.getTeamId(), freeConsumed); } + // Return the prepaid units this job drew to the team's pools (best-effort — see + // PrepaidBundleService.restore). + if (bundleConsumed > 0 && row.getTeamId() != null) { + prepaidBundleService.restore(row.getTeamId(), bundleConsumed); + } } } } @@ -506,10 +557,14 @@ public class JobChargeService { // free grant is app-side only (Stripe's Prices are plain per-unit, no free tier), so the // free units were already withheld when this row's free_units_consumed was set. int freeConsumed = row.getFreeUnitsConsumed() == null ? 0 : row.getFreeUnitsConsumed(); - int paidUnits = units - freeConsumed; + int bundleConsumed = + row.getBundleUnitsConsumed() == null ? 0 : row.getBundleUnitsConsumed(); + // Metered = units beyond BOTH the free grant and the prepaid bundle; only this hits Stripe. + int paidUnits = units - freeConsumed - bundleConsumed; if (paidUnits <= 0) { log.debug( - "close({}): all {} units came from the free grant → no meter event", + "close({}): all {} units covered by free grant + prepaid bundle → no meter" + + " event", jobId, units); return; diff --git a/app/saas/src/main/java/stirling/software/saas/payg/charge/SaasClassificationRunBiller.java b/app/saas/src/main/java/stirling/software/saas/payg/charge/SaasClassificationRunBiller.java new file mode 100644 index 0000000000..a4536d6c23 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/charge/SaasClassificationRunBiller.java @@ -0,0 +1,49 @@ +package stirling.software.saas.payg.charge; + +import org.springframework.context.annotation.Profile; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; + +import stirling.software.proprietary.classification.ClassificationRunBiller; +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken; +import stirling.software.proprietary.security.model.User; +import stirling.software.saas.payg.model.BillingCategory; +import stirling.software.saas.payg.model.JobSource; +import stirling.software.saas.payg.model.ProcessType; +import stirling.software.saas.util.AuthenticationUtils; + +/** + * Charges one PAYG unit per document as an AUTOMATION job, matching what a server-side classify + * policy step bills. + */ +@Component +@Profile("saas") +@RequiredArgsConstructor +public class SaasClassificationRunBiller implements ClassificationRunBiller { + + private final UserRepository userRepository; + private final JobChargeService jobChargeService; + + @Override + public void recordClassificationRun(int documentCount) { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + User user = AuthenticationUtils.getCurrentUser(auth, userRepository); + if (user == null || user.getTeam() == null) { + return; + } + JobSource source = + auth instanceof ApiKeyAuthenticationToken ? JobSource.API : JobSource.WEB; + ChargeContext ctx = + new ChargeContext( + user.getId(), + user.getTeam().getId(), + source, + ProcessType.AUTOMATION, + BillingCategory.AUTOMATION); + jobChargeService.chargeStandalone(ctx, Math.max(1, documentCount)); + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementGuard.java b/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementGuard.java index 54ebd24c04..ae8474fa42 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementGuard.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementGuard.java @@ -31,6 +31,7 @@ import jakarta.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; import stirling.software.common.annotations.AutoJobPostMapping; +import stirling.software.proprietary.policy.controller.PolicyRunRoutes; import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken; import stirling.software.proprietary.security.model.User; @@ -46,9 +47,11 @@ import stirling.software.saas.util.AuthenticationUtils; * *

    Scope: routes whose handler method (or bean type) carries either {@link AutoJobPostMapping} * (multipart tool POSTs) or {@link RequiresFeature} (AI controllers, future non-multipart gated - * routes). Admin / info / config endpoints are excluded by the path-pattern in {@code - * PaygWebMvcConfig} and are additionally skipped here when they carry neither annotation, so non- - * billable infra never trips the guard. + * routes), plus two proprietary route families recognised by path since they can't carry the + * annotation: AI document tools ({@link AiToolRoutes} gated on AI_SUPPORT) and policy execute + * endpoints ({@link PolicyRunRoutes} gated on AUTOMATION). Admin / info / config endpoints are + * excluded by the path-pattern in {@code PaygWebMvcConfig} and are additionally skipped here when + * they carry no annotation and match no such family, so non-billable infra never trips the guard. * *

    Decision matrix: * @@ -137,13 +140,20 @@ public class EntitlementGuard implements HandlerInterceptor { // @RequiresFeature; recognise them by path so they're gated on AI_SUPPORT — see // AiToolRoutes and PaygChargeInterceptor, which classify the same routes as AI. boolean aiToolRoute = AiToolRoutes.matches(request); - if (!hasAutoJobPostMapping && !hasRequiresFeature && !aiToolRoute) { + // Policy execute routes (/api/v1/policies/**/run etc.) are proprietary and can't carry + // @RequiresFeature; recognise them by path and gate on AUTOMATION (mirrors aiToolRoute). + boolean policyRunRoute = PolicyRunRoutes.matches(request); + if (!hasAutoJobPostMapping && !hasRequiresFeature && !aiToolRoute && !policyRunRoute) { skippedNoAnnotationCounter.increment(); return true; } FeatureGate[] required = - aiToolRoute ? new FeatureGate[] {FeatureGate.AI_SUPPORT} : resolveRequiredGates(hm); + aiToolRoute + ? new FeatureGate[] {FeatureGate.AI_SUPPORT} + : policyRunRoute + ? new FeatureGate[] {FeatureGate.AUTOMATION} + : resolveRequiredGates(hm); Authentication auth = SecurityContextHolder.getContext().getAuthentication(); boolean anonymous = isAnonymous(auth); diff --git a/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementService.java b/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementService.java index 397a714bfa..f2863a56d5 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementService.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementService.java @@ -18,6 +18,7 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.saas.payg.billing.TeamBillingContext; import stirling.software.saas.payg.billing.TeamBillingService; +import stirling.software.saas.payg.bundle.PrepaidBundleService; import stirling.software.saas.payg.cap.CapEvaluator; import stirling.software.saas.payg.cap.CapEvaluator.Evaluation; import stirling.software.saas.payg.model.EntitlementState; @@ -51,17 +52,21 @@ public class EntitlementService { private final TeamBillingService teamBillingService; private final WalletPolicyRepository walletPolicyRepository; private final WalletLedgerRepository ledgerRepository; + private final PrepaidBundleService prepaidBundleService; private final Cache snapshotCache; public EntitlementService( TeamBillingService teamBillingService, WalletPolicyRepository walletPolicyRepository, - WalletLedgerRepository ledgerRepository) { + WalletLedgerRepository ledgerRepository, + PrepaidBundleService prepaidBundleService) { this.teamBillingService = Objects.requireNonNull(teamBillingService, "teamBillingService"); this.walletPolicyRepository = Objects.requireNonNull(walletPolicyRepository, "walletPolicyRepository"); this.ledgerRepository = Objects.requireNonNull(ledgerRepository, "ledgerRepository"); + this.prepaidBundleService = + Objects.requireNonNull(prepaidBundleService, "prepaidBundleService"); this.snapshotCache = Caffeine.newBuilder() .maximumSize(CACHE_MAX_SIZE) @@ -135,21 +140,45 @@ public class EntitlementService { long periodSpend = signedNet < 0 ? -signedNet : 0L; Long cap = billing.monthlyCapDocUnits(); eval = CapEvaluator.evaluate(periodSpend, cap, warnAtPct, degradeAtPct, degradedSet); + // A live prepaid pool sits OUTSIDE the metered cap: bundle draws are netted out of + // period + // spend in JobChargeService, so they never count toward it. So a subscribed team that + // has + // hit its cap but still holds prepaid capacity stays fully entitled — the job draws + // from + // the pool, not the meter, and the cap is irrelevant while the pool has balance. + // Queried + // lazily (only when the cap would otherwise degrade) to keep the under-cap path off the + // prepaid table. + if (eval.state() == EntitlementState.DEGRADED + && prepaidBundleService.prepaidRemainingUnits(teamId) > 0L) { + eval = fullyEntitledOnPrepaid(); + } snapshotSpend = periodSpend; snapshotCap = cap; } else { - // Unsubscribed: gate on the one-time lifetime free grant. Exhausted (remaining ≤ 0, or - // no grant configured) → DEGRADED so billable categories hard-stop; otherwise evaluate - // the warn/degrade band on used-of-grant. + // Unsubscribed: gate on the one-time lifetime free grant, then on a prepaid pool. While + // the free grant has balance, evaluate the warn/degrade band on used-of-grant. Once the + // free grant is spent, a live prepaid pool keeps the team fully entitled — paid-for + // capacity is usable on its own merit, independent of any metered subscription (the + // pool + // is drawn in JobChargeService; only the metered remainder stays gated on the sub). + // Only + // when BOTH the free grant and prepaid are exhausted do billable categories hard-stop. long grant = billing.freeGrantUnits(); long remaining = billing.freeRemainingUnits(); long used = Math.max(0L, grant - remaining); if (remaining <= 0L) { - eval = - new Evaluation( - EntitlementState.DEGRADED, - degradedSet, - CapEvaluator.gatesFor(degradedSet)); + long prepaidRemaining = prepaidBundleService.prepaidRemainingUnits(teamId); + if (prepaidRemaining > 0L) { + eval = fullyEntitledOnPrepaid(); + } else { + eval = + new Evaluation( + EntitlementState.DEGRADED, + degradedSet, + CapEvaluator.gatesFor(degradedSet)); + } } else { eval = CapEvaluator.evaluate(used, grant, warnAtPct, degradeAtPct, degradedSet); } @@ -168,6 +197,17 @@ public class EntitlementService { billing.subscribed()); } + /** + * A team holding a live prepaid pool is fully entitled regardless of the free-grant or the + * monthly-cap gate: the pool is drawn in the charge pipeline and its units are netted out of + * metered spend, so it sits OUTSIDE both gates. All feature gates are on. Shared by the + * unsubscribed (grant-exhausted) and subscribed (over-cap) branches. + */ + private static Evaluation fullyEntitledOnPrepaid() { + return new Evaluation( + EntitlementState.FULL, FeatureSet.FULL, CapEvaluator.gatesFor(FeatureSet.FULL)); + } + /** * Inclusive-start / exclusive-end window for the calendar-month period. Test seam — takes a * clock value so tests don't race the calendar boundary. The live snapshot window comes from diff --git a/app/saas/src/main/java/stirling/software/saas/payg/policy/PricingPolicy.java b/app/saas/src/main/java/stirling/software/saas/payg/policy/PricingPolicy.java index 9581177369..c9cda11831 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/policy/PricingPolicy.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/policy/PricingPolicy.java @@ -114,6 +114,21 @@ public class PricingPolicy implements Serializable { @Column(name = "stripe_price_id", nullable = false, length = 128) private Set stripePriceIds = new HashSet<>(); + /** + * One-time Stripe Price id for prepaid-bundle checkout — same {@code unit_amount} as the + * metered price (Stripe {@code currency_options} cover all currencies on one Price). Read by + * the create-payg-bundle-checkout edge fn via {@code payg_get_bundle_checkout_context}. Null = + * bundles not offered for this policy. Money lives in Stripe; this is just the handle. + */ + @Column(name = "bundle_stripe_price_id", length = 128) + private String bundleStripePriceId; + + // No bundle_coupon_id field: the 12-for-10 discount is minted per-quote as an inline amount_off + // coupon by the create-payg-bundle-quote edge fn (computed from the bundle Price), so the + // pre-made percent coupon this policy used to carry is no longer consulted by anything. The + // column still exists (payg_get_bundle_pricing returns it) and is dropped in a later cleanup; + // ddl-auto=update never drops columns, so removing the mapping here is safe. + /** * Exactly one row in the table has {@code is_default = true}; enforced by partial unique idx. */ diff --git a/app/saas/src/main/java/stirling/software/saas/payg/shadow/PaygShadowCharge.java b/app/saas/src/main/java/stirling/software/saas/payg/shadow/PaygShadowCharge.java index 57aea1a0e4..6a00ea6d18 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/shadow/PaygShadowCharge.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/shadow/PaygShadowCharge.java @@ -67,6 +67,20 @@ public class PaygShadowCharge implements Serializable { @Column(name = "free_units_consumed", nullable = false) private Integer freeUnitsConsumed = 0; + /** + * How many of {@link #paygUnits} were drawn from prepaid bundles (the {@code BOUGHT} bucket) at + * charge time — the tier between the free grant and the meter. The paid (Stripe-metered) + * portion is {@code paygUnits - freeUnitsConsumed - bundleUnitsConsumed}; a refund restores + * this many units to the pool(s) they came from. {@code 0} for pre-bundle rows and jobs that + * drew none. The {@code columnDefinition} default keeps the ddl-auto ADD COLUMN safe on the + * populated table. + */ + @Column( + name = "bundle_units_consumed", + nullable = false, + columnDefinition = "integer not null default 0") + private Integer bundleUnitsConsumed = 0; + @Column(name = "legacy_credits_charged", nullable = false) private Integer legacyCreditsCharged; diff --git a/app/saas/src/main/java/stirling/software/saas/payg/wallet/WalletLedgerEntry.java b/app/saas/src/main/java/stirling/software/saas/payg/wallet/WalletLedgerEntry.java index fab6647b2c..4b64629660 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/wallet/WalletLedgerEntry.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/wallet/WalletLedgerEntry.java @@ -87,9 +87,13 @@ public class WalletLedgerEntry implements Serializable { private Integer docCount = 1; /** - * SHA-256 of this entry's input file set; {@code COUNT(DISTINCT ...)} over a period gives - * unique PDFs processed. {@code null} for aggregate/system entries (grants, linked-instance - * sync) that don't map to a single document set. + * SHA-256 of this entry's input file set (the charge's whole input list, sorted). + * {@code COUNT(DISTINCT ...)} over a period approximates unique PDFs processed. Exact within a + * run (a file's chain/split steps share the run's charge, so one fingerprint), and for the + * single-input common case; but the same file reused across different groupings — e.g. + * standalone, then later merged as {A,B} — yields different set-fingerprints and is counted + * once per grouping. {@code null} for aggregate/system entries (grants, linked-instance sync) + * that don't map to a single document set. */ @Column(name = "document_fingerprint", length = 64) private String documentFingerprint; diff --git a/app/saas/src/main/java/stirling/software/saas/security/SupabaseAuthenticationFilter.java b/app/saas/src/main/java/stirling/software/saas/security/SupabaseAuthenticationFilter.java index aa678f7cbf..a4b2366379 100644 --- a/app/saas/src/main/java/stirling/software/saas/security/SupabaseAuthenticationFilter.java +++ b/app/saas/src/main/java/stirling/software/saas/security/SupabaseAuthenticationFilter.java @@ -18,6 +18,7 @@ import java.util.Map; import java.util.Optional; import java.util.UUID; +import org.slf4j.MDC; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; @@ -43,6 +44,8 @@ import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken; import stirling.software.proprietary.security.model.AuthenticationType; import stirling.software.proprietary.security.model.Authority; import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.service.ApiKeyAuthenticationService; +import stirling.software.proprietary.security.service.ApiKeyAuthenticationService.ApiKeyAuthentication; import stirling.software.proprietary.security.service.TeamService; import stirling.software.proprietary.security.service.UserService; import stirling.software.saas.model.AmrMethod; @@ -65,6 +68,7 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter { private final SupabaseUserService supabaseUserService; private final SaasTeamService saasTeamService; private final JwtDecoder jwtDecoder; + private final ApiKeyAuthenticationService apiKeyAuthenticationService; private final AuthenticationEntryPoint authenticationEntryPoint = new BearerTokenAuthenticationEntryPoint(); @@ -73,12 +77,14 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter { UserService userService, SupabaseUserService supabaseUserService, SaasTeamService saasTeamService, - JwtDecoder jwtDecoder) { + JwtDecoder jwtDecoder, + ApiKeyAuthenticationService apiKeyAuthenticationService) { this.teamService = teamService; this.userService = userService; this.supabaseUserService = supabaseUserService; this.saasTeamService = saasTeamService; this.jwtDecoder = jwtDecoder; + this.apiKeyAuthenticationService = apiKeyAuthenticationService; } @Override @@ -86,6 +92,9 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter { HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + // Start clean so a pooled thread can't inherit a prior request's API-key label. + MDC.remove(ApiKeyAuthenticationService.AUDIT_LABEL_MDC_KEY); + if (isStaticResource(request.getContextPath(), request.getRequestURI())) { filterChain.doFilter(request, response); return; @@ -262,10 +271,8 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter { user.setUsername(supabaseUser.getEmail()); } try { - User saved = userService.saveUser(user); // Give the account its own team rather than the shared Default team. - saved.setTeam(saasTeamService.ensurePersonalTeam(saved)); - return saved; + return saasTeamService.saveUserWithPersonalTeam(user); } catch (DataIntegrityViolationException e) { log.warn( "Email collision upgrading anonymous user {} to {}: {}", @@ -363,36 +370,22 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter { throw new AuthenticationFailureException("Failed to create SupabaseUser", e); } - User savedUser; - boolean weCreatedThisUser = true; + // Guests get NO team: the editor is free and needs none. Everyone else is provisioned + // atomically, so a user visible to a parallel request always already has one. try { - savedUser = userService.saveUser(newUser); + return isAnonymous(jwt) + ? userService.saveUser(newUser) + : saasTeamService.saveUserWithPersonalTeam(newUser); } catch (DataIntegrityViolationException dup) { // Parallel filter won the race; fetch the winning row. - weCreatedThisUser = false; - savedUser = - userService - .findBySupabaseId(supabaseId) - .orElseThrow( - () -> - new AuthenticationFailureException( - "User creation conflict, but unable to find existing user", - dup)); + return userService + .findBySupabaseId(supabaseId) + .orElseThrow( + () -> + new AuthenticationFailureException( + "User creation conflict, but unable to find existing user", + dup)); } - - // Only the DB-race winner runs first-time init; the losers skip it. - if (weCreatedThisUser) { - try { - savedUser.setTeam(saasTeamService.ensurePersonalTeam(savedUser)); - } catch (Exception e) { - log.warn( - "Failed to create personal team for new user {} ({}): {}", - LogRedactionUtils.redactSupabaseId(supabaseId), - LogRedactionUtils.redactEmail(savedUser.getUsername()), - e.getMessage()); - } - } - return savedUser; } private boolean apiKeyAuthenticated(HttpServletRequest request) throws AuthenticationException { @@ -406,16 +399,22 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter { return false; } - Optional user = userService.getUserByApiKey(apiKey); - if (user.isEmpty()) { + // Resolves the multi-key table then the legacy key, records per-key usage, and yields a + // label for the processor's document-source attribution. + Optional resolved = apiKeyAuthenticationService.authenticate(apiKey); + if (resolved.isEmpty()) { throw new InvalidBearerTokenException("Invalid API Key."); } + User user = resolved.get().user(); - userService.trackApiKeyFirstUse(user.get()); + userService.trackApiKeyFirstUse(user); ApiKeyAuthenticationToken authToken = - new ApiKeyAuthenticationToken(user.get(), apiKey, user.get().getAuthorities()); + new ApiKeyAuthenticationToken(user, apiKey, resolved.get().authorities()); SecurityContextHolder.getContext().setAuthentication(authToken); + if (resolved.get().auditLabel() != null) { + MDC.put(ApiKeyAuthenticationService.AUDIT_LABEL_MDC_KEY, resolved.get().auditLabel()); + } return true; } diff --git a/app/saas/src/main/java/stirling/software/saas/security/SupabaseSecurityConfig.java b/app/saas/src/main/java/stirling/software/saas/security/SupabaseSecurityConfig.java index 5fe27a11e8..ce0c937d6e 100644 --- a/app/saas/src/main/java/stirling/software/saas/security/SupabaseSecurityConfig.java +++ b/app/saas/src/main/java/stirling/software/saas/security/SupabaseSecurityConfig.java @@ -48,6 +48,7 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.util.RequestUriUtils; import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.service.ApiKeyAuthenticationService; import stirling.software.proprietary.security.service.TeamService; import stirling.software.proprietary.security.service.UserService; import stirling.software.saas.accountlink.DeviceCredentialAuthenticationFilter; @@ -69,6 +70,7 @@ public class SupabaseSecurityConfig { private final SupabaseUserService supabaseUserService; private final SaasTeamService saasTeamService; private final ApplicationProperties applicationProperties; + private final ApiKeyAuthenticationService apiKeyAuthenticationService; @Value("${app.supabase.issuer:}") private String issuer; @@ -125,7 +127,8 @@ public class SupabaseSecurityConfig { userService, supabaseUserService, saasTeamService, - jwtDecoder), + jwtDecoder, + apiKeyAuthenticationService), BearerTokenAuthenticationFilter.class) .exceptionHandling( ex -> diff --git a/app/saas/src/main/java/stirling/software/saas/service/SaasTeamService.java b/app/saas/src/main/java/stirling/software/saas/service/SaasTeamService.java index 94996ca01c..0038cca42a 100644 --- a/app/saas/src/main/java/stirling/software/saas/service/SaasTeamService.java +++ b/app/saas/src/main/java/stirling/software/saas/service/SaasTeamService.java @@ -46,23 +46,25 @@ public class SaasTeamService { private final UserRoleService userRoleService; private final SaasTeamExtensionService saasTeamExtensionService; private final SaasTeamExtensionsRepository saasTeamExtensionsRepository; + private final SaasUserExtensionService saasUserExtensionService; private final LinkedInstanceRepository linkedInstanceRepository; private final stirling.software.proprietary.security.service.UserService userService; - private final stirling.software.proprietary.access.repository.ResourceGrantRepository - resourceGrantRepository; - private final stirling.software.proprietary.integration.repository.IntegrationConfigRepository - integrationConfigRepository; - - // Team-owned integration configs + team grants FK the teams row; purge before deleting a team. - private void purgeTeamOwnedResources(Long teamId) { - integrationConfigRepository.deleteByOwnerTeam_Id(teamId); - resourceGrantRepository.deleteByPrincipalTypeAndPrincipalId( - stirling.software.proprietary.access.model.PrincipalType.TEAM, teamId); - } public static final String DEFAULT_TEAM_NAME = "Default"; public static final String INTERNAL_TEAM_NAME = "Internal"; + /** + * Persist a user and their personal team atomically: an account with no team has no portal + * access and no way to acquire one, so a teamless user must never be committed. Constraint + * violations (the concurrent-signup race) propagate for the caller to resolve. + */ + @Transactional + public User saveUserWithPersonalTeam(User user) { + User saved = userService.saveUser(user); + saved.setTeam(ensurePersonalTeam(saved)); + return saved; + } + /** Returns the user's personal team, creating one if they have none. Idempotent. */ @Transactional public Team ensurePersonalTeam(User user) { @@ -70,9 +72,37 @@ public class SaasTeamService { if (existing != null && saasTeamExtensionService.isPersonal(existing)) { return existing; } + // An empty users.team_id does not prove there is no personal team; adopt one the user + // already owns rather than minting a second. + Team owned = existingPersonalTeam(user); + if (owned != null) { + user.setTeam(owned); + userService.saveUser(user); + return owned; + } return createPersonalTeam(user); } + /** + * The personal team the user already owns — their recorded home, else a solo team they lead. + */ + private Team existingPersonalTeam(User user) { + Long homeId = saasUserExtensionService.getHomeTeamId(user); + if (homeId != null) { + Team home = teamRepository.findById(homeId).orElse(null); + if (home != null) { + return home; + } + } + for (TeamMembership membership : membershipRepository.findByUserId(user.getId())) { + Team team = membership.getTeam(); + if (membership.isLeader() && membershipRepository.countByTeamId(team.getId()) == 1) { + return team; + } + } + return null; + } + /** * Create personal team for new user during signup or migrate existing user from Default team * @@ -114,6 +144,9 @@ public class SaasTeamService { user.setTeam(savedTeam); userRepository.save(user); + // A freshly-created personal team is the user's durable home team. + saasUserExtensionService.setHomeTeamId(user, savedTeam.getId()); + // Clean up old Default/Internal team membership if (oldTeam != null && (DEFAULT_TEAM_NAME.equals(oldTeam.getName()) @@ -129,6 +162,54 @@ public class SaasTeamService { return savedTeam; } + /** + * The user's durable home team id (the team they fall back to when leaving a joined team). Uses + * the stored pointer; if unset (e.g. an existing user before the backfill), derives it from a + * solo team they lead and persists it. Returns null when the user has no such team (e.g. they + * joined under the old delete-on-join flow); callers mint a fresh home in that case. + */ + private Long resolveHomeTeamId(User user) { + Long homeId = saasUserExtensionService.getHomeTeamId(user); + if (homeId != null) { + return homeId; + } + for (TeamMembership m : membershipRepository.findByUserId(user.getId())) { + if (m.isLeader() && membershipRepository.countByTeamId(m.getTeam().getId()) == 1) { + saasUserExtensionService.setHomeTeamId(user, m.getTeam().getId()); + return m.getTeam().getId(); + } + } + return null; + } + + /** + * Move the user back to their durable home team. Reuses the existing home membership when + * present (the durable model keeps it across joins); mints a fresh personal home team only when + * the user has none. + */ + private void returnUserToHome(User user) { + Long homeId = resolveHomeTeamId(user); + Team home = homeId == null ? null : teamRepository.findById(homeId).orElse(null); + if (home == null) { + createPersonalTeam(user); + return; + } + if (membershipRepository.findByTeamIdAndUserId(home.getId(), user.getId()).isEmpty()) { + TeamMembership membership = new TeamMembership(); + membership.setTeam(home); + membership.setUser(user); + membership.setRole(TeamRole.LEADER); + membership.setInvitedAt(LocalDateTime.now()); + membership.setAcceptedAt(LocalDateTime.now()); + membershipRepository.save(membership); + // Ignore the result: a full home returns 0, the correct end state (1 member = 1 seat) - + // throwing here would wrongly block the return. + saasTeamExtensionsRepository.incrementSeatsUsed(home.getId()); + } + userRepository.updateUserTeamId(user.getId(), home.getId()); + user.setTeam(home); + } + /** * Invite user to team (sends email via Supabase Edge Function) * @@ -314,29 +395,29 @@ public class SaasTeamService { throw new IllegalStateException("Team has no available seats"); } - // Validate: accepting won't orphan a team the user leads or that has a paid plan. - // Accepting moves the user off their current team; leaveTeam already blocks the - // last leader of a team from walking away, so accept must enforce the same rule. - assertCanLeaveCurrentTeamsToJoinAnother(acceptingUser); + // Establish/park the durable home team: joining never deletes it. + Long homeTeamId = resolveHomeTeamId(acceptingUser); - // User can only be in one team . leave existing teams before joining new one - List existingMemberships = - membershipRepository.findByUserId(acceptingUser.getId()); - List teamsToDelete = new java.util.ArrayList<>(); + // Guard: block only if joining would strand a paid/linked team the user is the last + // leader of. Home teams are parked (kept), so a join never orphans them. + assertCanLeaveCurrentTeamsToJoinAnother(acceptingUser, homeTeamId, team.getId()); - for (TeamMembership existingMembership : existingMemberships) { + // Leave any non-home team the user currently belongs to; keep the home team + membership. + for (TeamMembership existingMembership : + membershipRepository.findByUserId(acceptingUser.getId())) { Team oldTeam = existingMembership.getTeam(); - - membershipRepository.delete(existingMembership); - - saasTeamExtensionsRepository.decrementSeatsUsed(oldTeam.getId()); - - // Mark personal team for deletion if it's now empty (user was the only member) - if (saasTeamExtensionService.isPersonal(oldTeam) - && membershipRepository.countByTeamId(oldTeam.getId()) == 0) { - teamsToDelete.add(oldTeam); + if ((homeTeamId != null && homeTeamId.equals(oldTeam.getId())) + || oldTeam.getId().equals(team.getId())) { + continue; // keep the durable home; skip the team being joined } - + // Keep any team the user leads that still has other members: leaving it would orphan + // them (members, zero leaders). Solo/empty led teams and plain memberships still leave. + if (existingMembership.isLeader() + && membershipRepository.countByTeamId(oldTeam.getId()) > 1) { + continue; + } + membershipRepository.delete(existingMembership); + saasTeamExtensionsRepository.decrementSeatsUsed(oldTeam.getId()); log.info( "User {} left team {} to join team {}", acceptingUser.getUsername(), @@ -347,43 +428,27 @@ public class SaasTeamService { // Native query: avoids Hibernate touching the read-only supabase_auth_id column. userRepository.updateUserTeamId(acceptingUser.getId(), team.getId()); acceptingUser.setTeam(team); - log.info( - "User {} team reference updated to team {}", - acceptingUser.getUsername(), - team.getName()); - // Now safe to delete empty personal teams - for (Team teamToDelete : teamsToDelete) { - log.info( - "Deleting empty personal team {} after user {} joined another team", - teamToDelete.getId(), - acceptingUser.getUsername()); - purgeTeamOwnedResources(teamToDelete.getId()); - teamRepository.delete(teamToDelete); + // Add the MEMBER membership on the joined team (unless already present). + if (membershipRepository + .findByTeamIdAndUserId(team.getId(), acceptingUser.getId()) + .isEmpty()) { + TeamMembership membership = new TeamMembership(); + membership.setTeam(team); + membership.setUser(acceptingUser); + membership.setRole(TeamRole.MEMBER); + membership.setInvitedBy(inviter); + membership.setInvitedAt(invitation.getCreatedAt()); + membership.setAcceptedAt(LocalDateTime.now()); + membershipRepository.save(membership); + + // incrementSeatsUsed enforces the cap atomically; rowsUpdated==0 means at capacity. + int rowsUpdated = saasTeamExtensionsRepository.incrementSeatsUsed(team.getId()); + if (rowsUpdated == 0) { + throw new IllegalStateException("Team has no available seats"); + } } - // Create team membership - TeamMembership membership = new TeamMembership(); - membership.setTeam(team); - membership.setUser(acceptingUser); - membership.setRole(TeamRole.MEMBER); - membership.setInvitedBy(inviter); - membership.setInvitedAt(invitation.getCreatedAt()); - membership.setAcceptedAt(LocalDateTime.now()); - membershipRepository.save(membership); - - log.info( - "User {} added to team {} with role MEMBER", - acceptingUser.getUsername(), - team.getName()); - - // incrementSeatsUsed enforces the seat cap atomically; rowsUpdated==0 means at capacity. - int rowsUpdated = saasTeamExtensionsRepository.incrementSeatsUsed(team.getId()); - if (rowsUpdated == 0) { - throw new IllegalStateException("Team has no available seats"); - } - log.info("Team {} seats_used incremented", team.getName()); - // Don't set inviteeUser; acceptance is recorded via status + TeamMembership row. invitation.setStatus(InvitationStatus.ACCEPTED); invitationRepository.save(invitation); @@ -434,89 +499,61 @@ public class SaasTeamService { // Atomically decrement team seats_used (prevents race condition) saasTeamExtensionsRepository.decrementSeatsUsed(teamId); - // Fetch team for downstream checks - Team team = teamRepository.findById(teamId).orElseThrow(); + // Return the removed user to their durable home team (mints one only if they have none). + returnUserToHome(userToRemove); - // Create new personal team for removed user - createPersonalTeam(userToRemove); - - // Downgrade user to FREE tier after leaving team - // They either had a trial (which was cancelled) or had an existing subscription - // Either way, they should be FREE after leaving + // Downgrade to FREE after leaving the team. downgradeUserToFree(userToRemove); - // Delete non-personal team if it's now empty - if (!saasTeamExtensionService.isPersonal(team) - && membershipRepository.countByTeamId(teamId) == 0) { - log.info("Deleting empty non-personal team {} after last member removed", teamId); - purgeTeamOwnedResources(team.getId()); - teamRepository.delete(team); - } - log.info( - "User {} removed user {} from team {} and created new personal team", + "User {} removed user {} from team {}; returned them to their home team", remover.getId(), memberUserId, teamId); } /** - * Guard against silently orphaning a team when a user accepts an invite to another one. + * Guard against orphaning a still-billing team when a user joins another. * - *

    {@link #acceptInvitation} moves a user to the inviting team by first leaving their current - * team(s). Personal teams are disposable (they get deleted on accept), but a non-personal team - * must not be left memberless while still billing. {@link #leaveTeam} already refuses to let - * the last leader walk away; accept took a shortcut around that check, which let a paid team's - * leader join another team and orphan their old team together with its live subscription. + *

    In the durable-home model a join parks the user's home team (keeps the team, its + * membership and its wallet) rather than deleting it, so a plain team is never orphaned. The + * only real hazard is a team the user is the last leader of that still carries live + * billing: an active paid/PAYG subscription, or a non-revoked linked self-hosted instance + * ("Mode A"). Those block the join until the plan is cancelled / leadership transferred / + * instances revoked. An unpaid, unlinked team (personal or shared) no longer blocks. * - *

    So: for each non-personal team the user leads as its last leader, block the - * accept. The message points them at the right remedy — cancel the plan if the team is paid, - * otherwise transfer leadership first. - * - *

    Linked self-hosted instances (combined-billing "Mode A") bind to a team via {@code - * linked_instance.team_id}, so they too orphan a team that is left memberless — a personal team - * that accept deletes, or a non-personal team left by its last leader. They're checked in that - * same orphaning branch (not for a non-leader leaving a team that lives on); the remedy is to - * revoke them. + *

    The home team and the team being joined are excluded: neither is left by the join (home is + * parked, the joined team is kept), so their live billing cannot be stranded. * * @param user the user attempting to accept an invitation - * @throws IllegalStateException if accepting would orphan a team the user leads or its - * instances + * @param homeTeamId the user's durable home team, parked by the join (may be null) + * @param joinedTeamId the team being joined + * @throws IllegalStateException if joining would strand a paid/linked team the user last-leads */ - private void assertCanLeaveCurrentTeamsToJoinAnother(User user) { + private void assertCanLeaveCurrentTeamsToJoinAnother( + User user, Long homeTeamId, Long joinedTeamId) { for (TeamMembership membership : membershipRepository.findByUserId(user.getId())) { Team team = membership.getTeam(); - boolean personal = saasTeamExtensionService.isPersonal(team); - if (!personal && !membership.isLeader()) { - // A non-leader leaving a shared team never orphans it. + // Home is parked and the joined team is kept, so neither can be orphaned. + if (team.getId().equals(joinedTeamId) || team.getId().equals(homeTeamId)) { continue; } - if (!personal - && membershipRepository.countByTeamIdAndRole(team.getId(), TeamRole.LEADER) + // Only a sole leader can strand a team; a member or co-leader leaving never does. + if (!membership.isLeader() + || membershipRepository.countByTeamIdAndRole(team.getId(), TeamRole.LEADER) > 1) { - // Another leader remains, so the team keeps an owner. continue; } - // Leaving here orphans the team: a personal team is deleted on accept; a non-personal - // team is being left by its last leader. Either way its linked self-hosted instances - // lose their billing team, so block until they're revoked. if (linkedInstanceRepository.countByTeamIdAndRevokedAtIsNull(team.getId()) > 0) { throw new IllegalStateException( "Revoke linked self-hosted instances on this team before joining another" + " team."); } - if (personal) { - // Personal teams are disposable (deleted on accept) and never billed/shared. - continue; - } if (hasActivePaidSubscription(team)) { throw new IllegalStateException( "Your team has an active plan and you are its last leader. Cancel the plan" + " or transfer leadership before joining another team."); } - throw new IllegalStateException( - "You are the last leader of your team. Transfer leadership before joining" - + " another team."); } } @@ -549,26 +586,13 @@ public class SaasTeamService { // Atomically decrement team seats_used (prevents race condition) saasTeamExtensionsRepository.decrementSeatsUsed(teamId); - // Fetch team for downstream checks - Team team = teamRepository.findById(teamId).orElseThrow(); + // Return the user to their durable home team (mints one only if they have none). + returnUserToHome(user); - // Create new personal team for user who left - createPersonalTeam(user); - - // Check if user should be downgraded after leaving team - // If user has an active subscription (including trial), they keep PRO access - // Otherwise, downgrade to FREE tier + // Downgrade to FREE unless they still hold their own active subscription. downgradeUserToFree(user); - // Delete non-personal team if it's now empty - if (!saasTeamExtensionService.isPersonal(team) - && membershipRepository.countByTeamId(teamId) == 0) { - log.info("Deleting empty non-personal team {} after last member left", teamId); - purgeTeamOwnedResources(team.getId()); - teamRepository.delete(team); - } - - log.info("User {} left team {} and created new personal team", user.getId(), teamId); + log.info("User {} left team {} and returned to their home team", user.getId(), teamId); } /** @@ -780,8 +804,8 @@ public class SaasTeamService { // Atomically decrement seats_used count (prevents race condition) saasTeamExtensionsRepository.decrementSeatsUsed(teamId); - // Create new personal team for removed user - createPersonalTeam(userToRemove); + // Return the evicted user to their durable home team. + returnUserToHome(userToRemove); // Downgrade user to FREE tier downgradeUserToFree(userToRemove); diff --git a/app/saas/src/main/java/stirling/software/saas/service/SaasUserExtensionService.java b/app/saas/src/main/java/stirling/software/saas/service/SaasUserExtensionService.java index ffd63b56e0..c62e686665 100644 --- a/app/saas/src/main/java/stirling/software/saas/service/SaasUserExtensionService.java +++ b/app/saas/src/main/java/stirling/software/saas/service/SaasUserExtensionService.java @@ -52,6 +52,21 @@ public class SaasUserExtensionService { .orElse(null); } + /** The user's durable home team id (the team they fall back to), or null if unset. */ + public Long getHomeTeamId(User user) { + return repository + .findByUserId(user.getId()) + .map(SaasUserExtensions::getHomeTeamId) + .orElse(null); + } + + @Transactional + public void setHomeTeamId(User user, Long homeTeamId) { + SaasUserExtensions ext = getOrCreate(user); + ext.setHomeTeamId(homeTeamId); + repository.save(ext); + } + /** Idempotent first-use marker. Records the first time this user's API key fired a request. */ @Transactional public void trackApiKeyFirstUse(User user) { diff --git a/app/saas/src/main/resources/application-saas.properties b/app/saas/src/main/resources/application-saas.properties index 49798d64cd..f0630aaac1 100644 --- a/app/saas/src/main/resources/application-saas.properties +++ b/app/saas/src/main/resources/application-saas.properties @@ -1,6 +1,11 @@ # Stirling-PDF SaaS profile. Pure multi-tenant cloud. # Activated when the :saas module is on the classpath. +# ---------- AI engine ---------- +# SaaS AI backend is env-driven (reached via AiProxyController); never push settings-derived +# config to it, so pin the config-push off. +aiEngine.pushConfigToEngine=false + # ---------- Datasource ---------- system.datasource.enableCustomDatabase=true system.datasource.customDatabaseUrl=${SAAS_DB_URL:} @@ -14,15 +19,13 @@ spring.datasource.username=${SAAS_DB_USERNAME:postgres} spring.datasource.password=${SAAS_DB_PASSWORD:} # ---------- DB schema / migrations ---------- +# Schema is authored by the Supabase migrations in the Stirling-PDF-SaaS repo and applied to +# Supabase by its GitHub integration (merge to main -> prod). The Java side only pins the target +# schema and lets Hibernate reconcile the entity tables on boot. spring.jpa.properties.hibernate.default_schema=stirling_pdf spring.jpa.properties.hibernate.hbm2ddl.create_namespaces=true spring.jpa.hibernate.ddl-auto=update -spring.flyway.enabled=true -spring.flyway.baseline-on-migrate=true -spring.flyway.locations=classpath:db/migration,classpath:db/migration/saas -spring.flyway.schemas=stirling_pdf -spring.flyway.default-schema=stirling_pdf # ---------- Supabase JWT auth ---------- # Required: set SAAS_DB_PROJECT_REF via env. @@ -74,10 +77,6 @@ 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/V11__saas_payg_model.sql b/app/saas/src/main/resources/db/migration/saas/V11__saas_payg_model.sql deleted file mode 100644 index b7450984b2..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V11__saas_payg_model.sql +++ /dev/null @@ -1,226 +0,0 @@ --- PAYG data model: pricing policy, processing jobs + lineage, wallet ledger, wallet policy, --- entitlement snapshots, shadow-mode comparison rows, plus a payg_team_extensions sidecar table --- carrying team-level PAYG fields, and a cap_units column on team_memberships. --- --- Sidecar pattern (mirrors saas_team_extensions): PAYG-only team fields don't sit directly on --- `teams`, so OSS deployments running Hibernate ddl-auto=update against the proprietary Team --- entity never see PAYG columns they don't have entities for. --- --- Everything is purely additive. No existing rows are modified, no columns are dropped. - --- --------------------------------------------------------------------------------------------- --- 1. pricing_policy — versioned economic config (units, lifecycle metadata). --- step_limits and stripe_price_ids live on normalised child tables below — typed columns, no --- JSON parsing, queryable directly. --- --------------------------------------------------------------------------------------------- -CREATE TABLE IF NOT EXISTS pricing_policy ( - policy_id BIGSERIAL PRIMARY KEY, - version VARCHAR(32) NOT NULL UNIQUE, - effective_from TIMESTAMP NOT NULL, - effective_to TIMESTAMP, - doc_pages_per_unit INTEGER NOT NULL, - doc_bytes_per_unit BIGINT NOT NULL, - min_charge_units INTEGER NOT NULL DEFAULT 1, - file_unit_cap INTEGER NOT NULL DEFAULT 1000, - is_default BOOLEAN NOT NULL DEFAULT FALSE, - notes TEXT, - created_by VARCHAR(255), - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -); - -CREATE UNIQUE INDEX IF NOT EXISTS uq_pricing_policy_default - ON pricing_policy (is_default) WHERE is_default = TRUE; - --- Max steps allowed per process for each caller surface (JobSource). -CREATE TABLE IF NOT EXISTS pricing_policy_step_limit ( - policy_id BIGINT NOT NULL REFERENCES pricing_policy(policy_id) ON DELETE CASCADE, - job_source VARCHAR(32) NOT NULL, - step_limit INTEGER NOT NULL, - PRIMARY KEY (policy_id, job_source) -); - --- Stripe Price IDs this policy resolves to, one per supported currency. Currency itself isn't --- stored here — it lives on stripe.prices.currency and is looked up via Sync Engine when picking --- the right Price for a customer's subscription. All prices in one policy must share the same --- Billing Meter and the same first-tier upper bound in units (deploy-time CI check). -CREATE TABLE IF NOT EXISTS pricing_policy_stripe_price ( - policy_id BIGINT NOT NULL REFERENCES pricing_policy(policy_id) ON DELETE CASCADE, - stripe_price_id VARCHAR(128) NOT NULL, - PRIMARY KEY (policy_id, stripe_price_id) -); - --- --------------------------------------------------------------------------------------------- --- 2. payg_team_extensions — sidecar carrying PAYG-only team fields. 1:1 with teams via shared PK. --- --------------------------------------------------------------------------------------------- -CREATE TABLE IF NOT EXISTS payg_team_extensions ( - team_id BIGINT PRIMARY KEY REFERENCES teams(team_id) ON DELETE CASCADE, - pricing_policy_id BIGINT REFERENCES pricing_policy(policy_id), - stripe_customer_id VARCHAR(128) UNIQUE, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - version BIGINT NOT NULL DEFAULT 0 -); - -COMMENT ON COLUMN payg_team_extensions.pricing_policy_id IS - 'Override policy for this team. NULL means use the row in pricing_policy with is_default=TRUE.'; -COMMENT ON COLUMN payg_team_extensions.stripe_customer_id IS - 'Stripe customer id for this team. Eager-created so every team has billing identity on file.'; - --- --------------------------------------------------------------------------------------------- --- 3. team_memberships column addition: optional per-member sub-cap. Lives directly on the table --- because team_memberships is already a SaaS-only table. --- --------------------------------------------------------------------------------------------- -ALTER TABLE team_memberships - ADD COLUMN IF NOT EXISTS cap_units BIGINT; -COMMENT ON COLUMN team_memberships.cap_units IS - 'Per-period spend cap for this member inside their team wallet, in doc units. NULL = no member-level cap.'; - --- --------------------------------------------------------------------------------------------- --- 4. processing_job — one billable process; step_count and last_step_at track the workflow window. --- --------------------------------------------------------------------------------------------- -CREATE TABLE IF NOT EXISTS processing_job ( - job_id UUID PRIMARY KEY, - owner_user_id BIGINT NOT NULL, - owner_team_id BIGINT, - process_type VARCHAR(32) NOT NULL, - source VARCHAR(32) NOT NULL, - document_fingerprint VARCHAR(64), - doc_units INTEGER NOT NULL DEFAULT 0, - step_count INTEGER NOT NULL DEFAULT 0, - started_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - last_step_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - closed_at TIMESTAMP, - policy_id BIGINT NOT NULL REFERENCES pricing_policy(policy_id), - charged_units INTEGER, - charged_cents INTEGER, - status VARCHAR(32) NOT NULL, - idempotency_key VARCHAR(128) UNIQUE, - metadata JSONB -); - -CREATE INDEX IF NOT EXISTS idx_processing_job_owner_open - ON processing_job (owner_user_id, status) WHERE status = 'OPEN'; - -CREATE INDEX IF NOT EXISTS idx_processing_job_last_step - ON processing_job (status, last_step_at) WHERE status = 'OPEN'; - --- --------------------------------------------------------------------------------------------- --- 5. processing_job_step — per-tool-call audit within a job. --- --------------------------------------------------------------------------------------------- -CREATE TABLE IF NOT EXISTS processing_job_step ( - step_id BIGSERIAL PRIMARY KEY, - job_id UUID NOT NULL REFERENCES processing_job(job_id) ON DELETE CASCADE, - tool_id VARCHAR(128) NOT NULL, - status VARCHAR(32) NOT NULL, - started_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - completed_at TIMESTAMP, - input_pages INTEGER, - input_bytes BIGINT, - error_code VARCHAR(64) -); - -CREATE INDEX IF NOT EXISTS idx_processing_job_step_job - ON processing_job_step (job_id); - --- --------------------------------------------------------------------------------------------- --- 6. job_artifact_hash — per-step input/output content hashes used by the lineage detector. --- --------------------------------------------------------------------------------------------- --- content_hash holds "type:value" signature keys; VARCHAR(128) fits SHA-256 and future schemes. -CREATE TABLE IF NOT EXISTS job_artifact_hash ( - job_id UUID NOT NULL REFERENCES processing_job(job_id) ON DELETE CASCADE, - content_hash VARCHAR(128) NOT NULL, - kind VARCHAR(8) NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (job_id, content_hash, kind) -); - -CREATE INDEX IF NOT EXISTS idx_artifact_hash_lookup - ON job_artifact_hash (content_hash, created_at); - --- --------------------------------------------------------------------------------------------- --- 7. wallet_ledger — append-only signed-amount ledger keyed on team_id. --- amount_units is INTEGER (per-row delta, always small); cap and rollup columns are BIGINT --- because they accumulate across a billing period. --- --------------------------------------------------------------------------------------------- -CREATE TABLE IF NOT EXISTS wallet_ledger ( - entry_id BIGSERIAL PRIMARY KEY, - team_id BIGINT NOT NULL REFERENCES teams(team_id) ON DELETE CASCADE, - actor_user_id BIGINT, - entry_type VARCHAR(32) NOT NULL, - bucket VARCHAR(16) NOT NULL, - amount_units INTEGER NOT NULL, - reference_type VARCHAR(32) NOT NULL, - reference_id VARCHAR(128) NOT NULL, - policy_id BIGINT, - stripe_event_id VARCHAR(128), - occurred_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - metadata JSONB -); - -CREATE INDEX IF NOT EXISTS idx_wallet_ledger_team - ON wallet_ledger (team_id, occurred_at); - -CREATE INDEX IF NOT EXISTS idx_wallet_ledger_actor - ON wallet_ledger (team_id, actor_user_id, occurred_at) WHERE actor_user_id IS NOT NULL; - -CREATE UNIQUE INDEX IF NOT EXISTS uq_wallet_ledger_ref - ON wallet_ledger (reference_type, reference_id, entry_type, bucket); - -CREATE UNIQUE INDEX IF NOT EXISTS uq_wallet_ledger_stripe_event - ON wallet_ledger (stripe_event_id) WHERE stripe_event_id IS NOT NULL; - --- --------------------------------------------------------------------------------------------- --- 8. wallet_policy — per-team charging engine, cap, degradation rules, lineage strategy. --- --------------------------------------------------------------------------------------------- -CREATE TABLE IF NOT EXISTS wallet_policy ( - policy_id BIGSERIAL PRIMARY KEY, - team_id BIGINT NOT NULL UNIQUE REFERENCES teams(team_id) ON DELETE CASCADE, - engine VARCHAR(16) NOT NULL DEFAULT 'LEGACY', - cap_period VARCHAR(16) NOT NULL DEFAULT 'CALENDAR_MONTH', - cap_units BIGINT, - -- Customer's money intent ("I want $50/month"); the currency comes from the team's Stripe - -- customer at recompute time, not stored separately here. - cap_source_money BIGINT, - warn_at_pct INTEGER NOT NULL DEFAULT 80, - degrade_at_pct INTEGER NOT NULL DEFAULT 100, - degraded_feature_set VARCHAR(32) NOT NULL DEFAULT 'MINIMAL', - auto_group_strategy VARCHAR(16) NOT NULL DEFAULT 'AUTO', - notification_emails JSONB NOT NULL DEFAULT '[]'::jsonb, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -); - --- --------------------------------------------------------------------------------------------- --- 9. wallet_entitlement_snapshot — hot-path state for the entitlement guard. --- user_id = 0 is the team-wide sentinel (Postgres treats NULL as not-equal-to-NULL in unique --- constraints, so 0 is the cleaner choice for a composite PK). --- --------------------------------------------------------------------------------------------- -CREATE TABLE IF NOT EXISTS wallet_entitlement_snapshot ( - team_id BIGINT NOT NULL REFERENCES teams(team_id) ON DELETE CASCADE, - user_id BIGINT NOT NULL DEFAULT 0, - period_start TIMESTAMP NOT NULL, - period_end TIMESTAMP NOT NULL, - period_spend_units BIGINT NOT NULL DEFAULT 0, - period_cap_units BIGINT, - state VARCHAR(16) NOT NULL DEFAULT 'FULL', - feature_set VARCHAR(32) NOT NULL DEFAULT 'FULL', - enabled_gates JSONB NOT NULL DEFAULT '[]'::jsonb, - computed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (team_id, user_id) -); - --- --------------------------------------------------------------------------------------------- --- 10. payg_shadow_charge — per-job legacy-vs-PAYG diff during PAYG_SHADOW engine mode. --- --------------------------------------------------------------------------------------------- -CREATE TABLE IF NOT EXISTS payg_shadow_charge ( - shadow_id BIGSERIAL PRIMARY KEY, - team_id BIGINT NOT NULL REFERENCES teams(team_id) ON DELETE CASCADE, - job_id UUID NOT NULL, - policy_id BIGINT NOT NULL REFERENCES pricing_policy(policy_id), - payg_units INTEGER NOT NULL, - legacy_credits_charged INTEGER NOT NULL, - diff_pct INTEGER NOT NULL, - occurred_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX IF NOT EXISTS idx_payg_shadow_team_time - ON payg_shadow_charge (team_id, occurred_at); diff --git a/app/saas/src/main/resources/db/migration/saas/V12__seed_default_payg_policy.sql b/app/saas/src/main/resources/db/migration/saas/V12__seed_default_payg_policy.sql deleted file mode 100644 index d587a80b2e..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V12__seed_default_payg_policy.sql +++ /dev/null @@ -1,37 +0,0 @@ --- Seed the V1 default pricing policy. Idempotent — only inserts when no default row exists. --- Units sized so a typical 25-page / 5 MiB document is 1 unit; tune via admin endpoints once --- Stripe Prices are wired in production. --- --- This migration is separated from V11 because V11 has already shipped to main — adding rows to --- it would change its Flyway checksum and break existing deployments. - -INSERT INTO pricing_policy ( - version, effective_from, doc_pages_per_unit, doc_bytes_per_unit, - min_charge_units, file_unit_cap, is_default, notes, created_by -) -SELECT - 'v1-initial', CURRENT_TIMESTAMP, 25, 5242880, - 1, 1000, TRUE, - 'V1 default seeded by V12 migration. Tune via admin once Stripe Prices are configured.', - 'system' -WHERE NOT EXISTS ( - SELECT 1 FROM pricing_policy WHERE is_default = TRUE -); - --- Step limits for the default policy across every JobSource. References the row inserted above --- via the partial unique index on is_default=TRUE. -INSERT INTO pricing_policy_step_limit (policy_id, job_source, step_limit) -SELECT p.policy_id, src.job_source, src.step_limit -FROM pricing_policy p -CROSS JOIN ( - VALUES - ('WEB', 10), - ('API', 10), - ('PIPELINE', 20), -- automations get a longer chain - ('DESKTOP_APP', 10) -) AS src(job_source, step_limit) -WHERE p.is_default = TRUE - AND NOT EXISTS ( - SELECT 1 FROM pricing_policy_step_limit s - WHERE s.policy_id = p.policy_id AND s.job_source = src.job_source - ); diff --git a/app/saas/src/main/resources/db/migration/saas/V13__payg_shadow_charge_status.sql b/app/saas/src/main/resources/db/migration/saas/V13__payg_shadow_charge_status.sql deleted file mode 100644 index 66da92a108..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V13__payg_shadow_charge_status.sql +++ /dev/null @@ -1,20 +0,0 @@ --- Refund tracking on shadow rows. Shadow models the eventual Stripe meter_event_adjustment(cancel) --- by flipping status from CHARGED to REFUNDED in the same request's afterCompletion when a --- freshly-opened process fails with 5xx on its first step. --- --- Reconciliation report selects SUM(payg_units) WHERE status = 'CHARGED' to get the true net --- Stripe would bill. - -ALTER TABLE payg_shadow_charge - ADD COLUMN IF NOT EXISTS status VARCHAR(16) NOT NULL DEFAULT 'CHARGED', - ADD COLUMN IF NOT EXISTS refunded_at TIMESTAMP, - ADD COLUMN IF NOT EXISTS refund_reason VARCHAR(128); - -CREATE INDEX IF NOT EXISTS idx_payg_shadow_status_time - ON payg_shadow_charge (status, occurred_at); - --- Hot-path index for findFirstByJobIdOrderByIdAsc: hit on every 5xx-first-step refund to flip --- the row to REFUNDED. UNIQUE because at most one shadow row exists per processing_job by --- construction (openProcess writes exactly one on OPENED, zero on JOINED). -CREATE UNIQUE INDEX IF NOT EXISTS uq_payg_shadow_job_id - ON payg_shadow_charge (job_id); diff --git a/app/saas/src/main/resources/db/migration/saas/V14__payg_subscription_state.sql b/app/saas/src/main/resources/db/migration/saas/V14__payg_subscription_state.sql deleted file mode 100644 index eca2665de7..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V14__payg_subscription_state.sql +++ /dev/null @@ -1,189 +0,0 @@ --- PAYG subscription state — the column + functions that let new customers reach Stripe billing. --- --- This migration is half of the Stripe/Supabase wire-up (PR-SB-1 in `notes/PAYG_DESIGN.md` --- revision note + `payg-stripe-supabase-plan.html`). It's strictly additive: --- * one new column on payg_team_extensions (payg_subscription_id) --- * one new column on pricing_policy (free_tier_units_per_cycle) --- * two RPC functions (payg_link_subscription, payg_unlink_subscription) — the only writers --- of subscription state, called by stripe-webhook + create-payg-team-subscription edge fns --- * an AFTER-INSERT trigger on teams that auto-creates the payg_team_extensions sidecar row --- so every new signup is PAYG-by-default --- * an RLS policy that lets team LEADERs (and the service role) link subscriptions --- --- No behaviour change for the running app until PR-SB-4 wires PaygMeterReportingService and --- the free-tier gate into JobChargeService. Until then this just exposes new state for the --- edge functions in PR-SB-2 to write through to. --- --- Design references: --- * notes/PAYG_DESIGN.md (revision note 2026-06-03 — "subscription presence is the gate") --- * payg-stripe-supabase-plan.html §3.1 — RPC functions; §3.5 — RLS policy - --- --------------------------------------------------------------------------------------------- --- 1. New columns --- --------------------------------------------------------------------------------------------- - -ALTER TABLE stirling_pdf.payg_team_extensions - ADD COLUMN IF NOT EXISTS payg_subscription_id VARCHAR(128) UNIQUE; - -COMMENT ON COLUMN stirling_pdf.payg_team_extensions.payg_subscription_id IS - 'Stripe subscription id (sub_xxx) for this team''s PAYG metered subscription. ' - 'NULL = team has not added a card yet; engine writes shadow rows only. ' - 'NOT NULL = engine posts meter events to Stripe on every billable tool call. ' - 'Mutated exclusively by payg_link_subscription / payg_unlink_subscription RPC functions.'; - -ALTER TABLE stirling_pdf.pricing_policy - ADD COLUMN IF NOT EXISTS free_tier_units_per_cycle BIGINT NOT NULL DEFAULT 0; - -COMMENT ON COLUMN stirling_pdf.pricing_policy.free_tier_units_per_cycle IS - 'Doc units a team on this policy can consume per cycle before they must add a card. ' - 'Default 0 = no free tier (block immediately). The seeded default policy will set this ' - 'to the launch free-tier size; the special "launch" policy used by the day-1 legacy ' - 'migration script (see PAYG_DESIGN.md §3.10 revised) can override.'; - --- --------------------------------------------------------------------------------------------- --- 2. RPC: payg_link_subscription --- --- Called by: --- * supabase/functions/create-payg-team-subscription/index.ts (post-Stripe-Checkout, with --- either user JWT [normal path, RLS-enforced] or service-role [day-1 migration script]) --- * supabase/functions/stripe-webhook/handlers/payg-subscription.ts on --- customer.subscription.created (idempotent — second invocation with same args is a no-op) --- --------------------------------------------------------------------------------------------- - -CREATE OR REPLACE FUNCTION stirling_pdf.payg_link_subscription( - p_team_id BIGINT, - p_customer_id TEXT, - p_subscription_id TEXT -) RETURNS VOID -LANGUAGE plpgsql -SECURITY INVOKER -AS $$ -BEGIN - UPDATE stirling_pdf.payg_team_extensions - SET stripe_customer_id = p_customer_id, - payg_subscription_id = p_subscription_id, - updated_at = now() - WHERE team_id = p_team_id; - - IF NOT FOUND THEN - RAISE EXCEPTION 'payg_team_extensions row missing for team %', p_team_id - USING ERRCODE = 'foreign_key_violation'; - END IF; - - INSERT INTO stirling_pdf.payg_subscription_change_log(team_id, action, subscription_id) - VALUES (p_team_id, 'LINKED', p_subscription_id); -END $$; - -COMMENT ON FUNCTION stirling_pdf.payg_link_subscription(BIGINT, TEXT, TEXT) IS - 'Idempotent link of a Stripe subscription to a team. SECURITY INVOKER means RLS applies — ' - 'the caller must be a LEADER of the team (or hold the service-role bypass). ' - 'Writes an audit row to payg_subscription_change_log.'; - --- --------------------------------------------------------------------------------------------- --- 3. RPC: payg_unlink_subscription --- --- Called by stripe-webhook handlers/payg-subscription.ts on customer.subscription.deleted --- (after Stripe's own retries have given up). Drops the team back to free-tier-then-block. --- --------------------------------------------------------------------------------------------- - -CREATE OR REPLACE FUNCTION stirling_pdf.payg_unlink_subscription( - p_team_id BIGINT, - p_reason TEXT -) RETURNS VOID -LANGUAGE plpgsql -SECURITY INVOKER -AS $$ -BEGIN - UPDATE stirling_pdf.payg_team_extensions - SET payg_subscription_id = NULL, - updated_at = now() - WHERE team_id = p_team_id; - -- We deliberately keep stripe_customer_id — the team may add a new card later and we'd - -- like to reuse the existing Stripe customer record rather than create a duplicate. - - INSERT INTO stirling_pdf.payg_subscription_change_log(team_id, action, reason) - VALUES (p_team_id, 'UNLINKED', p_reason); -END $$; - -COMMENT ON FUNCTION stirling_pdf.payg_unlink_subscription(BIGINT, TEXT) IS - 'Drops the team back to free-tier-then-block derived state. Reason is logged for audit ' - '(typically subscription_deleted | admin | card_removed).'; - --- --------------------------------------------------------------------------------------------- --- 4. Auto-create payg_team_extensions row when a team is created --- --- Every new signup gets a payg_team_extensions row with NULL pricing_policy_id (which the --- backend's PricingPolicyService resolves to the default policy). The free-tier gate kicks in --- from the very first tool call. --- --------------------------------------------------------------------------------------------- - -CREATE OR REPLACE FUNCTION stirling_pdf.payg_create_team_extensions_trigger() -RETURNS TRIGGER -LANGUAGE plpgsql -AS $$ -BEGIN - INSERT INTO stirling_pdf.payg_team_extensions(team_id) - VALUES (NEW.team_id) - ON CONFLICT (team_id) DO NOTHING; - RETURN NEW; -END $$; - -DROP TRIGGER IF EXISTS trg_payg_create_team_extensions ON stirling_pdf.teams; -CREATE TRIGGER trg_payg_create_team_extensions - AFTER INSERT ON stirling_pdf.teams - FOR EACH ROW - EXECUTE FUNCTION stirling_pdf.payg_create_team_extensions_trigger(); - -COMMENT ON TRIGGER trg_payg_create_team_extensions ON stirling_pdf.teams IS - 'Ensures every team has a payg_team_extensions sidecar row from creation. New customers ' - 'are PAYG-default from minute one — they consume free-tier units until they add a card.'; - --- --------------------------------------------------------------------------------------------- --- 5. Backfill: any existing team without a sidecar row gets one now --- --------------------------------------------------------------------------------------------- - -INSERT INTO stirling_pdf.payg_team_extensions(team_id) -SELECT t.team_id - FROM stirling_pdf.teams t - WHERE NOT EXISTS ( - SELECT 1 FROM stirling_pdf.payg_team_extensions x WHERE x.team_id = t.team_id - ); - --- --------------------------------------------------------------------------------------------- --- 6. RLS policy --- --- Service-role bypasses RLS (backend reads + day-1 migration script writes via the service-role --- key). For user-initiated writes via the frontend Add-Card flow, only team LEADERs can link a --- subscription. SELECT remains permissive — anyone in the team can see the row. --- --------------------------------------------------------------------------------------------- - -ALTER TABLE stirling_pdf.payg_team_extensions ENABLE ROW LEVEL SECURITY; - --- Read: any team member can see their team's payg row. -DROP POLICY IF EXISTS payg_team_ext_select ON stirling_pdf.payg_team_extensions; -CREATE POLICY payg_team_ext_select - ON stirling_pdf.payg_team_extensions - FOR SELECT - USING ( - team_id IN ( - SELECT tm.team_id - FROM stirling_pdf.team_memberships tm - JOIN stirling_pdf.users u ON u.user_id = tm.user_id - WHERE u.supabase_auth_id = auth.uid() - ) - ); - --- Update: only LEADERs of the team can update (i.e. link / unlink a subscription). -DROP POLICY IF EXISTS payg_team_ext_leader_update ON stirling_pdf.payg_team_extensions; -CREATE POLICY payg_team_ext_leader_update - ON stirling_pdf.payg_team_extensions - FOR UPDATE - USING ( - team_id IN ( - SELECT tm.team_id - FROM stirling_pdf.team_memberships tm - JOIN stirling_pdf.users u ON u.user_id = tm.user_id - WHERE u.supabase_auth_id = auth.uid() - AND tm.role = 'LEADER' - ) - ); diff --git a/app/saas/src/main/resources/db/migration/saas/V15__payg_audit_logs.sql b/app/saas/src/main/resources/db/migration/saas/V15__payg_audit_logs.sql deleted file mode 100644 index 1b7e61e9c1..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V15__payg_audit_logs.sql +++ /dev/null @@ -1,76 +0,0 @@ --- PAYG audit-log tables. Two append-only logs: --- --- * payg_meter_event_log — written by the backend's PaygMeterReportingService --- on every Stripe meter event POST attempt. Gives us a --- record independent of Stripe's own logs so we can --- replay-after-24h-window (Stripe's idempotency window) --- and run nightly reconciliation against Stripe's --- meter-event list. --- --- * payg_subscription_change_log — written by V14's two RPC functions on every --- subscription link / unlink. Independent of Stripe's --- webhook log; lets us diagnose "why is this team in --- free-tier-block when their Stripe sub is active?" --- without leaving our DB. --- --- Both are pure additive; nothing reads them yet. PR-SB-5 (nightly reconcile) wires the --- meter-event log; the subscription change log is queried only from admin tooling. --- --- Design references: --- * payg-stripe-supabase-plan.html §3.10 — twin migrations --- * payg-stripe-supabase-plan.html §8 H5 — 24h idempotency window mitigation - --- --------------------------------------------------------------------------------------------- --- 1. payg_meter_event_log — backend-side audit of every Stripe meter event we tried to post. --- --------------------------------------------------------------------------------------------- - -CREATE TABLE IF NOT EXISTS stirling_pdf.payg_meter_event_log ( - event_id BIGSERIAL PRIMARY KEY, - team_id BIGINT NOT NULL REFERENCES stirling_pdf.teams(team_id) ON DELETE CASCADE, - job_id UUID, - idempotency_key VARCHAR(128) NOT NULL UNIQUE, - units INTEGER NOT NULL, - occurred_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - posted_to_stripe_at TIMESTAMP, - -- NULL while pending; set when the meter-payg-units edge fn returns success. NULL after - -- 24h means the event never made it to Stripe — nightly reconcile retries with a fresh - -- idempotency-key suffix (see §8 H5 mitigation). - stripe_error_code VARCHAR(64), - stripe_error_body TEXT, - metadata JSONB -); - -CREATE INDEX IF NOT EXISTS idx_payg_meter_event_team_time - ON stirling_pdf.payg_meter_event_log (team_id, occurred_at); - -CREATE INDEX IF NOT EXISTS idx_payg_meter_event_unposted - ON stirling_pdf.payg_meter_event_log (occurred_at) - WHERE posted_to_stripe_at IS NULL; - -COMMENT ON TABLE stirling_pdf.payg_meter_event_log IS - 'Backend audit of every Stripe meter event POST attempt. Independent of Stripe meter ' - 'history. idempotency_key is the same one passed to Stripe; the UNIQUE constraint here ' - 'gives us safe at-least-once semantics even on backend retry. Rows older than 24h with ' - 'posted_to_stripe_at IS NULL are stuck and retried by the nightly reconcile job.'; - --- --------------------------------------------------------------------------------------------- --- 2. payg_subscription_change_log — written by V14's RPC functions on every link / unlink. --- --------------------------------------------------------------------------------------------- - -CREATE TABLE IF NOT EXISTS stirling_pdf.payg_subscription_change_log ( - change_id BIGSERIAL PRIMARY KEY, - team_id BIGINT NOT NULL REFERENCES stirling_pdf.teams(team_id) ON DELETE CASCADE, - action VARCHAR(32) NOT NULL, - -- LINKED — payg_link_subscription written subscription_id - -- UNLINKED — payg_unlink_subscription cleared the subscription - subscription_id VARCHAR(128), - reason TEXT, - changed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX IF NOT EXISTS idx_payg_sub_change_team_time - ON stirling_pdf.payg_subscription_change_log (team_id, changed_at); - -COMMENT ON TABLE stirling_pdf.payg_subscription_change_log IS - 'Append-only log of every subscription link / unlink. Written by V14 RPC functions; ' - 'never updated. Diagnostic value when reconciling against Stripe webhook history.'; diff --git a/app/saas/src/main/resources/db/migration/saas/V16__payg_billing_category.sql b/app/saas/src/main/resources/db/migration/saas/V16__payg_billing_category.sql deleted file mode 100644 index f78458a1b6..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V16__payg_billing_category.sql +++ /dev/null @@ -1,58 +0,0 @@ --- PAYG analytics axis: stamp every billable ledger entry / shadow row with the category that --- produced it (API | AI | AUTOMATION | BYPASSED). PAYG stays on a single flat-priced Stripe meter --- forever — this column is for in-app breakdowns and analytics, never for Stripe pricing. --- --- All adds are nullable: pre-V16 rows have no category and stay NULL; the interceptor populates --- it for new rows going forward. - --- --------------------------------------------------------------------------------------------- --- 1. wallet_ledger.billing_category --- --------------------------------------------------------------------------------------------- -ALTER TABLE wallet_ledger ADD COLUMN IF NOT EXISTS billing_category VARCHAR(16) NULL; -COMMENT ON COLUMN wallet_ledger.billing_category IS - 'API | AI | AUTOMATION | BYPASSED. NULL = system entry or pre-V16 backfill.'; - --- Partial index — only billable rows ever read this column, and NULLs would just bloat the tree. -CREATE INDEX IF NOT EXISTS idx_wallet_ledger_team_category_period - ON wallet_ledger (team_id, billing_category, occurred_at) - WHERE billing_category IS NOT NULL; - --- --------------------------------------------------------------------------------------------- --- 2. payg_shadow_charge.billing_category + job_source --- --------------------------------------------------------------------------------------------- -ALTER TABLE payg_shadow_charge - ADD COLUMN IF NOT EXISTS billing_category VARCHAR(16) NULL, - ADD COLUMN IF NOT EXISTS job_source VARCHAR(32) NULL; - --- Backfill job_source from processing_job (best-effort — rows whose job has already been pruned --- stay NULL, which is fine: the shadow row is self-describing post-V16 and only legacy ones lack --- the column.) -UPDATE payg_shadow_charge sc - SET job_source = pj.source - FROM processing_job pj - WHERE pj.job_id = sc.job_id - AND sc.job_source IS NULL; - --- --------------------------------------------------------------------------------------------- --- 3. pricing_policy_stripe_price.stripe_product_id --- Operator populates this manually per row when seeding new policies. Nullable for backward --- compatibility with existing rows that don't carry a Product reference. --- --------------------------------------------------------------------------------------------- -ALTER TABLE pricing_policy_stripe_price - ADD COLUMN IF NOT EXISTS stripe_product_id VARCHAR(128) NULL; - --- --------------------------------------------------------------------------------------------- --- 4. wallet_category_summary view — pre-grouped per-team, per-month, per-category aggregate that --- the in-app breakdown widget reads. Recomputed live on every SELECT; cheap thanks to the --- partial index above. --- --------------------------------------------------------------------------------------------- -CREATE OR REPLACE VIEW wallet_category_summary AS -SELECT - team_id, - date_trunc('month', occurred_at) AS period_start, - billing_category, - SUM(CASE WHEN amount_units < 0 THEN -amount_units ELSE 0 END) AS units_debited, - COUNT(*) FILTER (WHERE entry_type = 'DEBIT') AS debit_count -FROM wallet_ledger -WHERE billing_category IS NOT NULL -GROUP BY team_id, date_trunc('month', occurred_at), billing_category; diff --git a/app/saas/src/main/resources/db/migration/saas/V17__merge_supabase_id_into_auth_id.sql b/app/saas/src/main/resources/db/migration/saas/V17__merge_supabase_id_into_auth_id.sql deleted file mode 100644 index cc6b5d033e..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V17__merge_supabase_id_into_auth_id.sql +++ /dev/null @@ -1,34 +0,0 @@ --- Consolidate users.supabase_id into users.supabase_auth_id. --- --- The `supabase_auth_id` column is the canonical link to Supabase Auth — it was --- created by the initial Supabase schema migration (Sep 2025) and is referenced --- by every RLS policy in the Supabase side of the world (V14's --- payg_team_ext_select / payg_team_ext_leader_update, the public.payg_* --- SECURITY DEFINER RPCs, etc.). --- --- PR #6384 ("SaaS Consolidation") accidentally added a parallel `supabase_id` --- column via Flyway V2 — same purpose, different name. Java's User entity then --- mapped to this new column. The result was a split-brain: --- * Pre-#6384 users had supabase_auth_id populated, supabase_id NULL. --- * Post-#6384 users had supabase_id populated, supabase_auth_id NULL. --- * RLS policies + RPCs always check supabase_auth_id, so post-#6384 users --- failed every membership check. --- --- This migration: --- 1. Backfills supabase_auth_id from supabase_id where the former is NULL. --- 2. Drops the supabase_id column and its unique index. --- --- The Java User entity has been switched to @Column(name = "supabase_auth_id") --- in the same change-set; this migration assumes the new code is already --- deployed (or will be deployed together with this migration). - --- 1. Backfill the canonical column from the duplicate, where needed. -UPDATE users - SET supabase_auth_id = supabase_id - WHERE supabase_auth_id IS NULL - AND supabase_id IS NOT NULL; - --- 2. Drop the duplicate column. IF EXISTS guards against environments where --- the column was already removed manually. -DROP INDEX IF EXISTS uk_users_supabase_id; -ALTER TABLE users DROP COLUMN IF EXISTS supabase_id; diff --git a/app/saas/src/main/resources/db/migration/saas/V19__payg_lifetime_free_grant.sql b/app/saas/src/main/resources/db/migration/saas/V19__payg_lifetime_free_grant.sql deleted file mode 100644 index ed62b34e02..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V19__payg_lifetime_free_grant.sql +++ /dev/null @@ -1,93 +0,0 @@ --- PAYG free allowance: monthly per-cycle allowance → one-time LIFETIME grant. --- --- Product decision (2026-06-11): every team gets a one-time free document grant. It does NOT --- replenish monthly and is NOT lost when the team subscribes — they keep whatever is unused. --- --- Mechanics: the grant is tracked as a running counter on the team sidecar --- (payg_team_extensions.free_units_remaining), seeded once from the team's effective pricing --- policy and maintained by the charge pipeline (deducted when a billable DEBIT is written, --- restored on a first-step refund). Because the counter is authoritative, the wallet_ledger is --- no longer the source of truth for the grant and its old rows can be pruned after a retention --- window (separate future job). - --- --------------------------------------------------------------------------------------------- --- 1. Rename the policy column — it is no longer "per cycle", it's the one-time grant size. --- --------------------------------------------------------------------------------------------- - -ALTER TABLE stirling_pdf.pricing_policy - RENAME COLUMN free_tier_units_per_cycle TO free_tier_units; - -COMMENT ON COLUMN stirling_pdf.pricing_policy.free_tier_units IS - 'One-time lifetime free document grant handed to a team on creation (copied into ' - 'payg_team_extensions.free_units_remaining). NOT per-cycle: it never replenishes and ' - 'survives subscribing. 0 = no free grant (block / meter from the first document).'; - --- --------------------------------------------------------------------------------------------- --- 2. The running counter on the team sidecar. --- --------------------------------------------------------------------------------------------- - -ALTER TABLE stirling_pdf.payg_team_extensions - ADD COLUMN IF NOT EXISTS free_units_remaining BIGINT NOT NULL DEFAULT 0; - -COMMENT ON COLUMN stirling_pdf.payg_team_extensions.free_units_remaining IS - 'Remaining one-time free documents for this team. Seeded from the effective pricing ' - 'policy''s free_tier_units at row creation; decremented by min(jobUnits, remaining) when a ' - 'billable charge is written; restored on a first-step refund. Lifetime — never resets. ' - 'Authoritative source for the free grant (independent of wallet_ledger retention).'; - --- --------------------------------------------------------------------------------------------- --- 3. Per-job free/paid split on the shadow row — makes metering + refunds exact and removes --- any need to SUM the ledger over a team's lifetime. --- --------------------------------------------------------------------------------------------- - -ALTER TABLE stirling_pdf.payg_shadow_charge - ADD COLUMN IF NOT EXISTS free_units_consumed INT NOT NULL DEFAULT 0; - -COMMENT ON COLUMN stirling_pdf.payg_shadow_charge.free_units_consumed IS - 'How many of this job''s payg_units came out of the team''s free grant at charge time. ' - 'Paid (metered) units = payg_units - free_units_consumed. A refund restores this many ' - 'units to payg_team_extensions.free_units_remaining.'; - --- --------------------------------------------------------------------------------------------- --- 4. Seed the counter at team creation. Replace the V14 trigger function so new teams get the --- default policy's grant from minute one. (The trigger itself still points at this function.) --- --------------------------------------------------------------------------------------------- - -CREATE OR REPLACE FUNCTION stirling_pdf.payg_create_team_extensions_trigger() -RETURNS TRIGGER -LANGUAGE plpgsql -AS $$ -BEGIN - INSERT INTO stirling_pdf.payg_team_extensions(team_id, free_units_remaining) - VALUES ( - NEW.team_id, - COALESCE( - (SELECT pp.free_tier_units FROM stirling_pdf.pricing_policy pp - WHERE pp.is_default = TRUE LIMIT 1), - 0) - ) - ON CONFLICT (team_id) DO NOTHING; - RETURN NEW; -END $$; - --- --------------------------------------------------------------------------------------------- --- 5. Backfill existing teams. remaining = max(0, grant - lifetime_consumed). lifetime_consumed --- is -SUM(amount_units) over the team's DEBIT+REFUND ledger entries (debits negative, refunds --- positive), so grant + SUM(amount_units) collapses to grant - consumed. One-time read of the --- ledger; after this the counter stands alone. Grant = team override policy, else the default. --- --------------------------------------------------------------------------------------------- - -UPDATE stirling_pdf.payg_team_extensions ext - SET free_units_remaining = GREATEST( - 0, - COALESCE( - (SELECT pp.free_tier_units FROM stirling_pdf.pricing_policy pp - WHERE pp.policy_id = ext.pricing_policy_id), - (SELECT pp.free_tier_units FROM stirling_pdf.pricing_policy pp - WHERE pp.is_default = TRUE LIMIT 1), - 0) - + COALESCE( - (SELECT SUM(wl.amount_units) FROM stirling_pdf.wallet_ledger wl - WHERE wl.team_id = ext.team_id - AND wl.entry_type IN ('DEBIT', 'REFUND')), - 0)); diff --git a/app/saas/src/main/resources/db/migration/saas/V20__payg_launch_free_grant.sql b/app/saas/src/main/resources/db/migration/saas/V20__payg_launch_free_grant.sql deleted file mode 100644 index d9dc756e31..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V20__payg_launch_free_grant.sql +++ /dev/null @@ -1,42 +0,0 @@ --- PAYG launch free grant: give the default pricing policy a real one-time grant. --- --- V14 added pricing_policy.free_tier_units with DEFAULT 0, and the default policy seeded in V12 --- predates the column — so on a fresh deploy every team's free_units_remaining seeds to 0 and --- V19's "every team gets a one-time free grant" intent ships dead (teams are gated / metered from --- the very first billable document). This migration sets the launch grant on the default policy --- and re-seeds existing teams that V19 left at 0 (V19 ran its backfill while the grant was still --- 0, so every then-existing team computed to 0). --- --- The launch value lives on the default policy row; tune it there (or via a future admin surface). --- Both updates are guarded so a deliberately-tuned value — e.g. a smaller test grant — is never --- clobbered. - --- --------------------------------------------------------------------------------------------- --- 1. Launch grant on the default policy, only where it's still the accidental 0. --- --------------------------------------------------------------------------------------------- -UPDATE stirling_pdf.pricing_policy - SET free_tier_units = 500 - WHERE is_default = TRUE - AND free_tier_units = 0; - --- --------------------------------------------------------------------------------------------- --- 2. Re-seed existing teams V19 left at 0. Same recompute as V19's backfill — remaining = --- max(0, grant + net signed DEBIT/REFUND) — now that the grant is non-zero. Guarded to --- free_units_remaining = 0: a team with a deliberately-set positive balance is left alone, and --- a team that genuinely exhausted a real grant also recomputes to 0, so the guard is safe. --- --------------------------------------------------------------------------------------------- -UPDATE stirling_pdf.payg_team_extensions ext - SET free_units_remaining = GREATEST( - 0, - COALESCE( - (SELECT pp.free_tier_units FROM stirling_pdf.pricing_policy pp - WHERE pp.policy_id = ext.pricing_policy_id), - (SELECT pp.free_tier_units FROM stirling_pdf.pricing_policy pp - WHERE pp.is_default = TRUE LIMIT 1), - 0) - + COALESCE( - (SELECT SUM(wl.amount_units) FROM stirling_pdf.wallet_ledger wl - WHERE wl.team_id = ext.team_id - AND wl.entry_type IN ('DEBIT', 'REFUND')), - 0)) - WHERE ext.free_units_remaining = 0; diff --git a/app/saas/src/main/resources/db/migration/saas/V21__drop_wallet_category_summary_view.sql b/app/saas/src/main/resources/db/migration/saas/V21__drop_wallet_category_summary_view.sql deleted file mode 100644 index 857e0bfa65..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V21__drop_wallet_category_summary_view.sql +++ /dev/null @@ -1,11 +0,0 @@ --- Drop the unused wallet_category_summary view. --- --- V16 created this view to back the wallet's per-category spend breakdown via --- WalletCategorySummaryDao. That DAO was never wired up — the breakdown is built from the JPA --- repository (WalletLedgerRepository.sumPeriodAmountByCategory) instead — so both the DAO and this --- view have zero readers. The DAO is deleted in the same change; this drops the dead view. --- --- Done as a new migration (not by editing V16) so Flyway's checksum validation doesn't fail on --- databases that already applied V16. IF EXISTS keeps it safe on DBs where V16 hasn't run. - -DROP VIEW IF EXISTS stirling_pdf.wallet_category_summary; 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 deleted file mode 100644 index babdaef562..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V22__policy_engine_tables.sql +++ /dev/null @@ -1,35 +0,0 @@ --- 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/app/saas/src/main/resources/db/migration/saas/V23__policy_source_doc_counts.sql b/app/saas/src/main/resources/db/migration/saas/V23__policy_source_doc_counts.sql deleted file mode 100644 index 7dcbaf1c0e..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V23__policy_source_doc_counts.sql +++ /dev/null @@ -1,26 +0,0 @@ --- Per-source document throughput, in two tables: --- --- policy_source_doc_counts one row per source per hour bucket (hours-since-epoch), holding how --- many documents that source fed into runs in that hour. Feeds the --- rolling last-24h / last-30d windows and the 30-day daily series, and --- is pruned to that window so it stays bounded. --- policy_source_doc_totals a denormalized lifetime total per source, incremented alongside the --- hourly bucket, so the overview reads the all-time figure in one row --- instead of scanning a source's whole bucket history - and so the --- hourly buckets can be pruned without losing it. --- --- Gated by policies.enabled like the rest of the subsystem; Hibernate ddl-auto would also create --- these, but the migration keeps the schema explicit for the Flyway-managed deployments. - -CREATE TABLE IF NOT EXISTS policy_source_doc_counts ( - source_id VARCHAR(255) NOT NULL, - bucket_hour BIGINT NOT NULL, - doc_count BIGINT NOT NULL DEFAULT 0, - PRIMARY KEY (source_id, bucket_hour) -); - -CREATE TABLE IF NOT EXISTS policy_source_doc_totals ( - source_id VARCHAR(255) NOT NULL, - doc_total BIGINT NOT NULL DEFAULT 0, - PRIMARY KEY (source_id) -); diff --git a/app/saas/src/main/resources/db/migration/saas/V24__account_link_instances.sql b/app/saas/src/main/resources/db/migration/saas/V24__account_link_instances.sql deleted file mode 100644 index 9c975f1ce7..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V24__account_link_instances.sql +++ /dev/null @@ -1,44 +0,0 @@ --- Account-link instances. One row per self-hosted instance that has linked a SaaS account. --- --- Part of the combined-billing "Mode A" (connected self-hosted) flow: --- 1. An admin signs into their SaaS account in the Stirling Portal via the Supabase JS SDK --- (a short-lived Supabase JWT, refreshed client-side — it never reaches the server long-term). --- 2. That JWT is used ONCE to call POST /api/v1/account-link/register, which mints a --- device_id + device_secret bound to the admin's team. The secret is returned once and --- stored only on the instance; we keep a SHA-256 hash here (the secret is high-entropy, --- so an unsalted hash is sufficient — same posture as API keys). --- 3. The instance authenticates all unattended metering / entitlement calls with that device --- credential. No long-lived user JWT lives on the server side. --- --- Twin of supabase/migrations/20260619000000_account_link_instances.sql (Stirling-PDF-SaaS). --- Inert until release: the AccountLinkController + device-credential filter are gated behind --- stirling.billing.account-link.enabled (default off). The table itself is harmless additive. - -CREATE TABLE IF NOT EXISTS stirling_pdf.linked_instance ( - instance_id BIGSERIAL PRIMARY KEY, - team_id BIGINT NOT NULL REFERENCES stirling_pdf.teams(team_id) ON DELETE CASCADE, - created_by_user_id BIGINT, - -- admin who registered the instance; informational only (no FK so a user delete never - -- cascades a working instance offline). - device_id VARCHAR(64) NOT NULL UNIQUE, - -- public, non-secret identifier the instance presents on every request. - device_secret_hash VARCHAR(64) NOT NULL, - -- SHA-256 hex of the device secret; the secret itself is never stored. - name VARCHAR(255), - -- operator-set display label (hostname etc.) for the "Linked instances" list. - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - last_seen_at TIMESTAMP, - -- stamped when the device credential last authenticated; powers staleness display. - revoked_at TIMESTAMP - -- NULL = active. Set on unlink/revoke; a revoked credential fails authentication. -); - -CREATE INDEX IF NOT EXISTS idx_linked_instance_team - ON stirling_pdf.linked_instance (team_id); - -COMMENT ON TABLE stirling_pdf.linked_instance IS - 'One row per self-hosted instance linked to a SaaS account (combined-billing Mode A). ' - 'device_id is the public identifier; device_secret_hash is the SHA-256 of the bearer ' - 'secret (returned once at registration, stored only on the instance). The instance ' - 'authenticates unattended metering / entitlement calls with this credential; revoked_at ' - 'IS NULL means active.'; diff --git a/app/saas/src/main/resources/db/migration/saas/V25__payg_instance_usage.sql b/app/saas/src/main/resources/db/migration/saas/V25__payg_instance_usage.sql deleted file mode 100644 index 7ab65b8b78..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V25__payg_instance_usage.sql +++ /dev/null @@ -1,35 +0,0 @@ --- Twin of supabase/migrations/_payg_instance_usage.sql (Stirling-PDF-SaaS). Keep the table --- definition byte-identical to the Supabase twin — both repos own this stirling_pdf table (the SaaS --- profile runs this Flyway migration against the Supabase-backed DB; non-Hibernate consumers — RLS, --- PostgREST, edge functions — rely on the Supabase migration ledger having the matching entry). --- --- Per-(team, billing period, category) last-seen cumulative usage reported by a linked self-hosted --- instance (combined-billing "Mode A"). The instance reports monotonic cumulative unit totals on --- its daily sync; SaaS bills the DELTA since the last sync — idempotent (a resend bills nothing) and --- tamper-evident (a counter that drops is a signal) — by reusing the standard charge path --- (JobChargeService.chargeStandalone), so no separate billing logic exists for this flow. --- --- Inert until release: written only by the InstanceController /sync endpoint, gated behind --- stirling.billing.account-link.enabled (default off). Additive, idempotent table. - -CREATE TABLE IF NOT EXISTS stirling_pdf.payg_instance_usage ( - id BIGSERIAL PRIMARY KEY, - team_id BIGINT NOT NULL REFERENCES stirling_pdf.teams(team_id) ON DELETE CASCADE, - period_start TIMESTAMP NOT NULL, - category VARCHAR(32) NOT NULL, - -- Highest cumulative unit total seen for this (team, period, category); the next sync bills - -- (reported cumulative - this). - last_cumulative_units BIGINT NOT NULL DEFAULT 0, - -- Highest sync sequence applied; a sync at or below this is a replay and is ignored. - last_sync_seq BIGINT NOT NULL DEFAULT 0, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT uk_payg_instance_usage UNIQUE (team_id, period_start, category) -); - -CREATE INDEX IF NOT EXISTS idx_payg_instance_usage_team - ON stirling_pdf.payg_instance_usage (team_id); - -COMMENT ON TABLE stirling_pdf.payg_instance_usage IS - 'Last-seen cumulative usage per (team, billing period, category) reported by linked self-hosted ' - 'instances (combined-billing Mode A). SaaS bills the delta vs last_cumulative_units via the ' - 'standard charge path; last_sync_seq dedups replays.'; diff --git a/app/saas/src/main/resources/db/migration/saas/V25__resource_grants.sql b/app/saas/src/main/resources/db/migration/saas/V25__resource_grants.sql deleted file mode 100644 index f1eab58b15..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V25__resource_grants.sql +++ /dev/null @@ -1,21 +0,0 @@ --- Resource access grants: which user/team may use a gated resource (portal, integration config). - -CREATE TABLE IF NOT EXISTS resource_grants ( - resource_grant_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, - resource_type VARCHAR(64) NOT NULL, - resource_id VARCHAR(255) NOT NULL DEFAULT '', - principal_type VARCHAR(32) NOT NULL, - principal_id BIGINT NOT NULL, - permission VARCHAR(32) NOT NULL, - granted_by_user_id BIGINT, - created_at TIMESTAMP NOT NULL DEFAULT now() -); - -CREATE UNIQUE INDEX IF NOT EXISTS uk_resource_grant - ON resource_grants (resource_type, resource_id, principal_type, principal_id, permission); - -CREATE INDEX IF NOT EXISTS idx_resource_grants_lookup - ON resource_grants (resource_type, resource_id); - -CREATE INDEX IF NOT EXISTS idx_resource_grants_principal - ON resource_grants (principal_type, principal_id); diff --git a/app/saas/src/main/resources/db/migration/saas/V26__integration_configs.sql b/app/saas/src/main/resources/db/migration/saas/V26__integration_configs.sql deleted file mode 100644 index c78660248e..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V26__integration_configs.sql +++ /dev/null @@ -1,25 +0,0 @@ --- S3/MCP/API integration configs; config_encrypted holds an AES-GCM encrypted JSON blob. - -CREATE TABLE IF NOT EXISTS integration_configs ( - integration_config_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, - integration_type VARCHAR(32) NOT NULL, - name VARCHAR(255) NOT NULL, - scope VARCHAR(32) NOT NULL, - owner_user_id BIGINT, - owner_team_id BIGINT, - enabled BOOLEAN NOT NULL DEFAULT TRUE, - locked BOOLEAN NOT NULL DEFAULT FALSE, - default_access VARCHAR(32) NOT NULL DEFAULT 'EXPLICIT_ONLY', - config_encrypted TEXT, - created_at TIMESTAMP NOT NULL DEFAULT now(), - updated_at TIMESTAMP NOT NULL DEFAULT now() -); - -CREATE INDEX IF NOT EXISTS idx_integration_configs_owner - ON integration_configs (owner_user_id); - -CREATE INDEX IF NOT EXISTS idx_integration_configs_type - ON integration_configs (integration_type); - -CREATE INDEX IF NOT EXISTS idx_integration_configs_scope - ON integration_configs (scope); diff --git a/app/saas/src/main/resources/db/migration/saas/V26__payg_shadow_charge_linked_instance_source.sql b/app/saas/src/main/resources/db/migration/saas/V26__payg_shadow_charge_linked_instance_source.sql deleted file mode 100644 index f79097e325..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V26__payg_shadow_charge_linked_instance_source.sql +++ /dev/null @@ -1,19 +0,0 @@ --- Twin of supabase/migrations/_payg_shadow_charge_linked_instance_source.sql (Stirling-PDF-SaaS). --- Keep byte-identical to the Supabase twin. --- --- Widen the payg_shadow_charge.job_source CHECK to allow LINKED_INSTANCE (combined-billing "Mode --- A"). A linked instance's daily-sync charge runs through JobChargeService.chargeStandalone, which --- writes a payg_shadow_charge row with job_source=LINKED_INSTANCE — a JobSource value added after --- the original constraint, so the insert was failing the check and 500ing POST /api/v1/instance/sync. --- --- Idempotent (DROP IF EXISTS + ADD, so it survives being applied by both the Flyway and Supabase --- migration sets against the same schema) and additive (the new set is a superset of the JobSource --- enum; the app only ever writes enum values, so no existing row can violate it). - -ALTER TABLE stirling_pdf.payg_shadow_charge - DROP CONSTRAINT IF EXISTS payg_shadow_charge_job_source_check; - -ALTER TABLE stirling_pdf.payg_shadow_charge - ADD CONSTRAINT payg_shadow_charge_job_source_check - CHECK (job_source IS NULL - OR job_source IN ('WEB', 'API', 'PIPELINE', 'DESKTOP_APP', 'LINKED_INSTANCE')); diff --git a/app/saas/src/main/resources/db/migration/saas/V27__procurement.sql b/app/saas/src/main/resources/db/migration/saas/V27__procurement.sql deleted file mode 100644 index 0048f999c1..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V27__procurement.sql +++ /dev/null @@ -1,72 +0,0 @@ --- Enterprise procurement: the tables that track a linked team's journey from trial to live. --- --- One deal per team (the commercial journey: trial -> quote -> agreement -> payment -> live), the --- quotes built against it (the itemised, priced offers), and an append-only activity log for the --- money/licence-touching actions. The resulting subscription is mirrored in billing_subscriptions --- (seeded on trial start / payment); the entitlement that unlocks the product is a Keygen licence --- referenced by procurement_deal.license_ref. Prices are computed server-side (ProcurementPricingService). --- --- Additive and idempotent (IF NOT EXISTS) — safe on the shared dev branch. - -CREATE TABLE IF NOT EXISTS stirling_pdf.procurement_deal ( - deal_id BIGSERIAL PRIMARY KEY, - team_id BIGINT NOT NULL UNIQUE REFERENCES stirling_pdf.teams(team_id) ON DELETE CASCADE, - -- one active deal per team; the journey lives on this row. - stage VARCHAR(32) NOT NULL DEFAULT 'trial', - -- trial | quote | security (agreement) | procurement (payment) | active (live) - trial_started_at TIMESTAMP, - trial_ends_at TIMESTAMP, - trial_extensions_used INT NOT NULL DEFAULT 0, - license_ref VARCHAR(128), - -- Keygen licence id issued for this deal (trial or annual). Mocked until Keygen mgmt lands. - subscription_id VARCHAR(255), - -- Stripe subscription id, mirrored into billing_subscriptions once commercial. - accepted_quote_id BIGINT, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - version BIGINT NOT NULL DEFAULT 0 -); - -CREATE TABLE IF NOT EXISTS stirling_pdf.procurement_quote ( - quote_id BIGSERIAL PRIMARY KEY, - deal_id BIGINT NOT NULL REFERENCES stirling_pdf.procurement_deal(deal_id) ON DELETE CASCADE, - quote_number VARCHAR(64) NOT NULL, - status VARCHAR(24) NOT NULL DEFAULT 'draft', - -- draft | sent | accepted | expired - currency VARCHAR(8) NOT NULL DEFAULT 'USD', - volume BIGINT NOT NULL, - seats INT, - deployment VARCHAR(24), - term_years INT NOT NULL, - service_level VARCHAR(24) NOT NULL, - indemnification BOOLEAN NOT NULL DEFAULT FALSE, - training BOOLEAN NOT NULL DEFAULT FALSE, - qbr BOOLEAN NOT NULL DEFAULT FALSE, - annual_net_minor BIGINT NOT NULL, - -- recurring annual fee after the multi-year discount, in minor units (cents). - tcv_minor BIGINT NOT NULL, - -- total contract value across the term incl. one-time fees, minor units. - line_items TEXT, - -- JSON snapshot of the itemised lines the order form renders. - stripe_price_id VARCHAR(128), - checkout_session_id VARCHAR(255), - checkout_url TEXT, - valid_until DATE, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - version BIGINT NOT NULL DEFAULT 0 -); - -CREATE TABLE IF NOT EXISTS stirling_pdf.procurement_activity ( - activity_id BIGSERIAL PRIMARY KEY, - deal_id BIGINT NOT NULL REFERENCES stirling_pdf.procurement_deal(deal_id) ON DELETE CASCADE, - actor_user_id BIGINT, - -- the internal/portal user who took the action; informational (no FK). - action VARCHAR(48) NOT NULL, - -- trial_started | trial_extended | quote_built | quote_accepted | checkout_created | went_live ... - detail TEXT, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX IF NOT EXISTS idx_procurement_quote_deal ON stirling_pdf.procurement_quote (deal_id); -CREATE INDEX IF NOT EXISTS idx_procurement_activity_deal ON stirling_pdf.procurement_activity (deal_id); diff --git a/app/saas/src/main/resources/db/migration/saas/V28__procurement_stripe_quote.sql b/app/saas/src/main/resources/db/migration/saas/V28__procurement_stripe_quote.sql deleted file mode 100644 index 5d83ee35f8..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V28__procurement_stripe_quote.sql +++ /dev/null @@ -1,8 +0,0 @@ --- Stripe Quote support: a procurement quote is issued as a real Stripe Quote (finalized → PDF + --- shareable), and on acceptance Stripe creates the committed subscription + first invoice. The --- Stripe operations live in Supabase edge functions; these columns hold the references they write --- back. Twin of Supabase migration 20260703000000_procurement_stripe_quote.sql. - -ALTER TABLE stirling_pdf.procurement_quote - ADD COLUMN IF NOT EXISTS stripe_quote_id VARCHAR(128), - ADD COLUMN IF NOT EXISTS stripe_invoice_url TEXT; diff --git a/app/saas/src/main/resources/db/migration/saas/V29__procurement_business_name.sql b/app/saas/src/main/resources/db/migration/saas/V29__procurement_business_name.sql deleted file mode 100644 index ba9c74dde8..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V29__procurement_business_name.sql +++ /dev/null @@ -1,5 +0,0 @@ --- Persist the buyer's company name on the quote so re-editing remembers it and it can be shown on --- the quote/agreement. Twin of Supabase migration 20260705000000_procurement_business_name.sql. - -ALTER TABLE stirling_pdf.procurement_quote - ADD COLUMN IF NOT EXISTS business_name VARCHAR(255); diff --git a/app/saas/src/main/resources/db/migration/saas/V2__saas_columns.sql b/app/saas/src/main/resources/db/migration/saas/V2__saas_columns.sql deleted file mode 100644 index 74f591c1df..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V2__saas_columns.sql +++ /dev/null @@ -1,7 +0,0 @@ --- SaaS-only column additions on top of the OSS users schema. Idempotent. - -ALTER TABLE users ADD COLUMN IF NOT EXISTS email VARCHAR(255); -ALTER TABLE users ADD COLUMN IF NOT EXISTS supabase_id UUID; - -CREATE UNIQUE INDEX IF NOT EXISTS uk_users_email ON users (email); -CREATE UNIQUE INDEX IF NOT EXISTS uk_users_supabase_id ON users (supabase_id); diff --git a/app/saas/src/main/resources/db/migration/saas/V30__classification_labels.sql b/app/saas/src/main/resources/db/migration/saas/V30__classification_labels.sql deleted file mode 100644 index 0719d73fb1..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V30__classification_labels.sql +++ /dev/null @@ -1,13 +0,0 @@ --- Classification labels (gated by policies.enabled): the flat multi-label vocabulary the document --- classifier runs against. One admin-editable row per team. The whole label set lives as JSON in --- labels_json (authoritative on read). team_id is a natural key and a plain value (not a foreign --- key) to stay decoupled from the security entities, so classification can be enabled or disabled --- without touching them; the sentinel 0 holds the unteamed (login-disabled) team set. Hibernate --- ddl-auto would also create this, but this keeps the schema explicit for Flyway-managed deploys. - -CREATE TABLE IF NOT EXISTS classification_labels ( - team_id BIGINT PRIMARY KEY, - labels_json TEXT, - updated_at TIMESTAMP, - updated_by VARCHAR(255) -); diff --git a/app/saas/src/main/resources/db/migration/saas/V30__procurement_offline_license.sql b/app/saas/src/main/resources/db/migration/saas/V30__procurement_offline_license.sql deleted file mode 100644 index e7ca0233a7..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V30__procurement_offline_license.sql +++ /dev/null @@ -1,6 +0,0 @@ --- Offline / air-gapped licence add-on flag on the quote (a paid add-on; priced like QBR). Written --- and read by the Java backend via JPA. Twin of Supabase migration --- 20260710000000_procurement_offline_license.sql. - -ALTER TABLE stirling_pdf.procurement_quote - ADD COLUMN IF NOT EXISTS offline_license BOOLEAN NOT NULL DEFAULT false; diff --git a/app/saas/src/main/resources/db/migration/saas/V31__policy_sort_order.sql b/app/saas/src/main/resources/db/migration/saas/V31__policy_sort_order.sql deleted file mode 100644 index 2b2bdbd25b..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V31__policy_sort_order.sql +++ /dev/null @@ -1,5 +0,0 @@ --- Team-wide policy run order (see PolicyEntity.sortOrder). The order policies run in is stored --- server-side and shared by the whole team (not per-user in the browser), and is admin-editable. --- Nullable so existing rows keep working; reads treat a null as 0 (coalesce), and the store --- appends a new policy at max(order)+1 so setting one up adds it to the end of the queue. -ALTER TABLE policies ADD COLUMN sort_order INTEGER; diff --git a/app/saas/src/main/resources/db/migration/saas/V31__procurement_intensity.sql b/app/saas/src/main/resources/db/migration/saas/V31__procurement_intensity.sql deleted file mode 100644 index 4920d52d26..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V31__procurement_intensity.sql +++ /dev/null @@ -1,6 +0,0 @@ --- Policy posture (runs per PDF) on the quote: the D71 meter is denominated in runs, so a quote --- must remember the posture it was priced at (Essentials 2, Governed 4, Regulated 7). Defaults to --- Governed (4). Written and read by the Java backend via JPA. A Supabase twin migration mirrors it. - -ALTER TABLE stirling_pdf.procurement_quote - ADD COLUMN IF NOT EXISTS intensity INTEGER NOT NULL DEFAULT 4; diff --git a/app/saas/src/main/resources/db/migration/saas/V32__policy_processed_files.sql b/app/saas/src/main/resources/db/migration/saas/V32__policy_processed_files.sql deleted file mode 100644 index 4be3fa07d8..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V32__policy_processed_files.sql +++ /dev/null @@ -1,36 +0,0 @@ --- Per-policy processed-file ledger: --- --- policy_processed_files one row per (policy, file identity) recording the version a policy --- last settled that file at, so folder sources track files in place --- instead of moving them into a work directory. signature is a cheap --- version gate (folder: size:mtime); content_hash an optional strong --- token consulted only when the gate moves. Rows are claimed into --- PROCESSING, settled to DONE/ERROR, flipped to INTERRUPTED at boot if --- a run died with the JVM, and pruned once the file is gone from all of --- the policy's sources, so the table stays near the set of files --- currently present. --- --- Gated by policies.enabled like the rest of the subsystem; Hibernate ddl-auto would also create --- this, but the migration keeps the schema explicit for the Flyway-managed deployments. - -CREATE TABLE IF NOT EXISTS policy_processed_files ( - policy_id VARCHAR(255) NOT NULL, - identity_hash VARCHAR(64) NOT NULL, - identity VARCHAR(4096), - signature VARCHAR(255) NOT NULL, - content_hash VARCHAR(64), - status VARCHAR(16) NOT NULL, - attempts SMALLINT NOT NULL DEFAULT 1, - last_seen BIGINT NOT NULL DEFAULT 0, - updated_at BIGINT NOT NULL DEFAULT 0, - PRIMARY KEY (policy_id, identity_hash) -); - -CREATE INDEX IF NOT EXISTS idx_processed_files_policy_seen - ON policy_processed_files (policy_id, last_seen); - --- The cross-policy deletion-consensus check (existsByIdentityHashAndStatusNot) filters identity_hash --- alone, so it cannot use the (policy_id, identity_hash) primary key; it runs once per successfully --- consumed file, so index it to avoid a full scan on the hot path. -CREATE INDEX IF NOT EXISTS idx_processed_files_identity - ON policy_processed_files (identity_hash); diff --git a/app/saas/src/main/resources/db/migration/saas/V33__api_keys.sql b/app/saas/src/main/resources/db/migration/saas/V33__api_keys.sql new file mode 100644 index 0000000000..abced067e6 --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V33__api_keys.sql @@ -0,0 +1,24 @@ +-- Named, multi-key personal API keys, plus per-key daily usage. +-- Idempotent: Hibernate ddl-auto=update may already have created these on some deployments. + +CREATE TABLE IF NOT EXISTS api_keys ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + key_hash VARCHAR(64) NOT NULL, + prefix VARCHAR(32) NOT NULL, + owner_user_id BIGINT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL, + last_used_at TIMESTAMPTZ, + revoked_at TIMESTAMPTZ +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_api_key_hash ON api_keys (key_hash); +CREATE INDEX IF NOT EXISTS idx_api_key_owner ON api_keys (owner_user_id); + +CREATE TABLE IF NOT EXISTS api_key_daily_usage ( + api_key_id BIGINT NOT NULL, + epoch_day BIGINT NOT NULL, + count BIGINT NOT NULL DEFAULT 0, + PRIMARY KEY (api_key_id, epoch_day) +); diff --git a/app/saas/src/main/resources/db/migration/saas/V33__procurement_invoice_pdf.sql b/app/saas/src/main/resources/db/migration/saas/V33__procurement_invoice_pdf.sql deleted file mode 100644 index 946b8415c6..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V33__procurement_invoice_pdf.sql +++ /dev/null @@ -1,7 +0,0 @@ --- Direct PDF link for a procurement quote's first invoice (Stripe invoice_pdf), stored at accept --- alongside stripe_invoice_url so the portal's "Download invoice" button survives a reload instead --- of relying on the transient accept response. Written by the accept edge function via the --- procurement_set_quote_accepted RPC; read by the Java backend via JPA. A Supabase twin mirrors it. - -ALTER TABLE stirling_pdf.procurement_quote - ADD COLUMN IF NOT EXISTS stripe_invoice_pdf TEXT; diff --git a/app/saas/src/main/resources/db/migration/saas/V34__procurement_deal_setup.sql b/app/saas/src/main/resources/db/migration/saas/V34__procurement_deal_setup.sql deleted file mode 100644 index 186e180cb2..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V34__procurement_deal_setup.sql +++ /dev/null @@ -1,10 +0,0 @@ --- Deployment target + seat count captured at the trial-start step (the setup dialog the demo shows --- before a trial begins), stored on the deal so the quote builder seeds from the buyer's real --- environment instead of a hardcoded default. deployment: cloud | selfhost | airgap. seats: 0 = --- unspecified. Written and read by the Java backend via JPA. A Supabase twin migration mirrors it. - -ALTER TABLE stirling_pdf.procurement_deal - ADD COLUMN IF NOT EXISTS deployment VARCHAR(16) NOT NULL DEFAULT 'cloud'; - -ALTER TABLE stirling_pdf.procurement_deal - ADD COLUMN IF NOT EXISTS seats INTEGER NOT NULL DEFAULT 0; diff --git a/app/saas/src/main/resources/db/migration/saas/V35__procurement_renewal.sql b/app/saas/src/main/resources/db/migration/saas/V35__procurement_renewal.sql deleted file mode 100644 index 75a4479c31..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V35__procurement_renewal.sql +++ /dev/null @@ -1,7 +0,0 @@ --- Persist the first post-term renewal fee (annual net + one CPI step) computed at quote time, so the --- figure shown to the buyer is locked to what they were quoted rather than recomputed from the --- current rate card on every read. Minor units. Written and read by the Java backend via JPA; a --- Supabase twin migration mirrors it. - -ALTER TABLE stirling_pdf.procurement_quote - ADD COLUMN IF NOT EXISTS renewal_annual_minor BIGINT NOT NULL DEFAULT 0; diff --git a/app/saas/src/main/resources/db/migration/saas/V36__procurement_size_mult.sql b/app/saas/src/main/resources/db/migration/saas/V36__procurement_size_mult.sql deleted file mode 100644 index 7c213cb3dc..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V36__procurement_size_mult.sql +++ /dev/null @@ -1,7 +0,0 @@ --- File-size tier multiplier on the quote (D93): larger, image-heavy PDFs cost more, so the buyer --- picks a size tier (Compact 1.0 / Standard 1.4 / Heavy 2.4) that scales the per-run rate. Persisted --- so the quote re-prices and re-seeds the builder consistently. Defaults to 1.0 (no uplift) for rows --- that predate the column. Written and read by the Java backend via JPA. A Supabase twin mirrors it. - -ALTER TABLE stirling_pdf.procurement_quote - ADD COLUMN IF NOT EXISTS size_mult DOUBLE PRECISION NOT NULL DEFAULT 1.0; diff --git a/app/saas/src/main/resources/db/migration/saas/V37__procurement_quote_billing_details.sql b/app/saas/src/main/resources/db/migration/saas/V37__procurement_quote_billing_details.sql deleted file mode 100644 index 3f07f201bb..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V37__procurement_quote_billing_details.sql +++ /dev/null @@ -1,17 +0,0 @@ --- Buyer / AP details captured on the quote's "Your details" step: the signatory contact and a --- billing address, plus a PO number and tax id for the invoice. All optional (never gate quote --- generation). Persisted so the quote re-seeds the builder on a re-edit and so the issue edge --- function can put them on the Stripe customer (name + bill-to address) and invoice (PO / tax id --- as custom fields). Country and currency are intentionally out of scope for now. Written and read --- by the Java backend via JPA. A Supabase twin mirrors these columns. - -ALTER TABLE stirling_pdf.procurement_quote - ADD COLUMN IF NOT EXISTS contact_name VARCHAR(255), - ADD COLUMN IF NOT EXISTS contact_email VARCHAR(255), - ADD COLUMN IF NOT EXISTS address_line1 VARCHAR(255), - ADD COLUMN IF NOT EXISTS address_line2 VARCHAR(255), - ADD COLUMN IF NOT EXISTS city VARCHAR(128), - ADD COLUMN IF NOT EXISTS region VARCHAR(128), - ADD COLUMN IF NOT EXISTS postal_code VARCHAR(32), - ADD COLUMN IF NOT EXISTS po_number VARCHAR(128), - ADD COLUMN IF NOT EXISTS tax_id VARCHAR(64); diff --git a/app/saas/src/main/resources/db/migration/saas/V38__payg_run_grouping_doc_count.sql b/app/saas/src/main/resources/db/migration/saas/V38__payg_run_grouping_doc_count.sql deleted file mode 100644 index bc1a4e0f16..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V38__payg_run_grouping_doc_count.sql +++ /dev/null @@ -1,39 +0,0 @@ --- PAYG size-scaled billing: run-scoped grouping + per-input-file counting. --- --- Two independent axes now live on a charge: --- * doc_units — billing quantity, scales with file size (existing column) --- * doc_count — number of INPUT files (the "unique PDFs" dimension); a split (1→many) stays 1, --- a merge (N→1) is N. Fixed at open; joined steps never change it. --- Plus run_id, the automation-run correlation id used to group a run's tool sub-steps into one --- charge (replacing the old content+time-window grouping) and to keep two separate runs distinct. --- --- Everything is additive; no existing rows are modified, no columns dropped. - --- ── processing_job ─────────────────────────────────────────────────────────── -ALTER TABLE processing_job ADD COLUMN IF NOT EXISTS run_id VARCHAR(64); -ALTER TABLE processing_job ADD COLUMN IF NOT EXISTS doc_count INTEGER NOT NULL DEFAULT 1; - -COMMENT ON COLUMN processing_job.run_id IS - 'Automation-run correlation id (X-Stirling-Run-Id); NULL for a standalone tool call. Lineage ' - 'joins are scoped to one run_id, so separate runs never merge even on identical bytes.'; -COMMENT ON COLUMN processing_job.doc_count IS - 'Number of input files this charge represents (the count dimension, distinct from size-scaled ' - 'doc_units). Split=1, merge=N. Fixed at open.'; - --- ── wallet_ledger ──────────────────────────────────────────────────────────── --- Denormalise the count dimension + input fingerprint onto the DEBIT row so usage analytics --- (unique PDFs, per-category counts, size-multiplier average) query one table and survive --- processing_job pruning. -ALTER TABLE wallet_ledger ADD COLUMN IF NOT EXISTS doc_count INTEGER NOT NULL DEFAULT 1; -ALTER TABLE wallet_ledger ADD COLUMN IF NOT EXISTS document_fingerprint VARCHAR(64); - -COMMENT ON COLUMN wallet_ledger.doc_count IS - 'Input-file count for this entry (mirrors processing_job.doc_count); summed for "PDFs processed".'; -COMMENT ON COLUMN wallet_ledger.document_fingerprint IS - 'SHA-256 of the entry''s input file set; COUNT(DISTINCT ...) gives unique PDFs. NULL for ' - 'aggregate/system entries (e.g. linked-instance sync).'; - --- Distinct-PDF + size-multiplier queries scan by team + period. -CREATE INDEX IF NOT EXISTS idx_wallet_ledger_team_period_fp - ON wallet_ledger (team_id, occurred_at, document_fingerprint) - WHERE document_fingerprint IS NOT NULL; diff --git a/app/saas/src/main/resources/db/migration/saas/V3__saas_billing.sql b/app/saas/src/main/resources/db/migration/saas/V3__saas_billing.sql deleted file mode 100644 index 169f58cce5..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V3__saas_billing.sql +++ /dev/null @@ -1,16 +0,0 @@ --- Stripe billing subscription mirror, populated by Supabase webhooks. - -CREATE TABLE IF NOT EXISTS billing_subscriptions ( - id VARCHAR(255) PRIMARY KEY, - user_id UUID NOT NULL, - team_id BIGINT, - status VARCHAR(64) NOT NULL, - price_id VARCHAR(255), - current_period_end TIMESTAMP, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX IF NOT EXISTS idx_billing_subscriptions_user_id ON billing_subscriptions (user_id); -CREATE INDEX IF NOT EXISTS idx_billing_subscriptions_team_id ON billing_subscriptions (team_id); -CREATE INDEX IF NOT EXISTS idx_billing_subscriptions_status ON billing_subscriptions (status); diff --git a/app/saas/src/main/resources/db/migration/saas/V4__saas_credits.sql b/app/saas/src/main/resources/db/migration/saas/V4__saas_credits.sql deleted file mode 100644 index 63384bdd5a..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V4__saas_credits.sql +++ /dev/null @@ -1,39 +0,0 @@ --- Per-user and per-team credit pools. - -CREATE TABLE IF NOT EXISTS user_credits ( - credit_id BIGSERIAL PRIMARY KEY, - user_id BIGINT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, - cycle_credits_remaining INTEGER NOT NULL DEFAULT 0, - cycle_credits_allocated INTEGER NOT NULL DEFAULT 0, - bought_credits_remaining INTEGER NOT NULL DEFAULT 0, - total_bought_credits INTEGER NOT NULL DEFAULT 0, - last_cycle_reset_at TIMESTAMP, - last_api_usage TIMESTAMP, - total_api_calls_made BIGINT NOT NULL DEFAULT 0, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - version BIGINT NOT NULL DEFAULT 0, - CONSTRAINT uk_user_credits_user_id UNIQUE (user_id) -); - -CREATE INDEX IF NOT EXISTS idx_user_credits_user_id ON user_credits (user_id); -CREATE INDEX IF NOT EXISTS idx_user_credits_last_reset ON user_credits (last_cycle_reset_at); -CREATE INDEX IF NOT EXISTS idx_user_credits_last_usage ON user_credits (last_api_usage); - -CREATE TABLE IF NOT EXISTS team_credits ( - credit_id BIGSERIAL PRIMARY KEY, - team_id BIGINT NOT NULL UNIQUE REFERENCES teams(id) ON DELETE CASCADE, - cycle_credits_remaining INTEGER NOT NULL DEFAULT 0, - cycle_credits_allocated INTEGER NOT NULL DEFAULT 0, - bought_credits_remaining INTEGER NOT NULL DEFAULT 0, - total_bought_credits INTEGER NOT NULL DEFAULT 0, - last_cycle_reset_at TIMESTAMP, - last_api_usage TIMESTAMP, - total_api_calls_made BIGINT NOT NULL DEFAULT 0, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - version BIGINT NOT NULL DEFAULT 0 -); - -CREATE INDEX IF NOT EXISTS idx_team_credits_team_id ON team_credits (team_id); -CREATE INDEX IF NOT EXISTS idx_team_credits_last_reset ON team_credits (last_cycle_reset_at); diff --git a/app/saas/src/main/resources/db/migration/saas/V5__saas_teams.sql b/app/saas/src/main/resources/db/migration/saas/V5__saas_teams.sql deleted file mode 100644 index dccacfe2b7..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V5__saas_teams.sql +++ /dev/null @@ -1,40 +0,0 @@ --- Team memberships and email-based team invitations. - -CREATE TABLE IF NOT EXISTS team_memberships ( - membership_id BIGSERIAL PRIMARY KEY, - team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE, - user_id BIGINT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, - role VARCHAR(50) NOT NULL DEFAULT 'MEMBER', - invited_by_user_id BIGINT REFERENCES users(user_id) ON DELETE SET NULL, - invited_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - accepted_at TIMESTAMP, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT uk_team_memberships_team_user UNIQUE (team_id, user_id), - CONSTRAINT chk_team_memberships_role CHECK (role IN ('LEADER', 'MEMBER')) -); - -CREATE INDEX IF NOT EXISTS idx_team_memberships_team ON team_memberships (team_id); -CREATE INDEX IF NOT EXISTS idx_team_memberships_user ON team_memberships (user_id); -CREATE INDEX IF NOT EXISTS idx_team_memberships_team_role ON team_memberships (team_id, role); - -CREATE TABLE IF NOT EXISTS team_invitations ( - invitation_id BIGSERIAL PRIMARY KEY, - team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE, - inviter_user_id BIGINT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, - invitee_email VARCHAR(255) NOT NULL, - invitee_user_id BIGINT REFERENCES users(user_id) ON DELETE CASCADE, - status VARCHAR(50) NOT NULL DEFAULT 'PENDING', - invitation_token VARCHAR(255) UNIQUE NOT NULL, - expires_at TIMESTAMP NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT chk_team_invitations_status CHECK ( - status IN ('PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED', 'EXPIRED') - ) -); - -CREATE INDEX IF NOT EXISTS idx_team_invitations_team ON team_invitations (team_id); -CREATE INDEX IF NOT EXISTS idx_team_invitations_email ON team_invitations (invitee_email); -CREATE INDEX IF NOT EXISTS idx_team_invitations_token ON team_invitations (invitation_token); -CREATE INDEX IF NOT EXISTS idx_team_invitations_status ON team_invitations (status); diff --git a/app/saas/src/main/resources/db/migration/saas/V6__saas_user_error_tracker.sql b/app/saas/src/main/resources/db/migration/saas/V6__saas_user_error_tracker.sql deleted file mode 100644 index 12f6a16b00..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V6__saas_user_error_tracker.sql +++ /dev/null @@ -1,15 +0,0 @@ --- Per-user processing-error tracker. - -CREATE TABLE IF NOT EXISTS user_error_tracker ( - error_tracker_id BIGSERIAL PRIMARY KEY, - user_id BIGINT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, - endpoint VARCHAR(255), - processing_error_count INTEGER NOT NULL DEFAULT 0, - last_processing_error TIMESTAMP, - reset_after TIMESTAMP, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX IF NOT EXISTS idx_user_error_tracker_user_id ON user_error_tracker (user_id); -CREATE INDEX IF NOT EXISTS idx_user_error_tracker_endpoint ON user_error_tracker (endpoint); diff --git a/app/saas/src/main/resources/db/migration/saas/V8__saas_ai_create_sessions.sql b/app/saas/src/main/resources/db/migration/saas/V8__saas_ai_create_sessions.sql deleted file mode 100644 index 1b8364793c..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V8__saas_ai_create_sessions.sql +++ /dev/null @@ -1,28 +0,0 @@ --- AI document-creation sessions for chat / outline-to-PDF flows. - -CREATE TABLE IF NOT EXISTS ai_create_sessions ( - session_id VARCHAR(64) PRIMARY KEY, - user_id VARCHAR(255) NOT NULL, - doc_type VARCHAR(255), - template_id VARCHAR(255), - template_tex VARCHAR(255), - preview_tex VARCHAR(255), - prompt_initial TEXT, - prompt_latest TEXT, - outline_text TEXT, - outline_filename VARCHAR(255), - outline_approved BOOLEAN NOT NULL DEFAULT FALSE, - outline_constraints TEXT, - draft_sections TEXT, - polished_latex TEXT, - pdf_url VARCHAR(2048), - status VARCHAR(32) NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX IF NOT EXISTS idx_ai_create_sessions_user_id ON ai_create_sessions (user_id); -CREATE INDEX IF NOT EXISTS idx_ai_create_sessions_updated_at ON ai_create_sessions (updated_at DESC); -CREATE INDEX IF NOT EXISTS idx_ai_create_sessions_user_pdf - ON ai_create_sessions (user_id, updated_at DESC) - WHERE pdf_url IS NOT NULL; diff --git a/app/saas/src/main/resources/db/migration/saas/V9__saas_user_team_extensions.sql b/app/saas/src/main/resources/db/migration/saas/V9__saas_user_team_extensions.sql deleted file mode 100644 index df9b8000ca..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V9__saas_user_team_extensions.sql +++ /dev/null @@ -1,77 +0,0 @@ --- Saas-only sidecar tables for user and team metadata. - -CREATE TABLE IF NOT EXISTS saas_user_extensions ( - user_id BIGINT PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE, - has_metered_billing_enabled BOOLEAN NOT NULL DEFAULT FALSE, - api_key_first_used_at TIMESTAMP, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX IF NOT EXISTS idx_saas_user_extensions_metered_billing - ON saas_user_extensions (has_metered_billing_enabled); - -CREATE TABLE IF NOT EXISTS saas_team_extensions ( - team_id BIGINT PRIMARY KEY REFERENCES teams(id) ON DELETE CASCADE, - team_type VARCHAR(32) NOT NULL DEFAULT 'STANDARD', - is_personal BOOLEAN NOT NULL DEFAULT FALSE, - seat_count INTEGER NOT NULL DEFAULT 1, - seats_used INTEGER NOT NULL DEFAULT 0, - max_seats INTEGER NOT NULL DEFAULT 1, - created_by_user_id BIGINT, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - version BIGINT NOT NULL DEFAULT 0 -); - -CREATE INDEX IF NOT EXISTS idx_saas_team_extensions_is_personal - ON saas_team_extensions (is_personal); -CREATE INDEX IF NOT EXISTS idx_saas_team_extensions_created_by_user_id - ON saas_team_extensions (created_by_user_id); - --- Backfill from any pre-existing columns on users / teams, then drop them. -DO $$ -BEGIN - IF EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = 'users' - AND column_name = 'has_metered_billing_enabled' - ) THEN - INSERT INTO saas_user_extensions (user_id, has_metered_billing_enabled, api_key_first_used_at) - SELECT user_id, COALESCE(has_metered_billing_enabled, FALSE), api_key_first_used_at - FROM users - ON CONFLICT (user_id) DO NOTHING; - END IF; - - IF EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = 'teams' - AND column_name = 'team_type' - ) THEN - INSERT INTO saas_team_extensions ( - team_id, team_type, is_personal, seat_count, seats_used, max_seats, created_by_user_id - ) - SELECT id, - COALESCE(team_type, 'STANDARD'), - COALESCE(is_personal, FALSE), - COALESCE(seat_count, 1), - COALESCE(seats_used, 0), - COALESCE(max_seats, 1), - created_by_user_id - FROM teams - ON CONFLICT (team_id) DO NOTHING; - - ALTER TABLE teams DROP COLUMN IF EXISTS team_type; - ALTER TABLE teams DROP COLUMN IF EXISTS is_personal; - ALTER TABLE teams DROP COLUMN IF EXISTS seat_count; - ALTER TABLE teams DROP COLUMN IF EXISTS seats_used; - ALTER TABLE teams DROP COLUMN IF EXISTS max_seats; - ALTER TABLE teams DROP COLUMN IF EXISTS created_by_user_id; - END IF; - - ALTER TABLE users DROP COLUMN IF EXISTS has_metered_billing_enabled; - ALTER TABLE users DROP COLUMN IF EXISTS api_key_first_used_at; -END -$$; diff --git a/app/saas/src/test/java/stirling/software/saas/config/SaasJpaConfigScanTest.java b/app/saas/src/test/java/stirling/software/saas/config/SaasJpaConfigScanTest.java index faf7f561ec..1ec5d64214 100644 --- a/app/saas/src/test/java/stirling/software/saas/config/SaasJpaConfigScanTest.java +++ b/app/saas/src/test/java/stirling/software/saas/config/SaasJpaConfigScanTest.java @@ -25,7 +25,10 @@ class SaasJpaConfigScanTest { "stirling.software.saas.repository", "stirling.software.saas.billing.repository", "stirling.software.saas.ai.repository", - "stirling.software.saas.payg.repository"); + "stirling.software.saas.payg.repository", + // PrepaidBundleRepository lives in payg.bundle, not payg.repository — the repo + // scan is leaf-level, not recursive. + "stirling.software.saas.payg.bundle"); private static final List EXPECTED_ENTITY_PACKAGES = List.of( diff --git a/app/saas/src/test/java/stirling/software/saas/payg/api/PaygWalletControllerTest.java b/app/saas/src/test/java/stirling/software/saas/payg/api/PaygWalletControllerTest.java index 1625116af0..01e112a1c8 100644 --- a/app/saas/src/test/java/stirling/software/saas/payg/api/PaygWalletControllerTest.java +++ b/app/saas/src/test/java/stirling/software/saas/payg/api/PaygWalletControllerTest.java @@ -39,6 +39,7 @@ import stirling.software.saas.payg.api.PaygWalletController.UpdateCapRequest; import stirling.software.saas.payg.api.WalletSnapshotResponse.MemberRow; import stirling.software.saas.payg.billing.TeamBillingContext; import stirling.software.saas.payg.billing.TeamBillingService; +import stirling.software.saas.payg.bundle.PrepaidBundleService; import stirling.software.saas.payg.entitlement.EntitlementService; import stirling.software.saas.payg.entitlement.EntitlementSnapshot; import stirling.software.saas.payg.model.BillingCategory; @@ -69,6 +70,7 @@ class PaygWalletControllerTest { @Mock private WalletLedgerRepository ledgerRepo; @Mock private PaygShadowChargeRepository shadowRepo; @Mock private UserRepository userRepository; + @Mock private PrepaidBundleService prepaidBundleService; private PaygWalletController controller; @@ -83,7 +85,8 @@ class PaygWalletControllerTest { policyRepo, ledgerRepo, shadowRepo, - userRepository); + userRepository, + prepaidBundleService); } /** diff --git a/app/saas/src/test/java/stirling/software/saas/payg/api/WalletSnapshotResponseTest.java b/app/saas/src/test/java/stirling/software/saas/payg/api/WalletSnapshotResponseTest.java index e8547de268..7960def2a9 100644 --- a/app/saas/src/test/java/stirling/software/saas/payg/api/WalletSnapshotResponseTest.java +++ b/app/saas/src/test/java/stirling/software/saas/payg/api/WalletSnapshotResponseTest.java @@ -39,7 +39,12 @@ class WalletSnapshotResponseTest { /* categoryDocs= */ new CategoryBreakdown(3, 2, 1), /* docsProcessedThisPeriod= */ 6, /* uniquePdfsThisPeriod= */ 5, - /* sizeMultiplierPdfsThisPeriod= */ 2); + /* sizeMultiplierPdfsThisPeriod= */ 2, + /* prepaidUnitsRemaining= */ 40_000L, + /* prepaidUnitsTotal= */ 120_000L, + /* prepaidExpiresAt= */ "2027-06-01", + /* billingMode= */ "prepaid", + /* bundleRatePerCreditMinor= */ new BigDecimal("1")); } @Test @@ -115,10 +120,16 @@ class WalletSnapshotResponseTest { new CategoryBreakdown(0, 0, 0), 0, 0, - 0); + 0, + 0L, + 0L, + null, + "payg", + null); assertThat(free.billableLimit()).isNull(); assertThat(free.pricePerDocMinor()).isNull(); + assertThat(free.bundleRatePerCreditMinor()).isNull(); assertThat(free.currency()).isNull(); assertThat(free.estimatedBillMinor()).isNull(); assertThat(free.capUsd()).isNull(); diff --git a/app/saas/src/test/java/stirling/software/saas/payg/bundle/PrepaidBundleServiceTest.java b/app/saas/src/test/java/stirling/software/saas/payg/bundle/PrepaidBundleServiceTest.java new file mode 100644 index 0000000000..2c700fcd1b --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/payg/bundle/PrepaidBundleServiceTest.java @@ -0,0 +1,172 @@ +package stirling.software.saas.payg.bundle; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import stirling.software.saas.payg.bundle.PrepaidBundleService.PrepaidSummary; + +/** + * Unit tests for the prepaid-bundle draw/restore/summarize money logic. The repository is mocked, + * so these pin the in-Java arithmetic (FIFO depletion, per-pool caps, best-effort restore, + * aggregation) — the FIFO-by-expiry ordering and the expiry/lock filters live in the repository + * JPQL and are covered separately by the query definitions, not here. + */ +class PrepaidBundleServiceTest { + + private static final Long TEAM = 42L; + private static final LocalDateTime SOON = LocalDateTime.of(2026, 8, 1, 0, 0); + private static final LocalDateTime LATER = LocalDateTime.of(2026, 12, 1, 0, 0); + + private PrepaidBundleRepository repo; + private PrepaidBundleService service; + + @BeforeEach + void setUp() { + repo = Mockito.mock(PrepaidBundleRepository.class); + service = new PrepaidBundleService(repo); + } + + private static PrepaidBundle pool(long total, long remaining, LocalDateTime expiresAt) { + PrepaidBundle b = new PrepaidBundle(); + b.setTeamId(TEAM); + b.setUnitsTotal(total); + b.setUnitsRemaining(remaining); + b.setExpiresAt(expiresAt); + return b; + } + + // ── draw ──────────────────────────────────────────────────────────────── + + @Test + void draw_nullTeamOrNonPositiveUnits_returnsZeroWithoutTouchingRepo() { + assertThat(service.draw(null, 100)).isZero(); + assertThat(service.draw(TEAM, 0)).isZero(); + assertThat(service.draw(TEAM, -5)).isZero(); + verifyNoInteractions(repo); + } + + @Test + void draw_partialFromSinglePool_leavesRemainder() { + PrepaidBundle p = pool(1000, 1000, SOON); + when(repo.findDrawableForUpdate(eq(TEAM), any())).thenReturn(List.of(p)); + + int drawn = service.draw(TEAM, 300); + + assertThat(drawn).isEqualTo(300); + assertThat(p.getUnitsRemaining()).isEqualTo(700); + verify(repo).saveAll(List.of(p)); + } + + @Test + void draw_spansPoolsFifo_cappedAtEachPoolBalance() { + // Repo returns soonest-expiring first; the earlier pool is depleted before the later one. + PrepaidBundle first = pool(1000, 100, SOON); + PrepaidBundle second = pool(1000, 1000, LATER); + when(repo.findDrawableForUpdate(eq(TEAM), any())).thenReturn(List.of(first, second)); + + int drawn = service.draw(TEAM, 250); + + assertThat(drawn).isEqualTo(250); + assertThat(first.getUnitsRemaining()).isZero(); // fully depleted first + assertThat(second.getUnitsRemaining()).isEqualTo(850); // 150 taken from the later pool + } + + @Test + void draw_moreThanAvailable_drawsOnlyWhatExists() { + PrepaidBundle p = pool(1000, 120, SOON); + when(repo.findDrawableForUpdate(eq(TEAM), any())).thenReturn(List.of(p)); + + int drawn = service.draw(TEAM, 500); + + assertThat(drawn).isEqualTo(120); // partial draw; the remainder meters + assertThat(p.getUnitsRemaining()).isZero(); + } + + @Test + void draw_noDrawablePools_returnsZeroAndDoesNotSave() { + when(repo.findDrawableForUpdate(eq(TEAM), any())).thenReturn(List.of()); + + assertThat(service.draw(TEAM, 100)).isZero(); + verify(repo, never()).saveAll(any()); + } + + // ── restore ───────────────────────────────────────────────────────────── + + @Test + void restore_capsEachPoolAtItsOriginalTotal() { + // Two in-term pools with headroom 60 then 200; restoring 100 fills the first, spills 40 to + // the + // second, and never exceeds units_total on either. + PrepaidBundle first = pool(1000, 940, SOON); // headroom 60 + PrepaidBundle second = pool(1000, 800, LATER); // headroom 200 + when(repo.findInTermForUpdate(eq(TEAM), any())).thenReturn(List.of(first, second)); + + int restored = service.restore(TEAM, 100); + + assertThat(restored).isEqualTo(100); + assertThat(first.getUnitsRemaining()).isEqualTo(1000); // capped at total + assertThat(second.getUnitsRemaining()).isEqualTo(840); + verify(repo).saveAll(List.of(first, second)); + } + + @Test + void restore_dropsUnitsThatCannotBePlaced() { + // Only 60 headroom for a 100 restore → 60 restored, 40 dropped (best-effort). + PrepaidBundle p = pool(1000, 940, SOON); + when(repo.findInTermForUpdate(eq(TEAM), any())).thenReturn(List.of(p)); + + int restored = service.restore(TEAM, 100); + + assertThat(restored).isEqualTo(60); + assertThat(p.getUnitsRemaining()).isEqualTo(1000); + } + + @Test + void restore_nullTeamOrNonPositive_returnsZeroWithoutRepo() { + assertThat(service.restore(null, 10)).isZero(); + assertThat(service.restore(TEAM, 0)).isZero(); + verifyNoInteractions(repo); + } + + // ── summarize / prepaidRemainingUnits ───────────────────────────────────── + + @Test + void summarize_nullWhenNoInTermPools() { + when(repo.findInTerm(eq(TEAM), any())).thenReturn(List.of()); + assertThat(service.summarize(TEAM)).isNull(); + } + + @Test + void summarize_sumsBalancesAndPicksSoonestExpiry() { + when(repo.findInTerm(eq(TEAM), any())) + .thenReturn(List.of(pool(1000, 250, LATER), pool(500, 500, SOON))); + + PrepaidSummary summary = service.summarize(TEAM); + + assertThat(summary.unitsRemaining()).isEqualTo(750); + assertThat(summary.unitsTotal()).isEqualTo(1500); + assertThat(summary.expiresAt()).isEqualTo(SOON); // earliest across pools + } + + @Test + void prepaidRemainingUnits_delegatesToSummary_zeroWhenNone() { + when(repo.findInTerm(eq(TEAM), any())).thenReturn(List.of()); + assertThat(service.prepaidRemainingUnits(TEAM)).isZero(); + + when(repo.findInTerm(eq(TEAM), any())) + .thenReturn(List.of(pool(1000, 400, SOON), pool(1000, 350, LATER))); + assertThat(service.prepaidRemainingUnits(TEAM)).isEqualTo(750); + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/payg/charge/JobChargeServiceTest.java b/app/saas/src/test/java/stirling/software/saas/payg/charge/JobChargeServiceTest.java index e669e1b5bd..43abfe24e1 100644 --- a/app/saas/src/test/java/stirling/software/saas/payg/charge/JobChargeServiceTest.java +++ b/app/saas/src/test/java/stirling/software/saas/payg/charge/JobChargeServiceTest.java @@ -31,6 +31,7 @@ import org.springframework.transaction.support.TransactionSynchronization; import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.web.multipart.MultipartFile; +import stirling.software.saas.payg.bundle.PrepaidBundleService; import stirling.software.saas.payg.docs.DocumentClassifier; import stirling.software.saas.payg.docs.DocumentMetrics; import stirling.software.saas.payg.job.JobContext; @@ -70,6 +71,7 @@ class JobChargeServiceTest { private PaygTeamExtensionsRepository teamExtRepo; private PaygMeterReportingService meterReporter; private WalletLedgerRepository ledgerRepo; + private PrepaidBundleService prepaidBundleService; private JobChargeService service; @BeforeEach @@ -82,6 +84,9 @@ class JobChargeServiceTest { teamExtRepo = Mockito.mock(PaygTeamExtensionsRepository.class); meterReporter = Mockito.mock(PaygMeterReportingService.class); ledgerRepo = Mockito.mock(WalletLedgerRepository.class); + // Defaults to draw()==0 (Mockito int default) → no prepaid consumed unless a test stubs it, + // so existing free/metered assertions are unaffected. + prepaidBundleService = Mockito.mock(PrepaidBundleService.class); // findByIdForUpdate defaults to Optional.empty() (Mockito) → no free grant consumed unless // a test stubs the sidecar row. The free split is decided at openProcess time now, not at // close, so the meter tests just set free_units_consumed on the shadow row directly. @@ -94,7 +99,8 @@ class JobChargeServiceTest { jobRepo, teamExtRepo, meterReporter, - ledgerRepo); + ledgerRepo, + prepaidBundleService); } @AfterEach @@ -202,6 +208,42 @@ class JobChargeServiceTest { assertThat(debit.getBillingCategory()).isEqualTo(BillingCategory.API); } + @Test + void openProcess_prepaidBundleDraw_netsBundleUnitsOutOfLedgerAndRecordsSplit(@TempDir Path tmp) + throws IOException { + // Bundle covers 3 of the 4 charged units. Only the metered remainder (1) lands in the + // ledger's CYCLE spend (so prepaid stays outside the cap + Stripe meter), while the shadow + // row freezes the split and doc_count is untouched (the PDF is still counted once). + PricingPolicy policy = stubPolicy(/*minCharge*/ 1, Map.of(JobSource.WEB, 10)); + when(policyService.getEffectivePolicy(100L)).thenReturn(policy); + ProcessingJob newJob = openJob(UUID.randomUUID()); + when(jobService.joinOrOpen(any(JobContext.class), anyList())) + .thenReturn(new JoinOrOpenResult(newJob, JoinOrOpenResult.Disposition.OPENED)); + when(classifier.classify(any(MultipartFile.class), any(Path.class), eq(policy))) + .thenReturn(new DocumentMetrics(50, 1024L, "application/pdf", 4)); + // No free grant (findByIdForUpdate defaults empty); prepaid pools cover 3 of the 4 units. + when(prepaidBundleService.draw(eq(100L), eq(4))).thenReturn(3); + + JobInput in = jobInput(tmp, "in.pdf", "application/pdf"); + service.openProcess( + new ChargeContext( + 42L, 100L, JobSource.WEB, ProcessType.SINGLE_TOOL, BillingCategory.API), + List.of(in)); + + ArgumentCaptor shadowCaptor = + ArgumentCaptor.forClass(PaygShadowCharge.class); + verify(shadowRepo).save(shadowCaptor.capture()); + assertThat(shadowCaptor.getValue().getPaygUnits()).isEqualTo(4); + assertThat(shadowCaptor.getValue().getBundleUnitsConsumed()).isEqualTo(3); + + ArgumentCaptor ledgerCaptor = + ArgumentCaptor.forClass(WalletLedgerEntry.class); + verify(ledgerRepo).save(ledgerCaptor.capture()); + // 4 charged − 3 prepaid = 1 metered unit, stored negative. + assertThat(ledgerCaptor.getValue().getAmountUnits()).isEqualTo(-1); + assertThat(ledgerCaptor.getValue().getBucket()).isEqualTo(LedgerBucket.CYCLE); + } + @Test void openProcess_bypassedCategory_writesShadowRowButNoLedgerDebit(@TempDir Path tmp) throws IOException { diff --git a/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementGuardTest.java b/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementGuardTest.java index d66e4b17fe..0a77de3e7e 100644 --- a/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementGuardTest.java +++ b/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementGuardTest.java @@ -198,6 +198,97 @@ class EntitlementGuardTest { assertThat(body.get("category").asText()).isEqualTo("AI"); } + // --------------------------------------------------------------------------------------- + // Policy execute routes (proprietary; recognised by path, gated on AUTOMATION) + // --------------------------------------------------------------------------------------- + + @Test + void policyRunRoute_noAnnotation_isInScopeAndGatedOnAutomation() throws Exception { + UUID supabaseId = UUID.randomUUID(); + SecurityContextHolder.getContext().setAuthentication(jwtAuth(supabaseId)); + when(userRepository.findBySupabaseId(supabaseId)) + .thenReturn(Optional.of(userWithTeam(7L, 42L))); + when(entitlementService.getSnapshot(42L)).thenReturn(degradedSnapshot()); + + HandlerMethod hm = handlerFor("plainEndpoint"); // no annotations + MockHttpServletRequest req = new MockHttpServletRequest(); + req.setRequestURI("/api/v1/policies/pol-123/run"); + MockHttpServletResponse res = new MockHttpServletResponse(); + + boolean proceed = guard.preHandle(req, res, hm); + + assertThat(proceed).isFalse(); + assertThat(res.getStatus()).isEqualTo(402); + JsonNode body = json.readTree(res.getContentAsByteArray()); + assertThat(body.get("error").asText()).isEqualTo("FEATURE_DEGRADED"); + assertThat(body.get("missingGates").get(0).asText()).isEqualTo("AUTOMATION"); + verify(entitlementService).getSnapshot(42L); + } + + @Test + void policyRunRoute_anonymous_returns401WithAutomationCategory() throws Exception { + SecurityContextHolder.getContext() + .setAuthentication( + new AnonymousAuthenticationToken( + "key", + "anonymousUser", + List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS")))); + + HandlerMethod hm = handlerFor("plainEndpoint"); + MockHttpServletRequest req = new MockHttpServletRequest(); + req.setRequestURI("/api/v1/policies/pol-123/trigger"); + MockHttpServletResponse res = new MockHttpServletResponse(); + + boolean proceed = guard.preHandle(req, res, hm); + + assertThat(proceed).isFalse(); + assertThat(res.getStatus()).isEqualTo(401); + JsonNode body = json.readTree(res.getContentAsByteArray()); + assertThat(body.get("error").asText()).isEqualTo("SIGNUP_REQUIRED"); + assertThat(body.get("category").asText()).isEqualTo("AUTOMATION"); + } + + @Test + void policyRunRoute_authenticatedFull_passesThrough() throws Exception { + UUID supabaseId = UUID.randomUUID(); + SecurityContextHolder.getContext().setAuthentication(jwtAuth(supabaseId)); + when(userRepository.findBySupabaseId(supabaseId)) + .thenReturn(Optional.of(userWithTeam(7L, 42L))); + when(entitlementService.getSnapshot(42L)).thenReturn(fullSnapshot()); + + HandlerMethod hm = handlerFor("plainEndpoint"); + MockHttpServletRequest req = new MockHttpServletRequest(); + req.setRequestURI("/api/v1/policies/run"); + MockHttpServletResponse res = new MockHttpServletResponse(); + + boolean proceed = guard.preHandle(req, res, hm); + + assertThat(proceed).isTrue(); + assertThat(res.getStatus()).isEqualTo(200); + } + + @Test + void policyReadRoute_notGated_passesThroughEvenDegraded() throws Exception { + // Listing policies must stay ungated so the UI can show them and prompt on use. + SecurityContextHolder.getContext() + .setAuthentication( + new AnonymousAuthenticationToken( + "key", + "anonymousUser", + List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS")))); + + HandlerMethod hm = handlerFor("plainEndpoint"); + MockHttpServletRequest req = new MockHttpServletRequest(); + req.setRequestURI("/api/v1/policies"); + MockHttpServletResponse res = new MockHttpServletResponse(); + + boolean proceed = guard.preHandle(req, res, hm); + + assertThat(proceed).isTrue(); + assertThat(res.getStatus()).isEqualTo(200); + Mockito.verifyNoInteractions(entitlementService); + } + // --------------------------------------------------------------------------------------- // Anonymous user // --------------------------------------------------------------------------------------- diff --git a/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementServiceTest.java b/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementServiceTest.java index 430e1b7e0e..3079115c6e 100644 --- a/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementServiceTest.java +++ b/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementServiceTest.java @@ -17,6 +17,7 @@ import org.mockito.Mockito; import stirling.software.saas.payg.billing.TeamBillingContext; import stirling.software.saas.payg.billing.TeamBillingService; +import stirling.software.saas.payg.bundle.PrepaidBundleService; import stirling.software.saas.payg.model.EntitlementState; import stirling.software.saas.payg.model.FeatureGate; import stirling.software.saas.payg.model.FeatureSet; @@ -47,6 +48,7 @@ class EntitlementServiceTest { private TeamBillingService billingService; private WalletPolicyRepository walletPolicyRepo; private WalletLedgerRepository ledgerRepo; + private PrepaidBundleService prepaidBundleService; private EntitlementService service; @BeforeEach @@ -54,7 +56,10 @@ class EntitlementServiceTest { billingService = Mockito.mock(TeamBillingService.class); walletPolicyRepo = Mockito.mock(WalletPolicyRepository.class); ledgerRepo = Mockito.mock(WalletLedgerRepository.class); - service = new EntitlementService(billingService, walletPolicyRepo, ledgerRepo); + prepaidBundleService = Mockito.mock(PrepaidBundleService.class); + service = + new EntitlementService( + billingService, walletPolicyRepo, ledgerRepo, prepaidBundleService); } @Test @@ -122,10 +127,13 @@ class EntitlementServiceTest { } @Test - void exhaustedGrant_returnsDegradedWithMinimalGates() { - // Grant fully consumed (remaining 0) → billable categories hard-stop for an unsubscribed - // team. The displayed cap stays the grant size; spend reads as the full grant. + void exhaustedGrantAndNoPrepaid_returnsDegradedWithMinimalGates() { + // Grant fully consumed (remaining 0) AND no prepaid pool → billable categories hard-stop + // for + // an unsubscribed team. The displayed cap stays the grant size; spend reads as the full + // grant. stubBilling(42L, freeContext(100L, 0L)); + when(prepaidBundleService.prepaidRemainingUnits(42L)).thenReturn(0L); when(walletPolicyRepo.findByTeamId(42L)) .thenReturn(Optional.of(walletPolicyThresholds(FeatureSet.MINIMAL))); @@ -142,6 +150,110 @@ class EntitlementServiceTest { .doesNotContain(FeatureGate.AUTOMATION, FeatureGate.AI_SUPPORT); } + @Test + void exhaustedGrantButLivePrepaidPool_staysFullyEntitled() { + // Free grant spent (remaining 0) but the team holds a paid prepaid pool → fully entitled, + // NOT degraded, even with no metered subscription. All gates (incl. AUTOMATION + AI) are + // on; + // the pool is drawn in the charge pipeline. This is the Phase-1 fix: paid-for usage is + // usable + // on its own merit rather than gated behind a subscription that may not have provisioned. + stubBilling(42L, freeContext(100L, 0L)); + when(prepaidBundleService.prepaidRemainingUnits(42L)).thenReturn(480_000L); + when(walletPolicyRepo.findByTeamId(42L)) + .thenReturn(Optional.of(walletPolicyThresholds(FeatureSet.MINIMAL))); + + EntitlementSnapshot snap = service.getSnapshot(42L); + + assertThat(snap.state()).isEqualTo(EntitlementState.FULL); + assertThat(snap.featureSet()).isEqualTo(FeatureSet.FULL); + assertThat(snap.enabledGates()) + .containsExactlyInAnyOrder( + FeatureGate.OFFSITE_PROCESSING, + FeatureGate.AUTOMATION, + FeatureGate.AI_SUPPORT, + FeatureGate.CLIENT_SIDE); + assertThat(snap.subscribed()).isFalse(); + } + + @Test + void grantStillHasBalance_prepaidNotConsulted() { + // While the free grant has balance, the gate never queries prepaid (lazy — only checked + // when + // the grant is exhausted). Keeps the common free-tier path off the prepaid table. + stubBilling(42L, freeContext(500L, 400L)); + when(walletPolicyRepo.findByTeamId(42L)).thenReturn(Optional.empty()); + + service.getSnapshot(42L); + + Mockito.verifyNoInteractions(prepaidBundleService); + } + + @Test + void subscribedOverCapButLivePrepaidPool_staysFullyEntitled() { + // Subscribed team at/over its metered cap but holding a live prepaid pool → fully entitled, + // NOT degraded. Prepaid sits outside the cap (its draws are netted out of metered spend in + // JobChargeService), so the job draws from the pool and the cap is irrelevant while it + // lasts. + stubBilling(42L, subscribedContext(1000L)); + when(walletPolicyRepo.findByTeamId(42L)) + .thenReturn(Optional.of(walletPolicyThresholds(FeatureSet.MINIMAL))); + // 1000 spent of a 1000 cap → 100% → cap gate degrades, but the pool overrides it. + when(ledgerRepo.sumPeriodNetBillable(eq(42L), any(), any())).thenReturn(-1000L); + when(prepaidBundleService.prepaidRemainingUnits(42L)).thenReturn(5000L); + + EntitlementSnapshot snap = service.getSnapshot(42L); + + assertThat(snap.state()).isEqualTo(EntitlementState.FULL); + assertThat(snap.featureSet()).isEqualTo(FeatureSet.FULL); + assertThat(snap.enabledGates()) + .containsExactlyInAnyOrder( + FeatureGate.OFFSITE_PROCESSING, + FeatureGate.AUTOMATION, + FeatureGate.AI_SUPPORT, + FeatureGate.CLIENT_SIDE); + // Still a subscribed team; the cap figures are unchanged, only the entitlement is + // overridden. + assertThat(snap.subscribed()).isTrue(); + assertThat(snap.periodCapUnits()).isEqualTo(1000L); + assertThat(snap.periodSpendUnits()).isEqualTo(1000L); + } + + @Test + void subscribedOverCapNoPrepaid_returnsDegraded() { + // Subscribed, over cap, no pool → degraded as before (regression guard: the pool override + // must + // not open the gate for a team that has none). + stubBilling(42L, subscribedContext(1000L)); + when(walletPolicyRepo.findByTeamId(42L)) + .thenReturn(Optional.of(walletPolicyThresholds(FeatureSet.MINIMAL))); + when(ledgerRepo.sumPeriodNetBillable(eq(42L), any(), any())).thenReturn(-1000L); + when(prepaidBundleService.prepaidRemainingUnits(42L)).thenReturn(0L); + + EntitlementSnapshot snap = service.getSnapshot(42L); + + assertThat(snap.state()).isEqualTo(EntitlementState.DEGRADED); + assertThat(snap.featureSet()).isEqualTo(FeatureSet.MINIMAL); + assertThat(snap.enabledGates()) + .containsExactlyInAnyOrder(FeatureGate.OFFSITE_PROCESSING, FeatureGate.CLIENT_SIDE); + } + + @Test + void subscribedUnderCap_prepaidNotConsulted() { + // Under cap → FULL, so the prepaid pool is never queried (lazy — only when the cap gate + // would + // otherwise degrade). Keeps the common subscribed path off the prepaid table. + stubBilling(42L, subscribedContext(1000L)); + when(walletPolicyRepo.findByTeamId(42L)) + .thenReturn(Optional.of(walletPolicyThresholds(FeatureSet.MINIMAL))); + when(ledgerRepo.sumPeriodNetBillable(eq(42L), any(), any())).thenReturn(-250L); + + EntitlementSnapshot snap = service.getSnapshot(42L); + + assertThat(snap.state()).isEqualTo(EntitlementState.FULL); + Mockito.verifyNoInteractions(prepaidBundleService); + } + @Test void grantInWarnBand_returnsWarnedButFullFeatureSet() { // grant 100, remaining 15 → used 85 = 85% (between warn 80 and degrade 100). diff --git a/app/saas/src/test/java/stirling/software/saas/security/SupabaseAuthenticationFilterMoreTest.java b/app/saas/src/test/java/stirling/software/saas/security/SupabaseAuthenticationFilterMoreTest.java index 448bafae6b..f6181dcebe 100644 --- a/app/saas/src/test/java/stirling/software/saas/security/SupabaseAuthenticationFilterMoreTest.java +++ b/app/saas/src/test/java/stirling/software/saas/security/SupabaseAuthenticationFilterMoreTest.java @@ -58,6 +58,10 @@ class SupabaseAuthenticationFilterMoreTest { @Mock private SaasTeamService saasTeamService; @Mock private JwtDecoder jwtDecoder; + @Mock + private stirling.software.proprietary.security.service.ApiKeyAuthenticationService + apiKeyAuthenticationService; + private SupabaseAuthenticationFilter filter; private MockHttpServletRequest request; private MockHttpServletResponse response; @@ -68,7 +72,12 @@ class SupabaseAuthenticationFilterMoreTest { SecurityContextHolder.clearContext(); filter = new SupabaseAuthenticationFilter( - teamService, userService, supabaseUserService, saasTeamService, jwtDecoder); + teamService, + userService, + supabaseUserService, + saasTeamService, + jwtDecoder, + apiKeyAuthenticationService); request = new MockHttpServletRequest(); response = new MockHttpServletResponse(); chain = new MockFilterChain(); @@ -234,7 +243,12 @@ class SupabaseAuthenticationFilterMoreTest { @DisplayName("returns true and skips lookup when an api key sets an authenticated context") void apiKeyValidStillAuthenticates() throws Exception { User user = newUser("alice"); - when(userService.getUserByApiKey("k1")).thenReturn(Optional.of(user)); + when(apiKeyAuthenticationService.authenticate("k1")) + .thenReturn( + Optional.of( + new stirling.software.proprietary.security.service + .ApiKeyAuthenticationService.ApiKeyAuthentication( + user, null, user.getAuthorities()))); request.setRequestURI("/api/v1/something"); request.setMethod("POST"); @@ -276,6 +290,8 @@ class SupabaseAuthenticationFilterMoreTest { filter.doFilter(request, response, chain); verify(userService, times(1)).saveUser(any(User.class)); + // Guests get NO team; a home team + grant is provisioned only on signup/upgrade. + verify(saasTeamService, never()).ensurePersonalTeam(any()); // Anonymous mirror row created with null email and anon flag true. verify(supabaseUserService).createSupabaseUser(supabaseId, null, true); assertThat(SecurityContextHolder.getContext().getAuthentication()) @@ -299,14 +315,13 @@ class SupabaseAuthenticationFilterMoreTest { local.setSupabaseId(supabaseId); local.setAuthenticationType(AuthenticationType.ANONYMOUS); when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.of(local)); - when(userService.saveUser(any(User.class))).thenAnswer(inv -> inv.getArgument(0)); - when(saasTeamService.ensurePersonalTeam(any(User.class))).thenReturn(new Team()); + when(saasTeamService.saveUserWithPersonalTeam(any(User.class))) + .thenAnswer(inv -> inv.getArgument(0)); bearer("tok"); filter.doFilter(request, response, chain); - verify(userService).saveUser(any(User.class)); - verify(saasTeamService).ensurePersonalTeam(any(User.class)); + verify(saasTeamService).saveUserWithPersonalTeam(any(User.class)); assertThat(local.getEmail()).isEqualTo("real@example.com"); assertThat(local.getUsername()).isEqualTo("real@example.com"); assertThat(local.getAuthenticationType()) @@ -326,8 +341,8 @@ class SupabaseAuthenticationFilterMoreTest { local.setSupabaseId(supabaseId); local.setAuthenticationType(AuthenticationType.ANONYMOUS); when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.of(local)); - when(userService.saveUser(any(User.class))).thenAnswer(inv -> inv.getArgument(0)); - when(saasTeamService.ensurePersonalTeam(any(User.class))).thenReturn(new Team()); + when(saasTeamService.saveUserWithPersonalTeam(any(User.class))) + .thenAnswer(inv -> inv.getArgument(0)); bearer("tok"); filter.doFilter(request, response, chain); @@ -349,7 +364,7 @@ class SupabaseAuthenticationFilterMoreTest { local.setSupabaseId(supabaseId); local.setAuthenticationType(AuthenticationType.ANONYMOUS); when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.of(local)); - when(userService.saveUser(any(User.class))) + when(saasTeamService.saveUserWithPersonalTeam(any(User.class))) .thenThrow(new DataIntegrityViolationException("email exists")); bearer("tok"); @@ -473,12 +488,13 @@ class SupabaseAuthenticationFilterMoreTest { org.mockito.Mockito.doThrow(new DataIntegrityViolationException("dup")) .when(supabaseUserService) .createSupabaseUser(eq(supabaseId), any(), eq(false)); - when(userService.saveUser(any(User.class))).thenAnswer(inv -> inv.getArgument(0)); + when(saasTeamService.saveUserWithPersonalTeam(any(User.class))) + .thenAnswer(inv -> inv.getArgument(0)); bearer("tok"); filter.doFilter(request, response, chain); - verify(userService, times(1)).saveUser(any(User.class)); + verify(saasTeamService, times(1)).saveUserWithPersonalTeam(any(User.class)); assertThat(SecurityContextHolder.getContext().getAuthentication()) .isInstanceOf(EnhancedJwtAuthenticationToken.class); } @@ -500,7 +516,7 @@ class SupabaseAuthenticationFilterMoreTest { filter.doFilter(request, response, chain); assertThat(response.getStatus()).isEqualTo(401); - verify(userService, never()).saveUser(any()); + verify(saasTeamService, never()).saveUserWithPersonalTeam(any()); } @Test @@ -517,13 +533,13 @@ class SupabaseAuthenticationFilterMoreTest { when(userService.findBySupabaseId(supabaseId)) .thenReturn(Optional.empty()) .thenReturn(Optional.of(winner)); - when(userService.saveUser(any(User.class))) + when(saasTeamService.saveUserWithPersonalTeam(any(User.class))) .thenThrow(new DataIntegrityViolationException("dup user")); bearer("tok"); filter.doFilter(request, response, chain); - // Race loser does not run first-time init (ensurePersonalTeam). + // The winner committed user and team together, so the loser just adopts its row. verify(saasTeamService, never()).ensurePersonalTeam(any()); assertThat(SecurityContextHolder.getContext().getAuthentication()) .isInstanceOf(EnhancedJwtAuthenticationToken.class); @@ -538,7 +554,7 @@ class SupabaseAuthenticationFilterMoreTest { when(supabaseUserService.getUser(supabaseId)) .thenReturn(supabaseUser(supabaseId, "lost@example.com", false)); when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.empty()); - when(userService.saveUser(any(User.class))) + when(saasTeamService.saveUserWithPersonalTeam(any(User.class))) .thenThrow(new DataIntegrityViolationException("dup user")); bearer("tok"); @@ -548,25 +564,99 @@ class SupabaseAuthenticationFilterMoreTest { } @Test - @DisplayName("personal team creation failure for a new user is swallowed") - void personalTeamFailureSwallowed() throws Exception { + @DisplayName("personal team creation failure fails the request, it is not swallowed") + void personalTeamFailureFailsAuth() throws Exception { UUID supabaseId = UUID.randomUUID(); Jwt jwt = fullJwt(supabaseId, "team@example.com", false, "email"); when(jwtDecoder.decode("tok")).thenReturn(jwt); when(supabaseUserService.getUser(supabaseId)) .thenReturn(supabaseUser(supabaseId, "team@example.com", false)); when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.empty()); - when(userService.saveUser(any(User.class))).thenAnswer(inv -> inv.getArgument(0)); - when(saasTeamService.ensurePersonalTeam(any(User.class))) + when(saasTeamService.saveUserWithPersonalTeam(any(User.class))) .thenThrow(new IllegalStateException("team boom")); bearer("tok"); filter.doFilter(request, response, chain); - // Auth still succeeds even though team creation failed. - assertThat(SecurityContextHolder.getContext().getAuthentication()) - .isInstanceOf(EnhancedJwtAuthenticationToken.class); - verify(userService, times(1)).saveUser(any(User.class)); + // A teamless account has no portal access, so a failed provision must surface + // rather than admit a half-built user. + assertThat(response.getStatus()).isEqualTo(401); + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + } + } + + @Nested + @DisplayName("Existing accounts are never re-provisioned on the request path") + class ExistingAccountProvisioning { + + private User existingWebUser(UUID supabaseId) { + User local = newUser("real@example.com"); + local.setSupabaseId(supabaseId); + local.setAuthenticationType(AuthenticationType.WEB); + return local; + } + + @Test + @DisplayName("a teamless account is left alone, not healed on every request") + void teamlessAccountIsNotHealed() throws Exception { + UUID supabaseId = UUID.randomUUID(); + when(jwtDecoder.decode("tok")) + .thenReturn(fullJwt(supabaseId, "real@example.com", false, "email")); + when(supabaseUserService.getUser(supabaseId)) + .thenReturn(supabaseUser(supabaseId, "real@example.com", false)); + + User local = existingWebUser(supabaseId); + when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.of(local)); + + bearer("tok"); + filter.doFilter(request, response, chain); + + // Healing here would run per request with no mutual exclusion, so parallel + // requests would mint duplicate teams. Provisioning belongs to signup alone. + verify(saasTeamService, never()).ensurePersonalTeam(any(User.class)); + verify(saasTeamService, never()).saveUserWithPersonalTeam(any(User.class)); + assertThat(local.getTeam()).isNull(); + } + + @Test + @DisplayName("an account that already has a team is left alone") + void noOpWhenTeamPresent() throws Exception { + UUID supabaseId = UUID.randomUUID(); + when(jwtDecoder.decode("tok")) + .thenReturn(fullJwt(supabaseId, "real@example.com", false, "email")); + when(supabaseUserService.getUser(supabaseId)) + .thenReturn(supabaseUser(supabaseId, "real@example.com", false)); + + User local = existingWebUser(supabaseId); + Team existing = new Team(); + local.setTeam(existing); + when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.of(local)); + + bearer("tok"); + filter.doFilter(request, response, chain); + + verify(saasTeamService, never()).ensurePersonalTeam(any(User.class)); + assertThat(local.getTeam()).isSameAs(existing); + } + + @Test + @DisplayName("a guest session is never given a team") + void guestStaysTeamless() throws Exception { + UUID supabaseId = UUID.randomUUID(); + when(jwtDecoder.decode("tok")).thenReturn(fullJwt(supabaseId, null, true, "email")); + when(supabaseUserService.getUser(supabaseId)) + .thenReturn(supabaseUser(supabaseId, null, true)); + + User local = newUser("anon_guest"); + local.setSupabaseId(supabaseId); + local.setAuthenticationType(AuthenticationType.ANONYMOUS); + when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.of(local)); + + bearer("tok"); + filter.doFilter(request, response, chain); + + verify(saasTeamService, never()).ensurePersonalTeam(any(User.class)); + assertThat(local.getTeam()).isNull(); } } } diff --git a/app/saas/src/test/java/stirling/software/saas/security/SupabaseAuthenticationFilterTest.java b/app/saas/src/test/java/stirling/software/saas/security/SupabaseAuthenticationFilterTest.java index e906655edc..d78be84189 100644 --- a/app/saas/src/test/java/stirling/software/saas/security/SupabaseAuthenticationFilterTest.java +++ b/app/saas/src/test/java/stirling/software/saas/security/SupabaseAuthenticationFilterTest.java @@ -48,6 +48,10 @@ class SupabaseAuthenticationFilterTest { @Mock private stirling.software.saas.service.SaasTeamService saasTeamService; @Mock private JwtDecoder jwtDecoder; + @Mock + private stirling.software.proprietary.security.service.ApiKeyAuthenticationService + apiKeyAuthenticationService; + private SupabaseAuthenticationFilter filter; private MockHttpServletRequest request; private MockHttpServletResponse response; @@ -58,7 +62,12 @@ class SupabaseAuthenticationFilterTest { SecurityContextHolder.clearContext(); filter = new SupabaseAuthenticationFilter( - teamService, userService, supabaseUserService, saasTeamService, jwtDecoder); + teamService, + userService, + supabaseUserService, + saasTeamService, + jwtDecoder, + apiKeyAuthenticationService); request = new MockHttpServletRequest(); response = new MockHttpServletResponse(); chain = new MockFilterChain(); @@ -85,7 +94,12 @@ class SupabaseAuthenticationFilterTest { void apiKeyHeaderPopulatesSecurityContext() throws Exception { User user = newUser("alice"); user.setApiKey("api-key-123"); - when(userService.getUserByApiKey("api-key-123")).thenReturn(Optional.of(user)); + when(apiKeyAuthenticationService.authenticate("api-key-123")) + .thenReturn( + Optional.of( + new stirling.software.proprietary.security.service + .ApiKeyAuthenticationService.ApiKeyAuthentication( + user, null, user.getAuthorities()))); request.setRequestURI("/api/v1/something"); request.setMethod("POST"); @@ -103,7 +117,7 @@ class SupabaseAuthenticationFilterTest { @Test void invalidApiKeyTriggers401() throws Exception { - when(userService.getUserByApiKey("nope")).thenReturn(Optional.empty()); + when(apiKeyAuthenticationService.authenticate("nope")).thenReturn(Optional.empty()); request.setRequestURI("/api/v1/something"); request.setMethod("POST"); @@ -154,7 +168,7 @@ class SupabaseAuthenticationFilterTest { when(supabaseUserService.getUser(supabaseId)) .thenReturn(supabaseUserMatching(supabaseId, "bob@example.com", false)); when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.empty()); - when(userService.saveUser(any())).thenAnswer(inv -> inv.getArgument(0)); + when(saasTeamService.saveUserWithPersonalTeam(any())).thenAnswer(inv -> inv.getArgument(0)); request.setRequestURI("/api/v1/something"); request.setMethod("POST"); @@ -162,10 +176,9 @@ class SupabaseAuthenticationFilterTest { filter.doFilter(request, response, chain); - verify(userService, times(1)).saveUser(any(User.class)); verify(supabaseUserService).createSupabaseUser(supabaseId, "bob@example.com", false); - // New users get their own personal team, never the shared Default team. - verify(saasTeamService).ensurePersonalTeam(any(User.class)); + // Own personal team, never the shared Default team, written with the user. + verify(saasTeamService, times(1)).saveUserWithPersonalTeam(any(User.class)); verify(teamService, never()).getOrCreateDefaultTeam(); assertThat(SecurityContextHolder.getContext().getAuthentication()) .isInstanceOf(EnhancedJwtAuthenticationToken.class); @@ -180,7 +193,7 @@ class SupabaseAuthenticationFilterTest { when(supabaseUserService.getUser(supabaseId)) .thenReturn(supabaseUserMatching(supabaseId, "carol@example.com", false)); when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.empty()); - when(userService.saveUser(any(User.class))) + when(saasTeamService.saveUserWithPersonalTeam(any(User.class))) .thenAnswer( inv -> { User u = inv.getArgument(0); @@ -196,7 +209,7 @@ class SupabaseAuthenticationFilterTest { filter.doFilter(request, response, chain); - verify(userService, times(1)).saveUser(any(User.class)); + verify(saasTeamService, times(1)).saveUserWithPersonalTeam(any(User.class)); } @Test @@ -208,7 +221,7 @@ class SupabaseAuthenticationFilterTest { when(supabaseUserService.getUser(supabaseId)) .thenReturn(supabaseUserMatching(supabaseId, "dave@example.com", false)); when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.empty()); - when(userService.saveUser(any(User.class))) + when(saasTeamService.saveUserWithPersonalTeam(any(User.class))) .thenAnswer( inv -> { User u = inv.getArgument(0); @@ -224,7 +237,7 @@ class SupabaseAuthenticationFilterTest { filter.doFilter(request, response, chain); - verify(userService, times(1)).saveUser(any(User.class)); + verify(saasTeamService, times(1)).saveUserWithPersonalTeam(any(User.class)); } @Test @@ -236,7 +249,7 @@ class SupabaseAuthenticationFilterTest { when(supabaseUserService.getUser(supabaseId)) .thenReturn(supabaseUserMatching(supabaseId, "eve@example.com", false)); when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.empty()); - when(userService.saveUser(any(User.class))) + when(saasTeamService.saveUserWithPersonalTeam(any(User.class))) .thenAnswer( inv -> { User u = inv.getArgument(0); @@ -252,7 +265,7 @@ class SupabaseAuthenticationFilterTest { filter.doFilter(request, response, chain); - verify(userService, times(1)).saveUser(any(User.class)); + verify(saasTeamService, times(1)).saveUserWithPersonalTeam(any(User.class)); } @Test diff --git a/app/saas/src/test/java/stirling/software/saas/security/SupabaseSecurityConfigMoreTest.java b/app/saas/src/test/java/stirling/software/saas/security/SupabaseSecurityConfigMoreTest.java index 54b6e56391..da719e7ba5 100644 --- a/app/saas/src/test/java/stirling/software/saas/security/SupabaseSecurityConfigMoreTest.java +++ b/app/saas/src/test/java/stirling/software/saas/security/SupabaseSecurityConfigMoreTest.java @@ -43,9 +43,18 @@ class SupabaseSecurityConfigMoreTest { @Mock private SupabaseUserService supabaseUserService; @Mock private SaasTeamService saasTeamService; + @Mock + private stirling.software.proprietary.security.service.ApiKeyAuthenticationService + apiKeyAuthenticationService; + private SupabaseSecurityConfig config(ApplicationProperties props) { return new SupabaseSecurityConfig( - userService, teamService, supabaseUserService, saasTeamService, props); + userService, + teamService, + supabaseUserService, + saasTeamService, + props, + apiKeyAuthenticationService); } @Nested diff --git a/app/saas/src/test/java/stirling/software/saas/security/TeamSecurityExpressionsTest.java b/app/saas/src/test/java/stirling/software/saas/security/TeamSecurityExpressionsTest.java index c698daccca..ee74f66a29 100644 --- a/app/saas/src/test/java/stirling/software/saas/security/TeamSecurityExpressionsTest.java +++ b/app/saas/src/test/java/stirling/software/saas/security/TeamSecurityExpressionsTest.java @@ -21,6 +21,7 @@ import org.springframework.security.core.context.SecurityContextHolder; import stirling.software.common.model.enumeration.TeamRole; import stirling.software.proprietary.model.Team; import stirling.software.proprietary.model.TeamMembership; +import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken; import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.repository.TeamMembershipRepository; import stirling.software.proprietary.security.service.UserService; @@ -107,6 +108,27 @@ class TeamSecurityExpressionsTest { assertNull(expressions().currentUserTeamId()); } + private User leaderUser() { + User leader = new User(); + leader.setId(USER_ID); + Team team = new Team(); + team.setId(TEAM_ID); + leader.setTeam(team); + return leader; + } + + @Test + void apiKeyOfALeaderStillLeads() { + // A key acts as the owner; if they lead the team, the key leads. + SecurityContextHolder.getContext() + .setAuthentication( + new ApiKeyAuthenticationToken(leaderUser(), "sk_personal", List.of())); + when(membershipRepository.findByTeamIdAndUserId(TEAM_ID, USER_ID)) + .thenReturn(Optional.of(membershipWithRole(TeamRole.LEADER))); + + assertTrue(expressions().isCurrentUserTeamLeader()); + } + @Test void unauthenticatedIsNotLeader() { // No authentication set on the context. diff --git a/app/saas/src/test/java/stirling/software/saas/service/SaasTeamServiceTest.java b/app/saas/src/test/java/stirling/software/saas/service/SaasTeamServiceTest.java index 5f1083cb3f..fc1fbcb0ab 100644 --- a/app/saas/src/test/java/stirling/software/saas/service/SaasTeamServiceTest.java +++ b/app/saas/src/test/java/stirling/software/saas/service/SaasTeamServiceTest.java @@ -65,17 +65,10 @@ class SaasTeamServiceTest { @Mock private UserRoleService userRoleService; @Mock private SaasTeamExtensionService saasTeamExtensionService; @Mock private SaasTeamExtensionsRepository saasTeamExtensionsRepository; + @Mock private SaasUserExtensionService saasUserExtensionService; @Mock private LinkedInstanceRepository linkedInstanceRepository; @Mock private stirling.software.proprietary.security.service.UserService userService; - @Mock - private stirling.software.proprietary.access.repository.ResourceGrantRepository - resourceGrantRepository; - - @Mock - private stirling.software.proprietary.integration.repository.IntegrationConfigRepository - integrationConfigRepository; - @InjectMocks private SaasTeamService service; private static final UUID SUPABASE_ID = UUID.fromString("11111111-2222-3333-4444-555555555555"); @@ -694,7 +687,6 @@ class SaasTeamServiceTest { when(saasTeamExtensionService.hasAvailableSeats(newTeam)).thenReturn(true); when(membershipRepository.findByUserId(5L)) .thenReturn(List.of(membership(ownTeam, u, TeamRole.LEADER))); - when(saasTeamExtensionService.isPersonal(ownTeam)).thenReturn(false); when(membershipRepository.countByTeamIdAndRole(200L, TeamRole.LEADER)).thenReturn(1L); when(billingSubscriptionRepository.existsActiveSubscriptionForTeam(200L)) .thenReturn(true); @@ -705,9 +697,10 @@ class SaasTeamServiceTest { } @Test - @DisplayName( - "blocks accept when the user is the last leader of an unpaid non-personal team") - void lastLeaderOfUnpaidTeam_blocksAccept() { + @DisplayName("allows accept for the last leader of an unpaid non-personal team") + void lastLeaderOfUnpaidTeam_allowsAccept() { + // The old over-broad "transfer leadership" block is gone: an unpaid, unlinked team is + // never orphaned in the durable model, so the join proceeds. User u = user(5L, "b@x.com", "bob"); Team newTeam = team(100L, "Acme"); Team ownTeam = team(200L, "Bob Co"); @@ -719,44 +712,40 @@ class SaasTeamServiceTest { when(saasTeamExtensionService.hasAvailableSeats(newTeam)).thenReturn(true); when(membershipRepository.findByUserId(5L)) .thenReturn(List.of(membership(ownTeam, u, TeamRole.LEADER))); - when(saasTeamExtensionService.isPersonal(ownTeam)).thenReturn(false); when(membershipRepository.countByTeamIdAndRole(200L, TeamRole.LEADER)).thenReturn(1L); when(billingSubscriptionRepository.existsActiveSubscriptionForTeam(200L)) .thenReturn(false); + when(saasTeamExtensionsRepository.incrementSeatsUsed(100L)).thenReturn(1); - assertThatThrownBy(() -> service.acceptInvitation("tok-123", u)) - .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("Transfer leadership"); + service.acceptInvitation("tok-123", u); + + assertThat(inv.getStatus()).isEqualTo(InvitationStatus.ACCEPTED); + verify(userRepository).updateUserTeamId(5L, 100L); } @Test - @DisplayName( - "happy path: leaves personal team, deletes it, joins new team, increments seats") - void success_migratesFromPersonalTeam() { + @DisplayName("parks the home team (keeps it) and joins the new team, incrementing seats") + void success_parksHomeTeamAndJoins() { User u = user(5L, "b@x.com", "bob"); Team newTeam = team(100L, "Acme"); - Team personal = team(200L, "My Team"); + Team home = team(200L, "My Team"); User inviter = user(1L, "a@x.com", "alice"); TeamInvitation inv = pendingInvitation(newTeam, inviter, "b@x.com"); - TeamMembership personalMembership = membership(personal, u, TeamRole.LEADER); + TeamMembership homeMembership = membership(home, u, TeamRole.LEADER); when(userRepository.findById(5L)).thenReturn(Optional.of(u)); when(invitationRepository.findByInvitationToken("tok-123")) .thenReturn(Optional.of(inv)); when(saasTeamExtensionService.hasAvailableSeats(newTeam)).thenReturn(true); - // assertCanLeave... iterates memberships; personal team is skipped. - when(membershipRepository.findByUserId(5L)) - .thenReturn(List.of(personalMembership)) - .thenReturn(List.of(personalMembership)); - when(saasTeamExtensionService.isPersonal(personal)).thenReturn(true); - when(membershipRepository.countByTeamId(200L)).thenReturn(0L); + when(saasUserExtensionService.getHomeTeamId(u)).thenReturn(200L); + when(membershipRepository.findByUserId(5L)).thenReturn(List.of(homeMembership)); when(saasTeamExtensionsRepository.incrementSeatsUsed(100L)).thenReturn(1); service.acceptInvitation("tok-123", u); - verify(membershipRepository).delete(personalMembership); - verify(saasTeamExtensionsRepository).decrementSeatsUsed(200L); - verify(teamRepository).delete(personal); + // Home team + its membership are kept (parked), never deleted. + verify(membershipRepository, never()).delete(homeMembership); + verify(teamRepository, never()).delete(home); verify(userRepository).updateUserTeamId(5L, 100L); assertThat(inv.getStatus()).isEqualTo(InvitationStatus.ACCEPTED); ArgumentCaptor mcap = ArgumentCaptor.forClass(TeamMembership.class); @@ -803,7 +792,6 @@ class SaasTeamServiceTest { when(membershipRepository.findByUserId(5L)) .thenReturn(List.of(oldMembership)) .thenReturn(List.of(oldMembership)); - when(saasTeamExtensionService.isPersonal(oldTeam)).thenReturn(false); when(saasTeamExtensionsRepository.incrementSeatsUsed(100L)).thenReturn(1); service.acceptInvitation("tok-123", u); @@ -811,6 +799,34 @@ class SaasTeamServiceTest { verify(teamRepository, never()).delete(oldTeam); verify(userRepository).updateUserTeamId(5L, 100L); } + + @Test + @DisplayName("keeps a led team with other members (parks it) instead of orphaning it") + void soleLeaderOfSharedTeam_teamKeptNotOrphaned() { + User u = user(5L, "b@x.com", "bob"); + Team newTeam = team(100L, "Acme"); + Team shared = team(300L, "Bob's Org"); // u solely leads it; it has other members + TeamMembership sharedMembership = membership(shared, u, TeamRole.LEADER); + TeamInvitation inv = + pendingInvitation(newTeam, user(1L, "a@x.com", "alice"), "b@x.com"); + + when(userRepository.findById(5L)).thenReturn(Optional.of(u)); + when(invitationRepository.findByInvitationToken("tok-123")) + .thenReturn(Optional.of(inv)); + when(saasTeamExtensionService.hasAvailableSeats(newTeam)).thenReturn(true); + when(membershipRepository.findByUserId(5L)).thenReturn(List.of(sharedMembership)); + // Shared team: sole leader, but >1 member - leaving would orphan the other member. + when(membershipRepository.countByTeamId(300L)).thenReturn(2L); + when(membershipRepository.countByTeamIdAndRole(300L, TeamRole.LEADER)).thenReturn(1L); + when(saasTeamExtensionsRepository.incrementSeatsUsed(100L)).thenReturn(1); + + service.acceptInvitation("tok-123", u); + + // The led team is parked: its membership is kept, not deleted. + verify(membershipRepository, never()).delete(sharedMembership); + verify(userRepository).updateUserTeamId(5L, 100L); + assertThat(inv.getStatus()).isEqualTo(InvitationStatus.ACCEPTED); + } } // ============================================================================================= @@ -987,8 +1003,8 @@ class SaasTeamServiceTest { } @Test - @DisplayName("removes the member, decrements seats, makes a personal team, downgrades") - void success_removesMemberAndDeletesEmptyTeam() { + @DisplayName("removes the member, decrements seats, returns them home; no team deletion") + void success_removesMemberReturnsHome() { User remover = user(1L, "a@x.com", "alice"); User target = user(2L, "b@x.com", "bob"); Team t = team(teamId, "Acme"); @@ -1001,19 +1017,16 @@ class SaasTeamServiceTest { .thenReturn(List.of(leaderM)); when(membershipRepository.findByTeamIdAndUserId(teamId, 2L)) .thenReturn(Optional.of(targetM)); - when(teamRepository.findById(teamId)).thenReturn(Optional.of(t)); - // createPersonalTeam + downgradeUserToFree both refetch the removed user by id. + // No home stubbed for the removed user -> returnUserToHome mints a fresh personal home. // target has no PRO authority, so downgrade hits the early return. stubCreatePersonalTeam(target, 500L); - // team becomes empty + non-personal -> deleted - when(saasTeamExtensionService.isPersonal(t)).thenReturn(false); - when(membershipRepository.countByTeamId(teamId)).thenReturn(0L); service.removeTeamMember(teamId, 2L, remover); verify(membershipRepository).delete(targetM); verify(saasTeamExtensionsRepository).decrementSeatsUsed(teamId); - verify(teamRepository).delete(t); + // Teams are durable now - the emptied team is not deleted. + verify(teamRepository, never()).delete(any()); } @Test @@ -1031,10 +1044,7 @@ class SaasTeamServiceTest { .thenReturn(List.of(leaderM)); when(membershipRepository.findByTeamIdAndUserId(teamId, 2L)) .thenReturn(Optional.of(targetM)); - when(teamRepository.findById(teamId)).thenReturn(Optional.of(t)); stubCreatePersonalTeam(target, 500L); - when(saasTeamExtensionService.isPersonal(t)).thenReturn(false); - when(membershipRepository.countByTeamId(teamId)).thenReturn(2L); service.removeTeamMember(teamId, 2L, remover); @@ -1078,28 +1088,27 @@ class SaasTeamServiceTest { } @Test - @DisplayName("member leaves: deletes membership, decrements, makes personal team") + @DisplayName("member leaves: deletes membership, decrements, returns them home") void memberLeaves_success() { User u = user(1L, "a@x.com", "alice"); Team t = team(teamId, "Acme"); TeamMembership memberM = membership(t, u, TeamRole.MEMBER); when(membershipRepository.findByTeamIdAndUserId(teamId, 1L)) .thenReturn(Optional.of(memberM)); - when(teamRepository.findById(teamId)).thenReturn(Optional.of(t)); + // No home stubbed -> returnUserToHome mints a fresh personal home team. stubCreatePersonalTeam(u, 500L); - when(saasTeamExtensionService.isPersonal(t)).thenReturn(true); service.leaveTeam(teamId, u); verify(membershipRepository).delete(memberM); verify(saasTeamExtensionsRepository).decrementSeatsUsed(teamId); - // Personal team is never deleted on leave. + // Teams are durable now; none is deleted on leave. verify(teamRepository, never()).delete(any()); } @Test - @DisplayName("leader leaves when another leader remains: deletes empty non-personal team") - void leaderLeavesWithCoLeader_deletesEmptyTeam() { + @DisplayName("co-leader leaves and returns home; the team is durable (not deleted)") + void leaderLeavesWithCoLeader_returnsHome() { User u = user(1L, "a@x.com", "alice"); Team t = team(teamId, "Acme"); TeamMembership leaderM = membership(t, u, TeamRole.LEADER); @@ -1108,14 +1117,12 @@ class SaasTeamServiceTest { .thenReturn(Optional.of(leaderM)); when(membershipRepository.findByTeamIdAndRole(teamId, TeamRole.LEADER)) .thenReturn(List.of(leaderM, coLeaderM)); - when(teamRepository.findById(teamId)).thenReturn(Optional.of(t)); stubCreatePersonalTeam(u, 500L); - when(saasTeamExtensionService.isPersonal(t)).thenReturn(false); - when(membershipRepository.countByTeamId(teamId)).thenReturn(0L); service.leaveTeam(teamId, u); - verify(teamRepository).delete(t); + verify(membershipRepository).delete(leaderM); + verify(teamRepository, never()).delete(any()); } @Test @@ -1126,9 +1133,7 @@ class SaasTeamServiceTest { TeamMembership memberM = membership(t, u, TeamRole.MEMBER); when(membershipRepository.findByTeamIdAndUserId(teamId, 1L)) .thenReturn(Optional.of(memberM)); - when(teamRepository.findById(teamId)).thenReturn(Optional.of(t)); stubTeamSave(500L); - when(saasTeamExtensionService.isPersonal(t)).thenReturn(true); // Both createPersonalTeam and downgradeUserToFree refetch by id; return the PRO user // with an active sub -> keep PRO. User proRefetch = proUser(1L, "a@x.com", "alice"); @@ -1150,9 +1155,7 @@ class SaasTeamServiceTest { TeamMembership memberM = membership(t, u, TeamRole.MEMBER); when(membershipRepository.findByTeamIdAndUserId(teamId, 1L)) .thenReturn(Optional.of(memberM)); - when(teamRepository.findById(teamId)).thenReturn(Optional.of(t)); stubTeamSave(500L); - when(saasTeamExtensionService.isPersonal(t)).thenReturn(true); User proRefetch = proUser(1L, "a@x.com", "alice"); proRefetch.setSupabaseId(SUPABASE_ID); when(userRepository.findById(1L)).thenReturn(Optional.of(proRefetch)); @@ -1172,9 +1175,7 @@ class SaasTeamServiceTest { TeamMembership memberM = membership(t, u, TeamRole.MEMBER); when(membershipRepository.findByTeamIdAndUserId(teamId, 1L)) .thenReturn(Optional.of(memberM)); - when(teamRepository.findById(teamId)).thenReturn(Optional.of(t)); stubTeamSave(500L); - when(saasTeamExtensionService.isPersonal(t)).thenReturn(true); // PRO user without supabaseId skips the subscription check and downgrades. User proRefetch = proUser(1L, "a@x.com", "alice"); when(userRepository.findById(1L)).thenReturn(Optional.of(proRefetch)); @@ -1192,9 +1193,7 @@ class SaasTeamServiceTest { TeamMembership memberM = membership(t, u, TeamRole.MEMBER); when(membershipRepository.findByTeamIdAndUserId(teamId, 1L)) .thenReturn(Optional.of(memberM)); - when(teamRepository.findById(teamId)).thenReturn(Optional.of(t)); stubTeamSave(500L); - when(saasTeamExtensionService.isPersonal(t)).thenReturn(true); User proRefetch = proUser(1L, "a@x.com", "alice"); proRefetch.setSupabaseId(SUPABASE_ID); when(userRepository.findById(1L)).thenReturn(Optional.of(proRefetch)); @@ -1472,6 +1471,36 @@ class SaasTeamServiceTest { assertThat(invitation.getStatus()).isEqualTo(InvitationStatus.ACCEPTED); } + @Test + @DisplayName("does not block when the only linked instance is on the parked home team") + void passesGuardWhenLinkedInstanceIsOnHomeTeam() { + User joiner = user(USER_ID, EMAIL, EMAIL); + Team homeTeam = team(OLD_TEAM_ID, "home-team"); + Team newTeam = team(NEW_TEAM_ID, "new-team"); + TeamInvitation invitation = pendingInvitation(newTeam, joiner); + TeamMembership homeMembership = membership(homeTeam, joiner, TeamRole.LEADER); + + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(joiner)); + when(invitationRepository.findByInvitationToken(TOKEN)) + .thenReturn(Optional.of(invitation)); + when(saasTeamExtensionService.hasAvailableSeats(newTeam)).thenReturn(true); + // The linked team IS the durable home, so the join parks it rather than orphaning it. + when(saasUserExtensionService.getHomeTeamId(joiner)).thenReturn(OLD_TEAM_ID); + when(membershipRepository.findByUserId(USER_ID)).thenReturn(List.of(homeMembership)); + // Home still carries a non-revoked linked instance - the old guard wrongly blocked + // here. + when(linkedInstanceRepository.countByTeamIdAndRevokedAtIsNull(OLD_TEAM_ID)) + .thenReturn(1L); + when(saasTeamExtensionsRepository.incrementSeatsUsed(NEW_TEAM_ID)).thenReturn(1); + + service.acceptInvitation(TOKEN, joiner); + + // Home parked (never deleted), user re-pointed to the new team, invite accepted. + verify(membershipRepository, never()).delete(homeMembership); + verify(userRepository).updateUserTeamId(USER_ID, NEW_TEAM_ID); + assertThat(invitation.getStatus()).isEqualTo(InvitationStatus.ACCEPTED); + } + private TeamInvitation pendingInvitation(Team team, User invitee) { TeamInvitation inv = new TeamInvitation(); inv.setTeam(team); diff --git a/app/saas/src/test/java/stirling/software/saas/service/SaasUserExtensionServiceTest.java b/app/saas/src/test/java/stirling/software/saas/service/SaasUserExtensionServiceTest.java index 4bf9b06baa..4ef7569bce 100644 --- a/app/saas/src/test/java/stirling/software/saas/service/SaasUserExtensionServiceTest.java +++ b/app/saas/src/test/java/stirling/software/saas/service/SaasUserExtensionServiceTest.java @@ -214,4 +214,42 @@ class SaasUserExtensionServiceTest { assertThat(captor.getValue().getApiKeyFirstUsedAt()).isNotNull(); } } + + @Nested + @DisplayName("home team") + class HomeTeam { + + @Test + @DisplayName("getHomeTeamId returns the stored id when a row exists") + void existing_returnsId() { + SaasUserExtensions ext = new SaasUserExtensions(user); + ext.setHomeTeamId(7L); + when(repository.findByUserId(USER_ID)).thenReturn(Optional.of(ext)); + + assertThat(service.getHomeTeamId(user)).isEqualTo(7L); + } + + @Test + @DisplayName("getHomeTeamId returns null when no row exists") + void missing_returnsNull() { + when(repository.findByUserId(USER_ID)).thenReturn(Optional.empty()); + + assertThat(service.getHomeTeamId(user)).isNull(); + verify(repository, never()).save(any()); + } + + @Test + @DisplayName("setHomeTeamId writes the id on the existing row and saves") + void set_updatesExistingRow() { + SaasUserExtensions ext = new SaasUserExtensions(user); + when(repository.findByUserId(USER_ID)).thenReturn(Optional.of(ext)); + when(repository.save(any(SaasUserExtensions.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + service.setHomeTeamId(user, 9L); + + assertThat(ext.getHomeTeamId()).isEqualTo(9L); + verify(repository).save(ext); + } + } } diff --git a/build.gradle b/build.gradle index 13ebd36870..beb90e7849 100644 --- a/build.gradle +++ b/build.gradle @@ -5,7 +5,7 @@ plugins { id "org.springframework.boot" version "4.0.6" id "org.springdoc.openapi-gradle-plugin" version "1.9.0" id "io.swagger.swaggerhub" version "1.3.2" - id "com.diffplug.spotless" version "8.5.0" + id "com.diffplug.spotless" version "8.8.0" id "com.github.jk1.dependency-license-report" version "3.1.2" //id "nebula.lint" version "19.0.3" id "org.sonarqube" version "7.2.3.7755" @@ -28,7 +28,7 @@ ext { springSecuritySamlVersion = "7.0.5" openSamlVersion = "5.2.1" commonmarkVersion = "0.28.0" - googleJavaFormatVersion = "1.28.0" + googleJavaFormatVersion = "1.35.0" logback = "1.5.32" commonsIoVersion = "2.22.0" commonsLang3 = "3.20.0" @@ -36,6 +36,7 @@ ext { okhttpBomVersion = "5.3.2" gsonVersion = "2.14.0" guavaVersion = "33.6.0-jre" + jinjavaVersion = "2.8.3" bucket4jVersion = "8.19.0" archunitVersion = "1.4.2" batikVersion = "1.19" @@ -58,11 +59,15 @@ ext { modernJavaVersion = 25 } +def buildJavaMajorVersion = (project.findProperty('javaVersion') ?: ext.modernJavaVersion).toString().toInteger() +def buildJavaLanguageVersion = JavaLanguageVersion.of(buildJavaMajorVersion) +def buildJavaVersion = JavaVersion.toVersion(buildJavaMajorVersion.toString()) + java { - sourceCompatibility = JavaVersion.VERSION_25 - targetCompatibility = JavaVersion.VERSION_25 + sourceCompatibility = buildJavaVersion + targetCompatibility = buildJavaVersion toolchain { - languageVersion = JavaLanguageVersion.of(project.findProperty('javaVersion')?.toString() ?: '25') + languageVersion = buildJavaLanguageVersion } } @@ -102,7 +107,7 @@ springBoot { allprojects { group = 'stirling.software' - version = '2.14.1' + version = '2.14.2' configurations.configureEach { exclude group: "org.springframework.boot", module: "spring-boot-starter-tomcat" @@ -180,12 +185,13 @@ subprojects { apply plugin: 'org.springframework.boot' apply plugin: 'io.spring.dependency-management' apply plugin: 'jacoco' + apply from: rootProject.file('gradle/spotless.gradle') java { - sourceCompatibility = JavaVersion.VERSION_25 - targetCompatibility = JavaVersion.VERSION_25 + sourceCompatibility = buildJavaVersion + targetCompatibility = buildJavaVersion toolchain { - languageVersion = JavaLanguageVersion.of(25) + languageVersion = buildJavaLanguageVersion } } @@ -235,6 +241,13 @@ subprojects { resolutionStrategy.force "commons-io:commons-io:${commonsIoVersion}" // CVE-2024-47554: velocity-engine-core 2.3 shades commons-io 2.8.0; 2.4.1 unshades it resolutionStrategy.force "org.apache.velocity:velocity-engine-core:${velocityVersion}" + // Jackson 2 is transitive-only here (jinjava, opensaml, jjwt request older versions); + // pin the family to a current release and keep modules aligned. + resolutionStrategy.force "com.fasterxml.jackson.core:jackson-core:${jackson2Version}" + resolutionStrategy.force "com.fasterxml.jackson.core:jackson-databind:${jackson2Version}" + resolutionStrategy.force "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:${jackson2Version}" + resolutionStrategy.force "com.fasterxml.jackson.datatype:jackson-datatype-jdk8:${jackson2Version}" + resolutionStrategy.force "com.fasterxml.jackson.datatype:jackson-datatype-jsr310:${jackson2Version}" // Keep BouncyCastle modules aligned to avoid runtime linkage errors resolutionStrategy.force "org.bouncycastle:bcprov-jdk18on:${bouncycastleVersion}" resolutionStrategy.force "org.bouncycastle:bcpkix-jdk18on:${bouncycastleVersion}" @@ -298,7 +311,7 @@ subprojects { tasks.withType(JavaCompile).configureEach { options.encoding = "UTF-8" - options.release = rootProject.ext.modernJavaVersion + options.release = buildJavaMajorVersion if (!project.hasProperty("noSpotless")) { dependsOn "spotlessApply" } @@ -693,10 +706,14 @@ tasks.register('compileRestartHelper', JavaCompile) { source = fileTree(dir: 'scripts', include: 'RestartHelper.java') classpath = files() destinationDirectory = layout.buildDirectory.dir("restart-helper-classes") - def restartMajorVersion = project.ext.modernJavaVersion + def restartMajorVersion = buildJavaMajorVersion + def restartLanguageVersion = JavaLanguageVersion.of(restartMajorVersion) def restartCompatibility = JavaVersion.toVersion(restartMajorVersion.toString()) sourceCompatibility = restartCompatibility targetCompatibility = restartCompatibility + javaCompiler = javaToolchains.compilerFor { + languageVersion = restartLanguageVersion + } options.release.set(restartMajorVersion) } diff --git a/docker/backend/Dockerfile b/docker/backend/Dockerfile index 5ed6a240a1..37d46f4cca 100644 --- a/docker/backend/Dockerfile +++ b/docker/backend/Dockerfile @@ -1,10 +1,10 @@ # Stirling-PDF backend-only image — JAR built with -PbuildWithFrontend=false, UI ships separately. -ARG BASE_VERSION=1.0.4 +ARG BASE_VERSION=1.0.4@sha256:c77c8af1695b4d618bc58ecf28c44cc537c050406a3b3040505c734484dc7b9e ARG BASE_IMAGE=stirlingtools/stirling-pdf-base:${BASE_VERSION} # Stage 1: Build the Java application (backend only, no frontend) -FROM gradle:9.6.0-jdk25@sha256:e3905233ae349e72016daf8a0e19f085a1dd89ded8ec88b3d8335d3fd0b350f4 AS app-build +FROM gradle:9.6.1-jdk25@sha256:d25357611c559203299f3c6673101154156bdd28985858ff6f465fc69bd27ca4 AS app-build # JDK 25+: --add-exports is no longer accepted via JAVA_TOOL_OPTIONS; use JDK_JAVA_OPTIONS instead ENV JDK_JAVA_OPTIONS="--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \ diff --git a/docker/base/Dockerfile b/docker/base/Dockerfile index c36064afd7..e46d2a70c1 100644 --- a/docker/base/Dockerfile +++ b/docker/base/Dockerfile @@ -5,7 +5,7 @@ ARG TARGETPLATFORM # Stage 1: Build and strip Calibre -FROM ubuntu:noble@sha256:786a8b558f7be160c6c8c4a54f9a57274f3b4fb1491cf65146521ae77ff1dc54 AS calibre-build +FROM ubuntu:noble@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90 AS calibre-build ARG TARGETPLATFORM ARG CALIBRE_VERSION=9.4.0 ARG CALIBRE_STRIP_WEBENGINE=false @@ -270,7 +270,7 @@ RUN if [ "${CALIBRE_STRIP_WEBENGINE}" = "true" ]; then \ # Stage 2: Build Ghostscript from source -FROM ubuntu:noble@sha256:786a8b558f7be160c6c8c4a54f9a57274f3b4fb1491cf65146521ae77ff1dc54 AS gs-build +FROM ubuntu:noble@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90 AS gs-build ARG TARGETPLATFORM ARG GS_VERSION=10.06.0 @@ -294,7 +294,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ # Stage 3: Build PDF Tools (QPDF and ImageMagick 7) -FROM ubuntu:noble@sha256:786a8b558f7be160c6c8c4a54f9a57274f3b4fb1491cf65146521ae77ff1dc54 AS pdf-tools-build +FROM ubuntu:noble@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90 AS pdf-tools-build ARG TARGETPLATFORM ARG QPDF_VERSION=12.3.2 ARG IM_VERSION=7.1.2-13 @@ -339,7 +339,7 @@ RUN mkdir -p /magick-export/usr/bin \ # Stage 4: Build Python venv -FROM ubuntu:noble@sha256:786a8b558f7be160c6c8c4a54f9a57274f3b4fb1491cf65146521ae77ff1dc54 AS python-venv-build +FROM ubuntu:noble@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90 AS python-venv-build ARG TARGETPLATFORM ARG UNOSERVER_VERSION=3.6 diff --git a/docker/embedded/Dockerfile b/docker/embedded/Dockerfile index d572e44941..cbd0de0bdc 100644 --- a/docker/embedded/Dockerfile +++ b/docker/embedded/Dockerfile @@ -1,17 +1,17 @@ # Stirling-PDF - Full version (embedded frontend) # Uses pre-built base image for fast builds -ARG BASE_VERSION=1.0.4 +ARG BASE_VERSION=1.0.4@sha256:c77c8af1695b4d618bc58ecf28c44cc537c050406a3b3040505c734484dc7b9e ARG BASE_IMAGE=stirlingtools/stirling-pdf-base:${BASE_VERSION} # Stage 1: Build the Java application and frontend -FROM gradle:9.6.0-jdk25@sha256:e3905233ae349e72016daf8a0e19f085a1dd89ded8ec88b3d8335d3fd0b350f4 AS app-build +FROM gradle:9.6.1-jdk25@sha256:d25357611c559203299f3c6673101154156bdd28985858ff6f465fc69bd27ca4 AS app-build ARG TASK_VERSION=3.49.1 RUN apt-get update \ && apt-get install -y --no-install-recommends curl ca-certificates \ && update-ca-certificates \ - && curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ + && curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ && apt-get install -y --no-install-recommends nodejs \ && ARCH=$(dpkg --print-architecture) \ && curl -fsSL "https://github.com/go-task/task/releases/download/v${TASK_VERSION}/task_${TASK_VERSION}_linux_${ARCH}.deb" -o /tmp/task.deb \ diff --git a/docker/embedded/Dockerfile.fat b/docker/embedded/Dockerfile.fat index f88d5eb856..aa51222327 100644 --- a/docker/embedded/Dockerfile.fat +++ b/docker/embedded/Dockerfile.fat @@ -2,17 +2,17 @@ # Extra fonts for air-gapped environments # Uses pre-built base image for fast builds -ARG BASE_VERSION=1.0.4 +ARG BASE_VERSION=1.0.4@sha256:c77c8af1695b4d618bc58ecf28c44cc537c050406a3b3040505c734484dc7b9e ARG BASE_IMAGE=stirlingtools/stirling-pdf-base:${BASE_VERSION} # Stage 1: Build the Java application and frontend -FROM gradle:9.6.0-jdk25@sha256:e3905233ae349e72016daf8a0e19f085a1dd89ded8ec88b3d8335d3fd0b350f4 AS app-build +FROM gradle:9.6.1-jdk25@sha256:d25357611c559203299f3c6673101154156bdd28985858ff6f465fc69bd27ca4 AS app-build ARG TASK_VERSION=3.49.1 RUN apt-get update \ && apt-get install -y --no-install-recommends curl ca-certificates \ && update-ca-certificates \ - && curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ + && curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ && apt-get install -y --no-install-recommends nodejs \ && ARCH=$(dpkg --print-architecture) \ && curl -fsSL "https://github.com/go-task/task/releases/download/v${TASK_VERSION}/task_${TASK_VERSION}_linux_${ARCH}.deb" -o /tmp/task.deb \ @@ -54,7 +54,7 @@ RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo li --no-daemon # Stage 2: Extract Spring Boot Layers -FROM eclipse-temurin:25-jre-noble AS jar-extract +FROM eclipse-temurin:25-jre-noble@sha256:b27ca47660a8fa837e47a8533b9b1a3a430295cf29ca28d91af4fd121572dc29 AS jar-extract WORKDIR /tmp COPY --from=app-build /app/app/core/build/libs/*.jar app.jar RUN java -Djarmode=tools -jar app.jar extract --layers --destination /layers diff --git a/docker/embedded/Dockerfile.ultra-lite b/docker/embedded/Dockerfile.ultra-lite index 7952eac309..a22fa9414b 100644 --- a/docker/embedded/Dockerfile.ultra-lite +++ b/docker/embedded/Dockerfile.ultra-lite @@ -2,13 +2,13 @@ # Single JAR contains both frontend and backend with minimal dependencies # Stage 1: Build application with embedded frontend -FROM gradle:9.6.0-jdk25@sha256:e3905233ae349e72016daf8a0e19f085a1dd89ded8ec88b3d8335d3fd0b350f4 AS build +FROM gradle:9.6.1-jdk25@sha256:d25357611c559203299f3c6673101154156bdd28985858ff6f465fc69bd27ca4 AS build # Install Node.js and npm for frontend build ARG TASK_VERSION=3.49.1 RUN apt-get update && apt-get install -y --no-install-recommends \ curl \ - && curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ + && curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ && apt-get install -y --no-install-recommends nodejs \ && npm --version \ && node --version \ diff --git a/engine/.env b/engine/.env index a99abf0ccf..1a90ad8c18 100644 --- a/engine/.env +++ b/engine/.env @@ -88,3 +88,7 @@ STIRLING_LOG_FILE= # Use when diagnosing worker stalls: a hung call shows a "Request" line with # no matching "Response" line. Noisy; leave off in normal use. STIRLING_HTTP_DEBUG=false + +# Let the Java processor push admin AI settings to POST /api/v1/config at startup. +# Set false in env-driven deployments so the environment is the single source of truth. +STIRLING_ALLOW_CONFIG_PUSH=true diff --git a/engine/Dockerfile.dev b/engine/Dockerfile.dev index 5020fd0590..b3e7cc30a7 100644 --- a/engine/Dockerfile.dev +++ b/engine/Dockerfile.dev @@ -1,6 +1,5 @@ # syntax=docker/dockerfile:1.5 -FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim - +FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim@sha256:531f855bda2c73cd6ef67d56b733b357cea384185b3022bd09f05e002cd144ca WORKDIR /app COPY pyproject.toml uv.lock ./ diff --git a/engine/pyproject.toml b/engine/pyproject.toml index fa972c8495..8cbb94daf8 100644 --- a/engine/pyproject.toml +++ b/engine/pyproject.toml @@ -4,13 +4,14 @@ version = "0.1.0" description = "AI Document Engine" requires-python = ">=3.13" dependencies = [ + "cryptography>=44.0.0", "fastapi>=0.116.0", - "jinja2>=3.1.0", "pgvector>=0.3.6", "psycopg[binary,pool]>=3.2", "pydantic>=2.0.0", - "pydantic-ai>=1.67.0", - "pydantic-ai-slim[voyageai]>=1.67.0", + # <2 cap: 1.99.0 patches CVE-2026-46678; 2.0 is an untested major migration + "pydantic-ai>=1.99.0,<2.0.0", + "pydantic-ai-slim[voyageai]>=1.99.0,<2.0.0", "pydantic-settings>=2.0.0", "python-dotenv>=1.2.1", "sqlite-vec>=0.1.6", diff --git a/engine/src/stirling/agents/document_classifier.py b/engine/src/stirling/agents/document_classifier.py index bb4ab5ef00..73d97595ed 100644 --- a/engine/src/stirling/agents/document_classifier.py +++ b/engine/src/stirling/agents/document_classifier.py @@ -116,8 +116,8 @@ class DocumentClassifierAgent: ) async def classify(self, request: ClassifyDocumentRequest) -> ClassifyDocumentResponse: - # The caller (the backend) always supplies the allowed vocabulary — the - # team's stored labels — so the engine holds no vocabulary of its own. + # The caller (the backend) always supplies the allowed vocabulary — its + # fixed built-in label set — so the engine holds no vocabulary of its own. allowed = request.labels window = select_window(request.pages) prompt = self._build_prompt(request.file_name, allowed, window) diff --git a/engine/src/stirling/agents/math_presentation.py b/engine/src/stirling/agents/math_presentation.py index bfb6fa7ef8..a8652572a6 100644 --- a/engine/src/stirling/agents/math_presentation.py +++ b/engine/src/stirling/agents/math_presentation.py @@ -15,6 +15,7 @@ from __future__ import annotations from pydantic import Field from pydantic_ai import Agent +from stirling.agents.output_mode import output_retries from stirling.contracts import ( MathAuditorToolReportArtifact, OrchestratorRequest, @@ -68,6 +69,9 @@ class MathIntentClassifier: self._agent: Agent[None, _MathIntentDecision] = Agent( model=runtime.fast_model, output_type=_MathIntentDecision, + # Local models emit valid structured output only intermittently; extra + # retries make this hot-path classifier reliable. No-op for real providers. + retries=output_retries(runtime.settings.chat_provider), system_prompt=_MATH_INTENT_SYSTEM_PROMPT, model_settings=runtime.fast_model_settings, ) diff --git a/engine/src/stirling/agents/orchestrator.py b/engine/src/stirling/agents/orchestrator.py index e07e771e34..bb7f2682df 100644 --- a/engine/src/stirling/agents/orchestrator.py +++ b/engine/src/stirling/agents/orchestrator.py @@ -2,12 +2,14 @@ from __future__ import annotations import logging from dataclasses import dataclass -from typing import assert_never +from typing import Literal, assert_never +from pydantic import ConfigDict, Field from pydantic_ai import Agent -from pydantic_ai.output import ToolOutput +from pydantic_ai.output import NativeOutput, ToolOutput from pydantic_ai.tools import RunContext +from stirling.agents.output_mode import output_retries, uses_tool_output from stirling.agents.pdf_create import PdfCreateAgent from stirling.agents.pdf_edit import PdfEditAgent from stirling.agents.pdf_questions import PdfQuestionAgent @@ -27,6 +29,7 @@ from stirling.contracts import ( format_file_names, ) from stirling.contracts.pdf_create import PdfCreateOrchestrateResponse +from stirling.models import ApiModel from stirling.services import AppRuntime logger = logging.getLogger(__name__) @@ -38,6 +41,34 @@ class OrchestratorDeps: request: OrchestratorRequest +# Enum routing for Ollama/custom local models: they pass the user message as args to the +# zero-arg tool delegates below, which reject it, so pick a capability by name and dispatch in Python. +_RouteCapability = Literal["pdf_edit", "pdf_question", "user_spec", "pdf_review", "pdf_create", "unsupported"] + + +class _RouteDecision(ApiModel): + # Local models add stray tool args and send null for optional fields; tolerate both. + model_config = ConfigDict(extra="ignore") + capability: _RouteCapability + message: str | None = Field( + default=None, + description="Only for capability='unsupported': a short, helpful message to show the user.", + ) + + +_ROUTER_SYSTEM_PROMPT = ( + "You are the top-level router. Choose exactly one capability that best handles the request:\n" + "- pdf_edit: modify or convert one or more attached PDFs.\n" + "- pdf_question: answer questions about the contents of the attached PDFs.\n" + "- user_spec: create or define an agent spec.\n" + "- pdf_review: return the PDF with review comments/annotations attached.\n" + "- pdf_create: generate a NEW document from scratch (invoice, report, letter) - no input file.\n" + "- unsupported: none of the above fit, or the user asks about the assistant itself; put a " + "helpful message in 'message'.\n" + "Respond with the capability and (only for unsupported) a message." +) + + class OrchestratorAgent: def __init__(self, runtime: AppRuntime) -> None: self.runtime = runtime @@ -86,6 +117,8 @@ class OrchestratorAgent: description="Return this when none of the delegate outputs fit the request.", ), ], + # Local models pick a delegate less reliably; extra retries. No-op for real providers. + retries=output_retries(runtime.settings.chat_provider), deps_type=OrchestratorDeps, system_prompt=( "You are the top-level orchestrator. " @@ -103,6 +136,21 @@ class OrchestratorAgent: ), model_settings=runtime.fast_model_settings, ) + # Local models can't drive the zero-arg tool delegates; route by name instead (#6163: unify these paths). + self._route_via_enum = uses_tool_output(runtime.settings.chat_provider) + # The router has no tools, so NativeOutput works on Ollama here; a lone output tool + # would tempt a local model to answer in plain text and never call it. + self._router = ( + Agent( + model=runtime.fast_model, + output_type=NativeOutput([_RouteDecision]), + retries=output_retries(runtime.settings.chat_provider), + system_prompt=_ROUTER_SYSTEM_PROMPT, + model_settings=runtime.fast_model_settings, + ) + if self._route_via_enum + else None + ) async def handle(self, request: OrchestratorRequest) -> OrchestratorResponse: logger.info( @@ -114,6 +162,8 @@ class OrchestratorAgent: ) if request.resume_with is not None: return await self._resume(request, request.resume_with) + if self._router is not None: + return await self._route_and_dispatch(request) result = await self.agent.run( self._build_prompt(request), deps=OrchestratorDeps(runtime=self.runtime, request=request), @@ -121,6 +171,31 @@ class OrchestratorAgent: logger.info("[orchestrator] routed -> %s", type(result.output).__name__) return result.output + async def _route_and_dispatch(self, request: OrchestratorRequest) -> OrchestratorResponse: + """Local-model routing: pick a capability by name, then dispatch in Python.""" + assert self._router is not None + result = await self._router.run(self._build_prompt(request)) + decision = result.output + logger.info("[orchestrator] enum-routed -> %s", decision.capability) + match decision.capability: + case "pdf_edit": + return await self._run_pdf_edit(request) + case "pdf_question": + return await self._run_pdf_question(request) + case "user_spec": + return await self._run_agent_draft(request) + case "pdf_review": + return await self._run_pdf_review(request) + case "pdf_create": + return await self._run_pdf_create(request) + case "unsupported": + return UnsupportedCapabilityResponse( + capability="orchestrate", + message=decision.message or "I can't help with that request.", + ) + case _ as unreachable: + assert_never(unreachable) + async def _resume(self, request: OrchestratorRequest, capability: SupportedCapability) -> OrchestratorResponse: """Fast-path to get back to the correct endpoint without having to call AI. diff --git a/engine/src/stirling/agents/output_mode.py b/engine/src/stirling/agents/output_mode.py new file mode 100644 index 0000000000..8751178448 --- /dev/null +++ b/engine/src/stirling/agents/output_mode.py @@ -0,0 +1,28 @@ +"""Provider-aware output: Ollama/custom block tools under native json-schema, so use ToolOutput not NativeOutput.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any + +from pydantic_ai.output import NativeOutput, ToolOutput + +# Providers whose OpenAI-compatible endpoint needs tool-delivered structured output. +_TOOL_OUTPUT_PROVIDERS = frozenset({"ollama", "custom"}) + + +def uses_tool_output(chat_provider: str) -> bool: + return chat_provider in _TOOL_OUTPUT_PROVIDERS + + +def structured_output(output_types: Sequence[Any], *, chat_provider: str) -> Any: + """Pick a structured-output spec compatible with the active chat provider.""" + types = list(output_types) + if uses_tool_output(chat_provider): + return [ToolOutput(t) for t in types] + return NativeOutput(types) + + +def output_retries(chat_provider: str, *, native: int = 1, tool: int = 6) -> int: + """Local models delivering via ToolOutput need more output-validation retries.""" + return tool if uses_tool_output(chat_provider) else native diff --git a/engine/src/stirling/agents/pdf_create/agent.py b/engine/src/stirling/agents/pdf_create/agent.py index bd159060df..671d4867ca 100644 --- a/engine/src/stirling/agents/pdf_create/agent.py +++ b/engine/src/stirling/agents/pdf_create/agent.py @@ -10,7 +10,7 @@ Flow: 4. SectionWriterAgents (smart_model) run in parallel via asyncio.gather. Each returns a WrittenSections with fully populated DocumentSection objects. 5. The assembler collects sections in plan order → GeneratedDocument. - 6. Jinja renders the document to HTML. The LLM never writes HTML. + 6. The assembled document is emitted as structured fields. The LLM never writes HTML. The planner is split into two calls (meta then sections) so each LLM output schema stays small enough for grammar compilation on all model tiers including Haiku. @@ -22,9 +22,7 @@ import asyncio import logging import re from dataclasses import dataclass -from pathlib import Path -from jinja2 import Environment, FileSystemLoader from pydantic_ai import Agent from pydantic_ai.output import NativeOutput @@ -51,8 +49,6 @@ from stirling.services import AppRuntime logger = logging.getLogger(__name__) -_TEMPLATES_DIR = Path(__file__).parent / "templates" - # ── Token budget ────────────────────────────────────────────────────────────────────────────────── # Conservative per-section token estimates mapped from planner-assigned depth. @@ -166,10 +162,13 @@ Analyse the user's request and produce a DocumentMeta with: document, if the user provides one. Leave empty if the user provides no such context. - style_primary_color: accent and heading colour. Set ONLY when the user explicitly names a - colour or colour scheme (e.g. "make it red", "use navy blue"). Use CSS named colours - (e.g. "magenta", "navy", "crimson") or hex values. Leave null if no colour is stated. -- style_background_color: page background colour. Set only if explicitly requested. -- style_body_text_color: body text colour. Set only if explicitly requested. + colour or colour scheme (e.g. "make it red", "use navy blue"). Express it as a 6-digit hex + code in #RRGGBB format (map any named colour to its hex value yourself, e.g. "navy" → + "#000080"). No other format is accepted. Leave null if no colour is stated. +- style_background_color: page background colour, same #RRGGBB format. Set only if explicitly + requested. +- style_body_text_color: body text colour, same #RRGGBB format. Set only if explicitly + requested. - cannot_do_reason: set this ONLY when the request is not asking to create a document at all (e.g. a question, a greeting, an edit request to an existing document). Never set it @@ -299,15 +298,6 @@ def _build_writer_prompt(plan: DocumentPlan, chunk: _Chunk) -> str: # ── Helpers ─────────────────────────────────────────────────────────────────────────────────────── -def _build_jinja_env() -> Environment: - return Environment( - loader=FileSystemLoader(str(_TEMPLATES_DIR)), - autoescape=True, - trim_blocks=True, - lstrip_blocks=True, - ) - - def _safe_filename(title: str) -> str: slug = re.sub(r"[^\w\s-]", "", title.lower()) slug = re.sub(r"[\s_-]+", "-", slug).strip("-") @@ -320,7 +310,6 @@ def _safe_filename(title: str) -> str: class PdfCreateAgent: def __init__(self, runtime: AppRuntime) -> None: self.runtime = runtime - self._jinja_env = _build_jinja_env() self._meta_planner: Agent[None, DocumentMeta] = Agent( model=runtime.smart_model, @@ -401,14 +390,12 @@ class PdfCreateAgent: sections=all_sections, ) - # ── Phase 6: render ──────────────────────────────────────────────────── - logger.info("[pdf-create] phase 6/6: rendering HTML") - html = self._render(doc) + # ── Phase 6: emit ────────────────────────────────────────────────────── filename = _safe_filename(plan.title) logger.info( - "[pdf-create] done — filename=%r html_bytes=%d", + "[pdf-create] done — filename=%r sections=%d", filename, - len(html), + len(all_sections), ) return EditPlanResponse( @@ -417,7 +404,7 @@ class PdfCreateAgent: ToolOperationStep( tool=AgentToolId.CREATE_PDF_FROM_HTML_AGENT, parameters=CreatePdfFromHtmlAgentParams( - html_content=html, + document=doc.model_dump_json(), filename=filename, ), ) @@ -437,7 +424,3 @@ class PdfCreateAgent: len(result.output.sections), ) return result.output - - def _render(self, doc: GeneratedDocument) -> str: - template = self._jinja_env.get_template("document.html.jinja2") - return template.render(doc=doc) diff --git a/engine/src/stirling/agents/pdf_questions.py b/engine/src/stirling/agents/pdf_questions.py index 5b956c468b..f594c2fdd7 100644 --- a/engine/src/stirling/agents/pdf_questions.py +++ b/engine/src/stirling/agents/pdf_questions.py @@ -3,10 +3,10 @@ from __future__ import annotations import logging from pydantic_ai import Agent -from pydantic_ai.output import NativeOutput from stirling.agents.contradiction import ContradictionCapability, ContradictionDetector from stirling.agents.math_presentation import MathIntentClassifier, extract_math_verdict +from stirling.agents.output_mode import output_retries, structured_output from stirling.agents.shared import ChunkedReasoner, WholeDocReaderCapability from stirling.contracts import ( AiFile, @@ -196,9 +196,15 @@ class PdfQuestionAgent: files=request.files, principals=principals, ) + # Ollama/custom block tool-calling under native json-schema output, so deliver the + # structured result via a tool call or the model answers ungrounded. See agents.output_mode. + provider = self.runtime.settings.chat_provider agent = Agent( model=self.runtime.smart_model, - output_type=NativeOutput([PdfQuestionAnswerResponse, PdfQuestionNotFoundResponse]), + output_type=structured_output( + [PdfQuestionAnswerResponse, PdfQuestionNotFoundResponse], chat_provider=provider + ), + retries=output_retries(provider), system_prompt=PDF_QUESTION_SYSTEM_PROMPT, # pydantic-ai accepts a list of (string-or-callable) instruction sources; # it resolves each at run time and concatenates them for the model. diff --git a/engine/src/stirling/api/app.py b/engine/src/stirling/api/app.py index 34b0da36fd..60de81360f 100644 --- a/engine/src/stirling/api/app.py +++ b/engine/src/stirling/api/app.py @@ -3,28 +3,20 @@ from __future__ import annotations import asyncio import logging from contextlib import asynccontextmanager -from typing import Annotated -from fastapi import Depends, FastAPI +from fastapi import Depends, FastAPI, Request from pydantic_ai import Agent +from pydantic_ai.models import Model from pydantic_ai.models.instrumented import InstrumentationSettings -from stirling.agents import ( - DocumentClassifierAgent, - ExecutionPlanningAgent, - OrchestratorAgent, - PdfEditAgent, - PdfQuestionAgent, - UserSpecAgent, -) -from stirling.agents.ledger import MathAuditorAgent -from stirling.agents.pdf_comment import PdfCommentAgent +from stirling.api.bootstrap import apply_app_state, build_app_state from stirling.api.dependencies import enforce_required_user_id from stirling.api.engine_auth import EngineSharedSecretMiddleware from stirling.api.middleware import UserIdMiddleware from stirling.api.routes import ( agent_capabilities_router, agent_draft_router, + config_router, document_classifier_router, document_router, execution_router, @@ -34,38 +26,42 @@ from stirling.api.routes import ( pdf_edit_router, pdf_question_router, ) +from stirling.api.routes.config import CONFIG_APPLY_ERRORS, apply_to_app, resolve_and_apply from stirling.config import AppSettings, load_settings +from stirling.config.config_cache import cache_stamp, load_config from stirling.contracts import HealthResponse -from stirling.documents import DocumentService -from stirling.services import build_runtime, setup_posthog_tracking +from stirling.documents import DocumentService, EmbeddingService +from stirling.services import setup_posthog_tracking logger = logging.getLogger(__name__) +# Seconds the lifespan waits for a background task to drain before cancelling it. +_BACKGROUND_TASK_DRAIN_SECONDS = 10 + + +async def _sleep_until(stop: asyncio.Event, seconds: float) -> bool: + """Wait up to ``seconds``; True if asked to stop, False if the interval elapsed.""" + try: + await asyncio.wait_for(stop.wait(), timeout=seconds) + except TimeoutError: + return False + return True + + async def _run_expired_doc_reaper( documents: DocumentService, interval_seconds: int, + stop: asyncio.Event, ) -> None: - """Periodically delete documents whose ``expires_at`` has passed. - - A reaped collection drops everything rooted at that document. Backstop - for the explicit logout purge: catches sessions that ended without a - clean logout (tab close, JWT expiry, engine restart). Persistent rows - (``expires_at`` null, the shape we use for org-shared docs) are never - touched. Runs until cancelled by the lifespan teardown. - """ + """Backstop purge of documents past ``expires_at``; persistent (null expires_at) rows are never touched.""" await _reap(documents) - while True: - await asyncio.sleep(interval_seconds) + while not await _sleep_until(stop, interval_seconds): await _reap(documents) async def _reap(documents: DocumentService) -> None: - """One reaper iteration. Logs the deleted count on success and the full - exception with traceback on failure; never re-raises non-cancel errors so - a bad iteration doesn't kill the loop. ``asyncio.CancelledError`` is - re-raised so the lifespan teardown can cancel the task cleanly. - """ + """One reaper iteration; swallows non-cancel errors so a bad iteration doesn't kill the loop.""" try: deleted = await documents.reap_expired() if deleted: @@ -76,6 +72,45 @@ async def _reap(documents: DocumentService) -> None: logger.exception("Document reaper iteration failed; will retry on next interval") +def _adopt_cached_config_if_changed(fast_api: FastAPI) -> None: + """Re-apply the persisted config when the shared cache file changed under us; never raises.""" + # Read the stamp before the payload: a write landing between the two re-applies next + # tick, whereas the reverse order would record the newer stamp and skip forever. + stamp = cache_stamp() + if stamp is None or stamp == getattr(fast_api.state, "config_cache_stamp", None): + return + # Claim the stamp up front so a cache we cannot read or apply is not retried every tick. + fast_api.state.config_cache_stamp = stamp + cached = load_config() + if cached is None: + return + try: + effective, _ = apply_to_app(fast_api, cached) + except CONFIG_APPLY_ERRORS: + logger.warning("Config pushed to another worker could not be applied here", exc_info=True) + return + logger.info( + "Adopted AI config pushed to another worker: smart_model=%s fast_model=%s", + effective.smart_model_name, + effective.fast_model_name, + ) + + +async def _run_config_cache_watcher( + fast_api: FastAPI, + interval_seconds: int, + stop: asyncio.Event, +) -> None: + """Poll the shared config cache so every worker converges on the last push.""" + while not await _sleep_until(stop, interval_seconds): + try: + _adopt_cached_config_if_changed(fast_api) + except asyncio.CancelledError: + raise + except Exception: + logger.exception("Config cache watcher iteration failed; will retry on next interval") + + def _load_startup_settings(fast_api: FastAPI) -> AppSettings: override = fast_api.dependency_overrides.get(load_settings) if override is not None: @@ -83,37 +118,84 @@ def _load_startup_settings(fast_api: FastAPI) -> AppSettings: return load_settings() +def _restore_cached_config( + settings: AppSettings, +) -> tuple[AppSettings, Model | None, Model | None, EmbeddingService | None]: + """Restore the last-applied pushed config from the encrypted cache, or env settings on any failure.""" + if not settings.allow_config_push: + return settings, None, None, None + cached = load_config() + if cached is None: + return settings, None, None, None + try: + effective, smart_model, fast_model, embedder, notes = resolve_and_apply(settings, cached) + except CONFIG_APPLY_ERRORS: + logger.warning("Cached AI config could not be applied; falling back to env settings", exc_info=True) + return settings, None, None, None + logger.info( + "Restored cached AI config: smart_model=%s fast_model=%s%s", + effective.smart_model_name, + effective.fast_model_name, + f"; {'; '.join(notes)}" if notes else "", + ) + return effective, smart_model, fast_model, embedder + + @asynccontextmanager async def lifespan(fast_api: FastAPI): # Load env vars on startup so we can immediately crash if required env vars aren't set settings = _load_startup_settings(fast_api) - runtime = build_runtime(settings) - fast_api.state.settings = settings - fast_api.state.runtime = runtime - fast_api.state.orchestrator_agent = OrchestratorAgent(runtime) - fast_api.state.pdf_edit_agent = PdfEditAgent(runtime) - fast_api.state.pdf_question_agent = PdfQuestionAgent(runtime) - fast_api.state.user_spec_agent = UserSpecAgent(runtime) - fast_api.state.execution_planning_agent = ExecutionPlanningAgent(runtime) - fast_api.state.math_auditor_agent = MathAuditorAgent(runtime) - fast_api.state.pdf_comment_agent = PdfCommentAgent(runtime) - fast_api.state.document_classifier_agent = DocumentClassifierAgent(runtime) - tracer_provider = setup_posthog_tracking(settings) + # Precedence: env < persisted cache < live push. Stamp first so a push landing mid-boot + # is re-adopted by the watcher rather than mistaken for the config we just restored. + fast_api.state.config_cache_stamp = cache_stamp() + effective, smart_model, fast_model, embedder = _restore_cached_config(settings) + app_state = build_app_state( + effective, + fast_model=fast_model, + smart_model=smart_model, + embedder=embedder, + ) + fast_api.state.settings = effective + apply_app_state(fast_api.state, app_state) + runtime = app_state.runtime + tracer_provider = setup_posthog_tracking(effective) if tracer_provider: Agent.instrument_all(InstrumentationSettings(tracer_provider=tracer_provider)) + stop_background = asyncio.Event() reaper_task = asyncio.create_task( _run_expired_doc_reaper( runtime.documents, interval_seconds=settings.documents_reaper_interval_seconds, + stop=stop_background, ), name="expired-document-reaper", ) + background_tasks = [reaper_task] + if effective.allow_config_push: + # A push reaches only one uvicorn worker; this watcher is how the rest of the + # pool picks it up, otherwise most requests keep running the previous models. + background_tasks.append( + asyncio.create_task( + _run_config_cache_watcher( + fast_api, + interval_seconds=settings.config_cache_poll_interval_seconds, + stop=stop_background, + ), + name="config-cache-watcher", + ) + ) yield - reaper_task.cancel() - try: - await reaper_task - except asyncio.CancelledError: - pass + # Drain the loops rather than cancel: cancelling a reaper mid `to_thread` sqlite call lets + # close() pull the connection out from under it and segfault sqlite-vec. Cancel is a backstop. + stop_background.set() + _, pending = await asyncio.wait(background_tasks, timeout=_BACKGROUND_TASK_DRAIN_SECONDS) + for task in pending: + logger.warning("Background task %s did not stop in time; cancelling", task.get_name()) + task.cancel() + try: + await task + except asyncio.CancelledError: + pass await runtime.documents.close() if tracer_provider: tracer_provider.shutdown() @@ -135,10 +217,16 @@ app.include_router(ledger_router, dependencies=_user_gate) app.include_router(pdf_comments_router, dependencies=_user_gate) app.include_router(agent_capabilities_router, dependencies=_user_gate) app.include_router(document_classifier_router, dependencies=_user_gate) +# Config push is a system sync with no X-User-Id, so it is guarded by the shared secret +# and allow_config_push flag only, deliberately NOT the per-user identity gate. +app.include_router(config_router) @app.get("/health", response_model=HealthResponse) -async def healthcheck(settings: Annotated[AppSettings, Depends(load_settings)]) -> HealthResponse: +async def healthcheck(http_request: Request) -> HealthResponse: + # Report the LIVE config on app.state, not the boot-time env cache, so an admin + # "Test connection" shows the model actually in use after a push. Falls back to env. + settings: AppSettings = getattr(http_request.app.state, "settings", None) or load_settings() return HealthResponse( status="ok", smart_model=settings.smart_model_name, diff --git a/engine/src/stirling/api/bootstrap.py b/engine/src/stirling/api/bootstrap.py new file mode 100644 index 0000000000..241578438e --- /dev/null +++ b/engine/src/stirling/api/bootstrap.py @@ -0,0 +1,72 @@ +"""Assemble the runtime + agents into one bundle, swapped onto app.state atomically to change models at runtime.""" + +from __future__ import annotations + +from dataclasses import dataclass, fields +from typing import Any + +from pydantic_ai.models import Model + +from stirling.agents import ( + DocumentClassifierAgent, + ExecutionPlanningAgent, + OrchestratorAgent, + PdfEditAgent, + PdfQuestionAgent, + UserSpecAgent, +) +from stirling.agents.ledger import MathAuditorAgent +from stirling.agents.pdf_comment import PdfCommentAgent +from stirling.config import AppSettings +from stirling.documents import DocumentService, EmbeddingService +from stirling.services import AppRuntime, build_runtime + + +@dataclass(frozen=True) +class AppState: + """Every object the lifespan assigns onto ``fast_api.state``.""" + + runtime: AppRuntime + orchestrator_agent: OrchestratorAgent + pdf_edit_agent: PdfEditAgent + pdf_question_agent: PdfQuestionAgent + user_spec_agent: UserSpecAgent + execution_planning_agent: ExecutionPlanningAgent + math_auditor_agent: MathAuditorAgent + pdf_comment_agent: PdfCommentAgent + document_classifier_agent: DocumentClassifierAgent + + +def build_app_state( + settings: AppSettings, + *, + documents: DocumentService | None = None, + fast_model: Model | None = None, + smart_model: Model | None = None, + embedder: EmbeddingService | None = None, +) -> AppState: + """Build the runtime and every agent from ``settings``.""" + runtime = build_runtime( + settings, + documents=documents, + fast_model=fast_model, + smart_model=smart_model, + embedder=embedder, + ) + return AppState( + runtime=runtime, + orchestrator_agent=OrchestratorAgent(runtime), + pdf_edit_agent=PdfEditAgent(runtime), + pdf_question_agent=PdfQuestionAgent(runtime), + user_spec_agent=UserSpecAgent(runtime), + execution_planning_agent=ExecutionPlanningAgent(runtime), + math_auditor_agent=MathAuditorAgent(runtime), + pdf_comment_agent=PdfCommentAgent(runtime), + document_classifier_agent=DocumentClassifierAgent(runtime), + ) + + +def apply_app_state(state: Any, app_state: AppState) -> None: + """Copy every field of ``app_state`` onto a Starlette ``app.state`` object.""" + for field in fields(app_state): + setattr(state, field.name, getattr(app_state, field.name)) diff --git a/engine/src/stirling/api/routes/__init__.py b/engine/src/stirling/api/routes/__init__.py index f40e9918f8..440ff18f9d 100644 --- a/engine/src/stirling/api/routes/__init__.py +++ b/engine/src/stirling/api/routes/__init__.py @@ -1,5 +1,6 @@ from .agent_capabilities import router as agent_capabilities_router from .agent_drafts import router as agent_draft_router +from .config import router as config_router from .document_classifier import router as document_classifier_router from .documents import router as document_router from .execution import router as execution_router @@ -12,6 +13,7 @@ from .pdf_questions import router as pdf_question_router __all__ = [ "agent_capabilities_router", "agent_draft_router", + "config_router", "document_classifier_router", "document_router", "execution_router", diff --git a/engine/src/stirling/api/routes/config.py b/engine/src/stirling/api/routes/config.py new file mode 100644 index 0000000000..cc06a8d87e --- /dev/null +++ b/engine/src/stirling/api/routes/config.py @@ -0,0 +1,250 @@ +from __future__ import annotations + +import ipaddress +import logging + +from fastapi import APIRouter, FastAPI, HTTPException, Request, status +from openai import OpenAIError +from pydantic_ai.exceptions import UserError +from pydantic_ai.models import Model + +from stirling.api.bootstrap import apply_app_state, build_app_state +from stirling.config import AppSettings +from stirling.config.config_cache import cache_stamp, save_config +from stirling.contracts import ConfigApplyResponse, ConfigPushRequest +from stirling.documents import EmbeddingService +from stirling.services import AppRuntime +from stirling.services.runtime import _build_model, validate_structured_output_support + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/v1/config", tags=["config"]) + +# Model/provider construction + validation failures. The HTTP route maps these to a +# 400 (no swap); boot catches them to fall back to env when a cached config is bad. +CONFIG_APPLY_ERRORS = (ValueError, UserError, OpenAIError) + +_REINDEX_NOTE = ( + "Embedding model changed; existing indexed documents were embedded with the previous model and " + "must be re-indexed. If the embedding dimensionality changed, re-ingest before searching." +) + + +def _strip_provider_prefix(model_name: str) -> str: + """Drop a leading ``provider:`` from an env model string ("anthropic:x" -> "x").""" + _, sep, rest = model_name.partition(":") + return rest if sep else model_name + + +def _compose_embedding_model(provider: str, model: str) -> str: + """Compose the engine's ``provider:model`` embedding string from pushed parts.""" + provider = provider.strip() + return f"{provider}:{model}" if provider else model + + +def _split_embedding_ref(ref: str) -> tuple[str, str]: + """Split an env embedding string ("voyageai:voyage-4") into (provider, model).""" + provider, sep, model = ref.partition(":") + return (provider, model) if sep else ("", ref) + + +def _keep(pushed: int | None, current: int) -> int: + """Return the pushed value, or the current one when the push omitted it.""" + return pushed if pushed is not None else current + + +# Presence of any of these means request.client.host may be proxy-rewritten and cannot be +# trusted as the transport peer, so a spoofed X-Forwarded-For could otherwise read as loopback. +_FORWARDING_HEADERS = ("x-forwarded-for", "x-forwarded-host", "x-real-ip", "forwarded") + + +def _is_direct_loopback_client(request: Request) -> bool: + """True only for a direct local connection with no proxy; fails closed if any forwarding header is present.""" + if any(h in request.headers for h in _FORWARDING_HEADERS): + return False + client = request.client + if client is None: + return False + try: + return ipaddress.ip_address(client.host).is_loopback + except ValueError: + return client.host == "localhost" + + +def resolve_and_apply( + current: AppSettings, + request: ConfigPushRequest, +) -> tuple[AppSettings, Model, Model, EmbeddingService | None, list[str]]: + """Resolve a pushed config against the running settings; it never swaps live state (the caller does).""" + models = request.models + rag = request.rag + limits = request.limits + notes: list[str] = [] + + provider = models.provider.strip() + api_key = models.api_key + base_url = models.base_url + use_explicit_provider = bool(provider or api_key or base_url) + + if use_explicit_provider and not current.chat_provider: + # First push over an env engine: running names are still "provider:model", strip the prefix. + smart_name = models.smart_model or _strip_provider_prefix(current.smart_model_name) + fast_name = models.fast_model or _strip_provider_prefix(current.fast_model_name) + elif use_explicit_provider: + # A provider was already pushed, so the running names are bare and may legitimately + # contain a colon ("llama3.1:8b"). Stripping again would truncate them to "8b". + smart_name = models.smart_model or current.smart_model_name + fast_name = models.fast_model or current.fast_model_name + else: + # No provider/credentials pushed: keep the fully env-driven model strings. + smart_name = models.smart_model or current.smart_model_name + fast_name = models.fast_model or current.fast_model_name + + def _build(bare: str) -> Model: + if use_explicit_provider: + return _build_model(bare, provider=provider or None, api_key=api_key or None, base_url=base_url or None) + return _build_model(bare) + + smart_model = _build(smart_name) + fast_model = _build(fast_name) + validate_structured_output_support(smart_model, smart_name) + validate_structured_output_support(fast_model, fast_name) + + # Scalars: None / empty keep the current value. + smart_max_tokens = _keep(models.smart_max_tokens, current.smart_model_max_tokens) + fast_max_tokens = _keep(models.fast_max_tokens, current.fast_model_max_tokens) + top_k = _keep(rag.top_k, current.rag_default_top_k) + max_searches = _keep(rag.max_searches, current.rag_max_searches) + max_pages = _keep(limits.max_pages, current.max_pages) + max_characters = _keep(limits.max_characters, current.max_characters) + model_max_concurrency = _keep(limits.model_max_concurrency, current.model_max_concurrency) + + # Embedding: any non-empty embedding field triggers a rebuild; empty fields fall + # back to the running provider/model/creds so a partial push never clobbers env. + embedding_changed = bool( + rag.embedding_provider.strip() or rag.embedding_model.strip() or rag.embedding_api_key or rag.embedding_base_url + ) + rag_embedding_model = current.rag_embedding_model + new_embedder: EmbeddingService | None = None + if embedding_changed: + current_provider, current_model = _split_embedding_ref(current.rag_embedding_model) + embed_provider = rag.embedding_provider.strip() or current_provider + embed_model = rag.embedding_model.strip() or current_model + rag_embedding_model = _compose_embedding_model(embed_provider, embed_model) + new_embedder = EmbeddingService( + model_name=embed_model, + chunk_size=current.rag_chunk_size, + chunk_overlap=current.rag_chunk_overlap, + provider=embed_provider or None, + api_key=rag.embedding_api_key or None, + base_url=rag.embedding_base_url or None, + ) + notes.append(_REINDEX_NOTE) + + effective = current.model_copy( + update={ + "chat_provider": provider, + "smart_model_name": smart_name, + "fast_model_name": fast_name, + "smart_model_max_tokens": smart_max_tokens, + "fast_model_max_tokens": fast_max_tokens, + "rag_embedding_model": rag_embedding_model, + "rag_default_top_k": top_k, + "rag_max_searches": max_searches, + "max_pages": max_pages, + "max_characters": max_characters, + "model_max_concurrency": model_max_concurrency, + } + ) + return effective, smart_model, fast_model, new_embedder, notes + + +def apply_to_app(app: FastAPI, request: ConfigPushRequest) -> tuple[AppSettings, list[str]]: + """Resolve ``request`` and swap the bundle onto app.state; no await, so the swap is atomic wrt the event loop.""" + current: AppSettings = app.state.settings + runtime: AppRuntime = app.state.runtime + effective, smart_model, fast_model, new_embedder, notes = resolve_and_apply(current, request) + new_state = build_app_state( + effective, + documents=runtime.documents, + fast_model=fast_model, + smart_model=smart_model, + ) + app.state.settings = effective + apply_app_state(app.state, new_state) + # Retune retrieval breadth on the reused store without rebuilding it. + runtime.documents.default_top_k = effective.rag_default_top_k + if new_embedder is not None: + # Swap the embedder onto the reused DocumentService, never tearing down the live store. + runtime.documents.embedder = new_embedder + return effective, notes + + +@router.post("", response_model=ConfigApplyResponse) +async def apply_config(request: ConfigPushRequest, http_request: Request) -> ConfigApplyResponse: + """Apply admin-pushed AI settings by rebuilding the runtime + agents, persisting so it survives a restart.""" + app = http_request.app + current: AppSettings = app.state.settings + if not current.allow_config_push: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Config push is disabled on this deployment (STIRLING_ALLOW_CONFIG_PUSH is false).", + ) + # Secure-by-default: with no shared secret set, only trust a direct loopback caller, since a + # pushed base_url/model could repoint the engine to exfiltrate document content. + if not current.engine_shared_secret and not _is_direct_loopback_client(http_request): + client_host = http_request.client.host if http_request.client else "unknown" + logger.warning( + "Rejected config push from non-local/proxied caller %s with no shared secret set", + client_host, + ) + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=( + "Config push from a non-local or proxied caller requires" + " STIRLING_ENGINE_SHARED_SECRET to be set on both the engine and the processor." + ), + ) + + try: + effective, notes = apply_to_app(app, request) + except CONFIG_APPLY_ERRORS as exc: + # Reject without touching the running config. + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + + # Persist (encrypted) so the config survives a restart and sibling workers adopt it. + # Best-effort: it is already applied live, so a persist failure must never become a 500. + try: + save_config(request) + # Claim the stamp we just wrote so this worker's watcher does not rebuild for it. + app.state.config_cache_stamp = cache_stamp() + except Exception: # noqa: BLE001 - best-effort persist, never fail the applied push + logger.warning("Applied AI config but failed to persist the encrypted cache", exc_info=True) + notes.append( + "Config applied on this worker but could not be persisted; it will not survive an" + " engine restart and other workers will not pick it up." + ) + + logger.info( + "Applied pushed AI config: provider=%s smart_model=%s fast_model=%s top_k=%s", + request.models.provider.strip() or "", + effective.smart_model_name, + effective.fast_model_name, + effective.rag_default_top_k, + ) + + return ConfigApplyResponse( + status="applied", + provider=request.models.provider.strip(), + smart_model=effective.smart_model_name, + fast_model=effective.fast_model_name, + smart_max_tokens=effective.smart_model_max_tokens, + fast_max_tokens=effective.fast_model_max_tokens, + rag_embedding_model=effective.rag_embedding_model, + rag_top_k=effective.rag_default_top_k, + rag_max_searches=effective.rag_max_searches, + max_pages=effective.max_pages, + max_characters=effective.max_characters, + model_max_concurrency=effective.model_max_concurrency, + notes=notes, + ) diff --git a/engine/src/stirling/config/config_cache.py b/engine/src/stirling/config/config_cache.py new file mode 100644 index 0000000000..deb5864f97 --- /dev/null +++ b/engine/src/stirling/config/config_cache.py @@ -0,0 +1,116 @@ +"""Persistent, Fernet-encrypted cache of the last-applied config-push. Boot restores it so an +engine-only restart keeps admin config; sibling uvicorn workers poll it to adopt a push.""" + +from __future__ import annotations + +import base64 +import logging +import os +import stat +from pathlib import Path + +from cryptography.fernet import Fernet, InvalidToken +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.kdf.hkdf import HKDF + +from stirling.config.settings import ENGINE_ROOT, load_settings +from stirling.contracts import ConfigPushRequest + +logger = logging.getLogger(__name__) + +_CACHE_FILENAME = "ai_config_cache.enc" +_KEY_FILENAME = "ai_config_cache.key" +# Constant salt/info so the same shared secret always derives the same Fernet key. +_HKDF_SALT = b"stirling-ai-config-cache/v1/salt" +_HKDF_INFO = b"stirling-ai-config-cache/v1/fernet-key" + +_keyfile_warned = False + + +def _default_data_dir() -> Path: + """The engine data dir (where the sqlite store lives by default).""" + return ENGINE_ROOT / "data" + + +def _shared_secret() -> str: + return load_settings().engine_shared_secret + + +def _derive_key_from_secret(secret: str) -> bytes: + hkdf = HKDF(algorithm=hashes.SHA256(), length=32, salt=_HKDF_SALT, info=_HKDF_INFO) + return base64.urlsafe_b64encode(hkdf.derive(secret.encode("utf-8"))) + + +def _write_private_bytes(path: Path, payload: bytes) -> None: + """Write ``payload`` to ``path`` atomically (temp file + rename) and owner-only (0600) from the moment it exists.""" + tmp_path = path.with_name(f"{path.name}.tmp") + fd = os.open(tmp_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, stat.S_IRUSR | stat.S_IWUSR) + try: + with os.fdopen(fd, "wb") as handle: + handle.write(payload) + handle.flush() + os.fsync(handle.fileno()) + os.replace(tmp_path, path) + except BaseException: + tmp_path.unlink(missing_ok=True) + raise + + +def _load_or_create_keyfile(data_dir: Path) -> bytes: + global _keyfile_warned + key_path = data_dir / _KEY_FILENAME + if key_path.exists(): + return key_path.read_bytes().strip() + key = Fernet.generate_key() + data_dir.mkdir(parents=True, exist_ok=True) + _write_private_bytes(key_path, key) + if not _keyfile_warned: + logger.warning( + "STIRLING_ENGINE_SHARED_SECRET is not set; encrypting the AI config cache with a local" + " keyfile at %s (0600, best-effort). This is modest protection only - set a shared secret" + " for HKDF key derivation in any deployment where the cache must be strongly protected.", + key_path, + ) + _keyfile_warned = True + return key + + +def _fernet(data_dir: Path) -> Fernet: + secret = _shared_secret() + if secret: + return Fernet(_derive_key_from_secret(secret)) + return Fernet(_load_or_create_keyfile(data_dir)) + + +def save_config(request: ConfigPushRequest, *, data_dir: Path | None = None) -> None: + """Encrypt and persist the last-applied pushed config, overwriting any prior file.""" + data_dir = data_dir or _default_data_dir() + data_dir.mkdir(parents=True, exist_ok=True) + payload = request.model_dump_json(by_alias=True).encode("utf-8") + token = _fernet(data_dir).encrypt(payload) + _write_private_bytes(data_dir / _CACHE_FILENAME, token) + + +def cache_stamp(*, data_dir: Path | None = None) -> tuple[int, int] | None: + """Identify the current cache file as (mtime_ns, size), or None when absent; cheap enough to poll.""" + cache_path = (data_dir or _default_data_dir()) / _CACHE_FILENAME + try: + info = cache_path.stat() + except OSError: + return None + return (info.st_mtime_ns, info.st_size) + + +def load_config(*, data_dir: Path | None = None) -> ConfigPushRequest | None: + """Load + decrypt the persisted config; returns None (never raises) when absent, corrupt, or wrong key.""" + data_dir = data_dir or _default_data_dir() + cache_path = data_dir / _CACHE_FILENAME + if not cache_path.exists(): + return None + try: + token = cache_path.read_bytes() + payload = _fernet(data_dir).decrypt(token) + return ConfigPushRequest.model_validate_json(payload) + except (InvalidToken, ValueError, OSError) as exc: + logger.warning("Ignoring unreadable AI config cache at %s: %s", cache_path, exc) + return None diff --git a/engine/src/stirling/config/settings.py b/engine/src/stirling/config/settings.py index 1293cc371f..d0cf6eba2f 100644 --- a/engine/src/stirling/config/settings.py +++ b/engine/src/stirling/config/settings.py @@ -25,6 +25,9 @@ class AppSettings(BaseSettings): smart_model_name: str = Field(validation_alias="STIRLING_SMART_MODEL") fast_model_name: str = Field(validation_alias="STIRLING_FAST_MODEL") + # Provider backing the active chat models; empty for env/native, 'ollama'/'custom' by push. + # Agents read it to pick a tool-compatible output strategy since local models block tools under native json-schema. + chat_provider: str = Field(default="") smart_model_max_tokens: int = Field(validation_alias="STIRLING_SMART_MODEL_MAX_TOKENS") fast_model_max_tokens: int = Field(validation_alias="STIRLING_FAST_MODEL_MAX_TOKENS") # Process-wide ceiling on concurrent model API calls, shared by both model @@ -123,6 +126,15 @@ class AppSettings(BaseSettings): engine_shared_secret: str = Field(default="", validation_alias="STIRLING_ENGINE_SHARED_SECRET") engine_require_auth: bool = Field(default=False, validation_alias="STIRLING_ENGINE_REQUIRE_AUTH") + # When true, the Java processor may push admin AI settings to POST /api/v1/config at startup. + # Turn off in env-driven deployments so the environment is the single source of truth. + allow_config_push: bool = Field(default=True, validation_alias="STIRLING_ALLOW_CONFIG_PUSH") + # How often each worker polls the shared config cache; bounds how long the pool can disagree on the active model. + config_cache_poll_interval_seconds: int = Field( + default=15, + validation_alias="STIRLING_CONFIG_CACHE_POLL_INTERVAL_SECONDS", + ) + def _configure_logging(level_name: str, log_file: str, http_debug: bool) -> None: """Configure the ``stirling`` logger hierarchy.""" diff --git a/engine/src/stirling/contracts/__init__.py b/engine/src/stirling/contracts/__init__.py index 513906d610..caf41effa0 100644 --- a/engine/src/stirling/contracts/__init__.py +++ b/engine/src/stirling/contracts/__init__.py @@ -29,6 +29,13 @@ from .common import ( format_conversation_history, format_file_names, ) +from .config import ( + ConfigApplyResponse, + ConfigLimitsSection, + ConfigModelsSection, + ConfigPushRequest, + ConfigRagSection, +) from .contradiction import ( Claim, Contradiction, @@ -139,6 +146,11 @@ __all__ = [ "Claim", "CommentSpec", "CompletedExecutionAction", + "ConfigApplyResponse", + "ConfigLimitsSection", + "ConfigModelsSection", + "ConfigPushRequest", + "ConfigRagSection", "Contradiction", "ContradictionReport", "ContradictionSeverity", diff --git a/engine/src/stirling/contracts/config.py b/engine/src/stirling/contracts/config.py new file mode 100644 index 0000000000..fed75ac320 --- /dev/null +++ b/engine/src/stirling/contracts/config.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from pydantic import ConfigDict, Field + +from stirling.models import ApiModel + + +class TolerantApiModel(ApiModel): + """Push-contract base: unknown fields from a newer processor are ignored rather than + rejecting the whole push. Overrides only the extra policy; camelCase aliasing is inherited.""" + + model_config = ConfigDict(extra="ignore") + + +class ConfigModelsSection(TolerantApiModel): + """Model provider + credentials pushed by the Java processor; empty fields mean "keep the engine's env value".""" + + provider: str = "" + smart_model: str = "" + fast_model: str = "" + smart_max_tokens: int | None = Field(default=None, ge=1) + fast_max_tokens: int | None = Field(default=None, ge=1) + api_key: str = "" + base_url: str = "" + + +class ConfigRagSection(TolerantApiModel): + embedding_provider: str = "" + embedding_model: str = "" + embedding_api_key: str = "" + # OpenAI-compatible endpoint URL for ollama/custom embedding providers; empty keeps the env value. + embedding_base_url: str = "" + top_k: int | None = Field(default=None, ge=1) + # 0 is a legitimate "no retrieval searches" setting, so this floors at 0 not 1. + max_searches: int | None = Field(default=None, ge=0) + + +class ConfigLimitsSection(TolerantApiModel): + max_pages: int | None = Field(default=None, ge=1) + max_characters: int | None = Field(default=None, ge=1) + # Must be >= 1: it becomes an asyncio.Semaphore bound, and 0 constructs a permanently locked + # semaphore that hangs every model call; the push is persisted so a restart won't clear it. + model_max_concurrency: int | None = Field(default=None, ge=1) + + +class ConfigPushRequest(TolerantApiModel): + """Admin-configured AI settings pushed at processor startup.""" + + models: ConfigModelsSection = Field(default_factory=ConfigModelsSection) + rag: ConfigRagSection = Field(default_factory=ConfigRagSection) + limits: ConfigLimitsSection = Field(default_factory=ConfigLimitsSection) + + +class ConfigApplyResponse(ApiModel): + """Summary of the effective config after a push. Never echoes credentials.""" + + status: str + provider: str + smart_model: str + fast_model: str + smart_max_tokens: int + fast_max_tokens: int + rag_embedding_model: str + rag_top_k: int + rag_max_searches: int + max_pages: int + max_characters: int + model_max_concurrency: int + notes: list[str] = Field(default_factory=list) diff --git a/engine/src/stirling/contracts/pdf_create.py b/engine/src/stirling/contracts/pdf_create.py index 761a192dd1..00d5eb4091 100644 --- a/engine/src/stirling/contracts/pdf_create.py +++ b/engine/src/stirling/contracts/pdf_create.py @@ -1,14 +1,14 @@ """Contracts for the PDF Create Agent. The agent accepts a natural-language prompt and returns a single -CREATE_PDF_FROM_HTML_AGENT plan step carrying the rendered HTML. +CREATE_PDF_FROM_HTML_AGENT plan step carrying the assembled document. Pipeline: 1. PlannerAgent (smart_model) → DocumentPlan: structured skeleton, no body text. 2. Python chunks the plan by token budget. 3. SectionWriterAgents (smart_model, parallel) → WrittenSections per chunk. 4. Assembler collects sections in plan order → GeneratedDocument. - 5. Jinja renders GeneratedDocument → HTML. The LLM never writes HTML. + 5. The document is emitted as structured fields. The LLM never writes HTML. """ from __future__ import annotations @@ -81,14 +81,12 @@ type DocumentSection = Annotated[ ] -# Named colour or hex only — anything else is dropped so a colour can't inject CSS into the -# +

    diff --git a/frontend/editor/src/core/components/onboarding/OnboardingStepper.stories.tsx b/frontend/editor/src/core/components/onboarding/OnboardingStepper.stories.tsx new file mode 100644 index 0000000000..7784d7dfdf --- /dev/null +++ b/frontend/editor/src/core/components/onboarding/OnboardingStepper.stories.tsx @@ -0,0 +1,21 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { OnboardingStepper } from "@app/components/onboarding/OnboardingStepper"; + +const meta = { + title: "Onboarding/OnboardingStepper", + component: OnboardingStepper, + args: { totalSteps: 5, activeStep: 2 }, + argTypes: { + totalSteps: { control: { type: "number", min: 1, max: 10 } }, + activeStep: { control: { type: "number", min: 0, max: 9 } }, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { args: { totalSteps: 5, activeStep: 2 } }; + +export const FirstStep: Story = { args: { totalSteps: 5, activeStep: 0 } }; + +export const LastStep: Story = { args: { totalSteps: 5, activeStep: 4 } }; diff --git a/frontend/editor/src/core/components/onboarding/OnboardingTour.css b/frontend/editor/src/core/components/onboarding/OnboardingTour.css index a1cbd4f3d8..a689b9f3f4 100644 --- a/frontend/editor/src/core/components/onboarding/OnboardingTour.css +++ b/frontend/editor/src/core/components/onboarding/OnboardingTour.css @@ -13,7 +13,7 @@ box-shadow: 0 0 0 2px var(--mantine-primary-color-filled), 0 0 15px var(--mantine-primary-color-filled), - inset 0 0 15px rgba(59, 130, 246, 0.1); + inset 0 0 15px color-mix(in srgb, var(--c-primary) 10%, transparent); border-radius: 8px; } @@ -23,13 +23,13 @@ box-shadow: 0 0 0 3px var(--mantine-primary-color-filled), 0 0 20px var(--mantine-primary-color-filled), - inset 0 0 20px rgba(59, 130, 246, 0.1); + inset 0 0 20px color-mix(in srgb, var(--c-primary) 10%, transparent); } 50% { box-shadow: 0 0 0 3px var(--mantine-primary-color-filled), 0 0 30px var(--mantine-primary-color-filled), - inset 0 0 30px rgba(59, 130, 246, 0.2); + inset 0 0 30px color-mix(in srgb, var(--c-primary) 20%, transparent); } } diff --git a/frontend/editor/src/core/components/onboarding/OnboardingTour.stories.tsx b/frontend/editor/src/core/components/onboarding/OnboardingTour.stories.tsx new file mode 100644 index 0000000000..d3dedcf848 --- /dev/null +++ b/frontend/editor/src/core/components/onboarding/OnboardingTour.stories.tsx @@ -0,0 +1,69 @@ +import { useTranslation } from "react-i18next"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { StepType } from "@reactour/tour"; +import OnboardingTour from "@app/components/onboarding/OnboardingTour"; +import "@app/components/onboarding/OnboardingTour.css"; + +/** + * Selectors go unused here since the target elements don't exist on the + * canvas, so reactour just centers the popover instead of anchoring to them. + */ +const SAMPLE_STEPS: StepType[] = [ + { + selector: "body", + content: "Welcome to Stirling PDF! Let's take a quick look around.", + }, + { + selector: "body", + content: "Here you can upload and manage your files.", + }, + { + selector: "body", + content: "This is the tools panel where you apply operations to a PDF.", + }, +]; + +function TourStage(props: { + tourType?: string; + isRTL?: boolean; + dimBackground?: boolean; +}) { + const { t } = useTranslation(); + return ( + { + const isLast = currentStep === (steps?.length ?? 0) - 1; + if (isLast) return; + setCurrentStep((prev) => prev + 1); + }} + onClose={({ setIsOpen }) => setIsOpen(false)} + dimBackground={props.dimBackground} + /> + ); +} + +const meta = { + title: "Onboarding/OnboardingTour", + component: TourStage, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +/** Default tour, mask dimmed to 70% opacity. */ +export const Default: Story = { args: {} }; + +/** Admin tour uses a dark mask class instead of the default dim mask. */ +export const AdminTour: Story = { args: { tourType: "admin" } }; + +/** RTL layout swaps the "next" arrow direction. */ +export const RTL: Story = { args: { isRTL: true } }; + +/** `dimBackground={false}` keeps the page fully visible behind the popover. */ +export const NoDim: Story = { args: { dimBackground: false } }; diff --git a/frontend/editor/src/core/components/onboarding/StaticOnboardingSlide.stories.tsx b/frontend/editor/src/core/components/onboarding/StaticOnboardingSlide.stories.tsx new file mode 100644 index 0000000000..46aa79429f --- /dev/null +++ b/frontend/editor/src/core/components/onboarding/StaticOnboardingSlide.stories.tsx @@ -0,0 +1,105 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +// The shared preview only loads the portal tokens; the onboarding modal reads +// the editor theme tokens (--bg-surface, --onboarding-title, …), so load them +// here or the modal surface renders transparent over the dark overlay. +import "@app/styles/theme.css"; +import StaticOnboardingSlide from "@app/components/onboarding/StaticOnboardingSlide"; +import { DEFAULT_RUNTIME_STATE } from "@app/components/onboarding/orchestrator/onboardingConfig"; + +/** + * Renders the "interrupt" onboarding modals — slides shown outside the normal + * step flow (analytics consent, first-login password change, MFA setup, the + * external server-license notice) — each with dismissal disabled, since none + * of these can be skipped by the user. + */ +const meta = { + title: "Onboarding/Static Onboarding Slide", + component: StaticOnboardingSlide, + // Excluded from the automated (Vitest browser) test run: some slides fetch + // remote assets/endpoints (analytics, licensing) that aren't served in the + // headless scan, so they 404 and reject. Still renders in the Storybook UI + // for manual review. + tags: ["!test"], + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +/** Opt-in analytics choice, shown once after first login. */ +export const AnalyticsChoice: Story = { + args: { + slideId: "analytics-choice", + runtimeState: DEFAULT_RUNTIME_STATE, + params: { analyticsError: null, analyticsLoading: false }, + onSkip: () => {}, + onAction: () => {}, + allowDismiss: false, + }, +}; + +/** Forced password change on first login with the default credentials. */ +export const FirstLogin: Story = { + args: { + slideId: "first-login", + runtimeState: { + ...DEFAULT_RUNTIME_STATE, + requiresPasswordChange: true, + firstLoginUsername: "admin", + usingDefaultCredentials: true, + }, + params: { + firstLoginUsername: "admin", + onPasswordChanged: () => {}, + usingDefaultCredentials: true, + }, + onSkip: () => {}, + onAction: () => {}, + allowDismiss: false, + }, +}; + +/** Two-factor setup, triggered when the account requires MFA. */ +export const MfaSetup: Story = { + args: { + slideId: "mfa-setup", + runtimeState: { ...DEFAULT_RUNTIME_STATE, requiresMfaSetup: true }, + params: { onMfaSetupComplete: () => {} }, + onSkip: () => {}, + onAction: () => {}, + allowDismiss: false, + }, +}; + +/** External license notice — the back button is stripped via + * `transformButtons` since there's no prior slide to return to. */ +export const ServerLicenseNotice: Story = { + args: { + slideId: "server-license", + transformButtons: (buttons) => + buttons.filter((btn) => btn.key !== "license-back"), + runtimeState: { + ...DEFAULT_RUNTIME_STATE, + licenseNotice: { + totalUsers: 12, + freeTierLimit: 5, + isOverLimit: true, + requiresLicense: true, + }, + }, + params: { + osOptions: [], + onDownloadUrlChange: () => {}, + licenseNotice: { + totalUsers: 12, + freeTierLimit: 5, + isOverLimit: true, + requiresLicense: true, + }, + loginEnabled: true, + }, + onSkip: () => {}, + onAction: () => {}, + allowDismiss: false, + }, +}; diff --git a/frontend/editor/src/core/components/onboarding/slides/AnalyticsChoiceSlide.stories.tsx b/frontend/editor/src/core/components/onboarding/slides/AnalyticsChoiceSlide.stories.tsx new file mode 100644 index 0000000000..3a91079712 --- /dev/null +++ b/frontend/editor/src/core/components/onboarding/slides/AnalyticsChoiceSlide.stories.tsx @@ -0,0 +1,41 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import AnalyticsChoiceSlide from "@app/components/onboarding/slides/AnalyticsChoiceSlide"; + +// AnalyticsChoiceSlide is a slide-content factory (returns a SlideConfig), not +// a component, so wrap it to render its `body` the way the real onboarding +// shell does. +function AnalyticsChoiceSlideDemo({ + analyticsError = null, +}: { + analyticsError?: string | null; +}) { + const slide = AnalyticsChoiceSlide({ analyticsError }); + + return ( +
    +

    {slide.title}

    + {slide.body} +
    + ); +} + +const meta = { + title: "Onboarding/AnalyticsChoiceSlide", + component: AnalyticsChoiceSlideDemo, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { args: {} }; + +export const WithError: Story = { + args: { analyticsError: "Failed to save your analytics preference." }, +}; diff --git a/frontend/editor/src/core/components/onboarding/slides/AnimatedSlideBackground.module.css b/frontend/editor/src/core/components/onboarding/slides/AnimatedSlideBackground.module.css index e278703955..7ca0b4fd93 100644 --- a/frontend/editor/src/core/components/onboarding/slides/AnimatedSlideBackground.module.css +++ b/frontend/editor/src/core/components/onboarding/slides/AnimatedSlideBackground.module.css @@ -40,7 +40,7 @@ position: absolute; border-radius: 50%; pointer-events: none; - box-shadow: 0 18px 36px rgba(15, 23, 42, 0.12); + box-shadow: 0 18px 36px rgba(0, 0, 0, 0.12); animation-name: circleSway; animation-timing-function: ease-in-out; animation-iteration-count: infinite; diff --git a/frontend/editor/src/core/components/onboarding/slides/AnimatedSlideBackground.stories.tsx b/frontend/editor/src/core/components/onboarding/slides/AnimatedSlideBackground.stories.tsx new file mode 100644 index 0000000000..d436942893 --- /dev/null +++ b/frontend/editor/src/core/components/onboarding/slides/AnimatedSlideBackground.stories.tsx @@ -0,0 +1,44 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import AnimatedSlideBackground from "@app/components/onboarding/slides/AnimatedSlideBackground"; +import type { AnimatedCircleConfig } from "@app/types/types"; + +const circles: AnimatedCircleConfig[] = [ + { + size: 320, + color: "rgba(255, 255, 255, 0.4)", + position: "bottom-left", + blur: 40, + }, + { + size: 220, + color: "rgba(255, 255, 255, 0.3)", + position: "top-right", + opacity: 0.6, + blur: 30, + }, +]; + +const meta = { + title: "Onboarding/AnimatedSlideBackground", + component: AnimatedSlideBackground, + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + gradientStops: ["#6E56CF", "#3B82F6"], + circles, + isActive: true, + slideKey: "welcome", + }, +}; + +export const Inactive: Story = { + args: { + ...Default.args, + isActive: false, + }, +}; diff --git a/frontend/editor/src/core/components/onboarding/slides/DesktopInstallTitle.stories.tsx b/frontend/editor/src/core/components/onboarding/slides/DesktopInstallTitle.stories.tsx new file mode 100644 index 0000000000..8618158336 --- /dev/null +++ b/frontend/editor/src/core/components/onboarding/slides/DesktopInstallTitle.stories.tsx @@ -0,0 +1,39 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { + DesktopInstallTitle, + type OSOption, +} from "@app/components/onboarding/slides/DesktopInstallTitle"; + +const OS_OPTIONS: OSOption[] = [ + { label: "macOS (Apple Silicon)", url: "#mac-arm", value: "mac-arm" }, + { label: "macOS (Intel)", url: "#mac-intel", value: "mac-intel" }, + { label: "Windows", url: "#windows", value: "windows" }, + { label: "Linux", url: "#linux", value: "linux" }, +]; + +const meta = { + title: "Onboarding/Slides/DesktopInstallTitle", + component: DesktopInstallTitle, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** Multiple OS options: title plus a dropdown to switch the download target. */ +export const Default: Story = { + args: { + osLabel: "macOS (Apple Silicon)", + osUrl: "#mac-arm", + osOptions: OS_OPTIONS, + onDownloadUrlChange: () => {}, + }, +}; + +/** A single detected OS collapses to plain text — no dropdown affordance. */ +export const SingleOption: Story = { + args: { + osLabel: "Windows", + osUrl: "#windows", + osOptions: [{ label: "Windows", url: "#windows", value: "windows" }], + onDownloadUrlChange: () => {}, + }, +}; diff --git a/frontend/editor/src/core/components/onboarding/slides/DesktopInstallTitle.tsx b/frontend/editor/src/core/components/onboarding/slides/DesktopInstallTitle.tsx index efb95b95bb..b76eff2c30 100644 --- a/frontend/editor/src/core/components/onboarding/slides/DesktopInstallTitle.tsx +++ b/frontend/editor/src/core/components/onboarding/slides/DesktopInstallTitle.tsx @@ -129,9 +129,9 @@ export const DesktopInstallTitle: React.FC = ({ leftSection={iconKey ? : undefined} style={{ backgroundColor: isSelected - ? "var(--bg-muted, #f1f5f9)" + ? "var(--c-surface-sunken, #f1f5f9)" : "transparent", - color: "var(--onboarding-title, #0f172a)", + color: "var(--c-text, #0f172a)", fontWeight: isSelected ? 600 : 500, }} > diff --git a/frontend/editor/src/core/components/onboarding/slides/FirstLoginSlide.stories.tsx b/frontend/editor/src/core/components/onboarding/slides/FirstLoginSlide.stories.tsx new file mode 100644 index 0000000000..14b9b4009b --- /dev/null +++ b/frontend/editor/src/core/components/onboarding/slides/FirstLoginSlide.stories.tsx @@ -0,0 +1,47 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import FirstLoginSlide from "@app/components/onboarding/slides/FirstLoginSlide"; + +// FirstLoginSlide is a slide-content factory (returns a SlideConfig), not a +// component, so wrap it to render its `body` the way the real onboarding shell +// does. +function FirstLoginSlideDemo({ + username = "admin", + usingDefaultCredentials = false, +}: { + username?: string; + usingDefaultCredentials?: boolean; +}) { + const slide = FirstLoginSlide({ + username, + onPasswordChanged: () => {}, + usingDefaultCredentials, + }); + + return ( +
    +

    {slide.title}

    + {slide.body} +
    + ); +} + +const meta = { + title: "Onboarding/FirstLoginSlide", + component: FirstLoginSlideDemo, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { args: { username: "admin" } }; + +export const UsingDefaultCredentials: Story = { + args: { username: "admin", usingDefaultCredentials: true }, +}; diff --git a/frontend/editor/src/core/components/onboarding/slides/FirstLoginSlide.tsx b/frontend/editor/src/core/components/onboarding/slides/FirstLoginSlide.tsx index 89de08e6e7..d684592a4a 100644 --- a/frontend/editor/src/core/components/onboarding/slides/FirstLoginSlide.tsx +++ b/frontend/editor/src/core/components/onboarding/slides/FirstLoginSlide.tsx @@ -125,7 +125,7 @@ function FirstLoginForm({ icon="info-rounded" width={20} height={20} - style={{ color: "#3B82F6", flexShrink: 0 }} + style={{ color: "var(--c-primary)", flexShrink: 0 }} /> {t( diff --git a/frontend/editor/src/core/components/onboarding/slides/PlanOverviewSlide.stories.tsx b/frontend/editor/src/core/components/onboarding/slides/PlanOverviewSlide.stories.tsx new file mode 100644 index 0000000000..2aafbaed68 --- /dev/null +++ b/frontend/editor/src/core/components/onboarding/slides/PlanOverviewSlide.stories.tsx @@ -0,0 +1,60 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import PlanOverviewSlide from "@app/components/onboarding/slides/PlanOverviewSlide"; +import type { LicenseNotice } from "@app/types/types"; + +interface PlanOverviewStageProps { + isAdmin: boolean; + licenseNotice?: LicenseNotice; + loginEnabled?: boolean; +} + +// PlanOverviewSlide returns a SlideConfig (title/body nodes plus background +// config) rather than JSX, so this stage has to render those pieces itself. +function PlanOverviewStage({ + isAdmin, + licenseNotice, + loginEnabled, +}: PlanOverviewStageProps) { + const slide = PlanOverviewSlide({ isAdmin, licenseNotice, loginEnabled }); + return ( +
    +

    {slide.title}

    +
    {slide.body}
    +
    + ); +} + +const meta = { + title: "Onboarding/Slides/PlanOverviewSlide", + component: PlanOverviewStage, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +/** Regular user overview — no admin controls, no free-tier notice. */ +export const Default: Story = { + args: { isAdmin: false }, +}; + +/** Admin overview with login mode already enabled. */ +export const AdminLoginEnabled: Story = { + args: { + isAdmin: true, + loginEnabled: true, + licenseNotice: { + totalUsers: 3, + freeTierLimit: 5, + isOverLimit: false, + requiresLicense: false, + }, + }, +}; + +/** Admin overview before login mode is enabled — different body copy. */ +export const AdminLoginDisabled: Story = { + args: { + isAdmin: true, + loginEnabled: false, + }, +}; diff --git a/frontend/editor/src/core/components/onboarding/slides/ProcessorIntroSlide.stories.tsx b/frontend/editor/src/core/components/onboarding/slides/ProcessorIntroSlide.stories.tsx new file mode 100644 index 0000000000..b5e6ed4659 --- /dev/null +++ b/frontend/editor/src/core/components/onboarding/slides/ProcessorIntroSlide.stories.tsx @@ -0,0 +1,33 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ProcessorIntroSlide from "@app/components/onboarding/slides/ProcessorIntroSlide"; + +// ProcessorIntroSlide is a slide-content factory (returns a SlideConfig), not +// a component, so wrap it to render its `title`/`body` the way the real +// onboarding shell does. +function ProcessorIntroSlideDemo() { + const slide = ProcessorIntroSlide(); + + return ( +
    +

    {slide.title}

    +
    {slide.body}
    +
    + ); +} + +const meta = { + title: "Onboarding/ProcessorIntroSlide", + component: ProcessorIntroSlideDemo, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { args: {} }; diff --git a/frontend/editor/src/core/components/onboarding/slides/SecurityCheckSlide.stories.tsx b/frontend/editor/src/core/components/onboarding/slides/SecurityCheckSlide.stories.tsx new file mode 100644 index 0000000000..73981e4bfb --- /dev/null +++ b/frontend/editor/src/core/components/onboarding/slides/SecurityCheckSlide.stories.tsx @@ -0,0 +1,46 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import SecurityCheckSlide from "@app/components/onboarding/slides/SecurityCheckSlide"; + +// SecurityCheckSlide is a slide-content factory (returns a SlideConfig), not a +// component, so wrap it to render its `body` the way the real onboarding shell +// does, with local state standing in for the modal's role selection state. +function SecurityCheckSlideDemo({ + initialRole = null, +}: { + initialRole?: "admin" | "user" | null; +}) { + const [selectedRole, setSelectedRole] = useState(initialRole); + const slide = SecurityCheckSlide({ + selectedRole, + onRoleSelect: setSelectedRole, + }); + + return ( +
    +

    {slide.title}

    + {slide.body} +
    + ); +} + +const meta = { + title: "Onboarding/SecurityCheckSlide", + component: SecurityCheckSlideDemo, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { args: {} }; + +export const AdminSelected: Story = { args: { initialRole: "admin" } }; + +export const UserSelected: Story = { args: { initialRole: "user" } }; diff --git a/frontend/editor/src/core/components/onboarding/slides/SecurityCheckSlide.tsx b/frontend/editor/src/core/components/onboarding/slides/SecurityCheckSlide.tsx index 02b3e8502c..6101605772 100644 --- a/frontend/editor/src/core/components/onboarding/slides/SecurityCheckSlide.tsx +++ b/frontend/editor/src/core/components/onboarding/slides/SecurityCheckSlide.tsx @@ -26,7 +26,7 @@ export default function SecurityCheckSlide({ icon="error" width={20} height={20} - style={{ color: "#F04438", flexShrink: 0 }} + style={{ color: "var(--c-danger)", flexShrink: 0 }} /> {i18n.t( diff --git a/frontend/editor/src/core/components/onboarding/slides/ServerLicenseSlide.stories.tsx b/frontend/editor/src/core/components/onboarding/slides/ServerLicenseSlide.stories.tsx new file mode 100644 index 0000000000..fae21c8748 --- /dev/null +++ b/frontend/editor/src/core/components/onboarding/slides/ServerLicenseSlide.stories.tsx @@ -0,0 +1,64 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ServerLicenseSlide from "@app/components/onboarding/slides/ServerLicenseSlide"; +import type { LicenseNotice } from "@app/types/types"; + +interface ServerLicenseStageProps { + licenseNotice?: LicenseNotice; +} + +// ServerLicenseSlide is a factory that returns a SlideConfig (title/body nodes +// plus background config), not JSX itself, so this stage renders those pieces +// directly to preview the slide in isolation. +function ServerLicenseStage({ licenseNotice }: ServerLicenseStageProps) { + const slide = ServerLicenseSlide({ licenseNotice }); + return ( +
    +

    {slide.title}

    +
    {slide.body}
    +
    + ); +} + +const meta = { + title: "Onboarding/Slides/ServerLicenseSlide", + component: ServerLicenseStage, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +/** Default free-tier notice — under the limit. */ +export const Default: Story = { + args: {}, +}; + +/** Under the free-tier limit with a known user count. */ +export const UnderLimit: Story = { + args: { + licenseNotice: { + totalUsers: 3, + freeTierLimit: 5, + isOverLimit: false, + requiresLicense: false, + }, + }, +}; + +/** Over the free-tier limit — prompts to upgrade with a different gradient. */ +export const OverLimit: Story = { + args: { + licenseNotice: { + totalUsers: 12, + freeTierLimit: 5, + isOverLimit: true, + requiresLicense: true, + }, + }, +}; diff --git a/frontend/editor/src/core/components/onboarding/slides/WelcomeSlide.stories.tsx b/frontend/editor/src/core/components/onboarding/slides/WelcomeSlide.stories.tsx new file mode 100644 index 0000000000..f3ae1a76d4 --- /dev/null +++ b/frontend/editor/src/core/components/onboarding/slides/WelcomeSlide.stories.tsx @@ -0,0 +1,33 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import WelcomeSlide from "@app/components/onboarding/slides/WelcomeSlide"; + +// WelcomeSlide is a slide-content factory (returns a SlideConfig), not +// a component, so wrap it to render its `title`/`body` the way the real +// onboarding shell does. +function WelcomeSlideDemo() { + const slide = WelcomeSlide(); + + return ( +
    +

    {slide.title}

    +
    {slide.body}
    +
    + ); +} + +const meta = { + title: "Onboarding/WelcomeSlide", + component: WelcomeSlideDemo, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { args: {} }; diff --git a/frontend/editor/src/core/components/pageEditor/DragDropGrid.module.css b/frontend/editor/src/core/components/pageEditor/DragDropGrid.module.css index afad117774..519ec608e0 100644 --- a/frontend/editor/src/core/components/pageEditor/DragDropGrid.module.css +++ b/frontend/editor/src/core/components/pageEditor/DragDropGrid.module.css @@ -28,15 +28,15 @@ .selectionBox { position: absolute; - border: 2px dashed #3b82f6; - background-color: rgba(59, 130, 246, 0.1); + border: 2px dashed var(--c-primary); + background-color: color-mix(in srgb, var(--c-primary) 10%, transparent); pointer-events: none; } .dropIndicator { position: absolute; width: 4px; - background-color: rgba(96, 165, 250, 0.8); + background-color: color-mix(in srgb, var(--c-primary) 80%, transparent); border-radius: 2px; pointer-events: none; } @@ -50,8 +50,8 @@ position: absolute; top: -8px; right: -8px; - background-color: #3b82f6; - color: #ffffff; + background-color: var(--c-primary); + color: var(--c-text-on-primary); border-radius: 50%; width: 32px; height: 32px; diff --git a/frontend/editor/src/core/components/pageEditor/DragDropGrid.stories.tsx b/frontend/editor/src/core/components/pageEditor/DragDropGrid.stories.tsx new file mode 100644 index 0000000000..be7616f1f9 --- /dev/null +++ b/frontend/editor/src/core/components/pageEditor/DragDropGrid.stories.tsx @@ -0,0 +1,112 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import DragDropGrid from "@app/components/pageEditor/DragDropGrid"; + +interface MockGridItem { + id: string; + pageNumber?: number; + originalFileId?: string; +} + +const buildItems = (count: number): MockGridItem[] => + Array.from({ length: count }, (_, index) => ({ + id: `page-${index + 1}`, + pageNumber: index + 1, + originalFileId: "file-1", + })); + +const renderItem = ( + item: MockGridItem, + index: number, + refs: React.MutableRefObject>, + boxSelectedIds: string[], + clearBoxSelection: () => void, + activeDragIds: string[], + justMoved: boolean, + dragHandleProps?: any, + zoomLevel?: number, +) => { + const { ref: dndRef, ...restDragProps } = dragHandleProps ?? {}; + const isBoxSelected = boxSelectedIds.includes(item.id); + const isDragging = activeDragIds.includes(item.id); + + return ( +
    { + if (element) { + refs.current.set(item.id, element); + } else { + refs.current.delete(item.id); + } + dndRef?.(element); + }} + {...restDragProps} + onClick={clearBoxSelection} + style={{ + width: `calc(10rem * ${zoomLevel ?? 1})`, + height: `calc(13rem * ${zoomLevel ?? 1})`, + display: "flex", + alignItems: "center", + justifyContent: "center", + borderRadius: "0.5rem", + border: isBoxSelected + ? "2px solid var(--mantine-color-blue-6)" + : "1px solid var(--mantine-color-gray-4)", + background: isDragging + ? "var(--mantine-color-gray-1)" + : "var(--mantine-color-body)", + opacity: justMoved ? 0.7 : 1, + cursor: "grab", + }} + > + Page {item.pageNumber ?? index + 1} +
    + ); +}; + +const noopReorder = () => {}; + +const meta = { + title: "PageEditor/DragDropGrid", + component: DragDropGrid, + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +const ScrollDecorator = (StoryComponent: React.ComponentType) => ( +
    + +
    +); + +export const Default: Story = { + args: { + items: buildItems(8), + onReorderPages: noopReorder, + renderItem, + }, + decorators: [ScrollDecorator], +}; + +export const Empty: Story = { + args: { + items: [], + onReorderPages: noopReorder, + renderItem, + }, + decorators: [ScrollDecorator], +}; + +export const Zoomed: Story = { + args: { + items: buildItems(6), + onReorderPages: noopReorder, + renderItem, + zoomLevel: 1.5, + }, + decorators: [ScrollDecorator], +}; diff --git a/frontend/editor/src/core/components/pageEditor/FileThumbnail.tsx b/frontend/editor/src/core/components/pageEditor/FileThumbnail.tsx index 472fc141d7..f3730a9399 100644 --- a/frontend/editor/src/core/components/pageEditor/FileThumbnail.tsx +++ b/frontend/editor/src/core/components/pageEditor/FileThumbnail.tsx @@ -241,7 +241,7 @@ const FileThumbnail = ({ onToggleFile(file.id)} - color="var(--checkbox-checked-bg)" + color="var(--c-primary)" /> ) : (
    @@ -372,7 +372,7 @@ const FileThumbnail = ({ objectFit: "contain", borderRadius: 0, background: "#ffffff", - border: "1px solid var(--border-default)", + border: "1px solid var(--c-border)", display: "block", marginLeft: "auto", marginRight: "auto", diff --git a/frontend/editor/src/core/components/pageEditor/PageEditor.module.css b/frontend/editor/src/core/components/pageEditor/PageEditor.module.css index 3be018ffa2..fe8660a04a 100644 --- a/frontend/editor/src/core/components/pageEditor/PageEditor.module.css +++ b/frontend/editor/src/core/components/pageEditor/PageEditor.module.css @@ -21,13 +21,13 @@ @keyframes pageMovedHighlight { 0% { - background-color: rgba(59, 130, 246, 0.32); + background-color: color-mix(in srgb, var(--c-primary) 32%, transparent); } 60% { - background-color: rgba(59, 130, 246, 0.12); + background-color: color-mix(in srgb, var(--c-primary) 12%, transparent); } 100% { - background-color: rgba(59, 130, 246, 0); + background-color: color-mix(in srgb, var(--c-primary) 0%, transparent); } } @@ -63,7 +63,7 @@ /* Action styles */ .actionRow:hover { - background: var(--hover-bg); + background: var(--c-hover); } .actionDanger { @@ -72,7 +72,7 @@ .actionsDivider { height: 1px; - background: var(--border-default); + background: var(--c-border); margin: 4px 0; } @@ -86,7 +86,7 @@ .unsupportedPill { margin-left: 1.75rem; - background: #6b7280; + background: var(--c-text-subtle); color: white; padding: 4px 8px; border-radius: 12px; diff --git a/frontend/editor/src/core/components/pageEditor/PageEditorControls.stories.tsx b/frontend/editor/src/core/components/pageEditor/PageEditorControls.stories.tsx new file mode 100644 index 0000000000..5838a6c7d4 --- /dev/null +++ b/frontend/editor/src/core/components/pageEditor/PageEditorControls.stories.tsx @@ -0,0 +1,57 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import PageEditorControls from "@app/components/pageEditor/PageEditorControls"; + +const meta = { + title: "PageEditor/PageEditorControls", + component: PageEditorControls, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +const baseArgs = { + onClosePdf: () => {}, + onUndo: () => {}, + onRedo: () => {}, + canUndo: true, + canRedo: true, + onRotate: () => {}, + onDelete: () => {}, + onSplit: () => {}, + onSplitAll: () => {}, + onPageBreak: () => {}, + onPageBreakAll: () => {}, + onExportAll: () => {}, + exportLoading: false, + selectionMode: true, + selectedPageIds: ["page-1", "page-2"], + displayDocument: { + pages: [ + { id: "page-1", pageNumber: 1 }, + { id: "page-2", pageNumber: 2 }, + { id: "page-3", pageNumber: 3 }, + ], + }, + splitPositions: new Set(), + totalPages: 3, +}; + +export const Default: Story = { + args: baseArgs, +}; + +export const NoSelection: Story = { + args: { + ...baseArgs, + selectedPageIds: [], + canUndo: false, + canRedo: false, + }, +}; + +export const WithExistingSplits: Story = { + args: { + ...baseArgs, + splitPositions: new Set(["page-1", "page-2"]), + }, +}; diff --git a/frontend/editor/src/core/components/pageEditor/PageEditorControls.tsx b/frontend/editor/src/core/components/pageEditor/PageEditorControls.tsx index 96762a9203..fe73984fef 100644 --- a/frontend/editor/src/core/components/pageEditor/PageEditorControls.tsx +++ b/frontend/editor/src/core/components/pageEditor/PageEditorControls.tsx @@ -121,8 +121,8 @@ const PageEditorControls = ({ borderBottomLeftRadius: 0, borderBottomRightRadius: 0, boxShadow: "0 -2px 8px rgba(0,0,0,0.04)", - backgroundColor: "var(--bg-toolbar)", - border: "1px solid var(--border-default)", + backgroundColor: "var(--c-bg-raised)", + border: "1px solid var(--c-border)", borderRadius: "16px 16px 0 0", pointerEvents: "auto", minWidth: 360, diff --git a/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/AdvancedSelectionPanel.stories.tsx b/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/AdvancedSelectionPanel.stories.tsx new file mode 100644 index 0000000000..5f3f1138d5 --- /dev/null +++ b/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/AdvancedSelectionPanel.stories.tsx @@ -0,0 +1,40 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import AdvancedSelectionPanel from "@app/components/pageEditor/bulkSelectionPanel/AdvancedSelectionPanel"; + +const meta = { + title: "PageEditor/BulkSelectionPanel/AdvancedSelectionPanel", + component: AdvancedSelectionPanel, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + csvInput: "", + setCsvInput: () => {}, + onUpdatePagesFromCSV: () => {}, + maxPages: 20, + advancedOpened: true, + }, +}; + +export const WithExpression: Story = { + args: { + csvInput: "1-5, odd", + setCsvInput: () => {}, + onUpdatePagesFromCSV: () => {}, + maxPages: 20, + advancedOpened: true, + }, +}; + +export const Closed: Story = { + args: { + csvInput: "", + setCsvInput: () => {}, + onUpdatePagesFromCSV: () => {}, + maxPages: 20, + advancedOpened: false, + }, +}; diff --git a/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css b/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css index f80bebaa26..1ecd05808b 100644 --- a/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css +++ b/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css @@ -31,7 +31,7 @@ .rightCol { width: 8rem; - border-left: 0.0625rem solid var(--border-default); + border-left: 0.0625rem solid var(--c-border); padding-left: 0.75rem; display: flex; flex-direction: column; @@ -47,15 +47,15 @@ width: 100%; border-radius: 1.25rem; border: 0.0625rem solid var(--bulk-card-border); - background-color: var(--bulk-card-bg); - color: var(--text-primary); + background-color: var(--c-surface); + color: var(--c-text); transition: all 0.2s ease; min-height: 2rem; } .operatorChip:hover:not(:disabled) { border-color: var(--bulk-card-hover-border); - background-color: var(--hover-bg); + background-color: var(--c-hover); transform: translateY(-0.0625rem); box-shadow: var(--shadow-sm); } @@ -70,24 +70,12 @@ cursor: not-allowed; } -:global([data-mantine-color-scheme="dark"]) .operatorChip { - background-color: var(--bulk-card-bg); - border-color: var(--bulk-card-border); - color: var(--text-primary); -} - -:global([data-mantine-color-scheme="dark"]) .operatorChip:hover:not(:disabled) { - background-color: var(--hover-bg); - border-color: var(--bulk-card-hover-border); - color: var(--text-primary); -} - .dropdownHeader { display: flex; justify-content: space-between; align-items: center; padding: 0.75rem; - border-bottom: 0.0625rem solid var(--border-default); + border-bottom: 0.0625rem solid var(--c-border); margin-bottom: 0.5rem; } @@ -111,7 +99,7 @@ } .chevron { - color: var(--text-muted); + color: var(--c-text-subtle); } /* Icon-based chevrons */ @@ -151,13 +139,13 @@ .selectedList { max-height: 8rem; overflow: auto; - background-color: var(--bg-raised); - border: 0.0625rem solid var(--border-default); + background-color: var(--c-surface-raised); + border: 0.0625rem solid var(--c-border); border-radius: 0.75rem; padding: 0.5rem 0.75rem; margin-top: 0.5rem; min-width: 24rem; - color: var(--text-primary); + color: var(--c-text); } .selectedText { @@ -175,7 +163,7 @@ justify-content: space-between; align-items: center; padding: 0.75rem; - border-bottom: 0.0625rem solid var(--border-default); + border-bottom: 0.0625rem solid var(--c-border); margin-bottom: 0.5rem; } @@ -193,26 +181,18 @@ } .advancedItem:hover { - background-color: var(--hover-bg); -} - -:global([data-mantine-color-scheme="dark"]) .advancedItem:hover { - background-color: var(--hover-bg); + background-color: var(--c-hover); } .advancedCard { - background-color: var(--bulk-card-bg); + background-color: var(--c-surface); border: none; border-radius: 0.75rem; padding: 0.25rem; margin-bottom: 0.5rem; width: 100%; box-sizing: border-box; - color: var(--text-primary); -} - -:global([data-mantine-color-scheme="dark"]) .advancedCard { - background-color: var(--bulk-card-bg); + color: var(--c-text); } .inputGroup { @@ -226,24 +206,17 @@ .applyButton { min-width: 4rem; flex-shrink: 0; -} - -/* Style inputs and buttons within advanced cards to match bg-raised */ -.advancedCard :global(.mantine-NumberInput-input) { - background-color: var(--bg-raised) !important; - border-color: var(--border-default) !important; - color: var(--text-primary) !important; -} - +} /* Style inputs and buttons within advanced cards to match bg-raised */ +.advancedCard :global(.mantine-NumberInput-input), .advancedCard :global(.mantine-Button-root) { - background-color: var(--bg-raised) !important; - border-color: var(--border-default) !important; - color: var(--text-primary) !important; + background-color: var(--c-surface-raised) !important; + border-color: var(--c-border) !important; + color: var(--c-text) !important; } .advancedCard :global(.mantine-Button-root:hover) { - background-color: var(--hover-bg) !important; - border-color: var(--border-strong) !important; + background-color: var(--c-hover) !important; + border-color: var(--c-border-strong) !important; } /* Error helper text above the input */ @@ -254,8 +227,8 @@ /* Compact error container for inline tool settings */ .errorCompact { - background-color: var(--bg-raised); - border: 0.0625rem solid var(--border-default); + background-color: var(--c-surface-raised); + border: 0.0625rem solid var(--c-border); border-radius: 0.75rem; padding: 0.5rem 0.75rem; margin-top: 0.5rem; @@ -271,11 +244,6 @@ overflow: hidden; } -/* Dark-mode adjustments */ -:global([data-mantine-color-scheme="dark"]) .selectedList { - background-color: var(--bg-raised); -} - /* Small screens: allow the section to shrink instead of enforcing a large min width */ @media (max-width: 480px) { .panelGroup, @@ -290,16 +258,16 @@ .panelContainer { max-height: 95vh; overflow: auto; - background-color: var(--bulk-panel-bg); - color: var(--text-primary); + background-color: var(--c-surface); + color: var(--c-text); border-radius: 0.5rem; } /* Override Mantine Popover dropdown background */ :global(.mantine-Popover-dropdown) { - background-color: var(--bulk-panel-bg) !important; + background-color: var(--c-surface) !important; border-color: var(--bulk-card-border) !important; - color: var(--text-primary) !important; + color: var(--c-text) !important; } /* Override Mantine Switch outline */ diff --git a/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/OperatorsSection.stories.tsx b/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/OperatorsSection.stories.tsx new file mode 100644 index 0000000000..b913dbbde5 --- /dev/null +++ b/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/OperatorsSection.stories.tsx @@ -0,0 +1,23 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import OperatorsSection from "@app/components/pageEditor/bulkSelectionPanel/OperatorsSection"; + +const meta = { + title: "PageEditor/BulkSelectionPanel/OperatorsSection", + component: OperatorsSection, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + csvInput: "1,2,3", + onInsertOperator: (op) => console.log("insert operator", op), + }, +}; + +export const EmptyInput: Story = { + args: { + csvInput: "", + onInsertOperator: (op) => console.log("insert operator", op), + }, +}; diff --git a/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/OperatorsSection.tsx b/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/OperatorsSection.tsx index d2ff79ad97..682a134268 100644 --- a/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/OperatorsSection.tsx +++ b/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/OperatorsSection.tsx @@ -17,7 +17,7 @@ const OperatorsSection = ({ return (
    - + {t("bulkSelection.keywords.title", "Keywords")}: diff --git a/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/PageSelectionInput.tsx b/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/PageSelectionInput.tsx index 2e5513a7c8..f0add9d978 100644 --- a/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/PageSelectionInput.tsx +++ b/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/PageSelectionInput.tsx @@ -44,7 +44,7 @@ const PageSelectionInput = ({ icon="gpp-maybe-outline-rounded" width="1rem" height="1rem" - style={{ color: "var(--text-instruction)" }} + style={{ color: "var(--c-accent-fg)" }} /> {t("bulkSelection.pageSelection.title", "Page Selection")} @@ -53,7 +53,7 @@ const PageSelectionInput = ({ {typeof advancedOpened === "boolean" && ( - + {t("bulkSelection.advanced.title", "Advanced")} ; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + title: "Select pages", + placeholder: "Page number", + onApply: (value: number) => console.log("apply", value), + maxPages: 10, + }, +}; + +export const WithValidation: Story = { + args: { + title: "Select pages", + placeholder: "Page number", + onApply: (value: number) => console.log("apply", value), + maxPages: 10, + validationFn: (value: number) => + value > 10 ? "Page number exceeds document length" : null, + }, +}; + +export const Range: Story = { + args: { + title: "Select page range", + placeholder: "Start page", + onApply: (value: number) => console.log("apply", value), + maxPages: 10, + isRange: true, + rangeEndValue: 5, + onRangeEndChange: (value: string | number) => + console.log("range end", value), + rangeEndPlaceholder: "End page", + }, +}; diff --git a/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/SelectPages.tsx b/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/SelectPages.tsx index 5fc7341430..d2daa6499c 100644 --- a/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/SelectPages.tsx +++ b/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/SelectPages.tsx @@ -50,7 +50,7 @@ const SelectPages = ({ return (
    - + {title} {error && ( diff --git a/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/SelectedPagesDisplay.stories.tsx b/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/SelectedPagesDisplay.stories.tsx new file mode 100644 index 0000000000..3b5d8421d1 --- /dev/null +++ b/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/SelectedPagesDisplay.stories.tsx @@ -0,0 +1,34 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import SelectedPagesDisplay from "@app/components/pageEditor/bulkSelectionPanel/SelectedPagesDisplay"; + +const displayDocument = { + pages: [ + { id: "page-1", pageNumber: 1 }, + { id: "page-2", pageNumber: 2 }, + { id: "page-3", pageNumber: 3 }, + { id: "page-4", pageNumber: 4 }, + ], +}; + +const meta = { + title: "PageEditor/SelectedPagesDisplay", + component: SelectedPagesDisplay, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + selectedPageIds: ["page-1", "page-3"], + displayDocument, + syntaxError: null, + }, +}; + +export const SyntaxError: Story = { + args: { + selectedPageIds: ["page-1", "page-3"], + displayDocument, + syntaxError: "Invalid page range: 1-abc", + }, +}; diff --git a/frontend/editor/src/core/components/policies/policyCategoryIcon.tsx b/frontend/editor/src/core/components/policies/policyCategoryIcon.tsx new file mode 100644 index 0000000000..bf50c76037 --- /dev/null +++ b/frontend/editor/src/core/components/policies/policyCategoryIcon.tsx @@ -0,0 +1,36 @@ +// Shared source of truth for a policy category's outline icon, keyed by category +// id (not a parallel icon-name vocabulary). Used by the editor's policy +// definitions and the portal's catalogue cards, summaries, and setup wizard. + +import type { ReactNode } from "react"; +import type { SxProps, Theme } from "@mui/material"; +import LayersOutlinedIcon from "@mui/icons-material/LayersOutlined"; +import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined"; +import LabelOutlinedIcon from "@mui/icons-material/LabelOutlined"; +import CheckCircleOutlinedIcon from "@mui/icons-material/CheckCircleOutlined"; +import AltRouteOutlinedIcon from "@mui/icons-material/AltRouteOutlined"; +import ScheduleOutlinedIcon from "@mui/icons-material/ScheduleOutlined"; + +type MuiIcon = React.ComponentType<{ sx?: SxProps; className?: string }>; + +/** Policy category id → outline glyph. */ +const POLICY_CATEGORY_ICONS: Record = { + ingestion: LayersOutlinedIcon, + security: ShieldOutlinedIcon, + classification: LabelOutlinedIcon, + compliance: CheckCircleOutlinedIcon, + routing: AltRouteOutlinedIcon, + retention: ScheduleOutlinedIcon, +}; + +const FALLBACK_ICON = LabelOutlinedIcon; + +// Defaults to inheriting the surrounding font-size so a wrapping box controls size. +export function policyCategoryIcon( + categoryId: string, + sx: SxProps = { fontSize: "inherit" }, + className?: string, +): ReactNode { + const Icon = POLICY_CATEGORY_ICONS[categoryId] ?? FALLBACK_ICON; + return ; +} diff --git a/frontend/editor/src/core/components/shared/AllToolsNavButton.stories.tsx b/frontend/editor/src/core/components/shared/AllToolsNavButton.stories.tsx new file mode 100644 index 0000000000..a4912a750c --- /dev/null +++ b/frontend/editor/src/core/components/shared/AllToolsNavButton.stories.tsx @@ -0,0 +1,63 @@ +import { useState } from "react"; +import type { ReactElement } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import AllToolsNavButton from "@app/components/shared/AllToolsNavButton"; +import { PreferencesProvider } from "@app/contexts/PreferencesContext"; +import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider"; +import { NavigationProvider } from "@app/contexts/NavigationContext"; +import { ToolWorkflowProvider } from "@app/contexts/ToolWorkflowContext"; + +/** + * The button reads/writes tool selection and panel state via ToolWorkflowContext, + * and derives the home link href from NavigationContext plus the tool registry — + * all four providers must be present for it to render. + */ +function withProviders(Story: () => ReactElement) { + return ( + + + + + + + + + + ); +} + +const meta = { + title: "Shared/AllToolsNavButton", + component: AllToolsNavButton, + decorators: [withProviders], + args: { + activeButton: "tools", + setActiveButton: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +/** Highlighted when it is the active quick-access button. */ +export const Default: Story = { + args: { + activeButton: "tools", + setActiveButton: () => {}, + }, +}; + +function InactiveDemo() { + const [activeButton, setActiveButton] = useState("home"); + return ( + + ); +} + +/** Not the active button — a different quick-access item is selected. */ +export const Inactive: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/shared/AppConfigModal.css b/frontend/editor/src/core/components/shared/AppConfigModal.css index 3f979258fb..46033eaf97 100644 --- a/frontend/editor/src/core/components/shared/AppConfigModal.css +++ b/frontend/editor/src/core/components/shared/AppConfigModal.css @@ -63,7 +63,8 @@ } .modal-nav-scroll:hover { - scrollbar-color: rgba(128, 128, 128, 0.5) transparent; + scrollbar-color: color-mix(in srgb, var(--c-border-strong) 50%, transparent) + transparent; } .modal-nav-scroll::-webkit-scrollbar { @@ -81,11 +82,11 @@ } .modal-nav-scroll:hover::-webkit-scrollbar-thumb { - background-color: rgba(128, 128, 128, 0.5); + background-color: color-mix(in srgb, var(--c-border-strong) 50%, transparent); } .modal-nav-scroll::-webkit-scrollbar-thumb:hover { - background-color: rgba(128, 128, 128, 0.7); + background-color: color-mix(in srgb, var(--c-border-strong) 70%, transparent); } .modal-nav-section { @@ -131,7 +132,8 @@ } .modal-content-scroll:hover { - scrollbar-color: rgba(128, 128, 128, 0.5) transparent; + scrollbar-color: color-mix(in srgb, var(--c-border-strong) 50%, transparent) + transparent; } .modal-content-scroll::-webkit-scrollbar { @@ -149,11 +151,11 @@ } .modal-content-scroll:hover::-webkit-scrollbar-thumb { - background-color: rgba(128, 128, 128, 0.5); + background-color: color-mix(in srgb, var(--c-border-strong) 50%, transparent); } .modal-content-scroll::-webkit-scrollbar-thumb:hover { - background-color: rgba(128, 128, 128, 0.7); + background-color: color-mix(in srgb, var(--c-border-strong) 70%, transparent); } .modal-header { @@ -212,8 +214,8 @@ bottom: 0; left: 0; right: 0; - background: var(--modal-content-bg); - border-top: 1px solid var(--modal-header-border); + background: var(--c-surface); + border-top: 1px solid var(--c-border-subtle); padding: 1rem 2rem; margin: 0 -2rem; margin-bottom: -1rem; diff --git a/frontend/editor/src/core/components/shared/AppConfigModal.tsx b/frontend/editor/src/core/components/shared/AppConfigModal.tsx index 83f7342a74..9ad7963b6f 100644 --- a/frontend/editor/src/core/components/shared/AppConfigModal.tsx +++ b/frontend/editor/src/core/components/shared/AppConfigModal.tsx @@ -165,13 +165,13 @@ const AppConfigModalInner: React.FC = ({ const colors = useMemo( () => ({ - navBg: "var(--modal-nav-bg)", - sectionTitle: "var(--modal-nav-section-title)", + navBg: "var(--c-bg-raised)", + sectionTitle: "var(--c-text-subtle)", navItem: "var(--modal-nav-item)", - navItemActive: "var(--modal-nav-item-active)", - navItemActiveBg: "var(--modal-nav-item-active-bg)", - contentBg: "var(--modal-content-bg)", - headerBorder: "var(--modal-header-border)", + navItemActive: "var(--c-accent-fg)", + navItemActiveBg: "var(--c-primary-subtle)", + contentBg: "var(--c-surface)", + headerBorder: "var(--c-border-subtle)", }), [], ); @@ -216,6 +216,7 @@ const AppConfigModalInner: React.FC = ({ runningEE, loginEnabled, handleCloseSync, + config?.showSettingsWhenNoLogin ?? true, ); const configNavSections = useMemo( () => diff --git a/frontend/editor/src/core/components/shared/AppSwitch.css b/frontend/editor/src/core/components/shared/AppSwitch.css index c40080b1e9..bccf8fbceb 100644 --- a/frontend/editor/src/core/components/shared/AppSwitch.css +++ b/frontend/editor/src/core/components/shared/AppSwitch.css @@ -9,15 +9,15 @@ background: none; cursor: pointer; border-radius: var(--radius-sm); - color: var(--color-text-4); + color: var(--c-text-subtle); transition: background var(--motion-fast), color var(--motion-fast); } .app-switch-btn:hover { - background: var(--color-bg-hover); - color: var(--color-text-2); + background: var(--c-hover); + color: var(--c-text-muted); } .app-switch-icon { diff --git a/frontend/editor/src/core/components/shared/AppSwitch.stories.tsx b/frontend/editor/src/core/components/shared/AppSwitch.stories.tsx new file mode 100644 index 0000000000..6d4449fe58 --- /dev/null +++ b/frontend/editor/src/core/components/shared/AppSwitch.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { AppSwitch } from "@app/components/shared/AppSwitch"; + +/** The editor ⇄ processor app switcher rendered by both the editor and portal sidebars. */ +const meta: Meta = { + title: "Shared/AppSwitch", + component: AppSwitch, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +export const Editor: Story = { + args: { + current: "editor", + theme: "light", + onSwitch: () => {}, + }, +}; + +export const Processor: Story = { + args: { + current: "processor", + theme: "light", + onSwitch: () => {}, + }, +}; + +export const DarkTheme: Story = { + args: { + current: "editor", + theme: "dark", + onSwitch: () => {}, + }, +}; diff --git a/frontend/editor/src/core/components/shared/Badge.tsx b/frontend/editor/src/core/components/shared/Badge.tsx index dd15bec465..04ca973a8e 100644 --- a/frontend/editor/src/core/components/shared/Badge.tsx +++ b/frontend/editor/src/core/components/shared/Badge.tsx @@ -79,8 +79,8 @@ const Badge: React.FC = ({ // Default styling return { - background: "var(--tool-header-badge-bg)", - color: "var(--tool-header-badge-text)", + background: "var(--c-surface-raised)", + color: "var(--c-accent-fg)", }; }; diff --git a/frontend/editor/src/core/components/shared/BulkShareModal.stories.tsx b/frontend/editor/src/core/components/shared/BulkShareModal.stories.tsx new file mode 100644 index 0000000000..cfa1f223ee --- /dev/null +++ b/frontend/editor/src/core/components/shared/BulkShareModal.stories.tsx @@ -0,0 +1,75 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { ReactElement } from "react"; +import BulkShareModal from "@app/components/shared/BulkShareModal"; +import { AppConfigProvider } from "@app/contexts/AppConfigContext"; +import { FileContextProvider } from "@app/contexts/FileContext"; +import type { StirlingFileStub } from "@app/types/fileContext"; +import type { FileId } from "@app/types/file"; + +const mockFiles: StirlingFileStub[] = [ + { + id: "story-file-1" as FileId, + name: "quarterly-report.pdf", + type: "application/pdf", + size: 2_400_000, + lastModified: Date.now(), + isLeaf: true, + originalFileId: "story-file-1", + versionNumber: 1, + }, + { + id: "story-file-2" as FileId, + name: "cover-letter.pdf", + type: "application/pdf", + size: 180_000, + lastModified: Date.now(), + isLeaf: true, + originalFileId: "story-file-2", + versionNumber: 1, + }, +]; + +/** + * BulkShareModal reads useFileActions() from FileContext, which isn't part of + * the shared preview decorators, so it's provided here for every story. + * AppConfig (gating share links on `storageShareLinksEnabled`) is added + * per-story instead, since Default and LinksEnabled need different values. + */ +function withFileContext(Story: () => ReactElement) { + return ( + + + + ); +} + +const meta = { + title: "Shared/BulkShareModal", + component: BulkShareModal, + parameters: { layout: "fullscreen" }, + args: { + opened: true, + onClose: () => {}, + files: mockFiles, + }, + decorators: [withFileContext], +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** Share links disabled by server config — default when no config is loaded. */ +export const Default: Story = {}; + +/** Share links enabled — the role selector and "Generate Link" action are active. */ +export const LinksEnabled: Story = { + decorators: [ + (Story) => ( + + + + ), + ], +}; diff --git a/frontend/editor/src/core/components/shared/BulkUploadToServerModal.stories.tsx b/frontend/editor/src/core/components/shared/BulkUploadToServerModal.stories.tsx new file mode 100644 index 0000000000..0b800195f5 --- /dev/null +++ b/frontend/editor/src/core/components/shared/BulkUploadToServerModal.stories.tsx @@ -0,0 +1,67 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { ReactElement } from "react"; +import BulkUploadToServerModal from "@app/components/shared/BulkUploadToServerModal"; +import { FileContextProvider } from "@app/contexts/FileContext"; +import type { StirlingFileStub } from "@app/types/fileContext"; +import type { FileId } from "@app/types/file"; + +const mockFiles: StirlingFileStub[] = [ + { + id: "file-1" as FileId, + name: "quarterly-report.pdf", + type: "application/pdf", + size: 2_400_000, + lastModified: Date.now(), + isLeaf: true, + originalFileId: "file-1" as FileId, + versionNumber: 1, + }, + { + id: "file-2" as FileId, + name: "invoice-march.pdf", + type: "application/pdf", + size: 512_000, + lastModified: Date.now(), + isLeaf: true, + originalFileId: "file-2" as FileId, + versionNumber: 1, + }, +]; + +/** + * The modal dispatches updateStirlingFileStub on upload, so it needs + * FileContext (also supplies IndexedDBContext) mounted above it. + */ +function withProviders(Story: () => ReactElement) { + return ( + + + + ); +} + +const meta = { + title: "Shared/BulkUploadToServerModal", + component: BulkUploadToServerModal, + decorators: [withProviders], + args: { + onClose: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + opened: true, + files: mockFiles, + }, +}; + +export const SingleFile: Story = { + args: { + opened: true, + files: [mockFiles[0]], + }, +}; diff --git a/frontend/editor/src/core/components/shared/ButtonToggle.stories.tsx b/frontend/editor/src/core/components/shared/ButtonToggle.stories.tsx new file mode 100644 index 0000000000..88183305d3 --- /dev/null +++ b/frontend/editor/src/core/components/shared/ButtonToggle.stories.tsx @@ -0,0 +1,58 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { + ButtonToggle, + ButtonToggleOption, +} from "@app/components/shared/ButtonToggle"; + +const meta: Meta = { + title: "Shared/ButtonToggle", + component: ButtonToggle, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
    + +
    + ), + ], +}; +export default meta; +type Story = StoryObj; + +const options: ButtonToggleOption[] = [ + { + value: "automatic", + label: "Automatic", + description: "Detect and redact automatically", + }, + { value: "manual", label: "Manual", description: "Select regions yourself" }, +]; + +function ToggleDemo({ + disabled, + size, +}: { + disabled?: boolean; + size?: "xs" | "sm" | "md" | "lg"; +}) { + const [value, setValue] = useState("automatic"); + return ( + + ); +} + +/** Default toggle with two options, each carrying a label + description. */ +export const Default: Story = { render: () => }; + +/** Disabled state — the selected segment must still be legible. */ +export const Disabled: Story = { render: () => }; + +/** Small size variant. */ +export const Small: Story = { render: () => }; diff --git a/frontend/editor/src/core/components/shared/CloudStorageIcons.stories.tsx b/frontend/editor/src/core/components/shared/CloudStorageIcons.stories.tsx new file mode 100644 index 0000000000..9d44784233 --- /dev/null +++ b/frontend/editor/src/core/components/shared/CloudStorageIcons.stories.tsx @@ -0,0 +1,33 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { + GoogleDriveIcon, + OneDriveIcon, + DropboxIcon, +} from "@app/components/shared/CloudStorageIcons"; + +/** Cloud storage brand icons with brand-color / muted current-color variants. */ +const meta: Meta = { + title: "Shared/CloudStorageIcons", + component: GoogleDriveIcon, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +export const GoogleDrive: Story = { + args: { colored: true }, +}; + +export const GoogleDriveMuted: Story = { + args: { colored: false }, +}; + +export const OneDrive: Story = { + render: (args) => , + args: { colored: true }, +}; + +export const Dropbox: Story = { + render: (args) => , + args: { colored: true }, +}; diff --git a/frontend/editor/src/core/components/shared/DropdownListWithFooter.stories.tsx b/frontend/editor/src/core/components/shared/DropdownListWithFooter.stories.tsx new file mode 100644 index 0000000000..c52f1c9298 --- /dev/null +++ b/frontend/editor/src/core/components/shared/DropdownListWithFooter.stories.tsx @@ -0,0 +1,86 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Button } from "@mantine/core"; +import DropdownListWithFooter, { + DropdownItem, +} from "@app/components/shared/DropdownListWithFooter"; + +const meta: Meta = { + title: "Shared/DropdownListWithFooter", + component: DropdownListWithFooter, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
    + +
    + ), + ], +}; +export default meta; +type Story = StoryObj; + +const items: DropdownItem[] = [ + { value: "single", name: "Single page" }, + { value: "facing", name: "Facing pages" }, + { value: "book", name: "Book view" }, + { value: "continuous", name: "Continuous scroll", disabled: true }, +]; + +function SingleSelectDemo() { + const [value, setValue] = useState("single"); + return ( + setValue(v as string)} + /> + ); +} + +function MultiSelectDemo() { + const [value, setValue] = useState(["single"]); + return ( + setValue(v as string[])} + multiSelect + searchable + footer={ + + } + /> + ); +} + +/** Single-select dropdown with a disabled item. */ +export const Default: Story = { render: () => }; + +/** Multi-select with search box and a footer action. */ +export const MultiSelectWithFooter: Story = { + render: () => , +}; + +/** No items available — empty state message inside the dropdown. */ +export const Empty: Story = { + render: () => { + return ( + {}} + /> + ); + }, +}; diff --git a/frontend/editor/src/core/components/shared/EditableSecretField.stories.tsx b/frontend/editor/src/core/components/shared/EditableSecretField.stories.tsx new file mode 100644 index 0000000000..b49fbd2806 --- /dev/null +++ b/frontend/editor/src/core/components/shared/EditableSecretField.stories.tsx @@ -0,0 +1,61 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import EditableSecretField from "@app/components/shared/EditableSecretField"; + +const meta: Meta = { + title: "Shared/EditableSecretField", + component: EditableSecretField, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
    + +
    + ), + ], +}; +export default meta; +type Story = StoryObj; + +function SecretFieldDemo({ + initialValue = "", + ...rest +}: { + initialValue?: string; + label?: string; + description?: string; + placeholder?: string; + disabled?: boolean; + error?: string; +}) { + const [value, setValue] = useState(initialValue); + return ( + + ); +} + +/** Empty value: renders a normal password input. */ +export const Default: Story = { + render: () => , +}; + +/** Backend returned a masked value (********): shows a read-only display + Edit button. */ +export const Masked: Story = { + render: () => , +}; + +/** Disabled state — the Edit button on a masked value must still read as inert. */ +export const MaskedDisabled: Story = { + render: () => , +}; + +/** Validation error surfaced under the password input. */ +export const WithError: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/shared/EditableSecretField.tsx b/frontend/editor/src/core/components/shared/EditableSecretField.tsx index 24e6d881c3..4168996959 100644 --- a/frontend/editor/src/core/components/shared/EditableSecretField.tsx +++ b/frontend/editor/src/core/components/shared/EditableSecretField.tsx @@ -78,7 +78,11 @@ export default function EditableSecretField({ )} {description && (

    {description}

    diff --git a/frontend/editor/src/core/components/shared/EncryptedPdfUnlockModal.stories.tsx b/frontend/editor/src/core/components/shared/EncryptedPdfUnlockModal.stories.tsx new file mode 100644 index 0000000000..dd83b6eece --- /dev/null +++ b/frontend/editor/src/core/components/shared/EncryptedPdfUnlockModal.stories.tsx @@ -0,0 +1,60 @@ +import { useState, type ComponentProps } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import EncryptedPdfUnlockModal from "@app/components/shared/EncryptedPdfUnlockModal"; + +const meta = { + title: "Shared/EncryptedPdfUnlockModal", + component: EncryptedPdfUnlockModal, + args: { + opened: true, + password: "", + isProcessing: false, + remainingCount: 0, + onPasswordChange: () => {}, + onUnlock: () => {}, + onUnlockAll: () => {}, + onSkip: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function UnlockDemo( + props: Partial>, +) { + const [password, setPassword] = useState(""); + return ( + {}} + onUnlockAll={() => {}} + onSkip={() => {}} + {...props} + /> + ); +} + +export const Default: Story = { render: () => }; + +export const MultipleFilesRemaining: Story = { + render: () => , +}; + +export const IncorrectPassword: Story = { + render: () => ( + + ), +}; + +export const Processing: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/shared/ErrorBoundary.stories.tsx b/frontend/editor/src/core/components/shared/ErrorBoundary.stories.tsx new file mode 100644 index 0000000000..22a5f06feb --- /dev/null +++ b/frontend/editor/src/core/components/shared/ErrorBoundary.stories.tsx @@ -0,0 +1,44 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Text } from "@mantine/core"; +import ErrorBoundary from "@app/components/shared/ErrorBoundary"; + +const meta: Meta = { + title: "Shared/ErrorBoundary", + component: ErrorBoundary, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +function ThrowingChild(): never { + throw new Error("Simulated render error for Storybook"); +} + +/** Normal path — children render untouched when nothing throws. */ +export const Default: Story = { + args: { + children: Protected content renders normally., + }, +}; + +/** A child throwing during render is caught, showing the default fallback with a retry button. */ +export const CaughtError: Story = { + args: { + children: , + }, +}; + +/** A custom fallback component receives the error and a retry callback. */ +export const CustomFallback: Story = { + args: { + children: , + fallback: ({ error, retry }) => ( + + Custom fallback: {error?.message} + + + ), + }, +}; diff --git a/frontend/editor/src/core/components/shared/ErrorBoundary.tsx b/frontend/editor/src/core/components/shared/ErrorBoundary.tsx index a2003a7d61..b5330058c4 100644 --- a/frontend/editor/src/core/components/shared/ErrorBoundary.tsx +++ b/frontend/editor/src/core/components/shared/ErrorBoundary.tsx @@ -117,7 +117,7 @@ export default class ErrorBoundary extends React.Component< style={{ fontSize: "0.75rem", overflow: "auto", - backgroundColor: "#f5f5f5", + backgroundColor: "var(--c-surface-sunken)", padding: "1rem", borderRadius: "4px", maxHeight: "300px", diff --git a/frontend/editor/src/core/components/shared/FileCard.stories.tsx b/frontend/editor/src/core/components/shared/FileCard.stories.tsx new file mode 100644 index 0000000000..48edd5854e --- /dev/null +++ b/frontend/editor/src/core/components/shared/FileCard.stories.tsx @@ -0,0 +1,65 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import FileCard from "@app/components/shared/FileCard"; +import { FileContextProvider } from "@app/contexts/FileContext"; +import { StirlingFileStub, FileId } from "@app/types/fileContext"; + +function makeFile(name: string, type = "application/pdf"): File { + return new File(["%PDF-1.4 storybook fixture"], name, { + type, + lastModified: Date.now(), + }); +} + +function makeStub(id: string): StirlingFileStub { + return { + id: id as FileId, + name: "Annual-Report-2026.pdf", + type: "application/pdf", + size: 245_760, + lastModified: Date.now(), + isLeaf: true, + originalFileId: id, + versionNumber: 1, + }; +} + +/** FileCard reads/writes files via FileContext + IndexedDB, so it needs a real provider tree. */ +const meta = { + title: "Shared/FileCard", + component: FileCard, + parameters: { layout: "padded" }, + decorators: [ + (Story) => ( + + + + ), + ], +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + file: makeFile("Annual-Report-2026.pdf"), + fileStub: makeStub("story-file-1"), + onRemove: () => {}, + onView: () => {}, + onEdit: () => {}, + }, +}; + +export const Selected: Story = { + args: { + ...Default.args, + isSelected: true, + onSelect: () => {}, + }, +}; + +export const Unsupported: Story = { + args: { + ...Default.args, + isSupported: false, + }, +}; diff --git a/frontend/editor/src/core/components/shared/FileCard.tsx b/frontend/editor/src/core/components/shared/FileCard.tsx index c731092459..e43dbeeb1c 100644 --- a/frontend/editor/src/core/components/shared/FileCard.tsx +++ b/frontend/editor/src/core/components/shared/FileCard.tsx @@ -74,7 +74,7 @@ const FileCard = ({ diff --git a/frontend/editor/src/core/components/shared/FileDocIcon.stories.tsx b/frontend/editor/src/core/components/shared/FileDocIcon.stories.tsx new file mode 100644 index 0000000000..b42f7085a2 --- /dev/null +++ b/frontend/editor/src/core/components/shared/FileDocIcon.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FileDocIcon } from "@app/components/shared/FileDocIcon"; + +const meta = { + title: "Shared/FileDocIcon", + component: FileDocIcon, + parameters: { layout: "padded" }, + args: { variant: "pdf" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { variant: "pdf" }, +}; + +/** All file-type variants, each using its own default accent color. */ +export const AllVariants: Story = { + render: () => ( +
    + + + + + + + +
    + ), +}; + +/** Explicit `color` overrides the variant's default accent. */ +export const CustomColor: Story = { + args: { variant: "pdf", color: "#e64980" }, +}; diff --git a/frontend/editor/src/core/components/shared/FileDropdownMenu.stories.tsx b/frontend/editor/src/core/components/shared/FileDropdownMenu.stories.tsx new file mode 100644 index 0000000000..bb6b7d2670 --- /dev/null +++ b/frontend/editor/src/core/components/shared/FileDropdownMenu.stories.tsx @@ -0,0 +1,49 @@ +import type { CSSProperties } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FileDropdownMenu } from "@app/components/shared/FileDropdownMenu"; + +const viewOptionStyle: CSSProperties = { + display: "flex", + alignItems: "center", + gap: "0.25rem", + padding: "0.25rem 0.5rem", +}; + +const activeFiles = [ + { fileId: "file-1", name: "Contract-Draft-v1.pdf" }, + { fileId: "file-2", name: "Invoice-2026-04.pdf", versionNumber: 2 }, + { fileId: "file-3", name: "Scanned-Document-With-A-Very-Long-Name.pdf" }, +]; + +const meta: Meta = { + title: "Shared/FileDropdownMenu", + component: FileDropdownMenu, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + displayName: "Contract-Draft-v1.pdf", + activeFiles, + currentFileIndex: 0, + viewOptionStyle, + onFileSelect: () => {}, + onFileRemove: () => {}, + }, +}; + +export const Switching: Story = { + args: { + ...Default.args, + switchingTo: "viewer", + }, +}; + +export const NoRemove: Story = { + args: { + ...Default.args, + onFileRemove: undefined, + }, +}; diff --git a/frontend/editor/src/core/components/shared/FileDropdownMenu.tsx b/frontend/editor/src/core/components/shared/FileDropdownMenu.tsx index 735d633fee..00eb8b7c56 100644 --- a/frontend/editor/src/core/components/shared/FileDropdownMenu.tsx +++ b/frontend/editor/src/core/components/shared/FileDropdownMenu.tsx @@ -54,8 +54,8 @@ export const FileDropdownMenu: React.FC = ({ + * useIndexedDBThumbnail. That hook reads IndexedDBContext + FileContext, + * neither of which is part of the shared preview decorators, so + * FileContextProvider (which wraps IndexedDBProvider internally) is stood up + * here. + */ +function withFileContext(Story: () => ReactElement) { + return ( + + + + ); +} + +const buildFile = (name: string, size: number, type: string): File => { + return new File([new Uint8Array(size)], name, { + type, + lastModified: Date.now(), + }); +}; + +const buildRecord = ( + id: string, + overrides: Partial = {}, +): StirlingFileStub => ({ + id: id as FileId, + name: overrides.name ?? "report.pdf", + type: overrides.type ?? "application/pdf", + size: overrides.size ?? 1_240_000, + lastModified: overrides.lastModified ?? Date.now(), + isLeaf: true, + originalFileId: id, + versionNumber: 1, + // Set so useLazyThumbnail short-circuits on the stored thumbnail instead of + // trying to read file bytes out of IndexedDB. + thumbnailUrl: + "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='160'%3E%3Crect width='120' height='160' fill='%23e9ecef'/%3E%3C/svg%3E", + ...overrides, +}); + +const files = [ + { + file: buildFile("report.pdf", 1_240_000, "application/pdf"), + record: buildRecord("file-1", { name: "report.pdf" }), + }, + { + file: buildFile("invoice.pdf", 540_000, "application/pdf"), + record: buildRecord("file-2", { + name: "invoice.pdf", + size: 540_000, + }), + }, + { + file: buildFile( + "budget.xlsx", + 82_000, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ), + record: buildRecord("file-3", { + name: "budget.xlsx", + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + size: 82_000, + thumbnailUrl: undefined, + }), + }, +]; + +const meta = { + title: "Shared/FileGrid", + component: FileGrid, + decorators: [withFileContext], + args: { + files, + onRemove: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const SearchAndSort: Story = { + args: { + showSearch: true, + showSort: true, + onDeleteAll: () => {}, + }, +}; + +export const Empty: Story = { + args: { + files: [], + showSearch: true, + }, +}; diff --git a/frontend/editor/src/core/components/shared/FilePickerModal.stories.tsx b/frontend/editor/src/core/components/shared/FilePickerModal.stories.tsx new file mode 100644 index 0000000000..51eac29632 --- /dev/null +++ b/frontend/editor/src/core/components/shared/FilePickerModal.stories.tsx @@ -0,0 +1,37 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import FilePickerModal from "@app/components/shared/FilePickerModal"; + +const mockStoredFiles = [ + { id: "file-1", name: "invoice.pdf", size: 245_000, thumbnail: null }, + { + id: "file-2", + name: "contract-draft.pdf", + size: 1_240_000, + thumbnail: null, + }, + { id: "file-3", name: "scanned-form.pdf", size: 3_400_000, thumbnail: null }, +]; + +const meta = { + title: "Shared/FilePickerModal", + component: FilePickerModal, + parameters: { layout: "padded" }, + args: { + opened: true, + onClose: () => {}, + onSelectFiles: () => {}, + storedFiles: mockStoredFiles, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** Storage populated with a few files available to pick from. */ +export const Default: Story = {}; + +/** No files exist in storage yet — shows the empty-state message. */ +export const Empty: Story = { + args: { + storedFiles: [], + }, +}; diff --git a/frontend/editor/src/core/components/shared/FilePreview.stories.tsx b/frontend/editor/src/core/components/shared/FilePreview.stories.tsx new file mode 100644 index 0000000000..30e2fec884 --- /dev/null +++ b/frontend/editor/src/core/components/shared/FilePreview.stories.tsx @@ -0,0 +1,57 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import FilePreview from "@app/components/shared/FilePreview"; +import { StirlingFileStub } from "@app/types/fileContext"; +import type { FileId } from "@app/types/file"; + +const mockFile: StirlingFileStub = { + id: "file-1" as FileId, + name: "annual-report.pdf", + type: "application/pdf", + size: 245_000, + lastModified: Date.now(), + isLeaf: true, + originalFileId: "file-1", + versionNumber: 1, +}; + +const meta = { + title: "Shared/FilePreview", + component: FilePreview, + parameters: { layout: "padded" }, + decorators: [ + (Story) => ( +
    + +
    + ), + ], +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + file: mockFile, + thumbnail: null, + }, +}; + +export const Empty: Story = { + args: { + file: null, + }, +}; + +export const WithNavigation: Story = { + args: { + file: mockFile, + thumbnail: null, + showStacking: true, + showHoverOverlay: true, + showNavigation: true, + totalFiles: 3, + onFileClick: () => {}, + onPrevious: () => {}, + onNext: () => {}, + }, +}; diff --git a/frontend/editor/src/core/components/shared/FileSelectorPicker.module.css b/frontend/editor/src/core/components/shared/FileSelectorPicker.module.css index 89c57dd3fa..cb388c28aa 100644 --- a/frontend/editor/src/core/components/shared/FileSelectorPicker.module.css +++ b/frontend/editor/src/core/components/shared/FileSelectorPicker.module.css @@ -1,9 +1,9 @@ /* ── Trigger ─────────────────────────────────────── */ .trigger { - border: 1px solid var(--border-default); + border: 1px solid var(--c-border); border-radius: var(--radius-md); padding: 0.5rem 0.75rem; - background: var(--bg-surface); + background: var(--c-surface); cursor: pointer; flex: 1; min-width: 0; @@ -47,8 +47,8 @@ justify-content: space-between; padding: 0.3rem 0.45rem; gap: 0.4rem; - border-bottom: 1px solid var(--border-default); - background: var(--bg-surface); + border-bottom: 1px solid var(--c-border); + background: var(--c-surface); } .tabGroup { @@ -66,7 +66,7 @@ min-width: 0; min-height: 24px; max-height: 26px; - border: 1px solid var(--border-default); + border: 1px solid var(--c-border); border-radius: var(--radius-sm); background: color-mix( in srgb, @@ -185,8 +185,8 @@ /* ── Search / filter row ─────────────────────────── */ .searchRow { padding: 0.3rem 0.45rem; - border-bottom: 1px solid var(--border-default); - background: var(--bg-surface); + border-bottom: 1px solid var(--c-border); + background: var(--c-surface); } .searchInput { @@ -195,7 +195,7 @@ height: 1.625rem; padding: 0 0.5rem; font-size: 0.7rem; - border: 1px solid var(--border-subtle, var(--border-default)); + border: 1px solid var(--c-border-subtle, var(--c-border)); border-radius: var(--radius-sm); background: var(--mantine-color-default-hover); color: var(--mantine-color-text); @@ -233,7 +233,7 @@ cursor: pointer; background: transparent; border: none; - border-top: 1px solid var(--border-default); + border-top: 1px solid var(--c-border); text-align: left; transition: background 0.08s ease; } diff --git a/frontend/editor/src/core/components/shared/FileSelectorPicker.stories.tsx b/frontend/editor/src/core/components/shared/FileSelectorPicker.stories.tsx new file mode 100644 index 0000000000..3d01996b99 --- /dev/null +++ b/frontend/editor/src/core/components/shared/FileSelectorPicker.stories.tsx @@ -0,0 +1,48 @@ +import type { ReactElement } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FileSelectorPicker } from "@app/components/shared/FileSelectorPicker"; +import { FileContextProvider } from "@app/contexts/FileContext"; + +/** + * Reads from FileContext (workbench files) and IndexedDBContext (persisted + * saved files) further up the tree — neither is part of the shared preview + * decorators, so FileContextProvider (which also wraps IndexedDBProvider) is + * stood up here. The popover starts closed, so no IndexedDB read happens + * until a story interacts with it. + */ +function withFileContext(Story: () => ReactElement) { + return ( + +
    + +
    +
    + ); +} + +const meta = { + title: "Shared/FileSelectorPicker", + component: FileSelectorPicker, + parameters: { layout: "padded" }, + args: { + onSelect: () => {}, + }, + decorators: [withFileContext], +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const CustomPlaceholder: Story = { + args: { + placeholder: "Choose a comparison file", + }, +}; + +export const Disabled: Story = { + args: { + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/shared/FileSelectorPicker.tsx b/frontend/editor/src/core/components/shared/FileSelectorPicker.tsx index 8c8123bd7d..83a249c38f 100644 --- a/frontend/editor/src/core/components/shared/FileSelectorPicker.tsx +++ b/frontend/editor/src/core/components/shared/FileSelectorPicker.tsx @@ -20,6 +20,7 @@ import apiClient from "@app/services/apiClient"; import { parseContentDispositionFilename, extractLatestFilesFromBundle, + readResponseHeader, } from "@app/services/shareBundleUtils"; import { truncateCenter } from "@app/utils/textUtils"; import { generateThumbnailForFile } from "@app/utils/thumbnailUtils"; @@ -270,14 +271,8 @@ export function FileSelectorPicker({ skipAuthRedirect: true, } as any, ); - const ct = - res.headers?.["content-type"] || - res.headers?.["Content-Type"] || - ""; - const disp = - res.headers?.["content-disposition"] || - res.headers?.["Content-Disposition"] || - ""; + const ct = readResponseHeader(res.headers, "content-type"); + const disp = readResponseHeader(res.headers, "content-disposition"); const files = await extractLatestFilesFromBundle( res.data as Blob, parseContentDispositionFilename(disp) || "shared-file", @@ -294,14 +289,8 @@ export function FileSelectorPicker({ skipAuthRedirect: true, } as any, ); - const ct = - res.headers?.["content-type"] || - res.headers?.["Content-Type"] || - ""; - const disp = - res.headers?.["content-disposition"] || - res.headers?.["Content-Disposition"] || - ""; + const ct = readResponseHeader(res.headers, "content-type"); + const disp = readResponseHeader(res.headers, "content-disposition"); const files = await extractLatestFilesFromBundle( res.data as Blob, parseContentDispositionFilename(disp) || stub.name, diff --git a/frontend/editor/src/core/components/shared/FileSidebar.css b/frontend/editor/src/core/components/shared/FileSidebar.css index 3261f60211..5832609680 100644 --- a/frontend/editor/src/core/components/shared/FileSidebar.css +++ b/frontend/editor/src/core/components/shared/FileSidebar.css @@ -1,8 +1,8 @@ /* ========== FILE SIDEBAR ========== */ .file-sidebar { - background-color: var(--bg-toolbar); - border-right: 1px solid var(--border-subtle); + background-color: var(--c-bg-raised); + border-right: 1px solid var(--c-border-subtle); display: flex; flex-direction: column; height: 100%; @@ -24,7 +24,7 @@ /* ---- Native file drag-and-drop ---- */ .file-sidebar[data-file-drag-over] { - outline: 2px dashed var(--mantine-color-blue-6, #3b82f6); + outline: 2px dashed var(--mantine-color-blue-6, var(--c-primary)); outline-offset: -4px; } @@ -42,20 +42,20 @@ pointer-events: none; background-color: color-mix( in srgb, - var(--mantine-color-blue-6, #3b82f6) 12%, - var(--bg-toolbar) + var(--mantine-color-blue-6, var(--c-primary)) 12%, + var(--c-bg-raised) ); } .file-sidebar-drop-overlay-icon { - color: var(--mantine-color-blue-6, #3b82f6) !important; + color: var(--mantine-color-blue-6, var(--c-primary)) !important; font-size: 28px !important; } .file-sidebar-drop-overlay-text { font-size: 13px; font-weight: 600; - color: var(--mantine-color-blue-6, #3b82f6); + color: var(--mantine-color-blue-6, var(--c-primary)); } /* ---- Header ---- */ @@ -75,11 +75,11 @@ /* Icons stay left-aligned during animation; overflow:hidden on inner clips text naturally */ .file-sidebar-header:hover { - background-color: var(--hover-bg); + background-color: var(--c-hover); } .file-sidebar-menu-icon { - color: var(--text-muted) !important; + color: var(--c-text-subtle) !important; font-size: 18px !important; flex-shrink: 0; } @@ -129,11 +129,11 @@ } .file-sidebar-search-row:not(.active):hover { - background-color: var(--hover-bg); + background-color: var(--c-hover); } .file-sidebar-search-icon { - color: var(--text-muted) !important; + color: var(--c-text-subtle) !important; font-size: 18px !important; flex-shrink: 0; } @@ -148,19 +148,19 @@ border: none; outline: none; font-size: 14px; - color: var(--text-primary); + color: var(--c-text); margin-left: 12px; min-width: 0; } .file-sidebar-search-input::placeholder { - color: var(--text-muted); + color: var(--c-text-subtle); } .file-sidebar-search-label { margin-left: 12px; font-size: 14px; - color: var(--text-secondary); + color: var(--c-text-muted); } /* ---- Scrollable content ---- */ @@ -199,7 +199,7 @@ } .file-sidebar-action-row:hover { - background-color: var(--hover-bg); + background-color: var(--c-hover); } .file-sidebar-action-row.disabled { @@ -212,7 +212,7 @@ } .file-sidebar-action-icon { - color: var(--text-muted) !important; + color: var(--c-text-subtle) !important; font-size: 18px !important; flex-shrink: 0; display: inline-flex; @@ -234,7 +234,7 @@ .file-sidebar-action-label { margin-left: 12px; font-size: 14px; - color: var(--text-secondary); + color: var(--c-text-muted); white-space: nowrap; } @@ -253,7 +253,7 @@ } .file-sidebar-cloud-row:not(.disabled):hover { - background-color: var(--hover-bg); + background-color: var(--c-hover); } .file-sidebar-cloud-row.disabled { @@ -320,19 +320,19 @@ border: none; background: transparent; cursor: pointer; - color: var(--text-default, var(--color-text-1)); + color: var(--text-default, var(--c-text)); border-radius: 0; font-size: 13px; font-weight: 600; text-align: left; } .file-sidebar-group-header:hover { - background: var(--hover-bg, var(--color-bg-hover)); + background: var(--c-hover, var(--c-hover)); } .file-sidebar-group-icon { flex: none; - color: var(--text-muted, var(--color-text-3)); + color: var(--c-text-subtle, var(--c-text-subtle)); } .file-sidebar-group-label { @@ -347,7 +347,7 @@ flex: none; font-size: 11px; font-weight: 500; - color: var(--text-muted, var(--color-text-3)); + color: var(--c-text-subtle, var(--c-text-subtle)); } /* Full-width, flat rows: no inset, no radius, so the hover/selected highlight @@ -363,7 +363,7 @@ } /* Hover reads neutral (not the default blue tint); selected stays blue. */ .file-sidebar-group-items .file-sidebar-file-item:hover:not(.selected) { - background-color: var(--color-bg-hover, rgba(120, 120, 120, 0.1)); + background-color: var(--c-hover); } /* A plain circle where the selected check sits — on hover, and blue when selected. */ .file-sidebar-group-items .file-sidebar-file-checkbox-hover, @@ -413,7 +413,7 @@ border: none; background: transparent; cursor: pointer; - color: var(--color-blue, #2563eb); + color: var(--c-accent-fg, var(--c-primary)); font-size: 12px; font-weight: 500; border-radius: 6px; @@ -424,7 +424,7 @@ display: none; } .file-sidebar-view-all:hover { - background: var(--color-bg-hover, rgba(120, 120, 120, 0.1)); + background: var(--c-hover); } .file-sidebar-file-list::-webkit-scrollbar { @@ -438,11 +438,11 @@ border-radius: 2px; } .file-sidebar-file-list::-webkit-scrollbar-thumb:hover { - background: var(--text-muted); + background: var(--c-text-subtle); } .file-sidebar-section-divider { - border-top: 1px solid var(--border-subtle); + border-top: 1px solid var(--c-border-subtle); margin: 10px 14px 6px 14px; } @@ -457,7 +457,7 @@ .file-sidebar-section-header { position: relative; - border-top: 1px solid var(--border-subtle); + border-top: 1px solid var(--c-border-subtle); padding-top: 10px; margin-top: 4px; flex-shrink: 0; @@ -467,7 +467,7 @@ font-size: 13px; font-weight: 600; letter-spacing: 0.02em; - color: var(--text-muted); + color: var(--c-text-subtle); text-transform: uppercase; } @@ -481,7 +481,7 @@ align-items: center; justify-content: space-between; font-size: 11px; - color: var(--text-muted); + color: var(--c-text-subtle); margin-bottom: 4px; } .file-sidebar-bulk-add-count { @@ -490,19 +490,19 @@ .file-sidebar-bulk-add-track { height: 3px; border-radius: 999px; - background: color-mix(in srgb, var(--color-blue, #3b82f6) 15%, transparent); + background: color-mix(in srgb, var(--c-primary) 15%, transparent); overflow: hidden; } .file-sidebar-bulk-add-bar { height: 100%; border-radius: 999px; - background: var(--color-blue, #3b82f6); + background: var(--c-primary); transition: width 0.15s ease-out; } .file-sidebar-section-count { font-size: 11px; - color: var(--text-muted); + color: var(--c-text-subtle); } .file-sidebar-section-btn { @@ -515,7 +515,7 @@ background: transparent; border-radius: 4px; cursor: pointer; - color: var(--text-muted); + color: var(--c-text-subtle); padding: 0; outline: none; transition: @@ -525,13 +525,13 @@ } .file-sidebar-section-btn:focus-visible { - outline: 2px solid #3b82f6; + outline: 2px solid var(--c-primary); outline-offset: 1px; } .file-sidebar-section-btn:hover { - background-color: var(--hover-bg); - color: var(--text-primary); + background-color: var(--c-hover); + color: var(--c-text); } .file-sidebar-section-btn-external { @@ -576,7 +576,7 @@ align-items: center; gap: 8px; padding: 8px 10px; - border-top: 1px solid var(--border-subtle); + border-top: 1px solid var(--c-border-subtle); flex-shrink: 0; min-height: 48px; } @@ -587,8 +587,8 @@ width: 28px; height: 28px; border-radius: 50%; - background-color: var(--mantine-color-blue-6, #3b82f6); - color: #fff; + background-color: var(--mantine-color-blue-6, var(--c-primary)); + color: var(--c-text-on-primary); font-size: 12px; font-weight: 600; display: flex; @@ -615,7 +615,7 @@ flex: 1; font-size: 13px; font-weight: 500; - color: var(--text-primary); + color: var(--c-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -623,11 +623,11 @@ } .file-sidebar-bottom-bar[role="button"]:hover { - background-color: var(--hover-bg); + background-color: var(--c-hover); } .file-sidebar-bottom-bar[role="button"]:focus-visible { - outline: 2px solid #3b82f6; + outline: 2px solid var(--c-primary); outline-offset: -2px; } @@ -638,7 +638,7 @@ width: 28px; height: 28px; border-radius: 6px; - color: var(--text-muted); + color: var(--c-text-subtle); padding: 0; flex-shrink: 0; margin-left: auto; diff --git a/frontend/editor/src/core/components/shared/FileSidebar.tsx b/frontend/editor/src/core/components/shared/FileSidebar.tsx index b5c815f824..501c5385d1 100644 --- a/frontend/editor/src/core/components/shared/FileSidebar.tsx +++ b/frontend/editor/src/core/components/shared/FileSidebar.tsx @@ -1114,7 +1114,7 @@ const FileSidebar = forwardRef(
    {isGoogleDriveEnabled && ( ( aria-label={t("watchedFolders.sidebarTitle", "Watched Folders")} style={ isWatchedFoldersActive - ? { backgroundColor: "var(--active-bg)" } + ? { backgroundColor: "var(--c-active)" } : undefined } > @@ -1197,7 +1197,7 @@ const FileSidebar = forwardRef( {!stubsLoaded ? (
    - +
    ) : filteredFileStubs.length > 0 ? (
    diff --git a/frontend/editor/src/core/components/shared/FileSidebarFileItem.css b/frontend/editor/src/core/components/shared/FileSidebarFileItem.css index 51278c1673..4d742f7be6 100644 --- a/frontend/editor/src/core/components/shared/FileSidebarFileItem.css +++ b/frontend/editor/src/core/components/shared/FileSidebarFileItem.css @@ -17,7 +17,7 @@ background: transparent; } .file-sidebar-file-list::-webkit-scrollbar-thumb { - background: rgb(var(--border)); + background: var(--c-border); border-radius: 2px; } @@ -50,29 +50,28 @@ } .file-sidebar-file-item:hover .file-sidebar-file-actions, -.file-sidebar-file-item.viewed .file-sidebar-file-actions { +.file-sidebar-file-item:focus-within .file-sidebar-file-actions, +.file-sidebar-file-item.viewed .file-sidebar-file-actions, +.file-sidebar-file-item.selected .file-sidebar-file-actions { pointer-events: auto; } -/* Only shrink the name to clear the buttons while they're visible. */ +/* Always reserve space for action buttons so file name truncation dots (...) + never bleed under action buttons or overlap their outline/focus box. */ .file-sidebar-file-info { - transition: padding-right 0.12s ease; -} -.file-sidebar-file-item:hover .file-sidebar-file-info, -.file-sidebar-file-item.viewed .file-sidebar-file-info { - padding-right: 3.5rem; + padding-right: 3.75rem; } .file-sidebar-file-item:hover:not(.selected) { - background-color: rgba(59, 130, 246, 0.06); + background-color: color-mix(in srgb, var(--c-primary) 6%, transparent); } .file-sidebar-file-item.selected { - background-color: rgba(59, 130, 246, 0.12); + background-color: color-mix(in srgb, var(--c-primary) 12%, transparent); } .file-sidebar-file-item.active:not(.selected) { - background-color: rgba(59, 130, 246, 0.06); + background-color: color-mix(in srgb, var(--c-primary) 6%, transparent); } /* Icon wrapper */ @@ -96,7 +95,7 @@ width: 20px; height: 20px; border-radius: 5px; - border: 1.5px solid var(--border-hover, var(--border-strong)); + border: 1.5px solid var(--c-border-strong); } .file-sidebar-file-item:hover .file-sidebar-file-checkbox-hover { @@ -117,7 +116,7 @@ width: 20px; height: 20px; border-radius: 5px; - background-color: #3b82f6; + background-color: var(--c-primary); display: flex; align-items: center; justify-content: center; @@ -125,7 +124,7 @@ } .file-sidebar-check-svg { - color: white; + color: var(--c-text-on-primary); flex-shrink: 0; } @@ -180,14 +179,14 @@ display: block; font-size: 13px; font-weight: 500; - color: var(--text-primary); + color: var(--c-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .file-sidebar-file-item.selected .file-sidebar-file-name { - color: #3b82f6; + color: var(--c-accent-fg); } .file-sidebar-file-meta-row { @@ -200,7 +199,7 @@ .file-sidebar-file-meta { font-size: 11px; - color: var(--text-muted); + color: var(--c-text-subtle); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -212,7 +211,7 @@ align-items: center; justify-content: center; flex-shrink: 0; - color: var(--accent-interactive, #6366f1); + color: var(--c-primary); } /* ---- Folder membership tags ---- */ @@ -251,7 +250,7 @@ font-size: 10px; font-weight: 600; line-height: 1.2; - color: var(--text-secondary); + color: var(--c-text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -261,7 +260,7 @@ font-size: 10px; font-weight: 600; line-height: 1; - color: var(--text-muted); + color: var(--c-text-subtle); cursor: default; flex-shrink: 0; } @@ -278,7 +277,7 @@ background: transparent; border-radius: 4px; cursor: pointer; - color: var(--text-muted); + color: var(--c-text-subtle); padding: 0; opacity: 0; transition: @@ -298,48 +297,45 @@ bottom: 0; z-index: 2; /* solid base + green tint overlay so content behind doesn't bleed through */ - background-color: var(--bg-toolbar); + background-color: var(--c-bg-raised); background-image: linear-gradient( - rgba(34, 197, 94, 0.1), - rgba(34, 197, 94, 0.1) + color-mix(in srgb, var(--c-success) 10%, transparent), + color-mix(in srgb, var(--c-success) 10%, transparent) ); } .file-sidebar-file-item.viewed .file-sidebar-file-name { - color: #22c55e; + color: var(--c-success); } .file-sidebar-file-item.viewed .file-sidebar-file-check { - background-color: #22c55e; + background-color: var(--c-success); } /* viewed takes precedence over selected */ .file-sidebar-file-item.viewed.selected { - background-color: var(--bg-toolbar); + background-color: var(--c-bg-raised); background-image: linear-gradient( - rgba(34, 197, 94, 0.1), - rgba(34, 197, 94, 0.1) + color-mix(in srgb, var(--c-success) 10%, transparent), + color-mix(in srgb, var(--c-success) 10%, transparent) ); } /* Always show eye for the currently viewed file */ .file-sidebar-file-item.viewed .file-sidebar-eye-btn { opacity: 1; - color: #22c55e; + color: var(--c-success); } .file-sidebar-eye-btn:hover { - color: var(--text-primary); + color: var(--c-text); } /* Eye open: shown by default */ .file-sidebar-eye-open { display: block !important; } -.file-sidebar-eye-closed { - display: none !important; -} - +.file-sidebar-eye-closed, /* On hover over a viewed item: swap to eye-closed */ .file-sidebar-file-item.viewed:hover .file-sidebar-eye-open { display: none !important; @@ -360,7 +356,7 @@ background: transparent; border-radius: 4px; cursor: pointer; - color: var(--text-muted); + color: var(--c-text-subtle); padding: 0; opacity: 0; transition: @@ -368,21 +364,23 @@ color 0.12s ease; } -/* Reveal on row hover, and keep it shown while its menu is open. */ +/* Reveal on row hover/focus/selected, and keep it shown while its menu is open. */ .file-sidebar-file-item:hover .file-sidebar-kebab-btn, +.file-sidebar-file-item:focus-within .file-sidebar-kebab-btn, +.file-sidebar-file-item.selected .file-sidebar-kebab-btn, .file-sidebar-kebab-btn[aria-expanded="true"] { opacity: 1; } .file-sidebar-kebab-btn:hover { - color: var(--text-primary); + color: var(--c-text); } /* ---- Date group headers ---- */ .file-sidebar-date-group-header { font-size: 11px; font-weight: 500; - color: var(--text-muted); + color: var(--c-text-subtle); padding: 0 10px 2px 10px; margin-top: 8px; user-select: none; @@ -409,13 +407,13 @@ .file-sidebar-empty-text { font-size: 12px; - color: var(--text-muted); + color: var(--c-text-subtle); margin: 0 0 2px 0; } .file-sidebar-empty-hint { font-size: 11px; - color: var(--text-muted); + color: var(--c-text-subtle); opacity: 0.7; margin: 0; } @@ -425,8 +423,8 @@ position: fixed; z-index: 9999; transform: translateY(-50%); - background: var(--bg-surface, #fff); - border: 1px solid var(--border-subtle); + background: var(--c-surface); + border: 1px solid var(--c-border-subtle); border-radius: 8px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18); padding: 4px; diff --git a/frontend/editor/src/core/components/shared/FileUploadButton.stories.tsx b/frontend/editor/src/core/components/shared/FileUploadButton.stories.tsx new file mode 100644 index 0000000000..d0411f1be9 --- /dev/null +++ b/frontend/editor/src/core/components/shared/FileUploadButton.stories.tsx @@ -0,0 +1,56 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import FileUploadButton from "@app/components/shared/FileUploadButton"; + +const meta: Meta = { + title: "Shared/FileUploadButton", + component: FileUploadButton, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
    + +
    + ), + ], +}; +export default meta; +type Story = StoryObj; + +function UploadDemo({ + initialFile, + ...rest +}: { + initialFile?: File; + disabled?: boolean; + accept?: string; + placeholder?: string; +}) { + const [file, setFile] = useState(initialFile); + return ( + setFile(next ?? undefined)} + {...rest} + /> + ); +} + +/** No file chosen yet — shows the default "Choose File" placeholder. */ +export const Default: Story = { render: () => }; + +/** A file has already been selected — the button shows its name. */ +export const WithFileSelected: Story = { + render: () => ( + + ), +}; + +/** Disabled state — should still be legible but non-interactive. */ +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/shared/FirstLoginModal.stories.tsx b/frontend/editor/src/core/components/shared/FirstLoginModal.stories.tsx new file mode 100644 index 0000000000..30941579f4 --- /dev/null +++ b/frontend/editor/src/core/components/shared/FirstLoginModal.stories.tsx @@ -0,0 +1,18 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import FirstLoginModal from "@app/components/shared/FirstLoginModal"; + +const meta = { + title: "Shared/FirstLoginModal", + component: FirstLoginModal, + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + opened: true, + username: "jane.doe", + onPasswordChanged: () => {}, + }, +}; diff --git a/frontend/editor/src/core/components/shared/FitText.stories.tsx b/frontend/editor/src/core/components/shared/FitText.stories.tsx new file mode 100644 index 0000000000..63b93d0ed7 --- /dev/null +++ b/frontend/editor/src/core/components/shared/FitText.stories.tsx @@ -0,0 +1,41 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import FitText from "@app/components/shared/FitText"; + +const meta: Meta = { + title: "Shared/FitText", + component: FitText, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
    + +
    + ), + ], +}; +export default meta; +type Story = StoryObj; + +/** Single-line text that shrinks its font size to fit the available width. */ +export const Default: Story = { + args: { + text: "Invoice_2026_Quarterly_Report.pdf", + }, +}; + +/** Multi-line clamp with soft-break hints inserted after '/', '-' and '_'. */ +export const MultiLine: Story = { + args: { + text: "path/to/some-very/long_document/name_that_needs_multiple_lines.pdf", + lines: 3, + }, +}; + +/** Explicit font size (rem) with a lower minimum shrink scale. */ +export const CustomFontSize: Story = { + args: { + text: "Custom Sized Label", + fontSize: 1.5, + minimumFontScale: 0.5, + }, +}; diff --git a/frontend/editor/src/core/components/shared/Footer.stories.tsx b/frontend/editor/src/core/components/shared/Footer.stories.tsx new file mode 100644 index 0000000000..af1d4a5805 --- /dev/null +++ b/frontend/editor/src/core/components/shared/Footer.stories.tsx @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import Footer from "@app/components/shared/Footer"; + +const meta = { + title: "Shared/Footer", + component: Footer, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** Defaults: no overrides supplied, only the always-present links render. */ +export const Default: Story = { + args: {}, +}; + +/** All optional legal links populated, plus the cookie preferences button. */ +export const AllLinksAndCookieBanner: Story = { + args: { + privacyPolicy: "https://example.com/privacy", + termsAndConditions: "https://example.com/terms", + accessibilityStatement: "https://example.com/accessibility", + cookiePolicy: "https://example.com/cookies", + impressum: "https://example.com/impressum", + analyticsEnabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/shared/Footer.tsx b/frontend/editor/src/core/components/shared/Footer.tsx index 2fb7312fba..8ae0d3d4c1 100644 --- a/frontend/editor/src/core/components/shared/Footer.tsx +++ b/frontend/editor/src/core/components/shared/Footer.tsx @@ -54,8 +54,8 @@ export default function Footer({
    , + label: "Edit", + onClick: () => {}, + }, + { + id: "download", + icon: , + label: "Download", + onClick: () => {}, + }, + { + id: "delete", + icon: , + label: "Delete", + onClick: () => {}, + color: "var(--text-error)", + }, +]; + +const meta: Meta = { + title: "Shared/HoverActionMenu", + component: HoverActionMenu, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
    + +
    + ), + ], +}; +export default meta; +type Story = StoryObj; + +/** Visible menu with the standard edit/download/delete action set. */ +export const Default: Story = { + args: { + show: true, + actions, + }, +}; + +/** Hidden state (`show: false`) — menu stays mounted but faded/non-interactive. */ +export const Hidden: Story = { + args: { + show: false, + actions, + }, +}; + +/** One action disabled with a custom tooltip explaining why. */ +export const WithDisabledAction: Story = { + args: { + show: true, + actions: [ + actions[0], + actions[1], + { + ...actions[2], + disabled: true, + tooltip: "Deletion is restricted by policy", + }, + ], + }, +}; diff --git a/frontend/editor/src/core/components/shared/HoverActionMenu.tsx b/frontend/editor/src/core/components/shared/HoverActionMenu.tsx index d912aace48..68660ede47 100644 --- a/frontend/editor/src/core/components/shared/HoverActionMenu.tsx +++ b/frontend/editor/src/core/components/shared/HoverActionMenu.tsx @@ -70,7 +70,7 @@ const HoverActionMenu: React.FC = ({ disabled={action.disabled} onClick={action.onClick} aria-label={action.label} - style={{ color: action.color || "var(--text-secondary)" }} + style={{ color: action.color || "var(--c-text-muted)" }} data-tour={action.dataTour} > {action.icon} diff --git a/frontend/editor/src/core/components/shared/InfoBanner.stories.tsx b/frontend/editor/src/core/components/shared/InfoBanner.stories.tsx new file mode 100644 index 0000000000..5fad071d05 --- /dev/null +++ b/frontend/editor/src/core/components/shared/InfoBanner.stories.tsx @@ -0,0 +1,38 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { InfoBanner } from "@app/components/shared/InfoBanner"; + +const meta = { + title: "Shared/InfoBanner", + component: InfoBanner, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + icon: "info-rounded", + title: "Heads up", + message: "This document contains form fields that will be flattened.", + }, +}; + +export const Warning: Story = { + args: { + tone: "warning", + icon: "warning-rounded", + title: "Action required", + message: "Some pages could not be processed and were skipped.", + buttonText: "Review", + onButtonClick: () => {}, + }, +}; + +export const Compact: Story = { + args: { + compact: true, + icon: "info-rounded", + message: "Autosave is enabled for this file.", + dismissible: false, + }, +}; diff --git a/frontend/editor/src/core/components/shared/LandingActions.tsx b/frontend/editor/src/core/components/shared/LandingActions.tsx index 3d801f82de..ec237b0394 100644 --- a/frontend/editor/src/core/components/shared/LandingActions.tsx +++ b/frontend/editor/src/core/components/shared/LandingActions.tsx @@ -34,12 +34,7 @@ export function LandingActions({
    @@ -505,29 +505,33 @@ const GeneralSection: React.FC = ({ ]} />
    -
    -
    - - {t("settings.general.language", "Language")} - - - {t( - "settings.general.languageDescription", - "Choose the display language", - )} - -
    - -
    + {/* Language */} + +
    +
    + + {t("settings.general.language", "Language")} + + + {t( + "settings.general.languageDescription", + "Choose the display language", + )} + +
    + +
    +
    +
    {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + isAdmin: false, + }, +}; + +export const Admin: Story = { + args: { + isAdmin: true, + }, +}; diff --git a/frontend/editor/src/core/components/shared/config/configSections/HotkeysSection.stories.tsx b/frontend/editor/src/core/components/shared/config/configSections/HotkeysSection.stories.tsx new file mode 100644 index 0000000000..94420ad5cd --- /dev/null +++ b/frontend/editor/src/core/components/shared/config/configSections/HotkeysSection.stories.tsx @@ -0,0 +1,40 @@ +import type { ReactElement } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import HotkeysSection from "@app/components/shared/config/configSections/HotkeysSection"; +import { PreferencesProvider } from "@app/contexts/PreferencesContext"; +import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider"; +import { NavigationProvider } from "@app/contexts/NavigationContext"; +import { ToolWorkflowProvider } from "@app/contexts/ToolWorkflowContext"; +import { HotkeyProvider } from "@app/contexts/HotkeyContext"; + +/** + * HotkeyContext reads the tool registry and selection state off + * ToolWorkflowContext, so both providers must wrap the story. + */ +function withProviders(Story: () => ReactElement) { + return ( + + + + + + + + + + + + ); +} + +const meta = { + title: "Shared/Config/ConfigSections/HotkeysSection", + component: HotkeysSection, + decorators: [withProviders], +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +/** Full tool list with default keyboard shortcuts assigned. */ +export const Default: Story = {}; diff --git a/frontend/editor/src/core/components/shared/config/configSections/LegalSection.stories.tsx b/frontend/editor/src/core/components/shared/config/configSections/LegalSection.stories.tsx new file mode 100644 index 0000000000..6b0c958725 --- /dev/null +++ b/frontend/editor/src/core/components/shared/config/configSections/LegalSection.stories.tsx @@ -0,0 +1,74 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import LegalSection from "@app/components/shared/config/configSections/LegalSection"; +import { AppConfigProvider } from "@app/contexts/AppConfigContext"; + +// Reads legal document links and the analytics flag via useAppConfig() (the +// preview's own provider tree doesn't supply this — that's the portal +// context), so wrap here. AppConfigProvider uses autoFetch off so stories +// render a fixed config instead of hitting the API. +const meta = { + title: "Shared/Config/ConfigSections/LegalSection", + component: LegalSection, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** All optional legal documents configured, analytics disabled — no Cookie Preferences card. */ +export const Default: Story = { + decorators: [ + (StoryComponent) => ( + + + + ), + ], +}; + +/** Analytics enabled — adds the Cookie Preferences card with its "Manage" button. */ +export const WithAnalyticsEnabled: Story = { + decorators: [ + (StoryComponent) => ( + + + + ), + ], +}; + +/** No legal documents configured — only Privacy Policy and Terms show, using the stirling.com fallback links. */ +export const MinimalLinks: Story = { + decorators: [ + (StoryComponent) => ( + + + + ), + ], +}; diff --git a/frontend/editor/src/core/components/shared/config/configSections/Overview.stories.tsx b/frontend/editor/src/core/components/shared/config/configSections/Overview.stories.tsx new file mode 100644 index 0000000000..9b04d088e9 --- /dev/null +++ b/frontend/editor/src/core/components/shared/config/configSections/Overview.stories.tsx @@ -0,0 +1,59 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import Overview from "@app/components/shared/config/configSections/Overview"; +import { AppConfigProvider } from "@app/contexts/AppConfigContext"; + +// Reads config via useAppConfig() — no props. Wrap in AppConfigProvider with +// autoFetch off so stories render a fixed config instead of hitting the API. +const meta = { + title: "Shared/Config/Overview", + component: Overview, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** No config resolved yet (default context state) — shows the loading spinner. */ +export const Default: Story = {}; + +/** Config loaded — renders the basic/security/system/integration sections. */ +export const Loaded: Story = { + decorators: [ + (StoryComponent) => ( + + + + ), + ], +}; + +/** Config resolved but carrying a server-reported warning. */ +export const WithWarning: Story = { + decorators: [ + (StoryComponent) => ( + + + + ), + ], +}; diff --git a/frontend/editor/src/core/components/shared/config/configSections/ProviderCard.stories.tsx b/frontend/editor/src/core/components/shared/config/configSections/ProviderCard.stories.tsx new file mode 100644 index 0000000000..0d7a44c38f --- /dev/null +++ b/frontend/editor/src/core/components/shared/config/configSections/ProviderCard.stories.tsx @@ -0,0 +1,80 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ProviderCard from "@app/components/shared/config/configSections/ProviderCard"; +import type { Provider } from "@app/components/shared/config/configSections/providerDefinitions"; + +const mockProvider: Provider = { + id: "google", + name: "Google", + icon: "key-rounded", + type: "oauth2", + scope: "Sign-in authentication", + documentationUrl: "https://docs.stirlingpdf.com/Configuration/OAuth", + fields: [ + { + key: "clientId", + type: "text", + label: "Client ID", + description: "The OAuth2 client ID from Google Cloud Console", + placeholder: "your-client-id.apps.googleusercontent.com", + }, + { + key: "clientSecret", + type: "password", + label: "Client Secret", + description: "The OAuth2 client secret from Google Cloud Console", + }, + { + key: "scopes", + type: "tags", + label: "Scopes", + description: "OAuth2 scopes to request", + defaultValue: ["email", "profile"], + }, + { + key: "autoProvision", + type: "switch", + label: "Auto Provision Users", + description: "Automatically create accounts for new sign-ins", + defaultValue: false, + }, + ], +}; + +const meta = { + title: "Shared/Config/ConfigSections/ProviderCard", + component: ProviderCard, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + provider: mockProvider, + isConfigured: false, + }, +}; + +export const Configured: Story = { + args: { + provider: mockProvider, + isConfigured: true, + settings: { + clientId: "example-client-id.apps.googleusercontent.com", + scopes: ["email", "profile"], + autoProvision: true, + }, + }, +}; + +export const ReadOnly: Story = { + args: { + provider: mockProvider, + isConfigured: true, + readOnly: true, + settings: { + clientId: "example-client-id.apps.googleusercontent.com", + scopes: ["email", "profile"], + }, + }, +}; diff --git a/frontend/editor/src/core/components/shared/config/configSections/ProviderCard.tsx b/frontend/editor/src/core/components/shared/config/configSections/ProviderCard.tsx index 93999ce438..c24178635c 100644 --- a/frontend/editor/src/core/components/shared/config/configSections/ProviderCard.tsx +++ b/frontend/editor/src/core/components/shared/config/configSections/ProviderCard.tsx @@ -32,10 +32,16 @@ interface ProviderCardProps { readOnly?: boolean; } +// Shared default so an omitted `settings` prop keeps the same identity across +// renders. An inline `settings = {}` would allocate a new object every render, +// and the sync effect below lists `settings` as a dependency — so it would +// re-run and setState on every render, looping until React bails out. +const NO_SETTINGS: Record = {}; + export default function ProviderCard({ provider, isConfigured, - settings = {}, + settings = NO_SETTINGS, onSave, onDisconnect, onChange, diff --git a/frontend/editor/src/core/components/shared/config/configSections/ThirdPartyLicensesSection.stories.tsx b/frontend/editor/src/core/components/shared/config/configSections/ThirdPartyLicensesSection.stories.tsx new file mode 100644 index 0000000000..7d149b2b3b --- /dev/null +++ b/frontend/editor/src/core/components/shared/config/configSections/ThirdPartyLicensesSection.stories.tsx @@ -0,0 +1,18 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ThirdPartyLicensesSection, { + FrontendThirdPartyLicensesSection, +} from "@app/components/shared/config/configSections/ThirdPartyLicensesSection"; + +const meta = { + title: "Shared/Config/ConfigSections/ThirdPartyLicensesSection", + component: ThirdPartyLicensesSection, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Frontend: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/shared/config/configSections/ThirdPartyLicensesSection.tsx b/frontend/editor/src/core/components/shared/config/configSections/ThirdPartyLicensesSection.tsx index 5d0de982fc..334e769a74 100644 --- a/frontend/editor/src/core/components/shared/config/configSections/ThirdPartyLicensesSection.tsx +++ b/frontend/editor/src/core/components/shared/config/configSections/ThirdPartyLicensesSection.tsx @@ -32,6 +32,9 @@ interface LicensesSectionBodyProps { dependencies: Dependency[]; } +const getModuleUrl = (dependency: Dependency) => + dependency.moduleUrl || dependency.moduleLicenseUrl; + function LicensesSectionBody({ title, description, @@ -106,9 +109,9 @@ function LicensesSectionBody({ sortedDependencies.map((dependency) => ( - {dependency.moduleUrl ? ( + {getModuleUrl(dependency) ? ( diff --git a/frontend/editor/src/core/components/shared/config/types.ts b/frontend/editor/src/core/components/shared/config/types.ts index 5cbdd1b169..6b7d62192d 100644 --- a/frontend/editor/src/core/components/shared/config/types.ts +++ b/frontend/editor/src/core/components/shared/config/types.ts @@ -31,7 +31,12 @@ export const VALID_NAV_KEYS = [ "adminUsage", "adminEndpoints", "adminStorageSharing", + "adminFolderAccess", "adminMcp", + "adminAiGeneral", + "adminAiModels", + "adminAiDocuments", + "adminAiLimits", "help", "legal", "backendThirdPartyLicenses", diff --git a/frontend/editor/src/core/components/shared/filePreview/DocumentStack.stories.tsx b/frontend/editor/src/core/components/shared/filePreview/DocumentStack.stories.tsx new file mode 100644 index 0000000000..f76bc47831 --- /dev/null +++ b/frontend/editor/src/core/components/shared/filePreview/DocumentStack.stories.tsx @@ -0,0 +1,51 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Box } from "@mantine/core"; +import DocumentStack from "@app/components/shared/filePreview/DocumentStack"; + +const meta = { + title: "Shared/FilePreview/DocumentStack", + component: DocumentStack, + parameters: { layout: "padded" }, + decorators: [ + (Story) => ( + + + + ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const previewContent = ( + +); + +export const SingleFile: Story = { + args: { + totalFiles: 1, + children: previewContent, + }, +}; + +export const TwoFiles: Story = { + args: { + totalFiles: 2, + children: previewContent, + }, +}; + +export const ManyFiles: Story = { + args: { + totalFiles: 5, + children: previewContent, + }, +}; diff --git a/frontend/editor/src/core/components/shared/filePreview/DocumentThumbnail.stories.tsx b/frontend/editor/src/core/components/shared/filePreview/DocumentThumbnail.stories.tsx new file mode 100644 index 0000000000..d410c3af78 --- /dev/null +++ b/frontend/editor/src/core/components/shared/filePreview/DocumentThumbnail.stories.tsx @@ -0,0 +1,49 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import DocumentThumbnail from "@app/components/shared/filePreview/DocumentThumbnail"; + +const mockFile = new File(["dummy content"], "sample-report.pdf", { + type: "application/pdf", +}); + +const meta = { + title: "Shared/FilePreview/DocumentThumbnail", + component: DocumentThumbnail, + parameters: { layout: "padded" }, + args: { + file: mockFile, + }, + decorators: [ + (Story) => ( +
    + +
    + ), + ], +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const WithThumbnail: Story = { + args: { + thumbnail: + "data:image/svg+xml;utf8," + + encodeURIComponent( + '', + ), + }, +}; + +export const Encrypted: Story = { + args: { + isEncrypted: true, + }, +}; + +export const Loading: Story = { + args: { + isLoading: true, + }, +}; diff --git a/frontend/editor/src/core/components/shared/filePreview/DocumentThumbnail.tsx b/frontend/editor/src/core/components/shared/filePreview/DocumentThumbnail.tsx index 9b5ce16eaf..5eb1bad56c 100644 --- a/frontend/editor/src/core/components/shared/filePreview/DocumentThumbnail.tsx +++ b/frontend/editor/src/core/components/shared/filePreview/DocumentThumbnail.tsx @@ -153,7 +153,7 @@ const DocumentThumbnail: React.FC = ({ fontWeight: 700, letterSpacing: "0.1em", textTransform: "uppercase", - color: "var(--text-secondary)", + color: "var(--c-text-muted)", background: "rgb(var(--border))", padding: "3px 10px", borderRadius: "6px", diff --git a/frontend/editor/src/core/components/shared/filePreview/HoverOverlay.stories.tsx b/frontend/editor/src/core/components/shared/filePreview/HoverOverlay.stories.tsx new file mode 100644 index 0000000000..560d129843 --- /dev/null +++ b/frontend/editor/src/core/components/shared/filePreview/HoverOverlay.stories.tsx @@ -0,0 +1,31 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Box, Text } from "@mantine/core"; +import HoverOverlay from "@app/components/shared/filePreview/HoverOverlay"; + +const meta = { + title: "Shared/FilePreview/HoverOverlay", + component: HoverOverlay, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + children: ( + + Page thumbnail + + ), + }, +}; diff --git a/frontend/editor/src/core/components/shared/filePreview/NavigationArrows.stories.tsx b/frontend/editor/src/core/components/shared/filePreview/NavigationArrows.stories.tsx new file mode 100644 index 0000000000..76a6fbc99e --- /dev/null +++ b/frontend/editor/src/core/components/shared/filePreview/NavigationArrows.stories.tsx @@ -0,0 +1,34 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import NavigationArrows from "@app/components/shared/filePreview/NavigationArrows"; + +const meta = { + title: "Shared/FilePreview/NavigationArrows", + component: NavigationArrows, + parameters: { layout: "padded" }, + args: { + onPrevious: () => {}, + onNext: () => {}, + }, + decorators: [ + (Story) => ( +
    + +
    + ), + ], +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + children:
    Page 1 of 5
    , + }, +}; + +export const Disabled: Story = { + args: { + disabled: true, + children:
    Page 1 of 1
    , + }, +}; diff --git a/frontend/editor/src/core/components/shared/quickAccessBar/QuickAccessButton.stories.tsx b/frontend/editor/src/core/components/shared/quickAccessBar/QuickAccessButton.stories.tsx new file mode 100644 index 0000000000..cc984b007f --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickAccessBar/QuickAccessButton.stories.tsx @@ -0,0 +1,46 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import HomeIcon from "@mui/icons-material/HomeRounded"; +import QuickAccessButton from "@app/components/shared/quickAccessBar/QuickAccessButton"; + +const meta = { + title: "Shared/QuickAccessBar/QuickAccessButton", + component: QuickAccessButton, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
    + +
    + ), + ], +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + icon: , + label: "Home", + isActive: false, + ariaLabel: "Home", + }, +}; + +export const Active: Story = { + args: { + icon: , + label: "Home", + isActive: true, + ariaLabel: "Home", + }, +}; + +export const Disabled: Story = { + args: { + icon: , + label: "Home", + isActive: false, + ariaLabel: "Home", + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/shared/signing/CreateSessionFlow.stories.tsx b/frontend/editor/src/core/components/shared/signing/CreateSessionFlow.stories.tsx new file mode 100644 index 0000000000..9de692e597 --- /dev/null +++ b/frontend/editor/src/core/components/shared/signing/CreateSessionFlow.stories.tsx @@ -0,0 +1,71 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { CreateSessionFlow } from "@app/components/shared/signing/CreateSessionFlow"; +import type { FileState } from "@app/types/file"; + +const mockFile: FileState = { + name: "contract.pdf", + size: 245_760, +}; + +function CreateSessionFlowDemo({ + initialFiles, +}: { + initialFiles: FileState[]; +}) { + const [selectedUserIds, setSelectedUserIds] = useState([]); + const [dueDate, setDueDate] = useState(""); + + return ( + {}} + /> + ); +} + +const meta = { + title: "Shared/Signing/CreateSessionFlow", + component: CreateSessionFlow, + parameters: { layout: "padded" }, + args: { + selectedFiles: [mockFile], + selectedUserIds: [], + onSelectedUserIdsChange: () => {}, + dueDate: "", + onDueDateChange: () => {}, + creating: false, + onSubmit: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +/** A single file is selected, so step 1 shows its picker instead of the "no file" message. */ +export const Default: Story = { + render: () => , +}; + +/** No file selected yet: step 1 shows the empty-state prompt instead of the document picker. */ +export const NoFileSelected: Story = { + render: () => , +}; + +/** Session creation in flight: the review step's submit action is disabled. */ +export const Creating: Story = { + args: { + selectedFiles: [mockFile], + selectedUserIds: [1, 2], + onSelectedUserIdsChange: () => {}, + dueDate: "2026-08-01", + onDueDateChange: () => {}, + creating: true, + onSubmit: () => {}, + }, +}; diff --git a/frontend/editor/src/core/components/shared/signing/SharedSigningLauncher.stories.tsx b/frontend/editor/src/core/components/shared/signing/SharedSigningLauncher.stories.tsx new file mode 100644 index 0000000000..ae08d23771 --- /dev/null +++ b/frontend/editor/src/core/components/shared/signing/SharedSigningLauncher.stories.tsx @@ -0,0 +1,104 @@ +import type React from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { http, HttpResponse } from "msw"; +import SharedSigningLauncher from "@app/components/shared/signing/SharedSigningLauncher"; +import { AppConfigProvider } from "@app/contexts/AppConfigContext"; +import { PreferencesProvider } from "@app/contexts/PreferencesContext"; +import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider"; +import { NavigationProvider } from "@app/contexts/NavigationContext"; +import { ToolWorkflowProvider } from "@app/contexts/ToolWorkflowContext"; +import type { SignRequestSummary } from "@app/types/signingSession"; + +/** + * SharedSigningLauncher reads server config via AppConfigContext (whether + * group signing is enabled) and tool selection via ToolWorkflowContext (the + * "Open shared signing" click target), so both must wrap it here. + */ +function withProviders(groupSigningEnabled: boolean) { + return function Decorator(Story: () => React.JSX.Element) { + return ( + + + + + + + + + + + + ); + }; +} + +const meta = { + title: "Shared/Signing/SharedSigningLauncher", + component: SharedSigningLauncher, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +const noSignRequests: SignRequestSummary[] = []; + +const pendingSignRequests: SignRequestSummary[] = [ + { + sessionId: "session-1", + documentName: "NDA-acme-corp.pdf", + ownerUsername: "alex", + createdAt: "2026-07-01T09:00:00Z", + dueDate: "2026-07-20T00:00:00Z", + myStatus: "PENDING", + }, + { + sessionId: "session-2", + documentName: "vendor-agreement.pdf", + ownerUsername: "jordan", + createdAt: "2026-07-05T14:30:00Z", + dueDate: "2026-07-22T00:00:00Z", + myStatus: "VIEWED", + }, +]; + +/** Group signing enabled, no sign requests awaiting the user's action. */ +export const Default: Story = { + decorators: [withProviders(true)], + parameters: { + msw: { + handlers: [ + http.get("/api/v1/security/cert-sign/sign-requests", () => + HttpResponse.json(noSignRequests), + ), + http.get("/api/v1/security/cert-sign/sessions", () => + HttpResponse.json([]), + ), + ], + }, + }, +}; + +/** Two sign requests awaiting this user — the count badge appears on the button. */ +export const PendingRequests: Story = { + decorators: [withProviders(true)], + parameters: { + msw: { + handlers: [ + http.get("/api/v1/security/cert-sign/sign-requests", () => + HttpResponse.json(pendingSignRequests), + ), + http.get("/api/v1/security/cert-sign/sessions", () => + HttpResponse.json([]), + ), + ], + }, + }, +}; + +/** Group signing disabled on the server — the component renders nothing. */ +export const Disabled: Story = { + decorators: [withProviders(false)], +}; diff --git a/frontend/editor/src/core/components/shared/signing/steps/ConfigureSignatureDefaultsStep.stories.tsx b/frontend/editor/src/core/components/shared/signing/steps/ConfigureSignatureDefaultsStep.stories.tsx new file mode 100644 index 0000000000..b33c1e37b5 --- /dev/null +++ b/frontend/editor/src/core/components/shared/signing/steps/ConfigureSignatureDefaultsStep.stories.tsx @@ -0,0 +1,83 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ConfigureSignatureDefaultsStep } from "@app/components/shared/signing/steps/ConfigureSignatureDefaultsStep"; +import { SignatureSettings } from "@app/components/tools/certSign/SignatureSettingsInput"; + +const meta = { + title: "Shared/Signing/Steps/ConfigureSignatureDefaultsStep", + component: ConfigureSignatureDefaultsStep, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
    + +
    + ), + ], + args: { + settings: {}, + onSettingsChange: () => {}, + onBack: () => {}, + onNext: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function ConfigureDefaultsDemo({ + disabled, + initial, +}: { + disabled?: boolean; + initial: SignatureSettings; +}) { + const [settings, setSettings] = useState(initial); + return ( + {}} + onNext={() => {}} + disabled={disabled} + /> + ); +} + +export const Default: Story = { + render: () => ( + + ), +}; + +export const InvisibleSignature: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/core/components/shared/signing/steps/ReviewSessionStep.stories.tsx b/frontend/editor/src/core/components/shared/signing/steps/ReviewSessionStep.stories.tsx new file mode 100644 index 0000000000..60cacfd9ea --- /dev/null +++ b/frontend/editor/src/core/components/shared/signing/steps/ReviewSessionStep.stories.tsx @@ -0,0 +1,50 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ReviewSessionStep } from "@app/components/shared/signing/steps/ReviewSessionStep"; +import type { FileState } from "@app/types/file"; +import type { SignatureSettings } from "@app/components/tools/certSign/SignatureSettingsInput"; + +const selectedFile: FileState = { + name: "contract-agreement.pdf", + size: 2.4 * 1024 * 1024, +}; + +const signatureSettings: SignatureSettings = { + showSignature: true, + pageNumber: 1, + reason: "Approval of contract terms", + location: "London, UK", + showLogo: true, +}; + +const meta = { + title: "Shared/Signing/Steps/ReviewSessionStep", + component: ReviewSessionStep, + parameters: { layout: "padded" }, + args: { + selectedFile, + participantCount: 3, + signatureSettings, + dueDate: "2026-08-01", + onDueDateChange: () => {}, + onBack: () => {}, + onSubmit: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const InvisibleSignature: Story = { + args: { + signatureSettings: { + showSignature: false, + }, + }, +}; + +export const Disabled: Story = { + args: { + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/shared/signing/steps/SelectDocumentStep.stories.tsx b/frontend/editor/src/core/components/shared/signing/steps/SelectDocumentStep.stories.tsx new file mode 100644 index 0000000000..f8fbfacc1d --- /dev/null +++ b/frontend/editor/src/core/components/shared/signing/steps/SelectDocumentStep.stories.tsx @@ -0,0 +1,34 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { SelectDocumentStep } from "@app/components/shared/signing/steps/SelectDocumentStep"; +import type { FileState } from "@app/types/file"; + +const meta = { + title: "Shared/Signing/SelectDocumentStep", + component: SelectDocumentStep, + parameters: { layout: "padded" }, + args: { + selectedFiles: [] as FileState[], + onNext: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +const mockFile: FileState = { + name: "contract-agreement.pdf", + size: 2.4 * 1024 * 1024, +}; + +export const NoFileSelected: Story = {}; + +export const Default: Story = { + args: { + selectedFiles: [mockFile], + }, +}; + +export const MultipleFilesSelected: Story = { + args: { + selectedFiles: [mockFile, { name: "addendum.pdf", size: 512 * 1024 }], + }, +}; diff --git a/frontend/editor/src/core/components/shared/signing/steps/SelectParticipantsStep.stories.tsx b/frontend/editor/src/core/components/shared/signing/steps/SelectParticipantsStep.stories.tsx new file mode 100644 index 0000000000..7e74abb11c --- /dev/null +++ b/frontend/editor/src/core/components/shared/signing/steps/SelectParticipantsStep.stories.tsx @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { SelectParticipantsStep } from "@app/components/shared/signing/steps/SelectParticipantsStep"; + +const meta = { + title: "Shared/Signing/Steps/SelectParticipantsStep", + component: SelectParticipantsStep, + parameters: { layout: "padded" }, + args: { + selectedUserIds: [], + onSelectedUserIdsChange: () => {}, + onBack: () => {}, + onNext: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const WithSelection: Story = { + args: { selectedUserIds: [1, 2] }, +}; + +export const Disabled: Story = { + args: { disabled: true }, +}; diff --git a/frontend/editor/src/core/components/shared/sliderWithInput/SliderWithInput.stories.tsx b/frontend/editor/src/core/components/shared/sliderWithInput/SliderWithInput.stories.tsx new file mode 100644 index 0000000000..78f1710e69 --- /dev/null +++ b/frontend/editor/src/core/components/shared/sliderWithInput/SliderWithInput.stories.tsx @@ -0,0 +1,46 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import SliderWithInput from "@app/components/shared/sliderWithInput/SliderWithInput"; + +/** Reproduces the compression "Quality" slider used in tool settings panels. */ +const meta: Meta = { + title: "Shared/SliderWithInput", + component: SliderWithInput, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
    + +
    + ), + ], +}; +export default meta; +type Story = StoryObj; + +function SliderDemo({ + disabled, + initial = 60, +}: { + disabled?: boolean; + initial?: number; +}) { + const [value, setValue] = useState(initial); + return ( + + ); +} + +export const Default: Story = { render: () => }; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/shared/textInput/TextInput.module.css b/frontend/editor/src/core/components/shared/textInput/TextInput.module.css index 72fa8abb7b..b5131b014f 100644 --- a/frontend/editor/src/core/components/shared/textInput/TextInput.module.css +++ b/frontend/editor/src/core/components/shared/textInput/TextInput.module.css @@ -15,7 +15,7 @@ display: flex; align-items: center; justify-content: center; - color: var(--search-text-and-icon-color); + color: var(--c-text-subtle); } .input { @@ -27,12 +27,12 @@ outline: none; box-shadow: none; transition: box-shadow 0.2s ease; - background-color: var(--input-bg); - color: var(--search-text-and-icon-color); + background-color: var(--c-input-bg); + color: var(--c-text-subtle); } .input::placeholder { - color: var(--search-text-and-icon-color); + color: var(--c-text-subtle); opacity: 1; } @@ -65,13 +65,9 @@ justify-content: center; font-size: 16px; transition: background-color 0.2s ease; - color: var(--search-text-and-icon-color); + color: var(--c-text-subtle); } .clearButton:hover { - background-color: rgba(0, 0, 0, 0.1); -} - -[data-mantine-color-scheme="dark"] .clearButton:hover { - background-color: rgba(255, 255, 255, 0.1); + background-color: var(--c-hover); } diff --git a/frontend/editor/src/core/components/shared/tooltip/Tooltip.module.css b/frontend/editor/src/core/components/shared/tooltip/Tooltip.module.css index e2b5ecca10..fa4023dc56 100644 --- a/frontend/editor/src/core/components/shared/tooltip/Tooltip.module.css +++ b/frontend/editor/src/core/components/shared/tooltip/Tooltip.module.css @@ -1,9 +1,9 @@ /* Tooltip Container */ .tooltip-container { position: fixed; - border: 0.0625rem solid var(--border-default); + border: 0.0625rem solid var(--c-border); border-radius: 0.75rem; - background-color: var(--bg-raised); + background-color: var(--c-surface-raised); box-shadow: 0 0.625rem 0.9375rem -0.1875rem rgba(0, 0, 0, 0.1), 0 0.25rem 0.375rem -0.125rem rgba(0, 0, 0, 0.05); @@ -15,25 +15,25 @@ transform 100ms ease-out; max-width: 50vh; max-height: 80vh; - color: var(--text-primary); + color: var(--c-text); display: flex; flex-direction: column; } /* Pinned tooltip indicator */ .tooltip-container.pinned { - border-color: var(--primary-color, #3b82f6); + border-color: var(--primary-color, var(--c-primary)); box-shadow: 0 0.625rem 0.9375rem -0.1875rem rgba(0, 0, 0, 0.1), 0 0.25rem 0.375rem -0.125rem rgba(0, 0, 0, 0.05), - 0 0 0 0.125rem rgba(59, 130, 246, 0.1); + 0 0 0 0.125rem color-mix(in srgb, var(--c-primary) 10%, transparent); } /* Pinned tooltip header */ .tooltip-container.pinned .tooltip-header { - background-color: var(--primary-color, #3b82f6); + background-color: var(--primary-color, var(--c-primary)); color: white; - border-color: var(--primary-color, #3b82f6); + border-color: var(--primary-color, var(--c-primary)); } /* Close button */ @@ -42,10 +42,10 @@ top: 0.5rem; right: 0.5rem; font-size: 0.875rem; - background: var(--bg-raised); + background: var(--c-surface-raised); padding: 0.25rem; border-radius: 0.25rem; - border: 0.0625rem solid var(--border-default); + border: 0.0625rem solid var(--c-border); cursor: pointer; transition: background-color 0.2s ease, @@ -64,15 +64,15 @@ } .tooltip-pin-button:hover { - background-color: #ef4444 !important; - border-color: #ef4444 !important; + background-color: var(--c-danger) !important; + border-color: var(--c-danger) !important; } .tooltip-pin-button:focus, .tooltip-pin-button:focus-visible { outline: none; - border-color: var(--border-default) !important; - background-color: var(--bg-raised) !important; + border-color: var(--c-border) !important; + background-color: var(--c-surface-raised) !important; } /* Tooltip Header */ @@ -107,7 +107,7 @@ /* Tooltip Body */ .tooltip-body { padding: 1rem; - color: var(--text-primary) !important; + color: var(--c-text) !important; font-size: 0.875rem !important; line-height: 1.6 !important; overflow-y: auto; @@ -116,47 +116,49 @@ } .tooltip-body * { - color: var(--text-primary) !important; + color: var(--c-text) !important; } /* Link styling within tooltips */ .tooltip-body a { - color: var(--link-color, #3b82f6) !important; + color: var(--link-color, var(--c-primary)) !important; text-decoration: underline; - text-decoration-color: var(--link-underline-color, rgba(59, 130, 246, 0.3)); + text-decoration-color: var( + --link-underline-color, + color-mix(in srgb, var(--c-primary) 30%, transparent) + ); transition: color 0.2s ease, text-decoration-color 0.2s ease; } .tooltip-body a:hover { - color: var(--link-hover-color, #2563eb) !important; + color: var(--link-hover-color, var(--c-primary-hover)) !important; text-decoration-color: var( --link-hover-underline-color, - rgba(37, 99, 235, 0.5) + color-mix(in srgb, var(--c-primary-hover) 50%, transparent) ); } - -.tooltip-container .tooltip-body { - color: var(--text-primary) !important; -} - +.tooltip-container .tooltip-body, .tooltip-container .tooltip-body * { - color: var(--text-primary) !important; + color: var(--c-text) !important; } /* Ensure links maintain their styling */ .tooltip-container .tooltip-body a { - color: var(--link-color, #3b82f6) !important; + color: var(--link-color, var(--c-primary)) !important; text-decoration: underline; - text-decoration-color: var(--link-underline-color, rgba(59, 130, 246, 0.3)); + text-decoration-color: var( + --link-underline-color, + color-mix(in srgb, var(--c-primary) 30%, transparent) + ); } .tooltip-container .tooltip-body a:hover { - color: var(--link-hover-color, #2563eb) !important; + color: var(--link-hover-color, var(--c-primary-hover)) !important; text-decoration-color: var( --link-hover-underline-color, - rgba(37, 99, 235, 0.5) + color-mix(in srgb, var(--c-primary-hover) 50%, transparent) ); } @@ -165,8 +167,8 @@ position: absolute; width: 0.5rem; height: 0.5rem; - background: var(--bg-raised); - border: 0.0625rem solid var(--border-default); + background: var(--c-surface-raised); + border: 0.0625rem solid var(--c-border); transform: rotate(45deg); } diff --git a/frontend/editor/src/core/components/shared/tooltip/TooltipContent.stories.tsx b/frontend/editor/src/core/components/shared/tooltip/TooltipContent.stories.tsx new file mode 100644 index 0000000000..8f06789bac --- /dev/null +++ b/frontend/editor/src/core/components/shared/tooltip/TooltipContent.stories.tsx @@ -0,0 +1,63 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { TooltipContent } from "@app/components/shared/tooltip/TooltipContent"; +import type { TooltipTip } from "@app/types/tips"; + +const meta = { + title: "Shared/Tooltip/TooltipContent", + component: TooltipContent, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
    + +
    + ), + ], +} satisfies Meta; +export default meta; +type Story = StoryObj; + +const singleTip: TooltipTip[] = [ + { + title: "Tip", + description: + "Choose a page range before running the split to keep sections in order.", + bullets: [ + "Ranges use commas, e.g. 1-3,5", + "Leave blank to include all pages", + ], + }, +]; + +const multipleTips: TooltipTip[] = [ + { + title: "Step 1", + description: "Select the pages you want to extract.", + }, + { + title: "Step 2", + description: "Confirm the output order matches your expectations.", + bullets: ["Drag to reorder", "Remove any page with the trash icon"], + }, +]; + +/** Plain text content with no structured tips. */ +export const Default: Story = { + args: { + content: "Drag and drop files here, or click to browse your computer.", + }, +}; + +/** A single tip with a title, description, and bullet list. */ +export const SingleTip: Story = { + args: { + tips: singleTip, + }, +}; + +/** Multiple tips rendered as separate sections, each with its own spacing. */ +export const MultipleTips: Story = { + args: { + tips: multipleTips, + }, +}; diff --git a/frontend/editor/src/core/components/shared/tooltip/TooltipContent.tsx b/frontend/editor/src/core/components/shared/tooltip/TooltipContent.tsx index 08436799f9..28d18c4e4c 100644 --- a/frontend/editor/src/core/components/shared/tooltip/TooltipContent.tsx +++ b/frontend/editor/src/core/components/shared/tooltip/TooltipContent.tsx @@ -17,13 +17,13 @@ export const TooltipContent: React.FC = ({
    -
    +
    {tips ? ( <> {tips.map((tip, index) => ( @@ -35,8 +35,8 @@ export const TooltipContent: React.FC = ({
    = ({

    = ({ style={{ margin: "0", paddingLeft: "16px", - color: "var(--text-secondary)", + color: "var(--c-text-muted)", fontSize: "13px", }} > diff --git a/frontend/editor/src/core/components/shared/wetSignature/DrawSignatureCanvas.stories.tsx b/frontend/editor/src/core/components/shared/wetSignature/DrawSignatureCanvas.stories.tsx new file mode 100644 index 0000000000..1a4d260d08 --- /dev/null +++ b/frontend/editor/src/core/components/shared/wetSignature/DrawSignatureCanvas.stories.tsx @@ -0,0 +1,38 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { DrawSignatureCanvas } from "@app/components/shared/wetSignature/DrawSignatureCanvas"; + +const meta = { + title: "Shared/WetSignature/DrawSignatureCanvas", + component: DrawSignatureCanvas, + parameters: { layout: "padded" }, + args: { + signature: null, + onChange: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +function DrawSignatureCanvasDemo( + props: Partial>, +) { + const [signature, setSignature] = useState(null); + + return ( + + ); +} + +/** Empty canvas ready for the user to draw a signature. */ +export const Default: Story = { render: () => }; + +/** Disabled state — drawing and clearing are both blocked. */ +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/shared/wetSignature/SignatureTypeSelector.stories.tsx b/frontend/editor/src/core/components/shared/wetSignature/SignatureTypeSelector.stories.tsx new file mode 100644 index 0000000000..0c6cf99dcf --- /dev/null +++ b/frontend/editor/src/core/components/shared/wetSignature/SignatureTypeSelector.stories.tsx @@ -0,0 +1,37 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { + SignatureTypeSelector, + type SignatureType, +} from "@app/components/shared/wetSignature/SignatureTypeSelector"; + +const meta: Meta = { + title: "Shared/WetSignature/SignatureTypeSelector", + component: SignatureTypeSelector, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +function SignatureTypeSelectorDemo({ + initialValue = "draw", + disabled, +}: { + initialValue?: SignatureType; + disabled?: boolean; +}) { + const [value, setValue] = useState(initialValue); + return ( + + ); +} + +export const Default: Story = { render: () => }; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/shared/wetSignature/TypeSignatureText.stories.tsx b/frontend/editor/src/core/components/shared/wetSignature/TypeSignatureText.stories.tsx new file mode 100644 index 0000000000..b3996cbaa4 --- /dev/null +++ b/frontend/editor/src/core/components/shared/wetSignature/TypeSignatureText.stories.tsx @@ -0,0 +1,61 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { TypeSignatureText } from "@app/components/shared/wetSignature/TypeSignatureText"; + +const meta = { + title: "Shared/WetSignature/TypeSignatureText", + component: TypeSignatureText, + parameters: { layout: "padded" }, + args: { + text: "Jane Doe", + fontFamily: "Arial", + fontSize: 40, + color: "#000000", + onTextChange: () => {}, + onFontFamilyChange: () => {}, + onFontSizeChange: () => {}, + onColorChange: () => {}, + onSignatureChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function TypeSignatureTextDemo( + props: Partial>, +) { + const [text, setText] = useState(props.text ?? "Jane Doe"); + const [fontFamily, setFontFamily] = useState(props.fontFamily ?? "Arial"); + const [fontSize, setFontSize] = useState(props.fontSize ?? 40); + const [color, setColor] = useState(props.color ?? "#000000"); + + return ( + {})} + /> + ); +} + +/** Typed signature with text, font, size and colour controls plus a live preview. */ +export const Default: Story = { + render: () => , +}; + +/** No text entered yet, so the preview is hidden. */ +export const Empty: Story = { + render: () => , +}; + +/** All controls disabled, e.g. while the signature is being submitted. */ +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/shared/wetSignature/UploadSignatureImage.stories.tsx b/frontend/editor/src/core/components/shared/wetSignature/UploadSignatureImage.stories.tsx new file mode 100644 index 0000000000..4612e37442 --- /dev/null +++ b/frontend/editor/src/core/components/shared/wetSignature/UploadSignatureImage.stories.tsx @@ -0,0 +1,36 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { UploadSignatureImage } from "@app/components/shared/wetSignature/UploadSignatureImage"; + +const meta = { + title: "Shared/WetSignature/UploadSignatureImage", + component: UploadSignatureImage, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Empty: Story = { + args: { + signature: null, + onChange: () => {}, + }, +}; + +export const WithSignature: Story = { + args: { + signature: + "data:image/svg+xml;base64," + + btoa( + 'Jane Doe', + ), + onChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + signature: null, + onChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/toast/ToastRenderer.css b/frontend/editor/src/core/components/toast/ToastRenderer.css index 6ecd9e3dfd..9a9bbbe609 100644 --- a/frontend/editor/src/core/components/toast/ToastRenderer.css +++ b/frontend/editor/src/core/components/toast/ToastRenderer.css @@ -75,26 +75,26 @@ /* Toast Alert Type Colors */ .toast-item--success { background: var(--color-green-100); - color: var(--text-primary); + color: var(--c-text); border: 1px solid var(--color-green-400); } .toast-item--error { background: var(--color-red-100); - color: var(--text-primary); + color: var(--c-text); border: 1px solid var(--color-red-400); } .toast-item--warning { background: var(--color-yellow-100); - color: var(--text-primary); + color: var(--c-text); border: 1px solid var(--color-yellow-400); } .toast-item--neutral { - background: var(--bg-surface); - color: var(--text-primary); - border: 1px solid var(--border-default); + background: var(--c-surface); + color: var(--c-text); + border: 1px solid var(--c-border); } /* Toast Header Row */ @@ -146,7 +146,7 @@ border-radius: 999px; border: none; background: transparent; - color: var(--text-secondary); + color: var(--c-text-muted); cursor: pointer; display: flex; align-items: center; @@ -166,7 +166,7 @@ .toast-progress-container { margin-top: 8px; height: 6px; - background: var(--bg-muted); + background: var(--c-surface-sunken); border-radius: 999px; overflow: hidden; } @@ -217,21 +217,21 @@ } .toast-action-button--success { - color: var(--text-primary); + color: var(--c-text); border-color: var(--color-green-400); } .toast-action-button--error { - color: var(--text-primary); + color: var(--c-text); border-color: var(--color-red-400); } .toast-action-button--warning { - color: var(--text-primary); + color: var(--c-text); border-color: var(--color-yellow-400); } .toast-action-button--neutral { - color: var(--text-primary); - border-color: var(--border-default); + color: var(--c-text); + border-color: var(--c-border); } diff --git a/frontend/editor/src/core/components/toast/ToastRenderer.stories.tsx b/frontend/editor/src/core/components/toast/ToastRenderer.stories.tsx new file mode 100644 index 0000000000..ffed473833 --- /dev/null +++ b/frontend/editor/src/core/components/toast/ToastRenderer.stories.tsx @@ -0,0 +1,102 @@ +import { useEffect } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ToastRenderer from "@app/components/toast/ToastRenderer"; +import { ToastProvider, useToast } from "@app/components/toast/ToastContext"; +import type { ToastOptions } from "@app/components/toast/types"; + +// ToastRenderer takes no props — it renders whatever is in ToastContext. The +// only way to exercise it is to seed toasts through the same provider/show() +// API the app uses, then let the renderer subscribe to that context. +function SeedToasts({ + toasts, + children, +}: { + toasts: ToastOptions[]; + children: React.ReactNode; +}) { + const { show } = useToast(); + useEffect(() => { + toasts.forEach((toast) => show(toast)); + }, []); + return <>{children}; +} + +const meta = { + title: "Toast/ToastRenderer", + component: ToastRenderer, + parameters: { layout: "fullscreen" }, + decorators: [ + (Story) => ( + + + + ), + ], +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export const WithProgress: Story = { + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export const WithActionButton: Story = { + decorators: [ + (Story) => ( + {}, + }, + ]} + > + + + ), + ], +}; diff --git a/frontend/editor/src/core/components/tools/RightSidebar.tsx b/frontend/editor/src/core/components/tools/RightSidebar.tsx index 41110ac034..73ec5db69f 100644 --- a/frontend/editor/src/core/components/tools/RightSidebar.tsx +++ b/frontend/editor/src/core/components/tools/RightSidebar.tsx @@ -168,7 +168,7 @@ export default function RightSidebar() { ref={toolPanelRef} data-sidebar="tool-panel" data-tour={fullscreenExpanded ? undefined : "tool-panel"} - className={`tool-panel flex flex-col ${fullscreenExpanded ? "tool-panel--fullscreen-active" : "overflow-hidden"} bg-[var(--bg-toolbar)] border-l border-[var(--border-subtle)] transition-all duration-300 ease-out ${isMobile ? "h-full border-r-0" : "h-screen"} ${fullscreenExpanded ? "tool-panel--fullscreen" : ""}`} + className={`tool-panel flex flex-col ${fullscreenExpanded ? "tool-panel--fullscreen-active" : "overflow-hidden"} bg-[var(--c-bg-raised)] border-l border-[var(--c-border-subtle)] transition-all duration-300 ease-out ${isMobile ? "h-full border-r-0" : "h-screen"} ${fullscreenExpanded ? "tool-panel--fullscreen" : ""}`} style={{ width: computedWidth(), padding: "0", diff --git a/frontend/editor/src/core/components/tools/ToolLoadingFallback.stories.tsx b/frontend/editor/src/core/components/tools/ToolLoadingFallback.stories.tsx new file mode 100644 index 0000000000..4135be2c79 --- /dev/null +++ b/frontend/editor/src/core/components/tools/ToolLoadingFallback.stories.tsx @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ToolLoadingFallback from "@app/components/tools/ToolLoadingFallback"; + +const meta: Meta = { + title: "Tools/ToolLoadingFallback", + component: ToolLoadingFallback, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; + +export const WithToolName: Story = { + args: { + toolName: "Merge PDF", + }, +}; diff --git a/frontend/editor/src/core/components/tools/ToolPanel.css b/frontend/editor/src/core/components/tools/ToolPanel.css index e9cbb9b1b2..8a2d17f3fa 100644 --- a/frontend/editor/src/core/components/tools/ToolPanel.css +++ b/frontend/editor/src/core/components/tools/ToolPanel.css @@ -4,8 +4,8 @@ align-items: center; gap: 2px; padding: 4px 8px; - border-bottom: 1px solid var(--border-subtle); - background-color: var(--bg-toolbar); + border-bottom: 1px solid var(--c-border-subtle); + background-color: var(--c-bg-raised); flex-shrink: 0; } @@ -13,115 +13,107 @@ .tool-panel__fullscreen-surface-inner { --fullscreen-bg-surface-1: color-mix( in srgb, - var(--bg-toolbar) 96%, + var(--c-bg-raised) 96%, transparent ); - --fullscreen-bg-surface-2: color-mix( - in srgb, - var(--bg-background) 90%, - transparent - ); - --fullscreen-bg-header: var(--bg-toolbar); - --fullscreen-bg-controls-1: var(--bg-toolbar); + --fullscreen-bg-surface-2: color-mix(in srgb, var(--c-bg) 90%, transparent); + --fullscreen-bg-header: var(--c-bg-raised); + --fullscreen-bg-controls-1: var(--c-bg-raised); --fullscreen-bg-controls-2: color-mix( in srgb, - var(--bg-toolbar) 95%, - var(--bg-background) - ); - --fullscreen-bg-body-1: color-mix( - in srgb, - var(--bg-background) 86%, - transparent + var(--c-bg-raised) 95%, + var(--c-bg) ); + --fullscreen-bg-body-1: color-mix(in srgb, var(--c-bg) 86%, transparent); --fullscreen-bg-body-2: color-mix( in srgb, - var(--bg-toolbar) 78%, + var(--c-bg-raised) 78%, transparent ); - --fullscreen-bg-group: color-mix(in srgb, var(--bg-toolbar) 82%, transparent); - --fullscreen-bg-item: color-mix(in srgb, var(--bg-toolbar) 88%, transparent); + --fullscreen-bg-group: color-mix( + in srgb, + var(--c-bg-raised) 82%, + transparent + ); + --fullscreen-bg-item: color-mix(in srgb, var(--c-bg-raised) 88%, transparent); --fullscreen-bg-list-item: color-mix( in srgb, - var(--bg-toolbar) 86%, + var(--c-bg-raised) 86%, transparent ); --fullscreen-bg-icon-detailed: color-mix( in srgb, - var(--bg-muted) 75%, + var(--c-surface-sunken) 75%, transparent ); --fullscreen-bg-icon-compact: color-mix( in srgb, - var(--bg-muted) 70%, + var(--c-surface-sunken) 70%, transparent ); --fullscreen-border-subtle-75: color-mix( in srgb, - var(--border-subtle) 75%, + var(--c-border-subtle) 75%, transparent ); --fullscreen-border-subtle-70: color-mix( in srgb, - var(--border-subtle) 70%, + var(--c-border-subtle) 70%, transparent ); --fullscreen-border-subtle-65: color-mix( in srgb, - var(--border-subtle) 65%, + var(--c-border-subtle) 65%, transparent ); --fullscreen-border-favorites: color-mix( in srgb, var(--special-color-favorites) 25%, - var(--border-subtle) + var(--c-border-subtle) ); --fullscreen-border-recommended: color-mix( in srgb, var(--special-color-recommended) 25%, - var(--border-subtle) + var(--c-border-subtle) ); --fullscreen-shadow-primary: color-mix( in srgb, - var(--shadow-color, rgba(15, 23, 42, 0.55)) 25%, + var(--shadow-color, color-mix(in srgb, black 55%, transparent)) 25%, transparent ); --fullscreen-shadow-secondary: color-mix( in srgb, - var(--shadow-color, rgba(15, 23, 42, 0.35)) 30%, + var(--shadow-color, color-mix(in srgb, black 35%, transparent)) 30%, transparent ); --fullscreen-shadow-group: color-mix( in srgb, - var(--shadow-color, rgba(15, 23, 42, 0.45)) 18%, + var(--shadow-color, color-mix(in srgb, black 45%, transparent)) 18%, transparent ); --fullscreen-accent-hover: color-mix( in srgb, - var(--text-primary) 20%, - var(--border-subtle) + var(--c-text) 20%, + var(--c-border-subtle) ); --fullscreen-accent-selected: color-mix( in srgb, - var(--text-primary) 30%, - var(--border-subtle) - ); - --fullscreen-accent-ring: color-mix( - in srgb, - var(--text-primary) 15%, - transparent + var(--c-text) 30%, + var(--c-border-subtle) ); + --fullscreen-accent-ring: color-mix(in srgb, var(--c-text) 15%, transparent); --fullscreen-accent-list-bg: color-mix( in srgb, - var(--text-primary) 8%, - var(--bg-toolbar) + var(--c-text) 8%, + var(--c-bg-raised) ); --fullscreen-accent-list-border: color-mix( in srgb, - var(--text-primary) 20%, - var(--border-subtle) + var(--c-text) 20%, + var(--c-border-subtle) ); - --fullscreen-text-icon: var(--text-primary); - --fullscreen-text-icon-compact: var(--text-primary); + --fullscreen-text-icon: var(--c-text); + --fullscreen-text-icon-compact: var(--c-text); } .tool-panel { @@ -153,7 +145,7 @@ .tool-panel__collapsed-divider { height: 1px; - background: var(--border-subtle); + background: var(--c-border-subtle); margin: 0 0.5rem 8px; } @@ -178,7 +170,7 @@ border: 1px solid transparent; border-radius: 0.5rem; background: transparent; - color: var(--tools-text-and-icon-color); + color: var(--c-text); cursor: pointer; transition: background 120ms ease-out, @@ -198,8 +190,8 @@ .tool-panel__expand-btn { flex-shrink: 0; - color: var(--text-secondary) !important; - border-color: var(--border-subtle) !important; + color: var(--c-text-muted) !important; + border-color: var(--c-border-subtle) !important; } /* The collapse/expand toggle keeps a stable identity across the collapsed strip @@ -210,25 +202,25 @@ } .tool-panel__expand-btn svg { - color: var(--text-secondary) !important; + color: var(--c-text-muted) !important; } .tool-panel__expand-btn:hover { - color: var(--text-primary) !important; + color: var(--c-text) !important; border-color: var(--border) !important; } .tool-panel__collapsed-search-btn { flex-shrink: 0; - color: var(--text-secondary) !important; + color: var(--c-text-muted) !important; } .tool-panel__collapsed-search-btn svg { - color: var(--text-secondary) !important; + color: var(--c-text-muted) !important; } .tool-panel__collapsed-search-btn:hover { - color: var(--text-primary) !important; + color: var(--c-text) !important; } .tool-panel__back-btn { @@ -237,7 +229,7 @@ .tool-panel__back-bar { flex-shrink: 0; - border-bottom: 1px solid var(--border-subtle) !important; + border-bottom: 1px solid var(--c-border-subtle) !important; } .tool-panel--fullscreen-active { @@ -282,7 +274,7 @@ /* Search that separates the Policies section from the Tools list below it. */ .tool-panel__between-search { padding: 0.5rem 0.75rem 0.25rem; - border-top: 1px solid var(--border-subtle, var(--color-border)); + border-top: 1px solid var(--c-border-subtle, var(--c-border)); } .tool-panel__between-search .search-input-container { @@ -292,7 +284,7 @@ /* Slightly recessed search field so it reads as distinct from the panel. */ .tool-panel__between-search input { - background-color: var(--bg-muted); + background-color: var(--c-surface-sunken); } ::view-transition-old(tool-rail) { @@ -334,7 +326,7 @@ } .tool-panel--fullscreen { - background: var(--bg-toolbar); + background: var(--c-bg-raised); } .tool-panel__placeholder { @@ -342,7 +334,7 @@ display: flex; align-items: center; justify-content: center; - color: var(--text-muted); + color: var(--c-text-subtle); font-size: 0.9rem; padding: 1.5rem; text-align: center; @@ -403,8 +395,8 @@ align-items: center; gap: 1rem; padding: 0.75rem 1.75rem; - border-bottom: 1px solid var(--border-subtle); - background: var(--bg-toolbar); + border-bottom: 1px solid var(--c-border-subtle); + background: var(--c-bg-raised); } .tool-panel__fullscreen-brand { @@ -428,8 +420,8 @@ align-items: center; gap: 1rem; padding: 0.75rem 1.75rem; - border-bottom: 1px solid var(--tool-panel-search-border-bottom); - background: var(--tool-panel-search-bg); + border-bottom: 1px solid var(--c-border); + background: var(--c-surface-sunken); } .tool-panel__fullscreen-controls .search-input-container { @@ -546,7 +538,10 @@ .tool-panel__fullscreen-item:hover:not([aria-disabled="true"]):not(:disabled) { transform: translateY(-2px); border-color: var(--fullscreen-accent-hover); - box-shadow: var(--shadow-xl, 0 18px 34px rgba(15, 23, 42, 0.14)); + box-shadow: var( + --shadow-xl, + 0 18px 34px color-mix(in srgb, black 14%, transparent) + ); } .tool-panel__fullscreen-item--selected { @@ -619,8 +614,8 @@ inset: 0; background: linear-gradient( 135deg, - color-mix(in srgb, var(--text-primary) 12%, transparent), - color-mix(in srgb, var(--text-primary) 4%, transparent) + color-mix(in srgb, var(--c-text) 12%, transparent), + color-mix(in srgb, var(--c-text) 4%, transparent) ); opacity: 0; transition: opacity 0.2s ease; @@ -630,8 +625,7 @@ .tool-panel__fullscreen-item:hover:not([aria-disabled="true"]) .tool-panel__fullscreen-icon { transform: scale(1.08); - box-shadow: 0 4px 12px - color-mix(in srgb, var(--text-primary) 15%, transparent); + box-shadow: 0 4px 12px color-mix(in srgb, var(--c-text) 15%, transparent); } .tool-panel__fullscreen-item:hover:not([aria-disabled="true"]) @@ -656,7 +650,7 @@ } .tool-panel__fullscreen-name { - color: var(--text-primary); + color: var(--c-text); font-size: 13px !important; font-weight: 500 !important; } @@ -753,8 +747,8 @@ inset: 0; background: linear-gradient( 135deg, - color-mix(in srgb, var(--text-primary) 10%, transparent), - color-mix(in srgb, var(--text-primary) 3%, transparent) + color-mix(in srgb, var(--c-text) 10%, transparent), + color-mix(in srgb, var(--c-text) 3%, transparent) ); opacity: 0; transition: opacity 0.2s ease; @@ -764,7 +758,7 @@ .tool-panel__fullscreen-list-item:hover:not([aria-disabled="true"]) .tool-panel__fullscreen-list-icon { transform: scale(1.06); - box-shadow: 0 2px 8px color-mix(in srgb, var(--text-primary) 12%, transparent); + box-shadow: 0 2px 8px color-mix(in srgb, var(--c-text) 12%, transparent); } .tool-panel__fullscreen-list-item:hover:not([aria-disabled="true"]) diff --git a/frontend/editor/src/core/components/tools/ToolPanelModePrompt.css b/frontend/editor/src/core/components/tools/ToolPanelModePrompt.css index 2749abe537..58421faa4c 100644 --- a/frontend/editor/src/core/components/tools/ToolPanelModePrompt.css +++ b/frontend/editor/src/core/components/tools/ToolPanelModePrompt.css @@ -1,10 +1,10 @@ .tool-panel-mode-prompt__modal { - background: color-mix(in srgb, var(--bg-toolbar) 94%, transparent); - border: 1px solid color-mix(in srgb, var(--border-subtle) 70%, transparent); + background: color-mix(in srgb, var(--c-bg-raised) 94%, transparent); + border: 1px solid color-mix(in srgb, var(--c-border-subtle) 70%, transparent); box-shadow: 0 32px 64px color-mix( in srgb, - var(--shadow-color, rgba(15, 23, 42, 0.55)) 20%, + var(--shadow-color, color-mix(in srgb, black 55%, transparent)) 20%, transparent ); max-width: min(46rem, 100%); @@ -22,8 +22,8 @@ gap: 1rem; background: linear-gradient( 145deg, - color-mix(in srgb, var(--bg-surface) 96%, transparent), - color-mix(in srgb, var(--bg-muted) 70%, transparent) + color-mix(in srgb, var(--c-surface) 96%, transparent), + color-mix(in srgb, var(--c-surface-sunken) 70%, transparent) ); width: 100%; max-width: 19rem; @@ -31,28 +31,20 @@ .tool-panel-mode-prompt__card--sidebar { border: 1px solid - color-mix( - in srgb, - var(--accent-primary, var(--mantine-color-blue-6, #228be6)) 18%, - var(--border-subtle) - ); + color-mix(in srgb, var(--c-primary) 18%, var(--c-border-subtle)); background: linear-gradient( 165deg, - color-mix(in srgb, var(--bg-surface) 96%, transparent), - color-mix( - in srgb, - var(--accent-primary, var(--mantine-color-blue-6, #228be6)) 8%, - transparent - ) + color-mix(in srgb, var(--c-surface) 96%, transparent), + color-mix(in srgb, var(--c-primary) 8%, transparent) ); } .tool-panel-mode-prompt__preview { border-radius: 0.9rem; - border: 1px solid color-mix(in srgb, var(--border-subtle) 70%, transparent); + border: 1px solid color-mix(in srgb, var(--c-border-subtle) 70%, transparent); background: linear-gradient( 135deg, - color-mix(in srgb, var(--bg-muted) 82%, transparent), + color-mix(in srgb, var(--c-surface-sunken) 82%, transparent), transparent 75% ); padding: 0.75rem; @@ -76,54 +68,49 @@ gap: 0.45rem; background: linear-gradient( 180deg, - color-mix(in srgb, var(--bg-muted) 88%, transparent), - color-mix(in srgb, var(--bg-muted) 72%, transparent) + color-mix(in srgb, var(--c-surface-sunken) 88%, transparent), + color-mix(in srgb, var(--c-surface-sunken) 72%, transparent) ); - border: 1px solid color-mix(in srgb, var(--border-subtle) 65%, transparent); + border: 1px solid color-mix(in srgb, var(--c-border-subtle) 65%, transparent); } .tool-panel-mode-prompt__sidebar-search { height: 0.5rem; border-radius: 0.4rem; - background: color-mix(in srgb, var(--bg-background) 90%, transparent); - border: 1px solid color-mix(in srgb, var(--border-subtle) 60%, transparent); + background: color-mix(in srgb, var(--c-bg) 90%, transparent); + border: 1px solid color-mix(in srgb, var(--c-border-subtle) 60%, transparent); } .tool-panel-mode-prompt__sidebar-item { height: 0.55rem; border-radius: 0.35rem; - background: color-mix( - in srgb, - var(--accent-primary, var(--mantine-color-blue-6, #228be6)) 32%, - var(--bg-muted) - ); + background: color-mix(in srgb, var(--c-primary) 32%, var(--c-surface-sunken)); } .tool-panel-mode-prompt__sidebar-item--muted { - background: color-mix(in srgb, var(--bg-background) 88%, transparent); + background: color-mix(in srgb, var(--c-bg) 88%, transparent); } .tool-panel-mode-prompt__workspace { flex: 1; border-radius: 0.65rem; - border: 1px solid color-mix(in srgb, var(--border-subtle) 65%, transparent); + border: 1px solid color-mix(in srgb, var(--c-border-subtle) 65%, transparent); padding: 0.5rem; display: grid; gap: 0.35rem; grid-template-rows: 1.4fr 0.6fr; background: linear-gradient( 160deg, - color-mix(in srgb, var(--bg-background) 94%, transparent), - color-mix(in srgb, var(--bg-muted) 68%, transparent) + color-mix(in srgb, var(--c-bg) 94%, transparent), + color-mix(in srgb, var(--c-surface-sunken) 68%, transparent) ); } .tool-panel-mode-prompt__workspace-page { border-radius: 0.45rem; - background: color-mix(in srgb, var(--bg-surface) 96%, transparent); - border: 1px solid color-mix(in srgb, var(--border-subtle) 55%, transparent); - box-shadow: inset 0 0 0 1px - color-mix(in srgb, var(--bg-background) 60%, transparent); + background: color-mix(in srgb, var(--c-surface) 96%, transparent); + border: 1px solid color-mix(in srgb, var(--c-border-subtle) 55%, transparent); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--c-bg) 60%, transparent); } .tool-panel-mode-prompt__workspace-page--secondary { @@ -151,11 +138,11 @@ .tool-panel-mode-prompt__legacy-card { border-radius: 0.45rem; - border: 1px solid color-mix(in srgb, var(--border-subtle) 55%, transparent); + border: 1px solid color-mix(in srgb, var(--c-border-subtle) 55%, transparent); background: linear-gradient( 150deg, - color-mix(in srgb, var(--bg-muted) 88%, transparent), - color-mix(in srgb, var(--bg-background) 76%, transparent) + color-mix(in srgb, var(--c-surface-sunken) 88%, transparent), + color-mix(in srgb, var(--c-bg) 76%, transparent) ); height: 1.2rem; } @@ -185,11 +172,11 @@ .tool-panel-mode-prompt__fullscreen-card { border-radius: 0.45rem; - border: 1px solid color-mix(in srgb, var(--border-subtle) 55%, transparent); + border: 1px solid color-mix(in srgb, var(--c-border-subtle) 55%, transparent); background: linear-gradient( 150deg, - color-mix(in srgb, var(--bg-muted) 88%, transparent), - color-mix(in srgb, var(--bg-background) 76%, transparent) + color-mix(in srgb, var(--c-surface-sunken) 88%, transparent), + color-mix(in srgb, var(--c-bg) 76%, transparent) ); height: 1.2rem; } @@ -210,20 +197,15 @@ } .tool-panel-mode-prompt__action:hover { - box-shadow: 0 10px 18px - color-mix( - in srgb, - var(--accent-primary, var(--mantine-color-blue-6, #228be6)) 25%, - transparent - ); + box-shadow: 0 10px 18px color-mix(in srgb, var(--c-primary) 25%, transparent); } .tool-panel-mode-prompt__maybe-later { - color: color-mix(in srgb, var(--text-secondary) 90%, var(--text-muted)); + color: color-mix(in srgb, var(--c-text-muted) 90%, var(--c-text-subtle)); } .tool-panel-mode-prompt__maybe-later:hover { - background: color-mix(in srgb, var(--bg-muted) 78%, transparent); + background: color-mix(in srgb, var(--c-surface-sunken) 78%, transparent); } @media (max-width: 600px) { diff --git a/frontend/editor/src/core/components/tools/ToolPanelModePrompt.stories.tsx b/frontend/editor/src/core/components/tools/ToolPanelModePrompt.stories.tsx new file mode 100644 index 0000000000..2290784b00 --- /dev/null +++ b/frontend/editor/src/core/components/tools/ToolPanelModePrompt.stories.tsx @@ -0,0 +1,51 @@ +import type { ReactElement } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ToolPanelModePrompt from "@app/components/tools/ToolPanelModePrompt"; +import { PreferencesProvider } from "@app/contexts/PreferencesContext"; +import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider"; +import { NavigationProvider } from "@app/contexts/NavigationContext"; +import { ToolWorkflowProvider } from "@app/contexts/ToolWorkflowContext"; + +/** + * ToolWorkflowProvider reads navigation and tool-registry state on mount, so + * NavigationProvider and ToolRegistryProvider must wrap it; PreferencesProvider + * backs the persisted tool-panel-mode choice. + */ +function withProviders(Story: () => ReactElement) { + return ( + + + + + + + + + + ); +} + +const meta = { + title: "Tools/ToolPanelModePrompt", + component: ToolPanelModePrompt, + decorators: [withProviders], +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +/** Orchestrator controls visibility directly via `forceOpen`. */ +export const Default: Story = { + args: { + forceOpen: true, + onComplete: () => {}, + }, +}; + +/** Closed — nothing renders on top of the story canvas. */ +export const Closed: Story = { + args: { + forceOpen: false, + onComplete: () => {}, + }, +}; diff --git a/frontend/editor/src/core/components/tools/ToolPicker.tsx b/frontend/editor/src/core/components/tools/ToolPicker.tsx index 779e945b8c..6cda34aff2 100644 --- a/frontend/editor/src/core/components/tools/ToolPicker.tsx +++ b/frontend/editor/src/core/components/tools/ToolPicker.tsx @@ -37,7 +37,7 @@ const HEADER_TEXT_STYLE: React.CSSProperties = { padding: "0.25rem 0 0.35rem 0.5rem", textTransform: "uppercase", letterSpacing: "0.06em", - color: "var(--text-muted)", + color: "var(--c-text-subtle)", }; const SCROLLABLE_STYLE: React.CSSProperties = { flex: 1, @@ -50,7 +50,7 @@ const SCROLLABLE_STYLE: React.CSSProperties = { const CONTAINER_STYLE: React.CSSProperties = { display: "flex", flexDirection: "column", - background: "var(--bg-toolbar)", + background: "var(--c-bg-raised)", }; const toTitleCase = (s: string) => s.replace( diff --git a/frontend/editor/src/core/components/tools/ToolRenderer.stories.tsx b/frontend/editor/src/core/components/tools/ToolRenderer.stories.tsx new file mode 100644 index 0000000000..fbca2dfc4f --- /dev/null +++ b/frontend/editor/src/core/components/tools/ToolRenderer.stories.tsx @@ -0,0 +1,55 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { ReactElement } from "react"; +import ToolRenderer from "@app/components/tools/ToolRenderer"; +import { PreferencesProvider } from "@app/contexts/PreferencesContext"; +import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider"; +import { NavigationProvider } from "@app/contexts/NavigationContext"; +import { ToolWorkflowProvider } from "@app/contexts/ToolWorkflowContext"; + +/** + * ToolWorkflowContext reads the tool registry, preferences, and navigation + * state, so all three providers must be present above it for ToolRenderer + * to resolve a tool. + */ +function withProviders(Story: () => ReactElement) { + return ( + + + + + + + + + + ); +} + +const meta = { + title: "Tools/ToolRenderer", + component: ToolRenderer, + decorators: [withProviders], +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +/** A registered tool with a component renders its lazy-loaded settings UI. */ +export const Default: Story = { + args: { + selectedToolKey: "compress", + onPreviewFile: () => {}, + onComplete: () => {}, + onError: () => {}, + }, +}; + +/** An unknown tool key falls back to the "Tool not found" message. */ +export const ToolNotFound: Story = { + args: { + selectedToolKey: "not-a-real-tool" as Story["args"]["selectedToolKey"], + onPreviewFile: () => {}, + onComplete: () => {}, + onError: () => {}, + }, +}; diff --git a/frontend/editor/src/core/components/tools/addPageNumbers/PageNumberPreview.module.css b/frontend/editor/src/core/components/tools/addPageNumbers/PageNumberPreview.module.css index 8a20a78340..d2ae85a88b 100644 --- a/frontend/editor/src/core/components/tools/addPageNumbers/PageNumberPreview.module.css +++ b/frontend/editor/src/core/components/tools/addPageNumbers/PageNumberPreview.module.css @@ -16,7 +16,7 @@ } .containerBorder { - border: 1px solid var(--border-default, #333); + border: 1px solid var(--c-border); } /* Page thumbnail styles */ @@ -105,8 +105,8 @@ .gridTileSelected, .gridTileHovered { - border: 2px solid var(--mantine-primary-color-filled, #3b82f6); - background-color: rgba(59, 130, 246, 0.2); + border: 2px solid var(--c-primary); + background-color: color-mix(in srgb, var(--c-primary) 20%, transparent); } /* Preview header */ @@ -116,14 +116,14 @@ .divider { height: 1px; - background-color: var(--border-default, #333); + background-color: var(--c-border); margin-bottom: 8px; } .previewLabel { font-size: 14px; font-weight: 500; - color: var(--text-primary); + color: var(--c-text); text-align: center; } diff --git a/frontend/editor/src/core/components/tools/addPageNumbers/PageNumberPreview.stories.tsx b/frontend/editor/src/core/components/tools/addPageNumbers/PageNumberPreview.stories.tsx new file mode 100644 index 0000000000..c4dcc8a202 --- /dev/null +++ b/frontend/editor/src/core/components/tools/addPageNumbers/PageNumberPreview.stories.tsx @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import PageNumberPreview from "@app/components/tools/addPageNumbers/PageNumberPreview"; +import { defaultParameters } from "@app/components/tools/addPageNumbers/useAddPageNumbersParameters"; + +const meta = { + title: "Tools/AddPageNumbers/PageNumberPreview", + component: PageNumberPreview, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +}; + +export const WithQuickGrid: Story = { + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + showQuickGrid: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/addPageNumbers/PageNumberPreview.tsx b/frontend/editor/src/core/components/tools/addPageNumbers/PageNumberPreview.tsx index fe92d1ff42..6e35b286a9 100644 --- a/frontend/editor/src/core/components/tools/addPageNumbers/PageNumberPreview.tsx +++ b/frontend/editor/src/core/components/tools/addPageNumbers/PageNumberPreview.tsx @@ -220,7 +220,7 @@ export default function PageNumberPreview({ width: "100%", aspectRatio: `${(pageSize?.widthPts ?? 595.28) / (pageSize?.heightPts ?? 841.89)} / 1`, backgroundColor: pageThumbnail ? "white" : "rgba(255,255,255,0.03)", - border: "1px solid var(--border-default, #333)", + border: "1px solid var(--c-border, #333)", overflow: "hidden" as const, }), [pageSize, pageThumbnail], diff --git a/frontend/editor/src/core/components/tools/addPassword/AddPasswordSettings.stories.tsx b/frontend/editor/src/core/components/tools/addPassword/AddPasswordSettings.stories.tsx new file mode 100644 index 0000000000..be16c2037c --- /dev/null +++ b/frontend/editor/src/core/components/tools/addPassword/AddPasswordSettings.stories.tsx @@ -0,0 +1,70 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import AddPasswordSettings from "@app/components/tools/addPassword/AddPasswordSettings"; +import { AddPasswordParameters } from "@app/hooks/tools/addPassword/useAddPasswordParameters"; + +const meta = { + title: "Tools/AddPassword/AddPasswordSettings", + component: AddPasswordSettings, + args: { + parameters: { password: "", ownerPassword: "", keyLength: 128 }, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +// The component owns no state itself, so each story wraps it in a small +// stateful shim to keep the password/key-length inputs interactive in the canvas. +const AddPasswordSettingsDemo = (props: { + initialParameters: AddPasswordParameters; + disabled?: boolean; +}) => { + const [parameters, setParameters] = useState( + props.initialParameters, + ); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={props.disabled} + /> + ); +}; + +export const Default: Story = { + render: () => ( + + ), +}; + +export const Filled: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/core/components/tools/addStamp/StampPreview.module.css b/frontend/editor/src/core/components/tools/addStamp/StampPreview.module.css index 7e740f6f51..dccf897eaa 100644 --- a/frontend/editor/src/core/components/tools/addStamp/StampPreview.module.css +++ b/frontend/editor/src/core/components/tools/addStamp/StampPreview.module.css @@ -16,7 +16,7 @@ } .containerBorder { - border: 1px solid var(--border-default, #333); + border: 1px solid var(--c-border); } /* Page thumbnail styles */ @@ -95,7 +95,7 @@ .gridTileSelected, .gridTileHovered { - border: 2px solid var(--mantine-primary-color-filled, #3b82f6); + border: 2px solid var(--c-primary); } /* Preview header */ @@ -105,14 +105,14 @@ .divider { height: 1px; - background-color: var(--border-default, #333); + background-color: var(--c-border); margin-bottom: 8px; } .previewLabel { font-size: 14px; font-weight: 500; - color: var(--text-primary); + color: var(--c-text); text-align: center; } @@ -127,7 +127,7 @@ /* Information text container */ .informationContainer { - background-color: var(--information-text-bg); + background-color: var(--c-surface); padding: 2px; padding-left: 8px; padding-right: 8px; diff --git a/frontend/editor/src/core/components/tools/addStamp/StampPreview.stories.tsx b/frontend/editor/src/core/components/tools/addStamp/StampPreview.stories.tsx new file mode 100644 index 0000000000..74a8c95690 --- /dev/null +++ b/frontend/editor/src/core/components/tools/addStamp/StampPreview.stories.tsx @@ -0,0 +1,36 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import StampPreview from "@app/components/tools/addStamp/StampPreview"; +import { defaultParameters } from "@app/components/tools/addStamp/useAddStampParameters"; + +const meta = { + title: "Tools/AddStamp/StampPreview", + component: StampPreview, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +}; + +export const WithText: Story = { + args: { + parameters: { + ...defaultParameters, + stampText: "CONFIDENTIAL", + }, + onParameterChange: () => {}, + }, +}; + +export const WithQuickGrid: Story = { + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + showQuickGrid: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/addStamp/StampPreviewUtils.ts b/frontend/editor/src/core/components/tools/addStamp/StampPreviewUtils.ts index 97120c0fcf..8ac3c73551 100644 --- a/frontend/editor/src/core/components/tools/addStamp/StampPreviewUtils.ts +++ b/frontend/editor/src/core/components/tools/addStamp/StampPreviewUtils.ts @@ -280,7 +280,7 @@ export function computeStampPreviewStyle( width: "100%", aspectRatio: `${(pageSize?.widthPts ?? 595.28) / (pageSize?.heightPts ?? 841.89)} / 1`, backgroundColor: hasPageThumbnail ? "white" : "rgba(255,255,255,0.03)", - border: "1px solid var(--border-default, #333)", + border: "1px solid var(--c-border, #333)", overflow: "hidden", }, item: { diff --git a/frontend/editor/src/core/components/tools/addStamp/StampSetupSettings.stories.tsx b/frontend/editor/src/core/components/tools/addStamp/StampSetupSettings.stories.tsx new file mode 100644 index 0000000000..b50df8c70f --- /dev/null +++ b/frontend/editor/src/core/components/tools/addStamp/StampSetupSettings.stories.tsx @@ -0,0 +1,70 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import StampSetupSettings from "@app/components/tools/addStamp/StampSetupSettings"; +import { + AddStampParameters, + defaultParameters, +} from "@app/components/tools/addStamp/useAddStampParameters"; + +const meta = { + title: "Tools/AddStamp/StampSetupSettings", + component: StampSetupSettings, + parameters: { layout: "padded" }, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function Demo({ + initialParameters = defaultParameters, + disabled, + filename, +}: { + initialParameters?: AddStampParameters; + disabled?: boolean; + filename?: string; +}) { + const [parameters, setParameters] = + useState(initialParameters); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + filename={filename} + /> + ); +} + +export const Default: Story = { render: () => }; + +export const TextStampWithPreview: Story = { + render: () => ( + + ), +}; + +export const ImageStamp: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { render: () => }; diff --git a/frontend/editor/src/core/components/tools/addWatermark/AddWatermarkSingleStepSettings.stories.tsx b/frontend/editor/src/core/components/tools/addWatermark/AddWatermarkSingleStepSettings.stories.tsx new file mode 100644 index 0000000000..1476e951e5 --- /dev/null +++ b/frontend/editor/src/core/components/tools/addWatermark/AddWatermarkSingleStepSettings.stories.tsx @@ -0,0 +1,87 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import AddWatermarkSingleStepSettings from "@app/components/tools/addWatermark/AddWatermarkSingleStepSettings"; +import { + AddWatermarkParameters, + defaultParameters, +} from "@app/hooks/tools/addWatermark/useAddWatermarkParameters"; + +const meta = { + title: "Tools/AddWatermark/AddWatermarkSingleStepSettings", + component: AddWatermarkSingleStepSettings, + parameters: { layout: "padded" }, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function SettingsDemo({ + initialParameters = defaultParameters, + disabled, + showFlatten, + textOnly, +}: { + initialParameters?: AddWatermarkParameters; + disabled?: boolean; + showFlatten?: boolean; + textOnly?: boolean; +}) { + const [parameters, setParameters] = + useState(initialParameters); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + showFlatten={showFlatten} + textOnly={textOnly} + /> + ); +} + +export const Default: Story = { + render: () => , +}; + +export const TextWatermark: Story = { + render: () => ( + + ), +}; + +export const TextOnly: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/core/components/tools/addWatermark/WatermarkFormatting.stories.tsx b/frontend/editor/src/core/components/tools/addWatermark/WatermarkFormatting.stories.tsx new file mode 100644 index 0000000000..6e9e099c5d --- /dev/null +++ b/frontend/editor/src/core/components/tools/addWatermark/WatermarkFormatting.stories.tsx @@ -0,0 +1,32 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import WatermarkFormatting from "@app/components/tools/addWatermark/WatermarkFormatting"; +import { defaultParameters } from "@app/hooks/tools/addWatermark/useAddWatermarkParameters"; + +const meta = { + title: "Tools/AddWatermark/WatermarkFormatting", + component: WatermarkFormatting, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + parameters: { ...defaultParameters, watermarkType: "text" }, + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + ...Default.args, + disabled: true, + }, +}; + +export const WithoutFlattenOption: Story = { + args: { + ...Default.args, + showFlatten: false, + }, +}; diff --git a/frontend/editor/src/core/components/tools/addWatermark/WatermarkImageFile.stories.tsx b/frontend/editor/src/core/components/tools/addWatermark/WatermarkImageFile.stories.tsx new file mode 100644 index 0000000000..b5568e1779 --- /dev/null +++ b/frontend/editor/src/core/components/tools/addWatermark/WatermarkImageFile.stories.tsx @@ -0,0 +1,72 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import WatermarkImageFile from "@app/components/tools/addWatermark/WatermarkImageFile"; +import { + AddWatermarkParameters, + defaultParameters, +} from "@app/hooks/tools/addWatermark/useAddWatermarkParameters"; + +const meta = { + title: "Tools/AddWatermark/WatermarkImageFile", + component: WatermarkImageFile, + args: { + parameters: { ...defaultParameters, watermarkType: "image" }, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +const makeImageFile = (name: string, sizeBytes: number): File => + new File([new Uint8Array(sizeBytes)], name, { type: "image/png" }); + +// The component owns no state itself, so each story wraps it in a small +// stateful shim to keep the file picker interaction working in the canvas. +const WatermarkImageFileDemo = (props: { + initialParameters: AddWatermarkParameters; + disabled?: boolean; +}) => { + const [parameters, setParameters] = useState( + props.initialParameters, + ); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={props.disabled} + /> + ); +}; + +export const Default: Story = { + render: () => ( + + ), +}; + +export const WithSelectedImage: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/core/components/tools/addWatermark/WatermarkStyleSettings.stories.tsx b/frontend/editor/src/core/components/tools/addWatermark/WatermarkStyleSettings.stories.tsx new file mode 100644 index 0000000000..316e377511 --- /dev/null +++ b/frontend/editor/src/core/components/tools/addWatermark/WatermarkStyleSettings.stories.tsx @@ -0,0 +1,38 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import WatermarkStyleSettings from "@app/components/tools/addWatermark/WatermarkStyleSettings"; +import { + AddWatermarkParameters, + defaultParameters, +} from "@app/hooks/tools/addWatermark/useAddWatermarkParameters"; + +const meta: Meta = { + title: "Tools/AddWatermark/WatermarkStyleSettings", + component: WatermarkStyleSettings, +}; +export default meta; +type Story = StoryObj; + +function WatermarkStyleSettingsDemo({ disabled }: { disabled?: boolean }) { + const [parameters, setParameters] = useState({ + ...defaultParameters, + watermarkType: "text", + watermarkText: "CONFIDENTIAL", + }); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { render: () => }; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/addWatermark/WatermarkTextStyle.stories.tsx b/frontend/editor/src/core/components/tools/addWatermark/WatermarkTextStyle.stories.tsx new file mode 100644 index 0000000000..5bee6d393a --- /dev/null +++ b/frontend/editor/src/core/components/tools/addWatermark/WatermarkTextStyle.stories.tsx @@ -0,0 +1,41 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import WatermarkTextStyle from "@app/components/tools/addWatermark/WatermarkTextStyle"; +import { + AddWatermarkParameters, + defaultParameters, +} from "@app/hooks/tools/addWatermark/useAddWatermarkParameters"; + +const meta: Meta = { + title: "AddWatermark/WatermarkTextStyle", + component: WatermarkTextStyle, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +function WatermarkTextStyleDemo({ + initialParameters = defaultParameters, + disabled, +}: { + initialParameters?: AddWatermarkParameters; + disabled?: boolean; +}) { + const [parameters, setParameters] = + useState(initialParameters); + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { render: () => }; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/addWatermark/WatermarkTypeSettings.stories.tsx b/frontend/editor/src/core/components/tools/addWatermark/WatermarkTypeSettings.stories.tsx new file mode 100644 index 0000000000..9ce7f4ff4b --- /dev/null +++ b/frontend/editor/src/core/components/tools/addWatermark/WatermarkTypeSettings.stories.tsx @@ -0,0 +1,28 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import WatermarkTypeSettings from "@app/components/tools/addWatermark/WatermarkTypeSettings"; + +const meta: Meta = { + title: "Tools/AddWatermark/WatermarkTypeSettings", + component: WatermarkTypeSettings, +}; +export default meta; +type Story = StoryObj; + +function WatermarkTypeSettingsDemo({ disabled }: { disabled?: boolean }) { + const [watermarkType, setWatermarkType] = useState<"text" | "image">("text"); + + return ( + + ); +} + +export const Default: Story = { render: () => }; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/addWatermark/WatermarkWording.stories.tsx b/frontend/editor/src/core/components/tools/addWatermark/WatermarkWording.stories.tsx new file mode 100644 index 0000000000..c95c2f37f1 --- /dev/null +++ b/frontend/editor/src/core/components/tools/addWatermark/WatermarkWording.stories.tsx @@ -0,0 +1,53 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import WatermarkWording from "@app/components/tools/addWatermark/WatermarkWording"; +import { + AddWatermarkParameters, + defaultParameters, +} from "@app/hooks/tools/addWatermark/useAddWatermarkParameters"; + +const meta: Meta = { + title: "AddWatermark/WatermarkWording", + component: WatermarkWording, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +function WatermarkWordingDemo({ + initialText = "", + disabled, +}: { + initialText?: string; + disabled?: boolean; +}) { + const [parameters, setParameters] = useState({ + ...defaultParameters, + watermarkText: initialText, + }); + + const handleParameterChange = ( + key: K, + value: AddWatermarkParameters[K], + ) => { + setParameters((prev) => ({ ...prev, [key]: value })); + }; + + return ( + + ); +} + +export const Default: Story = { render: () => }; + +export const Filled: Story = { + render: () => , +}; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/adjustContrast/AdjustContrastBasicSettings.stories.tsx b/frontend/editor/src/core/components/tools/adjustContrast/AdjustContrastBasicSettings.stories.tsx new file mode 100644 index 0000000000..c4b4ecef9b --- /dev/null +++ b/frontend/editor/src/core/components/tools/adjustContrast/AdjustContrastBasicSettings.stories.tsx @@ -0,0 +1,57 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import AdjustContrastBasicSettings from "@app/components/tools/adjustContrast/AdjustContrastBasicSettings"; +import { + AdjustContrastParameters, + defaultParameters, +} from "@app/hooks/tools/adjustContrast/useAdjustContrastParameters"; + +const meta = { + title: "Tools/AdjustContrast/AdjustContrastBasicSettings", + component: AdjustContrastBasicSettings, + parameters: { layout: "padded" }, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function Demo({ + initialParameters = defaultParameters, + disabled, +}: { + initialParameters?: AdjustContrastParameters; + disabled?: boolean; +}) { + const [parameters, setParameters] = + useState(initialParameters); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { render: () => }; + +export const Adjusted: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { render: () => }; diff --git a/frontend/editor/src/core/components/tools/adjustContrast/AdjustContrastColorSettings.stories.tsx b/frontend/editor/src/core/components/tools/adjustContrast/AdjustContrastColorSettings.stories.tsx new file mode 100644 index 0000000000..13155ae524 --- /dev/null +++ b/frontend/editor/src/core/components/tools/adjustContrast/AdjustContrastColorSettings.stories.tsx @@ -0,0 +1,57 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import AdjustContrastColorSettings from "@app/components/tools/adjustContrast/AdjustContrastColorSettings"; +import { + AdjustContrastParameters, + defaultParameters, +} from "@app/hooks/tools/adjustContrast/useAdjustContrastParameters"; + +const meta = { + title: "Tools/AdjustContrast/AdjustContrastColorSettings", + component: AdjustContrastColorSettings, + parameters: { layout: "padded" }, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function Demo({ + initialParameters = defaultParameters, + disabled, +}: { + initialParameters?: AdjustContrastParameters; + disabled?: boolean; +}) { + const [parameters, setParameters] = + useState(initialParameters); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { render: () => }; + +export const Adjusted: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { render: () => }; diff --git a/frontend/editor/src/core/components/tools/adjustContrast/AdjustContrastPreview.stories.tsx b/frontend/editor/src/core/components/tools/adjustContrast/AdjustContrastPreview.stories.tsx new file mode 100644 index 0000000000..e1d605188f --- /dev/null +++ b/frontend/editor/src/core/components/tools/adjustContrast/AdjustContrastPreview.stories.tsx @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import AdjustContrastPreview from "@app/components/tools/adjustContrast/AdjustContrastPreview"; +import { defaultParameters } from "@app/hooks/tools/adjustContrast/useAdjustContrastParameters"; + +const meta = { + title: "Tools/AdjustContrast/AdjustContrastPreview", + component: AdjustContrastPreview, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +// No file selected yet: the component shows the obscured "select a PDF" state +// without attempting any thumbnail generation, which needs a live PDF worker. +export const Default: Story = { + args: { + file: null, + parameters: defaultParameters, + }, +}; diff --git a/frontend/editor/src/core/components/tools/adjustContrast/AdjustContrastSingleStepSettings.stories.tsx b/frontend/editor/src/core/components/tools/adjustContrast/AdjustContrastSingleStepSettings.stories.tsx new file mode 100644 index 0000000000..6352b0fe15 --- /dev/null +++ b/frontend/editor/src/core/components/tools/adjustContrast/AdjustContrastSingleStepSettings.stories.tsx @@ -0,0 +1,60 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import AdjustContrastSingleStepSettings from "@app/components/tools/adjustContrast/AdjustContrastSingleStepSettings"; +import { + AdjustContrastParameters, + defaultParameters, +} from "@app/hooks/tools/adjustContrast/useAdjustContrastParameters"; + +const meta = { + title: "Tools/AdjustContrast/AdjustContrastSingleStepSettings", + component: AdjustContrastSingleStepSettings, + parameters: { layout: "padded" }, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function Demo({ + initialParameters = defaultParameters, + disabled, +}: { + initialParameters?: AdjustContrastParameters; + disabled?: boolean; +}) { + const [parameters, setParameters] = + useState(initialParameters); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { render: () => }; + +export const Adjusted: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { render: () => }; diff --git a/frontend/editor/src/core/components/tools/adjustPageScale/AdjustPageScaleSettings.stories.tsx b/frontend/editor/src/core/components/tools/adjustPageScale/AdjustPageScaleSettings.stories.tsx new file mode 100644 index 0000000000..00d3aaba85 --- /dev/null +++ b/frontend/editor/src/core/components/tools/adjustPageScale/AdjustPageScaleSettings.stories.tsx @@ -0,0 +1,46 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import AdjustPageScaleSettings from "@app/components/tools/adjustPageScale/AdjustPageScaleSettings"; +import { + AdjustPageScaleParameters, + PageSize, +} from "@app/hooks/tools/adjustPageScale/useAdjustPageScaleParameters"; + +const meta = { + title: "Tools/AdjustPageScale/AdjustPageScaleSettings", + component: AdjustPageScaleSettings, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +const defaultParameters: AdjustPageScaleParameters = { + scaleFactor: 1.0, + pageSize: PageSize.KEEP, + orientation: "PORTRAIT", +}; + +export const Default: Story = { + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +}; + +export const CustomPageSize: Story = { + args: { + parameters: { + scaleFactor: 2.5, + pageSize: PageSize.A4, + orientation: "LANDSCAPE", + }, + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/autoRename/AutoRenameSettings.stories.tsx b/frontend/editor/src/core/components/tools/autoRename/AutoRenameSettings.stories.tsx new file mode 100644 index 0000000000..6e632564c1 --- /dev/null +++ b/frontend/editor/src/core/components/tools/autoRename/AutoRenameSettings.stories.tsx @@ -0,0 +1,30 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import AutoRenameSettings from "@app/components/tools/autoRename/AutoRenameSettings"; +import { AutoRenameParameters } from "@app/hooks/tools/autoRename/useAutoRenameParameters"; + +const meta = { + title: "Tools/AutoRename/AutoRenameSettings", + component: AutoRenameSettings, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +const baseParameters: AutoRenameParameters = { + useFirstTextAsFallback: false, +}; + +export const Default: Story = { + args: { + parameters: baseParameters, + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + parameters: baseParameters, + onParameterChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/automate/AutomationCreation.stories.tsx b/frontend/editor/src/core/components/tools/automate/AutomationCreation.stories.tsx new file mode 100644 index 0000000000..8299f7222c --- /dev/null +++ b/frontend/editor/src/core/components/tools/automate/AutomationCreation.stories.tsx @@ -0,0 +1,59 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import AutomationCreation from "@app/components/tools/automate/AutomationCreation"; +import { AutomationMode } from "@app/types/automation"; +import type { AutomationConfig } from "@app/types/automation"; +import type { ToolRegistry } from "@app/data/toolsTaxonomy"; + +const emptyToolRegistry: Partial = {}; + +const existingAutomation: AutomationConfig = { + id: "automation-1", + name: "Weekly Cleanup", + description: "Compress and flatten incoming PDFs.", + icon: "CompressIcon", + operations: [ + { operation: "compress", parameters: {} }, + { operation: "flatten", parameters: {} }, + ], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), +}; + +const meta = { + title: "Tools/Automate/AutomationCreation", + component: AutomationCreation, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + mode: AutomationMode.CREATE, + onBack: () => {}, + onComplete: () => {}, + toolRegistry: emptyToolRegistry, + }, +}; + +export const EditExisting: Story = { + args: { + mode: AutomationMode.EDIT, + existingAutomation, + onBack: () => {}, + onComplete: () => {}, + toolRegistry: emptyToolRegistry, + }, +}; + +export const EmbeddedHideMetadata: Story = { + args: { + mode: AutomationMode.CREATE, + hideMetadata: true, + nameOverride: "Watched Folder Automation", + onBack: () => {}, + onComplete: () => {}, + onSaveFailed: () => {}, + toolRegistry: emptyToolRegistry, + }, +}; diff --git a/frontend/editor/src/core/components/tools/automate/AutomationEntry.tsx b/frontend/editor/src/core/components/tools/automate/AutomationEntry.tsx index 322d2d87fc..8ffbfeda81 100644 --- a/frontend/editor/src/core/components/tools/automate/AutomationEntry.tsx +++ b/frontend/editor/src/core/components/tools/automate/AutomationEntry.tsx @@ -191,11 +191,9 @@ export default function AutomationEntry({ className="tool-button" style={{ borderRadius: 0, - color: "var(--tools-text-and-icon-color)", + color: "var(--c-text)", overflow: "visible", - backgroundColor: shouldShowMenu - ? "var(--automation-entry-hover-bg)" - : undefined, + backgroundColor: shouldShowMenu ? "var(--c-hover)" : undefined, }} > {buttonContent} diff --git a/frontend/editor/src/core/components/tools/automate/AutomationImportModal.stories.tsx b/frontend/editor/src/core/components/tools/automate/AutomationImportModal.stories.tsx new file mode 100644 index 0000000000..b3fea378df --- /dev/null +++ b/frontend/editor/src/core/components/tools/automate/AutomationImportModal.stories.tsx @@ -0,0 +1,31 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import AutomationImportModal from "@app/components/tools/automate/AutomationImportModal"; +import type { ToolRegistry } from "@app/data/toolsTaxonomy"; + +const emptyToolRegistry: Partial = {}; + +const meta = { + title: "Tools/Automate/AutomationImportModal", + component: AutomationImportModal, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + opened: true, + toolRegistry: emptyToolRegistry, + onCancel: () => {}, + onImport: () => {}, + }, +}; + +export const Closed: Story = { + args: { + opened: false, + toolRegistry: emptyToolRegistry, + onCancel: () => {}, + onImport: () => {}, + }, +}; diff --git a/frontend/editor/src/core/components/tools/automate/AutomationRun.tsx b/frontend/editor/src/core/components/tools/automate/AutomationRun.tsx index e59996ab8f..4f63a80d3c 100644 --- a/frontend/editor/src/core/components/tools/automate/AutomationRun.tsx +++ b/frontend/editor/src/core/components/tools/automate/AutomationRun.tsx @@ -153,7 +153,7 @@ export default function AutomationRun({ style={{ width: 16, height: 16, - border: "2px solid #ccc", + border: "2px solid var(--c-border)", borderRadius: "50%", }} /> diff --git a/frontend/editor/src/core/components/tools/automate/IconSelector.stories.tsx b/frontend/editor/src/core/components/tools/automate/IconSelector.stories.tsx new file mode 100644 index 0000000000..1a8fdadcd1 --- /dev/null +++ b/frontend/editor/src/core/components/tools/automate/IconSelector.stories.tsx @@ -0,0 +1,28 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import IconSelector from "@app/components/tools/automate/IconSelector"; + +const meta = { + title: "Automate/IconSelector", + component: IconSelector, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function IconSelectorDemo({ size }: { size?: "sm" | "md" | "lg" }) { + const [value, setValue] = useState("SettingsIcon"); + return ; +} + +export const Default: Story = { + render: () => , +}; + +export const Medium: Story = { + render: () => , +}; + +export const Large: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/automate/ToolConfigurationModal.stories.tsx b/frontend/editor/src/core/components/tools/automate/ToolConfigurationModal.stories.tsx new file mode 100644 index 0000000000..81aa48e300 --- /dev/null +++ b/frontend/editor/src/core/components/tools/automate/ToolConfigurationModal.stories.tsx @@ -0,0 +1,80 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { TextInput } from "@mantine/core"; +import ToolConfigurationModal from "@app/components/tools/automate/ToolConfigurationModal"; +import { + ToolRegistry, + ToolCategoryId, + SubcategoryId, +} from "@app/data/toolsTaxonomy"; +import { + ToolAutomationSettingsProps, + ErasedToolParams, +} from "@app/hooks/tools/shared/toolOperationTypes"; + +const meta = { + title: "Tools/Automate/ToolConfigurationModal", + component: ToolConfigurationModal, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +/** Registry entry with no automationSettings: the modal falls back to a "no settings" message. */ +export const Default: Story = { + args: { + opened: true, + tool: { + id: "1", + operation: "autoRename", + name: "Auto Rename", + }, + onSave: () => {}, + onCancel: () => {}, + toolRegistry: {}, + }, +}; + +function DemoSettings({ + parameters, + onParameterChange, + disabled, +}: ToolAutomationSettingsProps) { + return ( + + onParameterChange("prefix", event.currentTarget.value) + } + disabled={disabled} + /> + ); +} + +const registryWithSettings: Partial = { + autoRename: { + icon: null, + name: "Auto Rename", + component: null, + description: "Automatically rename files.", + categoryId: ToolCategoryId.STANDARD_TOOLS, + subcategoryId: SubcategoryId.GENERAL, + automationSettings: DemoSettings, + }, +}; + +/** Registry entry with a settings component: renders the tool's own configuration fields. */ +export const WithSettings: Story = { + args: { + opened: true, + tool: { + id: "1", + operation: "autoRename", + name: "Auto Rename", + parameters: { prefix: "invoice-" }, + }, + onSave: () => {}, + onCancel: () => {}, + toolRegistry: registryWithSettings, + }, +}; diff --git a/frontend/editor/src/core/components/tools/automate/ToolList.stories.tsx b/frontend/editor/src/core/components/tools/automate/ToolList.stories.tsx new file mode 100644 index 0000000000..f9c83419c8 --- /dev/null +++ b/frontend/editor/src/core/components/tools/automate/ToolList.stories.tsx @@ -0,0 +1,55 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ToolList from "@app/components/tools/automate/ToolList"; +import type { AutomationTool } from "@app/types/automation"; +import type { ToolRegistry } from "@app/data/toolsTaxonomy"; + +// Kept empty: ToolSelector resolves `tool.operation` against this registry, and an +// unmatched operation falls back to the search-input display rather than ToolButton +// (which needs Hotkey/ToolWorkflow context this story doesn't mount). +const emptyToolRegistry: Partial = {}; + +const configuredTools: AutomationTool[] = [ + { + id: "step-1", + operation: "compress", + name: "Compress", + configured: true, + parameters: {}, + }, + { + id: "step-2", + operation: "flatten", + name: "Flatten", + configured: false, + parameters: {}, + }, +]; + +const meta = { + title: "Tools/Automate/ToolList", + component: ToolList, + args: { + toolRegistry: emptyToolRegistry, + onToolUpdate: () => {}, + onToolRemove: () => {}, + onToolConfigure: () => {}, + onToolAdd: () => {}, + getToolName: (operation: string) => operation, + getToolDefaultParameters: () => ({}), + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + tools: configuredTools, + }, +}; + +export const Empty: Story = { + args: { + tools: [], + }, +}; diff --git a/frontend/editor/src/core/components/tools/automate/ToolList.tsx b/frontend/editor/src/core/components/tools/automate/ToolList.tsx index ad67ed4aa9..e2fe54deb0 100644 --- a/frontend/editor/src/core/components/tools/automate/ToolList.tsx +++ b/frontend/editor/src/core/components/tools/automate/ToolList.tsx @@ -148,7 +148,7 @@ export default function ToolList({ borderTop: "none", borderRadius: "0 0 var(--mantine-radius-lg) var(--mantine-radius-lg)", - backgroundColor: "var(--active-bg)", + backgroundColor: "var(--c-active)", padding: "var(--mantine-spacing-xs)", }} > diff --git a/frontend/editor/src/core/components/tools/automate/ToolSelector.stories.tsx b/frontend/editor/src/core/components/tools/automate/ToolSelector.stories.tsx new file mode 100644 index 0000000000..504fed0b1e --- /dev/null +++ b/frontend/editor/src/core/components/tools/automate/ToolSelector.stories.tsx @@ -0,0 +1,31 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ToolSelector from "@app/components/tools/automate/ToolSelector"; +import type { ToolRegistry } from "@app/data/toolsTaxonomy"; + +// ToolSelector only mounts ToolButton once a tool is selected or the dropdown +// is opened with matches, and ToolButton needs Hotkey/ToolWorkflow/AppConfig +// context the shared preview doesn't mount. Leaving `selectedValue` unset and +// the registry empty keeps the story on the closed search-input display. +const emptyToolRegistry: Partial = {}; + +const meta = { + title: "Tools/Automate/ToolSelector", + component: ToolSelector, + args: { + onSelect: () => {}, + toolRegistry: emptyToolRegistry, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +/** Closed search input, showing the default "Add a tool..." placeholder. */ +export const Default: Story = {}; + +/** Custom placeholder text, e.g. when embedded in a different flow. */ +export const CustomPlaceholder: Story = { + args: { + placeholder: "Choose a step...", + }, +}; diff --git a/frontend/editor/src/core/components/tools/bookletImposition/BookletImpositionSettings.stories.tsx b/frontend/editor/src/core/components/tools/bookletImposition/BookletImpositionSettings.stories.tsx new file mode 100644 index 0000000000..7d1dc55e4d --- /dev/null +++ b/frontend/editor/src/core/components/tools/bookletImposition/BookletImpositionSettings.stories.tsx @@ -0,0 +1,42 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import BookletImpositionSettings from "@app/components/tools/bookletImposition/BookletImpositionSettings"; +import { + BookletImpositionParameters, + defaultParameters, +} from "@app/hooks/tools/bookletImposition/useBookletImpositionParameters"; + +const meta = { + title: "Tools/BookletImposition/BookletImpositionSettings", + component: BookletImpositionSettings, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +const manualDuplexParameters: BookletImpositionParameters = { + ...defaultParameters, + doubleSided: false, + duplexPass: "FIRST", +}; + +export const Default: Story = { + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +}; + +export const ManualDuplex: Story = { + args: { + parameters: manualDuplexParameters, + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/certSign/CertSignAutomationSettings.stories.tsx b/frontend/editor/src/core/components/tools/certSign/CertSignAutomationSettings.stories.tsx new file mode 100644 index 0000000000..449ca5bf6a --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/CertSignAutomationSettings.stories.tsx @@ -0,0 +1,59 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import CertSignAutomationSettings from "@app/components/tools/certSign/CertSignAutomationSettings"; +import { + CertSignParameters, + defaultParameters, +} from "@app/hooks/tools/certSign/useCertSignParameters"; + +const meta = { + title: "Tools/CertSign/CertSignAutomationSettings", + component: CertSignAutomationSettings, + parameters: { layout: "padded" }, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function SettingsDemo({ + initialParameters = defaultParameters, + disabled, +}: { + initialParameters?: CertSignParameters; + disabled?: boolean; +}) { + const [parameters, setParameters] = + useState(initialParameters); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { + render: () => , +}; + +export const AutoSignMode: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/certSign/CertificateFilesSettings.stories.tsx b/frontend/editor/src/core/components/tools/certSign/CertificateFilesSettings.stories.tsx new file mode 100644 index 0000000000..137fc8272a --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/CertificateFilesSettings.stories.tsx @@ -0,0 +1,81 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import CertificateFilesSettings from "@app/components/tools/certSign/CertificateFilesSettings"; +import { + CertSignParameters, + defaultParameters, +} from "@app/hooks/tools/certSign/useCertSignParameters"; + +const meta = { + title: "Tools/CertSign/CertificateFilesSettings", + component: CertificateFilesSettings, + parameters: { layout: "padded" }, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function SettingsDemo({ + initialParameters = defaultParameters, + disabled, +}: { + initialParameters?: CertSignParameters; + disabled?: boolean; +}) { + const [parameters, setParameters] = + useState(initialParameters); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { + render: () => ( + + ), +}; + +export const Pkcs12: Story = { + render: () => ( + + ), +}; + +export const Jks: Story = { + render: () => ( + + ), +}; + +export const AutoSignMode: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/core/components/tools/certSign/CertificateFormatSettings.stories.tsx b/frontend/editor/src/core/components/tools/certSign/CertificateFormatSettings.stories.tsx new file mode 100644 index 0000000000..02a30d6382 --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/CertificateFormatSettings.stories.tsx @@ -0,0 +1,33 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import CertificateFormatSettings from "@app/components/tools/certSign/CertificateFormatSettings"; +import { defaultParameters } from "@app/hooks/tools/certSign/useCertSignParameters"; + +const meta = { + title: "Tools/CertSign/CertificateFormatSettings", + component: CertificateFormatSettings, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +}; + +export const Selected: Story = { + args: { + parameters: { ...defaultParameters, certType: "PKCS12" }, + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/certSign/CertificateSelector.stories.tsx b/frontend/editor/src/core/components/tools/certSign/CertificateSelector.stories.tsx new file mode 100644 index 0000000000..8caa8726f5 --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/CertificateSelector.stories.tsx @@ -0,0 +1,82 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { + CertificateSelector, + CertificateType, + UploadFormat, +} from "@app/components/tools/certSign/CertificateSelector"; + +const meta = { + title: "Tools/CertSign/CertificateSelector", + component: CertificateSelector, + parameters: { layout: "padded" }, + args: { + certType: "UPLOAD", + onCertTypeChange: () => {}, + uploadFormat: "PKCS12", + onUploadFormatChange: () => {}, + p12File: null, + onP12FileChange: () => {}, + privateKeyFile: null, + onPrivateKeyFileChange: () => {}, + certFile: null, + onCertFileChange: () => {}, + jksFile: null, + onJksFileChange: () => {}, + password: "", + onPasswordChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function SelectorDemo({ + initialCertType = "UPLOAD", + initialUploadFormat = "PKCS12", + disabled, +}: { + initialCertType?: CertificateType; + initialUploadFormat?: UploadFormat; + disabled?: boolean; +}) { + const [certType, setCertType] = useState(initialCertType); + const [uploadFormat, setUploadFormat] = + useState(initialUploadFormat); + const [p12File, setP12File] = useState(null); + const [privateKeyFile, setPrivateKeyFile] = useState(null); + const [certFile, setCertFile] = useState(null); + const [jksFile, setJksFile] = useState(null); + const [password, setPassword] = useState(""); + + return ( + + ); +} + +export const Default: Story = { + render: () => , +}; + +export const PemFormat: Story = { + render: () => , +}; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/certSign/CertificateTypeSettings.stories.tsx b/frontend/editor/src/core/components/tools/certSign/CertificateTypeSettings.stories.tsx new file mode 100644 index 0000000000..2ad8f660a0 --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/CertificateTypeSettings.stories.tsx @@ -0,0 +1,82 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import CertificateTypeSettings from "@app/components/tools/certSign/CertificateTypeSettings"; +import { AppConfigProvider } from "@app/contexts/AppConfigContext"; +import { + CertSignParameters, + defaultParameters, +} from "@app/hooks/tools/certSign/useCertSignParameters"; + +const meta = { + title: "Tools/CertSign/CertificateTypeSettings", + component: CertificateTypeSettings, + parameters: { layout: "padded" }, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function SettingsDemo({ + initialParameters = defaultParameters, + disabled, + serverCertificateEnabled = false, + hardwareSigningAvailable = false, +}: { + initialParameters?: CertSignParameters; + disabled?: boolean; + serverCertificateEnabled?: boolean; + hardwareSigningAvailable?: boolean; +}) { + const [parameters, setParameters] = + useState(initialParameters); + + return ( + + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + + ); +} + +/** No server certificate or hardware signing available — just the informational message. */ +export const Default: Story = { + render: () => , +}; + +/** Server certificate and on-device signing both available alongside upload. */ +export const AllSourcesAvailable: Story = { + render: () => ( + + ), +}; + +/** Server certificate selected as the active source. */ +export const ServerSelected: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/core/components/tools/certSign/CertificateTypeSettings.tsx b/frontend/editor/src/core/components/tools/certSign/CertificateTypeSettings.tsx index 4311c5f6ca..aaab1cb93e 100644 --- a/frontend/editor/src/core/components/tools/certSign/CertificateTypeSettings.tsx +++ b/frontend/editor/src/core/components/tools/certSign/CertificateTypeSettings.tsx @@ -75,7 +75,7 @@ const CertificateTypeSettings = ({ if (!hasAlternativeSources) { return ( -

    +
    {t( "certSign.source.noOtherSources", "No other certificate sources are available.", diff --git a/frontend/editor/src/core/components/tools/certSign/HardwareCertificateSettings.stories.tsx b/frontend/editor/src/core/components/tools/certSign/HardwareCertificateSettings.stories.tsx new file mode 100644 index 0000000000..2e34f1e2a5 --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/HardwareCertificateSettings.stories.tsx @@ -0,0 +1,79 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import HardwareCertificateSettings from "@app/components/tools/certSign/HardwareCertificateSettings"; +import { CertSignParameters } from "@app/hooks/tools/certSign/useCertSignParameters"; + +const baseParameters: CertSignParameters = { + signMode: "DEVICE", + certType: "WINDOWS_STORE", + password: "", + showSignature: false, + reason: "", + location: "", + name: "", + pageNumber: 1, + showLogo: true, +}; + +const meta = { + title: "Tools/CertSign/HardwareCertificateSettings", + component: HardwareCertificateSettings, + args: { + parameters: baseParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +// The component owns no state itself, so each story wraps it in a small +// stateful shim to keep the kind toggle / driver / PIN inputs interactive +// in the canvas. Capability + certificate lookups hit the backend and are +// expected to fail in Storybook - the component treats that as best-effort +// and still renders the picker. +const HardwareCertificateSettingsDemo = (props: { + initialParameters: CertSignParameters; + disabled?: boolean; +}) => { + const [parameters, setParameters] = useState( + props.initialParameters, + ); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={props.disabled} + /> + ); +}; + +export const Default: Story = { + render: () => ( + + ), +}; + +export const Pkcs11: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/core/components/tools/certSign/SignatureAppearanceSettings.stories.tsx b/frontend/editor/src/core/components/tools/certSign/SignatureAppearanceSettings.stories.tsx new file mode 100644 index 0000000000..13c1e333f6 --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/SignatureAppearanceSettings.stories.tsx @@ -0,0 +1,67 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import SignatureAppearanceSettings from "@app/components/tools/certSign/SignatureAppearanceSettings"; +import { + CertSignParameters, + defaultParameters, +} from "@app/hooks/tools/certSign/useCertSignParameters"; + +const meta = { + title: "Tools/CertSign/SignatureAppearanceSettings", + component: SignatureAppearanceSettings, + parameters: { layout: "padded" }, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function SettingsDemo({ + initialParameters = defaultParameters, + disabled, +}: { + initialParameters?: CertSignParameters; + disabled?: boolean; +}) { + const [parameters, setParameters] = + useState(initialParameters); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { + render: () => , +}; + +export const VisibleSignature: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/core/components/tools/certSign/SignatureSettingsDisplay.stories.tsx b/frontend/editor/src/core/components/tools/certSign/SignatureSettingsDisplay.stories.tsx new file mode 100644 index 0000000000..cd41aa7ca1 --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/SignatureSettingsDisplay.stories.tsx @@ -0,0 +1,40 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import SignatureSettingsDisplay from "@app/components/tools/certSign/SignatureSettingsDisplay"; + +const meta = { + title: "CertSign/SignatureSettingsDisplay", + component: SignatureSettingsDisplay, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + showSignature: true, + pageNumber: 1, + reason: "Document approval", + location: "New York, USA", + showLogo: true, + }, +}; + +export const Invisible: Story = { + args: { + showSignature: false, + pageNumber: null, + reason: null, + location: null, + showLogo: false, + }, +}; + +export const MinimalDetails: Story = { + args: { + showSignature: true, + pageNumber: null, + reason: null, + location: null, + showLogo: false, + }, +}; diff --git a/frontend/editor/src/core/components/tools/certSign/SignatureSettingsInput.stories.tsx b/frontend/editor/src/core/components/tools/certSign/SignatureSettingsInput.stories.tsx new file mode 100644 index 0000000000..87b4127647 --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/SignatureSettingsInput.stories.tsx @@ -0,0 +1,59 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import SignatureSettingsInput, { + SignatureSettings, +} from "@app/components/tools/certSign/SignatureSettingsInput"; + +const meta = { + title: "Tools/CertSign/SignatureSettingsInput", + component: SignatureSettingsInput, + parameters: { layout: "padded" }, + args: { + value: {}, + onChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function SettingsDemo({ + initialValue = {}, + disabled, +}: { + initialValue?: SignatureSettings; + disabled?: boolean; +}) { + const [value, setValue] = useState(initialValue); + + return ( + + ); +} + +export const Default: Story = { + render: () => , +}; + +export const VisibleSignature: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/core/components/tools/certSign/WetSignatureInput.stories.tsx b/frontend/editor/src/core/components/tools/certSign/WetSignatureInput.stories.tsx new file mode 100644 index 0000000000..12a1ef08fe --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/WetSignatureInput.stories.tsx @@ -0,0 +1,60 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import WetSignatureInput from "@app/components/tools/certSign/WetSignatureInput"; + +const meta = { + title: "Tools/CertSign/WetSignatureInput", + component: WetSignatureInput, + parameters: { layout: "padded" }, + args: { + onSignatureDataChange: () => {}, + onSignatureTypeChange: () => {}, + onCertTypeChange: () => {}, + onP12FileChange: () => {}, + onPasswordChange: () => {}, + certType: "USER_CERT", + p12File: null, + password: "", + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function WetSignatureInputDemo( + props: Partial>, +) { + const [certType, setCertType] = useState<"SERVER" | "USER_CERT" | "UPLOAD">( + props.certType ?? "USER_CERT", + ); + const [p12File, setP12File] = useState(props.p12File ?? null); + const [password, setPassword] = useState(props.password ?? ""); + + return ( + {}} + onSignatureTypeChange={() => {}} + onP12FileChange={setP12File} + onPasswordChange={setPassword} + {...props} + certType={certType} + onCertTypeChange={setCertType} + p12File={p12File} + password={password} + /> + ); +} + +/** Default state: personal certificate selected, canvas signature type. */ +export const Default: Story = { + render: () => , +}; + +/** Upload-certificate flow, revealing the P12 file and password fields. */ +export const UploadCertificate: Story = { + render: () => , +}; + +/** All controls disabled. */ +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/certSign/modals/AddParticipantsFlow.stories.tsx b/frontend/editor/src/core/components/tools/certSign/modals/AddParticipantsFlow.stories.tsx new file mode 100644 index 0000000000..316ad9d8b0 --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/modals/AddParticipantsFlow.stories.tsx @@ -0,0 +1,22 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { AddParticipantsFlow } from "@app/components/tools/certSign/modals/AddParticipantsFlow"; + +const meta = { + title: "Tools/CertSign/Modals/AddParticipantsFlow", + component: AddParticipantsFlow, + parameters: { layout: "padded" }, + args: { + opened: true, + onClose: () => {}, + onSubmit: async () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const Closed: Story = { + args: { opened: false }, +}; diff --git a/frontend/editor/src/core/components/tools/certSign/modals/CertificateConfigModal.stories.tsx b/frontend/editor/src/core/components/tools/certSign/modals/CertificateConfigModal.stories.tsx new file mode 100644 index 0000000000..2962196a74 --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/modals/CertificateConfigModal.stories.tsx @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { CertificateConfigModal } from "@app/components/tools/certSign/modals/CertificateConfigModal"; + +const meta = { + title: "Tools/CertSign/Modals/CertificateConfigModal", + component: CertificateConfigModal, + parameters: { layout: "fullscreen" }, + args: { + opened: true, + onClose: () => {}, + onSign: async () => {}, + signatureCount: 1, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const MultipleSignatures: Story = { + args: { signatureCount: 3 }, +}; + +export const Disabled: Story = { + args: { disabled: true }, +}; diff --git a/frontend/editor/src/core/components/tools/certSign/modals/SelectSignatureModal.stories.tsx b/frontend/editor/src/core/components/tools/certSign/modals/SelectSignatureModal.stories.tsx new file mode 100644 index 0000000000..28f2948249 --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/modals/SelectSignatureModal.stories.tsx @@ -0,0 +1,18 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { SelectSignatureModal } from "@app/components/tools/certSign/modals/SelectSignatureModal"; + +const meta = { + title: "Tools/CertSign/Modals/SelectSignatureModal", + component: SelectSignatureModal, + parameters: { layout: "fullscreen" }, + args: { + opened: true, + onClose: () => {}, + onSignatureSelected: () => {}, + onCreateNew: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/editor/src/core/components/tools/certSign/panels/ParticipantListPanel.stories.tsx b/frontend/editor/src/core/components/tools/certSign/panels/ParticipantListPanel.stories.tsx new file mode 100644 index 0000000000..fc4df3c7c8 --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/panels/ParticipantListPanel.stories.tsx @@ -0,0 +1,63 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ParticipantListPanel } from "@app/components/tools/certSign/panels/ParticipantListPanel"; +import type { ParticipantInfo } from "@app/types/signingSession"; + +const meta = { + title: "CertSign/ParticipantListPanel", + component: ParticipantListPanel, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +const participants: ParticipantInfo[] = [ + { + id: 1, + userId: 101, + email: "alice@example.com", + name: "Alice Anderson", + status: "SIGNED", + lastUpdated: "2026-07-10T12:00:00Z", + }, + { + id: 2, + userId: 102, + email: "bob@example.com", + name: "Bob Brown", + status: "PENDING", + lastUpdated: "2026-07-11T09:00:00Z", + }, + { + id: 3, + userId: 103, + email: "carol@example.com", + name: "Carol Clark", + status: "DECLINED", + lastUpdated: "2026-07-12T15:30:00Z", + }, +]; + +export const Default: Story = { + args: { + participants, + finalized: false, + onRemove: () => {}, + }, +}; + +export const Finalized: Story = { + args: { + participants, + finalized: true, + onRemove: () => {}, + }, +}; + +export const Empty: Story = { + args: { + participants: [], + finalized: false, + onRemove: () => {}, + }, +}; diff --git a/frontend/editor/src/core/components/tools/certSign/panels/SessionActionsPanel.stories.tsx b/frontend/editor/src/core/components/tools/certSign/panels/SessionActionsPanel.stories.tsx new file mode 100644 index 0000000000..95575dab64 --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/panels/SessionActionsPanel.stories.tsx @@ -0,0 +1,73 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { SessionActionsPanel } from "@app/components/tools/certSign/panels/SessionActionsPanel"; +import type { SessionDetail } from "@app/types/signingSession"; + +const baseSession: SessionDetail = { + sessionId: "session-1", + documentName: "Contract.pdf", + ownerEmail: "owner@example.com", + message: "Please review and sign by end of week.", + dueDate: "2026-08-01", + createdAt: "2026-07-01T09:00:00Z", + updatedAt: "2026-07-10T09:00:00Z", + finalized: false, + participants: [ + { + id: 1, + userId: 1, + email: "alice@example.com", + name: "Alice", + status: "SIGNED", + lastUpdated: "2026-07-05T09:00:00Z", + }, + { + id: 2, + userId: 2, + email: "bob@example.com", + name: "Bob", + status: "PENDING", + lastUpdated: "2026-07-01T09:00:00Z", + }, + ], +}; + +const meta = { + title: "Tools/CertSign/Panels/SessionActionsPanel", + component: SessionActionsPanel, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + session: baseSession, + onAddParticipants: () => {}, + onFinalize: () => {}, + onLoadSignedPdf: () => {}, + finalizing: false, + loadingPdf: false, + }, +}; + +export const AllSigned: Story = { + args: { + ...Default.args, + session: { + ...baseSession, + participants: baseSession.participants.map((p) => ({ + ...p, + status: "SIGNED", + })), + }, + }, +}; + +export const Finalized: Story = { + args: { + ...Default.args, + session: { ...baseSession, finalized: true }, + loadingPdf: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/certSign/panels/SessionDetailPanel.stories.tsx b/frontend/editor/src/core/components/tools/certSign/panels/SessionDetailPanel.stories.tsx new file mode 100644 index 0000000000..fcc489a6b1 --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/panels/SessionDetailPanel.stories.tsx @@ -0,0 +1,74 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { SessionDetailPanel } from "@app/components/tools/certSign/panels/SessionDetailPanel"; +import type { SigningDetailData } from "@app/hooks/signing/useSigningSessionController"; +import type { SessionDetail } from "@app/types/signingSession"; + +const baseSession: SessionDetail = { + sessionId: "session-1", + documentName: "Employment-Contract.pdf", + ownerEmail: "owner@example.com", + message: "Please review and sign by the due date.", + dueDate: "2026-08-01T00:00:00Z", + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-10T00:00:00Z", + finalized: false, + participants: [ + { + id: 1, + userId: 101, + email: "alice@example.com", + name: "Alice Johnson", + status: "SIGNED", + lastUpdated: "2026-07-05T00:00:00Z", + }, + { + id: 2, + userId: 102, + email: "bob@example.com", + name: "Bob Smith", + status: "PENDING", + lastUpdated: "2026-07-01T00:00:00Z", + }, + ], +}; + +function buildData(session: SessionDetail): SigningDetailData { + return { + session, + pdfFile: null, + onFinalize: async () => {}, + onLoadSignedPdf: async () => {}, + onAddParticipants: async () => {}, + onRemoveParticipant: async () => {}, + onDelete: async () => {}, + onBack: () => {}, + onRefresh: async () => {}, + }; +} + +const meta = { + title: "CertSign/SessionDetailPanel", + component: SessionDetailPanel, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + data: buildData(baseSession), + }, +}; + +export const Finalized: Story = { + args: { + data: buildData({ + ...baseSession, + finalized: true, + participants: baseSession.participants.map((p) => ({ + ...p, + status: "SIGNED", + })), + }), + }, +}; diff --git a/frontend/editor/src/core/components/tools/certSign/panels/SignControlsPanel.stories.tsx b/frontend/editor/src/core/components/tools/certSign/panels/SignControlsPanel.stories.tsx new file mode 100644 index 0000000000..2a1792b5c3 --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/panels/SignControlsPanel.stories.tsx @@ -0,0 +1,51 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import SignControlsPanel from "@app/components/tools/certSign/panels/SignControlsPanel"; +import type { SignParameters } from "@app/hooks/tools/sign/useSignParameters"; + +const meta = { + title: "CertSign/SignControlsPanel", + component: SignControlsPanel, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +const textSignatureConfig: SignParameters = { + signatureType: "text", + signerName: "Alice Anderson", + fontFamily: "Helvetica", + fontSize: 16, + textColor: "#000000", + signatureData: "Alice Anderson", +}; + +export const Default: Story = { + args: { + placementMode: false, + onPlacementModeChange: () => {}, + onSignatureSelected: () => {}, + onComplete: () => {}, + canComplete: true, + signatureConfig: textSignatureConfig, + hasSelectedAnnotation: true, + onDeleteSelected: () => {}, + }, +}; + +export const PlacingNoSelection: Story = { + args: { + ...Default.args, + placementMode: true, + canComplete: false, + hasSelectedAnnotation: false, + }, +}; + +export const NoSignatureChosen: Story = { + args: { + ...Default.args, + signatureConfig: { signatureType: "canvas" }, + hasSelectedAnnotation: false, + }, +}; diff --git a/frontend/editor/src/core/components/tools/certSign/panels/SignRequestPanel.tsx b/frontend/editor/src/core/components/tools/certSign/panels/SignRequestPanel.tsx index 2b128ebd11..0f21335833 100644 --- a/frontend/editor/src/core/components/tools/certSign/panels/SignRequestPanel.tsx +++ b/frontend/editor/src/core/components/tools/certSign/panels/SignRequestPanel.tsx @@ -317,9 +317,9 @@ const SignRequestPanel = ({ data }: SignRequestPanelProps) => { onClick={handleAddToActiveFiles} fullWidth style={{ - backgroundColor: "var(--landing-inner-paper-bg)", - color: "var(--btn-open-file)", - border: "1px solid var(--landing-inner-paper-border)", + backgroundColor: "var(--c-surface-raised)", + color: "var(--c-primary)", + border: "1px solid var(--c-border)", }} > {t("certSign.collab.signRequest.addToFiles", "Add to Active Files")} diff --git a/frontend/editor/src/core/components/tools/certSign/steps/AddSignaturesStep.stories.tsx b/frontend/editor/src/core/components/tools/certSign/steps/AddSignaturesStep.stories.tsx new file mode 100644 index 0000000000..5da3dbd5ea --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/steps/AddSignaturesStep.stories.tsx @@ -0,0 +1,33 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { AddSignaturesStep } from "@app/components/tools/certSign/steps/AddSignaturesStep"; + +const meta = { + title: "Tools/CertSign/Steps/AddSignaturesStep", + component: AddSignaturesStep, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + onRequestPlacement: () => {}, + placementMode: false, + }, +}; + +export const PlacementMode: Story = { + args: { + onRequestPlacement: () => {}, + onCancelPlacement: () => {}, + placementMode: true, + }, +}; + +export const Disabled: Story = { + args: { + onRequestPlacement: () => {}, + placementMode: false, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/certSign/steps/CertificateSelectionStep.stories.tsx b/frontend/editor/src/core/components/tools/certSign/steps/CertificateSelectionStep.stories.tsx new file mode 100644 index 0000000000..025ea7d574 --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/steps/CertificateSelectionStep.stories.tsx @@ -0,0 +1,102 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { CertificateSelectionStep } from "@app/components/tools/certSign/steps/CertificateSelectionStep"; +import { + CertificateType, + UploadFormat, +} from "@app/components/tools/certSign/CertificateSelector"; + +const meta = { + title: "Tools/CertSign/CertificateSelectionStep", + component: CertificateSelectionStep, + parameters: { layout: "padded" }, + args: { + certType: "UPLOAD", + onCertTypeChange: () => {}, + uploadFormat: "PKCS12", + onUploadFormatChange: () => {}, + p12File: null, + onP12FileChange: () => {}, + privateKeyFile: null, + onPrivateKeyFileChange: () => {}, + certFile: null, + onCertFileChange: () => {}, + jksFile: null, + onJksFileChange: () => {}, + password: "", + onPasswordChange: () => {}, + onBack: () => {}, + onNext: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function StepDemo({ + initialCertType = "UPLOAD", + initialUploadFormat = "PKCS12", + withUploadedFile = false, + disabled, +}: { + initialCertType?: CertificateType; + initialUploadFormat?: UploadFormat; + withUploadedFile?: boolean; + disabled?: boolean; +}) { + const [certType, setCertType] = useState(initialCertType); + const [uploadFormat, setUploadFormat] = + useState(initialUploadFormat); + const [p12File, setP12File] = useState( + withUploadedFile + ? new File(["mock"], "certificate.p12", { + type: "application/x-pkcs12", + }) + : null, + ); + const [privateKeyFile, setPrivateKeyFile] = useState(null); + const [certFile, setCertFile] = useState(null); + const [jksFile, setJksFile] = useState(null); + const [password, setPassword] = useState(withUploadedFile ? "secret" : ""); + + return ( + {}} + onNext={() => {}} + disabled={disabled} + /> + ); +} + +/** Upload flow with no file/password yet — "Continue" stays disabled. */ +export const Default: Story = { + render: () => , +}; + +/** Upload flow with a certificate + password already provided — "Continue" is enabled. */ +export const UploadReady: Story = { + render: () => , +}; + +/** Pre-installed user certificate — always valid, no upload fields required. */ +export const UserCertificate: Story = { + render: () => , +}; + +/** Whole step disabled (e.g. while a request is in flight). */ +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/certSign/steps/ReviewSignatureStep.stories.tsx b/frontend/editor/src/core/components/tools/certSign/steps/ReviewSignatureStep.stories.tsx new file mode 100644 index 0000000000..76e920b0db --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/steps/ReviewSignatureStep.stories.tsx @@ -0,0 +1,57 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ReviewSignatureStep } from "@app/components/tools/certSign/steps/ReviewSignatureStep"; +import type { SignRequestDetail } from "@app/types/signingSession"; + +const signRequest: SignRequestDetail = { + sessionId: "session-1", + documentName: "Contract.pdf", + ownerUsername: "owner@example.com", + message: "Please sign the attached contract.", + dueDate: "2026-08-01T00:00:00Z", + createdAt: "2026-07-15T00:00:00Z", + myStatus: "VIEWED", + showSignature: true, + pageNumber: 1, + reason: "Contract approval", + location: "London, UK", +}; + +const meta = { + title: "CertSign/ReviewSignatureStep", + component: ReviewSignatureStep, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + signatureCount: 1, + certType: "USER_CERT", + uploadFormat: "PKCS12", + p12File: null, + signRequest, + onBack: () => {}, + onSign: () => {}, + onDecline: () => {}, + }, +}; + +export const MultipleSignatures: Story = { + args: { + ...Default.args, + signatureCount: 3, + certType: "SERVER", + }, +}; + +export const UploadedCertificateDisabled: Story = { + args: { + ...Default.args, + certType: "UPLOAD", + uploadFormat: "PFX", + p12File: new File(["dummy"], "my-cert.pfx"), + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/certSign/steps/SignatureCreationStep.stories.tsx b/frontend/editor/src/core/components/tools/certSign/steps/SignatureCreationStep.stories.tsx new file mode 100644 index 0000000000..c99f9a0fa9 --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/steps/SignatureCreationStep.stories.tsx @@ -0,0 +1,53 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { SignatureCreationStep } from "@app/components/tools/certSign/steps/SignatureCreationStep"; + +const meta = { + title: "CertSign/Steps/SignatureCreationStep", + component: SignatureCreationStep, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + signatureType: "draw", + onSignatureTypeChange: () => {}, + signature: null, + onSignatureChange: () => {}, + signatureText: "", + fontFamily: "Helvetica", + fontSize: 32, + textColor: "#000000", + onSignatureTextChange: () => {}, + onFontFamilyChange: () => {}, + onFontSizeChange: () => {}, + onTextColorChange: () => {}, + onNext: () => {}, + }, +}; + +export const WithSignature: Story = { + args: { + ...Default.args, + signature: + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", + }, +}; + +export const TypeMode: Story = { + args: { + ...Default.args, + signatureType: "type", + signatureText: "Jane Doe", + signature: + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", + }, +}; + +export const Disabled: Story = { + args: { + ...Default.args, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/certSign/steps/SignaturePlacementStep.stories.tsx b/frontend/editor/src/core/components/tools/certSign/steps/SignaturePlacementStep.stories.tsx new file mode 100644 index 0000000000..685d06b89f --- /dev/null +++ b/frontend/editor/src/core/components/tools/certSign/steps/SignaturePlacementStep.stories.tsx @@ -0,0 +1,53 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { SignaturePlacementStep } from "@app/components/tools/certSign/steps/SignaturePlacementStep"; + +const meta = { + title: "Tools/CertSign/SignaturePlacementStep", + component: SignaturePlacementStep, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + isPlaced: false, + placementInfo: null, + onBack: () => {}, + onNext: () => {}, + children: ( +
    + ), + }, +}; + +export const Placed: Story = { + args: { + isPlaced: true, + placementInfo: { page: 2, x: 120, y: 340 }, + onBack: () => {}, + onNext: () => {}, + children: ( +
    + ), + }, +}; + +export const Disabled: Story = { + args: { + isPlaced: true, + placementInfo: { page: 1, x: 50, y: 50 }, + onBack: () => {}, + onNext: () => {}, + disabled: true, + children: ( +
    + ), + }, +}; diff --git a/frontend/editor/src/core/components/tools/changeMetadata/steps/AdvancedOptionsStep.stories.tsx b/frontend/editor/src/core/components/tools/changeMetadata/steps/AdvancedOptionsStep.stories.tsx new file mode 100644 index 0000000000..ce15dd5922 --- /dev/null +++ b/frontend/editor/src/core/components/tools/changeMetadata/steps/AdvancedOptionsStep.stories.tsx @@ -0,0 +1,36 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import AdvancedOptionsStep from "@app/components/tools/changeMetadata/steps/AdvancedOptionsStep"; +import { defaultParameters } from "@app/hooks/tools/changeMetadata/useChangeMetadataParameters"; +import { TrappedStatus } from "@app/types/metadata"; + +const meta = { + title: "Tools/ChangeMetadata/Steps/AdvancedOptionsStep", + component: AdvancedOptionsStep, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + addCustomMetadata: () => {}, + removeCustomMetadata: () => {}, + updateCustomMetadata: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const WithCustomMetadata: Story = { + args: { + parameters: { + ...defaultParameters, + trapped: TrappedStatus.TRUE, + customMetadata: [{ id: "1", key: "CustomField", value: "CustomValue" }], + }, + }, +}; + +export const Disabled: Story = { + args: { + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/changeMetadata/steps/CustomMetadataStep.stories.tsx b/frontend/editor/src/core/components/tools/changeMetadata/steps/CustomMetadataStep.stories.tsx new file mode 100644 index 0000000000..4ba366f972 --- /dev/null +++ b/frontend/editor/src/core/components/tools/changeMetadata/steps/CustomMetadataStep.stories.tsx @@ -0,0 +1,79 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import CustomMetadataStep from "@app/components/tools/changeMetadata/steps/CustomMetadataStep"; +import { + ChangeMetadataParameters, + defaultParameters, + createCustomMetadataFunctions, +} from "@app/hooks/tools/changeMetadata/useChangeMetadataParameters"; +import { CustomMetadataEntry } from "@app/types/metadata"; + +const meta = { + title: "Tools/ChangeMetadata/CustomMetadataStep", + component: CustomMetadataStep, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + addCustomMetadata: () => {}, + removeCustomMetadata: () => {}, + updateCustomMetadata: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function CustomMetadataStepDemo({ + disabled, + customMetadata = [], +}: { + disabled?: boolean; + customMetadata?: CustomMetadataEntry[]; +}) { + const [parameters, setParameters] = useState({ + ...defaultParameters, + customMetadata, + }); + + const onParameterChange = ( + key: K, + value: ChangeMetadataParameters[K], + ) => setParameters((prev) => ({ ...prev, [key]: value })); + + const { addCustomMetadata, removeCustomMetadata, updateCustomMetadata } = + createCustomMetadataFunctions(parameters, onParameterChange); + + return ( + + ); +} + +export const Default: Story = { + render: () => , +}; + +export const WithEntries: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/core/components/tools/changeMetadata/steps/DeleteAllStep.stories.tsx b/frontend/editor/src/core/components/tools/changeMetadata/steps/DeleteAllStep.stories.tsx new file mode 100644 index 0000000000..d03e977157 --- /dev/null +++ b/frontend/editor/src/core/components/tools/changeMetadata/steps/DeleteAllStep.stories.tsx @@ -0,0 +1,53 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import DeleteAllStep from "@app/components/tools/changeMetadata/steps/DeleteAllStep"; +import { + ChangeMetadataParameters, + defaultParameters, +} from "@app/hooks/tools/changeMetadata/useChangeMetadataParameters"; + +const meta = { + title: "Tools/ChangeMetadata/DeleteAllStep", + component: DeleteAllStep, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function DeleteAllStepDemo({ + disabled, + deleteAll = false, +}: { + disabled?: boolean; + deleteAll?: boolean; +}) { + const [parameters, setParameters] = useState({ + ...defaultParameters, + deleteAll, + }); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { + render: () => , +}; + +export const Checked: Story = { + render: () => , +}; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/changeMetadata/steps/DocumentDatesStep.stories.tsx b/frontend/editor/src/core/components/tools/changeMetadata/steps/DocumentDatesStep.stories.tsx new file mode 100644 index 0000000000..d6b8de2337 --- /dev/null +++ b/frontend/editor/src/core/components/tools/changeMetadata/steps/DocumentDatesStep.stories.tsx @@ -0,0 +1,66 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import DocumentDatesStep from "@app/components/tools/changeMetadata/steps/DocumentDatesStep"; +import { + ChangeMetadataParameters, + defaultParameters, +} from "@app/hooks/tools/changeMetadata/useChangeMetadataParameters"; + +const meta = { + title: "Tools/ChangeMetadata/DocumentDatesStep", + component: DocumentDatesStep, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function DocumentDatesStepDemo({ + disabled, + creationDate = null, + modificationDate = null, +}: { + disabled?: boolean; + creationDate?: Date | null; + modificationDate?: Date | null; +}) { + const [parameters, setParameters] = useState({ + ...defaultParameters, + creationDate, + modificationDate, + }); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { + render: () => , +}; + +export const Filled: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/core/components/tools/changeMetadata/steps/StandardMetadataStep.stories.tsx b/frontend/editor/src/core/components/tools/changeMetadata/steps/StandardMetadataStep.stories.tsx new file mode 100644 index 0000000000..34dacc7d41 --- /dev/null +++ b/frontend/editor/src/core/components/tools/changeMetadata/steps/StandardMetadataStep.stories.tsx @@ -0,0 +1,62 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import StandardMetadataStep from "@app/components/tools/changeMetadata/steps/StandardMetadataStep"; +import { + ChangeMetadataParameters, + defaultParameters, +} from "@app/hooks/tools/changeMetadata/useChangeMetadataParameters"; + +const meta = { + title: "Tools/ChangeMetadata/StandardMetadataStep", + component: StandardMetadataStep, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function StandardMetadataStepDemo({ + disabled, + filled = false, +}: { + disabled?: boolean; + filled?: boolean; +}) { + const [parameters, setParameters] = useState({ + ...defaultParameters, + ...(filled + ? { + title: "Annual Report 2026", + author: "Jane Doe", + subject: "Financial Summary", + keywords: "finance, report, annual", + creator: "Stirling PDF", + producer: "Stirling PDF", + } + : {}), + }); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { + render: () => , +}; + +export const Filled: Story = { + render: () => , +}; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/changePermissions/ChangePermissionsSettings.stories.tsx b/frontend/editor/src/core/components/tools/changePermissions/ChangePermissionsSettings.stories.tsx new file mode 100644 index 0000000000..8a05186886 --- /dev/null +++ b/frontend/editor/src/core/components/tools/changePermissions/ChangePermissionsSettings.stories.tsx @@ -0,0 +1,48 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ChangePermissionsSettings from "@app/components/tools/changePermissions/ChangePermissionsSettings"; +import { ChangePermissionsParameters } from "@app/hooks/tools/changePermissions/useChangePermissionsParameters"; + +const mockParameters: ChangePermissionsParameters = { + preventAssembly: false, + preventExtractContent: false, + preventExtractForAccessibility: false, + preventFillInForm: false, + preventModify: false, + preventModifyAnnotations: false, + preventPrinting: false, + preventPrintingFaithful: false, +}; + +const meta: Meta = { + title: "Tools/ChangePermissions/ChangePermissionsSettings", + component: ChangePermissionsSettings, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + parameters: mockParameters, + onParameterChange: () => {}, + }, +}; + +export const SomeRestricted: Story = { + args: { + parameters: { + ...mockParameters, + preventPrinting: true, + preventModify: true, + }, + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + parameters: mockParameters, + onParameterChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/compare/CompareDocumentPane.stories.tsx b/frontend/editor/src/core/components/tools/compare/CompareDocumentPane.stories.tsx new file mode 100644 index 0000000000..09d27d5ad5 --- /dev/null +++ b/frontend/editor/src/core/components/tools/compare/CompareDocumentPane.stories.tsx @@ -0,0 +1,75 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import CompareDocumentPane from "@app/components/tools/compare/CompareDocumentPane"; +import type { PagePreview } from "@app/types/compare"; + +// 1x1 transparent PNG so the pane's resolves without a network request. +const PLACEHOLDER_PAGE_IMAGE = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="; + +const buildPages = (count: number): PagePreview[] => + Array.from({ length: count }, (_, index) => ({ + pageNumber: index + 1, + width: 612, + height: 792, + rotation: 0, + url: PLACEHOLDER_PAGE_IMAGE, + })); + +const meta = { + title: "Tools/Compare/CompareDocumentPane", + component: CompareDocumentPane, + // Excluded from the automated (Vitest browser) test run: mounting several of + // these panes in one page exhausts the headless browser's memory and the page + // is dropped mid-run, which fails the whole file rather than this component. + // It still renders in the Storybook UI. + tags: ["!test"], + args: { + pane: "base", + layout: "side-by-side", + scrollRef: { current: null }, + peerScrollRef: { current: null }, + handleScrollSync: () => {}, + handleWheelZoom: () => {}, + handleWheelOverscroll: () => {}, + onTouchStart: () => {}, + onTouchMove: () => {}, + onTouchEnd: () => {}, + isPanMode: false, + zoom: 1, + title: "original-document.pdf", + changes: [ + { value: "change-1", label: "Paragraph 1 change", pageNumber: 1 }, + { value: "change-2", label: "Paragraph 2 change", pageNumber: 2 }, + ], + onNavigateChange: () => {}, + isLoading: false, + processingMessage: "Processing...", + pages: buildPages(2), + pairedPages: buildPages(2), + getRowHeightPx: () => 792, + wordHighlightMap: new Map(), + metaIndexToGroupId: new Map(), + documentLabel: "Original", + pageLabel: "Page", + altLabel: "Document page preview", + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Loading: Story = { + args: { + isLoading: true, + pages: [], + pairedPages: [], + }, +}; + +export const NoChanges: Story = { + args: { + changes: [], + dropdownPlaceholder: "No changes", + }, +}; diff --git a/frontend/editor/src/core/components/tools/compare/CompareNavigationDropdown.stories.tsx b/frontend/editor/src/core/components/tools/compare/CompareNavigationDropdown.stories.tsx new file mode 100644 index 0000000000..0e8f59f40e --- /dev/null +++ b/frontend/editor/src/core/components/tools/compare/CompareNavigationDropdown.stories.tsx @@ -0,0 +1,48 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import CompareNavigationDropdown from "@app/components/tools/compare/CompareNavigationDropdown"; + +const meta = { + title: "Compare/CompareNavigationDropdown", + component: CompareNavigationDropdown, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +const sampleChanges = [ + { + value: "change-1", + label: "Introduction paragraph reworded", + pageNumber: 1, + }, + { value: "change-2", label: "Budget table updated", pageNumber: 1 }, + { value: "change-3", label: "New clause added", pageNumber: 2 }, + { value: "change-4", label: "Signature date changed", pageNumber: 3 }, +]; + +export const Default: Story = { + args: { + changes: sampleChanges, + placeholder: "Jump to change", + onNavigate: (value, pageNumber) => { + console.log("navigate", value, pageNumber); + }, + renderedPageNumbers: new Set([1, 2, 3]), + }, +}; + +export const Empty: Story = { + args: { + changes: [], + placeholder: "Jump to change", + onNavigate: () => {}, + }, +}; + +export const RenderingInProgress: Story = { + args: { + changes: sampleChanges, + placeholder: "Jump to change", + onNavigate: () => {}, + renderedPageNumbers: new Set([1]), + }, +}; diff --git a/frontend/editor/src/core/components/tools/compare/ComparePixelWorkbenchView.stories.tsx b/frontend/editor/src/core/components/tools/compare/ComparePixelWorkbenchView.stories.tsx new file mode 100644 index 0000000000..14d47c6761 --- /dev/null +++ b/frontend/editor/src/core/components/tools/compare/ComparePixelWorkbenchView.stories.tsx @@ -0,0 +1,113 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ComparePixelWorkbenchView from "@app/components/tools/compare/ComparePixelWorkbenchView"; +import type { CompareResultPixelData } from "@app/types/compare"; + +// A tiny transparent PNG data URI so the elements have something valid to +// load without reaching out to a real file or network resource. +const PLACEHOLDER_IMAGE = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="; + +const baseResult: CompareResultPixelData = { + mode: "pixel", + base: { fileId: "base-file", fileName: "contract-v1.pdf" }, + comparison: { fileId: "comparison-file", fileName: "contract-v2.pdf" }, + pages: [ + { + pageNumber: 1, + width: 612, + height: 792, + baseImageUrl: PLACEHOLDER_IMAGE, + comparisonImageUrl: PLACEHOLDER_IMAGE, + diffImageUrl: PLACEHOLDER_IMAGE, + diffPixels: 1200, + totalPixels: 484704, + diffRatio: 0.0025, + sizeMismatch: false, + }, + { + pageNumber: 2, + width: 612, + height: 792, + baseImageUrl: PLACEHOLDER_IMAGE, + comparisonImageUrl: PLACEHOLDER_IMAGE, + diffImageUrl: PLACEHOLDER_IMAGE, + diffPixels: 0, + totalPixels: 484704, + diffRatio: 0, + sizeMismatch: false, + }, + { + pageNumber: 3, + width: 612, + height: 792, + baseImageUrl: PLACEHOLDER_IMAGE, + comparisonImageUrl: PLACEHOLDER_IMAGE, + diffImageUrl: PLACEHOLDER_IMAGE, + diffPixels: 96940, + totalPixels: 484704, + diffRatio: 0.2, + sizeMismatch: true, + missingComparison: true, + }, + ], + totals: { + diffPixels: 98140, + totalPixels: 1454112, + diffRatio: 0.0675, + pagesWithChanges: 2, + durationMs: 842, + processedAt: 1752300000000, + }, + warnings: [], + settings: { + dpi: 150, + threshold: 10, + }, +}; + +const meta = { + title: "Tools/Compare/ComparePixelWorkbenchView", + component: ComparePixelWorkbenchView, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + result: baseResult, + }, +}; + +export const NoDifferences: Story = { + args: { + result: { + ...baseResult, + pages: baseResult.pages.map((page) => ({ + ...page, + diffPixels: 0, + diffRatio: 0, + sizeMismatch: false, + missingBase: undefined, + missingComparison: undefined, + })), + totals: { + ...baseResult.totals, + diffPixels: 0, + diffRatio: 0, + pagesWithChanges: 0, + }, + }, + }, +}; + +export const WithWarnings: Story = { + args: { + result: { + ...baseResult, + warnings: [ + "Page 3 could not be rendered at the requested DPI and was downscaled.", + ], + }, + }, +}; diff --git a/frontend/editor/src/core/components/tools/compare/compareView.css b/frontend/editor/src/core/components/tools/compare/compareView.css index 7a86c69d4a..aaa703fcc1 100644 --- a/frontend/editor/src/core/components/tools/compare/compareView.css +++ b/frontend/editor/src/core/components/tools/compare/compareView.css @@ -5,19 +5,14 @@ .compare-dropdown-sticky { position: sticky; z-index: 2; - background: var(--compare-page-label-bg); + background: var(--c-surface-sunken); color: var(--compare-page-label-fg); font-size: 0.75rem; padding: 0.25rem 0.5rem; - border-bottom: 1px solid var(--border-subtle); + border-bottom: 1px solid var(--c-border-subtle); pointer-events: none; } -[data-mantine-color-scheme="dark"] .compare-dropdown-sticky { - background: var(--compare-page-label-bg); - color: var(--compare-page-label-fg); - border-bottom: 1px solid var(--border-default); -} .compare-workbench { display: flex; flex-direction: column; @@ -91,9 +86,9 @@ position: sticky; top: 0; z-index: 10; - background: var(--bg-toolbar); + background: var(--c-bg-raised); backdrop-filter: blur(8px); - border-bottom: 1px solid var(--border-default); + border-bottom: 1px solid var(--c-border); padding: 0.5rem; margin: -0.5rem -0.5rem 0.5rem -0.5rem; } @@ -164,21 +159,13 @@ margin: 0 !important; border-top-left-radius: 8px !important; border-top-right-radius: 8px !important; -} - -/* Style the dropdown container */ -.compare-changes-select .mantine-Combobox-dropdown { - border: 1px solid var(--border-subtle) !important; - border-radius: 8px !important; - box-shadow: var(--shadow-md) !important; - background-color: var(--bg-surface) !important; -} - +} /* Style the dropdown container */ +.compare-changes-select .mantine-Combobox-dropdown, .compare-changes-select--comparison .mantine-Combobox-dropdown { - border: 1px solid var(--border-subtle) !important; + border: 1px solid var(--c-border-subtle) !important; border-radius: 8px !important; box-shadow: var(--shadow-md) !important; - background-color: var(--bg-surface) !important; + background-color: var(--c-surface) !important; } /* Custom scrollbar for ScrollArea */ @@ -187,18 +174,18 @@ } .compare-changes-select .mantine-ScrollArea-viewport::-webkit-scrollbar-track { - background: var(--bg-muted) !important; + background: var(--c-surface-sunken) !important; border-radius: 3px !important; } .compare-changes-select .mantine-ScrollArea-viewport::-webkit-scrollbar-thumb { - background: var(--border-strong) !important; + background: var(--c-border-strong) !important; border-radius: 3px !important; } .compare-changes-select .mantine-ScrollArea-viewport::-webkit-scrollbar-thumb:hover { - background: var(--text-muted) !important; + background: var(--c-text-subtle) !important; } .compare-changes-select--comparison @@ -208,27 +195,21 @@ .compare-changes-select--comparison .mantine-ScrollArea-viewport::-webkit-scrollbar-track { - background: var(--bg-muted) !important; + background: var(--c-surface-sunken) !important; border-radius: 3px !important; } .compare-changes-select--comparison .mantine-ScrollArea-viewport::-webkit-scrollbar-thumb { - background: var(--border-strong) !important; + background: var(--c-border-strong) !important; border-radius: 3px !important; } .compare-changes-select--comparison .mantine-ScrollArea-viewport::-webkit-scrollbar-thumb:hover { - background: var(--text-muted) !important; -} - -/* Style the dropdown options */ -.compare-changes-select .mantine-Combobox-option { - font-size: 0.875rem !important; - padding: 8px 12px !important; -} - + background: var(--c-text-subtle) !important; +} /* Style the dropdown options */ +.compare-changes-select .mantine-Combobox-option, .compare-changes-select--comparison .mantine-Combobox-option { font-size: 0.875rem !important; padding: 8px 12px !important; @@ -240,27 +221,16 @@ .compare-changes-select--comparison .mantine-Combobox-option:hover { background-color: var(--spdf-compare-added-badge-bg) !important; -} - -/* Style the search input */ -.compare-changes-select .mantine-Combobox-search { - font-size: 0.875rem !important; - padding: 8px 12px !important; - border-bottom: 1px solid var(--border-subtle) !important; -} - +} /* Style the search input */ +.compare-changes-select .mantine-Combobox-search, .compare-changes-select--comparison .mantine-Combobox-search { font-size: 0.875rem !important; padding: 8px 12px !important; - border-bottom: 1px solid var(--border-subtle) !important; + border-bottom: 1px solid var(--c-border-subtle) !important; } - -.compare-changes-select .mantine-Combobox-search::placeholder { - color: var(--text-muted) !important; -} - +.compare-changes-select .mantine-Combobox-search::placeholder, .compare-changes-select--comparison .mantine-Combobox-search::placeholder { - color: var(--text-muted) !important; + color: var(--c-text-subtle) !important; } /* Style the chevron - ensure proper coloring */ @@ -273,29 +243,49 @@ /* Flash/pulse highlight for navigated change */ @keyframes compare-flash { 0% { - outline: 4px solid rgba(255, 235, 59, 0); - box-shadow: 0 0 0 rgba(255, 235, 59, 0); - background-color: rgba(255, 235, 59, 0.2) !important; + outline: 4px solid color-mix(in srgb, var(--c-highlight) 0%, transparent); + box-shadow: 0 0 0 color-mix(in srgb, var(--c-highlight) 0%, transparent); + background-color: color-mix( + in srgb, + var(--c-highlight) 20%, + transparent + ) !important; } 25% { - outline: 4px solid rgba(255, 235, 59, 1); - box-shadow: 0 0 20px rgba(255, 235, 59, 0.8); - background-color: rgba(255, 235, 59, 0.4) !important; + outline: 4px solid color-mix(in srgb, var(--c-highlight) 100%, transparent); + box-shadow: 0 0 20px color-mix(in srgb, var(--c-highlight) 80%, transparent); + background-color: color-mix( + in srgb, + var(--c-highlight) 40%, + transparent + ) !important; } 50% { - outline: 4px solid rgba(255, 235, 59, 1); - box-shadow: 0 0 30px rgba(255, 235, 59, 0.9); - background-color: rgba(255, 235, 59, 0.5) !important; + outline: 4px solid color-mix(in srgb, var(--c-highlight) 100%, transparent); + box-shadow: 0 0 30px color-mix(in srgb, var(--c-highlight) 90%, transparent); + background-color: color-mix( + in srgb, + var(--c-highlight) 50%, + transparent + ) !important; } 75% { - outline: 4px solid rgba(255, 235, 59, 0.8); - box-shadow: 0 0 15px rgba(255, 235, 59, 0.6); - background-color: rgba(255, 235, 59, 0.3) !important; + outline: 4px solid color-mix(in srgb, var(--c-highlight) 80%, transparent); + box-shadow: 0 0 15px color-mix(in srgb, var(--c-highlight) 60%, transparent); + background-color: color-mix( + in srgb, + var(--c-highlight) 30%, + transparent + ) !important; } 100% { - outline: 4px solid rgba(255, 235, 59, 0); - box-shadow: 0 0 0 rgba(255, 235, 59, 0); - background-color: rgba(255, 235, 59, 0) !important; + outline: 4px solid color-mix(in srgb, var(--c-highlight) 0%, transparent); + box-shadow: 0 0 0 color-mix(in srgb, var(--c-highlight) 0%, transparent); + background-color: color-mix( + in srgb, + var(--c-highlight) 0%, + transparent + ) !important; } } @@ -304,14 +294,18 @@ z-index: 1000; position: relative; /* Bonus: temporarily override red/green to yellow during flash for clarity */ - background-color: rgba(255, 235, 59, 0.5) !important; + background-color: color-mix( + in srgb, + var(--c-highlight) 50%, + transparent + ) !important; } /* Union overlay for group flash */ .compare-diff-flash-overlay { animation: compare-flash 1.5s ease-in-out 1; z-index: 999; - background-color: rgba(255, 235, 59, 0.4); + background-color: color-mix(in srgb, var(--c-highlight) 40%, transparent); pointer-events: none; border-radius: 2px; } @@ -328,7 +322,7 @@ width: 0.75rem; height: 0.75rem; border-radius: 999px; - border: 1px solid rgba(15, 23, 42, 0.15); + border: 1px solid var(--c-border); } .compare-summary__stats { @@ -343,10 +337,10 @@ } .compare-summary__segment { - border: 1px solid var(--mantine-color-gray-3); + border: 1px solid var(--c-border); border-radius: 0.5rem; padding: 0.75rem; - background-color: var(--mantine-color-gray-0); + background-color: var(--c-surface); } .compare-diff-page { @@ -357,10 +351,10 @@ .compare-diff-page__canvas { position: relative; - border: 1px solid var(--border-strong); + border: 1px solid var(--c-border-strong); border-radius: 0.75rem; overflow: hidden; - background-color: var(--bg-surface); + background-color: var(--c-surface); width: 100%; } @@ -381,7 +375,7 @@ margin-right: auto; max-width: 100%; background-color: #fff; /* ensure stable white backing during load */ - border: 1px solid var(--border-subtle); + border: 1px solid var(--c-border-subtle); will-change: transform; } @@ -403,7 +397,7 @@ display: inline-block; padding: 2px 8px; border-radius: 8px; - background-color: var(--compare-page-label-bg); + background-color: var(--c-surface-sunken); color: var(--compare-page-label-fg); } @@ -432,7 +426,7 @@ } .compare-dropdown-option__page { font-size: 0.7rem; - color: var(--text-muted); + color: var(--c-text-subtle); } .compare-dropdown-option__text { display: -webkit-box; @@ -446,11 +440,11 @@ /* Non-sticky in-flow group headers; sticky handled by floating header */ .compare-dropdown-group { position: static; - background: var(--compare-page-label-bg); + background: var(--c-surface-sunken); color: var(--compare-page-label-fg); font-size: 0.75rem; padding: 0.25rem 0.5rem; - border-bottom: 1px solid var(--border-subtle); + border-bottom: 1px solid var(--c-border-subtle); } .compare-dropdown-group.compare-dropdown-group--hidden { @@ -461,15 +455,9 @@ overflow: hidden; } -[data-mantine-color-scheme="dark"] .compare-dropdown-group { - background: var(--compare-page-label-bg); - color: var(--compare-page-label-fg); - border-bottom: 1px solid var(--border-default); -} - /* Light grey rendering flag next to page labels in the dropdown */ .compare-dropdown-rendering-flag { - color: var(--text-muted); + color: var(--c-text-subtle); margin-left: 0.25rem; } @@ -489,7 +477,7 @@ position: sticky; top: 0; z-index: 2; - background: var(--bg-background); + background: var(--c-bg); padding: 0.25rem 0; } @@ -550,7 +538,7 @@ padding-bottom: 32px; } .compare-pixel-page { - border: 1px solid var(--mantine-color-default-border, #dee2e6); + border: 1px solid var(--mantine-color-default-border, var(--c-border)); border-radius: 8px; padding: 12px; background: var(--mantine-color-body, #fff); @@ -568,13 +556,13 @@ } .compare-pixel-triptych figure img { display: block; - border: 1px solid var(--mantine-color-default-border, #dee2e6); + border: 1px solid var(--mantine-color-default-border, var(--c-border)); background: #fff; } .compare-pixel-triptych figcaption { font-size: 11px; text-align: center; - color: var(--mantine-color-dimmed, #868e96); + color: var(--mantine-color-dimmed, var(--c-text-muted)); } .compare-pixel-overlay { position: relative; @@ -583,7 +571,7 @@ .compare-pixel-overlay-img { display: block; width: 100%; - border: 1px solid var(--mantine-color-default-border, #dee2e6); + border: 1px solid var(--mantine-color-default-border, var(--c-border)); } .compare-pixel-overlay-top { position: absolute; diff --git a/frontend/editor/src/core/components/tools/compress/CompressSettings.stories.tsx b/frontend/editor/src/core/components/tools/compress/CompressSettings.stories.tsx new file mode 100644 index 0000000000..672f436e56 --- /dev/null +++ b/frontend/editor/src/core/components/tools/compress/CompressSettings.stories.tsx @@ -0,0 +1,76 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import CompressSettings from "@app/components/tools/compress/CompressSettings"; +import { + CompressParameters, + defaultParameters, +} from "@app/hooks/tools/compress/useCompressParameters"; + +const meta = { + title: "Tools/Compress/CompressSettings", + component: CompressSettings, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +// The component owns no state itself, so each story wraps it in a small +// stateful shim to keep the sliders/inputs interactive in the canvas. +const CompressSettingsDemo = (props: { + initialParameters: CompressParameters; + disabled?: boolean; +}) => { + const [parameters, setParameters] = useState( + props.initialParameters, + ); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={props.disabled} + /> + ); +}; + +export const Default: Story = { + render: () => , +}; + +export const FileSizeMethod: Story = { + render: () => ( + + ), +}; + +export const LineArtEnabled: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/core/components/tools/convert/ConvertFromCbrSettings.stories.tsx b/frontend/editor/src/core/components/tools/convert/ConvertFromCbrSettings.stories.tsx new file mode 100644 index 0000000000..0f9f386add --- /dev/null +++ b/frontend/editor/src/core/components/tools/convert/ConvertFromCbrSettings.stories.tsx @@ -0,0 +1,66 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ConvertFromCbrSettings from "@app/components/tools/convert/ConvertFromCbrSettings"; +import { + ConvertParameters, + defaultParameters, +} from "@app/hooks/tools/convert/useConvertParameters"; + +const meta = { + title: "Tools/Convert/ConvertFromCbrSettings", + component: ConvertFromCbrSettings, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +// The component owns no state itself, so each story wraps it in a small +// stateful shim to keep the checkbox interactive in the canvas. +const ConvertFromCbrSettingsDemo = (props: { + initialParameters: ConvertParameters; + disabled?: boolean; +}) => { + const [parameters, setParameters] = useState( + props.initialParameters, + ); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={props.disabled} + /> + ); +}; + +export const Default: Story = { + render: () => ( + + ), +}; + +export const OptimizeForEbookEnabled: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/core/components/tools/convert/ConvertFromCbzSettings.stories.tsx b/frontend/editor/src/core/components/tools/convert/ConvertFromCbzSettings.stories.tsx new file mode 100644 index 0000000000..abec19fcc2 --- /dev/null +++ b/frontend/editor/src/core/components/tools/convert/ConvertFromCbzSettings.stories.tsx @@ -0,0 +1,52 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ConvertFromCbzSettings from "@app/components/tools/convert/ConvertFromCbzSettings"; +import { + defaultParameters, + ConvertParameters, +} from "@app/hooks/tools/convert/useConvertParameters"; + +const meta = { + title: "Tools/ConvertFromCbzSettings", + component: ConvertFromCbzSettings, + parameters: { layout: "padded" }, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function CbzSettingsDemo({ + disabled, + initialOptimize = false, +}: { + disabled?: boolean; + initialOptimize?: boolean; +}) { + const [parameters, setParameters] = useState({ + ...defaultParameters, + cbzOptions: { optimizeForEbook: initialOptimize }, + }); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { render: () => }; + +export const OptimizedForEbook: Story = { + render: () => , +}; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/convert/ConvertFromEbookSettings.stories.tsx b/frontend/editor/src/core/components/tools/convert/ConvertFromEbookSettings.stories.tsx new file mode 100644 index 0000000000..e0eafa1835 --- /dev/null +++ b/frontend/editor/src/core/components/tools/convert/ConvertFromEbookSettings.stories.tsx @@ -0,0 +1,42 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ConvertFromEbookSettings from "@app/components/tools/convert/ConvertFromEbookSettings"; +import { + ConvertParameters, + defaultParameters, +} from "@app/hooks/tools/convert/useConvertParameters"; + +const meta = { + title: "Tools/Convert/ConvertFromEbookSettings", + component: ConvertFromEbookSettings, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +function ConvertFromEbookSettingsDemo({ disabled }: { disabled?: boolean }) { + const [parameters, setParameters] = + useState(defaultParameters); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { + render: () => , +}; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/convert/ConvertFromEmailSettings.stories.tsx b/frontend/editor/src/core/components/tools/convert/ConvertFromEmailSettings.stories.tsx new file mode 100644 index 0000000000..4e860948f9 --- /dev/null +++ b/frontend/editor/src/core/components/tools/convert/ConvertFromEmailSettings.stories.tsx @@ -0,0 +1,62 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ConvertFromEmailSettings from "@app/components/tools/convert/ConvertFromEmailSettings"; +import { + ConvertParameters, + defaultParameters, +} from "@app/hooks/tools/convert/useConvertParameters"; + +const meta = { + title: "Tools/Convert/ConvertFromEmailSettings", + component: ConvertFromEmailSettings, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function ConvertFromEmailSettingsDemo( + props: Partial>, +) { + const [parameters, setParameters] = useState( + props.parameters ?? defaultParameters, + ); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + /> + ); +} + +/** Default state: attachments included, so the max-size input is visible. */ +export const Default: Story = { + render: () => , +}; + +/** Attachments excluded, hiding the max-attachment-size input. */ +export const WithoutAttachments: Story = { + render: () => ( + + ), +}; + +/** All controls disabled, e.g. while a conversion is running. */ +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/convert/ConvertFromImageSettings.stories.tsx b/frontend/editor/src/core/components/tools/convert/ConvertFromImageSettings.stories.tsx new file mode 100644 index 0000000000..2392e13ec3 --- /dev/null +++ b/frontend/editor/src/core/components/tools/convert/ConvertFromImageSettings.stories.tsx @@ -0,0 +1,38 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ConvertFromImageSettings from "@app/components/tools/convert/ConvertFromImageSettings"; +import { + ConvertParameters, + defaultParameters, +} from "@app/hooks/tools/convert/useConvertParameters"; + +const meta: Meta = { + title: "Tools/Convert/ConvertFromImageSettings", + component: ConvertFromImageSettings, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +function ConvertFromImageSettingsDemo({ disabled }: { disabled?: boolean }) { + const [parameters, setParameters] = + useState(defaultParameters); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { + render: () => , +}; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/convert/ConvertFromSvgSettings.stories.tsx b/frontend/editor/src/core/components/tools/convert/ConvertFromSvgSettings.stories.tsx new file mode 100644 index 0000000000..8ca41ba2bb --- /dev/null +++ b/frontend/editor/src/core/components/tools/convert/ConvertFromSvgSettings.stories.tsx @@ -0,0 +1,38 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ConvertFromSvgSettings from "@app/components/tools/convert/ConvertFromSvgSettings"; +import { + ConvertParameters, + defaultParameters, +} from "@app/hooks/tools/convert/useConvertParameters"; + +const meta: Meta = { + title: "Tools/Convert/ConvertFromSvgSettings", + component: ConvertFromSvgSettings, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +function ConvertFromSvgSettingsDemo({ disabled }: { disabled?: boolean }) { + const [parameters, setParameters] = + useState(defaultParameters); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { + render: () => , +}; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/convert/ConvertFromWebSettings.stories.tsx b/frontend/editor/src/core/components/tools/convert/ConvertFromWebSettings.stories.tsx new file mode 100644 index 0000000000..ddc756f723 --- /dev/null +++ b/frontend/editor/src/core/components/tools/convert/ConvertFromWebSettings.stories.tsx @@ -0,0 +1,38 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ConvertFromWebSettings from "@app/components/tools/convert/ConvertFromWebSettings"; +import { + ConvertParameters, + defaultParameters, +} from "@app/hooks/tools/convert/useConvertParameters"; + +const meta: Meta = { + title: "Tools/Convert/ConvertFromWebSettings", + component: ConvertFromWebSettings, +}; +export default meta; +type Story = StoryObj; + +function ConvertFromWebSettingsDemo({ disabled }: { disabled?: boolean }) { + const [parameters, setParameters] = useState({ + ...defaultParameters, + fromExtension: "html", + toExtension: "pdf", + }); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { render: () => }; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/convert/ConvertToCbrSettings.stories.tsx b/frontend/editor/src/core/components/tools/convert/ConvertToCbrSettings.stories.tsx new file mode 100644 index 0000000000..3a202e95fa --- /dev/null +++ b/frontend/editor/src/core/components/tools/convert/ConvertToCbrSettings.stories.tsx @@ -0,0 +1,52 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ConvertToCbrSettings from "@app/components/tools/convert/ConvertToCbrSettings"; +import { + ConvertParameters, + defaultParameters, +} from "@app/hooks/tools/convert/useConvertParameters"; + +const meta = { + title: "Tools/Convert/ConvertToCbrSettings", + component: ConvertToCbrSettings, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +// The component owns no state itself, so each story wraps it in a small +// stateful shim to keep the DPI input interactive in the canvas. +const ConvertToCbrSettingsDemo = (props: { + initialParameters: ConvertParameters; + disabled?: boolean; +}) => { + const [parameters, setParameters] = useState( + props.initialParameters, + ); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={props.disabled} + /> + ); +}; + +export const Default: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/core/components/tools/convert/ConvertToCbzSettings.stories.tsx b/frontend/editor/src/core/components/tools/convert/ConvertToCbzSettings.stories.tsx new file mode 100644 index 0000000000..0d31069b86 --- /dev/null +++ b/frontend/editor/src/core/components/tools/convert/ConvertToCbzSettings.stories.tsx @@ -0,0 +1,25 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ConvertToCbzSettings from "@app/components/tools/convert/ConvertToCbzSettings"; +import { defaultParameters } from "@app/hooks/tools/convert/useConvertParameters"; + +const meta = { + title: "Tools/Convert/ConvertToCbzSettings", + component: ConvertToCbzSettings, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + ...Default.args, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/convert/ConvertToEpubSettings.stories.tsx b/frontend/editor/src/core/components/tools/convert/ConvertToEpubSettings.stories.tsx new file mode 100644 index 0000000000..c2087cf00f --- /dev/null +++ b/frontend/editor/src/core/components/tools/convert/ConvertToEpubSettings.stories.tsx @@ -0,0 +1,54 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ConvertToEpubSettings from "@app/components/tools/convert/ConvertToEpubSettings"; +import { + ConvertParameters, + defaultParameters, +} from "@app/hooks/tools/convert/useConvertParameters"; + +const meta: Meta = { + title: "Tools/Convert/ConvertToEpubSettings", + component: ConvertToEpubSettings, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +function ConvertToEpubSettingsDemo({ + toExtension = "epub", + disabled, +}: { + toExtension?: string; + disabled?: boolean; +}) { + const [parameters, setParameters] = useState({ + ...defaultParameters, + fromExtension: "docx", + toExtension, + }); + + const handleParameterChange = ( + key: K, + value: ConvertParameters[K], + ) => { + setParameters((prev) => ({ ...prev, [key]: value })); + }; + + return ( + + ); +} + +export const Default: Story = { render: () => }; + +export const Azw3Output: Story = { + render: () => , +}; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/convert/ConvertToImageSettings.stories.tsx b/frontend/editor/src/core/components/tools/convert/ConvertToImageSettings.stories.tsx new file mode 100644 index 0000000000..242c0c631d --- /dev/null +++ b/frontend/editor/src/core/components/tools/convert/ConvertToImageSettings.stories.tsx @@ -0,0 +1,38 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ConvertToImageSettings from "@app/components/tools/convert/ConvertToImageSettings"; +import { + ConvertParameters, + defaultParameters, +} from "@app/hooks/tools/convert/useConvertParameters"; + +const meta: Meta = { + title: "Tools/Convert/ConvertToImageSettings", + component: ConvertToImageSettings, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +function ConvertToImageSettingsDemo({ disabled }: { disabled?: boolean }) { + const [parameters, setParameters] = + useState(defaultParameters); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { + render: () => , +}; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/convert/ConvertToPdfaSettings.stories.tsx b/frontend/editor/src/core/components/tools/convert/ConvertToPdfaSettings.stories.tsx new file mode 100644 index 0000000000..2bffbe1391 --- /dev/null +++ b/frontend/editor/src/core/components/tools/convert/ConvertToPdfaSettings.stories.tsx @@ -0,0 +1,34 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ConvertToPdfaSettings from "@app/components/tools/convert/ConvertToPdfaSettings"; +import { defaultParameters } from "@app/hooks/tools/convert/useConvertParameters"; + +const meta = { + title: "Tools/Convert/ConvertToPdfaSettings", + component: ConvertToPdfaSettings, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + selectedFiles: [], + disabled: false, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const StrictMode: Story = { + args: { + parameters: { + ...defaultParameters, + pdfaOptions: { outputFormat: "pdfa-1", strict: true }, + }, + }, +}; + +export const Disabled: Story = { + args: { + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/convert/ConvertToPdfxSettings.stories.tsx b/frontend/editor/src/core/components/tools/convert/ConvertToPdfxSettings.stories.tsx new file mode 100644 index 0000000000..30d59e42fb --- /dev/null +++ b/frontend/editor/src/core/components/tools/convert/ConvertToPdfxSettings.stories.tsx @@ -0,0 +1,57 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ConvertToPdfxSettings from "@app/components/tools/convert/ConvertToPdfxSettings"; +import { + ConvertParameters, + defaultParameters, +} from "@app/hooks/tools/convert/useConvertParameters"; +import { StirlingFile } from "@app/types/fileContext"; + +const meta = { + title: "Tools/Convert/ConvertToPdfxSettings", + component: ConvertToPdfxSettings, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + selectedFiles: [], + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +// The component renders no UI — it only reconciles the "outputFormat" field +// on mount — so the shim just proves it mounts and updates parameters without +// throwing. +const ConvertToPdfxSettingsDemo = (props: { + initialParameters: ConvertParameters; + selectedFiles?: StirlingFile[]; + disabled?: boolean; +}) => { + const [parameters, setParameters] = useState( + props.initialParameters, + ); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + selectedFiles={props.selectedFiles ?? []} + disabled={props.disabled} + /> + ); +}; + +export const Default: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/core/components/tools/convert/GroupedFormatDropdown.stories.tsx b/frontend/editor/src/core/components/tools/convert/GroupedFormatDropdown.stories.tsx new file mode 100644 index 0000000000..4d1c029da0 --- /dev/null +++ b/frontend/editor/src/core/components/tools/convert/GroupedFormatDropdown.stories.tsx @@ -0,0 +1,55 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import GroupedFormatDropdown from "@app/components/tools/convert/GroupedFormatDropdown"; + +const documentOptions = [ + { value: "pdf", label: "PDF", group: "Document" }, + { value: "docx", label: "Word", group: "Document" }, + { value: "odt", label: "OpenDocument", group: "Document" }, + { value: "png", label: "PNG", group: "Image" }, + { value: "jpg", label: "JPEG", group: "Image" }, + { value: "epub", label: "EPUB", group: "Ebook", usesCloud: true }, + { value: "mobi", label: "MOBI", group: "Ebook", usesCloud: true }, + { value: "cbr", label: "CBR", group: "Comic", enabled: false }, +]; + +const meta = { + title: "Tools/Convert/GroupedFormatDropdown", + component: GroupedFormatDropdown, + args: { + options: documentOptions, + onChange: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +// The component owns no state itself, so each story wraps it in a small +// stateful shim to keep the selected format interactive in the canvas. +const GroupedFormatDropdownDemo = ( + props: Partial>, +) => { + const [value, setValue] = useState(props.value); + + return ( + + ); +}; + +export const Default: Story = { + render: () => , +}; + +export const Selected: Story = { + render: () => , +}; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/convert/GroupedFormatDropdown.tsx b/frontend/editor/src/core/components/tools/convert/GroupedFormatDropdown.tsx index 7ffd30fa85..8ab2f10662 100644 --- a/frontend/editor/src/core/components/tools/convert/GroupedFormatDropdown.tsx +++ b/frontend/editor/src/core/components/tools/convert/GroupedFormatDropdown.tsx @@ -104,7 +104,7 @@ const GroupedFormatDropdown = ({ cursor: disabled ? "not-allowed" : "pointer", width: "100%", color: disabled - ? "var(--dropdown-trigger-text-disabled)" + ? "var(--c-text-subtle)" : "var(--dropdown-trigger-text)", }} > diff --git a/frontend/editor/src/core/components/tools/crop/CropAreaSelector.stories.tsx b/frontend/editor/src/core/components/tools/crop/CropAreaSelector.stories.tsx new file mode 100644 index 0000000000..fbe30e65f5 --- /dev/null +++ b/frontend/editor/src/core/components/tools/crop/CropAreaSelector.stories.tsx @@ -0,0 +1,83 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Box, MantineProvider } from "@mantine/core"; +import CropAreaSelector from "@app/components/tools/crop/CropAreaSelector"; +import { Rectangle, PDFBounds } from "@app/utils/cropCoordinates"; +import { mantineTheme } from "@app/theme/mantineTheme"; + +// CropAreaSelector reads theme.other.crop (overlay/handle colors), which only +// the core app's Mantine theme defines. Nesting it here supplies that value +// without disturbing whatever theme wraps the story globally, since Mantine +// merges nested providers with their parent. +const pdfBounds: PDFBounds = { + actualWidth: 595.28, + actualHeight: 841.89, + thumbnailWidth: 300, + thumbnailHeight: 424, + offsetX: 0, + offsetY: 0, + scale: 300 / 595.28, +}; + +const meta = { + title: "Tools/Crop/CropAreaSelector", + component: CropAreaSelector, + parameters: { layout: "padded" }, + // Every story below supplies its own `render`, which ignores these args, but + // Storybook's types still require `args` to satisfy CropAreaSelector's + // required props. + args: { + pdfBounds, + cropArea: { x: 50, y: 50, width: 300, height: 400 }, + onCropAreaChange: () => {}, + children: null, + }, + decorators: [ + (Story) => ( + + + + ), + ], +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function Demo({ + initialCropArea = { x: 50, y: 50, width: 300, height: 400 }, + disabled, +}: { + initialCropArea?: Rectangle; + disabled?: boolean; +}) { + const [cropArea, setCropArea] = useState(initialCropArea); + + return ( + + + + + + ); +} + +export const Default: Story = { render: () => }; + +export const Disabled: Story = { render: () => }; diff --git a/frontend/editor/src/core/components/tools/crop/CropAutomationSettings.stories.tsx b/frontend/editor/src/core/components/tools/crop/CropAutomationSettings.stories.tsx new file mode 100644 index 0000000000..dfb1aede62 --- /dev/null +++ b/frontend/editor/src/core/components/tools/crop/CropAutomationSettings.stories.tsx @@ -0,0 +1,59 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import CropAutomationSettings from "@app/components/tools/crop/CropAutomationSettings"; +import { + CropParameters, + defaultParameters, +} from "@app/hooks/tools/crop/useCropParameters"; + +const meta = { + title: "Tools/Crop/CropAutomationSettings", + component: CropAutomationSettings, + parameters: { layout: "padded" }, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function SettingsDemo({ + initialParameters = defaultParameters, + disabled, +}: { + initialParameters?: CropParameters; + disabled?: boolean; +}) { + const [parameters, setParameters] = + useState(initialParameters); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { + render: () => , +}; + +export const CustomArea: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/crop/CropCoordinateInputs.stories.tsx b/frontend/editor/src/core/components/tools/crop/CropCoordinateInputs.stories.tsx new file mode 100644 index 0000000000..fbf034b7e7 --- /dev/null +++ b/frontend/editor/src/core/components/tools/crop/CropCoordinateInputs.stories.tsx @@ -0,0 +1,63 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import CropCoordinateInputs from "@app/components/tools/crop/CropCoordinateInputs"; +import { Rectangle, PDFBounds } from "@app/utils/cropCoordinates"; + +const meta = { + title: "Tools/Crop/CropCoordinateInputs", + component: CropCoordinateInputs, + parameters: { layout: "padded" }, + args: { + cropArea: { x: 50, y: 50, width: 300, height: 400 }, + onCoordinateChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +const pdfBounds: PDFBounds = { + actualWidth: 595.28, + actualHeight: 841.89, + thumbnailWidth: 300, + thumbnailHeight: 424, + offsetX: 0, + offsetY: 0, + scale: 300 / 595.28, +}; + +function Demo({ + initialCropArea = { x: 50, y: 50, width: 300, height: 400 }, + disabled, + showAutomationInfo, + withBounds = true, +}: { + initialCropArea?: Rectangle; + disabled?: boolean; + showAutomationInfo?: boolean; + withBounds?: boolean; +}) { + const [cropArea, setCropArea] = useState(initialCropArea); + + return ( + + setCropArea((prev) => ({ + ...prev, + [field]: typeof value === "number" ? value : Number(value) || 0, + })) + } + disabled={disabled} + pdfBounds={withBounds ? pdfBounds : undefined} + showAutomationInfo={showAutomationInfo} + /> + ); +} + +export const Default: Story = { render: () => }; + +export const AutomationInfo: Story = { + render: () => , +}; + +export const Disabled: Story = { render: () => }; diff --git a/frontend/editor/src/core/components/tools/editTableOfContents/BookmarkEditor.stories.tsx b/frontend/editor/src/core/components/tools/editTableOfContents/BookmarkEditor.stories.tsx new file mode 100644 index 0000000000..945bd29745 --- /dev/null +++ b/frontend/editor/src/core/components/tools/editTableOfContents/BookmarkEditor.stories.tsx @@ -0,0 +1,45 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import BookmarkEditor from "@app/components/tools/editTableOfContents/BookmarkEditor"; +import { createBookmarkNode } from "@app/utils/editTableOfContents"; + +const meta = { + title: "Tools/EditTableOfContents/BookmarkEditor", + component: BookmarkEditor, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + bookmarks: [ + createBookmarkNode({ + title: "Chapter 1: Introduction", + pageNumber: 1, + children: [ + createBookmarkNode({ + title: "Section 1.1: Background", + pageNumber: 2, + }), + createBookmarkNode({ title: "Section 1.2: Scope", pageNumber: 4 }), + ], + }), + createBookmarkNode({ title: "Chapter 2: Methodology", pageNumber: 8 }), + ], + onChange: () => {}, + }, +}; + +export const Empty: Story = { + args: { + bookmarks: [], + onChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + ...Default.args, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/editTableOfContents/BookmarkEditor.tsx b/frontend/editor/src/core/components/tools/editTableOfContents/BookmarkEditor.tsx index 9b6152cf55..c1711a635c 100644 --- a/frontend/editor/src/core/components/tools/editTableOfContents/BookmarkEditor.tsx +++ b/frontend/editor/src/core/components/tools/editTableOfContents/BookmarkEditor.tsx @@ -219,8 +219,9 @@ export default function BookmarkEditor({ withBorder p="md" style={{ - borderColor: "var(--border-default)", - background: level === 0 ? "var(--bg-surface)" : "var(--bg-muted)", + borderColor: "var(--c-border)", + background: + level === 0 ? "var(--c-surface)" : "var(--c-surface-sunken)", }} > @@ -380,7 +381,7 @@ export default function BookmarkEditor({ {bookmark.children.map((child) => ( diff --git a/frontend/editor/src/core/components/tools/editTableOfContents/EditTableOfContentsSettings.stories.tsx b/frontend/editor/src/core/components/tools/editTableOfContents/EditTableOfContentsSettings.stories.tsx new file mode 100644 index 0000000000..a9daf70b4e --- /dev/null +++ b/frontend/editor/src/core/components/tools/editTableOfContents/EditTableOfContentsSettings.stories.tsx @@ -0,0 +1,69 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import EditTableOfContentsSettings from "@app/components/tools/editTableOfContents/EditTableOfContentsSettings"; +import { BookmarkNode } from "@app/utils/editTableOfContents"; + +const sampleBookmarks: BookmarkNode[] = [ + { + id: "1", + title: "Chapter 1", + pageNumber: 1, + expanded: true, + children: [ + { + id: "1.1", + title: "Section 1.1", + pageNumber: 2, + expanded: false, + children: [], + }, + ], + }, + { + id: "2", + title: "Chapter 2", + pageNumber: 5, + expanded: true, + children: [], + }, +]; + +const meta = { + title: "Tools/EditTableOfContents/EditTableOfContentsSettings", + component: EditTableOfContentsSettings, + args: { + bookmarks: sampleBookmarks, + replaceExisting: true, + onReplaceExistingChange: () => {}, + onSelectFiles: () => {}, + onLoadFromPdf: () => {}, + onImportJson: () => {}, + onImportClipboard: () => {}, + onExportJson: () => {}, + onExportClipboard: () => {}, + isLoading: false, + loadError: null, + canReadClipboard: true, + canWriteClipboard: true, + disabled: false, + selectedFileName: "document.pdf", + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const NoFileSelected: Story = { + args: { + selectedFileName: undefined, + bookmarks: [], + }, +}; + +export const LoadingWithError: Story = { + args: { + isLoading: true, + loadError: "Failed to read bookmarks from the selected PDF.", + }, +}; diff --git a/frontend/editor/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.stories.tsx b/frontend/editor/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.stories.tsx new file mode 100644 index 0000000000..265d7c0786 --- /dev/null +++ b/frontend/editor/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.stories.tsx @@ -0,0 +1,86 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import EditTableOfContentsWorkbenchView, { + type EditTableOfContentsWorkbenchViewData, +} from "@app/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView"; +import { createBookmarkNode } from "@app/utils/editTableOfContents"; + +const meta = { + title: "Tools/EditTableOfContents/EditTableOfContentsWorkbenchView", + component: EditTableOfContentsWorkbenchView, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +const sampleFile = new File(["%PDF-1.4"], "annual-report.pdf", { + type: "application/pdf", +}); + +const sampleBookmarks = [ + createBookmarkNode({ + title: "Introduction", + pageNumber: 1, + }), + createBookmarkNode({ + title: "Chapter 1: Overview", + pageNumber: 3, + children: [ + createBookmarkNode({ title: "Background", pageNumber: 4 }), + createBookmarkNode({ title: "Scope", pageNumber: 6 }), + ], + }), + createBookmarkNode({ + title: "Conclusion", + pageNumber: 20, + }), +]; + +const baseData: EditTableOfContentsWorkbenchViewData = { + bookmarks: sampleBookmarks, + selectedFileName: sampleFile.name, + disabled: false, + files: [sampleFile], + thumbnails: [undefined], + downloadUrl: null, + downloadFilename: null, + errorMessage: null, + isGeneratingThumbnails: false, + isExecuteDisabled: false, + isExecuting: false, + onClearError: () => {}, + onBookmarksChange: () => {}, + onExecute: () => {}, + onUndo: () => {}, + onFileClick: () => {}, +}; + +export const Default: Story = { + args: { + data: baseData, + }, +}; + +export const Empty: Story = { + args: { + data: null, + }, +}; + +export const WithResults: Story = { + args: { + data: { + ...baseData, + downloadUrl: "blob:https://example.com/annual-report-toc.pdf", + downloadFilename: "annual-report-toc.pdf", + }, + }, +}; + +export const WithError: Story = { + args: { + data: { + ...baseData, + errorMessage: "Failed to apply the table of contents.", + }, + }, +}; diff --git a/frontend/editor/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.tsx b/frontend/editor/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.tsx index 148917dba6..d5f8901673 100644 --- a/frontend/editor/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.tsx +++ b/frontend/editor/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.tsx @@ -100,7 +100,7 @@ const EditTableOfContentsWorkbenchView = ({ width: "100%", height: "100%", overflowY: "auto", - background: "var(--bg-raised)", + background: "var(--c-surface-raised)", }} > @@ -121,8 +121,8 @@ const EditTableOfContentsWorkbenchView = ({ radius="md" p="xl" style={{ - backgroundColor: "var(--bg-surface)", - borderColor: "var(--border-default)", + backgroundColor: "var(--c-surface)", + borderColor: "var(--c-border)", boxShadow: "var(--shadow-md)", }} > @@ -167,8 +167,8 @@ const EditTableOfContentsWorkbenchView = ({ radius="md" p="xl" style={{ - backgroundColor: "var(--bg-surface)", - borderColor: "var(--border-default)", + backgroundColor: "var(--c-surface)", + borderColor: "var(--c-border)", boxShadow: "var(--shadow-md)", }} > diff --git a/frontend/editor/src/core/components/tools/extractImages/ExtractImagesSettings.stories.tsx b/frontend/editor/src/core/components/tools/extractImages/ExtractImagesSettings.stories.tsx new file mode 100644 index 0000000000..e0f7c0a014 --- /dev/null +++ b/frontend/editor/src/core/components/tools/extractImages/ExtractImagesSettings.stories.tsx @@ -0,0 +1,60 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ExtractImagesSettings from "@app/components/tools/extractImages/ExtractImagesSettings"; +import { + ExtractImagesParameters, + defaultParameters, +} from "@app/hooks/tools/extractImages/useExtractImagesParameters"; + +const meta = { + title: "Tools/ExtractImages/ExtractImagesSettings", + component: ExtractImagesSettings, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +// The component owns no state itself, so each story wraps it in a small +// stateful shim to keep the dropdown interactive in the canvas. +const ExtractImagesSettingsDemo = (props: { + initialParameters: ExtractImagesParameters; + disabled?: boolean; +}) => { + const [parameters, setParameters] = useState( + props.initialParameters, + ); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={props.disabled} + /> + ); +}; + +export const Default: Story = { + render: () => ( + + ), +}; + +export const JpgFormat: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/core/components/tools/extractPages/ExtractPagesSettings.stories.tsx b/frontend/editor/src/core/components/tools/extractPages/ExtractPagesSettings.stories.tsx new file mode 100644 index 0000000000..0ebf63c149 --- /dev/null +++ b/frontend/editor/src/core/components/tools/extractPages/ExtractPagesSettings.stories.tsx @@ -0,0 +1,39 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ExtractPagesSettings from "@app/components/tools/extractPages/ExtractPagesSettings"; +import { ExtractPagesParameters } from "@app/hooks/tools/extractPages/useExtractPagesParameters"; + +const meta = { + title: "Tools/ExtractPages/ExtractPagesSettings", + component: ExtractPagesSettings, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +const buildParameters = ( + overrides: Partial = {}, +): ExtractPagesParameters => ({ + pageNumbers: "", + ...overrides, +}); + +export const Default: Story = { + args: { + parameters: buildParameters(), + onParameterChange: () => {}, + }, +}; + +export const Filled: Story = { + args: { + parameters: buildParameters({ pageNumbers: "1,3,5-8" }), + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + parameters: buildParameters({ pageNumbers: "1-10" }), + onParameterChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/flatten/FlattenSettings.stories.tsx b/frontend/editor/src/core/components/tools/flatten/FlattenSettings.stories.tsx new file mode 100644 index 0000000000..9f5e84b3b4 --- /dev/null +++ b/frontend/editor/src/core/components/tools/flatten/FlattenSettings.stories.tsx @@ -0,0 +1,48 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import FlattenSettings from "@app/components/tools/flatten/FlattenSettings"; +import { FlattenParameters } from "@app/hooks/tools/flatten/useFlattenParameters"; + +const buildParameters = ( + overrides: Partial = {}, +): FlattenParameters => ({ + flattenOnlyForms: false, + renderDpi: undefined, + ...overrides, +}); + +const meta = { + title: "Tools/Flatten/FlattenSettings", + component: FlattenSettings, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + parameters: buildParameters(), + onParameterChange: () => {}, + }, +}; + +export const FlattenOnlyForms: Story = { + args: { + parameters: buildParameters({ flattenOnlyForms: true }), + onParameterChange: () => {}, + }, +}; + +export const CustomRenderDpi: Story = { + args: { + parameters: buildParameters({ renderDpi: 300 }), + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + parameters: buildParameters(), + onParameterChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/fullscreen/DetailedToolItem.stories.tsx b/frontend/editor/src/core/components/tools/fullscreen/DetailedToolItem.stories.tsx new file mode 100644 index 0000000000..cd443e248a --- /dev/null +++ b/frontend/editor/src/core/components/tools/fullscreen/DetailedToolItem.stories.tsx @@ -0,0 +1,103 @@ +import type React from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import DetailedToolItem from "@app/components/tools/fullscreen/DetailedToolItem"; +import { PreferencesProvider } from "@app/contexts/PreferencesContext"; +import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider"; +import { NavigationProvider } from "@app/contexts/NavigationContext"; +import { + ToolWorkflowProvider, + useToolWorkflow, +} from "@app/contexts/ToolWorkflowContext"; +import { HotkeyProvider } from "@app/contexts/HotkeyContext"; +import { AppConfigProvider } from "@app/contexts/AppConfigContext"; +import type { ToolId } from "@app/types/toolId"; +import { + ToolCategoryId, + SubcategoryId, + type ToolRegistryEntry, +} from "@app/data/toolsTaxonomy"; + +// DetailedToolItem reads hotkeys/favourites/availability via useToolMeta, which +// pulls from HotkeyContext, ToolWorkflowContext and AppConfigContext, so every +// provider here must be present or those reads fail. AppConfigProvider uses +// autoFetch={false} to skip the network fetch and render synchronously instead +// of showing a loading state. +function withProviders(Story: () => React.JSX.Element) { + return ( + + + + + + + + + + + + + + ); +} + +// Pulls a real entry out of the same registry the component reads internally +// (via useToolMeta), so the icon/description/availability match a real render. +function ToolItemDemo({ + toolId, + isSelected = false, +}: { + toolId: ToolId; + isSelected?: boolean; +}) { + const { toolRegistry } = useToolWorkflow(); + const tool = toolRegistry[toolId]; + if (!tool) return null; + + return ( + {}} + /> + ); +} + +// Meta-level args are inherited by every story below. The stories themselves +// use `render` to swap in ToolItemDemo (which pulls a real registry entry), +// so these values never actually reach DetailedToolItem — they only exist to +// satisfy the required-props type on StoryAnnotations. +const mockTool: ToolRegistryEntry = { + icon: null, + name: "Split", + component: null, + description: "Split a PDF into multiple files", + categoryId: ToolCategoryId.STANDARD_TOOLS, + subcategoryId: SubcategoryId.GENERAL, + automationSettings: null, +}; + +const meta = { + title: "Tools/Fullscreen/DetailedToolItem", + component: DetailedToolItem, + decorators: [withProviders], + args: { + id: "split", + tool: mockTool, + isSelected: false, + onClick: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +/** An available tool rendered in its default, unselected state. */ +export const Default: Story = { + render: () => , +}; + +/** The active tool in the panel — highlighted selected state. */ +export const Selected: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/getPdfInfo/GetPdfInfoReportView.stories.tsx b/frontend/editor/src/core/components/tools/getPdfInfo/GetPdfInfoReportView.stories.tsx new file mode 100644 index 0000000000..08352aaab6 --- /dev/null +++ b/frontend/editor/src/core/components/tools/getPdfInfo/GetPdfInfoReportView.stories.tsx @@ -0,0 +1,112 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import GetPdfInfoReportView from "@app/components/tools/getPdfInfo/GetPdfInfoReportView"; +import type { PdfInfoReportData } from "@app/types/getPdfInfo"; + +const filledData: PdfInfoReportData = { + generatedAt: Date.now(), + entries: [ + { + fileId: "file-1", + fileName: "annual-report.pdf", + fileSize: 245_760, + lastModified: Date.now(), + thumbnailUrl: null, + error: null, + data: { + Metadata: { + Title: "Annual Report 2025", + Author: "Stirling PDF", + Subject: "Financials", + Keywords: "annual, report, finance", + Creator: "Stirling PDF", + Producer: "Stirling PDF", + CreationDate: "2025-01-15T10:00:00Z", + ModificationDate: "2025-02-01T08:30:00Z", + }, + BasicInfo: { + FileSizeInBytes: 245_760, + WordCount: 12_400, + ParagraphCount: 320, + CharacterCount: 78_000, + Compression: true, + CompressionType: "Flate", + Language: "en-US", + "Number of pages": 24, + TotalImages: 6, + }, + DocumentInfo: { + "PDF version": "1.7", + Trapped: "False", + "Page Mode": "UseOutlines", + }, + Encryption: { + IsEncrypted: false, + }, + Permissions: { + Printing: "Allowed", + Modifying: "Not Allowed", + "Extracting Content": "Allowed", + }, + Compliancy: { + "IsPDF/ACompliant": true, + "PDF/AConformanceLevel": "2B", + }, + "Bookmarks/Outline/TOC": [ + { Title: "Introduction" }, + { Title: "Financial Summary" }, + { Title: "Appendix" }, + ], + Other: { + Attachments: [], + EmbeddedFiles: [], + JavaScript: [], + }, + PerPageInfo: { + "Page 1": { + Rotation: 0, + "Page Orientation": "Portrait", + }, + }, + SummaryData: { + encrypted: false, + restrictedPermissions: ["Modifying"], + restrictedPermissionsCount: 1, + Compliance: [ + { + Standard: "PDF/A", + Compliant: true, + Summary: "Fully compliant with PDF/A-2B.", + }, + ], + }, + }, + summaryGeneratedAt: Date.now(), + }, + ], +}; + +const emptyData: PdfInfoReportData = { + generatedAt: Date.now(), + entries: [], +}; + +const meta = { + title: "Tools/GetPdfInfo/GetPdfInfoReportView", + component: GetPdfInfoReportView, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + data: filledData, + }, +}; + +export const NoData: Story = { + args: { + data: emptyData, + }, +}; diff --git a/frontend/editor/src/core/components/tools/getPdfInfo/GetPdfInfoResults.stories.tsx b/frontend/editor/src/core/components/tools/getPdfInfo/GetPdfInfoResults.stories.tsx new file mode 100644 index 0000000000..92d8f82377 --- /dev/null +++ b/frontend/editor/src/core/components/tools/getPdfInfo/GetPdfInfoResults.stories.tsx @@ -0,0 +1,103 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import GetPdfInfoResults from "@app/components/tools/getPdfInfo/GetPdfInfoResults"; +import type { GetPdfInfoOperationHook } from "@app/hooks/tools/getPdfInfo/useGetPdfInfoOperation"; +import type { PdfInfoReportEntry } from "@app/types/getPdfInfo"; + +const mockEntry: PdfInfoReportEntry = { + fileId: "file-1", + fileName: "sample.pdf", + fileSize: 245_760, + lastModified: Date.now(), + thumbnailUrl: null, + data: {}, + error: null, + summaryGeneratedAt: Date.now(), +}; + +const baseOperation: GetPdfInfoOperationHook = { + files: [], + thumbnails: [], + isGeneratingThumbnails: false, + downloadUrl: null, + downloadFilename: "", + isLoading: false, + status: "", + errorMessage: null, + progress: null, + executeOperation: async () => {}, + resetResults: () => {}, + clearError: () => {}, + cancelOperation: () => {}, + undoOperation: async () => {}, + results: [], +}; + +const meta = { + title: "Tools/GetPdfInfo/GetPdfInfoResults", + component: GetPdfInfoResults, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + operation: { + ...baseOperation, + results: [mockEntry], + files: [ + new File([JSON.stringify(mockEntry.data)], "response.json", { + type: "application/json", + }), + ], + }, + isLoading: false, + errorMessage: null, + }, +}; + +export const Loading: Story = { + args: { + operation: { + ...baseOperation, + results: [], + }, + isLoading: true, + errorMessage: null, + }, +}; + +export const Empty: Story = { + args: { + operation: { + ...baseOperation, + results: [], + }, + isLoading: false, + errorMessage: null, + }, +}; + +export const PartialError: Story = { + args: { + operation: { + ...baseOperation, + results: [ + mockEntry, + { + ...mockEntry, + fileId: "file-2", + fileName: "broken.pdf", + error: "Could not read file", + }, + ], + files: [ + new File([JSON.stringify(mockEntry.data)], "response.json", { + type: "application/json", + }), + ], + }, + isLoading: false, + errorMessage: "Some files could not be processed.", + }, +}; diff --git a/frontend/editor/src/core/components/tools/getPdfInfo/sections/ComplianceSection.stories.tsx b/frontend/editor/src/core/components/tools/getPdfInfo/sections/ComplianceSection.stories.tsx new file mode 100644 index 0000000000..b2cb29b81d --- /dev/null +++ b/frontend/editor/src/core/components/tools/getPdfInfo/sections/ComplianceSection.stories.tsx @@ -0,0 +1,59 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ComplianceSection from "@app/components/tools/getPdfInfo/sections/ComplianceSection"; + +const meta = { + title: "Tools/GetPdfInfo/ComplianceSection", + component: ComplianceSection, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + anchorId: "compliance", + complianceSummary: [ + { + Standard: "pdfa-2b", + Compliant: true, + Summary: "Document conforms to PDF/A-2B requirements", + }, + { + Standard: "pdfua-1", + Compliant: false, + Summary: + "Document is missing required tagging structure for accessibility", + }, + ], + legacyCompliance: { + "IsPDF/SECCompliant": true, + }, + }, +}; + +export const AllPassed: Story = { + args: { + anchorId: "compliance-passed", + complianceSummary: [ + { + Standard: "pdfa-3b", + Compliant: true, + Summary: "Document conforms to PDF/A-3B requirements", + }, + { + Standard: "pdfua-1", + Compliant: true, + Summary: "Document meets PDF/UA-1 accessibility requirements", + }, + ], + legacyCompliance: null, + }, +}; + +export const Empty: Story = { + args: { + anchorId: "compliance-empty", + complianceSummary: [], + legacyCompliance: null, + }, +}; diff --git a/frontend/editor/src/core/components/tools/getPdfInfo/sections/KeyValueSection.stories.tsx b/frontend/editor/src/core/components/tools/getPdfInfo/sections/KeyValueSection.stories.tsx new file mode 100644 index 0000000000..75b2dd679d --- /dev/null +++ b/frontend/editor/src/core/components/tools/getPdfInfo/sections/KeyValueSection.stories.tsx @@ -0,0 +1,32 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import KeyValueSection from "@app/components/tools/getPdfInfo/sections/KeyValueSection"; + +const meta = { + title: "Tools/GetPdfInfo/KeyValueSection", + component: KeyValueSection, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + title: "Document Info", + anchorId: "document-info", + obj: { + Title: "Sample Document", + Author: "Jane Doe", + Producer: "Stirling-PDF", + CreationDate: "2026-01-15", + }, + }, +}; + +export const Empty: Story = { + args: { + title: "Custom Metadata", + anchorId: "custom-metadata", + obj: {}, + emptyLabel: "No custom metadata found", + }, +}; diff --git a/frontend/editor/src/core/components/tools/getPdfInfo/sections/OtherSection.stories.tsx b/frontend/editor/src/core/components/tools/getPdfInfo/sections/OtherSection.stories.tsx new file mode 100644 index 0000000000..f087e0e8f7 --- /dev/null +++ b/frontend/editor/src/core/components/tools/getPdfInfo/sections/OtherSection.stories.tsx @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import OtherSection from "@app/components/tools/getPdfInfo/sections/OtherSection"; +import type { PdfOtherInfo } from "@app/types/getPdfInfo"; + +const populatedOther: PdfOtherInfo = { + Attachments: [ + { Name: "invoice.xlsx", Description: "Original invoice", FileSize: 24576 }, + ], + EmbeddedFiles: [ + { + Name: "font-data.bin", + FileSize: 10240, + MimeType: "application/octet-stream", + CreationDate: "2026-01-01", + ModificationDate: "2026-02-01", + }, + ], + JavaScript: [{ "JS Name": "AutoPrint", "JS Script Length": 42 }], + Layers: [{ Name: "Watermark" }], + StructureTree: [{ Type: "Document" }], + XMPMetadata: "...", +}; + +const meta = { + title: "Tools/GetPdfInfo/Sections/OtherSection", + component: OtherSection, + parameters: { layout: "padded" }, + args: { + anchorId: "other", + other: populatedOther, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const Empty: Story = { + args: { + other: {}, + }, +}; diff --git a/frontend/editor/src/core/components/tools/getPdfInfo/sections/PerPageSection.stories.tsx b/frontend/editor/src/core/components/tools/getPdfInfo/sections/PerPageSection.stories.tsx new file mode 100644 index 0000000000..3cf0bbc9d7 --- /dev/null +++ b/frontend/editor/src/core/components/tools/getPdfInfo/sections/PerPageSection.stories.tsx @@ -0,0 +1,72 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import PerPageSection from "@app/components/tools/getPdfInfo/sections/PerPageSection"; +import type { PdfPerPageInfo } from "@app/types/getPdfInfo"; + +const perPage: PdfPerPageInfo = { + "Page 1": { + Size: { + "Width (px)": "612", + "Height (px)": "792", + "Width (in)": "8.5", + "Height (in)": "11", + "Standard Page": "Letter", + }, + Rotation: 0, + "Page Orientation": "Portrait", + MediaBox: "[0.0, 0.0, 612.0, 792.0]", + CropBox: "[0.0, 0.0, 612.0, 792.0]", + "Text Characters Count": 1284, + Annotations: { + AnnotationsCount: 2, + SubtypeCount: 1, + ContentsCount: 1, + }, + Images: [{ Name: "Im0", Width: 400, Height: 300, ColorSpace: "DeviceRGB" }], + Links: [{ URI: "https://stirlingpdf.com" }], + Fonts: [ + { Name: "Helvetica", IsEmbedded: true, Subtype: "Type1" }, + { Name: "Times-Roman", IsEmbedded: false, Subtype: "Type1" }, + ], + XObjectCounts: { Image: 1, Form: 0, Other: 0 }, + Multimedia: [], + }, + "Page 2": { + Size: { + "Width (px)": "612", + "Height (px)": "792", + "Standard Page": "Letter", + }, + Rotation: 90, + "Page Orientation": "Landscape", + MediaBox: "[0.0, 0.0, 612.0, 792.0]", + "Text Characters Count": 0, + Images: [], + Links: [], + Fonts: [], + Multimedia: [], + }, +}; + +const meta = { + title: "GetPdfInfo/PerPageSection", + component: PerPageSection, + parameters: { layout: "padded" }, + args: { + anchorId: "per-page-info", + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + perPage, + }, +}; + +export const Empty: Story = { + args: { + perPage: null, + }, +}; diff --git a/frontend/editor/src/core/components/tools/getPdfInfo/sections/PerPageSection.tsx b/frontend/editor/src/core/components/tools/getPdfInfo/sections/PerPageSection.tsx index 0c11cf592a..80eb184a42 100644 --- a/frontend/editor/src/core/components/tools/getPdfInfo/sections/PerPageSection.tsx +++ b/frontend/editor/src/core/components/tools/getPdfInfo/sections/PerPageSection.tsx @@ -125,8 +125,8 @@ const PerPageSection: React.FC = ({
    ; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + sections: fullSections, + }, +}; + +export const Empty: Story = { + args: { + sections: emptySections, + }, +}; + +export const HiddenTitle: Story = { + args: { + sections: fullSections, + hideSectionTitle: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/getPdfInfo/sections/TableOfContentsSection.stories.tsx b/frontend/editor/src/core/components/tools/getPdfInfo/sections/TableOfContentsSection.stories.tsx new file mode 100644 index 0000000000..9645e7d1ec --- /dev/null +++ b/frontend/editor/src/core/components/tools/getPdfInfo/sections/TableOfContentsSection.stories.tsx @@ -0,0 +1,31 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import TableOfContentsSection from "@app/components/tools/getPdfInfo/sections/TableOfContentsSection"; +import type { PdfTocEntry } from "@app/types/getPdfInfo"; + +const tocArray: PdfTocEntry[] = [ + { Title: "Chapter 1: Introduction" }, + { Title: "Chapter 2: Getting Started" }, + { Title: "Chapter 3: Advanced Topics" }, +]; + +const meta = { + title: "Tools/GetPdfInfo/TableOfContentsSection", + component: TableOfContentsSection, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + anchorId: "table-of-contents", + tocArray, + }, +}; + +export const Empty: Story = { + args: { + anchorId: "table-of-contents-empty", + tocArray: [], + }, +}; diff --git a/frontend/editor/src/core/components/tools/getPdfInfo/shared/KeyValueList.stories.tsx b/frontend/editor/src/core/components/tools/getPdfInfo/shared/KeyValueList.stories.tsx new file mode 100644 index 0000000000..6c3bed6e0f --- /dev/null +++ b/frontend/editor/src/core/components/tools/getPdfInfo/shared/KeyValueList.stories.tsx @@ -0,0 +1,28 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import KeyValueList from "@app/components/tools/getPdfInfo/shared/KeyValueList"; + +const meta = { + title: "Tools/GetPdfInfo/KeyValueList", + component: KeyValueList, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + obj: { + Title: "Sample Document", + Author: "Jane Doe", + Producer: "Stirling-PDF", + CreationDate: "2026-01-15", + }, + }, +}; + +export const Empty: Story = { + args: { + obj: {}, + emptyLabel: "No custom metadata found", + }, +}; diff --git a/frontend/editor/src/core/components/tools/getPdfInfo/shared/ScrollableCodeBlock.stories.tsx b/frontend/editor/src/core/components/tools/getPdfInfo/shared/ScrollableCodeBlock.stories.tsx new file mode 100644 index 0000000000..30ff2ccb19 --- /dev/null +++ b/frontend/editor/src/core/components/tools/getPdfInfo/shared/ScrollableCodeBlock.stories.tsx @@ -0,0 +1,38 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ScrollableCodeBlock from "@app/components/tools/getPdfInfo/shared/ScrollableCodeBlock"; + +const meta = { + title: "Tools/GetPdfInfo/Shared/ScrollableCodeBlock", + component: ScrollableCodeBlock, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + content: ` + + + + Sample Document + Stirling PDF + + + +`, + }, +}; + +export const Empty: Story = { + args: { + content: null, + }, +}; + +export const CustomEmptyMessage: Story = { + args: { + content: undefined, + emptyMessage: "No structure tree found in this document", + }, +}; diff --git a/frontend/editor/src/core/components/tools/getPdfInfo/shared/ScrollableCodeBlock.tsx b/frontend/editor/src/core/components/tools/getPdfInfo/shared/ScrollableCodeBlock.tsx index 4d66e247b8..35373c0ef7 100644 --- a/frontend/editor/src/core/components/tools/getPdfInfo/shared/ScrollableCodeBlock.tsx +++ b/frontend/editor/src/core/components/tools/getPdfInfo/shared/ScrollableCodeBlock.tsx @@ -32,8 +32,8 @@ const ScrollableCodeBlock: React.FC = ({ block style={{ whiteSpace: "pre-wrap", - backgroundColor: "var(--bg-raised)", - color: "var(--text-primary)", + backgroundColor: "var(--c-surface-raised)", + color: "var(--c-text)", maxHeight, overflowY: "auto", }} diff --git a/frontend/editor/src/core/components/tools/getPdfInfo/shared/SectionBlock.stories.tsx b/frontend/editor/src/core/components/tools/getPdfInfo/shared/SectionBlock.stories.tsx new file mode 100644 index 0000000000..efd32402a9 --- /dev/null +++ b/frontend/editor/src/core/components/tools/getPdfInfo/shared/SectionBlock.stories.tsx @@ -0,0 +1,28 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import SectionBlock from "@app/components/tools/getPdfInfo/shared/SectionBlock"; +import KeyValueList from "@app/components/tools/getPdfInfo/shared/KeyValueList"; + +const meta = { + title: "Tools/GetPdfInfo/Shared/SectionBlock", + component: SectionBlock, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + title: "Document Info", + anchorId: "document-info", + children: ( + + ), + }, +}; diff --git a/frontend/editor/src/core/components/tools/getPdfInfo/shared/accordionStyles.ts b/frontend/editor/src/core/components/tools/getPdfInfo/shared/accordionStyles.ts index 73e45eee63..b10d60174f 100644 --- a/frontend/editor/src/core/components/tools/getPdfInfo/shared/accordionStyles.ts +++ b/frontend/editor/src/core/components/tools/getPdfInfo/shared/accordionStyles.ts @@ -5,7 +5,7 @@ type AccordionStyles = Partial>; export const pdfInfoAccordionStyles: AccordionStyles = { item: { - backgroundColor: "var(--accordion-item-bg)", + backgroundColor: "var(--c-surface-raised)", }, control: { backgroundColor: "transparent", diff --git a/frontend/editor/src/core/components/tools/merge/MergeFileSorter.stories.tsx b/frontend/editor/src/core/components/tools/merge/MergeFileSorter.stories.tsx new file mode 100644 index 0000000000..d919d3ef3a --- /dev/null +++ b/frontend/editor/src/core/components/tools/merge/MergeFileSorter.stories.tsx @@ -0,0 +1,23 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import MergeFileSorter from "@app/components/tools/merge/MergeFileSorter"; + +const meta = { + title: "Tools/Merge/MergeFileSorter", + component: MergeFileSorter, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + onSortFiles: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + onSortFiles: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/merge/MergeSettings.stories.tsx b/frontend/editor/src/core/components/tools/merge/MergeSettings.stories.tsx new file mode 100644 index 0000000000..689e210612 --- /dev/null +++ b/frontend/editor/src/core/components/tools/merge/MergeSettings.stories.tsx @@ -0,0 +1,44 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import MergeSettings from "@app/components/tools/merge/MergeSettings"; +import { MergeParameters } from "@app/hooks/tools/merge/useMergeParameters"; + +const meta = { + title: "tools/merge/MergeSettings", + component: MergeSettings, + args: { + parameters: { + removeDigitalSignature: false, + generateTableOfContents: false, + }, + onParameterChange: () => {}, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +function MergeSettingsDemo({ disabled }: { disabled?: boolean }) { + const [parameters, setParameters] = useState({ + removeDigitalSignature: false, + generateTableOfContents: false, + }); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { + render: () => , +}; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/ocr/AdvancedOCRSettings.stories.tsx b/frontend/editor/src/core/components/tools/ocr/AdvancedOCRSettings.stories.tsx new file mode 100644 index 0000000000..863f730d7a --- /dev/null +++ b/frontend/editor/src/core/components/tools/ocr/AdvancedOCRSettings.stories.tsx @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import AdvancedOCRSettings from "@app/components/tools/ocr/AdvancedOCRSettings"; + +const meta = { + title: "Tools/Ocr/AdvancedOCRSettings", + component: AdvancedOCRSettings, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + advancedOptions: [], + ocrRenderType: "hocr", + onParameterChange: () => {}, + }, +}; + +export const OptionsSelected: Story = { + args: { + advancedOptions: ["sidecar", "deskew"], + ocrRenderType: "hocr", + onParameterChange: () => {}, + }, +}; + +export const CompatibilityMode: Story = { + args: { + advancedOptions: [], + ocrRenderType: "sandwich", + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + advancedOptions: ["clean"], + ocrRenderType: "hocr", + onParameterChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/ocr/LanguagePicker.module.css b/frontend/editor/src/core/components/tools/ocr/LanguagePicker.module.css index 6f7ae437b1..02e91270fa 100644 --- a/frontend/editor/src/core/components/tools/ocr/LanguagePicker.module.css +++ b/frontend/editor/src/core/components/tools/ocr/LanguagePicker.module.css @@ -5,9 +5,9 @@ flex-direction: row; align-items: center; /* Center align items vertically */ height: 32px; - border: 1px solid var(--border-default); - background-color: var(--mantine-color-white); /* Use Mantine color variable */ - color: var(--text-secondary); + border: 1px solid var(--c-border); + background-color: var(--c-input-bg); + color: var(--c-text-muted); border-radius: var(--radius-sm); padding: 4px 8px; font-size: 13px; @@ -15,25 +15,9 @@ transition: all 0.2s ease; } -/* Dark mode background */ -[data-mantine-color-scheme="dark"] .languagePicker { - background-color: var( - --mantine-color-dark-6 - ); /* Use Mantine dark color instead of hardcoded */ -} - .languagePicker:hover { - border-color: var(--border-strong); - background-color: var( - --mantine-color-gray-0 - ); /* Light gray on hover for light mode */ -} - -/* Dark mode hover */ -[data-mantine-color-scheme="dark"] .languagePicker:hover { - background-color: var( - --mantine-color-dark-5 - ); /* Use Mantine color variable */ + border-color: var(--c-border-strong); + background-color: var(--c-hover); } .languagePicker:disabled { @@ -43,30 +27,25 @@ .languagePickerIcon { font-size: 16px; - color: var(--text-muted); + color: var(--c-text-subtle); margin-left: auto; display: flex; align-items: center; /* Center the icon vertically */ } .languagePickerDropdown { - background-color: var(--mantine-color-white); /* Use Mantine color variable */ - border: 1px solid var(--border-default); + background-color: var(--c-surface); + border: 1px solid var(--c-border); border-radius: var(--radius-sm); padding: 4px; } -/* Dark mode dropdown background */ -[data-mantine-color-scheme="dark"] .languagePickerDropdown { - background-color: var(--mantine-color-dark-6); -} - .languagePickerOption { padding: 6px 10px; cursor: pointer; border-radius: var(--radius-xs); font-size: 13px; - color: var(--text-primary); + color: var(--c-text); transition: background-color 0.2s ease; } @@ -81,14 +60,7 @@ } .languagePickerOption:hover { - background-color: var( - --mantine-color-gray-0 - ); /* Light gray on hover for light mode */ -} - -/* Dark mode option hover */ -[data-mantine-color-scheme="dark"] .languagePickerOption:hover { - background-color: var(--mantine-color-dark-5); + background-color: var(--c-hover); } /* Additional helper classes for the component */ @@ -110,7 +82,7 @@ .languagePickerScrollArea { max-height: 180px; - border-bottom: 1px solid var(--border-default); + border-bottom: 1px solid var(--c-border); padding-bottom: 8px; } @@ -121,12 +93,7 @@ } .languagePickerLink { - color: var(--mantine-color-blue-6); + color: var(--c-accent-fg); text-decoration: underline; cursor: pointer; } - -/* Dark mode link */ -[data-mantine-color-scheme="dark"] .languagePickerLink { - color: var(--mantine-color-blue-4); -} diff --git a/frontend/editor/src/core/components/tools/ocr/LanguagePicker.stories.tsx b/frontend/editor/src/core/components/tools/ocr/LanguagePicker.stories.tsx new file mode 100644 index 0000000000..3ca16b9aba --- /dev/null +++ b/frontend/editor/src/core/components/tools/ocr/LanguagePicker.stories.tsx @@ -0,0 +1,63 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { http, HttpResponse } from "msw"; +import LanguagePicker from "@app/components/tools/ocr/LanguagePicker"; + +const meta = { + title: "Tools/OCR/LanguagePicker", + component: LanguagePicker, + args: { + value: [], + onChange: () => {}, + }, + parameters: { + msw: { + handlers: [ + http.get("/api/v1/ui-data/ocr-pdf", () => + HttpResponse.json({ + languages: ["eng", "fra", "deu", "spa", "ita", "por"], + }), + ), + ], + }, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +/** Controlled wrapper so selecting/removing languages actually updates the picker. */ +function LanguagePickerDemo({ + initialValue = [], + disabled, +}: { + initialValue?: string[]; + disabled?: boolean; +}) { + const [value, setValue] = useState(initialValue); + return ( + + ); +} + +/** No languages selected yet, backend list loaded via MSW. */ +export const Default: Story = { + render: () => , +}; + +/** One language already selected. */ +export const WithSelection: Story = { + render: () => , +}; + +/** Disabled — e.g. while OCR is running elsewhere in the tool panel. */ +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/ocr/LanguagePicker.tsx b/frontend/editor/src/core/components/tools/ocr/LanguagePicker.tsx index 4649df64b4..78d51bcc3b 100644 --- a/frontend/editor/src/core/components/tools/ocr/LanguagePicker.tsx +++ b/frontend/editor/src/core/components/tools/ocr/LanguagePicker.tsx @@ -158,7 +158,7 @@ const LanguagePicker: React.FC = ({ = {}, +): OCRParameters => ({ + languages: [], + ocrType: "skip-text", + ocrRenderType: "hocr", + additionalOptions: [], + ...overrides, +}); + +const meta = { + title: "Tools/OCR/OCRSettings", + component: OCRSettings, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + parameters: buildParameters(), + onParameterChange: () => {}, + }, +}; + +export const LanguagesSelected: Story = { + args: { + parameters: buildParameters({ languages: ["eng", "fra"] }), + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + parameters: buildParameters(), + onParameterChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/overlayPdfs/OverlayPdfsSettings.module.css b/frontend/editor/src/core/components/tools/overlayPdfs/OverlayPdfsSettings.module.css index b6efb3bbc0..377d73dfd5 100644 --- a/frontend/editor/src/core/components/tools/overlayPdfs/OverlayPdfsSettings.module.css +++ b/frontend/editor/src/core/components/tools/overlayPdfs/OverlayPdfsSettings.module.css @@ -29,11 +29,7 @@ white-space: normal; word-break: break-word; } - -.fileSize { - flex-shrink: 0; -} - +.fileSize, .removeButton { flex-shrink: 0; } diff --git a/frontend/editor/src/core/components/tools/pageLayout/LayoutPreview.stories.tsx b/frontend/editor/src/core/components/tools/pageLayout/LayoutPreview.stories.tsx new file mode 100644 index 0000000000..fdac5554d2 --- /dev/null +++ b/frontend/editor/src/core/components/tools/pageLayout/LayoutPreview.stories.tsx @@ -0,0 +1,44 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import LayoutPreview from "@app/components/tools/pageLayout/LayoutPreview"; +import { defaultParameters } from "@app/hooks/tools/pageLayout/usePageLayoutParameters"; + +const meta = { + title: "Tools/PageLayout/LayoutPreview", + component: LayoutPreview, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + parameters: defaultParameters, + }, +}; + +export const CustomGridLandscape: Story = { + args: { + parameters: { + ...defaultParameters, + mode: "CUSTOM", + rows: 2, + cols: 3, + orientation: "LANDSCAPE", + addBorder: true, + }, + }, +}; + +export const RightToLeftReading: Story = { + args: { + parameters: { + ...defaultParameters, + mode: "CUSTOM", + rows: 2, + cols: 2, + arrangement: "BY_ROWS", + readingDirection: "RTL", + addBorder: true, + }, + }, +}; diff --git a/frontend/editor/src/core/components/tools/pageLayout/PageLayoutAdvancedSettings.stories.tsx b/frontend/editor/src/core/components/tools/pageLayout/PageLayoutAdvancedSettings.stories.tsx new file mode 100644 index 0000000000..712ca94741 --- /dev/null +++ b/frontend/editor/src/core/components/tools/pageLayout/PageLayoutAdvancedSettings.stories.tsx @@ -0,0 +1,68 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import PageLayoutAdvancedSettings from "@app/components/tools/pageLayout/PageLayoutAdvancedSettings"; +import { + PageLayoutParameters, + defaultParameters, +} from "@app/hooks/tools/pageLayout/usePageLayoutParameters"; + +const meta = { + title: "Tools/PageLayout/PageLayoutAdvancedSettings", + component: PageLayoutAdvancedSettings, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
    + +
    + ), + ], + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function AdvancedSettingsDemo({ + disabled, + initialParameters, +}: { + disabled?: boolean; + initialParameters?: PageLayoutParameters; +}) { + const [parameters, setParameters] = useState( + initialParameters ?? defaultParameters, + ); + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { + render: () => , +}; + +export const Disabled: Story = { + render: () => , +}; + +export const LandscapeRTL: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/core/components/tools/pageLayout/PageLayoutMarginsBordersSettings.stories.tsx b/frontend/editor/src/core/components/tools/pageLayout/PageLayoutMarginsBordersSettings.stories.tsx new file mode 100644 index 0000000000..67d9f06388 --- /dev/null +++ b/frontend/editor/src/core/components/tools/pageLayout/PageLayoutMarginsBordersSettings.stories.tsx @@ -0,0 +1,58 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import PageLayoutMarginsBordersSettings from "@app/components/tools/pageLayout/PageLayoutMarginsBordersSettings"; +import { + PageLayoutParameters, + defaultParameters, +} from "@app/hooks/tools/pageLayout/usePageLayoutParameters"; + +const meta = { + title: "Tools/PageLayout/PageLayoutMarginsBordersSettings", + component: PageLayoutMarginsBordersSettings, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +const withMarginsParameters: PageLayoutParameters = { + ...defaultParameters, + topMargin: 20, + bottomMargin: 20, + leftMargin: 15, + rightMargin: 15, + innerMargin: 5, +}; + +const withBorderParameters: PageLayoutParameters = { + ...defaultParameters, + addBorder: true, + borderWidth: 2, +}; + +export const Default: Story = { + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +}; + +export const WithMargins: Story = { + args: { + parameters: withMarginsParameters, + onParameterChange: () => {}, + }, +}; + +export const WithBorder: Story = { + args: { + parameters: withBorderParameters, + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/pageLayout/PageLayoutPreview.stories.tsx b/frontend/editor/src/core/components/tools/pageLayout/PageLayoutPreview.stories.tsx new file mode 100644 index 0000000000..e3c2a66183 --- /dev/null +++ b/frontend/editor/src/core/components/tools/pageLayout/PageLayoutPreview.stories.tsx @@ -0,0 +1,40 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import PageLayoutPreview from "@app/components/tools/pageLayout/PageLayoutPreview"; +import { defaultParameters } from "@app/hooks/tools/pageLayout/usePageLayoutParameters"; + +const meta = { + title: "Tools/PageLayout/PageLayoutPreview", + component: PageLayoutPreview, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + parameters: defaultParameters, + }, +}; + +export const CustomGridWithBorder: Story = { + args: { + parameters: { + ...defaultParameters, + mode: "CUSTOM", + rows: 2, + cols: 3, + addBorder: true, + borderWidth: 2, + }, + }, +}; + +export const Landscape: Story = { + args: { + parameters: { + ...defaultParameters, + orientation: "LANDSCAPE", + pagesPerSheet: 2, + }, + }, +}; diff --git a/frontend/editor/src/core/components/tools/pageLayout/PageLayoutSettings.stories.tsx b/frontend/editor/src/core/components/tools/pageLayout/PageLayoutSettings.stories.tsx new file mode 100644 index 0000000000..4ed2a6219f --- /dev/null +++ b/frontend/editor/src/core/components/tools/pageLayout/PageLayoutSettings.stories.tsx @@ -0,0 +1,61 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import PageLayoutSettings from "@app/components/tools/pageLayout/PageLayoutSettings"; +import { + PageLayoutParameters, + defaultParameters, +} from "@app/hooks/tools/pageLayout/usePageLayoutParameters"; + +const meta: Meta = { + title: "Tools/PageLayout/PageLayoutSettings", + component: PageLayoutSettings, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
    + +
    + ), + ], +}; +export default meta; +type Story = StoryObj; + +function SettingsDemo({ + initial, + disabled, +}: { + initial: PageLayoutParameters; + disabled?: boolean; +}) { + const [parameters, setParameters] = useState(initial); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +/** Default mode: pages-per-sheet select plus its description banner. */ +export const Default: Story = { + render: () => , +}; + +/** Custom mode: rows/columns number inputs instead of the sheet-count select. */ +export const CustomMode: Story = { + render: () => ( + + ), +}; + +/** Disabled: all controls locked, e.g. while no file is selected. */ +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/pageLayout/PageLayoutSettings.tsx b/frontend/editor/src/core/components/tools/pageLayout/PageLayoutSettings.tsx index ff6d0e71c8..d432d63c53 100644 --- a/frontend/editor/src/core/components/tools/pageLayout/PageLayoutSettings.tsx +++ b/frontend/editor/src/core/components/tools/pageLayout/PageLayoutSettings.tsx @@ -61,7 +61,7 @@ export default function PageLayoutSettings({ {selectedPagesPerSheetOption && (
    {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function AdvancedSettingsDemo({ disabled }: { disabled?: boolean }) { + const [parameters, setParameters] = + useState(defaultParameters); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { render: () => }; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/redact/RedactModeSelector.stories.tsx b/frontend/editor/src/core/components/tools/redact/RedactModeSelector.stories.tsx new file mode 100644 index 0000000000..03ca1abe12 --- /dev/null +++ b/frontend/editor/src/core/components/tools/redact/RedactModeSelector.stories.tsx @@ -0,0 +1,54 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import RedactModeSelector from "@app/components/tools/redact/RedactModeSelector"; +import type { RedactMode } from "@app/hooks/tools/redact/useRedactParameters"; + +const meta = { + title: "Tools/Redact/RedactModeSelector", + component: RedactModeSelector, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
    + +
    + ), + ], + args: { + mode: "automatic", + onModeChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function ModeDemo({ + disabled, + hasAnyFiles, +}: { + disabled?: boolean; + hasAnyFiles?: boolean; +}) { + const [mode, setMode] = useState("automatic"); + return ( + + ); +} + +/** Files present: both Automatic and Manual are selectable. */ +export const Default: Story = { render: () => }; + +/** No files uploaded yet: both options disabled with a tooltip on Automatic. */ +export const NoFiles: Story = { + render: () => , +}; + +/** Selector disabled entirely (e.g. while an operation is running). */ +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/redact/RedactSingleStepSettings.stories.tsx b/frontend/editor/src/core/components/tools/redact/RedactSingleStepSettings.stories.tsx new file mode 100644 index 0000000000..e34cc33924 --- /dev/null +++ b/frontend/editor/src/core/components/tools/redact/RedactSingleStepSettings.stories.tsx @@ -0,0 +1,78 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import RedactSingleStepSettings from "@app/components/tools/redact/RedactSingleStepSettings"; +import { + RedactParameters, + defaultParameters, +} from "@app/hooks/tools/redact/useRedactParameters"; + +const meta = { + title: "Tools/Redact/RedactSingleStepSettings", + component: RedactSingleStepSettings, + parameters: { layout: "padded" }, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function SettingsDemo({ + initialParameters = defaultParameters, + disabled, +}: { + initialParameters?: RedactParameters; + disabled?: boolean; +}) { + const [parameters, setParameters] = + useState(initialParameters); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { + render: () => , +}; + +export const AutomaticWithWords: Story = { + render: () => ( + + ), +}; + +export const ManualMode: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/core/components/tools/redact/RedactSingleStepSettings.tsx b/frontend/editor/src/core/components/tools/redact/RedactSingleStepSettings.tsx index cf73302210..c537b327bc 100644 --- a/frontend/editor/src/core/components/tools/redact/RedactSingleStepSettings.tsx +++ b/frontend/editor/src/core/components/tools/redact/RedactSingleStepSettings.tsx @@ -56,7 +56,11 @@ const RedactSingleStepSettings = ({
    Manual redaction interface will be available here when implemented. diff --git a/frontend/editor/src/core/components/tools/redact/WordsToRedactInput.stories.tsx b/frontend/editor/src/core/components/tools/redact/WordsToRedactInput.stories.tsx new file mode 100644 index 0000000000..ec6d2b8492 --- /dev/null +++ b/frontend/editor/src/core/components/tools/redact/WordsToRedactInput.stories.tsx @@ -0,0 +1,47 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import WordsToRedactInput from "@app/components/tools/redact/WordsToRedactInput"; + +const meta = { + title: "Tools/Redact/WordsToRedactInput", + component: WordsToRedactInput, + args: { + wordsToRedact: [], + onWordsChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function WordsToRedactInputDemo({ + initialWords = [], + disabled, +}: { + initialWords?: string[]; + disabled?: boolean; +}) { + const [words, setWords] = useState(initialWords); + return ( + + ); +} + +export const Default: Story = { + render: () => , +}; + +export const WithWords: Story = { + render: () => ( + + ), +}; + +export const Disabled: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/core/components/tools/removeAnnotations/RemoveAnnotationsSettings.stories.tsx b/frontend/editor/src/core/components/tools/removeAnnotations/RemoveAnnotationsSettings.stories.tsx new file mode 100644 index 0000000000..d0461743ad --- /dev/null +++ b/frontend/editor/src/core/components/tools/removeAnnotations/RemoveAnnotationsSettings.stories.tsx @@ -0,0 +1,12 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import RemoveAnnotationsSettings from "@app/components/tools/removeAnnotations/RemoveAnnotationsSettings"; + +const meta = { + title: "Tools/RemoveAnnotations/RemoveAnnotationsSettings", + component: RemoveAnnotationsSettings, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/editor/src/core/components/tools/removeBlanks/RemoveBlanksSettings.stories.tsx b/frontend/editor/src/core/components/tools/removeBlanks/RemoveBlanksSettings.stories.tsx new file mode 100644 index 0000000000..946dc5074b --- /dev/null +++ b/frontend/editor/src/core/components/tools/removeBlanks/RemoveBlanksSettings.stories.tsx @@ -0,0 +1,41 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import RemoveBlanksSettings from "@app/components/tools/removeBlanks/RemoveBlanksSettings"; +import { RemoveBlanksParameters } from "@app/hooks/tools/removeBlanks/useRemoveBlanksParameters"; + +const buildParameters = ( + overrides: Partial = {}, +): RemoveBlanksParameters => ({ + threshold: 10, + whitePercent: 99.9, + includeBlankPages: false, + ...overrides, +}); + +const meta = { + title: "Tools/RemoveBlanks/RemoveBlanksSettings", + component: RemoveBlanksSettings, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + parameters: buildParameters(), + onParameterChange: () => {}, + }, +}; + +export const IncludeBlankPages: Story = { + args: { + parameters: buildParameters({ includeBlankPages: true }), + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + parameters: buildParameters(), + onParameterChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/removeCertificateSign/RemoveCertificateSignSettings.stories.tsx b/frontend/editor/src/core/components/tools/removeCertificateSign/RemoveCertificateSignSettings.stories.tsx new file mode 100644 index 0000000000..4af89e165f --- /dev/null +++ b/frontend/editor/src/core/components/tools/removeCertificateSign/RemoveCertificateSignSettings.stories.tsx @@ -0,0 +1,28 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import RemoveCertificateSignSettings from "@app/components/tools/removeCertificateSign/RemoveCertificateSignSettings"; +import { RemoveCertificateSignParameters } from "@app/hooks/tools/removeCertificateSign/useRemoveCertificateSignParameters"; + +const baseParameters: RemoveCertificateSignParameters = {}; + +const meta = { + title: "Tools/RemoveCertificateSign/RemoveCertificateSignSettings", + component: RemoveCertificateSignSettings, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + parameters: baseParameters, + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + parameters: baseParameters, + onParameterChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/removePages/RemovePagesSettings.stories.tsx b/frontend/editor/src/core/components/tools/removePages/RemovePagesSettings.stories.tsx new file mode 100644 index 0000000000..92db55641e --- /dev/null +++ b/frontend/editor/src/core/components/tools/removePages/RemovePagesSettings.stories.tsx @@ -0,0 +1,44 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import RemovePagesSettings from "@app/components/tools/removePages/RemovePagesSettings"; +import { RemovePagesParameters } from "@app/hooks/tools/removePages/useRemovePagesParameters"; + +const meta = { + title: "Tools/RemovePages/RemovePagesSettings", + component: RemovePagesSettings, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +const baseParameters: RemovePagesParameters = { + pageNumbers: "", +}; + +export const Default: Story = { + args: { + parameters: baseParameters, + onParameterChange: () => {}, + }, +}; + +export const FilledValid: Story = { + args: { + parameters: { pageNumbers: "1,3,5-8,10" }, + onParameterChange: () => {}, + }, +}; + +export const InvalidInput: Story = { + args: { + parameters: { pageNumbers: "abc" }, + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + parameters: baseParameters, + onParameterChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/removePassword/RemovePasswordSettings.stories.tsx b/frontend/editor/src/core/components/tools/removePassword/RemovePasswordSettings.stories.tsx new file mode 100644 index 0000000000..5997482e5d --- /dev/null +++ b/frontend/editor/src/core/components/tools/removePassword/RemovePasswordSettings.stories.tsx @@ -0,0 +1,39 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { fn } from "storybook/test"; + +import RemovePasswordSettings from "@app/components/tools/removePassword/RemovePasswordSettings"; +import { RemovePasswordParameters } from "@app/hooks/tools/removePassword/useRemovePasswordParameters"; + +const parameters: RemovePasswordParameters = { + password: "", +}; + +const meta = { + title: "Tools/RemovePassword/RemovePasswordSettings", + component: RemovePasswordSettings, + parameters: { layout: "padded" }, + args: { + parameters, + onParameterChange: fn(), + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const Filled: Story = { + args: { + parameters: { + ...parameters, + password: "correct-horse-battery-staple", + }, + }, +}; + +export const Disabled: Story = { + args: { + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/reorganizePages/ReorganizePagesSettings.stories.tsx b/frontend/editor/src/core/components/tools/reorganizePages/ReorganizePagesSettings.stories.tsx new file mode 100644 index 0000000000..07586069b9 --- /dev/null +++ b/frontend/editor/src/core/components/tools/reorganizePages/ReorganizePagesSettings.stories.tsx @@ -0,0 +1,58 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ReorganizePagesSettings from "@app/components/tools/reorganizePages/ReorganizePagesSettings"; +import { + defaultReorganizePagesParameters, + ReorganizePagesParameters, +} from "@app/hooks/tools/reorganizePages/useReorganizePagesParameters"; + +const meta: Meta = { + title: "Tools/ReorganizePages/ReorganizePagesSettings", + component: ReorganizePagesSettings, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
    + +
    + ), + ], +}; +export default meta; +type Story = StoryObj; + +function SettingsDemo({ + initial, + disabled, +}: { + initial?: Partial; + disabled?: boolean; +}) { + const [parameters, setParameters] = useState({ + ...defaultReorganizePagesParameters, + ...initial, + }); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +/** Custom order mode (default): the page order text input is shown. */ +export const Default: Story = { render: () => }; + +/** A preset mode (e.g. reverse) that doesn't require a page order input. */ +export const PresetMode: Story = { + render: () => , +}; + +/** Disabled state, e.g. while no files are loaded or processing is in progress. */ +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/reorganizePages/ReorganizePagesSettings.tsx b/frontend/editor/src/core/components/tools/reorganizePages/ReorganizePagesSettings.tsx index b01b717bb0..a5bbe2fa66 100644 --- a/frontend/editor/src/core/components/tools/reorganizePages/ReorganizePagesSettings.tsx +++ b/frontend/editor/src/core/components/tools/reorganizePages/ReorganizePagesSettings.tsx @@ -40,7 +40,7 @@ export default function ReorganizePagesSettings({ {selectedMode && (
    {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const Disabled: Story = { + args: { + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/replaceColor/ReplaceColorSettings.stories.tsx b/frontend/editor/src/core/components/tools/replaceColor/ReplaceColorSettings.stories.tsx new file mode 100644 index 0000000000..ff11addc1e --- /dev/null +++ b/frontend/editor/src/core/components/tools/replaceColor/ReplaceColorSettings.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ReplaceColorSettings from "@app/components/tools/replaceColor/ReplaceColorSettings"; +import { + ReplaceColorParameters, + defaultParameters, +} from "@app/hooks/tools/replaceColor/useReplaceColorParameters"; + +const meta = { + title: "Tools/ReplaceColor/ReplaceColorSettings", + component: ReplaceColorSettings, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const CustomColor: Story = { + args: { + parameters: { + ...defaultParameters, + replaceAndInvertOption: "CUSTOM_COLOR", + } satisfies ReplaceColorParameters, + }, +}; + +export const Disabled: Story = { + args: { + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/rotate/RotateAutomationSettings.stories.tsx b/frontend/editor/src/core/components/tools/rotate/RotateAutomationSettings.stories.tsx new file mode 100644 index 0000000000..e967671741 --- /dev/null +++ b/frontend/editor/src/core/components/tools/rotate/RotateAutomationSettings.stories.tsx @@ -0,0 +1,46 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import RotateAutomationSettings from "@app/components/tools/rotate/RotateAutomationSettings"; +import { RotateParameters } from "@app/hooks/tools/rotate/useRotateParameters"; + +const meta = { + title: "Tools/Rotate/RotateAutomationSettings", + component: RotateAutomationSettings, + parameters: { layout: "padded" }, + args: { + parameters: { angle: 0 }, + onParameterChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function RotateDemo({ + disabled, + initialAngle = 0, +}: { + disabled?: boolean; + initialAngle?: number; +}) { + const [parameters, setParameters] = useState({ + angle: initialAngle, + }); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +export const Default: Story = { render: () => }; + +export const Rotated90: Story = { + render: () => , +}; + +export const Disabled: Story = { render: () => }; diff --git a/frontend/editor/src/core/components/tools/sanitize/SanitizeSettings.stories.tsx b/frontend/editor/src/core/components/tools/sanitize/SanitizeSettings.stories.tsx new file mode 100644 index 0000000000..c5463cbfc0 --- /dev/null +++ b/frontend/editor/src/core/components/tools/sanitize/SanitizeSettings.stories.tsx @@ -0,0 +1,40 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import SanitizeSettings from "@app/components/tools/sanitize/SanitizeSettings"; +import { defaultParameters } from "@app/hooks/tools/sanitize/useSanitizeParameters"; + +const meta = { + title: "Tools/Sanitize/SanitizeSettings", + component: SanitizeSettings, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +}; + +export const AllSelected: Story = { + args: { + parameters: { + removeJavaScript: true, + removeEmbeddedFiles: true, + removeXMPMetadata: true, + removeMetadata: true, + removeLinks: true, + removeFonts: true, + }, + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/scannerImageSplit/ScannerImageSplitSettings.stories.tsx b/frontend/editor/src/core/components/tools/scannerImageSplit/ScannerImageSplitSettings.stories.tsx new file mode 100644 index 0000000000..05f4a41cf4 --- /dev/null +++ b/frontend/editor/src/core/components/tools/scannerImageSplit/ScannerImageSplitSettings.stories.tsx @@ -0,0 +1,36 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ScannerImageSplitSettings from "@app/components/tools/scannerImageSplit/ScannerImageSplitSettings"; +import { ScannerImageSplitParameters } from "@app/hooks/tools/scannerImageSplit/useScannerImageSplitParameters"; + +const buildParameters = ( + overrides: Partial = {}, +): ScannerImageSplitParameters => ({ + angle_threshold: 10, + tolerance: 30, + min_area: 10000, + min_contour_area: 500, + border_size: 1, + ...overrides, +}); + +const meta = { + title: "Tools/ScannerImageSplit/ScannerImageSplitSettings", + component: ScannerImageSplitSettings, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + parameters: buildParameters(), + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + parameters: buildParameters(), + onParameterChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/shared/ErrorNotification.stories.tsx b/frontend/editor/src/core/components/tools/shared/ErrorNotification.stories.tsx new file mode 100644 index 0000000000..81f6c658bc --- /dev/null +++ b/frontend/editor/src/core/components/tools/shared/ErrorNotification.stories.tsx @@ -0,0 +1,25 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ErrorNotification from "@app/components/tools/shared/ErrorNotification"; + +const meta = { + title: "ToolsShared/ErrorNotification", + component: ErrorNotification, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + error: "Something went wrong while processing the file.", + onClose: () => {}, + }, +}; + +export const CustomTitle: Story = { + args: { + error: "The uploaded file could not be read.", + onClose: () => {}, + title: "Upload failed", + color: "orange", + }, +}; diff --git a/frontend/editor/src/core/components/tools/shared/FileMetadata.stories.tsx b/frontend/editor/src/core/components/tools/shared/FileMetadata.stories.tsx new file mode 100644 index 0000000000..750d751e6d --- /dev/null +++ b/frontend/editor/src/core/components/tools/shared/FileMetadata.stories.tsx @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import FileMetadata from "@app/components/tools/shared/FileMetadata"; + +const buildFile = (name = "report.pdf", type = "application/pdf"): File => + new File(["%PDF-1.4 mock content"], name, { + type, + lastModified: new Date("2026-01-15T10:30:00Z").getTime(), + }); + +const meta = { + title: "ToolsShared/FileMetadata", + component: FileMetadata, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + file: buildFile(), + }, +}; + +export const UnknownType: Story = { + args: { + file: buildFile("data.bin", ""), + }, +}; diff --git a/frontend/editor/src/core/components/tools/shared/NavigationControls.stories.tsx b/frontend/editor/src/core/components/tools/shared/NavigationControls.stories.tsx new file mode 100644 index 0000000000..e34b1cb329 --- /dev/null +++ b/frontend/editor/src/core/components/tools/shared/NavigationControls.stories.tsx @@ -0,0 +1,36 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import NavigationControls from "@app/components/tools/shared/NavigationControls"; + +const meta = { + title: "ToolsShared/NavigationControls", + component: NavigationControls, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + currentIndex: 0, + totalFiles: 5, + onPrevious: () => {}, + onNext: () => {}, + }, +}; + +export const LastFile: Story = { + args: { + currentIndex: 4, + totalFiles: 5, + onPrevious: () => {}, + onNext: () => {}, + }, +}; + +export const SingleFile: Story = { + args: { + currentIndex: 0, + totalFiles: 1, + onPrevious: () => {}, + onNext: () => {}, + }, +}; diff --git a/frontend/editor/src/core/components/tools/shared/NoToolsFound.stories.tsx b/frontend/editor/src/core/components/tools/shared/NoToolsFound.stories.tsx new file mode 100644 index 0000000000..6ac9f9a3b0 --- /dev/null +++ b/frontend/editor/src/core/components/tools/shared/NoToolsFound.stories.tsx @@ -0,0 +1,11 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import NoToolsFound from "@app/components/tools/shared/NoToolsFound"; + +const meta = { + title: "Tools/Shared/NoToolsFound", + component: NoToolsFound, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/editor/src/core/components/tools/shared/NumberInputWithUnit.stories.tsx b/frontend/editor/src/core/components/tools/shared/NumberInputWithUnit.stories.tsx new file mode 100644 index 0000000000..b6e1d8280f --- /dev/null +++ b/frontend/editor/src/core/components/tools/shared/NumberInputWithUnit.stories.tsx @@ -0,0 +1,37 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import NumberInputWithUnit from "@app/components/tools/shared/NumberInputWithUnit"; + +const meta = { + title: "Tools/Shared/NumberInputWithUnit", + component: NumberInputWithUnit, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + label: "Margin", + value: 10, + onChange: () => {}, + unit: "px", + }, +}; + +export const WithMinMax: Story = { + args: { + ...Default.args, + label: "Opacity", + value: 50, + unit: "%", + min: 0, + max: 100, + }, +}; + +export const Disabled: Story = { + args: { + ...Default.args, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/shared/ResultsPreview.stories.tsx b/frontend/editor/src/core/components/tools/shared/ResultsPreview.stories.tsx new file mode 100644 index 0000000000..174a192613 --- /dev/null +++ b/frontend/editor/src/core/components/tools/shared/ResultsPreview.stories.tsx @@ -0,0 +1,46 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ResultsPreview, { + ReviewFile, +} from "@app/components/tools/shared/ResultsPreview"; + +function makeFile(name: string, type: string, size: number): File { + return new File([new Uint8Array(size)], name, { type }); +} + +const files: ReviewFile[] = [ + { file: makeFile("contract-final.pdf", "application/pdf", 245_760) }, + { file: makeFile("scan-001.pdf", "application/pdf", 1_048_576) }, + { file: makeFile("invoice-march.pdf", "application/pdf", 51_200) }, +]; + +const meta = { + title: "Tools/Shared/ResultsPreview", + component: ResultsPreview, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + files, + }, +}; + +export const SingleFile: Story = { + args: { + files: [files[0]], + }, +}; + +export const Loading: Story = { + args: { + files: [], + isGeneratingThumbnails: true, + }, +}; + +export const Empty: Story = { + args: { + files: [], + }, +}; diff --git a/frontend/editor/src/core/components/tools/shared/SubcategoryHeader.stories.tsx b/frontend/editor/src/core/components/tools/shared/SubcategoryHeader.stories.tsx new file mode 100644 index 0000000000..634841969a --- /dev/null +++ b/frontend/editor/src/core/components/tools/shared/SubcategoryHeader.stories.tsx @@ -0,0 +1,24 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import SubcategoryHeader from "@app/components/tools/shared/SubcategoryHeader"; + +const meta = { + title: "Tools/Shared/SubcategoryHeader", + component: SubcategoryHeader, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + label: "Page organization", + }, +}; + +export const CustomSpacing: Story = { + args: { + label: "Security", + mt: "2rem", + mb: "1rem", + }, +}; diff --git a/frontend/editor/src/core/components/tools/shared/ToolStep.stories.tsx b/frontend/editor/src/core/components/tools/shared/ToolStep.stories.tsx new file mode 100644 index 0000000000..e3c10425f0 --- /dev/null +++ b/frontend/editor/src/core/components/tools/shared/ToolStep.stories.tsx @@ -0,0 +1,36 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Text } from "@mantine/core"; +import ToolStep from "@app/components/tools/shared/ToolStep"; + +const meta = { + title: "Tools/Shared/ToolStep", + component: ToolStep, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + title: "Select pages", + children: Step content goes here., + }, +}; + +export const WithHelpTextAndNumber: Story = { + args: { + title: "Choose output format", + helpText: "Pick the format your converted file should use.", + showNumber: true, + _stepNumber: 2, + children: Step content goes here., + }, +}; + +export const Collapsed: Story = { + args: { + title: "Advanced settings", + isCollapsed: true, + onCollapsedClick: () => {}, + children: Step content goes here., + }, +}; diff --git a/frontend/editor/src/core/components/tools/shared/ToolStep.tsx b/frontend/editor/src/core/components/tools/shared/ToolStep.tsx index 0325b7566c..5b261d4883 100644 --- a/frontend/editor/src/core/components/tools/shared/ToolStep.tsx +++ b/frontend/editor/src/core/components/tools/shared/ToolStep.tsx @@ -168,7 +168,11 @@ const ToolStep = ({ )}
    ); diff --git a/frontend/editor/src/core/components/tools/showJS/ShowJSView.css b/frontend/editor/src/core/components/tools/showJS/ShowJSView.css index 301589a930..7237fe7a3b 100644 --- a/frontend/editor/src/core/components/tools/showJS/ShowJSView.css +++ b/frontend/editor/src/core/components/tools/showJS/ShowJSView.css @@ -7,7 +7,7 @@ white-space: pre; tab-size: 2; margin: 0; - color: var(--text-primary); + color: var(--c-text); } .tok-kw { @@ -35,7 +35,7 @@ align-items: center; gap: 6px; min-width: 64px; - color: var(--text-muted); + color: var(--c-text-subtle); user-select: none; } .line-number { @@ -45,7 +45,7 @@ .fold-toggle { border: none; background: transparent; - color: var(--text-muted); + color: var(--c-text-subtle); cursor: pointer; padding: 0 2px; } @@ -62,21 +62,29 @@ flex: 1 1 auto; } .collapsed-indicator { - color: var(--text-muted); + color: var(--c-text-subtle); font-style: italic; cursor: pointer; padding-left: 8px; } .collapsed-inline { - color: var(--text-muted); + color: var(--c-text-subtle); margin-left: 6px; } .search-hit { - background: rgba(255, 235, 59, 0.4); /* yellow highlight */ + background: color-mix( + in srgb, + var(--c-highlight) 40%, + transparent + ); /* yellow highlight */ border-radius: 2px; } .search-hit-active { - background: rgba(33, 150, 243, 0.4); /* active blue */ + background: color-mix( + in srgb, + var(--c-primary) 40%, + transparent + ); /* active blue */ } .showjs-root { @@ -92,7 +100,7 @@ border: 1px solid var(--mantine-color-gray-4); border-radius: 8px; overflow: hidden; - background: var(--bg-file-manager); + background: var(--c-bg); } .showjs-toolbar { diff --git a/frontend/editor/src/core/components/tools/showJS/ShowJSView.stories.tsx b/frontend/editor/src/core/components/tools/showJS/ShowJSView.stories.tsx new file mode 100644 index 0000000000..fcb29ed009 --- /dev/null +++ b/frontend/editor/src/core/components/tools/showJS/ShowJSView.stories.tsx @@ -0,0 +1,45 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ShowJSView from "@app/components/tools/showJS/ShowJSView"; + +const SAMPLE_SCRIPT = `function greet(name) { + // say hello + if (!name) { + return "Hello, stranger!"; + } + return "Hello, " + name + "!"; +} + +for (let i = 0; i < 3; i++) { + console.log(greet("World")); +} +`; + +const meta = { + title: "Tools/ShowJS/ShowJSView", + component: ShowJSView, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + data: SAMPLE_SCRIPT, + }, +}; + +export const WithDownload: Story = { + args: { + data: { + scriptText: SAMPLE_SCRIPT, + downloadUrl: "blob:mock-download-url", + downloadFilename: "extracted.js", + }, + }, +}; + +export const Empty: Story = { + args: { + data: "", + }, +}; diff --git a/frontend/editor/src/core/components/tools/sign/PenSizeSelector.stories.tsx b/frontend/editor/src/core/components/tools/sign/PenSizeSelector.stories.tsx new file mode 100644 index 0000000000..658d4ebb6e --- /dev/null +++ b/frontend/editor/src/core/components/tools/sign/PenSizeSelector.stories.tsx @@ -0,0 +1,36 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import PenSizeSelector from "@app/components/tools/sign/PenSizeSelector"; + +const meta = { + title: "Tools/Sign/PenSizeSelector", + component: PenSizeSelector, + args: { + value: 5, + inputValue: "5", + onValueChange: () => {}, + onInputChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function PenSizeSelectorDemo({ disabled }: { disabled?: boolean }) { + const [value, setValue] = useState(5); + const [inputValue, setInputValue] = useState("5"); + return ( + + ); +} + +export const Default: Story = { render: () => }; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/sign/SavedSignaturesSection.stories.tsx b/frontend/editor/src/core/components/tools/sign/SavedSignaturesSection.stories.tsx new file mode 100644 index 0000000000..9079bb08ab --- /dev/null +++ b/frontend/editor/src/core/components/tools/sign/SavedSignaturesSection.stories.tsx @@ -0,0 +1,99 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import SavedSignaturesSection from "@app/components/tools/sign/SavedSignaturesSection"; +import type { SavedSignature } from "@app/hooks/tools/sign/useSavedSignatures"; + +const mockSignatures: SavedSignature[] = [ + { + id: "sig-1", + label: "My signature", + scope: "personal", + type: "text", + dataUrl: "", + signerName: "Jordan Lee", + fontFamily: "cursive", + fontSize: 32, + textColor: "#1a1a1a", + createdAt: Date.now(), + updatedAt: Date.now(), + }, + { + id: "sig-2", + label: "Company stamp", + scope: "shared", + type: "image", + dataUrl: + "data:image/svg+xml;base64," + + btoa( + 'Approved', + ), + createdAt: Date.now(), + updatedAt: Date.now(), + }, + { + id: "sig-3", + label: "Quick draw", + scope: "localStorage", + type: "canvas", + dataUrl: + "data:image/svg+xml;base64," + + btoa( + '', + ), + createdAt: Date.now(), + updatedAt: Date.now(), + }, +]; + +const meta = { + title: "Tools/Sign/SavedSignaturesSection", + component: SavedSignaturesSection, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + signatures: mockSignatures, + isAtCapacity: false, + maxLimit: 5, + onUseSignature: () => {}, + onDeleteSignature: () => {}, + onRenameSignature: () => {}, + }, +}; + +export const Empty: Story = { + args: { + signatures: [], + isAtCapacity: false, + maxLimit: 5, + onUseSignature: () => {}, + onDeleteSignature: () => {}, + onRenameSignature: () => {}, + }, +}; + +export const AtCapacity: Story = { + args: { + signatures: mockSignatures, + isAtCapacity: true, + maxLimit: 3, + onUseSignature: () => {}, + onDeleteSignature: () => {}, + onRenameSignature: () => {}, + }, +}; + +export const AdminWithSharedDelete: Story = { + args: { + signatures: mockSignatures, + isAtCapacity: false, + maxLimit: 5, + isAdmin: true, + onUseSignature: () => {}, + onDeleteSignature: () => {}, + onRenameSignature: () => {}, + }, +}; diff --git a/frontend/editor/src/core/components/tools/singleLargePage/SingleLargePageSettings.stories.tsx b/frontend/editor/src/core/components/tools/singleLargePage/SingleLargePageSettings.stories.tsx new file mode 100644 index 0000000000..9433f691af --- /dev/null +++ b/frontend/editor/src/core/components/tools/singleLargePage/SingleLargePageSettings.stories.tsx @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import SingleLargePageSettings from "@app/components/tools/singleLargePage/SingleLargePageSettings"; +import type { SingleLargePageParameters } from "@app/hooks/tools/singleLargePage/useSingleLargePageParameters"; + +const parameters: SingleLargePageParameters = {}; + +const meta = { + title: "Tools/SingleLargePage/SingleLargePageSettings", + component: SingleLargePageSettings, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + parameters, + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + parameters, + onParameterChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/split/SplitAutomationSettings.stories.tsx b/frontend/editor/src/core/components/tools/split/SplitAutomationSettings.stories.tsx new file mode 100644 index 0000000000..a3b12a58e4 --- /dev/null +++ b/frontend/editor/src/core/components/tools/split/SplitAutomationSettings.stories.tsx @@ -0,0 +1,45 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import SplitAutomationSettings from "@app/components/tools/split/SplitAutomationSettings"; +import { + defaultParameters, + SplitParameters, +} from "@app/hooks/tools/split/useSplitParameters"; +import { SPLIT_METHODS } from "@app/constants/splitConstants"; + +const meta = { + title: "Tools/Split/SplitAutomationSettings", + component: SplitAutomationSettings, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +}; + +export const MethodSelected: Story = { + args: { + parameters: { + ...defaultParameters, + method: SPLIT_METHODS.BY_PAGES, + pages: "1,3,5", + } satisfies SplitParameters, + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + parameters: { + ...defaultParameters, + method: SPLIT_METHODS.BY_PAGES, + pages: "1,3,5", + } satisfies SplitParameters, + onParameterChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/split/SplitSettings.stories.tsx b/frontend/editor/src/core/components/tools/split/SplitSettings.stories.tsx new file mode 100644 index 0000000000..a3411db819 --- /dev/null +++ b/frontend/editor/src/core/components/tools/split/SplitSettings.stories.tsx @@ -0,0 +1,104 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import SplitSettings from "@app/components/tools/split/SplitSettings"; +import { SPLIT_METHODS } from "@app/constants/splitConstants"; +import { + defaultParameters, + SplitParameters, +} from "@app/hooks/tools/split/useSplitParameters"; + +const meta = { + title: "Tools/Split/SplitSettings", + component: SplitSettings, + parameters: { layout: "padded" }, + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, + decorators: [ + (S) => ( +
    + +
    + ), + ], +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function SplitSettingsDemo({ + initialParameters, + disabled, +}: { + initialParameters: SplitParameters; + disabled?: boolean; +}) { + const [parameters, setParameters] = + useState(initialParameters); + + return ( + + setParameters((prev) => ({ ...prev, [key]: value })) + } + disabled={disabled} + /> + ); +} + +/** No method chosen yet — shows the "select a method first" placeholder. */ +export const NoMethodSelected: Story = { + render: () => , +}; + +/** Split by pages: a single page-range text input. */ +export const ByPages: Story = { + render: () => ( + + ), +}; + +/** Split by sections: divisions, split mode, and merge checkbox. */ +export const BySections: Story = { + render: () => ( + + ), +}; + +/** Split by poster print: page size + division factors + orientation. */ +export const ByPoster: Story = { + render: () => ( + + ), +}; + +/** Disabled state: all inputs are non-interactive. */ +export const Disabled: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/core/components/tools/timestampPdf/TimestampPdfSettings.stories.tsx b/frontend/editor/src/core/components/tools/timestampPdf/TimestampPdfSettings.stories.tsx new file mode 100644 index 0000000000..8a86d24755 --- /dev/null +++ b/frontend/editor/src/core/components/tools/timestampPdf/TimestampPdfSettings.stories.tsx @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import TimestampPdfSettings from "@app/components/tools/timestampPdf/TimestampPdfSettings"; +import { defaultParameters } from "@app/hooks/tools/timestampPdf/useTimestampPdfParameters"; + +const meta = { + title: "Tools/TimestampPdf/TimestampPdfSettings", + component: TimestampPdfSettings, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/toolPicker/FavoriteStar.stories.tsx b/frontend/editor/src/core/components/tools/toolPicker/FavoriteStar.stories.tsx new file mode 100644 index 0000000000..51e1d08e09 --- /dev/null +++ b/frontend/editor/src/core/components/tools/toolPicker/FavoriteStar.stories.tsx @@ -0,0 +1,32 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import FavoriteStar from "@app/components/tools/toolPicker/FavoriteStar"; + +const meta = { + title: "Tools/ToolPicker/FavoriteStar", + component: FavoriteStar, + parameters: { layout: "centered" }, + args: { + isFavorite: false, + onToggle: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Favorited: Story = { + args: { + isFavorite: true, + }, +}; + +export const Sizes: Story = { + render: () => ( +
    + {(["xs", "sm", "md", "lg", "xl"] as const).map((size) => ( + {}} size={size} /> + ))} +
    + ), +}; diff --git a/frontend/editor/src/core/components/tools/toolPicker/ToolPicker.css b/frontend/editor/src/core/components/tools/toolPicker/ToolPicker.css index b48932d6aa..b3b8fe3a2b 100644 --- a/frontend/editor/src/core/components/tools/toolPicker/ToolPicker.css +++ b/frontend/editor/src/core/components/tools/toolPicker/ToolPicker.css @@ -12,12 +12,12 @@ } .tool-picker-scrollable::-webkit-scrollbar-thumb { - background-color: var(--mantine-color-gray-4); + background-color: var(--c-border); border-radius: 0.1875rem; } .tool-picker-scrollable::-webkit-scrollbar-thumb:hover { - background-color: var(--mantine-color-gray-5); + background-color: var(--c-border-strong); } .search-input { @@ -28,7 +28,7 @@ text-transform: uppercase; padding-bottom: 0.5rem; font-size: 0.75rem; - color: var(--tool-subcategory-text-color); + color: var(--c-text-subtle); /* Align the text with tool labels to account for icon gutter */ padding-left: 1rem; } @@ -44,24 +44,20 @@ text-transform: uppercase; font-weight: 600; font-size: 0.75rem; - color: var(--tool-subcategory-text-color); + color: var(--c-text-subtle); white-space: nowrap; overflow: visible; } .tool-subcategory-row-rule { height: 1px; - background-color: var(--tool-subcategory-rule-color); + background-color: var(--c-border-subtle); flex: 1 1 auto; } -/* Selected tool highlight — theme-aware via CSS variable */ +/* Selected tool highlight — theme-aware via the adaptive --c-active token */ :root { - --tool-button-selected-bg: var(--mantine-color-gray-2); -} - -[data-mantine-color-scheme="dark"] { - --tool-button-selected-bg: var(--mantine-color-dark-4); + --tool-button-selected-bg: var(--c-active); } /* Compact tool buttons (padding via the Button `p`/`py` props). diff --git a/frontend/editor/src/core/components/tools/toolPicker/ToolSearch.stories.tsx b/frontend/editor/src/core/components/tools/toolPicker/ToolSearch.stories.tsx new file mode 100644 index 0000000000..c7dfd1b4eb --- /dev/null +++ b/frontend/editor/src/core/components/tools/toolPicker/ToolSearch.stories.tsx @@ -0,0 +1,82 @@ +import { useState, type ReactElement } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ToolSearch from "@app/components/tools/toolPicker/ToolSearch"; +import { PreferencesProvider } from "@app/contexts/PreferencesContext"; +import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider"; +import { NavigationProvider } from "@app/contexts/NavigationContext"; +import { + ToolWorkflowProvider, + useToolWorkflow, +} from "@app/contexts/ToolWorkflowContext"; + +// ToolSearchDemo below sources toolRegistry via useToolWorkflow(), so +// ToolWorkflowProvider must be present — and it in turn needs +// ToolRegistryProvider and NavigationProvider as ancestors to build a real registry. +function withProviders(Story: () => ReactElement) { + return ( + + + + + + + + + + ); +} + +function ToolSearchDemo({ + mode = "filter", + initialValue = "", +}: { + mode?: "filter" | "dropdown" | "unstyled"; + initialValue?: string; +}) { + const { toolRegistry } = useToolWorkflow(); + const [value, setValue] = useState(initialValue); + + return ( + + ); +} + +const meta = { + title: "Tools/ToolPicker/ToolSearch", + component: ToolSearch, + decorators: [withProviders], + args: { + value: "", + onChange: () => {}, + toolRegistry: {}, + mode: "filter", + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +/** Filter mode: plain input, no dropdown, used inline above a tool grid. */ +export const Default: Story = { + render: () => , +}; + +/** + * Dropdown mode with a query pre-filled. The results panel itself only opens + * in response to a user typing (internal `dropdownOpen` state), so this + * renders the closed input — type in it to see the fuzzy-matched list. + */ +export const DropdownMode: Story = { + render: () => , +}; + +/** Unstyled mode: bare input with no wrapping container, for embedding elsewhere. */ +export const Unstyled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/tools/toolPicker/ToolSearch.tsx b/frontend/editor/src/core/components/tools/toolPicker/ToolSearch.tsx index 66463b9005..39eaf2a57b 100644 --- a/frontend/editor/src/core/components/tools/toolPicker/ToolSearch.tsx +++ b/frontend/editor/src/core/components/tools/toolPicker/ToolSearch.tsx @@ -148,15 +148,13 @@ const ToolSearch = ({ setDropdownOpen(false); }} leftSection={ -
    - {tool.icon} -
    +
    {tool.icon}
    } fullWidth justify="start" style={{ borderRadius: "6px", - color: "var(--tools-text-and-icon-color)", + color: "var(--c-text)", padding: "8px 12px", }} > diff --git a/frontend/editor/src/core/components/tools/unlockPdfForms/UnlockPdfFormsSettings.stories.tsx b/frontend/editor/src/core/components/tools/unlockPdfForms/UnlockPdfFormsSettings.stories.tsx new file mode 100644 index 0000000000..2e88b16eec --- /dev/null +++ b/frontend/editor/src/core/components/tools/unlockPdfForms/UnlockPdfFormsSettings.stories.tsx @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import UnlockPdfFormsSettings from "@app/components/tools/unlockPdfForms/UnlockPdfFormsSettings"; +import { UnlockPdfFormsParameters } from "@app/hooks/tools/unlockPdfForms/useUnlockPdfFormsParameters"; + +const defaultParameters: UnlockPdfFormsParameters = {}; + +const meta = { + title: "Tools/UnlockPdfForms/UnlockPdfFormsSettings", + component: UnlockPdfFormsSettings, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + parameters: defaultParameters, + onParameterChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/validateSignature/ValidateSignatureReportView.stories.tsx b/frontend/editor/src/core/components/tools/validateSignature/ValidateSignatureReportView.stories.tsx new file mode 100644 index 0000000000..1203f3bee1 --- /dev/null +++ b/frontend/editor/src/core/components/tools/validateSignature/ValidateSignatureReportView.stories.tsx @@ -0,0 +1,140 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ValidateSignatureReportView from "@app/components/tools/validateSignature/ValidateSignatureReportView"; +import type { + SignatureValidationReportData, + SignatureValidationSignature, +} from "@app/types/validateSignature"; + +const baseSignature: SignatureValidationSignature = { + id: "sig-1", + valid: true, + chainValid: true, + trustValid: true, + chainValidationError: null, + certPathLength: 2, + notExpired: true, + coversEntireDocument: true, + revocationChecked: true, + revocationStatus: "good", + validationTimeSource: "signing-time", + signerName: "Jane Doe", + signatureDate: "2026-05-12T10:30:00Z", + reason: "Approved", + location: "London, UK", + issuerDN: "CN=Example CA, O=Example Corp", + subjectDN: "CN=Jane Doe, O=Example Corp", + serialNumber: "1A2B3C4D5E", + validFrom: "2025-01-01T00:00:00Z", + validUntil: "2027-01-01T00:00:00Z", + signatureAlgorithm: "SHA256withRSA", + keySize: 2048, + version: "1", + keyUsages: ["digitalSignature", "nonRepudiation"], + selfSigned: false, + errorMessage: null, +}; + +const validData: SignatureValidationReportData = { + generatedAt: Date.parse("2026-05-12T11:00:00Z"), + entries: [ + { + fileId: "file-1", + fileName: "contract.pdf", + fileSize: 245_760, + lastModified: Date.parse("2026-05-12T10:30:00Z"), + thumbnailUrl: null, + createdAtLabel: "12 May 2026", + signatures: [baseSignature], + }, + ], +}; + +const multiSignatureData: SignatureValidationReportData = { + generatedAt: Date.parse("2026-05-12T11:00:00Z"), + entries: [ + { + fileId: "file-1", + fileName: "agreement.pdf", + fileSize: 512_000, + lastModified: Date.parse("2026-05-12T10:30:00Z"), + thumbnailUrl: null, + createdAtLabel: "12 May 2026", + signatures: [ + baseSignature, + { + ...baseSignature, + id: "sig-2", + signerName: "John Smith", + valid: false, + chainValid: false, + trustValid: false, + chainValidationError: "Certificate chain could not be verified", + errorMessage: "Untrusted certificate", + }, + ], + }, + ], +}; + +const noSignaturesData: SignatureValidationReportData = { + generatedAt: Date.parse("2026-05-12T11:00:00Z"), + entries: [ + { + fileId: "file-2", + fileName: "unsigned-report.pdf", + fileSize: 128_000, + lastModified: Date.parse("2026-05-12T10:30:00Z"), + thumbnailUrl: null, + createdAtLabel: "12 May 2026", + signatures: [], + }, + ], +}; + +const errorData: SignatureValidationReportData = { + generatedAt: Date.parse("2026-05-12T11:00:00Z"), + entries: [ + { + fileId: "file-3", + fileName: "corrupted.pdf", + fileSize: 64_000, + lastModified: Date.parse("2026-05-12T10:30:00Z"), + thumbnailUrl: null, + createdAtLabel: "12 May 2026", + signatures: [], + error: "Unable to parse document signatures", + }, + ], +}; + +const meta = { + title: "Tools/ValidateSignature/ValidateSignatureReportView", + component: ValidateSignatureReportView, + parameters: { layout: "padded" }, + args: { + data: validData, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const MultipleSignatures: Story = { + args: { + data: multiSignatureData, + }, +}; + +export const NoSignatures: Story = { + args: { + data: noSignaturesData, + }, +}; + +export const Error: Story = { + args: { + data: errorData, + }, +}; diff --git a/frontend/editor/src/core/components/tools/validateSignature/ValidateSignatureSettings.stories.tsx b/frontend/editor/src/core/components/tools/validateSignature/ValidateSignatureSettings.stories.tsx new file mode 100644 index 0000000000..3d8b1baa1e --- /dev/null +++ b/frontend/editor/src/core/components/tools/validateSignature/ValidateSignatureSettings.stories.tsx @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ValidateSignatureSettings from "@app/components/tools/validateSignature/ValidateSignatureSettings"; +import { ValidateSignatureParameters } from "@app/hooks/tools/validateSignature/useValidateSignatureParameters"; + +const meta = { + title: "Tools/ValidateSignature/ValidateSignatureSettings", + component: ValidateSignatureSettings, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +const buildParameters = ( + overrides: Partial = {}, +): ValidateSignatureParameters => ({ + certFile: null, + ...overrides, +}); + +export const Default: Story = { + args: { + parameters: buildParameters(), + onParameterChange: () => {}, + }, +}; + +export const WithCertFile: Story = { + args: { + parameters: buildParameters({ + certFile: new File(["cert-data"], "trusted-root.crt", { + type: "application/x-x509-ca-cert", + }), + }), + onParameterChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + parameters: buildParameters(), + onParameterChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/tools/validateSignature/reportView/FieldBlock.stories.tsx b/frontend/editor/src/core/components/tools/validateSignature/reportView/FieldBlock.stories.tsx new file mode 100644 index 0000000000..499b76e393 --- /dev/null +++ b/frontend/editor/src/core/components/tools/validateSignature/reportView/FieldBlock.stories.tsx @@ -0,0 +1,21 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import FieldBlock from "@app/components/tools/validateSignature/reportView/FieldBlock"; + +// FieldBlock is a plain function that returns a JSX element (called directly +// as `FieldBlock(label, value)`), not a React component consumed via JSX +// props, so every story renders it through a `render` override instead of +// `args`. +const meta = { + title: "Tools/ValidateSignature/ReportView/FieldBlock", +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: () => FieldBlock("Signer Name", "Jane Doe"), +}; + +export const EmptyValue: Story = { + render: () => FieldBlock("Reason", ""), +}; diff --git a/frontend/editor/src/core/components/tools/validateSignature/reportView/FileSummaryHeader.stories.tsx b/frontend/editor/src/core/components/tools/validateSignature/reportView/FileSummaryHeader.stories.tsx new file mode 100644 index 0000000000..066510a0ef --- /dev/null +++ b/frontend/editor/src/core/components/tools/validateSignature/reportView/FileSummaryHeader.stories.tsx @@ -0,0 +1,34 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import FileSummaryHeader from "@app/components/tools/validateSignature/reportView/FileSummaryHeader"; + +const meta = { + title: "Tools/ValidateSignature/ReportView/FileSummaryHeader", + component: FileSummaryHeader, + args: { + fileSize: 2_456_789, + createdAt: "2026-01-15T09:30:00Z", + totalSignatures: 2, + lastSignatureDate: "2026-03-04T14:12:00Z", + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const NoSignatures: Story = { + args: { + totalSignatures: 0, + lastSignatureDate: null, + }, +}; + +export const MissingMetadata: Story = { + args: { + fileSize: null, + createdAt: null, + totalSignatures: 1, + lastSignatureDate: null, + }, +}; diff --git a/frontend/editor/src/core/components/tools/validateSignature/reportView/SignatureSection.stories.tsx b/frontend/editor/src/core/components/tools/validateSignature/reportView/SignatureSection.stories.tsx new file mode 100644 index 0000000000..7c8b8edc1f --- /dev/null +++ b/frontend/editor/src/core/components/tools/validateSignature/reportView/SignatureSection.stories.tsx @@ -0,0 +1,80 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import SignatureSection from "@app/components/tools/validateSignature/reportView/SignatureSection"; +import type { SignatureValidationSignature } from "@app/types/validateSignature"; + +const buildSignature = ( + overrides: Partial = {}, +): SignatureValidationSignature => ({ + id: "sig-1", + valid: true, + chainValid: true, + trustValid: true, + chainValidationError: null, + certPathLength: 2, + notExpired: true, + coversEntireDocument: true, + revocationChecked: true, + revocationStatus: "good", + validationTimeSource: "signing-time", + signerName: "Jane Doe", + signatureDate: "2026-06-01T10:00:00Z", + reason: "Approved", + location: "London, UK", + issuerDN: "CN=Example CA, O=Example Corp", + subjectDN: "CN=Jane Doe, O=Example Corp", + serialNumber: "0x4F2A9C", + validFrom: "2025-01-01T00:00:00Z", + validUntil: "2027-01-01T00:00:00Z", + signatureAlgorithm: "SHA256withRSA", + keySize: 2048, + version: "3", + keyUsages: ["digitalSignature", "nonRepudiation"], + selfSigned: false, + errorMessage: null, + ...overrides, +}); + +const meta = { + title: "Tools/ValidateSignature/SignatureSection", + component: SignatureSection, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + signature: buildSignature(), + index: 0, + }, +}; + +export const InvalidWithError: Story = { + args: { + signature: buildSignature({ + valid: false, + chainValid: false, + trustValid: false, + notExpired: false, + errorMessage: "Certificate has expired", + }), + index: 1, + }, +}; + +export const SelfSignedMinimalData: Story = { + args: { + signature: buildSignature({ + signerName: "", + reason: "", + location: "", + issuerDN: "", + subjectDN: "", + serialNumber: "", + keySize: null, + version: "", + keyUsages: [], + selfSigned: true, + }), + index: 2, + }, +}; diff --git a/frontend/editor/src/core/components/tools/validateSignature/reportView/SignatureStatusBadge.stories.tsx b/frontend/editor/src/core/components/tools/validateSignature/reportView/SignatureStatusBadge.stories.tsx new file mode 100644 index 0000000000..aba78f9f2c --- /dev/null +++ b/frontend/editor/src/core/components/tools/validateSignature/reportView/SignatureStatusBadge.stories.tsx @@ -0,0 +1,68 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import SignatureStatusBadge from "@app/components/tools/validateSignature/reportView/SignatureStatusBadge"; +import type { SignatureValidationSignature } from "@app/types/validateSignature"; + +const baseSignature: SignatureValidationSignature = { + id: "sig-1", + valid: true, + chainValid: true, + trustValid: true, + chainValidationError: null, + certPathLength: 2, + notExpired: true, + coversEntireDocument: true, + revocationChecked: true, + revocationStatus: "good", + validationTimeSource: "signing-time", + signerName: "Jane Doe", + signatureDate: "2026-06-01T12:00:00Z", + reason: "Document approval", + location: "London, UK", + issuerDN: "CN=Stirling PDF CA", + subjectDN: "CN=Jane Doe", + serialNumber: "0123456789ABCDEF", + validFrom: "2025-01-01T00:00:00Z", + validUntil: "2027-01-01T00:00:00Z", + signatureAlgorithm: "SHA256withRSA", + keySize: 2048, + version: "2", + keyUsages: ["digitalSignature", "nonRepudiation"], + selfSigned: false, + errorMessage: null, +}; + +const meta = { + title: "Tools/ValidateSignature/SignatureStatusBadge", + component: SignatureStatusBadge, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + signature: baseSignature, + }, +}; + +export const UntrustedSigner: Story = { + args: { + signature: { + ...baseSignature, + id: "sig-2", + trustValid: false, + chainValid: false, + selfSigned: true, + }, + }, +}; + +export const Invalid: Story = { + args: { + signature: { + ...baseSignature, + id: "sig-3", + valid: false, + errorMessage: "Signature does not match document contents", + }, + }, +}; diff --git a/frontend/editor/src/core/components/tools/validateSignature/reportView/ThumbnailPreview.stories.tsx b/frontend/editor/src/core/components/tools/validateSignature/reportView/ThumbnailPreview.stories.tsx new file mode 100644 index 0000000000..feccc477d3 --- /dev/null +++ b/frontend/editor/src/core/components/tools/validateSignature/reportView/ThumbnailPreview.stories.tsx @@ -0,0 +1,29 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ThumbnailPreview from "@app/components/tools/validateSignature/reportView/ThumbnailPreview"; + +const meta = { + title: "Tools/ValidateSignature/ReportView/ThumbnailPreview", + component: ThumbnailPreview, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +const SAMPLE_THUMBNAIL = + "data:image/svg+xml;utf8," + + encodeURIComponent( + '', + ); + +export const Default: Story = { + args: { + thumbnailUrl: SAMPLE_THUMBNAIL, + fileName: "signed-contract.pdf", + }, +}; + +export const NoThumbnail: Story = { + args: { + thumbnailUrl: null, + fileName: "signed-contract.pdf", + }, +}; diff --git a/frontend/editor/src/core/components/tools/validateSignature/reportView/styles.css b/frontend/editor/src/core/components/tools/validateSignature/reportView/styles.css index 096470305e..b4c9e1c815 100644 --- a/frontend/editor/src/core/components/tools/validateSignature/reportView/styles.css +++ b/frontend/editor/src/core/components/tools/validateSignature/reportView/styles.css @@ -44,7 +44,7 @@ .simulated-page { width: min(820px, 100%); min-height: 1040px; - background-color: var(--bg-raised) !important; + background-color: var(--c-surface-raised) !important; box-shadow: 0 12px 32px var(--shadow-color) !important; border-radius: 12px !important; padding: 48px 56px !important; @@ -52,7 +52,7 @@ overflow: hidden; display: flex; flex-direction: column; - color: var(--text-primary) !important; + color: var(--c-text) !important; } /* Container for the interactive report view */ @@ -60,19 +60,19 @@ width: 100%; height: 100%; /* Match Active Files/Page Editor background */ - background: var(--bg-background) !important; + background: var(--c-bg) !important; padding: 32px 24px 48px; overflow-y: auto; } /* Keep field blocks stable colors across themes */ .field-value { - border: 1px solid var(--border-default) !important; - background-color: var(--bg-raised) !important; + border: 1px solid var(--c-border) !important; + background-color: var(--c-surface-raised) !important; } .field-container { - color: var(--text-primary) !important; + color: var(--c-text) !important; } /* Thumbnail preview styles */ @@ -111,19 +111,19 @@ /* Flash highlight animation for section navigation */ @keyframes section-flash { 0% { - background-color: rgba(255, 235, 59, 0); + background-color: color-mix(in srgb, var(--c-highlight) 0%, transparent); box-shadow: none; } 20% { - background-color: rgba(255, 235, 59, 0.35); - box-shadow: 0 0 20px rgba(255, 235, 59, 0.5); + background-color: color-mix(in srgb, var(--c-highlight) 35%, transparent); + box-shadow: 0 0 20px color-mix(in srgb, var(--c-highlight) 50%, transparent); } 50% { - background-color: rgba(255, 235, 59, 0.25); - box-shadow: 0 0 15px rgba(255, 235, 59, 0.4); + background-color: color-mix(in srgb, var(--c-highlight) 25%, transparent); + box-shadow: 0 0 15px color-mix(in srgb, var(--c-highlight) 40%, transparent); } 100% { - background-color: rgba(255, 235, 59, 0); + background-color: color-mix(in srgb, var(--c-highlight) 0%, transparent); box-shadow: none; } } diff --git a/frontend/editor/src/core/components/viewer/AnnotationMenuButtons.stories.tsx b/frontend/editor/src/core/components/viewer/AnnotationMenuButtons.stories.tsx new file mode 100644 index 0000000000..65f57faa9d --- /dev/null +++ b/frontend/editor/src/core/components/viewer/AnnotationMenuButtons.stories.tsx @@ -0,0 +1,73 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { + DeleteButton, + EditTextButton, + AttachCommentButton, + CommentButton, + LinkButton, +} from "@app/components/viewer/AnnotationMenuButtons"; + +// This module has no single default-exported component -- it's a set of small +// action-icon buttons shared by the annotation menu. Document each separately. +const meta: Meta = { + title: "Viewer/AnnotationMenuButtons", + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +export const Delete: Story = { + render: () => {}} />, +}; + +export const EditText: Story = { + render: () => {}} />, +}; + +export const AttachCommentAdd: Story = { + render: () => ( + {}} + onAdd={() => {}} + /> + ), +}; + +export const AttachCommentView: Story = { + render: () => ( + {}} + onAdd={() => {}} + /> + ), +}; + +export const CommentEmpty: Story = { + render: () => {}} />, +}; + +export const CommentWithContent: Story = { + render: () => {}} />, +}; + +export const LinkAdd: Story = { + render: () => ( + {}} + onAddLink={() => {}} + /> + ), +}; + +export const LinkGoTo: Story = { + render: () => ( + {}} + onAddLink={() => {}} + /> + ), +}; diff --git a/frontend/editor/src/core/components/viewer/AnnotationSelectionMenu.stories.tsx b/frontend/editor/src/core/components/viewer/AnnotationSelectionMenu.stories.tsx new file mode 100644 index 0000000000..bd9d86f0c6 --- /dev/null +++ b/frontend/editor/src/core/components/viewer/AnnotationSelectionMenu.stories.tsx @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { AnnotationSelectionMenu } from "@app/components/viewer/AnnotationSelectionMenu"; + +// AnnotationSelectionMenu reads the active document from ActiveDocumentContext, which +// defaults to `null` outside of a live EmbedPDF document-manager session (not something the +// shared preview can stub). With no active document it short-circuits and renders nothing, +// so this story only exercises that no-active-document mount path without throwing. +const meta = { + title: "Viewer/AnnotationSelectionMenu", + component: AnnotationSelectionMenu, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + selected: false, + }, +}; diff --git a/frontend/editor/src/core/components/viewer/AnnotationTypeButtons.stories.tsx b/frontend/editor/src/core/components/viewer/AnnotationTypeButtons.stories.tsx new file mode 100644 index 0000000000..860f743a99 --- /dev/null +++ b/frontend/editor/src/core/components/viewer/AnnotationTypeButtons.stories.tsx @@ -0,0 +1,69 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { AnnotationTypeButtons } from "@app/components/viewer/AnnotationTypeButtons"; + +const meta = { + title: "Viewer/AnnotationTypeButtons", + component: AnnotationTypeButtons, + parameters: { layout: "centered" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +const baseArgs = { + annotation: undefined, + documentId: "doc-1", + pageIndex: 0, + annotationId: "annotation-1", + menuWidth: 260, + obj: undefined, + firstLinkTarget: null, + hasCommentContent: false, + isInSidebar: false, + currentColor: "#000000", + strokeColor: "#000000", + fillColor: "#0000ff", + backgroundColor: "#ffffff", + textColor: "#000000", + currentOpacity: 100, + currentWidth: 2, + onDelete: () => {}, + onEdit: () => {}, + onColorChange: () => {}, + onOpacityChange: () => {}, + onWidthChange: () => {}, + onPropertiesUpdate: () => {}, + onGoToLink: () => {}, + onAddLink: () => {}, + onAddToSidebar: () => {}, + onViewComment: () => {}, + onCommentColorChange: () => {}, +}; + +export const TextMarkup: Story = { + args: { + ...baseArgs, + annotationType: "textMarkup", + }, +}; + +export const Ink: Story = { + args: { + ...baseArgs, + annotationType: "ink", + }, +}; + +export const Comment: Story = { + args: { + ...baseArgs, + annotationType: "comment", + hasCommentContent: true, + }, +}; + +export const Shape: Story = { + args: { + ...baseArgs, + annotationType: "shape", + }, +}; diff --git a/frontend/editor/src/core/components/viewer/AttachmentSidebar.css b/frontend/editor/src/core/components/viewer/AttachmentSidebar.css index 6c20964ab3..addaca8979 100644 --- a/frontend/editor/src/core/components/viewer/AttachmentSidebar.css +++ b/frontend/editor/src/core/components/viewer/AttachmentSidebar.css @@ -21,7 +21,7 @@ border-radius: 0.65rem; cursor: pointer; transition: all 0.2s ease; - background: color-mix(in srgb, var(--bg-toolbar) 86%, transparent); + background: color-mix(in srgb, var(--c-bg-raised) 86%, transparent); border: 1px solid transparent; width: 100%; box-sizing: border-box; @@ -31,12 +31,8 @@ } .attachment-item:hover { - background: color-mix(in srgb, var(--text-primary) 8%, var(--bg-toolbar)); - border-color: color-mix( - in srgb, - var(--text-primary) 20%, - var(--border-subtle) - ); + background: color-mix(in srgb, var(--c-text) 8%, var(--c-bg-raised)); + border-color: color-mix(in srgb, var(--c-text) 20%, var(--c-border-subtle)); transform: translateX(2px); } @@ -46,7 +42,7 @@ .attachment-item:focus-visible { outline: 2px solid - color-mix(in srgb, var(--text-primary) 30%, var(--border-subtle)); + color-mix(in srgb, var(--c-text) 30%, var(--c-border-subtle)); outline-offset: 2px; } @@ -54,7 +50,7 @@ .attachment-item__download-icon { flex-shrink: 0; transition: transform 0.2s ease; - color: var(--text-muted); + color: var(--c-text-subtle); } .attachment-item:hover .attachment-item__download-icon { @@ -75,7 +71,7 @@ line-height: 1.35; overflow-wrap: anywhere; word-break: break-word; - color: var(--text-primary); + color: var(--c-text); } .attachment-item__meta { diff --git a/frontend/editor/src/core/components/viewer/BookmarkSidebar.css b/frontend/editor/src/core/components/viewer/BookmarkSidebar.css index 74214fe8b9..5d8beab4e0 100644 --- a/frontend/editor/src/core/components/viewer/BookmarkSidebar.css +++ b/frontend/editor/src/core/components/viewer/BookmarkSidebar.css @@ -32,16 +32,12 @@ .bookmark-item--clickable { cursor: pointer; - background: color-mix(in srgb, var(--bg-toolbar) 86%, transparent); + background: color-mix(in srgb, var(--c-bg-raised) 86%, transparent); } .bookmark-item--clickable:hover { - background: color-mix(in srgb, var(--text-primary) 8%, var(--bg-toolbar)); - border-color: color-mix( - in srgb, - var(--text-primary) 20%, - var(--border-subtle) - ); + background: color-mix(in srgb, var(--c-text) 8%, var(--c-bg-raised)); + border-color: color-mix(in srgb, var(--c-text) 20%, var(--c-border-subtle)); transform: translateX(2px); } @@ -51,7 +47,7 @@ .bookmark-item--clickable:focus-visible { outline: 2px solid - color-mix(in srgb, var(--text-primary) 30%, var(--border-subtle)); + color-mix(in srgb, var(--c-text) 30%, var(--c-border-subtle)); outline-offset: 2px; } @@ -75,7 +71,7 @@ width: 2rem; height: 2rem; flex-shrink: 0; - color: var(--text-muted); + color: var(--c-text-subtle); font-size: 1.25rem; opacity: 0.5; font-weight: 300; @@ -94,11 +90,11 @@ line-height: 1.35; overflow-wrap: anywhere; word-break: break-word; - color: var(--text-primary); + color: var(--c-text); } .bookmark-item--clickable:hover .bookmark-item__title { - color: var(--text-primary); + color: var(--c-text); } .bookmark-item__page { diff --git a/frontend/editor/src/core/components/viewer/BookmarkSidebar.tsx b/frontend/editor/src/core/components/viewer/BookmarkSidebar.tsx index a9125215bc..2acd3e309d 100644 --- a/frontend/editor/src/core/components/viewer/BookmarkSidebar.tsx +++ b/frontend/editor/src/core/components/viewer/BookmarkSidebar.tsx @@ -845,9 +845,10 @@ export const BookmarkSidebar = ({ p="sm" data-testid="bookmark-add-form" style={{ - border: "1px solid var(--border-subtle)", + border: "1px solid var(--c-border-subtle)", borderRadius: 6, - background: "var(--bg-raised, var(--mantine-color-gray-0))", + background: + "var(--c-surface-raised, var(--mantine-color-gray-0))", }} > @@ -945,8 +946,8 @@ export const BookmarkSidebar = ({ px="sm" py="xs" style={{ - borderTop: "1px solid var(--border-subtle)", - backgroundColor: "var(--bg-toolbar)", + borderTop: "1px solid var(--c-border-subtle)", + backgroundColor: "var(--c-bg-raised)", flexShrink: 0, }} > diff --git a/frontend/editor/src/core/components/viewer/CommentsSidebar.tsx b/frontend/editor/src/core/components/viewer/CommentsSidebar.tsx index e25f9671d9..3bbc5c8f53 100644 --- a/frontend/editor/src/core/components/viewer/CommentsSidebar.tsx +++ b/frontend/editor/src/core/components/viewer/CommentsSidebar.tsx @@ -679,8 +679,8 @@ export function CommentsSidebar({ top: 0, bottom: 0, width: SIDEBAR_WIDTH, - backgroundColor: "var(--bg-file-manager)", - borderLeft: "1px solid var(--border-subtle)", + backgroundColor: "var(--c-bg)", + borderLeft: "1px solid var(--c-border-subtle)", zIndex: 998, display: "flex", flexDirection: "column", @@ -690,7 +690,7 @@ export function CommentsSidebar({
    @@ -902,9 +902,9 @@ export function CommentsSidebar({ style={{ border: selectedAnnotationIds.has(id) ? "1px solid var(--mantine-color-blue-3)" - : "1px solid var(--border-subtle)", + : "1px solid var(--c-border-subtle)", borderRadius: 8, - backgroundColor: "var(--bg-raised)", + backgroundColor: "var(--c-surface-raised)", }} > ; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + documentId: "doc-1", + pageIndex: 0, + scale: 1, + highlightColor: "rgba(255, 220, 0, 0.4)", + activeHighlightColor: "rgba(255, 140, 0, 0.6)", + opacity: 1, + padding: 2, + borderRadius: 4, + }, +}; diff --git a/frontend/editor/src/core/components/viewer/DocumentReadyWrapper.stories.tsx b/frontend/editor/src/core/components/viewer/DocumentReadyWrapper.stories.tsx new file mode 100644 index 0000000000..cb0576dfb2 --- /dev/null +++ b/frontend/editor/src/core/components/viewer/DocumentReadyWrapper.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Center, Text } from "@mantine/core"; +import { DocumentReadyWrapper } from "@app/components/viewer/DocumentReadyWrapper"; + +/** + * Outside of a live `` tree the document-manager plugin never + * finishes loading, so this always renders its `fallback` — the same state + * shown in the real viewer while the PDF engine is still initializing. + */ +const meta = { + title: "Viewer/DocumentReadyWrapper", + component: DocumentReadyWrapper, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + fallback: ( +
    + + Loading document… + +
    + ), + children: (documentId: string) => Document ready: {documentId}, + }, +}; + +export const NoFallback: Story = { + args: { + children: (documentId: string) => Document ready: {documentId}, + }, +}; diff --git a/frontend/editor/src/core/components/viewer/LayerSidebar.css b/frontend/editor/src/core/components/viewer/LayerSidebar.css index 40ff79ae9b..305853ec9f 100644 --- a/frontend/editor/src/core/components/viewer/LayerSidebar.css +++ b/frontend/editor/src/core/components/viewer/LayerSidebar.css @@ -38,16 +38,12 @@ } .layer-item:hover { - background: color-mix(in srgb, var(--text-primary) 6%, var(--bg-toolbar)); - border-color: color-mix( - in srgb, - var(--text-primary) 14%, - var(--border-subtle) - ); + background: color-mix(in srgb, var(--c-text) 6%, var(--c-bg-raised)); + border-color: color-mix(in srgb, var(--c-text) 14%, var(--c-border-subtle)); } .layer-item:active { - background: color-mix(in srgb, var(--text-primary) 10%, var(--bg-toolbar)); + background: color-mix(in srgb, var(--c-text) 10%, var(--c-bg-raised)); } .layer-item__expand-btn { @@ -58,7 +54,7 @@ width: 1.25rem; height: 1.25rem; cursor: pointer; - color: var(--text-muted); + color: var(--c-text-subtle); opacity: 0.7; transition: opacity 0.15s ease; } @@ -78,16 +74,20 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - color: var(--text-primary); + color: var(--c-text); line-height: 1.4; cursor: pointer; user-select: none; } .layer-item--hidden .layer-item__label { - color: var(--text-muted); + color: var(--c-text-subtle); text-decoration: line-through; - text-decoration-color: color-mix(in srgb, var(--text-muted) 60%, transparent); + text-decoration-color: color-mix( + in srgb, + var(--c-text-subtle) 60%, + transparent + ); } /* Children Container */ @@ -97,7 +97,7 @@ gap: 0.125rem; margin-top: 0.125rem; border-left: 2px solid - color-mix(in srgb, var(--border-subtle) 60%, transparent); + color-mix(in srgb, var(--c-border-subtle) 60%, transparent); margin-left: 0.875rem; padding-left: 0.25rem; } @@ -105,8 +105,8 @@ /* Footer with Apply button */ .layer-sidebar__footer { padding: 0.625rem 0.875rem; - background: var(--bg-toolbar); - border-top: 1px solid var(--border-subtle); + background: var(--c-bg-raised); + border-top: 1px solid var(--c-border-subtle); } /* Dirty indicator badge */ diff --git a/frontend/editor/src/core/components/viewer/LinkLayer.stories.tsx b/frontend/editor/src/core/components/viewer/LinkLayer.stories.tsx new file mode 100644 index 0000000000..376cdca0df --- /dev/null +++ b/frontend/editor/src/core/components/viewer/LinkLayer.stories.tsx @@ -0,0 +1,22 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { LinkLayer } from "@app/components/viewer/LinkLayer"; + +// LinkLayer reads its link annotations from EmbedPDF's react context +// (useDocumentState/useScroll/useAnnotation). Outside of a live +// provider those hooks resolve to their documented empty defaults (no +// annotations, scale 1), so the layer renders null — this still exercises +// the component's mount path without needing a real PDF engine. +const meta = { + title: "Viewer/LinkLayer", + component: LinkLayer, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + documentId: "storybook-doc", + pageIndex: 0, + }, +}; diff --git a/frontend/editor/src/core/components/viewer/LocalEmbedPDF.stories.tsx b/frontend/editor/src/core/components/viewer/LocalEmbedPDF.stories.tsx new file mode 100644 index 0000000000..8880c58afa --- /dev/null +++ b/frontend/editor/src/core/components/viewer/LocalEmbedPDF.stories.tsx @@ -0,0 +1,39 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { LocalEmbedPDF } from "@app/components/viewer/LocalEmbedPDF"; + +const meta = { + title: "Viewer/LocalEmbedPDF", + component: LocalEmbedPDF, + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +// No file/url supplied — renders the "No PDF provided" empty state without +// touching the pdfium engine or blob URL setup. +export const Empty: Story = { + args: {}, + decorators: [ + (Story) => ( +
    + +
    + ), + ], +}; + +// A non-PDF file surfaces the "cannot preview" guard instead of attempting to +// load the pdfium engine. +export const UnsupportedFile: Story = { + args: { + file: new File(["not a pdf"], "notes.txt", { type: "text/plain" }), + }, + decorators: [ + (Story) => ( +
    + +
    + ), + ], +}; diff --git a/frontend/editor/src/core/components/viewer/LocalEmbedPDF.tsx b/frontend/editor/src/core/components/viewer/LocalEmbedPDF.tsx index f9f8d9794a..547fba12e7 100644 --- a/frontend/editor/src/core/components/viewer/LocalEmbedPDF.tsx +++ b/frontend/editor/src/core/components/viewer/LocalEmbedPDF.tsx @@ -997,7 +997,7 @@ export function LocalEmbedPDF({ ReactElement) { + return ( + + + + + + + + + + ); +} + +function makeFile(name: string, type: string, contents: string): File { + return new File([contents], name, { type }); +} + +const meta = { + title: "Viewer/NonPdfViewer", + component: NonPdfViewer, + parameters: { layout: "fullscreen" }, + decorators: [withProviders], +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +/** CSV preview: parsed into a scrollable table. */ +export const Csv: Story = { + args: { + sidebarsVisible: true, + setSidebarsVisible: () => {}, + file: makeFile( + "invoice.csv", + "text/csv", + "Item,Qty,Price\nWidget,3,9.99\nGadget,1,19.99\n", + ), + }, +}; + +/** JSON preview: syntax-highlighted / pretty-printed. */ +export const Json: Story = { + args: { + sidebarsVisible: true, + setSidebarsVisible: () => {}, + file: makeFile( + "config.json", + "application/json", + JSON.stringify({ name: "Stirling PDF", version: 1 }, null, 2), + ), + }, +}; + +/** Unsupported file type: falls back to the "Preview not available" state. */ +export const Unsupported: Story = { + args: { + sidebarsVisible: true, + setSidebarsVisible: () => {}, + file: makeFile("archive.zip", "application/zip", "binary-ish-content"), + }, +}; diff --git a/frontend/editor/src/core/components/viewer/PdfViewerToolbar.tsx b/frontend/editor/src/core/components/viewer/PdfViewerToolbar.tsx index 893cd35d44..8d7429eba8 100644 --- a/frontend/editor/src/core/components/viewer/PdfViewerToolbar.tsx +++ b/frontend/editor/src/core/components/viewer/PdfViewerToolbar.tsx @@ -18,6 +18,12 @@ import ZoomInIcon from "@mui/icons-material/ZoomIn"; import ZoomOutIcon from "@mui/icons-material/ZoomOut"; import MoreVertIcon from "@mui/icons-material/MoreVert"; +// Sizing constants for the page number input +const MIN_PAGE_DIGITS = 2; +const MIN_INPUT_WIDTH_PX = 48; +const BASE_INPUT_WIDTH_PX = 32; +const PX_PER_DIGIT = 8; + interface PdfViewerToolbarProps { // Page navigation props (placeholders for now) currentPage?: number; @@ -131,6 +137,15 @@ export function PdfViewerToolbar({ scrollActions.scrollToLastPage(); }; + const totalPagesDigits = Math.max( + MIN_PAGE_DIGITS, + (scrollState.totalPages || 1).toString().length, + ); + const inputWidth = Math.max( + MIN_INPUT_WIDTH_PX, + BASE_INPUT_WIDTH_PX + totalPagesDigits * PX_PER_DIGIT, + ); + return ( @@ -341,7 +359,7 @@ export function PdfViewerToolbar({ minWidth: "2.5rem", textAlign: "center", fontSize: 12, - color: "var(--text-muted)", + color: "var(--c-text-subtle)", }} > {displayZoomPercent}% diff --git a/frontend/editor/src/core/components/viewer/RedactionPendingTracker.stories.tsx b/frontend/editor/src/core/components/viewer/RedactionPendingTracker.stories.tsx new file mode 100644 index 0000000000..40062505f8 --- /dev/null +++ b/frontend/editor/src/core/components/viewer/RedactionPendingTracker.stories.tsx @@ -0,0 +1,16 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { RedactionPendingTracker } from "@app/components/viewer/RedactionPendingTracker"; + +// RedactionPendingTracker reads the active document from ActiveDocumentContext, which +// defaults to `null` outside of a live EmbedPDF document-manager session (not something the +// shared preview can stub). With no active document it short-circuits and renders nothing, +// so this story only exercises that no-active-document mount path without throwing. +const meta = { + title: "Viewer/RedactionPendingTracker", + component: RedactionPendingTracker, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/editor/src/core/components/viewer/RedactionSelectionMenu.stories.tsx b/frontend/editor/src/core/components/viewer/RedactionSelectionMenu.stories.tsx new file mode 100644 index 0000000000..af01665228 --- /dev/null +++ b/frontend/editor/src/core/components/viewer/RedactionSelectionMenu.stories.tsx @@ -0,0 +1,16 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { RedactionSelectionMenu } from "@app/components/viewer/RedactionSelectionMenu"; + +// RedactionSelectionMenu renders only when there's an active document ID +// (ActiveDocumentContext) and a selected redaction annotation from the live +// EmbedPDF redaction plugin. Neither exists in Storybook, so the component's +// own guard clause renders nothing here - that's its real empty state. +const meta = { + title: "Viewer/RedactionSelectionMenu", + component: RedactionSelectionMenu, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/editor/src/core/components/viewer/SearchInterface.stories.tsx b/frontend/editor/src/core/components/viewer/SearchInterface.stories.tsx new file mode 100644 index 0000000000..1e1a255465 --- /dev/null +++ b/frontend/editor/src/core/components/viewer/SearchInterface.stories.tsx @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { SearchInterface } from "@app/components/viewer/SearchInterface"; + +// SearchInterface reads ViewerContext via useContext with optional chaining +// throughout, so it renders fine without a ViewerProvider mounted — search +// state simply resolves to its empty/no-results defaults. +const meta = { + title: "Viewer/SearchInterface", + component: SearchInterface, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + visible: true, + onClose: () => {}, + }, +}; + +export const Hidden: Story = { + args: { + visible: false, + onClose: () => {}, + }, +}; diff --git a/frontend/editor/src/core/components/viewer/SidebarBase.css b/frontend/editor/src/core/components/viewer/SidebarBase.css index 058796038d..8e7771dead 100644 --- a/frontend/editor/src/core/components/viewer/SidebarBase.css +++ b/frontend/editor/src/core/components/viewer/SidebarBase.css @@ -6,15 +6,15 @@ flex-direction: column; background: linear-gradient( 135deg, - color-mix(in srgb, var(--bg-toolbar) 96%, transparent), - color-mix(in srgb, var(--bg-background) 90%, transparent) + color-mix(in srgb, var(--c-bg-raised) 96%, transparent), + color-mix(in srgb, var(--c-bg) 90%, transparent) ); border-left: 1px solid - color-mix(in srgb, var(--border-subtle) 75%, transparent); + color-mix(in srgb, var(--c-border-subtle) 75%, transparent); box-shadow: -2px 0 16px color-mix( in srgb, - var(--shadow-color, rgba(15, 23, 42, 0.35)) 20%, + var(--shadow-color, color-mix(in srgb, black 35%, transparent)) 20%, transparent ); backdrop-filter: blur(12px); @@ -26,8 +26,8 @@ align-items: center; justify-content: space-between; padding: 0.75rem 0.875rem; - background: var(--bg-toolbar); - border-bottom: 1px solid var(--border-subtle); + background: var(--c-bg-raised); + border-bottom: 1px solid var(--c-border-subtle); } .sidebar-base__header-title { @@ -46,9 +46,8 @@ /* Search Section (used by bookmark & attachment sidebars) */ .sidebar-base__search { - background: var(--tool-panel-search-bg, var(--bg-toolbar)); - border-bottom: 1px solid - var(--tool-panel-search-border-bottom, var(--border-subtle)); + background: var(--c-surface-sunken, var(--c-bg-raised)); + border-bottom: 1px solid var(--c-border, var(--c-border-subtle)); padding-top: 0.75rem !important; } diff --git a/frontend/editor/src/core/components/viewer/SignatureFieldOverlay.stories.tsx b/frontend/editor/src/core/components/viewer/SignatureFieldOverlay.stories.tsx new file mode 100644 index 0000000000..5dc1e73380 --- /dev/null +++ b/frontend/editor/src/core/components/viewer/SignatureFieldOverlay.stories.tsx @@ -0,0 +1,22 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import SignatureFieldOverlay from "@app/components/viewer/SignatureFieldOverlay"; + +// With no pdfSource the overlay resolves zero fields and renders null — this +// is the state it's mounted in until the host viewer has a document loaded. +const meta = { + title: "Viewer/SignatureFieldOverlay", + component: SignatureFieldOverlay, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + pageIndex: 0, + pdfSource: null, + documentId: "doc-1", + pageWidth: 612, + pageHeight: 792, + }, +}; diff --git a/frontend/editor/src/core/components/viewer/SignaturePlacementOverlay.stories.tsx b/frontend/editor/src/core/components/viewer/SignaturePlacementOverlay.stories.tsx new file mode 100644 index 0000000000..c6e4551333 --- /dev/null +++ b/frontend/editor/src/core/components/viewer/SignaturePlacementOverlay.stories.tsx @@ -0,0 +1,94 @@ +import { useEffect, useRef } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { SignaturePlacementOverlay } from "@app/components/viewer/SignaturePlacementOverlay"; +import { SignatureProvider } from "@app/contexts/SignatureContext"; +import type { SignParameters } from "@app/hooks/tools/sign/useSignParameters"; + +const textSignature: SignParameters = { + signatureType: "text", + signerName: "Jordan Blake", + fontFamily: "Helvetica", + fontSize: 32, + textColor: "#1e293b", + textAlign: "left", +}; + +// The overlay positions itself relative to containerRef and only paints once a +// mousemove has been observed inside that element, so the harness supplies a +// sized, positioned container and fires a synthetic mousemove after mount to +// simulate the cursor already being over the page. +function PlacementHarness({ + signatureConfig, +}: { + signatureConfig: SignParameters | null; +}) { + const containerRef = useRef(null); + + useEffect(() => { + const element = containerRef.current; + if (!element) return; + const rect = element.getBoundingClientRect(); + element.dispatchEvent( + new MouseEvent("mousemove", { + clientX: rect.left + rect.width / 2, + clientY: rect.top + rect.height / 2, + bubbles: true, + }), + ); + }, [signatureConfig]); + + return ( +
    + +
    + ); +} + +const noopContainerRef = { current: null }; + +const meta = { + title: "Viewer/SignaturePlacementOverlay", + component: SignaturePlacementOverlay, + // SignaturePlacementOverlay reads useSignature() to report its preview size + // back up — that context isn't mounted by the shared preview, so stub it here. + decorators: [ + (Story) => ( + + + + ), + ], + // Stories below override render with PlacementHarness, but Storybook's types + // still require args to satisfy the component's required props. + args: { + containerRef: noopContainerRef, + isActive: true, + signatureConfig: textSignature, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +/** Cursor-following preview of a text signature over the page. */ +export const Default: Story = { + render: () => , +}; + +/** No signature configured yet — the overlay renders nothing. */ +export const NoSignatureConfigured: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/viewer/SignaturePreviewLayer.stories.tsx b/frontend/editor/src/core/components/viewer/SignaturePreviewLayer.stories.tsx new file mode 100644 index 0000000000..a5580dd505 --- /dev/null +++ b/frontend/editor/src/core/components/viewer/SignaturePreviewLayer.stories.tsx @@ -0,0 +1,63 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { SignaturePreviewLayer } from "@app/components/viewer/SignaturePreviewLayer"; +import type { SignaturePreview } from "@app/components/viewer/viewerTypes"; + +// A 1x1 transparent PNG — enough to satisfy the src without a real signature asset. +const PLACEHOLDER_SIGNATURE_DATA = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="; + +const SAMPLE_PREVIEWS: SignaturePreview[] = [ + { + id: "sig-1", + pageIndex: 0, + x: 0.2, + y: 0.6, + width: 0.25, + height: 0.1, + signatureData: PLACEHOLDER_SIGNATURE_DATA, + signatureType: "image", + participantName: "Jane Doe", + }, +]; + +// SignaturePreviewLayer reads pause/resume from EmbedPDF's interaction-manager +// react context (useInteractionManagerCapability). Outside a mounted PDFContext +// provider that hook resolves to its documented empty default (no capability), +// so drag/resize handlers no-op — this still exercises the component's mount +// and render path without needing a real PDF engine. +const meta = { + title: "Viewer/SignaturePreviewLayer", + component: SignaturePreviewLayer, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + pageIndex: 0, + pageWidth: 600, + pageHeight: 800, + previews: SAMPLE_PREVIEWS, + readOnly: false, + placementMode: false, + onChange: () => {}, + }, +}; + +export const ReadOnly: Story = { + args: { + ...Default.args, + readOnly: true, + }, +}; + +export const PlacementMode: Story = { + args: { + ...Default.args, + previews: [], + placementMode: true, + placementData: PLACEHOLDER_SIGNATURE_DATA, + placementType: "image", + }, +}; diff --git a/frontend/editor/src/core/components/viewer/StampPlacementOverlay.stories.tsx b/frontend/editor/src/core/components/viewer/StampPlacementOverlay.stories.tsx new file mode 100644 index 0000000000..e2a7f64fc7 --- /dev/null +++ b/frontend/editor/src/core/components/viewer/StampPlacementOverlay.stories.tsx @@ -0,0 +1,95 @@ +import { createRef, useEffect, useRef } from "react"; +import type { Meta, StoryObj, Decorator } from "@storybook/react-vite"; +import { StampPlacementOverlay } from "@app/components/viewer/StampPlacementOverlay"; +import { SignatureProvider } from "@app/contexts/SignatureContext"; +import type { SignParameters } from "@app/hooks/tools/sign/useSignParameters"; + +// StampPlacementOverlay reads/writes SignatureContext (useSignature, for +// placementPreviewSize), which isn't mounted by the shared preview. +const withProviders: Decorator = (Story) => ( + + + +); + +const textSignature: SignParameters = { + signatureType: "text", + signerName: "Jane Doe", + fontFamily: "Helvetica", + fontSize: 32, + textColor: "#1e3a5f", + textAlign: "left", +}; + +// StampPlacementOverlay tracks the mouse over containerRef and only renders a +// preview once it has both a built signature image and a cursor position, so +// the demo dispatches a synthetic mousemove after mount to show it in place. +function StampPlacementOverlayDemo( + props: Partial>, +) { + const containerRef = useRef(null); + + useEffect(() => { + const element = containerRef.current; + if (!element) return; + const rect = element.getBoundingClientRect(); + element.dispatchEvent( + new MouseEvent("mousemove", { + clientX: rect.left + rect.width / 2, + clientY: rect.top + rect.height / 2, + bubbles: true, + }), + ); + }, []); + + return ( +
    + +
    + ); +} + +const meta = { + title: "Viewer/StampPlacementOverlay", + component: StampPlacementOverlay, + parameters: { layout: "padded" }, + decorators: [withProviders], + // Actual rendering is handled by StampPlacementOverlayDemo's own containerRef; + // these are just placeholder values to satisfy the component's required props. + args: { + containerRef: createRef(), + isActive: true, + signatureConfig: textSignature, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +/** Active placement mode with a text signature preview following the cursor. */ +export const Default: Story = { + render: () => , +}; + +/** Inactive placement mode — the overlay renders nothing (returns null). */ +export const Inactive: Story = { + render: () => , +}; + +/** No signature configured yet — nothing to preview, so it renders nothing. */ +export const NoSignatureConfig: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/viewer/TextSelectionMenu.stories.tsx b/frontend/editor/src/core/components/viewer/TextSelectionMenu.stories.tsx new file mode 100644 index 0000000000..1321fe7ea8 --- /dev/null +++ b/frontend/editor/src/core/components/viewer/TextSelectionMenu.stories.tsx @@ -0,0 +1,36 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { SelectionSelectionMenuProps } from "@embedpdf/plugin-selection/react"; +import { TextSelectionMenu } from "@app/components/viewer/TextSelectionMenu"; + +function baseProps( + overrides: Partial = {}, +): SelectionSelectionMenuProps { + return { + rect: { origin: { x: 0, y: 0 }, size: { width: 120, height: 20 } }, + menuWrapperProps: { style: {}, ref: () => {} }, + selected: true, + placement: { suggestTop: true }, + context: { type: "selection", pageIndex: 0 }, + ...overrides, + }; +} + +const meta = { + title: "Viewer/TextSelectionMenu", + component: TextSelectionMenu, + parameters: { layout: "centered" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: baseProps(), +}; + +export const BelowSelection: Story = { + args: baseProps({ placement: { suggestTop: false } }), +}; + +export const NotSelected: Story = { + args: baseProps({ selected: false }), +}; diff --git a/frontend/editor/src/core/components/viewer/ThumbnailSidebar.tsx b/frontend/editor/src/core/components/viewer/ThumbnailSidebar.tsx index 6b195c4485..c44271258a 100644 --- a/frontend/editor/src/core/components/viewer/ThumbnailSidebar.tsx +++ b/frontend/editor/src/core/components/viewer/ThumbnailSidebar.tsx @@ -238,7 +238,7 @@ export function ThumbnailSidebar({ onMouseEnter={(e) => { if (scrollState.currentPage !== pageIndex + 1) { e.currentTarget.style.backgroundColor = - "var(--hover-bg)"; + "var(--c-hover)"; } }} onMouseLeave={(e) => { @@ -259,7 +259,7 @@ export function ThumbnailSidebar({ height: "auto", borderRadius: "4px", boxShadow: "0 2px 4px rgba(0, 0, 0, 0.1)", - border: "1px solid var(--border-subtle)", + border: "1px solid var(--c-border-subtle)", }} /> @@ -285,13 +285,13 @@ export function ThumbnailSidebar({ style={{ width: "11.5rem", height: "15rem", - backgroundColor: "var(--bg-muted)", - border: "1px solid var(--border-subtle)", + backgroundColor: "var(--c-surface-sunken)", + border: "1px solid var(--c-border-subtle)", borderRadius: "4px", display: "flex", alignItems: "center", justifyContent: "center", - color: "var(--text-muted)", + color: "var(--c-text-subtle)", fontSize: "12px", }} > @@ -307,7 +307,7 @@ export function ThumbnailSidebar({ color: scrollState.currentPage === pageIndex + 1 ? "var(--color-primary-500)" - : "var(--text-muted)", + : "var(--c-text-subtle)", }} > Page {pageIndex + 1} diff --git a/frontend/editor/src/core/components/viewer/nonpdf/CsvViewer.stories.tsx b/frontend/editor/src/core/components/viewer/nonpdf/CsvViewer.stories.tsx new file mode 100644 index 0000000000..eb88d5f548 --- /dev/null +++ b/frontend/editor/src/core/components/viewer/nonpdf/CsvViewer.stories.tsx @@ -0,0 +1,48 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { CsvViewer } from "@app/components/viewer/nonpdf/CsvViewer"; + +const buildCsvFile = (contents: string, name = "data.csv"): File => + new File([contents], name, { type: "text/csv" }); + +const SAMPLE_CSV = [ + "Name,Age,City", + "Alice,30,New York", + "Bob,25,Los Angeles", + "Charlie,35,Chicago", +].join("\n"); + +const SAMPLE_TSV = [ + "Name\tAge\tCity", + "Alice\t30\tNew York", + "Bob\t25\tLos Angeles", +].join("\n"); + +const meta = { + title: "Viewer/NonPdf/CsvViewer", + component: CsvViewer, + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + file: buildCsvFile(SAMPLE_CSV), + isTsv: false, + }, +}; + +export const Tsv: Story = { + args: { + file: buildCsvFile(SAMPLE_TSV, "data.tsv"), + isTsv: true, + }, +}; + +export const Empty: Story = { + args: { + file: buildCsvFile(""), + isTsv: false, + }, +}; diff --git a/frontend/editor/src/core/components/viewer/nonpdf/HtmlViewer.stories.tsx b/frontend/editor/src/core/components/viewer/nonpdf/HtmlViewer.stories.tsx new file mode 100644 index 0000000000..173fc38903 --- /dev/null +++ b/frontend/editor/src/core/components/viewer/nonpdf/HtmlViewer.stories.tsx @@ -0,0 +1,23 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { HtmlViewer } from "@app/components/viewer/nonpdf/HtmlViewer"; + +const sampleHtmlFile = new File( + ["

    Sample document

    Preview content.

    "], + "sample.html", + { type: "text/html" }, +); + +const meta = { + title: "Viewer/Nonpdf/HtmlViewer", + component: HtmlViewer, + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + file: sampleHtmlFile, + }, +}; diff --git a/frontend/editor/src/core/components/viewer/nonpdf/ImageViewer.stories.tsx b/frontend/editor/src/core/components/viewer/nonpdf/ImageViewer.stories.tsx new file mode 100644 index 0000000000..53d0329733 --- /dev/null +++ b/frontend/editor/src/core/components/viewer/nonpdf/ImageViewer.stories.tsx @@ -0,0 +1,39 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ImageViewer } from "@app/components/viewer/nonpdf/ImageViewer"; + +// 2x2 red PNG, used so the component's URL.createObjectURL(file) has real image bytes to render. +const PNG_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAFUlEQVR42mNk+M9QDwAEZ" + + "AGvRy0PbwAAAABJRU5ErkJggg=="; + +function makeImageFile(name: string): File { + const bytes = atob(PNG_BASE64); + const buffer = new Uint8Array(bytes.length); + for (let i = 0; i < bytes.length; i++) { + buffer[i] = bytes.charCodeAt(i); + } + return new File([buffer], name, { type: "image/png" }); +} + +const meta = { + title: "Viewer/NonPdf/ImageViewer", + component: ImageViewer, + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + file: makeImageFile("sample.png"), + fileName: "sample.png", + }, + decorators: [ + (Story) => ( +
    + +
    + ), + ], +}; diff --git a/frontend/editor/src/core/components/viewer/nonpdf/JsonViewer.stories.tsx b/frontend/editor/src/core/components/viewer/nonpdf/JsonViewer.stories.tsx new file mode 100644 index 0000000000..7261e24ad6 --- /dev/null +++ b/frontend/editor/src/core/components/viewer/nonpdf/JsonViewer.stories.tsx @@ -0,0 +1,47 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { JsonViewer } from "@app/components/viewer/nonpdf/JsonViewer"; + +function jsonFile(name: string, contents: string) { + return new File([contents], name, { type: "application/json" }); +} + +const meta = { + title: "Viewer/NonPdf/JsonViewer", + component: JsonViewer, + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + file: jsonFile( + "config.json", + JSON.stringify( + { + name: "Stirling PDF", + version: "2.0.0", + features: ["merge", "split", "compress"], + settings: { theme: "dark", locale: "en-US" }, + }, + null, + 2, + ), + ), + }, + decorators: [ + (Story) => ( +
    + +
    + ), + ], +}; + +export const InvalidJson: Story = { + args: { + file: jsonFile("broken.json", "{ this is not valid json "), + }, + decorators: Default.decorators, +}; diff --git a/frontend/editor/src/core/components/viewer/nonpdf/NonPdfBanner.stories.tsx b/frontend/editor/src/core/components/viewer/nonpdf/NonPdfBanner.stories.tsx new file mode 100644 index 0000000000..d37267484c --- /dev/null +++ b/frontend/editor/src/core/components/viewer/nonpdf/NonPdfBanner.stories.tsx @@ -0,0 +1,30 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { NonPdfBanner } from "@app/components/viewer/nonpdf/NonPdfBanner"; + +const meta = { + title: "Viewer/NonPdf/NonPdfBanner", + component: NonPdfBanner, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + onConvertToPdf: () => {}, + }, + decorators: [ + (Story) => ( +
    + +
    + ), + ], +}; + +export const Hidden: Story = { + args: { + onConvertToPdf: undefined, + }, +}; diff --git a/frontend/editor/src/core/components/viewer/nonpdf/TextViewer.stories.tsx b/frontend/editor/src/core/components/viewer/nonpdf/TextViewer.stories.tsx new file mode 100644 index 0000000000..6b006b8327 --- /dev/null +++ b/frontend/editor/src/core/components/viewer/nonpdf/TextViewer.stories.tsx @@ -0,0 +1,53 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { TextViewer } from "@app/components/viewer/nonpdf/TextViewer"; + +const meta = { + title: "Viewer/NonPdf/TextViewer", + component: TextViewer, + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +const plainTextFile = new File( + [ + Array.from( + { length: 20 }, + (_, i) => `Line ${i + 1}: the quick brown fox jumps over the lazy dog.`, + ).join("\n"), + ], + "notes.txt", + { type: "text/plain" }, +); + +const markdownFile = new File( + [ + [ + "# Sample document", + "", + "This is a **markdown** file rendered by the text viewer.", + "", + "- item one", + "- item two", + "", + "> A blockquote for good measure.", + ].join("\n"), + ], + "README.md", + { type: "text/markdown" }, +); + +export const Default: Story = { + args: { + file: plainTextFile, + isMarkdown: false, + }, +}; + +export const Markdown: Story = { + args: { + file: markdownFile, + isMarkdown: true, + }, +}; diff --git a/frontend/editor/src/core/constants/featureFlags.ts b/frontend/editor/src/core/constants/featureFlags.ts index 0ada1cc673..a60770ac3a 100644 --- a/frontend/editor/src/core/constants/featureFlags.ts +++ b/frontend/editor/src/core/constants/featureFlags.ts @@ -11,11 +11,3 @@ // Annotated as `boolean` (not the literal `false`) so call sites aren't treated // as constant/unreachable conditions by the type checker and linter. export const WATCHED_FOLDERS_ENABLED: boolean = false; - -/** - * Policies — a proprietary, automation-backed feature (like Watched Folders but - * backend-driven, with non-folder triggers). The implementation lives under - * `proprietary/`; this core value stays `false` so the shared sidebar entry - * never appears in the open-source build. - */ -export const POLICIES_ENABLED: boolean = false; diff --git a/frontend/editor/src/core/constants/theme.ts b/frontend/editor/src/core/constants/theme.ts index b9263772c9..71e54c18f8 100644 --- a/frontend/editor/src/core/constants/theme.ts +++ b/frontend/editor/src/core/constants/theme.ts @@ -1,9 +1,8 @@ // Theme constants and utilities -// Stored theme preference. "system" follows the OS. export type ThemeMode = "light" | "dark" | "system"; -// The concrete scheme applied to the UI. +// The concrete light/dark base applied to Mantine + the neutral ramp. export type ColorScheme = "light" | "dark"; // Detect the OS theme preference. Never throws: if the environment can't be @@ -24,12 +23,13 @@ export function getSystemTheme(): ColorScheme { } } -// Resolve a theme preference to a concrete light/dark scheme. -// Falls back to systemScheme for unrecognised values (e.g. stale "rainbow"). +// Resolve the theme MODE to the concrete light/dark base Mantine uses. +// "system" follows the OS; anything unrecognised falls back to it too. export function resolveColorScheme( mode: ThemeMode, systemScheme: ColorScheme, ): ColorScheme { - if (mode === "light" || mode === "dark") return mode; + if (mode === "light") return "light"; + if (mode === "dark") return "dark"; return systemScheme; } diff --git a/frontend/editor/src/core/contexts/FileManagerContext.tsx b/frontend/editor/src/core/contexts/FileManagerContext.tsx index a557a9d31b..338a910508 100644 --- a/frontend/editor/src/core/contexts/FileManagerContext.tsx +++ b/frontend/editor/src/core/contexts/FileManagerContext.tsx @@ -22,6 +22,7 @@ import { alert } from "@app/components/toast"; import { extractLatestFilesFromBundle, parseContentDispositionFilename, + readResponseHeader, } from "@app/services/shareBundleUtils"; import { useTranslation } from "react-i18next"; import { openFilesFromDisk } from "@app/services/openFilesFromDisk"; @@ -1003,16 +1004,14 @@ export const FileManagerProvider: React.FC = ({ skipAuthRedirect: true, } as any, ); - const contentType = - (response.headers && - (response.headers["content-type"] || - response.headers["Content-Type"])) || - ""; - const disposition = - (response.headers && - (response.headers["content-disposition"] || - response.headers["Content-Disposition"])) || - ""; + const contentType = readResponseHeader( + response.headers, + "content-type", + ); + const disposition = readResponseHeader( + response.headers, + "content-disposition", + ); const filename = parseContentDispositionFilename(disposition) || file.name || @@ -1270,6 +1269,3 @@ export const useFileManagerContext = (): FileManagerContextValue => { return context; }; - -// Export the context for advanced use cases -export { FileManagerContext }; diff --git a/frontend/editor/src/core/contexts/FilesModalContext.tsx b/frontend/editor/src/core/contexts/FilesModalContext.tsx index 9d9bf0bd52..73d1b0477f 100644 --- a/frontend/editor/src/core/contexts/FilesModalContext.tsx +++ b/frontend/editor/src/core/contexts/FilesModalContext.tsx @@ -23,6 +23,7 @@ import { isZipBundle, loadShareBundleEntries, parseContentDispositionFilename, + readResponseHeader, } from "@app/services/shareBundleUtils"; interface FilesModalContextType { @@ -184,16 +185,11 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ skipAuthRedirect: true, } as any, ); - const contentType = - (response.headers && - (response.headers["content-type"] || - response.headers["Content-Type"])) || - ""; - const disposition = - (response.headers && - (response.headers["content-disposition"] || - response.headers["Content-Disposition"])) || - ""; + const contentType = readResponseHeader(response.headers, "content-type"); + const disposition = readResponseHeader( + response.headers, + "content-disposition", + ); const filename = parseContentDispositionFilename(disposition) || "server-file"; const blob = response.data as Blob; @@ -210,16 +206,11 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ skipAuthRedirect: true, } as any, ); - const contentType = - (response.headers && - (response.headers["content-type"] || - response.headers["Content-Type"])) || - ""; - const disposition = - (response.headers && - (response.headers["content-disposition"] || - response.headers["Content-Disposition"])) || - ""; + const contentType = readResponseHeader(response.headers, "content-type"); + const disposition = readResponseHeader( + response.headers, + "content-disposition", + ); const filename = parseContentDispositionFilename(disposition) || "shared-file"; const blob = response.data as Blob; diff --git a/frontend/editor/src/core/data/ogMetadata.test.ts b/frontend/editor/src/core/data/ogMetadata.test.ts index fe64fb90f6..e43ef11b9e 100644 --- a/frontend/editor/src/core/data/ogMetadata.test.ts +++ b/frontend/editor/src/core/data/ogMetadata.test.ts @@ -102,6 +102,28 @@ describe("injectOg (build-time prerender)", () => { ); expect(tags).toContain("A "B" & <C>"); }); + + it("uses ogTitle for the social card but title for the tag", () => { + const out = injectOg( + TEMPLATE, + { + image: "/og_images/saas/app.png", + title: "Stirling - Edit any PDF. Govern every PDF.", + ogTitle: "Edit any PDF. Govern every PDF.", + description: "d", + }, + {}, + ); + expect(out).toContain( + "<title>Stirling - Edit any PDF. Govern every PDF.", + ); + expect(out).toContain( + '', + ); + expect(out).toContain( + '', + ); + }); }); describe("prerenderOg (flat + nested route files)", () => { diff --git a/frontend/editor/src/core/data/useTranslatedToolRegistry.tsx b/frontend/editor/src/core/data/useTranslatedToolRegistry.tsx index 58b61c71be..120f955b42 100644 --- a/frontend/editor/src/core/data/useTranslatedToolRegistry.tsx +++ b/frontend/editor/src/core/data/useTranslatedToolRegistry.tsx @@ -1199,7 +1199,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { icon="open-in-new-rounded" width="1.5rem" height="1.5rem" - style={{ color: "#2F7BF6" }} + style={{ color: "var(--c-accent-fg, var(--c-primary))" }} /> ), name: t("home.devApi.title", "API"), @@ -1219,7 +1219,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { icon="open-in-new-rounded" width="1.5rem" height="1.5rem" - style={{ color: "#2F7BF6" }} + style={{ color: "var(--c-accent-fg, var(--c-primary))" }} /> ), name: t("home.devFolderScanning.title", "Automated Folder Scanning"), @@ -1242,7 +1242,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { icon="open-in-new-rounded" width="1.5rem" height="1.5rem" - style={{ color: "#2F7BF6" }} + style={{ color: "var(--c-accent-fg, var(--c-primary))" }} /> ), name: t("home.devSsoGuide.title", "SSO Guide"), @@ -1262,7 +1262,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { icon="open-in-new-rounded" width="1.5rem" height="1.5rem" - style={{ color: "#2F7BF6" }} + style={{ color: "var(--c-accent-fg, var(--c-primary))" }} /> ), name: t("home.devAirgapped.title", "Air-gapped Setup"), diff --git a/frontend/editor/src/core/hooks/tools/shared/toolAutomation.test.ts b/frontend/editor/src/core/hooks/tools/shared/toolAutomation.test.ts index 7caf6ee120..03b85798d8 100644 --- a/frontend/editor/src/core/hooks/tools/shared/toolAutomation.test.ts +++ b/frontend/editor/src/core/hooks/tools/shared/toolAutomation.test.ts @@ -13,7 +13,6 @@ import { import { deserializeToolStep, getExecutableTools, - serializeStepFromEndpoint, serializeToolStep, stepRequiresUpload, type WorkingToolStep, @@ -199,41 +198,6 @@ describe("serialize/deserialize round-trip", () => { }); }); -describe("serializeStepFromEndpoint", () => { - test("maps a wizard step's UI params to the backend contract, filling defaults", () => { - // The shape the policy setup wizard holds: an endpoint plus UI-shaped params - // (redact's `wordsToRedact`), with several fields left to their defaults. - const api = serializeStepFromEndpoint( - "/api/v1/security/auto-redact", - { mode: "automatic", useRegex: true, wordsToRedact: ["ssn", "card"] }, - dynamicRegistry, - ); - - expect(api.operation).toBe("/api/v1/security/auto-redact"); - // wordsToRedact -> listOfText (the field the backend actually reads), and the - // frontend-only `mode` is dropped. - expect(api.parameters).toMatchObject({ listOfText: "ssn\ncard" }); - expect(api.parameters).not.toHaveProperty("wordsToRedact"); - expect(api.parameters).not.toHaveProperty("mode"); - // Fields the wizard never set still get their defaults so the body is complete. - expect(api.parameters).toHaveProperty("wholeWordSearch"); - expect(api.parameters).toHaveProperty("customPadding"); - }); - - test("passes an unmapped endpoint's params through unchanged", () => { - expect( - serializeStepFromEndpoint( - "/api/v1/unknown/thing", - { keep: true }, - dynamicRegistry, - ), - ).toEqual({ - operation: "/api/v1/unknown/thing", - parameters: { keep: true }, - }); - }); -}); - describe("stepRequiresUpload", () => { const step = (params: Record): WorkingToolStep => ({ toolId: "compress" as ToolId, diff --git a/frontend/editor/src/core/hooks/tools/shared/toolAutomation.ts b/frontend/editor/src/core/hooks/tools/shared/toolAutomation.ts index 2685881bf4..d567a1dad1 100644 --- a/frontend/editor/src/core/hooks/tools/shared/toolAutomation.ts +++ b/frontend/editor/src/core/hooks/tools/shared/toolAutomation.ts @@ -197,31 +197,6 @@ export function serializeToolStep( return { operation, parameters }; } -/** - * Serialize a step held as an endpoint path plus frontend-shaped params - the form the policy setup - * wizard keeps, where params match the tool's UI shape (e.g. redact's `wordsToRedact`) rather than - * the backend contract - into the backend step contract, mapping params through the tool's - * `toApiParams` (merged over its defaults, so fields the wizard never set still get their defaults). - * The endpoint maps to a tool by path, so this works for dynamic-endpoint tools whose config - * endpoint is a function. Endpoints that map to no known tool pass through unchanged. - */ -export function serializeStepFromEndpoint( - operation: string, - params: ErasedToolParams, - registry: Partial, -): ToolApiStep { - const match = findToolByEndpoint({ operation, parameters: params }, registry); - const config = match?.[1].operationConfig; - if (!config) return { operation, parameters: params }; - const merged = { ...(config.defaultParameters ?? {}), ...params }; - return { - operation: resolveEndpoint(config, merged) ?? operation, - parameters: config.toApiParams - ? (config.toApiParams(merged) as Record) - : {}, - }; -} - /** * Find the registry tool for a stored step's endpoint: exact match for static endpoints, else * membership in a dynamic tool's declared `endpoints` set (replaying its function can't recover a diff --git a/frontend/editor/src/core/hooks/tools/shared/toolOperationDescriptor.test.ts b/frontend/editor/src/core/hooks/tools/shared/toolOperationDescriptor.test.ts new file mode 100644 index 0000000000..18bb5e282e --- /dev/null +++ b/frontend/editor/src/core/hooks/tools/shared/toolOperationDescriptor.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "vitest"; +import { describeToolOperation } from "@app/hooks/tools/shared/toolOperationDescriptor"; + +interface Params { + a: number; +} + +// A minimal config that type-checks against the flatten endpoint's model. +const CONFIG = { + endpoint: "/api/v1/misc/flatten" as const, + defaultParameters: { a: 1 } satisfies Params, + toApiParams: (p: Params) => ({ renderDpi: p.a }), + fromApiParams: (api: { renderDpi?: number }) => ({ a: api.renderDpi ?? 0 }), +}; + +describe("describeToolOperation", () => { + test("wraps the config's mappers and endpoint into a descriptor", () => { + const d = describeToolOperation("/api/v1/misc/flatten", CONFIG); + expect(d.endpoint).toBe("/api/v1/misc/flatten"); + expect(d.toApi({ a: 200 })).toEqual({ renderDpi: 200 }); + }); + + test("fromApi merges the mapped values over the defaults", () => { + const d = describeToolOperation("/api/v1/misc/flatten", CONFIG); + expect(d.fromApi({ renderDpi: 72 })).toEqual({ a: 72 }); + }); + + test("throws when the config lacks a mapper", () => { + expect(() => + describeToolOperation("/api/v1/misc/flatten", { + endpoint: "/api/v1/misc/flatten" as const, + defaultParameters: { a: 1 }, + toApiParams: (p: Params) => ({ renderDpi: p.a }), + }), + ).toThrow(/mappers/); + }); +}); diff --git a/frontend/editor/src/core/hooks/tools/shared/toolOperationDescriptor.ts b/frontend/editor/src/core/hooks/tools/shared/toolOperationDescriptor.ts new file mode 100644 index 0000000000..3c8f78d1b8 --- /dev/null +++ b/frontend/editor/src/core/hooks/tools/shared/toolOperationDescriptor.ts @@ -0,0 +1,59 @@ +/** + * Typed wrapper over a tool's `toApiParams`/`fromApiParams` mappers, binding one endpoint to safe + * frontend<->backend parameter conversion. + */ + +import type { ToolApiParams, ToolEndpoint } from "@app/types/toolApiTypes"; + +export interface ToolOperationDescriptor { + readonly endpoint: E; + readonly defaultParameters: TParams; + toApi(params: TParams): ToolApiParams[E]; + /** Backend model -> full frontend params (defaults merged under the mapped values). */ + fromApi(api: ToolApiParams[E]): TParams; +} + +/** + * Structural subset of a tool's config. `CE` is the config's declared endpoint type, inferred from + * the `endpoint` field: the literal for static tools, or the whole `ToolEndpoint` union for + * dynamic-endpoint tools (whose endpoint is a function typed against the union). + */ +export interface BidirectionalToolConfig { + endpoint: CE | null | ((params: TParams) => CE | null); + defaultParameters?: TParams; + toApiParams?(params: TParams): ToolApiParams[CE]; + fromApiParams?(api: ToolApiParams[CE]): Partial; +} + +/** + * Pin a config to `endpoint` (passed explicitly, since dynamic-endpoint tools declare `endpoint` as + * a function). `E extends CE` rejects pairing a static tool's config with the wrong endpoint, while + * allowing a dynamic tool whose `CE` is the full union. Throws when mappers or defaults are missing. + */ +export function describeToolOperation< + E extends CE, + CE extends ToolEndpoint, + TParams, +>( + endpoint: E, + config: BidirectionalToolConfig, +): ToolOperationDescriptor { + const { toApiParams, fromApiParams, defaultParameters } = config; + if (!toApiParams || !fromApiParams || defaultParameters === undefined) { + throw new Error( + `describeToolOperation: "${endpoint}" is missing mappers or defaults`, + ); + } + return { + endpoint, + defaultParameters, + // A dynamic tool's mapper is typed against the union; narrow to this endpoint (sound - the + // runtime mapper produces this endpoint's model). + toApi: (params) => toApiParams(params) as ToolApiParams[E], + fromApi: (api) => + ({ + ...defaultParameters, + ...fromApiParams(api as ToolApiParams[CE]), + }) as TParams, + }; +} diff --git a/frontend/editor/src/core/hooks/tools/validateSignature/outputtedPDFSections/SignatureSection.ts b/frontend/editor/src/core/hooks/tools/validateSignature/outputtedPDFSections/SignatureSection.ts index 285dc825a7..1640067e30 100644 --- a/frontend/editor/src/core/hooks/tools/validateSignature/outputtedPDFSections/SignatureSection.ts +++ b/frontend/editor/src/core/hooks/tools/validateSignature/outputtedPDFSections/SignatureSection.ts @@ -3,12 +3,12 @@ import { PdfiumFont, PdfiumPage } from "@app/services/pdfiumDocBuilder"; import { SignatureValidationSignature } from "@app/types/validateSignature"; import { drawFieldBox } from "@app/hooks/tools/validateSignature/outputtedPDFSections/FieldBoxSection"; import { drawStatusBadge } from "@app/hooks/tools/validateSignature/outputtedPDFSections/StatusBadgeSection"; -import { - computeSignatureStatus, - statusKindToPdfColor, -} from "@app/hooks/tools/validateSignature/utils/signatureStatus"; +import { computeSignatureStatus } from "@app/hooks/tools/validateSignature/utils/signatureStatus"; import { formatDate } from "@app/hooks/tools/validateSignature/utils/pdfText"; -import { colorPalette } from "@app/hooks/tools/validateSignature/utils/pdfPalette"; +import { + colorPalette, + statusKindToPdfColor, +} from "@app/hooks/tools/validateSignature/utils/pdfPalette"; interface DrawSignatureSectionOptions { page: PdfiumPage; diff --git a/frontend/editor/src/core/hooks/tools/validateSignature/utils/pdfPalette.ts b/frontend/editor/src/core/hooks/tools/validateSignature/utils/pdfPalette.ts index 1f321535d7..ba2ae1809b 100644 --- a/frontend/editor/src/core/hooks/tools/validateSignature/utils/pdfPalette.ts +++ b/frontend/editor/src/core/hooks/tools/validateSignature/utils/pdfPalette.ts @@ -1,4 +1,5 @@ import { rgb } from "@app/services/pdfiumDocBuilder"; +import type { SignatureStatusKind } from "@app/hooks/tools/validateSignature/utils/signatureStatus"; type RgbTuple = [number, number, number]; @@ -93,3 +94,16 @@ export const colorPalette = { defaultLightPalette.neutral, ), }; + +export const statusKindToPdfColor = (kind: SignatureStatusKind) => { + switch (kind) { + case "valid": + return colorPalette.success; + case "warning": + return colorPalette.warning; + case "invalid": + return colorPalette.danger; + default: + return colorPalette.neutral; + } +}; diff --git a/frontend/editor/src/core/hooks/tools/validateSignature/utils/signatureStatus.ts b/frontend/editor/src/core/hooks/tools/validateSignature/utils/signatureStatus.ts index 4f46641d4c..2355dd6638 100644 --- a/frontend/editor/src/core/hooks/tools/validateSignature/utils/signatureStatus.ts +++ b/frontend/editor/src/core/hooks/tools/validateSignature/utils/signatureStatus.ts @@ -1,6 +1,5 @@ import type { TFunction } from "i18next"; import type { SignatureValidationSignature } from "@app/types/validateSignature"; -import { colorPalette } from "@app/hooks/tools/validateSignature/utils/pdfPalette"; export type SignatureStatusKind = "valid" | "warning" | "invalid" | "neutral"; @@ -131,16 +130,3 @@ export const computeSignatureStatus = ( details: issues, }; }; - -export const statusKindToPdfColor = (kind: SignatureStatusKind) => { - switch (kind) { - case "valid": - return colorPalette.success; - case "warning": - return colorPalette.warning; - case "invalid": - return colorPalette.danger; - default: - return colorPalette.neutral; - } -}; diff --git a/frontend/editor/src/core/hooks/useClassificationEnabled.ts b/frontend/editor/src/core/hooks/useClassificationEnabled.ts index e5a2b48ff6..441b81cb45 100644 --- a/frontend/editor/src/core/hooks/useClassificationEnabled.ts +++ b/frontend/editor/src/core/hooks/useClassificationEnabled.ts @@ -1,10 +1,5 @@ -// Whether document classification (and everything it drives in the UI: the -// Files-sidebar category grouping, per-file label chips, and the file-details -// Classification section) is active in this build. Classification is a -// SaaS-only feature gated on the AI engine, so core — and every build that -// doesn't override this seam (proprietary, desktop, cloud) — returns false, and -// none of that UI ever renders. The saas layer overrides it to track the AI -// engine's enabled flag, so the feature shows up only on SaaS when AI is on. +// Whether classification (sidebar grouping, label chips, file-details section) +// is active in this build. Core has no classifier; proprietary overrides to true. export function useClassificationEnabled(): boolean { return false; diff --git a/frontend/editor/src/core/i18n/translationAudit.ts b/frontend/editor/src/core/i18n/translationAudit.ts index 79fee63a7c..9e4e57a8a5 100644 --- a/frontend/editor/src/core/i18n/translationAudit.ts +++ b/frontend/editor/src/core/i18n/translationAudit.ts @@ -89,6 +89,10 @@ export const I18N_PROJECTS: TranslationProject[] = [ // components/sources/sourceTypes.ts (t(field.labelKey)), invisible to the // static scan. /^portal\.sources\.types\./, + // The connection catalogue (connectionTypes.ts) mirrors source types: every key is + // t(`${PREFIX}.${id}.label`) / t(`${COMMON}.${field}.label`) with multi-segment const + // prefixes, so the whole family is matched here rather than by the shape heuristic. + /^portal\.connections\.(types|commonFields)\./, // Portal catalogue copy stored as i18n keys in api/.ts constants // (label maps, role/policy/journey catalogues) and rendered via // t(constant), invisible to the static scan. @@ -99,6 +103,11 @@ export const I18N_PROJECTS: TranslationProject[] = [ /^portal\.procurement\.journeySteps\./, /^portal\.users\.roles\./, /^portal\.policies\.(categories|config|endpoints)\./, + // The integration operations catalogue (stepOperations.ts) assembles every key as + // t(`${PREFIX}.${id}.label`) where PREFIX is the multi-segment const + // "portal.policies.operations" - the shape heuristic treats that interpolation as one + // segment, so this whole catalogue-driven family is matched here instead. + /^portal\.policies\.operations\./, // Policy field labels + option display copy are looked up with keys // derived from catalogue data (t(`policies.field.${key}`), // t(`policyOption.${id}`)) in the PolicyFieldRows and setup wizards — diff --git a/frontend/editor/src/core/pages/HomePage.css b/frontend/editor/src/core/pages/HomePage.css index cfd3bded5c..7af1e89404 100644 --- a/frontend/editor/src/core/pages/HomePage.css +++ b/frontend/editor/src/core/pages/HomePage.css @@ -3,13 +3,13 @@ flex-direction: column; height: 100%; width: 100%; - background-color: var(--bg-background); + background-color: var(--c-bg); } .mobile-toggle { padding: 0.5rem 0.75rem; - border-bottom: 1px solid var(--border-subtle); - background: var(--bg-toolbar); + border-bottom: 1px solid var(--c-border-subtle); + background: var(--c-bg-raised); display: flex; flex-direction: column; gap: 0.35rem; @@ -47,8 +47,8 @@ gap: 0.25rem; padding: 0.2rem; border-radius: 9999px; - background: var(--bg-background); - border: 1px solid var(--border-subtle); + background: var(--c-bg); + border: 1px solid var(--c-border-subtle); } .mobile-toggle-button { @@ -57,7 +57,7 @@ padding: 0.4rem 0.9rem; font-size: 0.8125rem; font-weight: 600; - color: var(--text-muted); + color: var(--c-text-subtle); background: transparent; transition: background 0.2s ease, @@ -65,18 +65,18 @@ } .mobile-toggle-button:focus-visible { - outline: 2px solid var(--primary-color, #228be6); + outline: 2px solid var(--c-primary); outline-offset: 2px; } .mobile-toggle-button.active { - background: var(--primary-surface, rgba(34, 139, 230, 0.12)); - color: var(--text-primary); + background: var(--c-primary-subtle); + color: var(--c-text); } .mobile-toggle-hint { font-size: 0.7rem; - color: var(--text-muted); + color: var(--c-text-subtle); text-align: center; } @@ -134,8 +134,8 @@ align-items: center; justify-content: space-around; padding: 0.5rem; - border-top: 1px solid var(--border-subtle); - background: var(--bg-toolbar); + border-top: 1px solid var(--c-border-subtle); + background: var(--c-bg-raised); gap: 0.5rem; position: relative; z-index: 10; @@ -152,7 +152,7 @@ padding: 0.5rem; border: none; background: transparent; - color: var(--text-primary); + color: var(--c-text); cursor: pointer; border-radius: 0.5rem; transition: background 0.2s ease; @@ -165,16 +165,16 @@ @media (hover: hover) and (pointer: fine) { .mobile-bottom-button:hover { - background: var(--bg-hover, rgba(0, 0, 0, 0.05)); + background: var(--c-hover); } } .mobile-bottom-button:active { - background: var(--bg-active, rgba(0, 0, 0, 0.1)); + background: var(--c-active); } .mobile-bottom-button-label { font-size: 0.75rem; font-weight: 500; - color: var(--text-muted); + color: var(--c-text-subtle); } diff --git a/frontend/editor/src/core/pages/MobileScannerPage.tsx b/frontend/editor/src/core/pages/MobileScannerPage.tsx index cf4e3e3564..bce84cf371 100644 --- a/frontend/editor/src/core/pages/MobileScannerPage.tsx +++ b/frontend/editor/src/core/pages/MobileScannerPage.tsx @@ -995,7 +995,7 @@ export default function MobileScannerPage() { @@ -1025,7 +1025,7 @@ export default function MobileScannerPage() { background: loadingStatus.includes("✗") ? "var(--mantine-color-red-1)" : "var(--mantine-color-blue-1)", - borderBottom: "1px solid var(--border-subtle)", + borderBottom: "1px solid var(--c-border-subtle)", fontSize: "0.85rem", fontFamily: "monospace", textAlign: "center", @@ -1231,8 +1231,8 @@ export default function MobileScannerPage() { {/* Controls bar - fixed at bottom */} @@ -1372,8 +1372,8 @@ export default function MobileScannerPage() { {/* Controls bar - fixed at bottom */} @@ -1401,7 +1401,7 @@ export default function MobileScannerPage() { )} {capturedImages.length > 0 && ( - + {t("mobileScanner.batchImages", "Batch")} ({capturedImages.length} @@ -1442,7 +1442,7 @@ export default function MobileScannerPage() { height: "80px", borderRadius: "var(--radius-sm)", overflow: "hidden", - border: "2px solid var(--border-subtle)", + border: "2px solid var(--c-border-subtle)", }} > unknown; + [key: string]: unknown; +}; + +function headerValueToString(value: unknown): string { + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + if (Array.isArray(value)) { + const firstString = value.find( + (entry): entry is string => typeof entry === "string", + ); + return firstString ?? ""; + } + return ""; +} + +function toHeaderKey(name: string): string { + return name.replace( + /(^|-)([a-z])/g, + (_, prefix: string, char: string) => prefix + char.toUpperCase(), + ); +} + +export function readResponseHeader(headers: unknown, name: string): string { + if (!headers || typeof headers !== "object") { + return ""; + } + + const typedHeaders = headers as HeaderLike; + if (typeof typedHeaders.get === "function") { + return headerValueToString(typedHeaders.get(name)); + } + + return ( + headerValueToString(typedHeaders[name]) || + headerValueToString(typedHeaders[toHeaderKey(name)]) + ); +} + export function parseContentDispositionFilename( disposition?: string, ): string | null { diff --git a/frontend/editor/src/core/services/signatureStorageService.ts b/frontend/editor/src/core/services/signatureStorageService.ts index 018d39cfa7..1cc0ac9360 100644 --- a/frontend/editor/src/core/services/signatureStorageService.ts +++ b/frontend/editor/src/core/services/signatureStorageService.ts @@ -1,5 +1,6 @@ import apiClient from "@app/services/apiClient"; import type { SavedSignature } from "@app/types/signature"; +import { readResponseHeader } from "@app/services/shareBundleUtils"; export type StorageType = "backend" | "localStorage"; @@ -166,7 +167,9 @@ class SignatureStorageService { // Convert to data URL (base64) for both display and use const blob = new Blob([imageResponse.data], { - type: imageResponse.headers["content-type"] || "image/png", + type: + readResponseHeader(imageResponse.headers, "content-type") || + "image/png", }); const dataUrl = await new Promise((resolve, reject) => { diff --git a/frontend/editor/src/core/styles/cookieconsent.css b/frontend/editor/src/core/styles/cookieconsent.css index 4c226e6d0d..4e6374918e 100644 --- a/frontend/editor/src/core/styles/cookieconsent.css +++ b/frontend/editor/src/core/styles/cookieconsent.css @@ -1,10 +1,7 @@ /* Cookie Consent Modal Styling - Ensure proper z-index */ /* Ensure cookie consent appears above everything */ -#cc-main { - z-index: 999999 !important; -} - +#cc-main, /* Additional styling if needed */ .cm-wrapper, .pm-wrapper { @@ -15,26 +12,26 @@ * matches the rest of the UI (navy-indigo surfaces) instead of a flat grey. */ .cc--darkmode .cm, .cc--darkmode .pm { - background: var(--bg-surface) !important; - color: var(--text-primary) !important; - border: 1px solid var(--border-subtle) !important; + background: var(--c-surface) !important; + color: var(--c-text) !important; + border: 1px solid var(--c-border-subtle) !important; } .cc--darkmode .pm-overlay { - background: rgba(0, 0, 0, 0.6) !important; + background: var(--c-overlay) !important; } /* Buttons — match the shared neutral surface treatment. */ .cc--darkmode .cm__btn, .cc--darkmode .pm__btn { - background: var(--bg-muted) !important; - color: var(--text-primary) !important; - border: 1px solid var(--border-default) !important; + background: var(--c-surface-sunken) !important; + color: var(--c-text) !important; + border: 1px solid var(--c-border) !important; } .cc--darkmode .cm__btn:hover, .cc--darkmode .pm__btn:hover { - background: var(--hover-bg) !important; + background: var(--c-hover) !important; } /* Ensure ScrollArea doesn't interfere */ @@ -44,5 +41,5 @@ /* Override any potential conflicts */ [data-mantine-color-scheme="dark"] #cc-main { - color: #ffffff !important; + color: var(--c-text) !important; } diff --git a/frontend/editor/src/core/styles/index.css b/frontend/editor/src/core/styles/index.css index 66a98cc8c8..01a5279f31 100644 --- a/frontend/editor/src/core/styles/index.css +++ b/frontend/editor/src/core/styles/index.css @@ -79,5 +79,9 @@ code { } .viewer-file-tab[data-active] { - background-color: rgba(147, 197, 253, 0.5); + background-color: color-mix( + in srgb, + var(--c-tab-active-tint) 50%, + transparent + ); } diff --git a/frontend/editor/src/core/styles/theme.css b/frontend/editor/src/core/styles/theme.css index c02965cf0b..c8a1663b4c 100644 --- a/frontend/editor/src/core/styles/theme.css +++ b/frontend/editor/src/core/styles/theme.css @@ -1,37 +1,53 @@ :root { /* Compare highlight colors (same in light/dark) */ - --spdf-compare-removed-bg: rgba(255, 107, 107, 0.45); /* #ff6b6b @ 0.45 */ - --spdf-compare-added-bg: rgba(81, 207, 102, 0.35); /* #51cf66 @ 0.35 */ + --spdf-compare-removed-bg: color-mix( + in srgb, + var(--p-red-400) 45%, + transparent + ); /* #ff6b6b @ 0.45 */ + --spdf-compare-added-bg: color-mix( + in srgb, + var(--p-green-500) 35%, + transparent + ); /* #51cf66 @ 0.35 */ /* Badge colors for dropdowns */ - --spdf-compare-removed-badge-bg: rgba(255, 59, 48, 0.15); - --spdf-compare-removed-badge-fg: #b91c1c; - --spdf-compare-added-badge-bg: rgba(52, 199, 89, 0.18); - --spdf-compare-added-badge-fg: #1b5e20; + --spdf-compare-removed-badge-bg: color-mix( + in srgb, + var(--p-red-500) 15%, + transparent + ); + --spdf-compare-removed-badge-fg: var(--p-red-600); + --spdf-compare-added-badge-bg: color-mix( + in srgb, + var(--p-green-500) 18%, + transparent + ); + --spdf-compare-added-badge-fg: var(--p-green-700); /* Inline highlights in summary */ - --spdf-compare-inline-removed-bg: rgba(255, 59, 48, 0.25); - --spdf-compare-inline-added-bg: rgba(52, 199, 89, 0.25); + --spdf-compare-inline-removed-bg: color-mix( + in srgb, + var(--p-red-500) 25%, + transparent + ); + --spdf-compare-inline-added-bg: color-mix( + in srgb, + var(--p-green-500) 25%, + transparent + ); /* File Manager active file colors */ - --file-active-bg: rgba( - 59, - 130, - 246, - 0.1 - ); /* Transparent blue for active file background */ - --file-active-badge-bg: rgba( - 34, - 197, - 94, - 0.15 + --file-active-badge-bg: color-mix( + in srgb, + var(--p-green-500) 15%, + transparent ); /* Transparent green for active badge */ - --file-active-badge-fg: #15803d; /* Green text for active badge */ - --file-active-badge-border: rgba( - 34, - 197, - 94, - 0.3 + --file-active-badge-fg: var(--p-green-700); /* Green text for active badge */ + --file-active-badge-border: color-mix( + in srgb, + var(--p-green-500) 30%, + transparent ); /* Green border for active badge */ } @@ -42,13 +58,6 @@ --fullscreen-anim-duration-in: 0.28s; --fullscreen-anim-duration-out: 0.22s; - /* Z-index constants (added in this PR) */ - --z-over-fullscreen-surface: 1400; - --z-fullscreen-surface: 1200; - --z-fullscreen-favorite-star: 2; - --z-fullscreen-icon-svg: 1; - --z-toolpicker-star: 1; - /* Standard gray scale */ --gray-50: 249 250 251; --gray-100: 243 244 246; @@ -67,108 +76,97 @@ --border: 229 231 235; /* Colors for Mantine integration */ - --color-primary-50: #eff6ff; - --color-primary-100: #dbeafe; - --color-primary-200: #bfdbfe; - --color-primary-300: #93c5fd; - --color-primary-400: #60a5fa; - --color-primary-500: #3b82f6; - --color-primary-600: #2563eb; - --color-primary-700: #1d4ed8; - --color-primary-800: #1e40af; - --color-primary-900: #1e3a8a; + --color-primary-50: var(--p-blue-400); + --color-primary-100: var(--p-blue-400); + --color-primary-200: var(--p-blue-400); + --color-primary-300: var(--p-blue-400); + --color-primary-400: var(--p-blue-400); + --color-primary-500: var(--p-blue-500); + --color-primary-600: var(--p-blue-600); + --color-primary-700: var(--p-blue-700); + --color-primary-800: var(--p-blue-700); + --color-primary-900: var(--p-blue-700); /* Success (green) */ - --color-green-50: #f0fdf4; - --color-green-100: #dcfce7; - --color-green-200: #bbf7d0; - --color-green-300: #86efac; - --color-green-400: #4ade80; - --color-green-500: #22c55e; - --color-green-600: #16a34a; - --color-green-700: #15803d; - --color-green-800: #166534; - --color-green-900: #14532d; + --color-green-50: var(--p-green-500); + --color-green-100: var(--p-green-500); + --color-green-200: var(--p-green-500); + --color-green-300: var(--p-green-500); + --color-green-400: var(--p-green-500); + --color-green-500: var(--p-green-500); + --color-green-600: var(--p-green-600); + --color-green-700: var(--p-green-700); + --color-green-800: var(--p-green-700); + --color-green-900: var(--p-green-700); /* Warning (yellow) */ - --color-yellow-50: #fefce8; - --color-yellow-100: #fef9c3; - --color-yellow-200: #fef08a; - --color-yellow-300: #fde047; - --color-yellow-400: #facc15; + --color-yellow-50: var(--p-amber-400); + --color-yellow-100: var(--p-amber-400); + --color-yellow-200: var(--p-amber-400); + --color-yellow-300: var(--p-amber-400); + --color-yellow-400: var(--p-amber-400); /* Category colors - consistent across light and dark modes */ - --category-color-removal: #ef4444; /* Red for removal tools */ - --category-color-security: #f59e0b; /* Orange for security tools */ - --category-color-formatting: #8b5cf6; /* Purple for formatting tools */ - --category-color-extraction: #06b6d4; /* Cyan for extraction tools */ - --category-color-signing: #10b981; /* Green for signing tools */ - --category-color-general: #3b82f6; /* Blue for general tools */ - --category-color-verification: #f97316; /* Orange for verification tools */ - --category-color-automation: #ec4899; /* Pink for automation tools */ - --category-color-developer: #6b7280; /* Gray for developer tools */ - --category-color-default: #6b7280; /* Default gray */ + --category-color-removal: var(--p-red-500); /* Red for removal tools */ + --category-color-security: var(--p-amber-500); /* Orange for security tools */ + --category-color-formatting: var( + --p-blue-400 + ); /* Purple for formatting tools */ + --category-color-extraction: var( + --p-blue-500 + ); /* Cyan for extraction tools */ + --category-color-signing: var(--p-green-500); /* Green for signing tools */ + --category-color-general: var(--p-blue-500); /* Blue for general tools */ + --category-color-verification: var( + --p-amber-600 + ); /* Orange for verification tools */ + --category-color-automation: var(--p-red-400); /* Pink for automation tools */ + --category-color-developer: var(--p-gray-500); /* Gray for developer tools */ + --category-color-default: var(--p-gray-500); /* Default gray */ /* Special section colors - consistent across light and dark modes */ - --special-color-favorites: #ffc107; /* Yellow/gold for favorites */ - --special-color-recommended: #1bb1d4; /* Cyan for recommended */ - --color-yellow-500: #eab308; - --color-yellow-600: #ca8a04; - --color-yellow-700: #a16207; - --color-yellow-800: #854d0e; - --color-yellow-900: #713f12; + --special-color-favorites: var(--p-amber-400); /* Yellow/gold for favorites */ + --special-color-recommended: var(--p-blue-500); /* Cyan for recommended */ + --color-yellow-500: var(--p-amber-500); + --color-yellow-600: var(--p-amber-600); + --color-yellow-700: var(--p-amber-600); + --color-yellow-800: var(--p-amber-600); + --color-yellow-900: var(--p-amber-600); - --color-red-50: #fef2f2; - --color-red-100: #fee2e2; - --color-red-200: #fecaca; - --color-red-300: #fca5a5; - --color-red-400: #f87171; - --color-red-500: #ef4444; - --color-red-600: #dc2626; - --color-red-700: #b91c1c; - --color-red-800: #991b1b; - --color-red-900: #7f1d1d; + --color-red-50: var(--p-red-400); + --color-red-100: var(--p-red-400); + --color-red-200: var(--p-red-400); + --color-red-300: var(--p-red-400); + --color-red-400: var(--p-red-400); + --color-red-500: var(--p-red-500); + --color-red-600: var(--p-red-600); + --color-red-700: var(--p-red-600); + --color-red-800: var(--p-red-600); + --color-red-900: var(--p-red-600); - --color-gray-50: #f9fafb; - --color-gray-100: #f3f4f6; - --color-gray-200: #e5e7eb; - --color-gray-300: #d1d5db; - --color-gray-400: #9ca3af; - --color-gray-500: #6b7280; - --color-gray-600: #4b5563; - --color-gray-700: #374151; - --color-gray-800: #1f2937; - --color-gray-900: #111827; + --color-gray-50: var(--p-gray-50); + --color-gray-100: var(--p-gray-100); + --color-gray-200: var(--p-gray-200); + --color-gray-300: var(--p-gray-300); + --color-gray-400: var(--p-gray-400); + --color-gray-500: var(--p-gray-500); + --color-gray-600: var(--p-gray-600); + --color-gray-700: var(--p-gray-700); + --color-gray-800: var(--p-gray-800); + --color-gray-900: var(--p-gray-900); /* Categorical accents (sidebar groups, file-type icons, badges). One hue per token; the dark block overrides each with a LIGHTER shade for contrast on dark backgrounds. JS consumers go through utils/accentColors.ts. */ - --accent-green: #16a34a; - --accent-blue: #2563eb; - --accent-red: #dc2626; - --accent-orange: #ea580c; - --accent-purple: #9333ea; - --accent-teal: #0d9488; - --accent-pink: #db2777; - --accent-amber: #d97706; - --accent-indigo: #4f46e5; - --accent-cyan: #0891b2; - --accent-violet: #7c3aed; - --accent-gray: #6b7280; - - /* Spacing system */ - --space-xs: 4px; - --space-sm: 8px; - --space-md: 16px; - --space-lg: 24px; - --space-xl: 32px; - - /* Radius system */ - --radius-xs: 2px; - --radius-sm: 4px; - --radius-md: 8px; - --radius-lg: 12px; - --radius-xl: 16px; + --accent-orange: var(--p-amber-600); + --accent-purple: var(--p-blue-500); + --accent-teal: var(--p-blue-700); + --accent-pink: var(--p-red-500); + --accent-amber: var(--p-amber-600); + --accent-indigo: var(--p-blue-600); + --accent-cyan: var(--p-blue-600); + --accent-violet: var(--p-blue-600); + --accent-gray: var(--p-gray-500); /* Shadow system */ --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.05); @@ -177,201 +175,81 @@ --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1); --shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.1); - /* Font weights */ - --font-weight-normal: 400; - --font-weight-medium: 500; - --font-weight-semibold: 600; - --font-weight-bold: 700; - - /* Light theme semantic colors */ - --bg-surface: #ffffff; - --bg-raised: #f9fafb; - --bg-muted: #f3f4f6; - --bg-background: #f9fafb; - --bg-toolbar: #ffffff; - --bg-file-manager: #f5f6f8; - --bg-file-list: #ffffff; - --btn-open-file: #0a8bff; - --text-primary: #111827; - --text-secondary: #4b5563; - --text-muted: #6b7280; /* Always-dark text (for use on light backgrounds like alerts) - does not change in dark mode */ - --text-always-dark: #1f2937; - --text-always-dark-muted: #6b7280; - --border-subtle: #e5e7eb; - --border-default: #e2e8f0; - --border-strong: #9ca3af; - --border-hover: #9ca3af; - --hover-bg: #f9fafb; - --active-bg: #f3f4f6; - --automation-entry-hover-bg: var(--color-gray-100); + --text-always-dark: var(--p-gray-800); + --text-always-dark-muted: var(--p-gray-500); /* Icon colors for light mode */ - --icon-user-bg: #9ca3af; - --icon-user-color: #ffffff; - --icon-notifications-bg: #9ca3af; - --icon-notifications-color: #ffffff; - --icon-tools-bg: #1e88e5; + --icon-tools-bg: var(--p-blue-500); --icon-tools-color: #ffffff; - --icon-read-bg: #4caf50; - --icon-read-color: #ffffff; - --icon-sign-bg: #3ba99c; - --icon-sign-color: #ffffff; - --icon-automate-bg: #a576e3; - --icon-automate-color: #ffffff; - --icon-files-bg: #d3e7f7; - --icon-files-color: #0a8bff; - --icon-activity-bg: #d3e7f7; - --icon-activity-color: #0a8bff; - --icon-config-bg: #9ca3af; - --icon-config-color: #ffffff; + --icon-files-color: var(--p-blue-500); /* Colors for tooltips */ - --tooltip-title-bg: #dbefff; - --tooltip-title-color: #31528e; - --tooltip-header-bg: #31528e; + --tooltip-header-bg: var(--p-blue-700); --tooltip-header-color: white; - --tooltip-border: var(--border-default); + --tooltip-border: var(--c-border); /* Inactive icon colors for light mode */ - --icon-inactive-bg: #9ca3af; + --icon-inactive-bg: var(--p-gray-400); --icon-inactive-color: #ffffff; - /* New theme colors for text and icons */ - --tools-text-and-icon-color: var(--text-primary); - - /* Tool picker sticky header variables (light mode) */ - --tool-header-bg: #dbefff; - --tool-header-border: #bee2ff; - --tool-header-text: #1e88e5; - --tool-header-badge-bg: #c0ddff; - --tool-header-badge-text: #004e99; - - /* Subcategory title styling (light mode) */ - --tool-subcategory-text-color: #9ca3af; /* lighter text */ - --tool-subcategory-rule-color: #e5e7eb; /* doubly lighter rule line */ - --accent-interactive: #4a90e2; - --text-instruction: #4a90e2; - --text-brand: var(--color-gray-700); - --text-brand-accent: #dc2626; + /* doubly lighter rule line */ + --text-brand-accent: var(--p-red-600); /* PDF text selection colors */ - --pdf-selection-bg: rgba(59, 130, 246, 0.2); - --pdf-selection-ring: rgba(59, 130, 246, 0.18); - - /* Placeholder text colors */ - --search-text-and-icon-color: #6b7382; - --input-bg: #ffffff; - - /* Tool panel search bar background colors */ - --tool-panel-search-bg: #eff1f4; - --tool-panel-search-border-bottom: #eff1f4; + --pdf-selection-bg: color-mix(in srgb, var(--p-blue-500) 20%, transparent); + --pdf-selection-ring: color-mix(in srgb, var(--p-blue-500) 18%, transparent); /* container */ - --landing-paper-bg: var(--bg-surface); - --landing-inner-paper-bg: #eef8ff; - --landing-inner-paper-border: #cdeaff; - --landing-button-bg: var(--bg-surface); - --landing-button-color: var(--icon-tools-bg); - --landing-button-border: #e0f2f7; - --landing-button-hover-bg: rgb(251, 251, 251); + --landing-button-bg: var(--c-surface); + --landing-button-border: var(--p-blue-400); + --landing-button-hover-bg: var(--p-gray-50); /* drop state */ - --landing-drop-paper-bg: #e3f2fd; - --landing-drop-inner-paper-bg: #bbdefb; - --landing-drop-inner-paper-border: #90caf9; + --landing-drop-inner-paper-bg: var(--p-blue-400); + --landing-drop-inner-paper-border: var(--p-blue-400); /* landing hero & stack */ - --landing-hero-gradient: linear-gradient(135deg, #4c8bf5 0%, #3a7be8 100%); + --landing-hero-gradient: linear-gradient( + 135deg, + var(--p-blue-500) 0%, + var(--p-blue-500) 100% + ); --landing-stack-w: 224px; --landing-stack-h: 176px; - --landing-stack-glow-bg: radial-gradient( - circle, - rgba(74, 144, 226, 0.18) 0%, - transparent 70% - ); /* landing doc stack shadows */ --landing-doc-shadow-back-idle: 0 4px 20px rgba(0, 0, 0, 0.08), 0 1px 3px rgba(0, 0, 0, 0.04); - --landing-doc-shadow-back-hover: - 0 12px 40px rgba(0, 0, 0, 0.15), 0 4px 12px rgba(0, 0, 0, 0.08); --landing-doc-shadow-front-idle: 0 8px 30px rgba(0, 0, 0, 0.12), 0 4px 12px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(0, 0, 0, 0.02); - --landing-doc-shadow-front-hover: - 0 18px 48px rgba(0, 0, 0, 0.18), 0 8px 20px rgba(0, 0, 0, 0.1), - 0 0 0 1px rgba(0, 0, 0, 0.04); - - /* landing action button shadows */ - --landing-action-transition: - transform 0.28s cubic-bezier(0.4, 0, 0.2, 1), - box-shadow 0.32s cubic-bezier(0.4, 0, 0.2, 1); - --landing-action-shadow-idle: - 0 6px 18px rgba(0, 0, 0, 0), 0 2px 6px rgba(0, 0, 0, 0); - --landing-action-shadow-hover: - 0 6px 18px rgba(0, 0, 0, 0.14), 0 2px 6px rgba(0, 0, 0, 0.08); - --landing-action-primary-shadow-idle: - 0 10px 26px rgba(58, 123, 232, 0), 0 4px 12px rgba(0, 0, 0, 0); - --landing-action-primary-shadow-hover: - 0 10px 26px rgba(58, 123, 232, 0.42), 0 4px 12px rgba(0, 0, 0, 0.1); /* selected file header colors */ - --header-selected-bg: #1e88e5; /* light mode selected header matches dark */ + --header-selected-bg: var( + --p-blue-500 + ); /* light mode selected header matches dark */ --header-selected-fg: #ffffff; - --file-card-bg: #ffffff; /* file card background (light/dark paired) */ - --accordion-item-bg: #e8eaed; /* accordion item background - more distinguishable */ - /* shadows */ - --drop-shadow-color: rgba(0, 0, 0, 0.08); - --drop-shadow-color-strong: rgba(0, 0, 0, 0.04); - --drop-shadow-filter: drop-shadow(0 0.2rem 0.4rem rgba(0, 0, 0, 0.08)) - drop-shadow(0 0.6rem 0.6rem rgba(0, 0, 0, 0.06)) - drop-shadow(0 1.2rem 1rem rgba(0, 0, 0, 0.04)); + /* slightly more blue than dark mode header */ - /* Light mode card hover and selection */ - --header-hover-bg: #3b4b6e; /* same family as selected, a touch muted for hover */ - --card-selected-border: #3fafff; /* slightly more blue than dark mode header */ - --checkbox-border: #2f83bf; - --checkbox-checked-bg: #3fafff; - --checkbox-tick: #ffffff; - - --information-text-bg: #eaeaea; - --information-text-color: #5e5e5e; - /* Bulk selection panel specific colors (light mode) */ - --bulk-panel-bg: #ffffff; /* white background for parent container */ - --bulk-card-bg: #ffffff; /* white background for cards */ - --bulk-card-border: #e5e7eb; /* light gray border for cards and buttons */ - --bulk-card-hover-border: #d1d5db; /* slightly darker on hover */ - --unsupported-bar-bg: #5a616e; - --unsupported-bar-border: #6b7280; + --information-text-color: var(--p-zinc-400); + /* white background for cards */ + --bulk-card-border: var( + --p-gray-200 + ); /* light gray border for cards and buttons */ + --bulk-card-hover-border: var(--p-gray-300); /* slightly darker on hover */ /* Config Modal colors (light mode) */ - --modal-nav-bg: #f5f6f8; - --modal-nav-section-title: #6b7280; - --modal-nav-item: #374151; - --modal-nav-item-active: #0a8bff; - --modal-nav-item-active-bg: rgba(10, 139, 255, 0.08); - --modal-content-bg: #ffffff; - --modal-header-border: rgba(0, 0, 0, 0.06); + --modal-nav-item: var(--p-gray-700); /* API Keys section colors (light mode) */ - --api-keys-card-bg: #ffffff; - --api-keys-card-border: #e0e0e0; --api-keys-card-shadow: rgba(0, 0, 0, 0.06); - --api-keys-input-bg: #f8f8f8; - --api-keys-input-border: #e0e0e0; /* PDF Report Colors (always light) */ - --pdf-light-header-bg: 239 246 255; - --pdf-light-accent: 59 130 246; - --pdf-light-text-primary: 30 41 59; --pdf-light-text-muted: 100 116 139; --pdf-light-box-bg: 248 250 252; --pdf-light-box-border: 226 232 240; - --pdf-light-warning: 234 179 8; - --pdf-light-danger: 248 113 113; - --pdf-light-success: 34 197 94; --pdf-light-neutral: 148 163 184; --pdf-light-status-valid-bg: 209 250 229; --pdf-light-status-valid-text: 6 95 70; @@ -381,32 +259,20 @@ --pdf-light-status-invalid-text: 153 27 27; --pdf-light-status-neutral-bg: 229 231 235; --pdf-light-status-neutral-text: 55 65 81; - --pdf-light-report-container-bg: 249 250 251; - --pdf-light-simulated-page-bg: 255 255 255; --pdf-light-simulated-page-text: 15 23 42; /* Code token colors (light mode) */ - --code-kw-color: #1d4ed8; /* blue-700 */ - --code-str-color: #16a34a; /* green-600 */ - --code-num-color: #4338ca; /* indigo-700 */ - --code-com-color: #6b7280; /* gray-500 */ - /* Compare tool specific colors - only for colors that don't have existing theme pairs */ - --compare-upload-dropzone-bg: rgba(241, 245, 249, 0.45); - --compare-upload-dropzone-border: rgba(148, 163, 184, 0.6); - --compare-upload-icon-bg: rgba(148, 163, 184, 0.2); - --compare-upload-icon-color: rgba(17, 24, 39, 0.75); - --compare-upload-divider: rgba(148, 163, 184, 0.5); + --code-kw-color: var(--p-blue-700); /* blue-700 */ + --code-str-color: var(--p-green-600); /* green-600 */ + --code-num-color: var(--p-blue-700); /* indigo-700 */ + --code-com-color: var(--p-gray-500); /* gray-500 */ /* Compare page label chip (light mode): slightly lighter than surrounding rows */ - --compare-page-label-bg: var(--bg-muted); - --compare-page-label-fg: var(--text-secondary); + --compare-page-label-fg: var(--c-text-muted); /* Status indicator dot ring shadow */ --status-dot-ring: 0 0 0 2px rgba(0, 0, 0, 0.08); - /* Input element styling */ - --input-bg: #ffffff; - /* Select/dropdown placeholder state */ --select-placeholder-bg: var(--mantine-color-gray-1); --select-placeholder-text: var(--mantine-color-gray-6); @@ -414,7 +280,6 @@ /* Grouped format dropdown */ --dropdown-trigger-bg: var(--mantine-color-white); --dropdown-trigger-text: var(--mantine-color-dark-9); - --dropdown-trigger-text-disabled: var(--mantine-color-dark-7); --dropdown-trigger-icon: var(--mantine-color-gray-6); --dropdown-panel-bg: var(--mantine-color-white); --dropdown-panel-border: var(--mantine-color-gray-4); @@ -423,211 +288,146 @@ /* Onboarding (light mode) */ :root { - --onboarding-title: #0a0a0a; - --onboarding-body: #4a5565; - --onboarding-step-active: #1e2939; - --onboarding-step-inactive: #d1d5dc; + --onboarding-step-active: var(--p-gray-800); + --onboarding-step-inactive: var(--p-gray-300); } [data-mantine-color-scheme="dark"] { - /* Dark theme gray scale (inverted) — navy-indigo tinted */ - --gray-50: 9 11 24; - --gray-100: 13 16 32; - --gray-200: 19 23 41; - --gray-300: 28 35 64; - --gray-400: 45 55 96; - --gray-500: 90 98 128; - --gray-600: 144 153 185; - --gray-700: 195 200 230; - --gray-800: 218 222 245; - --gray-900: 238 241 252; + /* Inverted dark gray scale — RGB channels for Tailwind's rgb(var(--gray-N)). */ + --gray-50: 15 15 16; /* zinc-950 */ + --gray-100: 16 16 18; /* zinc-900 */ + --gray-200: 24 24 27; /* zinc-800 */ + --gray-300: 42 42 46; /* zinc-650 */ + --gray-400: 63 63 70; /* zinc-500 */ + --gray-500: 82 82 91; /* zinc-400 */ + --gray-600: 113 113 122; /* zinc-300 */ + --gray-700: 161 161 170; /* zinc-200 */ + --gray-800: 212 212 216; + --gray-900: 244 244 245; /* zinc-100 */ - /* Dark semantic colors for Tailwind — aligned with portal tokens.css */ - --surface: 21 28 46; /* #151c2e — portal --color-surface */ - --background: 9 12 20; /* #090c14 — portal --color-bg */ - --border: 40 50 72; /* #283248 — portal --color-border */ + /* Dark semantic channels for Tailwind. */ + --surface: 24 24 27; /* zinc-800 */ + --background: 15 15 16; /* zinc-950 */ + --border: 42 42 46; /* zinc-650 */ /* Dark theme Mantine colors */ - --color-red-50: #2d1b1b; - --color-red-100: #3a2323; - --color-red-200: #4a2d2d; - --color-red-300: #5c3535; - --color-red-400: #7c4a4a; - --color-red-500: #ef4444; - --color-red-600: #dc2626; - --color-red-700: #b91c1c; - --color-red-800: #991b1b; - --color-red-900: #7f1d1d; + --color-red-50: var(--p-zinc-775); + --color-red-100: var(--p-zinc-700); + --color-red-200: var(--p-zinc-600); + --color-red-300: var(--p-zinc-500); + --color-red-400: var(--p-zinc-400); + --color-red-500: var(--p-red-500); + --color-red-600: var(--p-red-600); + --color-red-700: var(--p-red-600); + --color-red-800: var(--p-red-600); + --color-red-900: var(--p-red-600); - --color-gray-50: #111827; - --color-gray-100: #1f2329; - --color-gray-200: #2a2f36; - --color-gray-300: #374151; - --color-gray-400: #4b5563; - --color-gray-500: #6b7280; - --color-gray-600: #9ca3af; - --color-gray-700: #d1d5db; - --color-gray-800: #e5e7eb; - --color-gray-900: #f3f4f6; + --color-gray-50: var(--p-gray-900); + --color-gray-100: var(--p-zinc-775); + --color-gray-200: var(--p-zinc-650); + --color-gray-300: var(--p-gray-700); + --color-gray-400: var(--p-gray-600); + --color-gray-500: var(--p-gray-500); + --color-gray-600: var(--p-gray-400); + --color-gray-700: var(--p-gray-300); + --color-gray-800: var(--p-gray-200); + --color-gray-900: var(--p-gray-100); /* Categorical accents: lighter shades of the same hues, for contrast on dark. */ - --accent-green: #4ade80; - --accent-blue: #60a5fa; - --accent-red: #f87171; - --accent-orange: #fb923c; - --accent-purple: #c084fc; - --accent-teal: #2dd4bf; - --accent-pink: #f472b6; - --accent-amber: #fbbf24; - --accent-indigo: #818cf8; - --accent-cyan: #22d3ee; - --accent-violet: #a78bfa; - --accent-gray: #9ca3af; + --accent-orange: var(--p-amber-500); + --accent-purple: var(--p-blue-400); + --accent-teal: var(--p-blue-400); + --accent-pink: var(--p-red-400); + --accent-amber: var(--p-amber-400); + --accent-indigo: var(--p-blue-400); + --accent-cyan: var(--p-blue-400); + --accent-violet: var(--p-blue-400); + --accent-gray: var(--p-gray-400); /* Category colors - same as light mode for consistency */ - --category-color-removal: #ef4444; /* Red for removal tools */ - --category-color-security: #f59e0b; /* Orange for security tools */ - --category-color-formatting: #8b5cf6; /* Purple for formatting tools */ - --category-color-extraction: #06b6d4; /* Cyan for extraction tools */ - --category-color-signing: #10b981; /* Green for signing tools */ - --category-color-general: #3b82f6; /* Blue for general tools */ - --category-color-verification: #f97316; /* Orange for verification tools */ - --category-color-automation: #ec4899; /* Pink for automation tools */ - --category-color-developer: #6b7280; /* Gray for developer tools */ - --category-color-default: #6b7280; /* Default gray */ + --category-color-removal: var(--p-red-500); /* Red for removal tools */ + --category-color-security: var(--p-amber-500); /* Orange for security tools */ + --category-color-formatting: var( + --p-blue-400 + ); /* Purple for formatting tools */ + --category-color-extraction: var( + --p-blue-500 + ); /* Cyan for extraction tools */ + --category-color-signing: var(--p-green-500); /* Green for signing tools */ + --category-color-general: var(--p-blue-500); /* Blue for general tools */ + --category-color-verification: var( + --p-amber-600 + ); /* Orange for verification tools */ + --category-color-automation: var(--p-red-400); /* Pink for automation tools */ + --category-color-developer: var(--p-gray-500); /* Gray for developer tools */ + --category-color-default: var(--p-gray-500); /* Default gray */ /* Special section colors - same as light mode for consistency */ - --special-color-favorites: #ffc107; /* Yellow/gold for favorites */ - --special-color-recommended: #1bb1d4; /* Cyan for recommended */ + --special-color-favorites: var(--p-amber-400); /* Yellow/gold for favorites */ + --special-color-recommended: var(--p-blue-500); /* Cyan for recommended */ /* Success (green) - dark */ - --color-green-50: #052e16; - --color-green-100: #064e3b; - --color-green-200: #065f46; - --color-green-300: #047857; - --color-green-400: #059669; - --color-green-500: #22c55e; - --color-green-600: #16a34a; - --color-green-700: #4ade80; - --color-green-800: #86efac; - --color-green-900: #bbf7d0; + --color-green-50: var(--p-green-700); + --color-green-100: var(--p-green-700); + --color-green-200: var(--p-green-700); + --color-green-300: var(--p-green-700); + --color-green-400: var(--p-green-600); + --color-green-500: var(--p-green-500); + --color-green-600: var(--p-green-600); + --color-green-700: var(--p-green-500); + --color-green-800: var(--p-green-500); + --color-green-900: var(--p-green-500); /* Warning (yellow) - dark */ - --color-yellow-50: #451a03; - --color-yellow-100: #713f12; - --color-yellow-200: #854d0e; - --color-yellow-300: #a16207; - --color-yellow-400: #ca8a04; - --color-yellow-500: #eab308; - --color-yellow-600: #facc15; - --color-yellow-700: #fde047; - --color-yellow-800: #fef08a; - --color-yellow-900: #fef9c3; + --color-yellow-50: var(--p-amber-600); + --color-yellow-100: var(--p-amber-600); + --color-yellow-200: var(--p-amber-600); + --color-yellow-300: var(--p-amber-600); + --color-yellow-400: var(--p-amber-600); + --color-yellow-500: var(--p-amber-500); + --color-yellow-600: var(--p-amber-400); + --color-yellow-700: var(--p-amber-400); + --color-yellow-800: var(--p-amber-400); + --color-yellow-900: var(--p-amber-400); - /* Dark theme semantic colors — deep navy/indigo palette, aligned with portal - tokens.css so the editor and portal (Processor) dark modes don't drift. - Chrome surfaces (--bg-toolbar, --bg-file-manager) sit at the portal's - sidebar level (#0d1120) so the left/right sidebars lift off the darker - canvas (--bg-background) instead of blending into it. */ - --bg-surface: #151c2e; /* portal --color-surface */ - --bg-raised: #0d1120; /* portal --color-bg-alt */ - --bg-muted: #0d1120; - --bg-background: #090c14; /* portal --color-bg */ - --bg-toolbar: #0d1120; /* portal --color-sidebar-bg — lifted off the canvas */ - --bg-file-manager: #0d1120; /* portal --color-sidebar-bg */ - --bg-file-list: #151c2e; /* portal --color-surface */ - --btn-open-file: #4f8ef5; - --text-primary: #e8eaf6; - --text-secondary: #9299b0; - --text-muted: #5b6280; + /* Dark theme semantic colours — the neutral zinc ramp. These legacy + `--bg-*`/`--text-*` names are shadowed by compat.css (→ --c-*), so this is + kept only for any consumer not yet on the semantic tokens. */ /* Always-dark text (for use on light backgrounds like alerts) - does not change in dark mode */ - --text-always-dark: #1f2937; - --text-always-dark-muted: #6b7280; - --border-subtle: rgba(255, 255, 255, 0.05); - --border-default: #1e2840; /* portal --color-border-light */ - --border-strong: #283248; /* portal --color-border */ - --border-hover: #3d4f6a; /* portal --color-border-hover */ - --hover-bg: #1c2640; /* portal --color-bg-hover */ - --active-bg: #243044; /* portal --color-bg-muted */ - --automation-entry-hover-bg: var(--color-gray-200); + --text-always-dark: var(--p-gray-800); + --text-always-dark-muted: var(--p-gray-500); /* Icon colors for dark mode */ - --icon-user-bg: #131729; - --icon-user-color: #6e7898; - --icon-notifications-bg: #131729; - --icon-notifications-color: #6e7898; - --icon-tools-bg: #1c2340; - --icon-tools-color: #e0e3f8; - --icon-read-bg: #1c2340; - --icon-read-color: #e0e3f8; - --icon-sign-bg: #1c2340; - --icon-sign-color: #e0e3f8; - --icon-automate-bg: #1c2340; - --icon-automate-color: #e0e3f8; - --icon-files-bg: #1c2340; - --icon-files-color: #e0e3f8; - --icon-activity-bg: #1c2340; - --icon-activity-color: #e0e3f8; - --icon-config-bg: #1c2340; - --icon-config-color: #e0e3f8; + --icon-tools-bg: var(--p-zinc-650); + --icon-tools-color: var(--p-blue-400); + --icon-files-color: var(--p-blue-400); /* Inactive icon colors for dark mode */ - --icon-inactive-bg: #131729; - --icon-inactive-color: #6e7898; + --icon-inactive-bg: var(--p-zinc-800); + --icon-inactive-color: var(--p-gray-500); /* Dark mode tooltip colors */ - --tooltip-title-bg: #1c2340; - --tooltip-title-color: #e8eaf6; - --tooltip-header-bg: var(--bg-raised); - --tooltip-header-color: var(--text-primary); - --tooltip-border: var(--border-default); + --tooltip-header-bg: var(--c-surface-raised); + --tooltip-header-color: var(--c-text); + --tooltip-border: var(--c-border); - --accent-interactive: #e8eaf6; - --text-instruction: #e8eaf6; - --text-brand: var(--color-gray-800); - --text-brand-accent: #ef4444; + --text-brand-accent: var(--p-red-500); /* Compare badge text colors (dark mode): lighter for readability */ --spdf-compare-removed-badge-fg: var(--color-red-500); --spdf-compare-added-badge-fg: var(--color-green-500); - /* container */ - --landing-paper-bg: #090b18; - --landing-inner-paper-bg: var(--bg-raised); - --landing-inner-paper-border: #1c2340; - /* landing dark overrides */ - --landing-stack-glow-bg: radial-gradient( - circle, - rgba(50, 90, 210, 0.22) 0%, - transparent 70% - ); --landing-doc-shadow-back-idle: 0 4px 20px rgba(0, 0, 0, 0.45), 0 1px 3px rgba(0, 0, 0, 0.35); - --landing-doc-shadow-back-hover: - 0 14px 44px rgba(0, 0, 0, 0.65), 0 6px 16px rgba(0, 0, 0, 0.45); --landing-doc-shadow-front-idle: 0 8px 30px rgba(0, 0, 0, 0.55), 0 4px 12px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.04); - --landing-doc-shadow-front-hover: - 0 20px 52px rgba(0, 0, 0, 0.7), 0 10px 24px rgba(0, 0, 0, 0.5), - 0 0 0 1px rgba(255, 255, 255, 0.06); - --landing-button-color: #e8eaf6; - --landing-button-hover-bg: var(--bg-raised); + --landing-button-hover-bg: var(--c-surface-raised); /* selected file header colors for dark */ - --header-selected-bg: #2040a0; + --header-selected-bg: var(--p-blue-700); --header-selected-fg: #ffffff; - /* file card background (dark) */ - --file-card-bg: #0d1120; - --accordion-item-bg: #1c2340; /* accordion item background - more distinguishable */ - - /* shadows */ - --drop-shadow-color: rgba(255, 255, 255, 0.05); - --drop-shadow-color-strong: rgba(255, 255, 255, 0.03); - --drop-shadow-filter: drop-shadow(0 0.2rem 0.4rem rgba(0, 0, 0, 0.3)) - drop-shadow(0 0.6rem 0.6rem rgba(0, 0, 0, 0.2)) - drop-shadow(0 1.2rem 1rem rgba(0, 0, 0, 0.15)); /* Adjust shadows for dark mode */ --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.4); @@ -636,87 +436,43 @@ --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.5); --shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.5); - --tools-text-and-icon-color: var(--text-primary); - - /* Tool picker sticky header variables (dark mode) */ - --tool-header-bg: #131729; - --tool-header-border: #1c2340; - --tool-header-text: #c2c8e0; - --tool-header-badge-bg: #1c2340; - --tool-header-badge-text: #e8eaf6; - - /* Subcategory title styling (dark mode) */ - --tool-subcategory-text-color: #5b6280; - --tool-subcategory-rule-color: #1c2340; - /* PDF text selection colors (dark mode) */ - --pdf-selection-bg: rgba(96, 130, 250, 0.25); - --pdf-selection-ring: rgba(96, 130, 250, 0.28); - - /* Placeholder text colors (dark mode) */ - --search-text-and-icon-color: #e8eaf6 !important; - --input-bg: #1c2340; + --pdf-selection-bg: color-mix(in srgb, var(--p-blue-400) 25%, transparent); + --pdf-selection-ring: color-mix(in srgb, var(--p-blue-400) 28%, transparent); /* Tool panel search bar background colors (dark mode) */ - --tool-panel-search-bg: #0d1020; - --tool-panel-search-border-bottom: #1c2340; - --information-text-bg: #131729; - --information-text-color: #e0e3f8; + --information-text-color: var(--p-blue-400); - /* Bulk selection panel specific colors (dark mode) */ - --bulk-panel-bg: var(--bg-raised); /* dark background for parent container */ - --bulk-card-bg: var(--bg-raised); /* dark background for cards */ + /* dark background for cards */ --bulk-card-border: var( - --border-default + --c-border ); /* default border for cards and buttons */ - --bulk-card-hover-border: var(--border-strong); /* stronger border on hover */ - --unsupported-bar-bg: #0d1020; - --unsupported-bar-border: #1c2340; + --bulk-card-hover-border: var( + --c-border-strong + ); /* stronger border on hover */ /* Config Modal colors (dark mode) */ - --modal-nav-bg: #0d1020; - --modal-nav-section-title: #5b6280; - --modal-nav-item: #c2c8e0; - --modal-nav-item-active: #4f8ef5; - --modal-nav-item-active-bg: rgba(79, 142, 245, 0.15); - --modal-content-bg: #131729; - --modal-header-border: rgba(255, 255, 255, 0.05); + --modal-nav-item: var(--p-gray-300); /* Onboarding (dark mode) */ - --onboarding-title: #e8eaf6; - --onboarding-body: #9299b0; - --onboarding-step-active: #d8dcf0; - --onboarding-step-inactive: #2d3560; + --onboarding-step-active: var(--p-gray-250); + --onboarding-step-inactive: var(--p-zinc-500); /* API Keys section colors (dark mode) */ - --api-keys-card-bg: #131729; - --api-keys-card-border: #1c2340; --api-keys-card-shadow: none; - --api-keys-input-bg: #0d1020; - --api-keys-input-border: #1c2340; /* Code token colors (dark mode - Cursor-like) */ - --code-kw-color: #c792ea; /* purple */ - --code-str-color: #c3e88d; /* green */ - --code-num-color: #f78c6c; /* orange */ - --code-com-color: #697098; /* muted gray-blue */ - /* Compare tool specific colors (dark mode) - only for colors that don't have existing theme pairs */ - --compare-upload-dropzone-bg: rgba(9, 11, 24, 0.45); - --compare-upload-dropzone-border: rgba(28, 35, 64, 0.6); - --compare-upload-icon-bg: rgba(28, 35, 64, 0.2); - --compare-upload-icon-color: rgba(232, 234, 246, 0.75); - --compare-upload-divider: rgba(28, 35, 64, 0.5); + --code-kw-color: var(--p-blue-400); /* purple */ + --code-str-color: var(--p-green-500); /* green */ + --code-num-color: var(--p-red-400); /* orange */ + --code-com-color: var(--p-gray-500); /* muted gray-blue */ /* Compare page label chip (dark mode): slightly darker than surrounding rows */ - --compare-page-label-bg: #0d1020; - --compare-page-label-fg: var(--text-secondary); + --compare-page-label-fg: var(--c-text-muted); /* Status indicator dot ring shadow (dark) */ --status-dot-ring: 0 0 0 2px rgba(255, 255, 255, 0.15); - /* Input element styling (dark) */ - --input-bg: #1c2340; - /* Select/dropdown placeholder state (dark) */ --select-placeholder-bg: var(--mantine-color-dark-5); --select-placeholder-text: var(--mantine-color-dark-2); @@ -724,7 +480,6 @@ /* Grouped format dropdown (dark) */ --dropdown-trigger-bg: var(--mantine-color-dark-6); --dropdown-trigger-text: var(--mantine-color-dark-0); - --dropdown-trigger-text-disabled: var(--mantine-color-dark-1); --dropdown-trigger-icon: var(--mantine-color-dark-2); --dropdown-panel-bg: var(--mantine-color-dark-7); --dropdown-panel-border: var(--mantine-color-dark-4); @@ -739,11 +494,10 @@ /* Plan section card borders - only override in dark mode */ [data-mantine-color-scheme="dark"] .plan-card { - --paper-border-color: rgb(44, 52, 120) !important; } [data-mantine-color-scheme="dark"] .plan-card [data-size="sm"] { - color: rgb(194, 200, 224) !important; + color: var(--p-c-c2c8e0) !important; } /* Current plan badge - use light mode green in dark mode */ @@ -753,24 +507,24 @@ /* Plan section button colors */ .plan-button:not(:disabled):not([data-disabled]) { - background-color: #0a8bff !important; + background-color: var(--p-azure-500) !important; } [data-mantine-color-scheme="dark"] .plan-button:not(:disabled):not([data-disabled]) { - background-color: #2040a0 !important; + background-color: var(--p-royal-700) !important; } /* Lighter grey for disabled plan buttons */ .plan-button:disabled, .plan-button[data-disabled] { - background-color: #7e7e7e !important; + background-color: var(--p-c-7e7e7e) !important; color: white; } [data-mantine-color-scheme="dark"] .plan-button:disabled, [data-mantine-color-scheme="dark"] .plan-button[data-disabled] { - background-color: #6b7280 !important; + background-color: var(--p-gray-500) !important; } /* Override the flat multiply blend for a clean, modern semi-transparent overlay */ @@ -787,19 +541,29 @@ /* Flash highlight for comment card (e.g. "View comment" from annotation menu) */ @keyframes comment-card-flash { 0% { - background-color: rgba(255, 235, 59, 0); + background-color: color-mix(in srgb, var(--p-flash-yellow) 0%, transparent); box-shadow: none; } 20% { - background-color: rgba(255, 235, 59, 0.35); - box-shadow: 0 0 20px rgba(255, 235, 59, 0.5); + background-color: color-mix( + in srgb, + var(--p-flash-yellow) 35%, + transparent + ); + box-shadow: 0 0 20px + color-mix(in srgb, var(--p-flash-yellow) 50%, transparent); } 50% { - background-color: rgba(255, 235, 59, 0.25); - box-shadow: 0 0 15px rgba(255, 235, 59, 0.4); + background-color: color-mix( + in srgb, + var(--p-flash-yellow) 25%, + transparent + ); + box-shadow: 0 0 15px + color-mix(in srgb, var(--p-flash-yellow) 40%, transparent); } 100% { - background-color: rgba(255, 235, 59, 0); + background-color: color-mix(in srgb, var(--p-flash-yellow) 0%, transparent); box-shadow: none; } } @@ -809,22 +573,22 @@ border-radius: 8px; } -/* Smooth transitions for theme switching */ +/* Smooth transitions for theme switching. Text `color` is deliberately EXCLUDED: + it must swap instantly, never fade white↔black through grey. */ * { transition: background-color 0.2s ease, - border-color 0.2s ease, - color 0.2s ease; + border-color 0.2s ease; } /* ── PDF Link Overlay (viewer) ── */ :root { - --link-hover-bg: rgba(10, 139, 255, 0.1); - --link-hover-border: rgba(10, 139, 255, 0.32); + --link-hover-bg: color-mix(in srgb, var(--p-blue-500) 10%, transparent); + --link-hover-border: color-mix(in srgb, var(--p-blue-500) 32%, transparent); --link-hover-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.35), - 0 1px 4px rgba(10, 139, 255, 0.12); - --link-focus-ring: rgba(10, 139, 255, 0.25); - --link-toolbar-bg: rgba(15, 23, 42, 0.88); + 0 1px 4px color-mix(in srgb, var(--p-azure-500) 12%, transparent); + --link-focus-ring: color-mix(in srgb, var(--p-blue-500) 25%, transparent); + --link-toolbar-bg: color-mix(in srgb, var(--p-blue-700) 88%, transparent); --link-toolbar-border: rgba(255, 255, 255, 0.1); --link-toolbar-shadow: 0 12px 40px rgba(0, 0, 0, 0.2), 0 4px 12px rgba(0, 0, 0, 0.16), @@ -832,12 +596,13 @@ } [data-mantine-color-scheme="dark"] { - --link-hover-bg: rgba(79, 130, 245, 0.14); - --link-hover-border: rgba(79, 130, 245, 0.38); + --link-hover-bg: color-mix(in srgb, var(--p-blue-500) 14%, transparent); + --link-hover-border: color-mix(in srgb, var(--p-blue-500) 38%, transparent); --link-hover-shadow: - inset 0 0 0 1px rgba(255, 255, 255, 0.1), 0 1px 4px rgba(79, 142, 245, 0.18); - --link-focus-ring: rgba(79, 130, 245, 0.3); - --link-toolbar-bg: rgba(9, 11, 24, 0.94); + inset 0 0 0 1px rgba(255, 255, 255, 0.1), + 0 1px 4px color-mix(in srgb, var(--p-c-4f8ef5) 18%, transparent); + --link-focus-ring: color-mix(in srgb, var(--p-blue-500) 30%, transparent); + --link-toolbar-bg: color-mix(in srgb, var(--p-blue-700) 94%, transparent); --link-toolbar-border: rgba(255, 255, 255, 0.07); --link-toolbar-shadow: 0 12px 40px rgba(0, 0, 0, 0.5), 0 4px 12px rgba(0, 0, 0, 0.35), @@ -967,7 +732,7 @@ } .pdf-link-toolbar-btn:hover .pdf-link-toolbar-label { - color: #60a5fa; /* Modern blue accent */ + color: var(--p-blue-400); /* Modern blue accent */ text-decoration: underline; text-underline-offset: 3px; } @@ -977,8 +742,8 @@ } .pdf-link-toolbar-btn--delete:hover { - background: rgba(239, 68, 68, 0.15); - color: #fca5a5; + background: color-mix(in srgb, var(--p-red-500) 15%, transparent); + color: var(--p-art-red-light); } .pdf-link-toolbar-sep { @@ -1001,8 +766,8 @@ } .pdf-link-toolbar-btn--delete:hover { - background: rgba(239, 68, 68, 0.2); - color: #f87171; + background: color-mix(in srgb, var(--p-red-500) 20%, transparent); + color: var(--p-red-400); } .pdf-link-toolbar-btn--go:hover { @@ -1021,7 +786,7 @@ } :root { - --shadow-color: rgba(15, 23, 42, 0.55); + --shadow-color: color-mix(in srgb, var(--p-blue-700) 55%, transparent); } [data-theme="dark"] { @@ -1032,23 +797,20 @@ .wordmark-dark-only { display: none; } -[data-mantine-color-scheme="dark"] .wordmark-light-only { +[data-mantine-color-scheme="dark"] .wordmark { display: none; } [data-mantine-color-scheme="dark"] .wordmark-dark-only { display: block; } -/* Theme-aware image display utilities */ -/* Use .theme-img-light-only for images to show in light mode only */ -/* Use .theme-img-dark-only for images to show in dark mode only */ -.theme-img-light-only { +.theme-img { display: inline; } .theme-img-dark-only { display: none; } -[data-mantine-color-scheme="dark"] .theme-img-light-only { +[data-mantine-color-scheme="dark"] .theme-img { display: none; } [data-mantine-color-scheme="dark"] .theme-img-dark-only { @@ -1057,19 +819,19 @@ /* Modern logo icon */ .logo-icon-modern__path1 { - fill: #acacac; + fill: var(--p-c-acacac); fill-opacity: 0.3; } .logo-icon-modern__path2 { - fill: #fc9999; + fill: var(--p-art-red-soft); fill-opacity: 0.5; } [data-mantine-color-scheme="dark"] .logo-icon-modern__path1 { - fill: #e6e6e6; + fill: var(--p-c-e6e6e6); fill-opacity: 0.4; } [data-mantine-color-scheme="dark"] .logo-icon-modern__path2 { - fill: #e6e6e6; + fill: var(--p-c-e6e6e6); fill-opacity: 0.7; } @@ -1078,13 +840,13 @@ opacity: 0.2; } .logo-icon-classic__body { - fill: #ff4b4b; + fill: var(--p-art-red); } .logo-icon-classic__flap { - fill: #545454; + fill: var(--p-c-545454); } .logo-icon-classic__triangle { - fill: #d0dbdc; + fill: var(--p-c-d0dbdc); } .logo-icon-classic__s { fill: white; @@ -1096,17 +858,17 @@ fill: white; } [data-mantine-color-scheme="dark"] .logo-icon-classic__flap { - fill: #d3d3d3; + fill: var(--p-c-d3d3d3); } [data-mantine-color-scheme="dark"] .logo-icon-classic__triangle { - fill: #d0dbdc; + fill: var(--p-c-d0dbdc); } [data-mantine-color-scheme="dark"] .logo-icon-classic__s { fill: var(--mantine-color-body); } -/* Wordmark muted variant (used in empty/placeholder states) */ -/* Stirling text: 30% opacity in light mode, full opacity in dark */ +/* Wordmark muted variant (empty/placeholder states). + Stirling text: 30% opacity in light mode, full opacity in dark. */ .wordmark__text--muted { fill-opacity: 0.3; } @@ -1115,11 +877,11 @@ } /* PDF text: dimmed red in light mode, standard red in dark */ .wordmark__pdf--muted { - fill: #d62626; + fill: var(--p-c-d62626); fill-opacity: 0.7; } [data-mantine-color-scheme="dark"] .wordmark__pdf--muted { - fill: #c56565; + fill: var(--p-art-red-muted); fill-opacity: 1; } diff --git a/frontend/editor/src/core/testing/serverExperienceSimulations.ts b/frontend/editor/src/core/testing/serverExperienceSimulations.ts index cd7ed8f2ba..97da95c730 100644 --- a/frontend/editor/src/core/testing/serverExperienceSimulations.ts +++ b/frontend/editor/src/core/testing/serverExperienceSimulations.ts @@ -38,7 +38,7 @@ const FREE_LICENSE_INFO: LicenseInfo = { const BASE_NO_LOGIN_CONFIG: AppConfig = { enableAnalytics: true, - appVersion: "2.14.1", + appVersion: "2.14.2", serverCertificateEnabled: false, enableAlphaFunctionality: false, serverPort: 8080, diff --git a/frontend/editor/src/core/tests/live/authentication-login.spec.ts b/frontend/editor/src/core/tests/live/authentication-login.spec.ts index af1733ac42..f9d0f6e628 100644 --- a/frontend/editor/src/core/tests/live/authentication-login.spec.ts +++ b/frontend/editor/src/core/tests/live/authentication-login.spec.ts @@ -162,34 +162,4 @@ test.describe("1. Authentication and Login", () => { }); }); }); - - test.describe("1.6 Login Page - Carousel/Slideshow", () => { - test("should navigate between carousel slides", async ({ page }) => { - // Carousel is hidden on small viewports (< 940px wide), ensure desktop size - await page.setViewportSize({ width: 1920, height: 1080 }); - - // Starting state: User is logged out; browser on /login - await page.goto("/login"); - await page.waitForLoadState("domcontentloaded"); - - // Step 1: Verify slide indicator dots are present (carousel uses aria-label "Go to slide N") - const slideButtons = page.getByRole("button", { name: /Go to slide/i }); - const count = await slideButtons.count(); - test.skip(count === 0, "No carousel slides configured on this instance"); - - // Step 2: Click through slides - if (count >= 2) { - await slideButtons.nth(1).click(); - await page.waitForTimeout(500); - } - if (count >= 3) { - await slideButtons.nth(2).click(); - await page.waitForTimeout(500); - } - - // Step 3: Click back to slide 1 - await slideButtons.nth(0).click(); - await page.waitForTimeout(500); - }); - }); }); diff --git a/frontend/editor/src/core/tests/stubbed/api-keys-ui.spec.ts b/frontend/editor/src/core/tests/stubbed/api-keys-ui.spec.ts new file mode 100644 index 0000000000..003a56fe31 --- /dev/null +++ b/frontend/editor/src/core/tests/stubbed/api-keys-ui.spec.ts @@ -0,0 +1,265 @@ +import { + test, + expect, + type APIRequestContext, + type Page, + type Route, +} from "@playwright/test"; +import { seedCookieConsent } from "@app/tests/helpers/api-stubs"; + +/** + * E2E coverage for the portal Infrastructure → API Keys tab (PR #6961: + * multiple named personal API keys with per-key usage tracking). Drives the + * list / create-and-reveal / revoke / empty / error flows through the real + * portal shell. + * + * Auth is real (a genuine admin login against the backend) because the portal's + * Spring AuthProvider doesn't settle under a fully-faked session; only the + * `api-keys` CRUD responses are stubbed via `page.route`, so the assertions stay + * deterministic and no real keys are created. + * + * Requirements to run for real: a backend on :8080 (reachable through the vite + * proxy) AND a portal-enabled frontend (`import.meta.env.DEV`, or a build with + * `VITE_INCLUDE_PORTAL=true`). The CI `vite preview` bundle ships with the + * portal off, so each test skips cleanly there - the same defensive pattern as + * audit-log-ui.spec.ts. Companion unit coverage that always runs in CI lives in + * ApiKeysTab.test.tsx. + */ + +const API_KEYS = "**/api/v1/proprietary/ui-data/infrastructure/api-keys"; + +type StubKey = { + id: string; + name: string; + prefix: string; + created: string; + lastUsed: string; + status: "active" | "revoked"; + usageToday: number; + usageMonth: number; + usageTotal: number; +}; + +function key(overrides: Partial = {}): StubKey { + return { + id: "1", + name: "Production ingest", + prefix: "sk_a1b2c3d4", + created: "2026-07-10", + lastUsed: "2026-07-15 09:30", + status: "active", + usageToday: 12, + usageMonth: 340, + usageTotal: 9001, + ...overrides, + }; +} + +/** Log in as the default admin; returns the JWT, or null when no backend answers. */ +async function adminJwt(request: APIRequestContext): Promise { + for (const password of ["adminadmin", "stirling"]) { + const res = await request + .post("/api/v1/auth/login", { data: { username: "admin", password } }) + .catch(() => null); + if (res?.ok()) { + const body = await res.json().catch(() => null); + const token = body?.session?.access_token; + if (token) return token as string; + } + } + return null; +} + +/** + * Real admin session + stubbed API-keys CRUD. `keys` seeds the list; create + * prepends an active key and reveals a one-time secret; delete flips the target + * to `revoked`; `loadStatus` forces the list GET to fail. Returns false (and the + * caller skips) when there's no backend to authenticate against. + */ +async function setUpApiKeys( + page: Page, + request: APIRequestContext, + opts: { keys?: StubKey[]; loadStatus?: number } = {}, +): Promise { + const token = await adminJwt(request); + if (!token) { + test.skip(true, "No backend on :8080 to authenticate the portal"); + return false; + } + + const list = [...(opts.keys ?? [])]; + + await seedCookieConsent(page); + await page.addInitScript((jwt) => { + localStorage.setItem("stirling_jwt", jwt); + }, token); + + // Revoke: flip the addressed key to revoked, 204 like the real endpoint. + await page.route(`${API_KEYS}/*`, (route: Route) => { + const id = route.request().url().split("/").pop() ?? ""; + const target = list.find((k) => k.id === id); + if (target) target.status = "revoked"; + return route.fulfill({ status: 204, body: "" }); + }); + + // List (GET) and create (POST). + await page.route(API_KEYS, async (route: Route) => { + if (route.request().method() === "POST") { + const body = route.request().postDataJSON() as { name: string }; + const created = key({ + id: String(list.length + 100), + name: body.name, + prefix: "sk_new00000", + created: "2026-07-15", + lastUsed: "Never", + usageToday: 0, + usageMonth: 0, + usageTotal: 0, + }); + list.unshift(created); + return route.fulfill({ + json: { + key: created, + secret: "sk_test_demo_secret_shown_once", + }, + }); + } + if (opts.loadStatus && opts.loadStatus >= 400) { + return route.fulfill({ + status: opts.loadStatus, + json: { error: "boom" }, + }); + } + return route.fulfill({ json: { keys: list } }); + }); + + await page.goto("/processor/infrastructure"); + return true; +} + +/** + * Open the API Keys tab, or skip when the portal isn't in this build (the tab + * never renders - e.g. the CI `vite preview` bundle). Returns false when skipped. + */ +async function openApiKeysTab(page: Page): Promise { + const tab = page.getByRole("button", { name: "API Keys" }); + if (!(await tab.isVisible({ timeout: 20_000 }).catch(() => false))) { + test.skip( + true, + "Portal (/processor) not available/bootstrapped in this build", + ); + return false; + } + await tab.click(); + return true; +} + +test.describe("Portal API Keys tab", () => { + test("shows the empty state when the caller has no keys", async ({ + page, + request, + }) => { + if (!(await setUpApiKeys(page, request, { keys: [] }))) return; + if (!(await openApiKeysTab(page))) return; + + await expect(page.getByText("No API keys yet")).toBeVisible({ + timeout: 10_000, + }); + }); + + test("lists existing keys with prefix, status and usage", async ({ + page, + request, + }) => { + if ( + !(await setUpApiKeys(page, request, { + keys: [ + key({ id: "1", name: "Production ingest", prefix: "sk_a1b2c3d4" }), + key({ + id: "2", + name: "Old key", + prefix: "sk_z9y8x7w6", + status: "revoked", + }), + ], + })) + ) + return; + if (!(await openApiKeysTab(page))) return; + + await expect(page.getByText("Production ingest")).toBeVisible({ + timeout: 10_000, + }); + await expect(page.getByText("sk_a1b2c3d4")).toBeVisible(); + // The revoked key still lists, tagged as such. + await expect(page.getByText("Old key")).toBeVisible(); + }); + + test("creates a key and reveals the one-time secret once", async ({ + page, + request, + }) => { + if (!(await setUpApiKeys(page, request, { keys: [] }))) return; + if (!(await openApiKeysTab(page))) return; + + await page.getByRole("button", { name: "Create key" }).click(); + + const dialog = page.getByRole("dialog"); + await expect(dialog).toBeVisible(); + await dialog.getByRole("textbox").fill("CI smoke key"); + await dialog.getByRole("button", { name: "Create key" }).click(); + + // The plaintext secret is shown exactly once, with a store-it-now warning. + await expect( + dialog.getByText("sk_test_demo_secret_shown_once"), + ).toBeVisible({ timeout: 10_000 }); + await expect(dialog.getByText(/won't be shown again/i)).toBeVisible(); + + await dialog.getByRole("button", { name: "Done" }).click(); + // Back on the list, the new key is present. + await expect(page.getByText("CI smoke key")).toBeVisible({ + timeout: 10_000, + }); + }); + + test("revokes a key after confirmation", async ({ page, request }) => { + if ( + !(await setUpApiKeys(page, request, { + keys: [key({ id: "1", name: "Doomed key", status: "active" })], + })) + ) + return; + if (!(await openApiKeysTab(page))) return; + + // Expand the card, then ask to revoke. + await page.getByRole("button", { name: /Doomed key/ }).click(); + await page.getByRole("button", { name: "Revoke key" }).first().click(); + + // Confirm in the dialog. + const dialog = page.getByRole("dialog"); + await expect(dialog).toBeVisible(); + await expect(dialog.getByText(/can't be undone/i)).toBeVisible(); + await dialog.getByRole("button", { name: "Revoke key" }).click(); + + // The dialog closes and the reloaded list shows the key as revoked. + await expect(dialog).toBeHidden({ timeout: 10_000 }); + await expect(page.getByText("Revoked").first()).toBeVisible({ + timeout: 10_000, + }); + }); + + test("surfaces a load error instead of a false empty state", async ({ + page, + request, + }) => { + if (!(await setUpApiKeys(page, request, { keys: [], loadStatus: 500 }))) + return; + if (!(await openApiKeysTab(page))) return; + + await expect( + page.getByText("Couldn't load your API keys. Please try again."), + ).toBeVisible({ timeout: 10_000 }); + // A failed load must not masquerade as "no keys yet". + await expect(page.getByText("No API keys yet")).toHaveCount(0); + }); +}); diff --git a/frontend/editor/src/core/tests/stubbed/classification-grouping.spec.ts b/frontend/editor/src/core/tests/stubbed/classification-grouping.spec.ts new file mode 100644 index 0000000000..682d591804 --- /dev/null +++ b/frontend/editor/src/core/tests/stubbed/classification-grouping.spec.ts @@ -0,0 +1,47 @@ +import path from "path"; +import { test, expect } from "@app/tests/helpers/stub-test-base"; +import { uploadFiles } from "@app/tests/helpers/ui-helpers"; + +// The sidebar groups files by the category in their `StirlingPDFClassification` +// metadata; these specs exercise that seam with pre-labelled fixtures. + +const FIXTURES = path.join( + import.meta.dirname, + "../test-fixtures/classification", +); + +const categoryHeaders = (page: import("@playwright/test").Page) => + page.locator(".file-sidebar-group .file-sidebar-group-header"); + +test("classified files group by category family in the sidebar", async ({ + page, +}) => { + await uploadFiles(page, [ + path.join(FIXTURES, "classified_invoice.pdf"), // -> Financial + path.join(FIXTURES, "classified_nda.pdf"), // -> Legal + path.join(FIXTURES, "classified_resume.pdf"), // -> HR + ]); + + // The backfill reads each file's classification metadata on idle and regroups; + // the category headers appear once it resolves (Playwright auto-retries). + const headers = categoryHeaders(page); + await expect(headers.filter({ hasText: "Financial" })).toBeVisible({ + timeout: 15_000, + }); + await expect(headers.filter({ hasText: "Legal" })).toBeVisible(); + await expect(headers.filter({ hasText: "HR" })).toBeVisible(); +}); + +test("an unclassified file is not placed in a category group", async ({ + page, +}) => { + // sample.pdf carries no StirlingPDFClassification metadata, so it must not + // create or join any category family group - it falls into the catch-all. + await uploadFiles(page, path.join(FIXTURES, "../sample.pdf")); + + // Give the idle backfill a chance to run and (find nothing to) regroup. + await expect(page.locator(".file-sidebar-file-item")).toHaveCount(1); + await expect( + categoryHeaders(page).filter({ hasText: "Financial" }), + ).toHaveCount(0); +}); diff --git a/frontend/editor/src/core/tests/stubbed/classification-heuristic-upload.spec.ts b/frontend/editor/src/core/tests/stubbed/classification-heuristic-upload.spec.ts new file mode 100644 index 0000000000..223a2eae51 --- /dev/null +++ b/frontend/editor/src/core/tests/stubbed/classification-heuristic-upload.spec.ts @@ -0,0 +1,81 @@ +import path from "path"; +import { test, expect } from "@app/tests/helpers/stub-test-base"; +import { uploadFiles } from "@app/tests/helpers/ui-helpers"; + +// A bulk upload must classify every file in the browser and group it - no file +// may be stranded in "Other" by races between the upload wave and delivery. + +test.use({ autoGoto: false }); + +const FIXTURES = path.join( + import.meta.dirname, + "../test-fixtures/classification/unlabelled", +); + +/** The stored policy DefaultClassificationPolicySeeder writes for a new team. */ +const SEEDED_POLICY = { + id: "seeded-classification", + name: "Classification Policy", + owner: "system", + enabled: true, + trigger: null, + sourceIds: [], + steps: [{ operation: "/api/v1/ai/tools/classify-and-label", parameters: {} }], + output: { + type: "inline", + options: { + categoryId: "classification", + runOn: "upload", + mode: "new_version", + sources: ["editor"], + scopeTypes: [], + reviewerEmail: "", + }, + }, + teamId: 1, +}; + +test("a 10-file upload wave classifies every file into its group", async ({ + page, +}) => { + test.setTimeout(180_000); + + await page.route("**/api/v1/policies", (route) => + route.fulfill({ json: [SEEDED_POLICY] }), + ); + await page.route("**/api/v1/policies/classify/meter", (route) => + route.fulfill({ status: 202, body: "" }), + ); + await page.goto("/", { waitUntil: "domcontentloaded", timeout: 120_000 }); + + await uploadFiles( + page, + [ + "invoice_acme.pdf", + "bank_statement.pdf", + "purchase_order.pdf", + "nda_mutual.pdf", + "service_agreement.pdf", + "resume_jane_doe.pdf", + "cover_letter.pdf", + "offer_letter.pdf", + "generic_notes.pdf", + "spanish_contrato.pdf", + ].map((f) => path.join(FIXTURES, f)), + ); + + // Each group header is a collapsible button whose name carries the member count. + // Classification runs a few files per idle pass; wait for the full drain. + const header = (name: string, count: number) => + page.getByRole("button", { name: `${name} ${count}`, exact: true }); + await expect(header("Financial", 3)).toBeVisible({ timeout: 90_000 }); + await expect(header("HR", 3)).toBeVisible({ timeout: 30_000 }); + await expect(header("Legal", 2)).toBeVisible({ timeout: 30_000 }); + + // The regression: nothing classifiable may be stranded in Other - only the + // genuinely unlabellable pair (generic prose + non-English) belongs there. + await expect(header("Other", 2)).toBeVisible({ timeout: 30_000 }); + // The filename can render in several places (Recent, group, viewer); any hit proves presence. + await expect(page.getByText("generic_notes.pdf").first()).toBeVisible(); + await expect(page.getByText("spanish_contrato.pdf").first()).toBeVisible(); +}); diff --git a/frontend/editor/src/core/tests/test-fixtures/classification/classified_invoice.pdf b/frontend/editor/src/core/tests/test-fixtures/classification/classified_invoice.pdf new file mode 100644 index 0000000000..185fb4cc81 --- /dev/null +++ b/frontend/editor/src/core/tests/test-fixtures/classification/classified_invoice.pdf @@ -0,0 +1,36 @@ +%PDF-1.4 +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Kids [3 0 R] /Count 1 >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >> +endobj +4 0 obj +<< /Length 38 >> +stream +BT /F1 24 Tf 72 720 Td (INVOICE) Tj ET +endstream +endobj +5 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> +endobj +6 0 obj +<< /Title (Invoice) /StirlingPDFClassification ({"labels":["invoice"]}) >> +endobj +xref +0 7 +0000000000 65535 f +0000000009 00000 n +0000000058 00000 n +0000000115 00000 n +0000000241 00000 n +0000000329 00000 n +0000000426 00000 n +trailer +<< /Size 7 /Root 1 0 R /Info 6 0 R >> +startxref +516 +%%EOF \ No newline at end of file diff --git a/frontend/editor/src/core/tests/test-fixtures/classification/classified_nda.pdf b/frontend/editor/src/core/tests/test-fixtures/classification/classified_nda.pdf new file mode 100644 index 0000000000..ec1eddb1ee --- /dev/null +++ b/frontend/editor/src/core/tests/test-fixtures/classification/classified_nda.pdf @@ -0,0 +1,36 @@ +%PDF-1.4 +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Kids [3 0 R] /Count 1 >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >> +endobj +4 0 obj +<< /Length 55 >> +stream +BT /F1 24 Tf 72 720 Td (NON-DISCLOSURE AGREEMENT) Tj ET +endstream +endobj +5 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> +endobj +6 0 obj +<< /Title (Non-Disclosure Agreement) /StirlingPDFClassification ({"labels":["nda"]}) >> +endobj +xref +0 7 +0000000000 65535 f +0000000009 00000 n +0000000058 00000 n +0000000115 00000 n +0000000241 00000 n +0000000346 00000 n +0000000443 00000 n +trailer +<< /Size 7 /Root 1 0 R /Info 6 0 R >> +startxref +546 +%%EOF \ No newline at end of file diff --git a/frontend/editor/src/core/tests/test-fixtures/classification/classified_resume.pdf b/frontend/editor/src/core/tests/test-fixtures/classification/classified_resume.pdf new file mode 100644 index 0000000000..79a17e901b --- /dev/null +++ b/frontend/editor/src/core/tests/test-fixtures/classification/classified_resume.pdf @@ -0,0 +1,36 @@ +%PDF-1.4 +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Kids [3 0 R] /Count 1 >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >> +endobj +4 0 obj +<< /Length 47 >> +stream +BT /F1 24 Tf 72 720 Td (CURRICULUM VITAE) Tj ET +endstream +endobj +5 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> +endobj +6 0 obj +<< /Title (Curriculum Vitae) /StirlingPDFClassification ({"labels":["resume"]}) >> +endobj +xref +0 7 +0000000000 65535 f +0000000009 00000 n +0000000058 00000 n +0000000115 00000 n +0000000241 00000 n +0000000338 00000 n +0000000435 00000 n +trailer +<< /Size 7 /Root 1 0 R /Info 6 0 R >> +startxref +533 +%%EOF \ No newline at end of file diff --git a/frontend/editor/src/core/tests/test-fixtures/classification/unlabelled/bank_statement.pdf b/frontend/editor/src/core/tests/test-fixtures/classification/unlabelled/bank_statement.pdf new file mode 100644 index 0000000000..ad4ef7ab7b Binary files /dev/null and b/frontend/editor/src/core/tests/test-fixtures/classification/unlabelled/bank_statement.pdf differ diff --git a/frontend/editor/src/core/tests/test-fixtures/classification/unlabelled/cover_letter.pdf b/frontend/editor/src/core/tests/test-fixtures/classification/unlabelled/cover_letter.pdf new file mode 100644 index 0000000000..4b8d0f462a Binary files /dev/null and b/frontend/editor/src/core/tests/test-fixtures/classification/unlabelled/cover_letter.pdf differ diff --git a/frontend/editor/src/core/tests/test-fixtures/classification/unlabelled/generic_notes.pdf b/frontend/editor/src/core/tests/test-fixtures/classification/unlabelled/generic_notes.pdf new file mode 100644 index 0000000000..15dce23df4 Binary files /dev/null and b/frontend/editor/src/core/tests/test-fixtures/classification/unlabelled/generic_notes.pdf differ diff --git a/frontend/editor/src/core/tests/test-fixtures/classification/unlabelled/invoice_acme.pdf b/frontend/editor/src/core/tests/test-fixtures/classification/unlabelled/invoice_acme.pdf new file mode 100644 index 0000000000..e45470639f Binary files /dev/null and b/frontend/editor/src/core/tests/test-fixtures/classification/unlabelled/invoice_acme.pdf differ diff --git a/frontend/editor/src/core/tests/test-fixtures/classification/unlabelled/nda_mutual.pdf b/frontend/editor/src/core/tests/test-fixtures/classification/unlabelled/nda_mutual.pdf new file mode 100644 index 0000000000..ceece9ecdf Binary files /dev/null and b/frontend/editor/src/core/tests/test-fixtures/classification/unlabelled/nda_mutual.pdf differ diff --git a/frontend/editor/src/core/tests/test-fixtures/classification/unlabelled/offer_letter.pdf b/frontend/editor/src/core/tests/test-fixtures/classification/unlabelled/offer_letter.pdf new file mode 100644 index 0000000000..9adce90b43 Binary files /dev/null and b/frontend/editor/src/core/tests/test-fixtures/classification/unlabelled/offer_letter.pdf differ diff --git a/frontend/editor/src/core/tests/test-fixtures/classification/unlabelled/purchase_order.pdf b/frontend/editor/src/core/tests/test-fixtures/classification/unlabelled/purchase_order.pdf new file mode 100644 index 0000000000..816ef625ab Binary files /dev/null and b/frontend/editor/src/core/tests/test-fixtures/classification/unlabelled/purchase_order.pdf differ diff --git a/frontend/editor/src/core/tests/test-fixtures/classification/unlabelled/resume_jane_doe.pdf b/frontend/editor/src/core/tests/test-fixtures/classification/unlabelled/resume_jane_doe.pdf new file mode 100644 index 0000000000..be462dcc6c --- /dev/null +++ b/frontend/editor/src/core/tests/test-fixtures/classification/unlabelled/resume_jane_doe.pdf @@ -0,0 +1,90 @@ +%PDF-1.3 +%éëñ¿ +1 0 obj +<< +/Count 1 +/Kids [3 0 R] +/MediaBox [0 0 595.28 841.89] +/Type /Pages +>> +endobj +2 0 obj +<< +/OpenAction [3 0 R /FitH null] +/PageLayout /OneColumn +/Pages 1 0 R +/Type /Catalog +>> +endobj +3 0 obj +<< +/Contents 4 0 R +/Parent 1 0 R +/Resources 7 0 R +/Type /Page +>> +endobj +4 0 obj +<< +/Filter /FlateDecode +/Length 685 +>> +stream +xœm“OsÚ0Åïý{$ÓT±±1§&”Ì$%ýLO¹{ J,É‘dˆ¿}W‚t +3 áÝ}¿÷V Ü~ŠØ(‡Ý§«.®cHREP®aVú£aÌâ1äã‚ S(kL—÷÷7Óå|ynÊËÙ”O‡w/®ˆãS壌 ‹P~ËÂ7@Ÿ'zfµÆ¯øÊeÛ «´ôçŸÓòœÚQ'C4תÖê–J8¬á»Pµ–o³_à}ÔpÈòØJY’ ¡ÜÁà—Ñk´VhÅXtRrÓOàR¾¶hªŠZZ½v;nP= +…h`'ܤ¦#·á +*è‘ z 퇖ðûpœ±< +Àï3`Õ‰¦&å`°|Õ ìp¼mQqG½,pUCƒ<¼e%oË¥e'H³‚’JüŒˆ%É8N €”ÿ\=aåÄ'PjpüA+à`Q mŽ„AŠö26H”7P¦—ÓƒìÁ>‹¦± ÔÂ:#VwÞöÖ¡ BOpgy~Ð4¨ÍÒŽz'Z¯ƒNû°:‡òQú¶fÿd6;z89eýd™¯ŠÙ8‹ƒ‹=éì0÷~hã6;AöÎ] ƒ$Š pš‚DrÅ=œ1˜ž×&Å£ qø ýÁŠŒð^µ wkmdHIHÚ-žt!NY6 +û¤E#\¼2Ú’sê¡qå—Ë¢ÙŠ +Of<*bVŒÑímQßᦚ¼¶ÓT{´Ô£yDÏuEkç‚fÉ…rô%Ъ³NK4_Ö¼òlkä®#/ ³ô懲‚m”§¬È[¸œì"wȬ5ÝaÀ-šjÞÿïh”E¨ÕÝ~å'pµ¨@¶´^Uˆ9\tjd½odÖWÕió̉®IÁ²pã÷¸¦E¦zryËEîX×’8ƒ/Õ«ÿ'˜„l +endstream +endobj +5 0 obj +<< +/BaseFont /Helvetica-Bold +/Encoding /WinAnsiEncoding +/Subtype /Type1 +/Type /Font +>> +endobj +6 0 obj +<< +/BaseFont /Helvetica +/Encoding /WinAnsiEncoding +/Subtype /Type1 +/Type /Font +>> +endobj +7 0 obj +<< +/Font <> +/ProcSet [/PDF /Text /ImageB /ImageC /ImageI] +>> +endobj +8 0 obj +<< +/CreationDate (D:20260716092355Z) +>> +endobj +xref +0 9 +0000000000 65535 f +0000000015 00000 n +0000000102 00000 n +0000000205 00000 n +0000000285 00000 n +0000001042 00000 n +0000001144 00000 n +0000001241 00000 n +0000001338 00000 n +trailer +<< +/Size 9 +/Root 2 0 R +/Info 8 0 R +/ID [<7F4FFDD51531EF25A5301EAFCA4542A0><7F4FFDD51531EF25A5301EAFCA4542A0>] +>> +startxref +1393 +%%EOF diff --git a/frontend/editor/src/core/tests/test-fixtures/classification/unlabelled/service_agreement.pdf b/frontend/editor/src/core/tests/test-fixtures/classification/unlabelled/service_agreement.pdf new file mode 100644 index 0000000000..68c7db8352 Binary files /dev/null and b/frontend/editor/src/core/tests/test-fixtures/classification/unlabelled/service_agreement.pdf differ diff --git a/frontend/editor/src/core/tests/test-fixtures/classification/unlabelled/spanish_contrato.pdf b/frontend/editor/src/core/tests/test-fixtures/classification/unlabelled/spanish_contrato.pdf new file mode 100644 index 0000000000..dc9a73c2d8 Binary files /dev/null and b/frontend/editor/src/core/tests/test-fixtures/classification/unlabelled/spanish_contrato.pdf differ diff --git a/frontend/editor/src/core/theme/README.md b/frontend/editor/src/core/theme/README.md new file mode 100644 index 0000000000..fb8160a2d8 --- /dev/null +++ b/frontend/editor/src/core/theme/README.md @@ -0,0 +1,74 @@ +# Theme system (`core/theme/`) + +Read this before touching colours or theming anywhere in the frontend. + +## TL;DR rules + +1. **Never write a raw colour** (`#hex`, `rgb()`, `hsl()`, or a named colour like `red`) in a component, inline style, or stylesheet. Use a token: + - `var(--c-…)` — a **semantic** token (preferred: `--c-text`, `--c-surface`, `--c-primary`, …). + - `var(--p-…)` — a **palette** primitive, only when no semantic token fits. +2. **The only file allowed to contain literal colours is `primitives.css`.** If you need a new hue, add it there first, then reference it. +3. Structural `black` / `white` / `transparent` (shadows, scrims, overlays) are allowed anywhere. +4. Colours must adapt to light/dark automatically. If you're reaching for a hardcoded colour "just for dark mode", you're doing it wrong — pick the right `--c-*` token. +5. `task frontend:lint:colors` enforces rules 1–3 **inside `core/theme/`** (see [Linter](#linter)). + +## Files + +| File | Role | +|---|---| +| `primitives.css` | **The palette.** 41 literal colours (`--p-*`) — one neutral ramp (`--p-gray-*` light, `--p-zinc-*` dark) + status hues (blue/green/amber/red). The ONLY place literals live. | +| `colors.css` | **Semantic tokens** (`--c-*`), mapped from primitives per theme. This is what you should reference. | +| `dimensions.css` | Non-colour tokens: spacing, radius, z-index, type, motion. | +| `index.css` | Barrel that `@import`s the above. Imported by `ThemeProvider` (and Storybook). | +| `mantineTheme.ts` | Mantine theme object wiring. | + +The old `compat.css` legacy-alias layer has been removed — every component now references `--c-*` directly. Two **legacy** colour files still exist outside this folder and are being phased out — prefer `--c-*` over their tokens, and don't add to them: +`core/styles/theme.css` (editor `--bg-*`/`--text-*` vocab) and `core/tokens/tokens.css` (SUI `--color-*` vocab, gradients, code palette). + +## Semantic tokens (`--c-*`) + +Reference these, not primitives, wherever possible: + +- **Surfaces (elevation):** `--c-bg` (canvas) < `--c-bg-raised` (sidebars/toolbars) < `--c-surface` (cards/modals) < `--c-surface-raised` < `--c-surface-sunken`; plus `--c-input-bg`, `--c-hover`, `--c-active`, `--c-overlay`. +- **Text:** `--c-text`, `--c-text-muted`, `--c-text-subtle`, `--c-text-on-primary` (foreground on a filled primary). +- **Borders:** `--c-border`, `--c-border-subtle`, `--c-border-strong`. +- **Accent:** `--c-primary`, `--c-primary-hover`, `--c-primary-subtle`, and `--c-accent-fg` (see below). +- **Status:** `--c-success`, `--c-danger`. + +## Theme model + +The **mode** and the **accent colour** are independent. + +- `preferences.theme` is the mode: `light` | `dark` | `system` (System follows the OS). There is no separate "custom" or "midnight" mode any more. +- Light and dark each have their **own accent**: `preferences.lightPrimary` / `preferences.darkPrimary` (both default `#3b82f6` blue). +- `ThemeProvider` resolves the mode to a concrete `light`/`dark` base, picks that side's accent, and **always** sets `data-app-theme="custom"` on ``. So the custom-tint blocks in `colors.css` are the only themed blocks that apply — the chosen accent drives every accent **and** a subtle app-wide surface tint. With the default blue the tint is near-neutral. +- Selection attributes on ``: `data-theme` = `light|dark` (SUI + the tint blocks), `data-mantine-color-scheme` = `light|dark` (Mantine). + +### Custom-theme contrast guardrails (`core/utils/customPrimary.ts`) + +Because the accent is user-chosen, `deriveAccessiblePrimary(pick, base)` clamps it and injects three vars on ``: + +- `--user-primary` → `--c-primary`. Lightness-clamped so it can't collapse into the base (dark floor `L ≥ 0.42`, light ceil `L ≤ 0.6`). Used for **fills**. +- `--user-primary-on` → `--c-text-on-primary`. White by default; flips to **black only for genuinely light picks** (relative-luminance cutoff `0.62`, so saturated amber/green/cyan keep white text). +- `--user-accent-fg` → `--c-accent-fg`. The accent tuned as a **foreground** (text/icon on the app surface): forced light on dark bases (`L ≥ 0.62`), dark on light (`L ≤ 0.45`), so accent text never goes dark-on-dark. + +**Rule of thumb:** a filled control's background uses `--c-primary` with its label on `--c-text-on-primary`; an accent used **as text/icon on a surface** (nav selection, links, tool-header text) uses `--c-accent-fg`. Reference it as `var(--c-accent-fg, var(--c-primary))` — the fallback keeps non-custom builds (where `--c-accent-fg` is unset) on the raw primary. + +The FAB / logo mark is a deliberate exception: it's pinned to white (`--p-white`), not the on-primary flip — a brand mark, not body text. + +## Linter + +One file: `editor/scripts/lint/theme-lint.mjs` (no baseline). Run via `task frontend:lint:colors` (part of `task frontend:lint`). + +- **Default (blocking):** enforces "literals only in `primitives.css`; everything else in `core/theme/` references tokens; no duplicate primitives." Scope is deliberately just `core/theme/` — the layer this owns, which is clean. +- **`node theme-lint.mjs contrast`** (task `frontend:contrast`, non-blocking): WCAG contrast report for text-on-surface / on-primary pairs per theme. + +App-wide "no hardcoded colours in components" is **not** enforced yet (there are 260+ legacy sites); that's a separate migration. Don't add new hardcoded colours regardless. + +## Gotchas + +- **`--mantine-*` vars are consumed by Mantine at runtime**, not via our `var()`. A source scan can't see their use — never delete them as "unused", and set them (not raw colours) when overriding Mantine. +- **`--accent-*` (categorical hues) are used dynamically** via `` `var(--accent-${hue})` `` in `utils/accentColors.ts`. A literal search won't find them — don't treat them as unused. +- **Tailwind consumes some vars** (`--gray-*`, `--color-*`, `--background`, `--border`) via `editor/tailwind.config.js` using `rgb(var(--x))`. That file is outside the usual scan roots — check it before removing those. +- **Specificity:** if a token you set in `colors.css` (`html[data-app-theme="custom"]`, 0,1,1) isn't winning, a `:root:root` or a `[data-mantine-color-scheme]`-compound block in the legacy files is probably overriding it — set it in the more specific block. +- **Adding a colour:** put the literal in `primitives.css`, map it to a `--c-*` in `colors.css` if it's a new semantic role, and reference the `--c-*` from components. Don't skip straight to a `--p-*` in a component unless there's genuinely no semantic fit. diff --git a/frontend/editor/src/core/theme/colors.css b/frontend/editor/src/core/theme/colors.css new file mode 100644 index 0000000000..ced2c5dcb6 --- /dev/null +++ b/frontend/editor/src/core/theme/colors.css @@ -0,0 +1,361 @@ +/* COLORS — canonical semantic core (~21 --c-* tokens) mapped from primitives.css per theme; compat.css aliases legacy names onto these. Editor: always data-app-theme="custom" + data-mantine-color-scheme=light|dark. Portal/Storybook: data-theme=light|dark → LIGHT/MIDNIGHT. */ +/* Surface elevation: --c-bg (canvas) < --c-bg-raised (sidebars) < --c-surface (cards) < --c-surface-raised < --c-surface-sunken; plus --c-input-bg, --c-hover, --c-active, --c-overlay. */ + +/* ── LIGHT ───────────────────────────────────────────────────────────────── */ +:root, +[data-theme="light"], +html[data-app-theme="light"] { + --c-bg: var(--p-gray-50); + --c-bg-raised: var(--p-white); + --c-surface: var(--p-white); + --c-surface-raised: var(--p-white); + --c-surface-sunken: var(--p-gray-100); + --c-input-bg: var(--p-white); + --c-hover: var(--p-gray-50); + --c-active: var(--p-gray-100); + --c-overlay: rgba(0, 0, 0, 0.5); + + --c-text: var(--p-gray-900); + --c-text-muted: var(--p-gray-600); + --c-text-subtle: var(--p-gray-500); + --c-text-on-primary: var(--p-white); + + --c-border: var(--p-gray-250); + --c-border-subtle: var(--p-gray-200); + --c-border-strong: var(--p-gray-400); + + --c-primary: var(--p-blue-500); + --c-primary-hover: var(--p-blue-600); + --c-primary-subtle: color-mix(in srgb, var(--p-blue-500) 10%, transparent); + + --c-success: var(--p-green-600); + --c-danger: var(--p-red-600); + --c-warning: var(--p-amber-600); + /* Highlight/flash (search hits, compare jump-to) — same in both themes. */ + --c-highlight: var(--p-flash-yellow); + /* Stirling brand red (auth CTAs) — a fixed brand colour, same in both themes. */ + --c-brand: var(--p-brand-red); + --c-brand-hover: var(--p-brand-red-600); + + --c-primary-tint: color-mix(in srgb, var(--c-primary) 14%, var(--c-surface)); + --c-primary-border: color-mix( + in srgb, + var(--c-primary) 32%, + var(--c-surface) + ); + --c-danger-subtle: color-mix(in srgb, var(--c-danger) 10%, var(--c-surface)); + --c-success-subtle: color-mix( + in srgb, + var(--c-success) 10%, + var(--c-surface) + ); + + /* ── Decorative / brand / categorical palette ────────────────────────── + Fixed hues that intentionally do NOT follow the chosen accent: brand + marks, vendor colours, categorical avatar dots, static illustrations, + and the multi-hue gradients on feature/upgrade/onboarding surfaces. + Named here so components reference a --c-* token, never a raw --p-*. */ + --c-brand-mark: var(--p-brand-red-650); /* Stirling logo mark fill */ + --c-accent-stripe: var(--p-periwinkle-500); /* Stripe "connect" CTA */ + + /* Feature-accent hues (fixed) used as stops in multi-hue gradients. */ + --c-hue-blue: var(--p-blue-500); + --c-hue-indigo: var(--p-indigo-500); + --c-hue-violet: var(--p-violet-600); + --c-hue-purple: var(--p-purple-500); + --c-hue-pink: var(--p-pink-500); + + /* Categorical avatar palette (per-user dot colour). */ + --c-avatar-1: var(--p-azure-500); + --c-avatar-2: var(--p-violet-500); + --c-avatar-3: var(--p-pink-500); + --c-avatar-4: var(--p-emerald-500); + --c-avatar-5: var(--p-amber-500); + --c-avatar-6: var(--p-cyan-500); + + /* Connection picker: muted categorical marks + brand-red accent. */ + --c-conn-accent: var(--p-art-red-muted); + --c-conn-storage: var(--p-art-blue-muted); + --c-conn-signing: var(--p-art-green-muted); + --c-conn-notify: var(--p-art-amber-muted); + --c-conn-neutral: var(--p-gray-mid); + + /* Always-dark marketing/product hero strip (portal). Same in both themes. */ + --c-hero-dark: var(--p-zinc-950); + --c-hero-dark-cta-text: var(--p-navy-700); + + /* Neutral solid badge fill (grey pill). */ + --c-neutral-fill: var(--p-gray-500); + + /* Static document illustration (theme-independent line work). */ + --c-illustration-line: var(--p-gray-200); + --c-illustration-line-strong: var(--p-gray-300); + + /* Viewer active file-tab tint base. */ + --c-tab-active-tint: var(--p-blue-200); + + /* Themed: file-card resting/unselected header (branded navy). */ + --c-file-header-resting: var(--p-navy-500); + + /* Themed: onboarding hero panel gradient + inset tile. */ + --c-onboarding-hero: linear-gradient( + 155deg, + var(--p-tint-blue) 0%, + var(--p-tint-violet) 55%, + var(--p-tint-pink) 100% + ); + --c-onboarding-hero-tile: var(--p-white); +} + +/* ── MIDNIGHT (original navy) — also the default portal/Storybook dark ────── */ +[data-theme="dark"], +html[data-app-theme="midnight"] { + --c-bg: var(--p-zinc-900); + --c-bg-raised: var(--p-zinc-850); + --c-surface: var(--p-zinc-800); + --c-surface-raised: var(--p-zinc-650); + --c-surface-sunken: var(--p-zinc-850); + --c-input-bg: var(--p-zinc-650); + --c-hover: var(--p-gray-800); + --c-active: var(--p-gray-800); + --c-overlay: rgba(0, 0, 0, 0.6); + + --c-text: var(--p-zinc-100); + --c-text-muted: var(--p-zinc-200); + --c-text-subtle: var(--p-zinc-300); + --c-text-on-primary: var(--p-white); + + --c-border: var(--p-zinc-650); + --c-border-subtle: rgba(255, 255, 255, 0.05); + --c-border-strong: var(--p-zinc-500); + + --c-primary: var(--p-blue-500); + --c-primary-hover: var(--p-blue-700); + --c-primary-subtle: color-mix(in srgb, var(--p-blue-500) 15%, transparent); + + --c-success: var(--p-green-500); + --c-danger: var(--p-red-500); + --c-warning: var(--p-amber-500); + + /* Themed decorative dark overrides (see :root for light + rationale). */ + --c-file-header-resting: var(--p-navy-950); + --c-onboarding-hero: linear-gradient( + 155deg, + var(--p-navy-850) 0%, + var(--p-navy-880) 55%, + var(--p-plum-900) 100% + ); + --c-onboarding-hero-tile: var(--p-navy-900); +} + +/* ── Base accent — fixed default (blue buttons); [data-accent="default"] blocks at the end keep surfaces neutral. LIGHT base here, DARK base next. ── */ +html[data-app-theme="custom"] { + --c-primary: var(--p-blue-500); + --c-primary-hover: color-mix(in srgb, var(--c-primary) 85%, var(--p-black)); + --c-primary-subtle: color-mix(in srgb, var(--c-primary) 14%, transparent); + --c-text-on-primary: var(--p-white); + --c-accent-fg: var(--c-primary); + + /* Primary-tinted surfaces (light base). Neutralised by the default override. */ + --c-bg: color-mix(in srgb, var(--c-primary) 7%, var(--p-gray-50)); + --c-bg-raised: color-mix(in srgb, var(--c-primary) 4%, var(--p-white)); + --c-surface: color-mix(in srgb, var(--c-primary) 3%, var(--p-white)); + --c-surface-raised: color-mix(in srgb, var(--c-primary) 4%, var(--p-white)); + --c-surface-sunken: color-mix( + in srgb, + var(--c-primary) 8%, + var(--p-gray-100) + ); + --c-input-bg: color-mix(in srgb, var(--c-primary) 2%, var(--p-white)); + --c-hover: color-mix(in srgb, var(--c-primary) 9%, var(--p-gray-50)); + --c-active: color-mix(in srgb, var(--c-primary) 13%, var(--p-gray-100)); + --c-border: color-mix(in srgb, var(--c-primary) 14%, var(--p-gray-250)); + --c-border-subtle: color-mix( + in srgb, + var(--c-primary) 10%, + var(--p-gray-200) + ); + + /* Mantine primary ramp (--color-primary-*) derived from --c-primary. */ + --color-primary-50: color-mix(in srgb, var(--c-primary) 12%, var(--p-white)); + --color-primary-100: color-mix(in srgb, var(--c-primary) 20%, var(--p-white)); + --color-primary-200: color-mix(in srgb, var(--c-primary) 35%, var(--p-white)); + --color-primary-300: color-mix(in srgb, var(--c-primary) 55%, var(--p-white)); + --color-primary-400: color-mix(in srgb, var(--c-primary) 78%, var(--p-white)); + --color-primary-500: var(--c-primary); + --color-primary-600: color-mix(in srgb, var(--c-primary) 88%, var(--p-black)); + --color-primary-700: color-mix(in srgb, var(--c-primary) 74%, var(--p-black)); + --color-primary-800: color-mix(in srgb, var(--c-primary) 60%, var(--p-black)); + --color-primary-900: color-mix(in srgb, var(--c-primary) 46%, var(--p-black)); + --mantine-primary-color-filled: var(--c-primary); + --mantine-primary-color-filled-hover: var(--c-primary-hover); + /* Mantine "light" variant surfaces (chips, subtle buttons, some panels). */ + --mantine-primary-color-light: color-mix( + in srgb, + var(--c-primary) 12%, + transparent + ); + --mantine-primary-color-light-hover: color-mix( + in srgb, + var(--c-primary) 18%, + transparent + ); + --mantine-primary-color-light-color: var(--c-primary); + + /* Brand-tint family — re-derived from --c-primary so legacy accents harmonise to the chosen hue. */ + + /* Fills / borders — text uses --c-text-on-primary. */ + --btn-open-file: var(--c-primary); + --header-selected-bg: var(--c-primary); + --header-selected-fg: var(--c-text-on-primary); + --checkbox-checked-bg: var(--c-primary); + --card-selected-border: var(--c-primary); + + /* Accent-as-text/icon tokens live in compat.css (:root:root wins); only --icon-files-color is owned here. */ + --icon-files-color: var(--c-accent-fg); + + /* Subtle surface tints (mix with the base surface so they adapt light/dark) */ + --tool-header-border: color-mix( + in srgb, + var(--c-primary) 28%, + var(--c-surface) + ); + --tool-header-badge-bg: color-mix( + in srgb, + var(--c-primary) 20%, + var(--c-surface) + ); + --tooltip-title-bg: color-mix( + in srgb, + var(--c-primary) 12%, + var(--c-surface) + ); + --landing-inner-paper-bg: color-mix( + in srgb, + var(--c-primary) 8%, + var(--c-surface) + ); + --landing-inner-paper-border: color-mix( + in srgb, + var(--c-primary) 25%, + var(--c-surface) + ); + --landing-button-border: color-mix( + in srgb, + var(--c-primary) 22%, + var(--c-surface) + ); + --landing-hero-gradient: linear-gradient( + 135deg, + var(--c-primary) 0%, + var(--c-primary-hover) 100% + ); + + /* Translucent state tints */ + --color-nav-active: color-mix(in srgb, var(--c-primary) 12%, transparent); + --modal-nav-item-active-bg: color-mix( + in srgb, + var(--c-primary) 10%, + transparent + ); + --pdf-selection-bg: color-mix(in srgb, var(--c-primary) 20%, transparent); + --pdf-selection-ring: color-mix(in srgb, var(--c-primary) 28%, transparent); + --tool-panel-search-bg: color-mix( + in srgb, + var(--c-primary) 6%, + var(--c-surface) + ); +} + +/* ── DARK — editor dark theme: neutral text/borders/icons + accent-tinted surfaces (default override opts out). After :root so it wins for dark. ── */ +html[data-app-theme="custom"][data-mantine-color-scheme="dark"] { + /* Neutral text / borders / overlay (not accent-tinted). */ + --c-text: var(--p-zinc-100); + --c-text-muted: var(--p-zinc-200); + --c-text-subtle: var(--p-zinc-300); + --c-border-strong: var(--p-zinc-500); + --c-overlay: rgba(0, 0, 0, 0.6); + + /* Accent-tinted surfaces (dark base). Neutralised by the default override. */ + --c-bg: color-mix(in srgb, var(--c-primary) 8%, var(--p-zinc-950)); + --c-bg-raised: color-mix(in srgb, var(--c-primary) 9%, var(--p-zinc-850)); + --c-surface: color-mix(in srgb, var(--c-primary) 8%, var(--p-zinc-800)); + --c-surface-raised: color-mix( + in srgb, + var(--c-primary) 9%, + var(--p-zinc-775) + ); + --c-surface-sunken: color-mix( + in srgb, + var(--c-primary) 8%, + var(--p-zinc-900) + ); + --c-input-bg: color-mix(in srgb, var(--c-primary) 7%, var(--p-zinc-900)); + --c-hover: color-mix(in srgb, var(--c-primary) 12%, var(--p-zinc-750)); + --c-active: color-mix(in srgb, var(--c-primary) 15%, var(--p-zinc-700)); + --c-border: color-mix(in srgb, var(--c-primary) 14%, var(--p-zinc-650)); + --c-border-subtle: color-mix( + in srgb, + var(--c-primary) 10%, + var(--p-zinc-700) + ); + + /* Dark-tuned status shades (lighter than the light-theme :root values). */ + --c-success: var(--p-green-500); + --c-danger: var(--p-red-400); + --c-warning: var(--p-amber-500); + + /* Themed decorative dark overrides (see :root for light + rationale). */ + --c-file-header-resting: var(--p-navy-950); + --c-onboarding-hero: linear-gradient( + 155deg, + var(--p-navy-850) 0%, + var(--p-navy-880) 55%, + var(--p-plum-900) 100% + ); + --c-onboarding-hero-tile: var(--p-navy-900); + + /* Category icon chips → neutral. */ + --icon-tools-bg: var(--c-surface-raised); + --icon-inactive-bg: var(--c-surface-raised); + --icon-tools-color: var(--c-text-muted); + --icon-files-color: var(--c-text-muted); + --icon-inactive-color: var(--c-text-muted); + --onboarding-step-inactive: var(--c-border-strong); + + /* Point Mantine's dark ramp at the --c-* surfaces so components follow the theme. */ + --mantine-color-dark-4: var(--c-border); + --mantine-color-dark-5: var(--c-surface-raised); + --mantine-color-dark-6: var(--c-surface); + --mantine-color-dark-7: var(--c-bg); + --mantine-color-body: var(--c-bg); + --mantine-color-default: var(--c-surface); + --mantine-color-default-hover: var(--c-hover); +} + +/* ── DEFAULT (no tint) — surfaces opt out of the accent tint (neutral white/grey light, zinc dark); --c-primary stays for buttons. Extra [data-accent="default"] beats the tinted blocks. ── */ +html[data-app-theme="custom"][data-accent="default"] { + --c-bg: var(--p-gray-50); + --c-bg-raised: var(--p-white); + --c-surface: var(--p-white); + --c-surface-raised: var(--p-white); + --c-surface-sunken: var(--p-gray-100); + --c-input-bg: var(--p-white); + --c-hover: var(--p-gray-50); + --c-active: var(--p-gray-100); + --c-border: var(--p-gray-250); + --c-border-subtle: var(--p-gray-200); +} + +html[data-app-theme="custom"][data-accent="default"][data-mantine-color-scheme="dark"] { + --c-bg: var(--p-zinc-950); + --c-bg-raised: var(--p-zinc-850); + --c-surface: var(--p-zinc-800); + --c-surface-raised: var(--p-zinc-775); + --c-surface-sunken: var(--p-zinc-900); + --c-input-bg: var(--p-zinc-900); + --c-hover: var(--p-zinc-750); + --c-active: var(--p-zinc-700); + --c-border: var(--p-zinc-650); + --c-border-subtle: var(--p-zinc-700); +} diff --git a/frontend/editor/src/core/theme/dimensions.css b/frontend/editor/src/core/theme/dimensions.css new file mode 100644 index 0000000000..8042fcade6 --- /dev/null +++ b/frontend/editor/src/core/theme/dimensions.css @@ -0,0 +1,65 @@ +/* DIMENSIONS — single source for every non-colour length (spacing, radius, sizing, borders, z-index, shadow, type, motion). Theme-agnostic. */ + +:root { + /* ── Spacing — 4px grid. Canonical numeric scale + named aliases ── */ + --space-0: 0; + --space-0_5: 0.125rem; /* 2px */ + --space-1: 0.25rem; /* 4px */ + --space-1_5: 0.375rem; /* 6px */ + --space-2: 0.5rem; /* 8px */ + --space-3: 0.75rem; /* 12px */ + --space-4: 1rem; /* 16px */ + --space-5: 1.25rem; /* 20px */ + --space-6: 1.5rem; /* 24px */ + --space-8: 2rem; /* 32px */ + + /* T-shirt aliases (legacy editor names) → the numeric scale above. */ + --space-xs: var(--space-1); /* 4px */ + --space-sm: var(--space-2); /* 8px */ + --space-md: var(--space-4); /* 16px */ + --space-lg: var(--space-6); /* 24px */ + --space-xl: var(--space-8); /* 32px */ + + /* ── Radius — one coherent scale (resolves the 8px-vs-6px collision) ── */ + --radius-xs: 2px; + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-xl: 16px; + --radius-pill: 9999px; + + /* ── Layout sizing ── */ + --footer-height: 2rem; + --landing-stack-w: 224px; + --landing-stack-h: 176px; + + /* --shadow-* is intentionally NOT defined here: editor (drop shadows) and SUI/portal (inset hairlines) reuse the name with different values. */ + + /* ── Typography ── */ + --font-sans: + "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, + sans-serif; + --font-mono: + "SF Mono", "Fira Code", Menlo, Consolas, "DejaVu Sans Mono", monospace; + --font-brand: "Alumni Sans", "Inter", sans-serif; + + --font-weight-medium: 500; + --font-weight-semibold: 600; + + /* ── Motion ── */ + --motion-fast: 0.15s ease; + --motion-base: 0.2s cubic-bezier(0.4, 0, 0.2, 1); + --motion-slow: 0.3s ease; + --motion-enter: 0.22s cubic-bezier(0.4, 0, 0.2, 1); + --fullscreen-anim-duration-in: 0.28s; + --fullscreen-anim-duration-out: 0.22s; + + /* ── Z-index ladder ── */ + --z-dropdown: 25; + --z-drawer: 50; + --z-toast: 200; + /* Fullscreen tool-picker surfaces (editor) */ + --z-fullscreen-icon-svg: 1; + --z-toolpicker-star: 1; + --z-fullscreen-surface: 1200; +} diff --git a/frontend/editor/src/core/theme/index.css b/frontend/editor/src/core/theme/index.css new file mode 100644 index 0000000000..9131c18ccb --- /dev/null +++ b/frontend/editor/src/core/theme/index.css @@ -0,0 +1,4 @@ +/* Consolidated theme entry. Order matters: primitives → dimensions → colors. */ +@import "./primitives.css"; +@import "./dimensions.css"; +@import "./colors.css"; diff --git a/frontend/editor/src/core/theme/mantineTheme.ts b/frontend/editor/src/core/theme/mantineTheme.ts index 743142e820..bd0f7b764f 100644 --- a/frontend/editor/src/core/theme/mantineTheme.ts +++ b/frontend/editor/src/core/theme/mantineTheme.ts @@ -58,21 +58,18 @@ const gray: MantineColorsTuple = [ "var(--color-gray-900)", ]; -// Navy-indigo dark scale — replaces Mantine's neutral gray defaults so all -// dark-mode components (SegmentedControl, inputs, dropdowns, etc.) use the -// portal palette automatically via --mantine-color-dark-*. -// dark-0..3 = text/icon shades, dark-4..7 = surface elevations, dark-8..9 = deepest bg. +// Neutral dark scale (zinc, mirroring --p-zinc-*) replacing Mantine's default gray ramp; colors.css re-points dark-4..7 at the --c-* surfaces. 0..3 text, 4..7 surfaces, 8..9 deepest. const dark: MantineColorsTuple = [ - "#c2c8e0", // dark-0 — primary text on dark bg - "#9299b0", // dark-1 — secondary text - "#6e7898", // dark-2 — muted text / icons - "#4a5282", // dark-3 — subtle text / dividers - "#1c2340", // dark-4 — elevated surface / selected bg (e.g. SegmentedControl indicator) - "#131729", // dark-5 — card / panel surface - "#0d1020", // dark-6 — toolbar / sidebar bg (e.g. SegmentedControl root) - "#090b18", // dark-7 — page background (deepest reachable surface) - "#07091a", // dark-8 - "#050714", // dark-9 + "#f4f4f5", // dark-0 — primary text on dark bg (zinc-100) + "#a1a1aa", // dark-1 — secondary text (zinc-200) + "#71717a", // dark-2 — muted text / icons (zinc-300) + "#52525b", // dark-3 — subtle text / dividers (zinc-400) + "#2a2a2e", // dark-4 — elevated surface / selected bg (zinc-650) + "#202023", // dark-5 — card / panel surface (zinc-775) + "#18181b", // dark-6 — toolbar / sidebar bg (zinc-800) + "#0f0f10", // dark-7 — page background (zinc-950) + "#070708", // dark-8 — deeper than the reachable surfaces + "#050506", // dark-9 — deepest ]; export const mantineTheme = createTheme({ @@ -121,7 +118,7 @@ export const mantineTheme = createTheme({ overlayBorder: "var(--color-primary-500)", overlayBackground: "rgba(59, 130, 246, 0.1)", // Blue with 10% opacity handleColor: "var(--color-primary-500)", - handleBorder: "var(--bg-surface)", + handleBorder: "var(--c-surface)", }, }, @@ -138,11 +135,11 @@ export const mantineTheme = createTheme({ // Custom button variant for PDF tools pdfTool: (_theme: MantineTheme) => ({ root: { - backgroundColor: "var(--bg-surface)", - border: "1px solid var(--border-default)", - color: "var(--text-primary)", + backgroundColor: "var(--c-surface)", + border: "1px solid var(--c-border)", + color: "var(--c-text)", "&:hover": { - backgroundColor: "var(--hover-bg)", + backgroundColor: "var(--c-hover)", borderColor: "var(--color-primary-500)", }, }, @@ -153,8 +150,8 @@ export const mantineTheme = createTheme({ Paper: { styles: { root: { - backgroundColor: "var(--bg-surface)", - border: "1px solid var(--border-subtle)", + backgroundColor: "var(--c-surface)", + border: "1px solid var(--c-border-subtle)", }, }, }, @@ -162,8 +159,8 @@ export const mantineTheme = createTheme({ Card: { styles: { root: { - backgroundColor: "var(--bg-surface)", - border: "1px solid var(--border-subtle)", + backgroundColor: "var(--c-surface)", + border: "1px solid var(--c-border-subtle)", boxShadow: "var(--shadow-sm)", }, }, @@ -173,7 +170,7 @@ export const mantineTheme = createTheme({ styles: { root: { backgroundColor: "var(--color-gray-100)", - color: "var(--text-primary)", + color: "var(--c-text)", }, }, }, @@ -181,16 +178,16 @@ export const mantineTheme = createTheme({ Textarea: { styles: (_theme: MantineTheme) => ({ input: { - backgroundColor: "var(--bg-surface)", - borderColor: "var(--border-default)", - color: "var(--text-primary)", + backgroundColor: "var(--c-surface)", + borderColor: "var(--c-border)", + color: "var(--c-text)", "&:focus": { borderColor: "var(--color-primary-500)", boxShadow: "0 0 0 1px var(--color-primary-500)", }, }, label: { - color: "var(--text-secondary)", + color: "var(--c-text-muted)", fontWeight: "var(--font-weight-medium)", }, }), @@ -199,16 +196,16 @@ export const mantineTheme = createTheme({ TextInput: { styles: (_theme: MantineTheme) => ({ input: { - backgroundColor: "var(--bg-surface)", - borderColor: "var(--border-default)", - color: "var(--text-primary)", + backgroundColor: "var(--c-surface)", + borderColor: "var(--c-border)", + color: "var(--c-text)", "&:focus": { borderColor: "var(--color-primary-500)", boxShadow: "0 0 0 1px var(--color-primary-500)", }, }, label: { - color: "var(--text-secondary)", + color: "var(--c-text-muted)", fontWeight: "var(--font-weight-medium)", }, }), @@ -217,16 +214,16 @@ export const mantineTheme = createTheme({ PasswordInput: { styles: (_theme: MantineTheme) => ({ input: { - backgroundColor: "var(--bg-surface)", - borderColor: "var(--border-default)", - color: "var(--text-primary)", + backgroundColor: "var(--c-surface)", + borderColor: "var(--c-border)", + color: "var(--c-text)", "&:focus": { borderColor: "var(--color-primary-500)", boxShadow: "0 0 0 1px var(--color-primary-500)", }, }, label: { - color: "var(--text-secondary)", + color: "var(--c-text-muted)", fontWeight: "var(--font-weight-medium)", }, }), @@ -235,26 +232,26 @@ export const mantineTheme = createTheme({ Select: { styles: { input: { - backgroundColor: "var(--bg-surface)", - borderColor: "var(--border-default)", - color: "var(--text-primary)", + backgroundColor: "var(--c-surface)", + borderColor: "var(--c-border)", + color: "var(--c-text)", "&:focus": { borderColor: "var(--color-primary-500)", boxShadow: "0 0 0 1px var(--color-primary-500)", }, }, label: { - color: "var(--text-secondary)", + color: "var(--c-text-muted)", fontWeight: "var(--font-weight-medium)", }, dropdown: { - backgroundColor: "var(--bg-surface)", - borderColor: "var(--border-subtle)", + backgroundColor: "var(--c-surface)", + borderColor: "var(--c-border-subtle)", boxShadow: "var(--shadow-lg)", }, option: { - color: "var(--text-primary)", - "--combobox-option-hover": "var(--hover-bg)", + color: "var(--c-text)", + "--combobox-option-hover": "var(--c-hover)", "--combobox-option-selected": "var(--color-primary-100)", }, }, @@ -263,26 +260,26 @@ export const mantineTheme = createTheme({ MultiSelect: { styles: { input: { - backgroundColor: "var(--bg-surface)", - borderColor: "var(--border-default)", - color: "var(--text-primary)", + backgroundColor: "var(--c-surface)", + borderColor: "var(--c-border)", + color: "var(--c-text)", "&:focus": { borderColor: "var(--color-primary-500)", boxShadow: "0 0 0 1px var(--color-primary-500)", }, }, label: { - color: "var(--text-secondary)", + color: "var(--c-text-muted)", fontWeight: "var(--font-weight-medium)", }, dropdown: { - backgroundColor: "var(--bg-surface)", - borderColor: "var(--border-subtle)", + backgroundColor: "var(--c-surface)", + borderColor: "var(--c-border-subtle)", boxShadow: "var(--shadow-lg)", }, option: { - color: "var(--text-primary)", - "--combobox-option-hover": "var(--hover-bg)", + color: "var(--c-text)", + "--combobox-option-hover": "var(--c-hover)", "--combobox-option-selected": "var(--color-primary-100)", }, }, @@ -290,9 +287,10 @@ export const mantineTheme = createTheme({ Tooltip: { styles: { tooltip: { - backgroundColor: "var( --tooltip-title-bg)", - color: "var( --tooltip-title-color)", - border: "1px solid var(--tooltip-borderp)", + backgroundColor: + "color-mix( in srgb, var(--c-primary) 12%, var(--c-surface) )", + color: "var(--c-text)", + border: "1px solid var(--tooltip-border)", fontSize: "0.75rem", fontWeight: "500", boxShadow: "var(--shadow-md)", @@ -304,14 +302,14 @@ export const mantineTheme = createTheme({ Checkbox: { styles: { input: { - borderColor: "var(--border-default)", + borderColor: "var(--c-border)", "&:checked": { backgroundColor: "var(--color-primary-500)", borderColor: "var(--color-primary-500)", }, }, label: { - color: "var(--text-primary)", + color: "var(--c-text)", }, }, }, @@ -319,7 +317,7 @@ export const mantineTheme = createTheme({ Slider: { styles: { track: { - backgroundColor: "var(--bg-muted)", + backgroundColor: "var(--c-surface-sunken)", }, bar: { backgroundColor: "var(--color-primary-500)", @@ -329,10 +327,10 @@ export const mantineTheme = createTheme({ borderColor: "var(--color-primary-500)", }, mark: { - borderColor: "var(--border-default)", + borderColor: "var(--c-border)", }, markLabel: { - color: "var(--text-muted)", + color: "var(--c-text-subtle)", }, }, }, @@ -340,16 +338,16 @@ export const mantineTheme = createTheme({ Modal: { styles: { content: { - backgroundColor: "var(--bg-surface)", - border: "1px solid var(--border-subtle)", + backgroundColor: "var(--c-surface)", + border: "1px solid var(--c-border-subtle)", boxShadow: "var(--shadow-xl)", }, header: { - backgroundColor: "var(--bg-surface)", - borderBottom: "1px solid var(--border-subtle)", + backgroundColor: "var(--c-surface)", + borderBottom: "1px solid var(--c-border-subtle)", }, title: { - color: "var(--text-primary)", + color: "var(--c-text)", fontWeight: "var(--font-weight-semibold)", }, }, @@ -358,15 +356,15 @@ export const mantineTheme = createTheme({ Notification: { styles: { root: { - backgroundColor: "var(--bg-surface)", - border: "1px solid var(--border-subtle)", + backgroundColor: "var(--c-surface)", + border: "1px solid var(--c-border-subtle)", boxShadow: "var(--shadow-lg)", }, title: { - color: "var(--text-primary)", + color: "var(--c-text)", }, description: { - color: "var(--text-secondary)", + color: "var(--c-text-muted)", }, }, }, diff --git a/frontend/editor/src/core/theme/primitives.css b/frontend/editor/src/core/theme/primitives.css new file mode 100644 index 0000000000..6ea844e9ed --- /dev/null +++ b/frontend/editor/src/core/theme/primitives.css @@ -0,0 +1,173 @@ +/* PRIMITIVES — the raw palette (theme-agnostic). The ONE place literal colours live: neutral ramps (gray=light, zinc=dark) + status hues; colors.css and compat.css reference these. */ + +:root { + --p-white: #ffffff; + --p-black: #000000; + --p-gray-50: #f9fafb; + --p-gray-100: #f3f4f6; + --p-gray-150: #eef0f2; + --p-gray-200: #e5e7eb; + --p-gray-250: #e2e8f0; + --p-gray-300: #d1d5db; + --p-gray-400: #9ca3af; + --p-gray-500: #6b7280; + --p-gray-600: #4b5563; + --p-gray-700: #374151; + --p-gray-800: #1f2937; + --p-gray-900: #111827; + --p-zinc-950: #0f0f10; + --p-zinc-900: #101012; + --p-zinc-850: #131315; + --p-zinc-800: #18181b; + --p-zinc-775: #202023; + --p-zinc-750: #1f1f23; + --p-zinc-700: #27272a; + --p-zinc-650: #2a2a2e; + --p-zinc-600: #333338; + --p-zinc-500: #3f3f46; + --p-zinc-400: #52525b; + --p-zinc-300: #71717a; + --p-zinc-200: #a1a1aa; + --p-zinc-100: #f4f4f5; + --p-blue-400: #60a5fa; + --p-blue-500: #3b82f6; + --p-blue-600: #2563eb; + --p-blue-700: #1d4ed8; + --p-green-500: #22c55e; + --p-green-600: #16a34a; + --p-green-700: #15803d; + --p-amber-400: #fbbf24; + --p-amber-500: #f59e0b; + --p-amber-600: #d97706; + --p-red-400: #f87171; + --p-red-500: #ef4444; + --p-red-600: #dc2626; + + /* Brand-red + ai-accent scales, consumed by core/ui/accents.css. */ + --p-brand-red-200: #d9a8a8; + --p-brand-red-300: #d98a8a; + --p-brand-red-650: #8e3131; + --p-brand-red-700: #7a2929; + --p-brand-red-900: #5a2424; + --p-cyan-400: #22d3ee; + --p-indigo-200: #c7d2fe; + --p-indigo-300: #a5b4fc; + --p-indigo-400: #818cf8; + --p-indigo-500: #6366f1; + --p-indigo-800: #3730a3; + + /* Extended accent/status/neutral weights referenced by app CSS. */ + --p-azure-300: #7ab4ff; + --p-azure-400: #38bdf8; + --p-cyan-500: #06b6d4; + --p-green-400: #4ade80; + --p-pink-500: #ec4899; + --p-purple-500: #6c5ce7; + --p-periwinkle-500: #635bff; + --p-teal-700: #0f7b6c; + --p-gray-450: #868e96; + --p-gray-b0: #b0b0b0; + --p-gray-mid: #808080; + + /* Brand red (login/auth CTAs) — base + hover, complementing the -200..-900 scale above. */ + --p-brand-red: #af3434; + --p-brand-red-600: #9a2e2e; + + /* Highlight flash (compare / show-JS / signature-report animations). */ + --p-flash-yellow: #ffeb3b; + + /* Onboarding hero gradient stops (dark decorative navies + light tints). */ + --p-navy-850: #1a2236; + --p-navy-880: #171e30; + --p-navy-900: #0f1626; + --p-plum-900: #201a28; + --p-tint-blue: #eef1fb; + --p-tint-violet: #f6f4fc; + --p-tint-pink: #fbf4f7; + + /* Notion-style procurement view palette. */ + --p-notion-blue: #2383e2; + --p-notion-blue-strong: #1b6ec2; + --p-notion-blue-border: #b8d5f2; + --p-notion-ink: #37352f; + --p-notion-gray: #9b9a97; + --p-notion-gray-strong: #787774; + --p-notion-paper: #f5f4f1; + --p-notion-paper-2: #f0eee9; + --p-notion-border: #e3e1dc; + --p-notion-border-2: #eae8e3; + --p-notion-border-cool: #d3d1cb; + + /* Vendor status chips (procurement / auth callback). */ + --p-vendor-red: #ef5350; + --p-vendor-red-text: #c62828; + --p-vendor-red-soft: #ef9a9a; + --p-vendor-red-bg: #ffebee; + --p-vendor-red-border: #ffcdd2; + --p-vendor-red-bg-dark: #3d2020; + --p-vendor-red-border-dark: #5d3030; + --p-vendor-green: #66bb6a; + + /* Palette entries migrated from literals in consuming files. */ + --p-c-0550ae: #0550ae; + --p-emerald-600: #059669; + --p-code-string: #0a3069; + --p-azure-500: #0a8bff; + --p-navy-950: #0d1020; + --p-c-0f172a: #0f172a; + --p-emerald-500: #10b981; + --p-navy-700: #16213e; + --p-c-1a2332: #1a2332; + --p-c-1c2340: #1c2340; + --p-c-1e293b: #1e293b; + --p-c-1f2328: #1f2328; + --p-royal-700: #2040a0; + --p-c-2d3560: #2d3560; + --p-emerald-400: #34d399; + --p-azure-650: #3a7be8; + --p-navy-500: #3b4b6e; + --p-c-475569: #475569; + --p-c-334155: #334155; + --p-c-cbd5e1: #cbd5e1; + --p-azure-550: #4c8bf5; + --p-c-545454: #545454; + --p-azure-450: #5b9bf7; + --p-c-64748b: #64748b; + --p-c-656d76: #656d76; + --p-c-6e7781: #6e7781; + --p-violet-600: #7c3aed; + --p-c-7e7e7e: #7e7e7e; + --p-c-8250df: #8250df; + --p-violet-500: #8b5cf6; + --p-c-8c959f: #8c959f; + --p-blue-200: #93c5fd; + --p-c-94a3b8: #94a3b8; + --p-code-type: #953800; + --p-violet-400: #a78bfa; + --p-c-acacac: #acacac; + --p-c-c084fc: #c084fc; + --p-art-red-muted: #c56565; + /* Muted category hues for connection-picker marks. */ + --p-art-blue-muted: #4a7ab5; + --p-art-green-muted: #5d8348; + --p-art-amber-muted: #ac7a2c; + --p-c-cf222e: #cf222e; + --p-c-d0d6dc: #d0d6dc; + --p-c-d0d7de: #d0d7de; + --p-c-d0dbdc: #d0dbdc; + --p-c-d3d3d3: #d3d3d3; + --p-c-d62626: #d62626; + --p-c-e6e6e6: #e6e6e6; + --p-c-eaeef2: #eaeef2; + --p-c-eef1f4: #eef1f4; + --p-c-f1f5f9: #f1f5f9; + --p-c-f472b6: #f472b6; + --p-c-f6f8fa: #f6f8fa; + --p-c-f8fafc: #f8fafc; + --p-art-red-soft: #fc9999; + --p-art-red-light: #fca5a5; + --p-art-red: #ff4b4b; + --p-c-ffc107: #ffc107; + --p-c-c2c8e0: #c2c8e0; + --p-c-4f8ef5: #4f8ef5; +} diff --git a/frontend/editor/src/core/tokens/Tokens.stories.tsx b/frontend/editor/src/core/tokens/Tokens.stories.tsx index f83c93517c..0003c06b17 100644 --- a/frontend/editor/src/core/tokens/Tokens.stories.tsx +++ b/frontend/editor/src/core/tokens/Tokens.stories.tsx @@ -42,8 +42,8 @@ function Group({ heading, swatches }: { heading: string; swatches: Swatch[] }) { alignItems: "center", gap: 10, padding: 10, - background: "var(--color-surface)", - border: "1px solid var(--color-border)", + background: "var(--c-surface)", + border: "1px solid var(--c-border)", borderRadius: "var(--radius-md)", }} > @@ -53,7 +53,7 @@ function Group({ heading, swatches }: { heading: string; swatches: Swatch[] }) { height: 36, borderRadius: 6, background: `var(${s.varName})`, - border: "1px solid var(--color-border)", + border: "1px solid var(--c-border)", flexShrink: 0, }} /> @@ -62,7 +62,7 @@ function Group({ heading, swatches }: { heading: string; swatches: Swatch[] }) { style={{ fontSize: 12, fontWeight: 500, - color: "var(--color-text-2)", + color: "var(--c-text-muted)", }} > {s.label} @@ -71,7 +71,7 @@ function Group({ heading, swatches }: { heading: string; swatches: Swatch[] }) { style={{ fontSize: 11, fontFamily: "var(--font-mono)", - color: "var(--color-text-4)", + color: "var(--c-text-subtle)", overflow: "hidden", textOverflow: "ellipsis", }} @@ -92,8 +92,10 @@ export const Colours: Story = {
    @@ -179,7 +181,7 @@ export const Typography: Story = {
    @@ -191,7 +193,7 @@ export const Typography: Story = {
    @@ -205,7 +207,7 @@ export const Typography: Story = {
    @@ -219,7 +221,7 @@ export const Typography: Story = {
    @@ -238,8 +240,8 @@ export const Motion: Story = {
    { data-slot-state="filled" data-slot-filename={stub?.name} style={{ - border: "1px solid var(--border-default)", + border: "1px solid var(--c-border)", borderRadius: "var(--radius-md)", padding: "0.75rem 1rem", - background: "var(--bg-surface)", + background: "var(--c-surface)", width: "100%", minHeight: "9rem", position: "relative", diff --git a/frontend/editor/src/core/tools/formFill/FormFieldSidebar.tsx b/frontend/editor/src/core/tools/formFill/FormFieldSidebar.tsx index 2e49991ceb..d348c5c241 100644 --- a/frontend/editor/src/core/tools/formFill/FormFieldSidebar.tsx +++ b/frontend/editor/src/core/tools/formFill/FormFieldSidebar.tsx @@ -82,9 +82,9 @@ export function FormFieldSidebar({ visible, onToggle }: FormFieldSidebarProps) { zIndex: 999, display: "flex", flexDirection: "column", - background: "var(--bg-toolbar, var(--mantine-color-body))", + background: "var(--c-bg-raised, var(--mantine-color-body))", borderLeft: - "1px solid var(--border-subtle, var(--mantine-color-default-border))", + "1px solid var(--c-border-subtle, var(--mantine-color-default-border))", boxShadow: "-4px 0 16px rgba(0,0,0,0.08)", }} > @@ -96,7 +96,7 @@ export function FormFieldSidebar({ visible, onToggle }: FormFieldSidebarProps) { justifyContent: "space-between", padding: "0.625rem 0.75rem", borderBottom: - "1px solid var(--border-subtle, var(--mantine-color-default-border))", + "1px solid var(--c-border-subtle, var(--mantine-color-default-border))", flexShrink: 0, }} > diff --git a/frontend/editor/src/core/tools/formFill/FormFill.module.css b/frontend/editor/src/core/tools/formFill/FormFill.module.css index 4cffc284f5..800e610c03 100644 --- a/frontend/editor/src/core/tools/formFill/FormFill.module.css +++ b/frontend/editor/src/core/tools/formFill/FormFill.module.css @@ -8,8 +8,7 @@ .modeTabs { flex-shrink: 0; - border-bottom: 1px solid - var(--border-default, var(--mantine-color-default-border)); + border-bottom: 1px solid var(--c-border, var(--mantine-color-default-border)); background: transparent; padding: 0.25rem; } @@ -44,7 +43,7 @@ font-weight: 700; text-transform: uppercase; letter-spacing: 0.02em; - color: var(--text-muted); + color: var(--c-text-subtle); transition: color 0.15s ease; line-height: 1; } @@ -59,8 +58,7 @@ flex-shrink: 0; padding: 0.75rem 1rem; background: transparent; - border-bottom: 1px solid - var(--border-default, var(--mantine-color-default-border)); + border-bottom: 1px solid var(--c-border, var(--mantine-color-default-border)); display: flex; flex-direction: column; gap: 0.625rem; @@ -76,7 +74,7 @@ .progressLabel { font-size: 0.6875rem; font-weight: 600; - color: var(--text-muted); + color: var(--c-text-subtle); white-space: nowrap; } @@ -133,7 +131,7 @@ content: ""; flex: 1; height: 1px; - background: var(--border-default, var(--mantine-color-default-border)); + background: var(--c-border, var(--mantine-color-default-border)); opacity: 0.2; } @@ -142,22 +140,22 @@ font-weight: 800; text-transform: uppercase; letter-spacing: 0.1em; - color: var(--text-muted); + color: var(--c-text-subtle); opacity: 0.6; } .fieldCard { padding: 0.625rem 0.75rem; border-radius: var(--radius-md); - border: 1px solid var(--border-default, var(--mantine-color-default-border)); - background: var(--bg-surface, var(--mantine-color-body)); + border: 1px solid var(--c-border, var(--mantine-color-default-border)); + background: var(--c-surface, var(--mantine-color-body)); cursor: pointer; transition: all 0.15s ease; } .fieldCard:hover { border-color: var(--mantine-color-blue-5); - background: var(--bg-surface); + background: var(--c-surface); } .fieldCardActive { @@ -193,7 +191,7 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - color: var(--text-primary); + color: var(--c-text); } .fieldRequired { @@ -214,7 +212,7 @@ .fieldHint { margin-top: 0.375rem; font-size: 0.6875rem; - color: var(--text-muted); + color: var(--c-text-subtle); line-height: 1.4; font-style: italic; opacity: 0.8; @@ -235,7 +233,7 @@ gap: 0.75rem; padding: 5rem 1.5rem; text-align: center; - color: var(--text-muted); + color: var(--c-text-subtle); background: transparent; } @@ -263,14 +261,13 @@ .statusBar { flex-shrink: 0; padding: 0.5rem 1rem; - border-top: 1px solid - var(--border-default, var(--mantine-color-default-border)); + border-top: 1px solid var(--c-border, var(--mantine-color-default-border)); display: flex; align-items: center; justify-content: space-between; font-size: 0.6875rem; font-weight: 600; - color: var(--text-muted); + color: var(--c-text-subtle); background: transparent; } @@ -294,7 +291,7 @@ .comingSoonTitle { font-size: 1rem; font-weight: 800; - color: var(--text-primary); + color: var(--c-text); text-transform: uppercase; letter-spacing: 0.05em; } @@ -302,6 +299,6 @@ .comingSoonDesc { font-size: 0.75rem; line-height: 1.6; - color: var(--text-muted); + color: var(--c-text-subtle); max-width: 200px; } diff --git a/frontend/editor/src/core/types/validateSignature.ts b/frontend/editor/src/core/types/validateSignature.ts index 7bd48347c1..4e18eaec7a 100644 --- a/frontend/editor/src/core/types/validateSignature.ts +++ b/frontend/editor/src/core/types/validateSignature.ts @@ -8,7 +8,7 @@ export interface SignatureValidationBackendResult { coversEntireDocument?: boolean | null; // false = content appended after signing revocationChecked?: boolean | null; revocationStatus?: string | null; // "not-checked" | "good" | "revoked" | "soft-fail" | "unknown" - validationTimeSource?: string | null; // "current" | "signing-time" | "timestamp" + validationTimeSource?: string | null; // "current" | "signing-time" | "timestamp" | "document-timestamp" signerName?: string | null; signatureDate?: string | null; reason?: string | null; @@ -37,7 +37,7 @@ export interface SignatureValidationSignature { coversEntireDocument?: boolean | null; // false = content appended after signing revocationChecked?: boolean | null; revocationStatus?: string | null; // "not-checked" | "good" | "revoked" | "soft-fail" | "unknown" - validationTimeSource?: string | null; // "current" | "signing-time" | "timestamp" + validationTimeSource?: string | null; // "current" | "signing-time" | "timestamp" | "document-timestamp" signerName: string; signatureDate: string; reason: string; diff --git a/frontend/editor/src/core/ui/ActionIcon.tsx b/frontend/editor/src/core/ui/ActionIcon.tsx index 44584be569..682e4b41e5 100644 --- a/frontend/editor/src/core/ui/ActionIcon.tsx +++ b/frontend/editor/src/core/ui/ActionIcon.tsx @@ -100,7 +100,7 @@ export const ActionIcon = forwardRef( "--ai-bg": "transparent", "--ai-hover": "transparent", "--ai-color": "var(--_text)", - "--ai-hover-color": "var(--color-text-1)", + "--ai-hover-color": "var(--c-text)", "--ai-bd": "1px solid transparent", } : { diff --git a/frontend/editor/src/core/ui/Avatar.css b/frontend/editor/src/core/ui/Avatar.css index 21089db8f0..e2e8dc63bd 100644 --- a/frontend/editor/src/core/ui/Avatar.css +++ b/frontend/editor/src/core/ui/Avatar.css @@ -70,6 +70,6 @@ background: var(--grad-red-btn); } .sui-avatar--neutral { - background: var(--color-bg-muted); - color: var(--color-text-2); + background: var(--c-surface-sunken); + color: var(--c-text-muted); } diff --git a/frontend/editor/src/core/ui/Banner.css b/frontend/editor/src/core/ui/Banner.css index 937905a915..6f3a53e930 100644 --- a/frontend/editor/src/core/ui/Banner.css +++ b/frontend/editor/src/core/ui/Banner.css @@ -10,29 +10,29 @@ } .sui-banner--info { - background: var(--color-blue-light); - color: var(--color-text-2); - border-color: var(--color-blue-border); + background: var(--c-primary-tint); + color: var(--c-text-muted); + border-color: var(--c-primary-border); } .sui-banner--success { background: var(--color-green-light); - color: var(--color-text-2); + color: var(--c-text-muted); border-color: var(--color-green-border); } .sui-banner--warning { background: var(--color-amber-light); - color: var(--color-text-2); + color: var(--c-text-muted); border-color: var(--color-amber-border); } .sui-banner--danger { background: var(--color-red-light); - color: var(--color-text-2); + color: var(--c-text-muted); border-color: var(--color-red-border); } .sui-banner--neutral { background: var(--color-bg-subtle); - color: var(--color-text-2); - border-color: var(--color-border); + color: var(--c-text-muted); + border-color: var(--c-border); } .sui-banner__icon { @@ -45,7 +45,7 @@ } .sui-banner--info .sui-banner__icon { - color: var(--color-blue); + color: var(--c-primary); } .sui-banner--success .sui-banner__icon { color: var(--color-green); @@ -63,10 +63,10 @@ } .sui-banner__title { font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); } .sui-banner__desc { - color: var(--color-text-3); + color: var(--c-text-subtle); margin-top: 0.125rem; } diff --git a/frontend/editor/src/core/ui/Button.css b/frontend/editor/src/core/ui/Button.css index e80bf13222..529d97fe16 100644 --- a/frontend/editor/src/core/ui/Button.css +++ b/frontend/editor/src/core/ui/Button.css @@ -61,11 +61,14 @@ background: var(--button-bg, transparent) !important; } +/* Disabled buttons in dark read as a muted surface (not a dimmed accent that + still looks clickable, and not an invisible transparent pill). Covers every + variant so a disabled primary and a disabled secondary look alike. */ +[data-theme="dark"] .sui-btn.mantine-Button-root:disabled:not([data-loading]), [data-theme="dark"] - .sui-btn--primary.mantine-Button-root:disabled:not([data-loading]), -[data-theme="dark"] - .sui-btn--primary.mantine-Button-root[data-disabled]:not([data-loading]) { - background: var(--button-bg); - color: var(--button-color); - opacity: 0.55; + .sui-btn.mantine-Button-root[data-disabled]:not([data-loading]) { + background: var(--c-surface-sunken); + color: var(--c-text-subtle); + border-color: transparent; + opacity: 1; } diff --git a/frontend/editor/src/core/ui/Button.stories.tsx b/frontend/editor/src/core/ui/Button.stories.tsx index d35a24aed7..374d31c46e 100644 --- a/frontend/editor/src/core/ui/Button.stories.tsx +++ b/frontend/editor/src/core/ui/Button.stories.tsx @@ -172,7 +172,7 @@ export const Loading: Story = { /** Disabled primary — dark mode keeps a muted accent instead of grey. */ export const DisabledDark: Story = { render: () => ( -
    +
    setOpen(false)}> -

    +

    The drawer body scrolls when its content overflows. The header and footer (when present) are sticky.

    @@ -79,7 +79,7 @@ export const WithFooter: Story = { } > -

    +

    Sticky footer demo — scroll the body, footer stays anchored.

    diff --git a/frontend/editor/src/core/ui/Dropdown.css b/frontend/editor/src/core/ui/Dropdown.css index 736371e6ae..38b20ee1d8 100644 --- a/frontend/editor/src/core/ui/Dropdown.css +++ b/frontend/editor/src/core/ui/Dropdown.css @@ -8,8 +8,8 @@ top: calc(100% + var(--space-1)); min-width: 12rem; padding: var(--space-1); - background: var(--color-dropdown-bg); - border: 1px solid var(--color-dropdown-border); + background: var(--c-surface); + border: 1px solid var(--c-border); border-radius: var(--radius-md); box-shadow: var(--shadow-lg); z-index: var(--z-dropdown); @@ -34,7 +34,7 @@ width: 100%; text-align: left; font-size: 0.8125rem; - color: var(--color-text-2); + color: var(--c-text-muted); border-radius: var(--radius-sm); /* Explicit button reset: hosts without a global button reset (e.g. the editor) would otherwise show the UA's buttonface background and border. */ @@ -48,13 +48,13 @@ } .sui-dd__item:hover:not(.is-disabled) { - background: var(--color-dropdown-hover); - color: var(--color-text-1); + background: var(--c-hover); + color: var(--c-text); } .sui-dd__item.is-active { - background: var(--color-nav-active); - color: var(--color-nav-active-text); + background: var(--c-primary-subtle); + color: var(--c-accent-fg); font-weight: 500; } @@ -73,11 +73,11 @@ margin-left: auto; font-family: var(--font-mono); font-size: 0.6875rem; - color: var(--color-text-5); + color: var(--c-text-subtle); } .sui-dd__divider { height: 1px; - background: var(--color-divider); + background: var(--c-border-subtle); margin: var(--space-1) 0; } diff --git a/frontend/editor/src/core/ui/EmptyState.css b/frontend/editor/src/core/ui/EmptyState.css index e78db8b664..bf8250bc82 100644 --- a/frontend/editor/src/core/ui/EmptyState.css +++ b/frontend/editor/src/core/ui/EmptyState.css @@ -14,7 +14,7 @@ .sui-empty__icon { display: inline-flex; margin-bottom: var(--space-2); - color: var(--color-text-4); + color: var(--c-text-subtle); } .sui-empty__eyebrow { @@ -22,14 +22,14 @@ font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; - color: var(--color-blue); + color: var(--c-primary); } .sui-empty__title { margin: 0; font-size: 1.125rem; font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); } .sui-empty--compact .sui-empty__title { @@ -41,7 +41,7 @@ max-width: 32rem; font-size: 0.875rem; line-height: 1.5; - color: var(--color-text-3); + color: var(--c-text-subtle); } .sui-empty--compact .sui-empty__copy { diff --git a/frontend/editor/src/core/ui/FilePicker.stories.tsx b/frontend/editor/src/core/ui/FilePicker.stories.tsx new file mode 100644 index 0000000000..53fa1ef2a0 --- /dev/null +++ b/frontend/editor/src/core/ui/FilePicker.stories.tsx @@ -0,0 +1,41 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FilePicker } from "@app/ui/FilePicker"; + +const meta = { + title: "Primitives/FilePicker", + component: FilePicker, + parameters: { layout: "centered" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + onChange: () => {}, + children: "Choose file", + }, +}; + +export const AcceptPdf: Story = { + args: { + onChange: () => {}, + accept: "application/pdf", + children: "Choose PDF", + }, +}; + +export const Multiple: Story = { + args: { + onChange: () => {}, + multiple: true, + children: "Choose files", + }, +}; + +export const Disabled: Story = { + args: { + onChange: () => {}, + disabled: true, + children: "Choose file", + }, +}; diff --git a/frontend/editor/src/core/ui/FormField.css b/frontend/editor/src/core/ui/FormField.css index 0ae531bc65..f5cbe2d5fc 100644 --- a/frontend/editor/src/core/ui/FormField.css +++ b/frontend/editor/src/core/ui/FormField.css @@ -24,7 +24,7 @@ .sui-field__help { font-size: 0.6875rem; - color: var(--color-text-4); + color: var(--c-text-subtle); line-height: 1.45; } diff --git a/frontend/editor/src/core/ui/IconBadge.css b/frontend/editor/src/core/ui/IconBadge.css index aaca06f217..9aa3f8f46f 100644 --- a/frontend/editor/src/core/ui/IconBadge.css +++ b/frontend/editor/src/core/ui/IconBadge.css @@ -7,7 +7,7 @@ /* Resolved tint. Each accent class sets --ib-base; --ib-accent defaults to it but a consumer can override --ib-accent alone (e.g. to neutralise the badge until hover) without losing the per-accent base. */ - --ib-accent: var(--ib-base, var(--color-blue)); + --ib-accent: var(--ib-base, var(--c-primary)); color: var(--ib-accent); background: color-mix(in srgb, var(--ib-accent) 14%, transparent); } @@ -20,7 +20,7 @@ height: 2rem; } .sui-iconbadge--blue { - --ib-base: var(--color-blue); + --ib-base: var(--c-primary); } .sui-iconbadge--purple { --ib-base: var(--color-purple); @@ -38,6 +38,6 @@ --ib-base: var(--color-orange); } .sui-iconbadge--neutral { - --ib-base: var(--color-text-1); + --ib-base: var(--c-text); background: none; } diff --git a/frontend/editor/src/core/ui/Inline.stories.tsx b/frontend/editor/src/core/ui/Inline.stories.tsx index 10233e1371..5149f36018 100644 --- a/frontend/editor/src/core/ui/Inline.stories.tsx +++ b/frontend/editor/src/core/ui/Inline.stories.tsx @@ -29,7 +29,7 @@ export const SpaceBetween: Story = { style={{ width: "30rem", padding: 12, - border: "1px solid var(--color-border)", + border: "1px solid var(--c-border)", borderRadius: 8, }} > diff --git a/frontend/editor/src/core/ui/Input.css b/frontend/editor/src/core/ui/Input.css index 09a7ca0cda..3c68bdd8c5 100644 --- a/frontend/editor/src/core/ui/Input.css +++ b/frontend/editor/src/core/ui/Input.css @@ -3,18 +3,18 @@ align-items: center; gap: var(--space-2); padding: 0 var(--space-2); - background: var(--color-surface); - border: 1px solid var(--color-border-input); + background: var(--c-surface); + border: 1px solid var(--c-border); border-radius: var(--radius-md); - color: var(--color-text-1); + color: var(--c-text); transition: border-color var(--motion-fast), box-shadow var(--motion-fast); } .sui-input:focus-within { - border-color: var(--color-blue); - box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-blue) 16%, transparent); + border-color: var(--c-primary); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--c-primary) 16%, transparent); } .sui-input--sm { @@ -61,6 +61,6 @@ .sui-input__icon { display: inline-flex; align-items: center; - color: var(--color-text-4); + color: var(--c-text-subtle); flex-shrink: 0; } diff --git a/frontend/editor/src/core/ui/LabelChip.css b/frontend/editor/src/core/ui/LabelChip.css deleted file mode 100644 index 6be1332b0a..0000000000 --- a/frontend/editor/src/core/ui/LabelChip.css +++ /dev/null @@ -1,57 +0,0 @@ -/* Shared classification-label pill (team labels editor + sidebar category manager). */ - -.sui-labelchip { - display: inline-flex; - align-items: center; - gap: var(--space-1); - padding: 0.2rem 0.3rem 0.2rem 0.25rem; - border: 1px solid var(--border-default); - border-radius: var(--radius-lg); - background: var(--color-surface); - max-width: 16rem; -} - -.sui-labelchip-icon { - display: inline-flex; - align-items: center; - justify-content: center; - width: 1.5rem; - height: 1.5rem; - color: var(--color-text-2); -} - -.sui-labelchip-name { - font-size: 0.8125rem; - color: var(--color-text-1); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.sui-labelchip-count { - font-size: 0.6875rem; - font-weight: 500; - color: var(--color-text-3); - padding-left: 0.1rem; -} - -.sui-labelchip-remove { - display: inline-flex; - align-items: center; - justify-content: center; - width: 1.25rem; - height: 1.25rem; - border: none; - border-radius: 999px; - background: transparent; - color: var(--color-text-3); - cursor: pointer; - transition: - background var(--motion-fast), - color var(--motion-fast); -} - -.sui-labelchip-remove:hover { - background: var(--color-bg-hover); - color: var(--color-red); -} diff --git a/frontend/editor/src/core/ui/LabelChip.stories.tsx b/frontend/editor/src/core/ui/LabelChip.stories.tsx deleted file mode 100644 index 79fc9b1be3..0000000000 --- a/frontend/editor/src/core/ui/LabelChip.stories.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { LabelChip } from "@app/ui/LabelChip"; - -const meta: Meta = { - title: "Primitives/LabelChip", - component: LabelChip, - tags: ["autodocs"], - parameters: { layout: "centered" }, - args: { label: "Invoice", icon: "receipt-long" }, - argTypes: { - label: { control: "text" }, - icon: { control: "text" }, - count: { control: "number" }, - onRemove: { action: "removed" }, - }, -}; -export default meta; -type Story = StoryObj; - -/** The classification-label pill shared by the labels editor and the sidebar category manager. */ -export const Playground: Story = {}; - -/** With a file count, as shown in the sidebar category manager. */ -export const WithCount: Story = { - args: { label: "Contract", icon: "handshake", count: 12 }, -}; - -/** Removable — the trailing × appears when `onRemove` is set. */ -export const Removable: Story = { - args: { label: "NDA", icon: "lock", onRemove: () => {} }, -}; - -/** Falls back to the default "sell" icon when none is given. */ -export const DefaultIcon: Story = { - args: { label: "Uncategorised label", icon: undefined }, -}; - -/** Long names truncate rather than overflow the pill. */ -export const LongName: Story = { - args: { label: "Memorandum of understanding and mutual agreement", count: 3 }, -}; - -export const Row: Story = { - render: () => ( -
    - - {}} /> - - -
    - ), -}; diff --git a/frontend/editor/src/core/ui/LabelChip.tsx b/frontend/editor/src/core/ui/LabelChip.tsx deleted file mode 100644 index 7e45bc4a2a..0000000000 --- a/frontend/editor/src/core/ui/LabelChip.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import type { ReactNode } from "react"; -import CloseIcon from "@mui/icons-material/Close"; -import { LocalIcon } from "@app/components/shared/LocalIcon"; -import "@app/ui/LabelChip.css"; - -export interface LabelChipProps { - /** The label text. */ - label: string; - /** Material Symbols key for a leading icon; ignored when `leading` is given. */ - icon?: string; - /** Custom leading node (e.g. an icon picker), overriding `icon`. */ - leading?: ReactNode; - /** Optional trailing count (e.g. how many files carry this label). */ - count?: number; - /** Show a trailing `×`; called on click. */ - onRemove?: () => void; - /** Accessible name for the remove button. */ - removeAriaLabel?: string; -} - -/** - * A classification-label pill: leading icon (or a custom control like an icon - * picker) + name, with an optional count and remove button. The shared look for - * every place labels are shown as chips — the team labels editor and the - * sidebar category manager both render this so they stay visually identical. - */ -export function LabelChip({ - label, - icon, - leading, - count, - onRemove, - removeAriaLabel, -}: LabelChipProps) { - return ( - - {leading ?? ( - - - - )} - - {label} - - {count != null && count > 0 && ( - {count} - )} - {onRemove && ( - - )} - - ); -} diff --git a/frontend/editor/src/core/ui/ListRow.css b/frontend/editor/src/core/ui/ListRow.css index 8317b76fc5..836dba4d8f 100644 --- a/frontend/editor/src/core/ui/ListRow.css +++ b/frontend/editor/src/core/ui/ListRow.css @@ -6,20 +6,20 @@ padding: 0.7rem 0.875rem; text-align: left; background: transparent; - color: var(--color-text-1); + color: var(--c-text); } .sui-listrow--divider { - border-top: 1px solid var(--color-border); + border-top: 1px solid var(--c-border); } .sui-listrow--interactive { cursor: pointer; transition: background var(--motion-fast); } .sui-listrow--interactive:hover { - background: var(--color-bg-hover); + background: var(--c-hover); } .sui-listrow--interactive:focus-visible { - outline: 0.125rem solid var(--color-blue); + outline: 0.125rem solid var(--c-primary); outline-offset: -0.125rem; } @@ -32,8 +32,8 @@ border-radius: var(--radius-md); flex-shrink: 0; margin-top: 0.05rem; - color: var(--color-text-4); - background: var(--color-bg-muted); + color: var(--c-text-subtle); + background: var(--c-surface-sunken); } .sui-listrow__leading[data-tone="success"] { color: var(--color-green); @@ -48,8 +48,8 @@ background: color-mix(in srgb, var(--color-red) 14%, transparent); } .sui-listrow__leading[data-tone="info"] { - color: var(--color-blue); - background: color-mix(in srgb, var(--color-blue) 14%, transparent); + color: var(--c-primary); + background: color-mix(in srgb, var(--c-primary) 14%, transparent); } .sui-listrow__leading[data-tone="purple"] { color: var(--color-purple); @@ -65,19 +65,19 @@ .sui-listrow__title { font-size: 0.8125rem; font-weight: 500; - color: var(--color-text-1); + color: var(--c-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sui-listrow__desc { font-size: 0.75rem; - color: var(--color-text-4); + color: var(--c-text-subtle); margin-top: 0.125rem; } .sui-listrow__meta { font-size: 0.6875rem; - color: var(--color-text-4); + color: var(--c-text-subtle); margin-top: 0.125rem; } .sui-listrow__trailing { diff --git a/frontend/editor/src/core/ui/MantineForms.css b/frontend/editor/src/core/ui/MantineForms.css index fb95e15c07..ebedf547f7 100644 --- a/frontend/editor/src/core/ui/MantineForms.css +++ b/frontend/editor/src/core/ui/MantineForms.css @@ -13,11 +13,11 @@ * here act as a typed reference and as a fallback for any slot Mantine reads * before the inline vars are applied. */ .sui-mantine-wrapper { - --input-bg: var(--color-surface); - --input-bd: var(--color-border-input); - --input-bd-focus: var(--color-blue); + --input-bg: var(--c-surface); + --input-bd: var(--c-border); + --input-bd-focus: var(--c-primary); --input-radius: var(--radius-md); - --input-color: var(--color-text-1); + --input-color: var(--c-text); --input-placeholder-color: var(--color-text-placeholder); --input-height-sm: 1.75rem; --input-height-md: 2.25rem; @@ -31,7 +31,7 @@ /* SUI focus ring — matches .sui-input:focus-within */ .sui-mantine-wrapper[data-focused], .sui-mantine-wrapper:focus-within { - box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-blue) 16%, transparent); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--c-primary) 16%, transparent); } /* Error state */ @@ -54,9 +54,9 @@ /* ---- MultiSelect pills ---- */ /* Pills match SUI's Chip component: small rounded tags. */ .sui-mantine-pill { - background: var(--color-blue-light) !important; - color: var(--color-blue-dark) !important; - border: 1px solid var(--color-blue-border) !important; + background: var(--c-primary-tint) !important; + color: var(--c-primary-hover) !important; + border: 1px solid var(--c-primary-border) !important; border-radius: var(--radius-sm) !important; font-size: 0.75rem !important; font-weight: 500 !important; @@ -69,26 +69,26 @@ /* ---- NumberInput controls (increment/decrement buttons) ---- */ .sui-mantine-control { - border-color: var(--color-border-input) !important; - color: var(--color-text-3) !important; + border-color: var(--c-border) !important; + color: var(--c-text-subtle) !important; } .sui-mantine-control:hover { - background: var(--color-bg-hover) !important; - color: var(--color-text-1) !important; + background: var(--c-hover) !important; + color: var(--c-text) !important; } /* ---- Select: hide Mantine's right-section clear button border ---- */ .sui-mantine-wrapper .mantine-Select-section { - color: var(--color-text-3); + color: var(--c-text-subtle); } /* ---- Slider ---- */ .sui-mantine-slider { - --slider-color: var(--color-blue); - --slider-track-bg: var(--color-border); - --slider-thumb-color: var(--color-surface); - --slider-thumb-bd: var(--color-blue); + --slider-color: var(--c-primary); + --slider-track-bg: var(--c-border); + --slider-thumb-color: var(--c-surface); + --slider-thumb-bd: var(--c-primary); } .sui-mantine-slider:focus-within { @@ -97,13 +97,13 @@ /* Thumb focus ring matches SUI */ .sui-mantine-slider .mantine-Slider-thumb:focus-visible { - box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-blue) 16%, transparent); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--c-primary) 16%, transparent); outline: none; } /* Mark labels use SUI text tokens */ .sui-mantine-slider .mantine-Slider-markLabel { - color: var(--color-text-3); + color: var(--c-text-subtle); font-size: 0.75rem; } @@ -112,7 +112,7 @@ * SuiProvider syncs forceColorScheme to SUI theme, so * [data-mantine-color-scheme="dark"] === [data-theme="dark"] in practice. ---- */ [data-mantine-color-scheme="dark"] .sui-mantine-wrapper { - --input-bg: var(--color-surface); - --input-bd: var(--color-border-input); - --input-color: var(--color-text-1); + --input-bg: var(--c-surface); + --input-bd: var(--c-border); + --input-color: var(--c-text); } diff --git a/frontend/editor/src/core/ui/MethodBadge.css b/frontend/editor/src/core/ui/MethodBadge.css index 75703b92d8..9b138ed389 100644 --- a/frontend/editor/src/core/ui/MethodBadge.css +++ b/frontend/editor/src/core/ui/MethodBadge.css @@ -10,14 +10,14 @@ line-height: 1.4; } .sui-method--get { - color: var(--color-green); + color: var(--color-green-dark); background: var(--color-green-light); border-color: var(--color-green-border); } .sui-method--post { - color: var(--color-blue); - background: var(--color-blue-light); - border-color: var(--color-blue-border); + color: var(--c-primary); + background: var(--c-primary-tint); + border-color: var(--c-primary-border); } .sui-method--put { color: var(--color-amber-dark); @@ -25,12 +25,12 @@ border-color: var(--color-amber-border); } .sui-method--patch { - color: var(--color-purple); + color: var(--color-purple-dark); background: var(--color-purple-light); border-color: var(--color-purple-border); } .sui-method--delete { - color: var(--color-red); + color: var(--color-red-dark); background: var(--color-red-light); border-color: var(--color-red-border); } diff --git a/frontend/editor/src/core/ui/MethodBadge.stories.tsx b/frontend/editor/src/core/ui/MethodBadge.stories.tsx index 9110ef9f06..6dd557ab82 100644 --- a/frontend/editor/src/core/ui/MethodBadge.stories.tsx +++ b/frontend/editor/src/core/ui/MethodBadge.stories.tsx @@ -23,7 +23,7 @@ export const InRow: Story = {
    /v1/coi diff --git a/frontend/editor/src/core/ui/MetricCard.css b/frontend/editor/src/core/ui/MetricCard.css index 59c2b45000..8c7adfeb00 100644 --- a/frontend/editor/src/core/ui/MetricCard.css +++ b/frontend/editor/src/core/ui/MetricCard.css @@ -3,8 +3,8 @@ flex-direction: column; gap: 0.5rem; padding: 1.125rem 1.25rem; - background: var(--color-surface); - border: 1px solid var(--color-border); + background: var(--c-surface); + border: 1px solid var(--c-border); border-radius: var(--radius-lg); box-shadow: var(--shadow-md); transition: @@ -36,12 +36,12 @@ cursor: pointer; } .sui-metric--interactive:hover { - border-color: var(--color-border-hover); + border-color: var(--c-border-strong); box-shadow: var(--shadow-lg); transform: translateY(-0.0625rem); } .sui-metric--interactive:focus-visible { - outline: 2px solid var(--color-blue); + outline: 2px solid var(--c-primary); outline-offset: 2px; } @@ -53,18 +53,18 @@ } .sui-metric__label { font-size: 0.75rem; - color: var(--color-text-4); + color: var(--c-text-subtle); font-weight: 500; letter-spacing: 0.01em; } .sui-metric__icon { - color: var(--color-text-5); + color: var(--c-text-subtle); display: inline-flex; } .sui-metric__value { font-size: 1.625rem; font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); line-height: 1.1; } .sui-metric__footer { @@ -88,9 +88,7 @@ .sui-metric__delta--down { color: var(--color-red); } -.sui-metric__delta--flat { - color: var(--color-text-5); -} +.sui-metric__delta--flat, .sui-metric__desc { - color: var(--color-text-4); + color: var(--c-text-subtle); } diff --git a/frontend/editor/src/core/ui/MetricStrip.css b/frontend/editor/src/core/ui/MetricStrip.css index c222e61583..472fce713e 100644 --- a/frontend/editor/src/core/ui/MetricStrip.css +++ b/frontend/editor/src/core/ui/MetricStrip.css @@ -9,3 +9,9 @@ grid-template-columns: repeat(2, 1fr); } } + +@media (max-width: 30rem) { + .sui-metric-strip { + grid-template-columns: 1fr; + } +} diff --git a/frontend/editor/src/core/ui/Modal.css b/frontend/editor/src/core/ui/Modal.css index 7d9ef68746..2773585415 100644 --- a/frontend/editor/src/core/ui/Modal.css +++ b/frontend/editor/src/core/ui/Modal.css @@ -1,7 +1,7 @@ .sui-modal__backdrop { position: fixed; inset: 0; - background: rgba(15, 23, 42, 0.55); + background: rgba(0, 0, 0, 0.55); display: flex; align-items: flex-start; justify-content: center; @@ -15,20 +15,32 @@ /* Set our own font: the modal portals to , so it can't inherit the app's font through the DOM (the portal's .portal-scope isn't an ancestor). */ font-family: var(--font-sans); - background: var(--color-surface); - border: 1px solid var(--color-border); + background: var(--c-surface); + border: 1px solid var(--c-border); border-radius: var(--radius-xl); box-shadow: - 0 1.25rem 3rem rgba(15, 23, 42, 0.35), - 0 0 0 1px var(--color-border-light); + 0 1.25rem 3rem rgba(0, 0, 0, 0.35), + 0 0 0 1px var(--c-border-subtle); display: flex; flex-direction: column; width: 100%; max-height: calc(100vh - 6.5rem); + max-height: calc(100dvh - 6.5rem); /* mobile browser chrome shrinks 100vh */ overflow: hidden; animation: scaleIn 0.2s cubic-bezier(0.4, 0, 0.2, 1) both; } +/* Phones: drop the tall top inset so the modal gets the vertical space */ +@media (max-width: 30rem) { + .sui-modal__backdrop { + padding: 1rem 0.75rem; + align-items: center; + } + .sui-modal { + max-height: calc(100dvh - 2rem); + } +} + .sui-modal--sm { max-width: 24rem; } @@ -47,7 +59,7 @@ align-items: flex-start; gap: 0.75rem; padding: 1rem 1.125rem 0.75rem; - border-bottom: 1px solid var(--color-border-light); + border-bottom: 1px solid var(--c-border-subtle); } .sui-modal__header-text { @@ -58,13 +70,13 @@ .sui-modal__title { font-size: 0.9375rem; font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); } .sui-modal__sub { margin-top: 0.125rem; font-size: 0.8125rem; - color: var(--color-text-4); + color: var(--c-text-subtle); } /* Sizing/background/hover are owned by the Button; this just keeps it from @@ -77,12 +89,12 @@ flex: 1 1 auto; overflow-y: auto; padding: 1rem 1.125rem 1.125rem; - color: var(--color-text-2); + color: var(--c-text-muted); } .sui-modal__footer { padding: 0.875rem 1.125rem; - border-top: 1px solid var(--color-border-light); + border-top: 1px solid var(--c-border-subtle); display: flex; justify-content: flex-end; gap: 0.5rem; diff --git a/frontend/editor/src/core/ui/MultiSelect.tsx b/frontend/editor/src/core/ui/MultiSelect.tsx index 31ac9544f8..0d2ab92ad8 100644 --- a/frontend/editor/src/core/ui/MultiSelect.tsx +++ b/frontend/editor/src/core/ui/MultiSelect.tsx @@ -8,11 +8,11 @@ import { useInputAria } from "@app/ui/ariaForwarding"; import "@app/ui/MantineForms.css"; const SUI_INPUT_VARS = { - "--input-bg": "var(--color-surface)", - "--input-bd": "var(--color-border-input)", - "--input-bd-focus": "var(--color-blue)", + "--input-bg": "var(--c-surface)", + "--input-bd": "var(--c-border)", + "--input-bd-focus": "var(--c-primary)", "--input-radius": "var(--radius-md)", - "--input-color": "var(--color-text-1)", + "--input-color": "var(--c-text)", "--input-placeholder-color": "var(--color-text-placeholder)", "--input-height-sm": "1.75rem", "--input-height-md": "2.25rem", diff --git a/frontend/editor/src/core/ui/NavItem.css b/frontend/editor/src/core/ui/NavItem.css index 982abbc26b..8b9949eab5 100644 --- a/frontend/editor/src/core/ui/NavItem.css +++ b/frontend/editor/src/core/ui/NavItem.css @@ -6,7 +6,7 @@ padding: 0.4375rem 0.75rem; margin: 0.0625rem 0.5rem; border-radius: var(--radius-lg); - color: var(--color-nav-text); + color: var(--c-text-subtle); font-size: 0.8125rem; font-weight: 400; transition: @@ -15,16 +15,16 @@ text-align: left; } .sui-navitem:hover { - background: var(--color-nav-hover); - color: var(--color-nav-hover-text); + background: var(--c-hover); + color: var(--c-text-muted); } .sui-navitem.is-active { - background: var(--color-nav-active); - color: var(--color-nav-active-text); + background: var(--c-primary-subtle); + color: var(--c-accent-fg); font-weight: 500; } .sui-navitem.is-active:hover { - background: var(--color-nav-active); + background: var(--c-primary-subtle); } .sui-navitem__icon { width: 1rem; @@ -43,7 +43,7 @@ margin-left: auto; } .sui-navitem:focus-visible { - outline: 0.125rem solid var(--color-blue); + outline: 0.125rem solid var(--c-primary); outline-offset: 0.125rem; } @@ -61,7 +61,7 @@ border-radius: 3px; } .sui-navitem[data-accent="blue"]::before { - background: var(--color-blue); + background: var(--c-primary); } .sui-navitem[data-accent="purple"]::before { background: var(--color-purple); @@ -76,7 +76,7 @@ background: var(--color-red); } .sui-navitem[data-accent="blue"] .sui-navitem__icon { - color: var(--color-blue); + color: var(--c-primary); } .sui-navitem[data-accent="purple"] .sui-navitem__icon { color: var(--color-purple); diff --git a/frontend/editor/src/core/ui/NavItem.stories.tsx b/frontend/editor/src/core/ui/NavItem.stories.tsx index 7ceb981d79..52d74138bc 100644 --- a/frontend/editor/src/core/ui/NavItem.stories.tsx +++ b/frontend/editor/src/core/ui/NavItem.stories.tsx @@ -3,7 +3,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { NavItem } from "@app/ui/NavItem"; import { SectionDivider } from "@app/ui/SectionDivider"; -function Dot({ color = "var(--color-blue)" }: { color?: string }) { +function Dot({ color = "var(--c-primary)" }: { color?: string }) { return ( = {
    @@ -55,8 +55,8 @@ export const WithTrailingBadge: Story = { @@ -75,8 +75,8 @@ export const InContext_UsageMeter: Story = { marginBottom: 6, }} > - Docs processed - + Docs processed + 412 / 500
    diff --git a/frontend/editor/src/core/ui/ProgressBar.tsx b/frontend/editor/src/core/ui/ProgressBar.tsx index 570fc43099..e6ca30c0e1 100644 --- a/frontend/editor/src/core/ui/ProgressBar.tsx +++ b/frontend/editor/src/core/ui/ProgressBar.tsx @@ -45,10 +45,10 @@ export function ProgressBar({ ? "linear-gradient(90deg, var(--color-red), color-mix(in srgb, var(--color-red) 70%, white))" : v >= 0.8 ? "linear-gradient(90deg, var(--color-amber), color-mix(in srgb, var(--color-amber) 70%, white))" - : "linear-gradient(90deg, var(--color-blue), color-mix(in srgb, var(--color-blue) 70%, white))"; + : "linear-gradient(90deg, var(--c-primary), color-mix(in srgb, var(--c-primary) 70%, white))"; } else { fill = - "linear-gradient(90deg, var(--color-blue), color-mix(in srgb, var(--color-blue) 70%, white))"; + "linear-gradient(90deg, var(--c-primary), color-mix(in srgb, var(--c-primary) 70%, white))"; } } return ( diff --git a/frontend/editor/src/core/ui/Radio.css b/frontend/editor/src/core/ui/Radio.css index 162d0a9d5e..7000c12219 100644 --- a/frontend/editor/src/core/ui/Radio.css +++ b/frontend/editor/src/core/ui/Radio.css @@ -17,7 +17,7 @@ gap: var(--space-2); cursor: pointer; font-size: 0.8125rem; - color: var(--color-text-2); + color: var(--c-text-muted); } .sui-radio--disabled { opacity: 0.5; @@ -37,8 +37,8 @@ height: 1rem; margin-top: 0.0625rem; border-radius: 50%; - border: 1.5px solid var(--color-border-hover); - background: var(--color-surface); + border: 1.5px solid var(--c-border-strong); + background: var(--c-surface); display: inline-flex; align-items: center; justify-content: center; @@ -47,12 +47,12 @@ } .sui-radio__input:focus-visible + .sui-radio__dot { - outline: 2px solid var(--color-blue); + outline: 2px solid var(--c-primary); outline-offset: 2px; } .sui-radio__input:checked + .sui-radio__dot { - border-color: var(--color-blue); + border-color: var(--c-primary); } .sui-radio__input:checked + .sui-radio__dot::after { @@ -60,7 +60,7 @@ width: 0.5rem; height: 0.5rem; border-radius: 50%; - background: var(--color-blue); + background: var(--c-primary); } .sui-radio__text { @@ -68,10 +68,10 @@ flex-direction: column; } .sui-radio__label { - color: var(--color-text-1); + color: var(--c-text); font-weight: 500; } .sui-radio__desc { font-size: 0.75rem; - color: var(--color-text-4); + color: var(--c-text-subtle); } diff --git a/frontend/editor/src/core/ui/SectionDivider.css b/frontend/editor/src/core/ui/SectionDivider.css index 8999f6f0a3..ddfa8f0363 100644 --- a/frontend/editor/src/core/ui/SectionDivider.css +++ b/frontend/editor/src/core/ui/SectionDivider.css @@ -1,5 +1,5 @@ .sui-divider { height: 0.0625rem; - background: var(--color-sidebar-divider); + background: var(--c-border-subtle); width: 100%; } diff --git a/frontend/editor/src/core/ui/SectionDivider.stories.tsx b/frontend/editor/src/core/ui/SectionDivider.stories.tsx index 8cd433e83a..dd758dfd39 100644 --- a/frontend/editor/src/core/ui/SectionDivider.stories.tsx +++ b/frontend/editor/src/core/ui/SectionDivider.stories.tsx @@ -13,9 +13,9 @@ type Story = StoryObj; export const Default: Story = { render: () => (
    -

    Section above

    +

    Section above

    -

    Section below

    +

    Section below

    ), }; @@ -25,11 +25,11 @@ export const InContext_SidebarGroups: Story = {
    diff --git a/frontend/editor/src/core/ui/SectionHeader.css b/frontend/editor/src/core/ui/SectionHeader.css index 63a354cc23..8a534d09a1 100644 --- a/frontend/editor/src/core/ui/SectionHeader.css +++ b/frontend/editor/src/core/ui/SectionHeader.css @@ -17,14 +17,14 @@ button.sui-sectionhdr { font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; - color: var(--color-text-4); + color: var(--c-text-subtle); } .sui-sectionhdr__count { font-size: 0.6875rem; - color: var(--color-text-4); + color: var(--c-text-subtle); } .sui-sectionhdr__chevron { - color: var(--color-text-4); + color: var(--c-text-subtle); flex-shrink: 0; transition: transform var(--motion-fast); } diff --git a/frontend/editor/src/core/ui/Select.css b/frontend/editor/src/core/ui/Select.css index b01cf2ecd1..7ced297651 100644 --- a/frontend/editor/src/core/ui/Select.css +++ b/frontend/editor/src/core/ui/Select.css @@ -2,18 +2,18 @@ position: relative; display: inline-flex; align-items: center; - background: var(--color-surface); - border: 1px solid var(--color-border-input); + background: var(--c-surface); + border: 1px solid var(--c-border); border-radius: var(--radius-md); - color: var(--color-text-1); + color: var(--c-text); transition: border-color var(--motion-fast), box-shadow var(--motion-fast); } .sui-select:focus-within { - border-color: var(--color-blue); - box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-blue) 16%, transparent); + border-color: var(--c-primary); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--c-primary) 16%, transparent); } .sui-select--sm { @@ -53,8 +53,8 @@ * select's color-scheme to the active SUI theme so the popup is always legible. */ .sui-select__el option { - background-color: var(--color-surface); - color: var(--color-text-1); + background-color: var(--c-surface); + color: var(--c-text); } [data-theme="dark"] .sui-select__el { color-scheme: dark; @@ -75,5 +75,5 @@ display: inline-flex; align-items: center; pointer-events: none; - color: var(--color-text-4); + color: var(--c-text-subtle); } diff --git a/frontend/editor/src/core/ui/Select.tsx b/frontend/editor/src/core/ui/Select.tsx index fa2d5346b7..242ddf9740 100644 --- a/frontend/editor/src/core/ui/Select.tsx +++ b/frontend/editor/src/core/ui/Select.tsx @@ -7,11 +7,11 @@ import { useInputAria } from "@app/ui/ariaForwarding"; import "@app/ui/MantineForms.css"; const SUI_INPUT_VARS = { - "--input-bg": "var(--color-surface)", - "--input-bd": "var(--color-border-input)", - "--input-bd-focus": "var(--color-blue)", + "--input-bg": "var(--c-surface)", + "--input-bd": "var(--c-border)", + "--input-bd-focus": "var(--c-primary)", "--input-radius": "var(--radius-md)", - "--input-color": "var(--color-text-1)", + "--input-color": "var(--c-text)", "--input-placeholder-color": "var(--color-text-placeholder)", "--input-height-sm": "1.75rem", "--input-height-md": "2.25rem", diff --git a/frontend/editor/src/core/ui/SettingsRow.css b/frontend/editor/src/core/ui/SettingsRow.css index f374a5fdab..18d707c489 100644 --- a/frontend/editor/src/core/ui/SettingsRow.css +++ b/frontend/editor/src/core/ui/SettingsRow.css @@ -13,11 +13,11 @@ .sui-settingsrow__label { font-size: 0.8125rem; font-weight: 500; - color: var(--color-text-1); + color: var(--c-text); } .sui-settingsrow__desc { font-size: 0.75rem; - color: var(--color-text-4); + color: var(--c-text-subtle); margin-top: 0.125rem; } .sui-settingsrow__control { diff --git a/frontend/editor/src/core/ui/SettingsRow.stories.tsx b/frontend/editor/src/core/ui/SettingsRow.stories.tsx index 4527ef2689..792171942b 100644 --- a/frontend/editor/src/core/ui/SettingsRow.stories.tsx +++ b/frontend/editor/src/core/ui/SettingsRow.stories.tsx @@ -48,7 +48,7 @@ export const List: Story = { key={r.label} style={{ padding: "0.7rem 0.875rem", - borderTop: i > 0 ? "1px solid var(--color-border)" : undefined, + borderTop: i > 0 ? "1px solid var(--c-border)" : undefined, }} > { const [active, setActive] = useState("profile"); return ( -
    +
    } > -

    +

    Content for the “{LABELS[active]}†section renders here.

    diff --git a/frontend/editor/src/core/ui/Skeleton.css b/frontend/editor/src/core/ui/Skeleton.css index 98da3c7dbe..42dccfb5be 100644 --- a/frontend/editor/src/core/ui/Skeleton.css +++ b/frontend/editor/src/core/ui/Skeleton.css @@ -2,9 +2,9 @@ display: inline-block; background: linear-gradient( 90deg, - var(--color-bg-muted) 0%, - var(--color-bg-hover) 50%, - var(--color-bg-muted) 100% + var(--c-surface-sunken) 0%, + var(--c-hover) 50%, + var(--c-surface-sunken) 100% ); background-size: 200% 100%; animation: shimmer 1.4s linear infinite; diff --git a/frontend/editor/src/core/ui/Slider.css b/frontend/editor/src/core/ui/Slider.css index 005ca0cbba..7be91a36c6 100644 --- a/frontend/editor/src/core/ui/Slider.css +++ b/frontend/editor/src/core/ui/Slider.css @@ -17,10 +17,10 @@ border-radius: var(--radius-pill); background: linear-gradient( 90deg, - var(--color-blue) 0%, - var(--color-blue) var(--slider-pct), - var(--color-bg-muted) var(--slider-pct), - var(--color-bg-muted) 100% + var(--c-primary) 0%, + var(--c-primary) var(--slider-pct), + var(--c-surface-sunken) var(--slider-pct), + var(--c-surface-sunken) 100% ); outline: none; } @@ -32,7 +32,7 @@ height: 1rem; border-radius: 50%; background: #fff; - border: 2px solid var(--color-blue); + border: 2px solid var(--c-primary); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15); cursor: pointer; transition: transform var(--motion-fast); @@ -43,13 +43,13 @@ height: 1rem; border-radius: 50%; background: #fff; - border: 2px solid var(--color-blue); + border: 2px solid var(--c-primary); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15); cursor: pointer; } .sui-slider__input:focus-visible::-webkit-slider-thumb { - outline: 2px solid var(--color-blue); + outline: 2px solid var(--c-primary); outline-offset: 2px; } @@ -64,5 +64,5 @@ font-family: var(--font-mono); font-size: 0.75rem; font-weight: 500; - color: var(--color-text-2); + color: var(--c-text-muted); } diff --git a/frontend/editor/src/core/ui/Spinner.stories.tsx b/frontend/editor/src/core/ui/Spinner.stories.tsx index 9435ffec3a..f2bc56a9ac 100644 --- a/frontend/editor/src/core/ui/Spinner.stories.tsx +++ b/frontend/editor/src/core/ui/Spinner.stories.tsx @@ -31,7 +31,7 @@ export const SizeRow: Story = { export const InheritsColor: Story = { render: () => (
    - + diff --git a/frontend/editor/src/core/ui/Stack.stories.tsx b/frontend/editor/src/core/ui/Stack.stories.tsx index d35a40928c..20f6948632 100644 --- a/frontend/editor/src/core/ui/Stack.stories.tsx +++ b/frontend/editor/src/core/ui/Stack.stories.tsx @@ -17,7 +17,7 @@ function Box({ children }: { children: React.ReactNode }) {
    {(["1", "2", "4", "6"] as const).map((gap) => ( -
    +
    gap {gap}
    A @@ -59,7 +59,7 @@ export const InCard: Story = {
    Card title
    -
    +
    Stack is the default vertical container — it's how you compose every card body, list, and form section.
    diff --git a/frontend/editor/src/core/ui/StatTile.css b/frontend/editor/src/core/ui/StatTile.css index c29455fc72..0fdc22c8ca 100644 --- a/frontend/editor/src/core/ui/StatTile.css +++ b/frontend/editor/src/core/ui/StatTile.css @@ -9,14 +9,14 @@ font-size: 0.6875rem; text-transform: uppercase; letter-spacing: 0.04em; - color: var(--color-text-5); + color: var(--c-text-subtle); font-weight: 600; } .sui-stat__value { font-size: 0.9375rem; font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); font-variant-numeric: tabular-nums; } @@ -34,6 +34,6 @@ .sui-stat__value code { font-family: var(--font-mono); font-size: 0.75rem; - color: var(--color-text-2); + color: var(--c-text-muted); word-break: break-all; } diff --git a/frontend/editor/src/core/ui/StatusBadge.css b/frontend/editor/src/core/ui/StatusBadge.css index a250f99d9a..230c99b40d 100644 --- a/frontend/editor/src/core/ui/StatusBadge.css +++ b/frontend/editor/src/core/ui/StatusBadge.css @@ -8,6 +8,17 @@ letter-spacing: 0.01em; border: 1px solid transparent; line-height: 1; + color: var(--sui-status-c, var(--c-text-subtle)); + background: color-mix( + in srgb, + var(--sui-status-c, var(--c-text-subtle)) 12%, + transparent + ); + border-color: color-mix( + in srgb, + var(--sui-status-c, var(--c-text-subtle)) 28%, + transparent + ); } .sui-status--sm { font-size: 0.6875rem; @@ -38,33 +49,26 @@ animation: pulseRing 1.4s ease-out infinite; } +/* Neutral keeps the plain muted surface rather than an accent tint. */ .sui-status--neutral { - color: var(--color-text-3); - background: var(--color-bg-muted); - border-color: var(--color-border-light); + color: var(--c-text-subtle); + background: var(--c-surface-sunken); + border-color: var(--c-border-subtle); } +/* Tones only pick the accent; the base rule builds the fill + border. `-dark` + is theme-adaptive, so text stays legible on the pale fill in both themes. */ .sui-status--success { - color: var(--color-green); - background: var(--color-green-light); - border-color: var(--color-green-border); + --sui-status-c: var(--color-green-dark); } .sui-status--warning { - color: var(--color-amber-dark); - background: var(--color-amber-light); - border-color: var(--color-amber-border); + --sui-status-c: var(--color-amber-dark); } .sui-status--danger { - color: var(--color-red); - background: var(--color-red-light); - border-color: var(--color-red-border); + --sui-status-c: var(--color-red-dark); } .sui-status--info { - color: var(--color-blue); - background: var(--color-blue-light); - border-color: var(--color-blue-border); + --sui-status-c: var(--c-primary-hover); } .sui-status--purple { - color: var(--color-purple); - background: var(--color-purple-light); - border-color: var(--color-purple-border); + --sui-status-c: var(--color-purple-dark); } diff --git a/frontend/editor/src/core/ui/StepIndicator.css b/frontend/editor/src/core/ui/StepIndicator.css index d5d46e7bd5..8c364d9c56 100644 --- a/frontend/editor/src/core/ui/StepIndicator.css +++ b/frontend/editor/src/core/ui/StepIndicator.css @@ -6,7 +6,7 @@ .sui-steps__bar { flex: 1; border-radius: 999px; - background: var(--color-border); + background: var(--c-border); transition: background var(--motion-fast); } .sui-steps--md .sui-steps__bar { @@ -17,11 +17,11 @@ } /* Completed steps: solid accent. */ .sui-steps__bar[data-state="done"] { - background: var(--color-blue); + background: var(--c-primary); } /* Current step: solid accent + a soft ring so it reads as "you are here". */ .sui-steps__bar[data-state="current"] { - background: var(--color-blue); + background: var(--c-primary); box-shadow: 0 0 0 0.1875rem - color-mix(in srgb, var(--color-blue) 22%, transparent); + color-mix(in srgb, var(--c-primary) 22%, transparent); } diff --git a/frontend/editor/src/core/ui/Table.css b/frontend/editor/src/core/ui/Table.css index 3b17a6c44d..696bbc8afb 100644 --- a/frontend/editor/src/core/ui/Table.css +++ b/frontend/editor/src/core/ui/Table.css @@ -1,9 +1,9 @@ .sui-table-wrap { width: 100%; overflow-x: auto; - border: 1px solid var(--color-border); + border: 1px solid var(--c-border); border-radius: var(--radius-md); - background: var(--color-surface); + background: var(--c-surface); } .sui-table { @@ -15,12 +15,12 @@ .sui-table__th { text-align: left; font-weight: 600; - color: var(--color-text-4); + color: var(--c-text-subtle); font-size: 0.6875rem; text-transform: uppercase; letter-spacing: 0.04em; padding: 0.625rem 0.875rem; - border-bottom: 1px solid var(--color-border); + border-bottom: 1px solid var(--c-border); white-space: nowrap; } .sui-table__th--right, @@ -34,8 +34,8 @@ .sui-table__td { padding: 0.625rem 0.875rem; - color: var(--color-text-2); - border-bottom: 1px solid var(--color-border-light); + color: var(--c-text-muted); + border-bottom: 1px solid var(--c-border-subtle); vertical-align: middle; } .sui-table tbody tr:last-child .sui-table__td { @@ -47,15 +47,15 @@ transition: background var(--motion-fast); } .sui-table__row--interactive:hover { - background: var(--color-bg-hover); + background: var(--c-hover); } .sui-table__row--interactive:focus-visible { - outline: 0.125rem solid var(--color-blue); + outline: 0.125rem solid var(--c-primary); outline-offset: -0.125rem; } .sui-table__empty { padding: 2rem; text-align: center; - color: var(--color-text-4); + color: var(--c-text-subtle); } diff --git a/frontend/editor/src/core/ui/Table.tsx b/frontend/editor/src/core/ui/Table.tsx index 0a07315a3d..fcd50e5fd6 100644 --- a/frontend/editor/src/core/ui/Table.tsx +++ b/frontend/editor/src/core/ui/Table.tsx @@ -19,6 +19,12 @@ export interface TableProps { rowKey: (row: T) => string; /** Makes rows interactive (hover + click + keyboard). */ onRowClick?: (row: T) => void; + /** + * Per-row gate for interactivity, checked only when {@link onRowClick} is set. A row for which + * this returns false is inert: no click/keyboard, and not announced as a button. Defaults to + * all rows interactive. + */ + isRowInteractive?: (row: T) => boolean; /** Rendered in place of the body when there are no rows. */ empty?: ReactNode; className?: string; @@ -35,6 +41,7 @@ export function Table({ rows, rowKey, onRowClick, + isRowInteractive, empty, className, }: TableProps) { @@ -66,38 +73,42 @@ export function Table({
    ) : ( - rows.map((row) => ( - onRowClick(row) : undefined} - tabIndex={interactive ? 0 : undefined} - role={interactive ? "button" : undefined} - onKeyDown={ - interactive - ? (e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - onRowClick?.(row); + rows.map((row) => { + const rowInteractive = + interactive && (isRowInteractive?.(row) ?? true); + return ( + onRowClick?.(row) : undefined} + tabIndex={rowInteractive ? 0 : undefined} + role={rowInteractive ? "button" : undefined} + onKeyDown={ + rowInteractive + ? (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onRowClick?.(row); + } } - } - : undefined - } - > - {columns.map((c) => ( - - ))} - - )) + : undefined + } + > + {columns.map((c) => ( + + ))} + + ); + }) )}
    {{ label }}{{ value }}{{ pair.label }}{{ pair.value }}
    ")); + assertTrue(html.contains("item one")); + assertTrue(html.contains("Alice")); + } + + @Test + void rendersMarkupCharactersAsText() { + AiDocument.Section text = section("text"); + text.setBody("a x & y"); + + String html = renderer.render(document("Doc", List.of(text))); + + assertFalse(html.contains("")); + assertTrue(html.contains("<b>")); + } + + @Test + void totalRowRenderedWhenPresent() { + AiDocument.Section items = section("line_items"); + items.setColumns(List.of("Item", "Total")); + items.setRows(List.of(List.of("Widget", "$10"))); + items.setTotalRow(List.of("Total", "$10")); + + assertTrue( + renderer.render(document("Table", List.of(items))) + .contains("
    - {c.render(row)} -
    + {c.render(row)} +
    diff --git a/frontend/editor/src/core/ui/Tabs.css b/frontend/editor/src/core/ui/Tabs.css index b9dd365fc7..9fa18ce5b4 100644 --- a/frontend/editor/src/core/ui/Tabs.css +++ b/frontend/editor/src/core/ui/Tabs.css @@ -7,7 +7,7 @@ .sui-tabs--pill { } .sui-tabs--underline { - border-bottom: 1px solid var(--color-border-light); + border-bottom: 1px solid var(--c-border-subtle); padding-bottom: var(--space-1); gap: var(--space-3); } @@ -18,7 +18,7 @@ gap: var(--space-1_5); padding: var(--space-1_5) var(--space-3); font-size: 0.8125rem; - color: var(--color-text-3); + color: var(--c-text-subtle); background: transparent; border: 1px solid transparent; transition: @@ -28,8 +28,8 @@ } .sui-tabs__tab:hover:not(.is-disabled) { - color: var(--color-text-1); - background: var(--color-bg-hover); + color: var(--c-text); + background: var(--c-hover); } .sui-tabs__tab.is-disabled { @@ -42,9 +42,9 @@ border-radius: var(--radius-pill); } .sui-tabs--pill .sui-tabs__tab.is-active { - color: var(--sui-tab-accent, var(--color-blue)); - background: var(--color-blue-light); - border-color: var(--sui-tab-accent, var(--color-blue-border)); + color: var(--sui-tab-accent, var(--c-primary)); + background: var(--c-primary-tint); + border-color: var(--sui-tab-accent, var(--c-primary-border)); font-weight: 500; } @@ -56,8 +56,8 @@ margin-bottom: -1px; } .sui-tabs--underline .sui-tabs__tab.is-active { - color: var(--sui-tab-accent, var(--color-blue)); - border-bottom-color: var(--sui-tab-accent, var(--color-blue)); + color: var(--sui-tab-accent, var(--c-primary)); + border-bottom-color: var(--sui-tab-accent, var(--c-primary)); font-weight: 500; } @@ -69,7 +69,7 @@ .sui-tabs__count { font-size: 0.6875rem; - color: var(--color-text-5); + color: var(--c-text-subtle); } .sui-tabs__tab.is-active .sui-tabs__count { diff --git a/frontend/editor/src/core/ui/Toast.css b/frontend/editor/src/core/ui/Toast.css index a1ca3a67bf..eff290522d 100644 --- a/frontend/editor/src/core/ui/Toast.css +++ b/frontend/editor/src/core/ui/Toast.css @@ -16,8 +16,8 @@ align-items: flex-start; gap: var(--space-3); padding: var(--space-3) var(--space-4); - background: var(--color-surface); - border: 1px solid var(--color-border); + background: var(--c-surface); + border: 1px solid var(--c-border); border-left-width: 3px; border-radius: var(--radius-md); box-shadow: var(--shadow-lg); @@ -25,7 +25,7 @@ } .sui-toast--info { - border-left-color: var(--color-blue); + border-left-color: var(--c-primary); } .sui-toast--success { border-left-color: var(--color-green); @@ -44,10 +44,10 @@ } .sui-toast__title { font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); } .sui-toast__desc { - color: var(--color-text-3); + color: var(--c-text-subtle); margin-top: 0.125rem; line-height: 1.45; } diff --git a/frontend/editor/src/core/ui/ToggleSwitch.css b/frontend/editor/src/core/ui/ToggleSwitch.css index f5025ebfbe..1b6aa90528 100644 --- a/frontend/editor/src/core/ui/ToggleSwitch.css +++ b/frontend/editor/src/core/ui/ToggleSwitch.css @@ -34,7 +34,7 @@ transition: transform var(--motion-base); } .sui-toggle input:checked + .sui-toggle__track { - background: var(--color-blue); + background: var(--c-primary); } .sui-toggle input:checked + .sui-toggle__track .sui-toggle__thumb { transform: translateX(1rem); @@ -65,14 +65,14 @@ .sui-toggle__label { font-size: 0.8125rem; font-weight: 500; - color: var(--color-text-2); + color: var(--c-text-muted); } .sui-toggle__desc { font-size: 0.75rem; - color: var(--color-text-4); + color: var(--c-text-subtle); } .sui-toggle input:focus-visible + .sui-toggle__track { - outline: 2px solid var(--color-blue); + outline: 2px solid var(--c-primary); outline-offset: 2px; } diff --git a/frontend/editor/src/core/ui/accents.css b/frontend/editor/src/core/ui/accents.css index 75001f6777..404f98cc52 100644 --- a/frontend/editor/src/core/ui/accents.css +++ b/frontend/editor/src/core/ui/accents.css @@ -2,20 +2,26 @@ * accents derive from --color-* tokens (auto dark), neutral/brand/ai are explicit. */ .sui-acc-default { - --_solid: var(--color-blue); - --_solid-hover: var(--color-blue-dark); + --_solid: var(--c-primary); + --_solid-hover: var(--c-primary-hover); --_on: #ffffff; - --_text: var(--color-blue-dark); - --_bd: var(--color-blue-border); - --_tint: color-mix(in srgb, var(--color-blue) 12%, transparent); + --_text: var(--c-primary-hover); + --_bd: color-mix(in srgb, var(--c-primary) 38%, var(--c-surface)); + --_tint: color-mix(in srgb, var(--c-primary) 12%, transparent); } + +html[data-app-theme="custom"] .sui-acc-default { + --_on: var(--c-text-on-primary); +} +/* Danger is pinned to a fixed deep red (not the theme-lightened coral), so the + fill and the outline/text are the SAME red in both light and dark. */ .sui-acc-danger { - --_solid: var(--color-red); - --_solid-hover: var(--color-red-dark); + --_solid: var(--p-red-500); + --_solid-hover: var(--p-red-600); --_on: #ffffff; - --_text: var(--color-red-dark); - --_bd: var(--color-red-border); - --_tint: color-mix(in srgb, var(--color-red) 12%, transparent); + --_text: var(--p-red-500); + --_bd: color-mix(in srgb, var(--p-red-500) 45%, transparent); + --_tint: color-mix(in srgb, var(--p-red-500) 12%, transparent); } .sui-acc-success { --_solid: var(--color-green); @@ -45,57 +51,57 @@ /* neutral: low-emphasis grey — no palette token, so explicit. */ .sui-acc-neutral { - --_solid: #475569; - --_solid-hover: #334155; + --_solid: var(--p-c-475569); + --_solid-hover: var(--p-c-334155); --_on: #ffffff; - --_text: #475569; - --_bd: #cbd5e1; - --_tint: rgba(71, 85, 105, 0.1); + --_text: var(--p-c-475569); + --_bd: var(--p-c-cbd5e1); + --_tint: color-mix(in srgb, var(--p-c-475569) 10%, transparent); } [data-theme="dark"] .sui-acc-neutral { - --_solid: #64748b; - --_solid-hover: #475569; - --_text: #cbd5e1; - --_bd: #334155; - --_tint: rgba(148, 163, 184, 0.16); + --_solid: var(--p-c-64748b); + --_solid-hover: var(--p-c-475569); + --_text: var(--p-c-cbd5e1); + --_bd: var(--p-c-334155); + --_tint: color-mix(in srgb, var(--p-c-94a3b8) 16%, transparent); } /* brand: Stirling red — a bespoke brand colour, not part of the token palette. */ .sui-acc-brand { - --_solid: #8e3131; - --_solid-hover: #7a2929; + --_solid: var(--p-brand-red-650); + --_solid-hover: var(--p-brand-red-700); --_on: #ffffff; - --_text: #8e3131; - --_bd: #d9a8a8; - --_tint: rgba(142, 49, 49, 0.09); + --_text: var(--p-brand-red-650); + --_bd: var(--p-brand-red-200); + --_tint: color-mix(in srgb, var(--p-brand-red-650) 9%, transparent); } [data-theme="dark"] .sui-acc-brand { - --_text: #d98a8a; - --_bd: #5a2424; - --_tint: rgba(217, 138, 138, 0.16); + --_text: var(--p-brand-red-300); + --_bd: var(--p-brand-red-900); + --_tint: color-mix(in srgb, var(--p-brand-red-300) 16%, transparent); } /* ai: multi-hue gradient for AI features — no single-colour token. */ .sui-acc-ai { --_solid: linear-gradient( 135deg, - #8b5cf6 0%, - #6366f1 38%, - #3b82f6 72%, - #22d3ee 100% + var(--p-violet-500) 0%, + var(--p-indigo-500) 38%, + var(--p-blue-500) 72%, + var(--p-cyan-400) 100% ); --_solid-hover: linear-gradient( 135deg, - #8b5cf6 0%, - #6366f1 38%, - #3b82f6 72%, - #22d3ee 100% + var(--p-violet-500) 0%, + var(--p-indigo-500) 38%, + var(--p-blue-500) 72%, + var(--p-cyan-400) 100% ); --_on: #ffffff; - --_text: #6366f1; - --_bd: #c7d2fe; - --_tint: rgba(99, 102, 241, 0.1); + --_text: var(--p-indigo-500); + --_bd: var(--p-indigo-200); + --_tint: color-mix(in srgb, var(--p-indigo-500) 10%, transparent); } [data-theme="dark"] .sui-acc-ai { - --_text: #a5b4fc; - --_bd: #3730a3; - --_tint: rgba(129, 140, 248, 0.18); + --_text: var(--p-indigo-300); + --_bd: var(--p-indigo-800); + --_tint: color-mix(in srgb, var(--p-indigo-400) 18%, transparent); } diff --git a/frontend/editor/src/core/utils/pdfiumBitmapUtils.ts b/frontend/editor/src/core/utils/pdfiumBitmapUtils.ts index 95022d80d1..b21eddcd47 100644 --- a/frontend/editor/src/core/utils/pdfiumBitmapUtils.ts +++ b/frontend/editor/src/core/utils/pdfiumBitmapUtils.ts @@ -82,6 +82,82 @@ export interface DecodedImage { height: number; } +function setImageObjectMatrix( + m: WrappedPdfiumModule, + imageObjPtr: number, + pdfX: number, + pdfY: number, + drawWidth: number, + drawHeight: number, +): boolean { + const matrixPtr = m.pdfium.wasmExports.malloc(6 * 4); + try { + m.pdfium.setValue(matrixPtr, drawWidth, "float"); + m.pdfium.setValue(matrixPtr + 4, 0, "float"); + m.pdfium.setValue(matrixPtr + 8, 0, "float"); + m.pdfium.setValue(matrixPtr + 12, drawHeight, "float"); + m.pdfium.setValue(matrixPtr + 16, pdfX, "float"); + m.pdfium.setValue(matrixPtr + 20, pdfY, "float"); + return m.FPDFPageObj_SetMatrix(imageObjPtr, matrixPtr); + } finally { + m.pdfium.wasmExports.free(matrixPtr); + } +} + +/** + * Create a PDFium image page object from decoded pixels. + * + * The caller owns the returned object until it is inserted into a page or + * appended to an annotation. Destroy it with FPDFPageObj_Destroy on failure. + */ +export function createBitmapImageObject( + m: WrappedPdfiumModule, + docPtr: number, + pagePtr: number, + image: DecodedImage, + pdfX: number, + pdfY: number, + drawWidth: number, + drawHeight: number, +): number | null { + const bitmapPtr = m.FPDFBitmap_Create(image.width, image.height, 1); + if (!bitmapPtr) return null; + + try { + const bufferPtr = m.FPDFBitmap_GetBuffer(bitmapPtr); + const stride = m.FPDFBitmap_GetStride(bitmapPtr); + + copyRgbaToBgraHeap( + m, + image.rgba, + bufferPtr, + image.width, + image.height, + stride, + ); + + const imageObjPtr = m.FPDFPageObj_NewImageObj(docPtr); + if (!imageObjPtr) return null; + + if (!m.FPDFImageObj_SetBitmap(pagePtr, 0, imageObjPtr, bitmapPtr)) { + m.FPDFPageObj_Destroy(imageObjPtr); + return null; + } + + if ( + !setImageObjectMatrix(m, imageObjPtr, pdfX, pdfY, drawWidth, drawHeight) + ) { + m.FPDFPageObj_Destroy(imageObjPtr); + return null; + } + + return imageObjPtr; + } finally { + // FPDFImageObj_SetBitmap copies the bitmap data into the image object. + m.FPDFBitmap_Destroy(bitmapPtr); + } +} + /** * Create a PDFium bitmap from decoded RGBA pixels, attach it to a new image * page object, position it via an affine matrix, and insert it into the page. @@ -99,70 +175,20 @@ export function embedBitmapImageOnPage( drawWidth: number, drawHeight: number, ): boolean { - const bitmapPtr = m.FPDFBitmap_Create(image.width, image.height, 1); - if (!bitmapPtr) return false; + const imageObjPtr = createBitmapImageObject( + m, + docPtr, + pagePtr, + image, + pdfX, + pdfY, + drawWidth, + drawHeight, + ); + if (!imageObjPtr) return false; - try { - const bufferPtr = m.FPDFBitmap_GetBuffer(bitmapPtr); - const stride = m.FPDFBitmap_GetStride(bitmapPtr); - - copyRgbaToBgraHeap( - m, - image.rgba, - bufferPtr, - image.width, - image.height, - stride, - ); - - const imageObjPtr = m.FPDFPageObj_NewImageObj(docPtr); - if (!imageObjPtr) return false; - - const setBitmapOk = m.FPDFImageObj_SetBitmap( - pagePtr, - 0, - imageObjPtr, - bitmapPtr, - ); - if (!setBitmapOk) { - m.FPDFPageObj_Destroy(imageObjPtr); - return false; - } - - // -- early-destroy the bitmap; PDFium has copied the pixel data internally - m.FPDFBitmap_Destroy(bitmapPtr); - - // Set affine transform: [a b c d e f] - const matrixPtr = m.pdfium.wasmExports.malloc(6 * 4); - try { - m.pdfium.setValue(matrixPtr, drawWidth, "float"); // a — scaleX - m.pdfium.setValue(matrixPtr + 4, 0, "float"); // b - m.pdfium.setValue(matrixPtr + 8, 0, "float"); // c - m.pdfium.setValue(matrixPtr + 12, drawHeight, "float"); // d — scaleY - m.pdfium.setValue(matrixPtr + 16, pdfX, "float"); // e — translateX - m.pdfium.setValue(matrixPtr + 20, pdfY, "float"); // f — translateY - - if (!m.FPDFPageObj_SetMatrix(imageObjPtr, matrixPtr)) { - m.FPDFPageObj_Destroy(imageObjPtr); - return false; - } - } finally { - m.pdfium.wasmExports.free(matrixPtr); - } - - m.FPDFPage_InsertObject(pagePtr, imageObjPtr); - return true; - } finally { - // Safety net: FPDFBitmap_Destroy is a no-op if ptr is 0 in most PDFium - // builds but guard anyway. If already destroyed above, the second call - // is harmless because we allow it to be idempotent. - // We use a try-catch to be safe across PDFium WASM builds. - try { - m.FPDFBitmap_Destroy(bitmapPtr); - } catch { - /* already freed */ - } - } + m.FPDFPage_InsertObject(pagePtr, imageObjPtr); + return true; } /** * Draw a simple light-grey rectangle as a placeholder for annotations @@ -206,8 +232,8 @@ export function decodeImageDataUrl( img.onload = () => { try { const canvas = document.createElement("canvas"); - canvas.width = img.width; - canvas.height = img.height; + canvas.width = img.naturalWidth || img.width; + canvas.height = img.naturalHeight || img.height; const ctx = canvas.getContext("2d"); if (!ctx) { resolve(null); diff --git a/frontend/editor/src/core/utils/scriptLoader.test.ts b/frontend/editor/src/core/utils/scriptLoader.test.ts new file mode 100644 index 0000000000..9fce749d56 --- /dev/null +++ b/frontend/editor/src/core/utils/scriptLoader.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect } from "vitest"; +import { loadScript, isScriptLoaded } from "@app/utils/scriptLoader"; + +/** jsdom doesn't fetch external scripts, so we drive the load/error events ourselves. */ +function scriptEl(id: string): HTMLScriptElement { + const el = document.getElementById(id) as HTMLScriptElement | null; + if (!el) throw new Error(`no script tag #${id}`); + return el; +} + +describe("loadScript", () => { + it("overlapping loads share one tag and resolve only after the script actually loads", async () => { + const id = "test-script-overlap"; + const src = "https://example.test/overlap.js"; + + let firstResolved = false; + let secondResolved = false; + const p1 = loadScript({ src, id }).then(() => { + firstResolved = true; + }); + // Second, overlapping call (e.g. a StrictMode double-effect or a remount before the + // first load settled). It must reuse the in-flight load, not resolve on tag existence. + const p2 = loadScript({ src, id }).then(() => { + secondResolved = true; + }); + + // Only one tag despite two calls. + expect(document.querySelectorAll(`#${id}`)).toHaveLength(1); + + // Regression guard: before the script executes, neither promise resolves — previously + // the second call resolved immediately because the tag existed, so callers ran before + // the script's globals were defined (the "blank until you reopen" bug). + await Promise.resolve(); + expect(firstResolved).toBe(false); + expect(secondResolved).toBe(false); + expect(isScriptLoaded(id)).toBe(false); + + scriptEl(id).dispatchEvent(new Event("load")); + await Promise.all([p1, p2]); + expect(firstResolved).toBe(true); + expect(secondResolved).toBe(true); + expect(isScriptLoaded(id)).toBe(true); + }); + + it("resolves immediately once the script has already loaded", async () => { + const id = "test-script-cached"; + const src = "https://example.test/cached.js"; + + const first = loadScript({ src, id }); + scriptEl(id).dispatchEvent(new Event("load")); + await first; + + // A later call must resolve from cache without waiting for a fresh load event (which + // would never come) and without adding a second tag. + await loadScript({ src, id }); + expect(document.querySelectorAll(`#${id}`)).toHaveLength(1); + }); + + it("rejects when the script fails to load", async () => { + const id = "test-script-error"; + const src = "https://example.test/error.js"; + + const p = loadScript({ src, id }); + scriptEl(id).dispatchEvent(new Event("error")); + await expect(p).rejects.toThrow(/Failed to load script/); + }); + + it("drops the failed tag so a retry re-attempts instead of hanging", async () => { + const id = "test-script-retry"; + const src = "https://example.test/retry.js"; + + // First attempt (e.g. a warm-up) fails. + const first = loadScript({ src, id }); + scriptEl(id).dispatchEvent(new Event("error")); + await expect(first).rejects.toThrow(/Failed to load script/); + // The poisoned tag must be gone — otherwise a retry would attach to a dead tag. + expect(document.querySelectorAll(`#${id}`)).toHaveLength(0); + + // Retry (e.g. the modal opening) creates a fresh tag and can now succeed. + const second = loadScript({ src, id }); + expect(document.querySelectorAll(`#${id}`)).toHaveLength(1); + scriptEl(id).dispatchEvent(new Event("load")); + await expect(second).resolves.toBeUndefined(); + expect(isScriptLoaded(id)).toBe(true); + }); +}); diff --git a/frontend/editor/src/core/utils/scriptLoader.ts b/frontend/editor/src/core/utils/scriptLoader.ts index 60c9c02ec8..58179c4b91 100644 --- a/frontend/editor/src/core/utils/scriptLoader.ts +++ b/frontend/editor/src/core/utils/scriptLoader.ts @@ -11,6 +11,11 @@ interface ScriptLoadOptions { } const loadedScripts = new Set(); +// Loads that have started but not yet finished, keyed by script id/src. Overlapping +// callers (e.g. a React StrictMode double-effect, or a component that remounts before +// the previous load settled) reuse the same promise so they all resolve only when the +// script has actually executed — never on tag existence alone. +const pendingScripts = new Map>(); export function loadScript({ src, @@ -19,43 +24,75 @@ export function loadScript({ defer = false, onLoad, }: ScriptLoadOptions): Promise { - return new Promise((resolve, reject) => { - // Check if already loaded - const scriptId = id || src; - if (loadedScripts.has(scriptId)) { - resolve(); - return; - } + const scriptId = id || src; - // Check if script already exists in DOM - const existingScript = id - ? document.getElementById(id) - : document.querySelector(`script[src="${src}"]`); - if (existingScript) { + // Already fully loaded and executed. + if (loadedScripts.has(scriptId)) { + onLoad?.(); + return Promise.resolve(); + } + + // A load for the same script is already in flight — wait for it rather than kicking + // off a second one (and, critically, don't resolve just because the tag is present). + const inFlight = pendingScripts.get(scriptId); + if (inFlight) { + return onLoad ? inFlight.then(() => onLoad()) : inFlight; + } + + const promise = new Promise((resolve, reject) => { + const settleLoaded = (script: HTMLScriptElement) => { + script.dataset.loaded = "true"; loadedScripts.add(scriptId); + pendingScripts.delete(scriptId); + onLoad?.(); resolve(); + }; + const settleError = (el: HTMLScriptElement) => { + pendingScripts.delete(scriptId); + // Drop the failed tag so a later retry (e.g. the modal after a warm-up that was + // blocked by an extension) creates a fresh one and re-attempts, rather than + // attaching to a dead tag whose error event won't fire again and hanging forever. + el.remove(); + reject(new Error(`Failed to load script: ${src}`)); + }; + + // A matching tag may already be in the DOM (added by an earlier load whose Set/Map + // state was lost, or injected elsewhere). If our own loader finished it, the + // data-loaded flag is set and we can resolve immediately; otherwise attach to its + // load lifecycle instead of assuming it is ready. + const existing = ( + id + ? document.getElementById(id) + : document.querySelector(`script[src="${src}"]`) + ) as HTMLScriptElement | null; + if (existing) { + if (existing.dataset.loaded === "true") { + loadedScripts.add(scriptId); + onLoad?.(); + resolve(); + return; + } + existing.addEventListener("load", () => settleLoaded(existing), { + once: true, + }); + existing.addEventListener("error", () => settleError(existing), { + once: true, + }); return; } - // Create and append script const script = document.createElement("script"); script.src = src; if (id) script.id = id; script.async = async; script.defer = defer; - - script.onload = () => { - loadedScripts.add(scriptId); - if (onLoad) onLoad(); - resolve(); - }; - - script.onerror = () => { - reject(new Error(`Failed to load script: ${src}`)); - }; - + script.addEventListener("load", () => settleLoaded(script), { once: true }); + script.addEventListener("error", () => settleError(script), { once: true }); document.head.appendChild(script); }); + + pendingScripts.set(scriptId, promise); + return promise; } export function isScriptLoaded(idOrSrc: string): boolean { diff --git a/frontend/editor/src/core/utils/signatureFlattening.test.ts b/frontend/editor/src/core/utils/signatureFlattening.test.ts new file mode 100644 index 0000000000..cb31b9cd01 --- /dev/null +++ b/frontend/editor/src/core/utils/signatureFlattening.test.ts @@ -0,0 +1,114 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { afterAll, beforeAll, describe, expect, test, vi } from "vitest"; +import { + PDFArray, + PDFDict, + PDFDocument, + PDFName, + PDFNumber, + PDFRawStream, + decodePDFRawStream, +} from "@cantoo/pdf-lib"; +import { embedSignatureImages } from "@app/utils/signatureFlattening"; + +const ONE_PIXEL_PNG = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="; + +beforeAll(async () => { + const wasmPath = path.resolve( + process.cwd(), + "node_modules/@embedpdf/pdfium/dist/pdfium.wasm", + ); + const wasmBytes = await readFile(wasmPath); + vi.stubGlobal( + "fetch", + vi.fn(async () => + Promise.resolve( + new Response(wasmBytes, { + headers: { "Content-Type": "application/wasm" }, + }), + ), + ), + ); +}); + +afterAll(() => { + vi.unstubAllGlobals(); +}); + +const readPageContentStreams = (document: PDFDocument): string[] => { + const contents = document.getPage(0).node.Contents(); + if (!(contents instanceof PDFArray)) return []; + + const decoder = new TextDecoder(); + const streams: string[] = []; + for (let index = 0; index < contents.size(); index++) { + const stream = contents.lookup(index, PDFRawStream); + streams.push(decoder.decode(decodePDFRawStream(stream).decode())); + } + return streams; +}; + +describe("signatureFlattening", () => { + test("adds a PDFium stamp without regenerating page content", async () => { + const sourceDocument = await PDFDocument.create(); + const sourcePage = sourceDocument.addPage([300, 400]); + const markerStream = sourceDocument.context.stream( + "q\n% ORIGINAL_TYPE3_CONTENT\nQ\n", + ); + sourcePage.node.addContentStream( + sourceDocument.context.register(markerStream), + ); + const sourceBytes = await sourceDocument.save(); + + const outputBytes = await embedSignatureImages( + Uint8Array.from(sourceBytes).buffer, + [ + { + pageIndex: 0, + annotations: [ + { + id: "signature-1", + // EmbedPDF may expose an internal asset reference here after the + // annotation has been placed. The persisted PNG must win. + imageData: "embedpdf-asset-reference", + rect: { + origin: { x: 25, y: 30 }, + size: { width: 120, height: 50 }, + }, + imageSrc: `data:image/png;base64,${ONE_PIXEL_PNG}`, + }, + ], + }, + ], + (id) => + id === "signature-1" + ? `data:image/png;base64,${ONE_PIXEL_PNG}` + : undefined, + async () => ({ + width: 1, + height: 1, + rgba: new Uint8Array([0, 80, 180, 255]), + }), + ); + + const outputDocument = await PDFDocument.load(outputBytes); + const contentStreams = readPageContentStreams(outputDocument); + const annotations = outputDocument.getPage(0).node.Annots(); + + expect(contentStreams).toContain("q\n% ORIGINAL_TYPE3_CONTENT\nQ\n"); + expect(annotations).toBeInstanceOf(PDFArray); + + const stamp = annotations?.lookup(0, PDFDict); + const stampRect = stamp?.lookup(PDFName.of("Rect"), PDFArray); + expect(stamp?.get(PDFName.of("Subtype"))).toEqual(PDFName.of("Stamp")); + expect(stamp?.lookup(PDFName.of("F"), PDFNumber).asNumber()).toBe(196); + expect(stamp?.get(PDFName.of("AP"))).toBeDefined(); + expect( + Array.from({ length: stampRect?.size() ?? 0 }, (_, index) => + stampRect?.lookup(index, PDFNumber).asNumber(), + ), + ).toEqual([25, 320, 145, 370]); + }, 20_000); +}); diff --git a/frontend/editor/src/core/utils/signatureFlattening.ts b/frontend/editor/src/core/utils/signatureFlattening.ts index 56d9d63fef..a6200f52f2 100644 --- a/frontend/editor/src/core/utils/signatureFlattening.ts +++ b/frontend/editor/src/core/utils/signatureFlattening.ts @@ -1,11 +1,15 @@ -// PDFium annotation subtype constants import { - FPDF_ANNOT_INK, - FPDF_ANNOT_LINE, - embedBitmapImageOnPage, - drawPlaceholderRect, + createBitmapImageObject, decodeImageDataUrl, + type DecodedImage, } from "@app/utils/pdfiumBitmapUtils"; +import { + closeDocAndFreeBuffer, + getPdfiumModule, + openRawDocumentSafe, + readEffectivePageBox, + saveRawDocument, +} from "@app/services/pdfiumService"; import { generateThumbnailWithMetadata } from "@app/utils/thumbnailUtils"; import { createChildStub, @@ -18,12 +22,6 @@ import { StirlingFileStub, } from "@app/types/fileContext"; import type { SignatureAPI } from "@app/components/viewer/viewerTypes"; -import { - getPdfiumModule, - openRawDocumentSafe, - closeDocAndFreeBuffer, - saveRawDocument, -} from "@app/services/pdfiumService"; interface MinimalFileContextSelectors { getAllFileIds: () => FileId[]; @@ -75,23 +73,9 @@ export async function flattenSignatures( const pageAnnotations = await signatureApiRef.current.getPageAnnotations(pageIndex); if (pageAnnotations && pageAnnotations.length > 0) { - const sessionAnnotations = pageAnnotations.filter((annotation) => { - const hasStoredImageData = - annotation.id && getImageData(annotation.id); - const hasDirectImageData = - annotation.imageData || - annotation.appearance || - annotation.stampData || - annotation.imageSrc || - annotation.contents || - annotation.data; - return ( - hasStoredImageData || - (hasDirectImageData && - typeof hasDirectImageData === "string" && - hasDirectImageData.startsWith("data:image")) - ); - }); + const sessionAnnotations = pageAnnotations.filter((annotation) => + Boolean(getAnnotationImageData(annotation, getImageData)), + ); if (sessionAnnotations.length > 0) { allAnnotations.push({ @@ -166,143 +150,23 @@ export async function flattenSignatures( type: "application/pdf", }); - // Step 4: Manually render extracted annotations onto the PDF using PDFium WASM + // Step 4: Add signatures as locked, printable PDFium stamp annotations. + // FPDFAnnot_AppendObject creates the annotation appearance without asking + // PDFium to regenerate the page's existing content. GenerateContent would + // corrupt some Type3/vector content, including the issue #7083 logo. if (allAnnotations.length > 0) { try { - const pdfArrayBufferForFlattening = await signedFile.arrayBuffer(); - const m = await getPdfiumModule(); - const docPtr = await openRawDocumentSafe(pdfArrayBufferForFlattening); - - try { - const pageCount = m.FPDF_GetPageCount(docPtr); - - for (const pageData of allAnnotations) { - const { pageIndex, annotations } = pageData; - - if (pageIndex < pageCount) { - const pagePtr = m.FPDF_LoadPage(docPtr, pageIndex); - if (!pagePtr) continue; - - const pageHeight = m.FPDF_GetPageHeightF(pagePtr); - - for (const annotation of annotations) { - try { - const rect = - annotation.rect || - annotation.bounds || - annotation.rectangle || - annotation.position; - - if (rect) { - const originalX = - rect.origin?.x || rect.x || rect.left || 0; - const originalY = - rect.origin?.y || rect.y || rect.top || 0; - const width = rect.size?.width || rect.width || 100; - const height = rect.size?.height || rect.height || 50; - - // Convert from CSS top-left to PDF bottom-left - const pdfX = originalX; - const pdfY = pageHeight - originalY - height; - - let imageDataUrl = - annotation.imageData || - annotation.appearance || - annotation.stampData || - annotation.imageSrc || - annotation.contents || - annotation.data; - - if (!imageDataUrl && annotation.id) { - const storedImageData = getImageData(annotation.id); - if (storedImageData) { - imageDataUrl = storedImageData; - } - } - - // Convert SVG to PNG first if needed - if ( - imageDataUrl && - typeof imageDataUrl === "string" && - imageDataUrl.startsWith("data:image/svg+xml") - ) { - const pngBytes = await rasteriseSvgToPng( - imageDataUrl, - width * 2, - height * 2, - ); - if (pngBytes) { - imageDataUrl = await uint8ArrayToPngDataUrl(pngBytes); - } else { - drawPlaceholderRect( - m, - pagePtr, - pdfX, - pdfY, - width, - height, - ); - continue; - } - } - - if ( - imageDataUrl && - typeof imageDataUrl === "string" && - imageDataUrl.startsWith("data:image") - ) { - // Decode the image data URL to raw pixels via canvas - const imageResult = - await decodeImageDataUrl(imageDataUrl); - if (imageResult) { - embedBitmapImageOnPage( - m, - docPtr, - pagePtr, - imageResult, - pdfX, - pdfY, - width, - height, - ); - } - } else if ( - annotation.type === FPDF_ANNOT_INK || - annotation.type === FPDF_ANNOT_LINE - ) { - drawPlaceholderRect( - m, - pagePtr, - pdfX, - pdfY, - width, - height, - ); - } - } - } catch (annotationError) { - console.warn( - "Failed to render annotation:", - annotationError, - ); - } - } - - m.FPDFPage_GenerateContent(pagePtr); - m.FPDF_ClosePage(pagePtr); - } - } - - const resultBuf = await saveRawDocument(docPtr); - signedFile = new File([resultBuf], currentFile.name, { - type: "application/pdf", - }); - } finally { - closeDocAndFreeBuffer(m, docPtr); - } + const resultBytes = await embedSignatureImages( + await signedFile.arrayBuffer(), + allAnnotations, + getImageData, + ); + signedFile = new File([resultBytes as BlobPart], currentFile.name, { + type: "application/pdf", + }); } catch (renderError) { - console.error("Failed to manually render annotations:", renderError); - console.warn("Signatures may only show as annotations"); + console.error("Failed to embed signature images:", renderError); + console.warn("Signatures may only remain as annotations"); } } @@ -343,16 +207,211 @@ export async function flattenSignatures( } } -/** - * Convert Uint8Array PNG bytes to a data URL for canvas decoding. - */ -function uint8ArrayToPngDataUrl(pngBytes: Uint8Array): Promise { - return new Promise((resolve) => { - const blob = new Blob([pngBytes as BlobPart], { type: "image/png" }); - const reader = new FileReader(); - reader.onloadend = () => resolve(reader.result as string); - reader.readAsDataURL(blob); - }); +type SignatureAnnotationsByPage = Array<{ + pageIndex: number; + annotations: any[]; +}>; + +function extractImageDataUrl( + value: unknown, + depth = 0, + visited: Set = new Set(), +): string | undefined { + if (!value || depth > 6) return undefined; + + if (typeof value === "string") { + return value.startsWith("data:image") ? value : undefined; + } + + if (typeof value !== "object" || visited.has(value)) return undefined; + visited.add(value); + + const entries = Array.isArray(value) + ? value + : Object.values(value as Record); + for (const entry of entries) { + const imageDataUrl = extractImageDataUrl(entry, depth + 1, visited); + if (imageDataUrl) return imageDataUrl; + } + + return undefined; +} + +function getAnnotationImageData( + annotation: any, + getImageData: (id: string) => string | undefined, +): string | undefined { + // EmbedPDF can replace fields such as imageData/appearance with an internal + // asset reference after placement. Prefer our persistent original and only + // accept values that actually contain an image data URL. + const candidates: unknown[] = [ + annotation.id ? getImageData(annotation.id) : undefined, + annotation.imageSrc, + annotation.imageData, + annotation.appearance, + annotation.stampData, + annotation.contents, + annotation.data, + annotation.customData, + annotation.asset, + ]; + + for (const candidate of candidates) { + const imageDataUrl = extractImageDataUrl(candidate); + if (imageDataUrl) return imageDataUrl; + } + + return undefined; +} + +export async function embedSignatureImages( + pdfArrayBuffer: ArrayBuffer, + annotationsByPage: SignatureAnnotationsByPage, + getImageData: (id: string) => string | undefined, + imageDecoder: ( + dataUrl: string, + ) => Promise = decodeImageDataUrl, +): Promise { + const m = await getPdfiumModule(); + const docPtr = await openRawDocumentSafe(pdfArrayBuffer); + + try { + const pageCount = m.FPDF_GetPageCount(docPtr); + + for (const { pageIndex, annotations } of annotationsByPage) { + if (pageIndex < 0 || pageIndex >= pageCount) continue; + + const pagePtr = m.FPDF_LoadPage(docPtr, pageIndex); + if (!pagePtr) continue; + + try { + const pageBox = readEffectivePageBox(m, pagePtr); + const cropHeight = pageBox.top - pageBox.bottom; + + for (const annotation of annotations) { + const rect = + annotation.rect ?? + annotation.bounds ?? + annotation.rectangle ?? + annotation.position; + if (!rect) continue; + + const originalX = rect.origin?.x ?? rect.x ?? rect.left ?? 0; + const originalY = rect.origin?.y ?? rect.y ?? rect.top ?? 0; + const width = rect.size?.width ?? rect.width ?? 100; + const height = rect.size?.height ?? rect.height ?? 50; + if (width <= 0 || height <= 0) continue; + + let imageDataUrl = getAnnotationImageData(annotation, getImageData); + if (!imageDataUrl) continue; + + if (imageDataUrl.startsWith("data:image/svg+xml")) { + const pngBytes = await rasteriseSvgToPng( + imageDataUrl, + width * 2, + height * 2, + ); + if (!pngBytes) continue; + imageDataUrl = `data:image/png;base64,${uint8ArrayToBase64(pngBytes)}`; + } + + const decodedImage = await imageDecoder(imageDataUrl); + if (!decodedImage) continue; + + const pdfX = pageBox.left + originalX; + const pdfY = pageBox.bottom + cropHeight - originalY - height; + appendStampAnnotation( + m, + docPtr, + pagePtr, + decodedImage, + pdfX, + pdfY, + width, + height, + ); + } + } finally { + m.FPDF_ClosePage(pagePtr); + } + } + + return await saveRawDocument(docPtr); + } finally { + closeDocAndFreeBuffer(m, docPtr); + } +} + +const FPDF_ANNOT_STAMP = 13; +const FPDF_ANNOT_FLAG_PRINT = 1 << 2; +const FPDF_ANNOT_FLAG_READONLY = 1 << 6; +const FPDF_ANNOT_FLAG_LOCKED = 1 << 7; + +function appendStampAnnotation( + m: Awaited>, + docPtr: number, + pagePtr: number, + image: DecodedImage, + pdfX: number, + pdfY: number, + width: number, + height: number, +): boolean { + const annotationIndex = m.FPDFPage_GetAnnotCount(pagePtr); + const annotPtr = m.FPDFPage_CreateAnnot(pagePtr, FPDF_ANNOT_STAMP); + if (!annotPtr) return false; + + let appended = false; + let imageObjPtr = 0; + const rectPtr = m.pdfium.wasmExports.malloc(4 * 4); + + try { + // FS_RECTF layout: left, top, right, bottom. + m.pdfium.setValue(rectPtr, pdfX, "float"); + m.pdfium.setValue(rectPtr + 4, pdfY + height, "float"); + m.pdfium.setValue(rectPtr + 8, pdfX + width, "float"); + m.pdfium.setValue(rectPtr + 12, pdfY, "float"); + if (!m.FPDFAnnot_SetRect(annotPtr, rectPtr)) return false; + + imageObjPtr = + createBitmapImageObject( + m, + docPtr, + pagePtr, + image, + pdfX, + pdfY, + width, + height, + ) ?? 0; + if (!imageObjPtr) return false; + + if (!m.FPDFAnnot_AppendObject(annotPtr, imageObjPtr)) return false; + imageObjPtr = 0; // The annotation owns the object after a successful append. + + m.FPDFAnnot_SetFlags( + annotPtr, + FPDF_ANNOT_FLAG_PRINT | FPDF_ANNOT_FLAG_READONLY | FPDF_ANNOT_FLAG_LOCKED, + ); + appended = true; + return true; + } finally { + m.pdfium.wasmExports.free(rectPtr); + if (imageObjPtr) m.FPDFPageObj_Destroy(imageObjPtr); + m.FPDFPage_CloseAnnot(annotPtr); + if (!appended) m.FPDFPage_RemoveAnnot(pagePtr, annotationIndex); + } +} + +function uint8ArrayToBase64(bytes: Uint8Array): string { + let binary = ""; + const chunkSize = 0x8000; + for (let offset = 0; offset < bytes.length; offset += chunkSize) { + binary += String.fromCharCode( + ...bytes.subarray(offset, offset + chunkSize), + ); + } + return btoa(binary); } /** diff --git a/frontend/editor/src/desktop/components/DesktopOnboardingModal.tsx b/frontend/editor/src/desktop/components/DesktopOnboardingModal.tsx index 96bddff2af..4f9222c2b0 100644 --- a/frontend/editor/src/desktop/components/DesktopOnboardingModal.tsx +++ b/frontend/editor/src/desktop/components/DesktopOnboardingModal.tsx @@ -15,7 +15,10 @@ import { connectionModeService } from "@app/services/connectionModeService"; const ONBOARDING_KEY = "stirling-desktop-onboarding-seen"; -const SIGN_IN_GRADIENT: [string, string] = ["#3B82F6", "#7C3AED"]; +const SIGN_IN_GRADIENT: [string, string] = [ + "var(--c-hue-blue)", + "var(--c-hue-violet)", +]; /** * Desktop-specific onboarding modal. @@ -76,7 +79,7 @@ export function DesktopOnboardingModal() { content: { overflow: "hidden", border: "none", - background: "var(--bg-surface)", + background: "var(--c-surface)", maxHeight: "90vh", display: "flex", flexDirection: "column", @@ -158,7 +161,7 @@ export function DesktopOnboardingModal() {
    {welcomeSlide.body}
    - +
    diff --git a/frontend/editor/src/desktop/components/SetupWizard/DesktopAuthLayout.tsx b/frontend/editor/src/desktop/components/SetupWizard/DesktopAuthLayout.tsx index f5e64f2927..7d4af4fa81 100644 --- a/frontend/editor/src/desktop/components/SetupWizard/DesktopAuthLayout.tsx +++ b/frontend/editor/src/desktop/components/SetupWizard/DesktopAuthLayout.tsx @@ -1,9 +1,5 @@ -import React, { useEffect, useMemo, useRef, useState } from "react"; -import { useTranslation } from "react-i18next"; -import LoginRightCarousel from "@app/auth/ui/LoginRightCarousel"; -import buildLoginSlides from "@app/components/shared/loginSlides"; -import styles from "@app/auth/ui/AuthShell.module.css"; -import { useLogoVariant } from "@app/hooks/useLogoVariant"; +import React from "react"; +import { AuthShell } from "@app/auth/ui/AuthShell"; interface DesktopAuthLayoutProps { children: React.ReactNode; @@ -12,52 +8,5 @@ interface DesktopAuthLayoutProps { export const DesktopAuthLayout: React.FC = ({ children, }) => { - const { t } = useTranslation(); - const cardRef = useRef(null); - const [hideRightPanel, setHideRightPanel] = useState(false); - const logoVariant = useLogoVariant(); - const imageSlides = useMemo( - () => buildLoginSlides(logoVariant, t), - [logoVariant, t], - ); - - useEffect(() => { - const update = () => { - // Use viewport to avoid hysteresis when the card is already in single-column mode - const viewportWidth = window.innerWidth; - const viewportHeight = window.innerHeight; - const cardWidthIfTwoCols = Math.min(1180, viewportWidth * 0.96); // matches min(73.75rem, 96vw) - const columnWidth = cardWidthIfTwoCols / 2; - const tooNarrow = columnWidth < 470; - const tooShort = viewportHeight < 740; - setHideRightPanel(tooNarrow || tooShort); - }; - update(); - window.addEventListener("resize", update); - window.addEventListener("orientationchange", update); - return () => { - window.removeEventListener("resize", update); - window.removeEventListener("orientationchange", update); - }; - }, []); - - return ( -
    -
    -
    -
    {children}
    -
    - {!hideRightPanel && ( - - )} -
    -
    - ); + return {children}; }; diff --git a/frontend/editor/src/desktop/components/SetupWizard/desktopOAuth.css b/frontend/editor/src/desktop/components/SetupWizard/desktopOAuth.css index 6661c6a99b..3483c3563a 100644 --- a/frontend/editor/src/desktop/components/SetupWizard/desktopOAuth.css +++ b/frontend/editor/src/desktop/components/SetupWizard/desktopOAuth.css @@ -17,12 +17,12 @@ own padding-block); horizontal stays in the shorthand. */ --sui-btn-py: 1rem; /* 16px (md) */ padding: 1rem 1rem; /* 16px */ - border: 1px solid var(--auth-input-border-light-only); + border: 1px solid var(--auth-input-border); border-radius: 0.75rem; /* 12px */ - background-color: var(--auth-card-bg-light-only); + background-color: var(--auth-card-bg); font-size: 1rem; /* 16px */ font-weight: 500; - color: var(--auth-text-primary-light-only); + color: var(--auth-text-primary); cursor: pointer; gap: 0.75rem; /* 12px */ font-family: inherit; @@ -30,7 +30,7 @@ } .oauth-button-vertical-desktop:hover:not(:disabled) { - background-color: var(--bg-raised); + background-color: var(--c-surface-raised); } .oauth-button-vertical-desktop:disabled { @@ -39,7 +39,7 @@ } .oauth-button-vertical-desktop:focus-visible { - outline: 2px solid var(--auth-border-focus-light-only); + outline: 2px solid var(--auth-border-focus); outline-offset: 2px; } diff --git a/frontend/editor/src/desktop/components/policies/usePoliciesEnabled.ts b/frontend/editor/src/desktop/components/policies/usePoliciesEnabled.ts index efc8f33fa7..e04859a45e 100644 --- a/frontend/editor/src/desktop/components/policies/usePoliciesEnabled.ts +++ b/frontend/editor/src/desktop/components/policies/usePoliciesEnabled.ts @@ -1,19 +1,5 @@ -import { POLICIES_ENABLED } from "@app/constants/featureFlags"; import { useConfirmedSaaSMode } from "@app/hooks/useConfirmedSaaSMode"; -/** - * Desktop shadow: policy runs execute + bill via the cloud (POST - * /api/v1/policies/.../run hits the SaaS backend), so the feature must stay - * off in local ("disconnected") and self-hosted modes — otherwise the - * auto-run controller would fire policy runs against a backend that doesn't - * serve them. - * - * Pessimistic SaaS-mode check (starts false): this gate controls whether - * PolicyAutoRunController mounts, and that fires GET /api/v1/policies on - * mount. useSaaSMode()'s optimistic-true default would leak that request - * against the local/self-hosted backend on cold start before the mode - * resolves. - */ export function usePoliciesEnabled(): boolean { - return POLICIES_ENABLED && useConfirmedSaaSMode(); + return useConfirmedSaaSMode(); } diff --git a/frontend/editor/src/desktop/components/shared/config/configNavSections.tsx b/frontend/editor/src/desktop/components/shared/config/configNavSections.tsx index 07afc16599..9a638d1461 100644 --- a/frontend/editor/src/desktop/components/shared/config/configNavSections.tsx +++ b/frontend/editor/src/desktop/components/shared/config/configNavSections.tsx @@ -23,6 +23,7 @@ export const useConfigNavSections = ( runningEE: boolean = false, loginEnabled: boolean = false, onRequestClose: () => void = () => {}, + showSettingsWhenNoLogin: boolean = true, ): ConfigNavSection[] => { const { t } = useTranslation(); @@ -53,6 +54,7 @@ export const useConfigNavSections = ( runningEE, loginEnabled, onRequestClose, + showSettingsWhenNoLogin, ); const connectionModeSection: ConfigNavSection = { diff --git a/frontend/editor/src/desktop/components/tools/toolPicker/ToolPickerFooterExtensions.tsx b/frontend/editor/src/desktop/components/tools/toolPicker/ToolPickerFooterExtensions.tsx index 1dc41e9497..d573f189af 100644 --- a/frontend/editor/src/desktop/components/tools/toolPicker/ToolPickerFooterExtensions.tsx +++ b/frontend/editor/src/desktop/components/tools/toolPicker/ToolPickerFooterExtensions.tsx @@ -40,8 +40,8 @@ export function ToolPickerFooterExtensions() { px="sm" py={10} style={{ - borderTop: "1px solid var(--border-default)", - background: "var(--bg-toolbar)", + borderTop: "1px solid var(--c-border)", + background: "var(--c-bg-raised)", flexShrink: 0, }} > diff --git a/frontend/editor/src/desktop/constants/featureFlags.ts b/frontend/editor/src/desktop/constants/featureFlags.ts deleted file mode 100644 index d003b3e642..0000000000 --- a/frontend/editor/src/desktop/constants/featureFlags.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Desktop-build feature gates. Shadows `proprietary/constants/featureFlags.ts` - * (the desktop `@app/*` alias has no saas layer). Re-exports the proprietary - * flags and re-enables Policies: the desktop Policies gate additionally requires - * an active SaaS connection (see the desktop `usePoliciesEnabled` shadow), so - * the flag must be on for that runtime check to ever apply. - */ -export * from "@proprietary/constants/featureFlags"; - -export const POLICIES_ENABLED: boolean = true; diff --git a/frontend/editor/src/desktop/routes/login/LoginHeader.tsx b/frontend/editor/src/desktop/routes/login/LoginHeader.tsx index fc715e6696..1b7900956e 100644 --- a/frontend/editor/src/desktop/routes/login/LoginHeader.tsx +++ b/frontend/editor/src/desktop/routes/login/LoginHeader.tsx @@ -63,7 +63,7 @@ export default function LoginHeader({ aria-label={t("common.close", "Close")} style={{ flexShrink: 0, - color: "var(--text-secondary)", + color: "var(--c-text-muted)", outline: "none", }} > diff --git a/frontend/editor/src/output.css b/frontend/editor/src/output.css index c77b20bc87..09e3931f55 100644 --- a/frontend/editor/src/output.css +++ b/frontend/editor/src/output.css @@ -1,362 +1,375 @@ -*, ::before, ::after { - --tw-border-spacing-x: 0; - --tw-border-spacing-y: 0; - --tw-translate-x: 0; - --tw-translate-y: 0; - --tw-rotate: 0; - --tw-skew-x: 0; - --tw-skew-y: 0; - --tw-scale-x: 1; - --tw-scale-y: 1; - --tw-pan-x: ; - --tw-pan-y: ; - --tw-pinch-zoom: ; - --tw-scroll-snap-strictness: proximity; - --tw-gradient-from-position: ; - --tw-gradient-via-position: ; - --tw-gradient-to-position: ; - --tw-ordinal: ; - --tw-slashed-zero: ; - --tw-numeric-figure: ; - --tw-numeric-spacing: ; - --tw-numeric-fraction: ; - --tw-ring-inset: ; - --tw-ring-offset-width: 0px; - --tw-ring-offset-color: #fff; - --tw-ring-color: rgb(59 130 246 / 0.5); - --tw-ring-offset-shadow: 0 0 #0000; - --tw-ring-shadow: 0 0 #0000; - --tw-shadow: 0 0 #0000; - --tw-shadow-colored: 0 0 #0000; - --tw-blur: ; - --tw-brightness: ; - --tw-contrast: ; - --tw-grayscale: ; - --tw-hue-rotate: ; - --tw-invert: ; - --tw-saturate: ; - --tw-sepia: ; - --tw-drop-shadow: ; - --tw-backdrop-blur: ; - --tw-backdrop-brightness: ; - --tw-backdrop-contrast: ; - --tw-backdrop-grayscale: ; - --tw-backdrop-hue-rotate: ; - --tw-backdrop-invert: ; - --tw-backdrop-opacity: ; - --tw-backdrop-saturate: ; - --tw-backdrop-sepia: ; - --tw-contain-size: ; - --tw-contain-layout: ; - --tw-contain-paint: ; - --tw-contain-style: +*, +::before, +::after { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-gradient-from-position: ; + --tw-gradient-via-position: ; + --tw-gradient-to-position: ; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgb(59 130 246 / 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; + --tw-contain-size: ; + --tw-contain-layout: ; + --tw-contain-paint: ; + --tw-contain-style: +; } ::backdrop { - --tw-border-spacing-x: 0; - --tw-border-spacing-y: 0; - --tw-translate-x: 0; - --tw-translate-y: 0; - --tw-rotate: 0; - --tw-skew-x: 0; - --tw-skew-y: 0; - --tw-scale-x: 1; - --tw-scale-y: 1; - --tw-pan-x: ; - --tw-pan-y: ; - --tw-pinch-zoom: ; - --tw-scroll-snap-strictness: proximity; - --tw-gradient-from-position: ; - --tw-gradient-via-position: ; - --tw-gradient-to-position: ; - --tw-ordinal: ; - --tw-slashed-zero: ; - --tw-numeric-figure: ; - --tw-numeric-spacing: ; - --tw-numeric-fraction: ; - --tw-ring-inset: ; - --tw-ring-offset-width: 0px; - --tw-ring-offset-color: #fff; - --tw-ring-color: rgb(59 130 246 / 0.5); - --tw-ring-offset-shadow: 0 0 #0000; - --tw-ring-shadow: 0 0 #0000; - --tw-shadow: 0 0 #0000; - --tw-shadow-colored: 0 0 #0000; - --tw-blur: ; - --tw-brightness: ; - --tw-contrast: ; - --tw-grayscale: ; - --tw-hue-rotate: ; - --tw-invert: ; - --tw-saturate: ; - --tw-sepia: ; - --tw-drop-shadow: ; - --tw-backdrop-blur: ; - --tw-backdrop-brightness: ; - --tw-backdrop-contrast: ; - --tw-backdrop-grayscale: ; - --tw-backdrop-hue-rotate: ; - --tw-backdrop-invert: ; - --tw-backdrop-opacity: ; - --tw-backdrop-saturate: ; - --tw-backdrop-sepia: ; - --tw-contain-size: ; - --tw-contain-layout: ; - --tw-contain-paint: ; - --tw-contain-style: + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-gradient-from-position: ; + --tw-gradient-via-position: ; + --tw-gradient-to-position: ; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgb(59 130 246 / 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; + --tw-contain-size: ; + --tw-contain-layout: ; + --tw-contain-paint: ; + --tw-contain-style: +; } .mb-3 { - margin-bottom: 0.75rem + margin-bottom: 0.75rem; } .mb-4 { - margin-bottom: 1rem + margin-bottom: 1rem; } .mr-2 { - margin-right: 0.5rem + margin-right: 0.5rem; } .mt-2 { - margin-top: 0.5rem + margin-top: 0.5rem; } .mt-4 { - margin-top: 1rem + margin-top: 1rem; } .block { - display: block + display: block; } .flex { - display: flex + display: flex; } .hidden { - display: none + display: none; } .h-6 { - height: 1.5rem + height: 1.5rem; } .h-full { - height: 100% + height: 100%; } .h-screen { - height: 100vh + height: 100vh; } .w-6 { - width: 1.5rem + width: 1.5rem; } .w-64 { - width: 16rem + width: 16rem; } .w-72 { - width: 18rem + width: 18rem; } .w-full { - width: 100% + width: 100%; } .max-w-3xl { - max-width: 48rem + max-width: 48rem; } .flex-1 { - flex: 1 1 0% + flex: 1 1 0%; } .cursor-pointer { - cursor: pointer + cursor: pointer; } .list-disc { - list-style-type: disc + list-style-type: disc; } .flex-col { - flex-direction: column + flex-direction: column; } .items-center { - align-items: center + align-items: center; } .justify-center { - justify-content: center + justify-content: center; } .justify-between { - justify-content: space-between + justify-content: space-between; } .space-x-2 > :not([hidden]) ~ :not([hidden]) { - --tw-space-x-reverse: 0; - margin-right: calc(0.5rem * var(--tw-space-x-reverse)); - margin-left: calc(0.5rem * calc(1 - var(--tw-space-x-reverse))) + --tw-space-x-reverse: 0; + margin-right: calc(0.5rem * var(--tw-space-x-reverse)); + margin-left: calc(0.5rem * calc(1 - var(--tw-space-x-reverse))); } .space-x-3 > :not([hidden]) ~ :not([hidden]) { - --tw-space-x-reverse: 0; - margin-right: calc(0.75rem * var(--tw-space-x-reverse)); - margin-left: calc(0.75rem * calc(1 - var(--tw-space-x-reverse))) + --tw-space-x-reverse: 0; + margin-right: calc(0.75rem * var(--tw-space-x-reverse)); + margin-left: calc(0.75rem * calc(1 - var(--tw-space-x-reverse))); } .space-y-2 > :not([hidden]) ~ :not([hidden]) { - --tw-space-y-reverse: 0; - margin-top: calc(0.5rem * calc(1 - var(--tw-space-y-reverse))); - margin-bottom: calc(0.5rem * var(--tw-space-y-reverse)) + --tw-space-y-reverse: 0; + margin-top: calc(0.5rem * calc(1 - var(--tw-space-y-reverse))); + margin-bottom: calc(0.5rem * var(--tw-space-y-reverse)); } .space-y-3 > :not([hidden]) ~ :not([hidden]) { - --tw-space-y-reverse: 0; - margin-top: calc(0.75rem * calc(1 - var(--tw-space-y-reverse))); - margin-bottom: calc(0.75rem * var(--tw-space-y-reverse)) + --tw-space-y-reverse: 0; + margin-top: calc(0.75rem * calc(1 - var(--tw-space-y-reverse))); + margin-bottom: calc(0.75rem * var(--tw-space-y-reverse)); } .space-y-4 > :not([hidden]) ~ :not([hidden]) { - --tw-space-y-reverse: 0; - margin-top: calc(1rem * calc(1 - var(--tw-space-y-reverse))); - margin-bottom: calc(1rem * var(--tw-space-y-reverse)) + --tw-space-y-reverse: 0; + margin-top: calc(1rem * calc(1 - var(--tw-space-y-reverse))); + margin-bottom: calc(1rem * var(--tw-space-y-reverse)); } .overflow-hidden { - overflow: hidden + overflow: hidden; } .overflow-y-auto { - overflow-y: auto + overflow-y: auto; } .rounded { - border-radius: 0.25rem + border-radius: 0.25rem; } .rounded-md { - border-radius: 0.375rem + border-radius: 0.375rem; } .border { - border-width: 1px + border-width: 1px; } .border-b { - border-bottom-width: 1px + border-bottom-width: 1px; } .border-l { - border-left-width: 1px + border-left-width: 1px; } .border-r { - border-right-width: 1px + border-right-width: 1px; } .border-none { - border-style: none + border-style: none; } .bg-blue-600 { - --tw-bg-opacity: 1; - background-color: rgb(37 99 235 / var(--tw-bg-opacity, 1)) + --tw-bg-opacity: 1; + background-color: rgb(37 99 235 / var(--tw-bg-opacity, 1)); } .bg-gray-100 { - --tw-bg-opacity: 1; - background-color: rgb(243 244 246 / var(--tw-bg-opacity, 1)) + --tw-bg-opacity: 1; + background-color: rgb(243 244 246 / var(--tw-bg-opacity, 1)); } .bg-gray-50 { - --tw-bg-opacity: 1; - background-color: rgb(249 250 251 / var(--tw-bg-opacity, 1)) + --tw-bg-opacity: 1; + background-color: rgb(249 250 251 / var(--tw-bg-opacity, 1)); } .bg-green-600 { - --tw-bg-opacity: 1; - background-color: rgb(22 163 74 / var(--tw-bg-opacity, 1)) + --tw-bg-opacity: 1; + background-color: rgb(22 163 74 / var(--tw-bg-opacity, 1)); } .bg-white { - --tw-bg-opacity: 1; - background-color: rgb(255 255 255 / var(--tw-bg-opacity, 1)) + --tw-bg-opacity: 1; + background-color: rgb(255 255 255 / var(--tw-bg-opacity, 1)); } .p-2 { - padding: 0.5rem + padding: 0.5rem; } .p-4 { - padding: 1rem + padding: 1rem; } .px-2 { - padding-left: 0.5rem; - padding-right: 0.5rem + padding-left: 0.5rem; + padding-right: 0.5rem; } .px-4 { - padding-left: 1rem; - padding-right: 1rem + padding-left: 1rem; + padding-right: 1rem; } .py-1 { - padding-top: 0.25rem; - padding-bottom: 0.25rem + padding-top: 0.25rem; + padding-bottom: 0.25rem; } .py-2 { - padding-top: 0.5rem; - padding-bottom: 0.5rem + padding-top: 0.5rem; + padding-bottom: 0.5rem; } .pl-5 { - padding-left: 1.25rem + padding-left: 1.25rem; } .text-left { - text-align: left + text-align: left; } .text-center { - text-align: center + text-align: center; } .text-lg { - font-size: 1.125rem; - line-height: 1.75rem + font-size: 1.125rem; + line-height: 1.75rem; } .text-sm { - font-size: 0.875rem; - line-height: 1.25rem + font-size: 0.875rem; + line-height: 1.25rem; } .text-xl { - font-size: 1.25rem; - line-height: 1.75rem + font-size: 1.25rem; + line-height: 1.75rem; } .text-xs { - font-size: 0.75rem; - line-height: 1rem + font-size: 0.75rem; + line-height: 1rem; } .font-medium { - font-weight: 500 + font-weight: 500; } .font-semibold { - font-weight: 600 + font-weight: 600; } .leading-none { - line-height: 1 + line-height: 1; } .text-blue-600 { - --tw-text-opacity: 1; - color: rgb(37 99 235 / var(--tw-text-opacity, 1)) + --tw-text-opacity: 1; + color: rgb(37 99 235 / var(--tw-text-opacity, 1)); } .text-gray-500 { - --tw-text-opacity: 1; - color: rgb(107 114 128 / var(--tw-text-opacity, 1)) + --tw-text-opacity: 1; + color: rgb(107 114 128 / var(--tw-text-opacity, 1)); } .text-gray-600 { - --tw-text-opacity: 1; - color: rgb(75 85 99 / var(--tw-text-opacity, 1)) + --tw-text-opacity: 1; + color: rgb(75 85 99 / var(--tw-text-opacity, 1)); } .text-red-500 { - --tw-text-opacity: 1; - color: rgb(239 68 68 / var(--tw-text-opacity, 1)) + --tw-text-opacity: 1; + color: rgb(239 68 68 / var(--tw-text-opacity, 1)); } .text-red-600 { - --tw-text-opacity: 1; - color: rgb(220 38 38 / var(--tw-text-opacity, 1)) + --tw-text-opacity: 1; + color: rgb(220 38 38 / var(--tw-text-opacity, 1)); } .text-white { - --tw-text-opacity: 1; - color: rgb(255 255 255 / var(--tw-text-opacity, 1)) + --tw-text-opacity: 1; + color: rgb(255 255 255 / var(--tw-text-opacity, 1)); } .underline { - text-decoration-line: underline + text-decoration-line: underline; } .shadow { - --tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); - --tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color); - box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow) + --tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); + --tw-shadow-colored: + 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color); + box-shadow: + var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), + var(--tw-shadow); } .shadow-sm { - --tw-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); - --tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color); - box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow) + --tw-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color); + box-shadow: + var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), + var(--tw-shadow); } .grayscale { - --tw-grayscale: grayscale(100%); - filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow) + --tw-grayscale: grayscale(100%); + filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) + var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) + var(--tw-sepia) var(--tw-drop-shadow); } .filter { - filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow) + filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) + var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) + var(--tw-sepia) var(--tw-drop-shadow); } .hover\:bg-blue-700:hover { - --tw-bg-opacity: 1; - background-color: rgb(29 78 216 / var(--tw-bg-opacity, 1)) + --tw-bg-opacity: 1; + background-color: rgb(29 78 216 / var(--tw-bg-opacity, 1)); } .hover\:bg-gray-200:hover { - --tw-bg-opacity: 1; - background-color: rgb(229 231 235 / var(--tw-bg-opacity, 1)) + --tw-bg-opacity: 1; + background-color: rgb(229 231 235 / var(--tw-bg-opacity, 1)); } .hover\:bg-green-700:hover { - --tw-bg-opacity: 1; - background-color: rgb(21 128 61 / var(--tw-bg-opacity, 1)) + --tw-bg-opacity: 1; + background-color: rgb(21 128 61 / var(--tw-bg-opacity, 1)); } .hover\:underline:hover { - text-decoration-line: underline + text-decoration-line: underline; } diff --git a/frontend/editor/src/portal-saas/LICENSE b/frontend/editor/src/portal-saas/LICENSE new file mode 100644 index 0000000000..d268556808 --- /dev/null +++ b/frontend/editor/src/portal-saas/LICENSE @@ -0,0 +1,51 @@ +Stirling PDF User License + +Copyright (c) 2025 Stirling PDF Inc. + +License Scope & Usage Rights + +Production use of the Stirling PDF Software is only permitted with a valid Stirling PDF User License. + +For purposes of this license, “the Software†refers to the Stirling PDF application and any associated documentation files +provided by Stirling PDF Inc. You or your organization may not use the Software in production, at scale, or for business-critical +processes unless you have agreed to, and remain in compliance with, the Stirling PDF Subscription Terms of Service +(https://www.stirlingpdf.com/terms) or another valid agreement with Stirling PDF, and hold an active User License subscription +covering the appropriate number of licensed users. + +Trial and Minimal Use + +You may use the Software without a paid subscription for the sole purposes of internal trial, evaluation, or minimal use, provided that: +* Use is limited to the capabilities and restrictions defined by the Software itself; +* You do not copy, distribute, sublicense, reverse-engineer, or use the Software in client-facing or commercial contexts. + +Continued use beyond this scope requires a valid Stirling PDF User License. + +Modifications and Derivative Works + +You may modify the Software only for development or internal testing purposes. Any such modifications or derivative works: + +* May not be deployed in production environments without a valid User License; +* May not be distributed or sublicensed; +* Remain the intellectual property of Stirling PDF and/or its licensors; +* May only be used, copied, or exploited in accordance with the terms of a valid Stirling PDF User License subscription. + +Prohibited Actions + +Unless explicitly permitted by a paid license or separate agreement, you may not: + +* Use the Software in production environments; +* Copy, merge, distribute, sublicense, or sell the Software; +* Remove or alter any licensing or copyright notices; +* Circumvent access restrictions or licensing requirements. + +Third-Party Components + +The Stirling PDF Software may include components subject to separate open source licenses. Such components remain governed by +their original license terms as provided by their respective owners. + +Disclaimer + +THE SOFTWARE IS PROVIDED “AS IS,†WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, OR NON-INFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/frontend/editor/src/portal-saas/auth/PortalAuthBoundary.test.tsx b/frontend/editor/src/portal-saas/auth/PortalAuthBoundary.test.tsx index 367a3c59f7..474089f33c 100644 --- a/frontend/editor/src/portal-saas/auth/PortalAuthBoundary.test.tsx +++ b/frontend/editor/src/portal-saas/auth/PortalAuthBoundary.test.tsx @@ -3,15 +3,21 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { render, screen } from "@testing-library/react"; import { allowConsole } from "@app/tests/failOnConsole"; -// Controllable auth state for the mocked provider. +// Controllable auth state for the mocked provider. `portalAccess` is the collapsed +// context value (raw user.portalAccess ?? isAdminRole(role)); `user.portalAccess` +// is the raw tri-state (undefined until /api/v1/auth/me resolves). const authState: { session: unknown; loading: boolean; isAnonymous: boolean; + portalAccess: boolean; + user: { portalAccess?: boolean } | null; } = { session: null, loading: false, isAnonymous: false, + portalAccess: false, + user: null, }; vi.mock("@app/auth", () => ({ @@ -24,57 +30,76 @@ vi.mock("@portal/auth/saasSupabase", () => ({ ensureSaasSupabase: vi.fn() })); import { PortalAuthBoundary } from "@portal/auth/PortalAuthBoundary"; +function renderBoundary() { + render( + +
    PORTAL
    +
    , + ); +} + describe("PortalAuthBoundary — SaaS", () => { beforeEach(() => { authState.session = null; authState.loading = false; authState.isAnonymous = false; + authState.portalAccess = false; + authState.user = null; }); - it("renders the portal when a real (non-guest) Supabase session is present", () => { + it("renders the portal for a real session WITH portal access", () => { authState.session = { user: { id: "u1" }, access_token: "tok" }; - render( - -
    PORTAL
    -
    , - ); + authState.portalAccess = true; + authState.user = { portalAccess: true }; + renderBoundary(); expect(screen.getByTestId("portal")).toBeInTheDocument(); }); + it("renders the portal for an admin (collapsed access true before /me resolves)", () => { + authState.session = { user: { id: "admin" }, access_token: "tok" }; + authState.portalAccess = true; // isAdminRole fallback + authState.user = {}; // raw portalAccess still undefined + renderBoundary(); + expect(screen.getByTestId("portal")).toBeInTheDocument(); + }); + + it("gates a real session WITHOUT portal access (member) and bounces to the editor", () => { + authState.session = { user: { id: "member" }, access_token: "tok" }; + authState.portalAccess = false; + authState.user = { portalAccess: false }; + allowConsole.error(/not implemented|navigation/i); + renderBoundary(); + expect(screen.queryByTestId("portal")).not.toBeInTheDocument(); + }); + + it("waits (no portal, no redirect) while portal access is still resolving", () => { + authState.session = { user: { id: "u1" }, access_token: "tok" }; + authState.portalAccess = false; + authState.user = {}; // /me not back yet -> raw portalAccess undefined + // Deliberately do NOT allow a navigation error: if the gate wrongly bounced + // this still-resolving user, jsdom's navigation warning would fail the test. + renderBoundary(); + expect(screen.queryByTestId("portal")).not.toBeInTheDocument(); + }); + it("gates (does not render the portal) for an anonymous guest session", () => { authState.session = { user: { id: "guest" }, access_token: "tok" }; authState.isAnonymous = true; - // The gate bounces a guest to the editor; jsdom doesn't implement - // navigation, so absorb that incidental warning. allowConsole.error(/not implemented|navigation/i); - render( - -
    PORTAL
    -
    , - ); + renderBoundary(); expect(screen.queryByTestId("portal")).not.toBeInTheDocument(); }); it("gates (does not render the portal) when there is no session", () => { authState.session = null; - // The gate bounces to /login; jsdom doesn't implement navigation, so absorb - // that incidental warning rather than fail the console guard. allowConsole.error(/not implemented|navigation/i); - render( - -
    PORTAL
    -
    , - ); + renderBoundary(); expect(screen.queryByTestId("portal")).not.toBeInTheDocument(); }); it("gates while the session is still resolving", () => { authState.loading = true; - render( - -
    PORTAL
    -
    , - ); + renderBoundary(); expect(screen.queryByTestId("portal")).not.toBeInTheDocument(); }); }); diff --git a/frontend/editor/src/portal-saas/auth/PortalAuthBoundary.tsx b/frontend/editor/src/portal-saas/auth/PortalAuthBoundary.tsx index 8ab2c58dcd..8824883baa 100644 --- a/frontend/editor/src/portal-saas/auth/PortalAuthBoundary.tsx +++ b/frontend/editor/src/portal-saas/auth/PortalAuthBoundary.tsx @@ -22,22 +22,40 @@ function FullScreen({ children }: { children: ReactNode }) { } /** - * SaaS gate: viewing your own usage is not admin-gated, so any real (signed-in, - * non-guest) account may enter - deliberately laxer than the self-hosted - * RequirePortalAccess admin gate. But an anonymous guest session has no account - * to view or manage, so it is not eligible: bounce it to the editor (where a - * guest can sign up), mirroring the self-hosted forbidden path. No session at - * all -> the editor's Supabase login, which returns here signed in. + * SaaS portal gate: enter only with backend-granted portal/processor access + * (`portalAccess`, from /api/v1/auth/me), mirroring self-hosted RequirePortalAccess. + * The old "any signed-in account may enter" behaviour let team members without + * access into the Processor. + * + * portalAccess resolves *after* the session does (/me runs once `loading` is + * already false), so treat "real session, access not yet known" (raw + * user.portalAccess still undefined, and not admin-by-role) as still-loading + * rather than bouncing a legitimate user mid-load. Once settled: no session -> + * login; a guest or a real account without access -> the free editor. */ function SaasPortalGate({ children }: { children: ReactNode }) { - const { session, loading, isAnonymous } = useAuth(); - const blocked = !loading && (!session || isAnonymous); + const { session, loading, isAnonymous, portalAccess, user } = useAuth(); + + const accessPending = + !!session && + !isAnonymous && + !portalAccess && + user?.portalAccess === undefined; + const settling = loading || accessPending; + + const redirectTo = settling + ? null + : !session + ? withBasePath("/login") + : isAnonymous || !portalAccess + ? EDITOR_URL + : null; + useEffect(() => { - if (!blocked) return; - // Guest (has a session but anonymous) -> editor; no session -> login. - window.location.href = session ? EDITOR_URL : withBasePath("/login"); - }, [blocked, session]); - if (loading || blocked) { + if (redirectTo) window.location.href = redirectTo; + }, [redirectTo]); + + if (settling || redirectTo) { return ( diff --git a/frontend/editor/src/portal-saas/components/sidebarGroups.test.ts b/frontend/editor/src/portal-saas/components/sidebarGroups.test.ts index 892d1b0514..547f11351b 100644 --- a/frontend/editor/src/portal-saas/components/sidebarGroups.test.ts +++ b/frontend/editor/src/portal-saas/components/sidebarGroups.test.ts @@ -1,19 +1,14 @@ import { describe, expect, it } from "vitest"; // Resolves to the SaaS override (src/portal-saas) via the @portal cascade. import { - GROUP_PRIMARY, - GROUP_OPERATIONAL, + GROUP_PROCESSOR, GROUP_PLATFORM, } from "@portal/components/sidebarGroups"; describe("sidebarGroups (SaaS)", () => { - it("drops Components from the operational nav", () => { - expect(GROUP_OPERATIONAL.map((e) => e.id)).not.toContain("components"); - }); - - it("inherits the other operational items from base", () => { - expect(GROUP_OPERATIONAL.map((e) => e.id)).toEqual([ - "users", + it("inherits the processor group unchanged from base", () => { + expect(GROUP_PROCESSOR.map((e) => e.id)).toEqual([ + "home", "sources", "policies", "pipelines", @@ -21,9 +16,10 @@ describe("sidebarGroups (SaaS)", () => { ]); }); - it("inherits the primary + platform groups unchanged", () => { - expect(GROUP_PRIMARY.map((e) => e.id)).toEqual(["home"]); + it("inherits the platform group unchanged from base", () => { expect(GROUP_PLATFORM.map((e) => e.id)).toEqual([ + "users", + "integrations", "infrastructure", "usage", "docs", diff --git a/frontend/editor/src/portal-saas/components/sidebarGroups.tsx b/frontend/editor/src/portal-saas/components/sidebarGroups.tsx index 1503d6a1e3..9e51b1b307 100644 --- a/frontend/editor/src/portal-saas/components/sidebarGroups.tsx +++ b/frontend/editor/src/portal-saas/components/sidebarGroups.tsx @@ -1,18 +1,11 @@ import { - GROUP_PRIMARY, - GROUP_OPERATIONAL as BASE_OPERATIONAL, + GROUP_PROCESSOR, GROUP_PLATFORM, type NavEntry, + type NavGroup, } from "@portal-proprietary/components/sidebarGroups"; -export { GROUP_PRIMARY, GROUP_PLATFORM }; -export type { NavEntry }; - -/** - * SaaS pre-release: the Components section isn't shipped there yet, so drop it - * from the operational nav. Everything else is inherited from the base groups, so - * new nav items appear in SaaS automatically — only Components is removed here. - */ -export const GROUP_OPERATIONAL: NavEntry[] = BASE_OPERATIONAL.filter( - (entry) => entry.id !== "components", -); +// SaaS shadows the base nav groups so sections not yet shipped there can be +// dropped. Nothing is currently removed, so the base groups pass through as-is. +export { GROUP_PROCESSOR, GROUP_PLATFORM }; +export type { NavEntry, NavGroup }; diff --git a/frontend/editor/src/portal-saas/components/sources/AgentBuilderAction.test.tsx b/frontend/editor/src/portal-saas/components/sources/AgentBuilderAction.test.tsx deleted file mode 100644 index 2dcafa1dcf..0000000000 --- a/frontend/editor/src/portal-saas/components/sources/AgentBuilderAction.test.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { render } from "@testing-library/react"; -import { AgentBuilderAction } from "@portal/components/sources/AgentBuilderAction"; - -describe("AgentBuilderAction (SaaS)", () => { - it("renders nothing — Agent Builder is hidden pre-release", () => { - const { container } = render(); - expect(container).toBeEmptyDOMElement(); - }); -}); diff --git a/frontend/editor/src/portal-saas/components/sources/AgentBuilderAction.tsx b/frontend/editor/src/portal-saas/components/sources/AgentBuilderAction.tsx deleted file mode 100644 index 0576cf45e9..0000000000 --- a/frontend/editor/src/portal-saas/components/sources/AgentBuilderAction.tsx +++ /dev/null @@ -1,7 +0,0 @@ -/** - * SaaS pre-release: Agent Builder isn't shipped yet, so its Sources-header entry - * point is hidden. - */ -export function AgentBuilderAction() { - return null; -} diff --git a/frontend/editor/src/portal-saas/views/AgentBuilder.tsx b/frontend/editor/src/portal-saas/views/AgentBuilder.tsx deleted file mode 100644 index e6d4dbb15b..0000000000 --- a/frontend/editor/src/portal-saas/views/AgentBuilder.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { Navigate } from "react-router-dom"; -import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext"; - -/** - * SaaS pre-release: Agent Builder isn't shipped yet and its Sources entry point is - * hidden, so redirect any /agent-builder deep link back to Home. - */ -export function AgentBuilder() { - return ; -} diff --git a/frontend/editor/src/portal/MOCKS.md b/frontend/editor/src/portal/MOCKS.md index 84fb8d97cd..6d66b969ee 100644 --- a/frontend/editor/src/portal/MOCKS.md +++ b/frontend/editor/src/portal/MOCKS.md @@ -62,7 +62,6 @@ non-2xx). Views consume via `useAsync()` + `useSectionFlags()` (`hooks/useAsync. | Documents | `GET /v1/documents` | `tier` | `fetchDocuments` | `DocumentsResponse` | | Pipelines | `GET /v1/pipelines` · `POST /v1/pipelines/:id/promote-to-policy` | `tier` | `fetchPipelines` · `promoteToPolicy` | `PipelinesResponse` | | Policies | `GET/POST /api/v1/policies` · `GET/DELETE /api/v1/policies/{id}` · `POST /api/v1/policies/{id}/run` | — | `fetchPolicies` · `savePolicy` · `deletePolicy` · `runPolicy` | `PoliciesResponse` · `Policy` | -| Agent Builder | `GET /v1/agents` | `tier` | `fetchAgents` | `AgentsResponse` | | Sources | `GET /v1/sources` | `tier` | `fetchSources` | `SourcesResponse` | | Components | `GET /v1/components` | `tier` | `fetchComponents` | `ComponentsResponse` | | Infrastructure | `GET /v1/infrastructure/deployments` | `tier` | `fetchDeployments` | `DeploymentsResponse` | diff --git a/frontend/editor/src/portal/PortalApp.tsx b/frontend/editor/src/portal/PortalApp.tsx index 1aa7da3535..27fc5652cb 100644 --- a/frontend/editor/src/portal/PortalApp.tsx +++ b/frontend/editor/src/portal/PortalApp.tsx @@ -1,9 +1,11 @@ -import { type ReactNode } from "react"; +import { useState, type ReactNode } from "react"; +import { QueryClientProvider } from "@tanstack/react-query"; import { PortalAuthBoundary } from "@portal/auth/PortalAuthBoundary"; import { ThemeProvider, useTheme } from "@portal/contexts/ThemeContext"; import { SuiProvider } from "@portal/theme/SuiProvider"; import { PortalProviders } from "@portal/PortalProviders"; import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider"; +import { createPortalQueryClient } from "@portal/queryClient"; // Reset + typography, scoped to .portal-scope below. import "@portal/theme/base.css"; @@ -28,20 +30,25 @@ function ThemedSuiProvider({ children }: { children: ReactNode }) { * self-hosted mounts the account-link layer, SaaS does not. */ export function PortalApp() { + // One client for the portal's lifetime. Sits above the router so its cache + // survives view navigation. Cheap and inert when no query hooks are mounted. + const [queryClient] = useState(createPortalQueryClient); return ( - - - {/* Scopes base.css to the portal so it doesn't restyle the host editor. */} -
    - {/* Tool registry is read by portal views (e.g. the policy setup - wizard); mount it above the per-flavor provider split. */} - - - - - -
    -
    -
    + + + + {/* Scopes base.css to the portal so it doesn't restyle the host editor. */} +
    + {/* Tool registry is read by portal views (e.g. the policy setup + wizard); mount it above the per-flavor provider split. */} + + + + + +
    +
    +
    +
    ); } diff --git a/frontend/editor/src/portal/ViewRouter.tsx b/frontend/editor/src/portal/ViewRouter.tsx index 5e6af09e38..76adc1ad1d 100644 --- a/frontend/editor/src/portal/ViewRouter.tsx +++ b/frontend/editor/src/portal/ViewRouter.tsx @@ -1,3 +1,4 @@ +import { lazy, Suspense } from "react"; import { Navigate, Route, Routes } from "react-router-dom"; import { Home } from "@portal/views/Home"; import { Users } from "@portal/views/Users"; @@ -5,16 +6,21 @@ import { Documents } from "@portal/views/Documents"; import { Pipelines } from "@portal/views/Pipelines"; import { PipelineBuilder } from "@portal/views/PipelineBuilder"; import { Sources } from "@portal/views/Sources"; -import { AgentBuilder } from "@portal/views/AgentBuilder"; +import { Integrations } from "@portal/views/Integrations"; import { Policies } from "@portal/views/Policies"; -import { Components } from "@portal/views/Components"; import { EditorAdmin } from "@portal/views/EditorAdmin"; import { Infrastructure } from "@portal/views/Infrastructure"; import { PortalBillingGate } from "@portal/components/billing/PortalBillingGate"; -import { DeveloperDocs } from "@portal/views/DeveloperDocs"; import { Procurement } from "@portal/views/Procurement"; import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext"; +// Lazy so the generated docs manifest (bundled JSON) lands in its own chunk. +const DeveloperDocs = lazy(() => + import("@portal/views/DeveloperDocs").then((m) => ({ + default: m.DeveloperDocs, + })), +); + // The portal mounts as a route-set under /processor/* in the editor app, so these // child routes are relative to that base: strip the leading slash from the // logical VIEW_PATHS, and home is the index route. Redirects use toPortalPath @@ -36,13 +42,20 @@ export function ViewRouter() { element={} /> } /> + {/* Source create/edit is a modal on the list now; old deep links land there. */} } + path={`${rel(VIEW_PATHS.sources)}/new`} + element={ + + } /> + } + /> + } /> } /> } /> - } /> } /> } /> } /> - } /> + + + + } + /> {/* Account-link is now a Settings panel; redirect legacy bookmarks home. */} = { - published: "success", - draft: "neutral", -}; - -/** - * Catalogue of tools an agent can be granted or denied. Surfaced as the chip - * palette in restricted mode so the deny list is picked from a known set - * rather than free-typed. - */ -export const TOOL_CATALOGUE = [ - "extract.fields", - "classify.document", - "route.pipeline", - "lookup.crm", - "send.email", - "write.audit", - "read.pii", - "invoke.webhook", -] as const; - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Endpoints */ -/* ──────────────────────────────────────────────────────────────────────── */ - -/** GET /v1/agents?tier=… — fleet summary + every agent with its full builder state. */ -export async function fetchAgents(tier: Tier): Promise { - return apiClient.local.json( - `/v1/agents?tier=${encodeURIComponent(tier)}`, - ); -} diff --git a/frontend/editor/src/portal/api/demoData.test.ts b/frontend/editor/src/portal/api/demoData.test.ts index a978703203..7a9ab50e13 100644 --- a/frontend/editor/src/portal/api/demoData.test.ts +++ b/frontend/editor/src/portal/api/demoData.test.ts @@ -12,7 +12,7 @@ describe("portal demo data seam", () => { it("is inert until enabled", async () => { expect( await resolveDemoResponse( - new URL("/v1/agents?tier=pro", window.location.origin), + new URL("/v1/notifications", window.location.origin), {}, ), ).toBeUndefined(); @@ -22,12 +22,12 @@ describe("portal demo data seam", () => { it("answers from the fixture handlers while enabled", async () => { await enablePortalDemoData(); const res = await resolveDemoResponse( - new URL("/v1/agents?tier=pro", window.location.origin), + new URL("/v1/notifications", window.location.origin), {}, ); expect(res?.status).toBe(200); - const body = (await res?.json()) as { agents: unknown[] }; - expect(body.agents.length).toBeGreaterThan(0); + const body = (await res?.json()) as unknown[]; + expect(body.length).toBeGreaterThan(0); }); it("releases back to the network on disable", async () => { @@ -35,7 +35,7 @@ describe("portal demo data seam", () => { disablePortalDemoData(); expect( await resolveDemoResponse( - new URL("/v1/agents?tier=pro", window.location.origin), + new URL("/v1/notifications", window.location.origin), {}, ), ).toBeUndefined(); diff --git a/frontend/editor/src/portal/api/editorDeploy.ts b/frontend/editor/src/portal/api/editorDeploy.ts index 67952d8bb4..e90cac89c3 100644 --- a/frontend/editor/src/portal/api/editorDeploy.ts +++ b/frontend/editor/src/portal/api/editorDeploy.ts @@ -119,14 +119,13 @@ export interface EditorDeploymentResponse { /* ──────────────────────────────────────────────────────────────────────── */ export interface TargetMeta { - icon: string; tone: "neutral" | "blue" | "purple"; } export const TARGET_META: Record = { - cloud: { icon: "â˜", tone: "blue" }, - docker: { icon: "â–£", tone: "neutral" }, - kubernetes: { icon: "⎈", tone: "purple" }, + cloud: { tone: "blue" }, + docker: { tone: "neutral" }, + kubernetes: { tone: "purple" }, }; export const INSTANCE_STATUS_TONE: Record< diff --git a/frontend/editor/src/portal/api/infrastructure.ts b/frontend/editor/src/portal/api/infrastructure.ts index d624f67e03..52a54179a5 100644 --- a/frontend/editor/src/portal/api/infrastructure.ts +++ b/frontend/editor/src/portal/api/infrastructure.ts @@ -43,23 +43,33 @@ export interface RecentDeployment { /* API Keys */ /* ──────────────────────────────────────────────────────────────────────── */ -export type ApiKeyStatus = "active" | "revoked" | "rotate-soon"; -export type ApiKeyPermission = "Read" | "Write" | "Admin"; +export type ApiKeyStatus = "active" | "revoked"; export interface ApiKey { id: string; name: string; - /** Masked prefix shown in the list, e.g. "sk_live_a3f8…". */ + /** Non-secret leading fragment, e.g. "sk_a3f81b2c". */ prefix: string; created: string; + /** Formatted last-use time, or "Never". */ lastUsed: string; status: ApiKeyStatus; - /** Requests/min ceiling. */ - rateLimit: number; - permissions: ApiKeyPermission[]; - allowedIps: string[]; + /** Requests made today (UTC). */ usageToday: number; + /** Requests in the trailing 30 days. */ usageMonth: number; + /** Lifetime request count. */ + usageTotal: number; +} + +export interface ApiKeysResponse { + keys: ApiKey[]; +} + +/** Returned once on creation: the listed row plus the plaintext secret, shown once. */ +export interface CreatedApiKey { + key: ApiKey; + secret: string; } /* ──────────────────────────────────────────────────────────────────────── */ @@ -278,11 +288,30 @@ export async function fetchDeployments( ); } -/** GET /v1/infrastructure/api-keys?tier=… */ -export async function fetchApiKeys(tier: Tier): Promise { - return apiClient.local.json( - `/v1/infrastructure/api-keys${q(tier)}`, - ); +const API_KEYS_PATH = "/api/v1/proprietary/ui-data/infrastructure/api-keys"; + +// Always this instance's own backend: a key authenticates the instance that issued it, so a +// self-hosted instance manages its own keys even when SaaS-linked (.local is the SaaS backend on SaaS). + +/** GET the caller's personal API keys; scoped server-side per user. */ +export async function fetchApiKeys(): Promise { + return apiClient.local.json(API_KEYS_PATH); +} + +/** POST a new key; the response carries the one-time secret. */ +export async function createApiKey(body: { + name: string; +}): Promise { + return apiClient.local.json(API_KEYS_PATH, { + method: "POST", + body, + }); +} + +/** DELETE (revoke) a key the caller owns. */ +export async function revokeApiKey(id: string): Promise { + const path = `${API_KEYS_PATH}/${encodeURIComponent(id)}`; + await apiClient.local.json(path, { method: "DELETE" }); } /** GET /v1/infrastructure/security?tier=… */ diff --git a/frontend/editor/src/portal/api/integrations.ts b/frontend/editor/src/portal/api/integrations.ts new file mode 100644 index 0000000000..01eebe96ab --- /dev/null +++ b/frontend/editor/src/portal/api/integrations.ts @@ -0,0 +1,88 @@ +/** + * Integrations service layer: stored connections (S3, Purview, ConsignO, and free-form API) that + * policy sources, pipeline outputs and integration steps reference by id instead of embedding + * credentials. Secrets are write-only - reads return them masked, and sending + * the mask back on update keeps the stored value. + */ +import { apiClient } from "@portal/api/http"; + +export type IntegrationType = "S3" | "MCP" | "API" | "PURVIEW" | "CONSIGNO"; +export type OwnerScope = "USER" | "TEAM" | "SERVER"; + +/** Mirrors the backend IntegrationConfigResponse; `config` values are masked. */ +export interface IntegrationConfig { + id: number; + integrationType: IntegrationType; + name: string; + scope: OwnerScope; + ownerUserId: number | null; + ownerTeamId: number | null; + enabled: boolean; + locked: boolean; + defaultAccess: string; + config: Record; + canManage: boolean; + createdAt: string; + updatedAt: string; +} + +/** Create/update body; omitted fields keep their stored values on update. */ +export interface IntegrationConfigRequest { + integrationType?: IntegrationType; + name?: string; + scope?: OwnerScope; + ownerTeamId?: number | null; + enabled?: boolean; + config?: Record; +} + +export async function fetchIntegrations(): Promise { + return apiClient.local.json("/api/v1/integrations"); +} + +/** The S3 connections the caller may use, for source/output pickers. */ +export async function fetchS3Connections(): Promise { + return (await fetchIntegrations()).filter( + (integration) => integration.integrationType === "S3", + ); +} + +/** + * What this caller may set up. Answered by the server because the custom-API gate is an + * authorization decision, not a presentation one - the create call is refused regardless. + */ +export interface IntegrationCapabilities { + customApi: boolean; +} + +export async function fetchIntegrationCapabilities(): Promise { + return apiClient.local.json( + "/api/v1/integrations/capabilities", + ); +} + +export async function createIntegration( + body: IntegrationConfigRequest, +): Promise { + return apiClient.local.json("/api/v1/integrations", { + method: "POST", + body, + }); +} + +export async function updateIntegration( + id: number, + body: IntegrationConfigRequest, +): Promise { + return apiClient.local.json( + `/api/v1/integrations/${encodeURIComponent(id)}`, + { method: "PUT", body }, + ); +} + +export async function deleteIntegration(id: number): Promise { + await apiClient.local.json( + `/api/v1/integrations/${encodeURIComponent(id)}`, + { method: "DELETE" }, + ); +} diff --git a/frontend/editor/src/portal/api/pipelines.ts b/frontend/editor/src/portal/api/pipelines.ts index e75711b912..2a4f25b79e 100644 --- a/frontend/editor/src/portal/api/pipelines.ts +++ b/frontend/editor/src/portal/api/pipelines.ts @@ -29,8 +29,8 @@ export interface OutputSpec { options: Record; } -/** The output destinations the pipeline builder can offer. */ -export type PipelineOutputMode = "inline" | "folder" | "s3"; +/** Source types that can be written to (used as a pipeline's output destination). */ +export type PipelineOutputMode = "folder" | "s3"; /** * The stored policy record: the create/update body (`id` blank on create) and what @@ -45,7 +45,17 @@ export interface Policy { trigger: TriggerConfig | null; sourceIds: string[]; steps: PipelineStep[]; + /** + * Inline output, used only when no destinations are referenced (editor/one-off runs that return + * results to the caller). Portal pipelines set {@link outputIds} instead. + */ output: OutputSpec; + /** + * The saved Sources this policy delivers its output to (each a source used as a write target), + * resolved live at run time; a run is delivered to every one. Empty means the inline {@link + * output} is used. + */ + outputIds: string[]; teamId?: number | null; } diff --git a/frontend/editor/src/portal/api/policies.ts b/frontend/editor/src/portal/api/policies.ts index 5c3c33a75a..6c122a3bc4 100644 --- a/frontend/editor/src/portal/api/policies.ts +++ b/frontend/editor/src/portal/api/policies.ts @@ -4,14 +4,17 @@ * The portal calls the real Stirling policy API (`/api/v1/policies`); * Storybook and tests intercept the same calls with MSW handlers. * - * `fetchPolicies()` assembles the decorated catalogue client-side from the - * backend's flat `WirePolicy[]` + `PolicyRunView[]`, mirroring the same + * The flat `WirePolicy[]` + `PolicyRunView[]` responses are assembled into the + * decorated catalogue client-side by `assemblePolicies()`, mirroring the same * approach the editor uses for its own catalogue view. */ +import type { TFunction } from "i18next"; import { apiClient } from "@portal/api/http"; import { fromWirePolicy, toWirePolicy } from "@app/policies/codec"; import { runsToActivity, runsToStats } from "@app/policies/runs"; +import { policyStep, type PolicyToolStep } from "@app/policies/operations"; +import type { ToolEndpoint } from "@app/types/toolApiTypes"; import type { PolicyDecodedState, PolicyRunView, @@ -53,11 +56,11 @@ export interface PolicyField { export interface PolicyCategory { id: string; label: string; - icon: string; tone: "neutral" | "blue" | "purple" | "green" | "amber" | "red"; desc: string; providesClassification?: boolean; comingSoon?: boolean; + requiresAiEngine?: boolean; } export interface PolicyConfigDef { @@ -65,7 +68,7 @@ export interface PolicyConfigDef { rules: string[]; scopeLabel: string; fields: PolicyField[]; - defaultOperations: WirePipelineStep[]; + defaultOperations: PolicyToolStep[]; } export interface PolicyState { @@ -127,33 +130,32 @@ export interface CatalogueEntry { } /* ──────────────────────────────────────────────────────────────────────── */ -/* Tool → endpoint registry */ +/* Endpoint display labels */ /* ──────────────────────────────────────────────────────────────────────── */ -export const TOOL_ENDPOINTS: Record = { - redact: "/api/v1/security/auto-redact", - sanitize: "/api/v1/security/sanitize-pdf", - watermark: "/api/v1/security/add-watermark", - ocr: "/api/v1/misc/ocr-pdf", - flatten: "/api/v1/misc/flatten", - compress: "/api/v1/misc/compress-pdf", -}; - -/** Values are i18n keys — render with t(). */ -export const ENDPOINT_LABELS: Record = { +/** + * i18n keys keyed by endpoint; labels stored steps in the detail view. Mostly + * {@link ToolEndpoint}s, plus the AI classify endpoint, which isn't part of the generated union. + */ +export const ENDPOINT_LABELS: Partial< + Record +> = { "/api/v1/security/auto-redact": "portal.policies.endpoints.autoRedact", "/api/v1/security/sanitize-pdf": "portal.policies.endpoints.sanitizePdf", "/api/v1/security/add-watermark": "portal.policies.endpoints.addWatermark", "/api/v1/misc/ocr-pdf": "portal.policies.endpoints.ocrPdf", "/api/v1/misc/flatten": "portal.policies.endpoints.flatten", "/api/v1/misc/compress-pdf": "portal.policies.endpoints.compressPdf", + "/api/v1/ai/tools/classify-and-label": + "portal.policies.endpoints.classifyAndLabel", }; export function humanizeEndpoint( path: string, t: (key: string) => string, ): string { - if (ENDPOINT_LABELS[path]) return t(ENDPOINT_LABELS[path]); + const label = ENDPOINT_LABELS[path as ToolEndpoint]; + if (label) return t(label); const last = path.split("/").filter(Boolean).pop() ?? path; return last .replace(/-/g, " ") @@ -175,7 +177,6 @@ export const POLICY_CATEGORIES: PolicyCategory[] = [ { id: "ingestion", label: "portal.policies.categories.ingestion.label", - icon: "layers", tone: "blue", desc: "portal.policies.categories.ingestion.desc", providesClassification: true, @@ -184,14 +185,19 @@ export const POLICY_CATEGORIES: PolicyCategory[] = [ { id: "security", label: "portal.policies.categories.security.label", - icon: "shield", tone: "purple", desc: "portal.policies.categories.security.desc", }, + { + id: "classification", + label: "portal.policies.categories.classification.label", + tone: "blue", + desc: "portal.policies.categories.classification.desc", + providesClassification: true, + }, { id: "compliance", label: "portal.policies.categories.compliance.label", - icon: "check", tone: "amber", desc: "portal.policies.categories.compliance.desc", comingSoon: true, @@ -199,7 +205,6 @@ export const POLICY_CATEGORIES: PolicyCategory[] = [ { id: "routing", label: "portal.policies.categories.routing.label", - icon: "route", tone: "green", desc: "portal.policies.categories.routing.desc", comingSoon: true, @@ -207,7 +212,6 @@ export const POLICY_CATEGORIES: PolicyCategory[] = [ { id: "retention", label: "portal.policies.categories.retention.label", - icon: "schedule", tone: "neutral", desc: "portal.policies.categories.retention.desc", comingSoon: true, @@ -229,10 +233,7 @@ export const POLICY_CONFIG: Record = { "portal.policies.config.ingestion.rules.3", ], scopeLabel: "portal.policies.config.scopeAll", - defaultOperations: [ - { operation: TOOL_ENDPOINTS.ocr, parameters: {} }, - { operation: TOOL_ENDPOINTS.flatten, parameters: {} }, - ], + defaultOperations: [policyStep("ocr"), policyStep("flatten")], fields: [ { label: "portal.policies.config.ingestion.fields.minConfidence", @@ -259,35 +260,29 @@ export const POLICY_CONFIG: Record = { ], scopeLabel: "portal.policies.config.scopeAll", defaultOperations: [ - { - operation: TOOL_ENDPOINTS.redact, - parameters: { - mode: "automatic", - useRegex: true, - convertPDFToImage: true, - wordsToRedact: DEFAULT_PII_PATTERNS, - }, - }, - { - operation: TOOL_ENDPOINTS.sanitize, - parameters: { - removeJavaScript: true, - removeEmbeddedFiles: false, - removeMetadata: false, - removeLinks: false, - removeFonts: false, - }, - }, - { - operation: TOOL_ENDPOINTS.watermark, - // convertPDFToImage bakes the watermark in so it can't be stripped - parameters: { - convertPDFToImage: true, - }, - }, + // Flatten to image so redactions can't be lifted off. + policyStep("redact", { + useRegex: true, + convertPDFToImage: true, + wordsToRedact: DEFAULT_PII_PATTERNS, + }), + // JavaScript removal only; the tool enables removeEmbeddedFiles by default, so turn it off. + policyStep("sanitize", { removeEmbeddedFiles: false }), + // Bake in via image so it can't be stripped. + policyStep("watermark", { convertPDFToImage: true }), ], fields: [], }, + classification: { + summary: "portal.policies.config.classification.summary", + rules: [ + "portal.policies.config.classification.rules.0", + "portal.policies.config.classification.rules.1", + ], + scopeLabel: "portal.policies.config.scopeAll", + defaultOperations: [policyStep("classify")], + fields: [], + }, compliance: { summary: "portal.policies.config.compliance.summary", rules: [ @@ -296,9 +291,13 @@ export const POLICY_CONFIG: Record = { "portal.policies.config.compliance.rules.2", ], scopeLabel: "portal.policies.config.scopeAll", + // Apply writes our sensitivity label into the document after it is sanitised and flattened. + // Offered only once a Purview tenant is connected (it needs a tenant connection and a label + // GUID, which no default can guess), and hidden entirely until then. defaultOperations: [ - { operation: TOOL_ENDPOINTS.sanitize, parameters: {} }, - { operation: TOOL_ENDPOINTS.flatten, parameters: {} }, + policyStep("sanitize"), + policyStep("flatten"), + policyStep("purviewApplyLabel"), ], fields: [ { @@ -342,7 +341,7 @@ export const POLICY_CONFIG: Record = { "portal.policies.config.routing.rules.2", ], scopeLabel: "portal.policies.config.scopeAll", - defaultOperations: [{ operation: TOOL_ENDPOINTS.compress, parameters: {} }], + defaultOperations: [policyStep("compress")], fields: [ { label: "portal.policies.config.routing.fields.destination", @@ -373,7 +372,7 @@ export const POLICY_CONFIG: Record = { "portal.policies.config.retention.rules.2", ], scopeLabel: "portal.policies.config.scopeAll", - defaultOperations: [{ operation: TOOL_ENDPOINTS.compress, parameters: {} }], + defaultOperations: [policyStep("compress")], fields: [ { label: "portal.policies.config.retention.fields.keepFor", @@ -456,15 +455,27 @@ function decoratePolicy( }; } -/** GET /api/v1/policies + GET /api/v1/policies/runs → assembled catalogue. */ -export async function fetchPolicies(): Promise { - const [wirePolicies, runs] = await Promise.all([ - apiClient.local.json("/api/v1/policies"), - apiClient.local - .json("/api/v1/policies/runs") - .catch(() => [] as PolicyRunView[]), - ]); +/** GET /api/v1/policies — the flat stored-policy records. */ +export function fetchPoliciesList(): Promise { + return apiClient.local.json("/api/v1/policies"); +} +/** GET /api/v1/policies/runs — best-effort (empty on a backend without runs). */ +export function fetchPolicyRuns(): Promise { + return apiClient.local + .json("/api/v1/policies/runs") + .catch(() => [] as PolicyRunView[]); +} + +/** + * Pure assembly of the decorated catalogue from the two raw responses. Split + * out so the React Query layer can fetch the list + runs as separate shared + * cache entries (deduped across Home + Policies) and assemble client-side. + */ +export function assemblePolicies( + wirePolicies: WirePolicy[], + runs: PolicyRunView[], +): PoliciesResponse { const decodedByCategory = new Map< string, { decoded: PolicyDecodedState; isDefault: boolean } @@ -556,17 +567,30 @@ const DEFAULT_RETRY_DELAY = 5; // POST /api/v1/policies endpoint. The real backend ignores unknown fields. type CatalogueWireBody = WirePolicy & { categoryId: string }; +/** + * The persisted policy name derived from its category, e.g. "Security Policy". + * `category.label` is an i18n key, so translate it before building the name; + * otherwise the raw key is persisted and surfaces in the UI (e.g. the Sources + * "Used by" pill). + */ +function policyDisplayName(entry: CatalogueEntry, t: TFunction): string { + return t("portal.policies.defaultName", { + category: t(entry.category.label), + }); +} + /** Build a wire policy from a setup wizard result. */ export function buildWireFromSetup( entry: CatalogueEntry, result: PolicySetupResult, + t: TFunction, enabled = true, ): CatalogueWireBody { return { categoryId: entry.category.id, ...toWirePolicy({ id: entry.policy?.state.backendId ?? "", - name: `${entry.category.label} Policy`, + name: policyDisplayName(entry, t), enabled, categoryId: entry.category.id, sources: result.sources, @@ -589,13 +613,14 @@ export function buildWireFromState( entry: CatalogueEntry, policy: DecoratedPolicy, enabled: boolean, + t: TFunction, ): CatalogueWireBody { const s = policy.state; return { categoryId: entry.category.id, ...toWirePolicy({ id: s.backendId ?? "", - name: `${entry.category.label} Policy`, + name: policyDisplayName(entry, t), enabled, categoryId: entry.category.id, sources: s.sources, diff --git a/frontend/editor/src/portal/api/processorFlow.ts b/frontend/editor/src/portal/api/processorFlow.ts new file mode 100644 index 0000000000..1ab2f72c62 --- /dev/null +++ b/frontend/editor/src/portal/api/processorFlow.ts @@ -0,0 +1,148 @@ +/** Assembles the home visualiser's sources → policies → outcomes from the real + * sources/policies/runs APIs. Counts are real; the flow motion is illustrative. */ + +import type { SourcesResponse } from "@portal/api/sources"; +import { POLICY_CATEGORIES } from "@portal/api/policies"; +import { fromWirePolicy } from "@app/policies/codec"; +import type { PolicyRunView, WirePolicy } from "@app/policies/types"; + +/** A source that actually feeds the processor today (editor, folder, S3, …). */ +export interface FlowSource { + id: string; + /** Display name (already resolved; editor rows get a friendly label). */ + name: string; + type: string; + /** Documents this source fed into runs over the trailing 24h. */ + docs24h: number; +} + +/** A "coming soon" connector shown in the sources column but not a real source + * type yet. `labelKey` is an i18n key. */ +export interface FlowComingSoonSource { + key: string; + labelKey: string; +} + +/** Row state (mirrors the Policies page): `active` = configured+enabled with a + * 24h count, `off` = available (offers "Set up"), `locked` = coming-soon. */ +export type FlowPolicyState = "active" | "off" | "locked"; + +/** One policies-column row — the full catalogue in Policies-page order, + * including the coming-soon categories. */ +export interface FlowPolicy { + /** Category id (also the lane key for the flow animation + its icon). */ + key: string; + /** i18n key for the category label. */ + labelKey: string; + state: FlowPolicyState; + configured: boolean; + runs24h: number; +} + +export type FlowOutcomeKey = "success" | "failed"; + +/** A terminal audit outcome node on the right, counted over the trailing 24h. */ +export interface FlowOutcome { + key: FlowOutcomeKey; + labelKey: string; + count24h: number; +} + +export interface ProcessorFlow { + sources: FlowSource[]; + comingSoonSources: FlowComingSoonSource[]; + policies: FlowPolicy[]; + outcomes: FlowOutcome[]; +} + +const DAY_MS = 86_400_000; + +/** Connector types the sources column advertises but can't create yet. */ +const COMING_SOON_SOURCES: FlowComingSoonSource[] = [ + { key: "apiMcp", labelKey: "portal.processorFlow.sources.comingSoon.apiMcp" }, + { + key: "cloud", + labelKey: "portal.processorFlow.sources.comingSoon.cloud", + }, + { + key: "email", + labelKey: "portal.processorFlow.sources.comingSoon.email", + }, +]; + +/** Full catalogue in Policies-page order (coming-soon → locked); `active` rows + * carry their trailing-24h run count. */ +function buildPolicies( + wirePolicies: WirePolicy[], + runs: PolicyRunView[], +): FlowPolicy[] { + const cutoff = Date.now() - DAY_MS; + const decoded = wirePolicies.map(fromWirePolicy); + + return POLICY_CATEGORIES.map((cat) => { + const dp = decoded.find((p) => p.categoryId === cat.id); + const configured = Boolean(dp?.enabled); + const state: FlowPolicyState = configured + ? "active" + : cat.comingSoon + ? "locked" + : "off"; + const runs24h = dp + ? runs.filter((r) => r.policyId === dp.id && r.createdAt >= cutoff).length + : 0; + return { + key: cat.id, + labelKey: cat.label, + state, + configured, + runs24h, + }; + }); +} + +/** Terminal audit outcomes over the trailing 24h — success vs failure. */ +function buildOutcomes(runs: PolicyRunView[]): FlowOutcome[] { + const cutoff = Date.now() - DAY_MS; + const recent = runs.filter((r) => r.createdAt >= cutoff); + const success = recent.filter((r) => r.status === "COMPLETED").length; + const failed = recent.filter( + (r) => r.status === "FAILED" || r.status === "CANCELLED", + ).length; + return [ + { + key: "success", + labelKey: "portal.processorFlow.outcomes.success", + count24h: success, + }, + { + key: "failed", + labelKey: "portal.processorFlow.outcomes.failed", + count24h: failed, + }, + ]; +} + +/** + * Pure assembly of the flow model from the three raw responses. Split out so + * the React Query layer composes it from the shared sources/policies/runs + * cache entries instead of re-fetching them (see useProcessorFlow). + */ +export function assembleProcessorFlow( + sourcesResp: SourcesResponse, + wirePolicies: WirePolicy[], + runs: PolicyRunView[], +): ProcessorFlow { + const sources: FlowSource[] = sourcesResp.sources.map((s) => ({ + id: s.id, + name: s.name, + type: s.type, + docs24h: s.docs24h, + })); + + return { + sources, + comingSoonSources: COMING_SOON_SOURCES, + policies: buildPolicies(wirePolicies, runs), + outcomes: buildOutcomes(runs), + }; +} diff --git a/frontend/editor/src/portal/api/sdkComponents.ts b/frontend/editor/src/portal/api/sdkComponents.ts deleted file mode 100644 index 99b3b547ef..0000000000 --- a/frontend/editor/src/portal/api/sdkComponents.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { apiClient } from "@portal/api/http"; -import type { Tier } from "@portal/contexts/TierContext"; - -/* - * "Components" are embeddable React/Vue/Vanilla SDK widgets a developer drops - * into their own product — a PDF Viewer, an E-Sign flow, an AI Review panel — - * each metered per action (per render, per review, per signature). Every - * component carries its npm package, maturity, supported frameworks, per-action - * price, an install/usage snippet, and its key props. - */ - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Types */ -/* ──────────────────────────────────────────────────────────────────────── */ - -export type ComponentMaturity = "ga" | "beta"; - -export type Framework = "React" | "Vue" | "Vanilla"; - -/** The action a component bills against — surfaces in the price unit label. */ -export type BillingUnit = - | "render" - | "review" - | "approval" - | "signature" - | "check" - | "event" - | "session"; - -export interface ComponentProp { - name: string; - /** TypeScript-ish type expression, shown verbatim in the API table. */ - type: string; - required: boolean; - description: string; -} - -export interface ComponentPricing { - /** Price per billed action in USD. */ - pricePerAction: number; - unit: BillingUnit; - /** Free-tier monthly allowance before metering kicks in; 0 = none. */ - freeQuota: number; -} - -export interface SdkComponent { - id: string; - name: string; - /** Package suffix — full name is `@stirling/`. */ - package: string; - description: string; - maturity: ComponentMaturity; - frameworks: Framework[]; - pricing: ComponentPricing; - /** Install command (npm). */ - install: string; - /** Minimal usage snippet shown under the Code tab. */ - usage: string; - props: ComponentProp[]; - /** - * Embeds attributed to this component over the trailing 30 days — drives the - * per-card usage line. Zero for never-embedded components. - */ - embeds30d: number; - /** - * Tier at which the component becomes available. Components above the active - * tier render locked with an upgrade nudge. `pro` is the default floor. - */ - minTier: Tier; -} - -export interface ComponentsSummary { - /** Count of GA (production-ready) components available to the tier. */ - gaCount: number; - /** Count of Beta components available to the tier. */ - betaCount: number; - /** Total embeds across all components this month. */ - embedsThisMonth: number; - /** Month-to-date spend attributed to component actions, in USD. */ - spendThisMonth: number; -} - -export interface ComponentsResponse { - summary: ComponentsSummary; - components: SdkComponent[]; -} - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Presentation metadata (client-side product copy, not data) */ -/* ──────────────────────────────────────────────────────────────────────── */ - -export interface MaturityMeta { - label: string; - tone: "success" | "info"; -} - -/** `label` values are i18n keys — render with t(). */ -export const MATURITY_META: Record = { - ga: { label: "portal.components.maturity.ga", tone: "success" }, - beta: { label: "portal.components.maturity.beta", tone: "info" }, -}; - -/** Values are i18n keys — render with t(). */ -export const BILLING_UNIT_LABEL: Record = { - render: "portal.components.billingUnit.render", - review: "portal.components.billingUnit.review", - approval: "portal.components.billingUnit.approval", - signature: "portal.components.billingUnit.signature", - check: "portal.components.billingUnit.check", - event: "portal.components.billingUnit.event", - session: "portal.components.billingUnit.session", -}; - -/** Format a price as the per-action string shown on cards, e.g. "$0.04 / review". */ -export function formatPrice( - pricing: ComponentPricing, - t: (key: string) => string, -): string { - return `$${pricing.pricePerAction.toFixed(2)} / ${t(BILLING_UNIT_LABEL[pricing.unit])}`; -} - -const TIER_RANK: Record = { free: 0, pro: 1, enterprise: 2 }; - -/** Whether a component is usable at the given tier (vs locked/upgrade). */ -export function isUnlocked(component: SdkComponent, tier: Tier): boolean { - return TIER_RANK[tier] >= TIER_RANK[component.minTier]; -} - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Endpoints */ -/* ──────────────────────────────────────────────────────────────────────── */ - -/** GET /v1/components?tier=… — summary strip + the embeddable SDK catalogue. */ -export async function fetchComponents(tier: Tier): Promise { - return apiClient.local.json( - `/v1/components?tier=${encodeURIComponent(tier)}`, - ); -} diff --git a/frontend/editor/src/portal/api/sources.ts b/frontend/editor/src/portal/api/sources.ts index 9a66aeef2d..4262a47a97 100644 --- a/frontend/editor/src/portal/api/sources.ts +++ b/frontend/editor/src/portal/api/sources.ts @@ -1,9 +1,31 @@ -import { apiClient } from "@portal/api/http"; +import { apiClient, HttpError } from "@portal/api/http"; /** * Sources service layer: the backend contract. */ +/** + * Backend marker (on the error body) for a folder source rejected because its + * directory falls outside the allowed roots - the one folder-access failure an + * admin can fix in the Folder Access settings. Mirrors + * SourceController.FOLDER_ACCESS_DENIED_CODE. + */ +export const FOLDER_ACCESS_DENIED_CODE = "folderAccessDenied"; + +/** + * True when a source save failed specifically because the folder is outside the + * allowed roots (so the caller can point the admin at settings), as opposed to + * any other 400 (blank directory, SaaS mode, the protected config dir). + */ +export function isFolderAccessDeniedError(error: unknown): boolean { + return ( + error instanceof HttpError && + typeof error.body === "object" && + error.body !== null && + (error.body as { code?: unknown }).code === FOLDER_ACCESS_DENIED_CODE + ); +} + /** Overview row status: referenced and enabled, enabled-but-orphaned, or disabled. */ export type SourceStatus = "active" | "unused" | "disabled"; @@ -32,6 +54,7 @@ export interface SourceView { docsTotal: number; docs24h: number; docs30d: number; + webhookPath?: string | null; } export interface SourceKpi { diff --git a/frontend/editor/src/portal/api/users.ts b/frontend/editor/src/portal/api/users.ts index 3505238927..19a686cf8c 100644 --- a/frontend/editor/src/portal/api/users.ts +++ b/frontend/editor/src/portal/api/users.ts @@ -1,3 +1,7 @@ +// The bare i18next singleton (the same instance @app/i18n initializes at +// startup), imported directly so this data module doesn't pull i18n's +// init side effects into unit tests that mock react-i18next. +import i18n from "i18next"; import { apiClient } from "@portal/api/http"; import type { Tier } from "@portal/contexts/TierContext"; @@ -269,22 +273,23 @@ function roleIdFor(u: AdminUserSummaryDto): RoleId { /** A member's last-seen time as plain language; "Never" when no session is tracked. */ function relativeTime(value: number | string | undefined): string { - if (value === undefined || value === null) return "Never"; + if (value === undefined || value === null) + return i18n.t("users.activity.never"); const ts = typeof value === "string" ? Date.parse(value) : value; - if (!Number.isFinite(ts) || ts <= 0) return "Never"; + if (!Number.isFinite(ts) || ts <= 0) return i18n.t("users.activity.never"); const mins = Math.max(0, Math.round((Date.now() - ts) / 60000)); - if (mins < 1) return "Just now"; - if (mins < 60) return `${mins}m ago`; + if (mins < 1) return i18n.t("users.activity.justNow"); + if (mins < 60) return i18n.t("users.activity.minutesAgo", { count: mins }); const hours = Math.round(mins / 60); - if (hours < 24) return `${hours}h ago`; + if (hours < 24) return i18n.t("users.activity.hoursAgo", { count: hours }); const days = Math.round(hours / 24); - if (days < 7) return `${days}d ago`; + if (days < 7) return i18n.t("users.activity.daysAgo", { count: days }); const weeks = Math.round(days / 7); - if (weeks < 5) return weeks === 1 ? "1 week ago" : `${weeks} weeks ago`; + if (weeks < 5) return i18n.t("users.activity.weeksAgo", { count: weeks }); const months = Math.round(days / 30); - if (months < 12) return months <= 1 ? "1 month ago" : `${months} months ago`; + if (months < 12) return i18n.t("users.activity.monthsAgo", { count: months }); const years = Math.round(days / 365); - return years <= 1 ? "1 year ago" : `${years} years ago`; + return i18n.t("users.activity.yearsAgo", { count: years }); } /** 0 / huge sentinel license values mean "no seat limit". */ diff --git a/frontend/editor/src/portal/billing/stripe.test.ts b/frontend/editor/src/portal/billing/stripe.test.ts index 53a732fb63..5b6d97b9f9 100644 --- a/frontend/editor/src/portal/billing/stripe.test.ts +++ b/frontend/editor/src/portal/billing/stripe.test.ts @@ -5,9 +5,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; * already-subscribed-redirect vs neither-secret-nor-url mapping, the mock flag, * unconfigured Supabase, and the portal-session path. */ -const { getClient, invoke } = vi.hoisted(() => ({ +const { getClient, invoke, rpc } = vi.hoisted(() => ({ getClient: vi.fn(), invoke: vi.fn(), + rpc: vi.fn(), })); vi.mock("@portal/auth/saasSupabase", () => ({ ensureSaasSupabase: vi.fn() })); @@ -17,16 +18,24 @@ vi.mock("@app/auth/supabase/supabaseClient", () => ({ })); import { + acceptBundleStripeQuote, + cancelBundleQuote, + createBundleStripeQuote, createCheckoutSession, createPortalSession, + fetchBundleQuotePdf, + finalizeBundleInvoice, + getLatestBundleQuote, StripeFunctionError, + upsertBundleQuote, } from "@portal/billing/stripe"; const req = { teamId: 1, successUrl: "s", cancelUrl: "c" } as const; beforeEach(() => { invoke.mockReset(); - getClient.mockReset().mockReturnValue({ functions: { invoke } }); + rpc.mockReset(); + getClient.mockReset().mockReturnValue({ functions: { invoke }, rpc }); }); afterEach(() => vi.restoreAllMocks()); @@ -41,7 +50,6 @@ describe("createCheckoutSession", () => { clientSecret: "cs_123", redirectUrl: null, alreadySubscribed: false, - mock: false, }); }); @@ -60,14 +68,6 @@ describe("createCheckoutSession", () => { 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" }, @@ -91,6 +91,276 @@ describe("createCheckoutSession", () => { }); }); +describe("createBundleStripeQuote", () => { + it("sends team_id + quote_id (+ po_number) and maps the Stripe quote handles", async () => { + invoke.mockResolvedValue({ + data: { + success: true, + stripe_quote_id: "qt_1", + stripe_quote_number: "QT-0007", + }, + error: null, + }); + const q = await createBundleStripeQuote({ + teamId: 1, + quoteId: 7, + poNumber: "PO-9", + }); + expect(q).toEqual({ stripeQuoteId: "qt_1", stripeQuoteNumber: "QT-0007" }); + const [name, opts] = invoke.mock.calls[0]; + expect(name).toBe("create-payg-bundle-quote"); + expect(opts.body).toMatchObject({ + team_id: 1, + quote_id: 7, + po_number: "PO-9", + }); + }); + + it("throws when the edge fn reports failure", async () => { + invoke.mockResolvedValue({ + data: { success: false, error: "bundle_pricing_not_configured" }, + error: null, + }); + await expect( + createBundleStripeQuote({ teamId: 1, quoteId: 7 }), + ).rejects.toThrow(/bundle_pricing_not_configured/); + }); +}); + +describe("acceptBundleStripeQuote", () => { + it("maps the invoice response and sends quote_id", async () => { + invoke.mockResolvedValue({ + data: { + success: true, + invoice_id: "in_1", + hosted_invoice_url: "https://pay/in_1", + invoice_pdf: "https://pdf/in_1", + status: "open", + }, + error: null, + }); + const inv = await acceptBundleStripeQuote({ teamId: 1, quoteId: 7 }); + expect(inv).toEqual({ + invoiceId: "in_1", + hostedInvoiceUrl: "https://pay/in_1", + invoicePdf: "https://pdf/in_1", + status: "open", + }); + const [name, opts] = invoke.mock.calls[0]; + expect(name).toBe("accept-payg-bundle-quote"); + expect(opts.body).toMatchObject({ team_id: 1, quote_id: 7 }); + }); + + it("throws when the edge fn reports failure", async () => { + invoke.mockResolvedValue({ + data: { success: false, error: "quote_not_issued" }, + error: null, + }); + await expect( + acceptBundleStripeQuote({ teamId: 1, quoteId: 7 }), + ).rejects.toThrow(/quote_not_issued/); + }); +}); + +describe("cancelBundleQuote", () => { + it("sends team_id + quote_id and resolves on success", async () => { + invoke.mockResolvedValue({ data: { success: true }, error: null }); + await cancelBundleQuote({ teamId: 1, quoteId: 7 }); + const [name, opts] = invoke.mock.calls[0]; + expect(name).toBe("cancel-payg-bundle-quote"); + expect(opts.body).toMatchObject({ team_id: 1, quote_id: 7 }); + }); + + it("throws when the edge fn reports failure (e.g. already paid)", async () => { + invoke.mockResolvedValue({ + data: { success: false, error: "invoice_already_paid" }, + error: null, + }); + await expect(cancelBundleQuote({ teamId: 1, quoteId: 7 })).rejects.toThrow( + /invoice_already_paid/, + ); + }); +}); + +describe("fetchBundleQuotePdf", () => { + it("returns the PDF blob from the GET route", async () => { + const blob = new Blob(["%PDF"], { type: "application/pdf" }); + invoke.mockResolvedValue({ data: blob, error: null }); + const out = await fetchBundleQuotePdf(7); + expect(out).toBe(blob); + const [name, opts] = invoke.mock.calls[0]; + expect(name).toBe("create-payg-bundle-quote?quote_id=7"); + expect(opts.method).toBe("GET"); + }); + + it("throws when the response is not a file", async () => { + invoke.mockResolvedValue({ data: { success: false }, error: null }); + await expect(fetchBundleQuotePdf(7)).rejects.toBeInstanceOf( + StripeFunctionError, + ); + }); +}); + +describe("upsertBundleQuote", () => { + const quoteInput = { + teamId: 1, + users: 25, + posturePolicies: 4, + sizeMult: 1.2, + pipelineMult: 1, + provisionedMonthlyVolume: 10000, + poolCredits: 576000, + priceMinor: 480000, + currency: "usd", + consented: true, + eulaVersion: "2026-07-draft", + } as const; + + it("maps the RPC row and sends p_* args (create — no p_quote_id)", async () => { + rpc.mockResolvedValue({ + data: [ + { + quote_id: 7, + status: "issued", + valid_until: "2026-08-16T00:00:00Z", + }, + ], + error: null, + }); + const q = await upsertBundleQuote(quoteInput); + expect(q).toEqual({ + quoteId: 7, + status: "issued", + validUntil: "2026-08-16T00:00:00Z", + }); + const [fn, args] = rpc.mock.calls[0]; + expect(fn).toBe("payg_upsert_bundle_quote"); + expect(args).toMatchObject({ + p_team_id: 1, + p_posture_policies: 4, + p_size_mult: 1.2, + p_pipeline_mult: 1, + p_pool_credits: 576000, + p_users: 25, + p_price_minor: 480000, + p_currency: "usd", + p_consented: true, + p_eula_version: "2026-07-draft", + }); + expect(args).not.toHaveProperty("p_quote_id"); + }); + + it("passes p_quote_id when editing an existing quote", async () => { + rpc.mockResolvedValue({ + data: [{ quote_id: 7, status: "issued", valid_until: "x" }], + error: null, + }); + await upsertBundleQuote({ ...quoteInput, quoteId: 7 }); + expect(rpc.mock.calls[0][1]).toMatchObject({ p_quote_id: 7 }); + }); + + it("throws a StripeFunctionError (with code) on an RPC error", async () => { + rpc.mockResolvedValue({ + data: null, + error: { message: "not a leader", code: "42501" }, + }); + const err = await upsertBundleQuote(quoteInput).catch((e: unknown) => e); + expect(err).toBeInstanceOf(StripeFunctionError); + expect((err as StripeFunctionError).code).toBe("42501"); + }); +}); + +describe("finalizeBundleInvoice", () => { + it("maps the invoice response and sends quote_id (+ optional po_number)", async () => { + invoke.mockResolvedValue({ + data: { + success: true, + invoice_id: "in_1", + hosted_invoice_url: "https://pay/in_1", + invoice_pdf: "https://pdf/in_1", + status: "open", + }, + error: null, + }); + const inv = await finalizeBundleInvoice({ + teamId: 1, + quoteId: 7, + poNumber: "PO-9", + }); + expect(inv).toEqual({ + invoiceId: "in_1", + hostedInvoiceUrl: "https://pay/in_1", + invoicePdf: "https://pdf/in_1", + status: "open", + }); + const [name, opts] = invoke.mock.calls[0]; + expect(name).toBe("finalize-payg-bundle-invoice"); + expect(opts.body).toMatchObject({ + team_id: 1, + quote_id: 7, + po_number: "PO-9", + }); + }); + + it("throws when the edge fn reports failure", async () => { + invoke.mockResolvedValue({ + data: { success: false, error: "quote_not_accepted" }, + error: null, + }); + await expect( + finalizeBundleInvoice({ teamId: 1, quoteId: 7 }), + ).rejects.toThrow(/quote_not_accepted/); + }); +}); + +describe("getLatestBundleQuote", () => { + it("maps the latest open quote row (numeric size_mult coerced)", async () => { + rpc.mockResolvedValue({ + data: [ + { + quote_id: 7, + users: 25, + posture_policies: 4, + size_mult: "1.2", + pipeline_mult: 1, + pool_credits: 576000, + price_minor: 480000, + currency: "usd", + consented_at: "2026-07-17T00:00:00Z", + stripe_quote_id: "qt_1", + stripe_quote_number: "QT-0007", + stripe_ref: "in_1", + valid_until: "2026-08-16T00:00:00Z", + }, + ], + error: null, + }); + const q = await getLatestBundleQuote(1); + expect(q).toEqual({ + quoteId: 7, + users: 25, + posturePolicies: 4, + sizeMult: 1.2, + pipelineMult: 1, + poolCredits: 576000, + priceMinor: 480000, + currency: "usd", + consentedAt: "2026-07-17T00:00:00Z", + stripeQuoteId: "qt_1", + stripeQuoteNumber: "QT-0007", + stripeRef: "in_1", + validUntil: "2026-08-16T00:00:00Z", + }); + expect(rpc.mock.calls[0][0]).toBe("payg_get_latest_bundle_quote"); + expect(rpc.mock.calls[0][1]).toEqual({ p_team_id: 1 }); + }); + + it("returns null when the team has no open quote", async () => { + rpc.mockResolvedValue({ data: [], error: null }); + expect(await getLatestBundleQuote(1)).toBeNull(); + }); +}); + describe("createPortalSession", () => { it("returns the portal URL", async () => { invoke.mockResolvedValue({ diff --git a/frontend/editor/src/portal/billing/stripe.ts b/frontend/editor/src/portal/billing/stripe.ts index 930ed3fef3..9efcc9b0a1 100644 --- a/frontend/editor/src/portal/billing/stripe.ts +++ b/frontend/editor/src/portal/billing/stripe.ts @@ -1,3 +1,4 @@ +import type { Stripe } from "@stripe/stripe-js"; import { getSupabaseClient } from "@app/auth/supabase/supabaseClient"; import { ensureSaasSupabase } from "@portal/auth/saasSupabase"; @@ -53,7 +54,6 @@ interface CheckoutResponse { url?: string; portal_url?: string; already_subscribed?: boolean; - mock?: boolean; error?: string; } @@ -87,6 +87,163 @@ async function invoke( return data; } +/** Call a SECURITY DEFINER public.* RPC with the admin's JWT (same client as {@link invoke}). */ +async function rpc(fn: string, args: Record): Promise { + ensureSaasSupabase(); + const supabase = getSupabaseClient(); + if (!supabase) { + throw new StripeFunctionError( + "SaaS Supabase not configured — set VITE_SUPABASE_URL.", + "unconfigured", + ); + } + const { data, error } = await supabase.rpc(fn, args); + if (error) { + throw new StripeFunctionError( + error.message ?? `RPC ${fn} failed`, + (error as { code?: string }).code, + ); + } + return data as T; +} + +/** Inputs to {@link upsertBundleQuote} — the sized config + computed figures. */ +export interface BundleQuoteInput { + teamId: number; + users: number; + posturePolicies: number; + sizeMult: number; + pipelineMult: number; + provisionedMonthlyVolume: number; + /** Size-folded run-credits = the Stripe line quantity when this quote is paid. */ + poolCredits: number; + /** + * Client-estimated discounted total in minor units, persisted for the pre-mint display only; null + * when the per-run rate is unknown. NOT authoritative: once the Stripe quote is minted, + * create-payg-bundle-quote overwrites the row's price_minor with the server-derived total + * (Price x qty - amount_off), and the Stripe quote/invoice amount is server-derived regardless. + */ + priceMinor: number | null; + currency: string; + /** Affirmative consent to the prepaid→metered auto-transition (ARL/EULA §7.2). */ + consented: boolean; + eulaVersion: string; + /** When set, edits that existing (unpaid) quote instead of creating a new one. */ + quoteId?: number; +} + +/** A persisted prepaid-bundle quote (proforma) — {@code payg_upsert_bundle_quote} result. */ +export interface BundleQuote { + quoteId: number; + status: string; + validUntil: string; +} + +interface BundleQuoteRow { + quote_id: number; + status: string; + valid_until: string; +} + +/** + * Create (or edit an unpaid) prepaid-bundle quote via {@code payg_upsert_bundle_quote}. LEADER-gated + * server-side. The quote persists the sized config + figures so the buyer can download a numbered + * proforma to share for approval and check out against it later; capacity is still credited only on + * payment (the webhook), never here. + */ +export async function upsertBundleQuote( + input: BundleQuoteInput, +): Promise { + const rows = await rpc("payg_upsert_bundle_quote", { + p_team_id: input.teamId, + p_posture_policies: input.posturePolicies, + p_size_mult: input.sizeMult, + p_pipeline_mult: input.pipelineMult, + p_pool_credits: input.poolCredits, + p_users: input.users, + p_provisioned_monthly_volume: input.provisionedMonthlyVolume, + p_price_minor: input.priceMinor, + p_currency: input.currency, + p_consented: input.consented, + p_eula_version: input.eulaVersion, + ...(input.quoteId != null ? { p_quote_id: input.quoteId } : {}), + }); + const row = rows?.[0]; + if (!row) { + throw new StripeFunctionError("payg_upsert_bundle_quote returned no row"); + } + return { + quoteId: row.quote_id, + status: row.status, + validUntil: row.valid_until, + }; +} + +/** A team's latest open bundle quote — {@code payg_get_latest_bundle_quote} result, for resume. */ +export interface LatestBundleQuote { + quoteId: number; + users: number | null; + posturePolicies: number; + sizeMult: number; + pipelineMult: number; + poolCredits: number; + priceMinor: number | null; + currency: string | null; + consentedAt: string | null; + stripeQuoteId: string | null; + stripeQuoteNumber: string | null; + /** The generated invoice id, set once the quote is accepted — lets the modal resume to the pay step. */ + stripeRef: string | null; + validUntil: string; +} + +interface LatestBundleQuoteRow { + quote_id: number; + users: number | null; + posture_policies: number; + size_mult: number | string; + pipeline_mult: number; + pool_credits: number; + price_minor: number | null; + currency: string | null; + consented_at: string | null; + stripe_quote_id: string | null; + stripe_quote_number: string | null; + stripe_ref: string | null; + valid_until: string; +} + +/** + * Fetch the team's most-recent OPEN (draft/issued, unexpired) bundle quote via + * {@code payg_get_latest_bundle_quote}, so the modal can resume it instead of minting a fresh Stripe + * quote on every reopen. Returns null when the team has none. + */ +export async function getLatestBundleQuote( + teamId: number, +): Promise { + const rows = await rpc( + "payg_get_latest_bundle_quote", + { p_team_id: teamId }, + ); + const row = rows?.[0]; + if (!row) return null; + return { + quoteId: row.quote_id, + users: row.users, + posturePolicies: row.posture_policies, + sizeMult: Number(row.size_mult), + pipelineMult: row.pipeline_mult, + poolCredits: row.pool_credits, + priceMinor: row.price_minor, + currency: row.currency, + consentedAt: row.consented_at, + stripeQuoteId: row.stripe_quote_id, + stripeQuoteNumber: row.stripe_quote_number, + stripeRef: row.stripe_ref, + validUntil: row.valid_until, + }; +} + /** * Result of {@link createCheckoutSession}. Exactly ONE of {@code clientSecret} * or {@code redirectUrl} is set: clientSecret drives embedded Stripe Checkout @@ -97,7 +254,6 @@ export interface CheckoutSession { clientSecret: string | null; redirectUrl: string | null; alreadySubscribed: boolean; - mock: boolean; } /** @@ -144,13 +300,207 @@ export async function createCheckoutSession( clientSecret, redirectUrl, alreadySubscribed, - mock: Boolean(res.mock) || clientSecret?.startsWith("cs_mock_") === true, }; } -/** {@code VITE_STRIPE_PUBLISHABLE_KEY} — the Stripe pk used by embedded Checkout. */ +/** Result of {@link createBundleStripeQuote} — the Stripe-issued quote handles. */ +export interface BundleStripeQuote { + stripeQuoteId: string; + stripeQuoteNumber: string | null; +} + +interface BundleStripeQuoteRequest { + teamId: number; + /** The persisted quote row (from {@link upsertBundleQuote}) to turn into a Stripe quote. */ + quoteId: number; + /** Optional PO number printed on the quote + carried to the eventual invoice. */ + poNumber?: string; + /** Net terms for the eventual invoice; defaults to 30 on the server. */ + daysUntilDue?: number; +} + +interface BundleStripeQuoteResponse { + success?: boolean; + stripe_quote_id?: string; + stripe_quote_number?: string | null; + error?: string; +} + +/** + * Create + finalize the Stripe QUOTE backing a persisted quote row, via {@code create-payg-bundle-quote}. + * The customer-facing quote number + PDF are Stripe's. On edit the server cancels the prior Stripe quote + * and issues a new one. Capacity is credited only when the accepted quote's invoice is PAID (the webhook). + */ +export async function createBundleStripeQuote( + req: BundleStripeQuoteRequest, +): Promise { + const res = await invoke( + "create-payg-bundle-quote", + { + team_id: req.teamId, + quote_id: req.quoteId, + ...(req.poNumber ? { po_number: req.poNumber } : {}), + ...(req.daysUntilDue != null ? { days_until_due: req.daysUntilDue } : {}), + }, + ); + if (!res.success || !res.stripe_quote_id) { + throw new StripeFunctionError( + res.error ?? "create-payg-bundle-quote failed", + ); + } + return { + stripeQuoteId: res.stripe_quote_id, + stripeQuoteNumber: res.stripe_quote_number ?? null, + }; +} + +/** A raised Stripe invoice — the {@code accept-payg-bundle-quote} result. */ +export interface BundleInvoice { + invoiceId: string; + /** Stripe-hosted page where the buyer pays / downloads the invoice. */ + hostedInvoiceUrl: string | null; + invoicePdf: string | null; + status: string | null; +} + +interface BundleInvoiceResponse { + success?: boolean; + invoice_id?: string; + hosted_invoice_url?: string | null; + invoice_pdf?: string | null; + status?: string | null; + error?: string; +} + +/** + * Accept the Stripe quote for a persisted quote row, via {@code accept-payg-bundle-quote}. Acceptance + * generates the net-terms invoice as a DRAFT (auto_advance off) and returns the hosted URL; the + * payment step ({@link finalizeBundleInvoice}) stamps the recipient + PO and finalizes it. Payable by + * card on the hosted page, or by bank transfer / PO. Capacity is credited only on invoice.paid. + */ +export async function acceptBundleStripeQuote(req: { + teamId: number; + quoteId: number; +}): Promise { + const res = await invoke("accept-payg-bundle-quote", { + team_id: req.teamId, + quote_id: req.quoteId, + }); + if (!res.success || !res.invoice_id) { + throw new StripeFunctionError( + res.error ?? "accept-payg-bundle-quote failed", + ); + } + return { + invoiceId: res.invoice_id, + hostedInvoiceUrl: res.hosted_invoice_url ?? null, + invoicePdf: res.invoice_pdf ?? null, + status: res.status ?? null, + }; +} + +/** + * Finalize the accepted bundle invoice (stamping an optional PO), via {@code finalize-payg-bundle-invoice}. + * Returns the hosted checkout URL + PDF. Called by both Download-invoice and Pay-online; idempotent + * server-side (an already-finalized invoice comes back as-is, PO locked). + */ +export async function finalizeBundleInvoice(req: { + teamId: number; + quoteId: number; + poNumber?: string; + /** Optional company — becomes the invoice bill-to name (no length cap). */ + companyName?: string; + /** Required account-holder name — the bill-to when there's no company, else an "Account holder" field. */ + accountName?: string; +}): Promise { + const res = await invoke( + "finalize-payg-bundle-invoice", + { + team_id: req.teamId, + quote_id: req.quoteId, + ...(req.poNumber ? { po_number: req.poNumber } : {}), + ...(req.companyName ? { company_name: req.companyName } : {}), + ...(req.accountName ? { account_name: req.accountName } : {}), + }, + ); + if (!res.success || !res.invoice_id) { + throw new StripeFunctionError( + res.error ?? "finalize-payg-bundle-invoice failed", + ); + } + return { + invoiceId: res.invoice_id, + hostedInvoiceUrl: res.hosted_invoice_url ?? null, + invoicePdf: res.invoice_pdf ?? null, + status: res.status ?? null, + }; +} + +/** + * Cancel an unpaid prepaid-bundle purchase via {@code cancel-payg-bundle-quote}: the edge fn voids the + * invoice (delete if draft, void if finalized), best-effort cancels the Stripe quote, and voids the quote + * row so the buyer can start over. Nothing was charged (capacity is credited on invoice.paid), so there's + * no refund. Throws a StripeFunctionError on failure (e.g. {@code invoice_already_paid}). + */ +export async function cancelBundleQuote(req: { + teamId: number; + quoteId: number; +}): Promise { + const res = await invoke<{ success?: boolean; error?: string }>( + "cancel-payg-bundle-quote", + { team_id: req.teamId, quote_id: req.quoteId }, + ); + if (!res.success) { + throw new StripeFunctionError( + res.error ?? "cancel-payg-bundle-quote failed", + ); + } +} + +/** + * Fetch the Stripe-rendered quote PDF for a persisted quote, via the {@code create-payg-bundle-quote} + * GET route (streams application/pdf). Returns a Blob the caller can object-URL for download. + */ +export async function fetchBundleQuotePdf(quoteId: number): Promise { + ensureSaasSupabase(); + const supabase = getSupabaseClient(); + if (!supabase) { + throw new StripeFunctionError( + "SaaS Supabase not configured — set VITE_SUPABASE_URL.", + "unconfigured", + ); + } + const { data, error } = await supabase.functions.invoke( + `create-payg-bundle-quote?quote_id=${quoteId}`, + { method: "GET" }, + ); + if (error) { + throw new StripeFunctionError(error.message ?? "quote PDF fetch failed"); + } + if (!(data instanceof Blob)) { + throw new StripeFunctionError("quote PDF response was not a file"); + } + return data; +} + +/** + * {@code VITE_STRIPE_PUBLISHABLE_KEY} — the Stripe pk used by embedded Checkout. Coalesces to "" when + * unset so the declared `string` return type is honest (Vite substitutes `undefined` for a missing + * env var); callers guard with a falsy check. + */ export function getStripePublishableKey(): string { - return import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY; + return import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY ?? ""; +} + +// Process-wide memoized Stripe.js loader, shared by the embedded-checkout modals so the SDK promise +// is created once rather than per-modal. loadStripe is dynamically imported so its chunk only loads +// when a checkout modal reaches its payment step. +let stripePromise: Promise | null = null; +export function loadStripeOnce(pk: string): Promise { + if (stripePromise === null) { + stripePromise = import("@stripe/stripe-js").then((m) => m.loadStripe(pk)); + } + return stripePromise; } /** diff --git a/frontend/editor/src/portal/components/AppShell.css b/frontend/editor/src/portal/components/AppShell.css index 03702966e4..6535832399 100644 --- a/frontend/editor/src/portal/components/AppShell.css +++ b/frontend/editor/src/portal/components/AppShell.css @@ -8,9 +8,10 @@ .portal-shell { display: flex; height: 100vh; + height: 100dvh; /* mobile browser chrome shrinks the viewport; dvh tracks it */ overflow: hidden; - background: var(--color-bg); - color: var(--color-text-2); + background: var(--c-bg); + color: var(--c-text-muted); } .portal-shell__main { @@ -27,3 +28,43 @@ overflow-y: auto; animation: fadeInUp var(--motion-enter) both; } + +/* Mobile topbar — hidden on desktop, where the sidebar is always visible. + Height matches the sidebar's logo row so the brand sits at the same level. */ +.portal-shell__topbar { + display: none; + height: 3.1875rem; + flex-shrink: 0; + align-items: center; + gap: 0.375rem; + padding: 0 0.625rem; + background: var(--color-sidebar-bg); + border-bottom: 1px solid var(--color-sidebar-border); +} + +.portal-shell__topbar-wordmark { + height: 1.375rem; + width: auto; + display: block; + margin-right: auto; +} + +/* Scrim behind the mobile nav drawer (below SUI modals at z-index 100). */ +.portal-shell__scrim { + display: none; + position: fixed; + inset: 0; + z-index: 75; + background: var(--c-overlay); +} + +@media (max-width: 48rem) { + .portal-shell__topbar { + display: flex; + } + .portal-shell__scrim { + display: block; + /* fadeIn keyframes come from core tokens.css */ + animation: fadeIn var(--motion-fast) both; + } +} diff --git a/frontend/editor/src/portal/components/AppShell.stories.tsx b/frontend/editor/src/portal/components/AppShell.stories.tsx new file mode 100644 index 0000000000..1df98c3197 --- /dev/null +++ b/frontend/editor/src/portal/components/AppShell.stories.tsx @@ -0,0 +1,34 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { AppShell } from "@portal/components/AppShell"; +import { Home } from "@portal/views/Home"; + +/** + * The full shell (sidebar + mobile topbar/drawer + scrolling view column) with + * the Home view inside. Resize the viewport below 48rem to exercise the mobile + * chrome: the sidebar becomes an off-canvas drawer behind a scrim, opened from + * the topbar hamburger. + */ +const meta: Meta = { + title: "Portal/Shell/AppShell", + component: AppShell, + parameters: { layout: "fullscreen" }, +}; +export default meta; +type Story = StoryObj; + +export const WithHomeView: Story = { + render: () => ( + + + + ), +}; + +export const Mobile: Story = { + render: () => ( + + + + ), + globals: { viewport: { value: "mobile2", isRotated: false } }, +}; diff --git a/frontend/editor/src/portal/components/AppShell.tsx b/frontend/editor/src/portal/components/AppShell.tsx index b3744d36bf..c50b4000e9 100644 --- a/frontend/editor/src/portal/components/AppShell.tsx +++ b/frontend/editor/src/portal/components/AppShell.tsx @@ -1,17 +1,90 @@ -import type { ReactNode } from "react"; +import { useEffect, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { useLocation } from "react-router-dom"; +import { ActionIcon } from "@app/ui"; import { Sidebar } from "@portal/components/Sidebar"; +import { useTheme } from "@portal/contexts/ThemeContext"; +import { useUI } from "@portal/contexts/UIContext"; +import { MenuIcon, SearchIcon } from "@portal/components/icons"; +import wordmarkLight from "@app/assets/brand/modern-logo/StirlingProcessorLogoBlackText.svg"; +import wordmarkDark from "@app/assets/brand/modern-logo/StirlingProcessorLogoWhiteText.svg"; import "@portal/components/AppShell.css"; +/** + * Compact header shown only under the mobile breakpoint (CSS-hidden on + * desktop): hamburger opens the sidebar drawer, search opens the palette + * (there's no ⌘K on a phone). + */ +function MobileTopbar() { + const { t } = useTranslation(); + const { theme } = useTheme(); + const { mobileNavOpen, toggleMobileNav, openSearch } = useUI(); + return ( +
    + + + + {t("portal.shell.sidebar.brandSuffix")} + + + +
    + ); +} + /** * Two-column layout: fixed-width sidebar on the left, a scrolling main column on - * the right. The Sidebar reads its state from context, so this shell stays - * prop-free. + * the right. Under the mobile breakpoint the sidebar becomes an off-canvas + * drawer behind a scrim, opened from the topbar hamburger. The Sidebar reads + * its state from context, so this shell stays prop-free. */ export function AppShell({ children }: { children: ReactNode }) { + const { mobileNavOpen, closeMobileNav } = useUI(); + const { pathname } = useLocation(); + + // Navigating (tap on a nav row, back button, deep link) always dismisses the + // drawer. Depends on pathname only: the close fn's identity changes with any + // UI state, and re-running on that would instantly close a just-opened drawer. + useEffect(() => { + closeMobileNav(); + }, [pathname]); + + useEffect(() => { + if (!mobileNavOpen) return; + function onKey(e: KeyboardEvent) { + if (e.key === "Escape") closeMobileNav(); + } + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [mobileNavOpen, closeMobileNav]); + return (
    + {mobileNavOpen && ( +
    + )}
    +
    {children}
    diff --git a/frontend/editor/src/portal/components/AssistantButton.css b/frontend/editor/src/portal/components/AssistantButton.css index 107ff7c1ac..6ae6e34e46 100644 --- a/frontend/editor/src/portal/components/AssistantButton.css +++ b/frontend/editor/src/portal/components/AssistantButton.css @@ -8,14 +8,14 @@ display: inline-flex; align-items: center; justify-content: center; - color: #fff; + color: var(--c-text-on-primary); background: linear-gradient( 135deg, - var(--color-blue) 0%, + var(--c-primary) 0%, var(--color-purple) 100% ); box-shadow: - 0 0.5rem 1.25rem rgba(59, 130, 246, 0.35), + 0 0.5rem 1.25rem color-mix(in srgb, var(--c-primary) 35%, transparent), inset 0 0.0625rem 0 rgba(255, 255, 255, 0.2); transition: transform var(--motion-base), @@ -26,7 +26,7 @@ .portal-assistant-btn:hover { transform: scale(1.08); box-shadow: - 0 0.75rem 1.75rem rgba(59, 130, 246, 0.45), + 0 0.75rem 1.75rem color-mix(in srgb, var(--c-primary) 45%, transparent), inset 0 0.0625rem 0 rgba(255, 255, 255, 0.25); } diff --git a/frontend/editor/src/portal/components/AssistantButton.stories.tsx b/frontend/editor/src/portal/components/AssistantButton.stories.tsx new file mode 100644 index 0000000000..cb6c83c043 --- /dev/null +++ b/frontend/editor/src/portal/components/AssistantButton.stories.tsx @@ -0,0 +1,32 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useEffect } from "react"; +import { AssistantButton } from "@portal/components/AssistantButton"; +import { useUI } from "@portal/contexts/UIContext"; + +function ForceOpen() { + const { openAssistant } = useUI(); + useEffect(() => { + openAssistant(); + }, [openAssistant]); + return null; +} + +const meta: Meta = { + title: "Portal/Assistant/AssistantButton", + component: AssistantButton, +}; +export default meta; +type Story = StoryObj; + +export const Closed: Story = {}; + +export const Open: Story = { + decorators: [ + (S) => ( + <> + + + + ), + ], +}; diff --git a/frontend/editor/src/portal/components/AssistantPanel.css b/frontend/editor/src/portal/components/AssistantPanel.css index e16f84f6c0..2e5edc907f 100644 --- a/frontend/editor/src/portal/components/AssistantPanel.css +++ b/frontend/editor/src/portal/components/AssistantPanel.css @@ -2,11 +2,12 @@ position: fixed; bottom: 5.5rem; right: 1.5rem; - width: 23.75rem; + /* Clamp to the viewport so small phones don't clip the panel off-screen */ + width: min(23.75rem, calc(100vw - 2rem)); height: 32.5rem; - max-height: calc(100vh - 7rem); - background: var(--color-surface); - border: 1px solid var(--color-border); + max-height: calc(100dvh - 7rem); + background: var(--c-surface); + border: 1px solid var(--c-border); border-radius: var(--radius-xl); box-shadow: var(--shadow-lg); display: flex; @@ -25,7 +26,7 @@ color: var(--color-text-on-accent); background: linear-gradient( 135deg, - var(--color-blue) 0%, + var(--c-primary) 0%, var(--color-purple) 100% ); } @@ -90,17 +91,17 @@ padding: 0.4375rem 0.625rem; font-size: 0.8125rem; text-align: left; - color: var(--color-text-2); - background: var(--color-surface); - border: 1px solid var(--color-border); + color: var(--c-text-muted); + background: var(--c-surface); + border: 1px solid var(--c-border); border-radius: var(--radius-md); transition: border-color var(--motion-fast), background var(--motion-fast); } .portal-assistant__suggestion:hover { - background: var(--color-bg-hover); - border-color: var(--color-blue-border); + background: var(--c-hover); + border-color: var(--c-primary-border); } .portal-assistant__bubble { @@ -115,16 +116,16 @@ .portal-assistant__bubble--user { align-self: flex-end; - background: var(--color-blue); + background: var(--c-primary); color: var(--color-text-on-accent); border-bottom-right-radius: 0.25rem; } .portal-assistant__bubble--assistant { align-self: flex-start; - background: var(--color-surface); - color: var(--color-text-2); - border: 1px solid var(--color-border); + background: var(--c-surface); + color: var(--c-text-muted); + border: 1px solid var(--c-border); border-bottom-left-radius: 0.25rem; } @@ -137,7 +138,7 @@ width: 0.375rem; height: 0.375rem; border-radius: 50%; - background: var(--color-text-5); + background: var(--c-text-subtle); animation: pulse 1.2s ease-in-out infinite; } .portal-assistant__typing span:nth-child(2) { @@ -152,8 +153,8 @@ align-items: center; gap: 0.5rem; padding: 0.625rem 0.75rem; - border-top: 1px solid var(--color-border); - background: var(--color-surface); + border-top: 1px solid var(--c-border); + background: var(--c-surface); } .portal-assistant__input { @@ -161,15 +162,15 @@ font: inherit; font-size: 0.8125rem; background: transparent; - border: 1px solid var(--color-border-input); + border: 1px solid var(--c-border); border-radius: var(--radius-md); padding: 0.4375rem 0.625rem; - color: var(--color-text-1); + color: var(--c-text); outline: none; transition: border-color var(--motion-fast); } .portal-assistant__input:focus { - border-color: var(--color-blue); + border-color: var(--c-primary); } .portal-assistant__send { diff --git a/frontend/editor/src/portal/components/AssistantPanel.stories.tsx b/frontend/editor/src/portal/components/AssistantPanel.stories.tsx index 92fe3e0bdf..911f389566 100644 --- a/frontend/editor/src/portal/components/AssistantPanel.stories.tsx +++ b/frontend/editor/src/portal/components/AssistantPanel.stories.tsx @@ -18,7 +18,7 @@ const meta: Meta = { parameters: { layout: "fullscreen" }, decorators: [ (S) => ( -
    +
    diff --git a/frontend/editor/src/portal/components/AuthGate.tsx b/frontend/editor/src/portal/components/AuthGate.tsx index a7f4e58ef5..0af62ee53b 100644 --- a/frontend/editor/src/portal/components/AuthGate.tsx +++ b/frontend/editor/src/portal/components/AuthGate.tsx @@ -19,7 +19,7 @@ function FullScreenMessage({ children }: { children: ReactNode }) { alignItems: "center", justifyContent: "center", gap: "0.75rem", - color: "var(--color-text-3)", + color: "var(--c-text-subtle)", }} > {children} diff --git a/frontend/editor/src/portal/components/BrandMarks.stories.tsx b/frontend/editor/src/portal/components/BrandMarks.stories.tsx new file mode 100644 index 0000000000..e25fde2b5d --- /dev/null +++ b/frontend/editor/src/portal/components/BrandMarks.stories.tsx @@ -0,0 +1,24 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { BrandMark } from "@portal/components/BrandMarks"; + +const meta = { + title: "Portal/BrandMarks", + component: BrandMark, + parameters: { layout: "padded" }, + args: { + id: "s3", + size: 24, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const CloudProvider: Story = { args: { id: "googledrive" } }; + +export const Neutral: Story = { args: { id: "sftp" } }; + +export const UnknownFallback: Story = { + args: { id: "some-unrecognised-source" }, +}; diff --git a/frontend/editor/src/portal/components/BrandMarks.tsx b/frontend/editor/src/portal/components/BrandMarks.tsx new file mode 100644 index 0000000000..04f1d79e83 --- /dev/null +++ b/frontend/editor/src/portal/components/BrandMarks.tsx @@ -0,0 +1,451 @@ +import type { CSSProperties, ReactNode } from "react"; +import { + DropboxIcon, + GoogleDriveIcon, + OneDriveIcon, +} from "@app/components/shared/CloudStorageIcons"; + +/** + * Full-colour brand marks for the integrations catalogue and source connectors, + * drawn on a transparent 24x24 canvas (no tinted badge behind them). Vendors + * with a recognisable mark get their brand geometry and colours; self-hosted or + * generic entries render neutral currentColor strokes so they follow the theme. + * Path-exempt from theme-lint's code-colors gate (brand hexes are the point). + */ + +interface MarkProps { + size?: number; + className?: string; + style?: CSSProperties; +} + +function Fill({ + size = 20, + className, + style, + children, + viewBox = "0 0 24 24", +}: MarkProps & { children: ReactNode; viewBox?: string }) { + return ( + + {children} + + ); +} + +function Stroke({ + size = 20, + className, + style, + children, +}: MarkProps & { children: ReactNode }) { + return ( + + {children} + + ); +} + +/** Brand-coloured marks, keyed by connection-type/source-type id. */ +const BRAND: Record ReactNode> = { + s3: (p) => ( + + + + + + ), + sharepoint: (p) => ( + + + + + + ), + purview: (p) => ( + + + + + + ), + box: (p) => ( + + + + + + ), + slack: (p) => ( + + + + + + + + + + + ), + teams: (p) => ( + + + + + ), + discord: (p) => ( + + + + + + ), + googlechat: (p) => ( + + + + + ), + zapier: (p) => ( + + + + + + + + + + ), + jira: (p) => ( + + + + + ), + confluence: (p) => ( + + + + + ), + nextcloud: (p) => ( + + + + + + + + ), + splunk: (p) => ( + + + + ), + elastic: (p) => ( + + + + + + ), + sumologic: (p) => ( + + + + + ), + sendgrid: (p) => ( + + + + + + + + + + + ), + mailgun: (p) => ( + + + + + + + ), + cloudmersive: (p) => ( + + + + + ), + cloudmersiveadvanced: (p) => ( + + + + + + ), + presidio: (p) => ( + + + + + + ), + clamav: (p) => ( + + + + + ), + consigno: (p) => ( + + + + + ), +}; + +/** Neutral currentColor strokes for generic / self-hosted / roadmap entries. */ +const NEUTRAL: Record = { + folder: ( + + ), + webhook: , + editor: ( + <> + + + + ), + api: ( + <> + + + + ), + network: ( + <> + + + + + ), + sftp: ( + <> + + + + + + + ), + email: ( + <> + + + + + + ), + _default: ( + <> + + + + + + ), +}; + +/** One mark for any integration/source id; unknown ids get a neutral plug. */ +export function BrandMark({ + id, + size = 20, + className, + style, +}: MarkProps & { id: string }) { + if (id === "googledrive") { + return ( + + ); + } + if (id === "onedrive") { + return ( + + ); + } + if (id === "dropbox") { + return ( + + ); + } + const brand = BRAND[id]; + if (brand) return <>{brand({ size, className, style })}; + return ( + + {NEUTRAL[id] ?? NEUTRAL._default} + + ); +} diff --git a/frontend/editor/src/portal/components/ChatFABWidget.stories.tsx b/frontend/editor/src/portal/components/ChatFABWidget.stories.tsx index b6124bbd25..af7d67eea7 100644 --- a/frontend/editor/src/portal/components/ChatFABWidget.stories.tsx +++ b/frontend/editor/src/portal/components/ChatFABWidget.stories.tsx @@ -55,7 +55,7 @@ function MockChatContent({ alignItems: "center", justifyContent: "space-between", padding: "14px 16px 10px", - borderBottom: "1px solid var(--color-border, #e3e8ee)", + borderBottom: "1px solid var(--c-border, #e3e8ee)", flexShrink: 0, }} > @@ -65,7 +65,7 @@ function MockChatContent({ shape="circle" onClick={onClose} aria-label="Close chat" - style={{ color: "var(--color-text-4, #64748b)" }} + style={{ color: "var(--c-text-subtle, #64748b)" }} > ✕ @@ -91,7 +91,7 @@ function MockChatContent({ background: m.role === "user" ? "#3b82f6" - : "var(--color-bg-muted, #f3f4f6)", + : "var(--c-surface-sunken, #f3f4f6)", color: m.role === "user" ? "#fff" : "inherit", borderRadius: 10, padding: "8px 12px", @@ -108,17 +108,17 @@ function MockChatContent({
    What do you want to do? @@ -149,7 +149,7 @@ function ChatFABWidgetDemo({ width: "100%", height: "100%", overflow: "hidden", - background: "var(--color-bg, #f8f9fb)", + background: "var(--c-bg, #f8f9fb)", }} > {/* FAB button */} @@ -249,7 +249,7 @@ function ChatFABFullFlowDemo() { padding: "4px 10px", borderRadius: 6, background: - step === s ? "#3b82f6" : "var(--color-bg-muted, #f3f4f6)", + step === s ? "#3b82f6" : "var(--c-surface-sunken, #f3f4f6)", color: step === s ? "#fff" : "inherit", fontWeight: step === s ? 600 : 400, }} diff --git a/frontend/editor/src/portal/components/DownloadEditorModal.css b/frontend/editor/src/portal/components/DownloadEditorModal.css index fba739501f..4742833be8 100644 --- a/frontend/editor/src/portal/components/DownloadEditorModal.css +++ b/frontend/editor/src/portal/components/DownloadEditorModal.css @@ -14,7 +14,7 @@ font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; - color: var(--color-text-5); + color: var(--c-text-subtle); } .portal-install__section:first-child { margin-top: 0; @@ -27,9 +27,9 @@ gap: 0.875rem; width: 100%; padding: 0.75rem 0.875rem; - border: 1px solid var(--color-border-light); + border: 1px solid var(--c-border-subtle); border-radius: var(--radius-md); - background: var(--color-surface); + background: var(--c-surface); text-align: left; cursor: pointer; transition: @@ -37,8 +37,8 @@ background var(--motion-fast); } .portal-install__option:hover { - border-color: var(--color-border-input); - background: var(--color-bg-hover); + border-color: var(--c-border); + background: var(--c-hover); } .portal-install__option-icon { @@ -48,8 +48,8 @@ height: 2.25rem; flex-shrink: 0; border-radius: 0.625rem; - color: var(--color-blue); - background: var(--color-blue-light); + color: var(--c-primary); + background: var(--c-primary-tint); } .portal-install__option-text { @@ -60,14 +60,14 @@ .portal-install__option-text strong { font-size: 0.9375rem; font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); } .portal-install__option-text span { font-size: 0.8125rem; - color: var(--color-text-4); + color: var(--c-text-subtle); } .portal-install__option-chevron { - color: var(--color-text-5); + color: var(--c-text-subtle); flex-shrink: 0; } @@ -88,13 +88,13 @@ font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; - color: var(--color-text-5); + color: var(--c-text-subtle); } .portal-install__note { margin: 0; font-size: 0.8125rem; line-height: 1.5; - color: var(--color-text-4); + color: var(--c-text-subtle); } .portal-install__guide { align-self: flex-start; @@ -106,7 +106,7 @@ background: none; font-size: 0.8125rem; font-weight: 600; - color: var(--color-blue); + color: var(--c-primary); cursor: pointer; } .portal-install__guide:hover { diff --git a/frontend/editor/src/portal/components/DownloadEditorModal.stories.tsx b/frontend/editor/src/portal/components/DownloadEditorModal.stories.tsx new file mode 100644 index 0000000000..6a9166c3b2 --- /dev/null +++ b/frontend/editor/src/portal/components/DownloadEditorModal.stories.tsx @@ -0,0 +1,14 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { DownloadEditorModal } from "@portal/components/DownloadEditorModal"; + +const meta: Meta = { + title: "Portal/DownloadEditorModal", + component: DownloadEditorModal, + parameters: { layout: "fullscreen" }, + args: { open: true, onClose: () => {} }, +}; +export default meta; +type Story = StoryObj; + +/** Landing list of desktop + self-hosted install options. */ +export const Open: Story = {}; diff --git a/frontend/editor/src/portal/components/DownloadEditorModal.tsx b/frontend/editor/src/portal/components/DownloadEditorModal.tsx index 3a4c9bb29d..602e3ca530 100644 --- a/frontend/editor/src/portal/components/DownloadEditorModal.tsx +++ b/frontend/editor/src/portal/components/DownloadEditorModal.tsx @@ -255,7 +255,7 @@ export function DownloadEditorModal({ open, onClose }: Props) { variant="secondary" leftSection={} onClick={() => { - window.location.href = EDITOR_URL; + window.open(EDITOR_URL, "_blank", "noopener,noreferrer"); }} > {t("portal.home.download.openInBrowser")} diff --git a/frontend/editor/src/portal/components/EditorStatusCard.css b/frontend/editor/src/portal/components/EditorStatusCard.css index 83426bae91..e71a50bac3 100644 --- a/frontend/editor/src/portal/components/EditorStatusCard.css +++ b/frontend/editor/src/portal/components/EditorStatusCard.css @@ -4,8 +4,8 @@ .portal-editor-hero { border-radius: var(--radius-xl); - border: 1px solid var(--color-border-input); - background: var(--color-surface); + border: 1px solid var(--c-border); + background: var(--c-surface); box-shadow: var(--shadow-sm); overflow: hidden; } @@ -15,7 +15,7 @@ align-items: center; gap: 1.25rem; padding: 1rem 1.25rem; - background: #16213e; + background: color-mix(in srgb, var(--c-primary) 22%, var(--c-hero-dark)); } .portal-editor-hero__logo { @@ -123,7 +123,7 @@ .portal-editor-hero__action .portal-editor-hero__cta.sui-btn { background: #ffffff; border-color: #ffffff; - color: #16213e; + color: var(--c-hero-dark-cta-text); } .portal-editor-hero__action .portal-editor-hero__cta.sui-btn:hover { background: rgba(255, 255, 255, 0.88); @@ -141,5 +141,5 @@ /* Attached footer strip (setup checklist). */ .portal-editor-hero__footer { - border-top: 1px solid var(--color-border-light); + border-top: 1px solid var(--c-border-subtle); } diff --git a/frontend/editor/src/portal/components/EditorStatusCard.tsx b/frontend/editor/src/portal/components/EditorStatusCard.tsx index 8d3b7dcbcf..9726cc1009 100644 --- a/frontend/editor/src/portal/components/EditorStatusCard.tsx +++ b/frontend/editor/src/portal/components/EditorStatusCard.tsx @@ -4,12 +4,8 @@ import { useTranslation } from "react-i18next"; import { Button, Skeleton } from "@app/ui"; import { useTier } from "@portal/contexts/TierContext"; import { useView } from "@portal/contexts/ViewContext"; -import { useAsync } from "@portal/hooks/useAsync"; -import { - fetchEditorDeployment, - type EditorDeploymentResponse, - type EditorInstance, -} from "@portal/api/editorDeploy"; +import { useEditorDeployment } from "@portal/queries/infrastructure"; +import { type EditorInstance } from "@portal/api/editorDeploy"; import { DownloadIcon, ExternalLinkIcon, @@ -28,7 +24,7 @@ function StirlingMark() { fill="none" aria-hidden > - + ( - () => fetchEditorDeployment(tier), - [tier], - ); + const { data, loading } = useEditorDeployment(tier); const view = useMemo(() => { if (!data) return null; diff --git a/frontend/editor/src/portal/components/ErrorBoundary.stories.tsx b/frontend/editor/src/portal/components/ErrorBoundary.stories.tsx new file mode 100644 index 0000000000..a680039046 --- /dev/null +++ b/frontend/editor/src/portal/components/ErrorBoundary.stories.tsx @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ErrorBoundary } from "@portal/components/ErrorBoundary"; + +function Boom(): never { + throw new Error("kaboom"); +} + +const meta = { + title: "Portal/ErrorBoundary", + component: ErrorBoundary, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** No error: children render straight through. */ +export const Default: Story = { + args: { + children:
    Everything is fine.
    , + }, +}; + +/** A child throws during render; the boundary contains it with the default + * fallback card instead of taking down the rest of the page. */ +export const CaughtError: Story = { + args: { + children: , + }, +}; + +/** A custom fallback receives the boundary's reset function so it can offer + * its own retry affordance. */ +export const CustomFallback: Story = { + args: { + children: , + fallback: (reset) => ( +
    +

    Custom error UI.

    + +
    + ), + }, +}; diff --git a/frontend/editor/src/portal/components/HomeGreeting.stories.tsx b/frontend/editor/src/portal/components/HomeGreeting.stories.tsx new file mode 100644 index 0000000000..e2cca6dbfb --- /dev/null +++ b/frontend/editor/src/portal/components/HomeGreeting.stories.tsx @@ -0,0 +1,12 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { HomeGreeting } from "@portal/components/HomeGreeting"; + +const meta = { + title: "Portal/Home/HomeGreeting", + component: HomeGreeting, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** Time-of-day greeting + today's date, shown above the paid-tier home hero. */ +export const Default: Story = {}; diff --git a/frontend/editor/src/portal/components/HomeHero.stories.tsx b/frontend/editor/src/portal/components/HomeHero.stories.tsx new file mode 100644 index 0000000000..dd1ea07c04 --- /dev/null +++ b/frontend/editor/src/portal/components/HomeHero.stories.tsx @@ -0,0 +1,32 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { HomeHero } from "@portal/components/HomeHero"; + +const meta = { + title: "Portal/Home/HomeHero", + component: HomeHero, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
    + +
    + ), + ], +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** Pay-as-you-go tier: welcome header + setup checklist until onboarding completes. */ +export const Default: Story = { + args: { tier: "pro" }, +}; + +/** Free tier renders the same welcome-header composition as pro. */ +export const FreeTier: Story = { + args: { tier: "free" }, +}; + +/** Enterprise tier hides the status chips — the procurement deal hero owns the invite step. */ +export const EnterpriseTier: Story = { + args: { tier: "enterprise" }, +}; diff --git a/frontend/editor/src/portal/components/LinkAccountFooterItem.stories.tsx b/frontend/editor/src/portal/components/LinkAccountFooterItem.stories.tsx new file mode 100644 index 0000000000..b65041164a --- /dev/null +++ b/frontend/editor/src/portal/components/LinkAccountFooterItem.stories.tsx @@ -0,0 +1,21 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { LinkAccountFooterItem } from "@portal/components/LinkAccountFooterItem"; + +const meta: Meta = { + title: "Portal/LinkAccountFooterItem", + component: LinkAccountFooterItem, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +/** Unlinked org — the "Link Stirling account" CTA appears in the sidebar footer. */ +export const Unlinked: Story = { + globals: { linkState: "unlinked" }, +}; + +/** Linked org — the CTA hides itself (renders nothing), since the state is + * already communicated elsewhere. */ +export const Linked: Story = { + globals: { linkState: "linked-subscribed" }, +}; diff --git a/frontend/editor/src/portal/components/LoginScreen.stories.tsx b/frontend/editor/src/portal/components/LoginScreen.stories.tsx new file mode 100644 index 0000000000..63db811b71 --- /dev/null +++ b/frontend/editor/src/portal/components/LoginScreen.stories.tsx @@ -0,0 +1,12 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { LoginScreen } from "@portal/components/LoginScreen"; + +const meta: Meta = { + title: "Portal/LoginScreen", + component: LoginScreen, + parameters: { layout: "fullscreen" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/editor/src/portal/components/LoginScreen.tsx b/frontend/editor/src/portal/components/LoginScreen.tsx index f16553d56d..ac69040c19 100644 --- a/frontend/editor/src/portal/components/LoginScreen.tsx +++ b/frontend/editor/src/portal/components/LoginScreen.tsx @@ -1,8 +1,4 @@ -import { useMemo } from "react"; -import { useTranslation } from "react-i18next"; import { AuthShell } from "@app/auth/ui/AuthShell"; -import LoginRightCarousel from "@app/auth/ui/LoginRightCarousel"; -import { buildDefaultLoginSlides } from "@app/auth/ui/loginSlides"; import SpringLoginForm from "@app/auth/ui/SpringLoginForm"; import { useSpringLogin } from "@app/auth/ui/useSpringLogin"; import { withBasePath } from "@app/constants/app"; @@ -12,7 +8,7 @@ import loginHeader from "@app/assets/brand/modern-logo/LoginLightModeHeader.svg" /** * Full-screen login shown by the portal's auth gate. Renders the shared - * AuthShell + carousel with the Spring form/auth wiring from @app/auth/ui. + * AuthShell with the Spring form/auth wiring from @app/auth/ui. * * It follows the user's light/dark theme (AuthShell is theme-aware — the same * screen the editor login uses; passing both logo variants keeps the header @@ -20,23 +16,10 @@ import loginHeader from "@app/assets/brand/modern-logo/LoginLightModeHeader.svg" * needs to collect credentials. */ export function LoginScreen() { - const { t } = useTranslation(); const login = useSpringLogin(); - const slides = useMemo( - () => buildDefaultLoginSlides((key, fallback) => t(key, fallback)), - [t], - ); return ( - - } - > + = { - title: "Portal/Home/PolicySummary", - component: PolicySummary, - parameters: { layout: "padded" }, - decorators: [ - (S) => ( -
    - -
    - ), - ], -}; -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Loading: Story = { - parameters: { - msw: { - handlers: [ - http.get("/api/v1/policies", async () => { - await delay("infinite"); - return HttpResponse.json({}); - }), - ], - }, - }, -}; - -export const Empty: Story = { - parameters: { - msw: { - handlers: [ - http.get("/api/v1/policies", () => - HttpResponse.json({ - summary: { active: 0, paused: 0, categories: 0, docsEnforced: 0 }, - catalogue: [], - }), - ), - ], - }, - }, -}; diff --git a/frontend/editor/src/portal/components/PolicySummary.tsx b/frontend/editor/src/portal/components/PolicySummary.tsx deleted file mode 100644 index cffb822387..0000000000 --- a/frontend/editor/src/portal/components/PolicySummary.tsx +++ /dev/null @@ -1,185 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { - Button, - Card, - EmptyState, - Skeleton, - StatusBadge, - Table, - type TableColumn, -} from "@app/ui"; -import { useView } from "@portal/contexts/ViewContext"; -import { useAsync, useSectionFlags } from "@portal/hooks/useAsync"; -import { - fetchPolicies, - type CatalogueEntry, - type PoliciesResponse, -} from "@portal/api/policies"; -import { policyIcon } from "@portal/components/policies/policyIcons"; -import "@portal/components/PolicySummary.css"; - -/** - * Each row's display state collapses a category's facts into one of three - * mutually-exclusive shapes: - * - * - `locked` — a coming-soon category; show a "Soon" affordance - * - `active` — a configured policy that's enabled; offer "Configure" - * - `off` — available but not set up (or paused); offer "Set up" - */ -type RowState = "locked" | "active" | "off"; - -interface PolicyRow { - entry: CatalogueEntry; - state: RowState; -} - -const STATE_BADGE: Record< - RowState, - { tone: "success" | "neutral" | "info"; labelKey: string } -> = { - active: { tone: "success", labelKey: "portal.policySummary.state.active" }, - off: { tone: "neutral", labelKey: "portal.policySummary.state.off" }, - locked: { tone: "info", labelKey: "portal.policySummary.state.soon" }, -}; - -function toRow(entry: CatalogueEntry): PolicyRow { - if (entry.category.comingSoon) return { entry, state: "locked" }; - const active = entry.policy?.state.status === "active"; - return { entry, state: active ? "active" : "off" }; -} - -export function PolicySummary() { - const { t } = useTranslation(); - const { setActiveView } = useView(); - const state = useAsync(() => fetchPolicies(), []); - const { data } = state; - const { isLoading, isEmpty } = useSectionFlags(state); - - const goToPolicies = () => setActiveView("policies"); - - const columns: TableColumn[] = [ - { - key: "category", - header: t("portal.policySummary.column.policy"), - render: ({ entry }) => ( -
    - - {policyIcon(entry.category.icon)} - -
    - {t(entry.category.label)} - {t(entry.category.desc)} -
    -
    - ), - }, - { - key: "status", - header: t("portal.policySummary.column.status"), - width: "7rem", - render: ({ state }) => { - const badge = STATE_BADGE[state]; - return ( - - {t(badge.labelKey)} - - ); - }, - }, - { - key: "rule", - header: t("portal.policySummary.column.activeRule"), - render: ({ entry, state }) => ( - - {state === "active" - ? t(entry.config.summary) - : t("portal.policySummary.noRule")} - - ), - }, - { - key: "action", - header: "", - align: "right", - width: "9rem", - render: ({ state }) => { - if (state === "locked") { - return ( - - ); - } - return ( - - ); - }, - }, - ]; - - const rows: PolicyRow[] = data?.catalogue?.map(toRow) ?? []; - - return ( -
    - -
    -
    -

    - {t("portal.policySummary.title")} -

    -

    - {t("portal.policySummary.subtitle")} -

    -
    - {data?.summary && ( - - {t("portal.policySummary.activeSummary", { - active: data.summary.active, - total: data.summary.categories, - })} - - )} -
    - - {isLoading && ( -
    - {Array.from({ length: 5 }).map((_, i) => ( -
    - - -
    - ))} -
    - )} - - {isEmpty && ( - - )} - - {data && rows.length > 0 && ( - r.entry.category.id} - onRowClick={goToPolicies} - /> - )} - - - ); -} diff --git a/frontend/editor/src/portal/components/PortalChrome.stories.tsx b/frontend/editor/src/portal/components/PortalChrome.stories.tsx new file mode 100644 index 0000000000..b491fee325 --- /dev/null +++ b/frontend/editor/src/portal/components/PortalChrome.stories.tsx @@ -0,0 +1,12 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { PortalChrome } from "@portal/components/PortalChrome"; + +const meta = { + title: "Portal/Shell/PortalChrome", + component: PortalChrome, + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/editor/src/portal/components/ProcessingStatusStrip.css b/frontend/editor/src/portal/components/ProcessingStatusStrip.css deleted file mode 100644 index 63341798f6..0000000000 --- a/frontend/editor/src/portal/components/ProcessingStatusStrip.css +++ /dev/null @@ -1,43 +0,0 @@ -/* Single inline row: plan + real processed-PDF volume + manage-plan shortcut. */ -.portal-statusstrip--paid { - display: flex; - align-items: center; - gap: 0.625rem; - padding: 0.625rem 0.875rem; - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius-md); -} - -.portal-statusstrip__plan { - display: inline-flex; - align-items: center; - gap: 0.4375rem; - font-size: 0.8125rem; - font-weight: 600; - color: var(--color-text-1); -} - -.portal-statusstrip__dot { - width: 0.5rem; - height: 0.5rem; - border-radius: 50%; -} - -.portal-statusstrip__sep { - color: var(--color-text-5); -} - -.portal-statusstrip__volume { - font-size: 0.8125rem; - color: var(--color-text-3); -} - -.portal-statusstrip__volume strong { - color: var(--color-text-1); - font-weight: 600; -} - -.portal-statusstrip__manage { - margin-left: auto; -} diff --git a/frontend/editor/src/portal/components/ProcessingStatusStrip.stories.tsx b/frontend/editor/src/portal/components/ProcessingStatusStrip.stories.tsx deleted file mode 100644 index c5df1139d5..0000000000 --- a/frontend/editor/src/portal/components/ProcessingStatusStrip.stories.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { http, HttpResponse, delay } from "msw"; -import { ProcessingStatusStrip } from "@portal/components/ProcessingStatusStrip"; - -const meta: Meta = { - title: "Portal/Home/ProcessingStatusStrip", - component: ProcessingStatusStrip, - parameters: { layout: "padded" }, - decorators: [ - (S) => ( -
    - -
    - ), - ], -}; -export default meta; -type Story = StoryObj; - -/** Switch the Tier toolbar to compare the free meter vs the paid plan row. */ -export const Default: Story = {}; - -/** Free tier pushed near its cap so the meter and upgrade nudge turn amber. */ -export const FreeNearCap: Story = { - globals: { tier: "free" }, - parameters: { - msw: { - handlers: [ - http.get("/v1/home/kpis", async () => { - await delay(80); - return HttpResponse.json([{ value: "472 / 500" }]); - }), - ], - }, - }, -}; diff --git a/frontend/editor/src/portal/components/ProcessingStatusStrip.tsx b/frontend/editor/src/portal/components/ProcessingStatusStrip.tsx deleted file mode 100644 index af198d488f..0000000000 --- a/frontend/editor/src/portal/components/ProcessingStatusStrip.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { Button, Skeleton } from "@app/ui"; -import { TIER_INFO, useTier } from "@portal/contexts/TierContext"; -import { useView } from "@portal/contexts/ViewContext"; -import { useAsync } from "@portal/hooks/useAsync"; -import { fetchFleetStats, type FleetStats } from "@portal/api/fleetStats"; -import "@portal/components/ProcessingStatusStrip.css"; - -/** - * A thin one-line header above the home content: current plan + the real 30-day - * processed-PDF volume (from the fleet-usage endpoint), with a shortcut to the - * Usage page. Renders "—" while loading or when the backend can't compute the - * figure (e.g. EE auditing disabled) — never a fabricated number. - */ -export function ProcessingStatusStrip() { - const { t } = useTranslation(); - const { tier } = useTier(); - const { setActiveView } = useView(); - const { data, loading } = useAsync(() => fetchFleetStats(), []); - - return ( -
    - - - {TIER_INFO[tier].label} - - - · - - - {loading ? ( - - ) : ( - {data?.pdfsProcessed?.toLocaleString() ?? "—"} - )}{" "} - {t("portal.processingStatus.volumeSuffix")} - - -
    - ); -} diff --git a/frontend/editor/src/portal/components/ProcessorFlow.css b/frontend/editor/src/portal/components/ProcessorFlow.css new file mode 100644 index 0000000000..16acbf91af --- /dev/null +++ b/frontend/editor/src/portal/components/ProcessorFlow.css @@ -0,0 +1,393 @@ +/* Processor-flow visualiser: SVG overlay (bézier wires + rAF particle layer) + measured from the HTML cards. See ProcessorFlow.tsx. */ + +.portal-pf { + --pf-accent: var(--color-green); +} + +/* ── Header ──────────────────────────────────────────────────────────────── */ +.portal-pf__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 1rem; +} + +.portal-pf__head-text { + display: flex; + align-items: baseline; + gap: 0.5rem; + min-width: 0; +} + +.portal-pf__head-actions { + display: flex; + align-items: center; + gap: 0.625rem; + flex: none; +} + +.portal-pf__live { + align-self: center; + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + background: var(--c-border); +} + +.portal-pf__live--on { + background: var(--pf-accent); + box-shadow: 0 0 0 0 color-mix(in srgb, var(--pf-accent) 55%, transparent); + animation: pf-pulse 2.4s ease-out infinite; +} + +.portal-pf__title { + margin: 0; + font-size: 0.9375rem; + font-weight: 600; + color: var(--c-text); +} + +.portal-pf__connected { + font-size: 0.75rem; + color: var(--c-text-subtle); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ── Stage: relative box the SVG overlays are measured against ───────────── */ +.portal-pf__stage { + position: relative; + padding: 0.25rem 0 0.5rem; +} + +.portal-pf__wires, +.portal-pf__particles { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + pointer-events: none; + overflow: visible; +} + +.portal-pf__wires { + z-index: 0; +} + +.portal-pf__particles { + z-index: 2; +} + +.portal-pf__wire-path { + fill: none; + stroke: var(--c-border-subtle); + stroke-width: 1.25; +} + +/* ── Columns ─────────────────────────────────────────────────────────────── */ +.portal-pf__cols { + position: relative; + z-index: 1; + display: flex; + justify-content: space-between; + align-items: stretch; + gap: 1rem; +} + +.portal-pf__col { + display: flex; + flex-direction: column; + justify-content: center; + gap: 0.5rem; + flex: none; + width: 15rem; +} + +.portal-pf__col-head, +.portal-pf__policies-head { + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--c-text-subtle); + margin-bottom: 0.125rem; +} + +/* ── Nodes (source + outcome cards) ─────────────────────────────────────────── */ +.portal-pf__node { + height: auto; + width: 100%; + text-align: left; + background: var(--c-bg); + border: 1px solid var(--c-border-subtle); + border-radius: var(--radius-md); + transition: + background var(--motion-fast), + border-color var(--motion-fast); +} + +.portal-pf__node .mantine-Button-label { + flex: 1 1 auto; + justify-content: flex-start; + overflow: visible; +} + +.portal-pf__node:hover { + background: var(--c-hover); + border-color: var(--c-border); +} + +.portal-pf__node:focus-visible { + outline: 2px solid var(--c-primary); + outline-offset: 2px; +} + +.portal-pf__node--soon { + background: transparent; + border-style: dashed; + opacity: 0.75; +} + +.portal-pf__node-icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex: none; + width: 1.75rem; + height: 1.75rem; + border-radius: var(--radius-md); + font-size: 0.9375rem; + background: var(--c-surface-sunken); + color: var(--c-text-subtle); +} + +.portal-pf__node--soon .portal-pf__node-icon { + color: var(--c-text-subtle); + font-weight: 600; +} + +.portal-pf__node--success .portal-pf__node-icon { + background: color-mix(in srgb, var(--color-green) 14%, transparent); + color: var(--color-green-dark); +} + +.portal-pf__node--failed .portal-pf__node-icon { + background: color-mix(in srgb, var(--color-red) 14%, transparent); + color: var(--color-red-dark); +} + +.portal-pf__node-text { + display: flex; + flex-direction: column; + min-width: 0; +} + +.portal-pf__node-text strong { + font-size: 0.8125rem; + font-weight: 600; + color: var(--c-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.portal-pf__node-text span { + font-size: 0.6875rem; + color: var(--c-text-subtle); +} + +/* ── Policies card (core) ───────────────────────────────────────────────────── */ +.portal-pf__policies { + align-self: center; + flex: none; + width: 18rem; + padding: 0.75rem; + background: var(--c-bg); + border: 1px solid var(--c-border-subtle); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); +} + +.portal-pf__policies-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 0.25rem 0.5rem; +} + +.portal-pf__policies-active { + text-transform: none; + letter-spacing: 0; + color: var(--color-green-dark); +} + +.portal-pf__policy { + padding: 0.5rem 0.25rem; +} + +.portal-pf__policy + .portal-pf__policy { + border-top: 1px solid var(--c-border-subtle); +} + +.portal-pf__policy-line { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.portal-pf__policy-icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex: none; + width: 1.5rem; + height: 1.5rem; + border-radius: 6px; + font-size: 0.875rem; + color: var(--c-text-subtle); + transition: + color 0.3s ease, + background-color 0.3s ease, + box-shadow 0.3s ease; +} + +.portal-pf__policy--active .portal-pf__policy-icon { + color: var(--pf-accent); +} + +.portal-pf__policy--locked .portal-pf__policy-icon { + color: var(--c-text-subtle); +} + +/* Leading-LED blink: added for 150ms as a particle threads this row's lane, + then eased back out by the transition above. */ +.portal-pf__policy-icon.is-pulse { + color: var(--color-green-dark); + background-color: color-mix(in srgb, var(--color-green) 20%, transparent); + box-shadow: 0 0 8px 1px + color-mix(in srgb, var(--color-green) 55%, transparent); +} + +.portal-pf__policy-label { + flex: 1 1 auto; + font-size: 0.8125rem; + font-weight: 600; + color: var(--c-text-muted); + min-width: 0; +} + +.portal-pf__policy--active .portal-pf__policy-label { + color: var(--c-text); +} + +.portal-pf__policy--locked .portal-pf__policy-label { + color: var(--c-text-subtle); + font-weight: 500; +} + +.portal-pf__policy-count { + font-size: 0.75rem; + color: var(--c-text-subtle); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.portal-pf__policy-soon { + flex: none; + font-size: 0.6875rem; + font-weight: 600; + color: var(--c-text-subtle); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +/* Vertical padding via the `py` prop; height auto so it grows with it. */ +.portal-pf__setup { + flex: none; + height: auto; +} + +/* ── Footnote / loading ─────────────────────────────────────────────────────── */ +.portal-pf__foot { + margin: 0.875rem 0 0; + font-size: 0.6875rem; + color: var(--c-text-subtle); +} + +.portal-pf__loading { + padding: 0.5rem 0; +} + +/* ── Sankey lens ────────────────────────────────────────────────────────────── */ +.portal-pf__sankey { + max-width: 46rem; + margin: 0.5rem auto 0; +} + +.portal-pf__sankey svg { + display: block; + width: 100%; + height: auto; + overflow: visible; +} + +.portal-pf__sankey-label { + font-size: 12px; + font-weight: 600; + fill: var(--c-text-muted); + font-variant-numeric: tabular-nums; +} + +.portal-pf__sankey-caption { + font-size: 10.5px; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; + fill: var(--c-text-subtle); +} + +.portal-pf__sankey-empty { + display: flex; + align-items: center; + justify-content: center; + min-height: 11rem; +} + +@keyframes pf-pulse { + 0% { + box-shadow: 0 0 0 0 color-mix(in srgb, var(--pf-accent) 55%, transparent); + } + 70% { + box-shadow: 0 0 0 0.4rem transparent; + } + 100% { + box-shadow: 0 0 0 0 transparent; + } +} + +/* ── Responsive: stack the columns; the measured-geometry flow overlay only + makes sense on the wide 3-column layout, so drop it below the breakpoint. ── */ +@media (max-width: 60rem) { + .portal-pf__wires, + .portal-pf__particles { + display: none; + } + .portal-pf__cols { + flex-direction: column; + gap: 0.75rem; + } + .portal-pf__col, + .portal-pf__policies { + width: 100%; + align-self: stretch; + } +} + +@media (prefers-reduced-motion: reduce) { + .portal-pf__live--on { + animation: none; + } +} diff --git a/frontend/editor/src/portal/components/ProcessorFlow.stories.tsx b/frontend/editor/src/portal/components/ProcessorFlow.stories.tsx new file mode 100644 index 0000000000..0622b31e55 --- /dev/null +++ b/frontend/editor/src/portal/components/ProcessorFlow.stories.tsx @@ -0,0 +1,179 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { http, HttpResponse } from "msw"; +import { ProcessorFlow } from "@portal/components/ProcessorFlow"; +import type { + FlowOutcome, + FlowPolicy, + ProcessorFlow as ProcessorFlowModel, +} from "@portal/api/processorFlow"; + +/** Home processor visualiser, backed by the global portal MSW handlers. + * Particles animate via rAF (paused for hidden tabs — view in a focused tab). */ +const meta: Meta = { + title: "Portal/Components/ProcessorFlow", + component: ProcessorFlow, + parameters: { layout: "padded" }, +}; +export default meta; + +type Story = StoryObj; + +/** Live machine: Security configured + real throughput → the flow runs. */ +export const Default: Story = {}; + +/** Nothing set up and no activity — the empty state. The flow stays still here + * in production (DEV_KEEP_FLOWING can force it on while iterating). */ +export const IdleEmpty: Story = { + parameters: { + msw: { + handlers: [ + http.get("/api/v1/sources", () => + HttpResponse.json({ + kpis: [], + sources: [ + { + id: "editor", + name: "Editor", + type: "editor", + status: "active", + referenceCount: 0, + referencingPolicies: [], + config: [], + docsTotal: 0, + docs24h: 0, + docs30d: 0, + }, + ], + }), + ), + http.get("/api/v1/policies", () => HttpResponse.json([])), + http.get("/api/v1/policies/runs", () => HttpResponse.json([])), + ], + }, + }, +}; + +/* ── Playground ───────────────────────────────────────────────────────────── */ + +interface PlaygroundArgs { + /** Editor input volume (docs / 24h). */ + editorRate: number; + /** "Claims intake" input volume (docs / 24h). */ + claimsRate: number; + /** "Contracts drop" input volume (docs / 24h). */ + contractsRate: number; + /** Delivered (success) outcomes over 24h. */ + delivered: number; + /** Failed outcomes over 24h — drives the red-dot ratio. */ + failed: number; + /** Whether the Classification policy is active (a second particle lane). */ + classificationActive: boolean; +} + +const CATEGORY_LABEL = (id: string) => `portal.policies.categories.${id}.label`; + +/** Build a flow model from the playground controls. */ +function buildModel(a: PlaygroundArgs): ProcessorFlowModel { + const sources = [ + { id: "editor", name: "Editor", type: "editor", docs24h: a.editorRate }, + { + id: "claims", + name: "Claims intake", + type: "folder", + docs24h: a.claimsRate, + }, + { + id: "contracts", + name: "Contracts drop", + type: "folder", + docs24h: a.contractsRate, + }, + ]; + const policies: FlowPolicy[] = [ + { + key: "ingestion", + labelKey: CATEGORY_LABEL("ingestion"), + state: "locked", + configured: false, + runs24h: 0, + }, + { + key: "security", + labelKey: CATEGORY_LABEL("security"), + state: "active", + configured: true, + runs24h: Math.round(a.delivered * 0.6), + }, + { + key: "classification", + labelKey: CATEGORY_LABEL("classification"), + state: a.classificationActive ? "active" : "off", + configured: a.classificationActive, + runs24h: a.classificationActive ? Math.round(a.delivered * 0.4) : 0, + }, + { + key: "compliance", + labelKey: CATEGORY_LABEL("compliance"), + state: "locked", + configured: false, + runs24h: 0, + }, + { + key: "routing", + labelKey: CATEGORY_LABEL("routing"), + state: "locked", + configured: false, + runs24h: 0, + }, + { + key: "retention", + labelKey: CATEGORY_LABEL("retention"), + state: "locked", + configured: false, + runs24h: 0, + }, + ]; + const outcomes: FlowOutcome[] = [ + { + key: "success", + labelKey: "portal.processorFlow.outcomes.success", + count24h: a.delivered, + }, + { + key: "failed", + labelKey: "portal.processorFlow.outcomes.failed", + count24h: a.failed, + }, + ]; + const comingSoonSources = [ + { + key: "apiMcp", + labelKey: "portal.processorFlow.sources.comingSoon.apiMcp", + }, + { key: "cloud", labelKey: "portal.processorFlow.sources.comingSoon.cloud" }, + { key: "email", labelKey: "portal.processorFlow.sources.comingSoon.email" }, + ]; + return { sources, comingSoonSources, policies, outcomes }; +} + +/** Tune each input rate and the delivered/failed split live to watch emission + * speed, per-source scaling, the 250ms ceiling, and the ratio (focused tab). */ +export const Playground: StoryObj = { + args: { + editorRate: 400, + claimsRate: 800, + contractsRate: 150, + delivered: 90, + failed: 10, + classificationActive: true, + }, + argTypes: { + editorRate: { control: { type: "range", min: 0, max: 2000, step: 10 } }, + claimsRate: { control: { type: "range", min: 0, max: 2000, step: 10 } }, + contractsRate: { control: { type: "range", min: 0, max: 2000, step: 10 } }, + delivered: { control: { type: "number", min: 0 } }, + failed: { control: { type: "number", min: 0 } }, + classificationActive: { control: "boolean" }, + }, + render: (args) => , +}; diff --git a/frontend/editor/src/portal/components/ProcessorFlow.tsx b/frontend/editor/src/portal/components/ProcessorFlow.tsx new file mode 100644 index 0000000000..547ec21ca7 --- /dev/null +++ b/frontend/editor/src/portal/components/ProcessorFlow.tsx @@ -0,0 +1,177 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { Card, SegmentedControl, Skeleton, StatusBadge } from "@app/ui"; +import { + useView, + VIEW_PATHS, + toPortalPath, +} from "@portal/contexts/ViewContext"; +import { useProcessorFlow } from "@portal/queries/processorFlow"; +import { type ProcessorFlow as ProcessorFlowModel } from "@portal/api/processorFlow"; +import { + DEV_KEEP_FLOWING, + DEV_SYNTH_RATE, + type Lens, +} from "@portal/components/processor-flow/flowTypes"; +import { useFlowGeometry } from "@portal/components/processor-flow/useFlowGeometry"; +import { useFlowParticles } from "@portal/components/processor-flow/useFlowParticles"; +import { FlowSources } from "@portal/components/processor-flow/FlowSources"; +import { FlowPolicies } from "@portal/components/processor-flow/FlowPolicies"; +import { FlowOutcomes } from "@portal/components/processor-flow/FlowOutcomes"; +import { FlowSankey } from "@portal/components/processor-flow/FlowSankey"; +import "@portal/components/ProcessorFlow.css"; + +/** Home processor visualiser: sources → policies → outcomes, as a live particle + * flow or a Sankey. Data + gating here; moving parts live under `processor-flow/`. */ +interface ProcessorFlowProps { + /** Testing seam: render this model directly instead of fetching. Never set in + * the app — used by the Playground story to drive rates/counts from controls. */ + dataOverride?: ProcessorFlowModel; +} + +export function ProcessorFlow({ dataOverride }: ProcessorFlowProps = {}) { + const { t } = useTranslation(); + const { setActiveView } = useView(); + const navigate = useNavigate(); + const fetched = useProcessorFlow(); + const data = dataOverride ?? fetched.data; + const loading = dataOverride ? false : fetched.loading; + + const [lens, setLens] = useState("flow"); + const isLoading = loading && data === null; + + /** Deep-link to the Policies page and auto-open that policy's setup wizard. */ + const openPolicySetup = (key: string) => + navigate( + `${toPortalPath(VIEW_PATHS.policies)}?setup=${encodeURIComponent(key)}`, + ); + + /** Deep-link to Infrastructure with the audit-log tab open. */ + const openAuditLog = () => + navigate(`${toPortalPath(VIEW_PATHS.infrastructure)}?tab=audit`); + + const sources = data?.sources ?? []; + const policies = data?.policies ?? []; + const outcomes = data?.outcomes ?? []; + const comingSoonSources = data?.comingSoonSources ?? []; + + // ── Flow gating: run only when something is set up AND there's activity. + const totalRate = sources.reduce((sum, s) => sum + s.docs24h, 0); + const hasConfigured = policies.some((p) => p.configured); + const liveFlow = hasConfigured && totalRate > 0; + // When forcing for dev with no live flow, synthesise rates + thread every row. + const devForced = DEV_KEEP_FLOWING && !liveFlow; + const animate = liveFlow || devForced; + + // Particles only thread configured (active) policies; while dev-forcing with + // no live flow, thread the available (non-locked) rows so the demo has lanes. + const laneKeys = policies + .filter((p) => (devForced ? p.state !== "locked" : p.state === "active")) + .map((p) => p.key); + + const activeCount = policies.filter((p) => p.state === "active").length; + const pdfsProcessed = outcomes.reduce((sum, o) => sum + o.count24h, 0); + const statsLabel = t("portal.processorFlow.stats", { + connected: sources.length, + processed: pdfsProcessed.toLocaleString(), + }); + + // Per-source rates + outcome weights feeding the particle loop. + const rates = sources.map((s) => (devForced ? DEV_SYNTH_RATE : s.docs24h)); + const weights = (() => { + const raw = outcomes.map((o) => o.count24h); + const sum = raw.reduce((a, b) => a + b, 0); + if (sum > 0) return raw.map((v) => v / sum); + // No real outcomes yet (dev flow): success-heavy default. + return outcomes.map((o) => (o.key === "failed" ? 0.15 : 0.85)); + })(); + const outcomeKeys = outcomes.map((o) => o.key); + + const { wrapRef, srcRefs, outRefs, coreRef, laneRefs, geoRef, wires } = + useFlowGeometry(); + const pGroupRef = useFlowParticles({ + geoRef, + animate, + lens, + rates, + weights, + laneKeys, + outcomeKeys, + }); + + return ( + +
    +
    + +

    + {t("portal.processorFlow.title")} +

    + {statsLabel} +
    +
    + + {t("portal.processorFlow.liveBadge")} + + + size="xs" + value={lens} + onChange={setLens} + ariaLabel={t("portal.processorFlow.lens.ariaLabel")} + options={[ + { label: t("portal.processorFlow.lens.flow"), value: "flow" }, + { label: t("portal.processorFlow.lens.sankey"), value: "sankey" }, + ]} + /> +
    +
    + + {isLoading ? ( +
    + +
    + ) : lens === "sankey" ? ( + + ) : ( +
    + + {wires} + + +
    + setActiveView("sources")} + /> + + +
    + + + + +
    + )} + +

    {t("portal.processorFlow.footnote")}

    +
    + ); +} diff --git a/frontend/editor/src/portal/components/RecentActivity.css b/frontend/editor/src/portal/components/RecentActivity.css deleted file mode 100644 index 9fceadfe84..0000000000 --- a/frontend/editor/src/portal/components/RecentActivity.css +++ /dev/null @@ -1,138 +0,0 @@ -.portal-activity { - display: flex; - flex-direction: column; -} - -.portal-activity__head { - display: flex; - align-items: center; - justify-content: space-between; - padding: 0.875rem 1rem; - border-bottom: 1px solid var(--color-border-light); -} - -.portal-activity__title { - margin: 0; - font-size: 0.9375rem; - font-weight: 600; - color: var(--color-text-1); -} - -.portal-activity__more { - font-size: 0.75rem; - font-weight: 500; - color: var(--color-blue); - padding: 0.25rem 0.5rem; - border-radius: var(--radius-sm); - transition: background var(--motion-fast); -} - -.portal-activity__more:hover { - background: var(--color-bg-hover); -} - -.portal-activity__list { - list-style: none; - margin: 0; - padding: 0; -} - -.portal-activity__item { - display: flex; - gap: 0.75rem; - padding: 0.75rem 1rem; - border-bottom: 1px solid var(--color-border-light); - transition: background var(--motion-fast); -} - -.portal-activity__item:last-child { - border-bottom: none; -} - -.portal-activity__item:hover { - background: var(--color-bg-hover); -} - -.portal-activity__rail { - width: 0.1875rem; - border-radius: var(--radius-pill); - flex-shrink: 0; -} - -.portal-activity__body { - flex: 1 1 auto; - min-width: 0; -} - -.portal-activity__row { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 0.5rem; -} - -.portal-activity__action { - font-size: 0.8125rem; - font-weight: 600; - color: var(--color-text-1); -} - -.portal-activity__time { - font-size: 0.6875rem; - color: var(--color-text-5); -} - -.portal-activity__subject { - font-size: 0.75rem; - color: var(--color-text-3); - margin-top: 0.0625rem; -} - -.portal-activity__detail-row { - margin-top: 0.375rem; - display: flex; - align-items: center; - justify-content: space-between; - gap: 0.5rem; -} - -.portal-activity__detail { - font-size: 0.75rem; - color: var(--color-text-4); - font-family: var(--font-mono); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - min-width: 0; -} - -.portal-activity__item--skeleton { - cursor: default; -} - -.portal-activity__item--skeleton .portal-activity__rail { - background: var(--color-border-light); -} - -.portal-activity__skel { - height: 0.75rem; - border-radius: var(--radius-pill); - background: linear-gradient( - 90deg, - var(--color-bg-muted) 0%, - var(--color-bg-hover) 50%, - var(--color-bg-muted) 100% - ); - background-size: 200% 100%; - animation: shimmer 1.4s linear infinite; - margin-bottom: 0.375rem; -} - -.portal-activity__skel--md { - width: 70%; -} - -.portal-activity__skel--sm { - width: 50%; - height: 0.625rem; -} diff --git a/frontend/editor/src/portal/components/RecentActivity.stories.tsx b/frontend/editor/src/portal/components/RecentActivity.stories.tsx deleted file mode 100644 index 5b33617ea4..0000000000 --- a/frontend/editor/src/portal/components/RecentActivity.stories.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { http, HttpResponse, delay } from "msw"; -import { RecentActivity } from "@portal/components/RecentActivity"; - -const meta: Meta = { - title: "Portal/Home/RecentActivity", - component: RecentActivity, - 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/activity", async () => { - await delay("infinite"); - return HttpResponse.json([]); - }), - ], - }, - }, -}; - -export const Empty: Story = { - parameters: { - msw: { - handlers: [http.get("/v1/activity", () => HttpResponse.json([]))], - }, - }, -}; diff --git a/frontend/editor/src/portal/components/RecentActivity.tsx b/frontend/editor/src/portal/components/RecentActivity.tsx deleted file mode 100644 index 3ebd940151..0000000000 --- a/frontend/editor/src/portal/components/RecentActivity.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { Button, Card, EmptyState, Skeleton, StatusBadge } from "@app/ui"; -import { useTier } from "@portal/contexts/TierContext"; -import { useView } from "@portal/contexts/ViewContext"; -import { useAsync } from "@portal/hooks/useAsync"; -import { - fetchAuditLog, - type AuditLogResponse, - type AuditStatus, -} from "@portal/api/infrastructure"; -import { - AUDIT_CAT_LABEL, - AUDIT_STATUS_LABEL, - AUDIT_TONE, -} from "@portal/components/infrastructure/infraFormat"; -import "@portal/components/RecentActivity.css"; - -/** Rail colour by outcome — mirrors the audit status tones. */ -const STATUS_COLOUR: Record = { - success: "var(--color-green)", - warning: "var(--color-amber)", - danger: "var(--color-red)", - info: "var(--color-blue)", -}; - -/** How many of the most recent audit events the home card shows. */ -const MAX_EVENTS = 6; - -/** - * The home "Recent activity" card, backed by the real audit log (the same - * endpoint the Infrastructure → Audit tab reads). Shows the latest few events; - * "View all" jumps to the full audit view. Failures and an empty log both fall - * through to the empty state — never an error banner on the dashboard. - */ -export function RecentActivity() { - const { t } = useTranslation(); - const { tier } = useTier(); - const { setActiveView } = useView(); - const { data, loading } = useAsync( - () => fetchAuditLog(tier), - [tier], - ); - - const events = data?.events.slice(0, MAX_EVENTS) ?? []; - const isLoading = loading && data === null; - const isEmpty = !isLoading && events.length === 0; - - return ( - -
    -

    - {t("portal.recentActivity.title")} -

    - -
    - - {isLoading && ( -
      - {Array.from({ length: 5 }).map((_, i) => ( -
    • - -
      - - -
      -
    • - ))} -
    - )} - - {isEmpty && ( - - )} - - {events.length > 0 && ( -
      - {events.map((event) => ( -
    1. - -
      -
      - - {event.action} - - - {event.timestamp} - -
      -
      {event.target}
      -
      - - {t(AUDIT_CAT_LABEL[event.category])} · {event.actor} - - - {t(AUDIT_STATUS_LABEL[event.status])} - -
      -
      -
    2. - ))} -
    - )} -
    - ); -} diff --git a/frontend/editor/src/portal/components/SearchModal.css b/frontend/editor/src/portal/components/SearchModal.css index 6b139c975d..501cfd140a 100644 --- a/frontend/editor/src/portal/components/SearchModal.css +++ b/frontend/editor/src/portal/components/SearchModal.css @@ -3,8 +3,8 @@ align-items: center; gap: 0.625rem; padding: 0.25rem 0.25rem 0.75rem; - border-bottom: 1px solid var(--color-border-light); - color: var(--color-text-4); + border-bottom: 1px solid var(--c-border-subtle); + color: var(--c-text-subtle); } .portal-search__input { @@ -14,7 +14,7 @@ background: transparent; border: none; outline: none; - color: var(--color-text-1); + color: var(--c-text); } .portal-search__input::placeholder { @@ -26,8 +26,8 @@ font-size: 0.6875rem; padding: 0.0625rem 0.375rem; border-radius: var(--radius-xs); - background: var(--color-bg-muted); - color: var(--color-text-4); + background: var(--c-surface-sunken); + color: var(--c-text-subtle); } .portal-search__results { @@ -54,17 +54,17 @@ justify-content: space-between; padding: 0.5rem 0.625rem; border-radius: var(--radius-sm); - color: var(--color-text-2); + color: var(--c-text-muted); font-size: 0.8125rem; text-align: left; } .portal-search__item:hover { - background: var(--color-bg-hover); + background: var(--c-hover); } .portal-search__item-hint { font-family: var(--font-mono); font-size: 0.6875rem; - color: var(--color-text-5); + color: var(--c-text-subtle); } diff --git a/frontend/editor/src/portal/components/SearchModal.stories.tsx b/frontend/editor/src/portal/components/SearchModal.stories.tsx index 16de3449e6..892c99c3ae 100644 --- a/frontend/editor/src/portal/components/SearchModal.stories.tsx +++ b/frontend/editor/src/portal/components/SearchModal.stories.tsx @@ -18,7 +18,7 @@ const meta: Meta = { parameters: { layout: "fullscreen" }, decorators: [ (S) => ( -
    +
    diff --git a/frontend/editor/src/portal/components/SetupChecklist.css b/frontend/editor/src/portal/components/SetupChecklist.css index a19919e640..0fcb71e678 100644 --- a/frontend/editor/src/portal/components/SetupChecklist.css +++ b/frontend/editor/src/portal/components/SetupChecklist.css @@ -21,7 +21,7 @@ width: 100%; padding: 0.6875rem 1.25rem; border: none; - border-top: 1px solid var(--color-border-light); + border-top: 1px solid var(--c-border-subtle); background: transparent; text-align: left; cursor: pointer; @@ -31,7 +31,7 @@ border-top: none; } .portal-setup__row:hover { - background: var(--color-bg-hover); + background: var(--c-hover); } /* Numbered step marker */ @@ -42,10 +42,10 @@ height: 1.5rem; flex-shrink: 0; border-radius: 50%; - border: 1px solid var(--color-border-input); + border: 1px solid var(--c-border); font-size: 0.75rem; font-weight: 600; - color: var(--color-text-4); + color: var(--c-text-subtle); } /* Completed step: filled green check. */ .portal-setup__num.is-done { @@ -54,7 +54,7 @@ color: #fff; } .portal-setup__row.is-done .portal-setup__text strong { - color: var(--color-text-2); + color: var(--c-text-muted); } .portal-setup__text { @@ -65,12 +65,12 @@ .portal-setup__text strong { font-size: 0.875rem; font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); } .portal-setup__text span { font-size: 0.75rem; line-height: 1.4; - color: var(--color-text-4); + color: var(--c-text-subtle); } /* ── Enterprise upsell rung ── */ @@ -81,10 +81,10 @@ gap: 1rem; flex-wrap: wrap; padding: 0.75rem 1.25rem; - border-top: 1px solid var(--color-border-light); + border-top: 1px solid var(--c-border-subtle); background: linear-gradient( 90deg, - color-mix(in srgb, var(--color-blue) 5%, transparent) 0%, + color-mix(in srgb, var(--c-primary) 5%, transparent) 0%, transparent 55% ); } @@ -105,18 +105,18 @@ font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; - color: var(--color-blue-dark); - background: var(--color-blue-light); + color: var(--c-primary-hover); + background: var(--c-primary-tint); } .portal-setup__enterprise-text { margin: 0; font-size: 0.8125rem; line-height: 1.45; - color: var(--color-text-3); + color: var(--c-text-subtle); min-width: 0; } .portal-setup__enterprise-text strong { - color: var(--color-text-1); + color: var(--c-text); font-weight: 700; } diff --git a/frontend/editor/src/portal/components/SetupChecklist.stories.tsx b/frontend/editor/src/portal/components/SetupChecklist.stories.tsx index 78af75d188..432be155d7 100644 --- a/frontend/editor/src/portal/components/SetupChecklist.stories.tsx +++ b/frontend/editor/src/portal/components/SetupChecklist.stories.tsx @@ -23,10 +23,10 @@ const meta: Meta = {
    diff --git a/frontend/editor/src/portal/components/Sidebar.css b/frontend/editor/src/portal/components/Sidebar.css index 98a9b63ec3..40e1fd25f5 100644 --- a/frontend/editor/src/portal/components/Sidebar.css +++ b/frontend/editor/src/portal/components/Sidebar.css @@ -1,8 +1,9 @@ .portal-sidebar { width: 15rem; height: 100vh; - background: var(--color-sidebar-bg); - border-right: 1px solid var(--color-sidebar-border); + height: 100dvh; /* track mobile browser chrome */ + background: var(--c-bg-raised); + border-right: 1px solid var(--c-border); display: flex; flex-direction: column; flex-shrink: 0; @@ -10,18 +11,63 @@ top: 0; } +/* Mobile close button (shared ActionIcon) — only shown inside the drawer. */ +.portal-sidebar__close { + display: none; + flex-shrink: 0; +} + +/* Off-canvas drawer under the shell breakpoint (keep in sync with + AppShell.css and Sidebar.tsx). Slides from the inline-start edge so RTL + locales get the mirrored behavior for free. */ +@media (max-width: 48rem) { + .portal-sidebar { + position: fixed; + inset-block: 0; + inset-inline-start: 0; + z-index: 80; /* above the scrim (75), below SUI modals (100) */ + width: min(17rem, 85vw); + border-right: none; + border-inline-end: 1px solid var(--color-sidebar-border); + transform: translateX(calc(var(--sui-dir, 1) * -100%)); + visibility: hidden; + transition: + transform var(--motion-fast), + visibility var(--motion-fast); + } + [dir="rtl"] .portal-sidebar { + --sui-dir: -1; + } + .portal-sidebar--open { + transform: translateX(0); + visibility: visible; + box-shadow: 0 0.5rem 2rem rgba(0, 0, 0, 0.35); + } + .portal-sidebar__close { + display: inline-flex; + } +} + /* Logo block */ .portal-sidebar__logo { height: 3.1875rem; /* 51px */ padding: 0 0.875rem; display: flex; align-items: center; - gap: 0.5rem; - border-bottom: 1px solid var(--color-sidebar-divider); + gap: 0.4375rem; + border-bottom: 1px solid var(--c-border-subtle); } -/* Stirling Processor wordmark (theme-switched in Sidebar.tsx); matches the - editor's 22px wordmark so the two apps read as one brand. */ +/* Brand mark (parallelogram icon) leading the wordmark; same in both themes. */ +.portal-sidebar__mark { + height: 1.5rem; + width: auto; + display: block; + flex-shrink: 0; +} + +/* Stirling wordmark (theme-switched in Sidebar.tsx); matches the editor's + 22px wordmark so the two apps read as one brand. */ .portal-sidebar__wordmark { height: 1.375rem; width: auto; @@ -29,6 +75,19 @@ flex-shrink: 0; } +/* Show the wordmark that matches the rendered scheme (black text on light, + white text on dark). Keyed on data-mantine-color-scheme so it follows the + actual theme, not the portal's separate (and sometimes stale) theme state. */ +.portal-sidebar__wordmark--dark { + display: none; +} +[data-mantine-color-scheme="dark"] .portal-sidebar__wordmark--light { + display: none; +} +[data-mantine-color-scheme="dark"] .portal-sidebar__wordmark--dark { + display: block; +} + /* App switcher (down-arrow → Portal / Editor); button and menu styling live with the shared AppSwitch element. */ .portal-sidebar__app-switch { @@ -46,21 +105,35 @@ gap: 0.5rem; } +/* Each section is a labelled card: a small header above its nav items. */ +.portal-sidebar__section { + background: var(--c-surface); + border: 1px solid var(--c-border-subtle); + border-radius: 0.625rem; + padding: 0.5rem 0.375rem 0.375rem; + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.portal-sidebar__section-label { + margin: 0; + padding: 0 0.5rem; + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.02em; + color: var(--c-text-subtle); +} + .portal-sidebar__group { display: flex; flex-direction: column; gap: 0.125rem; } -.portal-sidebar__divider { - height: 1px; - background: var(--color-sidebar-divider); - margin: 0.25rem 0; -} - /* Footer */ .portal-sidebar__footer { - border-top: 1px solid var(--color-sidebar-divider); + border-top: 1px solid var(--c-border-subtle); padding: 0.5rem 0.625rem 0.75rem; display: flex; flex-direction: column; diff --git a/frontend/editor/src/portal/components/Sidebar.stories.tsx b/frontend/editor/src/portal/components/Sidebar.stories.tsx index df4d6c1cdb..d40d710adb 100644 --- a/frontend/editor/src/portal/components/Sidebar.stories.tsx +++ b/frontend/editor/src/portal/components/Sidebar.stories.tsx @@ -11,7 +11,7 @@ const meta: Meta = { style={{ display: "flex", height: "100vh", - background: "var(--color-bg)", + background: "var(--c-bg)", }} > diff --git a/frontend/editor/src/portal/components/Sidebar.tsx b/frontend/editor/src/portal/components/Sidebar.tsx index 6e21d8b8d2..a6f2a97a01 100644 --- a/frontend/editor/src/portal/components/Sidebar.tsx +++ b/frontend/editor/src/portal/components/Sidebar.tsx @@ -1,4 +1,5 @@ -import { NavItem } from "@app/ui"; +import { useMediaQuery } from "@mantine/hooks"; +import { ActionIcon, NavItem } from "@app/ui"; import { AppSwitch } from "@app/components/shared/AppSwitch"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; @@ -7,23 +8,35 @@ import { useTheme } from "@portal/contexts/ThemeContext"; import { useUI } from "@portal/contexts/UIContext"; import { LinkAccountFooterItem } from "@portal/components/LinkAccountFooterItem"; import { EDITOR_URL, EDITOR_IS_SAME_APP } from "@portal/auth/editorUrl"; -import wordmarkLight from "@app/assets/brand/modern-logo/StirlingProcessorLogoBlackText.svg"; -import wordmarkDark from "@app/assets/brand/modern-logo/StirlingProcessorLogoWhiteText.svg"; -import { SettingsIcon } from "@portal/components/icons"; +import mark from "@app/assets/brand/modern-logo/StirlingProcessorLogoNoText.svg"; +import wordmarkLight from "@app/assets/brand/modern-logo/StirlingLogoBlackText.svg"; +import wordmarkDark from "@app/assets/brand/modern-logo/StirlingLogoWhiteText.svg"; +import { CloseIcon, SettingsIcon } from "@portal/components/icons"; import { - GROUP_PRIMARY, - GROUP_OPERATIONAL, + GROUP_PROCESSOR, GROUP_PLATFORM, type NavEntry, + type NavGroup, } from "@portal/components/sidebarGroups"; import "@portal/components/Sidebar.css"; +const NAV_SECTIONS: NavGroup[] = [ + { labelKey: "portal.nav.section.processor", entries: GROUP_PROCESSOR }, + { labelKey: "portal.nav.section.platform", entries: GROUP_PLATFORM }, +]; + +/** Must match the shell breakpoint in AppShell.css / Sidebar.css. */ +const MOBILE_QUERY = "(max-width: 48rem)"; + export function Sidebar() { const { activeView, setActiveView } = useView(); const { theme } = useTheme(); - const { openSettings } = useUI(); + const { openSettings, mobileNavOpen, closeMobileNav } = useUI(); const { t } = useTranslation(); const navigate = useNavigate(); + const isMobile = useMediaQuery(MOBILE_QUERY, false, { + getInitialValueInEffect: false, + }); // Editor and portal are one SPA when the editor serves this origin's root, so // the switch stays client-side; an absolute EDITOR_URL (dev cross-app setup) @@ -45,6 +58,9 @@ export function Sidebar() { icon={entry.icon} isActive={activeView === entry.id} onClick={(id) => { + // Route changes also close the drawer (AppShell), but re-selecting the + // active view or opening an external tab changes no route — close here. + closeMobileNav(); if (entry.externalUrl) { window.open(entry.externalUrl, "_blank", "noopener,noreferrer"); } else { @@ -57,14 +73,32 @@ export function Sidebar() { return (
    -
    diff --git a/frontend/editor/src/portal/components/billing/FreePdfEditorsCard.tsx b/frontend/editor/src/portal/components/billing/FreePdfEditorsCard.tsx index 76fa5a9109..25ef05ec9e 100644 --- a/frontend/editor/src/portal/components/billing/FreePdfEditorsCard.tsx +++ b/frontend/editor/src/portal/components/billing/FreePdfEditorsCard.tsx @@ -3,8 +3,7 @@ import { useTranslation } from "react-i18next"; import { Button, Card, MetricCard, MetricStrip } from "@app/ui"; import GroupsIcon from "@mui/icons-material/GroupsRounded"; import PersonAddIcon from "@mui/icons-material/PersonAddAltRounded"; -import { useAsync } from "@portal/hooks/useAsync"; -import { fetchFleetStats } from "@portal/api/fleetStats"; +import { useFleetStats } from "@portal/queries/infrastructure"; /** * "Free PDF Editors" team-fleet card. Editors-deployed / active-this-month / @@ -22,7 +21,7 @@ function fmtMetric(value: number | null | undefined, loading: boolean): string { export function FreePdfEditorsCard() { const navigate = useNavigate(); const { t } = useTranslation(); - const { data, loading } = useAsync((signal) => fetchFleetStats(signal), []); + const { data, loading } = useFleetStats(); return (
    diff --git a/frontend/editor/src/portal/components/billing/FreePlanView.tsx b/frontend/editor/src/portal/components/billing/FreePlanView.tsx index a2027ff756..d3a1baf036 100644 --- a/frontend/editor/src/portal/components/billing/FreePlanView.tsx +++ b/frontend/editor/src/portal/components/billing/FreePlanView.tsx @@ -8,6 +8,10 @@ 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"; +import { ActivationChoiceModal } from "@portal/components/billing/ActivationChoiceModal"; +import { BundleCheckoutModal } from "@portal/components/billing/BundleCheckoutModal"; +import { PrepaidCapacityCard } from "@portal/components/billing/PrepaidCapacityCard"; +import { useBundleFlowState } from "@portal/hooks/useBundleFlowState"; interface Props { wallet: Wallet; @@ -32,7 +36,9 @@ function isSaasCurrency(c: string | null): c is SaasCurrency { */ export function FreePlanView({ wallet, unsynced, onSubscribed }: Props) { const { t } = useTranslation(); - const [modalOpen, setModalOpen] = useState(false); + // Activation fork (demo D97): choose → the metered checkout (payg) or the + // discounted bundle (prepay). Exactly one is open at a time. + const [step, setStep] = useState<"choose" | "payg" | "prepay" | null>(null); const [missingTeam, setMissingTeam] = useState(null); const isLeader = wallet.role === "leader"; @@ -40,7 +46,12 @@ export function FreePlanView({ wallet, unsynced, onSubscribed }: Props) { ? wallet.currency : "usd"; - function openCheckout() { + // Where this team sits in the prepaid-bundle flow, read on load so the CTA names + // the resume action rather than always restarting the fork. Leader + team gated + // (the RPC 403s otherwise). Refreshed when any activation modal closes. + const flow = useBundleFlowState(wallet.teamId, isLeader); + + function requireTeam(): boolean { if (wallet.teamId == null) { setMissingTeam( t( @@ -48,23 +59,44 @@ export function FreePlanView({ wallet, unsynced, onSubscribed }: Props) { "No team is resolved on your wallet yet — refresh and try again.", ), ); - return; + return false; } setMissingTeam(null); - setModalOpen(true); + return true; + } + + // No quote yet → open the pay-as-you-go vs prepay fork. A quote already in flight + // → skip the fork and reopen the bundle modal directly; its resume effect lands + // on the calculator (quote) or the payment step (invoice awaiting payment). + function openActivation() { + if (requireTeam()) setStep("choose"); + } + function resumeBundle() { + if (requireTeam()) setStep("prepay"); + } + + // Closing any activation modal re-reads the flow state so the CTA reflects a + // freshly-minted quote / invoice without a full page reload. + function closeModals() { + setStep(null); + flow.refresh(); } const switchOnAction = isLeader ? ( ) : null; @@ -93,6 +125,15 @@ export function FreePlanView({ wallet, unsynced, onSubscribed }: Props) { + {/* Prepaid capacity is usable independent of a metered subscription, so surface it here on the + free plan too (not just the subscribed dashboard) whenever the team holds a live pool. */} + {wallet.prepaidUnitsRemaining > 0 && ( + + )} + {/* Processor trial — meter with the inline upgrade CTA */} + setStep("payg")} + onChoosePrepay={() => setStep("prepay")} + /> + {wallet.teamId != null && ( setModalOpen(false)} + open={step === "payg"} + onClose={closeModals} teamId={wallet.teamId} currency={currency} pricePerDocMinor={wallet.pricePerDocMinor} @@ -134,6 +182,24 @@ export function FreePlanView({ wallet, unsynced, onSubscribed }: Props) { onComplete={() => onSubscribed?.() ?? Promise.resolve(false)} /> )} + + {/* Prepay reuses the bundle modal (free team → first-purchase copy, no cap + step). On completion the webhook credits the pool; we still poll onSubscribed + like the payg path, but flipping the wallet to subscribed depends on the + metered-subscription auto-provisioning off the saved card, a known follow-up + that's NOT yet wired — so for a prepay-only team this poll can just time out + until then. */} + {wallet.teamId != null && ( + { + closeModals(); + void onSubscribed?.(); + }} + /> + )}
    ); } diff --git a/frontend/editor/src/portal/components/billing/InvoicesList.stories.tsx b/frontend/editor/src/portal/components/billing/InvoicesList.stories.tsx new file mode 100644 index 0000000000..90aba9a4fe --- /dev/null +++ b/frontend/editor/src/portal/components/billing/InvoicesList.stories.tsx @@ -0,0 +1,77 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { http, HttpResponse } from "msw"; +import { InvoicesList } from "@portal/components/billing/InvoicesList"; +import type { Invoice } from "@portal/api/billing"; +import "@portal/components/billing/billing.css"; + +const meta: Meta = { + title: "Portal/Billing/InvoicesList", + component: InvoicesList, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +function invoice(overrides: Partial & { id: string }): Invoice { + return { + number: null, + status: "paid", + totalMinor: 4900, + currency: "usd", + createdAt: "2026-06-01T00:00:00Z", + periodStart: "2026-05-01T00:00:00Z", + periodEnd: "2026-06-01T00:00:00Z", + hostedInvoiceUrl: "https://invoice.stripe.com/i/mock", + invoicePdf: "https://invoice.stripe.com/i/mock/pdf", + description: "Stirling Processor Plan", + pdfsProcessed: 1204, + ...overrides, + }; +} + +const SEVEN_INVOICES: Invoice[] = Array.from({ length: 7 }, (_, i) => + invoice({ + id: `in_mock_${i}`, + number: `INV-${1000 + i}`, + status: i === 0 ? "open" : "paid", + createdAt: `2026-0${(i % 6) + 1}-01T00:00:00Z`, + }), +); + +/** A handful of recent invoices — the common case. */ +export const Default: Story = { + parameters: { + msw: { + handlers: [ + http.get("*/api/v1/payg/invoices", () => + HttpResponse.json(SEVEN_INVOICES), + ), + ], + }, + }, +}; + +/** No invoices yet — free team or a subscription with no closed cycle. */ +export const Empty: Story = { + parameters: { + msw: { + handlers: [ + http.get("*/api/v1/payg/invoices", () => HttpResponse.json([])), + ], + }, + }, +}; + +/** Fetch fails — the inline error message renders instead of the table. */ +export const LoadError: Story = { + parameters: { + msw: { + handlers: [ + http.get( + "*/api/v1/payg/invoices", + () => new HttpResponse(null, { status: 500 }), + ), + ], + }, + }, +}; diff --git a/frontend/editor/src/portal/components/billing/PdfsProcessedCard.stories.tsx b/frontend/editor/src/portal/components/billing/PdfsProcessedCard.stories.tsx index d7da10398b..e06c07ec94 100644 --- a/frontend/editor/src/portal/components/billing/PdfsProcessedCard.stories.tsx +++ b/frontend/editor/src/portal/components/billing/PdfsProcessedCard.stories.tsx @@ -43,3 +43,26 @@ export const Empty: Story = { }, }, }; + +/** Combined-billing: zero synced PDFs but unsynced instance units → "pending sync" note (no 0-PDF summary). */ +export const UnsyncedOnly: Story = { + args: { + wallet: { + ...subscribedWallet, + billableUsed: 0, + spendUnitsThisPeriod: 0, + categoryBreakdown: { api: 0, ai: 0, automation: 0 }, + categoryDocs: { api: 0, ai: 0, automation: 0 }, + docsProcessedThisPeriod: 0, + uniquePdfsThisPeriod: 0, + sizeMultiplierPdfsThisPeriod: 0, + }, + unsynced: { + periodStart: subscribedWallet.billingPeriodStart, + apiUnsyncedUnits: 8, + aiUnsyncedUnits: 0, + automationUnsyncedUnits: 4, + totalUnsyncedUnits: 12, + }, + }, +}; diff --git a/frontend/editor/src/portal/components/billing/PdfsProcessedCard.tsx b/frontend/editor/src/portal/components/billing/PdfsProcessedCard.tsx index 794ac39ae5..d36da95dd3 100644 --- a/frontend/editor/src/portal/components/billing/PdfsProcessedCard.tsx +++ b/frontend/editor/src/portal/components/billing/PdfsProcessedCard.tsx @@ -73,17 +73,15 @@ export function PdfsProcessedCard({ const perDocs: WalletCategoryBreakdown = wallet.categoryDocs; const totalDocs = perDocs.api + perDocs.ai + perDocs.automation; - // Average cost per PDF in minor currency units — meter units × the per-unit rate, - // spread over the input files processed. Shown only when the rate is known (free-tier - // and unknown-price snapshots omit the term rather than imply $0.00). + // Average cost per PDF in minor currency units — SYNCED units × the per-unit rate, + // spread over the (synced) input files. Deliberately excludes combined-billing + // pendingUnits: those are units-only (no doc count), so dividing them over synced docs + // would inflate the average. Numerator and denominator therefore cover the same + // population. Shown only when the rate is known (free-tier / unknown-price omit it). const rate = wallet.pricePerDocMinor; const showAvgCost = docs > 0 && rate != null; const avgCostMinor = - rate != null && docs > 0 ? (meterUnits / docs) * rate : 0; - - // Something ran once there are either counted PDFs or metered units (instance-local - // unsynced usage is units-only, so it keeps the card out of the empty state). - const hasActivity = docs > 0 || meterUnits > 0; + rate != null && docs > 0 ? (wallet.spendUnitsThisPeriod / docs) * rate : 0; return ( @@ -100,7 +98,7 @@ export function PdfsProcessedCard({ - {hasActivity ? ( + {docs > 0 ? ( <>

    {showAvgCost @@ -182,6 +180,18 @@ export function PdfsProcessedCard({

    ) : null} + ) : pendingUnits > 0 ? ( + // docs == 0 but instance-local units have accrued that SaaS hasn't billed yet + // (combined-billing, units-only, no PDF count). Surface the pending figure directly + // instead of a bare "0 PDFs" headline with a count-less summary + no split. Gated on + // pendingUnits (not meterUnits) so the "pending sync" wording is always exact. +

    + {t( + "portal.billing.pdfsProcessed.unitsPending", + "{{units}} meter units pending sync from linked instances", + { count: pendingUnits, units: pendingUnits.toLocaleString() }, + )} +

    ) : (

    {t( diff --git a/frontend/editor/src/portal/components/billing/PrepaidCapacityCard.stories.tsx b/frontend/editor/src/portal/components/billing/PrepaidCapacityCard.stories.tsx new file mode 100644 index 0000000000..a8a3d64d0d --- /dev/null +++ b/frontend/editor/src/portal/components/billing/PrepaidCapacityCard.stories.tsx @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { PrepaidCapacityCard } from "@portal/components/billing/PrepaidCapacityCard"; +import { + subscribedWallet, + prepaidWallet, +} from "@portal/components/billing/walletFixtures"; +import "@portal/components/billing/billing.css"; + +const meta: Meta = { + title: "Portal/Billing/PrepaidCapacityCard", + component: PrepaidCapacityCard, + parameters: { layout: "padded" }, + args: { + wallet: subscribedWallet, + onBuy: () => {}, + }, +}; +export default meta; +type Story = StoryObj; + +/** No bundle yet — the "12 months for the price of 10" offer nudge (leader view). */ +export const OfferNudge: Story = { + args: { + wallet: subscribedWallet, + }, +}; + +/** Bundle held, plenty of capacity left — meter + "Top up" action (leader view). */ +export const BundleHealthy: Story = { + args: { + wallet: prepaidWallet, + }, +}; + +/** Bundle nearly drawn down — meter crosses into the "Running low" band. */ +export const BundleLow: Story = { + args: { + wallet: { + ...prepaidWallet, + prepaidUnitsRemaining: 10_000, + }, + }, +}; diff --git a/frontend/editor/src/portal/components/billing/PrepaidCapacityCard.tsx b/frontend/editor/src/portal/components/billing/PrepaidCapacityCard.tsx new file mode 100644 index 0000000000..b70fbb5a26 --- /dev/null +++ b/frontend/editor/src/portal/components/billing/PrepaidCapacityCard.tsx @@ -0,0 +1,109 @@ +import { useTranslation } from "react-i18next"; +import { Button, Card } from "@app/ui"; +import { formatPeriodDate, MeterBar, meterState } from "@app/billing"; +import type { Wallet } from "@portal/api/billing"; + +/** + * Prepaid-bundle capacity for the subscribed Processor dashboard, and the entry + * point for buying it. Two faces, driven by whether the team holds a bundle: + * + * - No bundle → a slim "Get 12 months for the price of 10" offer nudge with a + * "Review offer" CTA (the demo's commit-nudge card), shown only when a buyer + * ({@code onBuy}, leader) is present. + * - Bundle held → the capacity meter (fills as the pool is drawn down, so it + * warns as capacity runs low) plus a "Top up" action for the leader. + * + * Prepaid is consumed before metered billing and sits outside the spend limit, so + * it reads as its own dimension. Buying/topping up opens {@code BundleCheckoutModal} + * via {@code onBuy}; members (no {@code onBuy}) get the display-only meter. + */ +export function PrepaidCapacityCard({ + wallet, + onBuy, +}: { + wallet: Wallet; + /** Leader-only: opens the purchase/top-up modal. Omit for members. */ + onBuy?: () => void; +}) { + const { t } = useTranslation(); + + // No bundle yet — show the buy nudge (leader only), else nothing. + if (wallet.prepaidUnitsTotal <= 0) { + if (!onBuy) return null; + return ( + +

    +
    + {t( + "portal.billing.prepaid.offer.title", + "Get 12 months for the price of 10", + )} +
    +

    + {t( + "portal.billing.prepaid.offer.subtitle", + "Prepay a year of PDF processing and get two months free — used before metered billing, outside your spend limit.", + )} +

    +
    + +
    + ); + } + + const remaining = wallet.prepaidUnitsRemaining; + const total = wallet.prepaidUnitsTotal; + const used = Math.max(0, total - remaining); + const { state, pct } = meterState(used, total); + const stateLabel = + state === "DEGRADED" + ? t("portal.billing.prepaid.state.exhausted", "Used up") + : state === "WARNED" + ? t("portal.billing.prepaid.state.low", "Running low") + : t("portal.billing.prepaid.state.healthy", "Plenty left"); + + return ( + + + {t("portal.billing.prepaid.eyebrow", "Prepaid capacity")} + + + {t("portal.billing.prepaid.expires", "Expires {{date}}", { + date: formatPeriodDate(wallet.prepaidExpiresAt, { year: true }), + })} + + ) : undefined + } + /> +
    +

    + {t( + "portal.billing.prepaid.note", + "Used before metered billing and outside your spend limit.", + )} +

    + {onBuy && ( + + )} +
    +
    + ); +} diff --git a/frontend/editor/src/portal/components/billing/PrepayModalHeader.stories.tsx b/frontend/editor/src/portal/components/billing/PrepayModalHeader.stories.tsx new file mode 100644 index 0000000000..d91d9f754e --- /dev/null +++ b/frontend/editor/src/portal/components/billing/PrepayModalHeader.stories.tsx @@ -0,0 +1,30 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { PrepayModalHeader } from "@portal/components/billing/PrepayModalHeader"; +import "@portal/components/billing/billing.css"; + +const meta: Meta = { + title: "Portal/Billing/PrepayModalHeader", + component: PrepayModalHeader, + args: { + step: 1, + total: 3, + title: "Choose an amount", + onClose: () => console.log("close"), + }, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +/** Prepaid wizard, step 1 of 3 — badge + 3-segment progress bar. */ +export const StepOfThree: Story = {}; + +/** Metered checkout, step 2 of 2 — badge + 2-segment progress bar. */ +export const StepOfTwo: Story = { + args: { step: 2, total: 2, title: "Add a payment method" }, +}; + +/** No step supplied — badge and progress bar are hidden (e.g. a terminal confirmation screen). */ +export const NoSteps: Story = { + args: { step: undefined, title: "You're all set" }, +}; diff --git a/frontend/editor/src/portal/components/billing/PrepayModalHeader.tsx b/frontend/editor/src/portal/components/billing/PrepayModalHeader.tsx new file mode 100644 index 0000000000..c099836ee4 --- /dev/null +++ b/frontend/editor/src/portal/components/billing/PrepayModalHeader.tsx @@ -0,0 +1,91 @@ +import { useTranslation } from "react-i18next"; +import { Button } from "@app/ui"; +// The trademarked Stirling wordmark — the font is baked into the SVG (no brand webfont is loaded), so +// we render the same asset the portal nav uses rather than styled text. Theme-switched in CSS. +import wordmarkLight from "@app/assets/brand/modern-logo/StirlingProcessorLogoBlackText.svg"; +import wordmarkDark from "@app/assets/brand/modern-logo/StirlingProcessorLogoWhiteText.svg"; + +/** + * Shared header for the prepay-flow modals — the prepaid wizard (activation → calculator → pay, of 3) + * and the metered checkout (spend limit → payment, of 2): Stirling brand + "Step N of M" badge + close, + * an M-segment progress bar, and the step title. Pass {@code step=undefined} to hide the badge + + * progress (e.g. a terminal confirmation). + */ +export function PrepayModalHeader({ + step, + total = 3, + title, + onClose, +}: { + step?: number; + /** Total steps in this flow (3 for the prepaid wizard, 2 for the metered checkout). */ + total?: number; + title: string; + onClose: () => void; +}) { + const { t } = useTranslation(); + const showSteps = step != null; + const filled = step ?? 0; + return ( +
    +
    +
    + Stirling + +
    +
    + {showSteps && ( + + {t( + "portal.billing.prepaid.buy.step", + "Step {{current}} of {{total}}", + { current: step, total }, + )} + + )} +
    +
    + {showSteps && ( +
    + = 1 ? "is-filled" : ""} /> + = 2 ? "is-filled" : ""} /> + {total >= 3 && = 3 ? "is-filled" : ""} />} +
    + )} +
    {title}
    +
    + ); +} diff --git a/frontend/editor/src/portal/components/billing/StripeCheckoutModal.tsx b/frontend/editor/src/portal/components/billing/StripeCheckoutModal.tsx index 65d32ed8ea..4787c40b37 100644 --- a/frontend/editor/src/portal/components/billing/StripeCheckoutModal.tsx +++ b/frontend/editor/src/portal/components/billing/StripeCheckoutModal.tsx @@ -1,19 +1,24 @@ import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Banner, Button, Modal, Skeleton, Spinner } from "@app/ui"; -import { SpendCapControl } from "@app/billing"; +import { + currencySymbol, + docCapForMoney, + formatMinor, +} from "@app/billing/format"; import { EmbeddedCheckout, EmbeddedCheckoutProvider, } from "@stripe/react-stripe-js"; -import type { Stripe } from "@stripe/stripe-js"; import { updateCap } from "@portal/api/billing"; import { createCheckoutSession, getStripePublishableKey, + loadStripeOnce, type SaasCurrency, } from "@portal/billing/stripe"; -import { LockIcon } from "@portal/components/icons"; +import { CardPlaceholder } from "@portal/components/billing/CardPlaceholder"; +import { PrepayModalHeader } from "@portal/components/billing/PrepayModalHeader"; interface Props { open: boolean; @@ -29,10 +34,10 @@ interface Props { /** Optional billing email prefill (Stripe locks the field when set). */ billingOwnerEmail?: string; /** - * Fired when Stripe (or the mock continue button) signals payment success. - * Runs the caller's activation flow (poll the wallet until the subscription - * webhook lands) and resolves {@code true} once subscribed, {@code false} if - * it's taking longer than the poll window. + * Fired when Stripe signals payment success. Runs the caller's activation flow + * (poll the wallet until the subscription webhook lands) and resolves {@code + * true} once subscribed, {@code false} if it's taking longer than the poll + * window. */ onComplete: () => Promise; } @@ -51,29 +56,6 @@ const DEFAULT_CAP_USD = 100; * 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. */ -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; -} - -/** Two-segment progress header: step 1 = spend limit, step 2 = payment. */ -function StepProgress({ step }: { step: 1 | 2 }) { - const { t } = useTranslation(); - return ( -
    -
    - - = 2 ? "is-done" : ""} /> -
    - - {t("portal.billing.checkout.stepCount", "Step {{step}} of 2", { step })} - -
    - ); -} /** Payment done, waiting for the subscription webhook to activate the plan. */ function CheckoutFinalizing() { @@ -125,33 +107,116 @@ function CheckoutActivationSlow({ onClose }: { onClose: () => void }) { } /** - * Placeholder for the card form when no Stripe publishable key is configured - * (Storybook / preview / mis-config). Mirrors the real embedded form's framing - * so the step reads correctly without mounting Stripe. + * Spend-limit picker for step 1: a primary editable amount ({@code $ 100 / mo}) with quick-pick chips + * below. The amount field is the main entry — always visible, defaulted, and typeable — and clicking a + * chip (or "No cap") just writes into it. Controlled via {@code capUsd}/{@code onChange} + * (null = no cap, a number = a monthly ceiling). */ -function CardPlaceholder() { +function SpendLimitPicker({ + capUsd, + onChange, + currency, + pricePerDocMinor, + presets, + disabled, +}: { + capUsd: number | null; + onChange: (v: number | null) => void; + currency: SaasCurrency; + pricePerDocMinor?: number | null; + presets: readonly number[]; + disabled?: boolean; +}) { const { t } = useTranslation(); + const sym = currencySymbol(currency); + const isNoCap = capUsd === null; + // Local mirror so partial typing isn't clobbered by the controlled value; resync when not focused + // (e.g. a chip sets the amount, or an initial cap loads in). + const [text, setText] = useState(capUsd != null ? String(capUsd) : ""); + const [focused, setFocused] = useState(false); + useEffect(() => { + if (!focused) setText(capUsd != null ? String(capUsd) : ""); + }, [capUsd, focused]); + + const docs = docCapForMoney(capUsd, pricePerDocMinor); + + const onInput = (raw: string) => { + const cleaned = raw.replace(/[^0-9]/g, ""); + setText(cleaned); + // Empty maps to 0 — the "nothing entered yet" sentinel (distinct from the explicit null "No limit"). + // The parent treats 0 as incomplete and blocks Continue, so a cleared field can't become a $0 cap. + onChange(cleaned === "" ? 0 : parseInt(cleaned, 10)); + }; + return ( -
    -
    - {t("portal.billing.checkout.card.label", "Card details")} - Stripe -
    -
    - - - {t( - "portal.billing.checkout.card.fields", - "Card number · MM / YY · CVC · ZIP", +
    +
    + {sym} + onInput(e.target.value)} + onFocus={() => setFocused(true)} + onBlur={() => setFocused(false)} + disabled={disabled} + /> + + {t("portal.billing.checkout.cap.perMonth", "/ mo")}
    -

    - {t( - "portal.billing.checkout.card.note", - "Card details collected by Stripe. Stirling never stores PAN or CVC.", - )} -

    + +
    + {presets.map((p) => ( + + ))} + +
    + + {docs != null && ( +
    + + {t("payg.cap.docsEstimate", "≈ {{docs}} credits / month", { + docs: docs.toLocaleString(), + })} + + + {t("payg.cap.docsRate", "at {{rate}} / credit", { + rate: formatMinor(pricePerDocMinor ?? 0, currency), + })} + +
    + )}
    ); } @@ -263,6 +328,10 @@ export function StripeCheckoutModal({ // Apply the chosen ceiling, then advance to payment. Applying it up front keeps // "you're never billed past it" true from the first processed PDF. async function handleContinue() { + // Guard the empty-field sentinel: clearing the input maps to 0, which is neither a real cap nor the + // explicit "No limit" (null). Proceeding would set a $0 ceiling (processing immediately paused), so + // treat it as incomplete and stay put — the Continue button is disabled in this state too. + if (capUsd !== null && capUsd <= 0) return; setCapBusy(true); setCapError(null); try { @@ -294,30 +363,40 @@ export function StripeCheckoutModal({ if (dismissable) onClose(); }; - const onCapStep = phase === "cap"; - const title = onCapStep - ? t("portal.billing.checkout.cap.title", "Set your spend limit") - : t("portal.billing.checkout.title", "Add a payment method"); - const subtitle = onCapStep - ? t( - "portal.billing.checkout.cap.subtitle", - "You're billed only for PDFs you process past your first 500 free, never for seats — so your ceiling is yours from day one.", - ) - : t( - "portal.billing.checkout.subtitle", - "Add a card to keep going past your free Editor-plan grant. Stripe handles the rest.", - ); + // Valid to continue when a positive cap is set OR "No limit" (null) was explicitly picked. A cleared + // field maps to 0 (incomplete) — block Continue rather than let it become an accidental $0 ceiling. + const capValid = capUsd === null || capUsd > 0; + + // The chosen cap, formatted for the payment-step recap (null = "No cap" was picked on step 1). + const capLabel = + capUsd != null + ? new Intl.NumberFormat(undefined, { + style: "currency", + currency: currency.toUpperCase(), + maximumFractionDigits: 0, + }).format(capUsd) + : null; return ( {phase === "finalizing" && } {phase === "activationSlow" && ( @@ -326,80 +405,118 @@ export function StripeCheckoutModal({ {phase === "cap" && (
    - - -

    - {t( - "portal.billing.checkout.cap.finePrint", - "Invoices post on the 1st. Cancel anytime and revert to the Editor plan; your policies and history stay intact.", - )} -

    - {capError && ( - + +

    + {t( + "portal.billing.checkout.cap.note", + "You're never billed past your limit. Processing just pauses.", )} - > - {capError} - - )} -

    - - +

    +

    + {t( + "portal.billing.checkout.cap.finePrint", + "Invoices post monthly. Cancel anytime.", + )} +

    + {capError && ( + + {capError} + + )} +
    + + +
    )} {phase === "checkout" && (
    - - {!publishableKey && } - {publishableKey && error && ( - - {error} - - )} - {publishableKey && loading && !error && ( -
    - - -
    - )} - {publishableKey && canRender && stripe && clientSecret && ( - - - - )} + +
    +

    + {capLabel + ? t( + "portal.billing.checkout.pay.limit", + "Monthly spend limit {{amount}}/mo. Processing pauses at the limit.", + { amount: capLabel }, + ) + : t( + "portal.billing.checkout.pay.limitNoCap", + "No spend limit. Processing won't pause. Invoices post monthly.", + )} +

    + {!publishableKey && } + {publishableKey && error && ( + + {error} + + )} + {publishableKey && loading && !error && ( +
    + + +
    + )} + {publishableKey && canRender && stripe && clientSecret && ( +
    + + + +
    + )} +
    )}
    diff --git a/frontend/editor/src/portal/components/billing/SubscribedPlanView.stories.tsx b/frontend/editor/src/portal/components/billing/SubscribedPlanView.stories.tsx index 4083c687f0..1b9c5cc2f8 100644 --- a/frontend/editor/src/portal/components/billing/SubscribedPlanView.stories.tsx +++ b/frontend/editor/src/portal/components/billing/SubscribedPlanView.stories.tsx @@ -1,7 +1,10 @@ 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 { + prepaidWallet, + subscribedWallet, +} from "@portal/components/billing/walletFixtures"; import "@portal/components/billing/billing.css"; const card = http.get("*/api/v1/payg/payment-method", () => @@ -64,12 +67,18 @@ const invoices = http.get("*/api/v1/payg/invoices", () => const meta: Meta = { title: "Portal/Billing/SubscribedPlanView", component: SubscribedPlanView, - parameters: { layout: "padded", msw: { handlers: [card, invoices] } }, + parameters: { + layout: "padded", + msw: { handlers: [card, invoices] }, + }, }; export default meta; type Story = StoryObj; -/** The full Processor-plan dashboard — leader, within cap. */ +/** + * The full Processor-plan dashboard — leader, within cap, no prepaid bundle yet. + * Surfaces the "Get 12 months for the price of 10" prepay nudge ("Review offer"). + */ export const Leader: Story = { args: { wallet: subscribedWallet } }; /** Approaching the cap — surfaces the over-cap warning banner + projection. */ @@ -83,3 +92,6 @@ export const ApproachingCap: Story = { }, }, }; + +/** Drawing on a prepaid bundle — surfaces the capacity meter + a "Top up" action. */ +export const WithPrepaid: Story = { args: { wallet: prepaidWallet } }; diff --git a/frontend/editor/src/portal/components/billing/SubscribedPlanView.tsx b/frontend/editor/src/portal/components/billing/SubscribedPlanView.tsx index 4d92eecf77..ba787a6ad6 100644 --- a/frontend/editor/src/portal/components/billing/SubscribedPlanView.tsx +++ b/frontend/editor/src/portal/components/billing/SubscribedPlanView.tsx @@ -7,6 +7,8 @@ import type { LocalUsage } from "@portal/api/link"; import { useStripePortal } from "@portal/hooks/useStripePortal"; import { FreePdfEditorsCard } from "@portal/components/billing/FreePdfEditorsCard"; import { PdfsProcessedCard } from "@portal/components/billing/PdfsProcessedCard"; +import { PrepaidCapacityCard } from "@portal/components/billing/PrepaidCapacityCard"; +import { BundleCheckoutModal } from "@portal/components/billing/BundleCheckoutModal"; import { SpendThisMonthCard } from "@portal/components/billing/SpendThisMonthCard"; import { SpendLimitCard } from "@portal/components/billing/SpendLimitCard"; import { PaymentMethodCard } from "@portal/components/billing/PaymentMethodCard"; @@ -40,9 +42,13 @@ export function SubscribedPlanView({ }: Props) { const { t } = useTranslation(); const [adjusting, setAdjusting] = useState(false); + const [bundleOpen, setBundleOpen] = useState(false); const portal = useStripePortal(wallet); const isLeader = wallet.role === "leader"; + // Buying/topping up prepaid capacity is a commercial action — leader-only, and + // needs a resolved team to scope checkout. + const canBuyBundle = isLeader && wallet.teamId != null; const spent = wallet.estimatedBillMinor != null ? wallet.estimatedBillMinor / 100 : 0; const capActive = !wallet.noCap && wallet.capUsd != null; @@ -100,6 +106,11 @@ export function SubscribedPlanView({ + setBundleOpen(true) : undefined} + /> +
    @@ -127,6 +138,15 @@ export function SubscribedPlanView({ {portal.error} )} + + {canBuyBundle && ( + setBundleOpen(false)} + wallet={wallet} + onComplete={onWalletChange} + /> + )}
    ); } diff --git a/frontend/editor/src/portal/components/billing/billing.css b/frontend/editor/src/portal/components/billing/billing.css index 1809a4f80c..cfcbf50697 100644 --- a/frontend/editor/src/portal/components/billing/billing.css +++ b/frontend/editor/src/portal/components/billing/billing.css @@ -28,7 +28,7 @@ .portal-billing__spend-foot { margin-top: auto; padding-top: 1rem; - border-top: 1px solid var(--color-border); + border-top: 1px solid var(--c-border); } .portal-billing__spend-foot:not(:first-child) { margin-top: 1.25rem; @@ -42,25 +42,25 @@ padding: 0.3rem 0.7rem; font-size: 0.8125rem; font-weight: 500; - color: var(--color-blue); + color: var(--c-primary); background: var(--color-bg-subtle); - border: 1px solid var(--color-border); + border: 1px solid var(--c-border); border-radius: 999px; cursor: pointer; } .portal-billing__suggested:hover { - border-color: var(--color-blue); + border-color: var(--c-primary); } .portal-billing__guardrail { margin-top: 0.85rem; padding: 0.7rem 0.85rem; font-size: 0.8125rem; - color: var(--color-text-3); + color: var(--c-text-subtle); background: var(--color-bg-subtle); border-radius: 0.6rem; } .portal-billing__guardrail strong { - color: var(--color-text-1); + color: var(--c-text); } .portal-billing__edit-actions { display: flex; @@ -99,27 +99,27 @@ font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; - color: var(--color-text-3); + color: var(--c-text-subtle); margin-bottom: 0.25rem; } .portal-billing__section-title { font-size: 1.05rem; font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); margin: 0 0 0.25rem; } .portal-billing__section-sub { font-size: 0.875rem; - color: var(--color-text-3); + color: var(--c-text-subtle); margin: 0 0 1rem; } /* Free grant still offsetting spend — reads as a positive credit note, pulled up under the "PDFs processed" line. */ .portal-billing__free-remaining { - color: var(--color-green-600, var(--color-green)); + color: var(--color-green); margin-top: -0.5rem; } @@ -129,16 +129,6 @@ gap: 0.75rem; } -/* Widen the checkout modal past the ~1000px iframe threshold where Stripe - Embedded Checkout flips from its single-column ("mobile") layout to the - two-column desktop one — matching the SaaS Plan page's UpgradeModal (1100px - cap → ~1056px iframe). Two-class selector so it beats .sui-modal--xl's - max-width regardless of stylesheet order. width:100% still shrinks it on - narrow viewports, where Stripe falls back to single column on its own. */ -.sui-modal.portal-billing__checkout-modal { - max-width: 1100px; -} - /* Post-checkout activation state, shown inside the checkout modal while the subscription webhook lands (and the "almost there" fallback if it lags). */ .portal-billing__checkout-finalizing { @@ -153,25 +143,25 @@ .portal-billing__checkout-status-title { font-size: 1.125rem; font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); margin: 0.25rem 0 0; } .portal-billing__checkout-status-body { font-size: 0.9375rem; - color: var(--color-text-2); + color: var(--c-text-muted); margin: 0; max-width: 32rem; } .portal-billing__checkout-status-hint { font-size: 0.8125rem; - color: var(--color-text-3); + color: var(--c-text-subtle); margin: 0; } .portal-billing__error { - color: var(--color-red, #b91c1c); + color: var(--color-red, var(--color-red-dark)); font-size: 0.875rem; margin: 0.5rem 0; } @@ -188,7 +178,7 @@ font-size: 1.25rem; font-weight: 600; margin: 0; - color: var(--color-text-1); + color: var(--c-text); } .portal-billing__meter-figures { @@ -206,21 +196,21 @@ .portal-billing__meter-num { font-size: 1.75rem; font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); } .portal-billing__meter-num--muted { - color: var(--color-text-3); + color: var(--c-text-subtle); } .portal-billing__meter-label { font-size: 0.8125rem; - color: var(--color-text-3); + color: var(--c-text-subtle); } .portal-billing__meter-track { height: 0.5rem; - background: var(--color-bg-subtle, #e5e7eb); + background: var(--color-bg-subtle, var(--c-surface-sunken)); border-radius: 999px; overflow: hidden; margin-bottom: 0.75rem; @@ -228,18 +218,14 @@ .portal-billing__meter-fill { height: 100%; - background: linear-gradient( - 90deg, - var(--color-blue, #0a8bff), - var(--color-purple, #8b5cf6) - ); + background: linear-gradient(90deg, var(--c-primary), var(--color-purple)); border-radius: 999px; transition: width 200ms ease; } .portal-billing__meter-foot { font-size: 0.8125rem; - color: var(--color-text-3); + color: var(--c-text-subtle); margin: 0.75rem 0 0; } @@ -269,12 +255,12 @@ font-weight: 750; line-height: 1; letter-spacing: -0.02em; - color: var(--color-text-1, #0f172a); + color: var(--c-text); font-variant-numeric: tabular-nums; } .paygf-meter__cap { font-size: 0.85rem; - color: var(--color-text-3, #64748b); + color: var(--c-text-subtle); font-variant-numeric: tabular-nums; } .paygf-meter .payg-bar { @@ -287,7 +273,7 @@ align-items: center; gap: 6px 10px; font-size: 0.78rem; - color: var(--color-text-2, #475569); + color: var(--c-text-muted); } /* Status chip (Healthy / Approaching / Cap reached). Solid hex colours @@ -308,28 +294,28 @@ border-radius: 999px; } .payg-status[data-state="FULL"] { - background: #dcfce7; - color: #15803d; + background: var(--color-green-light); + color: var(--color-green-dark); } .payg-status[data-state="FULL"] .payg-status__dot { - background: #22c55e; - box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.18); + background: var(--color-green); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-green) 18%, transparent); } .payg-status[data-state="WARNED"] { - background: #fef3c7; - color: #a16207; + background: var(--color-amber-light); + color: var(--color-amber-dark); } .payg-status[data-state="WARNED"] .payg-status__dot { - background: #eab308; - box-shadow: 0 0 0 3px rgba(234, 179, 8, 0.2); + background: var(--color-amber); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-amber) 20%, transparent); } .payg-status[data-state="DEGRADED"] { - background: #fee2e2; - color: #b91c1c; + background: var(--color-red-light); + color: var(--color-red-dark); } .payg-status[data-state="DEGRADED"] .payg-status__dot { - background: #ef4444; - box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.2); + background: var(--color-red); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-red) 20%, transparent); } /* Segmented usage bar */ @@ -337,7 +323,7 @@ margin-top: 18px; height: 10px; border-radius: 999px; - background: var(--color-bg-subtle, #e5e7eb); + background: var(--color-bg-subtle, var(--c-surface-sunken)); overflow: hidden; position: relative; } @@ -347,13 +333,13 @@ transition: width 0.5s cubic-bezier(0.16, 1, 0.3, 1); } .payg-bar__fill[data-state="FULL"] { - background: linear-gradient(90deg, #0a8bff, #38bdf8); + background: var(--c-primary); } .payg-bar__fill[data-state="WARNED"] { - background: linear-gradient(90deg, #f59e0b, #fbbf24); + background: var(--color-amber); } .payg-bar__fill[data-state="DEGRADED"] { - background: linear-gradient(90deg, #dc2626, #f87171); + background: var(--color-red); } /* ── Plan card (free state) ─────────────────────────────────────────────── */ @@ -367,12 +353,12 @@ font-size: 1.25rem; font-weight: 600; margin: 0; - color: var(--color-text-1); + color: var(--c-text); } .portal-billing__plan-sub { font-size: 0.9375rem; - color: var(--color-text-2); + color: var(--c-text-muted); margin: 0 0 0.5rem; } @@ -387,14 +373,14 @@ .portal-billing__plan-features li { font-size: 0.875rem; - color: var(--color-text-2); + color: var(--c-text-muted); padding-left: 1.25rem; position: relative; } .portal-billing__plan-features li::before { content: "✓"; - color: var(--color-green, #10b981); + color: var(--color-green); position: absolute; left: 0; font-weight: 600; @@ -409,12 +395,12 @@ .portal-billing__plan-reassure { font-size: 0.75rem; - color: var(--color-text-3); + color: var(--c-text-subtle); } .portal-billing__plan-readonly { font-size: 0.875rem; - color: var(--color-text-3); + color: var(--c-text-subtle); font-style: italic; margin: 0; } @@ -436,16 +422,16 @@ display: flex; justify-content: space-between; font-size: 0.8125rem; - color: var(--color-text-2); + color: var(--c-text-muted); } .portal-billing__breakdown-value { - color: var(--color-text-3); + color: var(--c-text-subtle); } .portal-billing__breakdown-track { height: 0.5rem; - background: var(--color-bg-subtle, #e5e7eb); + background: var(--color-bg-subtle, var(--c-surface-sunken)); border-radius: 999px; overflow: hidden; } @@ -457,15 +443,15 @@ } .portal-billing__breakdown-fill--blue { - background: #0a8bff; + background: var(--c-primary); } .portal-billing__breakdown-fill--purple { - background: #8b5cf6; + background: var(--color-purple); } .portal-billing__breakdown-fill--teal { - background: #06b6d4; + background: var(--color-cat-extraction); } /* ── Cap control ────────────────────────────────────────────────────────── */ @@ -482,13 +468,13 @@ flex-direction: column; gap: 0.25rem; font-size: 0.8125rem; - color: var(--color-text-3); + color: var(--c-text-subtle); } .portal-billing__cap-input { width: 8rem; padding: 0.5rem 0.75rem; - border: 1px solid var(--color-border, #d1d5db); + border: 1px solid var(--c-border); border-radius: 0.375rem; font-size: 0.9375rem; font-family: inherit; @@ -499,7 +485,7 @@ align-items: center; gap: 0.375rem; font-size: 0.875rem; - color: var(--color-text-2); + color: var(--c-text-muted); cursor: pointer; } @@ -516,12 +502,12 @@ .portal-billing__member-name { font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); } .portal-billing__member-email { font-size: 0.8125rem; - color: var(--color-text-3); + color: var(--c-text-subtle); } .portal-billing__invoice-num { @@ -530,7 +516,7 @@ } .portal-billing__invoice-desc { - color: var(--color-text-1, #0f172a); + color: var(--c-text); font-size: 0.9375rem; } @@ -570,26 +556,26 @@ 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); + --scc-accent: var(--c-primary); + --scc-accent-text: var(--c-primary); + --scc-accent-soft: color-mix(in srgb, var(--c-primary) 12%, transparent); + --scc-accent-border: color-mix(in srgb, var(--c-primary) 25%, transparent); + --scc-chip-bg: var(--c-surface-sunken); + --scc-chip-border: var(--c-border); + --scc-text-primary: var(--c-text); + --scc-text-secondary: var(--c-text-muted); + --scc-text-muted: var(--c-text-subtle); + --scc-border-strong: var(--c-text-subtle); 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; +[data-theme="dark"] .scc { + --scc-accent-text: var(--c-primary); + --scc-accent-soft: color-mix(in srgb, var(--c-primary) 16%, transparent); + --scc-chip-bg: var(--c-surface-sunken); + --scc-chip-border: var(--c-border); } .scc-row { @@ -723,7 +709,7 @@ } .portal-billing__planhead-eyebrow { font-size: 0.78rem; - color: var(--color-text-3); + color: var(--c-text-subtle); } .portal-billing__role-pill { display: inline-flex; @@ -736,14 +722,14 @@ 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); + background: color-mix(in srgb, var(--c-primary) 12%, transparent); + color: var(--c-primary); + border: 1px solid color-mix(in srgb, var(--c-primary) 25%, transparent); } .portal-billing__role-pill[data-leader="false"] { - background: var(--color-bg-muted); - color: var(--color-text-3); - border: 1px solid var(--color-border); + background: var(--c-surface-sunken); + color: var(--c-text-subtle); + border: 1px solid var(--c-border); } .portal-billing__planhead-split { display: grid; @@ -755,7 +741,7 @@ .portal-billing__planhead-col--meter { padding-right: 0; padding-left: 22px; - border-left: 1px solid var(--color-border); + border-left: 1px solid var(--c-border); } .portal-billing__planhead-lbl { display: inline-flex; @@ -768,30 +754,30 @@ margin-bottom: 8px; } .portal-billing__planhead-lbl--free { - color: #10b981; + color: var(--color-green); } .portal-billing__planhead-lbl--meter { - color: #0a8bff; + color: var(--c-primary); } .portal-billing__planhead-title { margin: 0; font-size: 1.05rem; font-weight: 700; - color: var(--color-text-1); + color: var(--c-text); 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); + color: var(--c-text-subtle); 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); + border-top: 1px solid var(--c-border); } @media (max-width: 640px) { .portal-billing__planhead-split { @@ -805,7 +791,7 @@ padding-left: 0; padding-top: 16px; border-left: none; - border-top: 1px solid var(--color-border); + border-top: 1px solid var(--c-border); } } @@ -824,7 +810,7 @@ border-radius: 0.375rem; font-size: 0.8125rem; font-weight: 500; - color: var(--color-text-1, #0f172a); + color: var(--c-text); text-decoration: none; border: 1px solid transparent; transition: @@ -833,14 +819,14 @@ } .portal-billing__invoice-link:hover { - background: var(--color-bg-muted, #f1f5f9); - border-color: var(--color-border, #e2e8f0); + background: var(--c-surface-sunken); + border-color: var(--c-border); } .portal-billing__invoice-link:focus-visible { outline: none; - border-color: var(--color-blue, #0a8bff); - background: var(--color-bg-muted, #f1f5f9); + border-color: var(--c-primary); + background: var(--c-surface-sunken); } .portal-billing__invoice-footer { @@ -865,7 +851,7 @@ margin: 0; font-size: 1.5rem; font-weight: 700; - color: var(--color-text-1); + color: var(--c-text); } /* ── Free PDF Editors (fleet) card ───────────────────────────────────── */ @@ -884,8 +870,8 @@ line-height: 0; border-radius: 0.65rem; background: var(--color-bg-subtle); - border: 1px solid var(--color-border); - color: var(--color-blue); + border: 1px solid var(--c-border); + color: var(--c-primary); } /* 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 @@ -906,7 +892,7 @@ background: transparent; border: 0; box-shadow: none; - border-left: 1px solid var(--color-border); + border-left: 1px solid var(--c-border); padding: 0 0 0 1.5rem; min-width: 0; gap: 0.3rem; @@ -935,7 +921,7 @@ border-radius: 0; font-size: 0.85rem; font-weight: 400; - color: var(--color-text-3, #64748b); + color: var(--c-text-subtle); } .portal-billing__trial-meter .payg-status__dot, .portal-billing__spend-meter .payg-status__dot { @@ -953,6 +939,12 @@ align-items: flex-start; gap: 1rem; } +/* Keep the CTA on one line at its natural width — in the narrow subscribed + column it was shrinking and clipping its label. */ +.portal-billing__enterprise-head > .sui-btn { + flex-shrink: 0; + white-space: nowrap; +} /* Bare variant — embeds in another card's column without its own surface. */ .portal-billing__enterprise-bare { display: block; @@ -970,31 +962,31 @@ font-weight: 750; line-height: 1; letter-spacing: -0.02em; - color: var(--color-text-1); + color: var(--c-text); font-variant-numeric: tabular-nums; } .portal-billing__bignum-unit { font-size: 0.85rem; - color: var(--color-text-3); + color: var(--c-text-subtle); } .portal-billing__segbar { display: flex; height: 0.55rem; border-radius: 999px; overflow: hidden; - background: var(--color-bg-muted, #f1f5f9); + background: var(--c-surface-sunken); } .portal-billing__segbar-seg { height: 100%; } .portal-billing__segbar-seg--blue { - background: #0a8bff; + background: var(--c-primary); } .portal-billing__segbar-seg--purple { - background: #8b5cf6; + background: var(--color-purple); } .portal-billing__segbar-seg--teal { - background: #06b6d4; + background: var(--color-cat-extraction); } .portal-billing__seglegend { display: flex; @@ -1016,75 +1008,97 @@ flex-shrink: 0; } .portal-billing__dot--blue { - background: #0a8bff; + background: var(--c-primary); } .portal-billing__dot--purple { - background: #8b5cf6; + background: var(--color-purple); } .portal-billing__dot--teal { - background: #06b6d4; + background: var(--color-cat-extraction); } .portal-billing__seglegend-label { font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); } .portal-billing__seglegend-val { - color: var(--color-text-2); + color: var(--c-text-muted); font-variant-numeric: tabular-nums; } .portal-billing__seglegend-desc { - color: var(--color-text-4); + color: var(--c-text-subtle); } /* ── Spend-limit projection line ─────────────────────────────────────── */ .portal-billing__projection { margin: 0.85rem 0 0; font-size: 0.85rem; - color: var(--color-text-3); + color: var(--c-text-subtle); } .portal-billing__projection strong { - color: var(--color-amber, #d97706); + color: var(--color-amber, var(--c-warning)); } -/* ── Checkout modal: 2-step (spend limit → payment) ──────────────────── */ +/* ── Checkout modal: spend-limit + payment steps ───────────────────────── + Framed layout: the PrepayModalHeader stays fixed and only the content below + it scrolls (not the whole modal). The phase fills the modal body as a flex + column — header (fixed) + a scroll region. */ +.portal-billing__checkout-modal--framed .sui-modal__body { + padding: 0; + overflow: hidden; + /* Flex column + min-height:0 so the body is bounded by the modal's max-height and its children's + flex sizing takes effect — otherwise the phase grows to full content height and nothing scrolls. */ + display: flex; + flex-direction: column; + min-height: 0; +} +/* Checkout step only: wider than the xl preset so the embedded Stripe iframe clears its ~1000px + two-column threshold (otherwise it renders the narrow single-column "portrait" layout). Two-class + selector to beat .sui-modal--xl's max-width regardless of stylesheet order. */ +.sui-modal.portal-billing__checkout-modal--wide { + max-width: 1120px; +} +/* Let the Stripe embedded checkout fill the scroll region's full width (it's the widest thing here). */ +.portal-billing__checkout-embed { + width: 100%; +} .portal-billing__checkout-cap, .portal-billing__checkout-pay { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; +} +/* Header owns its padding now that the modal body's is removed; stays fixed at the top. */ +.portal-billing__checkout-modal--framed .portal-billing__bundle-head { + flex: 0 0 auto; + margin-bottom: 0; + padding: 1rem 1.125rem 0.875rem; +} +.portal-billing__checkout-scroll { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; display: flex; flex-direction: column; gap: 1rem; -} - -.portal-billing__checkout-progress { - display: flex; - align-items: center; - gap: 0.75rem; -} -.portal-billing__checkout-steps { - display: flex; - gap: 0.375rem; - flex: 1; -} -.portal-billing__checkout-steps span { - height: 4px; - flex: 1; - border-radius: var(--radius-pill); - background: var(--color-border); -} -.portal-billing__checkout-steps span.is-done { - background: var(--color-blue); -} -.portal-billing__checkout-stepcount { - font-size: 0.75rem; - font-weight: 600; - color: var(--color-text-5); - white-space: nowrap; + padding: 0.25rem 1.125rem 1.125rem; } .portal-billing__checkout-finePrint { margin: 0; font-size: 0.75rem; line-height: 1.55; - color: var(--color-text-5); + color: var(--c-text-subtle); +} + +/* Payment-step recap of the spend limit chosen on step 1 (the card form itself is Stripe's iframe). */ +.portal-billing__checkout-limit { + margin: 0; + padding: 0.625rem 0.875rem; + border: 1px solid var(--c-border-subtle); + border-radius: 8px; + font-size: 0.8125rem; + color: var(--c-text-subtle); } .portal-billing__checkout-cap-actions { display: flex; @@ -1093,15 +1107,100 @@ margin-top: 0.25rem; } +/* Amount-first spend-limit picker (step 1): the $ /mo field is the primary control; the quick chips + below just write into it. Flat estimate (no accent box) to match the demo. */ +.portal-billing__caplimit { + display: flex; + flex-direction: column; + gap: 0.875rem; +} +.portal-billing__caplimit-field { + display: flex; + align-items: baseline; + gap: 0.5rem; + padding: 0.75rem 1rem; + border: 1px solid var(--c-border); + border-radius: 10px; + background: var(--c-surface); +} +.portal-billing__caplimit-field:focus-within { + border-color: var(--c-primary); +} +.portal-billing__caplimit-sym { + font-size: 1.5rem; + font-weight: 700; + color: var(--c-text-subtle); +} +.portal-billing__caplimit-input { + flex: 1; + min-width: 0; + border: none; + outline: none; + background: transparent; + color: var(--c-text); + font-size: 1.5rem; + font-weight: 700; + font-variant-numeric: tabular-nums; +} +.portal-billing__caplimit-input::placeholder { + color: var(--c-text-subtle); + font-weight: 600; +} +.portal-billing__caplimit-suffix { + font-size: 0.9375rem; + color: var(--c-text-subtle); +} +.portal-billing__caplimit-chips { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} +.portal-billing__caplimit-chip { + height: 34px; + padding: 0 16px; + border-radius: 999px; + border: 1px solid var(--c-border); + background: var(--c-surface-sunken); + color: var(--c-text-muted); + font-size: 0.85rem; + font-weight: 600; + font-variant-numeric: tabular-nums; +} +.portal-billing__caplimit-chip:hover:not(:disabled) { + border-color: var(--c-text-subtle); + color: var(--c-text); +} +.portal-billing__caplimit-chip[data-selected="true"] { + background: color-mix(in srgb, var(--c-primary) 12%, transparent); + border-color: var(--c-primary); + color: var(--c-primary); +} +.portal-billing__caplimit-chip:disabled { + opacity: 0.45; +} +.portal-billing__caplimit-estimate { + display: flex; + flex-direction: column; + gap: 1px; +} +.portal-billing__caplimit-estimate-main { + font-size: 0.875rem; + color: var(--c-text-muted); +} +.portal-billing__caplimit-estimate-sub { + font-size: 0.75rem; + color: var(--c-text-subtle); +} + /* Card-form placeholder shown when Stripe isn't configured (Storybook / preview). */ .portal-billing__card-placeholder { display: flex; flex-direction: column; gap: 0.5rem; padding: 1rem; - border: 1px solid var(--color-border-input); + border: 1px solid var(--c-border); border-radius: var(--radius-md); - background: var(--color-surface); + background: var(--c-surface); } .portal-billing__card-placeholder-head { display: flex; @@ -1109,14 +1208,14 @@ justify-content: space-between; font-size: 0.75rem; font-weight: 600; - color: var(--color-text-2); + color: var(--c-text-muted); } .portal-billing__card-placeholder-badge { font-size: 0.625rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; - color: var(--color-text-5); + color: var(--c-text-subtle); background: var(--color-bg-code); padding: 0.125rem 0.375rem; border-radius: var(--radius-sm); @@ -1127,15 +1226,399 @@ gap: 0.625rem; padding: 0.75rem 0.875rem; background: var(--color-bg-subtle); - border: 1px solid var(--color-border-light); + border: 1px solid var(--c-border-subtle); border-radius: var(--radius-sm); font-family: var(--font-mono); font-size: 0.8125rem; - color: var(--color-text-5); + color: var(--c-text-subtle); } .portal-billing__card-placeholder-note { margin: 0; font-size: 0.6875rem; line-height: 1.5; - color: var(--color-text-5); + color: var(--c-text-subtle); +} + +/* ── Prepaid bundle: buy nudge + top-up + purchase modal ───────────────────── */ + +/* Slim commit-nudge card shown when the team holds no bundle: title/subtitle + left, "Review offer" CTA right. Mirrors the demo's prepay nudge. */ +.portal-billing__prepaid-offer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; +} +.portal-billing__prepaid-offer-sub { + margin: 0.25rem 0 0; + font-size: 0.875rem; + line-height: 1.5; + color: var(--c-text-subtle); + max-width: 46ch; +} + +/* Meter card foot: the "used before metered billing" note + a leader Top-up. */ +.portal-billing__prepaid-foot { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-top: 0.75rem; +} +.portal-billing__prepaid-foot .portal-billing__section-sub { + margin: 0; +} + +/* Purchase modal — calculator step. */ +.portal-billing__bundle-calc { + display: flex; + flex-direction: column; + gap: 1.25rem; +} +.portal-billing__bundle-fields { + display: flex; + flex-direction: column; + gap: 1rem; +} +.portal-billing__bundle-field-label { + font-size: 0.8125rem; + font-weight: 600; + color: var(--c-text-muted); + margin-bottom: 0.375rem; +} +.portal-billing__bundle-field-hint { + margin: 0.375rem 0 0; + font-size: 0.75rem; + color: var(--c-text-subtle); + line-height: 1.4; +} +.portal-billing__bundle-summary { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 1rem 1.125rem; + border: 1px solid var(--c-border-subtle); + border-radius: var(--radius-md); + background: var(--color-bg-subtle); +} +.portal-billing__bundle-summary-row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 1rem; + font-size: 0.9375rem; +} +.portal-billing__bundle-summary-row strong { + font-size: 1rem; +} +.portal-billing__bundle-savings { + margin: 0; + font-size: 0.75rem; + color: var(--c-text-subtle); +} +.portal-billing__bundle-pool { + margin: 0; + font-size: 0.75rem; + color: var(--c-text-subtle); + line-height: 1.45; +} + +/* Users field — compact stepper box (demo D147: hugs its content). */ +.portal-billing__bundle-users { + max-width: 12rem; +} + +/* Progressive-disclosure finer settings: summary rows that bloom a card picker. */ +.portal-billing__bundle-rows { + border: 1px solid var(--c-border-subtle); + border-radius: var(--radius-md); + overflow: hidden; +} +.portal-billing__bundle-row + .portal-billing__bundle-row { + border-top: 1px solid var(--c-border-subtle); +} +.portal-billing__bundle-row-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + width: 100%; + padding: 0.75rem 0.875rem; + border: none; + background: transparent; + cursor: pointer; + text-align: left; + font: inherit; +} +.portal-billing__bundle-row-label { + font-size: 0.75rem; + font-weight: 600; + color: var(--c-text-subtle); +} +.portal-billing__bundle-row-value { + display: inline-flex; + align-items: baseline; + gap: 0.625rem; + font-size: 0.8125rem; + font-weight: 600; + color: var(--c-text); + text-align: right; +} +.portal-billing__bundle-row-change { + font-size: 0.75rem; + font-weight: 600; + color: var(--c-primary); + flex-shrink: 0; +} +.portal-billing__bundle-row-body { + padding: 0 0.875rem 0.875rem; +} + +/* Card pickers (deployment / sizing / governance / pipelines). */ +.portal-billing__bundle-cards { + display: flex; + gap: 0.625rem; + flex-wrap: wrap; +} +.portal-billing__bundle-card { + flex: 1; + min-width: 9.5rem; + padding: 0.75rem 0.875rem; + border: 1px solid var(--c-border); + border-radius: var(--radius-sm); + background: var(--c-surface); + cursor: pointer; + text-align: left; + font: inherit; +} +.portal-billing__bundle-card--active { + border-color: var(--c-primary); + box-shadow: inset 0 0 0 1px var(--c-primary); +} +.portal-billing__bundle-card-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.5rem; +} +.portal-billing__bundle-card-title { + font-size: 0.8125rem; + font-weight: 700; + color: var(--c-text); +} +.portal-billing__bundle-card--active .portal-billing__bundle-card-title { + color: var(--c-primary); +} +.portal-billing__bundle-card-meta { + font-size: 0.6875rem; + font-weight: 600; + color: var(--c-text-subtle); + flex-shrink: 0; +} +.portal-billing__bundle-card--active .portal-billing__bundle-card-meta { + color: var(--c-primary); +} +.portal-billing__bundle-card-desc { + margin-top: 0.1875rem; + font-size: 0.7188rem; + line-height: 1.4; + color: var(--c-text-subtle); +} + +/* Receipt card — "your Processor / your year / pool / download". */ +.portal-billing__bundle-receipt { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 1rem 1.125rem; + border: 1px solid var(--c-border-subtle); + border-radius: var(--radius-md); + background: var(--color-bg-subtle); +} +.portal-billing__bundle-receipt-row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 1rem; + font-size: 0.875rem; + color: var(--c-text-muted); +} +.portal-billing__bundle-receipt-row strong { + font-size: 0.9375rem; + color: var(--c-text); +} +/* First row reads as the header — bold both sides ("Your Processor / handles N credits / mo"). */ +.portal-billing__bundle-receipt-row--head span { + font-weight: 700; + color: var(--c-text); +} +.portal-billing__bundle-receipt-price { + color: var(--c-primary) !important; +} +.portal-billing__bundle-consent { + padding-top: 0.75rem; + border-top: 1px solid var(--c-border-subtle); +} +/* Recipient fields on the payment step, kept compact so the step fits without scrolling. */ +.portal-billing__bundle-fields { + display: flex; + flex-direction: column; + gap: 0.625rem; +} +/* Company name + PO number share a row. */ +.portal-billing__bundle-field-row { + display: flex; + gap: 0.625rem; +} +.portal-billing__bundle-field-row > * { + flex: 1 1 0; + min-width: 0; +} +/* Bundle modal header — brand · step badge · progress · step title. */ +.portal-billing__bundle-head { + display: flex; + flex-direction: column; + gap: 0.875rem; + margin-bottom: 1.25rem; +} +.portal-billing__bundle-head-top { + display: flex; + align-items: center; + justify-content: space-between; +} +.portal-billing__bundle-brand { + display: flex; + align-items: center; + gap: 0.5rem; + font-weight: 700; + font-size: 0.9375rem; + color: var(--c-text); +} +/* Trademarked wordmark SVG (theme-switched via .wordmark-light-only/.wordmark-dark-only). Height + matches the portal nav's 22px wordmark so the modal and app read as one brand. No `display` here — + the theme-switch utilities own visibility. */ +.portal-billing__bundle-wordmark { + height: 1.375rem; + width: auto; +} +.portal-billing__bundle-head-right { + display: flex; + align-items: center; + gap: 0.5rem; +} +.portal-billing__bundle-step { + padding: 0.1875rem 0.625rem; + border: 1px solid var(--c-border-subtle); + border-radius: 999px; + font-size: 0.75rem; + font-weight: 600; + color: var(--c-text-subtle); + white-space: nowrap; +} +.portal-billing__bundle-progress { + display: flex; + gap: 0.375rem; +} +.portal-billing__bundle-progress > span { + flex: 1; + height: 4px; + border-radius: 999px; + background: var(--c-border-subtle); +} +.portal-billing__bundle-progress > span.is-filled { + background: var(--c-primary); +} +.portal-billing__bundle-head-title { + font-size: 1.25rem; + font-weight: 700; + color: var(--c-text); +} + +/* Payment step: quote receipt + recipient fields + consent. */ +.portal-billing__bundle-pay { + display: flex; + flex-direction: column; + gap: 0.75rem; +} +/* "Download quote (PDF) · share for approval" — a quiet link under the receipt. */ +.portal-billing__bundle-download { + display: inline-flex; + align-items: center; + gap: 0.4375rem; + margin-top: 0.25rem; + padding: 0.5rem 0 0; + border: none; + border-top: 1px solid var(--c-border-subtle); + background: none; + cursor: pointer; + font: inherit; + font-size: 0.75rem; + font-weight: 600; + color: var(--c-primary); + text-align: left; +} +.portal-billing__bundle-download-share { + color: var(--c-text-subtle); + font-weight: 400; +} +.portal-billing__bundle-foot-end { + display: flex; + justify-content: flex-end; + gap: 0.5rem; +} + +/* Activation fork (demo D97): two "how do you want to pay?" door-cards. */ +.portal-billing__door-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} +/* "Maybe later" — a quiet dismiss under the door-cards, left-aligned. */ +.portal-billing__door-later { + display: flex; + justify-content: flex-start; + margin-top: 1rem; +} +@media (max-width: 560px) { + .portal-billing__door-grid { + grid-template-columns: 1fr; + } +} +.portal-billing__door { + display: flex; + flex-direction: column; + gap: 6px; + height: 100%; + text-align: left; + cursor: pointer; +} +.portal-billing__door--accent { + border-color: var(--c-primary); + background: var(--c-primary-subtle); +} +.portal-billing__door-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; +} +.portal-billing__door-title { + font-size: 14px; + font-weight: 600; + color: var(--c-text); +} +.portal-billing__door-badge { + flex-shrink: 0; + font-size: 11px; + font-weight: 600; + color: var(--c-primary); + background: var(--c-surface); + padding: 2px 8px; + border-radius: 8px; +} +.portal-billing__door-sub { + font-size: 12.5px; + line-height: 1.5; + color: var(--c-text-subtle); } diff --git a/frontend/editor/src/portal/components/billing/walletFixtures.ts b/frontend/editor/src/portal/components/billing/walletFixtures.ts index cd754af3fc..75ed0dd867 100644 --- a/frontend/editor/src/portal/components/billing/walletFixtures.ts +++ b/frontend/editor/src/portal/components/billing/walletFixtures.ts @@ -12,6 +12,7 @@ export const freeWallet: Wallet = { freeAllowance: 500, freeRemaining: 380, pricePerDocMinor: 2, + bundleRatePerCreditMinor: 1, currency: "usd", estimatedBillMinor: null, capUsd: null, @@ -23,6 +24,10 @@ export const freeWallet: Wallet = { docsProcessedThisPeriod: 90, uniquePdfsThisPeriod: 84, sizeMultiplierPdfsThisPeriod: 12, + billingMode: "payg", + prepaidUnitsRemaining: 0, + prepaidUnitsTotal: 0, + prepaidExpiresAt: null, members: [], recent: [], }; @@ -39,6 +44,7 @@ export const subscribedWallet: Wallet = { freeAllowance: 500, freeRemaining: 0, pricePerDocMinor: 2, + bundleRatePerCreditMinor: 1, currency: "usd", estimatedBillMinor: 4500, capUsd: 1000, @@ -50,6 +56,10 @@ export const subscribedWallet: Wallet = { docsProcessedThisPeriod: 1750, uniquePdfsThisPeriod: 1600, sizeMultiplierPdfsThisPeriod: 320, + billingMode: "payg", + prepaidUnitsRemaining: 0, + prepaidUnitsTotal: 0, + prepaidExpiresAt: null, members: [ { userId: "u1", @@ -66,3 +76,16 @@ export const subscribedWallet: Wallet = { ], recent: [], }; + +/** + * Subscribed team currently drawing on a prepaid bundle — {@code billingMode: + * "prepaid"} with a mid-drawn pool (78k of 120k left) expiring in-term. Drives the + * prepaid-capacity card + the "Prepaid year" chip. + */ +export const prepaidWallet: Wallet = { + ...subscribedWallet, + billingMode: "prepaid", + prepaidUnitsRemaining: 78000, + prepaidUnitsTotal: 120000, + prepaidExpiresAt: "2027-03-01", +}; diff --git a/frontend/editor/src/portal/components/catalogue/ComponentCard.stories.tsx b/frontend/editor/src/portal/components/catalogue/ComponentCard.stories.tsx deleted file mode 100644 index 0b6c284d93..0000000000 --- a/frontend/editor/src/portal/components/catalogue/ComponentCard.stories.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { componentsFor } from "@portal/mocks/sdkComponents"; -import { ComponentCard } from "@portal/components/catalogue/ComponentCard"; - -const PRO = componentsFor("pro"); -const GA = PRO.find((c) => c.maturity === "ga")!; -const BETA = PRO.find((c) => c.maturity === "beta")!; - -const meta: Meta = { - title: "Portal/Components/ComponentCard", - component: ComponentCard, - parameters: { layout: "padded" }, - args: { component: GA, unlocked: true, onOpen: () => {} }, -}; -export default meta; -type Story = StoryObj; - -export const GeneralAvailability: Story = {}; - -export const Beta: Story = { - args: { component: BETA }, -}; - -/** Sits above the tier — dimmed with a lock affordance. */ -export const Locked: Story = { - args: { component: BETA, unlocked: false }, -}; diff --git a/frontend/editor/src/portal/components/catalogue/ComponentCard.tsx b/frontend/editor/src/portal/components/catalogue/ComponentCard.tsx deleted file mode 100644 index 5357ffd52f..0000000000 --- a/frontend/editor/src/portal/components/catalogue/ComponentCard.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { Card, Chip, StatusBadge } from "@app/ui"; -import { - type SdkComponent, - MATURITY_META, - formatPrice, -} from "@portal/api/sdkComponents"; -import "@portal/views/Components.css"; - -interface ComponentCardProps { - component: SdkComponent; - /** False when the component sits above the active tier — renders locked. */ - unlocked: boolean; - onOpen: (component: SdkComponent) => void; -} - -/** A single catalogue tile: name, maturity, description, price and frameworks. */ -export function ComponentCard({ - component, - unlocked, - onOpen, -}: ComponentCardProps) { - const { t } = useTranslation(); - const maturity = MATURITY_META[component.maturity]; - - return ( - onOpen(component)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - onOpen(component); - } - }} - > -
    -

    {component.name}

    - - {t(maturity.label)} - - {!unlocked && ( - - 🔒 - - )} -
    - -

    {component.description}

    - -
    - - {formatPrice(component.pricing, t)} - - - @stirling/{component.package} - -
    - -
    - {component.frameworks.map((fw) => ( - - {fw} - - ))} -
    -
    - ); -} diff --git a/frontend/editor/src/portal/components/catalogue/ComponentDetailModal.stories.tsx b/frontend/editor/src/portal/components/catalogue/ComponentDetailModal.stories.tsx deleted file mode 100644 index 7a8be30dde..0000000000 --- a/frontend/editor/src/portal/components/catalogue/ComponentDetailModal.stories.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { componentsFor } from "@portal/mocks/sdkComponents"; -import { ComponentDetailModal } from "@portal/components/catalogue/ComponentDetailModal"; - -const PRO = componentsFor("pro"); -const VIEWER = PRO.find((c) => c.id === "viewer")!; -const TOOLKIT = PRO.find((c) => c.id === "toolkit")!; - -const meta: Meta = { - title: "Portal/Components/ComponentDetailModal", - component: ComponentDetailModal, - parameters: { layout: "fullscreen" }, - args: { component: VIEWER, unlocked: true, onClose: () => {} }, -}; -export default meta; -type Story = StoryObj; - -export const Unlocked: Story = {}; - -/** Enterprise-only component opened on a lower tier — shows the upgrade nudge. */ -export const Locked: Story = { - args: { component: TOOLKIT, unlocked: false }, -}; - -export const Closed: Story = { - args: { component: null }, -}; diff --git a/frontend/editor/src/portal/components/catalogue/ComponentDetailModal.tsx b/frontend/editor/src/portal/components/catalogue/ComponentDetailModal.tsx deleted file mode 100644 index 337cfc6119..0000000000 --- a/frontend/editor/src/portal/components/catalogue/ComponentDetailModal.tsx +++ /dev/null @@ -1,231 +0,0 @@ -import { useState } from "react"; -import { useTranslation } from "react-i18next"; -import { - Banner, - Button, - Chip, - CodeBlock, - Modal, - StatTile, - StatusBadge, - Tabs, -} from "@app/ui"; -import { - type SdkComponent, - MATURITY_META, - formatPrice, -} from "@portal/api/sdkComponents"; -import { ComponentPropsTable } from "@portal/components/catalogue/ComponentPropsTable"; -import "@portal/views/Components.css"; - -type DetailTab = "overview" | "code" | "props" | "pricing"; - -const TAB_KEYS: DetailTab[] = ["overview", "code", "props", "pricing"]; - -interface ComponentDetailModalProps { - component: SdkComponent | null; - /** False when the open component sits above the active tier. */ - unlocked: boolean; - onClose: () => void; -} - -/** - * Detail overlay for a catalogue component: a live-preview placeholder plus - * Overview / Code / Props / Pricing tabs. Locked components swap the install - * CTA for an upgrade nudge. - */ -export function ComponentDetailModal({ - component, - unlocked, - onClose, -}: ComponentDetailModalProps) { - const { t } = useTranslation(); - const [tab, setTab] = useState("overview"); - - // Reset to the first tab whenever a new component is opened. - const open = component !== null; - if (!component) { - return ( - - ); - } - - const tabs = TAB_KEYS.map((key) => ({ - key, - label: t(`portal.catalogue.detail.tabs.${key}`), - })); - - const maturity = MATURITY_META[component.maturity]; - const npm = `@stirling/${component.package}`; - - return ( - { - onClose(); - setTab("overview"); - }} - width="xl" - title={ - - {component.name} - - {t(maturity.label)} - - - } - subtitle={npm} - footer={ - unlocked ? ( -
    - - {formatPrice(component.pricing, t)} - - -
    - ) : ( - - ) - } - > - {!unlocked && ( - - )} - - {/* Live-preview sandbox — a styled placeholder until a real host mounts. */} -
    - {/* TODO(backend)/host: mount the live here, booting the - component against a demo document and the dev's publishable key. */} - - {t("portal.catalogue.detail.preview.badge")} - - - {t("portal.catalogue.detail.preview.note")} - -
    - - - className="portal-components__tabs" - items={tabs} - activeKey={tab} - onChange={setTab} - variant="underline" - ariaLabel={t("portal.catalogue.detail.tabsAriaLabel")} - /> - -
    - {tab === "overview" && ( -
    -

    - {component.description} -

    -
    - {component.frameworks.map((fw) => ( - - {fw} - - ))} -
    -
    - - - 0 - ? t("portal.catalogue.detail.stats.freeQuotaValue", { - amount: component.pricing.freeQuota.toLocaleString(), - }) - : t("portal.catalogue.detail.stats.none") - } - /> - -
    -
    - )} - - {tab === "code" && ( -
    - - -
    - )} - - {tab === "props" && } - - {tab === "pricing" && ( -
    -
    - - - 0 - ? t("portal.catalogue.detail.stats.freeQuotaValue", { - amount: component.pricing.freeQuota.toLocaleString(), - }) - : t("portal.catalogue.detail.stats.none") - } - /> -
    -

    - {t("portal.catalogue.detail.pricing.note", { - unit: component.pricing.unit, - })} -

    -
    - )} -
    -
    - ); -} diff --git a/frontend/editor/src/portal/components/catalogue/ComponentGrid.stories.tsx b/frontend/editor/src/portal/components/catalogue/ComponentGrid.stories.tsx deleted file mode 100644 index b11c302b60..0000000000 --- a/frontend/editor/src/portal/components/catalogue/ComponentGrid.stories.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { componentsFor } from "@portal/mocks/sdkComponents"; -import { ComponentGrid } from "@portal/components/catalogue/ComponentGrid"; - -const meta: Meta = { - title: "Portal/Components/ComponentGrid", - component: ComponentGrid, - parameters: { layout: "padded" }, - args: { components: componentsFor("pro"), tier: "pro", onOpen: () => {} }, -}; -export default meta; -type Story = StoryObj; - -export const Pro: Story = {}; - -/** Free locks every paid component — the whole grid shows upgrade nudges. */ -export const Free: Story = { - args: { components: componentsFor("free"), tier: "free" }, -}; - -/** Enterprise unlocks the enterprise-only Beta components. */ -export const Enterprise: Story = { - args: { components: componentsFor("enterprise"), tier: "enterprise" }, -}; diff --git a/frontend/editor/src/portal/components/catalogue/ComponentGrid.tsx b/frontend/editor/src/portal/components/catalogue/ComponentGrid.tsx deleted file mode 100644 index 140f71c07f..0000000000 --- a/frontend/editor/src/portal/components/catalogue/ComponentGrid.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { type SdkComponent, isUnlocked } from "@portal/api/sdkComponents"; -import type { Tier } from "@portal/contexts/TierContext"; -import { ComponentCard } from "@portal/components/catalogue/ComponentCard"; -import "@portal/views/Components.css"; - -interface ComponentGridProps { - components: SdkComponent[]; - tier: Tier; - onOpen: (component: SdkComponent) => void; -} - -/** Responsive grid of catalogue cards; locks components above the tier. */ -export function ComponentGrid({ - components, - tier, - onOpen, -}: ComponentGridProps) { - return ( -
    - {components.map((c) => ( - - ))} -
    - ); -} diff --git a/frontend/editor/src/portal/components/catalogue/ComponentPropsTable.stories.tsx b/frontend/editor/src/portal/components/catalogue/ComponentPropsTable.stories.tsx deleted file mode 100644 index 7ac11e6593..0000000000 --- a/frontend/editor/src/portal/components/catalogue/ComponentPropsTable.stories.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { componentsFor } from "@portal/mocks/sdkComponents"; -import { ComponentPropsTable } from "@portal/components/catalogue/ComponentPropsTable"; - -const VIEWER = componentsFor("pro").find((c) => c.id === "viewer")!; - -const meta: Meta = { - title: "Portal/Components/ComponentPropsTable", - component: ComponentPropsTable, - parameters: { layout: "padded" }, - args: { props: VIEWER.props }, -}; -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; diff --git a/frontend/editor/src/portal/components/catalogue/ComponentPropsTable.tsx b/frontend/editor/src/portal/components/catalogue/ComponentPropsTable.tsx deleted file mode 100644 index 9a87fc3c0d..0000000000 --- a/frontend/editor/src/portal/components/catalogue/ComponentPropsTable.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { useMemo } from "react"; -import { useTranslation } from "react-i18next"; -import { Chip, Table, type TableColumn } from "@app/ui"; -import type { ComponentProp } from "@portal/api/sdkComponents"; -import "@portal/views/Components.css"; - -interface ComponentPropsTableProps { - props: ComponentProp[]; -} - -/** Small Props/API reference shown under the detail modal's Props tab. */ -export function ComponentPropsTable({ props: rows }: ComponentPropsTableProps) { - const { t } = useTranslation(); - const columns = useMemo[]>( - () => [ - { - key: "name", - header: t("portal.catalogue.props.columns.name"), - render: (p) => ( - {p.name} - ), - }, - { - key: "type", - header: t("portal.catalogue.props.columns.type"), - render: (p) => ( - {p.type} - ), - }, - { - key: "required", - header: t("portal.catalogue.props.columns.required"), - render: (p) => - p.required ? ( - - {t("portal.catalogue.props.required")} - - ) : ( - - {t("portal.catalogue.props.optional")} - - ), - }, - { - key: "description", - header: t("portal.catalogue.props.columns.description"), - render: (p) => ( - {p.description} - ), - }, - ], - [t], - ); - - return ( - - columns={columns} - rows={rows} - rowKey={(p) => p.name} - /> - ); -} diff --git a/frontend/editor/src/portal/components/catalogue/ComponentsSummaryStrip.tsx b/frontend/editor/src/portal/components/catalogue/ComponentsSummaryStrip.tsx deleted file mode 100644 index ccb3965a5c..0000000000 --- a/frontend/editor/src/portal/components/catalogue/ComponentsSummaryStrip.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { MetricCard, MetricStrip } from "@app/ui"; -import type { ComponentsResponse } from "@portal/api/sdkComponents"; - -/** - * Labels are product copy — they describe what each metric IS, not its value, - * so the strip's structure stays stable across loading / empty / ready states. - * Only values flow from the API. - */ -const KPI_LABEL_KEYS = [ - "portal.catalogue.summary.componentsGa", - "portal.catalogue.summary.inBeta", - "portal.catalogue.summary.embedsThisMonth", - "portal.catalogue.summary.componentSpendMtd", -] as const; - -interface ComponentsSummaryStripProps { - data: ComponentsResponse | null; - loading: boolean; -} - -export function ComponentsSummaryStrip({ - data, - loading, -}: ComponentsSummaryStripProps) { - const { t } = useTranslation(); - const s = loading ? undefined : data?.summary; - const values: (string | number)[] = [ - s?.gaCount ?? "—", - s?.betaCount ?? "—", - s ? s.embedsThisMonth.toLocaleString() : "—", - s ? `$${s.spendThisMonth.toLocaleString()}` : "—", - ]; - - return ( - - {KPI_LABEL_KEYS.map((labelKey, i) => ( - - ))} - - ); -} diff --git a/frontend/editor/src/portal/components/docs/DocsNav.tsx b/frontend/editor/src/portal/components/docs/DocsNav.tsx index 48d325e9ae..1063d5e98f 100644 --- a/frontend/editor/src/portal/components/docs/DocsNav.tsx +++ b/frontend/editor/src/portal/components/docs/DocsNav.tsx @@ -1,8 +1,45 @@ +import { useEffect, useMemo, useRef, useState } from "react"; import { Button, Skeleton, StatusBadge } from "@app/ui"; import { useTranslation } from "react-i18next"; import type { DocsNavSection } from "@portal/api/docs"; -/** Left-hand documentation nav tree; each leaf selects an in-page section. */ +/** + * Left-hand documentation nav: a hierarchical accordion. Section ids encode their + * path ("functionality/security" is a child of "functionality"), so sub-sections + * nest under their parent. The root "Overview" section is static (always open, no + * toggle); every other section collapses, and only the branch leading to the + * active doc opens by default. (Search lives in DocsSearch above this.) + */ + +// Matches the generator's ROOT_SECTION_ID: the intro section is never collapsible. +const STATIC_SECTION_ID = "overview"; + +interface NavNode { + section: DocsNavSection; + children: NavNode[]; +} + +/** Split "a/b/c" → "a/b"; null for a top-level id. */ +function parentId(id: string): string | null { + const i = id.lastIndexOf("/"); + return i === -1 ? null : id.slice(0, i); +} + +/** Build the section tree from the flat, pre-sorted section list. */ +function buildTree(sections: DocsNavSection[]): NavNode[] { + const byId = new Map( + sections.map((s) => [s.id, { section: s, children: [] }]), + ); + const roots: NavNode[] = []; + for (const node of byId.values()) { + const pid = parentId(node.section.id); + const parent = pid ? byId.get(pid) : undefined; + if (parent) parent.children.push(node); + else roots.push(node); + } + return roots; +} + export function DocsNav({ sections, active, @@ -13,50 +50,125 @@ export function DocsNav({ onSelect: (id: string) => void; }) { const { t } = useTranslation(); + // Per-section manual open/close, overriding the "active branch only" default. + const [toggled, setToggled] = useState>({}); + const activeRef = useRef(null); + + const activeSectionId = useMemo( + () => sections.find((s) => s.items.some((i) => i.id === active))?.id, + [sections, active], + ); + + const tree = useMemo(() => buildTree(sections), [sections]); + + // Keep the active item in view when navigating (e.g. via a cross-link). + useEffect(() => { + activeRef.current?.scrollIntoView?.({ block: "nearest" }); + }, [active]); + + const isOpen = (id: string): boolean => { + if (id === STATIC_SECTION_ID) return true; + // Default-open the branch containing the active doc (self or ancestor). + const onActivePath = + !!activeSectionId && + (activeSectionId === id || activeSectionId.startsWith(id + "/")); + return toggled[id] ?? onActivePath; + }; + + const renderNode = (node: NavNode) => { + const { section, children } = node; + const isStatic = section.id === STATIC_SECTION_ID; + const open = isOpen(section.id); + return ( +
    + {isStatic ? ( +
    {section.label}
    + ) : ( + + )} + + {open && ( + <> + {section.items.length > 0 && ( +
      + {section.items.map((item) => { + const isActive = item.id === active; + return ( +
    • + +
    • + ); + })} +
    + )} + {children.length > 0 && ( +
    + {children.map(renderNode)} +
    + )} + + )} +
    + ); + }; + return ( ); } @@ -64,14 +176,16 @@ export function DocsNav({ export function DocsNavSkeleton() { return ( diff --git a/frontend/editor/src/portal/components/docs/DocsSearch.tsx b/frontend/editor/src/portal/components/docs/DocsSearch.tsx new file mode 100644 index 0000000000..49bdb3101f --- /dev/null +++ b/frontend/editor/src/portal/components/docs/DocsSearch.tsx @@ -0,0 +1,137 @@ +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@app/ui"; +import type { SearchResult, Segment } from "@portal/docs/search"; + +/** Render highlighted segments, wrapping matched runs in . */ +function Highlighted({ segments }: { segments: Segment[] }) { + return ( + <> + {segments.map((s, i) => + s.hit ? ( + + {s.text} + + ) : ( + {s.text} + ), + )} + + ); +} + +/** + * Docs search box + results. While a query is active it shows a ranked list of + * matching docs — each with its section, a highlighted title, and a content + * snippet — that navigates on click (or Enter). Arrow keys move the selection. + */ +export function DocsSearch({ + query, + onQueryChange, + results, + onSelect, +}: { + query: string; + onQueryChange: (q: string) => void; + results: SearchResult[]; + onSelect: (docId: string) => void; +}) { + const { t } = useTranslation(); + // -1 = nothing pre-selected; arrow keys drive this, the mouse uses CSS :hover. + const [activeIndex, setActiveIndex] = useState(-1); + const listRef = useRef(null); + const hasQuery = query.trim().length > 0; + + useEffect(() => setActiveIndex(-1), [query]); + + useEffect(() => { + listRef.current + ?.querySelector('[data-active="true"]') + ?.scrollIntoView?.({ block: "nearest" }); + }, [activeIndex]); + + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + onQueryChange(""); + return; + } + if (!results.length) return; + if (e.key === "ArrowDown") { + e.preventDefault(); + setActiveIndex((i) => Math.min(i + 1, results.length - 1)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setActiveIndex((i) => Math.max(i - 1, 0)); + } else if (e.key === "Enter") { + e.preventDefault(); + const hit = results[activeIndex >= 0 ? activeIndex : 0]; + if (hit) onSelect(hit.id); + } + }; + + return ( +
    +
    + + ⌕ + + onQueryChange(e.target.value)} + onKeyDown={onKeyDown} + aria-label={t("portal.docs.search.placeholder")} + /> +
    + + {hasQuery && ( +
    + {results.length === 0 ? ( +

    + {t("portal.docs.search.empty")} +

    + ) : ( + <> +
    + {t("portal.docs.search.results", { count: results.length })} +
    +
      + {results.map((r, i) => ( +
    • + +
    • + ))} +
    + + )} +
    + )} +
    + ); +} diff --git a/frontend/editor/src/portal/components/docs/DocsToc.tsx b/frontend/editor/src/portal/components/docs/DocsToc.tsx new file mode 100644 index 0000000000..1f6ee6ebad --- /dev/null +++ b/frontend/editor/src/portal/components/docs/DocsToc.tsx @@ -0,0 +1,80 @@ +import { useEffect, useState, type RefObject } from "react"; +import { useTranslation } from "react-i18next"; +import type { Heading } from "@portal/docs/headings"; + +/** + * "On this page" table of contents. Lists the current doc's H2/H3 headings, + * scrolls the reading pane to a heading on click, and highlights the section + * currently in view (scroll-spy against the pane's scroll container). + */ +export function DocsToc({ + headings, + scrollRef, +}: { + headings: Heading[]; + scrollRef: RefObject; +}) { + const { t } = useTranslation(); + const [active, setActive] = useState(headings[0]?.slug ?? ""); + + useEffect(() => { + const root = scrollRef.current; + if (!root || headings.length === 0) return; + setActive(headings[0].slug); + + const visible = new Set(); + const observer = new IntersectionObserver( + (entries) => { + for (const e of entries) { + if (e.isIntersecting) visible.add(e.target.id); + else visible.delete(e.target.id); + } + // The topmost heading currently within the active zone wins. + const current = headings.find((h) => visible.has(h.slug)); + if (current) setActive(current.slug); + }, + // Active zone = the top ~30% of the reading pane. + { root, rootMargin: "0px 0px -70% 0px", threshold: 0 }, + ); + + const els = headings + .map((h) => root.querySelector(`[id="${h.slug}"]`)) + .filter((el): el is Element => el !== null); + els.forEach((el) => observer.observe(el)); + return () => observer.disconnect(); + }, [headings, scrollRef]); + + const onSelect = (slug: string) => { + scrollRef.current + ?.querySelector(`[id="${slug}"]`) + ?.scrollIntoView({ block: "start", behavior: "smooth" }); + setActive(slug); + }; + + return ( + + ); +} diff --git a/frontend/editor/src/portal/components/docs/MarkdownDoc.tsx b/frontend/editor/src/portal/components/docs/MarkdownDoc.tsx new file mode 100644 index 0000000000..faf3339478 --- /dev/null +++ b/frontend/editor/src/portal/components/docs/MarkdownDoc.tsx @@ -0,0 +1,131 @@ +import { isValidElement, useState, type ReactNode } from "react"; +import ReactMarkdown, { + defaultUrlTransform, + type Components, +} from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { Button } from "@app/ui"; +import { makeSlugger } from "@portal/docs/headings"; + +/** Flatten a heading's React children to plain text for its anchor id. */ +function childText(node: ReactNode): string { + if (typeof node === "string" || typeof node === "number") return String(node); + if (Array.isArray(node)) return node.map(childText).join(""); + if (isValidElement(node)) { + return childText((node.props as { children?: ReactNode }).children); + } + return ""; +} + +// Keep our internal `doc:` scheme; sanitize every other URL as react-markdown +// would by default (it strips unknown protocols, which would kill doc: links). +function urlTransform(url: string): string { + return url.startsWith("doc:") ? url : defaultUrlTransform(url); +} + +/** + * Renders a doc's normalised markdown. Internal cross-doc links carry the + * `doc:` scheme (see the sync transform) and are intercepted here so they + * navigate within the portal instead of leaving the app. + */ + +function CopyButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false); + return ( + + ); +} + +function buildComponents( + onNavigate: (docId: string) => void, + slug: (text: string) => string, +): Components { + return { + h2: ({ children }) =>

    {children}

    , + h3: ({ children }) =>

    {children}

    , + a: ({ href, children }) => { + if (href?.startsWith("doc:")) { + const id = href.slice(4); + return ( + { + e.preventDefault(); + onNavigate(id); + }} + > + {children} + + ); + } + const external = /^https?:/i.test(href ?? ""); + return ( + + {children} + + ); + }, + // Eager, not lazy: lazy-loading inside the docs' own scroll container isn't + // reliably triggered, and docs pages have only a handful of images. + img: ({ node: _node, ...props }) => ( + + ), + pre: ({ children }) => { + const code = isValidElement(children) + ? String( + (children.props as { children?: unknown }).children ?? "", + ).replace(/\n$/, "") + : String(children ?? ""); + return ( +
    +
    {children}
    + +
    + ); + }, + table: ({ children }) => ( +
    +
    {children}
    +
    + ), + }; +} + +export function MarkdownDoc({ + markdown, + onNavigate, +}: { + markdown: string; + onNavigate: (docId: string) => void; +}) { + // A fresh de-duping slugger per render; react-markdown invokes h2/h3 in + // document order, so ids line up with the TOC's extractHeadings slugs. + const slug = makeSlugger(); + return ( +
    + + {markdown} + +
    + ); +} diff --git a/frontend/editor/src/portal/components/documents/ReviewQueue.tsx b/frontend/editor/src/portal/components/documents/ReviewQueue.tsx index 240d61f782..2200237cf6 100644 --- a/frontend/editor/src/portal/components/documents/ReviewQueue.tsx +++ b/frontend/editor/src/portal/components/documents/ReviewQueue.tsx @@ -157,7 +157,7 @@ export function ReviewQueue({ documents, loading }: ReviewQueueProps) {
    -
    -
    {t("portal.infrastructure.apiKeys.card.rateLimit")}
    -
    - {t("portal.infrastructure.apiKeys.card.rateLimitValue", { - value: apiKey.rateLimit.toLocaleString(), - })} -
    -
    {t("portal.infrastructure.apiKeys.card.usageToday")}
    @@ -71,36 +71,20 @@ export function ApiKeyCard({ apiKey }: { apiKey: ApiKey }) { {apiKey.usageMonth.toLocaleString()}
    -
    -
    {t("portal.infrastructure.apiKeys.card.permissions")}
    -
    - {apiKey.permissions.map((p) => ( - - {t( - `portal.infrastructure.apiKeyPermission.${p.toLowerCase()}`, - p, - )} - - ))} -
    -
    -
    -
    {t("portal.infrastructure.apiKeys.card.allowedIps")}
    -
    - {apiKey.allowedIps.length === 0 ? ( - - {t("portal.infrastructure.apiKeys.card.anyIp")} - - ) : ( - apiKey.allowedIps.map((ip) => ( - - {ip} - - )) - )} -
    -
    + + {revocable && ( +
    + +
    + )}
    )} diff --git a/frontend/editor/src/portal/components/infrastructure/ApiKeysTab.stories.tsx b/frontend/editor/src/portal/components/infrastructure/ApiKeysTab.stories.tsx index 7743dd4fdf..7b271f135d 100644 --- a/frontend/editor/src/portal/components/infrastructure/ApiKeysTab.stories.tsx +++ b/frontend/editor/src/portal/components/infrastructure/ApiKeysTab.stories.tsx @@ -20,14 +20,19 @@ type Story = StoryObj; export const Default: Story = {}; +const EMPTY = { keys: [] }; + export const Loading: Story = { parameters: { msw: { handlers: [ - http.get("/v1/infrastructure/api-keys", async () => { - await delay("infinite"); - return HttpResponse.json([]); - }), + http.get( + "*/api/v1/proprietary/ui-data/infrastructure/api-keys", + async () => { + await delay("infinite"); + return HttpResponse.json(EMPTY); + }, + ), ], }, }, @@ -37,7 +42,9 @@ export const Empty: Story = { parameters: { msw: { handlers: [ - http.get("/v1/infrastructure/api-keys", () => HttpResponse.json([])), + http.get("*/api/v1/proprietary/ui-data/infrastructure/api-keys", () => + HttpResponse.json(EMPTY), + ), ], }, }, diff --git a/frontend/editor/src/portal/components/infrastructure/ApiKeysTab.test.tsx b/frontend/editor/src/portal/components/infrastructure/ApiKeysTab.test.tsx new file mode 100644 index 0000000000..955124dea7 --- /dev/null +++ b/frontend/editor/src/portal/components/infrastructure/ApiKeysTab.test.tsx @@ -0,0 +1,124 @@ +import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { MantineProvider } from "@mantine/core"; +import type { ApiKey } from "@portal/api/infrastructure"; + +// Deterministic i18n: keys returned verbatim, so assertions are stable. +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { changeLanguage: vi.fn() }, + }), +})); + +// Stub the API layer so no real request is made. vi.hoisted keeps the mock fns +// defined before the hoisted vi.mock factory runs; createApiKey is present +// because the CreateKeyModal child imports it from the same module. +const { fetchApiKeys, revokeApiKey, createApiKey } = vi.hoisted(() => ({ + fetchApiKeys: vi.fn(), + revokeApiKey: vi.fn(), + createApiKey: vi.fn(), +})); +vi.mock("@portal/api/infrastructure", () => ({ + fetchApiKeys, + revokeApiKey, + createApiKey, +})); + +import { ApiKeysTab } from "@portal/components/infrastructure/ApiKeysTab"; + +const K = "portal.infrastructure.apiKeys"; + +function apiKey(overrides: Partial = {}): ApiKey { + return { + id: "1", + name: "Production ingest", + prefix: "sk_a1b2c3d4", + created: "2026-07-10", + lastUsed: "2026-07-15 09:30", + status: "active", + usageToday: 12, + usageMonth: 340, + usageTotal: 9001, + ...overrides, + }; +} + +function renderTab() { + return render( + + + , + ); +} + +describe("ApiKeysTab", () => { + it("renders the empty state when the caller has no keys", async () => { + fetchApiKeys.mockResolvedValueOnce({ keys: [] }); + renderTab(); + + expect(await screen.findByText(`${K}.empty.title`)).toBeInTheDocument(); + }); + + it("lists keys with their prefix, and keeps revoked keys visible", async () => { + fetchApiKeys.mockResolvedValueOnce({ + keys: [ + apiKey({ id: "1", name: "Production ingest", prefix: "sk_a1b2c3d4" }), + apiKey({ + id: "2", + name: "Old key", + prefix: "sk_z9y8x7w6", + status: "revoked", + }), + ], + }); + renderTab(); + + expect(await screen.findByText("Production ingest")).toBeInTheDocument(); + expect(screen.getByText("sk_a1b2c3d4")).toBeInTheDocument(); + expect(screen.getByText("Old key")).toBeInTheDocument(); + }); + + it("surfaces a load error instead of a misleading empty state", async () => { + fetchApiKeys.mockRejectedValueOnce(new Error("boom")); + renderTab(); + + expect(await screen.findByText(`${K}.error.load`)).toBeInTheDocument(); + // A failed load must not render as "no keys yet". + expect(screen.queryByText(`${K}.empty.title`)).not.toBeInTheDocument(); + }); + + it("revokes a key after confirmation and reloads the list", async () => { + fetchApiKeys + .mockResolvedValueOnce({ + keys: [apiKey({ id: "7", name: "Doomed key" })], + }) + .mockResolvedValueOnce({ + keys: [apiKey({ id: "7", name: "Doomed key", status: "revoked" })], + }); + revokeApiKey.mockResolvedValueOnce(undefined); + renderTab(); + + // Expand the card so the revoke action is reachable. + fireEvent.click(await screen.findByText("Doomed key")); + fireEvent.click( + await screen.findByRole("button", { name: `${K}.card.revoke` }), + ); + + // Confirm in the dialog (a distinct i18n key from the card action). + const confirm = await screen.findByRole("button", { + name: `${K}.revoke.confirm`, + }); + fireEvent.click(confirm); + + // The revoke targets the right key, then the confirm dialog closes and the + // list re-fetches to reflect the new state. + await waitFor(() => expect(revokeApiKey).toHaveBeenCalledWith("7")); + await waitFor(() => + expect( + screen.queryByRole("button", { name: `${K}.revoke.confirm` }), + ).not.toBeInTheDocument(), + ); + expect(fetchApiKeys.mock.calls.length).toBeGreaterThanOrEqual(2); + }); +}); diff --git a/frontend/editor/src/portal/components/infrastructure/ApiKeysTab.tsx b/frontend/editor/src/portal/components/infrastructure/ApiKeysTab.tsx index a93b2c0c56..6a2817d12d 100644 --- a/frontend/editor/src/portal/components/infrastructure/ApiKeysTab.tsx +++ b/frontend/editor/src/portal/components/infrastructure/ApiKeysTab.tsx @@ -1,20 +1,48 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { Button, EmptyState, Skeleton } from "@app/ui"; -import { useTier } from "@portal/contexts/TierContext"; -import { useAsync, useSectionFlags } from "@portal/hooks/useAsync"; -import { fetchApiKeys, type ApiKey } from "@portal/api/infrastructure"; +import { Banner, Button, EmptyState, Modal, Skeleton } from "@app/ui"; +import { useAsync } from "@portal/hooks/useAsync"; +import { + fetchApiKeys, + revokeApiKey, + type ApiKey, + type ApiKeysResponse, +} from "@portal/api/infrastructure"; +import { errorMessage } from "@portal/api/http"; import { ApiKeyCard } from "@portal/components/infrastructure/ApiKeyCard"; import { CreateKeyModal } from "@portal/components/infrastructure/CreateKeyModal"; import { SectionHeader } from "@portal/components/infrastructure/SectionHeader"; export function ApiKeysTab() { const { t } = useTranslation(); - const { tier } = useTier(); const [modalOpen, setModalOpen] = useState(false); - const state = useAsync(() => fetchApiKeys(tier), [tier]); - const { data: keys } = state; - const { isLoading, isEmpty } = useSectionFlags(state); + const [reloadKey, setReloadKey] = useState(0); + const [error, setError] = useState(null); + const [pendingRevoke, setPendingRevoke] = useState(null); + const [revoking, setRevoking] = useState(false); + const state = useAsync(() => fetchApiKeys(), [reloadKey]); + const { data, loading, error: loadError } = state; + + const reload = () => setReloadKey((n) => n + 1); + const keys = data?.keys ?? []; + const isLoading = loading && data === null; + // A failed load must not masquerade as a genuinely empty list. + const isEmpty = !loading && !loadError && keys.length === 0; + + async function confirmRevoke() { + if (!pendingRevoke) return; + setError(null); + setRevoking(true); + try { + await revokeApiKey(pendingRevoke.id); + setPendingRevoke(null); + reload(); + } catch (e) { + setError(errorMessage(e)); + } finally { + setRevoking(false); + } + } return (
    @@ -32,6 +60,14 @@ export function ApiKeysTab() {
    + {error && } + {!loading && loadError && ( + + )} + {isLoading && (
    {Array.from({ length: 3 }).map((_, i) => ( @@ -48,15 +84,52 @@ export function ApiKeysTab() { /> )} - {keys && keys.length > 0 && ( + {keys.length > 0 && (
    {keys.map((k) => ( - + ))}
    )} - setModalOpen(false)} /> + setModalOpen(false)} + onCreated={reload} + /> + + !revoking && setPendingRevoke(null)} + width="sm" + title={t("portal.infrastructure.apiKeys.revoke.title")} + footer={ +
    + + +
    + } + > +

    + {t("portal.infrastructure.apiKeys.revoke.body", { + name: pendingRevoke?.name ?? "", + })} +

    +
    ); } diff --git a/frontend/editor/src/portal/components/infrastructure/AuditTab.stories.tsx b/frontend/editor/src/portal/components/infrastructure/AuditTab.stories.tsx index 3bb2d6dc3c..3733b2aca9 100644 --- a/frontend/editor/src/portal/components/infrastructure/AuditTab.stories.tsx +++ b/frontend/editor/src/portal/components/infrastructure/AuditTab.stories.tsx @@ -42,7 +42,13 @@ export const Empty: Story = { handlers: [ http.get("*/api/v1/proprietary/ui-data/infrastructure/audit-log", () => HttpResponse.json({ - summary: { totalEvents: 0, processing: 0, elevation: 0, config: 0 }, + summary: { + totalEvents: 0, + policy: 0, + processing: 0, + elevation: 0, + config: 0, + }, events: [], fullServer: true, }), @@ -74,7 +80,13 @@ export const TeamLeadScoped: Story = { handlers: [ http.get("*/api/v1/proprietary/ui-data/infrastructure/audit-log", () => HttpResponse.json({ - summary: { totalEvents: 4, processing: 2, elevation: 0, config: 1 }, + summary: { + totalEvents: 4, + policy: 0, + processing: 2, + elevation: 0, + config: 1, + }, events: [ { id: "9102", diff --git a/frontend/editor/src/portal/components/infrastructure/AuditTab.tsx b/frontend/editor/src/portal/components/infrastructure/AuditTab.tsx index b27d57995e..a415abf107 100644 --- a/frontend/editor/src/portal/components/infrastructure/AuditTab.tsx +++ b/frontend/editor/src/portal/components/infrastructure/AuditTab.tsx @@ -12,13 +12,12 @@ import { type TableColumn, } from "@app/ui"; import { useTier } from "@portal/contexts/TierContext"; -import { useAsync, useSectionFlags } from "@portal/hooks/useAsync"; +import { useSectionFlags } from "@portal/hooks/useAsync"; +import { useAuditLog } from "@portal/queries/infrastructure"; import { HttpError } from "@portal/api/http"; import { - fetchAuditLog, type AuditCategory, type AuditEvent, - type AuditLogResponse, } from "@portal/api/infrastructure"; import { AuditExportModal } from "@portal/components/infrastructure/AuditExportModal"; import { SectionHeader } from "@portal/components/infrastructure/SectionHeader"; @@ -108,7 +107,7 @@ export function AuditTab() { }, ]; - const state = useAsync(() => fetchAuditLog(tier), [tier]); + const state = useAuditLog(tier); const { data, error } = state; const { isLoading, isEmpty } = useSectionFlags(state); // Backend returns 403 for scoped-out callers; show an access message, not an empty state. diff --git a/frontend/editor/src/portal/components/infrastructure/CreateKeyModal.stories.tsx b/frontend/editor/src/portal/components/infrastructure/CreateKeyModal.stories.tsx index f5e21035a6..ba5e3bc6a5 100644 --- a/frontend/editor/src/portal/components/infrastructure/CreateKeyModal.stories.tsx +++ b/frontend/editor/src/portal/components/infrastructure/CreateKeyModal.stories.tsx @@ -6,7 +6,11 @@ const meta: Meta = { title: "Portal/Infrastructure/CreateKeyModal", component: CreateKeyModal, parameters: { layout: "fullscreen" }, - args: { open: true, onClose: () => console.log("close") }, + args: { + open: true, + onClose: () => console.log("close"), + onCreated: () => console.log("created"), + }, }; export default meta; type Story = StoryObj; diff --git a/frontend/editor/src/portal/components/infrastructure/CreateKeyModal.test.tsx b/frontend/editor/src/portal/components/infrastructure/CreateKeyModal.test.tsx new file mode 100644 index 0000000000..435307823a --- /dev/null +++ b/frontend/editor/src/portal/components/infrastructure/CreateKeyModal.test.tsx @@ -0,0 +1,63 @@ +import type { ComponentProps } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { MantineProvider } from "@mantine/core"; + +// Deterministic i18n: keys returned verbatim, so assertions are stable. +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { changeLanguage: vi.fn() }, + }), +})); + +// Stub the API layer so no real request is made; capture the create payload. +// vi.hoisted keeps the mock fn defined before the hoisted vi.mock factory runs. +const { createApiKey } = vi.hoisted(() => ({ createApiKey: vi.fn() })); +vi.mock("@portal/api/infrastructure", () => ({ createApiKey })); + +import { CreateKeyModal } from "@portal/components/infrastructure/CreateKeyModal"; + +const K = "portal.infrastructure.createKey"; + +function renderModal(props: Partial>) { + return render( + + {}} onCreated={() => {}} {...props} /> + , + ); +} + +describe("CreateKeyModal", () => { + it("gates the create button on a non-empty name", () => { + renderModal({}); + const cta = screen.getByRole("button", { name: `${K}.createKey` }); + expect(cta).toBeDisabled(); + + fireEvent.change(screen.getByPlaceholderText(`${K}.keyNamePlaceholder`), { + target: { value: "Production ingest" }, + }); + expect(cta).toBeEnabled(); + }); + + it("creates a key and reveals the returned secret", async () => { + createApiKey.mockResolvedValueOnce({ + key: { id: "1", name: "Production ingest" }, + secret: "sk_live_demo_key_rotate_in_prod", + }); + const onCreated = vi.fn(); + renderModal({ onCreated }); + + fireEvent.change(screen.getByPlaceholderText(`${K}.keyNamePlaceholder`), { + target: { value: "Production ingest" }, + }); + fireEvent.click(screen.getByRole("button", { name: `${K}.createKey` })); + + expect(await screen.findByText(`${K}.secretWarning`)).toBeInTheDocument(); + expect( + screen.getByText("sk_live_demo_key_rotate_in_prod"), + ).toBeInTheDocument(); + await waitFor(() => expect(onCreated).toHaveBeenCalled()); + expect(createApiKey).toHaveBeenCalledWith({ name: "Production ingest" }); + }); +}); diff --git a/frontend/editor/src/portal/components/infrastructure/CreateKeyModal.tsx b/frontend/editor/src/portal/components/infrastructure/CreateKeyModal.tsx index 8461b9dd43..9a51fb176b 100644 --- a/frontend/editor/src/portal/components/infrastructure/CreateKeyModal.tsx +++ b/frontend/editor/src/portal/components/infrastructure/CreateKeyModal.tsx @@ -1,40 +1,30 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { - Banner, - Button, - Checkbox, - CodeBlock, - FormField, - Input, - Modal, -} from "@app/ui"; -import type { ApiKeyPermission } from "@portal/api/infrastructure"; - -const PERMISSION_OPTS: ApiKeyPermission[] = ["Read", "Write", "Admin"]; - -// Shown once after a key is created. TODO(backend): use the one-time secret -// returned by POST /v1/infrastructure/api-keys — it is never persisted server-side. -const DEMO_NEW_KEY_SECRET = "sk_live_demo_key_rotate_in_prod"; +import { Banner, Button, CodeBlock, FormField, Input, Modal } from "@app/ui"; +import { createApiKey, type CreatedApiKey } from "@portal/api/infrastructure"; +import { errorMessage } from "@portal/api/http"; export function CreateKeyModal({ open, onClose, + onCreated, }: { open: boolean; onClose: () => void; + /** Called after a successful create so the tab can refresh its list. */ + onCreated: () => void; }) { const { t } = useTranslation(); const [name, setName] = useState(""); - const [perms, setPerms] = useState(["Read"]); - const [ips, setIps] = useState(""); - const [created, setCreated] = useState(false); + const [created, setCreated] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); function reset() { setName(""); - setPerms(["Read"]); - setIps(""); - setCreated(false); + setCreated(null); + setSubmitting(false); + setError(null); } function close() { @@ -43,16 +33,18 @@ export function CreateKeyModal({ setTimeout(reset, 200); } - function togglePerm(p: ApiKeyPermission) { - setPerms((prev) => - prev.includes(p) ? prev.filter((x) => x !== p) : [...prev, p], - ); - } - - function createKey() { - // TODO(backend): POST /v1/infrastructure/api-keys { name, perms, ips } - // and render the one-time secret from the response instead of the fixture. - setCreated(true); + async function createKey() { + setSubmitting(true); + setError(null); + try { + const result = await createApiKey({ name: name.trim() }); + setCreated(result); + onCreated(); + } catch (e) { + setError(errorMessage(e)); + } finally { + setSubmitting(false); + } } return ( @@ -72,7 +64,7 @@ export function CreateKeyModal({ } footer={ created ? ( - ) : ( @@ -82,8 +74,7 @@ export function CreateKeyModal({ - )} - - ))} + + {p.name} + {p.detail} + + {p.connected ? ( + + + {t("portal.infrastructure.storage.gbValue", { + value: p.usedGb, + })} + + + {t("portal.infrastructure.storage.providers.connected")} + + + ) : ( + // TODO(backend): launch the provider OAuth/credential flow, + // then POST /v1/infrastructure/storage/providers/{id}/connect + + )} + + ); + })} @@ -205,7 +211,7 @@ export function StorageTab() {
    - → +
    @@ -217,7 +223,7 @@ export function StorageTab() {
    - → +
    diff --git a/frontend/editor/src/portal/components/infrastructure/infraFormat.ts b/frontend/editor/src/portal/components/infrastructure/infraFormat.ts index 8771712ecb..6e4cf31a77 100644 --- a/frontend/editor/src/portal/components/infrastructure/infraFormat.ts +++ b/frontend/editor/src/portal/components/infrastructure/infraFormat.ts @@ -55,13 +55,11 @@ export const DEPLOY_LABEL: Record = { export const KEY_TONE: Record = { active: "success", revoked: "danger", - "rotate-soon": "warning", }; export const KEY_LABEL: Record = { active: "portal.infrastructure.keyLabel.active", revoked: "portal.infrastructure.keyLabel.revoked", - "rotate-soon": "portal.infrastructure.keyLabel.rotateSoon", }; export const CERT_TONE: Record = { diff --git a/frontend/editor/src/portal/components/pipelines/DestinationPicker.tsx b/frontend/editor/src/portal/components/pipelines/DestinationPicker.tsx new file mode 100644 index 0000000000..683c6301b4 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/DestinationPicker.tsx @@ -0,0 +1,62 @@ +import { useTranslation } from "react-i18next"; +import AddRoundedIcon from "@mui/icons-material/AddRounded"; +import { Button, Checkbox } from "@app/ui"; + +/** + * Picks the saved sources a pipeline delivers its output to. A destination is just + * a source used as a write target, and a pipeline may write to several, so this is + * a checklist over the same locations the builder loaded (filtered to writable + * types by the caller) - mirroring the input-sources checklist. Creating a new one + * is delegated to {@code onCreateNew} (the builder navigates to the source builder, + * prompting about unsaved edits first). + */ +interface DestinationOption { + id: string; + name: string; +} + +interface DestinationPickerProps { + sources: DestinationOption[]; + value: string[]; + onChange: (outputIds: string[]) => void; + /** Leave the builder to create a new source location (navigate-away, like inputs). */ + onCreateNew: () => void; +} + +export function DestinationPicker({ + sources, + value, + onChange, + onCreateNew, +}: DestinationPickerProps) { + const { t } = useTranslation(); + + function toggle(id: string, checked: boolean) { + onChange( + checked ? [...value, id] : value.filter((existing) => existing !== id), + ); + } + + return ( + <> +
    + {sources.map((source) => ( + toggle(source.id, e.target.checked)} + label={source.name} + /> + ))} +
    + + + ); +} diff --git a/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.stories.tsx b/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.stories.tsx new file mode 100644 index 0000000000..d2bd6bc6f2 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.stories.tsx @@ -0,0 +1,76 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { ToolRegistry } from "@app/data/toolsTaxonomy"; +import type { WorkingToolStep } from "@app/hooks/tools/shared/toolAutomation"; +import { PipelineStepSettings } from "@portal/components/pipelines/PipelineStepSettings"; + +/** Stand-in for a tool's real automation settings UI. */ +function MockCompressSettings({ + parameters, + onParameterChange, +}: { + parameters: Record; + onParameterChange: (key: string, value: unknown) => void; +}) { + return ( + + ); +} + +const editableStep = { + support: "editable", + toolId: "compress", + params: { level: 5 }, +} as unknown as WorkingToolStep; + +const noSettingsStep = { + support: "noSettings", + toolId: "flatten", + params: {}, +} as unknown as WorkingToolStep; + +const unsupportedStep = { + support: "unsupported", + toolId: "convert", + params: {}, +} as unknown as WorkingToolStep; + +const registry = { + compress: { automationSettings: MockCompressSettings }, +} as unknown as Partial; + +const meta = { + title: "Portal/Pipelines/PipelineStepSettings", + component: PipelineStepSettings, + args: { + step: editableStep, + registry, + onChange: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** A tool with an editable settings UI, rendered via the tool's own component. */ +export const Editable: Story = {}; + +/** A migrated tool with no configurable parameters — shows an informational note. */ +export const NoSettings: Story = { + args: { + step: noSettingsStep, + }, +}; + +/** A tool not yet migrated to the automation mapper seam — shows a fallback warning. */ +export const Unsupported: Story = { + args: { + step: unsupportedStep, + registry: {}, + }, +}; diff --git a/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.test.tsx b/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.test.tsx new file mode 100644 index 0000000000..698fb0028a --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.test.tsx @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { MantineProvider } from "@mantine/core"; +import { Tooltip } from "@app/components/shared/Tooltip"; +import type { ToolRegistry } from "@app/data/toolsTaxonomy"; +import type { WorkingToolStep } from "@app/hooks/tools/shared/toolAutomation"; +import { PipelineStepSettings } from "@portal/components/pipelines/PipelineStepSettings"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, fallback?: string) => fallback ?? key, + }), +})); + +// A stand-in tool-settings UI that uses the shared editor Tooltip. The Tooltip +// pulls in the Preferences + Sidebar contexts, which the portal does not mount +// app-wide — so this reproduces the "usePreferences must be used within a +// PreferencesProvider" crash unless PipelineStepSettings supplies them. +function TooltipSettings() { + return ( + + + + ); +} + +const step = { + support: "editable", + toolId: "compress", + params: {}, +} as unknown as WorkingToolStep; + +const registry = { + compress: { automationSettings: TooltipSettings }, +} as unknown as Partial; + +describe("PipelineStepSettings", () => { + it("renders reused editor tool settings (which use the shared Tooltip) without app-wide Preferences/Sidebar providers", () => { + expect(() => + render( + + {}} + /> + , + ), + ).not.toThrow(); + expect(screen.getByText("field")).toBeInTheDocument(); + }); +}); diff --git a/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.tsx b/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.tsx index fe2ee9dd2a..213217de5b 100644 --- a/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.tsx +++ b/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.tsx @@ -1,10 +1,16 @@ import { Suspense } from "react"; import { useTranslation } from "react-i18next"; import { Banner } from "@app/ui"; +import { PreferencesProvider } from "@app/contexts/PreferencesContext"; +import { SidebarProvider } from "@app/contexts/SidebarContext"; import { type ToolRegistry } from "@app/data/toolsTaxonomy"; import { type ErasedToolParams } from "@app/hooks/tools/shared/toolOperationTypes"; import { type WorkingToolStep } from "@app/hooks/tools/shared/toolAutomation"; +import { PolicyExternalApiConfig } from "@portal/components/policies/PolicyExternalApiConfig"; +import { isIntegrationStep } from "@portal/components/pipelines/integrationStep"; +import type { ExternalApiStepParams } from "@portal/components/policies/stepOperations"; + interface PipelineStepSettingsProps { step: WorkingToolStep; registry: Partial; @@ -21,8 +27,21 @@ export function PipelineStepSettings({ registry, onChange, }: PipelineStepSettingsProps) { + // Hooks first: selecting a different step re-renders this same instance, so an early return + // above useTranslation would change the hook count between renders and crash. const { t } = useTranslation(); + // An integration step is configured by the operations catalogue, not by a tool's settings UI: + // it has no registry entry to look one up from. + if (isIntegrationStep(step)) { + return ( + onChange(params as never)} + /> + ); + } + if (step.support === "noSettings") { return ( - - onChange({ ...step.params, [key]: value }) - } - disabled={false} - /> - + + + + + onChange({ ...step.params, [key]: value }) + } + disabled={false} + /> + + + ); } diff --git a/frontend/editor/src/portal/components/pipelines/PipelinesTable.stories.tsx b/frontend/editor/src/portal/components/pipelines/PipelinesTable.stories.tsx new file mode 100644 index 0000000000..3f18b8e53f --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelinesTable.stories.tsx @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { PipelineView } from "@portal/api/pipelines"; +import { PipelinesTable } from "@portal/components/pipelines/PipelinesTable"; + +const PIPELINES: PipelineView[] = [ + { + id: "pipe-intake", + name: "Claims intake", + enabled: true, + status: "active", + trigger: "folder-watch", + sources: [{ id: "src-claims", name: "Claims intake" }], + steps: ["redact", "sanitize", "watermark"], + output: "folder", + owner: "jane@stirlingpdf.com", + }, + { + id: "pipe-archive", + name: "Archive reprocess", + enabled: false, + status: "paused", + trigger: "manual", + sources: [], + steps: [], + output: "inline", + owner: "jane@stirlingpdf.com", + }, +]; + +const meta: Meta = { + title: "Portal/Pipelines/PipelinesTable", + component: PipelinesTable, + parameters: { layout: "padded" }, + args: { pipelines: PIPELINES, onRowClick: () => {} }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Empty: Story = { + args: { pipelines: [] }, +}; diff --git a/frontend/editor/src/portal/components/pipelines/ToolPicker.stories.tsx b/frontend/editor/src/portal/components/pipelines/ToolPicker.stories.tsx new file mode 100644 index 0000000000..3035a9b343 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/ToolPicker.stories.tsx @@ -0,0 +1,70 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { SubcategoryId } from "@app/data/toolsTaxonomy"; +import { type ExecutableTool } from "@app/hooks/tools/shared/toolAutomation"; +import { ToolPicker } from "@portal/components/pipelines/ToolPicker"; + +const tools: ExecutableTool[] = [ + { + toolId: "merge", + name: "Merge", + icon: "🧩", + subcategoryId: SubcategoryId.GENERAL, + endpoint: "/api/v1/general/merge-pdfs", + support: "noSettings", + }, + { + toolId: "split", + name: "Split", + icon: "✂ï¸", + subcategoryId: SubcategoryId.GENERAL, + endpoint: "/api/v1/general/split-pages", + support: "editable", + }, + { + toolId: "watermark", + name: "Watermark", + icon: "💧", + subcategoryId: SubcategoryId.DOCUMENT_SECURITY, + endpoint: "/api/v1/security/add-watermark", + support: "editable", + }, + { + toolId: "removePassword", + name: "Remove password", + icon: "🔓", + subcategoryId: SubcategoryId.DOCUMENT_SECURITY, + endpoint: "/api/v1/security/remove-password", + support: "editable", + }, + { + toolId: "ocr", + name: "OCR", + icon: "ðŸ”", + subcategoryId: SubcategoryId.EXTRACTION, + endpoint: "/api/v1/misc/ocr-pdf", + support: "editable", + }, +]; + +const meta = { + title: "Portal/Pipelines/ToolPicker", + component: ToolPicker, + parameters: { layout: "padded" }, + args: { + tools, + onPick: () => {}, + onClose: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** Full tool list grouped by category, as offered when adding a pipeline step. */ +export const Default: Story = {}; + +/** No tools match the registry (or the search filter), showing the empty state. */ +export const NoMatches: Story = { + args: { + tools: [], + }, +}; diff --git a/frontend/editor/src/portal/components/pipelines/ToolPicker.tsx b/frontend/editor/src/portal/components/pipelines/ToolPicker.tsx index e031854ec1..99395a0ffe 100644 --- a/frontend/editor/src/portal/components/pipelines/ToolPicker.tsx +++ b/frontend/editor/src/portal/components/pipelines/ToolPicker.tsx @@ -7,21 +7,47 @@ import { type SubcategoryId, } from "@app/data/toolsTaxonomy"; import { type ExecutableTool } from "@app/hooks/tools/shared/toolAutomation"; +import { + searchOperations, + type StepOperation, +} from "@portal/components/policies/stepOperations"; +import { BrandMark } from "@portal/components/BrandMarks"; interface ToolPickerProps { tools: ExecutableTool[]; onPick: (tool: ExecutableTool) => void; onClose: () => void; + /** + * Catalogue operations that hand the document to an outside system. Kept apart from the tool + * groups because they are a different species - a tool transforms the document in place, these + * call somebody else - and grouping them under a tool subcategory would bury that. + */ + operations?: StepOperation[]; + onPickOperation?: (operation: StepOperation) => void; } /** * Type-to-filter, category-grouped tool picker for adding a step to a pipeline. Replaces the flat * wall of tool pills so the list stays usable as the tool count grows. */ -export function ToolPicker({ tools, onPick, onClose }: ToolPickerProps) { +export function ToolPicker({ + tools, + onPick, + onClose, + operations = [], + onPickOperation, +}: ToolPickerProps) { const { t } = useTranslation(); const [query, setQuery] = useState(""); + const matchedOperations = useMemo( + () => + onPickOperation + ? searchOperations(operations, query, (key) => t(key)) + : [], + [operations, onPickOperation, query, t], + ); + const groups = useMemo(() => { const q = query.trim().toLowerCase(); const matched = q @@ -58,7 +84,7 @@ export function ToolPicker({ tools, onPick, onClose }: ToolPickerProps) { />
    - {groups.length === 0 ? ( + {groups.length === 0 && matchedOperations.length === 0 ? (

    {t("portal.pipelines.builder.noToolMatches")}

    @@ -93,6 +119,39 @@ export function ToolPicker({ tools, onPick, onClose }: ToolPickerProps) {
    )) )} + + {matchedOperations.length > 0 && onPickOperation ? ( +
    +
    + {t("portal.pipelines.builder.sendToSystem")} +
    + {matchedOperations.map((op) => ( + + ))} +
    + ) : null}
    ); diff --git a/frontend/editor/src/portal/components/pipelines/integrationStep.test.ts b/frontend/editor/src/portal/components/pipelines/integrationStep.test.ts new file mode 100644 index 0000000000..e8ea993088 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/integrationStep.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; + +import { + INTEGRATION_ENDPOINT, + integrationStepConfigured, + isIntegrationStep, + newIntegrationStep, + stepOperation, +} from "@portal/components/pipelines/integrationStep"; +import { + buildStepParameters, + operationById, +} from "@portal/components/policies/stepOperations"; +import { + serializeToolStep, + type WorkingToolStep, +} from "@app/hooks/tools/shared/toolAutomation"; + +describe("integration steps in a pipeline", () => { + it("creates a step the backend will dispatch generically", () => { + const step = newIntegrationStep(operationById("discordNotify")!); + + expect(step.toolId).toBeNull(); + expect(step.operation).toBe(INTEGRATION_ENDPOINT); + expect(isIntegrationStep(step)).toBe(true); + }); + + it("survives serialisation verbatim, so the saved pipeline keeps its config", () => { + // toolId null takes serializeToolStep's unmapped path; if that ever changed, an integration + // step would be rewritten on save and silently lose its parameters. + const op = operationById("jiraAttach")!; + const step = newIntegrationStep(op); + // Configure it the way the inspector does: rebuild from the catalogue with real answers. + step.params = buildStepParameters(op, "12", { + issueKey: "OPS-42", + }) as never; + + const wire = serializeToolStep(step, {}); + + expect(wire.operation).toBe(INTEGRATION_ENDPOINT); + expect(wire.parameters.connectionId).toBe("12"); + expect(wire.parameters.path).toBe("/rest/api/3/issue/OPS-42/attachments"); + expect(JSON.parse(wire.parameters.headers as string)).toEqual({ + "X-Atlassian-Token": "no-check", + }); + }); + + it("leaves an unfilled field blank rather than shipping the placeholder", () => { + // A freshly added step is deliberately unconfigured. What matters is that {{issueKey}} does + // not survive into the wire call, where Jira would receive it as a literal path segment. + const step = newIntegrationStep(operationById("jiraAttach")!); + expect(step.params.path).not.toContain("{{"); + expect(integrationStepConfigured(step)).toBe(false); + }); + + it("remembers which operation it is, so the builder can name and edit it", () => { + const step = newIntegrationStep(operationById("splunkEvent")!); + expect(stepOperation(step)?.id).toBe("splunkEvent"); + }); + + it("is not configured until an account is chosen", () => { + const step = newIntegrationStep(operationById("clamavScan")!); + // Created deliberately blank so the operator sees it in the chain and fills it in. + expect(integrationStepConfigured(step)).toBe(false); + + (step.params as Record).connectionId = "4"; + expect(integrationStepConfigured(step)).toBe(true); + }); + + it("leaves ordinary tool steps alone", () => { + const toolStep = { + toolId: "compress", + operation: "/api/v1/misc/compress-pdf", + params: {}, + support: "supported", + } as unknown as WorkingToolStep; + expect(isIntegrationStep(toolStep)).toBe(false); + expect(stepOperation(toolStep)).toBeUndefined(); + expect(integrationStepConfigured(toolStep)).toBe(true); + }); +}); diff --git a/frontend/editor/src/portal/components/pipelines/integrationStep.ts b/frontend/editor/src/portal/components/pipelines/integrationStep.ts new file mode 100644 index 0000000000..93754ccefb --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/integrationStep.ts @@ -0,0 +1,62 @@ +/** + * Integration operations as pipeline steps. + * + * A pipeline step is an endpoint path plus parameters, and the engine dispatches it generically — + * so an integration operation is already a legal step. What it lacked was a way to *pick* one and + * *configure* it in the builder, whose picker is fed by the editor's tool registry and does not + * know about them. + * + * These steps deliberately stay `toolId: null`. They are not registry tools, and pretending + * otherwise would mean inventing a fake tool id that `serializeToolStep` would then try to resolve + * an endpoint from. The unmapped path already round-trips a step verbatim, which is exactly the + * behaviour wanted here; the only thing added is that the builder can now recognise and edit them + * rather than showing them as an opaque "unknown step". + */ + +import type { ErasedToolParams } from "@app/hooks/tools/shared/toolOperationTypes"; +import type { WorkingToolStep } from "@app/hooks/tools/shared/toolAutomation"; +import { + buildStepParameters, + emptyOperationValues, + operationById, + type StepOperation, +} from "@portal/components/policies/stepOperations"; + +/** The one endpoint every catalogue operation dispatches through. */ +export const INTEGRATION_ENDPOINT = "/api/v1/integration/external-api-call"; + +export function isIntegrationStep(step: WorkingToolStep): boolean { + return step.toolId === null && step.operation === INTEGRATION_ENDPOINT; +} + +/** A new pipeline step for a chosen operation, seeded with the catalogue's defaults. */ +export function newIntegrationStep(op: StepOperation): WorkingToolStep { + const values = emptyOperationValues(op); + return { + toolId: null, + operation: INTEGRATION_ENDPOINT, + // Connection is chosen in the inspector; the step is created unconfigured on purpose so the + // operator sees it in the chain and fills it in, rather than the picker blocking on a modal. + params: buildStepParameters(op, "", values) as unknown as ErasedToolParams, + support: "unknown", + }; +} + +/** + * The operation a step was built from, or undefined if it predates the catalogue (a pipeline + * authored through the API can name the endpoint without an operationId). + */ +export function stepOperation( + step: WorkingToolStep, +): StepOperation | undefined { + if (!isIntegrationStep(step)) return undefined; + const id = (step.params as Record).operationId; + return typeof id === "string" && id ? operationById(id) : undefined; +} + +/** True once the step can actually run: an operation chosen and an account selected. */ +export function integrationStepConfigured(step: WorkingToolStep): boolean { + if (!isIntegrationStep(step)) return true; + const params = step.params as Record; + return Boolean(params.operationId) && Boolean(params.connectionId); +} diff --git a/frontend/editor/src/portal/components/pipelines/outputModes.ts b/frontend/editor/src/portal/components/pipelines/outputModes.ts index a8e698c676..0d76cf122e 100644 --- a/frontend/editor/src/portal/components/pipelines/outputModes.ts +++ b/frontend/editor/src/portal/components/pipelines/outputModes.ts @@ -1,11 +1,11 @@ import type { PipelineOutputMode } from "@portal/api/pipelines"; /** - * The output destinations the pipeline builder offers. An extension point: - * deployments where a destination cannot work shadow this module and filter - * the list (e.g. hosted deployments never write to the server's filesystem, - * so folder outputs are not offered there). + * The source types that can be written to, i.e. offered as a pipeline's output destination. An + * extension point: deployments where a destination cannot work shadow this module and filter the + * list (e.g. hosted deployments never write to the server's filesystem, so folder destinations are + * not offered there and only S3 remains). */ export function availableOutputModes(): PipelineOutputMode[] { - return ["inline", "folder", "s3"]; + return ["folder", "s3"]; } diff --git a/frontend/editor/src/portal/components/policies/ClassificationLabelsSection.css b/frontend/editor/src/portal/components/policies/ClassificationLabelsSection.css new file mode 100644 index 0000000000..13c1f4812f --- /dev/null +++ b/frontend/editor/src/portal/components/policies/ClassificationLabelsSection.css @@ -0,0 +1,53 @@ +/* Read-only classification-vocabulary viewer in the policy wizard. */ +.classification-summary { + display: flex; + flex-direction: column; + gap: var(--space-2); +} +.classification-summary-stats { + display: flex; + gap: var(--space-3); + font-size: 0.8125rem; + color: var(--c-text-muted); +} +.classification-summary-note { + font-size: 0.75rem; + color: var(--c-text-subtle); +} + +/* Expandable category → labels list. */ +.classification-categories { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; +} +.classification-category { + border-top: 1px solid var(--c-border); +} +.classification-category:last-child { + border-bottom: 1px solid var(--c-border); +} +.classification-category-header { + min-height: 2.5rem; +} +.classification-category-lead { + display: inline-flex; + align-items: center; + gap: var(--space-2); +} +.classification-category-name { + font-weight: 600; + font-size: 0.875rem; +} +.classification-category-count { + font-size: 0.75rem; + color: var(--c-text-subtle); +} +.classification-category-labels { + display: flex; + flex-wrap: wrap; + gap: var(--space-1_5); + padding: 0 var(--space-2) var(--space-2) 2rem; +} diff --git a/frontend/editor/src/portal/components/policies/ClassificationLabelsSection.stories.tsx b/frontend/editor/src/portal/components/policies/ClassificationLabelsSection.stories.tsx new file mode 100644 index 0000000000..1586f9cc45 --- /dev/null +++ b/frontend/editor/src/portal/components/policies/ClassificationLabelsSection.stories.tsx @@ -0,0 +1,12 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ClassificationLabelsSection } from "@portal/components/policies/ClassificationLabelsSection"; + +const meta: Meta = { + title: "Portal/Policies/ClassificationLabelsSection", + component: ClassificationLabelsSection, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/editor/src/portal/components/policies/ClassificationLabelsSection.tsx b/frontend/editor/src/portal/components/policies/ClassificationLabelsSection.tsx new file mode 100644 index 0000000000..7a21cae2b7 --- /dev/null +++ b/frontend/editor/src/portal/components/policies/ClassificationLabelsSection.tsx @@ -0,0 +1,97 @@ +// Read-only view of the classification vocabulary shown in the policy wizard. The labels and their +// categories are a fixed, built-in set shared across the whole team — there's nothing to edit, but +// the full vocabulary is browsable: expand a category to see the labels it groups. + +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; +import KeyboardArrowRightIcon from "@mui/icons-material/KeyboardArrowRight"; +import { Button, Card, Chip } from "@app/ui"; +import { LocalIcon } from "@app/components/shared/LocalIcon"; +import { + DEFAULT_CLASSIFICATION_LABELS, + LABEL_FAMILIES, +} from "@app/data/classificationLabels"; +import "@portal/components/policies/ClassificationLabelsSection.css"; + +export function ClassificationLabelsSection() { + const { t } = useTranslation(); + const [expanded, setExpanded] = useState>(new Set()); + + const toggle = (id: string) => + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + + return ( + +
    +
    + + {DEFAULT_CLASSIFICATION_LABELS.length}{" "} + {t("policies.labels.labelCount", "labels")} + + + {LABEL_FAMILIES.length}{" "} + {t("policies.labels.categoryCount", "categories")} + +
    + +
      + {LABEL_FAMILIES.map((family) => { + const open = expanded.has(family.id); + return ( +
    • +
    • + ); + })} +
    + + + {t( + "policies.labels.sharedNote", + "These labels are built in and shared across your whole team.", + )} + +
    +
    + ); +} diff --git a/frontend/editor/src/portal/components/policies/PolicyCatalogueTable.tsx b/frontend/editor/src/portal/components/policies/PolicyCatalogueTable.tsx new file mode 100644 index 0000000000..6986f66f74 --- /dev/null +++ b/frontend/editor/src/portal/components/policies/PolicyCatalogueTable.tsx @@ -0,0 +1,138 @@ +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { Button, Chip, StatusBadge, Table, type TableColumn } from "@app/ui"; +import type { CatalogueEntry } from "@portal/api/policies"; +import { PolicyCategoryBadge } from "@portal/components/policies/PolicyCategoryIcon"; +import "@portal/views/Policies.css"; + +interface PolicyCatalogueTableProps { + entries: CatalogueEntry[]; + onOpen: (entry: CatalogueEntry) => void; + /** Setup is unavailable (e.g. the AI engine is off): shown, but not openable. */ + isLocked?: (entry: CatalogueEntry) => boolean; + /** Chip text explaining why setup is locked (e.g. "Requires AI engine"). */ + lockedLabel?: string; +} + +/** + * The policy catalogue as a proper data table (Policy / Enforces / Applies to / + * Docs / Status), replacing the stacked full-width cards that read as "blocky". + * Same shared Table + StatusBadge + Chip primitives the Sources, Documents and + * Home policy tables use, so every list page in the portal now reads alike. + */ +export function PolicyCatalogueTable({ + entries, + onOpen, + isLocked, + lockedLabel, +}: PolicyCatalogueTableProps) { + const { t } = useTranslation(); + + const columns = useMemo[]>( + () => [ + { + key: "policy", + header: t("portal.policies.table.policy", "Policy"), + render: (entry) => ( +
    + + + {t(entry.category.label)} + +
    + ), + }, + { + key: "enforces", + header: t("portal.policies.table.enforces", "Enforces"), + render: (entry) => ( +
    + {entry.config.rules.map((r) => ( + + {t(r)} + + ))} +
    + ), + }, + { + key: "scope", + header: t("portal.policies.table.appliesTo", "Applies to"), + render: (entry) => ( + + {t(entry.config.scopeLabel)} + + ), + }, + { + key: "docs", + header: t("portal.policies.table.docs", "Docs enforced"), + align: "right", + width: "8rem", + render: (entry) => ( + + {entry.policy ? entry.policy.stats.enforced.toLocaleString() : "—"} + + ), + }, + { + key: "status", + header: t("portal.policies.table.status", "Status"), + align: "right", + width: "8.5rem", + render: (entry) => { + if (entry.category.comingSoon) { + // One consistent neutral chip for every "Upgrade to Enterprise" — + // the same action should read the same on every row. + return ( + + {t("portal.policies.card.comingSoon")} + + ); + } + if (isLocked?.(entry)) { + return ( + + {lockedLabel ?? t("portal.policies.card.requiresAiEngine")} + + ); + } + if (entry.policy) { + const paused = entry.policy.state.status === "paused"; + return ( + + {paused + ? t("portal.policies.status.paused") + : t("portal.policies.status.active")} + + ); + } + return ( + + ); + }, + }, + ], + [t, onOpen, isLocked, lockedLabel], + ); + + return ( + + className="portal-policies__table" + columns={columns} + rows={entries} + rowKey={(e) => e.category.id} + onRowClick={(entry) => + entry.category.comingSoon || isLocked?.(entry) + ? undefined + : onOpen(entry) + } + /> + ); +} diff --git a/frontend/editor/src/portal/components/policies/PolicyCategoryCard.tsx b/frontend/editor/src/portal/components/policies/PolicyCategoryCard.tsx index 91abcd3f75..c5169217dd 100644 --- a/frontend/editor/src/portal/components/policies/PolicyCategoryCard.tsx +++ b/frontend/editor/src/portal/components/policies/PolicyCategoryCard.tsx @@ -1,19 +1,28 @@ import { useTranslation } from "react-i18next"; import { Card, Chip, StatusBadge } from "@app/ui"; import type { CatalogueEntry } from "@portal/api/policies"; -import { policyIcon } from "@portal/components/policies/policyIcons"; +import { policyCategoryIcon } from "@app/components/policies/policyCategoryIcon"; import "@portal/views/Policies.css"; interface PolicyCategoryCardProps { entry: CatalogueEntry; onOpen: (entry: CatalogueEntry) => void; + /** Setup is unavailable (e.g. the AI engine is off): shown, but not openable. */ + locked?: boolean; + /** Chip text explaining why setup is locked (e.g. "Requires AI engine"). */ + lockedLabel?: string; } -export function PolicyCategoryCard({ entry, onOpen }: PolicyCategoryCardProps) { +export function PolicyCategoryCard({ + entry, + onOpen, + locked = false, + lockedLabel, +}: PolicyCategoryCardProps) { const { t } = useTranslation(); const { category, config, policy } = entry; const comingSoon = category.comingSoon === true; - const openable = !comingSoon; + const openable = !comingSoon && !locked; const status = policy?.state.status; const enforces = config.rules.map((r) => t(r)).join(" · "); @@ -21,7 +30,7 @@ export function PolicyCategoryCard({ entry, onOpen }: PolicyCategoryCardProps) { onOpen(entry) : undefined} @@ -39,7 +48,7 @@ export function PolicyCategoryCard({ entry, onOpen }: PolicyCategoryCardProps) { } > - {policyIcon(category.icon)} + {policyCategoryIcon(category.id)}
    @@ -53,6 +62,10 @@ export function PolicyCategoryCard({ entry, onOpen }: PolicyCategoryCardProps) { {t("portal.policies.card.comingSoon")} + ) : locked ? ( + + {lockedLabel ?? t("portal.policies.card.requiresAiEngine")} + ) : policy ? (
    diff --git a/frontend/editor/src/portal/components/policies/PolicyCategoryIcon.css b/frontend/editor/src/portal/components/policies/PolicyCategoryIcon.css new file mode 100644 index 0000000000..ee0b6dde44 --- /dev/null +++ b/frontend/editor/src/portal/components/policies/PolicyCategoryIcon.css @@ -0,0 +1,15 @@ +/* Neutral gray rounded icon badge for a policy category. Lives with the + component so every consumer gets the styling without importing a view CSS. */ +.pcat-badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + flex-shrink: 0; + border-radius: var(--radius-lg); + background: var(--c-surface-sunken); + color: var(--c-text-subtle); + /* The shared MUI outline glyph inherits its size from here. */ + font-size: 1.15rem; +} diff --git a/frontend/editor/src/portal/components/policies/PolicyCategoryIcon.tsx b/frontend/editor/src/portal/components/policies/PolicyCategoryIcon.tsx new file mode 100644 index 0000000000..89243711c9 --- /dev/null +++ b/frontend/editor/src/portal/components/policies/PolicyCategoryIcon.tsx @@ -0,0 +1,16 @@ +import type { PolicyCategory } from "@portal/api/policies"; +import { policyCategoryIcon } from "@app/components/policies/policyCategoryIcon"; +import "@portal/components/policies/PolicyCategoryIcon.css"; + +/** A neutral gray rounded badge holding the category's shared outline icon. */ +export function PolicyCategoryBadge({ + category, +}: { + category: PolicyCategory; +}) { + return ( + + {policyCategoryIcon(category.id)} + + ); +} diff --git a/frontend/editor/src/portal/components/policies/PolicyExternalApiConfig.stories.tsx b/frontend/editor/src/portal/components/policies/PolicyExternalApiConfig.stories.tsx new file mode 100644 index 0000000000..627be84936 --- /dev/null +++ b/frontend/editor/src/portal/components/policies/PolicyExternalApiConfig.stories.tsx @@ -0,0 +1,66 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { + PolicyExternalApiConfig, + type ExternalApiParams, +} from "@portal/components/policies/PolicyExternalApiConfig"; +import { + buildStepParameters, + operationById, +} from "@portal/components/policies/stepOperations"; + +const meta: Meta = { + title: "Portal/Policies/PolicyExternalApiConfig", + component: PolicyExternalApiConfig, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +const EMPTY_PARAMS: ExternalApiParams = { + connectionId: "", + path: "", + method: "", + bodyMode: "", + fileFieldName: "", + responseMode: "", + resultUrlPath: "", + resultUrlHeader: "", + responseSelect: "", + requireTrue: "", + fields: "", + headers: "", + bodyTemplate: "", + includeContext: "", + includeFile: "", + operationId: "", + operationValues: "", +}; + +/** Keeps the parameters in local state, exercising onChange like the real editor does. */ +function Controlled({ initial }: { initial: ExternalApiParams }) { + const [parameters, setParameters] = useState(initial); + return ( + + ); +} + +export const OperationPicker: Story = { + render: () => , +}; + +const slackNotify = operationById("slackNotify")!; +const slackParams = buildStepParameters(slackNotify, "1", { + message: "{{run.policyName}} processed {{document.filename}}", +}); + +export const NotifyConfigured: Story = { + render: () => , +}; + +const customApiCall = operationById("customApiCall")!; +const customParams = buildStepParameters(customApiCall, "", {}); + +export const CustomApiEscapeHatch: Story = { + render: () => , +}; diff --git a/frontend/editor/src/portal/components/policies/PolicyExternalApiConfig.test.tsx b/frontend/editor/src/portal/components/policies/PolicyExternalApiConfig.test.tsx new file mode 100644 index 0000000000..7f1c232429 --- /dev/null +++ b/frontend/editor/src/portal/components/policies/PolicyExternalApiConfig.test.tsx @@ -0,0 +1,81 @@ +import { useState } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + fireEvent, + render as baseRender, + screen, + waitFor, +} from "@testing-library/react"; +import { MantineProvider } from "@mantine/core"; + +import { PolicyExternalApiConfig } from "@portal/components/policies/PolicyExternalApiConfig"; +import { + buildStepParameters, + operationById, + type ExternalApiStepParams, +} from "@portal/components/policies/stepOperations"; + +const render = (ui: Parameters[0]) => + baseRender(ui, { wrapper: MantineProvider }); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { changeLanguage: vi.fn() }, + }), +})); + +vi.mock("@portal/api/integrations", () => ({ + fetchIntegrations: () => Promise.resolve([]), + fetchIntegrationCapabilities: () => Promise.resolve({ customApi: false }), + createIntegration: vi.fn(), + updateIntegration: vi.fn(), +})); + +vi.mock("@portal/api/http", () => ({ + errorMessage: (e: unknown) => String(e), +})); + +// A stateful host so the controlled component behaves as it does in the builder, and the test can +// read the parameters after each change. +let latest: ExternalApiStepParams; +function Harness({ initial }: { initial: ExternalApiStepParams }) { + const [params, setParams] = useState(initial); + latest = params; + return ( + { + latest = p; + setParams(p); + }} + /> + ); +} + +describe("switching an operation's vendor", () => { + beforeEach(() => { + latest = undefined as unknown as ExternalApiStepParams; + }); + + it("drops the account, so a Slack webhook is never carried into a Jira step", async () => { + const discord = buildStepParameters(operationById("discordNotify")!, "5", { + message: "hi", + }); + render(); + + // Start on the Discord form with its account chosen. + expect(latest.operationId).toBe("discordNotify"); + expect(latest.connectionId).toBe("5"); + + // Change the operation, then pick a different vendor. + fireEvent.click(screen.getByText("portal.policies.operations.change")); + fireEvent.click( + await screen.findByText("portal.policies.operations.jiraAttach.label"), + ); + + await waitFor(() => expect(latest.operationId).toBe("jiraAttach")); + // The Discord account did not ride across to the Jira step. + expect(latest.connectionId).toBe(""); + }); +}); diff --git a/frontend/editor/src/portal/components/policies/PolicyExternalApiConfig.tsx b/frontend/editor/src/portal/components/policies/PolicyExternalApiConfig.tsx new file mode 100644 index 0000000000..76869bb77a --- /dev/null +++ b/frontend/editor/src/portal/components/policies/PolicyExternalApiConfig.tsx @@ -0,0 +1,417 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import SearchRoundedIcon from "@mui/icons-material/SearchRounded"; +import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded"; +import { Banner, Button, FormField, Input, Select } from "@app/ui"; +import { fetchIntegrationCapabilities } from "@portal/api/integrations"; +import { ConnectionPicker } from "@portal/components/sources/ConnectionPicker"; +import { + CONNECTION_CATEGORIES, + type ConnectionCategory, +} from "@portal/components/sources/connectionTypes"; +import { BrandMark } from "@portal/components/BrandMarks"; +import { + STEP_OPERATIONS, + buildStepParameters, + emptyOperationValues, + operationById, + operationsByCategory, + searchOperations, + type ExternalApiStepParams, + type StepOperation, +} from "@portal/components/policies/stepOperations"; + +/** + * Configures a "send the document to another system" step. + * + * The step's own API takes seventeen parameters — path, body mode, file field name, response mode + * and so on. Asking an operator for those is asking them to have read the vendor's API docs, which + * is the difference between supporting a vendor and merely being able to reach it. So this screen + * asks two questions instead: *what do you want to do*, and *with which account*. The catalogue + * fills in the rest. + * + * The escape hatch stays: choosing Custom API reveals the raw call, because an operator connecting + * something we do not ship a template for still needs a way through. + */ +/** + * Every step parameter is a flat string (the pipeline serialises them as form fields), so the + * operator's answers travel JSON-encoded in `operationValues` and are decoded here. + */ +export type ExternalApiParams = ExternalApiStepParams; + +function decodeValues(raw: string | undefined): Record { + if (!raw) return {}; + try { + const parsed: unknown = JSON.parse(raw); + return parsed && typeof parsed === "object" + ? (parsed as Record) + : {}; + } catch { + // A hand-edited or truncated value should not break the form. + return {}; + } +} + +interface PolicyExternalApiConfigProps { + parameters: ExternalApiParams; + onChange: (parameters: ExternalApiParams) => void; +} + +export function PolicyExternalApiConfig({ + parameters, + onChange, +}: PolicyExternalApiConfigProps) { + const { t } = useTranslation(); + const [query, setQuery] = useState(""); + // Whether to OFFER the escape hatch. The server refuses it regardless of what the client + // believes, so this is presentation only - the same contract the connections tab uses. + const [allowCustom, setAllowCustom] = useState(true); + + useEffect(() => { + fetchIntegrationCapabilities().then( + (c) => setAllowCustom(c.customApi !== false), + () => undefined, + ); + }, []); + + const selected = parameters.operationId + ? operationById(parameters.operationId) + : undefined; + const values = decodeValues(parameters.operationValues); + + const available = useMemo( + () => STEP_OPERATIONS.filter((op) => allowCustom || !op.custom), + [allowCustom], + ); + const matches = useMemo( + () => searchOperations(available, query, (key) => t(key)), + [available, query, t], + ); + const grouped = useMemo(() => operationsByCategory(matches), [matches]); + const searching = query.trim() !== ""; + + function choose(op: StepOperation) { + // Picking from the grid always starts the operation fresh with no account: a Slack webhook is + // not a valid Jira account, and reaching the grid means the operator is choosing anew. The + // account chosen for a previous operation would otherwise ride along, unlisted by the vendor + // filter yet still saved. + onChange(buildStepParameters(op, "", emptyOperationValues(op))); + } + + function setValue(key: string, value: string) { + if (!selected) return; + const next = { ...values, [key]: value }; + onChange( + buildStepParameters(selected, parameters.connectionId ?? "", next), + ); + } + + function setConnection(id: string) { + if (!selected) { + onChange({ ...parameters, connectionId: id }); + return; + } + onChange(buildStepParameters(selected, id, values)); + } + + // ---- step 1: pick what the step should do --------------------------------------------------- + if (!selected) { + const sections: ConnectionCategory[] = searching + ? [] + : CONNECTION_CATEGORIES.filter((c) => (grouped.get(c)?.length ?? 0) > 0); + + return ( +
    +
    + + setQuery(e.target.value)} + placeholder={t("portal.policies.operations.searchPlaceholder")} + aria-label={t("portal.policies.operations.searchPlaceholder")} + /> +
    + + {matches.length === 0 ? ( +

    + {t("portal.policies.operations.noResults")} +

    + ) : searching ? ( + + ) : ( + sections.map((category) => ( +
    +

    + {t(`portal.connections.categories.${category}.label`)} +

    + +
    + )) + )} +
    + ); + } + + // ---- step 2: the two questions that remain -------------------------------------------------- + return ( +
    + + +

    + {t(selected.descriptionKey)} +

    + + {selected.noteKey && ( + + )} + + + + + + {(selected.fields ?? []).map((field) => ( + + {field.control === "textarea" ? ( +