Commit Graph
247 Commits
Author SHA1 Message Date
ConnorYoh fe33378333 feat(portal): set a spend cap during PAYG checkout (two-step modal) (#6970) 2026-07-11 12:47:04 +01:00
Anthony Stirling 5944cd106b Portal audit: label policy runs by their policy, flag automation sub-steps (#6937) 2026-07-11 12:46:44 +01:00
EthanHealy01 fd81bf4cf8 Tighten whitespace between search bar and tool list (#6977) 2026-07-11 12:44:19 +01:00
EthanHealy01 d23318cfa6 Feature/onboarding updates for policies and portal (#6926) 2026-07-11 12:41:48 +01:00
Anthony Stirling 142544c9af Replace portal sidebar brand text with Stirling Processor wordmark (#6978) 2026-07-11 12:40:55 +01:00
James Brunton 863cad22bd Fix policy running of Redact (#6972)
# Description of Changes
Policies can currently throw when calling redact:

<img width="1186" height="824" alt="image"
src="https://github.com/user-attachments/assets/bdcc09fe-5bf4-4b0a-b119-bcc33c98c7f2"
/>

Policies really need to be updated to properly make use of the new
bidirectional mappings for this, but this will hopefully fix it for now.
2026-07-10 16:24:25 +00:00
EthanHealy01 c06657c8f9 Match external-link tool buttons to normal tool button size (#6974)
The external-link "Developer Tools" buttons (API, Automated Folder
Scanning, SSO Guide, Air-gapped Setup) used `p="sm"` while normal tool
buttons use `p="none"`, making them render larger; this aligns their
padding so they match the size of every other tool button.

<img width="308" height="196" alt="Screenshot 2026-07-10 at 5 01 40 PM"
src="https://github.com/user-attachments/assets/fb125500-28fb-4b83-85ed-2edc12e66fc0"
/>
2026-07-10 16:24:16 +00:00
Anthony Stirling d06a367b87 SaaS role-based login landing (team leads → Processor) (#6960) 2026-07-10 15:23:00 +01:00
ConnorYoh ce6abe6e23 PAYG: size-scaled units + per-input-file PDF count + run-id grouping (#6957)
Reworks the Processor (PAYG) meter to **size-scaled units** while
keeping a true **PDF count** visible and distinct from units, and
replaces the fragile content+time lineage grouping with **explicit
per-run grouping**. Built as one PR across three slices.

> Status: **all three slices committed + verified.** `:saas` payg suite
green (418 tests, 0 failures); FE green (typecheck 0, 1260 tests, lint
0, format 0). Remaining before it takes effect in prod: run the
size-scaled default-policy SQL (below) in the Supabase SQL editor +
attach the $0.01/unit Stripe price.

## Model (what we're implementing)
- **Size scaling**: 1 unit per 50 MiB (bytes only, no page charge, no
cap). *(policy-row config, applied separately via SQL.)*
- **Charge = number of input files**: split (1→N outputs) = 1 charge;
merge (N→1) = N charges. `doc_count` = input files, fixed at open;
joined steps add 0.
- **Grouping by run id, not time**: a pipeline/policy/AI run = one
`run_id`; its tool sub-steps group into one charge (content-lineage
still maps split/merge journeys *within* the run). Two separate runs on
identical bytes = two charges. The 5-min window survives only as a
stale-job janitor.
- **10-tool split kept**: within a run's single-file lineage, an 11th
tool run opens a 2nd charge (step limit 10).
- **Count vs units surfaced**: usage page shows unique PDFs,
per-category (automation/AI/API) counts + units, and how many PDFs hit a
size multiplier with avg units/PDF.

## Slice 1 — run-id grouping (behavioural core)
- `AutomationRunContext` (common) — thread-scoped run id.
- `InternalApiClient` — stamps `X-Stirling-Run-Id`.
- Orchestrators open a run scope **on the worker thread that
dispatches** (async-safe): `PipelineProcessor.runPipelineAgainstFiles`,
`PolicyEngine.runToCompletion` (uses `run.getRunId()`),
`AiWorkflowService.orchestrate`.
- `ChargeContext` + `JobContext`: add `runId`; the charge interceptor
reads the header.
- `JobService.joinOrOpen`: `runId == null` → always open fresh
(standalone never joins); non-null → match scoped to the same `run_id`.
`JpaJobLineageStore`/`JobArtifactHashRepository`: add `run_id` filter to
the match query. Step-limit 10 unchanged.

## Slice 2 — doc_count + document_fingerprint
- V33 migration + entity fields.
- `JobService.openFresh`: set `docCount = inputs.size()`, compute
`document_fingerprint` from input signatures, and denormalise both onto
the DEBIT row in `JobChargeService.recordLedgerDebit`.

## Slice 3 — usage analytics API + FE
- `WalletLedgerRepository`: per-category `SUM(units)` +
`SUM(doc_count)`, `COUNT(DISTINCT document_fingerprint)`, and count of
rows whose units exceed their doc_count (a size multiplier fired), over
the period.
- `WalletSnapshotResponse` + `PaygWalletController`: add `categoryDocs`,
`docsProcessedThisPeriod`, `uniquePdfsThisPeriod`,
`sizeMultiplierPdfsThisPeriod`.
- FE `types.ts` + `PdfsProcessedCard` + `useWallet` + `walletFixtures` +
i18n: headline is the **PDF count**; a summary line shows "{unique}
unique · {units} meter units · {avg} avg units/PDF"; the split bar is
per-category PDF counts; a size-multiplier line shows how many PDFs
scaled. Count is separated from meter units so a 5-unit large PDF reads
as "1 PDF, 5 units".

## Config (out of PR — run in the Supabase SQL editor)
Wrap in one transaction. The partial-unique `is_default` index only
allows one default, so the old default is flipped off **before** the new
one is inserted. The new policy carries the prior default's
`free_tier_units` forward (change the literal if the launch grant should
differ).
```sql
BEGIN;

-- 1) flip default off the current policy + close its effective window
UPDATE stirling_pdf.pricing_policy
SET is_default = FALSE, effective_to = now()
WHERE is_default = TRUE;

-- 2) new default: 1 unit / 5 MiB, no page charge, no scaling cap.
--    free_tier_units carried from whatever the last policy granted (COALESCE→0).
INSERT INTO stirling_pdf.pricing_policy
  (version, effective_from, doc_pages_per_unit, doc_bytes_per_unit,
   min_charge_units, file_unit_cap, free_tier_units, is_default, notes, created_by)
VALUES
  ('v2-size-scaled-2026-07', now(),
   2147483647,        -- doc_pages_per_unit = INT_MAX → pages never drive units
   52428800,          -- doc_bytes_per_unit = 50 MiB → +1 unit per 50 MiB
   1,                 -- min_charge_units
   2147483647,        -- file_unit_cap = INT_MAX → no cap on size scaling
   COALESCE((SELECT free_tier_units FROM stirling_pdf.pricing_policy
             ORDER BY effective_from DESC LIMIT 1), 0),
   TRUE, 'Size-scaled: 1 unit/5MiB, bytes only, no cap', 'connor');

-- 3) per-source step limits: standalone ops = own charge; pipelines split at 10
INSERT INTO stirling_pdf.pricing_policy_step_limit (policy_id, job_source, step_limit)
SELECT p.policy_id, s.src, s.lim
FROM stirling_pdf.pricing_policy p
CROSS JOIN (VALUES
  ('WEB',1),('API',1),('DESKTOP_APP',1),('LINKED_INSTANCE',1),('PIPELINE',10)
) AS s(src, lim)
WHERE p.version = 'v2-size-scaled-2026-07';

-- 4) attach the $0.01/unit Stripe price (you handle the real price id)
INSERT INTO stirling_pdf.pricing_policy_stripe_price (policy_id, stripe_price_id)
SELECT policy_id, 'price_XXXXXXXX'
FROM stirling_pdf.pricing_policy WHERE version = 'v2-size-scaled-2026-07';

COMMIT;
```
Note: the `free_tier_units` subquery reads the most-recent policy
*before* the insert — run it as written (the new row doesn't exist yet
at step 2's SELECT).

## Self-hosted parity — tracked follow-up (not in this PR)
Combined-billing (`stirling.billing.account-link.enabled`) is a
**separate metering engine** (`app/proprietary/accountlink` —
`UsageMeterService`/`LocalUsageService`/`UsageSyncService`). The unit
*math* is shared (`DocumentUnitCalculator`), so size scaling matches
once the policy is pushed. But run-id grouping, `doc_count`, and
fingerprints must be mirrored there, and the usage-sync protocol
extended to report counts/fingerprints, before the self-hosted usage
page shows the same breakdown. Frozen/deferred, so this PR does SaaS;
self-hosted mirrors when it ships.
2026-07-10 13:38:22 +00:00
ConnorYoh ece3562dc9 Portal: team-scoped Free PDF Editors usage card for SaaS (#6924)
## What

Phase 2 of the Free PDF Editors usage card (self-hosted shipped in
#6919): make it work on **SaaS**, where one backend serves many teams so
every figure must be scoped to the **caller's team**.

| Metric | SaaS (per team) |
|---|---|
| **Editors deployed** | team member count (`team_memberships`) |
| **Active this month** | distinct members with a free-UI
(`source='WEB'`, non-`UI_DATA`) audit event in 30d, clamped ≤ deployed |
| **PDFs edited** | the team's cumulative free-UI
`PDF_PROCESS`+`FILE_OPERATION` events |

Cost stays `$0`; uncomputable figures render **N/A**.

## Backend

- **Gate the self-hosted controller** `@Profile("!saas")` — its counts
are server-wide, which would leak across tenants on SaaS. New
team-scoped `SaasFleetUsageController` `@Profile("saas")` owns the same
`/api/v1/usage/fleet-stats` path (mutually exclusive profiles → no
mapping conflict).
- **Team resolution** mirrors `PaygWalletController`:
`AuthenticationUtils.getCurrentUser(auth, userRepo)` →
`TeamMembershipRepository.findPrimaryMembership` → members via
`findByTeamId`. `@PreAuthorize("isAuthenticated()")` (team leaders
aren't global admins; any member sees their own team's totals).
- **Audit → team join**: on SaaS the audit `principal` is the user's
email and `User.username == email`, so principals join cleanly to a
team's member usernames (no hashing — only raw-JWT/over-long principals
get hashed). Two new `principal IN` count queries do the filtering,
served by the `(source, timestamp, principal)` index from #6919.
- Billing/ledger is deliberately **not** used — it only records billable
ops; free-editor activity comes from audit (same `source='WEB'` signal
as self-hosted).
- `null`→N/A when EE auditing < STANDARD; 401 on no-auth; empty-fleet
guard for the (post-migration-shouldn't-happen) teamless caller.

## Frontend

- New `src/portal-saas/api/fleetStats.ts` (rides the `@portal/*` cascade
from #6900) reads via **`apiClient.saas`** — the Supabase JWT the SaaS
backend uses to resolve the team. Re-exports `FleetStats` via
`@portal-proprietary`. **The card and `useAsync` hook are untouched.**

## Tests

`STIRLING_FLAVOR=saas` build green — `:proprietary` + `:saas` compile,
`SaasFleetUsageControllerTest` (team scoping, audit-off→null, clamp,
no-team→empty, unauth→401) and the existing suites pass; spotless clean.

## Notes

- Requires SaaS auditing at STANDARD (it is) — else N/A.
- Depends on #6900 (merged) for the portal-saas override layer and #6919
(merged) for the audit `source` column + DTO.
2026-07-10 13:28:33 +00:00
James Brunton 84d4455682 Add virtual Editor source (#6959)
# Description of Changes
Adds Editor source permanently available in the Sources list. Excludes
it from the Pipelines list of available sources currently because it's
not a real source on the backend, so attempting to connect to it causes
an error. It'd be nice to extend in the future to be able to set up
policies in the editor from the pipelines page, but this'll do for now.
2026-07-10 13:27:40 +00:00
ConnorYoh b9a7f2083b Portal: realign home hero to the simplified marketing card (#6956)
## What

Reworks the free-tier home hero (`WelcomeBanner` + `SetupChecklist`) to
match marketing's reworked top card: a compact product header over
numbered getting-started steps, dropping the marketing chrome.

## aim
attachments/assets/75a80e5f-119e-46bb-80e7-fc4b9a62e5b6" />
<img width="1098" height="646" alt="01-aim-marketing-demo"
src="https://github.com/user-attachments/assets/681ca1b8-219e-4afe-9748-89435aafd440"
/>

## old hero
<img width="1800" height="1338" alt="02-before-old-hero"
src="https://github.com/user-attachments/assets/a396cec0-4752-4ac0-9951-9a49f9d50ea7"
/>


## new screenshots

<img width="1800" height="626" alt="03-after-onboarding-card"
src="https://github.com/user-attachments/assets/59332111-6c49-438d-af6d-200d99bf0f8f"
/>
<img width="1800" height="180" alt="04-after-deployed-header"
src="https://github.com/user-attachments/assets/ea631e6c-7878-4159-aaf7-1f0c2bd795ce"
/>
<img width="1024" height="1396" alt="05-after-install-modal-list"
src="https://github.com/user-attachments/assets/125d0a11-d799-40f0-bd8b-42f5dedcfe6d"
/>
<img width="1024" height="858" alt="06-after-install-modal-docker"
src="https://github.com/user-

## Changes

- **Compact dark header:** brand mark + "PDF Editor" + social-proof
stats (`30M downloads · 60+ PDF operations · Free forever`) + a single
**Open in browser** CTA (→ `EDITOR_URL`).
- **Dropped** the decorative editor mock, marketing
title/subtitle/"Open-source" badge/perks, the two extra banner buttons,
and the checklist's dismiss/progress/done tracking.
- **Numbered nav steps** (①②③) — each opens its in-app surface:

| # | Step | Goes to | Change |
|---|------|---------|--------|
| ① | Download the editor | `editor` view | was an external
`stirling.com/download` link → now in-app |
| ② | Confirm your policies | `policies` view | live active/recommended
counts retained |
| ③ | Invite teammates | `users` view | **replaces** "Connect your
sources" (sources dropped to match the demo) |

- **Enterprise rung** unchanged (Start Trial / Get Quote → procurement).

## Notes

- **Shared hero** — self-hosted sees it too (per decision).
- **One deliberate deviation from the demo:** the header CTA is blue
(brand primary) rather than the demo's white button. Trivial to flip —
say the word.
- Behaviour change: the hero is now a quick-start (navigational) rather
than a completion checklist — the dismiss control + per-step done chips
are gone to match the demo.
- Supersedes the incremental #6944 ("add Open in browser" 3-button
version) — that can be closed in favour of this.
- Portal `tsc` clean; `unusedTranslations` green (removed orphaned
welcome/onboarding keys, added the new ones).
2026-07-10 13:20:18 +00:00
Reece Browne b36f3e0875 Remove unused portal UI (#6949)
Removes some cluttered/unused UI from the portal:

- Search bar in the header
- The top bar entirely (breadcrumb, notification bell, plan switcher,
user menu)
- The plan/usage indicator in the sidebar footer
- The floating assistant badge

UI only. Where a component isn't deleted it's just no longer rendered,
so anything here is easy to restore.
2026-07-10 13:16:57 +00:00
ConnorYoh e4379184b5 fix(portal): translate policy category labels in PolicySummary (#6964)
## What

The portal's **"What runs on your PDFs"** table (`PolicySummary`)
rendered raw i18n keys instead of text:

- `portal.policies.categories.ingestion.label` / `.desc`
- `portal.policies.categories.security.label` / `.desc`
- …and the other three categories (compliance, routing, retention)

## Why it broke

[#6910 "Remove in-app portal
mocks"](https://github.com/Stirling-Tools/Stirling-PDF/pull/6910) moved
the policy catalogue to real data and converted each category's
`label`/`desc` (and each config's `summary`) into **i18n keys** — see
the `// values are i18n keys — render with t()` note in
`api/policies.ts`. Every consumer was updated to call `t()`
(`PolicyCategoryCard`, `PolicyDetailPanel`, `PolicySetupWizard`)… except
`PolicySummary`, which was not part of that PR and kept rendering the
fields verbatim.

The translation keys themselves already exist in
`en-US/translation.toml` (`[portal.policies.categories.*]`) — nothing
was missing, they just weren't being looked up.

## Fix

Wrap the values in `t()` in `PolicySummary.tsx` (the `t` from
`useTranslation` was already in scope):
- category `label` / `desc` in the Policy column
- `config.summary` in the Active-rule column (same keyed-value
treatment, latent until a policy is active)

## Test plan

- [ ] Open the portal Home / policies summary → each row shows the
translated category name + description (e.g. "Ingestion" / "Classify
documents…") instead of a dotted key.
- [ ] A row with an active policy shows its translated rule summary in
the Active rule column.
2026-07-10 13:05:46 +00:00
James Brunton 5ccb56da2d Add S3 policy source (#6948)
# Description of Changes
* Adds an Amazon S3 Source & Output
* Removes folder source from SaaS
* Some miscellaneous UX fixes around pipelines
2026-07-10 12:19:41 +00:00
Reece Browne 16f589448d Remove the policies management surface from the editor sidebar (#6932)
## What

Removes the policy **management** surface from the editor's right rail —
the Policies list above Tools, the open-policy detail takeover, and the
collapsed-rail policy icons — along with the whole UI tree only they
used: the setup wizard and its tool-config steps (PII / redact /
watermark), the detail panel, delete modal, selection store,
enforcement-queue status chip, activity/stats derivation, the catalog
hook, their i18n keys, dead types, and the admin-gate spec that tested
the wizard flow.

**Enforcement is untouched.** Auto-run on upload, the viewer blocking
overlay, exit-point blocking, file badges, and export-time enforcement
all stay. `usePoliciesEnabled` moves to its own module (core stub /
proprietary / desktop shadow with the SaaS-connection check) since it
still gates mounting the headless `PolicyAutoRunController` from the
rail.

## Why

Policies are configured in the admin portal now
(`src/portal/views/Policies.tsx`). Keeping a second management UI in the
editor rail meant two surfaces to maintain for one feature; the editor
only needs to *enforce*.

## Notes for review

- The rail UI lived in the shared `core` `RightSidebar`, so this removes
it from every build flavour at once; the deleted `PoliciesSidebar`
module existed at the core (stub) / proprietary / desktop alias layers
and all three are gone.
- Every deleted module was verified to have zero remaining importers;
near-misses that stay: `enforcementQueue` (used by export enforcement),
`poll` (test-imported), `usePolicies` (used by auto-run).
- Net −3,900 lines.

## Testing

- `task frontend:check` green: typecheck, ESLint + dpdm, Prettier, all
1,196 tests.
- All build-variant typechecks pass (core / proprietary / saas /
desktop).
2026-07-10 11:55:59 +00:00
ConnorYoh 75ea3c9a1f Portal procurement: pricing realignment, combined accept flow, licence & invoice fixes (#6946)
## What this PR does

Brings the enterprise procurement flow in line with the new D71 pricing,
tidies up the buyer journey, and fixes a handful of things we found
testing it end to end.

### Pricing
- Priced on the new run-based model (per PDF, per policy), USD only.
Dropped the old currency picker.
- Added the policy posture choice (Essentials / Governed / Regulated)
and show roughly how many policies each covers (~2 / ~4 / ~7).
- The live estimate in the quote builder now matches the real quote the
backend produces.
- Contracts renew each year with a fixed 3% increase. The agreement
shows this plus the first renewal figure, and we save that figure on the
quote so it can't drift later.

### Trial and journey
- Starting a trial now asks for your deployment (Cloud / Self-hosted /
Air-gapped) and team size up front, and that seeds the quote.
- Quote and agreement are now one step: you review the quote and the
agreement together and click "Accept & subscribe" once. No more
accepting a quote and then separately signing.
- "Start a trial" on the home page opens the setup popup right there
instead of sending you off to another page.
- The calculator asks for number of users again and works the volume out
from that.
- Removed the demo-only buttons (reset, simulate payment) and the "Key
documents" button (it wasn't real).
- The licence key now lives behind its own "Licence key" button instead
of being shown inside every popup.

### Air-gapped licence file
- Air-gapped teams can download their licence file (.lic) during the
trial, not only after they pay.
- The popup warns that a trial file needs re-downloading once the
agreement is done, because the file is a snapshot and doesn't refresh
itself the way the online key does.

### Fixes found while testing
- Accepting a quote now upgrades the licence from trial to full straight
away (it wasn't before).
- The "Download invoice" button keeps working after a page refresh (we
now save the invoice PDF link).
- Invoice line items read differently from each other instead of all
showing the same name.

### Notes for reviewers
- The matching backend changes (Stripe quote/accept functions, database
migrations) live in the Stirling-PDF-SaaS repo on `v3`. They ship when
we do the full v3 release.
- All checks are green.
2026-07-10 11:51:00 +00:00
EthanHealy01 7529190587 fix(ui): shared Button content-sizing + padding props, and button call-site cleanups (#6914)
## Summary

A batch of shared **design-system** fixes (Button, SegmentedControl,
Chip, a new CarouselDots) and the consumer/call-site cleanups they
unlock, following the button consolidation (#6787). Also includes
dark-theme token alignment and some portal/auth polish that rides on the
same components.

The shared Button now sizes to its content instead of clipping it, gains
per-axis padding controls, and no longer misbehaves while loading or
disabled; several call sites are then migrated onto the proper component
APIs.

## Shared components (`core/ui`)

### Button
- **Content-driven height.** `--button-height` is now a `min-height`,
not a fixed cap. Single-line buttons still land exactly on the shared
control-height scale (pixel-aligned with `ActionIcon` /
`SegmentedControl`), while taller content — wrapped labels, stacked
title + subtitle rows — grows the button instead of being clipped
mid-glyph. Short content is re-centered with `align-content`,
**without** overriding the root `display`, so a consumer's own layout
(e.g. a full-width list row) isn't disturbed.
- **Padding props.** New `p` / `px` / `py` props
(`none`/`xs`/`sm`/`md`/`lg`/`xl`) override the size-based padding per
axis. Vertical padding is applied through a `--sui-btn-py` CSS variable,
so consumers can also set it from their own class.
- **Loading no longer collapses.** A `fullWidth` button is never treated
as icon-only, so an execute button whose label is momentarily absent
while files hydrate (e.g. `ScopedOperationButton`) keeps its full width
with a centered spinner instead of shrinking to an icon-sized square for
a split second.
- **Disabled in dark mode.** A disabled *primary* button keeps a muted
version of its own accent fill (`opacity: 0.55`) instead of Mantine's
near-black `--mantine-color-disabled`, which blended into dark surfaces
and made the button all but disappear. Loading spinners are excluded so
they stay full-strength.

No breaking API changes — buttons that don't opt in render exactly as
before.

### SegmentedControl
- Fixed a bug where a segment marked `disabled` that also happened to be
the currently-selected value was rendered disabled, leaving the active
segment un-selectable/greyed. A disabled option is now only disabled
when it isn't the current value.

### CarouselDots (new)
- New shared dots indicator component (with Storybook story), used by
the login carousel.

### Chip / theme
- Dark-theme tokens in `theme.css` aligned to the portal's `tokens.css`
so the editor and portal (Processor) dark modes stop drifting (chrome
surfaces lift off the darker canvas); plus a Chip dark-mode styling fix
and a small `mantineTheme` cleanup.

## Consumer / call-site cleanups

- **Compare** tool: the swap control is now a regular shared Button
placed **between** the Original and Edited file cards (the bespoke
full-height vertical swap button and its CSS were removed), and the file
cards fill the full available width.
- **Certificate format**: replaced the inline-styled buttons with clean
two-state (primary / secondary) buttons.
- **ToolPicker**: restored the label selectors that #6787 renamed to the
never-emitted `.sui-btn__label`, and fixed the sidebar-search row
clipping.
- **File sidebar**: "View all files" row fix; `FileSidebarFileItem`
migrated off `display:flex` + `gap` on the Button root (which no longer
reaches the nested label) onto `leftSection` / `rightSection` + a
stacked label.

## Portal / auth polish

- Portal button consolidation and styling across Header, SettingsModal,
Home, Infrastructure, ApiKeyCard, and PopularUseCases.
- **Login**: onboarding text now shows the default starting username /
password; login carousel uses the new CarouselDots; desktop OAuth
styling tweak.

## Verification

- Storybook: button sizes measure exactly on the control-height scale
and match `ActionIcon`; icon-only buttons stay square and centered;
`fullWidth` loading buttons hold full width; disabled dark-mode primary
buttons render as a muted accent rather than grey.
- Single-line buttons are pixel-identical before/after; only buttons
whose content previously overflowed a fixed height render differently
(they now fit rather than clip).
- `task frontend:lint` clean; typecheck shows only the pre-existing
third-party `node_modules` noise also present on `main`.
2026-07-10 10:26:47 +00:00
Anthony Stirling b9f9f84907 Route portal Users page to SaasTeamController on SaaS via usersBackend seam (#6940)
## Why

The portal Users page worked on self-hosted but **403'd on SaaS**. It
called the proprietary admin endpoints (`/api/v1/user/admin/*`,
`/api/v1/team/*`, `ui-data/admin-settings`), all `hasRole('ADMIN')`.
SaaS users are always `ROLE_USER` (never `ROLE_ADMIN`), so those
endpoints reject them. This is the last SaaS-release blocker for the
portal.

## What

Route the SaaS build's Users page to the **existing**
`SaasTeamController` (invitation-based team management) - no new
backend. Done via a build-time flavor seam, mirroring the existing
`usersCapabilities` pattern.

- **New seam `@app/portal/usersBackend`** (interface in
`portal/api/usersBackend.ts`) with two impls resolved by the `@app/*`
alias:
- `proprietary/portal/usersBackend.ts` re-exports the existing
admin-endpoint functions - **self-hosted behaves exactly as before**.
- `saas/portal/usersBackend.ts` calls `SaasTeamController`
(`/api/v1/team/*`) via `apiClient.local` (already flavor-aware: SaaS
backend + Supabase JWT). Resolves the leader's team from `GET
/api/v1/team/my`, maps `TeamMemberDTO`/`InvitationDTO` onto the portal
`Member`/`PendingInvitation` types.
- **`manageInvitations` capability** (SaaS `true` / self-hosted `false`)
gates a new **Pending invitations** panel (list from `GET
/{teamId}/invitations`, Cancel via `DELETE
/api/v1/team/invitations/{id}`).
- **Remove re-enabled on SaaS** (was gated off): the roster remove
action now works at team scope against `DELETE
/{teamId}/members/{memberId}`, with a flavor-aware label ("Remove from
team" vs "Remove from org") and confirm copy.
- Invite (email) and rename routed through the seam (`POST /invite`,
`POST /{teamId}/rename`); `fetchAuthConfig` on SaaS is static (no
spurious admin-endpoint 403).
- **MSW handlers** (`mocks/handlers/teamSaas.ts`) mirror the controller
so the SaaS Users page is exercisable in mock mode. Registered in
`handlers` but deliberately **not** `embeddedDataHandlers` (would clash
with the editor's own `/api/v1/team/*` routes when portal shares its
origin).

## Constraints honoured

- No new backend endpoints - reuses `SaasTeamController`.
- Self-hosted path unchanged (proprietary impl re-exports the same
functions).
- No SaaS user is ever `ROLE_ADMIN` - `adminRole`/admin-only UI stay
hidden.

## Notes from an adversarial self-review (both fixed in this PR)

- Solo SaaS users' auto-created **personal team** now hides the Rename
control (the backend rejects renaming personal teams with 400) -
`isPersonal` threaded through `Team`/`TeamGroup`.
- Expired-but-still-`PENDING` invitations are filtered in the adapter,
and the expiry label no longer mislabels a just-expired invite as
"Expires today".

## Testing

- `task frontend:typecheck:all` - all 8 flavors pass.
- Portal vitest project: **122 passing** (added SaaS adapter +
shape-mapping tests via MSW, PendingInvitations panel, and
remove/manageInvitations/personal-team gating).
- `task frontend:lint` (ESLint `--max-warnings=0` + dpdm no circular
deps) and prettier clean.

## Open questions

- **Team resolution on SaaS**: I resolve the leader's single manageable
team (prefer a real non-personal team they lead). If a leader owns
multiple real teams, only the primary is shown - matches the "single
team" framing in the spec; flag if multi-team management is wanted.
- **`isSelf` on SaaS** uses the LEADER role (the portal Users page is
leader-only on SaaS, so the leader row is the viewer). Verified there's
no multi-leader creation path today; revisit if that changes.

Draft - not marking ready until reviewed.
2026-07-10 09:22:05 +00:00
Anthony Stirling 68ec176719 Portal empty states: add CTAs and hide stat boxes (#6952)
# Description of Changes

Empty-state polish across the four processor (portal) list pages, so a
fresh workspace gets clear next steps instead of a row of zeroed-out
stat boxes.

- **Sources / Pipelines** - hide the KPI stat strip when the list is
empty; the empty state now shows an icon plus a primary + secondary CTA
(Connect source / Read the docs; Create a pipeline / Connect a source).
Also closes a gap where a successfully-fetched empty list rendered stat
boxes over a blank page with no empty state at all.
- **Policies** - hide the summary stat strip until at least one policy
is configured; the catalogue cards stay as the "configure a policy"
CTAs.
- **Documents** - hide the filter-pill + search toolbar on an empty
queue; the empty state gains an icon plus Create a pipeline / Connect a
source CTAs.
- **Storybook** - added `Default` + `Empty` stories for all four views;
the preview now loads the real English copy so stories render shipped
text rather than raw i18n keys.

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [x] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [x] I have run `task check` to verify linters, typechecks, and tests
pass
- [x] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-07-10 08:54:05 +00:00
Anthony Stirling d17c3f4fec Portal: wire remaining hardcoded strings to i18n (#6953)
# Description of Changes

- Audited every portal page/component for hardcoded UI strings not
routed through `t()`
- Wired the remaining ones to i18n (~80 new `portal.*` keys in
`en-US/translation.toml`):
- Infrastructure status/label maps (deploy, api-key, cert, key-mode,
attestation, audit, model, region, environment) + API-key permissions
- Procurement "Key documents" modal, editor-admin deploy targets, users
seats label, pipeline output-folder placeholder
- Follows the existing house pattern: label maps store i18n keys,
resolved via `t(MAP[value])` at the render site
- Documents CSV export now reuses the on-screen column keys, and fixes a
latent bug where the exported status leaked the raw key instead of the
translated label
- No UI-copy change: en-US values are identical to the previously
hardcoded strings; other locales fall back to en-US as before

---

## Checklist

### General

- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [x] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-07-10 08:54:02 +00:00
Anthony Stirling 783a51950f Update translations for 40 languages via GPT-5.5 (#6954)
# Description of Changes

- Adds and updates translations across **40 languages** (~1,400–2,200
keys each) using GPT-5.5, filling previously-missing UI strings.
- Switches the translation scripts' default model from the year-old
`gpt-5` (5.0) to `gpt-5.5`, adding a `--model` flag and token/cost
reporting.
- Purely additive and validated: no existing translations changed, all
40 files match the en-US key structure, and no new placeholder issues
introduced.

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [x] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [x] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [x] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-07-10 08:53:59 +00:00
Reece Browne 51d3d27fd3 Portal policies: SUI setup forms fixes and improvements (#6927)
## What this does

Reworks the portal's policy setup screens so a policy reads as **its own
settings**
rather than a list of tools you wire together, and rebuilds the forms on
the
shared design system so they match the rest of the portal.

## Why

Setup showed one card per underlying tool (tool name + a toggle), which
exposed
the "a policy is a pipeline of tools" plumbing. A policy should read in
terms of
what it does to a document, not which tools run under the hood.

## Changes

- **Setup reads as policy settings.** The per-tool cards are now a
plain-language
list of what the policy does — "Redact sensitive information", "Strip
active
content", "Apply a watermark", and so on — each with a short description
and a
  toggle, with its options appearing inline when turned on.
- **Consistent design system.** The setup and edit forms use the shared
  components instead of one-off styling.
- **Simpler setup.** Removed two sections that aren't part of what ships
here:
  Document Types (scope-by-type) and Retries.
- **Watermarks are text-only.** A policy watermark is a text stamp, so
the image
  option and the type picker are hidden.
- **The editor always shows as a source** (it used to disappear when no
other
  sources were connected), and the source tiles now lay out correctly.
- **Clearer upsell copy.** Locked policies read **"Upgrade to
Enterprise"**
  instead of "Coming soon".

## Scope

UI only — no backend changes. Keeping a policy's settings in sync
between the
portal and the editor is a known, separate issue and is **not** part of
this PR.

## Testing

Prettier, ESLint, typecheck (proprietary + saas), and the
unused-translation
guard all pass. Setup screens verified in Storybook.
2026-07-09 19:13:10 +00:00
Reece Browne ccfd22b2a9 port editor settings into portal (#6945)
The portal's `SettingsModal` was a parallel, mock-backed settings
implementation. It's replaced by the editor's `AppConfigModal`, mounted
via a new `PortalSettingsHost` that supplies the contexts the portal
doesn't have (app config, flavor-resolved session, preferences, editor
theme). Flavor resolution does the rest: the self-hosted portal gets the
admin sections, the SaaS portal gets the saas shell. The self-hosted
account-link panel rides in through the existing seam as an extra
section.

The shell gains three host props (`urlSync`, `initialSection`,
`extraSections`); editor behaviour is unchanged. Net −1,300 lines.

Manually verified on both flavors against live backends.
2026-07-09 16:35:30 +00:00
James Brunton a5ee329c36 Further improvements to policies file tracking (#6941)
# Description of Changes
Fixes requested in review of #6903
2026-07-09 15:43:38 +00:00
Reece Browne 2091874050 Remove in-app portal mocks (#6910)
The portal no longer uses mock data — it always talks to the real
backend. Mocks still power Storybook and tests.

- Mocks button and all the in-app MSW machinery removed.
- Types the app needs moved out of mock files and into the api layer, so
the app no longer depends on `mocks/` at all.
- One deliberate exception for the onboarding tour (#6926):
`enablePortalDemoData()` fills the views with example data while a tour
runs, with zero cost the rest of the time.

Heads up: views without a real backend endpoint yet now show empty/error
states in dev.
2026-07-09 14:50:50 +00:00
ConnorYoh 22e8a82fa1 Portal: add 'Open in browser' CTA to the welcome hero (#6944)
## Screenshots

<img width="2522" height="1322" alt="image"
src="https://github.com/user-attachments/assets/010e2dce-00ae-4c7f-8ec8-7e6519beb4cd"
/>
2026-07-09 14:47:37 +00:00
James Brunton 01751bf2f0 Improve logic for tracking which files have already been processed in policies (#6903)
# Description of Changes
Replaces the `.stirling/done` folder and its friends with a ledger in
the DB which tracks which documents have been processed. This should
scale dramatically better since it's just a few bytes being written for
each PDF processed, rather than each PDF being duplicated and held in
the folder forever. It's designed to work with the current folder
source, but also with S3 buckets and other sources in mind - each source
will define its own strategy for ensuring it knows whether the documents
have had policies run on them or not, and they all get written to the
same ledger.
2026-07-09 12:07:26 +00:00
ConnorYoh 119eb1f5ad Portal: move the admin route from /portal to /processor (#6933)
## What this changes

Moves the admin portal's browser route from **`/portal`** to
**`/processor`**, to match the "Processor" product name (the in-app app
switcher already says "processor").

- `PORTAL_BASENAME` `"/portal"` → `"/processor"` — the single source of
truth for all portal paths on the frontend, so every `toPortalPath(...)`
link and redirect follows automatically.
- `adminRouteExtensions` now mounts at `` `${PORTAL_BASENAME}/*` ``
instead of a hardcoded `"/portal/*"`, so the route can't drift from the
constant.
- **Backend:** `RequestUriUtils.isStaticResource` now treats
`/processor` (not `/portal`) as the SPA shell, so a direct navigation or
hard refresh to `/processor` serves the app instead of 404ing. (This is
the only backend URL reference — there's no Spring Security matcher for
it.)
- Updated the two portal route tests and the doc comments that named the
old path.

**Not changed:** the `@portal/*` import alias — that's the code layer /
flavor-layering path, not the URL. Renaming it would be a much larger,
unrelated churn.

The portal isn't publicly launched yet, so there are no existing links
to preserve — no redirect from the old path is included.

## Testing
- `task frontend:typecheck:all`, full test suite (1,211), lint, format —
green
- `./gradlew :common:test --tests "*RequestUriUtilsTest"` — green
2026-07-09 12:01:48 +00:00
ConnorYoh d3638d786d Portal home: cut the mock blocks, wire the rest to real data (#6931)
## What this changes

Cleaning up the portal home page. Most of what was under the plan card
was mock — it hit endpoints that don't exist on any backend, so on SaaS
it just showed a wall of "—" and "Nothing here yet". I removed the fake
stuff and wired the bits worth keeping to real data.

**Scrapped (all mock, no backend anywhere — self-hosted included):**
- The "No usage yet" usage chart
- The KPI strip (Docs/30d, Pipelines, Agents active, Eval pass rate)
- The "Build a pipeline in seconds" fork wizard (fake build animation,
deploy was a TODO)
- The Sources/Pipelines/Agents product cards
- The "Popular use cases" marketing cards
- Enterprise region health
- The "Try a PDF operation" runner

Deleted their component/api/mock/MSW/story files too, plus the now-dead
i18n keys and CSS.

**Wired to real data (finished, not removed):**
- The plan strip and the sidebar footer now show the **real** 30-day
processed-PDF count from `/api/v1/usage/fleet-stats` (was a mock KPI).
Shows "—" honestly when the backend can't compute it, never a fake
number.
- **Recent activity** now reads the **real audit log** — the same
endpoint the Infrastructure → Audit tab uses.

**Result:** one simple layout for all tiers — plan strip → recent
activity + quick actions → "What runs on your PDFs" (the real policy
summary). Every block is backed by data that actually exists.

Net: **−3,221 / +89 lines**, 17 files removed.

## Testing
typecheck (all variants), full test suite (1211), saas + proprietary
builds, lint, format, storybook build, toml-sort — all green. The
`unusedTranslations` test guarantees no orphaned i18n keys were left
behind.
2026-07-09 11:58:32 +00:00
ConnorYoh e5a258a648 Dev: redirect bare subpath /app → /app/ when RUN_SUBPATH is set (#6934)
## Problem

With `RUN_SUBPATH=app`, the app is served under base `/app/`. Vite
serves `index.html` at `/app/` and redirects `/` → `/app/`, but a bare
**`/app`** (no trailing slash) returns **404** — so you had to type
`localhost:5173/app/` to load the app. `/app` should work too.

## Fix

A small dev + preview middleware that **301-redirects `/app` → `/app/`**
(query string preserved), so either form loads the app. Only active when
`RUN_SUBPATH` is set; no-op otherwise.

Also routed the vite `base` through the same slash-stripped `runSubpath`
value the middleware uses, so a stray `RUN_SUBPATH=/app/` can't produce
a doubled `//app//` base.

## Verified (dev server + prod build, `RUN_SUBPATH=app`)

| Request | Before | After |
|---|---|---|
| `GET /app` | 404 | **301 → `/app/`** |
| `GET /app?foo=1` | 404 | **301 → `/app/?foo=1`** (query kept) |
| `GET /app/` | 200 | 200 (unchanged) |
| `GET /` | 302 → `/app/` | 302 → `/app/` (unchanged) |

Production build under the subpath still emits `<base href="/app/">` and
`/app/assets/...`. Lint + format green.
2026-07-09 11:57:58 +00:00
EthanHealy01 c64369e56c Classifier Policy (#6898)
## Overview

Adds **AI document classification** and a **classification-aware Files
sidebar**: uploaded documents are automatically tagged with
document-type labels (Invoice, Contract, Lab report, …), and the sidebar
groups files under editable parent categories so a large library stays
navigable.

> [!IMPORTANT]
> **This feature only runs in the SaaS build.** Classification depends
on the AI engine and team-scoped label storage, so it's gated to SaaS
end-to-end:
> - The sidebar grouping is a `saas/`-layer override of the
`fileSidebarGrouping` seam; every other build (OSS core, self-hosted
proprietary, desktop) gets the null stub and renders the **unchanged
flat, recency-sorted list** — no categories, no "Other", no picker.
> - The classify/labels backend endpoints are gated on
`policies.enabled` (on in SaaS) and live in `app/proprietary`, so
they're absent from pure OSS and dormant in self-hosted unless
explicitly enabled.
> - The Python classifier is reached only via that gated path.
>
> Shared-layer changes that do compile everywhere are inert without the
engine (dormant schema/field additions) or intentional (`GetInfoOnPDF`
surfacing custom metadata).

## What it does

- **Classifier (engine):** reads the first/last two pages of a PDF and
assigns document-type labels from an allowed vocabulary. Labels are
deliberately document-*type* descriptors — no deep-content/PII
detection, since only a page window is read.
- **Team label vocabulary:** ~270 built-in defaults across ~15 families,
seeded per team. Editable by team leaders/admins in the Classification
policy settings (import/export/reset). Team-scoped and shared;
**per-user personal labels are intentionally out of scope** — the
vocabulary is team-level only.
- **Sidebar categories:** files group under parent categories
(Financial, Legal, Medical, …), busiest-first, collapsible, with a
"Recent" group on top and an "Other" group for anything uncategorised.
The category structure (names, icons, membership, custom categories) is
**device-local and user-editable** via a "Customize" picker — the only
per-user personalization; it never changes the team's label vocabulary.
- Classification results are written to PDF metadata
(`StirlingPDFClassification`), read back to keep files in their groups
without re-parsing.

## Architecture

Spans all three layers, mirroring the existing policy/source subsystem
conventions:
- **`frontend/editor`** — sidebar grouping seam + SaaS override,
category manager, labels editor, icon palette, file grouping, tests,
`en-US` i18n.
- **`app/proprietary` + `app/common` + `app/core`** —
`ClassifyLabelController`, team-scoped `ClassificationLabelStore` (Jpa +
in-process impls, same shape as `PolicyStore`/`SourceStore`), metadata
read/write.
- **`engine`** — the document-classifier agent, contracts, routes,
tests.

## Screenshots

**Files sidebar — grouped by category (SaaS)**

### Loading view

<img width="2056" height="1046" alt="Screenshot 2026-07-07 at 5 12
56 PM"
src="https://github.com/user-attachments/assets/1d712da5-50ae-4349-b0cd-e62665c3ec0c"
/>

### Organized in the sidebar

<img width="2056" height="1045" alt="Screenshot 2026-07-07 at 5 14
05 PM"
src="https://github.com/user-attachments/assets/3ea4fe21-da51-4cea-bc3a-18ce040d3d05"
/>

**Customize categories picker**
### Personal settings to change how labels are grouped in an individual
users editor

<img width="2056" height="1044" alt="Screenshot 2026-07-07 at 5 52
42 PM"
src="https://github.com/user-attachments/assets/40be03ce-0f63-4d1e-b58b-cec045d01cb2"
/>

**Classification labels editor (team settings)**

<img width="2056" height="1042" alt="Screenshot 2026-07-07 at 5 53
00 PM"
src="https://github.com/user-attachments/assets/337b0739-15c9-4749-9c6b-22e3b20825b8"
/>

## Testing

- Frontend `task frontend:check` — green (editor + portal tests,
typecheck across all flavors, lint, label-drift guard).
- Backend `task backend:check` (proprietary) and `:saas:test` — green.
- Engine `task engine:check` — green.
2026-07-09 11:47:37 +00:00
James Brunton f29500c138 Disable Portal UI for guests (#6936)
# Description of Changes
Disallow SaaS guests from accessing the portal. One day we might want to
make this better so they can go there but then have to sign up before
doing anything useful, but this is the easiest way to disallow it for
now.
2026-07-09 11:44:19 +00:00
Anthony Stirling 9d11918bd8 Add Storybook preview deploy + changed-stories comment on PRs (#6929)
## What

Adds a **Storybook preview** for PRs. When a PR changes any story
(`*.stories.{ts,tsx,mdx}`) or the `.storybook` config, this builds the
static Storybook, deploys it to the preview VPS on a PR-scoped port, and
comments with the URL plus an **expandable list of exactly which stories
changed**. Torn down automatically when the PR closes.

New file: `.github/workflows/storybook-preview.yml`. Nothing else is
touched.

## How

- **Detect** (`changes` job) - `dorny/paths-filter` with `list-files:
json` flags Storybook changes and captures the exact changed files.
Skipped on close and for fork PRs (which don't get the VPS secrets).
- **Deploy** (`deploy` job, only when Storybook changed) - builds the
static Storybook (`task frontend:prepare` + `frontend:storybook:build`),
tars it, and serves it from an `nginx:alpine` container on the VPS at
port `PR# + 20000` (offset from the app preview's bare-PR-number port to
avoid collisions). Mirrors `PR-Auto-Deploy-V2.yml`'s VPS SSH pattern and
reuses the same secrets.
- **Comment** - a single bot comment (replaced on each push) with the
preview URL and a `<details>` block listing the changed stories (and any
`.storybook` config changes), e.g.:

  > ## 📚 Storybook preview
  > 🔗 **Preview:** http://&lt;vps&gt;:26911
> <details><summary>2 stories changed (+1 config
file)</summary>…</details>

- **Cleanup** (`cleanup` job, on PR close) - stops the container,
removes the files, and deletes the comment.

## Validation

- Static Storybook builds locally (`task frontend:storybook:build` →
`frontend/storybook-static`, 151 stories).
- Confirmed `task frontend:prepare` regenerates the un-committed
`material-symbols-icons.json` that stories import, so a fresh CI
checkout builds (added it before the build step).
- Comment-markdown logic unit-checked against a sample changed-files
list.
- YAML validated; action pins match the repo (`setup-node` v6.4.0 / node
22, same `paths-filter`, `setup-bot`, `harden-runner`).

## Note

The VPS deploy mirrors the proven `PR-Auto-Deploy-V2` machinery but
couldn't be exercised end-to-end from a dev box (needs the VPS secrets)
- the first live run on a Storybook-touching PR will confirm the
deploy/serve/cleanup path. Everything build- and comment-side is
validated locally.
2026-07-09 10:51:45 +00:00
Anthony Stirlingandaikido-pr-checks[bot] 8d2bb14f99 Add portal user management and access control (#6913)
# Description of Changes

Portal access control + user management
What this does

- Adds server-side portal access enforcement: a ResourceGrant ACL (owner
→ admin → grant → default policy) gates the portal via
@resourceAccess.canUsePortal(), so access is authoritative on the
backend, not just hidden in the UI.
- New proprietary/access module: ResourceAccessService +
ResourceAccessSecurity, PrincipalResolver (default + SaaS + team-lead
lookup), OwnershipService, ResourceGrantController, and a SecretMasker
for safe config display.
- Exposes an authoritative portalAccess flag on /me (AuthController /
AdminUserSummary); drops the old org-principal shortcut.
- Full portal Users page: team + member management (members table,
invite, move-to-team, new/rename team, reset password, access controls,
confirm modals) wired to real user/team/grant endpoints.
- Per-flavor capabilities seam (usersCapabilities): self-hosted
org-admin gets everything; SaaS is trimmed to what a team leader can do
(no ROLE_ADMIN ever surfaced).


SaaS blockers (separate follow-up PR)
The portal Users page works on self-hosted but 403s on SaaS (it calls
the admin API hasRole('ADMIN'), and SaaS users are ROLE_USER). To ship
the portal on SaaS:

- Add a @app/portal/usersBackend seam and point the SaaS build at the
existing SaasTeamController (no new backend).
- Resolve the leader's team-id on SaaS and map member/invitation shapes
to the portal Member type.
- Add pending-invitation management (list + cancel) - the parity gap vs
the editor.
- Re-enable the roster remove action on SaaS against
SaasTeamController's remove-member endpoint.


<img width="1426" height="464" alt="image"
src="https://github.com/user-attachments/assets/7a441a35-7a57-472f-a8c7-e6d8ae998439"
/>

<img width="492" height="722" alt="image"
src="https://github.com/user-attachments/assets/d4e8a088-b2eb-4326-9e00-7ada6eb72a85"
/>

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.

---------

Co-authored-by: aikido-pr-checks[bot] <169896070+aikido-pr-checks[bot]@users.noreply.github.com>
2026-07-09 10:29:26 +00:00
ConnorYoh b53aaa7d03 Portal procurement: enterprise licence-key mechanism (generate at trial, upgrade on subscription, view/download) (#6902)
Builds on the procurement vertical slice (#6861). Adds the **enterprise
licence-key mechanism**: a Keygen licence is generated at trial,
upgraded in place when the committed subscription is created, and is
viewable/downloadable in the portal. Flag-gated — ships with the mock as
default until Keygen env vars are wired.

### What it does
- **Offline / air-gapped licence** is a new **priced add-on** on the
quote ($12k/yr, flat, alongside indemnification / training / QBR).
- **Licence key visible from the trial step** — the portal shows the key
with **Copy**, and (when the offline add-on is bought) a **Download
offline licence (.lic)** button.
- **Real Keygen client, called directly from Java**
(`KeygenEnterpriseLicenseService`), behind `stirling.keygen.enabled`;
`MockEnterpriseLicenseService` stays the default. All creds are env vars
(`STIRLING_KEYGEN_*`) — nothing committed.
- **Provisioning is driven by the Stripe `customer.subscription.created`
event** (source of truth), not a UI action — so a sales-led deal entered
manually in Stripe provisions a licence too. The webhook calls a new
admin `POST /api/v1/procurement/provision`, which upgrades the trial
licence **in place** to the committed annual term, **valid immediately**
(no wait for payment). The deal stays in the payment step so the
outstanding invoice remains visible.
- Offline `.lic` is checked out `base64+ed25519` (signed, unencrypted)
so the self-hosted `KeygenLicenseVerifier` validates it fully offline.

### Not in scope (deliberate, follow-ups)
- Cloud entitlement flip — a **cloud** customer sees/downloads the key
but the running cloud product doesn't unlock yet (self-hosted/air-gap
**are** unlocked by the key). Immediate next PR.
- `invoice.paid → fully-live` / `payment_failed → suspend` webhook
safety-net.

### Companion change (separate repo)
- The Stripe-webhook wiring that calls `/provision` lives in the
**Stirling-PDF-SaaS** repo (committed on `v3`, not part of this PR):
`stripe-webhook` routes enterprise-committed `subscription.created` →
`provisionProcurement()` → the Java admin endpoint.

### Prod setup required
- Create the committed-enterprise **Keygen policy with
`scheme=ed25519`** under the existing account, set
`STIRLING_KEYGEN_ENABLED=true` + account/token/policy env vars.

### Verified
saas `:saas:test` (procurement) · portal typecheck / eslint / prettier ·
82 portal tests · `deno check` on the webhook handler.

### Review follow-ups (PR review, tracked)
Low-hardening fixes applied in `85369633ed`: keep Keygen response bodies
out of thrown/logged messages; fail-fast at startup when the flag is on
but creds are missing; gate the offline `.lic` on the *accepted* quote
(not the latest draft).

Deliberately deferred, tracked here:
- **Pre-flag verification.** Before `stirling.keygen.enabled=true`,
confirm the id-vs-key addressing against live Keygen. (The shipping
self-hosted edge addresses licences by URL-safe key in the path and
Keygen docs allow it, so the client mirrors that — but confirm
empirically with the real committed-enterprise policy.)
- **No auto-revoke on non-payment.** Provision issues an
immediately-valid annual licence before payment settles; `invoice.paid →
live` and `payment_failed → suspend` are out of scope here. Note the
offline `.lic`, once downloaded, verifies offline for the full term and
**can't be revoked** — so the real mitigation for the offline case is a
shorter bridge term until `invoice.paid`, not just wiring `suspend`.
Enterprise is sales-led/ADMIN-gated, so this is a collections concern,
not mass abuse.
2026-07-08 19:31:26 +00:00
ConnorYoh 3fa0f30d43 Portal: prep for SaaS launch — hide unfinished sections, fix api client, docs link (#6921)
## What this changes

Getting the portal ready to show the world on SaaS. A few things bundled
in here:

**Developer docs tab** — now opens https://docs.stirlingpdf.com/ in a
new tab instead of taking you to an empty page (we haven't built the
in-app docs page yet).

**Hid the bits that aren't finished yet — SaaS only:**
- Took the Agent Builder button off the Sources page.
- Removed the Components page.
- Infrastructure: the tabs that aren't ready (Deployments, Security,
Models, Storage) are greyed out as "coming soon". API keys and Audit
stay live. Also dropped the "Manage editor deployment" button.
- Removed the floating AI assistant blob.

**Fixed the SaaS api client.** Before this, only the usage/billing page
actually reached the backend — everything else (sources, users,
policies, etc.) was going to the vite dev server with the wrong login,
so it never worked. Now every portal call goes to the one SaaS backend
using the Supabase login.

Self-hosted is left exactly as it was — all the SaaS hides go through
the saas override layer, so self-hosted still shows everything.

## Testing
typecheck (all variants), full test suite, both builds, lint + format —
all green.
2026-07-08 16:39:37 +00:00
Anthony Stirling 9ea848570f Wire portal audit tab and documents to real audit data (#6912)
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-07-08 15:31:01 +00:00
James Brunton d6061eb0aa Support tool selection in Pipelines page in Portal (#6905)
# Description of Changes

Redesigned the Portal Pipelines page so pipelines are created and edited
on their own dedicated builder page, replacing the previous modal
composer and inline detail card.

## Screenshots

### Pipelines list
The redesigned list with summary KPIs; each row opens that pipeline's
own page.

![Pipelines
list](https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/pr-6905-screenshots/screenshots/01-pipelines-list.png)

### Pipeline builder
The dedicated create page: pipeline settings (sources, trigger, output)
above, operations and per-tool settings below.

![Pipeline
builder](https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/pr-6905-screenshots/screenshots/02-builder-new.png)

### Tool picker
Type-to-filter, category-grouped picker for adding an operation to the
pipeline.

![Tool
picker](https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/pr-6905-screenshots/screenshots/03-tool-picker.png)

### Editing a pipeline
An existing pipeline in the builder: reorderable steps, per-tool
settings, and run/delete actions.

![Editing a
pipeline](https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/pr-6905-screenshots/screenshots/04-builder-edit.png)
2026-07-08 15:23:04 +00:00
ConnorYoh 1759e0bdd5 feat(portal): wire procurement "Schedule a call" to Calendly (#6920)
## What

The procurement flow's **Schedule a call** action (deal-status hero →
side modal) was a mock: a fake "SE" avatar and four hardcoded time-slot
buttons that just closed the dialog. This wires it up to the real
Calendly booking widget the admin provided.

## How

- **New `CalendlyInline` component**
(`portal/components/procurement/CalendlyInline.tsx`)
- Lazily loads `assets.calendly.com/assets/external/widget.js` via the
existing `@app/utils/scriptLoader` — only when the modal actually opens,
deduped across reopens.
- Calls `Calendly.initInlineWidget()` explicitly so it rebuilds on
reopen / theme change.
- Colours track the portal's light/dark theme (`useTheme`) via
Calendly's `background_color` / `text_color` / `primary_color` params,
mapped to the portal design tokens (surface / text-1 / primary), plus
`hide_event_type_details=1`.
- Graceful fallback to an "open in a new tab" link if the script fails
to load.
- Base URL overridable via `VITE_CALENDLY_URL` (defaults to the
group-discussion link).
- **`ScheduleCallModal`** now renders `<CalendlyInline />` instead of
the mock; copy moved into i18n (`portal.procurement.schedule.*`).
- `SideModal` gains a `wide` variant so the embed has room; removed the
now-dead `.portal-se*` / `.portal-slots*` CSS and `SLOTS` constant.

## Notes / follow-ups

- No app-level CSP blocks `calendly.com`, so the embed loads without
config changes.
- Verified with the portal typecheck (`tsc -p src/portal/tsconfig.json`)
and ESLint on the changed files; only pre-existing Storybook/msw dev-dep
type errors remain.


<img width="1160" height="642" alt="image"
src="https://github.com/user-attachments/assets/d9b5d92d-ea7e-4862-9f35-a71f65392a2c"
/>
<img width="3744" height="1990" alt="image"
src="https://github.com/user-attachments/assets/0b8002c6-dd3e-41e6-8e8b-6faf6090314c"
/>
<img width="2620" height="1928" alt="image"
src="https://github.com/user-attachments/assets/781b7a60-7065-4a7a-b9f7-1cdb0dece7b3"
/>
2026-07-08 15:06:55 +00:00
ConnorYoh 514b020f74 Portal: real Free PDF Editors usage card (self-hosted) (#6919)
## What

Replaces the **mocked** "Free PDF Editors" fleet card on the portal
Usage page with live figures. Cost stays a literal `$0`; any figure that
can't be computed renders **N/A** (never a misleading 0).

| Metric | Self-hosted source |
|---|---|
| **Editors deployed** | total users (`UserRepository.count()`) |
| **Active this month** | distinct `source=WEB` principals active in 30d
(excl. `UI_DATA` polling), clamped ≤ deployed |
| **PDFs edited** | cumulative `PDF_PROCESS` + `FILE_OPERATION` audit
events that are **free UI runs** |

## Why the counting approach

"Free operations = UI tool runs." Two dead ends first:
- **Billing/PAYG is the wrong source** — it *deliberately discards* free
ops (classified `BYPASSED`, no DB row); its tables only hold billable
(API/AI/automation).
- **Raw audit is also wrong** — a tool controller emits `PDF_PROCESS`
for UI **and** API/AI/automation calls, and billable traffic exists on
every tier.

So the count is **audit filtered to free UI runs**. Audit events gain a
`source` column, stamped from the always-on signal
`BillingCategoryClassifier.classify(...) == BYPASSED` (not API-key auth,
no `X-Stirling-Automation` header, not `/api/v1/ai/`) — zero
billing-module coupling. Captured on the request thread
(`AuditService.captureCurrentSource`), carried via MDC in
`ControllerAuditAspect` (same propagation as `requestId`), persisted by
`CustomAuditEventRepository`. The count filters `source = 'WEB'`.

## Endpoint

`GET /api/v1/usage/fleet-stats` — admin-gated, EE-only. Returns `null`
per field when EE auditing is off (→ N/A).

## Frontend

- New `portal/api/fleetStats.ts` → `apiClient.local` (this instance's
backend).
- `FreePdfEditorsCard` rewired to `useAsync(fetchFleetStats)`; preview
badge removed, `null`→"N/A", loading→"—".

## Tests

`:proprietary:build` green — `FleetUsageControllerTest` (4) and
`CustomAuditEventRepositoryTest` (+2 for source-from-MDC) pass; spotless
clean.

## Notes / follow-ups

- `deployed` currently counts all users incl. disabled — refine to
enabled-only later.
- **SaaS** (team-scoped endpoint + a `fleetStats.ts` override) is
deferred to a follow-up riding the portal-SaaS layering PR #6900.
- Depends on EE auditing running at `AuditLevel ≥ STANDARD` for the
audit-derived figures; otherwise they show N/A.
2026-07-08 14:42:44 +00:00
Reece BrowneandJames Brunton 18b0b19a67 Block file exit points while a per-file policy run is enforcing (#6904)
## What

While a per-file policy run is in flight, the editor now blocks every
way the file can leave the app, and shows why:

- **Viewer** — a blocking overlay with live progress ("Enforcing
policy…"). Dismissible: collapses to a corner badge (top right, tinted
with the policy's accent) so the file stays readable while the run
finishes.
- **Workbench bar** — Print / Download / Save As / Share are disabled
with an explanatory tooltip and progress bar. The Ctrl+P shortcut and
the form-fill bar's "Download PDF" button are covered too.
- **File lists** — the file sidebar, file-editor thumbnails, and files
page show a spinning shield badge on the affected file, and thumbnail
hover actions (download / upload to server) are blocked with the same
tooltip.

Once a run settles, everything unblocks — including FAILED and CANCELLED
runs. A failed check surfaces through the run's activity feed; it never
locks the user out of their file.

## Why

Upload-triggered policies exist so the enforced output is what leaves
the app. Before this, a file could be printed, downloaded, or shared
while its policy run was still processing.

## Also in here

- **One shared `PolicyBadges` component** — the sidebar, thumbnails, and
files page each had their own copy of the badge markup/CSS and had
drifted (different sizes, tints, missing spinner and glow on the files
page, hardcoded English tooltips). All badge surfaces now render the
same component: accent-tinted shield, spinner while enforcing, one-off
glow when recent, i18n'd tooltips.
- **Cascade fix:** outputs imported from reconciled
(server-rediscovered) runs are now tagged `derivedFromTool`, stopping an
auto-run → import → auto-run loop that produced ever-growing
`_sanitized_sanitized…` filename chains on fresh devices.
- **Core stub for `policyRunStore`** so the core build compiles —
`WorkbenchBar` and `ViewerShareButton` resolve `usePolicyRuns` via
`@app/*`.

## Testing

- `task frontend:check` green: proprietary typecheck, ESLint + dpdm,
Prettier, 915+ editor + 81 portal unit tests.
- `typecheck:core` / `saas` / `desktop` variants all pass.
- Enforcement flow exercised manually against a live backend with an
upload-triggered policy (overlay + progress during the run, dismiss to
corner badge, unblock on completion).

---------

Co-authored-by: James Brunton <jbrunton96@gmail.com>
2026-07-08 14:09:23 +00:00
ConnorYoh a8bda9240c feat(portal): replace free-tier carousel with static welcome hero (#6901)
## What this PR does

Redesigns the portal home to match the marketing demo, across all tiers.

- Swapped the old rotating welcome carousel for a static **welcome
hero** on the free tier
- Subscribed (Processor) + enterprise get a **deployed-editor hero** —
shows the live instance (host, version, active users) with an *Open in
browser* button, pulled from the real editor-deployment API (same data
the Editor admin page uses)
- Added a time-of-day **greeting** on the paid tiers
- Rebuilt the **"Finish setting up" checklist** so it's real: the counts
and tick-offs come from the actual policies + sources (a step is done
when there's at least one), not hardcoded numbers
- *Download the PDF Editor* → `https://stirling.com/download`; the other
steps deep-link to Policies / Sources
- **Procurement is now a bolt-on to any tier** — if a deal's in flight
the deal-status hero drops into the hero's footer, otherwise you get the
setup checklist
- All new copy is translated (en-US) and it reuses the shared UI kit,
icons and design tokens

## Tidy-ups / fixes found along the way
- The subscribed hero was shadowing the real `/v1/editor/deployment`
endpoint (broke the Editor admin page) — now reuses it
- Renamed the hero's CSS namespace to `.portal-welcome` so it stops
clashing with the procurement hero's `.portal-hero`
- Refactored the merged procurement component into a shared
`useProcurement` hook + banner + flow, so the deal hero can live inside
the tier hero — `/procurement` route unchanged

## Screenshots

<img width="1258" height="1338" alt="pr-free"
src="https://github.com/user-attachments/assets/9bb7db44-5f8e-4388-857a-7f113c2d7d82"
/>

<img width="1258" height="862" alt="pr-enterprise"
src="https://github.com/user-attachments/assets/f1f36faa-1467-404e-9036-dab12e3d0b54"
/>

<img width="1258" height="944" alt="pr-subscribed"
src="https://github.com/user-attachments/assets/775f1228-f182-47b5-82ff-7ac6d5932bd9"
/>

---

## Checklist

### General
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings

### UI Changes
- [x] Screenshots demonstrating the UI changes are attached

### Testing
- [x] Portal typecheck, ESLint and Prettier pass on the changed files
- [x] Verified all states in Storybook and ran the app locally via `task
dev:portal`
2026-07-08 13:44:30 +00:00
Anthony Stirling 0692058602 Embed admin portal as its own app in the jar behind buildWithPortal (#6911)
## What

Lets the admin portal ("Stirling Processor") ship **inside the JAR**,
gated by a build flag. On `main` the portal already exists as a lazy
`/portal/*` route in the editor but isn't included in production builds
and isn't reachable in a login-enabled server. This PR makes it a
**flag-gated, directly-navigable** part of the editor bundle, and wires
it into the PR preview deployment so it can be tried live.

It keeps the exact architecture `main` uses (portal = a lazy chunk of
the editor, not a separate app), so it inherits all the editor's global
providers/styles and there's no second build to maintain.

## How

**Frontend - gate the existing lazy route**
([`adminRouteExtensions.tsx`](frontend/editor/src/proprietary/routes/adminRouteExtensions.tsx))
```ts
const includePortal = import.meta.env.VITE_INCLUDE_PORTAL === "true" || import.meta.env.DEV;
const PortalApp = includePortal ? lazy(() => import("@portal/PortalApp")) : null;
```
Vite bakes the env to a literal, so when off the dynamic import is
**tree-shaken out entirely** (no `PortalApp` chunk emitted). Always on
in dev. `VITE_INCLUDE_PORTAL` is typed in `vite-env.d.ts` and declared
(default `false`) in `editor/.env`.

**Gradle** ([`build.gradle`](app/core/build.gradle)) -
`-PbuildWithPortal=true` forces `buildWithFrontend=true` and sets
`VITE_INCLUDE_PORTAL=true` on the editor build. Process-env takes
priority over `.env`, so the flag wins for JAR builds while plain `vite
build` / Cloudflare Pages default to off.

**Backend - make the shell reachable**
([`RequestUriUtils`](app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java))
- permits `/portal` + `/portal/*` as public SPA routes. The editor keeps
its JWT in localStorage (not a cookie), so a direct nav/refresh to
`/portal` isn't authenticated at the server and would otherwise redirect
to `/login` and never load. Serving the shell pre-auth (like the editor
root already is) lets it load; **access control is unchanged** - the
portal has its own auth gate + `RequirePortalAccess`, and its data APIs
stay protected.

**Docker** - the embedded Dockerfiles take `ARG BUILD_PORTAL=false` →
`-PbuildWithPortal=${BUILD_PORTAL}`. Default off, so official
`push-docker` images do **not** bundle the portal.

**CI - scoped to the PR preview deploy only**
([`PR-Auto-Deploy-V2.yml`](.github/workflows/PR-Auto-Deploy-V2.yml)) -
the one job that builds the JAR and comments owns all portal wiring:
passes `BUILD_PORTAL=true`, enables the portal's backend features
(`POLICIES_ENABLED`, `STIRLING_BILLING_ACCOUNT_LINK_ENABLED`), and adds
an "Admin portal included" line (linking `/portal` via the direct IP) to
the deployment comment. `push-docker`, `build.yml`, `test-build-docker`,
and the shared paths-filter are untouched.

## Validation (real, in the JAR)

Built and booted the JAR with `-PbuildWithPortal=true` and login
enabled:
- `/portal` and `/portal/users` load via direct nav and render **fully
themed** (dark surfaces, gradients, filled buttons).
- Editor-only build (`-PbuildWithFrontend=true`, no portal flag) →
editor ships, **0 portal chunks** (tree-shaken).
- `-PbuildWithPortal=true` → `PortalApp` chunk present.

Green: `frontend:check:all` (typecheck all variants, lint, format,
build, tests incl. the `VITE_*` env guard), backend compile,
`RequestUriUtilsTest`, spotless.

## Notes

- **Official images never bundle the portal** (Dockerfile default off);
only the PR preview does. Flip `BUILD_PORTAL` / `-PbuildWithPortal` to
include it elsewhere.
- The `/portal` shell being public is the one deviation from `main`, and
it's required for the route to be reachable at all in a login-enabled
server; data access is still fully gated.
2026-07-08 12:55:22 +00:00
James Brunton 8150d16b6f Support bidirectional mapping for Change Metadata (#6906)
# Description of Changes
The Change Metadata tool was missed from the bidirectional mappings
added in #6867. This PR adds it to the list of supported tools.
2026-07-08 12:47:11 +00:00
Reece Browne c8f238ae60 Portal/editor switcher (#6907)
Adds the portal's top-left app switcher to the editor sidebar, so you
can jump between the two apps from either side.

- Both sidebars render the same shared `AppSwitch` component (sui
dropdown).
- Switching is client-side (no page reload); portal→editor no longer
breaks when `VITE_EDITOR_URL` is unset.
- Editor side is admin-gated (`portalAccess`) and only exists in flavors
that ship the portal — core/desktop stub it out, same seam pattern as
the portal routes.
- Fixes en route: dropdown menu stacking in the editor sidebar, sui
dropdown item button reset, stale `dist-portal` ESLint ignore.
2026-07-08 12:17:00 +00:00
ConnorYoh 8df49ac053 feat(portal): build portal for SaaS and self-hosted via file-override layer (#6900)
## What

Ground-work so the admin portal can build for the **SaaS flavor**
alongside self-hosted, using the editor's existing **build-time
file-override** mechanism — no runtime flavor flags. This PR
demonstrates SaaS end-to-end (single-login + Usage page loading via the
inherited Supabase session) without changing self-hosted behaviour.

This is intentionally scoped as foundations, not the whole feature.

## How

**Build hook**
- `tsconfig.saas.vite.json`: `@portal/*` now cascades
(`src/saas/portal/*` → `src/portal/*`); added `@portalCore/*` for the
explicit base path.
- New `src/portal-saas/` layer (sibling of `src/portal`) holds SaaS-only
overrides, so `@app/*` resolves only editor layers and `@portal/*` only
portal layers. Self-hosted builds never import it (tree-shaken).

**Seams live in the api-client + composition layers — never in page
components**
- `saasApiBase` — base URL source (self-hosted: `VITE_SAAS_API_URL`;
SaaS reuses the single `VITE_API_BASE_URL` backend).
- `portalSaasSession` — flavor-agnostic token from the shared Supabase
client.
- `PortalAuthBoundary` — self-hosted: Spring `AuthProvider` +
`AuthGate`; SaaS: Supabase `AuthProvider` + session-only gate (inherits
the SaaS session, so no second login).

**Link concept pulled out of the Usage page (one clean cut)**
- `Usage` is now a link-free wallet renderer with generic
`onWalletLoaded` / `onReauth` callbacks; it always loads the wallet and
has zero flavor awareness.
- `PortalBillingGate` is the single flavor seam: self-hosted gates on
link (prompt when unlinked; wires the callbacks onto link/tier +
re-auth), SaaS is a passthrough that renders `Usage` directly.
- Keeps the flavor switch out of the page entirely (no per-flavor code
in `Usage`).

## Testing
Green locally and in CI (CI runs the umbrella `task
frontend:check:all`):
- `task frontend:typecheck:all` — clean across all 7 build variants
- `task frontend:test` — vitest suites pass (portal + saas cover this
change; 146 tests)
- `task frontend:build:saas` and `task frontend:build:proprietary` —
both green
- `task frontend:lint` and `task frontend:format:check` — clean

## Also in this PR (added after the initial foundations)
- **Tier from wallet + full link-layer excision on SaaS.** `TierContext`
no longer reads `LinkContext` (via a `usePlanTier` seam: self-hosted
from link state, SaaS from `wallet.status`), and the SaaS
`PortalProviders` drops `LinkProvider` / `AccountLinkProvider` /
`LinkModalHost` entirely — the link machinery is *absent* from the SaaS
bundle, not mounted-but-inert.

## Deliberately out of scope (follow-ups)
- SaaS-only read-only "connected servers" settings view.
- Shared wallet source so the SaaS tier badge and the Usage page don't
both fetch `/payg/wallet` (harmless double-fetch today).
2026-07-08 12:07:54 +00:00
Anthony Stirling f703a67817 Fix cert sign not showing under certain instances (#6908) 2026-07-07 22:39:57 +01:00
57bf17d348 Fix missing app icon on Linux/Wayland (#6875)
Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
Co-authored-by: Ludy <Ludy87@users.noreply.github.com>
2026-07-07 21:58:21 +01:00
Ludy 11df30b914 feat(ui): add dedicated third-party license sections to settings (#6820) 2026-07-07 21:57:34 +01:00