Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4351318f89 | ||
|
|
b53aaa7d03 | ||
|
|
3fa0f30d43 | ||
|
|
9ea848570f | ||
|
|
d6061eb0aa | ||
|
|
1759e0bdd5 | ||
|
|
514b020f74 | ||
|
|
18b0b19a67 | ||
|
|
a8bda9240c | ||
|
|
0692058602 | ||
|
|
8150d16b6f | ||
|
|
c8f238ae60 | ||
|
|
8df49ac053 | ||
|
|
328cd8c664 | ||
|
|
b27c46d72b | ||
|
|
b0ecc22260 | ||
|
|
91279e85fa | ||
|
|
55fb5cf6c4 | ||
|
|
9c3d1eeca6 | ||
|
|
a79efd8d39 | ||
|
|
ade36f7d86 | ||
|
|
8b69a5fc05 | ||
|
|
ae8c2ec293 | ||
|
|
63b00824bb |
@@ -0,0 +1,97 @@
|
||||
---
|
||||
name: feature-walkthrough
|
||||
description: >-
|
||||
Explain the full logic and process of the current branch end-to-end so someone
|
||||
with no prior knowledge of the task can understand, review, and reproduce it.
|
||||
Scopes the change from the branch diff, traces the flow across every layer it
|
||||
touches (frontend tool/hook/component, Java controller/service/endpoint, Python
|
||||
engine, config, i18n, tests), and produces a self-contained walkthrough document
|
||||
with Mermaid diagrams (sequence/flow/architecture), annotated file map with
|
||||
clickable references, before/after behavior, screenshots where a UI is involved,
|
||||
a "try it locally" section, and edge cases/risks. Use when asked for a feature or
|
||||
branch walkthrough, "explain what this branch does", a design/logic writeup, PR
|
||||
reviewer onboarding, or a hand-off doc. Pass --html to also emit a rendered HTML
|
||||
version; --no-screens to skip screenshots.
|
||||
argument-hint: "[branch-or-area] [--html] [--no-screens]"
|
||||
allowed-tools: Read, Write, Edit, Glob, Grep, Bash
|
||||
---
|
||||
|
||||
# Feature / Branch Walkthrough
|
||||
|
||||
Turn the current branch into a walkthrough a newcomer can follow. Audience:
|
||||
**someone who has never seen this task**. Explain the *why*, the *flow*, and *how to
|
||||
try it* - not just a diff summary.
|
||||
|
||||
`$ARGUMENTS` may name a branch or area to focus on; default is the current branch
|
||||
vs `main`. Flags: `--html` (also emit a rendered HTML twin), `--no-screens`.
|
||||
|
||||
## Process
|
||||
|
||||
### 1. Scope the change
|
||||
- `git log --oneline main..HEAD` and `git diff --stat main...HEAD` for the shape.
|
||||
- Read the PR description / commit messages for stated intent. Do **not** invent
|
||||
history or motivation that isn't evidenced (state current behavior in present tense).
|
||||
- Classify touched files by layer:
|
||||
- **Frontend**: tools (`frontend/editor/src/core/components/tools/*` or `.../core/tools/*`),
|
||||
hooks (`core/hooks/tools/*`, `useToolOperation`), contexts, routes, i18n
|
||||
(`public/locales/en-US`).
|
||||
- **Java backend**: controllers (`.../controller/api/...`), services, models, config.
|
||||
- **Engine**: `engine/src/stirling/{agents,contracts,api,services}`.
|
||||
- **Config / build / docker / tests.**
|
||||
|
||||
### 2. Trace the flow end-to-end
|
||||
Follow one real path from user action to result. For a typical PDF tool that's:
|
||||
UI control → `useToolOperation` hook → `POST /api/v1/...` → Spring controller →
|
||||
service (PDFBox / LibreOffice / engine call) → response → review panel → download.
|
||||
Read the actual files so the narrative is true to the code, and collect the exact
|
||||
file:line anchors you'll cite.
|
||||
|
||||
### 3. Draw the diagrams (Mermaid)
|
||||
Pick what fits; usually 2-3 of:
|
||||
- **Sequence diagram** - request/response across frontend → backend → engine.
|
||||
- **Flowchart** - the core decision/branching logic of the feature.
|
||||
- **Architecture/component** - new pieces and how they wire to existing ones.
|
||||
- **State** - if the feature has modes/steps.
|
||||
Keep nodes labeled in plain language. Validate the Mermaid parses before shipping.
|
||||
|
||||
### 4. Screenshots (unless --no-screens)
|
||||
If a UI is involved, capture key states with the stubbed Playwright harness
|
||||
(see the **ui-walkthrough** skill and `files-page-screenshots.spec.ts` for the
|
||||
pattern) or, for before/after, capture `main` then the branch. Drop PNGs in
|
||||
`walkthrough/<feature>/` and reference them from the doc. For backend-only
|
||||
changes, show request/response examples (curl + JSON) instead.
|
||||
|
||||
### 5. Write the walkthrough
|
||||
Create `walkthrough/<feature>/FEATURE-WALKTHROUGH.md` with:
|
||||
1. **TL;DR** - what the branch does and who it's for, in 3-4 sentences.
|
||||
2. **Problem & approach** - what wasn't possible before; the chosen solution.
|
||||
3. **Architecture diagram** + 1-paragraph orientation.
|
||||
4. **End-to-end flow** - the sequence diagram + a numbered walk of each step,
|
||||
each citing the real file (clickable `path:line`).
|
||||
5. **Key files** - annotated map (path → one line on its role).
|
||||
6. **Logic deep-dive** - the flowchart + prose for the non-obvious decisions.
|
||||
7. **Behavior** - before vs after; screenshots or request/response examples.
|
||||
8. **Try it locally** - exact steps (`task dev` / `task dev:all`, the route to
|
||||
open or the curl to run, any env like `DOCKER_ENABLE_SECURITY` or a test
|
||||
license key). Make it copy-pasteable.
|
||||
9. **Edge cases, risks, follow-ups** - what's untested, known limits, gotchas.
|
||||
|
||||
Markdown is the primary deliverable - it renders with diagrams in GitHub PRs and
|
||||
IDEs, no build step, ideal for review.
|
||||
|
||||
### 6. If `--html`
|
||||
Also emit `walkthrough/<feature>/walkthrough.html`: the same content with Mermaid
|
||||
rendered via `mermaid.initialize({startOnLoad:true})` (script from CDN; note in
|
||||
the file that rendering diagrams needs network, the `.md` is the offline copy) and
|
||||
screenshots inline. Keep it self-contained otherwise.
|
||||
|
||||
### 7. Deliver
|
||||
Give the doc path and a short chat summary. Offer to `SendUserFile` it.
|
||||
|
||||
## Principles
|
||||
- **True to the code.** Every claim traces to a file you read; cite `path:line`.
|
||||
No fabricated migration/version history.
|
||||
- **Newcomer-first.** Define repo-specific terms (FileContext, `useToolOperation`,
|
||||
the `@app/*` layer cascade, stubbed vs live tests) on first use.
|
||||
- **Show, don't assert.** Prefer a diagram + a real example over adjectives.
|
||||
- Don't commit the `walkthrough/` output unless asked.
|
||||
@@ -0,0 +1,122 @@
|
||||
---
|
||||
name: ui-before-after
|
||||
description: >-
|
||||
Analyse a branch or PR and automatically capture before/after screenshots of
|
||||
every UI surface its changes touch, then pixel-diff the pairs to surface what
|
||||
actually changed and assemble PR-ready before/after montage images. Generic and
|
||||
diff-driven: it derives the capture targets from the diff (changed tools/routes →
|
||||
URLs) instead of hand-listing screens, captures "before" from the base branch and
|
||||
"after" from the head, then keeps only the views that visually differ. Each
|
||||
comparison is auto-cropped to the region that actually changed (the bounding box of
|
||||
differing pixels), falling back to the full page only when the change spans most of
|
||||
it. Use for before/after shots, a visual diff of a branch/PR, "screenshots for the
|
||||
PR description", "show what changed in the UI", or a side-by-side of UI changes.
|
||||
Takes a PR number/URL (resolved via gh) or a branch; defaults to the current branch
|
||||
vs its base. Flags: --scope <selector>, --base <ref|merge-base>, --theme
|
||||
light|dark|both, --all (capture every route, not just changed), --no-autocrop,
|
||||
--pagewide <n>, --threshold <n>.
|
||||
argument-hint: "[PR# | PR-url | branch] [--scope <sel>] [--base <ref>] [--theme both] [--all] [--no-autocrop]"
|
||||
allowed-tools: Read, Write, Edit, Glob, Grep, Bash
|
||||
---
|
||||
|
||||
# UI Before / After (generic visual diff)
|
||||
|
||||
Point it at a branch or PR; it figures out which UI changed, screenshots every
|
||||
affected surface **before** (base) and **after** (head), pixel-diffs the pairs, and
|
||||
montages the ones that actually changed into images for the PR description.
|
||||
|
||||
`$ARGUMENTS`: a PR number/URL, a branch, or nothing (current branch vs base).
|
||||
By default it captures the full viewport and auto-crops each comparison to the region
|
||||
that changed. Flags: `--scope <css>` (narrow the *capture* to a container, e.g.
|
||||
`[data-sidebar="tool-panel"]`, when you already know where the change is),
|
||||
`--no-autocrop` (keep full frames), `--pagewide <fraction>` (above this share of the
|
||||
page, skip cropping; default 0.6), `--base <ref|merge-base>`,
|
||||
`--theme light|dark|both`, `--all` (walk every route, not just changed),
|
||||
`--threshold <fraction>` (diff sensitivity, default 0.001).
|
||||
|
||||
Shares the capture harness with **ui-walkthrough** - read its SKILL.md for the
|
||||
stubbed-Playwright setup, worktree node_modules + `generate-icons`, the
|
||||
stale-`:5173` gotcha, and the dark-mode init-script. Bundled helpers:
|
||||
[capture-spec.template.ts](capture-spec.template.ts), [diff-shots.mjs](diff-shots.mjs),
|
||||
[montage-template.html](montage-template.html), [shoot-sections.mjs](shoot-sections.mjs).
|
||||
|
||||
## Process
|
||||
|
||||
### 1. Resolve target + base
|
||||
```
|
||||
gh pr view <pr> --json number,title,headRefName,baseRefName,url,files # PR
|
||||
# or branch: base = merge-base(main, HEAD); head = HEAD
|
||||
gh pr diff <pr> --name-only # or: git diff --name-only <base>...HEAD
|
||||
```
|
||||
|
||||
### 2. Derive capture targets from the diff (the "analyse" step - no hand-listing)
|
||||
Map changed frontend files to URLs generically:
|
||||
- **Tools**: a changed `components/tools/<toolDir>/…` or `hooks/tools/<tool>/…` →
|
||||
toolId → URL via the repo's own rule `getToolUrlPath` in
|
||||
[toolsTaxonomy.ts:200](frontend/editor/src/core/data/toolsTaxonomy.ts): `/` + the
|
||||
id kebab-cased (`addPageNumbers` → `/add-page-numbers`).
|
||||
- **Pages/routes**: changed `filesPage/*` → `/files`, etc.
|
||||
- `--all`: enumerate every tool in the registry instead of just changed ones.
|
||||
Write `frontend/editor/screenshots/ui-diff/targets.json` =
|
||||
`[{ "id":"compress", "url":"/compress", "name":"Compress" }]`. This is what makes
|
||||
it generic - the spec never names a tool.
|
||||
|
||||
### 3. Capture AFTER (head) then BEFORE (base)
|
||||
Copy [capture-spec.template.ts](capture-spec.template.ts) →
|
||||
`src/core/tests/stubbed/ui-before-after.spec.ts` (it loops `targets.json`, seeds a
|
||||
sample PDF so file-dependent panels render, navigates to each URL, and screenshots
|
||||
the full viewport - or the `--scope` container if given). Ensure the harness is ready
|
||||
(node_modules + icons).
|
||||
```
|
||||
# after = current head
|
||||
cd frontend/editor && PR_SHOT_SIDE=after PR_SHOT_THEME=light \
|
||||
npx playwright test --project=stubbed ui-before-after.spec.ts
|
||||
# before = base, in an isolated worktree (copy the spec + targets.json in)
|
||||
git worktree add ../ba-base origin/<baseRefName> # or the merge-base
|
||||
# set up its frontend, copy spec + screenshots/ui-diff/targets.json across, then:
|
||||
cd ../ba-base/frontend/editor && PR_SHOT_SIDE=before PR_SHOT_THEME=light \
|
||||
npx playwright test --project=stubbed ui-before-after.spec.ts
|
||||
# copy its screenshots/ui-diff/before/ back next to after/. Repeat with
|
||||
# PR_SHOT_THEME=dark if --theme includes dark. Remove worktree when done.
|
||||
```
|
||||
|
||||
### 4. Auto-diff (surface what changed)
|
||||
```
|
||||
cd frontend/editor && node <skill>/diff-shots.mjs \
|
||||
screenshots/ui-diff/before screenshots/ui-diff/after screenshots/ui-diff
|
||||
```
|
||||
Produces `diff-report.json` classifying each view `unchanged | changed | added |
|
||||
removed`. For each changed view it computes the bounding box of differing pixels and
|
||||
writes cropped `__before_crop.png` / `__after_crop.png` / `__diff.png` to that region
|
||||
(+ padding) - **unless** the change covers more than `--pagewide` of the frame, where
|
||||
it keeps the full frame (`pageWide:true`). Drop `unchanged` - that's the noise the
|
||||
user doesn't want.
|
||||
|
||||
### 5. Montage the changes
|
||||
Build the manifest from the non-unchanged entries (group by tab/tool; each becomes a
|
||||
state row with before/after). For changed views use the cropped `cropBefore` /
|
||||
`cropAfter` from `diff-report.json` (tight on the affected region; full frame when
|
||||
`pageWide`); `added`/`removed` render the "not present" placeholder. Fill
|
||||
[montage-template.html](montage-template.html) (replace the `window.__BA__` data
|
||||
block; base64-inline the PNGs for portability), then render one PNG per section with
|
||||
[shoot-sections.mjs](shoot-sections.mjs). Optionally include the `__diff.png` overlay
|
||||
as a third column.
|
||||
|
||||
### 6. Deliver
|
||||
Output the `montage_<tab>.png` files + a short summary (N changed / added / removed,
|
||||
M unchanged skipped) and a paste-ready Markdown block. GitHub has no PR-body image
|
||||
API, so tell the user to drag the PNGs into the description. Do **not** post to the
|
||||
PR.
|
||||
|
||||
## Gotchas
|
||||
- Two installs (base worktree + head); junction main's node_modules only if its deps
|
||||
match that ref, else `npm ci` (see ui-walkthrough's stale-dep note).
|
||||
- A view that errors on one side (refactored/removed) → that side is missing; the
|
||||
diff marks it added/removed rather than failing the run.
|
||||
- Pixel diff needs equal dimensions, so capture at a fixed viewport (the template
|
||||
does); a view whose size changed is reported as "changed (dimensions differ)",
|
||||
uncropped.
|
||||
- Auto-crop uses a single bounding box, so two far-apart changes give one large crop
|
||||
(or trip `--pagewide`); narrow with `--scope` if that happens.
|
||||
- `getToolUrlPath` is the source of truth for tool URLs - use it, don't guess slugs.
|
||||
- Don't commit `screenshots/`, the throwaway spec, or the base worktree.
|
||||
@@ -0,0 +1,67 @@
|
||||
// Generic before/after capturer. NOT app-specific: it walks a targets.json that
|
||||
// the ui-before-after skill generates from the branch/PR diff, so nothing here is
|
||||
// hand-listed. Copy to src/core/tests/stubbed/ui-before-after.spec.ts, then run
|
||||
// once per (side, theme):
|
||||
// PR_SHOT_SIDE=after PR_SHOT_THEME=light \
|
||||
// npx playwright test --project=stubbed ui-before-after.spec.ts
|
||||
//
|
||||
// targets.json shape: [{ "id":"compress", "url":"/compress", "name":"Compress",
|
||||
// "needsFile": true }]
|
||||
import { test } from "@app/tests/helpers/stub-test-base";
|
||||
import type { Page } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const SIDE = process.env.PR_SHOT_SIDE ?? "after";
|
||||
const THEME = process.env.PR_SHOT_THEME ?? "light";
|
||||
// Capture the full viewport by default so the affected region is in frame
|
||||
// wherever it is; diff-shots.mjs crops each comparison to what actually changed.
|
||||
// Set PR_SHOT_SCOPE to a selector to narrow the capture to one container.
|
||||
const SCOPE = process.env.PR_SHOT_SCOPE ?? "";
|
||||
const ROOT = path.resolve(process.cwd(), "screenshots", "ui-diff");
|
||||
const OUT = path.join(ROOT, SIDE);
|
||||
// A tiny sample PDF so file-dependent tool panels render. Point at a real fixture.
|
||||
const SAMPLE_PDF = process.env.PR_SHOT_SAMPLE ?? "src/core/tests/test-fixtures/sample.pdf";
|
||||
|
||||
type Target = { id: string; url: string; name?: string; needsFile?: boolean };
|
||||
const targets: Target[] = JSON.parse(fs.readFileSync(path.join(ROOT, "targets.json"), "utf-8"));
|
||||
|
||||
test.use({ autoGoto: false, viewport: { width: 1600, height: 900 }, seedJwt: true });
|
||||
|
||||
async function applyTheme(page: Page): Promise<void> {
|
||||
if (THEME !== "dark") return;
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("mantine-color-scheme", "dark");
|
||||
localStorage.setItem("mantine-color-scheme-value", "dark");
|
||||
});
|
||||
await page.emulateMedia({ colorScheme: "dark" });
|
||||
}
|
||||
|
||||
async function seedFile(page: Page): Promise<void> {
|
||||
if (!fs.existsSync(SAMPLE_PDF)) return;
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByTestId("files-button").click().catch(() => {});
|
||||
await page.locator('[data-testid="file-input"]').setInputFiles(SAMPLE_PDF).catch(() => {});
|
||||
await page.locator(".file-sidebar-file-item").first().isVisible({ timeout: 8_000 }).catch(() => {});
|
||||
}
|
||||
|
||||
for (const t of targets) {
|
||||
// One test per target so a single failure doesn't drop the rest.
|
||||
test(`${SIDE}/${THEME} ${t.id}`, async ({ page }) => {
|
||||
fs.mkdirSync(OUT, { recursive: true });
|
||||
await applyTheme(page);
|
||||
if (t.needsFile !== false) await seedFile(page);
|
||||
await page.goto(t.url, { waitUntil: "domcontentloaded" });
|
||||
await page.waitForTimeout(400); // settle Mantine portals/transitions
|
||||
const shot = path.join(OUT, `${t.id}__${THEME}.png`);
|
||||
if (SCOPE) {
|
||||
const scope = page.locator(SCOPE).first();
|
||||
if (await scope.isVisible({ timeout: 8_000 }).catch(() => false)) {
|
||||
await scope.screenshot({ path: shot });
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Full viewport (fixed size → stable dimensions for pixel diffing).
|
||||
await page.screenshot({ path: shot });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// Auto-diff before/ vs after/ screenshots, classify each as
|
||||
// unchanged | changed | added | removed, and CROP each changed pair to the
|
||||
// affected region (bounding box of differing pixels + padding) - unless the
|
||||
// change spans most of the page, in which case the full frame is kept.
|
||||
// Run from frontend/editor (so deps resolve):
|
||||
// node <skill>/diff-shots.mjs <beforeDir> <afterDir> [outDir]
|
||||
// Env:
|
||||
// DIFF_THRESHOLD min fraction of differing pixels to count as changed (default 0.001)
|
||||
// DIFF_PAD padding px around the affected region (default 24)
|
||||
// DIFF_PAGEWIDE if affected bbox area / image area exceeds this, keep full frame (default 0.6)
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
const require = createRequire(path.join(process.cwd(), "noop.js"));
|
||||
const pm = require("pixelmatch");
|
||||
const pixelmatch = pm.default || pm;
|
||||
const { PNG } = require("pngjs");
|
||||
|
||||
const beforeDir = path.resolve(process.argv[2]);
|
||||
const afterDir = path.resolve(process.argv[3]);
|
||||
const outDir = path.resolve(process.argv[4] || afterDir);
|
||||
const THRESHOLD = Number(process.env.DIFF_THRESHOLD ?? "0.001");
|
||||
const PAD = Number(process.env.DIFF_PAD ?? "24");
|
||||
const PAGEWIDE = Number(process.env.DIFF_PAGEWIDE ?? "0.6");
|
||||
|
||||
const read = (p) => PNG.sync.read(fs.readFileSync(p));
|
||||
const isShot = (f) => f.endsWith(".png") && !/__(diff|before_crop|after_crop)\.png$/.test(f);
|
||||
const list = (d) => (fs.existsSync(d) ? fs.readdirSync(d).filter(isShot) : []);
|
||||
const names = [...new Set([...list(beforeDir), ...list(afterDir)])].sort();
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
|
||||
function cropPNG(src, x, y, w, h) {
|
||||
const out = new PNG({ width: w, height: h });
|
||||
PNG.bitblt(src, out, x, y, w, h, 0, 0);
|
||||
return out;
|
||||
}
|
||||
const writePNG = (p, png) => fs.writeFileSync(p, PNG.sync.write(png));
|
||||
|
||||
// Bounding box of differing pixels using a diff mask (alpha>0 where changed).
|
||||
function changedBBox(before, after, w, h) {
|
||||
const mask = new PNG({ width: w, height: h });
|
||||
pixelmatch(before.data, after.data, mask.data, w, h, { threshold: 0.1, diffMask: true });
|
||||
let minX = w, minY = h, maxX = -1, maxY = -1, count = 0;
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
if (mask.data[(y * w + x) * 4 + 3] > 0) {
|
||||
count++;
|
||||
if (x < minX) minX = x; if (x > maxX) maxX = x;
|
||||
if (y < minY) minY = y; if (y > maxY) maxY = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
return maxX < 0 ? null : { minX, minY, maxX, maxY, count };
|
||||
}
|
||||
|
||||
const report = [];
|
||||
for (const name of names) {
|
||||
const id = name.replace(/\.png$/, "");
|
||||
const bp = path.join(beforeDir, name), ap = path.join(afterDir, name);
|
||||
const hasB = fs.existsSync(bp), hasA = fs.existsSync(ap);
|
||||
if (hasB && !hasA) { report.push({ id, status: "removed", before: bp }); continue; }
|
||||
if (!hasB && hasA) { report.push({ id, status: "added", after: ap }); continue; }
|
||||
|
||||
const before = read(bp), after = read(ap);
|
||||
if (before.width !== after.width || before.height !== after.height) {
|
||||
report.push({ id, status: "changed", note: "dimensions differ", before: bp, after: ap });
|
||||
continue;
|
||||
}
|
||||
const w = after.width, h = after.height;
|
||||
const overlay = new PNG({ width: w, height: h });
|
||||
const px = pixelmatch(before.data, after.data, overlay.data, w, h, { threshold: 0.1 });
|
||||
const ratio = px / (w * h);
|
||||
if (ratio <= THRESHOLD) { report.push({ id, status: "unchanged", ratio: Number(ratio.toFixed(5)), before: bp, after: ap }); continue; }
|
||||
|
||||
const box = changedBBox(before, after, w, h);
|
||||
// Pad + clamp the affected region.
|
||||
const x = Math.max(0, box.minX - PAD), y = Math.max(0, box.minY - PAD);
|
||||
const x2 = Math.min(w, box.maxX + 1 + PAD), y2 = Math.min(h, box.maxY + 1 + PAD);
|
||||
const bw = x2 - x, bh = y2 - y;
|
||||
const pageWide = (bw * bh) / (w * h) > PAGEWIDE;
|
||||
|
||||
const entry = { id, status: "changed", ratio: Number(ratio.toFixed(5)), before: bp, after: ap, pageWide };
|
||||
if (pageWide) {
|
||||
// Change spans most of the page - keep the full frame, full overlay.
|
||||
const dp = path.join(outDir, `${id}__diff.png`); writePNG(dp, overlay);
|
||||
entry.diff = dp;
|
||||
} else {
|
||||
entry.bbox = { x, y, w: bw, h: bh };
|
||||
const cb = path.join(outDir, `${id}__before_crop.png`); writePNG(cb, cropPNG(before, x, y, bw, bh));
|
||||
const ca = path.join(outDir, `${id}__after_crop.png`); writePNG(ca, cropPNG(after, x, y, bw, bh));
|
||||
const dp = path.join(outDir, `${id}__diff.png`); writePNG(dp, cropPNG(overlay, x, y, bw, bh));
|
||||
entry.cropBefore = cb; entry.cropAfter = ca; entry.diff = dp;
|
||||
}
|
||||
report.push(entry);
|
||||
}
|
||||
|
||||
fs.writeFileSync(path.join(outDir, "diff-report.json"), JSON.stringify(report, null, 2));
|
||||
const changed = report.filter((r) => r.status !== "unchanged");
|
||||
console.log(`diffed ${report.length} view(s): ${changed.length} changed/added/removed, ${report.length - changed.length} unchanged`);
|
||||
for (const r of changed) {
|
||||
const tail = r.status !== "changed" ? ""
|
||||
: r.pageWide ? " (page-wide → full frame)"
|
||||
: ` (${(r.ratio * 100).toFixed(2)}%, cropped to ${r.bbox.w}×${r.bbox.h})`;
|
||||
console.log(` ${r.status.padEnd(9)} ${r.id}${tail}${r.note ? " - " + r.note : ""}`);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Build EXAMPLE.html from montage-template.html using REAL files-page shots as
|
||||
stand-in before/after pairs (layout demo, not an actual PR diff). Inlines PNGs as
|
||||
data URIs so the HTML is portable. Run: python make_example.py"""
|
||||
import base64
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
HERE = pathlib.Path(__file__).parent
|
||||
SHOTS = pathlib.Path(
|
||||
r"C:\Users\systo\git\Stirling-PDFNew\.claude\worktrees\kind-faraday-522a30"
|
||||
r"\frontend\editor\screenshots\files-page"
|
||||
)
|
||||
|
||||
|
||||
def uri(fname):
|
||||
p = SHOTS / fname
|
||||
return "data:image/png;base64," + base64.b64encode(p.read_bytes()).decode() if p.exists() else None
|
||||
|
||||
|
||||
data = {
|
||||
"pr": "DEMO",
|
||||
"title": "EXAMPLE — before/after montage (layout demo, real Files-page shots; not a real PR diff)",
|
||||
"base": "main", "head": "demo-branch",
|
||||
"cropSelector": "[data-sidebar=\"tool-panel\"] (real runs crop to the side; these demo shots are full-page)",
|
||||
"tabs": [
|
||||
{"id": "files", "title": "Files page", "ctx": "Each row = one flow state; left = base branch, right = this PR.",
|
||||
"states": [
|
||||
{"name": "Empty folder", "before": uri("01_empty_state_ctas.png"), "after": uri("02_empty_state_storage_off.png")},
|
||||
{"name": "Files + details panel", "before": uri("03_subtoolbar_with_files.png"), "after": uri("06_details_panel_save_to_server.png")},
|
||||
{"name": "Delete folder confirm", "before": None, "after": uri("19_delete_folder_dialog.png"), "note": "New in this PR"},
|
||||
]},
|
||||
{"id": "move", "title": "Move-to-folder dialog",
|
||||
"states": [
|
||||
{"name": "Dialog opened", "before": uri("07_move_dialog_collapsed.png"), "after": uri("08_move_dialog_create_folder_expanded.png")},
|
||||
{"name": "After folder created", "before": None, "after": uri("08b_move_dialog_after_create_folder.png"), "note": "New flow"},
|
||||
]},
|
||||
],
|
||||
}
|
||||
|
||||
tpl = (HERE / "montage-template.html").read_text(encoding="utf-8")
|
||||
out = re.sub(
|
||||
r"/\*__DATA__\*/.*?/\*__END__\*/",
|
||||
lambda _m: "/*__DATA__*/" + json.dumps(data) + "/*__END__*/",
|
||||
tpl, count=1, flags=re.S,
|
||||
)
|
||||
(HERE / "EXAMPLE.html").write_text(out, encoding="utf-8")
|
||||
print("wrote", HERE / "EXAMPLE.html", "(", (HERE / "EXAMPLE.html").stat().st_size // 1024, "KB )")
|
||||
@@ -0,0 +1,106 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
Before/After montage for a PR description. The ui-before-after skill replaces
|
||||
the JSON in the window.__BA__ data block below with the captured manifest, then
|
||||
screenshots each .tab-section (id="section-<tabId>") into a PNG to drag into the
|
||||
PR description. Self-contained; images may be relative paths or data URIs.
|
||||
|
||||
Data shape:
|
||||
{
|
||||
"pr":"6552","title":"...","base":"main","head":"feat/x",
|
||||
"cropSelector":"[data-sidebar=\"tool-panel\"]",
|
||||
"tabs":[
|
||||
{ "id":"sign","title":"Sign tool","states":[
|
||||
{"name":"Initial","before":"before/sign__initial.png","after":"after/sign__initial.png"},
|
||||
{"name":"Cert selected","before":null,"after":"after/sign__cert.png","note":"New in this PR"}
|
||||
]}
|
||||
]
|
||||
}
|
||||
-->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Before / After</title>
|
||||
<style>
|
||||
:root { --bg:#ffffff; --ink:#0b0c0e; --muted:#6b7280; --line:#e5e7eb;
|
||||
--before:#6b7280; --after:#1f883d; --frame:#f3f4f6; --note:#b45309; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin:0; background:var(--bg); color:var(--ink);
|
||||
font:14px/1.5 -apple-system,"Segoe UI",Roboto,system-ui,sans-serif; }
|
||||
.wrap { max-width:1100px; margin:0 auto; padding:24px; }
|
||||
.doc-head { margin-bottom:8px; }
|
||||
.doc-head h1 { font-size:18px; margin:0 0 2px; }
|
||||
.doc-head .sub { color:var(--muted); font-size:12.5px; }
|
||||
.legend { display:flex; gap:14px; align-items:center; margin:10px 0 4px; font-size:12px; color:var(--muted); }
|
||||
.chip { font-size:10px; font-weight:700; letter-spacing:.04em; text-transform:uppercase;
|
||||
padding:2px 8px; border-radius:999px; color:#fff; }
|
||||
.chip.before { background:var(--before); } .chip.after { background:var(--after); }
|
||||
|
||||
.tab-section { border:1px solid var(--line); border-radius:14px; padding:18px 18px 8px;
|
||||
margin:18px 0; background:var(--bg); }
|
||||
.tab-section > h2 { font-size:16px; margin:0 0 2px; }
|
||||
.tab-section > .ctx { color:var(--muted); font-size:12px; margin-bottom:14px; }
|
||||
.state { margin-bottom:18px; }
|
||||
.state .name { font-weight:600; font-size:13.5px; margin-bottom:8px; display:flex; gap:8px; align-items:center; }
|
||||
.state .name .note { font-weight:500; color:var(--note); font-size:12px; }
|
||||
.pair { display:grid; grid-template-columns:1fr 1fr; gap:14px; align-items:start; }
|
||||
.cell { border:1px solid var(--line); border-radius:10px; overflow:hidden; background:var(--frame); }
|
||||
.cell .cap { display:flex; align-items:center; gap:8px; padding:7px 10px; border-bottom:1px solid var(--line);
|
||||
background:var(--bg); }
|
||||
.cell .cap .meta { color:var(--muted); font-size:11px; }
|
||||
.cell img { display:block; width:100%; height:auto; background:#fff; }
|
||||
.cell.empty .ph { display:flex; align-items:center; justify-content:center; height:160px; color:var(--muted);
|
||||
font-size:12.5px; text-align:center; padding:0 16px; }
|
||||
.single .pair { grid-template-columns:1fr; }
|
||||
.empty-doc { color:var(--muted); padding:40px; text-align:center; }
|
||||
@media (max-width:760px){ .pair{ grid-template-columns:1fr; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap" id="root"></div>
|
||||
|
||||
<script id="data">
|
||||
window.__BA__ = /*__DATA__*/{"pr":"","title":"No data","base":"","head":"","cropSelector":"","tabs":[]}/*__END__*/;
|
||||
</script>
|
||||
<script>
|
||||
(function(){
|
||||
var D = window.__BA__ || { tabs: [] };
|
||||
var root = document.getElementById("root");
|
||||
function el(html){ var t=document.createElement("template"); t.innerHTML=html.trim(); return t.content.firstChild; }
|
||||
function esc(s){ return (s==null?"":String(s)).replace(/[&<>]/g, function(c){return {"&":"&","<":"<",">":">"}[c];}); }
|
||||
|
||||
function cell(kind, src){
|
||||
if (src) {
|
||||
return '<div class="cell"><div class="cap"><span class="chip '+kind+'">'+kind+'</span></div>'+
|
||||
'<img src="'+esc(src)+'" alt="'+kind+'"/></div>';
|
||||
}
|
||||
return '<div class="cell empty"><div class="cap"><span class="chip '+kind+'">'+kind+'</span>'+
|
||||
'<span class="meta">not present</span></div><div class="ph">No '+kind+' screenshot for this state</div></div>';
|
||||
}
|
||||
|
||||
var head = '<div class="doc-head"><h1>'+esc(D.title || ("PR #"+D.pr))+'</h1>'+
|
||||
'<div class="sub">Before / after · base <code>'+esc(D.base)+'</code> → head <code>'+esc(D.head)+'</code>'+
|
||||
(D.cropSelector ? ' · cropped to <code>'+esc(D.cropSelector)+'</code>' : '')+'</div></div>'+
|
||||
'<div class="legend"><span class="chip before">Before</span> base branch'+
|
||||
'<span class="chip after">After</span> this PR</div>';
|
||||
root.appendChild(el('<div>'+head+'</div>'));
|
||||
|
||||
if (!D.tabs || !D.tabs.length){ root.appendChild(el('<div class="empty-doc">No tabs captured yet.</div>')); return; }
|
||||
|
||||
D.tabs.forEach(function(tab){
|
||||
var states = (tab.states||[]).map(function(s){
|
||||
var onlyOne = (!s.before || !s.after);
|
||||
return '<div class="state'+(onlyOne?' ':'')+'">'+
|
||||
'<div class="name">'+esc(s.name)+(s.note?'<span class="note">'+esc(s.note)+'</span>':'')+'</div>'+
|
||||
'<div class="pair">'+cell("before", s.before)+cell("after", s.after)+'</div></div>';
|
||||
}).join("");
|
||||
var sec = '<section class="tab-section" id="section-'+esc(tab.id)+'">'+
|
||||
'<h2>'+esc(tab.title)+'</h2>'+
|
||||
(tab.ctx?'<div class="ctx">'+esc(tab.ctx)+'</div>':'')+
|
||||
states+'</section>';
|
||||
root.appendChild(el(sec));
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,25 @@
|
||||
// Render each .tab-section of a montage HTML into its own PNG (the PR-ready image).
|
||||
// Run from frontend/editor (so @playwright/test resolves):
|
||||
// node <skill>/shoot-sections.mjs <montage.html> <outDir>
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
const require = createRequire(path.join(process.cwd(), "noop.js"));
|
||||
const { chromium } = require("@playwright/test");
|
||||
|
||||
const htmlPath = path.resolve(process.argv[2]);
|
||||
const outDir = path.resolve(process.argv[3] || path.dirname(htmlPath));
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 1200, height: 1200 }, deviceScaleFactor: 2 });
|
||||
await page.goto(pathToFileURL(htmlPath).href, { waitUntil: "load" });
|
||||
await page.waitForTimeout(250); // let images/fonts paint
|
||||
const ids = await page.$$eval(".tab-section", (els) => els.map((e) => e.id));
|
||||
if (!ids.length) { console.error("no .tab-section found"); process.exit(1); }
|
||||
for (const id of ids) {
|
||||
const name = id.replace(/^section-/, "");
|
||||
await page.locator("#" + id).screenshot({ path: path.join(outDir, `montage_${name}.png`) });
|
||||
console.log("wrote montage_" + name + ".png");
|
||||
}
|
||||
await browser.close();
|
||||
@@ -0,0 +1,120 @@
|
||||
---
|
||||
name: ui-walkthrough
|
||||
description: >-
|
||||
Full UI investigation of the current branch's feature. Enumerates every view
|
||||
and state (empty, populated, loading, error, each dialog/menu/panel, responsive
|
||||
breakpoints, light + dark + RTL), captures them with the stubbed Playwright
|
||||
harness, assembles a single-image HTML walkthrough with a global light/dark
|
||||
toggle slider, then runs two review passes: visual/consistency (alignment,
|
||||
spacing, professionalism, dark/light parity, contrast, truncation) and
|
||||
UX/ease-of-use (flow, discoverability, affordances, empty/error states,
|
||||
expectations). Use when asked for a UI walkthrough, screenshot review, design
|
||||
or QA pass, "find anywhere to make it easier/better for users", or before
|
||||
merging frontend work. Pass --fix to auto-apply safe frontend fixes and
|
||||
re-capture; --theme to limit themes; --no-rtl to skip RTL.
|
||||
argument-hint: "[feature/area] [--fix] [--theme light|dark|both] [--no-rtl] [--breakpoints]"
|
||||
allowed-tools: Read, Write, Edit, Glob, Grep, Bash
|
||||
---
|
||||
|
||||
# UI Walkthrough
|
||||
|
||||
Produce a reviewable HTML walkthrough of a feature's UI in every state and theme,
|
||||
then critique it. Optionally auto-fix and re-capture.
|
||||
|
||||
`$ARGUMENTS` may name the feature/area to focus on. If empty, scope from the
|
||||
current branch diff. Flags: `--fix`, `--theme light|dark|both` (default both),
|
||||
`--no-rtl`, `--breakpoints` (also capture phone/narrow widths).
|
||||
|
||||
## What this repo gives you (use it, don't reinvent)
|
||||
|
||||
- **Stubbed Playwright project** = backend-free screenshots via `page.route()` mocks.
|
||||
Reference implementation: `frontend/editor/src/core/tests/stubbed/files-page-screenshots.spec.ts`.
|
||||
It already shows the light / **dark** / **RTL** passes, JWT seeding, IndexedDB
|
||||
seeding, and dumping PNGs to a `screenshots/<area>/` folder. Copy its shape.
|
||||
- Helpers: `frontend/editor/src/core/tests/helpers/ui-helpers.ts`
|
||||
(`uploadFiles`, `openSettings`, `waitForModalOpen`, `dismissTourTooltip`, …)
|
||||
and the `stub-test-base` fixtures (`autoGoto`, `seedJwt`, `viewport`).
|
||||
- Config: `frontend/editor/playwright.config.ts` (run from `frontend/editor/`).
|
||||
- Report template: [report-template.html](report-template.html) - self-contained,
|
||||
one big image at a time, a global light/dark slider that flips every shot,
|
||||
thumbnail rail, prev/next + arrow keys, and a Findings tab.
|
||||
|
||||
## Process
|
||||
|
||||
### 1. Scope the feature
|
||||
- If `$ARGUMENTS` is empty: `git diff --name-only main...HEAD` and read the PR/commits.
|
||||
Identify changed pages, tools (`core/components/tools/<tool>` or `core/tools/<tool>`),
|
||||
dialogs, panels, and routes.
|
||||
- Enumerate **every view and state** to capture, e.g.:
|
||||
empty / populated / loading / error / disabled; each dialog, menu, popover, tooltip;
|
||||
each tab or step; selection + multi-select; success/result panel; and (if relevant)
|
||||
permission/role variants. Write the list down before capturing - it's the report's spine.
|
||||
|
||||
### 2. Prepare the harness (worktree-safe)
|
||||
Worktrees have no `node_modules` and no generated icons. From repo root:
|
||||
```
|
||||
cd frontend && npm ci # or junction main's node_modules (see memory)
|
||||
cd frontend/editor && node scripts/generate-icons.js
|
||||
```
|
||||
Kill any stale dev server first (it serves old modules):
|
||||
`Get-NetTCPConnection -LocalPort 5173 -State Listen | %{ Stop-Process -Id $_.OwningProcess -Force }`
|
||||
|
||||
### 3. Write the capture spec
|
||||
Create `frontend/editor/src/core/tests/stubbed/<feature>-walkthrough.spec.ts`,
|
||||
modeled on `files-page-screenshots.spec.ts`. For each enumerated view:
|
||||
- stub the APIs it needs, drive the UI to that state, wait on a real locator
|
||||
(not a fixed sleep), `await settle(page)` for Mantine portals, then
|
||||
`page.screenshot({ path: shotPath("NN_name_<theme>") })`.
|
||||
- Capture each view in **light and dark** (and RTL unless `--no-rtl`). Reuse the
|
||||
`enableDarkMode` / `enableRtl` init-script pattern from the reference spec
|
||||
(`localStorage["mantine-color-scheme"]="dark"` + `emulateMedia({colorScheme:"dark"})`).
|
||||
- Name shots `NN_<view>_<theme>.png` so light/dark pair up by suffix.
|
||||
- Prefer **stable test-ids** over translated accessible names (RTL/i18n breaks text locators).
|
||||
|
||||
Run it: `cd frontend/editor && npx playwright test --project=stubbed <feature>-walkthrough.spec.ts`.
|
||||
Add `--project=stubbed-firefox`/`-webkit` only if cross-browser layout matters.
|
||||
|
||||
### 4. Build the report
|
||||
- Copy `report-template.html` to `screenshots/<feature>/walkthrough.html` (so the
|
||||
relative `screenshots/...` image paths resolve, or rewrite paths to sit beside it).
|
||||
- Build the manifest and inject it: replace the JSON between the
|
||||
`/*__DATA__*/` … `/*__END__*/` markers with one `views[]` entry per view
|
||||
(`{id,title,light,dark,viewport,notes}`) and an empty `findings` object you'll
|
||||
fill in step 5. Keep `light`/`dark` as relative paths.
|
||||
- The toggle slider answers the "one big image + flip light/dark for all" request:
|
||||
it shows a single large screenshot, and switching the slider re-themes every view.
|
||||
|
||||
### 5. Review pass 1 - visual & consistency
|
||||
Open each screenshot (Read the PNG) and judge against the others:
|
||||
alignment & spacing rhythm, control placement, button hierarchy, typography,
|
||||
**light/dark parity** (contrast, invisible borders, washed-out text, wrong tokens),
|
||||
truncation/overflow, RTL mirroring, focus states, icon consistency, professional polish.
|
||||
Record each issue as a finding `{severity:high|med|low, view, title, detail, fix}`.
|
||||
|
||||
### 6. Review pass 2 - UX & ease of use
|
||||
Walk the flow as a first-time user: discoverability, number of steps, affordance
|
||||
clarity, empty-state guidance, error recovery, destructive-action confirmation,
|
||||
defaults, loading feedback, mobile reachability, accessible names, and whether the
|
||||
UI matches user expectations for this kind of tool. Record findings the same way.
|
||||
|
||||
Write both finding lists into the report's `findings.visual` / `findings.ux`,
|
||||
and add short per-view `notes`. Re-inject the manifest.
|
||||
|
||||
### 7. If `--fix`
|
||||
Only safe, self-contained frontend fixes (spacing, alignment, tokens, missing
|
||||
dark-mode colors, labels, aria, obvious copy). For each: edit the component/CSS,
|
||||
mark the finding `fixed:true` with what changed, then **re-run the spec** to
|
||||
re-capture the affected shots and regenerate the report. Run `task frontend:check`.
|
||||
Leave anything risky or ambiguous as a finding, not a change.
|
||||
|
||||
### 8. Deliver
|
||||
Tell the user the report path and give a tight chat summary: N views ×
|
||||
themes captured, top findings by severity, and (if `--fix`) what changed.
|
||||
Optionally `SendUserFile` the `walkthrough.html`.
|
||||
|
||||
## Gotchas
|
||||
- Stale `:5173` server serves old bundles - kill it before capturing (see step 2).
|
||||
- Missing `material-symbols-icons.json` → blank app → every shot times out. Run
|
||||
`generate-icons.js` first.
|
||||
- `await settle(page)` before shots or portals/transitions tear mid-capture.
|
||||
- Don't commit the generated `screenshots/` or the throwaway spec unless asked.
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Build a self-contained EXAMPLE.html from report-template.html with mock
|
||||
light/dark screenshots, so the viewer + global theme slider can be demoed
|
||||
without a real capture run. Run: python make_example.py"""
|
||||
import base64
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
HERE = pathlib.Path(__file__).parent
|
||||
|
||||
|
||||
def svg(bg, fg, panel, accent, muted, label, kind):
|
||||
"""A simple fake 'screen' SVG: title bar, sidebar, content varies by kind."""
|
||||
parts = [
|
||||
f'<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="900" viewBox="0 0 1600 900">',
|
||||
f'<rect width="1600" height="900" fill="{bg}"/>',
|
||||
# top bar
|
||||
f'<rect width="1600" height="64" fill="{panel}"/>',
|
||||
f'<circle cx="40" cy="32" r="12" fill="{accent}"/>',
|
||||
f'<rect x="64" y="24" width="160" height="16" rx="6" fill="{muted}"/>',
|
||||
f'<rect x="1430" y="20" width="130" height="24" rx="12" fill="{accent}"/>',
|
||||
# left sidebar
|
||||
f'<rect x="0" y="64" width="220" height="836" fill="{panel}"/>',
|
||||
]
|
||||
for i in range(6):
|
||||
y = 100 + i * 56
|
||||
parts.append(f'<rect x="24" y="{y}" width="172" height="32" rx="8" fill="{bg}"/>')
|
||||
if kind == "empty":
|
||||
parts += [
|
||||
f'<rect x="700" y="360" width="200" height="120" rx="16" fill="none" stroke="{muted}" stroke-width="3" stroke-dasharray="10 8"/>',
|
||||
f'<rect x="690" y="510" width="220" height="44" rx="10" fill="{accent}"/>',
|
||||
f'<text x="800" y="600" fill="{muted}" font-family="sans-serif" font-size="26" text-anchor="middle">{label}</text>',
|
||||
]
|
||||
elif kind == "form":
|
||||
for i in range(4):
|
||||
y = 140 + i * 90
|
||||
parts.append(f'<rect x="280" y="{y}" width="160" height="16" rx="6" fill="{muted}"/>')
|
||||
parts.append(f'<rect x="280" y="{y+26}" width="900" height="44" rx="8" fill="{panel}" stroke="{muted}" stroke-width="1"/>')
|
||||
parts.append(f'<rect x="280" y="560" width="200" height="50" rx="10" fill="{accent}"/>')
|
||||
parts.append(f'<text x="800" y="850" fill="{muted}" font-family="sans-serif" font-size="24" text-anchor="middle">{label}</text>')
|
||||
else: # dialog
|
||||
parts += [
|
||||
f'<rect width="1600" height="900" fill="{fg}" opacity="0.45"/>',
|
||||
f'<rect x="520" y="280" width="560" height="360" rx="18" fill="{panel}"/>',
|
||||
f'<rect x="556" y="320" width="280" height="22" rx="8" fill="{fg}"/>',
|
||||
f'<rect x="556" y="372" width="488" height="14" rx="6" fill="{muted}"/>',
|
||||
f'<rect x="556" y="398" width="420" height="14" rx="6" fill="{muted}"/>',
|
||||
f'<rect x="820" y="560" width="110" height="44" rx="9" fill="{bg}" stroke="{muted}"/>',
|
||||
f'<rect x="946" y="560" width="98" height="44" rx="9" fill="{accent}"/>',
|
||||
f'<text x="800" y="700" fill="#fff" font-family="sans-serif" font-size="24" text-anchor="middle">{label}</text>',
|
||||
]
|
||||
parts.append("</svg>")
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def data_uri(s):
|
||||
return "data:image/svg+xml;base64," + base64.b64encode(s.encode()).decode()
|
||||
|
||||
|
||||
LIGHT = dict(bg="#ffffff", fg="#111418", panel="#f1f3f6", accent="#2f6fed", muted="#c2c8d0")
|
||||
DARK = dict(bg="#16181c", fg="#000000", panel="#1f232a", accent="#5b8cff", muted="#3a414b")
|
||||
|
||||
|
||||
def pair(kind, label):
|
||||
return (
|
||||
data_uri(svg(LIGHT["bg"], LIGHT["fg"], LIGHT["panel"], LIGHT["accent"], LIGHT["muted"], label, kind)),
|
||||
data_uri(svg(DARK["bg"], DARK["fg"], DARK["panel"], DARK["accent"], DARK["muted"], label, kind)),
|
||||
)
|
||||
|
||||
|
||||
views = []
|
||||
for idx, (kind, title, label) in enumerate([
|
||||
("empty", "Empty state", "Drop a PDF to start"),
|
||||
("form", "Tool options panel", "Compress options"),
|
||||
("dialog", "Confirm dialog", "Replace original file?"),
|
||||
], start=1):
|
||||
light, dark = pair(kind, label)
|
||||
views.append({
|
||||
"id": f"{idx:02d}_{kind}",
|
||||
"title": title,
|
||||
"light": light,
|
||||
"dark": dark,
|
||||
"viewport": "1600x900",
|
||||
"notes": ["This is mock data to demo the viewer."],
|
||||
})
|
||||
|
||||
data = {
|
||||
"feature": "EXAMPLE - Compress PDF (mock data)",
|
||||
"branch": "demo",
|
||||
"generated": "example",
|
||||
"views": views,
|
||||
"findings": {
|
||||
"visual": [
|
||||
{"severity": "high", "view": "03_dialog", "title": "Dialog buttons too close",
|
||||
"detail": "Cancel/Confirm have only 8px gap; easy to misclick.",
|
||||
"fix": "Increase gap to var(--mantine-spacing-md)."},
|
||||
{"severity": "low", "view": "02_form", "title": "Field labels low contrast in dark mode",
|
||||
"detail": "Muted token fails WCAG AA on the dark panel.",
|
||||
"fix": "Use --mantine-color-dimmed instead of a hard-coded grey."},
|
||||
],
|
||||
"ux": [
|
||||
{"severity": "med", "view": "01_empty", "title": "Primary CTA below the dropzone",
|
||||
"detail": "Users expect the action button adjacent to the dropzone.",
|
||||
"fix": "Move the button directly under the dashed zone."},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
tpl = (HERE / "report-template.html").read_text(encoding="utf-8")
|
||||
out = re.sub(
|
||||
r"/\*__DATA__\*/.*?/\*__END__\*/",
|
||||
lambda _m: "/*__DATA__*/" + json.dumps(data) + "/*__END__*/",
|
||||
tpl, count=1, flags=re.S,
|
||||
)
|
||||
(HERE / "EXAMPLE.html").write_text(out, encoding="utf-8")
|
||||
print("wrote", (HERE / "EXAMPLE.html"))
|
||||
@@ -0,0 +1,298 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
UI Walkthrough report template (self-contained, works from file://).
|
||||
The ui-walkthrough skill replaces the JSON in the window.__WALKTHROUGH__ data
|
||||
block below with the captured manifest. Do not add external CDN deps - it must open offline.
|
||||
|
||||
Data shape:
|
||||
{
|
||||
"feature": "Compress PDF tool",
|
||||
"branch": "claude/...",
|
||||
"generated": "2026-06-21",
|
||||
"views": [
|
||||
{ "id": "01_empty", "title": "Empty state",
|
||||
"light": "screenshots/compress/01_empty_light.png",
|
||||
"dark": "screenshots/compress/01_empty_dark.png",
|
||||
"viewport": "1600x900",
|
||||
"notes": ["Heading is centered", "Primary CTA below the fold on mobile"] }
|
||||
],
|
||||
"findings": {
|
||||
"visual": [ { "severity":"high", "view":"01_empty", "title":"...", "detail":"...", "fix":"..." } ],
|
||||
"ux": [ { "severity":"med", "view":"03_dialog", "title":"...", "detail":"...", "fix":"..." } ]
|
||||
}
|
||||
}
|
||||
-->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>UI Walkthrough</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f6f7f9; --panel: #ffffff; --panel-2: #f0f2f5; --text: #1a1b1e;
|
||||
--muted: #6b7280; --border: #e2e5ea; --accent: #2f6fed; --accent-weak: #e8f0fe;
|
||||
--shadow: 0 1px 3px rgba(0,0,0,.08), 0 8px 24px rgba(0,0,0,.06);
|
||||
--hi: #d92d20; --med: #d98e00; --low: #2f6fed; --stage: #0b0c0e;
|
||||
}
|
||||
html[data-theme="dark"] {
|
||||
--bg: #0d0e10; --panel: #16181c; --panel-2: #1d2024; --text: #e6e8eb;
|
||||
--muted: #9aa3ad; --border: #2a2e35; --accent: #5b8cff; --accent-weak: #1a2336;
|
||||
--shadow: 0 1px 3px rgba(0,0,0,.5), 0 8px 24px rgba(0,0,0,.4); --stage: #000;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; font: 14px/1.5 -apple-system, "Segoe UI", Roboto, system-ui, sans-serif;
|
||||
background: var(--bg); color: var(--text); }
|
||||
header { display: flex; align-items: center; gap: 16px; padding: 12px 20px;
|
||||
background: var(--panel); border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 5; }
|
||||
header h1 { font-size: 15px; margin: 0; font-weight: 650; }
|
||||
header .sub { color: var(--muted); font-size: 12px; }
|
||||
.spacer { flex: 1; }
|
||||
.counter { color: var(--muted); font-variant-numeric: tabular-nums; font-size: 13px; }
|
||||
.tabs { display: flex; gap: 4px; }
|
||||
.tab { border: 1px solid var(--border); background: var(--panel-2); color: var(--text);
|
||||
padding: 6px 12px; border-radius: 8px; cursor: pointer; font-size: 13px; }
|
||||
.tab.active { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
|
||||
/* Light/Dark slider */
|
||||
.theme-toggle { display: flex; align-items: center; gap: 9px; user-select: none; }
|
||||
.theme-toggle .lbl { font-size: 12px; color: var(--muted); }
|
||||
.theme-toggle .lbl.on { color: var(--text); font-weight: 600; }
|
||||
.switch { position: relative; width: 52px; height: 28px; }
|
||||
.switch input { opacity: 0; width: 0; height: 0; }
|
||||
.slider { position: absolute; inset: 0; cursor: pointer; background: var(--panel-2);
|
||||
border: 1px solid var(--border); border-radius: 999px; transition: .2s; }
|
||||
.slider:before { content: ""; position: absolute; height: 20px; width: 20px; left: 3px; top: 3px;
|
||||
background: #fbbf24; border-radius: 50%; transition: .2s; box-shadow: 0 1px 2px rgba(0,0,0,.3); }
|
||||
.switch input:checked + .slider { background: var(--accent); }
|
||||
.switch input:checked + .slider:before { transform: translateX(24px); background: #c7d2fe; }
|
||||
|
||||
main { display: grid; grid-template-columns: 240px 1fr; height: calc(100vh - 53px); }
|
||||
.rail { border-right: 1px solid var(--border); overflow-y: auto; background: var(--panel); padding: 8px; }
|
||||
.rail .group-label { font-size: 11px; text-transform: uppercase; letter-spacing: .05em;
|
||||
color: var(--muted); padding: 10px 8px 4px; }
|
||||
.thumb { display: flex; gap: 9px; align-items: center; padding: 7px; border-radius: 8px;
|
||||
cursor: pointer; border: 1px solid transparent; }
|
||||
.thumb:hover { background: var(--panel-2); }
|
||||
.thumb.active { background: var(--accent-weak); border-color: var(--accent); }
|
||||
.thumb img { width: 64px; height: 40px; object-fit: cover; border-radius: 4px; border: 1px solid var(--border); background: var(--stage); }
|
||||
.thumb .t { font-size: 12.5px; line-height: 1.3; }
|
||||
.thumb .badge { font-size: 10px; color: var(--muted); }
|
||||
.thumb .dot { width: 7px; height: 7px; border-radius: 50%; margin-left: auto; flex: none; }
|
||||
|
||||
.stagewrap { display: flex; flex-direction: column; min-width: 0; }
|
||||
.stage { flex: 1; display: flex; align-items: center; justify-content: center; padding: 22px;
|
||||
background: var(--stage); position: relative; min-height: 0; }
|
||||
.stage img { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 8px;
|
||||
box-shadow: 0 4px 30px rgba(0,0,0,.4); background: #fff; }
|
||||
html[data-theme="dark"] .stage img { background: #16181c; }
|
||||
.nav-btn { position: absolute; top: 50%; transform: translateY(-50%); width: 42px; height: 42px;
|
||||
border-radius: 50%; border: 1px solid var(--border); background: var(--panel);
|
||||
color: var(--text); cursor: pointer; font-size: 18px; opacity: .85; }
|
||||
.nav-btn:hover { opacity: 1; } .nav-btn.prev { left: 16px; } .nav-btn.next { right: 16px; }
|
||||
.nav-btn:disabled { opacity: .25; cursor: default; }
|
||||
.missing { color: var(--muted); font-size: 13px; text-align: center; }
|
||||
|
||||
.detail { border-top: 1px solid var(--border); background: var(--panel); padding: 14px 20px;
|
||||
max-height: 38vh; overflow-y: auto; }
|
||||
.detail h2 { margin: 0 0 4px; font-size: 15px; }
|
||||
.detail .meta { color: var(--muted); font-size: 12px; margin-bottom: 10px; }
|
||||
.notes { list-style: none; padding: 0; margin: 0; display: grid; gap: 6px; }
|
||||
.notes li { display: flex; gap: 8px; align-items: flex-start; }
|
||||
.sev { font-size: 10px; font-weight: 700; text-transform: uppercase; padding: 2px 7px; border-radius: 999px;
|
||||
color: #fff; flex: none; margin-top: 1px; }
|
||||
.sev.high { background: var(--hi); } .sev.med { background: var(--med); } .sev.low { background: var(--low); }
|
||||
.finding .fix { color: var(--muted); font-size: 12.5px; }
|
||||
.finding .fix b { color: var(--text); font-weight: 600; }
|
||||
|
||||
/* Summary tab */
|
||||
.summary { padding: 20px 28px; overflow-y: auto; }
|
||||
.summary h2 { font-size: 16px; margin: 22px 0 8px; }
|
||||
.summary .empty { color: var(--muted); }
|
||||
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
|
||||
padding: 12px 14px; margin-bottom: 8px; box-shadow: var(--shadow); }
|
||||
.card .head { display: flex; gap: 8px; align-items: center; }
|
||||
.card a { color: var(--accent); text-decoration: none; cursor: pointer; }
|
||||
.hide { display: none !important; }
|
||||
kbd { font: 11px ui-monospace, monospace; background: var(--panel-2); border: 1px solid var(--border);
|
||||
border-radius: 4px; padding: 1px 5px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div>
|
||||
<h1 id="feature-title">UI Walkthrough</h1>
|
||||
<div class="sub" id="feature-sub"></div>
|
||||
</div>
|
||||
<div class="spacer"></div>
|
||||
<div class="tabs">
|
||||
<button class="tab active" data-tab="viewer">Walkthrough</button>
|
||||
<button class="tab" data-tab="summary">Findings</button>
|
||||
</div>
|
||||
<div class="counter" id="counter"></div>
|
||||
<label class="theme-toggle" title="Toggle light / dark for every screenshot">
|
||||
<span class="lbl" id="lbl-light">Light</span>
|
||||
<span class="switch"><input type="checkbox" id="theme-switch" /><span class="slider"></span></span>
|
||||
<span class="lbl" id="lbl-dark">Dark</span>
|
||||
</label>
|
||||
</header>
|
||||
|
||||
<main id="viewer-pane">
|
||||
<aside class="rail" id="rail"></aside>
|
||||
<section class="stagewrap">
|
||||
<div class="stage">
|
||||
<button class="nav-btn prev" id="prev" aria-label="Previous">‹</button>
|
||||
<img id="stage-img" alt="" />
|
||||
<div class="missing hide" id="missing"></div>
|
||||
<button class="nav-btn next" id="next" aria-label="Next">›</button>
|
||||
</div>
|
||||
<div class="detail">
|
||||
<h2 id="view-title"></h2>
|
||||
<div class="meta" id="view-meta"></div>
|
||||
<ul class="notes" id="view-notes"></ul>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<section class="summary hide" id="summary-pane"></section>
|
||||
|
||||
<script id="data">
|
||||
window.__WALKTHROUGH__ = /*__DATA__*/{"feature":"No data","branch":"","generated":"","views":[],"findings":{"visual":[],"ux":[]}}/*__END__*/;
|
||||
</script>
|
||||
<script>
|
||||
(function () {
|
||||
var D = window.__WALKTHROUGH__ || { views: [], findings: { visual: [], ux: [] } };
|
||||
var views = D.views || [];
|
||||
var state = { i: 0, theme: localStorage.getItem("ui-wt-theme") || "light", tab: "viewer" };
|
||||
|
||||
var $ = function (id) { return document.getElementById(id); };
|
||||
function sevClass(s) { return s === "high" ? "high" : s === "med" || s === "medium" ? "med" : "low"; }
|
||||
|
||||
function applyChrome() {
|
||||
document.documentElement.setAttribute("data-theme", state.theme);
|
||||
$("theme-switch").checked = state.theme === "dark";
|
||||
$("lbl-light").classList.toggle("on", state.theme === "light");
|
||||
$("lbl-dark").classList.toggle("on", state.theme === "dark");
|
||||
}
|
||||
|
||||
function srcFor(v) { return state.theme === "dark" ? (v.dark || v.light) : (v.light || v.dark); }
|
||||
|
||||
function findingsForView(id) {
|
||||
var all = (D.findings && D.findings.visual || []).concat(D.findings && D.findings.ux || []);
|
||||
return all.filter(function (f) { return f.view === id; });
|
||||
}
|
||||
|
||||
function renderRail() {
|
||||
var rail = $("rail");
|
||||
rail.innerHTML = "";
|
||||
if (!views.length) { rail.innerHTML = '<div class="group-label">No views captured</div>'; return; }
|
||||
views.forEach(function (v, idx) {
|
||||
var fs = findingsForView(v.id);
|
||||
var worst = fs.some(function (f){return sevClass(f.severity)==="high";}) ? "var(--hi)"
|
||||
: fs.some(function (f){return sevClass(f.severity)==="med";}) ? "var(--med)"
|
||||
: fs.length ? "var(--low)" : "transparent";
|
||||
var el = document.createElement("div");
|
||||
el.className = "thumb" + (idx === state.i ? " active" : "");
|
||||
el.innerHTML = '<img src="' + srcFor(v) + '" alt="" />' +
|
||||
'<div><div class="t">' + (v.title || v.id) + '</div>' +
|
||||
'<div class="badge">' + (v.viewport || "") + '</div></div>' +
|
||||
'<span class="dot" style="background:' + worst + '"></span>';
|
||||
el.onclick = function () { state.i = idx; render(); };
|
||||
rail.appendChild(el);
|
||||
});
|
||||
}
|
||||
|
||||
function render() {
|
||||
applyChrome();
|
||||
if (!views.length) {
|
||||
$("missing").classList.remove("hide"); $("stage-img").classList.add("hide");
|
||||
$("missing").textContent = "No screenshots in this report yet.";
|
||||
$("counter").textContent = ""; return;
|
||||
}
|
||||
var v = views[state.i];
|
||||
var src = srcFor(v);
|
||||
var img = $("stage-img");
|
||||
if (src) {
|
||||
img.classList.remove("hide"); $("missing").classList.add("hide");
|
||||
img.src = src; img.alt = v.title || v.id;
|
||||
} else {
|
||||
img.classList.add("hide"); $("missing").classList.remove("hide");
|
||||
$("missing").textContent = "No " + state.theme + " screenshot for this view.";
|
||||
}
|
||||
$("counter").textContent = (state.i + 1) + " / " + views.length;
|
||||
$("view-title").textContent = v.title || v.id;
|
||||
$("view-meta").textContent = [v.viewport, state.theme + " mode"].filter(Boolean).join(" · ");
|
||||
var notes = $("view-notes"); notes.innerHTML = "";
|
||||
var fs = findingsForView(v.id);
|
||||
(v.notes || []).forEach(function (n) {
|
||||
var li = document.createElement("li"); li.textContent = "· " + n; notes.appendChild(li);
|
||||
});
|
||||
fs.forEach(function (f) {
|
||||
var li = document.createElement("li"); li.className = "finding";
|
||||
li.innerHTML = '<span class="sev ' + sevClass(f.severity) + '">' + (f.severity || "note") + '</span>' +
|
||||
'<span><b>' + (f.title || "") + '</b> — ' + (f.detail || "") +
|
||||
(f.fix ? ' <span class="fix"><b>Fix:</b> ' + f.fix + '</span>' : '') + '</span>';
|
||||
notes.appendChild(li);
|
||||
});
|
||||
$("prev").disabled = state.i === 0;
|
||||
$("next").disabled = state.i === views.length - 1;
|
||||
renderRail();
|
||||
}
|
||||
|
||||
function renderSummary() {
|
||||
var pane = $("summary-pane");
|
||||
function block(title, arr) {
|
||||
var h = '<h2>' + title + ' (' + arr.length + ')</h2>';
|
||||
if (!arr.length) return h + '<div class="empty">None found.</div>';
|
||||
return h + arr.map(function (f) {
|
||||
return '<div class="card"><div class="head">' +
|
||||
'<span class="sev ' + sevClass(f.severity) + '">' + (f.severity || "note") + '</span>' +
|
||||
'<b>' + (f.title || "") + '</b>' +
|
||||
(f.view ? ' <a data-jump="' + f.view + '">' + f.view + '</a>' : '') + '</div>' +
|
||||
'<div style="margin-top:6px">' + (f.detail || "") + '</div>' +
|
||||
(f.fix ? '<div class="finding" style="margin-top:6px"><span class="fix"><b>Fix:</b> ' + f.fix + '</span></div>' : '') +
|
||||
'</div>';
|
||||
}).join("");
|
||||
}
|
||||
pane.innerHTML = block("Visual & consistency", (D.findings && D.findings.visual) || []) +
|
||||
block("UX & ease of use", (D.findings && D.findings.ux) || []);
|
||||
pane.querySelectorAll("[data-jump]").forEach(function (a) {
|
||||
a.onclick = function () {
|
||||
var id = a.getAttribute("data-jump");
|
||||
var idx = views.findIndex(function (v) { return v.id === id; });
|
||||
if (idx >= 0) { state.i = idx; setTab("viewer"); }
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function setTab(t) {
|
||||
state.tab = t;
|
||||
document.querySelectorAll(".tab").forEach(function (b) { b.classList.toggle("active", b.dataset.tab === t); });
|
||||
$("viewer-pane").classList.toggle("hide", t !== "viewer");
|
||||
$("summary-pane").classList.toggle("hide", t !== "summary");
|
||||
if (t === "viewer") $("viewer-pane").style.display = "grid";
|
||||
if (t === "summary") renderSummary();
|
||||
}
|
||||
|
||||
// wiring
|
||||
$("feature-title").textContent = D.feature || "UI Walkthrough";
|
||||
$("feature-sub").textContent = [D.branch, D.generated].filter(Boolean).join(" · ");
|
||||
$("theme-switch").onchange = function () {
|
||||
state.theme = this.checked ? "dark" : "light";
|
||||
localStorage.setItem("ui-wt-theme", state.theme);
|
||||
render();
|
||||
};
|
||||
$("prev").onclick = function () { if (state.i > 0) { state.i--; render(); } };
|
||||
$("next").onclick = function () { if (state.i < views.length - 1) { state.i++; render(); } };
|
||||
document.addEventListener("keydown", function (e) {
|
||||
if (state.tab !== "viewer") return;
|
||||
if (e.key === "ArrowLeft") $("prev").click();
|
||||
if (e.key === "ArrowRight") $("next").click();
|
||||
if (e.key.toLowerCase() === "t") $("theme-switch").click();
|
||||
});
|
||||
document.querySelectorAll(".tab").forEach(function (b) { b.onclick = function () { setTab(b.dataset.tab); }; });
|
||||
|
||||
render();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -116,6 +116,9 @@ jobs:
|
||||
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"
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
@@ -246,7 +249,9 @@ jobs:
|
||||
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-args: |
|
||||
VERSION_TAG=v2-alpha
|
||||
BUILD_PORTAL=${{ env.BUILD_PORTAL }}
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Build and push V2 image (Docker fork fallback)
|
||||
@@ -259,7 +264,9 @@ jobs:
|
||||
cache-from: type=gha,scope=stirling-pdf-latest
|
||||
cache-to: type=gha,mode=max,scope=stirling-pdf-latest
|
||||
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-${{ steps.commit-hash.outputs.app_short }}
|
||||
build-args: VERSION_TAG=v2-alpha
|
||||
build-args: |
|
||||
VERSION_TAG=v2-alpha
|
||||
BUILD_PORTAL=${{ env.BUILD_PORTAL }}
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Set up SSH
|
||||
@@ -290,6 +297,8 @@ 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 }}"
|
||||
SECURITY_INITIALLOGIN_PASSWORD: "${{ secrets.TEST_LOGIN_PASSWORD }}"
|
||||
@@ -359,12 +368,19 @@ jobs:
|
||||
}
|
||||
|
||||
const deploymentUrl = `http://${{ secrets.NEW_VPS_HOST }}:${v2Port}`;
|
||||
const httpsUrl = `https://${v2Port}.ssl.stirlingpdf.cloud`;
|
||||
|
||||
// Only mention the portal when this image actually embeds it.
|
||||
// Use the direct IP URL - the SSL hostname isn't supported yet.
|
||||
const withPortal = "${{ env.BUILD_PORTAL }}" === "true";
|
||||
const portalNote = withPortal
|
||||
? `🧩 **Admin portal** included - try it at [${deploymentUrl}/portal](${deploymentUrl}/portal).\n\n`
|
||||
: ``;
|
||||
|
||||
const commentBody = `## 🚀 V2 Auto-Deployment Complete!\n\n` +
|
||||
`Your V2 PR with embedded architecture has been deployed!\n\n` +
|
||||
`🔗 **Direct Test URL (non-SSL)** [${deploymentUrl}](${deploymentUrl})\n\n` +
|
||||
`🔐 **Secure HTTPS URL**: [${httpsUrl}](${httpsUrl})\n\n` +
|
||||
`🔐 **Secure HTTPS URL**: unsupported currently\n\n` +
|
||||
portalNote +
|
||||
`_This deployment will be automatically cleaned up when the PR is closed._\n\n` +
|
||||
`🔄 **Auto-deployed** for approved V2 contributors.`;
|
||||
|
||||
|
||||
@@ -57,6 +57,15 @@ public class RequestUriUtils {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Admin portal SPA shell. Served publicly like the editor root so a direct
|
||||
// nav / refresh to /portal loads the app (the JWT lives in localStorage, not
|
||||
// a cookie, so the server can't authenticate the navigation itself). The
|
||||
// portal gates access via its own auth gate + RequirePortalAccess, and its
|
||||
// data APIs stay protected, so serving the shell pre-auth is safe.
|
||||
if (normalizedUri.equals("/portal") || normalizedUri.startsWith("/portal/")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Treat common static file extensions as static resources
|
||||
return normalizedUri.endsWith(".svg")
|
||||
|| normalizedUri.endsWith(".png")
|
||||
|
||||
@@ -73,6 +73,14 @@ class RequestUriUtilsTest {
|
||||
assertTrue(RequestUriUtils.isStaticResource("/mobile-scanner"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsStaticResource_portalShell() {
|
||||
// The admin portal SPA shell is served pre-auth so it's directly navigable.
|
||||
assertTrue(RequestUriUtils.isStaticResource("/portal"));
|
||||
assertTrue(RequestUriUtils.isStaticResource("/portal/users"));
|
||||
assertTrue(RequestUriUtils.isStaticResource("/app", "/app/portal"));
|
||||
}
|
||||
|
||||
// --- isFrontendRoute tests ---
|
||||
|
||||
@Test
|
||||
|
||||
+11
-1
@@ -175,6 +175,14 @@ springBoot {
|
||||
// Frontend build tasks - only enabled with -PbuildWithFrontend=true
|
||||
def buildWithFrontend = project.hasProperty('buildWithFrontend') && project.property('buildWithFrontend') == 'true'
|
||||
def buildPrototypes = project.hasProperty('prototypesMode') && project.property('prototypesMode') == 'true'
|
||||
// The admin portal ships as a lazy route inside the editor bundle (see
|
||||
// proprietary/routes/adminRouteExtensions). -PbuildWithPortal=true includes that
|
||||
// chunk via VITE_INCLUDE_PORTAL on the editor build; the deploy GHA sets it when
|
||||
// the portal or AI layers change. Building the portal implies building the editor.
|
||||
def buildWithPortal = project.hasProperty('buildWithPortal') && project.property('buildWithPortal') == 'true'
|
||||
if (buildWithPortal) {
|
||||
buildWithFrontend = true
|
||||
}
|
||||
// Workspace root holds package.json and node_modules (shared across editor /
|
||||
// future portal). Editor-specific paths (src, public, dist, tauri) live one
|
||||
// level deeper under frontend/editor/.
|
||||
@@ -297,9 +305,11 @@ tasks.register('npmBuild', Exec) {
|
||||
// Override VITE_API_BASE_URL to use relative paths for production builds
|
||||
// This ensures JARs work regardless of how they're deployed (direct, proxied, etc.)
|
||||
environment 'VITE_API_BASE_URL', '/'
|
||||
// Include the admin portal's lazy route/chunk in the editor build when requested.
|
||||
environment 'VITE_INCLUDE_PORTAL', (buildWithPortal ? 'true' : 'false')
|
||||
|
||||
doFirst {
|
||||
println "Building editor frontend application for production (mode=${frontendMode}, VITE_API_BASE_URL=/)"
|
||||
println "Building editor frontend application for production (mode=${frontendMode}, VITE_API_BASE_URL=/, portal=${buildWithPortal})"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+10
@@ -130,6 +130,7 @@ public class ControllerAuditAspect {
|
||||
|
||||
String previousPrincipal = MDC.get("auditPrincipal");
|
||||
String previousOrigin = MDC.get("auditOrigin");
|
||||
String previousSource = MDC.get("auditSource");
|
||||
String previousIp = MDC.get("auditIp");
|
||||
|
||||
// EARLY CAPTURE: Capture from SecurityContext on request thread, store in MDC for async
|
||||
@@ -161,6 +162,14 @@ public class ControllerAuditAspect {
|
||||
return joinPoint.proceed();
|
||||
}
|
||||
|
||||
// Stamp the free-UI source only for non-@Audited controller traffic — an actual
|
||||
// tool / UI action. @Audited events (login, settings) return above without a source,
|
||||
// so they never count as an "active editor" or a free UI run. The finally block
|
||||
// restores auditSource, so a pooled thread can't leak a stale "WEB" into them.
|
||||
if (previousSource == null) {
|
||||
MDC.put("auditSource", auditService.captureCurrentSource());
|
||||
}
|
||||
|
||||
long start = System.currentTimeMillis();
|
||||
|
||||
// Use auditService to create the base audit data
|
||||
@@ -247,6 +256,7 @@ public class ControllerAuditAspect {
|
||||
} finally {
|
||||
restoreMdcValue("auditPrincipal", previousPrincipal);
|
||||
restoreMdcValue("auditOrigin", previousOrigin);
|
||||
restoreMdcValue("auditSource", previousSource);
|
||||
restoreMdcValue("auditIp", previousIp);
|
||||
}
|
||||
}
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package stirling.software.proprietary.audit;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** Self-hosted default: admins see the whole-server audit log, everyone else is denied. */
|
||||
@Component
|
||||
public class DefaultPortalAuditScopeResolver implements PortalAuditScopeResolver {
|
||||
|
||||
@Override
|
||||
public PortalAuditScope resolve() {
|
||||
return PortalAuditScopeResolver.hasAdminAuthority()
|
||||
? PortalAuditScope.server()
|
||||
: PortalAuditScope.denied();
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package stirling.software.proprietary.audit;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/** Immutable, cacheable projection of an {@code audit_events} row, shared across portal views. */
|
||||
public record PortalAuditEventRow(
|
||||
long id, String principal, String type, String data, Instant timestamp) {}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package stirling.software.proprietary.audit;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** Resolved audit visibility: fullServer (admin), principals-scoped (team lead), or !allowed. */
|
||||
public record PortalAuditScope(
|
||||
boolean allowed, boolean fullServer, List<String> principals, String cacheKey) {
|
||||
|
||||
public static PortalAuditScope denied() {
|
||||
return new PortalAuditScope(false, false, List.of(), "denied");
|
||||
}
|
||||
|
||||
// Named server()/team() to avoid colliding with the record's fullServer() accessor.
|
||||
public static PortalAuditScope server() {
|
||||
return new PortalAuditScope(true, true, List.of(), "server");
|
||||
}
|
||||
|
||||
public static PortalAuditScope team(String cacheKey, List<String> principals) {
|
||||
return new PortalAuditScope(true, false, List.copyOf(principals), cacheKey);
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package stirling.software.proprietary.audit;
|
||||
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
|
||||
/** Resolves which slice of the audit log the caller may see. */
|
||||
public interface PortalAuditScopeResolver {
|
||||
|
||||
PortalAuditScope resolve();
|
||||
|
||||
/** True when the current authentication carries {@code ROLE_ADMIN}. */
|
||||
static boolean hasAdminAuthority() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
return auth != null
|
||||
&& auth.getAuthorities().stream()
|
||||
.anyMatch(a -> "ROLE_ADMIN".equals(a.getAuthority()));
|
||||
}
|
||||
}
|
||||
+3
@@ -60,6 +60,8 @@ public class CustomAuditEventRepository implements AuditEventRepository {
|
||||
clean.put("requestId", rid);
|
||||
}
|
||||
|
||||
String source = MDC.get("auditSource");
|
||||
|
||||
String auditEventData = mapper.writeValueAsString(clean);
|
||||
log.debug("AuditEvent data (JSON): {}", auditEventData);
|
||||
|
||||
@@ -67,6 +69,7 @@ public class CustomAuditEventRepository implements AuditEventRepository {
|
||||
PersistentAuditEvent.builder()
|
||||
.principal(safePrincipal(ev.getPrincipal()))
|
||||
.type(ev.getType())
|
||||
.source(source)
|
||||
.data(auditEventData)
|
||||
.timestamp(ev.getTimestamp())
|
||||
.build();
|
||||
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package stirling.software.proprietary.controller.api;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.enumeration.Role;
|
||||
import stirling.software.proprietary.audit.AuditLevel;
|
||||
import stirling.software.proprietary.config.AuditConfigurationProperties;
|
||||
import stirling.software.proprietary.model.api.usage.FleetUsageStats;
|
||||
import stirling.software.proprietary.repository.PersistentAuditEventRepository;
|
||||
import stirling.software.proprietary.security.config.EnterpriseEndpoint;
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
|
||||
/**
|
||||
* Admin endpoint exposing free-editor fleet usage for the portal Usage card. Audit-derived figures
|
||||
* (active editors, PDFs processed) are null (rendered as "N/A") rather than a misleading 0 whenever
|
||||
* the data can't exist: the events they count (PDF_PROCESS, FILE_OPERATION, HTTP_REQUEST) are all
|
||||
* STANDARD level, so a gate on {@code isEnabled()} alone would still return 0 at level=OFF/BASIC —
|
||||
* we gate on {@code isLevelEnabled(STANDARD)} instead.
|
||||
*
|
||||
* <p>Known limitation: on a login-disabled self-hosted instance every request is anonymous, so its
|
||||
* audit origin is SYSTEM (not WEB) and it is excluded from these WEB-only counts — active/PDFs then
|
||||
* read 0 despite real usage. Historical audit rows written before the {@code source} column existed
|
||||
* carry {@code source=null}, so the cumulative "PDFs edited" figure effectively starts at deploy.
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/usage")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@RequiredArgsConstructor
|
||||
@EnterpriseEndpoint
|
||||
public class FleetUsageController {
|
||||
|
||||
private final PersistentAuditEventRepository auditRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final AuditConfigurationProperties auditConfig;
|
||||
|
||||
@GetMapping("/fleet-stats")
|
||||
public FleetUsageStats fleetStats() {
|
||||
// Exclude the reserved INTERNAL_API_USER row that InitialSecuritySetup creates on every
|
||||
// install, so a fresh single-admin instance reads 1 editor, not 2.
|
||||
Long deployed = userRepository.countByUsernameNot(Role.INTERNAL_API_USER.getRoleId());
|
||||
// STANDARD is the level at which the counted events are recorded; below it the data
|
||||
// can't exist, so report N/A instead of a 0 that would misrepresent an empty table.
|
||||
boolean auditOn = auditConfig.isLevelEnabled(AuditLevel.STANDARD);
|
||||
Instant since = Instant.now().minus(30, ChronoUnit.DAYS);
|
||||
Long active =
|
||||
auditOn
|
||||
? auditRepository.countDistinctPrincipalsBySourceExcludingTypeAfter(
|
||||
"WEB", "UI_DATA", since)
|
||||
: null;
|
||||
Long pdfs =
|
||||
auditOn
|
||||
? auditRepository.countByTypeInAndSourceAndTimestampAfter(
|
||||
List.of("PDF_PROCESS", "FILE_OPERATION"), "WEB", Instant.EPOCH)
|
||||
: null;
|
||||
if (active != null && deployed != null && active > deployed) {
|
||||
active = deployed; // active editors are a subset of those deployed
|
||||
}
|
||||
return new FleetUsageStats(deployed, active, pdfs);
|
||||
}
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
package stirling.software.proprietary.controller.api;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.annotations.api.PipelineApi;
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
@Slf4j
|
||||
@PipelineApi
|
||||
public class PipelineConfigController {
|
||||
|
||||
private final RuntimePathConfig runtimePathConfig;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public PipelineConfigController(
|
||||
RuntimePathConfig runtimePathConfig, ObjectMapper objectMapper) {
|
||||
this.runtimePathConfig = runtimePathConfig;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@PostMapping("/watched-folders/config")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Operation(
|
||||
summary =
|
||||
"Save a folder-scanning pipeline config into pipeline/watchedFolders (optionally in a subfolder)")
|
||||
public ResponseEntity<?> saveConfigToWatchedFolder(
|
||||
@RequestBody SaveWatchedFolderConfigRequest request) {
|
||||
try {
|
||||
if (request == null || request.getConfig() == null) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("error", "Missing request body or config payload"));
|
||||
}
|
||||
|
||||
Path watchedRoot =
|
||||
Paths.get(runtimePathConfig.getPipelineWatchedFoldersPath())
|
||||
.toAbsolutePath()
|
||||
.normalize();
|
||||
|
||||
String subfolder = request.getSubfolder() == null ? "" : request.getSubfolder().trim();
|
||||
Path targetDir =
|
||||
subfolder.isEmpty()
|
||||
? watchedRoot
|
||||
: watchedRoot.resolve(subfolder).toAbsolutePath().normalize();
|
||||
|
||||
if (!targetDir.startsWith(watchedRoot)) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "Invalid subfolder path"));
|
||||
}
|
||||
|
||||
Files.createDirectories(targetDir);
|
||||
|
||||
String safeFileName = sanitizeFileName(request.getFileName());
|
||||
Path targetFile = targetDir.resolve(safeFileName).toAbsolutePath().normalize();
|
||||
|
||||
if (!targetFile.startsWith(watchedRoot)) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("error", "Invalid target file path"));
|
||||
}
|
||||
|
||||
String json =
|
||||
objectMapper
|
||||
.writerWithDefaultPrettyPrinter()
|
||||
.writeValueAsString(request.getConfig());
|
||||
Files.writeString(targetFile, json, StandardCharsets.UTF_8);
|
||||
|
||||
return ResponseEntity.ok(
|
||||
Map.of(
|
||||
"success",
|
||||
true,
|
||||
"savedPath",
|
||||
targetFile.toString(),
|
||||
"fileName",
|
||||
safeFileName));
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to write pipeline config to watched folder", e);
|
||||
return ResponseEntity.internalServerError()
|
||||
.body(Map.of("error", "Failed to write config file"));
|
||||
}
|
||||
}
|
||||
|
||||
private String sanitizeFileName(String fileName) {
|
||||
String base =
|
||||
fileName == null || fileName.isBlank()
|
||||
? "automation.folder-scan.json"
|
||||
: fileName.trim();
|
||||
String sanitized = base.replaceAll("[\\\\/:*?\"<>|]", "_");
|
||||
if (!sanitized.toLowerCase().endsWith(".json")) {
|
||||
sanitized = sanitized + ".json";
|
||||
}
|
||||
if (sanitized.length() > 200) {
|
||||
sanitized = sanitized.substring(0, 200);
|
||||
if (!sanitized.toLowerCase().endsWith(".json")) {
|
||||
sanitized = sanitized + ".json";
|
||||
}
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class SaveWatchedFolderConfigRequest {
|
||||
private String subfolder;
|
||||
private String fileName;
|
||||
private Object config;
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package stirling.software.proprietary.controller.api;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
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.audit.PortalAuditScope;
|
||||
import stirling.software.proprietary.audit.PortalAuditScopeResolver;
|
||||
import stirling.software.proprietary.model.api.documents.PortalDocumentsResponseDto;
|
||||
import stirling.software.proprietary.security.config.EnterpriseEndpoint;
|
||||
import stirling.software.proprietary.service.PortalDocumentsService;
|
||||
|
||||
/** Serves the portal Documents review queue, derived from real audit data and scoped per caller. */
|
||||
@ProprietaryUiDataApi
|
||||
@RequiredArgsConstructor
|
||||
@EnterpriseEndpoint
|
||||
public class PortalDocumentsController {
|
||||
|
||||
private final PortalDocumentsService portalDocumentsService;
|
||||
private final PortalAuditScopeResolver auditScopeResolver;
|
||||
|
||||
// tier accepted for mock-seam symmetry; ignored (queue isn't tier-scoped).
|
||||
@GetMapping("/documents")
|
||||
@Operation(
|
||||
summary = "Documents review queue",
|
||||
description = "Files processed through the org, derived from the audit trail.")
|
||||
public ResponseEntity<PortalDocumentsResponseDto> getDocuments(
|
||||
@RequestParam(value = "tier", required = false) String tier) {
|
||||
PortalAuditScope scope = auditScopeResolver.resolve();
|
||||
if (!scope.allowed()) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
}
|
||||
PortalDocumentsResponseDto body =
|
||||
scope.fullServer()
|
||||
? portalDocumentsService.serverDocuments()
|
||||
: portalDocumentsService.scopedDocuments(
|
||||
scope.cacheKey(), scope.principals());
|
||||
return ResponseEntity.ok(body);
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package stirling.software.proprietary.controller.api;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
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.audit.PortalAuditScope;
|
||||
import stirling.software.proprietary.audit.PortalAuditScopeResolver;
|
||||
import stirling.software.proprietary.model.api.audit.InfraAuditLogResponse;
|
||||
import stirling.software.proprietary.security.config.EnterpriseEndpoint;
|
||||
import stirling.software.proprietary.service.PortalInfraAuditService;
|
||||
|
||||
/** Serves the Infrastructure → Audit tab from real audit data, scoped and cached per caller. */
|
||||
@ProprietaryUiDataApi
|
||||
@RequiredArgsConstructor
|
||||
@EnterpriseEndpoint
|
||||
public class PortalInfraAuditController {
|
||||
|
||||
private final PortalInfraAuditService portalInfraAuditService;
|
||||
private final PortalAuditScopeResolver auditScopeResolver;
|
||||
|
||||
// tier accepted for endpoint symmetry; ignored (audit log isn't tier-scoped).
|
||||
@GetMapping("/infrastructure/audit-log")
|
||||
@Operation(
|
||||
summary = "Infrastructure audit log",
|
||||
description = "Recent audit events shaped for the portal Infrastructure → Audit tab.")
|
||||
public ResponseEntity<InfraAuditLogResponse> getInfrastructureAuditLog(
|
||||
@RequestParam(value = "tier", required = false) String tier) {
|
||||
PortalAuditScope scope = auditScopeResolver.resolve();
|
||||
if (!scope.allowed()) {
|
||||
// Return 403 (not throw) so the tab shows its access message, not a generic 500.
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
}
|
||||
InfraAuditLogResponse body =
|
||||
scope.fullServer()
|
||||
? portalInfraAuditService.serverAuditLog()
|
||||
: portalInfraAuditService.scopedAuditLog(
|
||||
scope.cacheKey(), scope.principals());
|
||||
return ResponseEntity.ok(body);
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package stirling.software.proprietary.model.api.audit;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* A single infrastructure audit-log row, shaped for the portal Infrastructure → Audit tab. Derived
|
||||
* from a {@code audit_events} row: the real {@link
|
||||
* stirling.software.proprietary.audit.AuditEventType} is mapped to a display category/action.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class InfraAuditEventDto {
|
||||
|
||||
@Schema(description = "Audit event id", example = "8841")
|
||||
private String id;
|
||||
|
||||
@Schema(description = "Display timestamp (UTC)", example = "2026-07-07 18:59:31")
|
||||
private String timestamp;
|
||||
|
||||
@Schema(description = "Category: auth | config | elevation | processing | security")
|
||||
private String category;
|
||||
|
||||
@Schema(description = "Human-readable action", example = "Compress PDF")
|
||||
private String action;
|
||||
|
||||
@Schema(description = "Actor principal", example = "alice.chen@acme.com")
|
||||
private String actor;
|
||||
|
||||
@Schema(description = "Affected target (file, endpoint, or session)")
|
||||
private String target;
|
||||
|
||||
@Schema(description = "Status: success | warning | danger | info")
|
||||
private String status;
|
||||
|
||||
@Schema(description = "Operation latency in milliseconds", example = "412")
|
||||
private long latencyMs;
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package stirling.software.proprietary.model.api.audit;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/** Response for the portal Infrastructure → Audit tab: summary strip + recent event rows. */
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class InfraAuditLogResponse {
|
||||
|
||||
@Schema(description = "Headline counts")
|
||||
private InfraAuditSummary summary;
|
||||
|
||||
@Schema(description = "Most-recent audit events, newest first")
|
||||
private List<InfraAuditEventDto> events;
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"True when this is the whole-server (admin) view. Team-scoped views are false; "
|
||||
+ "drives whether the admin-only, whole-server CSV export is offered.")
|
||||
private boolean fullServer;
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package stirling.software.proprietary.model.api.audit;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/** Headline counts for the infrastructure audit-log tab, derived from the returned events. */
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class InfraAuditSummary {
|
||||
|
||||
@Schema(description = "Total events in the returned window", example = "40")
|
||||
private int totalEvents;
|
||||
|
||||
@Schema(description = "Processing-category events", example = "24")
|
||||
private int processing;
|
||||
|
||||
@Schema(description = "Elevation-category events", example = "0")
|
||||
private int elevation;
|
||||
|
||||
@Schema(description = "Config-category events", example = "6")
|
||||
private int config;
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package stirling.software.proprietary.model.api.documents;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/** One event in a document's lifecycle timeline. */
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class PortalDocAuditEventDto {
|
||||
private String id;
|
||||
|
||||
/** ingested | extracted | flagged | reviewed | approved | archived | elevation */
|
||||
private String kind;
|
||||
|
||||
/** Relative-time string, e.g. "2m ago". */
|
||||
private String time;
|
||||
|
||||
private String actor;
|
||||
private String detail;
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package stirling.software.proprietary.model.api.documents;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/** Response for the portal Documents review queue. */
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class PortalDocumentsResponseDto {
|
||||
private PortalDocumentsSummaryDto summary;
|
||||
private List<PortalReviewDocumentDto> documents;
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package stirling.software.proprietary.model.api.documents;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/** KPI strip for the documents queue. */
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class PortalDocumentsSummaryDto {
|
||||
private int totalInQueue;
|
||||
private int processed;
|
||||
private int errors;
|
||||
private int processedToday;
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package stirling.software.proprietary.model.api.documents;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/** A single extracted field. Empty for audit-derived documents (no extraction data yet). */
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class PortalExtractionDto {
|
||||
private String field;
|
||||
private String value;
|
||||
private double confidence;
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package stirling.software.proprietary.model.api.documents;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* A document in the review queue, derived from the audit trail of a processed file. Extraction
|
||||
* fields ({@code confidence}, {@code extractions}) are absent/empty - that data doesn't exist yet.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class PortalReviewDocumentDto {
|
||||
private String id;
|
||||
private String name;
|
||||
private String type;
|
||||
|
||||
/** Where it was processed: "API" or "Editor". */
|
||||
private String product;
|
||||
|
||||
/** The operation / pipeline, e.g. "Compress PDF" (or "Editor"). */
|
||||
private String action;
|
||||
|
||||
/** The user who ran it. */
|
||||
private String user;
|
||||
|
||||
/** processed | error */
|
||||
private String status;
|
||||
|
||||
private String source;
|
||||
|
||||
/** Overall confidence 0..1, or null when there's no extraction data. */
|
||||
private Double confidence;
|
||||
|
||||
private int fieldsExtracted;
|
||||
|
||||
/** Relative-time string, e.g. "4m ago". */
|
||||
private String time;
|
||||
|
||||
private boolean sensitive;
|
||||
private List<PortalExtractionDto> extractions;
|
||||
private List<PortalDocAuditEventDto> audit;
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
package stirling.software.proprietary.model.api.usage;
|
||||
|
||||
/**
|
||||
* Free-editor fleet usage for the portal Usage card. Null fields render as "N/A" (uncomputable).
|
||||
*/
|
||||
public record FleetUsageStats(Long editorsDeployed, Long activeThisMonth, Long pdfsProcessed) {}
|
||||
+10
-1
@@ -18,7 +18,15 @@ import lombok.*;
|
||||
columnList = "principal,type"),
|
||||
@jakarta.persistence.Index(
|
||||
name = "idx_audit_type_timestamp",
|
||||
columnList = "type,timestamp")
|
||||
columnList = "type,timestamp"),
|
||||
@jakarta.persistence.Index(
|
||||
name = "idx_audit_type_source_timestamp",
|
||||
columnList = "type,source,timestamp"),
|
||||
// Leads with source (equality) for the active-editors query, which filters on
|
||||
// source then a timestamp range and counts distinct principal.
|
||||
@jakarta.persistence.Index(
|
||||
name = "idx_audit_source_timestamp_principal",
|
||||
columnList = "source,timestamp,principal")
|
||||
})
|
||||
@Data
|
||||
@Builder
|
||||
@@ -32,6 +40,7 @@ public class PersistentAuditEvent {
|
||||
|
||||
private String principal;
|
||||
private String type;
|
||||
private String source;
|
||||
|
||||
@Column(columnDefinition = "text")
|
||||
private String data; // JSON blob
|
||||
|
||||
+17
@@ -259,4 +259,21 @@ public interface PersistentAuditEventRepository extends JpaRepository<Persistent
|
||||
"SELECT e FROM PersistentAuditEvent e WHERE e.type != :excludeType AND e.timestamp > :startDate")
|
||||
List<PersistentAuditEvent> findAllExceptTypeAndTimestampAfterForExport(
|
||||
@Param("excludeType") String excludeType, @Param("startDate") Instant startDate);
|
||||
|
||||
// Free-editor fleet usage: count genuine free-UI operations (source = "WEB") by type.
|
||||
@Query(
|
||||
"SELECT COUNT(e) FROM PersistentAuditEvent e "
|
||||
+ "WHERE e.type IN :types AND e.source = :source AND e.timestamp > :since")
|
||||
long countByTypeInAndSourceAndTimestampAfter(
|
||||
@Param("types") List<String> types,
|
||||
@Param("source") String source,
|
||||
@Param("since") Instant since);
|
||||
|
||||
@Query(
|
||||
"SELECT COUNT(DISTINCT e.principal) FROM PersistentAuditEvent e "
|
||||
+ "WHERE e.source = :source AND e.type <> :excludeType AND e.timestamp > :since")
|
||||
long countDistinctPrincipalsBySourceExcludingTypeAfter(
|
||||
@Param("source") String source,
|
||||
@Param("excludeType") String excludeType,
|
||||
@Param("since") Instant since);
|
||||
}
|
||||
|
||||
+11
@@ -24,6 +24,9 @@ public class CacheConfig {
|
||||
this.applicationProperties = applicationProperties;
|
||||
}
|
||||
|
||||
/** Short-TTL cache of recent audit rows, shared by every audit-derived portal view. */
|
||||
private static final String PORTAL_AUDIT_EVENTS_CACHE = "portalAuditEvents";
|
||||
|
||||
@Bean
|
||||
public CacheManager cacheManager() {
|
||||
int keyRetentionDays = applicationProperties.getSecurity().getJwt().getKeyRetentionDays();
|
||||
@@ -33,6 +36,14 @@ public class CacheConfig {
|
||||
.maximumSize(1000) // Make configurable?
|
||||
.expireAfterWrite(Duration.ofDays(keyRetentionDays))
|
||||
.recordStats());
|
||||
// 30s TTL keeps audit views near-live without re-scanning the DB; one entry per scope.
|
||||
cacheManager.registerCustomCache(
|
||||
PORTAL_AUDIT_EVENTS_CACHE,
|
||||
Caffeine.newBuilder()
|
||||
.maximumSize(256)
|
||||
.expireAfterWrite(Duration.ofSeconds(30))
|
||||
.recordStats()
|
||||
.build());
|
||||
return cacheManager;
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -49,6 +49,9 @@ public interface UserRepository extends JpaRepository<User, Long> {
|
||||
|
||||
long countByTeam(Team team);
|
||||
|
||||
/** Count real users, excluding a reserved username such as the internal API user. */
|
||||
long countByUsernameNot(String username);
|
||||
|
||||
List<User> findAllByTeam(Team team);
|
||||
|
||||
// OAuth grandfathering queries
|
||||
|
||||
@@ -39,6 +39,7 @@ import stirling.software.common.model.api.PDFFile;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.RegexPatternUtils;
|
||||
import stirling.software.common.util.RequestUriUtils;
|
||||
import stirling.software.proprietary.accountlink.BillableOperationClassifier;
|
||||
import stirling.software.proprietary.audit.AuditEventType;
|
||||
import stirling.software.proprietary.audit.AuditLevel;
|
||||
import stirling.software.proprietary.audit.Audited;
|
||||
@@ -847,6 +848,39 @@ public class AuditService {
|
||||
return origin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refines {@link #determineOrigin()} into an audit {@code source} that isolates genuine
|
||||
* free-editor UI runs from automation/AI traffic that also arrives over the web channel.
|
||||
*
|
||||
* <p>API and SYSTEM origins pass through unchanged. A WEB origin is demoted to "AUTOMATION" or
|
||||
* "AI" when the request carries the automation marker or targets an AI surface; only a manual
|
||||
* interactive tool call ({@code BYPASSED}) stays "WEB". A "WEB" source therefore counts as a
|
||||
* free/BYPASSED UI run. The automation/AI resolution is delegated to {@link
|
||||
* BillableOperationClassifier} so it can't drift from the billing gate's own signal.
|
||||
*
|
||||
* <p>IMPORTANT: like {@link #captureCurrentOrigin()} this must be called on the request thread
|
||||
* before async execution, because it reads the current {@link HttpServletRequest}.
|
||||
*
|
||||
* @return "API", "SYSTEM", "AUTOMATION", "AI", or "WEB"
|
||||
*/
|
||||
public String captureCurrentSource() {
|
||||
String origin = determineOrigin();
|
||||
if (!"WEB".equals(origin)) {
|
||||
return origin;
|
||||
}
|
||||
|
||||
HttpServletRequest req = getCurrentRequest();
|
||||
if (req == null) {
|
||||
return "WEB";
|
||||
}
|
||||
// apiKey=false: a WEB origin already means the request is not API-key authenticated.
|
||||
return switch (BillableOperationClassifier.categorize(req, false)) {
|
||||
case AUTOMATION -> "AUTOMATION";
|
||||
case AI -> "AI";
|
||||
default -> "WEB";
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the origin of the request: API (X-API-KEY), WEB (JWT), or SYSTEM (no auth).
|
||||
* IMPORTANT: This must be called in the request thread before async execution.
|
||||
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package stirling.software.proprietary.service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.cache.annotation.Cacheable;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.proprietary.audit.PortalAuditEventRow;
|
||||
import stirling.software.proprietary.model.security.PersistentAuditEvent;
|
||||
import stirling.software.proprietary.repository.PersistentAuditEventRepository;
|
||||
|
||||
/** One cached read of recent {@code audit_events} per scope, shared by all audit-derived views. */
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class PortalAuditReadService {
|
||||
|
||||
/** Cache name - registered with a short TTL in CacheConfig. */
|
||||
public static final String CACHE_NAME = "portalAuditEvents";
|
||||
|
||||
/** Newest rows to scan; each surface filters this down to what it shows. */
|
||||
private static final int SCAN_LIMIT = 400;
|
||||
|
||||
private final PersistentAuditEventRepository auditRepository;
|
||||
|
||||
/** Recent whole-server events (admins). */
|
||||
@Cacheable(value = CACHE_NAME, key = "'server'")
|
||||
public List<PortalAuditEventRow> serverEvents() {
|
||||
return toRows(auditRepository.findAll(recentPage()).getContent());
|
||||
}
|
||||
|
||||
/** Recent events by the given principals (team scope). Empty principals yield an empty list. */
|
||||
@Cacheable(value = CACHE_NAME, key = "#cacheKey")
|
||||
public List<PortalAuditEventRow> scopedEvents(String cacheKey, List<String> principals) {
|
||||
if (principals.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
return toRows(auditRepository.findByPrincipalIn(principals, recentPage()).getContent());
|
||||
}
|
||||
|
||||
private static PageRequest recentPage() {
|
||||
return PageRequest.of(0, SCAN_LIMIT, Sort.by(Sort.Direction.DESC, "timestamp"));
|
||||
}
|
||||
|
||||
private static List<PortalAuditEventRow> toRows(List<PersistentAuditEvent> events) {
|
||||
return events.stream()
|
||||
.map(
|
||||
e ->
|
||||
new PortalAuditEventRow(
|
||||
e.getId() == null ? 0L : e.getId(),
|
||||
e.getPrincipal(),
|
||||
e.getType(),
|
||||
e.getData(),
|
||||
e.getTimestamp()))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
+268
@@ -0,0 +1,268 @@
|
||||
package stirling.software.proprietary.service;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.audit.AuditEventType;
|
||||
import stirling.software.proprietary.audit.PortalAuditEventRow;
|
||||
import stirling.software.proprietary.model.api.documents.PortalDocAuditEventDto;
|
||||
import stirling.software.proprietary.model.api.documents.PortalDocumentsResponseDto;
|
||||
import stirling.software.proprietary.model.api.documents.PortalDocumentsSummaryDto;
|
||||
import stirling.software.proprietary.model.api.documents.PortalReviewDocumentDto;
|
||||
|
||||
import tools.jackson.core.JacksonException;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/** Builds the Documents feed from audit_events: one row per file; extraction fields stay null. */
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class PortalDocumentsService {
|
||||
|
||||
private static final int RETURN_LIMIT = 40;
|
||||
|
||||
private final PortalAuditReadService auditReadService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/** Whole-server view (admins). */
|
||||
public PortalDocumentsResponseDto serverDocuments() {
|
||||
return build(auditReadService.serverEvents());
|
||||
}
|
||||
|
||||
/** Team-scoped view: only files touched by {@code principals}. */
|
||||
public PortalDocumentsResponseDto scopedDocuments(String cacheKey, List<String> principals) {
|
||||
return build(auditReadService.scopedEvents(cacheKey, principals));
|
||||
}
|
||||
|
||||
private PortalDocumentsResponseDto build(List<PortalAuditEventRow> events) {
|
||||
// Events arrive newest-first; each file in a processing event is one activity row.
|
||||
Instant dayAgo = Instant.now().minus(Duration.ofDays(1));
|
||||
List<PortalReviewDocumentDto> documents = new ArrayList<>();
|
||||
int processedToday = 0;
|
||||
for (PortalAuditEventRow event : events) {
|
||||
if (documents.size() >= RETURN_LIMIT) {
|
||||
break;
|
||||
}
|
||||
if (!isFileBearing(event.type())) {
|
||||
continue;
|
||||
}
|
||||
Map<String, Object> data = parseData(event);
|
||||
Object files = data.get("files");
|
||||
if (!(files instanceof List<?> fileList)) {
|
||||
continue;
|
||||
}
|
||||
String path = asString(data.get("path"));
|
||||
String source = sourceLabel(asString(data.get("__origin")));
|
||||
String product = "API integration".equals(source) ? "API" : "Editor";
|
||||
String action = prettyTool(path);
|
||||
boolean failed = isFailure(data);
|
||||
Instant ts = event.timestamp();
|
||||
long eventId = event.id();
|
||||
int idx = 0;
|
||||
for (Object f : fileList) {
|
||||
if (documents.size() >= RETURN_LIMIT) {
|
||||
break;
|
||||
}
|
||||
if (!(f instanceof Map<?, ?> fileMap)) {
|
||||
continue;
|
||||
}
|
||||
String name = asString(fileMap.get("name"));
|
||||
if (name == null || name.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
documents.add(
|
||||
toDocument(
|
||||
eventId + "-" + idx++,
|
||||
name,
|
||||
asString(fileMap.get("type")),
|
||||
product,
|
||||
action,
|
||||
event.principal(),
|
||||
failed,
|
||||
source,
|
||||
ts));
|
||||
if (!failed && ts != null && ts.isAfter(dayAgo)) {
|
||||
processedToday++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int processed =
|
||||
(int) documents.stream().filter(d -> "processed".equals(d.getStatus())).count();
|
||||
int errors = documents.size() - processed;
|
||||
|
||||
PortalDocumentsSummaryDto summary =
|
||||
PortalDocumentsSummaryDto.builder()
|
||||
.totalInQueue(documents.size())
|
||||
.processed(processed)
|
||||
.errors(errors)
|
||||
.processedToday(processedToday)
|
||||
.build();
|
||||
|
||||
return PortalDocumentsResponseDto.builder().summary(summary).documents(documents).build();
|
||||
}
|
||||
|
||||
/** Build one activity row from a single file inside one processing event. */
|
||||
private PortalReviewDocumentDto toDocument(
|
||||
String rowId,
|
||||
String name,
|
||||
String contentType,
|
||||
String product,
|
||||
String action,
|
||||
String user,
|
||||
boolean failed,
|
||||
String source,
|
||||
Instant timestamp) {
|
||||
PortalDocAuditEventDto op =
|
||||
PortalDocAuditEventDto.builder()
|
||||
.id(rowId + "-op")
|
||||
.kind(failed ? "flagged" : "extracted")
|
||||
.time(relativeTime(timestamp))
|
||||
.actor(user)
|
||||
.detail(failed ? action + " failed" : action + " via " + source)
|
||||
.build();
|
||||
|
||||
return PortalReviewDocumentDto.builder()
|
||||
.id("doc-" + rowId)
|
||||
.name(name)
|
||||
.type(docType(contentType, name))
|
||||
.product(product)
|
||||
.action(action)
|
||||
.user(user)
|
||||
.status(failed ? "error" : "processed")
|
||||
.source(source)
|
||||
.confidence(null)
|
||||
.fieldsExtracted(0)
|
||||
.time(relativeTime(timestamp))
|
||||
// Audit events don't reveal content sensitivity, so never guess it.
|
||||
.sensitive(false)
|
||||
.extractions(List.of())
|
||||
.audit(List.of(op))
|
||||
.build();
|
||||
}
|
||||
|
||||
private static boolean isFileBearing(String type) {
|
||||
return AuditEventType.PDF_PROCESS.name().equals(type)
|
||||
|| AuditEventType.FILE_OPERATION.name().equals(type);
|
||||
}
|
||||
|
||||
private static boolean isFailure(Map<String, Object> data) {
|
||||
Object status = data.get("status");
|
||||
if (status instanceof String s && "failure".equalsIgnoreCase(s)) {
|
||||
return true;
|
||||
}
|
||||
Object code = data.get("statusCode");
|
||||
return code instanceof Number n && n.intValue() >= 400;
|
||||
}
|
||||
|
||||
private static String sourceLabel(String origin) {
|
||||
if ("API".equals(origin)) {
|
||||
return "API integration";
|
||||
}
|
||||
if ("SYSTEM".equals(origin)) {
|
||||
return "System";
|
||||
}
|
||||
return "Web upload";
|
||||
}
|
||||
|
||||
private static String docType(String contentType, String name) {
|
||||
String ct = contentType == null ? "" : contentType.toLowerCase(Locale.ROOT);
|
||||
if (ct.contains("pdf") || name.toLowerCase(Locale.ROOT).endsWith(".pdf")) {
|
||||
return "PDF";
|
||||
}
|
||||
if (ct.startsWith("image/") || name.matches("(?i).*\\.(png|jpe?g|gif|webp|tiff?)$")) {
|
||||
return "Image";
|
||||
}
|
||||
if (ct.contains("word") || name.matches("(?i).*\\.docx?$")) {
|
||||
return "Word";
|
||||
}
|
||||
return "Document";
|
||||
}
|
||||
|
||||
private static String prettyTool(String path) {
|
||||
if (path == null || path.isBlank()) {
|
||||
return "Processed";
|
||||
}
|
||||
String[] parts = path.split("/");
|
||||
// Convert endpoints are /convert/{from}/{to}; label them as a conversion.
|
||||
for (int i = 0; i + 2 < parts.length; i++) {
|
||||
if ("convert".equals(parts[i]) && !parts[i + 1].isEmpty() && !parts[i + 2].isEmpty()) {
|
||||
return "Convert " + prettyWords(parts[i + 1]) + " to " + prettyWords(parts[i + 2]);
|
||||
}
|
||||
}
|
||||
String last = parts.length > 0 ? parts[parts.length - 1] : path;
|
||||
String pretty = prettyWords(last);
|
||||
return pretty.isEmpty() ? "Processed" : pretty;
|
||||
}
|
||||
|
||||
/** Title-case a hyphenated path segment, upper-casing known acronyms. */
|
||||
private static String prettyWords(String segment) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (String word : segment.split("-")) {
|
||||
if (word.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
if (sb.length() > 0) {
|
||||
sb.append(' ');
|
||||
}
|
||||
String lower = word.toLowerCase(Locale.ROOT);
|
||||
sb.append(
|
||||
switch (lower) {
|
||||
case "pdf" -> "PDF";
|
||||
case "pdfs" -> "PDFs";
|
||||
case "ocr" -> "OCR";
|
||||
case "img" -> "Image";
|
||||
case "csv" -> "CSV";
|
||||
default -> Character.toUpperCase(word.charAt(0)) + word.substring(1);
|
||||
});
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static String relativeTime(Instant ts) {
|
||||
if (ts == null) {
|
||||
return "";
|
||||
}
|
||||
long seconds = Duration.between(ts, Instant.now()).getSeconds();
|
||||
if (seconds < 60) {
|
||||
return "just now";
|
||||
}
|
||||
long minutes = seconds / 60;
|
||||
if (minutes < 60) {
|
||||
return minutes + "m ago";
|
||||
}
|
||||
long hours = minutes / 60;
|
||||
if (hours < 24) {
|
||||
return hours + "h ago";
|
||||
}
|
||||
return (hours / 24) + "d ago";
|
||||
}
|
||||
|
||||
private Map<String, Object> parseData(PortalAuditEventRow event) {
|
||||
if (event.data() == null || event.data().isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
try {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> parsed = objectMapper.readValue(event.data(), Map.class);
|
||||
// A literal "null" payload parses to null; treat it as empty, not an NPE.
|
||||
return parsed == null ? Map.of() : parsed;
|
||||
} catch (JacksonException e) {
|
||||
log.warn("Failed to parse audit event {} data as JSON", event.id());
|
||||
return Map.of();
|
||||
}
|
||||
}
|
||||
|
||||
private static String asString(Object o) {
|
||||
return o == null ? null : String.valueOf(o);
|
||||
}
|
||||
}
|
||||
+251
@@ -0,0 +1,251 @@
|
||||
package stirling.software.proprietary.service;
|
||||
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.audit.AuditEventType;
|
||||
import stirling.software.proprietary.audit.PortalAuditEventRow;
|
||||
import stirling.software.proprietary.model.api.audit.InfraAuditEventDto;
|
||||
import stirling.software.proprietary.model.api.audit.InfraAuditLogResponse;
|
||||
import stirling.software.proprietary.model.api.audit.InfraAuditSummary;
|
||||
|
||||
import tools.jackson.core.JacksonException;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/** Maps cached audit_events to the Infrastructure → Audit tab, dropping read-noise types. */
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class PortalInfraAuditService {
|
||||
|
||||
/** Rows returned to the tab after filtering. */
|
||||
private static final int RETURN_LIMIT = 40;
|
||||
|
||||
private static final DateTimeFormatter TS_FORMAT =
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneOffset.UTC);
|
||||
|
||||
private final PortalAuditReadService auditReadService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/** Whole-server view (admins). */
|
||||
public InfraAuditLogResponse serverAuditLog() {
|
||||
return buildFromEvents(auditReadService.serverEvents(), true);
|
||||
}
|
||||
|
||||
/** Team-scoped view: only events by the given principals; empty yields an empty log. */
|
||||
public InfraAuditLogResponse scopedAuditLog(String cacheKey, List<String> principals) {
|
||||
return buildFromEvents(auditReadService.scopedEvents(cacheKey, principals), false);
|
||||
}
|
||||
|
||||
private InfraAuditLogResponse buildFromEvents(
|
||||
List<PortalAuditEventRow> recent, boolean fullServer) {
|
||||
List<InfraAuditEventDto> events =
|
||||
recent.stream()
|
||||
.filter(e -> isInfraRelevant(e.type()))
|
||||
.map(this::toDto)
|
||||
.limit(RETURN_LIMIT)
|
||||
.toList();
|
||||
|
||||
int processing =
|
||||
(int) events.stream().filter(e -> "processing".equals(e.getCategory())).count();
|
||||
int elevation =
|
||||
(int) events.stream().filter(e -> "elevation".equals(e.getCategory())).count();
|
||||
int config = (int) events.stream().filter(e -> "config".equals(e.getCategory())).count();
|
||||
|
||||
InfraAuditSummary summary =
|
||||
InfraAuditSummary.builder()
|
||||
.totalEvents(events.size())
|
||||
.processing(processing)
|
||||
.elevation(elevation)
|
||||
.config(config)
|
||||
.build();
|
||||
|
||||
return InfraAuditLogResponse.builder()
|
||||
.summary(summary)
|
||||
.events(events)
|
||||
.fullServer(fullServer)
|
||||
.build();
|
||||
}
|
||||
|
||||
/** UI_DATA and HTTP_REQUEST are read/polling noise - excluded from the infrastructure view. */
|
||||
private static boolean isInfraRelevant(String type) {
|
||||
return !AuditEventType.UI_DATA.name().equals(type)
|
||||
&& !AuditEventType.HTTP_REQUEST.name().equals(type);
|
||||
}
|
||||
|
||||
private InfraAuditEventDto toDto(PortalAuditEventRow event) {
|
||||
Map<String, Object> data = parseData(event);
|
||||
String path = asString(data.get("path"));
|
||||
String category = categoryFor(event.type(), path);
|
||||
|
||||
return InfraAuditEventDto.builder()
|
||||
.id(String.valueOf(event.id()))
|
||||
.timestamp(event.timestamp() == null ? "" : TS_FORMAT.format(event.timestamp()))
|
||||
.category(category)
|
||||
.action(actionFor(event.type(), path))
|
||||
.actor(event.principal())
|
||||
.target(targetFor(category, path, data))
|
||||
.status(statusFor(event.type(), category, data))
|
||||
.latencyMs(asLong(data.get("latencyMs")))
|
||||
.build();
|
||||
}
|
||||
|
||||
private Map<String, Object> parseData(PortalAuditEventRow event) {
|
||||
if (event.data() == null || event.data().isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
try {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> parsed = objectMapper.readValue(event.data(), Map.class);
|
||||
// A literal "null" payload parses to null; treat it as empty, not an NPE.
|
||||
return parsed == null ? Map.of() : parsed;
|
||||
} catch (JacksonException e) {
|
||||
log.warn("Failed to parse audit event {} data as JSON", event.id());
|
||||
return Map.of();
|
||||
}
|
||||
}
|
||||
|
||||
private static String categoryFor(String type, String path) {
|
||||
AuditEventType t = AuditEventType.fromString(type);
|
||||
if (t == null) {
|
||||
return "processing";
|
||||
}
|
||||
return switch (t) {
|
||||
case USER_LOGIN, USER_LOGOUT, USER_FAILED_LOGIN -> "auth";
|
||||
case SETTINGS_CHANGED, USER_PROFILE_UPDATE -> "config";
|
||||
case PDF_PROCESS, FILE_OPERATION -> isSecurityPath(path) ? "security" : "processing";
|
||||
// UI_DATA / HTTP_REQUEST are filtered out before mapping.
|
||||
default -> "processing";
|
||||
};
|
||||
}
|
||||
|
||||
private static boolean isSecurityPath(String path) {
|
||||
if (path == null) {
|
||||
return false;
|
||||
}
|
||||
String p = path.toLowerCase(Locale.ROOT);
|
||||
return p.contains("/security/")
|
||||
|| p.contains("password")
|
||||
|| p.contains("watermark")
|
||||
|| p.contains("sign")
|
||||
|| p.contains("cert")
|
||||
|| p.contains("redact");
|
||||
}
|
||||
|
||||
private static String actionFor(String type, String path) {
|
||||
AuditEventType t = AuditEventType.fromString(type);
|
||||
if (t == null) {
|
||||
return prettyTool(path);
|
||||
}
|
||||
return switch (t) {
|
||||
case USER_LOGIN -> "User signed in";
|
||||
case USER_LOGOUT -> "User signed out";
|
||||
case USER_FAILED_LOGIN -> "Failed sign-in attempt";
|
||||
case USER_PROFILE_UPDATE -> "Profile settings updated";
|
||||
case SETTINGS_CHANGED -> "Admin settings changed";
|
||||
case PDF_PROCESS, FILE_OPERATION -> prettyTool(path);
|
||||
default -> prettyTool(path);
|
||||
};
|
||||
}
|
||||
|
||||
/** Acronyms/tokens that get special casing when title-casing a tool path. */
|
||||
private static final Map<String, String> WORD_FIXUPS =
|
||||
Map.of(
|
||||
"pdf", "PDF",
|
||||
"pdfs", "PDFs",
|
||||
"ocr", "OCR",
|
||||
"img", "Image",
|
||||
"csv", "CSV",
|
||||
"html", "HTML",
|
||||
"url", "URL",
|
||||
"xml", "XML");
|
||||
|
||||
/** "/api/v1/misc/compress-pdf" → "Compress PDF"; "merge-pdfs" → "Merge PDFs". */
|
||||
private static String prettyTool(String path) {
|
||||
if (path == null || path.isBlank()) {
|
||||
return "PDF operation";
|
||||
}
|
||||
String[] parts = path.split("/");
|
||||
String last = parts.length > 0 ? parts[parts.length - 1] : path;
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (String word : last.split("-")) {
|
||||
if (word.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
if (sb.length() > 0) {
|
||||
sb.append(' ');
|
||||
}
|
||||
String lower = word.toLowerCase(Locale.ROOT);
|
||||
sb.append(
|
||||
WORD_FIXUPS.getOrDefault(
|
||||
lower, Character.toUpperCase(word.charAt(0)) + word.substring(1)));
|
||||
}
|
||||
return sb.isEmpty() ? "PDF operation" : sb.toString();
|
||||
}
|
||||
|
||||
private static String targetFor(String category, String path, Map<String, Object> data) {
|
||||
if ("auth".equals(category)) {
|
||||
// Auth events don't act on a resource; the session is the closest thing.
|
||||
return "Web session";
|
||||
}
|
||||
if ("config".equals(category)) {
|
||||
return path != null && !path.isBlank() ? path : "System settings";
|
||||
}
|
||||
// processing / security: prefer the first affected file name.
|
||||
String file = firstFileName(data);
|
||||
if (file != null) {
|
||||
return file;
|
||||
}
|
||||
return path != null && !path.isBlank() ? prettyTool(path) : "Document";
|
||||
}
|
||||
|
||||
private static String statusFor(String type, String category, Map<String, Object> data) {
|
||||
if (AuditEventType.USER_FAILED_LOGIN.name().equals(type)) {
|
||||
return "danger";
|
||||
}
|
||||
String status = asString(data.get("status"));
|
||||
Integer code = asInteger(data.get("statusCode"));
|
||||
if ("failure".equalsIgnoreCase(status) || (code != null && code >= 500)) {
|
||||
return "danger";
|
||||
}
|
||||
if (code != null && code >= 400) {
|
||||
return "warning";
|
||||
}
|
||||
if ("config".equals(category)) {
|
||||
return "info";
|
||||
}
|
||||
return "success";
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static String firstFileName(Map<String, Object> data) {
|
||||
Object files = data.get("files");
|
||||
if (files instanceof List<?> list
|
||||
&& !list.isEmpty()
|
||||
&& list.get(0) instanceof Map<?, ?> f) {
|
||||
Object name = ((Map<String, Object>) f).get("name");
|
||||
return name != null ? String.valueOf(name) : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String asString(Object o) {
|
||||
return o == null ? null : String.valueOf(o);
|
||||
}
|
||||
|
||||
private static long asLong(Object o) {
|
||||
return o instanceof Number n ? n.longValue() : 0L;
|
||||
}
|
||||
|
||||
private static Integer asInteger(Object o) {
|
||||
return o instanceof Number n ? n.intValue() : null;
|
||||
}
|
||||
}
|
||||
+49
@@ -3,12 +3,61 @@ package stirling.software.proprietary.config;
|
||||
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.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.boot.actuate.audit.AuditEvent;
|
||||
|
||||
import stirling.software.proprietary.model.security.PersistentAuditEvent;
|
||||
import stirling.software.proprietary.repository.PersistentAuditEventRepository;
|
||||
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
class CustomAuditEventRepositoryTest {
|
||||
|
||||
@AfterEach
|
||||
void clearMdc() {
|
||||
MDC.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void sourceIsPopulatedFromMdcAuditSource() {
|
||||
PersistentAuditEventRepository repo = mock(PersistentAuditEventRepository.class);
|
||||
CustomAuditEventRepository writer =
|
||||
new CustomAuditEventRepository(repo, JsonMapper.builder().build());
|
||||
|
||||
MDC.put("auditSource", "WEB");
|
||||
writer.add(new AuditEvent(Instant.now(), "admin", "PDF_PROCESS", Map.of("k", "v")));
|
||||
|
||||
ArgumentCaptor<PersistentAuditEvent> captor =
|
||||
ArgumentCaptor.forClass(PersistentAuditEvent.class);
|
||||
verify(repo).save(captor.capture());
|
||||
assertEquals("WEB", captor.getValue().getSource());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sourceIsNullWhenMdcAbsent() {
|
||||
PersistentAuditEventRepository repo = mock(PersistentAuditEventRepository.class);
|
||||
CustomAuditEventRepository writer =
|
||||
new CustomAuditEventRepository(repo, JsonMapper.builder().build());
|
||||
|
||||
writer.add(new AuditEvent(Instant.now(), "admin", "PDF_PROCESS", Map.of("k", "v")));
|
||||
|
||||
ArgumentCaptor<PersistentAuditEvent> captor =
|
||||
ArgumentCaptor.forClass(PersistentAuditEvent.class);
|
||||
verify(repo).save(captor.capture());
|
||||
assertNull(captor.getValue().getSource());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shortPrincipalPassesThroughUnchanged() {
|
||||
assertEquals(
|
||||
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
package stirling.software.proprietary.controller.api;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
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 stirling.software.common.model.enumeration.Role;
|
||||
import stirling.software.proprietary.audit.AuditLevel;
|
||||
import stirling.software.proprietary.config.AuditConfigurationProperties;
|
||||
import stirling.software.proprietary.model.api.usage.FleetUsageStats;
|
||||
import stirling.software.proprietary.repository.PersistentAuditEventRepository;
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class FleetUsageControllerTest {
|
||||
|
||||
@Mock private PersistentAuditEventRepository auditRepository;
|
||||
@Mock private UserRepository userRepository;
|
||||
@Mock private AuditConfigurationProperties auditConfig;
|
||||
|
||||
private FleetUsageController controller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
controller = new FleetUsageController(auditRepository, userRepository, auditConfig);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("deployed reflects the user count, excluding the internal API user")
|
||||
void deployedFromUserCount() {
|
||||
when(userRepository.countByUsernameNot(anyString())).thenReturn(7L);
|
||||
when(auditConfig.isLevelEnabled(AuditLevel.STANDARD)).thenReturn(false);
|
||||
|
||||
FleetUsageStats stats = controller.fleetStats();
|
||||
|
||||
assertThat(stats.editorsDeployed()).isEqualTo(7L);
|
||||
verify(userRepository).countByUsernameNot(Role.INTERNAL_API_USER.getRoleId());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("audit-derived figures are null when auditing is below STANDARD")
|
||||
void auditOffYieldsNulls() {
|
||||
when(userRepository.countByUsernameNot(anyString())).thenReturn(3L);
|
||||
// Covers both disabled and the enabled-but-level=OFF/BASIC misconfig: isLevelEnabled
|
||||
// is false, so no events can exist and we must report N/A, not a 0 from an empty table.
|
||||
when(auditConfig.isLevelEnabled(AuditLevel.STANDARD)).thenReturn(false);
|
||||
|
||||
FleetUsageStats stats = controller.fleetStats();
|
||||
|
||||
assertThat(stats.activeThisMonth()).isNull();
|
||||
assertThat(stats.pdfsProcessed()).isNull();
|
||||
verify(auditRepository, never())
|
||||
.countDistinctPrincipalsBySourceExcludingTypeAfter(
|
||||
any(), any(), any(Instant.class));
|
||||
verify(auditRepository, never())
|
||||
.countByTypeInAndSourceAndTimestampAfter(anyList(), any(), any(Instant.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("audit-derived figures come from the repository when auditing is enabled")
|
||||
void auditOnReadsRepository() {
|
||||
when(userRepository.countByUsernameNot(anyString())).thenReturn(10L);
|
||||
when(auditConfig.isLevelEnabled(AuditLevel.STANDARD)).thenReturn(true);
|
||||
when(auditRepository.countDistinctPrincipalsBySourceExcludingTypeAfter(
|
||||
eq("WEB"), eq("UI_DATA"), any(Instant.class)))
|
||||
.thenReturn(4L);
|
||||
when(auditRepository.countByTypeInAndSourceAndTimestampAfter(
|
||||
anyList(), eq("WEB"), any(Instant.class)))
|
||||
.thenReturn(1234L);
|
||||
|
||||
FleetUsageStats stats = controller.fleetStats();
|
||||
|
||||
assertThat(stats.editorsDeployed()).isEqualTo(10L);
|
||||
assertThat(stats.activeThisMonth()).isEqualTo(4L);
|
||||
assertThat(stats.pdfsProcessed()).isEqualTo(1234L);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("active editors are clamped to deployed (active is a subset)")
|
||||
void activeClampedToDeployed() {
|
||||
when(userRepository.countByUsernameNot(anyString())).thenReturn(2L);
|
||||
when(auditConfig.isLevelEnabled(AuditLevel.STANDARD)).thenReturn(true);
|
||||
when(auditRepository.countDistinctPrincipalsBySourceExcludingTypeAfter(
|
||||
eq("WEB"), eq("UI_DATA"), any(Instant.class)))
|
||||
.thenReturn(9L);
|
||||
when(auditRepository.countByTypeInAndSourceAndTimestampAfter(
|
||||
anyList(), eq("WEB"), any(Instant.class)))
|
||||
.thenReturn(50L);
|
||||
|
||||
FleetUsageStats stats = controller.fleetStats();
|
||||
|
||||
assertThat(stats.activeThisMonth()).isEqualTo(2L);
|
||||
}
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
package stirling.software.proprietary.controller.api;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
|
||||
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 org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.ObjectWriter;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class PipelineConfigControllerTest {
|
||||
|
||||
@Mock private RuntimePathConfig runtimePathConfig;
|
||||
@Mock private ObjectMapper objectMapper;
|
||||
@Mock private ObjectWriter objectWriter;
|
||||
|
||||
@TempDir Path tempDir;
|
||||
|
||||
@Test
|
||||
void saveConfigToWatchedFolder_returnsBadRequest_whenRequestIsNull() {
|
||||
PipelineConfigController controller =
|
||||
new PipelineConfigController(runtimePathConfig, objectMapper);
|
||||
|
||||
ResponseEntity<?> response = controller.saveConfigToWatchedFolder(null);
|
||||
|
||||
assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
|
||||
Map<?, ?> body = (Map<?, ?>) response.getBody();
|
||||
assertNotNull(body);
|
||||
assertEquals("Missing request body or config payload", body.get("error"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveConfigToWatchedFolder_returnsBadRequest_whenConfigIsNull() {
|
||||
PipelineConfigController controller =
|
||||
new PipelineConfigController(runtimePathConfig, objectMapper);
|
||||
PipelineConfigController.SaveWatchedFolderConfigRequest request =
|
||||
new PipelineConfigController.SaveWatchedFolderConfigRequest();
|
||||
request.setConfig(null);
|
||||
|
||||
ResponseEntity<?> response = controller.saveConfigToWatchedFolder(request);
|
||||
|
||||
assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
|
||||
Map<?, ?> body = (Map<?, ?>) response.getBody();
|
||||
assertNotNull(body);
|
||||
assertEquals("Missing request body or config payload", body.get("error"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveConfigToWatchedFolder_returnsBadRequest_whenSubfolderEscapesWatchedRoot() {
|
||||
when(runtimePathConfig.getPipelineWatchedFoldersPath()).thenReturn(tempDir.toString());
|
||||
PipelineConfigController controller =
|
||||
new PipelineConfigController(runtimePathConfig, objectMapper);
|
||||
|
||||
PipelineConfigController.SaveWatchedFolderConfigRequest request =
|
||||
new PipelineConfigController.SaveWatchedFolderConfigRequest();
|
||||
request.setSubfolder("../outside");
|
||||
request.setFileName("config");
|
||||
request.setConfig(Map.of("enabled", true));
|
||||
|
||||
ResponseEntity<?> response = controller.saveConfigToWatchedFolder(request);
|
||||
|
||||
assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
|
||||
Map<?, ?> body = (Map<?, ?>) response.getBody();
|
||||
assertNotNull(body);
|
||||
assertEquals("Invalid subfolder path", body.get("error"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveConfigToWatchedFolder_savesFileAndReturnsSuccess() throws IOException {
|
||||
when(runtimePathConfig.getPipelineWatchedFoldersPath()).thenReturn(tempDir.toString());
|
||||
when(objectMapper.writerWithDefaultPrettyPrinter()).thenReturn(objectWriter);
|
||||
when(objectWriter.writeValueAsString(any())).thenReturn("{\"k\":\"v\"}");
|
||||
PipelineConfigController controller =
|
||||
new PipelineConfigController(runtimePathConfig, objectMapper);
|
||||
|
||||
PipelineConfigController.SaveWatchedFolderConfigRequest request =
|
||||
new PipelineConfigController.SaveWatchedFolderConfigRequest();
|
||||
request.setSubfolder("incoming");
|
||||
request.setFileName("my:config");
|
||||
request.setConfig(Map.of("k", "v"));
|
||||
|
||||
ResponseEntity<?> response = controller.saveConfigToWatchedFolder(request);
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
Map<?, ?> body = (Map<?, ?>) response.getBody();
|
||||
assertNotNull(body);
|
||||
assertEquals(true, body.get("success"));
|
||||
assertEquals("my_config.json", body.get("fileName"));
|
||||
|
||||
Path expectedFile = tempDir.resolve("incoming").resolve("my_config.json");
|
||||
assertTrue(Files.exists(expectedFile));
|
||||
assertEquals("{\"k\":\"v\"}", Files.readString(expectedFile));
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveConfigToWatchedFolder_returnsInternalServerError_whenWriteFails() throws IOException {
|
||||
Path watchedRootAsFile = tempDir.resolve("watched-root-file");
|
||||
Files.writeString(watchedRootAsFile, "not-a-directory");
|
||||
when(runtimePathConfig.getPipelineWatchedFoldersPath())
|
||||
.thenReturn(watchedRootAsFile.toString());
|
||||
PipelineConfigController controller =
|
||||
new PipelineConfigController(runtimePathConfig, objectMapper);
|
||||
|
||||
PipelineConfigController.SaveWatchedFolderConfigRequest request =
|
||||
new PipelineConfigController.SaveWatchedFolderConfigRequest();
|
||||
request.setSubfolder("incoming");
|
||||
request.setFileName("config");
|
||||
request.setConfig(Map.of("k", "v"));
|
||||
|
||||
ResponseEntity<?> response = controller.saveConfigToWatchedFolder(request);
|
||||
|
||||
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode());
|
||||
Map<?, ?> body = (Map<?, ?>) response.getBody();
|
||||
assertNotNull(body);
|
||||
assertEquals("Failed to write config file", body.get("error"));
|
||||
}
|
||||
}
|
||||
+56
@@ -35,6 +35,7 @@ import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.InternalApiClient;
|
||||
import stirling.software.proprietary.audit.AuditEventType;
|
||||
import stirling.software.proprietary.audit.AuditLevel;
|
||||
import stirling.software.proprietary.audit.Audited;
|
||||
@@ -83,6 +84,61 @@ class AuditServiceTest {
|
||||
RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("captureCurrentSource()")
|
||||
class CaptureSource {
|
||||
|
||||
@Test
|
||||
@DisplayName("a plain authenticated web tool call is WEB")
|
||||
void plainWebIsWeb() {
|
||||
authenticateAs("alice");
|
||||
bindRequest(new MockHttpServletRequest("POST", "/api/v1/general/merge-pdfs"));
|
||||
|
||||
assertThat(service.captureCurrentSource()).isEqualTo("WEB");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the automation marker header demotes WEB to AUTOMATION")
|
||||
void automationHeaderIsAutomation() {
|
||||
authenticateAs("alice");
|
||||
MockHttpServletRequest req =
|
||||
new MockHttpServletRequest("POST", "/api/v1/general/merge");
|
||||
req.addHeader(InternalApiClient.AUTOMATION_HEADER, "true");
|
||||
bindRequest(req);
|
||||
|
||||
assertThat(service.captureCurrentSource()).isEqualTo("AUTOMATION");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the AI surface demotes WEB to AI")
|
||||
void aiSurfaceIsAi() {
|
||||
authenticateAs("alice");
|
||||
bindRequest(new MockHttpServletRequest("POST", "/api/v1/ai/tools/ask"));
|
||||
|
||||
assertThat(service.captureCurrentSource()).isEqualTo("AI");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the AI surface is matched after stripping the deployment context path")
|
||||
void aiSurfaceWithContextPathIsAi() {
|
||||
authenticateAs("alice");
|
||||
MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/ai/tools/ask");
|
||||
req.setContextPath("/stirling");
|
||||
req.setRequestURI("/stirling/api/v1/ai/tools/ask");
|
||||
bindRequest(req);
|
||||
|
||||
assertThat(service.captureCurrentSource()).isEqualTo("AI");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an unauthenticated request is SYSTEM, not WEB")
|
||||
void anonymousIsSystem() {
|
||||
bindRequest(new MockHttpServletRequest("POST", "/api/v1/general/merge-pdfs"));
|
||||
|
||||
assertThat(service.captureCurrentSource()).isEqualTo("SYSTEM");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("audit() gating")
|
||||
class AuditGating {
|
||||
|
||||
+82
-25
@@ -2,9 +2,12 @@ package stirling.software.saas.procurement.api;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.Authentication;
|
||||
@@ -12,6 +15,7 @@ import org.springframework.web.bind.annotation.GetMapping;
|
||||
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.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -72,24 +76,28 @@ public class ProcurementController {
|
||||
public record QuoteRequest(
|
||||
long volume,
|
||||
int users,
|
||||
int intensity, // policy posture (runs/PDF): 2 / 4 / 7; 0 → default Governed
|
||||
String deployment,
|
||||
int termYears,
|
||||
String serviceLevel,
|
||||
boolean indemnification,
|
||||
boolean training,
|
||||
boolean qbr,
|
||||
boolean offlineLicense,
|
||||
String currency,
|
||||
String businessName) {
|
||||
QuoteConfig toConfig() {
|
||||
return new QuoteConfig(
|
||||
volume,
|
||||
users,
|
||||
intensity,
|
||||
deployment,
|
||||
termYears,
|
||||
serviceLevel,
|
||||
indemnification,
|
||||
training,
|
||||
qbr,
|
||||
offlineLicense,
|
||||
currency);
|
||||
}
|
||||
}
|
||||
@@ -115,12 +123,14 @@ public class ProcurementController {
|
||||
public record QuoteConfigEcho(
|
||||
long volume,
|
||||
int users,
|
||||
int intensity,
|
||||
String deployment,
|
||||
int termYears,
|
||||
String serviceLevel,
|
||||
boolean indemnification,
|
||||
boolean training,
|
||||
boolean qbr,
|
||||
boolean offlineLicense,
|
||||
String currency,
|
||||
String businessName) {}
|
||||
|
||||
@@ -131,6 +141,7 @@ public class ProcurementController {
|
||||
String trialEndsAt,
|
||||
int trialExtensionsUsed,
|
||||
boolean licensed,
|
||||
String licenseKey,
|
||||
QuoteResponse latestQuote) {}
|
||||
|
||||
// ---- endpoints ----------------------------------------------------------
|
||||
@@ -143,21 +154,49 @@ public class ProcurementController {
|
||||
@GetMapping
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<SnapshotResponse> snapshot(Authentication auth) {
|
||||
Long teamId = resolveTeam(auth);
|
||||
if (teamId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
|
||||
Optional<TeamMembership> membership = primaryMembership(auth);
|
||||
if (membership.isEmpty()) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
|
||||
Long teamId = membership.get().getTeam().getId();
|
||||
// The licence key is the team's secret entitlement — leader-only. Members still see the
|
||||
// journey (stage, trial, quote) but the key is withheld; the .lic file is likewise gated.
|
||||
boolean leader = membership.get().getRole() == TeamRole.LEADER;
|
||||
return ResponseEntity.ok(
|
||||
procurement.getDeal(teamId).map(this::toSnapshot).orElse(EMPTY_SNAPSHOT));
|
||||
procurement.getDeal(teamId).map(d -> toSnapshot(d, leader)).orElse(EMPTY_SNAPSHOT));
|
||||
}
|
||||
|
||||
private static final SnapshotResponse EMPTY_SNAPSHOT =
|
||||
new SnapshotResponse(null, null, null, null, 0, false, null);
|
||||
new SnapshotResponse(null, null, null, null, 0, false, null, null);
|
||||
|
||||
/**
|
||||
* Download the offline / air-gapped licence file (.lic) for the team, when the paid offline
|
||||
* add-on was purchased. 404 when there's no licence or the add-on wasn't taken — we don't leak
|
||||
* that a licence exists to a team without the add-on.
|
||||
*/
|
||||
@GetMapping("/license/file")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<String> licenseFile(Authentication auth) {
|
||||
// Leader-only: the offline .lic is the team's portable entitlement, not a member artefact.
|
||||
Long teamId = requireLeader(auth);
|
||||
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
return procurement
|
||||
.offlineLicenseFile(teamId)
|
||||
.<ResponseEntity<String>>map(
|
||||
cert ->
|
||||
ResponseEntity.ok()
|
||||
.header(
|
||||
HttpHeaders.CONTENT_DISPOSITION,
|
||||
"attachment; filename=\"stirling-enterprise.lic\"")
|
||||
.contentType(MediaType.TEXT_PLAIN)
|
||||
.body(cert))
|
||||
.orElseGet(() -> ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
@PostMapping("/trial/start")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<SnapshotResponse> startTrial(Authentication auth) {
|
||||
Long teamId = requireLeader(auth);
|
||||
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
return ResponseEntity.ok(toSnapshot(procurement.startTrial(teamId)));
|
||||
return ResponseEntity.ok(toSnapshot(procurement.startTrial(teamId), true));
|
||||
}
|
||||
|
||||
@PostMapping("/trial/extend")
|
||||
@@ -166,7 +205,7 @@ public class ProcurementController {
|
||||
Long teamId = requireLeader(auth);
|
||||
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
try {
|
||||
return ResponseEntity.ok(toSnapshot(procurement.extendTrial(teamId)));
|
||||
return ResponseEntity.ok(toSnapshot(procurement.extendTrial(teamId), true));
|
||||
} catch (IllegalStateException e) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT).build();
|
||||
}
|
||||
@@ -197,12 +236,30 @@ public class ProcurementController {
|
||||
Long teamId = requireLeader(auth);
|
||||
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
try {
|
||||
return ResponseEntity.ok(toSnapshot(procurement.startAgreement(teamId)));
|
||||
return ResponseEntity.ok(toSnapshot(procurement.startAgreement(teamId), true));
|
||||
} catch (IllegalStateException e) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT).build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provision on accept: upgrade the team's licence to the committed annual term, valid
|
||||
* immediately. Called server-side by the accept edge function (ROLE_ADMIN via X-API-Key) once
|
||||
* the subscription + invoice exist, so the buyer is licensed the moment they accept — the deal
|
||||
* stays in the payment step until the invoice settles. Idempotent.
|
||||
*/
|
||||
@PostMapping("/provision")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public ResponseEntity<Void> provision(@RequestParam("teamId") long teamId) {
|
||||
try {
|
||||
procurement.provisionLicense(teamId);
|
||||
return ResponseEntity.ok().build();
|
||||
} catch (IllegalStateException e) {
|
||||
log.warn("[procurement] provision rejected team={}: {}", teamId, e.getMessage());
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT).build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Demo/manual stand-in for the {@code invoice.paid} webhook: mark the deal live (issue the
|
||||
* annual licence, advance to active). The real go-live is webhook-driven once payment settles.
|
||||
@@ -214,7 +271,7 @@ public class ProcurementController {
|
||||
Long teamId = requireLeader(auth);
|
||||
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
try {
|
||||
return ResponseEntity.ok(toSnapshot(procurement.markLive(teamId)));
|
||||
return ResponseEntity.ok(toSnapshot(procurement.markLive(teamId), true));
|
||||
} catch (IllegalStateException e) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT).build();
|
||||
}
|
||||
@@ -233,34 +290,31 @@ public class ProcurementController {
|
||||
|
||||
// ---- helpers ------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolve the caller's team from their primary membership; null when unauthenticated/teamless.
|
||||
*/
|
||||
private Long resolveTeam(Authentication auth) {
|
||||
/** The caller's primary team membership; empty when unauthenticated/teamless. */
|
||||
private Optional<TeamMembership> primaryMembership(Authentication auth) {
|
||||
User user;
|
||||
try {
|
||||
user = AuthenticationUtils.getCurrentUser(auth, userRepository);
|
||||
} catch (SecurityException e) {
|
||||
return null;
|
||||
return Optional.empty();
|
||||
}
|
||||
List<TeamMembership> rows = memberRepo.findPrimaryMembership(user.getId());
|
||||
return rows.isEmpty() ? null : rows.get(0).getTeam().getId();
|
||||
return memberRepo.findPrimaryMembership(user.getId()).stream().findFirst();
|
||||
}
|
||||
|
||||
/** Team id only when the caller is the team leader; null otherwise (commercial actions). */
|
||||
private Long requireLeader(Authentication auth) {
|
||||
User user;
|
||||
try {
|
||||
user = AuthenticationUtils.getCurrentUser(auth, userRepository);
|
||||
} catch (SecurityException e) {
|
||||
return null;
|
||||
}
|
||||
List<TeamMembership> rows = memberRepo.findPrimaryMembership(user.getId());
|
||||
if (rows.isEmpty() || rows.get(0).getRole() != TeamRole.LEADER) return null;
|
||||
return rows.get(0).getTeam().getId();
|
||||
return primaryMembership(auth)
|
||||
.filter(m -> m.getRole() == TeamRole.LEADER)
|
||||
.map(m -> m.getTeam().getId())
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private SnapshotResponse toSnapshot(ProcurementDeal deal) {
|
||||
/**
|
||||
* Build the snapshot for a deal. {@code includeLicenseKey} is true only for the team leader; a
|
||||
* member sees {@code licensed} but not the key itself (see {@link #snapshot}). Mutation
|
||||
* endpoints are leader-gated, so they always pass true.
|
||||
*/
|
||||
private SnapshotResponse toSnapshot(ProcurementDeal deal, boolean includeLicenseKey) {
|
||||
QuoteResponse latest =
|
||||
procurement.quotesForDeal(deal.getDealId()).stream()
|
||||
.findFirst()
|
||||
@@ -273,6 +327,7 @@ public class ProcurementController {
|
||||
str(deal.getTrialEndsAt()),
|
||||
deal.getTrialExtensionsUsed(),
|
||||
deal.getLicenseRef() != null,
|
||||
includeLicenseKey ? deal.getLicenseRef() : null,
|
||||
latest);
|
||||
}
|
||||
|
||||
@@ -291,12 +346,14 @@ public class ProcurementController {
|
||||
new QuoteConfigEcho(
|
||||
q.getVolume(),
|
||||
0,
|
||||
q.getIntensity(),
|
||||
q.getDeployment(),
|
||||
q.getTermYears(),
|
||||
q.getServiceLevel(),
|
||||
q.isIndemnification(),
|
||||
q.isTraining(),
|
||||
q.isQbr(),
|
||||
q.isOfflineLicense(),
|
||||
q.getCurrency(),
|
||||
q.getBusinessName()));
|
||||
}
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package stirling.software.saas.procurement.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* Keygen credentials + policy for issuing enterprise procurement licences directly from Java.
|
||||
* Prefix {@code stirling.keygen}. All secrets come from the environment (relaxed binding: {@code
|
||||
* STIRLING_KEYGEN_ACCOUNT_ID}, {@code STIRLING_KEYGEN_API_TOKEN}, …) — never committed.
|
||||
*
|
||||
* <p>{@code enabled} is the switch between {@code MockEnterpriseLicenseService} (default) and the
|
||||
* real {@code KeygenEnterpriseLicenseService}; the mock stays in place until the env vars are
|
||||
* wired.
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Component
|
||||
@Profile("saas")
|
||||
@ConfigurationProperties(prefix = "stirling.keygen")
|
||||
public class KeygenConfigurationProperties {
|
||||
|
||||
/** Master switch: when true, the real Keygen client replaces the mock licence service. */
|
||||
private boolean enabled = false;
|
||||
|
||||
/** Keygen account id (UUID or slug). From {@code STIRLING_KEYGEN_ACCOUNT_ID}. */
|
||||
private String accountId;
|
||||
|
||||
/** Keygen admin API token. From {@code STIRLING_KEYGEN_API_TOKEN}. Never log this. */
|
||||
private String apiToken;
|
||||
|
||||
/** Policy the committed-enterprise licences are created under. From {@code ..._POLICY_ID}. */
|
||||
private String policyId;
|
||||
|
||||
/** API base; overridable for self-hosted Keygen, defaults to the hosted service. */
|
||||
private String apiBase = "https://api.keygen.sh/v1";
|
||||
|
||||
/**
|
||||
* License-file check-out algorithm. Must stay {@code base64+ed25519} — the self-hosted {@code
|
||||
* KeygenLicenseVerifier} only verifies that scheme (signed, unencrypted) offline.
|
||||
*/
|
||||
private String licenseFileAlgorithm = "base64+ed25519";
|
||||
|
||||
/** True when the credentials needed to talk to Keygen are all present. */
|
||||
public boolean isConfigured() {
|
||||
return notBlank(accountId) && notBlank(apiToken) && notBlank(policyId);
|
||||
}
|
||||
|
||||
private static boolean notBlank(String s) {
|
||||
return s != null && !s.isBlank();
|
||||
}
|
||||
}
|
||||
+26
-4
@@ -11,15 +11,37 @@ import java.time.LocalDateTime;
|
||||
*/
|
||||
public interface EnterpriseLicenseService {
|
||||
|
||||
/** Issue a time-boxed trial licence for the team; returns the licence reference. */
|
||||
String issueTrialLicense(Long teamId, LocalDateTime expiresAt);
|
||||
/**
|
||||
* Issue a time-boxed trial licence for the team, owned by {@code ownerEmail} (the team leader);
|
||||
* returns the licence reference (the Keygen key, stored on the deal).
|
||||
*/
|
||||
String issueTrialLicense(Long teamId, String ownerEmail, LocalDateTime expiresAt);
|
||||
|
||||
/** Move a licence's expiry out (trial extension). */
|
||||
void extendLicense(String licenseRef, LocalDateTime newExpiry);
|
||||
|
||||
/** Issue/upgrade to a committed annual licence with the quote's entitlements. */
|
||||
String issueAnnualLicense(Long teamId, String deployment, LocalDateTime expiresAt);
|
||||
/**
|
||||
* Issue/upgrade to a committed annual licence carrying the deal's {@link LicenseEntitlements}
|
||||
* ({@code seats} = 0 means unlimited). When {@code existingRef} is non-null (the team already
|
||||
* has a trial licence), that licence is upgraded in place so the key the buyer already holds
|
||||
* keeps working; otherwise a new licence is created. Owned by {@code ownerEmail}; returns the
|
||||
* licence reference.
|
||||
*/
|
||||
String issueAnnualLicense(
|
||||
Long teamId,
|
||||
String ownerEmail,
|
||||
LocalDateTime expiresAt,
|
||||
String existingRef,
|
||||
LicenseEntitlements entitlements);
|
||||
|
||||
/** Suspend a licence (e.g. payment failed, deal lost). */
|
||||
void suspendLicense(String licenseRef);
|
||||
|
||||
/**
|
||||
* Check out a signed, offline-verifiable licence file (a {@code -----BEGIN LICENSE FILE-----}
|
||||
* certificate) for the given licence, for an air-gapped self-hosted instance. The paid offline
|
||||
* add-on gates whether this is offered; the certificate itself is generated on demand and never
|
||||
* stored.
|
||||
*/
|
||||
String checkOutLicenseFile(String licenseRef);
|
||||
}
|
||||
|
||||
+304
@@ -0,0 +1,304 @@
|
||||
package stirling.software.saas.procurement.license;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
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.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.saas.procurement.config.KeygenConfigurationProperties;
|
||||
|
||||
/**
|
||||
* Real {@link EnterpriseLicenseService}: manages the team's enterprise licence directly against the
|
||||
* Keygen API (the "call Keygen from Java" direction), rather than via the Supabase edge functions
|
||||
* the self-hosted checkout uses. Active only when {@code stirling.keygen.enabled=true}; otherwise
|
||||
* {@link MockEnterpriseLicenseService} is the bean.
|
||||
*
|
||||
* <p>Licences are owned by the team leader (a Keygen user, found or created by email) and created
|
||||
* under the committed-enterprise policy. Metadata carries {@code isEnterprise} + {@code users} so a
|
||||
* checked-out offline licence file satisfies the self-hosted {@code KeygenLicenseVerifier}. The
|
||||
* licence key returned is stored on the deal as its {@code license_ref}.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@Profile("saas")
|
||||
@ConditionalOnProperty(name = "stirling.keygen.enabled", havingValue = "true")
|
||||
public class KeygenEnterpriseLicenseService implements EnterpriseLicenseService {
|
||||
|
||||
private static final String VND_JSON = "application/vnd.api+json";
|
||||
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
private final HttpClient http =
|
||||
HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
|
||||
private final KeygenConfigurationProperties config;
|
||||
|
||||
public KeygenEnterpriseLicenseService(KeygenConfigurationProperties config) {
|
||||
this.config = config;
|
||||
// Fail fast: if the flag is on but creds are missing, a misconfigured prod deploy should be
|
||||
// caught at startup, not at the first trial/provision. (Flag off → Mock bean, never here.)
|
||||
if (!config.isConfigured()) {
|
||||
throw new IllegalStateException(
|
||||
"stirling.keygen.enabled=true but Keygen is not fully configured — set "
|
||||
+ "STIRLING_KEYGEN_ACCOUNT_ID / _API_TOKEN / _POLICY_ID, or turn the flag off");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String issueTrialLicense(Long teamId, String ownerEmail, LocalDateTime expiresAt) {
|
||||
String ownerId = findOrCreateUser(ownerEmail);
|
||||
// Trial: enterprise entitlement, unlimited users, expiring at the trial end. No committed
|
||||
// volume/add-ons yet — those are stamped on the annual licence at provision.
|
||||
return createLicense(ownerId, expiresAt, trialMetadata(teamId));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void extendLicense(String licenseRef, LocalDateTime newExpiry) {
|
||||
Map<String, Object> attrs = new LinkedHashMap<>();
|
||||
attrs.put("expiry", iso(newExpiry));
|
||||
patchLicense(licenseRef, attrs);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String issueAnnualLicense(
|
||||
Long teamId,
|
||||
String ownerEmail,
|
||||
LocalDateTime expiresAt,
|
||||
String existingRef,
|
||||
LicenseEntitlements ent) {
|
||||
Map<String, Object> metadata = annualMetadata(teamId, ent);
|
||||
// Upgrade the trial licence in place so the key the buyer already holds keeps working.
|
||||
if (existingRef != null && !existingRef.isBlank()) {
|
||||
Map<String, Object> attrs = new LinkedHashMap<>();
|
||||
attrs.put("expiry", iso(expiresAt));
|
||||
attrs.put("suspended", false);
|
||||
attrs.put("metadata", metadata);
|
||||
patchLicense(existingRef, attrs);
|
||||
return existingRef;
|
||||
}
|
||||
String ownerId = findOrCreateUser(ownerEmail);
|
||||
return createLicense(ownerId, expiresAt, metadata);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void suspendLicense(String licenseRef) {
|
||||
HttpResponse<String> res =
|
||||
send(
|
||||
authed(licenseUrl(licenseRef) + "/actions/suspend")
|
||||
.POST(HttpRequest.BodyPublishers.noBody())
|
||||
.build());
|
||||
expect(res, 200, "suspend licence");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String checkOutLicenseFile(String licenseRef) {
|
||||
// Signed, unencrypted (base64+ed25519) so the self-hosted verifier can validate it offline;
|
||||
// include entitlements in the snapshot. Never encrypt — the verifier only reads
|
||||
// base64+ed25519.
|
||||
String url =
|
||||
licenseUrl(licenseRef)
|
||||
+ "/actions/check-out?include=entitlements&algorithm="
|
||||
+ enc(config.getLicenseFileAlgorithm());
|
||||
HttpResponse<String> res =
|
||||
send(authed(url).POST(HttpRequest.BodyPublishers.noBody()).build());
|
||||
expect(res, 200, "check-out licence file");
|
||||
JsonNode cert = readJson(res).at("/data/attributes/certificate");
|
||||
if (cert.isMissingNode() || cert.asText().isBlank()) {
|
||||
throw new IllegalStateException("Keygen check-out returned no certificate");
|
||||
}
|
||||
return cert.asText();
|
||||
}
|
||||
|
||||
// ---- Keygen primitives --------------------------------------------------
|
||||
|
||||
/** Find the Keygen user by email, creating it if absent; returns the user id. */
|
||||
private String findOrCreateUser(String email) {
|
||||
if (email == null || email.isBlank()) {
|
||||
throw new IllegalArgumentException("Cannot own a licence without an owner email");
|
||||
}
|
||||
HttpResponse<String> find =
|
||||
send(authed(accountUrl() + "/users/" + enc(email)).GET().build());
|
||||
if (find.statusCode() == 200) {
|
||||
return readJson(find).at("/data/id").asText();
|
||||
}
|
||||
if (find.statusCode() != 404) {
|
||||
expect(find, 200, "find Keygen user"); // throws with the real status
|
||||
}
|
||||
Map<String, Object> body =
|
||||
jsonApi("users", Map.of("email", email), null); // no relationships
|
||||
HttpResponse<String> create =
|
||||
send(
|
||||
authed(accountUrl() + "/users")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(write(body)))
|
||||
.build());
|
||||
expect(create, 201, "create Keygen user");
|
||||
return readJson(create).at("/data/id").asText();
|
||||
}
|
||||
|
||||
private String createLicense(
|
||||
String ownerId, LocalDateTime expiresAt, Map<String, Object> metadata) {
|
||||
Map<String, Object> attributes = new LinkedHashMap<>();
|
||||
attributes.put("expiry", iso(expiresAt));
|
||||
attributes.put("metadata", metadata);
|
||||
Map<String, Object> relationships =
|
||||
Map.of(
|
||||
"policy",
|
||||
Map.of("data", Map.of("type", "policies", "id", config.getPolicyId())),
|
||||
"owner",
|
||||
Map.of("data", Map.of("type", "users", "id", ownerId)));
|
||||
Map<String, Object> body = jsonApi("licenses", attributes, relationships);
|
||||
HttpResponse<String> res =
|
||||
send(
|
||||
authed(accountUrl() + "/licenses")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(write(body)))
|
||||
.build());
|
||||
expect(res, 201, "create licence");
|
||||
return readJson(res).at("/data/attributes/key").asText();
|
||||
}
|
||||
|
||||
private void patchLicense(String licenseRef, Map<String, Object> attributes) {
|
||||
Map<String, Object> body = jsonApi("licenses", attributes, null);
|
||||
HttpResponse<String> res =
|
||||
send(
|
||||
authed(licenseUrl(licenseRef))
|
||||
.method("PATCH", HttpRequest.BodyPublishers.ofString(write(body)))
|
||||
.build());
|
||||
expect(res, 200, "update licence");
|
||||
}
|
||||
|
||||
// ---- helpers ------------------------------------------------------------
|
||||
|
||||
// The self-hosted verifier only reads isEnterprise + users; everything else is informational
|
||||
// (dashboard / reconciliation) but kept so the licence is a self-describing record of the deal.
|
||||
|
||||
/** Trial licence: enterprise, unlimited users, no committed volume/add-ons yet. */
|
||||
private Map<String, Object> trialMetadata(Long teamId) {
|
||||
Map<String, Object> m = baseMetadata(teamId, true);
|
||||
m.put("users", 0); // 0 = unlimited during the trial
|
||||
m.put("seat_count", 0);
|
||||
return m;
|
||||
}
|
||||
|
||||
/** Committed annual licence: the full entitlement snapshot from the accepted quote + deal. */
|
||||
private Map<String, Object> annualMetadata(Long teamId, LicenseEntitlements ent) {
|
||||
Map<String, Object> m = baseMetadata(teamId, false);
|
||||
int seats = Math.max(0, ent.seats());
|
||||
m.put("users", seats); // 0 = unlimited
|
||||
m.put("seat_count", seats); // parity with the self-hosted edge's metadata
|
||||
m.put("volume", ent.volume()); // committed PDFs / year
|
||||
m.put("term_years", ent.termYears());
|
||||
if (ent.serviceLevel() != null && !ent.serviceLevel().isBlank()) {
|
||||
m.put("service_level", ent.serviceLevel());
|
||||
}
|
||||
m.put("indemnification", ent.indemnification());
|
||||
m.put("training", ent.training());
|
||||
m.put("qbr", ent.qbr());
|
||||
m.put("offline_license", ent.offlineLicense());
|
||||
if (ent.deployment() != null && !ent.deployment().isBlank()) {
|
||||
m.put("deployment", ent.deployment());
|
||||
}
|
||||
if (ent.dealId() != null) m.put("deal_id", ent.dealId());
|
||||
if (ent.subscriptionId() != null && !ent.subscriptionId().isBlank()) {
|
||||
m.put("subscription_id", ent.subscriptionId());
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
private Map<String, Object> baseMetadata(Long teamId, boolean trial) {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("team_id", teamId);
|
||||
m.put("plan_type", "enterprise");
|
||||
m.put("isEnterprise", true);
|
||||
m.put("trial", trial);
|
||||
return m;
|
||||
}
|
||||
|
||||
private Map<String, Object> jsonApi(
|
||||
String type, Map<String, Object> attributes, Map<String, Object> relationships) {
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("type", type);
|
||||
data.put("attributes", attributes);
|
||||
if (relationships != null) data.put("relationships", relationships);
|
||||
return Map.of("data", data);
|
||||
}
|
||||
|
||||
private HttpRequest.Builder authed(String url) {
|
||||
return HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.header("Authorization", "Bearer " + config.getApiToken())
|
||||
.header("Content-Type", VND_JSON)
|
||||
.header("Accept", VND_JSON)
|
||||
.timeout(Duration.ofSeconds(30));
|
||||
}
|
||||
|
||||
private HttpResponse<String> send(HttpRequest request) {
|
||||
try {
|
||||
return http.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
} catch (java.io.IOException e) {
|
||||
throw new IllegalStateException("Keygen request failed: " + e.getMessage(), e);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException("Keygen request interrupted", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void expect(HttpResponse<String> res, int status, String action) {
|
||||
if (res.statusCode() != status) {
|
||||
// Keep the response body out of the thrown message: it never carries the token, but can
|
||||
// echo owner emails / metadata, and the message reaches warn-level logs. Body at debug.
|
||||
log.debug(
|
||||
"[procurement][keygen] {} failed: HTTP {} body={}",
|
||||
action,
|
||||
res.statusCode(),
|
||||
res.body());
|
||||
throw new IllegalStateException(
|
||||
"Keygen " + action + " failed: HTTP " + res.statusCode());
|
||||
}
|
||||
}
|
||||
|
||||
private JsonNode readJson(HttpResponse<String> res) {
|
||||
try {
|
||||
return mapper.readTree(res.body());
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Keygen returned unparseable JSON", e);
|
||||
}
|
||||
}
|
||||
|
||||
private String write(Map<String, Object> body) {
|
||||
try {
|
||||
return mapper.writeValueAsString(body);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Failed to serialise Keygen request", e);
|
||||
}
|
||||
}
|
||||
|
||||
private String accountUrl() {
|
||||
return config.getApiBase() + "/accounts/" + config.getAccountId();
|
||||
}
|
||||
|
||||
private String licenseUrl(String licenseRef) {
|
||||
return accountUrl() + "/licenses/" + enc(licenseRef);
|
||||
}
|
||||
|
||||
private static String enc(String s) {
|
||||
return URLEncoder.encode(s, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static String iso(LocalDateTime dt) {
|
||||
return dt.toInstant(ZoneOffset.UTC).toString();
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package stirling.software.saas.procurement.license;
|
||||
|
||||
/**
|
||||
* The committed deal's entitlements, stamped onto the annual Keygen licence's metadata so the
|
||||
* licence is a self-describing record of what was bought. Only {@code seats} (as {@code users}) and
|
||||
* the enterprise flag are read by the self-hosted verifier; the rest is informational — kept "for
|
||||
* good measure" so the Keygen dashboard and any downstream reconciliation can see the full picture.
|
||||
* Built by {@code ProcurementService} from the accepted quote + deal.
|
||||
*/
|
||||
public record LicenseEntitlements(
|
||||
long volume, // committed PDFs / year
|
||||
int seats, // 0 = unlimited
|
||||
String deployment, // cloud | selfhost | airgap
|
||||
int termYears,
|
||||
String serviceLevel, // standard | priority | dedicated
|
||||
boolean indemnification,
|
||||
boolean training,
|
||||
boolean qbr,
|
||||
boolean offlineLicense,
|
||||
Long dealId,
|
||||
String subscriptionId) {}
|
||||
+35
-6
@@ -3,6 +3,7 @@ package stirling.software.saas.procurement.license;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@@ -13,15 +14,23 @@ import lombok.extern.slf4j.Slf4j;
|
||||
* returns a synthetic reference, without calling Keygen. Lets the whole procurement journey run
|
||||
* end-to-end while the real Keygen management client is a later drop-in — the seam and the stored
|
||||
* {@code license_ref} on the deal stay identical.
|
||||
*
|
||||
* <p>This is the default; it steps aside for {@code KeygenEnterpriseLicenseService} when {@code
|
||||
* stirling.keygen.enabled=true} (real Keygen secrets are wired).
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@Profile("saas")
|
||||
@ConditionalOnProperty(
|
||||
name = "stirling.keygen.enabled",
|
||||
havingValue = "false",
|
||||
matchIfMissing = true)
|
||||
public class MockEnterpriseLicenseService implements EnterpriseLicenseService {
|
||||
|
||||
@Override
|
||||
public String issueTrialLicense(Long teamId, LocalDateTime expiresAt) {
|
||||
public String issueTrialLicense(Long teamId, String ownerEmail, LocalDateTime expiresAt) {
|
||||
String ref = "mock-trial-" + UUID.randomUUID();
|
||||
// Owner email is deliberately not logged — it's PII and adds nothing to the mock trace.
|
||||
log.info(
|
||||
"[procurement][mock-license] issue trial team={} expires={} ref={}",
|
||||
teamId,
|
||||
@@ -36,14 +45,24 @@ public class MockEnterpriseLicenseService implements EnterpriseLicenseService {
|
||||
}
|
||||
|
||||
@Override
|
||||
public String issueAnnualLicense(Long teamId, String deployment, LocalDateTime expiresAt) {
|
||||
String ref = "mock-annual-" + UUID.randomUUID();
|
||||
public String issueAnnualLicense(
|
||||
Long teamId,
|
||||
String ownerEmail,
|
||||
LocalDateTime expiresAt,
|
||||
String existingRef,
|
||||
LicenseEntitlements ent) {
|
||||
// Upgrade in place when a trial licence already exists, so the key stays stable.
|
||||
String ref = existingRef != null ? existingRef : "mock-annual-" + UUID.randomUUID();
|
||||
// Owner email is deliberately not logged — it's PII and adds nothing to the mock trace.
|
||||
log.info(
|
||||
"[procurement][mock-license] issue annual team={} deployment={} expires={} ref={}",
|
||||
"[procurement][mock-license] issue annual team={} seats={} volume={} deployment={} expires={} ref={} upgrade={}",
|
||||
teamId,
|
||||
deployment,
|
||||
ent.seats(),
|
||||
ent.volume(),
|
||||
ent.deployment(),
|
||||
expiresAt,
|
||||
ref);
|
||||
ref,
|
||||
existingRef != null);
|
||||
return ref;
|
||||
}
|
||||
|
||||
@@ -51,4 +70,14 @@ public class MockEnterpriseLicenseService implements EnterpriseLicenseService {
|
||||
public void suspendLicense(String licenseRef) {
|
||||
log.info("[procurement][mock-license] suspend ref={}", licenseRef);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String checkOutLicenseFile(String licenseRef) {
|
||||
log.info("[procurement][mock-license] check-out licence file ref={}", licenseRef);
|
||||
// A syntactically shaped stand-in so the portal download path is exercisable without
|
||||
// Keygen; not a valid certificate.
|
||||
return "-----BEGIN LICENSE FILE-----\nmock-offline-license-for-"
|
||||
+ licenseRef
|
||||
+ "\n-----END LICENSE FILE-----\n";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,10 @@ public class ProcurementQuote implements Serializable {
|
||||
@Column(name = "seats")
|
||||
private Integer seats;
|
||||
|
||||
/** Policy posture as runs-per-PDF: Essentials 2, Governed 4, Regulated 7. Defaults Governed. */
|
||||
@Column(name = "intensity", nullable = false)
|
||||
private int intensity = 4;
|
||||
|
||||
@Column(name = "deployment", length = 24)
|
||||
private String deployment;
|
||||
|
||||
@@ -80,6 +84,9 @@ public class ProcurementQuote implements Serializable {
|
||||
@Column(name = "qbr", nullable = false)
|
||||
private boolean qbr;
|
||||
|
||||
@Column(name = "offline_license", nullable = false)
|
||||
private boolean offlineLicense;
|
||||
|
||||
@Column(name = "annual_net_minor", nullable = false)
|
||||
private long annualNetMinor;
|
||||
|
||||
|
||||
+26
-35
@@ -1,53 +1,44 @@
|
||||
package stirling.software.saas.procurement.pricing;
|
||||
|
||||
/**
|
||||
* The enterprise rate card: the inputs pricing multiplies against. In production these are read
|
||||
* from the Stripe price mirror (see {@code StripeMirrorPriceCatalog}); {@link #defaults()} is the
|
||||
* fallback used when the {@code stripe} schema isn't synced (dev / tests) and is the single source
|
||||
* of the numbers the marketing prototype encodes.
|
||||
* The enterprise rate card (D71): the inputs pricing derives a quote from. In production these are
|
||||
* read from the Stripe price mirror; {@link #defaults()} is the fallback used when the {@code
|
||||
* stripe} schema isn't synced (dev / tests) and is the single source of the numbers the marketing
|
||||
* prototype ({@code quotePricing}) encodes.
|
||||
*
|
||||
* <p>Per-PDF rates are in minor units (cents) per document. Multipliers are fractions (e.g. 0.15 =
|
||||
* +15%). Flat/one-time fees are in minor units.
|
||||
* <p>The meter is denominated in <b>runs</b> (a PDF running N policies is N runs). The per-run rate
|
||||
* is a fraction of a dollar, so it lives here as a {@code double} in dollars; flat and one-time
|
||||
* fees are whole-dollar amounts held in minor units (cents). See {@link ProcurementPricingService}
|
||||
* for how they combine.
|
||||
*/
|
||||
public record PricingRates(
|
||||
long perPdfMinorUnder1M,
|
||||
long perPdfMinorUnder5M,
|
||||
long perPdfMinor5MPlus,
|
||||
double priorityUplift,
|
||||
double dedicatedUplift,
|
||||
double indemnificationUplift,
|
||||
double[] termDiscountByYear, // index 0 = 1yr … index 4 = 5yr
|
||||
long qbrAnnualMinor,
|
||||
long trainingOneTimeMinor) {
|
||||
double listRatePerRun, // $0.01 list per run
|
||||
double floorRatePerRun, // $0.005 asymptotic floor (cost + margin)
|
||||
double discountPerDoubling, // 0.06 = 6% off the per-run rate per doubling past 1M runs/yr
|
||||
double[] termDiscountByYear, // meter-only discount; index 0 = 1yr … index 4 = 5yr
|
||||
double indemnificationRate, // fraction of the net meter (legal exposure scales with usage)
|
||||
long dedicatedSupportMinor, // flat: dedicated SE/CSM (standard + priority are included)
|
||||
long selfHostDeployMinor, // flat: self-hosted deployment
|
||||
long airgapDeployMinor, // flat: air-gapped deployment
|
||||
long qbrAnnualMinor, // flat: quarterly business reviews
|
||||
long trainingOneTimeMinor) { // one-time: onboarding & training
|
||||
|
||||
public static PricingRates defaults() {
|
||||
return new PricingRates(
|
||||
5, // $0.05 / PDF under 1M/yr
|
||||
4, // $0.04 / PDF at 1M–5M/yr
|
||||
3, // $0.03 / PDF at 5M+/yr
|
||||
0.15, // priority +15%
|
||||
0.30, // dedicated +30%
|
||||
0.05, // IP indemnification +5%
|
||||
new double[] {0.0, 0.05, 0.10, 0.12, 0.15},
|
||||
0.01, // $0.01 / run list
|
||||
0.005, // $0.005 / run floor
|
||||
0.06, // 6% off per doubling of committed runs past 1M/yr
|
||||
new double[] {0.0, 0.03, 0.05, 0.06, 0.07}, // 1–5 year term, meter only
|
||||
0.05, // IP indemnification = 5% of the net meter
|
||||
3_000_000, // dedicated SE/CSM $30,000 / yr
|
||||
1_200_000, // self-hosted $12,000 / yr
|
||||
3_600_000, // air-gapped $36,000 / yr
|
||||
800_000, // QBRs $8,000 / yr
|
||||
750_000); // onboarding & training $7,500 one-time
|
||||
}
|
||||
|
||||
/** Volume-banded per-PDF rate for an annual volume, in minor units. */
|
||||
public long perPdfMinor(long annualVolume) {
|
||||
if (annualVolume >= 5_000_000) return perPdfMinor5MPlus;
|
||||
if (annualVolume >= 1_000_000) return perPdfMinorUnder5M;
|
||||
return perPdfMinorUnder1M;
|
||||
}
|
||||
|
||||
public double termDiscount(int termYears) {
|
||||
int idx = Math.max(1, Math.min(termYears, 5)) - 1;
|
||||
return termDiscountByYear[idx];
|
||||
}
|
||||
|
||||
public double serviceLevelUplift(String serviceLevel) {
|
||||
if ("priority".equalsIgnoreCase(serviceLevel)) return priorityUplift;
|
||||
if ("dedicated".equalsIgnoreCase(serviceLevel)) return dedicatedUplift;
|
||||
return 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
+104
-51
@@ -2,34 +2,39 @@ package stirling.software.saas.procurement.pricing;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* The canonical enterprise pricing engine — the single server-side definition the quote builder,
|
||||
* the order form, and the Stripe checkout all derive from. A faithful port of the marketing
|
||||
* The canonical enterprise pricing engine (D71) — the single server-side definition the quote
|
||||
* builder, the order form, and Stripe checkout all derive from. A faithful port of the marketing
|
||||
* prototype's {@code quotePricing}:
|
||||
*
|
||||
* <pre>
|
||||
* annual = volume x perPdfRate x serviceLevelMult x (indemnification ? 1.05 : 1)
|
||||
* annualNet = round(annual x (1 - termDiscount)) + qbr
|
||||
* tcv = annualNet x termYears + training
|
||||
* runVol = volume x intensity // posture: Essentials x2, Governed x4, Regulated x7
|
||||
* volDisc = min(0.5, 0.06 x log2(runVol / 1M)) // 6% off the per-run rate per doubling past 1M
|
||||
* rate = max($0.005, $0.01 x (1 - volDisc)) // continuous curve, floors at half a cent
|
||||
* meterNet = round(runVol x rate x (1 - termDisc)) // whole dollars; term discounts the meter only
|
||||
* annualNet = meterNet + dedicated + deployment + indemnification + qbr // needs priced flat
|
||||
* tcv = annualNet x termYears + training
|
||||
* </pre>
|
||||
*
|
||||
* The multi-year discount applies to usage + service level + indemnification, but NOT to the flat
|
||||
* QBR fee (added after), and one-time training sits outside the recurring total. All money is in
|
||||
* minor units (cents). Rates come from {@link PricingRates} (Stripe-backed in prod).
|
||||
* <p>No volume tiers, no service-level multipliers, no ACV floor — those were the retired model.
|
||||
* Deployment (self-hosted / air-gapped) and dedicated support are flat line items at cost basis;
|
||||
* SSO / SCIM / RBAC / audit are always included. Money is USD only; amounts are in minor units
|
||||
* (cents). Rates come from {@link PricingRates} (Stripe-backed in prod).
|
||||
*/
|
||||
@Service
|
||||
public class ProcurementPricingService {
|
||||
|
||||
/** Bespoke/committed deals don't price below this ACV; the builder floors against it. */
|
||||
public static final long MIN_ACV_MINOR = 5_000_000L; // $50,000
|
||||
private static final double LOG2 = Math.log(2.0);
|
||||
private static final long RUN_CURVE_KNEE = 1_000_000L; // discount starts past 1M committed runs
|
||||
|
||||
/**
|
||||
* Estimated annual PDF volume from seat count (~2,012.5 PDFs/user/yr = 5 docs/day x 230 working
|
||||
* days x 1.75), used to prefill the builder's volume step. Rounded, matching the prototype's
|
||||
* {@code users x 5 x 230 x 1.75}.
|
||||
* days x 1.75), used to prefill the builder's volume step. Matches the prototype's {@code users
|
||||
* x 5 x 230 x 1.75}.
|
||||
*/
|
||||
public long estimateAnnualVolume(int users) {
|
||||
return Math.round(Math.max(0, users) * 5.0 * 230.0 * 1.75);
|
||||
@@ -40,55 +45,95 @@ public class ProcurementPricingService {
|
||||
}
|
||||
|
||||
public QuoteBreakdown price(QuoteConfig cfg, PricingRates rates) {
|
||||
// Never trust the client's volume: clamp to non-negative so a tampered request can't drive
|
||||
// a
|
||||
// negative amount. The rate card and formula are server-side, so the browser can't lower
|
||||
// the
|
||||
// price — only pick a smaller, legitimate config. (See MIN_ACV_MINOR for the committed
|
||||
// floor,
|
||||
// a policy decision that is intentionally not force-applied here — see the review notes.)
|
||||
// Never trust the client's volume/intensity: clamp so a tampered request can't drive a
|
||||
// negative amount. The curve and flat fees are server-side, so the browser can only pick a
|
||||
// smaller legitimate config, never a cheaper rate.
|
||||
long volume = Math.max(0, cfg.volume());
|
||||
long perPdf = rates.perPdfMinor(volume);
|
||||
long usage = Math.round(volume * (double) perPdf); // base, pre-service-level
|
||||
double slaUplift = rates.serviceLevelUplift(cfg.serviceLevel());
|
||||
long withSla = Math.round(usage * (1.0 + slaUplift));
|
||||
long withIndemnity =
|
||||
cfg.indemnification()
|
||||
? Math.round(withSla * (1.0 + rates.indemnificationUplift()))
|
||||
: withSla;
|
||||
int intensity = Math.max(1, cfg.intensity());
|
||||
long runVol = volume * (long) intensity;
|
||||
|
||||
double termDiscount = rates.termDiscount(cfg.termYears());
|
||||
long discount = Math.round(withIndemnity * termDiscount);
|
||||
// Committed-volume curve: continuous, no cliffs. Floors at the per-run cost + margin.
|
||||
double volDisc =
|
||||
runVol > RUN_CURVE_KNEE
|
||||
? Math.min(
|
||||
0.5,
|
||||
rates.discountPerDoubling()
|
||||
* (Math.log(runVol / (double) RUN_CURVE_KNEE) / LOG2))
|
||||
: 0.0;
|
||||
double rate = Math.max(rates.floorRatePerRun(), rates.listRatePerRun() * (1.0 - volDisc));
|
||||
double termDisc = rates.termDiscount(cfg.termYears());
|
||||
|
||||
// The meter is a whole-dollar figure (the quote reads in dollars), then minor units.
|
||||
long annualBaseMinor = Math.round((double) runVol * rate) * 100L;
|
||||
long meterNetMinor = Math.round((double) runVol * rate * (1.0 - termDisc)) * 100L;
|
||||
long termDiscountMinor = meterNetMinor - annualBaseMinor; // <= 0
|
||||
|
||||
long support =
|
||||
"dedicated".equalsIgnoreCase(cfg.serviceLevel())
|
||||
? rates.dedicatedSupportMinor()
|
||||
: 0L;
|
||||
long deploy = deployFeeMinor(cfg.deployment(), rates);
|
||||
long indemnity =
|
||||
cfg.indemnification()
|
||||
? Math.round(meterNetMinor * rates.indemnificationRate())
|
||||
: 0L;
|
||||
long qbr = cfg.qbr() ? rates.qbrAnnualMinor() : 0L;
|
||||
long training = cfg.training() ? rates.trainingOneTimeMinor() : 0L;
|
||||
|
||||
long annualNet = (withIndemnity - discount) + qbr;
|
||||
long annualNet = meterNetMinor + support + deploy + indemnity + qbr;
|
||||
long tcv = annualNet * cfg.termYears() + training;
|
||||
|
||||
double effectivePerPdf = rate * intensity; // quotes speak per-PDF-at-posture, never per-run
|
||||
|
||||
List<QuoteLineItem> lines = new ArrayList<>();
|
||||
lines.add(
|
||||
new QuoteLineItem("usage", "PDF processing", QuoteLineItem.Kind.RECURRING, usage));
|
||||
new QuoteLineItem(
|
||||
"usage",
|
||||
String.format(
|
||||
Locale.ROOT,
|
||||
"PDF processing — %,d PDFs/yr at $%.4f/PDF (%s posture)",
|
||||
volume,
|
||||
effectivePerPdf,
|
||||
postureLabel(intensity)),
|
||||
QuoteLineItem.Kind.RECURRING,
|
||||
annualBaseMinor));
|
||||
lines.add(
|
||||
new QuoteLineItem(
|
||||
"seats",
|
||||
"Unlimited users + SSO / SCIM / RBAC",
|
||||
"Unlimited users + SSO / SCIM / RBAC / audit",
|
||||
QuoteLineItem.Kind.INCLUDED,
|
||||
0L));
|
||||
if (withSla != usage) {
|
||||
if (termDiscountMinor < 0) {
|
||||
lines.add(
|
||||
new QuoteLineItem(
|
||||
"service-level",
|
||||
serviceLevelLabel(cfg.serviceLevel()),
|
||||
QuoteLineItem.Kind.RECURRING,
|
||||
withSla - usage));
|
||||
"multi-year",
|
||||
cfg.termYears() + "-year commitment",
|
||||
QuoteLineItem.Kind.DISCOUNT,
|
||||
termDiscountMinor));
|
||||
}
|
||||
if (withIndemnity != withSla) {
|
||||
if (support > 0) {
|
||||
lines.add(
|
||||
new QuoteLineItem(
|
||||
"support",
|
||||
"Dedicated SE / CSM",
|
||||
QuoteLineItem.Kind.RECURRING,
|
||||
support));
|
||||
}
|
||||
if (deploy > 0) {
|
||||
lines.add(
|
||||
new QuoteLineItem(
|
||||
"deployment",
|
||||
deploymentLabel(cfg.deployment()) + " deployment",
|
||||
QuoteLineItem.Kind.RECURRING,
|
||||
deploy));
|
||||
}
|
||||
if (indemnity > 0) {
|
||||
lines.add(
|
||||
new QuoteLineItem(
|
||||
"indemnification",
|
||||
"IP indemnification",
|
||||
QuoteLineItem.Kind.RECURRING,
|
||||
withIndemnity - withSla));
|
||||
indemnity));
|
||||
}
|
||||
if (qbr > 0) {
|
||||
lines.add(
|
||||
@@ -98,14 +143,6 @@ public class ProcurementPricingService {
|
||||
QuoteLineItem.Kind.RECURRING,
|
||||
qbr));
|
||||
}
|
||||
if (discount > 0) {
|
||||
lines.add(
|
||||
new QuoteLineItem(
|
||||
"multi-year",
|
||||
cfg.termYears() + "-year commitment",
|
||||
QuoteLineItem.Kind.DISCOUNT,
|
||||
-discount));
|
||||
}
|
||||
if (training > 0) {
|
||||
lines.add(
|
||||
new QuoteLineItem(
|
||||
@@ -117,9 +154,25 @@ public class ProcurementPricingService {
|
||||
return new QuoteBreakdown(lines, annualNet, tcv, cfg.currency());
|
||||
}
|
||||
|
||||
private static String serviceLevelLabel(String serviceLevel) {
|
||||
if ("priority".equalsIgnoreCase(serviceLevel)) return "Priority service level";
|
||||
if ("dedicated".equalsIgnoreCase(serviceLevel)) return "Dedicated service level";
|
||||
return "Standard service level";
|
||||
private static long deployFeeMinor(String deployment, PricingRates rates) {
|
||||
if ("airgap".equalsIgnoreCase(deployment)) return rates.airgapDeployMinor();
|
||||
if ("selfhost".equalsIgnoreCase(deployment)) return rates.selfHostDeployMinor();
|
||||
return 0L; // cloud (managed) has no deployment fee
|
||||
}
|
||||
|
||||
/** Buyer-facing posture name for the intensity (policy count); the demo's POLICY_POSTURES. */
|
||||
private static String postureLabel(int intensity) {
|
||||
return switch (intensity) {
|
||||
case 2 -> "Essentials";
|
||||
case 4 -> "Governed";
|
||||
case 7 -> "Regulated";
|
||||
default -> intensity + "-policy";
|
||||
};
|
||||
}
|
||||
|
||||
private static String deploymentLabel(String deployment) {
|
||||
if ("airgap".equalsIgnoreCase(deployment)) return "Air-gapped";
|
||||
if ("selfhost".equalsIgnoreCase(deployment)) return "Self-hosted";
|
||||
return "Stirling Cloud";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,17 +9,23 @@ package stirling.software.saas.procurement.pricing;
|
||||
public record QuoteConfig(
|
||||
long volume, // committed PDFs per year
|
||||
int users, // seats (drives the volume auto-estimate when the buyer hasn't overridden)
|
||||
String deployment, // cloud | selfhost | airgap (inherited from the trial; not priced)
|
||||
int intensity, // policy posture: runs per PDF — Essentials 2, Governed 4, Regulated 7
|
||||
String deployment, // cloud | selfhost | airgap (priced flat; inherited from the trial)
|
||||
int termYears, // 1..5
|
||||
String serviceLevel, // standard | priority | dedicated
|
||||
String serviceLevel, // standard | priority (both included) | dedicated (flat SE/CSM fee)
|
||||
boolean indemnification,
|
||||
boolean training,
|
||||
boolean qbr,
|
||||
String currency) { // USD | EUR | GBP
|
||||
boolean offlineLicense, // offline .lic availability (gates download; no longer priced here)
|
||||
String currency) { // USD only for now
|
||||
|
||||
/** Default posture when none is chosen — Governed (x4), per the pricing alignment decision. */
|
||||
public static final int DEFAULT_INTENSITY = 4;
|
||||
|
||||
public QuoteConfig {
|
||||
if (termYears < 1) termYears = 1;
|
||||
if (termYears > 5) termYears = 5;
|
||||
if (intensity < 1) intensity = DEFAULT_INTENSITY;
|
||||
if (serviceLevel == null || serviceLevel.isBlank()) serviceLevel = "standard";
|
||||
if (currency == null || currency.isBlank()) currency = "USD";
|
||||
}
|
||||
|
||||
+113
-19
@@ -16,8 +16,11 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.enumeration.TeamRole;
|
||||
import stirling.software.saas.model.TeamMembership;
|
||||
import stirling.software.saas.procurement.config.ProcurementConfigurationProperties;
|
||||
import stirling.software.saas.procurement.license.EnterpriseLicenseService;
|
||||
import stirling.software.saas.procurement.license.LicenseEntitlements;
|
||||
import stirling.software.saas.procurement.model.ProcurementDeal;
|
||||
import stirling.software.saas.procurement.model.ProcurementQuote;
|
||||
import stirling.software.saas.procurement.pricing.ProcurementPricingService;
|
||||
@@ -25,6 +28,7 @@ import stirling.software.saas.procurement.pricing.QuoteBreakdown;
|
||||
import stirling.software.saas.procurement.pricing.QuoteConfig;
|
||||
import stirling.software.saas.procurement.repository.ProcurementDealRepository;
|
||||
import stirling.software.saas.procurement.repository.ProcurementQuoteRepository;
|
||||
import stirling.software.saas.repository.TeamMembershipRepository;
|
||||
|
||||
/**
|
||||
* Orchestrates a linked team's procurement journey: start a (mock-licensed) trial, build a
|
||||
@@ -46,18 +50,37 @@ public class ProcurementService {
|
||||
private final ProcurementPricingService pricing;
|
||||
private final EnterpriseLicenseService licenses;
|
||||
private final ProcurementConfigurationProperties config;
|
||||
private final TeamMembershipRepository memberRepo;
|
||||
|
||||
public ProcurementService(
|
||||
ProcurementDealRepository dealRepo,
|
||||
ProcurementQuoteRepository quoteRepo,
|
||||
ProcurementPricingService pricing,
|
||||
EnterpriseLicenseService licenses,
|
||||
ProcurementConfigurationProperties config) {
|
||||
ProcurementConfigurationProperties config,
|
||||
TeamMembershipRepository memberRepo) {
|
||||
this.dealRepo = dealRepo;
|
||||
this.quoteRepo = quoteRepo;
|
||||
this.pricing = pricing;
|
||||
this.licenses = licenses;
|
||||
this.config = config;
|
||||
this.memberRepo = memberRepo;
|
||||
}
|
||||
|
||||
/**
|
||||
* The team leader's email — the natural owner of the team's Keygen licence. Falls back to the
|
||||
* username when no email is set; null when the team has no leader.
|
||||
*/
|
||||
private String leaderEmail(Long teamId) {
|
||||
return memberRepo.findByTeamIdAndRole(teamId, TeamRole.LEADER).stream()
|
||||
.findFirst()
|
||||
.map(TeamMembership::getUser)
|
||||
.map(
|
||||
u ->
|
||||
u.getEmail() != null && !u.getEmail().isBlank()
|
||||
? u.getEmail()
|
||||
: u.getUsername())
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
@@ -85,7 +108,7 @@ public class ProcurementService {
|
||||
deal.setTrialStartedAt(now);
|
||||
deal.setTrialEndsAt(ends);
|
||||
deal.setTrialExtensionsUsed(0);
|
||||
deal.setLicenseRef(licenses.issueTrialLicense(teamId, ends));
|
||||
deal.setLicenseRef(licenses.issueTrialLicense(teamId, leaderEmail(teamId), ends));
|
||||
deal = dealRepo.save(deal);
|
||||
log.info(
|
||||
"[procurement] trial started team={} deal={} ends={}",
|
||||
@@ -144,12 +167,14 @@ public class ProcurementService {
|
||||
quote.setCurrency(cfg.currency());
|
||||
quote.setVolume(cfg.volume());
|
||||
quote.setSeats(cfg.users() > 0 ? cfg.users() : null);
|
||||
quote.setIntensity(cfg.intensity());
|
||||
quote.setDeployment(cfg.deployment());
|
||||
quote.setTermYears(cfg.termYears());
|
||||
quote.setServiceLevel(cfg.serviceLevel());
|
||||
quote.setIndemnification(cfg.indemnification());
|
||||
quote.setTraining(cfg.training());
|
||||
quote.setQbr(cfg.qbr());
|
||||
quote.setOfflineLicense(cfg.offlineLicense());
|
||||
quote.setBusinessName(businessName);
|
||||
quote.setAnnualNetMinor(breakdown.annualNetMinor());
|
||||
quote.setTcvMinor(breakdown.tcvMinor());
|
||||
@@ -188,35 +213,104 @@ public class ProcurementService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the deal live: issue the annual licence and advance to the active stage. In production
|
||||
* this is driven by the {@code invoice.paid} webhook once the first invoice is settled; this
|
||||
* method is the demo/manual stand-in until that webhook lands.
|
||||
* Provision on accept: upgrade the team's licence to the committed annual term (valid
|
||||
* immediately), so the buyer can get going the moment they accept — before the invoice is paid.
|
||||
* Driven by the accept edge function once the subscription + invoice are created. Idempotent
|
||||
* (upgrades the existing licence in place); deliberately does NOT change the stage — the deal
|
||||
* stays in the payment step so the outstanding invoice remains visible until it settles.
|
||||
*/
|
||||
@Transactional
|
||||
public ProcurementDeal provisionLicense(Long teamId) {
|
||||
ProcurementDeal deal =
|
||||
dealRepo.findByTeamId(teamId)
|
||||
.orElseThrow(() -> new IllegalStateException("No deal for team " + teamId));
|
||||
deal.setLicenseRef(issueOrUpgradeAnnual(deal));
|
||||
deal = dealRepo.save(deal);
|
||||
log.info("[procurement] licence provisioned team={} deal={}", teamId, deal.getDealId());
|
||||
return deal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the deal fully live (advance to the active stage) once payment settles. In production
|
||||
* this is the {@code invoice.paid} webhook; here it's the demo/manual stand-in. Re-affirms the
|
||||
* annual licence in case provisioning didn't run at accept.
|
||||
*/
|
||||
@Transactional
|
||||
public ProcurementDeal markLive(Long teamId) {
|
||||
ProcurementDeal deal =
|
||||
dealRepo.findByTeamId(teamId)
|
||||
.orElseThrow(() -> new IllegalStateException("No deal for team " + teamId));
|
||||
int term = 1;
|
||||
String deployment = "cloud";
|
||||
if (deal.getAcceptedQuoteId() != null) {
|
||||
ProcurementQuote q = quoteRepo.findById(deal.getAcceptedQuoteId()).orElse(null);
|
||||
if (q != null) {
|
||||
term = Math.max(1, q.getTermYears());
|
||||
if (q.getDeployment() != null && !q.getDeployment().isBlank()) {
|
||||
deployment = q.getDeployment();
|
||||
}
|
||||
}
|
||||
}
|
||||
deal.setLicenseRef(
|
||||
licenses.issueAnnualLicense(
|
||||
teamId, deployment, LocalDateTime.now().plusYears(term)));
|
||||
deal.setLicenseRef(issueOrUpgradeAnnual(deal));
|
||||
deal.setStage(ProcurementDeal.STAGE_LIVE);
|
||||
deal = dealRepo.save(deal);
|
||||
log.info("[procurement] deal live team={} deal={}", teamId, deal.getDealId());
|
||||
return deal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue or upgrade the committed annual licence from the deal's accepted (else latest) quote,
|
||||
* stamping the full entitlement snapshot onto it and upgrading the trial licence in place when
|
||||
* one exists.
|
||||
*/
|
||||
private String issueOrUpgradeAnnual(ProcurementDeal deal) {
|
||||
ProcurementQuote q =
|
||||
deal.getAcceptedQuoteId() != null
|
||||
? quoteRepo.findById(deal.getAcceptedQuoteId()).orElse(null)
|
||||
: quoteRepo.findByDealIdOrderByCreatedAtDesc(deal.getDealId()).stream()
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
int term = q != null ? Math.max(1, q.getTermYears()) : 1;
|
||||
String deployment =
|
||||
q != null && q.getDeployment() != null && !q.getDeployment().isBlank()
|
||||
? q.getDeployment()
|
||||
: "cloud";
|
||||
int seats = q != null && q.getSeats() != null ? q.getSeats() : 0; // 0 = unlimited
|
||||
LicenseEntitlements entitlements =
|
||||
new LicenseEntitlements(
|
||||
q != null ? q.getVolume() : 0,
|
||||
seats,
|
||||
deployment,
|
||||
term,
|
||||
q != null ? q.getServiceLevel() : null,
|
||||
q != null && q.isIndemnification(),
|
||||
q != null && q.isTraining(),
|
||||
q != null && q.isQbr(),
|
||||
q != null && q.isOfflineLicense(),
|
||||
deal.getDealId(),
|
||||
deal.getSubscriptionId());
|
||||
return licenses.issueAnnualLicense(
|
||||
deal.getTeamId(),
|
||||
leaderEmail(deal.getTeamId()),
|
||||
LocalDateTime.now().plusYears(term),
|
||||
deal.getLicenseRef(),
|
||||
entitlements);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check out the offline/air-gapped licence file for a team, when the offline add-on was
|
||||
* purchased. Requires an issued licence on the deal and the accepted quote to carry the offline
|
||||
* add-on; returns empty otherwise (so the controller can 404 rather than leak that a licence
|
||||
* exists). The certificate is generated on demand by Keygen and never stored.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<String> offlineLicenseFile(Long teamId) {
|
||||
ProcurementDeal deal = dealRepo.findByTeamId(teamId).orElse(null);
|
||||
if (deal == null || deal.getLicenseRef() == null) return Optional.empty();
|
||||
if (!hasOfflineAddOn(deal)) return Optional.empty();
|
||||
return Optional.of(licenses.checkOutLicenseFile(deal.getLicenseRef()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the deal's <b>accepted</b> quote carries the paid offline-licence add-on. Gated on
|
||||
* the accepted quote (not the latest) so merely toggling the add-on on an unaccepted draft
|
||||
* can't unlock the offline file — it's only available once the add-on has actually been bought.
|
||||
*/
|
||||
private boolean hasOfflineAddOn(ProcurementDeal deal) {
|
||||
if (deal.getAcceptedQuoteId() == null) return false;
|
||||
ProcurementQuote quote = quoteRepo.findById(deal.getAcceptedQuoteId()).orElse(null);
|
||||
return quote != null && quote.isOfflineLicense();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset a team's procurement: delete the deal (quotes + activity cascade). For
|
||||
* re-demos/testing.
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package stirling.software.saas.security;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.proprietary.audit.PortalAuditScope;
|
||||
import stirling.software.proprietary.audit.PortalAuditScopeResolver;
|
||||
import stirling.software.saas.repository.TeamMembershipRepository;
|
||||
|
||||
/** SaaS audit visibility: admins see the server; team LEADERs see their team (by member email). */
|
||||
@Component
|
||||
@Primary
|
||||
@Profile("saas")
|
||||
@RequiredArgsConstructor
|
||||
public class SaasPortalAuditScopeResolver implements PortalAuditScopeResolver {
|
||||
|
||||
private final TeamSecurityExpressions teamSecurity;
|
||||
private final TeamMembershipRepository membershipRepository;
|
||||
|
||||
@Override
|
||||
public PortalAuditScope resolve() {
|
||||
if (PortalAuditScopeResolver.hasAdminAuthority()) {
|
||||
return PortalAuditScope.server();
|
||||
}
|
||||
if (!teamSecurity.isCurrentUserTeamLeader()) {
|
||||
return PortalAuditScope.denied();
|
||||
}
|
||||
Long teamId = teamSecurity.currentUserTeamId();
|
||||
if (teamId == null) {
|
||||
return PortalAuditScope.denied();
|
||||
}
|
||||
List<String> memberEmails =
|
||||
membershipRepository.findByTeamId(teamId).stream()
|
||||
.map(m -> m.getUser() == null ? null : m.getUser().getEmail())
|
||||
.filter(Objects::nonNull)
|
||||
.toList();
|
||||
return PortalAuditScope.team("team:" + teamId, memberEmails);
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,15 @@ app.supabase.clock-skew-seconds=${app.jwt.clock-skew-seconds:120}
|
||||
app.supabase.edge-function-url=https://${app.supabase.project-ref}.supabase.co/functions/v1
|
||||
app.supabase.edge-function-secret=${SUPABASE_EDGE_FUNCTION_SECRET:}
|
||||
|
||||
# ---------- Enterprise procurement licences (Keygen) ----------
|
||||
# Off by default → MockEnterpriseLicenseService (no real Keygen calls). Set enabled=true + the
|
||||
# three creds to switch to the real KeygenEnterpriseLicenseService. Same account/policy as the
|
||||
# self-hosted products; env names match the edge functions' KEYGEN_* for consistency.
|
||||
stirling.keygen.enabled=${STIRLING_KEYGEN_ENABLED:false}
|
||||
stirling.keygen.account-id=${KEYGEN_ACCOUNT_ID:}
|
||||
stirling.keygen.api-token=${KEYGEN_API_TOKEN:}
|
||||
stirling.keygen.policy-id=${KEYGEN_POLICY_ID:}
|
||||
|
||||
# ---------- PAYG meter reporting ----------
|
||||
# Posts billable usage to the Supabase `meter-payg-units` edge function in the JobChargeService
|
||||
# close() afterCommit hook. Defaults to empty so unit tests / local dev are no-ops; set
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
-- 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;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- 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;
|
||||
+104
-38
@@ -7,72 +7,138 @@ import org.junit.jupiter.api.Test;
|
||||
import stirling.software.saas.procurement.pricing.QuoteLineItem.Kind;
|
||||
|
||||
/**
|
||||
* Locks the pricing engine to the numbers the marketing prototype encodes — most importantly the
|
||||
* canonical quote QT-AC9F-0001 (1M PDFs, priority, 3-year) = $41,400/yr, $124,200 TCV.
|
||||
* Locks the D71 pricing engine to the numbers marketing publishes. The two anchor fixtures are the
|
||||
* ones the demo memo foots against: acme (90M PDFs · Governed · self-hosted · dedicated · 3-yr =
|
||||
* $1,752,000/yr, $5,256,000 TCV) and Northwind (6M · Governed · cloud · standard · 3-yr =
|
||||
* $165,278/yr). If either moves, the engine has drifted from marketing.
|
||||
*/
|
||||
class ProcurementPricingServiceTest {
|
||||
|
||||
private final ProcurementPricingService pricing = new ProcurementPricingService();
|
||||
|
||||
private static QuoteConfig cfg(long volume, String sla, int term) {
|
||||
return new QuoteConfig(volume, 0, "cloud", term, sla, false, false, false, "USD");
|
||||
private static QuoteConfig cfg(
|
||||
long volume, int intensity, String deployment, int term, String sla) {
|
||||
return new QuoteConfig(
|
||||
volume, 0, intensity, deployment, term, sla, false, false, false, false, "USD");
|
||||
}
|
||||
|
||||
@Test
|
||||
void canonicalQuoteMatchesPrototype() {
|
||||
QuoteBreakdown q = pricing.price(cfg(1_000_000, "priority", 3));
|
||||
void acmeFixtureFootsExactly() {
|
||||
// 90M PDFs × Governed(×4) = 360M runs → curve floors at $0.005/run → $0.0200/PDF effective.
|
||||
// meter $1.8M − 5% (3-yr) = $1,710,000 + self-hosted $12K + dedicated SE/CSM $30K.
|
||||
QuoteBreakdown q = pricing.price(cfg(90_000_000, 4, "selfhost", 3, "dedicated"));
|
||||
|
||||
assertThat(q.annualNetMinor()).isEqualTo(4_140_000L); // $41,400
|
||||
assertThat(q.tcvMinor()).isEqualTo(12_420_000L); // $124,200
|
||||
assertThat(lineAmount(q, "usage")).isEqualTo(4_000_000L); // $40,000 @ $0.04
|
||||
assertThat(lineAmount(q, "service-level")).isEqualTo(600_000L); // +15%
|
||||
assertThat(lineAmount(q, "multi-year")).isEqualTo(-460_000L); // -10%
|
||||
assertThat(q.annualNetMinor()).isEqualTo(175_200_000L); // $1,752,000
|
||||
assertThat(q.tcvMinor()).isEqualTo(525_600_000L); // $5,256,000
|
||||
assertThat(lineAmount(q, "support")).isEqualTo(3_000_000L); // dedicated SE/CSM $30K
|
||||
assertThat(lineAmount(q, "deployment")).isEqualTo(1_200_000L); // self-hosted $12K
|
||||
}
|
||||
|
||||
@Test
|
||||
void volumeBandsPickTheRightPerPdfRate() {
|
||||
assertThat(lineAmount(pricing.price(cfg(500_000, "standard", 1)), "usage"))
|
||||
.isEqualTo(2_500_000L); // 500k @ $0.05
|
||||
assertThat(lineAmount(pricing.price(cfg(1_000_000, "standard", 1)), "usage"))
|
||||
.isEqualTo(4_000_000L); // 1M @ $0.04
|
||||
assertThat(lineAmount(pricing.price(cfg(5_000_000, "standard", 1)), "usage"))
|
||||
.isEqualTo(15_000_000L); // 5M @ $0.03
|
||||
void northwindFixtureFootsExactly() {
|
||||
// 6M × Governed(×4) = 24M runs → $0.0290/PDF effective → $165,278/yr at 3-yr.
|
||||
QuoteBreakdown q = pricing.price(cfg(6_000_000, 4, "cloud", 3, "standard"));
|
||||
|
||||
assertThat(q.annualNetMinor()).isEqualTo(16_527_800L); // $165,278
|
||||
assertThat(q.tcvMinor()).isEqualTo(49_583_400L); // × 3 years
|
||||
// Cloud + standard: no deployment or support line.
|
||||
assertThat(q.lineItems()).noneMatch(l -> l.key().equals("deployment"));
|
||||
assertThat(q.lineItems()).noneMatch(l -> l.key().equals("support"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void addOnsAndTermStack() {
|
||||
void rateFloorsAtHalfACent() {
|
||||
// 100M × Regulated(×7) = 700M runs — deep past the knee, so the per-run rate is pinned to
|
||||
// the $0.005 floor: base meter = 700M × $0.005 = $3,500,000 (1-yr, no term discount).
|
||||
QuoteBreakdown q = pricing.price(cfg(100_000_000, 7, "cloud", 1, "standard"));
|
||||
assertThat(lineAmount(q, "usage")).isEqualTo(350_000_000L); // $3,500,000
|
||||
assertThat(q.annualNetMinor()).isEqualTo(350_000_000L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void postureDrivesThePrice() {
|
||||
// Same PDFs, three postures — the meter scales with runs, so Regulated > Governed >
|
||||
// Essentials. (This is what the retired flat-per-PDF model could not express.)
|
||||
long essentials = pricing.price(cfg(6_000_000, 2, "cloud", 3, "standard")).annualNetMinor();
|
||||
long governed = pricing.price(cfg(6_000_000, 4, "cloud", 3, "standard")).annualNetMinor();
|
||||
long regulated = pricing.price(cfg(6_000_000, 7, "cloud", 3, "standard")).annualNetMinor();
|
||||
|
||||
assertThat(essentials).isLessThan(governed);
|
||||
assertThat(governed).isLessThan(regulated);
|
||||
assertThat(governed).isEqualTo(16_527_800L); // Governed is the Northwind anchor
|
||||
}
|
||||
|
||||
@Test
|
||||
void deploymentIsAFlatFeeNotAMultiplier() {
|
||||
long cloud = pricing.price(cfg(6_000_000, 4, "cloud", 3, "standard")).annualNetMinor();
|
||||
long selfhost =
|
||||
pricing.price(cfg(6_000_000, 4, "selfhost", 3, "standard")).annualNetMinor();
|
||||
long airgap = pricing.price(cfg(6_000_000, 4, "airgap", 3, "standard")).annualNetMinor();
|
||||
|
||||
assertThat(selfhost - cloud).isEqualTo(1_200_000L); // +$12,000 flat
|
||||
assertThat(airgap - cloud).isEqualTo(3_600_000L); // +$36,000 flat
|
||||
}
|
||||
|
||||
@Test
|
||||
void standardAndPriorityIncludedDedicatedIsFlat() {
|
||||
long standard = pricing.price(cfg(6_000_000, 4, "cloud", 3, "standard")).annualNetMinor();
|
||||
long priority = pricing.price(cfg(6_000_000, 4, "cloud", 3, "priority")).annualNetMinor();
|
||||
long dedicated = pricing.price(cfg(6_000_000, 4, "cloud", 3, "dedicated")).annualNetMinor();
|
||||
|
||||
assertThat(priority).isEqualTo(standard); // both included, no uplift
|
||||
assertThat(dedicated - standard).isEqualTo(3_000_000L); // dedicated SE/CSM +$30,000 flat
|
||||
}
|
||||
|
||||
@Test
|
||||
void termDiscountsTheMeterOnly() {
|
||||
long oneYear = pricing.price(cfg(6_000_000, 4, "cloud", 1, "standard")).annualNetMinor();
|
||||
long twoYear = pricing.price(cfg(6_000_000, 4, "cloud", 2, "standard")).annualNetMinor();
|
||||
// 2-yr is 3% off the meter (discounted on the raw meter, then rounded to whole dollars).
|
||||
assertThat(oneYear).isEqualTo(17_397_700L); // no discount
|
||||
assertThat(twoYear).isEqualTo(16_875_700L); // −3% on the meter
|
||||
assertThat(twoYear).isLessThan(oneYear);
|
||||
}
|
||||
|
||||
@Test
|
||||
void indemnificationIsFivePercentOfTheMeter() {
|
||||
long base = pricing.price(cfg(6_000_000, 4, "cloud", 3, "standard")).annualNetMinor();
|
||||
QuoteConfig c =
|
||||
new QuoteConfig(1_000_000, 0, "cloud", 5, "dedicated", true, true, true, "USD");
|
||||
new QuoteConfig(
|
||||
6_000_000, 0, 4, "cloud", 3, "standard", true, false, false, false, "USD");
|
||||
QuoteBreakdown q = pricing.price(c);
|
||||
assertThat(lineAmount(q, "indemnification")).isEqualTo(Math.round(base * 0.05));
|
||||
}
|
||||
|
||||
long usage = 4_000_000L;
|
||||
long withSla = Math.round(usage * 1.30); // 5,200,000
|
||||
long withIndemnity = Math.round(withSla * 1.05); // 5,460,000
|
||||
long discount = Math.round(withIndemnity * 0.15); // 819,000
|
||||
long qbr = 800_000L;
|
||||
long expectedAnnual = (withIndemnity - discount) + qbr;
|
||||
long expectedTcv = expectedAnnual * 5 + 750_000L; // + training one-time
|
||||
@Test
|
||||
void trainingIsOneTimeOutsideTheAnnual() {
|
||||
QuoteConfig withTraining =
|
||||
new QuoteConfig(
|
||||
6_000_000, 0, 4, "cloud", 3, "standard", false, true, false, false, "USD");
|
||||
QuoteBreakdown q = pricing.price(withTraining);
|
||||
long baseAnnual = pricing.price(cfg(6_000_000, 4, "cloud", 3, "standard")).annualNetMinor();
|
||||
|
||||
assertThat(q.annualNetMinor()).isEqualTo(expectedAnnual);
|
||||
assertThat(q.tcvMinor()).isEqualTo(expectedTcv);
|
||||
assertThat(q.annualNetMinor()).isEqualTo(baseAnnual); // one-time never touches the annual
|
||||
assertThat(q.tcvMinor()).isEqualTo(baseAnnual * 3 + 750_000L); // + $7,500 once
|
||||
assertThat(q.lineItems())
|
||||
.anyMatch(l -> l.key().equals("training") && l.kind() == Kind.ONE_TIME);
|
||||
assertThat(q.lineItems()).anyMatch(l -> l.key().equals("qbr"));
|
||||
assertThat(q.lineItems()).anyMatch(l -> l.key().equals("indemnification"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void standardSingleYearHasNoUpliftOrDiscountLines() {
|
||||
QuoteBreakdown q = pricing.price(cfg(1_000_000, "standard", 1));
|
||||
assertThat(q.annualNetMinor()).isEqualTo(4_000_000L);
|
||||
assertThat(q.tcvMinor()).isEqualTo(4_000_000L);
|
||||
assertThat(q.lineItems()).noneMatch(l -> l.key().equals("service-level"));
|
||||
assertThat(q.lineItems()).noneMatch(l -> l.key().equals("multi-year"));
|
||||
void unsetPostureDefaultsToGoverned() {
|
||||
long defaulted = pricing.price(cfg(6_000_000, 0, "cloud", 3, "standard")).annualNetMinor();
|
||||
long governed = pricing.price(cfg(6_000_000, 4, "cloud", 3, "standard")).annualNetMinor();
|
||||
assertThat(defaulted).isEqualTo(governed);
|
||||
}
|
||||
|
||||
@Test
|
||||
void ssoIsAlwaysAnIncludedZeroLine() {
|
||||
QuoteBreakdown q = pricing.price(cfg(6_000_000, 4, "cloud", 3, "standard"));
|
||||
assertThat(q.lineItems())
|
||||
.anyMatch(l -> l.key().equals("seats") && l.kind() == Kind.INCLUDED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void volumeEstimateFromSeats() {
|
||||
// ~2,013 PDFs/user/yr
|
||||
assertThat(pricing.estimateAnnualVolume(100)).isEqualTo(201_250L);
|
||||
assertThat(pricing.estimateAnnualVolume(0)).isZero();
|
||||
}
|
||||
|
||||
@@ -42,6 +42,9 @@ COPY . .
|
||||
ARG PROTOTYPES_BUILD=false
|
||||
ARG STIRLING_FLAVOR=proprietary
|
||||
ENV STIRLING_FLAVOR=${STIRLING_FLAVOR}
|
||||
# Embed the admin portal app at /portal. Set true by the deploy workflow when the
|
||||
# portal or AI layers change; defaults false so normal builds skip the extra app.
|
||||
ARG BUILD_PORTAL=false
|
||||
|
||||
# Bundle only the JPDFium native for this image's target arch.
|
||||
ARG TARGETARCH
|
||||
@@ -49,6 +52,7 @@ RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo li
|
||||
STIRLING_FLAVOR=${STIRLING_FLAVOR} \
|
||||
gradle clean build \
|
||||
-PbuildWithFrontend=true \
|
||||
-PbuildWithPortal=${BUILD_PORTAL} \
|
||||
-PjpdfiumPlatforms="$JPDFIUM_PLATFORM" \
|
||||
-PprototypesMode=${PROTOTYPES_BUILD} \
|
||||
-x spotlessApply -x spotlessCheck -x test -x sonarqube \
|
||||
|
||||
@@ -40,12 +40,15 @@ RUN gradle dependencies --no-daemon || true
|
||||
|
||||
COPY . .
|
||||
|
||||
# Embed the admin portal app at /portal when the deploy workflow flags it.
|
||||
ARG BUILD_PORTAL=false
|
||||
# Bundle only the JPDFium native for this image's target arch.
|
||||
ARG TARGETARCH
|
||||
RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo linux-x64)" && \
|
||||
DISABLE_ADDITIONAL_FEATURES=false \
|
||||
gradle clean build \
|
||||
-PbuildWithFrontend=true \
|
||||
-PbuildWithPortal=${BUILD_PORTAL} \
|
||||
-PjpdfiumPlatforms="$JPDFIUM_PLATFORM" \
|
||||
-x spotlessApply -x spotlessCheck -x test -x sonarqube \
|
||||
--no-daemon
|
||||
|
||||
@@ -40,12 +40,15 @@ RUN ./gradlew dependencies --no-daemon || true
|
||||
COPY . .
|
||||
|
||||
# Build ultra-lite JAR with embedded frontend (minimal features).
|
||||
# Embed the admin portal app at /portal when the deploy workflow flags it.
|
||||
ARG BUILD_PORTAL=false
|
||||
# Bundle only the JPDFium native for this image's target arch.
|
||||
ARG TARGETARCH
|
||||
RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo linux-x64)" && \
|
||||
DISABLE_ADDITIONAL_FEATURES=true \
|
||||
./gradlew clean build \
|
||||
-PbuildWithFrontend=true \
|
||||
-PbuildWithPortal=${BUILD_PORTAL} \
|
||||
-PjpdfiumPlatforms="$JPDFIUM_PLATFORM" \
|
||||
-x spotlessApply -x spotlessCheck -x test -x sonarqube \
|
||||
--no-daemon
|
||||
|
||||
@@ -61,6 +61,11 @@ const config: StorybookConfig = {
|
||||
config.define = {
|
||||
...(config.define ?? {}),
|
||||
"import.meta.env.VITE_SAAS_API_URL": JSON.stringify("http://saas.mock"),
|
||||
// Keep the Supabase auth env empty so ensureSaasSupabase() is a no-op and
|
||||
// never replaces the mock SaaS client stubbed in preview.tsx.
|
||||
"import.meta.env.VITE_SUPABASE_URL": JSON.stringify(""),
|
||||
"import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY":
|
||||
JSON.stringify(""),
|
||||
};
|
||||
return config;
|
||||
},
|
||||
|
||||
@@ -29,10 +29,10 @@ initialize({ onUnhandledRequest: "bypass" }, handlers);
|
||||
|
||||
// Storybook-only: stub a SaaS session so apiClient.saas reads (invoices, payment
|
||||
// method, wallet) clear the session check and reach the MSW handlers instead of
|
||||
// failing with "No SaaS session". VITE_SAAS_SUPABASE_URL/KEY are intentionally
|
||||
// unset, so ensureSaasSupabase() is a no-op and never replaces this client; only
|
||||
// VITE_SAAS_API_URL (a mock origin MSW matches) is configured — injected via
|
||||
// .storybook/main.ts's viteFinal define, not a frontend/.env file.
|
||||
// failing with "No SaaS session". VITE_SUPABASE_URL/KEY are defined empty (see
|
||||
// .storybook/main.ts), so ensureSaasSupabase() is a no-op and never replaces this
|
||||
// client; only VITE_SAAS_API_URL (a mock origin MSW matches) is configured —
|
||||
// injected via .storybook/main.ts's viteFinal define, not a frontend/.env file.
|
||||
const saasStub = configureSupabase({
|
||||
url: "http://saas.mock",
|
||||
key: "storybook-anon-key",
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
# API base URL — use / for same-origin (default for web builds)
|
||||
VITE_API_BASE_URL=/
|
||||
|
||||
# Include the admin portal's lazy route/chunk in the build (set true by
|
||||
# -PbuildWithPortal in the JAR). Off by default; always on in dev.
|
||||
VITE_INCLUDE_PORTAL=false
|
||||
|
||||
# Google Drive integration
|
||||
VITE_GOOGLE_DRIVE_CLIENT_ID=
|
||||
VITE_GOOGLE_DRIVE_API_KEY=
|
||||
@@ -22,3 +26,6 @@ VITE_STRIPE_PUBLISHABLE_KEY=pk_live_51Q56W2P9mY5IAnSnp3kcxG50uyFMLuhM4fFs774DAP3
|
||||
# PostHog analytics
|
||||
VITE_PUBLIC_POSTHOG_KEY=phc_VOdeYnlevc2T63m3myFGjeBlRcIusRgmhfx6XL5a1iz
|
||||
VITE_PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com
|
||||
|
||||
# Calendly scheduling link (portal "Schedule a call")
|
||||
VITE_CALENDLY_URL=https://calendly.com/d/cm4p-zz5-yy8/stirling-pdf-15-minute-group-discussion
|
||||
|
||||
@@ -18,13 +18,6 @@ VITE_EDITOR_URL=/
|
||||
# backend.
|
||||
VITE_PORTAL_MOCKS=
|
||||
|
||||
# Hosted SaaS Supabase project for the self-hosted portal's IN-APP account
|
||||
# linking (both values are public). Set per deploy; absent -> the account-link
|
||||
# UI shows a "configure" state. For local e2e, point these at the SaaS Supabase
|
||||
# project the local backend links against.
|
||||
VITE_SAAS_SUPABASE_URL=
|
||||
VITE_SAAS_SUPABASE_ANON_KEY=
|
||||
|
||||
# Hosted SaaS Java backend base URL (e.g. https://api.stirlingpdf.com). Used for
|
||||
# ATTENDED portal -> SaaS reads (wallet, billing, plans, checkout) with the
|
||||
# admin's Supabase JWT. Distinct from the local backend (reached same-origin via
|
||||
|
||||
@@ -6062,6 +6062,17 @@ stepOf = "Step {{step}} of {{total}}"
|
||||
toolChainDesc = "Configure the tools this policy runs on each document."
|
||||
typesSelected = "{{count}} types selected"
|
||||
|
||||
[policy]
|
||||
badgeEnforcing = "{{name}} enforcing..."
|
||||
badgeRan = "{{name}} policy ran on this file"
|
||||
blockingAction = "{{action}} blocked while enforcing policy, please wait..."
|
||||
dismiss = "Dismiss overlay"
|
||||
enforcingTitle = "Enforcing policy..."
|
||||
viewAnyway = "View file (policy still enforcing)"
|
||||
|
||||
[portal]
|
||||
comingSoon = "Coming soon"
|
||||
|
||||
[portal.accountLink.card]
|
||||
billingNote = "Unattended processing bills against your org wallet."
|
||||
eyebrow = "Account link"
|
||||
@@ -6273,7 +6284,6 @@ cost = "Cost"
|
||||
editorsDeployed = "Editors deployed"
|
||||
inviteTeammates = "Invite teammates"
|
||||
pdfsEdited = "PDFs edited"
|
||||
previewBadge = "Preview · sample data"
|
||||
subtitle = "Deploy anywhere, for your whole team."
|
||||
title = "Free PDF Editors"
|
||||
|
||||
@@ -6569,7 +6579,9 @@ beforeHelper = "header against your signing secret before trusting a payload. SD
|
||||
beforeSignature = "Verify the"
|
||||
|
||||
[portal.documents]
|
||||
subtitle = "Review and approve documents moving through your pipelines."
|
||||
exportCsv = "Export CSV"
|
||||
search = "Search by filename, ID..."
|
||||
subtitle = "Every document your org has processed, with its full processing record. Content access is request-gated."
|
||||
title = "Documents"
|
||||
|
||||
[portal.documents.audit]
|
||||
@@ -6607,42 +6619,38 @@ value = "Value"
|
||||
|
||||
[portal.documents.filters]
|
||||
all = "All"
|
||||
archived = "Archived"
|
||||
ariaLabel = "Filter documents by status"
|
||||
needsReview = "Needs review"
|
||||
flagged = "Flagged"
|
||||
inReview = "In review"
|
||||
processed = "Processed"
|
||||
|
||||
[portal.documents.overview]
|
||||
confidence = "Confidence"
|
||||
fieldsExtracted = "Fields extracted"
|
||||
received = "Received"
|
||||
source = "Source"
|
||||
action = "Pipeline / Action"
|
||||
product = "Product"
|
||||
received = "Time"
|
||||
status = "Status"
|
||||
type = "Type"
|
||||
user = "User"
|
||||
|
||||
[portal.documents.queue.empty]
|
||||
description = "As sources feed documents into your pipelines they'll appear here for review."
|
||||
title = "No documents in the queue"
|
||||
|
||||
[portal.documents.summary]
|
||||
avgConfidence = "Avg confidence"
|
||||
inQueue = "In queue"
|
||||
needsReview = "Needs review"
|
||||
processedToday = "Processed today"
|
||||
|
||||
[portal.documents.table]
|
||||
auto = "Auto"
|
||||
editorAction = "Editor"
|
||||
empty = "No documents match this filter."
|
||||
rowActions = "Row actions"
|
||||
sensitiveLabel = "Sensitive"
|
||||
sensitiveTitle = "Sensitive — access required"
|
||||
|
||||
[portal.documents.table.columns]
|
||||
confidence = "Confidence"
|
||||
fields = "Fields"
|
||||
name = "Name"
|
||||
source = "Source"
|
||||
action = "Pipeline / Action"
|
||||
document = "Document"
|
||||
product = "Product"
|
||||
status = "Status"
|
||||
time = "Time"
|
||||
type = "Type"
|
||||
user = "User"
|
||||
|
||||
[portal.editorAdmin]
|
||||
subtitle = "Deploy the Stirling PDF Editor, pair self-hosted instances to your org, and operate the running fleet — targets, health, credentials, and offline activation in one place."
|
||||
@@ -6739,6 +6747,23 @@ ready = "Ready to deploy"
|
||||
description = "Once documents are processed, your 30-day usage appears here."
|
||||
title = "No usage yet"
|
||||
|
||||
[portal.home.editor]
|
||||
activeUsers = "{{n}} active"
|
||||
invite = "Invite teammates"
|
||||
name = "Stirling PDF Editor"
|
||||
open = "Open in browser"
|
||||
updated = "updated {{time}}"
|
||||
|
||||
[portal.home.editor.target]
|
||||
cloud = "Managed Cloud"
|
||||
docker = "Self-hosted · Docker"
|
||||
kubernetes = "Self-hosted · Kubernetes"
|
||||
|
||||
[portal.home.greeting]
|
||||
afternoon = "Good afternoon"
|
||||
evening = "Good evening"
|
||||
morning = "Good morning"
|
||||
|
||||
[portal.home.kpis.enterprise]
|
||||
docs30d = "Docs / 30d"
|
||||
evalPassRate = "Eval pass rate"
|
||||
@@ -6759,15 +6784,31 @@ evalPassRate = "Eval pass rate"
|
||||
pipelines = "Pipelines"
|
||||
|
||||
[portal.home.onboarding]
|
||||
progress = "{{done}} / {{total}} done"
|
||||
runAgain = "Run again"
|
||||
start = "Start"
|
||||
subtitle = "Four steps to a production-shaped Stirling project."
|
||||
title = "Get to value"
|
||||
dismiss = "Dismiss setup"
|
||||
notStarted = "Not started"
|
||||
progress = "{{done}} of {{total}} done"
|
||||
title = "Finish setting up"
|
||||
|
||||
[portal.home.onboarding.empty]
|
||||
description = "Onboarding tasks will appear here once your workspace is set up."
|
||||
title = "No onboarding steps yet"
|
||||
[portal.home.onboarding.enterprise]
|
||||
body = "Org-wide SSO + SCIM + RBAC, committed volume pricing, and air-gapped deployment."
|
||||
cta = "Start Trial"
|
||||
ctaQuote = "Get Quote"
|
||||
lead = "For 250+ employees."
|
||||
tag = "Enterprise"
|
||||
|
||||
[portal.home.onboarding.steps.editor]
|
||||
blurb = "Deploy the desktop app, free forever."
|
||||
title = "Download the PDF Editor"
|
||||
|
||||
[portal.home.onboarding.steps.policies]
|
||||
blurb = "{{active}} active · {{recommended}} recommended"
|
||||
chip = "{{active}} active"
|
||||
title = "Turn on policies"
|
||||
|
||||
[portal.home.onboarding.steps.sources]
|
||||
blurb = "{{connected}} connected · every PDF governed where it lands"
|
||||
chip = "{{connected}} connected"
|
||||
title = "Connect your sources"
|
||||
|
||||
[portal.home.productGrid]
|
||||
ariaLabel = "Process PDFs at scale"
|
||||
@@ -6854,13 +6895,32 @@ actor = "Actor"
|
||||
event = "Event"
|
||||
latency = "Latency"
|
||||
status = "Status"
|
||||
target = "Target"
|
||||
target = "Resource"
|
||||
timestamp = "Timestamp"
|
||||
|
||||
[portal.infrastructure.audit.empty]
|
||||
description = "Workspace activity will appear here as it happens."
|
||||
title = "No audit events"
|
||||
|
||||
[portal.infrastructure.audit.export]
|
||||
cancel = "Cancel"
|
||||
error = "Export failed. Please try again."
|
||||
exportButton = "Export"
|
||||
exporting = "Exporting…"
|
||||
fieldsLegend = "Columns"
|
||||
format = "Format"
|
||||
open = "Export"
|
||||
subtitle = "Download audit events as CSV or JSON. Choose which columns to include."
|
||||
title = "Export audit log"
|
||||
|
||||
[portal.infrastructure.audit.export.fields]
|
||||
date = "Date"
|
||||
documentName = "Document name"
|
||||
ipAddress = "IP address"
|
||||
outcome = "Outcome"
|
||||
tool = "Tool"
|
||||
username = "Username"
|
||||
|
||||
[portal.infrastructure.audit.filters]
|
||||
all = "All"
|
||||
auth = "Auth"
|
||||
@@ -6869,6 +6929,10 @@ elevation = "Elevation"
|
||||
processing = "Processing"
|
||||
security = "Security"
|
||||
|
||||
[portal.infrastructure.audit.forbidden]
|
||||
description = "The audit log is available to team leads and admins. Ask your team lead or an admin for access."
|
||||
title = "You don't have access"
|
||||
|
||||
[portal.infrastructure.audit.metrics]
|
||||
config = "Config"
|
||||
elevation = "Elevation"
|
||||
@@ -7197,18 +7261,39 @@ title = "Pipelines"
|
||||
[portal.pipelines.actions]
|
||||
newPipeline = "New pipeline"
|
||||
|
||||
[portal.pipelines.builder]
|
||||
addStep = "Add tool"
|
||||
back = "Back to pipelines"
|
||||
discard = "Discard changes"
|
||||
enabled = "Enabled"
|
||||
keepEditing = "Keep editing"
|
||||
needsUpload = "Needs an uploaded file"
|
||||
noToolMatches = "No tools match your search."
|
||||
pipelineSettings = "Pipeline settings"
|
||||
searchTools = "Search tools"
|
||||
selectToolBody = "Add a tool to build your pipeline."
|
||||
selectToolTitle = "No tools yet"
|
||||
toolSettings = "Tool settings"
|
||||
unknownStep = "Unrecognized operation, kept as-is."
|
||||
unsavedBody = "You have unsaved changes. Save them before leaving, or discard them?"
|
||||
unsavedTitle = "Unsaved changes"
|
||||
uploadUnsupported = "Uploaded files aren't supported in pipelines yet, so these steps can't be saved: {{tools}}."
|
||||
usesDefaults = "Runs with default settings"
|
||||
|
||||
[portal.pipelines.composer]
|
||||
addTool = "Add tool"
|
||||
cancel = "Cancel"
|
||||
chainEmpty = "Add operations from the palette below."
|
||||
chainEmpty = "Add a tool to start building your pipeline."
|
||||
create = "Create pipeline"
|
||||
directory = "Output folder"
|
||||
directoryHelp = "Absolute path on the server. Must be within the configured allowed folders."
|
||||
editTitle = "Edit pipeline"
|
||||
editingUnsupported = "Displaying these tool params for editing is not supported yet."
|
||||
moveDown = "Move down"
|
||||
moveUp = "Move up"
|
||||
name = "Name"
|
||||
namePlaceholder = "e.g. Redaction sweep"
|
||||
noSources = "No sources connected yet. The pipeline can still run on files supplied to it directly."
|
||||
noToolSettings = "This tool has no configurable settings."
|
||||
operations_one = "Operation ({{count}})"
|
||||
operations_other = "Operations ({{count}})"
|
||||
output = "Output"
|
||||
@@ -7217,8 +7302,6 @@ save = "Save changes"
|
||||
scheduleEvery = "Run every"
|
||||
sources = "Sources"
|
||||
sourcesLoading = "Loading sources..."
|
||||
subtitle = "Pick the sources it runs over, chain the operations, then choose when it runs and where output goes."
|
||||
title = "New pipeline"
|
||||
trigger = "Trigger"
|
||||
triggerManual = "Manual only"
|
||||
|
||||
@@ -7234,18 +7317,8 @@ confirm = "Delete"
|
||||
title = "Delete pipeline?"
|
||||
|
||||
[portal.pipelines.detail]
|
||||
closeAriaLabel = "Close detail"
|
||||
delete = "Delete pipeline"
|
||||
edit = "Edit"
|
||||
noSources = "No sources. Files are supplied directly to each run."
|
||||
noSteps = "No operations configured."
|
||||
output = "Output"
|
||||
pause = "Pause"
|
||||
resume = "Resume"
|
||||
run = "Run now"
|
||||
sources = "Sources"
|
||||
steps = "Operations"
|
||||
subtitle = "{{trigger}} · {{status}}"
|
||||
|
||||
[portal.pipelines.empty]
|
||||
action = "Create a pipeline"
|
||||
@@ -7267,6 +7340,7 @@ completed_other = "All {{count}} runs completed."
|
||||
empty = "Nothing to run: the sources had no documents to process."
|
||||
failed = "Run failed: {{error}}"
|
||||
running = "Run started; still in progress."
|
||||
timeout = "Run is taking longer than expected; it may still finish in the background."
|
||||
|
||||
[portal.pipelines.status]
|
||||
active = "Active"
|
||||
@@ -7477,6 +7551,8 @@ generate = "Generate quote"
|
||||
included = "Included"
|
||||
indemnification = "IP indemnification"
|
||||
indemnificationSub = "We defend qualifying IP claims, per the EULA"
|
||||
offlineLicense = "Offline / air-gapped licence"
|
||||
offlineLicenseSub = "A downloadable licence file for an air-gapped self-hosted instance"
|
||||
qbr = "Quarterly business reviews"
|
||||
qbrSub = "Your SE reviews usage and roadmap each quarter"
|
||||
running = "{{annual}} / yr · {{years}}-yr {{tcv}}"
|
||||
@@ -7562,6 +7638,14 @@ subtitle = "Your solutions engineer is on every step. One next action at a time;
|
||||
title = "From trial to live, one guided path"
|
||||
trialTitle = "Enterprise trial"
|
||||
|
||||
[portal.procurement.license]
|
||||
copied = "Copied"
|
||||
copy = "Copy key"
|
||||
downloadError = "Could not generate the offline licence file just yet — please try again in a moment."
|
||||
downloadOffline = "Download offline licence (.lic)"
|
||||
hint = "Paste this key into a self-hosted instance to activate it, or keep it for your records. Keep it safe."
|
||||
label = "Your licence key"
|
||||
|
||||
[portal.procurement.link]
|
||||
cta = "Link account"
|
||||
description = "Procurement runs on your linked Stirling account: it's how we provision the trial, price your quote, and start billing. Link an account to start."
|
||||
@@ -7614,12 +7698,18 @@ uploadCta = "Upload purchase order"
|
||||
uploadTitle = "Upload your purchase order"
|
||||
|
||||
[portal.procurement.payment]
|
||||
description = "Your quote is accepted and a committed annual subscription has been created. Pay the first invoice to go live — you can pay or download it right here, no email needed."
|
||||
description = "Your quote is accepted and your licence is already active — your team can start right away. Pay the first invoice when you're ready; you can pay or download it here, no email needed."
|
||||
downloadInvoice = "Download invoice"
|
||||
simulate = "Simulate payment received (demo)"
|
||||
title = "Subscription created"
|
||||
viewInvoice = "View & pay invoice"
|
||||
|
||||
[portal.procurement.schedule]
|
||||
fallback = "Couldn't load the scheduler."
|
||||
fallbackLink = "Open scheduling in a new tab"
|
||||
subtitle = "Your solutions engineer will walk your team through the rollout. Pick a time that suits you."
|
||||
title = "Schedule a call"
|
||||
|
||||
[portal.procurement.status]
|
||||
action = "Action needed"
|
||||
available = "Available"
|
||||
@@ -8015,36 +8105,14 @@ status = "Status"
|
||||
suspend = "Suspend"
|
||||
|
||||
[portal.welcome]
|
||||
ariaLabel = "Stirling product highlights"
|
||||
pagination = "Carousel pagination"
|
||||
slideLabel = "Slide {{number}}: {{title}}"
|
||||
|
||||
[portal.welcome.ornament.editor]
|
||||
critical = "Critical"
|
||||
ocrClean = "OCR-clean"
|
||||
schemaMatch = "schema match 0.97"
|
||||
signed = "signed"
|
||||
|
||||
[portal.welcome.slides.agents]
|
||||
eyebrow = "AI Agents"
|
||||
primary = "Try PDF Processor"
|
||||
secondary = "View MCP docs"
|
||||
sub = "Wire your agent via MCP, REST or tool definitions. Deterministic operations and guardrails — test with scenarios and evals before you ship."
|
||||
title = "PDF Processor for AI Agents"
|
||||
|
||||
[portal.welcome.slides.editor]
|
||||
eyebrow = "PDF Editor"
|
||||
primary = "Install PDF Editor"
|
||||
secondary = "Connect an instance"
|
||||
sub = "Annotate, sign, redact, and review locally or in the cloud. Brought to the platform as the credibility anchor of the Stirling control plane."
|
||||
title = "The #1 PDF Editor on GitHub"
|
||||
|
||||
[portal.welcome.slides.platform]
|
||||
eyebrow = "Platform"
|
||||
primary = "Try a PDF operation"
|
||||
secondary = "Get an API key"
|
||||
sub = "Ingest from agents, APIs and connectors. Run composable pipelines with evals and golden sets. Land in a vault with zero-standing-access controls."
|
||||
title = "PDF Infrastructure for Developers"
|
||||
ariaLabel = "Welcome to Stirling PDF"
|
||||
badge = "Open-source"
|
||||
installEditor = "Install the Editor"
|
||||
inviteTeammates = "Invite teammates"
|
||||
perks = "Free forever · Self-hostable"
|
||||
subtitle = "The world's most secure PDF Editor is free for teams of all sizes. Includes 60+ PDF operations and SSO."
|
||||
title = "Welcome to"
|
||||
titleAccent = "Stirling PDF"
|
||||
|
||||
[printFile]
|
||||
title = "Print File"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useMemo, useEffect } from "react";
|
||||
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { Center, Box, LoadingOverlay } from "@mantine/core";
|
||||
import { Dropzone } from "@mantine/dropzone";
|
||||
@@ -19,6 +19,10 @@ import { FileId, StirlingFile } from "@app/types/fileContext";
|
||||
import { alert } from "@app/components/toast";
|
||||
import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import { usePolicyFileBadges } from "@app/hooks/usePolicyFileBadges";
|
||||
import type { FileItemPolicyRef } from "@app/components/shared/PolicyBadges";
|
||||
|
||||
const EMPTY_POLICIES: FileItemPolicyRef[] = [];
|
||||
|
||||
interface FileEditorProps {
|
||||
onOpenPageEditor?: () => void;
|
||||
@@ -31,6 +35,8 @@ const FileEditor = ({
|
||||
toolMode = false,
|
||||
supportedExtensions = ["pdf"],
|
||||
}: FileEditorProps) => {
|
||||
const policyFileBadges = usePolicyFileBadges();
|
||||
|
||||
// Utility function to check if a file extension is supported
|
||||
const isFileSupported = useCallback(
|
||||
(fileName: string): boolean => {
|
||||
@@ -52,6 +58,14 @@ const FileEditor = ({
|
||||
[state.files.byId, state.files.ids],
|
||||
);
|
||||
|
||||
// Always-current refs so callbacks can read the latest stubs/selection without
|
||||
// closing over them as deps — prevents every callback from regenerating whenever
|
||||
// any stub changes (e.g. thumbnail load), which would bust React.memo on every thumbnail.
|
||||
const stubsRef = useRef(activeStirlingFileStubs);
|
||||
stubsRef.current = activeStirlingFileStubs;
|
||||
const selectedFileIdsRef = useRef(selectedFileIds);
|
||||
selectedFileIdsRef.current = selectedFileIds;
|
||||
|
||||
// Get navigation actions
|
||||
const { actions: navActions } = useNavigationActions();
|
||||
|
||||
@@ -141,7 +155,7 @@ const FileEditor = ({
|
||||
// File reordering handler for drag and drop
|
||||
const handleReorderFiles = useCallback(
|
||||
(sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => {
|
||||
const currentIds = activeStirlingFileStubs.map((r) => r.id);
|
||||
const currentIds = stubsRef.current.map((r) => r.id);
|
||||
|
||||
// Find indices
|
||||
const sourceIndex = currentIds.findIndex((id) => id === sourceFileId);
|
||||
@@ -211,38 +225,27 @@ const FileEditor = ({
|
||||
const moveCount = filesToMove.length;
|
||||
showStatus(`${moveCount > 1 ? `${moveCount} files` : "File"} reordered`);
|
||||
},
|
||||
[activeStirlingFileStubs, reorderFiles, _setStatus],
|
||||
[reorderFiles, showStatus],
|
||||
);
|
||||
|
||||
// File operations using context
|
||||
const handleCloseFile = useCallback(
|
||||
(fileId: FileId) => {
|
||||
const record = activeStirlingFileStubs.find((r) => r.id === fileId);
|
||||
const record = stubsRef.current.find((r) => r.id === fileId);
|
||||
const file = record ? selectors.getFile(record.id) : null;
|
||||
if (record && file) {
|
||||
// Remove file from context but keep in storage (close, don't delete)
|
||||
const contextFileId = record.id;
|
||||
removeFiles([contextFileId], false);
|
||||
|
||||
// Remove from context selections
|
||||
const currentSelected = selectedFileIds.filter(
|
||||
(id) => id !== contextFileId,
|
||||
removeFiles([record.id], false);
|
||||
setSelectedFiles(
|
||||
selectedFileIdsRef.current.filter((id) => id !== record.id),
|
||||
);
|
||||
setSelectedFiles(currentSelected);
|
||||
}
|
||||
},
|
||||
[
|
||||
activeStirlingFileStubs,
|
||||
selectors,
|
||||
removeFiles,
|
||||
setSelectedFiles,
|
||||
selectedFileIds,
|
||||
],
|
||||
[selectors, removeFiles, setSelectedFiles],
|
||||
);
|
||||
|
||||
const handleDownloadFile = useCallback(
|
||||
async (fileId: FileId) => {
|
||||
const record = activeStirlingFileStubs.find((r) => r.id === fileId);
|
||||
const record = stubsRef.current.find((r) => r.id === fileId);
|
||||
const file = record ? selectors.getFile(record.id) : null;
|
||||
console.log("[FileEditor] handleDownloadFile called:", {
|
||||
fileId,
|
||||
@@ -278,12 +281,12 @@ const FileEditor = ({
|
||||
}
|
||||
}
|
||||
},
|
||||
[activeStirlingFileStubs, selectors, fileActions],
|
||||
[selectors, fileActions],
|
||||
);
|
||||
|
||||
const handleUnzipFile = useCallback(
|
||||
async (fileId: FileId) => {
|
||||
const record = activeStirlingFileStubs.find((r) => r.id === fileId);
|
||||
const record = stubsRef.current.find((r) => r.id === fileId);
|
||||
const file = record ? selectors.getFile(record.id) : null;
|
||||
if (record && file) {
|
||||
try {
|
||||
@@ -326,24 +329,19 @@ const FileEditor = ({
|
||||
}
|
||||
}
|
||||
},
|
||||
[activeStirlingFileStubs, selectors, fileActions, removeFiles],
|
||||
[selectors, fileActions, removeFiles],
|
||||
);
|
||||
|
||||
const handleViewFile = useCallback(
|
||||
(fileId: FileId) => {
|
||||
const index = activeStirlingFileStubs.findIndex((r) => r.id === fileId);
|
||||
const index = stubsRef.current.findIndex((r) => r.id === fileId);
|
||||
if (index !== -1) {
|
||||
setActiveFileId(fileId as string);
|
||||
setActiveFileIndex(index);
|
||||
navActions.setWorkbench("viewer");
|
||||
}
|
||||
},
|
||||
[
|
||||
activeStirlingFileStubs,
|
||||
setActiveFileId,
|
||||
setActiveFileIndex,
|
||||
navActions.setWorkbench,
|
||||
],
|
||||
[setActiveFileId, setActiveFileIndex, navActions.setWorkbench],
|
||||
);
|
||||
|
||||
const handleLoadFromStorage = useCallback(async (selectedFiles: File[]) => {
|
||||
@@ -412,6 +410,10 @@ const FileEditor = ({
|
||||
onUnzipFile={handleUnzipFile}
|
||||
toolMode={toolMode}
|
||||
isSupported={isFileSupported(record.name)}
|
||||
policies={
|
||||
policyFileBadges.get(record.id as string) ??
|
||||
EMPTY_POLICIES
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -123,6 +123,14 @@
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: calc(var(--file-meta-line-height) + var(--file-meta-gap));
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fileNameText {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
@@ -131,6 +139,12 @@
|
||||
text-overflow: ellipsis;
|
||||
overflow-wrap: break-word;
|
||||
word-break: normal;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Layout-only wrapper for the shared PolicyBadges row in the file-name line. */
|
||||
.fileNameBadges {
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.fileMeta {
|
||||
@@ -207,6 +221,7 @@
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Policy badges pinned to top-right of thumbnail */
|
||||
.statusDot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useCallback, useRef, useMemo } from "react";
|
||||
import { Text, Modal, Group, Stack, Tooltip } from "@mantine/core";
|
||||
import { Text, Modal, Group, Loader, Stack, Tooltip } from "@mantine/core";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { useIsMobile } from "@app/hooks/useIsMobile";
|
||||
@@ -16,11 +16,17 @@ import HistoryIcon from "@mui/icons-material/History";
|
||||
import PushPinIcon from "@mui/icons-material/PushPin";
|
||||
import LockOpenIcon from "@mui/icons-material/LockOpen";
|
||||
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
|
||||
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
|
||||
import {
|
||||
draggable,
|
||||
dropTargetForElements,
|
||||
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
||||
import { StirlingFileStub } from "@app/types/fileContext";
|
||||
import {
|
||||
PolicyBadges,
|
||||
type FileItemPolicyRef,
|
||||
} from "@app/components/shared/PolicyBadges";
|
||||
import { PolicyEnforcingOverlay } from "@app/components/shared/PolicyEnforcingOverlay";
|
||||
import { zipFileService } from "@app/services/zipFileService";
|
||||
|
||||
import styles from "@app/components/fileEditor/FileEditorThumbnail.module.css";
|
||||
@@ -57,6 +63,7 @@ interface FileEditorThumbnailProps {
|
||||
onUnzipFile?: (fileId: FileId) => void;
|
||||
toolMode?: boolean;
|
||||
isSupported?: boolean;
|
||||
policies?: FileItemPolicyRef[];
|
||||
}
|
||||
|
||||
const FileEditorThumbnail = ({
|
||||
@@ -67,6 +74,7 @@ const FileEditorThumbnail = ({
|
||||
onDownloadFile,
|
||||
onUnzipFile,
|
||||
isSupported = true,
|
||||
policies = [],
|
||||
}: FileEditorThumbnailProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { config } = useAppConfig();
|
||||
@@ -286,8 +294,28 @@ const FileEditorThumbnail = ({
|
||||
|
||||
const [showVersionHistory, setShowVersionHistory] = useState(false);
|
||||
|
||||
const hoverActions = useMemo<HoverAction[]>(
|
||||
() => [
|
||||
const policyEnforcing = policies.some((p) => p.enforcing);
|
||||
|
||||
const hoverActions = useMemo<HoverAction[]>(() => {
|
||||
const uploadLabel = isUploaded
|
||||
? t("fileManager.updateOnServer", "Update on Server")
|
||||
: t("fileManager.uploadToServer", "Upload to Server");
|
||||
const enforcingTooltip = (action: string): React.ReactNode => (
|
||||
<Stack gap={4} py={2} w={180}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<ShieldOutlinedIcon style={{ fontSize: 13 }} />
|
||||
<Text size="xs" fw={600}>
|
||||
{t(
|
||||
"policy.blockingAction",
|
||||
"{{action}} blocked while enforcing policy, please wait...",
|
||||
{ action },
|
||||
)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Loader size="xs" />
|
||||
</Stack>
|
||||
);
|
||||
return [
|
||||
{
|
||||
id: "view",
|
||||
icon: <VisibilityIcon style={{ fontSize: 20 }} />,
|
||||
@@ -331,6 +359,10 @@ const FileEditorThumbnail = ({
|
||||
id: "download",
|
||||
icon: <DownloadOutlinedIcon style={{ fontSize: 20 }} />,
|
||||
label: terminology.download,
|
||||
disabled: policyEnforcing,
|
||||
tooltip: policyEnforcing
|
||||
? enforcingTooltip(terminology.download)
|
||||
: undefined,
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
onDownloadFile(file.id);
|
||||
@@ -341,9 +373,11 @@ const FileEditorThumbnail = ({
|
||||
{
|
||||
id: "upload",
|
||||
icon: <CloudUploadIcon style={{ fontSize: 20 }} />,
|
||||
label: isUploaded
|
||||
? t("fileManager.updateOnServer", "Update on Server")
|
||||
: t("fileManager.uploadToServer", "Upload to Server"),
|
||||
label: uploadLabel,
|
||||
disabled: policyEnforcing,
|
||||
tooltip: policyEnforcing
|
||||
? enforcingTooltip(uploadLabel)
|
||||
: undefined,
|
||||
onClick: (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setShowUploadModal(true);
|
||||
@@ -357,6 +391,10 @@ const FileEditorThumbnail = ({
|
||||
id: "share",
|
||||
icon: <LinkIcon style={{ fontSize: 20 }} />,
|
||||
label: t("fileManager.share", "Share"),
|
||||
disabled: policyEnforcing,
|
||||
tooltip: policyEnforcing
|
||||
? enforcingTooltip(t("fileManager.share", "Share"))
|
||||
: undefined,
|
||||
onClick: (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setShowShareModal(true);
|
||||
@@ -402,30 +440,30 @@ const FileEditorThumbnail = ({
|
||||
},
|
||||
color: "red",
|
||||
},
|
||||
],
|
||||
[
|
||||
t,
|
||||
file.id,
|
||||
file.name,
|
||||
file.versionNumber,
|
||||
isZipFile,
|
||||
isCBZ,
|
||||
isCBR,
|
||||
isPinned,
|
||||
actualFile,
|
||||
terminology,
|
||||
DownloadOutlinedIcon,
|
||||
onViewFile,
|
||||
onDownloadFile,
|
||||
onUnzipFile,
|
||||
handleCloseWithConfirmation,
|
||||
canUpload,
|
||||
canShare,
|
||||
isUploaded,
|
||||
pinFile,
|
||||
unpinFile,
|
||||
],
|
||||
);
|
||||
];
|
||||
}, [
|
||||
t,
|
||||
file.id,
|
||||
file.name,
|
||||
file.versionNumber,
|
||||
isZipFile,
|
||||
isCBZ,
|
||||
isCBR,
|
||||
isPinned,
|
||||
actualFile,
|
||||
terminology,
|
||||
DownloadOutlinedIcon,
|
||||
onViewFile,
|
||||
onDownloadFile,
|
||||
onUnzipFile,
|
||||
handleCloseWithConfirmation,
|
||||
policyEnforcing,
|
||||
canUpload,
|
||||
canShare,
|
||||
isUploaded,
|
||||
pinFile,
|
||||
unpinFile,
|
||||
]);
|
||||
|
||||
const handleCardClick = () => {
|
||||
if (!isSupported) return;
|
||||
@@ -497,6 +535,9 @@ const FileEditorThumbnail = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Policy enforcement overlay — shown while any policy is in-flight */}
|
||||
<PolicyEnforcingOverlay enforcing={policyEnforcing} zIndex={2} />
|
||||
|
||||
{/* Thumbnail image or loading state */}
|
||||
<DocumentThumbnail
|
||||
file={file}
|
||||
@@ -580,7 +621,10 @@ const FileEditorThumbnail = ({
|
||||
{/* File name + meta */}
|
||||
<div className={styles.fileText}>
|
||||
<p className={styles.fileName}>
|
||||
<PrivateContent>{truncateCenter(file.name, 40)}</PrivateContent>
|
||||
<span className={styles.fileNameText}>
|
||||
<PrivateContent>{truncateCenter(file.name, 40)}</PrivateContent>
|
||||
</span>
|
||||
<PolicyBadges policies={policies} className={styles.fileNameBadges} />
|
||||
</p>
|
||||
<p className={styles.fileMeta}>{metaLine}</p>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Checkbox, Menu, Tooltip } from "@mantine/core";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
||||
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
|
||||
import { PolicyBadges as PolicyBadgeRow } from "@app/components/shared/PolicyBadges";
|
||||
import FolderIcon from "@mui/icons-material/Folder";
|
||||
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
|
||||
import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile";
|
||||
@@ -581,26 +581,7 @@ function FolderCard({
|
||||
/** Shield badges for the policies that have run on a file. */
|
||||
function PolicyBadges({ fileId }: { fileId: string }) {
|
||||
const badges = usePolicyFileBadges().get(fileId) ?? [];
|
||||
if (badges.length === 0) return null;
|
||||
return (
|
||||
<span className="files-page-policy-badges" data-no-select>
|
||||
{badges.slice(0, 3).map((policy) => (
|
||||
<Tooltip
|
||||
key={policy.id}
|
||||
label={`${policy.name} policy ran on this file`}
|
||||
withArrow
|
||||
position="top"
|
||||
>
|
||||
<span
|
||||
className="files-page-policy-badge"
|
||||
style={{ color: policy.accentColor }}
|
||||
>
|
||||
<ShieldOutlinedIcon sx={{ fontSize: "0.7rem" }} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
return <PolicyBadgeRow policies={badges} />;
|
||||
}
|
||||
|
||||
interface FileCardProps {
|
||||
|
||||
@@ -484,24 +484,6 @@
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
/* Policy activity badges (a shield per policy that has run on the file). */
|
||||
.files-page-policy-badges {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.files-page-policy-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border-radius: 4px;
|
||||
/* `color` set inline to the policy accent; tint follows it. */
|
||||
background: color-mix(in srgb, currentColor 16%, transparent);
|
||||
}
|
||||
|
||||
/* Parent-folder breadcrumb shown on cards/rows during recursive search so
|
||||
the user can tell which folder each hit lives in without navigating. */
|
||||
.files-page-card-path {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Core stub — the real implementation lives in the proprietary overlay.
|
||||
* Returns empty state so core-build consumers compile without a policyRunStore module.
|
||||
*/
|
||||
|
||||
export interface PolicyRunRecord {
|
||||
runId: string;
|
||||
categoryId: string;
|
||||
fileId: string;
|
||||
fileName: string;
|
||||
status: string;
|
||||
currentStep?: number;
|
||||
stepCount?: number;
|
||||
error: string | null;
|
||||
retrying?: boolean;
|
||||
startedAt: number;
|
||||
}
|
||||
|
||||
export const POLICY_IN_FLIGHT_STATUSES = [
|
||||
"PENDING",
|
||||
"RUNNING",
|
||||
"WAITING_FOR_INPUT",
|
||||
] as const;
|
||||
|
||||
export function usePolicyRuns(): PolicyRunRecord[] {
|
||||
return [];
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/* Trigger: bare square icon button that blends into either sidebar's header. */
|
||||
.app-switch-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-4);
|
||||
transition:
|
||||
background var(--motion-fast),
|
||||
color var(--motion-fast);
|
||||
}
|
||||
|
||||
.app-switch-btn:hover {
|
||||
background: var(--color-bg-hover);
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.app-switch-icon {
|
||||
width: 1rem;
|
||||
height: 1.0625rem;
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Dropdown } from "@app/ui";
|
||||
import markLight from "@app/assets/brand/modern-logo/StirlingPDFLogoNoTextLight.svg";
|
||||
import markDark from "@app/assets/brand/modern-logo/StirlingPDFLogoNoTextDark.svg";
|
||||
import "@app/components/shared/AppSwitch.css";
|
||||
|
||||
export type AppSwitchTarget = "editor" | "processor";
|
||||
|
||||
function ChevronDownIcon() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={14}
|
||||
height={14}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.75}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
interface AppSwitchProps {
|
||||
/** The app this switcher is rendered in (shown as active in the menu). */
|
||||
current: AppSwitchTarget;
|
||||
/** Resolved color scheme; picks the brand mark for the menu items. */
|
||||
theme: "light" | "dark";
|
||||
/** Invoked with the selected app; only called for apps other than `current`. */
|
||||
onSwitch: (app: AppSwitchTarget) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The editor ⇄ processor app switcher (chevron button → app menu). The editor
|
||||
* and portal sidebars render this same element so the two apps present one
|
||||
* identical switcher; each host supplies its own theme source and navigation.
|
||||
*/
|
||||
export function AppSwitch({
|
||||
current,
|
||||
theme,
|
||||
onSwitch,
|
||||
className,
|
||||
}: AppSwitchProps) {
|
||||
const { t } = useTranslation();
|
||||
const mark = theme === "dark" ? markDark : markLight;
|
||||
const apps: Array<{ id: AppSwitchTarget; label: string }> = [
|
||||
{
|
||||
id: "processor",
|
||||
label: t("portal.shell.sidebar.appProcessor", "Processor"),
|
||||
},
|
||||
{ id: "editor", label: t("portal.shell.sidebar.appEditor", "Editor") },
|
||||
];
|
||||
return (
|
||||
<Dropdown.Root align="end" className={className}>
|
||||
<Dropdown.Trigger>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
className="app-switch-btn"
|
||||
aria-label={t("portal.shell.sidebar.switchApp", "Switch app")}
|
||||
>
|
||||
<ChevronDownIcon />
|
||||
</Button>
|
||||
</Dropdown.Trigger>
|
||||
<Dropdown.Menu width="11rem">
|
||||
{apps.map((app) => (
|
||||
<Dropdown.Item
|
||||
key={app.id}
|
||||
active={current === app.id}
|
||||
onSelect={app.id === current ? undefined : () => onSwitch(app.id)}
|
||||
leading={<img className="app-switch-icon" src={mark} alt="" />}
|
||||
>
|
||||
{app.label}
|
||||
</Dropdown.Item>
|
||||
))}
|
||||
</Dropdown.Menu>
|
||||
</Dropdown.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Core stub for the sidebar app switcher. Builds that bundle the admin portal
|
||||
* (proprietary/saas) shadow this with a real switcher; core has no portal, so
|
||||
* there is nothing to switch to.
|
||||
*/
|
||||
export function AppSwitcher() {
|
||||
return null;
|
||||
}
|
||||
@@ -102,6 +102,18 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* App switcher (portal builds only) sits at the far end of the header row.
|
||||
The content-fade animation makes this span a stacking context, which would
|
||||
trap the menu's z-index below later sidebar rows — elevate the span so the
|
||||
open menu paints above them. */
|
||||
.file-sidebar-app-switch {
|
||||
margin-inline-start: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
z-index: var(--z-dropdown);
|
||||
}
|
||||
|
||||
/* ---- Search row ---- */
|
||||
.file-sidebar-search-row {
|
||||
display: flex;
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
import { accountService } from "@app/services/accountService";
|
||||
import { GoogleDriveIcon } from "@app/components/shared/CloudStorageIcons";
|
||||
import { Wordmark } from "@app/components/shared/Wordmark";
|
||||
import { AppSwitcher } from "@app/components/shared/AppSwitcher";
|
||||
import type { StirlingFileStub } from "@app/types/fileContext";
|
||||
import MenuIcon from "@mui/icons-material/Menu";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
@@ -679,6 +680,17 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
className="file-sidebar-brand-text sidebar-content-fade"
|
||||
/>
|
||||
)}
|
||||
{!collapsed && (
|
||||
// The header row itself toggles collapse; stop the switcher's
|
||||
// clicks and key presses from reaching it.
|
||||
<span
|
||||
className="file-sidebar-app-switch sidebar-content-fade"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<AppSwitcher />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
|
||||
@@ -36,32 +36,6 @@
|
||||
background-color: rgba(59, 130, 246, 0.06);
|
||||
}
|
||||
|
||||
/* A policy-enforced file glows in that policy's accent colour so it's obvious
|
||||
the file has had a policy applied: it pulses a few times to catch the eye and
|
||||
then fades out (no lingering glow). */
|
||||
.file-sidebar-file-item.policy-enforced {
|
||||
animation: policy-glow-fade 4.5s ease-in-out forwards;
|
||||
}
|
||||
@keyframes policy-glow-fade {
|
||||
0%,
|
||||
24%,
|
||||
48% {
|
||||
box-shadow:
|
||||
inset 0 0 0 1px color-mix(in srgb, var(--policy-glow) 30%, transparent),
|
||||
0 0 6px -2px var(--policy-glow);
|
||||
}
|
||||
12%,
|
||||
36%,
|
||||
60% {
|
||||
box-shadow:
|
||||
inset 0 0 0 1px color-mix(in srgb, var(--policy-glow) 75%, transparent),
|
||||
0 0 16px 0 var(--policy-glow);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.file-sidebar-file-item.selected {
|
||||
background-color: rgba(59, 130, 246, 0.12);
|
||||
}
|
||||
@@ -160,25 +134,6 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ---- Policy activity badges (a shield per policy that has run on the file) ---- */
|
||||
.file-sidebar-policy-badges {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.file-sidebar-policy-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border-radius: 4px;
|
||||
/* `color` is set inline to the policy's accent; the tint follows it. */
|
||||
color: var(--text-secondary);
|
||||
background: color-mix(in srgb, currentColor 16%, transparent);
|
||||
}
|
||||
|
||||
/* ---- Cloud badge (file saved to the server) ---- */
|
||||
.file-sidebar-cloud-badge {
|
||||
display: inline-flex;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import React, { useState, useCallback, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Menu, Tooltip } from "@mantine/core";
|
||||
import { Group, Loader, Menu, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
|
||||
@@ -13,6 +13,10 @@ import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
|
||||
import HistoryIcon from "@mui/icons-material/History";
|
||||
import type { FileId } from "@app/types/file";
|
||||
import { FileDocIcon } from "@app/components/shared/FileDocIcon";
|
||||
import {
|
||||
PolicyBadges,
|
||||
type FileItemPolicyRef,
|
||||
} from "@app/components/shared/PolicyBadges";
|
||||
import { getFileDocVariant } from "@app/components/shared/filePreview/getFileTypeIcon";
|
||||
import { useLazyThumbnail } from "@app/hooks/useLazyThumbnail";
|
||||
import { IMAGE_EXTENSIONS } from "@app/utils/fileUtils";
|
||||
@@ -132,17 +136,6 @@ export interface FileItemFolderRef {
|
||||
accentColor: string;
|
||||
}
|
||||
|
||||
/** A policy that has run on this file, used for the activity badges. */
|
||||
export interface FileItemPolicyRef {
|
||||
id: string;
|
||||
name: string;
|
||||
/** CSS colour for the badge (matches the policy's accent). */
|
||||
accentColor: string;
|
||||
/** True only just after the policy was applied — drives the one-off glow, so
|
||||
* it doesn't replay on every reload of an already-enforced file. */
|
||||
recent: boolean;
|
||||
}
|
||||
|
||||
export interface FileItemProps {
|
||||
fileId: FileId;
|
||||
name: string;
|
||||
@@ -178,7 +171,6 @@ export interface FileItemProps {
|
||||
}
|
||||
|
||||
const MAX_VISIBLE_FOLDER_TAGS = 2;
|
||||
const MAX_VISIBLE_POLICY_BADGES = 3;
|
||||
|
||||
export function FileItem({
|
||||
fileId,
|
||||
@@ -208,6 +200,23 @@ export function FileItem({
|
||||
const dateLabel = lastModified ? formatFileDate(lastModified) : "";
|
||||
const typeLabel = ext ? ext.toUpperCase() : "File";
|
||||
|
||||
const policyEnforcing = policies.some((p) => p.enforcing);
|
||||
const enforcingTooltip = (action: string): React.ReactNode => (
|
||||
<Stack gap={6} py={2} w={200}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<ShieldOutlinedIcon style={{ fontSize: 13 }} />
|
||||
<Text size="xs" fw={600}>
|
||||
{t(
|
||||
"policy.blockingAction",
|
||||
"{{action}} blocked while enforcing policy, please wait...",
|
||||
{ action },
|
||||
)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Loader size="xs" />
|
||||
</Stack>
|
||||
);
|
||||
|
||||
const visibleFolders = folders.slice(0, MAX_VISIBLE_FOLDER_TAGS);
|
||||
const overflowFolders = folders.slice(MAX_VISIBLE_FOLDER_TAGS);
|
||||
|
||||
@@ -228,9 +237,6 @@ export function FileItem({
|
||||
|
||||
const handleMouseLeave = useCallback(() => setHoverRect(null), []);
|
||||
|
||||
// A just-applied policy (recent run) drives the one-off row glow.
|
||||
const recentPolicy = policies.find((p) => p.recent);
|
||||
|
||||
// Reactive: tooltip appears as soon as both hover rect and thumbnail are ready
|
||||
const thumbPos =
|
||||
hoverRect && resolvedThumbnail
|
||||
@@ -244,14 +250,7 @@ export function FileItem({
|
||||
<>
|
||||
<div
|
||||
ref={itemRef}
|
||||
className={`file-sidebar-file-item${isSelected ? " selected" : ""}${isActive ? " active" : ""}${isViewedInViewer ? " viewed" : ""}${recentPolicy ? " policy-enforced" : ""}`}
|
||||
style={
|
||||
recentPolicy
|
||||
? ({
|
||||
"--policy-glow": recentPolicy.accentColor,
|
||||
} as React.CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
className={`file-sidebar-file-item${isSelected ? " selected" : ""}${isActive ? " active" : ""}${isViewedInViewer ? " viewed" : ""}`}
|
||||
onClick={() => onClick(fileId)}
|
||||
draggable={draggable}
|
||||
onDragStart={
|
||||
@@ -301,25 +300,7 @@ export function FileItem({
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{policies.length > 0 && (
|
||||
<span className="file-sidebar-policy-badges" data-no-select>
|
||||
{policies.slice(0, MAX_VISIBLE_POLICY_BADGES).map((policy) => (
|
||||
<Tooltip
|
||||
key={policy.id}
|
||||
label={`${policy.name} policy ran on this file`}
|
||||
withArrow
|
||||
position="top"
|
||||
>
|
||||
<span
|
||||
className="file-sidebar-policy-badge"
|
||||
style={{ color: policy.accentColor }}
|
||||
>
|
||||
<ShieldOutlinedIcon sx={{ fontSize: "0.7rem" }} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
<PolicyBadges policies={policies} />
|
||||
</span>
|
||||
{folders.length > 0 && (
|
||||
<span className="file-sidebar-folder-tags" data-no-select>
|
||||
@@ -395,7 +376,7 @@ export function FileItem({
|
||||
/>
|
||||
</ActionIcon>
|
||||
{(onDelete ||
|
||||
onSaveToCloud ||
|
||||
(canSaveToCloud && onSaveToCloud) ||
|
||||
(hasVersionHistory && onVersionHistory)) && (
|
||||
<Menu position="bottom-end" withinPortal shadow="md" width={190}>
|
||||
<Menu.Target>
|
||||
@@ -425,17 +406,10 @@ export function FileItem({
|
||||
{t("fileSidebar.fileItem.versionHistory", "Version history")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{canSaveToCloud && onSaveToCloud && (
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
<CloudUploadOutlinedIcon sx={{ fontSize: 16 }} />
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSaveToCloud(fileId);
|
||||
}}
|
||||
>
|
||||
{isUploadedToCloud
|
||||
{canSaveToCloud &&
|
||||
onSaveToCloud &&
|
||||
(() => {
|
||||
const uploadLabel = isUploadedToCloud
|
||||
? t(
|
||||
"fileSidebar.fileItem.updateOnServer",
|
||||
"Update on server",
|
||||
@@ -443,21 +417,64 @@ export function FileItem({
|
||||
: t(
|
||||
"fileSidebar.fileItem.uploadToServer",
|
||||
"Upload to server",
|
||||
)}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{onDelete && (
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<DeleteOutlineIcon sx={{ fontSize: 16 }} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(fileId);
|
||||
}}
|
||||
>
|
||||
{t("fileSidebar.fileItem.delete", "Delete")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
);
|
||||
return (
|
||||
<Tooltip
|
||||
label={enforcingTooltip(uploadLabel)}
|
||||
disabled={!policyEnforcing}
|
||||
position="left"
|
||||
offset={6}
|
||||
withArrow
|
||||
>
|
||||
<div>
|
||||
<Menu.Item
|
||||
disabled={policyEnforcing}
|
||||
leftSection={
|
||||
<CloudUploadOutlinedIcon sx={{ fontSize: 16 }} />
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSaveToCloud(fileId);
|
||||
}}
|
||||
>
|
||||
{uploadLabel}
|
||||
</Menu.Item>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
})()}
|
||||
{onDelete &&
|
||||
(() => {
|
||||
const deleteLabel = t(
|
||||
"fileSidebar.fileItem.delete",
|
||||
"Delete",
|
||||
);
|
||||
return (
|
||||
<Tooltip
|
||||
label={enforcingTooltip(deleteLabel)}
|
||||
disabled={!policyEnforcing}
|
||||
position="left"
|
||||
offset={6}
|
||||
withArrow
|
||||
>
|
||||
<div>
|
||||
<Menu.Item
|
||||
disabled={policyEnforcing}
|
||||
color="red"
|
||||
leftSection={
|
||||
<DeleteOutlineIcon sx={{ fontSize: 16 }} />
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(fileId);
|
||||
}}
|
||||
>
|
||||
{deleteLabel}
|
||||
</Menu.Item>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
})()}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
)}
|
||||
|
||||
@@ -10,6 +10,8 @@ export interface HoverAction {
|
||||
label: string;
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
disabled?: boolean;
|
||||
/** Overrides label in the tooltip — use for rich ReactNode content (e.g. enforcement messages). */
|
||||
tooltip?: React.ReactNode;
|
||||
color?: string;
|
||||
hidden?: boolean;
|
||||
dataTour?: string;
|
||||
@@ -58,18 +60,22 @@ const HoverActionMenu: React.FC<HoverActionMenuProps> = ({
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{visibleActions.map((action) => (
|
||||
<Tooltip key={action.id} label={action.label}>
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="tertiary"
|
||||
disabled={action.disabled}
|
||||
onClick={action.onClick}
|
||||
aria-label={action.label}
|
||||
style={{ color: action.color || "var(--text-secondary)" }}
|
||||
data-tour={action.dataTour}
|
||||
>
|
||||
{action.icon}
|
||||
</ActionIcon>
|
||||
<Tooltip key={action.id} label={action.tooltip ?? action.label}>
|
||||
{/* Wrapper keeps the tooltip working when the button is disabled
|
||||
(disabled buttons don't emit the pointer events Tooltip needs). */}
|
||||
<div style={{ display: "inline-flex", alignItems: "center" }}>
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="tertiary"
|
||||
disabled={action.disabled}
|
||||
onClick={action.onClick}
|
||||
aria-label={action.label}
|
||||
style={{ color: action.color || "var(--text-secondary)" }}
|
||||
data-tour={action.dataTour}
|
||||
>
|
||||
{action.icon}
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/* Canonical policy badge styling — every per-file policy badge in the app
|
||||
* (sidebar, thumbnails, files page, viewer indicator) uses these classes so
|
||||
* colour and shape stay consistent. `color` is set inline to the policy's
|
||||
* accent; the background tint follows it. */
|
||||
|
||||
.policy-badges {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.policy-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border-radius: 4px;
|
||||
color: var(--text-secondary);
|
||||
background: color-mix(in srgb, currentColor 16%, transparent);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Larger variant for standalone indicators (e.g. the minimised viewer overlay). */
|
||||
.policy-badge--lg {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.policy-badge--enforcing svg {
|
||||
animation: policy-badge-spin 1s linear infinite;
|
||||
}
|
||||
@keyframes policy-badge-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.policy-badge--recent {
|
||||
animation: policy-badge-pulse 4.5s ease-in-out forwards;
|
||||
}
|
||||
@keyframes policy-badge-pulse {
|
||||
0%,
|
||||
24%,
|
||||
48% {
|
||||
box-shadow: 0 0 0 0 transparent;
|
||||
}
|
||||
12%,
|
||||
36% {
|
||||
box-shadow: 0 0 5px 2px currentColor;
|
||||
}
|
||||
60%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 transparent;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Tooltip } from "@mantine/core";
|
||||
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
|
||||
import AutorenewIcon from "@mui/icons-material/Autorenew";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import "@app/components/shared/PolicyBadges.css";
|
||||
|
||||
/** A policy that has run on this file, used for the activity badges. */
|
||||
export interface FileItemPolicyRef {
|
||||
id: string;
|
||||
name: string;
|
||||
/** CSS colour for the badge (matches the policy's accent). */
|
||||
accentColor: string;
|
||||
/** True only just after the policy was applied — drives the one-off glow, so
|
||||
* it doesn't replay on every reload of an already-enforced file. */
|
||||
recent: boolean;
|
||||
/** True while the policy run is actively in-flight on this file. */
|
||||
enforcing?: boolean;
|
||||
}
|
||||
|
||||
const MAX_VISIBLE = 3;
|
||||
|
||||
/**
|
||||
* The canonical policy badge row: one accent-tinted shield per policy that has
|
||||
* run on a file, spinning while a run is in flight, glowing briefly after it
|
||||
* lands. Every surface that shows per-file policy badges (file sidebar, file
|
||||
* editor thumbnails, files page) renders this so they stay identical.
|
||||
*/
|
||||
export function PolicyBadges({
|
||||
policies,
|
||||
className,
|
||||
}: {
|
||||
policies: FileItemPolicyRef[];
|
||||
/** Appended to the row for surface-specific layout (spacing only). */
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (policies.length === 0) return null;
|
||||
return (
|
||||
<span
|
||||
className={`policy-badges${className ? ` ${className}` : ""}`}
|
||||
data-no-select
|
||||
>
|
||||
{policies.slice(0, MAX_VISIBLE).map((policy) => (
|
||||
<Tooltip
|
||||
key={policy.id}
|
||||
label={
|
||||
policy.enforcing
|
||||
? t("policy.badgeEnforcing", "{{name}} enforcing…", {
|
||||
name: policy.name,
|
||||
})
|
||||
: t("policy.badgeRan", "{{name}} policy ran on this file", {
|
||||
name: policy.name,
|
||||
})
|
||||
}
|
||||
withArrow
|
||||
position="top"
|
||||
>
|
||||
<span
|
||||
className={`policy-badge${policy.enforcing ? " policy-badge--enforcing" : ""}${policy.recent && !policy.enforcing ? " policy-badge--recent" : ""}`}
|
||||
style={{ color: policy.accentColor }}
|
||||
>
|
||||
{policy.enforcing ? (
|
||||
<AutorenewIcon sx={{ fontSize: "0.7rem" }} />
|
||||
) : (
|
||||
<ShieldOutlinedIcon sx={{ fontSize: "0.7rem" }} />
|
||||
)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export function PolicyEnforcingOverlay(_props: {
|
||||
enforcing: boolean;
|
||||
progress?: number;
|
||||
zIndex?: number;
|
||||
}) {
|
||||
return null;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import React, {
|
||||
useRef,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import { Group, Loader, Progress, Stack, Text } from "@mantine/core";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import { SegmentedControl } from "@app/ui/SegmentedControl";
|
||||
@@ -33,6 +34,11 @@ import { Tooltip } from "@app/components/shared/Tooltip";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import ViewerShareButton from "@app/components/viewer/ViewerShareButton";
|
||||
import { useSharingEnabled } from "@app/hooks/useSharingEnabled";
|
||||
import { usePolicyFileBadges } from "@app/hooks/usePolicyFileBadges";
|
||||
import {
|
||||
POLICY_IN_FLIGHT_STATUSES,
|
||||
usePolicyRuns,
|
||||
} from "@app/components/policies/policyRunStore";
|
||||
import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy";
|
||||
import { enforceExportPolicies } from "@app/services/policyExport";
|
||||
import { downloadFile as downloadRaw } from "@app/services/downloadService";
|
||||
@@ -46,6 +52,7 @@ import InsertDriveFileOutlinedIcon from "@mui/icons-material/InsertDriveFileOutl
|
||||
import FolderOutlinedIcon from "@mui/icons-material/FolderOutlined";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import PrintIcon from "@mui/icons-material/Print";
|
||||
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
|
||||
import "@app/components/shared/WorkbenchBar.css";
|
||||
|
||||
const SECTION_ORDER: WorkbenchBarSection[] = ["top", "middle", "bottom"];
|
||||
@@ -119,6 +126,60 @@ export default function WorkbenchBar({
|
||||
const { actions: fileActions } = useFileActions();
|
||||
const activeFiles = selectors.getFiles();
|
||||
const { activeFileId, setActiveFileId } = useViewer();
|
||||
const policyFileBadges = usePolicyFileBadges();
|
||||
// Block print/export while any file the export would touch is under active
|
||||
// policy enforcement: the viewer exports its active file, every other view
|
||||
// exports the selection (or all files when nothing is selected).
|
||||
const exportTargetIds: string[] =
|
||||
currentView === "viewer"
|
||||
? activeFileId
|
||||
? [activeFileId]
|
||||
: []
|
||||
: selectedFileIds.length > 0
|
||||
? selectedFileIds
|
||||
: activeFiles.filter(isStirlingFile).map((f) => f.fileId);
|
||||
const enforcingFileId = exportTargetIds.find((id) =>
|
||||
(policyFileBadges.get(id) ?? []).some((p) => p.enforcing),
|
||||
);
|
||||
const policyEnforcing = enforcingFileId != null;
|
||||
const policyRuns = usePolicyRuns();
|
||||
const enforcingRun = policyEnforcing
|
||||
? policyRuns.find(
|
||||
(r) =>
|
||||
r.fileId === enforcingFileId &&
|
||||
(POLICY_IN_FLIGHT_STATUSES as readonly string[]).includes(r.status),
|
||||
)
|
||||
: undefined;
|
||||
const enforcingProgress =
|
||||
enforcingRun?.currentStep != null && enforcingRun.stepCount
|
||||
? Math.round((enforcingRun.currentStep / enforcingRun.stepCount) * 100)
|
||||
: undefined;
|
||||
const makeEnforcingTooltip = (action: string): React.ReactNode => (
|
||||
<Stack gap={6} py={2} w={200}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<ShieldOutlinedIcon style={{ fontSize: 13 }} />
|
||||
<Text size="xs" fw={600}>
|
||||
{t(
|
||||
"policy.blockingAction",
|
||||
"{{action}} blocked while enforcing policy, please wait",
|
||||
{ action },
|
||||
)}
|
||||
</Text>
|
||||
</Group>
|
||||
{enforcingProgress != null ? (
|
||||
<Progress
|
||||
w="100%"
|
||||
size="xs"
|
||||
radius="xl"
|
||||
value={enforcingProgress}
|
||||
striped
|
||||
animated
|
||||
/>
|
||||
) : (
|
||||
<Loader size="xs" />
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
const pageEditorTotalPages = pageEditorFunctions?.totalPages ?? 0;
|
||||
const pageEditorSelectedCount =
|
||||
pageEditorFunctions?.selectedPageIds?.length ?? 0;
|
||||
@@ -504,13 +565,18 @@ export default function WorkbenchBar({
|
||||
className="workbench-bar-action-icon"
|
||||
onClick={handlePrint}
|
||||
disabled={
|
||||
totalItems === 0 || allButtonsDisabled || disableForFullscreen
|
||||
totalItems === 0 ||
|
||||
allButtonsDisabled ||
|
||||
disableForFullscreen ||
|
||||
policyEnforcing
|
||||
}
|
||||
aria-label={t("workbenchBar.print", "Print PDF")}
|
||||
>
|
||||
<PrintIcon sx={{ fontSize: "1rem" }} />
|
||||
</ActionIcon>,
|
||||
t("workbenchBar.print", "Print PDF"),
|
||||
policyEnforcing
|
||||
? makeEnforcingTooltip(t("workbenchBar.print", "Print PDF"))
|
||||
: t("workbenchBar.print", "Print PDF"),
|
||||
)}
|
||||
|
||||
{/* Download (file-level action — not relevant in custom views) */}
|
||||
@@ -522,7 +588,10 @@ export default function WorkbenchBar({
|
||||
className="workbench-bar-action-icon"
|
||||
onClick={() => handleExportAll()}
|
||||
disabled={
|
||||
disableForFullscreen || totalItems === 0 || allButtonsDisabled
|
||||
disableForFullscreen ||
|
||||
totalItems === 0 ||
|
||||
allButtonsDisabled ||
|
||||
policyEnforcing
|
||||
}
|
||||
aria-label={downloadTooltip}
|
||||
>
|
||||
@@ -532,7 +601,9 @@ export default function WorkbenchBar({
|
||||
height="1rem"
|
||||
/>
|
||||
</ActionIcon>,
|
||||
downloadTooltip,
|
||||
policyEnforcing
|
||||
? makeEnforcingTooltip(downloadTooltip)
|
||||
: downloadTooltip,
|
||||
)}
|
||||
|
||||
{/* Save As */}
|
||||
@@ -545,7 +616,10 @@ export default function WorkbenchBar({
|
||||
className="workbench-bar-action-icon"
|
||||
onClick={() => handleExportAll(true)}
|
||||
disabled={
|
||||
disableForFullscreen || totalItems === 0 || allButtonsDisabled
|
||||
disableForFullscreen ||
|
||||
totalItems === 0 ||
|
||||
allButtonsDisabled ||
|
||||
policyEnforcing
|
||||
}
|
||||
aria-label={t("workbenchBar.saveAs", "Save As")}
|
||||
>
|
||||
@@ -555,7 +629,9 @@ export default function WorkbenchBar({
|
||||
height="1rem"
|
||||
/>
|
||||
</ActionIcon>,
|
||||
t("workbenchBar.saveAs", "Save As"),
|
||||
policyEnforcing
|
||||
? makeEnforcingTooltip(t("workbenchBar.saveAs", "Save As"))
|
||||
: t("workbenchBar.saveAs", "Save As"),
|
||||
)}
|
||||
|
||||
{/* Separator: export group | close */}
|
||||
|
||||
@@ -47,6 +47,7 @@ import { useWheelZoom } from "@app/hooks/useWheelZoom";
|
||||
import { useFormFill } from "@app/tools/formFill/FormFillContext";
|
||||
import { FormSaveBar } from "@app/tools/formFill/FormSaveBar";
|
||||
import { useViewerKeyCommand } from "@app/hooks/useViewerKeyCommand";
|
||||
import { usePolicyFileBadges } from "@app/hooks/usePolicyFileBadges";
|
||||
import { alert } from "@app/components/toast";
|
||||
|
||||
// ─── Measure dictionary extraction ────────────────────────────────────────────
|
||||
@@ -443,6 +444,15 @@ const EmbedPdfViewerContent = ({
|
||||
|
||||
const viewerKeyCommand = useViewerKeyCommand();
|
||||
|
||||
const policyFileBadges = usePolicyFileBadges();
|
||||
const policyEnforcing =
|
||||
!!activeFileId &&
|
||||
(policyFileBadges.get(activeFileId) ?? []).some((p) => p.enforcing);
|
||||
// Use a ref so the keydown handler always reads the latest value without
|
||||
// needing to be in the effect's dependency array.
|
||||
const policyEnforcingRef = useRef(false);
|
||||
policyEnforcingRef.current = policyEnforcing;
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
@@ -468,7 +478,9 @@ const EmbedPdfViewerContent = ({
|
||||
case "p":
|
||||
case "P":
|
||||
event.preventDefault();
|
||||
printActions.print();
|
||||
if (!policyEnforcingRef.current) {
|
||||
printActions.print();
|
||||
}
|
||||
return;
|
||||
case "a":
|
||||
case "A":
|
||||
@@ -1333,6 +1345,7 @@ const EmbedPdfViewerContent = ({
|
||||
file={currentFile ?? null}
|
||||
isFormFillToolActive={isFormFillToolActive}
|
||||
onApply={handleFormApply}
|
||||
policyEnforcing={policyEnforcing}
|
||||
/>
|
||||
<StampPlacementOverlay
|
||||
containerRef={pdfContainerRef}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState } from "react";
|
||||
import { Group, Modal, Stack, Text } from "@mantine/core";
|
||||
import { Group, Loader, Modal, Progress, Stack, Text } from "@mantine/core";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ShareIcon from "@mui/icons-material/Share";
|
||||
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
|
||||
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
|
||||
import { Tooltip } from "@app/components/shared/Tooltip";
|
||||
import ShareManagementModal from "@app/components/shared/ShareManagementModal";
|
||||
@@ -15,6 +16,11 @@ import { alert } from "@app/components/toast";
|
||||
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from "@app/styles/zIndex";
|
||||
import type { StirlingFileStub } from "@app/types/fileContext";
|
||||
import type { FileId } from "@app/types/file";
|
||||
import { usePolicyFileBadges } from "@app/hooks/usePolicyFileBadges";
|
||||
import {
|
||||
POLICY_IN_FLIGHT_STATUSES,
|
||||
usePolicyRuns,
|
||||
} from "@app/components/policies/policyRunStore";
|
||||
|
||||
interface ViewerShareButtonProps {
|
||||
disabled?: boolean;
|
||||
@@ -48,8 +54,54 @@ export default function ViewerShareButton({
|
||||
? stubs.find((s) => s.id === activeFileId)
|
||||
: undefined;
|
||||
|
||||
const policyFileBadges = usePolicyFileBadges();
|
||||
const runs = usePolicyRuns();
|
||||
const enforcing =
|
||||
!!activeFileId &&
|
||||
(policyFileBadges.get(activeFileId) ?? []).some((p) => p.enforcing);
|
||||
const enforcingRun = enforcing
|
||||
? runs.find(
|
||||
(r) =>
|
||||
r.fileId === activeFileId &&
|
||||
(POLICY_IN_FLIGHT_STATUSES as readonly string[]).includes(r.status),
|
||||
)
|
||||
: undefined;
|
||||
const enforcingProgress =
|
||||
enforcingRun?.currentStep != null && enforcingRun.stepCount
|
||||
? Math.round((enforcingRun.currentStep / enforcingRun.stepCount) * 100)
|
||||
: undefined;
|
||||
|
||||
const label = t("workbenchBar.share", "Share");
|
||||
const isDisabled = Boolean(disabled) || !stub;
|
||||
const isDisabled = Boolean(disabled) || !stub || enforcing;
|
||||
|
||||
const tooltipContent = enforcing ? (
|
||||
<Stack gap={6} py={2} w={200}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<ShieldOutlinedIcon style={{ fontSize: 13 }} />
|
||||
<Text size="xs" fw={600}>
|
||||
{t(
|
||||
"policy.blockingAction",
|
||||
"{{action}} blocked while enforcing policy, please wait",
|
||||
{ action: label },
|
||||
)}
|
||||
</Text>
|
||||
</Group>
|
||||
{enforcingProgress != null ? (
|
||||
<Progress
|
||||
w="100%"
|
||||
size="xs"
|
||||
radius="xl"
|
||||
value={enforcingProgress}
|
||||
striped
|
||||
animated
|
||||
/>
|
||||
) : (
|
||||
<Loader size="xs" />
|
||||
)}
|
||||
</Stack>
|
||||
) : (
|
||||
label
|
||||
);
|
||||
|
||||
const openShare = (target: StirlingFileStub) => {
|
||||
setShareStub(target);
|
||||
@@ -125,7 +177,7 @@ export default function ViewerShareButton({
|
||||
return (
|
||||
<>
|
||||
<Tooltip
|
||||
content={label}
|
||||
content={tooltipContent}
|
||||
position="bottom"
|
||||
offset={6}
|
||||
arrow
|
||||
|
||||
@@ -244,6 +244,7 @@ function FileContextInner({
|
||||
/** Persist to IDB without dispatching to workspace state. */
|
||||
skipWorkspaceDispatch?: boolean;
|
||||
skipUploadTracking?: boolean;
|
||||
derivedFromTool?: boolean;
|
||||
},
|
||||
): Promise<StirlingFile[]> => {
|
||||
const stirlingFiles = await addFiles(
|
||||
|
||||
@@ -254,6 +254,9 @@ interface AddFileOptions {
|
||||
) => Promise<boolean>; // Optional callback to confirm extraction of large ZIP files
|
||||
allowDuplicates?: boolean;
|
||||
skipUploadTracking?: boolean;
|
||||
/** When true, marks every added stub as derivedFromTool so the policy
|
||||
* auto-run skips it — used for policy outputs imported via addFiles. */
|
||||
derivedFromTool?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -368,6 +371,7 @@ export async function addFiles(
|
||||
|
||||
// Create new filestub with minimal metadata; hydrate thumbnails/processedFile asynchronously
|
||||
const fileStub = createNewStirlingFileStub(file, fileId);
|
||||
if (options.derivedFromTool) fileStub.derivedFromTool = true;
|
||||
|
||||
// Early encryption detection for PDFs — set the flag before dispatch so the
|
||||
// viewer gate and modal queue pick it up immediately instead of after hydration
|
||||
|
||||
+84
-2
@@ -1,5 +1,12 @@
|
||||
import { buildChangeMetadataFormData } from "@app/hooks/tools/changeMetadata/useChangeMetadataOperation";
|
||||
import { ChangeMetadataParameters } from "@app/hooks/tools/changeMetadata/useChangeMetadataParameters";
|
||||
import {
|
||||
buildChangeMetadataFormData,
|
||||
changeMetadataToApiParams,
|
||||
changeMetadataFromApiParams,
|
||||
} from "@app/hooks/tools/changeMetadata/useChangeMetadataOperation";
|
||||
import {
|
||||
ChangeMetadataParameters,
|
||||
defaultParameters,
|
||||
} from "@app/hooks/tools/changeMetadata/useChangeMetadataParameters";
|
||||
import { TrappedStatus } from "@app/types/metadata";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
@@ -142,3 +149,78 @@ describe("buildChangeMetadataFormData", () => {
|
||||
expect(formData.get("allRequestParams[customValue1]")).toBe("Engineering");
|
||||
});
|
||||
});
|
||||
|
||||
describe("changeMetadata mappers", () => {
|
||||
const fullApi: Parameters<typeof changeMetadataFromApiParams>[0] = {
|
||||
title: "Title",
|
||||
author: "Author",
|
||||
subject: "Subject",
|
||||
keywords: "a, b",
|
||||
creator: "Creator",
|
||||
producer: "Producer",
|
||||
creationDate: "2024/01/15 10:30:00",
|
||||
modificationDate: "2024/02/20 14:05:09",
|
||||
trapped: "True",
|
||||
deleteAll: false,
|
||||
allRequestParams: {
|
||||
customKey1: "Department",
|
||||
customValue1: "Engineering",
|
||||
customKey2: "Project",
|
||||
customValue2: "Falcon",
|
||||
},
|
||||
};
|
||||
|
||||
test("round-trips a full request through fromApiParams and back", () => {
|
||||
const roundTripped = changeMetadataToApiParams({
|
||||
...defaultParameters,
|
||||
...changeMetadataFromApiParams(fullApi),
|
||||
});
|
||||
|
||||
expect(roundTripped).toEqual(fullApi);
|
||||
});
|
||||
|
||||
test("reconstructs dates in local time and clears absent ones", () => {
|
||||
const params = changeMetadataFromApiParams(fullApi);
|
||||
expect(params.creationDate).toEqual(new Date(2024, 0, 15, 10, 30, 0));
|
||||
expect(params.modificationDate).toEqual(new Date(2024, 1, 20, 14, 5, 9));
|
||||
|
||||
const cleared = changeMetadataFromApiParams({
|
||||
creationDate: "",
|
||||
modificationDate: undefined,
|
||||
});
|
||||
expect(cleared.creationDate).toBeNull();
|
||||
expect(cleared.modificationDate).toBeNull();
|
||||
});
|
||||
|
||||
test.each([TrappedStatus.TRUE, TrappedStatus.FALSE, TrappedStatus.UNKNOWN])(
|
||||
"round-trips trapped=%s",
|
||||
(trapped) => {
|
||||
const api = changeMetadataToApiParams({ ...defaultParameters, trapped });
|
||||
expect(api.trapped).toBe(trapped);
|
||||
expect(changeMetadataFromApiParams(api).trapped).toBe(trapped);
|
||||
},
|
||||
);
|
||||
|
||||
test("falls back to the default for an unrecognised trapped value", () => {
|
||||
const params = changeMetadataFromApiParams({
|
||||
trapped: "Bogus",
|
||||
} as unknown as Parameters<typeof changeMetadataFromApiParams>[0]);
|
||||
expect(params.trapped).toBe(defaultParameters.trapped);
|
||||
});
|
||||
|
||||
test("reconstructs custom metadata, tolerating non-contiguous indices", () => {
|
||||
const params = changeMetadataFromApiParams({
|
||||
allRequestParams: {
|
||||
customKey1: "Department",
|
||||
customValue1: "Engineering",
|
||||
customKey3: "Project",
|
||||
customValue3: "Falcon",
|
||||
},
|
||||
});
|
||||
|
||||
expect(params.customMetadata).toEqual([
|
||||
{ key: "Department", value: "Engineering", id: "custom1" },
|
||||
{ key: "Project", value: "Falcon", id: "custom3" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
+126
-46
@@ -3,13 +3,22 @@ import {
|
||||
useToolOperation,
|
||||
defineSingleFileTool,
|
||||
} from "@app/hooks/tools/shared/useToolOperation";
|
||||
import {
|
||||
objectToFormData,
|
||||
type ToolApiParams,
|
||||
type ToolEndpoint,
|
||||
} from "@app/hooks/tools/shared/toolApiMapping";
|
||||
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
|
||||
import { TrappedStatus, CustomMetadataEntry } from "@app/types/metadata";
|
||||
import {
|
||||
ChangeMetadataParameters,
|
||||
defaultParameters,
|
||||
} from "@app/hooks/tools/changeMetadata/useChangeMetadataParameters";
|
||||
|
||||
// Helper function to format Date object to string
|
||||
const ENDPOINT = "/api/v1/misc/update-metadata" satisfies ToolEndpoint;
|
||||
type ChangeMetadataApiParams = ToolApiParams[typeof ENDPOINT];
|
||||
|
||||
// Backend date format (yyyy/MM/dd HH:mm:ss), in local time to mirror the parser.
|
||||
const formatDateForBackend = (date: Date | null): string => {
|
||||
if (!date) return "";
|
||||
const year = date.getFullYear();
|
||||
@@ -21,64 +30,135 @@ const formatDateForBackend = (date: Date | null): string => {
|
||||
return `${year}/${month}/${day} ${hours}:${minutes}:${seconds}`;
|
||||
};
|
||||
|
||||
// Inverse of formatDateForBackend; returns null for empty or unparseable input.
|
||||
const parseDateFromBackend = (value: string | undefined): Date | null => {
|
||||
if (!value) return null;
|
||||
const match = /^(\d{4})\/(\d{2})\/(\d{2}) (\d{2}):(\d{2}):(\d{2})$/.exec(
|
||||
value,
|
||||
);
|
||||
if (!match) return null;
|
||||
const [, year, month, day, hours, minutes, seconds] = match;
|
||||
return new Date(
|
||||
Number(year),
|
||||
Number(month) - 1,
|
||||
Number(day),
|
||||
Number(hours),
|
||||
Number(minutes),
|
||||
Number(seconds),
|
||||
);
|
||||
};
|
||||
|
||||
// Custom metadata is carried in the request's allRequestParams map as paired
|
||||
// customKey<N>/customValue<N> entries; the backend rejoins them by the shared
|
||||
// index N (see MetadataController). Only entries with a non-blank key and value
|
||||
// are sent, and they are re-numbered from 1 so the indices stay contiguous.
|
||||
const buildCustomMetadataMap = (
|
||||
customMetadata: CustomMetadataEntry[],
|
||||
): Record<string, string> | undefined => {
|
||||
const validEntries = customMetadata.filter(
|
||||
(entry) => entry.key.trim() && entry.value.trim(),
|
||||
);
|
||||
if (validEntries.length === 0) return undefined;
|
||||
|
||||
const map: Record<string, string> = {};
|
||||
validEntries.forEach((entry, index) => {
|
||||
const n = index + 1;
|
||||
map[`customKey${n}`] = entry.key.trim();
|
||||
map[`customValue${n}`] = entry.value.trim();
|
||||
});
|
||||
return map;
|
||||
};
|
||||
|
||||
// Rebuild the UI's custom metadata list from the allRequestParams map by pairing
|
||||
// customKey<N>/customValue<N> on their shared index N. Mirrors the backend
|
||||
// (MetadataController), which pairs by index across all entries and does not
|
||||
// assume the indices are contiguous, so a non-contiguous stored map round-trips.
|
||||
const parseCustomMetadataMap = (
|
||||
allRequestParams: ChangeMetadataApiParams["allRequestParams"],
|
||||
): CustomMetadataEntry[] => {
|
||||
if (!allRequestParams) return [];
|
||||
return Object.keys(allRequestParams)
|
||||
.map((key) => /^customKey(\d+)$/.exec(key)?.[1])
|
||||
.filter((n): n is string => n !== undefined)
|
||||
.map(Number)
|
||||
.sort((a, b) => a - b)
|
||||
.map((n) => ({
|
||||
key: allRequestParams[`customKey${n}`] ?? "",
|
||||
value: allRequestParams[`customValue${n}`] ?? "",
|
||||
id: `custom${n}`,
|
||||
}));
|
||||
};
|
||||
|
||||
// Map the backend's trapped string onto the UI enum, validating against the
|
||||
// actual enum values so an unrecognised value falls back to the default instead
|
||||
// of being force-cast.
|
||||
const parseTrapped = (value: string | undefined): TrappedStatus =>
|
||||
Object.values(TrappedStatus).find((status) => status === value) ??
|
||||
defaultParameters.trapped;
|
||||
|
||||
// Convert the tool's UI parameters into the update-metadata request body. The
|
||||
// return type is the generated backend model, so a spec change that renames or
|
||||
// drops a field breaks the build here.
|
||||
export const changeMetadataToApiParams = (
|
||||
parameters: ChangeMetadataParameters,
|
||||
): ChangeMetadataApiParams => ({
|
||||
title: parameters.title,
|
||||
author: parameters.author,
|
||||
subject: parameters.subject,
|
||||
keywords: parameters.keywords,
|
||||
creator: parameters.creator,
|
||||
producer: parameters.producer,
|
||||
creationDate: formatDateForBackend(parameters.creationDate),
|
||||
modificationDate: formatDateForBackend(parameters.modificationDate),
|
||||
trapped: parameters.trapped,
|
||||
deleteAll: parameters.deleteAll,
|
||||
allRequestParams: buildCustomMetadataMap(parameters.customMetadata),
|
||||
});
|
||||
|
||||
// Reconstruct the tool's UI parameters from an update-metadata request body, so
|
||||
// a stored or AI-authored step can be re-rendered in the settings UI.
|
||||
export const changeMetadataFromApiParams = (
|
||||
apiParams: ChangeMetadataApiParams,
|
||||
): Partial<ChangeMetadataParameters> => ({
|
||||
title: apiParams.title ?? defaultParameters.title,
|
||||
author: apiParams.author ?? defaultParameters.author,
|
||||
subject: apiParams.subject ?? defaultParameters.subject,
|
||||
keywords: apiParams.keywords ?? defaultParameters.keywords,
|
||||
creator: apiParams.creator ?? defaultParameters.creator,
|
||||
producer: apiParams.producer ?? defaultParameters.producer,
|
||||
creationDate: parseDateFromBackend(apiParams.creationDate),
|
||||
modificationDate: parseDateFromBackend(apiParams.modificationDate),
|
||||
trapped: parseTrapped(apiParams.trapped),
|
||||
deleteAll: apiParams.deleteAll ?? defaultParameters.deleteAll,
|
||||
customMetadata: parseCustomMetadataMap(apiParams.allRequestParams),
|
||||
});
|
||||
|
||||
// Static function that can be used by both the hook and automation executor
|
||||
export const buildChangeMetadataFormData = (
|
||||
parameters: ChangeMetadataParameters,
|
||||
file: File,
|
||||
): FormData => {
|
||||
const formData = new FormData();
|
||||
formData.append("fileInput", file);
|
||||
|
||||
// Standard metadata fields
|
||||
formData.append("title", parameters.title || "");
|
||||
formData.append("author", parameters.author || "");
|
||||
formData.append("subject", parameters.subject || "");
|
||||
formData.append("keywords", parameters.keywords || "");
|
||||
formData.append("creator", parameters.creator || "");
|
||||
formData.append("producer", parameters.producer || "");
|
||||
|
||||
// Date fields - convert Date objects to strings
|
||||
formData.append(
|
||||
"creationDate",
|
||||
formatDateForBackend(parameters.creationDate),
|
||||
);
|
||||
formData.append(
|
||||
"modificationDate",
|
||||
formatDateForBackend(parameters.modificationDate),
|
||||
);
|
||||
|
||||
// Trapped status
|
||||
formData.append("trapped", parameters.trapped || "");
|
||||
|
||||
// Delete all metadata flag
|
||||
formData.append("deleteAll", parameters.deleteAll.toString());
|
||||
|
||||
// Custom metadata - backend expects them as values to 'allRequestParams[customKeyX/customValueX]'
|
||||
let keyNumber = 0;
|
||||
if (parameters.customMetadata && Array.isArray(parameters.customMetadata)) {
|
||||
parameters.customMetadata.forEach((entry) => {
|
||||
if (entry.key.trim() && entry.value.trim()) {
|
||||
keyNumber += 1;
|
||||
formData.append(
|
||||
`allRequestParams[customKey${keyNumber}]`,
|
||||
entry.key.trim(),
|
||||
);
|
||||
formData.append(
|
||||
`allRequestParams[customValue${keyNumber}]`,
|
||||
entry.value.trim(),
|
||||
);
|
||||
}
|
||||
});
|
||||
// allRequestParams is a Spring-bound map: objectToFormData only serializes
|
||||
// primitives, so the scalar fields go through it and the map is flattened into
|
||||
// allRequestParams[<key>] form fields separately.
|
||||
const { allRequestParams, ...scalarParams } =
|
||||
changeMetadataToApiParams(parameters);
|
||||
const formData = objectToFormData(scalarParams, { fileInput: file });
|
||||
for (const [key, value] of Object.entries(allRequestParams ?? {})) {
|
||||
if (value !== undefined) {
|
||||
formData.append(`allRequestParams[${key}]`, value);
|
||||
}
|
||||
}
|
||||
|
||||
return formData;
|
||||
};
|
||||
|
||||
// Static configuration object
|
||||
export const changeMetadataOperationConfig = defineSingleFileTool({
|
||||
buildFormData: buildChangeMetadataFormData,
|
||||
toApiParams: changeMetadataToApiParams,
|
||||
fromApiParams: changeMetadataFromApiParams,
|
||||
operationType: "changeMetadata",
|
||||
endpoint: "/api/v1/misc/update-metadata",
|
||||
endpoint: ENDPOINT,
|
||||
defaultParameters,
|
||||
});
|
||||
|
||||
|
||||
@@ -71,6 +71,8 @@ export const redactOperationConfig = defineSingleFileTool({
|
||||
operationType: "redact",
|
||||
endpoint: (parameters: RedactParameters) =>
|
||||
parameters.mode === "automatic" ? AUTO_ENDPOINT : null,
|
||||
// Routing set: `mode` is frontend-only, so a stored step matches by this rather than by replay.
|
||||
endpoints: [AUTO_ENDPOINT],
|
||||
defaultParameters,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { type RegistryToolOperationConfig } from "@app/hooks/tools/shared/toolOperationTypes";
|
||||
import {
|
||||
ToolType,
|
||||
type RegistryToolOperationConfig,
|
||||
} from "@app/hooks/tools/shared/toolOperationTypes";
|
||||
import { objectToFormData } from "@app/hooks/tools/shared/toolApiMapping";
|
||||
|
||||
// Pilot tools.
|
||||
@@ -17,6 +20,7 @@ import { adjustPageScaleOperationConfig } from "@app/hooks/tools/adjustPageScale
|
||||
import { autoRenameOperationConfig } from "@app/hooks/tools/autoRename/useAutoRenameOperation";
|
||||
import { bookletImpositionOperationConfig } from "@app/hooks/tools/bookletImposition/useBookletImpositionOperation";
|
||||
import { certSignOperationConfig } from "@app/hooks/tools/certSign/useCertSignOperation";
|
||||
import { changeMetadataOperationConfig } from "@app/hooks/tools/changeMetadata/useChangeMetadataOperation";
|
||||
import { changePermissionsOperationConfig } from "@app/hooks/tools/changePermissions/useChangePermissionsOperation";
|
||||
import { cropOperationConfig } from "@app/hooks/tools/crop/useCropOperation";
|
||||
import { editTableOfContentsOperationConfig } from "@app/hooks/tools/editTableOfContents/useEditTableOfContentsOperation";
|
||||
@@ -57,6 +61,7 @@ const MIGRATED_CONFIGS = [
|
||||
autoRenameOperationConfig,
|
||||
bookletImpositionOperationConfig,
|
||||
certSignOperationConfig,
|
||||
changeMetadataOperationConfig,
|
||||
changePermissionsOperationConfig,
|
||||
cropOperationConfig,
|
||||
editTableOfContentsOperationConfig,
|
||||
@@ -102,15 +107,26 @@ describe("migrated tool mappers (sweep)", () => {
|
||||
expect(config.toApiParams).toBeDefined();
|
||||
expect(config.fromApiParams).toBeDefined();
|
||||
|
||||
// toApiParams(defaults) must produce a body objectToFormData can serialize
|
||||
// (i.e. only primitives / arrays of primitives). A mapper that leaked a
|
||||
// structured value would throw here.
|
||||
// Serialize the defaults through the tool's own buildFormData - the real
|
||||
// path the executor uses - so a tool whose toApiParams carries a structured
|
||||
// field that buildFormData flattens itself (e.g. changeMetadata's
|
||||
// allRequestParams map) is exercised too, not just tools whose mapper
|
||||
// output is directly objectToFormData-able. Custom tools have no
|
||||
// buildFormData, so fall back to serializing the mapper output directly.
|
||||
const params =
|
||||
config.defaultParameters ?? FALLBACK_PARAMS[config.operationType] ?? {};
|
||||
const apiParams = config.toApiParams!(params);
|
||||
expect(() =>
|
||||
objectToFormData(apiParams, { fileInput: file }),
|
||||
).not.toThrow();
|
||||
if (config.toolType === ToolType.multiFile) {
|
||||
const build = config.buildFormData;
|
||||
expect(() => build(params, [file])).not.toThrow();
|
||||
} else if (config.toolType === ToolType.singleFile) {
|
||||
const build = config.buildFormData;
|
||||
expect(() => build(params, file)).not.toThrow();
|
||||
} else {
|
||||
const toApiParams = config.toApiParams!;
|
||||
expect(() =>
|
||||
objectToFormData(toApiParams(params), { fileInput: file }),
|
||||
).not.toThrow();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
isToolEndpoint,
|
||||
objectToFormData,
|
||||
type ToolApiParams,
|
||||
} from "@app/hooks/tools/shared/toolApiMapping";
|
||||
|
||||
describe("isToolEndpoint", () => {
|
||||
test("accepts a generated endpoint and rejects an unknown path", () => {
|
||||
expect(isToolEndpoint("/api/v1/misc/compress-pdf")).toBe(true);
|
||||
expect(isToolEndpoint("/api/v1/misc/not-a-real-tool")).toBe(false);
|
||||
expect(isToolEndpoint("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("objectToFormData", () => {
|
||||
test("serializes primitive fields to string form values", () => {
|
||||
const request: ToolApiParams["/api/v1/misc/compress-pdf"] = {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
TOOL_ENDPOINTS,
|
||||
type ToolApiParams,
|
||||
type ToolApiRequest,
|
||||
type ToolEndpoint,
|
||||
@@ -6,6 +7,16 @@ import {
|
||||
|
||||
export type { ToolApiParams, ToolApiRequest, ToolEndpoint };
|
||||
|
||||
const TOOL_ENDPOINT_SET: ReadonlySet<string> = new Set(TOOL_ENDPOINTS);
|
||||
|
||||
/**
|
||||
* Runtime check that a string is one of the generated tool endpoints, so callers can narrow an
|
||||
* arbitrary endpoint path to {@link ToolEndpoint} against the real supported set rather than casting.
|
||||
*/
|
||||
export function isToolEndpoint(value: string): value is ToolEndpoint {
|
||||
return TOOL_ENDPOINT_SET.has(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapping for tools that take only a file and have no request parameters (their
|
||||
* generated model is `Record<string, never>`). Both directions are empty; the
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
ToolCategoryId,
|
||||
SubcategoryId,
|
||||
type ToolRegistry,
|
||||
type ToolRegistryEntry,
|
||||
} from "@app/data/toolsTaxonomy";
|
||||
import { type ToolId } from "@app/types/toolId";
|
||||
import {
|
||||
asRegistryConfig,
|
||||
ToolType,
|
||||
} from "@app/hooks/tools/shared/toolOperationTypes";
|
||||
import {
|
||||
deserializeToolStep,
|
||||
getExecutableTools,
|
||||
serializeToolStep,
|
||||
stepRequiresUpload,
|
||||
type WorkingToolStep,
|
||||
} from "@app/hooks/tools/shared/toolAutomation";
|
||||
import { compressOperationConfig } from "@app/hooks/tools/compress/useCompressOperation";
|
||||
import { defaultParameters as compressDefaults } from "@app/hooks/tools/compress/useCompressParameters";
|
||||
import { splitOperationConfig } from "@app/hooks/tools/split/useSplitOperation";
|
||||
import { SPLIT_METHODS } from "@app/constants/splitConstants";
|
||||
import { redactOperationConfig } from "@app/hooks/tools/redact/useRedactOperation";
|
||||
|
||||
function entry(over: Partial<ToolRegistryEntry>): ToolRegistryEntry {
|
||||
return {
|
||||
icon: null,
|
||||
name: "",
|
||||
component: null,
|
||||
description: "",
|
||||
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
|
||||
subcategoryId: SubcategoryId.GENERAL,
|
||||
automationSettings: null,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
const NoopSettings = () => null;
|
||||
|
||||
// A migrated, param-less config (mappers present, no settings UI) -> "noSettings".
|
||||
const repairConfig = asRegistryConfig({
|
||||
toolType: ToolType.singleFile,
|
||||
operationType: "repair",
|
||||
endpoint: "/api/v1/misc/repair",
|
||||
defaultParameters: {},
|
||||
buildFormData: () => new FormData(),
|
||||
toApiParams: () => ({}),
|
||||
fromApiParams: () => ({}),
|
||||
});
|
||||
|
||||
// A config with no mappers (not migrated) -> "unsupported".
|
||||
const changeMetadataConfig = asRegistryConfig({
|
||||
toolType: ToolType.singleFile,
|
||||
operationType: "changeMetadata",
|
||||
endpoint: "/api/v1/misc/update-metadata",
|
||||
defaultParameters: {},
|
||||
buildFormData: () => new FormData(),
|
||||
});
|
||||
|
||||
const registry: Partial<ToolRegistry> = {
|
||||
compress: entry({
|
||||
name: "Compress",
|
||||
automationSettings: NoopSettings,
|
||||
operationConfig: asRegistryConfig(compressOperationConfig),
|
||||
}),
|
||||
repair: entry({ name: "Repair", operationConfig: repairConfig }),
|
||||
changeMetadata: entry({
|
||||
name: "Change metadata",
|
||||
automationSettings: NoopSettings,
|
||||
operationConfig: changeMetadataConfig,
|
||||
}),
|
||||
// Excluded: automation explicitly off.
|
||||
sign: entry({
|
||||
name: "Sign",
|
||||
supportsAutomate: false,
|
||||
operationConfig: repairConfig,
|
||||
}),
|
||||
// Excluded: no operationConfig at all.
|
||||
extractPages: entry({ name: "Extract pages" }),
|
||||
};
|
||||
|
||||
// Separate registry so these don't change the getExecutableTools expectations above.
|
||||
const dynamicRegistry: Partial<ToolRegistry> = {
|
||||
split: entry({
|
||||
name: "Split",
|
||||
automationSettings: NoopSettings,
|
||||
operationConfig: asRegistryConfig(splitOperationConfig),
|
||||
}),
|
||||
redact: entry({
|
||||
name: "Redact",
|
||||
automationSettings: NoopSettings,
|
||||
operationConfig: asRegistryConfig(redactOperationConfig),
|
||||
}),
|
||||
};
|
||||
|
||||
describe("getExecutableTools", () => {
|
||||
test("lists automatable tools with a resolvable endpoint, classified by support", () => {
|
||||
const tools = getExecutableTools(registry);
|
||||
expect(tools.map((t) => t.toolId)).toEqual([
|
||||
"changeMetadata",
|
||||
"compress",
|
||||
"repair",
|
||||
]);
|
||||
expect(Object.fromEntries(tools.map((t) => [t.toolId, t.support]))).toEqual(
|
||||
{
|
||||
compress: "editable",
|
||||
repair: "noSettings",
|
||||
changeMetadata: "unsupported",
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("serialize/deserialize round-trip", () => {
|
||||
test("compress maps UI params to the backend body and back", () => {
|
||||
const step: WorkingToolStep = {
|
||||
toolId: "compress" as ToolId,
|
||||
operation: "/api/v1/misc/compress-pdf",
|
||||
params: {
|
||||
...compressDefaults,
|
||||
compressionLevel: 7,
|
||||
compressionMethod: "filesize",
|
||||
fileSizeValue: "2",
|
||||
fileSizeUnit: "MB",
|
||||
},
|
||||
support: "editable",
|
||||
};
|
||||
|
||||
const api = serializeToolStep(step, registry);
|
||||
expect(api.operation).toBe("/api/v1/misc/compress-pdf");
|
||||
expect(api.parameters).toMatchObject({
|
||||
optimizeLevel: 7,
|
||||
expectedOutputSize: "2MB",
|
||||
});
|
||||
|
||||
const back = deserializeToolStep(api, registry);
|
||||
expect(back.toolId).toBe("compress");
|
||||
expect(back.params).toMatchObject({
|
||||
compressionLevel: 7,
|
||||
compressionMethod: "filesize",
|
||||
fileSizeValue: "2",
|
||||
fileSizeUnit: "MB",
|
||||
});
|
||||
});
|
||||
|
||||
test("an unknown endpoint is preserved as an unmapped step", () => {
|
||||
const step = deserializeToolStep(
|
||||
{ operation: "/api/v1/unknown/thing", parameters: { keep: true } },
|
||||
registry,
|
||||
);
|
||||
expect(step.toolId).toBeNull();
|
||||
expect(step.support).toBe("unknown");
|
||||
expect(serializeToolStep(step, registry)).toEqual({
|
||||
operation: "/api/v1/unknown/thing",
|
||||
parameters: { keep: true },
|
||||
});
|
||||
});
|
||||
|
||||
test("a dynamic-endpoint tool (split by chapters) round-trips as an editable step", () => {
|
||||
const step: WorkingToolStep = {
|
||||
toolId: "split" as ToolId,
|
||||
operation: "/api/v1/general/split-pdf-by-chapters",
|
||||
params: { method: SPLIT_METHODS.BY_CHAPTERS, bookmarkLevel: "2" },
|
||||
support: "editable",
|
||||
};
|
||||
|
||||
const api = serializeToolStep(step, dynamicRegistry);
|
||||
expect(api.operation).toBe("/api/v1/general/split-pdf-by-chapters");
|
||||
expect(api.parameters).toMatchObject({ bookmarkLevel: 2 });
|
||||
|
||||
// No `method` in the stored body, so this only matches via the declared endpoint set.
|
||||
const back = deserializeToolStep(api, dynamicRegistry);
|
||||
expect(back.toolId).toBe("split");
|
||||
expect(back.support).toBe("editable");
|
||||
expect(back.operation).toBe("/api/v1/general/split-pdf-by-chapters");
|
||||
expect(back.params).toMatchObject({ method: SPLIT_METHODS.BY_CHAPTERS });
|
||||
});
|
||||
|
||||
test("a dynamic-endpoint tool whose routing field is dropped (redact) stays editable", () => {
|
||||
const step: WorkingToolStep = {
|
||||
toolId: "redact" as ToolId,
|
||||
operation: "/api/v1/security/auto-redact",
|
||||
params: { mode: "automatic", wordsToRedact: ["secret"] },
|
||||
support: "editable",
|
||||
};
|
||||
|
||||
const api = serializeToolStep(step, dynamicRegistry);
|
||||
expect(api.operation).toBe("/api/v1/security/auto-redact");
|
||||
expect(api.parameters).not.toHaveProperty("mode");
|
||||
|
||||
// No `mode` in the body, so it only matches via the declared set (replay would yield null).
|
||||
const back = deserializeToolStep(api, dynamicRegistry);
|
||||
expect(back.toolId).toBe("redact");
|
||||
expect(back.support).toBe("editable");
|
||||
expect(back.operation).toBe("/api/v1/security/auto-redact");
|
||||
expect(back.params).toMatchObject({ mode: "automatic" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("stepRequiresUpload", () => {
|
||||
const step = (params: Record<string, unknown>): WorkingToolStep => ({
|
||||
toolId: "compress" as ToolId,
|
||||
operation: "/api/v1/misc/compress-pdf",
|
||||
params,
|
||||
support: "editable",
|
||||
});
|
||||
|
||||
test("detects a File (or list of Files) among the parameters", () => {
|
||||
const image = new File(["x"], "logo.png", { type: "image/png" });
|
||||
expect(stepRequiresUpload(step({ level: 5 }))).toBe(false);
|
||||
expect(stepRequiresUpload(step({ watermarkImage: image }))).toBe(true);
|
||||
expect(stepRequiresUpload(step({ attachments: [image] }))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* Registry-level automation logic: which tools can be run as a backend operation step, how much
|
||||
* of their parameters can be edited in a UI, and conversion between a tool's frontend parameter
|
||||
* shape and the backend step contract (endpoint + backend parameters).
|
||||
*
|
||||
* This is core behaviour shared by every surface that composes or replays tool operations against
|
||||
* the backend engine (portal pipelines today; backend-executed automations and AI plans later),
|
||||
* so the "is this tool usable, and how" decision lives with the tools rather than in any one
|
||||
* feature. It builds on each tool's `operationConfig` mappers (`toApiParams` / `fromApiParams`),
|
||||
* keeping the frontend<->backend parameter mapping single-sourced in the tools.
|
||||
*/
|
||||
|
||||
import { type ReactNode } from "react";
|
||||
import {
|
||||
getToolSupportsAutomate,
|
||||
type SubcategoryId,
|
||||
type ToolRegistry,
|
||||
type ToolRegistryEntry,
|
||||
} from "@app/data/toolsTaxonomy";
|
||||
import { type ToolId } from "@app/types/toolId";
|
||||
import {
|
||||
isToolEndpoint,
|
||||
type ToolEndpoint,
|
||||
} from "@app/hooks/tools/shared/toolApiMapping";
|
||||
import {
|
||||
type ErasedToolParams,
|
||||
type RegistryToolOperationConfig,
|
||||
} from "@app/hooks/tools/shared/toolOperationTypes";
|
||||
|
||||
/**
|
||||
* How much of a tool's parameters a UI can edit when composing a backend step:
|
||||
* - `editable`: has both mappers and a settings UI -> render the settings UI.
|
||||
* - `noSettings`: migrated to the mapper seam but has no parameters to configure.
|
||||
* - `unsupported`: not migrated to the mapper seam -> parameters can't be mapped for editing yet;
|
||||
* the step runs with the backend's defaults.
|
||||
*/
|
||||
export type ToolStepSupport = "editable" | "noSettings" | "unsupported";
|
||||
|
||||
/** A tool that can be added to a backend operation chain, with its editing support. */
|
||||
export interface ExecutableTool {
|
||||
toolId: ToolId;
|
||||
name: string;
|
||||
icon: ReactNode;
|
||||
/** Subcategory the tool belongs to, so a picker can group tools without re-reading the registry. */
|
||||
subcategoryId: SubcategoryId;
|
||||
/** Endpoint resolved from default parameters, for display/inclusion. The stored step's endpoint is re-resolved from the configured parameters at serialization time. */
|
||||
endpoint: ToolEndpoint;
|
||||
support: ToolStepSupport;
|
||||
}
|
||||
|
||||
/**
|
||||
* The backend step contract: an endpoint path plus its backend-shaped parameters. `operation` is a
|
||||
* plain string, not a {@link ToolEndpoint}, because this is the raw backend boundary (it mirrors
|
||||
* the stored policy step) and a pipeline may reference endpoints the frontend does not model.
|
||||
*/
|
||||
export interface ToolApiStep {
|
||||
operation: string;
|
||||
parameters: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** A step being edited in a UI that maps to a known tool: parameters are in the tool's frontend shape. */
|
||||
export interface KnownToolStep {
|
||||
toolId: ToolId;
|
||||
operation: ToolEndpoint;
|
||||
params: ErasedToolParams;
|
||||
support: ToolStepSupport;
|
||||
}
|
||||
|
||||
/** A stored step whose endpoint maps to no known tool: preserved verbatim, not editable. */
|
||||
export interface UnknownToolStep {
|
||||
toolId: null;
|
||||
operation: string;
|
||||
params: ErasedToolParams;
|
||||
support: "unknown";
|
||||
}
|
||||
|
||||
/** A step being edited in a UI, discriminated by whether its endpoint maps to a known tool. */
|
||||
export type WorkingToolStep = KnownToolStep | UnknownToolStep;
|
||||
|
||||
/**
|
||||
* Resolve a tool's endpoint from parameters. Static endpoints ignore params; dynamic ones may
|
||||
* return undefined if the params don't determine one. The result is validated against the generated
|
||||
* endpoint set (via {@link isToolEndpoint}) rather than cast, so a config endpoint that is not a
|
||||
* known {@link ToolEndpoint} (e.g. a custom tool's arbitrary string) resolves to undefined.
|
||||
*/
|
||||
function resolveEndpoint(
|
||||
config: RegistryToolOperationConfig | undefined,
|
||||
params: ErasedToolParams,
|
||||
): ToolEndpoint | undefined {
|
||||
const endpoint = config?.endpoint;
|
||||
if (typeof endpoint === "string") {
|
||||
return isToolEndpoint(endpoint) ? endpoint : undefined;
|
||||
}
|
||||
if (typeof endpoint === "function") {
|
||||
const resolved = safeCall(endpoint, params);
|
||||
return typeof resolved === "string" && isToolEndpoint(resolved)
|
||||
? resolved
|
||||
: undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Invoke a dynamic-endpoint function defensively; a throw means the params don't determine one. */
|
||||
function safeCall(
|
||||
fn: (params: ErasedToolParams) => string | null | undefined,
|
||||
params: ErasedToolParams,
|
||||
): string | null | undefined {
|
||||
try {
|
||||
return fn(params);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Classify how much of a tool's parameters a UI can edit when composing a step. */
|
||||
export function classifyToolStepSupport(
|
||||
entry: ToolRegistryEntry,
|
||||
): ToolStepSupport {
|
||||
const config = entry.operationConfig;
|
||||
const hasMappers = Boolean(config?.toApiParams && config?.fromApiParams);
|
||||
if (!hasMappers) return "unsupported";
|
||||
return entry.automationSettings ? "editable" : "noSettings";
|
||||
}
|
||||
|
||||
function isFileValue(value: unknown): boolean {
|
||||
if (typeof File === "undefined") return false;
|
||||
if (value instanceof File) return true;
|
||||
return Array.isArray(value) && value.some((item) => item instanceof File);
|
||||
}
|
||||
|
||||
/**
|
||||
* True if any of a step's parameters is an uploaded file (or list of files). Such a step cannot be
|
||||
* saved into a stored pipeline yet: the file bytes are not persisted with the policy, so a later
|
||||
* (e.g. scheduled) run would have nothing to send for that named file field.
|
||||
*/
|
||||
export function stepRequiresUpload(step: WorkingToolStep): boolean {
|
||||
return Object.values(step.params).some(isFileValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* The tools that can be run as a backend operation step, sorted by name. Includes only automatable
|
||||
* tools whose endpoint resolves from defaults (so they can become a backend step); this drops
|
||||
* tools with no operationConfig and tools whose endpoint needs runtime input (e.g. convert).
|
||||
*/
|
||||
export function getExecutableTools(
|
||||
registry: Partial<ToolRegistry>,
|
||||
): ExecutableTool[] {
|
||||
const tools: ExecutableTool[] = [];
|
||||
for (const [id, entry] of Object.entries(registry)) {
|
||||
if (!entry || !getToolSupportsAutomate(entry)) continue;
|
||||
const config = entry.operationConfig;
|
||||
if (!config) continue;
|
||||
const endpoint = resolveEndpoint(config, config.defaultParameters ?? {});
|
||||
if (!endpoint) continue;
|
||||
tools.push({
|
||||
toolId: id as ToolId,
|
||||
name: entry.name,
|
||||
icon: entry.icon,
|
||||
subcategoryId: entry.subcategoryId,
|
||||
endpoint,
|
||||
support: classifyToolStepSupport(entry),
|
||||
});
|
||||
}
|
||||
return tools.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/** A fresh working step for a tool just added to a chain, seeded with its default parameters. */
|
||||
export function newWorkingToolStep(
|
||||
tool: ExecutableTool,
|
||||
registry: Partial<ToolRegistry>,
|
||||
): KnownToolStep {
|
||||
const config = registry[tool.toolId]?.operationConfig;
|
||||
return {
|
||||
toolId: tool.toolId,
|
||||
operation: tool.endpoint,
|
||||
params: { ...(config?.defaultParameters ?? {}) },
|
||||
support: tool.support,
|
||||
};
|
||||
}
|
||||
|
||||
/** Serialize a working step into the backend step contract (endpoint + backend parameters). */
|
||||
export function serializeToolStep(
|
||||
step: WorkingToolStep,
|
||||
registry: Partial<ToolRegistry>,
|
||||
): ToolApiStep {
|
||||
const config =
|
||||
step.toolId !== null ? registry[step.toolId]?.operationConfig : undefined;
|
||||
if (!config) {
|
||||
// Unmapped step (unknown endpoint on edit): round-trip it unchanged.
|
||||
return { operation: step.operation, parameters: step.params };
|
||||
}
|
||||
const merged = { ...(config.defaultParameters ?? {}), ...step.params };
|
||||
const operation = resolveEndpoint(config, merged) ?? step.operation;
|
||||
const parameters = config.toApiParams
|
||||
? (config.toApiParams(merged) as Record<string, unknown>)
|
||||
: {};
|
||||
return { operation, parameters };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* frontend-only routing field). Replay is only the fallback when no set is declared.
|
||||
*/
|
||||
function findToolByEndpoint(
|
||||
step: ToolApiStep,
|
||||
registry: Partial<ToolRegistry>,
|
||||
): [ToolId, ToolRegistryEntry] | undefined {
|
||||
let dynamic: [ToolId, ToolRegistryEntry] | undefined;
|
||||
for (const [id, entry] of Object.entries(registry)) {
|
||||
const endpoint = entry?.operationConfig?.endpoint;
|
||||
if (typeof endpoint === "string") {
|
||||
if (endpoint === step.operation) return [id as ToolId, entry];
|
||||
} else if (typeof endpoint === "function" && !dynamic) {
|
||||
const declared = entry?.operationConfig?.endpoints;
|
||||
const matched = declared
|
||||
? declared.some((e) => e === step.operation)
|
||||
: safeCall(endpoint, step.parameters) === step.operation;
|
||||
if (matched) dynamic = [id as ToolId, entry];
|
||||
}
|
||||
}
|
||||
return dynamic;
|
||||
}
|
||||
|
||||
/** A stored step kept verbatim because its endpoint maps to no known tool. */
|
||||
function unmappedStep(step: ToolApiStep): UnknownToolStep {
|
||||
return {
|
||||
toolId: null,
|
||||
operation: step.operation,
|
||||
params: { ...step.parameters },
|
||||
support: "unknown",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rehydrate a stored backend step into a working step for editing: map the endpoint back to a tool
|
||||
* and its backend parameters back to the frontend shape via `fromApiParams`. Steps whose endpoint
|
||||
* maps to no known tool are kept as an unmapped, non-editable working step (parameters preserved).
|
||||
*/
|
||||
export function deserializeToolStep(
|
||||
step: ToolApiStep,
|
||||
registry: Partial<ToolRegistry>,
|
||||
): WorkingToolStep {
|
||||
const match = findToolByEndpoint(step, registry);
|
||||
if (!match) return unmappedStep(step);
|
||||
const [toolId, entry] = match;
|
||||
const config = entry.operationConfig;
|
||||
const params: ErasedToolParams = config?.fromApiParams
|
||||
? {
|
||||
...(config.defaultParameters ?? {}),
|
||||
...config.fromApiParams(step.parameters as never),
|
||||
}
|
||||
: { ...(config?.defaultParameters ?? {}) };
|
||||
// Validate against the generated endpoint set instead of casting the matched string.
|
||||
const operation =
|
||||
resolveEndpoint(config, params) ??
|
||||
(isToolEndpoint(step.operation) ? step.operation : undefined);
|
||||
if (operation === undefined) return unmappedStep(step);
|
||||
return { toolId, operation, params, support: classifyToolStepSupport(entry) };
|
||||
}
|
||||
@@ -101,9 +101,9 @@ interface BaseToolOperationConfig<TParams, TEndpoint extends ToolEndpoint> {
|
||||
consumesAllInputs?: boolean;
|
||||
}
|
||||
|
||||
export interface SingleFileToolOperationConfig<
|
||||
interface SingleFileToolBody<
|
||||
TParams,
|
||||
TEndpoint extends ToolEndpoint = ToolEndpoint,
|
||||
TEndpoint extends ToolEndpoint,
|
||||
> extends BaseToolOperationConfig<TParams, TEndpoint> {
|
||||
/** This tool processes one file at a time. */
|
||||
toolType: ToolType.singleFile;
|
||||
@@ -111,18 +111,19 @@ export interface SingleFileToolOperationConfig<
|
||||
/** Builds FormData for API request. */
|
||||
buildFormData: (params: TParams, file: File) => FormData;
|
||||
|
||||
/**
|
||||
* API endpoint for the operation, or a function for dynamic routing. `null`
|
||||
* when the operation has no backend endpoint (see {@link ToolOperationEndpoint}).
|
||||
*/
|
||||
endpoint: TEndpoint | null | ((params: TParams) => TEndpoint | null);
|
||||
|
||||
customProcessor?: undefined;
|
||||
}
|
||||
|
||||
export interface MultiFileToolOperationConfig<
|
||||
/** Single-file tool config; see {@link EndpointBinding} for the endpoint/endpoints rule. */
|
||||
export type SingleFileToolOperationConfig<
|
||||
TParams,
|
||||
TEndpoint extends ToolEndpoint = ToolEndpoint,
|
||||
> = SingleFileToolBody<TParams, TEndpoint> &
|
||||
EndpointBinding<TParams, TEndpoint>;
|
||||
|
||||
interface MultiFileToolBody<
|
||||
TParams,
|
||||
TEndpoint extends ToolEndpoint,
|
||||
> extends BaseToolOperationConfig<TParams, TEndpoint> {
|
||||
/** This tool processes multiple files at once. */
|
||||
toolType: ToolType.multiFile;
|
||||
@@ -133,15 +134,15 @@ export interface MultiFileToolOperationConfig<
|
||||
/** Builds FormData for API request. */
|
||||
buildFormData: (params: TParams, files: File[]) => FormData;
|
||||
|
||||
/**
|
||||
* API endpoint for the operation, or a function for dynamic routing. `null`
|
||||
* when the operation has no backend endpoint (see {@link ToolOperationEndpoint}).
|
||||
*/
|
||||
endpoint: TEndpoint | null | ((params: TParams) => TEndpoint | null);
|
||||
|
||||
customProcessor?: undefined;
|
||||
}
|
||||
|
||||
/** Multi-file counterpart of {@link SingleFileToolOperationConfig}. */
|
||||
export type MultiFileToolOperationConfig<
|
||||
TParams,
|
||||
TEndpoint extends ToolEndpoint = ToolEndpoint,
|
||||
> = MultiFileToolBody<TParams, TEndpoint> & EndpointBinding<TParams, TEndpoint>;
|
||||
|
||||
export interface CustomToolOperationConfig<
|
||||
TParams,
|
||||
> extends BaseToolOperationConfig<TParams, ToolEndpoint> {
|
||||
@@ -157,6 +158,9 @@ export interface CustomToolOperationConfig<
|
||||
*/
|
||||
endpoint?: string | ((params: TParams) => string | undefined);
|
||||
|
||||
/** `never` so `endpoints` stays readable across the union; custom tools declare no set. */
|
||||
endpoints?: never;
|
||||
|
||||
/**
|
||||
* Custom processing logic that completely bypasses standard file processing.
|
||||
* This tool handles all API calls, response processing, and file creation.
|
||||
@@ -180,6 +184,23 @@ export type ToolOperationConfig<
|
||||
| MultiFileToolOperationConfig<TParams, TEndpoint>
|
||||
| CustomToolOperationConfig<TParams>;
|
||||
|
||||
/**
|
||||
* A static `endpoint` declares no set; a dynamic (function) `endpoint` must declare its full
|
||||
* `endpoints` set, which is how findToolByEndpoint maps a stored step back to its tool when the
|
||||
* endpoint-selecting parameter is frontend-only.
|
||||
*/
|
||||
type EndpointBinding<TParams, TEndpoint extends ToolEndpoint> =
|
||||
| { endpoint: TEndpoint | null; endpoints?: never }
|
||||
| {
|
||||
endpoint: (params: TParams) => TEndpoint | null;
|
||||
endpoints: readonly TEndpoint[];
|
||||
};
|
||||
|
||||
/** Union-distributing Omit, so stripping a key from a discriminated config keeps its branches. */
|
||||
type DistributiveOmit<T, K extends PropertyKey> = T extends unknown
|
||||
? Omit<T, K>
|
||||
: never;
|
||||
|
||||
/**
|
||||
* Define a single-file tool's operation config. Infers the endpoint literal from
|
||||
* `endpoint` and binds toApiParams/fromApiParams to that endpoint's request
|
||||
@@ -189,9 +210,15 @@ export function defineSingleFileTool<
|
||||
TParams,
|
||||
const TEndpoint extends ToolEndpoint,
|
||||
>(
|
||||
config: Omit<SingleFileToolOperationConfig<TParams, TEndpoint>, "toolType">,
|
||||
config: DistributiveOmit<
|
||||
SingleFileToolOperationConfig<TParams, TEndpoint>,
|
||||
"toolType"
|
||||
>,
|
||||
): SingleFileToolOperationConfig<TParams, TEndpoint> {
|
||||
return { ...config, toolType: ToolType.singleFile };
|
||||
return {
|
||||
...config,
|
||||
toolType: ToolType.singleFile,
|
||||
} as SingleFileToolOperationConfig<TParams, TEndpoint>;
|
||||
}
|
||||
|
||||
/** Multi-file counterpart of {@link defineSingleFileTool}. */
|
||||
@@ -199,9 +226,15 @@ export function defineMultiFileTool<
|
||||
TParams,
|
||||
const TEndpoint extends ToolEndpoint,
|
||||
>(
|
||||
config: Omit<MultiFileToolOperationConfig<TParams, TEndpoint>, "toolType">,
|
||||
config: DistributiveOmit<
|
||||
MultiFileToolOperationConfig<TParams, TEndpoint>,
|
||||
"toolType"
|
||||
>,
|
||||
): MultiFileToolOperationConfig<TParams, TEndpoint> {
|
||||
return { ...config, toolType: ToolType.multiFile };
|
||||
return {
|
||||
...config,
|
||||
toolType: ToolType.multiFile,
|
||||
} as MultiFileToolOperationConfig<TParams, TEndpoint>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user