From 75291905872356e78e7eddc1f9f9e9fe526764a8 Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:26:47 +0100 Subject: [PATCH 01/34] fix(ui): shared Button content-sizing + padding props, and button call-site cleanups (#6914) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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`. --- .../shared/ButtonSelector.stories.tsx | 42 +++ .../shared/FileSelectorPicker.module.css | 1 + .../components/shared/FileSidebarFileItem.css | 26 ++ .../components/shared/FileSidebarFileItem.tsx | 257 +++++++++--------- .../src/core/components/tools/ToolPanel.css | 8 +- .../certSign/CertificateFormatSettings.tsx | 111 ++------ .../components/tools/compare/compareView.css | 41 +-- .../tools/toolPicker/ToolButton.tsx | 3 + .../tools/toolPicker/ToolPicker.css | 25 +- frontend/editor/src/core/styles/theme.css | 40 +-- .../editor/src/core/theme/mantineTheme.ts | 17 -- frontend/editor/src/core/tools/Compare.tsx | 31 +-- frontend/editor/src/core/ui/Button.css | 30 ++ .../editor/src/core/ui/Button.stories.tsx | 51 +++- frontend/editor/src/core/ui/Button.tsx | 64 +++-- frontend/editor/src/core/ui/CarouselDots.css | 52 ++++ .../src/core/ui/CarouselDots.stories.tsx | 48 ++++ frontend/editor/src/core/ui/CarouselDots.tsx | 59 ++++ frontend/editor/src/core/ui/Chip.css | 11 + frontend/editor/src/core/ui/MantineForms.css | 4 + .../editor/src/core/ui/SegmentedControl.css | 7 + .../editor/src/core/ui/SegmentedControl.tsx | 9 +- frontend/editor/src/core/ui/controlSizes.ts | 41 +++ frontend/editor/src/core/ui/index.ts | 1 + .../components/SetupWizard/desktopOAuth.css | 5 +- .../editor/src/portal/components/Header.css | 15 +- .../editor/src/portal/components/Header.tsx | 15 +- .../components/infrastructure/ApiKeyCard.tsx | 26 +- frontend/editor/src/portal/views/Home.css | 16 +- frontend/editor/src/portal/views/Home.tsx | 137 +++++----- .../src/portal/views/Infrastructure.css | 5 +- .../auth/ui/LoginRightCarousel.tsx | 37 +-- .../editor/src/proprietary/auth/ui/auth.css | 28 +- .../components/policies/Policies.css | 3 +- .../editor/src/proprietary/routes/Login.tsx | 12 +- 35 files changed, 778 insertions(+), 500 deletions(-) create mode 100644 frontend/editor/src/core/components/shared/ButtonSelector.stories.tsx create mode 100644 frontend/editor/src/core/ui/CarouselDots.css create mode 100644 frontend/editor/src/core/ui/CarouselDots.stories.tsx create mode 100644 frontend/editor/src/core/ui/CarouselDots.tsx diff --git a/frontend/editor/src/core/components/shared/ButtonSelector.stories.tsx b/frontend/editor/src/core/components/shared/ButtonSelector.stories.tsx new file mode 100644 index 0000000000..1169c0b3a7 --- /dev/null +++ b/frontend/editor/src/core/components/shared/ButtonSelector.stories.tsx @@ -0,0 +1,42 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ButtonSelector from "@app/components/shared/ButtonSelector"; + +/** Reproduces the redaction "Mode" picker (Automatic / Manual). */ +const meta: Meta = { + title: "Shared/ButtonSelector", + component: ButtonSelector, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +function ModeDemo({ disabled }: { disabled?: boolean }) { + const [value, setValue] = useState("automatic"); + return ( + + ); +} + +/** Enabled (files present): the selected segment must be dark + readable. */ +export const RedactionMode: Story = { render: () => }; + +/** Both options disabled (no files) — the selected segment must STILL be legible. */ +export const DisabledOptions: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/shared/FileSelectorPicker.module.css b/frontend/editor/src/core/components/shared/FileSelectorPicker.module.css index 7d9b0251db..89c57dd3fa 100644 --- a/frontend/editor/src/core/components/shared/FileSelectorPicker.module.css +++ b/frontend/editor/src/core/components/shared/FileSelectorPicker.module.css @@ -224,6 +224,7 @@ /* ── Individual file row ─────────────────────────── */ .fileItem { width: 100%; + --sui-btn-py: 0.625rem; padding: 0.625rem 0.875rem; display: flex; align-items: center; diff --git a/frontend/editor/src/core/components/shared/FileSidebarFileItem.css b/frontend/editor/src/core/components/shared/FileSidebarFileItem.css index 7bce3162dc..51278c1673 100644 --- a/frontend/editor/src/core/components/shared/FileSidebarFileItem.css +++ b/frontend/editor/src/core/components/shared/FileSidebarFileItem.css @@ -23,6 +23,7 @@ /* ---- File item ---- */ .file-sidebar-file-item { + position: relative; display: flex; align-items: center; gap: 10px; @@ -37,6 +38,31 @@ contain-intrinsic-size: auto 46px; } +.file-sidebar-file-actions { + position: absolute; + top: 50%; + right: 8px; + transform: translateY(-50%); + display: flex; + align-items: center; + gap: 2px; + pointer-events: none; +} + +.file-sidebar-file-item:hover .file-sidebar-file-actions, +.file-sidebar-file-item.viewed .file-sidebar-file-actions { + pointer-events: auto; +} + +/* Only shrink the name to clear the buttons while they're visible. */ +.file-sidebar-file-info { + transition: padding-right 0.12s ease; +} +.file-sidebar-file-item:hover .file-sidebar-file-info, +.file-sidebar-file-item.viewed .file-sidebar-file-info { + padding-right: 3.5rem; +} + .file-sidebar-file-item:hover:not(.selected) { background-color: rgba(59, 130, 246, 0.06); } diff --git a/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx b/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx index 8621466120..8647e18a68 100644 --- a/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx +++ b/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx @@ -365,133 +365,138 @@ export function FileItem({ )} - { - e.stopPropagation(); - onEyeClick(fileId, e); - }} - tabIndex={-1} - aria-label={ - isViewedInViewer - ? t("fileSidebar.fileItem.closeViewer", "Close viewer") - : t("fileSidebar.fileItem.openInViewer", "Open in viewer") - } - > - - - - {(onDelete || - (canSaveToCloud && onSaveToCloud) || - (hasVersionHistory && onVersionHistory)) && ( - - - e.stopPropagation()} - tabIndex={-1} - aria-label={t( - "fileSidebar.fileItem.moreActions", - "More actions", - )} - > - - - - e.stopPropagation()}> - {hasVersionHistory && onVersionHistory && ( - } - onClick={(e) => { - e.stopPropagation(); - onVersionHistory(fileId); - }} +
+ { + e.stopPropagation(); + onEyeClick(fileId, e); + }} + tabIndex={-1} + aria-label={ + isViewedInViewer + ? t("fileSidebar.fileItem.closeViewer", "Close viewer") + : t("fileSidebar.fileItem.openInViewer", "Open in viewer") + } + > + + + + {(onDelete || + (canSaveToCloud && onSaveToCloud) || + (hasVersionHistory && onVersionHistory)) && ( + + + e.stopPropagation()} + tabIndex={-1} + aria-label={t( + "fileSidebar.fileItem.moreActions", + "More actions", + )} > - {t("fileSidebar.fileItem.versionHistory", "Version history")} - - )} - {canSaveToCloud && - onSaveToCloud && - (() => { - const uploadLabel = isUploadedToCloud - ? t( - "fileSidebar.fileItem.updateOnServer", - "Update on server", - ) - : t( - "fileSidebar.fileItem.uploadToServer", - "Upload to server", - ); - return ( - -
- - } - onClick={(e) => { - e.stopPropagation(); - onSaveToCloud(fileId); - }} - > - {uploadLabel} - -
-
- ); - })()} - {onDelete && - (() => { - const deleteLabel = t( - "fileSidebar.fileItem.delete", - "Delete", - ); - return ( - -
- - } - onClick={(e) => { - e.stopPropagation(); - onDelete(fileId); - }} - > - {deleteLabel} - -
-
- ); - })()} - -
- )} + + + + e.stopPropagation()}> + {hasVersionHistory && onVersionHistory && ( + } + onClick={(e) => { + e.stopPropagation(); + onVersionHistory(fileId); + }} + > + {t( + "fileSidebar.fileItem.versionHistory", + "Version history", + )} + + )} + {canSaveToCloud && + onSaveToCloud && + (() => { + const uploadLabel = isUploadedToCloud + ? t( + "fileSidebar.fileItem.updateOnServer", + "Update on server", + ) + : t( + "fileSidebar.fileItem.uploadToServer", + "Upload to server", + ); + return ( + +
+ + } + onClick={(e) => { + e.stopPropagation(); + onSaveToCloud(fileId); + }} + > + {uploadLabel} + +
+
+ ); + })()} + {onDelete && + (() => { + const deleteLabel = t( + "fileSidebar.fileItem.delete", + "Delete", + ); + return ( + +
+ + } + onClick={(e) => { + e.stopPropagation(); + onDelete(fileId); + }} + > + {deleteLabel} + +
+
+ ); + })()} +
+
+ )} + {useRasterThumb && diff --git a/frontend/editor/src/core/components/tools/ToolPanel.css b/frontend/editor/src/core/components/tools/ToolPanel.css index a8ad41588f..e9cbb9b1b2 100644 --- a/frontend/editor/src/core/components/tools/ToolPanel.css +++ b/frontend/editor/src/core/components/tools/ToolPanel.css @@ -511,7 +511,8 @@ flex-direction: row; gap: 0.75rem; align-items: flex-start; - padding: 0.85rem 0.95rem; + --sui-btn-py: 1rem; + padding: 1rem 0.95rem; border: 1px solid var(--fullscreen-border-subtle-70); border-radius: 0.95rem; background: var(--fullscreen-bg-item); @@ -526,6 +527,11 @@ min-width: 0; } +.tool-panel__fullscreen-item .mantine-Button-label { + gap: 1rem; + align-items: flex-start; +} + .tool-panel__fullscreen-item:focus-visible { outline: 2px solid var(--fullscreen-accent-selected); outline-offset: 3px; diff --git a/frontend/editor/src/core/components/tools/certSign/CertificateFormatSettings.tsx b/frontend/editor/src/core/components/tools/certSign/CertificateFormatSettings.tsx index 5be808b492..cbc4b67163 100644 --- a/frontend/editor/src/core/components/tools/certSign/CertificateFormatSettings.tsx +++ b/frontend/editor/src/core/components/tools/certSign/CertificateFormatSettings.tsx @@ -1,4 +1,4 @@ -import { Stack } from "@mantine/core"; +import { SimpleGrid } from "@mantine/core"; import { Button } from "@app/ui/Button"; import { CertSignParameters } from "@app/hooks/tools/certSign/useCertSignParameters"; @@ -8,106 +8,27 @@ interface CertificateFormatSettingsProps { disabled?: boolean; } +const FORMATS = ["PKCS12", "PFX", "PEM", "JKS"] as const; + const CertificateFormatSettings = ({ parameters, onParameterChange, disabled = false, }: CertificateFormatSettingsProps) => { return ( - -
- {/* First row - PKCS#12 and PFX */} -
- - -
- {/* Second row - PEM and JKS */} -
- - -
-
-
+ + {FORMATS.map((format) => ( + )} - + + + {t("compare.edited.label", "Edited PDF")} + + {renderSlot("comparison")} + = { ], }, size: { control: "inline-radio", options: ["sm", "md", "lg", "xl"] }, + p: { + control: "inline-radio", + options: [undefined, "none", "xs", "sm", "md", "lg", "xl"], + }, + px: { + control: "inline-radio", + options: [undefined, "none", "xs", "sm", "md", "lg", "xl"], + }, + py: { + control: "inline-radio", + options: [undefined, "none", "xs", "sm", "md", "lg", "xl"], + }, justify: { control: "inline-radio", options: ["center", "start", "end", "between"], @@ -141,6 +153,34 @@ export const Accents: Story = { ), }; +/** Loading keeps the button's shape. A labelless loader collapses to an icon + square — unless `fullWidth`, which must always span its container (e.g. an + execute button whose label is briefly absent while files hydrate). */ +export const Loading: Story = { + render: (args) => ( +
+
+ ), +}; + +/** Disabled primary — dark mode keeps a muted accent instead of grey. */ +export const DisabledDark: Story = { + render: () => ( +
+
+
+
+ ), +}; + /** Real size differences. */ export const Sizes: Story = { render: (args) => ( @@ -152,15 +192,12 @@ export const Sizes: Story = { ), }; -/** - * `fontSize` scales the label text independently of `size` (which sets height). - * Unset inherits the `size`-derived default (md for a default button). - */ -export const FontSizes: Story = { +/** Padding override via `p` (both axes); `px`/`py` override a single axis. */ +export const Padding: Story = { render: (args) => ( - {(["xs", "sm", "md", "lg", "xl"] as const).map((fontSize) => ( - diff --git a/frontend/editor/src/portal/components/infrastructure/ApiKeyCard.tsx b/frontend/editor/src/portal/components/infrastructure/ApiKeyCard.tsx index 221ad0543a..ce78ded6fa 100644 --- a/frontend/editor/src/portal/components/infrastructure/ApiKeyCard.tsx +++ b/frontend/editor/src/portal/components/infrastructure/ApiKeyCard.tsx @@ -15,25 +15,29 @@ export function ApiKeyCard({ apiKey }: { apiKey: ApiKey }) { {open && ( diff --git a/frontend/editor/src/portal/views/Home.css b/frontend/editor/src/portal/views/Home.css index 70752c714b..e1af68b768 100644 --- a/frontend/editor/src/portal/views/Home.css +++ b/frontend/editor/src/portal/views/Home.css @@ -61,12 +61,9 @@ gap: 0.375rem; } +/* Visual container only — layout (icon / text / arrow) is handled by the + shared Button's leftSection / label / rightSection, not overridden here. */ .portal-home__quick-row { - display: grid; - grid-template-columns: auto 1fr auto; - align-items: center; - gap: 0.625rem; - padding: 0.5rem 0.625rem; background: var(--color-bg-subtle); border: 1px solid var(--color-border-light); border-radius: var(--radius-md); @@ -81,6 +78,14 @@ border-color: var(--color-border); } +/* Let the label grow so the arrow (rightSection) is pushed to the far right, + and don't clip the stacked subtitle. */ +.portal-home__quick-row .mantine-Button-label { + flex: 1 1 auto; + justify-content: flex-start; + overflow: visible; +} + .portal-home__quick-icon { display: inline-flex; align-items: center; @@ -95,6 +100,7 @@ .portal-home__quick-text { display: flex; flex-direction: column; + align-items: flex-start; min-width: 0; } diff --git a/frontend/editor/src/portal/views/Home.tsx b/frontend/editor/src/portal/views/Home.tsx index b9a42fbab8..a1829d5476 100644 --- a/frontend/editor/src/portal/views/Home.tsx +++ b/frontend/editor/src/portal/views/Home.tsx @@ -1,7 +1,7 @@ import { useTranslation } from "react-i18next"; import { Button, Card } from "@app/ui"; import { useTier } from "@portal/contexts/TierContext"; -import { useView } from "@portal/contexts/ViewContext"; +import { useView, type ViewId } from "@portal/contexts/ViewContext"; import { HomeHero } from "@portal/components/HomeHero"; import { HomeGreeting } from "@portal/components/HomeGreeting"; import { RecentActivity } from "@portal/components/RecentActivity"; @@ -13,6 +13,37 @@ import "@portal/views/Home.css"; /* Quick actions card */ /* ──────────────────────────────────────────────────────────────────────── */ +/** Rows for the Quick Actions list. Each `view` navigates the portal. */ +const QUICK_ACTIONS: Array<{ + key: string; + glyph: string; + bg: string; + fg: string; + view: ViewId; +}> = [ + { + key: "buildPipeline", + glyph: "⌃", + bg: "var(--color-purple-light)", + fg: "var(--color-purple)", + view: "pipelines", + }, + { + key: "connectSource", + glyph: "⇢", + bg: "var(--color-green-light)", + fg: "var(--color-green-dark)", + view: "sources", + }, + { + key: "issueApiKey", + glyph: "⚙", + bg: "var(--color-amber-light)", + fg: "var(--color-amber-dark)", + view: "infrastructure", + }, +]; + function QuickActions() { const { t } = useTranslation(); const { setActiveView } = useView(); @@ -27,78 +58,40 @@ function QuickActions() {
- - - + + + {t(`portal.home.quickActions.${action.key}.title`)} + + {t(`portal.home.quickActions.${action.key}.blurb`)} + + + ))}
); diff --git a/frontend/editor/src/portal/views/Infrastructure.css b/frontend/editor/src/portal/views/Infrastructure.css index f0a2f55da9..bca7406359 100644 --- a/frontend/editor/src/portal/views/Infrastructure.css +++ b/frontend/editor/src/portal/views/Infrastructure.css @@ -205,11 +205,8 @@ } .portal-infra__key-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; width: 100%; + --sui-btn-py: 0.875rem; /* 14px */ padding: 0.875rem 1.125rem; background: transparent; text-align: left; diff --git a/frontend/editor/src/proprietary/auth/ui/LoginRightCarousel.tsx b/frontend/editor/src/proprietary/auth/ui/LoginRightCarousel.tsx index f6ad17ec7e..490aea923c 100644 --- a/frontend/editor/src/proprietary/auth/ui/LoginRightCarousel.tsx +++ b/frontend/editor/src/proprietary/auth/ui/LoginRightCarousel.tsx @@ -1,5 +1,5 @@ import { memo, useEffect, useMemo, useRef, useState } from "react"; -import { Button } from "@app/ui/Button"; +import { CarouselDots } from "@app/ui/CarouselDots"; import bgDefault from "@app/assets/login/LoginBackgroundPanel.png"; export type ImageSlide = { @@ -196,42 +196,21 @@ function LoginRightCarousel({ ))} {/* Dot navigation */} -
- {Array.from({ length: totalSlides }).map((_, i) => ( -
+ /> ); } diff --git a/frontend/editor/src/proprietary/auth/ui/auth.css b/frontend/editor/src/proprietary/auth/ui/auth.css index 249d6cdda5..31f1eb480a 100644 --- a/frontend/editor/src/proprietary/auth/ui/auth.css +++ b/frontend/editor/src/proprietary/auth/ui/auth.css @@ -172,6 +172,19 @@ align-items: center; } +/* These OAuth buttons are shared )} - {stage !== "active" && ( + {snapshot.licenseKey && ( )} {stage !== "active" && ( @@ -120,7 +121,7 @@ export function DealStatusHero({
- +
{inTrial && ( diff --git a/frontend/editor/src/portal/components/procurement/ProcurementAgreement.tsx b/frontend/editor/src/portal/components/procurement/ProcurementAgreement.tsx index 640fcdb2e3..336b6af5cb 100644 --- a/frontend/editor/src/portal/components/procurement/ProcurementAgreement.tsx +++ b/frontend/editor/src/portal/components/procurement/ProcurementAgreement.tsx @@ -15,16 +15,24 @@ import "@portal/views/Procurement.css"; export function ProcurementAgreement({ quote, busy, + downloading, onAgree, + onDownload, + onEdit, }: { quote: QuoteResult; busy: boolean; + downloading: boolean; + /** Accept the quote straight into a committed subscription (this is also the agreement). */ onAgree: () => void; + onDownload: () => void; + onEdit: () => void; }) { const { t } = useTranslation(); const [checked, setChecked] = useState(false); const annual = money(quote.annualNetMinor, quote.currency); const tcv = money(quote.tcvMinor, quote.currency); + const renewal = money(quote.renewalAnnualNetMinor, quote.currency); const years = quote.config.termYears; return ( @@ -73,7 +81,19 @@ export function ProcurementAgreement({ ))} -

3. End-User License Agreement

+

3. Term, renewal and annual fee adjustment

+

+ This Agreement runs for the committed {years}-year term set out in the + Order Form. It then renews automatically for successive one-year terms + unless either party gives written notice of non-renewal at least 30 + days before the end of the then-current term. On each renewal the + annual fee increases by {quote.cpiRatePct}%, a fixed CPI adjustment. + Based on this quote, the first renewal year would be approximately{" "} + {renewal} per year; the committed term above is + billed at the rate in the Order Form and is not affected. +

+ +

4. End-User License Agreement

Subject to the terms of this Agreement, Stirling grants Customer a non-exclusive, non-transferable right to use the Service for its @@ -82,7 +102,7 @@ export function ProcurementAgreement({ Service, and all intellectual property in it, remains Stirling's.

-

4. Data Processing Agreement

+

5. Data Processing Agreement

Where Stirling processes personal data on Customer's behalf, it does so only on Customer's documented instructions and applies appropriate @@ -92,7 +112,7 @@ export function ProcurementAgreement({ reference.

-

5. Acceptance

+

6. Acceptance

By agreeing below, Customer accepts this Agreement and the Order Form. On acceptance, Stirling will issue the committed annual subscription @@ -120,6 +140,12 @@ export function ProcurementAgreement({ > {t("portal.procurement.agreement.agreeCta")} + + ); diff --git a/frontend/editor/src/portal/components/procurement/ProcurementBanner.tsx b/frontend/editor/src/portal/components/procurement/ProcurementBanner.tsx index 902bb14c0d..d47ffa0b48 100644 --- a/frontend/editor/src/portal/components/procurement/ProcurementBanner.tsx +++ b/frontend/editor/src/portal/components/procurement/ProcurementBanner.tsx @@ -22,7 +22,7 @@ export function ControlledDealStatusHero({ busy={controller.busy} canSchedule={controller.isLinked} onExpand={() => controller.setOpen(true)} - onKeyDocs={() => controller.setExtra("docs")} + onLicense={() => controller.setExtra("license")} onInvite={() => setActiveView("users")} onSchedule={() => controller.setExtra("schedule")} onManageTrial={() => controller.setExtra("trial")} diff --git a/frontend/editor/src/portal/components/procurement/ProcurementExtras.tsx b/frontend/editor/src/portal/components/procurement/ProcurementExtras.tsx index 048bc2d606..44bbea1264 100644 --- a/frontend/editor/src/portal/components/procurement/ProcurementExtras.tsx +++ b/frontend/editor/src/portal/components/procurement/ProcurementExtras.tsx @@ -1,17 +1,17 @@ -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { Button } from "@app/ui"; import type { ProcurementSnapshot } from "@portal/api/procurement"; import { CalendlyInline } from "@portal/components/procurement/CalendlyInline"; +import { LicensePanel } from "@portal/components/procurement/ProcurementStages"; import { useFocusTrap } from "@portal/components/procurement/ProcurementModal"; import "@portal/views/Procurement.css"; /** - * Small centred dialogs that hang off the deal-status hero's quick actions — Key documents, Schedule - * a call, and trial management. Schedule a call embeds the live Calendly scheduler; Key documents is - * still mocked for the pilot (static demo data). The shells and wiring are real so the hero behaves - * like the marketing prototype. + * Small centred dialogs that hang off the deal-status hero's quick actions — the licence key, + * schedule a call, trial management, and trial setup. Schedule a call embeds the live Calendly + * scheduler. The shells and wiring are real so the hero behaves like the marketing prototype. */ function SideModal({ @@ -74,124 +74,45 @@ function SideModal({ ); } -// ── Key documents ──────────────────────────────────────────────────────────── -type DocStatus = "available" | "action" | "request"; -interface DocRow { - nameKey: string; - subKey: string; - status: DocStatus; - fee?: number; -} -// Static demo catalogue for the pilot; the copy lives in the locale under -// portal.procurement.keyDocs and is resolved via t() at render time. -const STAGE_DOCS: { groupKey: string; docs: DocRow[] }[] = [ - { - groupKey: "portal.procurement.keyDocs.groups.yourDeal", - docs: [ - { - nameKey: "portal.procurement.keyDocs.docs.formalQuote.name", - subKey: "portal.procurement.keyDocs.docs.formalQuote.sub", - status: "available", - }, - { - nameKey: "portal.procurement.keyDocs.docs.msa.name", - subKey: "portal.procurement.keyDocs.docs.msa.sub", - status: "action", - }, - { - nameKey: "portal.procurement.keyDocs.docs.bankTransfer.name", - subKey: "portal.procurement.keyDocs.docs.bankTransfer.sub", - status: "available", - }, - { - nameKey: "portal.procurement.keyDocs.docs.purchaseOrder.name", - subKey: "portal.procurement.keyDocs.docs.purchaseOrder.sub", - status: "request", - }, - ], - }, - { - groupKey: "portal.procurement.keyDocs.groups.evaluation", - docs: [ - { - nameKey: "portal.procurement.keyDocs.docs.soc2.name", - subKey: "portal.procurement.keyDocs.docs.soc2.sub", - status: "available", - }, - { - nameKey: "portal.procurement.keyDocs.docs.securityReview.name", - subKey: "portal.procurement.keyDocs.docs.securityReview.sub", - status: "request", - fee: 5000, - }, - { - nameKey: "portal.procurement.keyDocs.docs.baa.name", - subKey: "portal.procurement.keyDocs.docs.baa.sub", - status: "request", - fee: 2500, - }, - { - nameKey: "portal.procurement.keyDocs.docs.w9.name", - subKey: "portal.procurement.keyDocs.docs.w9.sub", - status: "available", - }, - { - nameKey: "portal.procurement.keyDocs.docs.coi.name", - subKey: "portal.procurement.keyDocs.docs.coi.sub", - status: "available", - }, - ], - }, -]; -const STATUS_LABEL: Record = { - available: "portal.procurement.keyDocs.status.available", - action: "portal.procurement.keyDocs.status.action", - request: "portal.procurement.keyDocs.status.request", -}; - -export function KeyDocumentsModal({ +// ── Licence key ────────────────────────────────────────────────────────────── +export function LicenseModal({ open, onClose, + licenseKey, + offlineAvailable, + downloadingLicense, + onDownloadOffline, + trial = false, }: { open: boolean; onClose: () => void; + licenseKey: string; + offlineAvailable: boolean; + downloadingLicense: boolean; + onDownloadOffline: () => void; + /** Licence is still the trial one (not yet upgraded on accept) — the downloadable .lic is a + * snapshot, so warn that it must be re-downloaded once the agreement is in place. */ + trial?: boolean; }) { const { t } = useTranslation(); return ( - {STAGE_DOCS.map((g) => ( -

-
{t(g.groupKey)}
-
    - {g.docs.map((d) => ( -
  • -
    - {t(d.nameKey)} - - {t(d.subKey)} - {d.fee - ? t("portal.procurement.keyDocs.oneTimeFee", { - amount: `$${d.fee.toLocaleString()}`, - }) - : ""} - -
    - - {t(STATUS_LABEL[d.status])} - -
  • - ))} -
-
- ))} + + {offlineAvailable && trial && ( +

+ {t("portal.procurement.license.trialFileHint")} +

+ )} ); } @@ -221,6 +142,97 @@ export function ScheduleCallModal({ ); } +// ── Trial setup ──────────────────────────────────────────────────────────── +const DEPLOYMENTS = ["cloud", "selfhost", "airgap"] as const; + +/** + * Captured before the trial starts: where the buyer will run Stirling (which drives the deployment + * fee and, for air-gapped, the offline licence) and their team size. Both seed the quote builder so + * it opens on their real environment; the trial only begins once this is confirmed. + */ +export function TrialSetupModal({ + open, + onClose, + busy, + onConfirm, +}: { + open: boolean; + onClose: () => void; + busy: boolean; + onConfirm: (deployment: string, seats: number) => void; +}) { + const { t } = useTranslation(); + const [deployment, setDeployment] = useState("cloud"); + const [seats, setSeats] = useState(""); + + // Reset to defaults each time the dialog opens, so a cancelled setup doesn't linger. + useEffect(() => { + if (open) { + setDeployment("cloud"); + setSeats(""); + } + }, [open]); + + return ( + onConfirm(deployment, Math.max(0, Number(seats) || 0))} + > + {t("portal.procurement.setup.start")} + + } + > + + + +

+ {t("portal.procurement.setup.seatsHint")} +

+
+ ); +} + // ── Trial management ───────────────────────────────────────────────────────── export function TrialManageModal({ open, diff --git a/frontend/editor/src/portal/components/procurement/ProcurementFlow.tsx b/frontend/editor/src/portal/components/procurement/ProcurementFlow.tsx index 94ee979b86..d1527c32bb 100644 --- a/frontend/editor/src/portal/components/procurement/ProcurementFlow.tsx +++ b/frontend/editor/src/portal/components/procurement/ProcurementFlow.tsx @@ -2,19 +2,18 @@ import { useTranslation } from "react-i18next"; import { Banner, Button, EmptyState, Skeleton } from "@app/ui"; import { useUI } from "@portal/contexts/UIContext"; import { useLinkedAccountEmail } from "@portal/hooks/useLinkedAccountEmail"; -import { JOURNEY } from "@portal/api/procurement"; +import { FLOW_JOURNEY } from "@portal/api/procurement"; import { ProcurementAgreement } from "@portal/components/procurement/ProcurementAgreement"; import { - KeyDocumentsModal, + LicenseModal, ScheduleCallModal, TrialManageModal, + TrialSetupModal, } from "@portal/components/procurement/ProcurementExtras"; import { ProcurementModal } from "@portal/components/procurement/ProcurementModal"; import { - LicensePanel, LiveStageCard, PaymentStageCard, - QuoteMilestoneCard, } from "@portal/components/procurement/ProcurementStages"; import { QuoteBuilder } from "@portal/components/procurement/QuoteBuilder"; import { StageStepper } from "@portal/components/procurement/StageStepper"; @@ -22,7 +21,7 @@ import type { ProcurementController } from "@portal/components/procurement/usePr /** * The procurement takeover flow: the full-screen journey modal (quote builder → - * milestone → agreement → payment → live) plus the key-documents, schedule-call, + * quote & agreement → payment → live) plus the licence-key, schedule-call, trial-setup, * and trial-management modals. Driven entirely by a shared ProcurementController * so it can sit next to a deal-status hero rendered elsewhere (e.g. inside the * tier hero card on Home). @@ -56,12 +55,11 @@ export function ProcurementFlow({ extra, setExtra, invoicePdf, + onConfirmSetup, onExtendTrial, onReset, onGenerate, - onAcceptQuote, onAgree, - onGoLive, onDownloadPdf, onDownloadOfflineLicense, } = controller; @@ -106,70 +104,66 @@ export function ProcurementFlow({ {isLinked && started && ( <>
- +
{(editing || (isDraft && (stage === "trial" || stage === "quote"))) && ( )} - {!editing && isIssued && stage === "quote" && latest && ( - setEditing(true)} - /> - )} - - {!editing && stage === "security" && latest && ( - - )} + {/* Quote + agreement are one step: review the itemised quote and the agreement, then + accept straight into a committed subscription. Once accepted you can't go back. + ("security" is the retired agreement stage — still handled so an older deal that + stopped there isn't left blank.) */} + {!editing && + isIssued && + (stage === "quote" || stage === "security") && + latest && ( + setEditing(true)} + /> + )} {!editing && stage === "procurement" && latest && ( )} {!editing && stage === "active" && } - - {data?.licenseKey && ( - - )} - -
- -
)} - setExtra(null)} + busy={busy} + onConfirm={onConfirmSetup} /> + {data?.licenseKey && ( + setExtra(null)} + licenseKey={data.licenseKey} + offlineAvailable={data.deployment === "airgap"} + downloadingLicense={downloadingLicense} + onDownloadOffline={onDownloadOfflineLicense} + trial={data.stage !== "procurement" && data.stage !== "active"} + /> + )} setExtra(null)} diff --git a/frontend/editor/src/portal/components/procurement/ProcurementStages.tsx b/frontend/editor/src/portal/components/procurement/ProcurementStages.tsx index bd5c0e3fc5..35460a7ba5 100644 --- a/frontend/editor/src/portal/components/procurement/ProcurementStages.tsx +++ b/frontend/editor/src/portal/components/procurement/ProcurementStages.tsx @@ -1,8 +1,6 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; import { Button, Card } from "@app/ui"; -import type { QuoteResult } from "@portal/api/procurement"; -import { money } from "@portal/components/procurement/format"; import "@portal/views/Procurement.css"; /** @@ -11,97 +9,13 @@ import "@portal/views/Procurement.css"; * a pure presentational view driven by props; ProcurementHome owns the state and the actions. */ -/** The issued Stripe Quote as a shareable milestone: itemised, with accept / download / edit. */ -export function QuoteMilestoneCard({ - quote, - busy, - downloading, - onAccept, - onDownload, - onEdit, -}: { - quote: QuoteResult; - busy: boolean; - downloading: boolean; - onAccept: () => void; - onDownload: () => void; - onEdit: () => void; -}) { - const { t } = useTranslation(); - return ( - - - {t("portal.procurement.milestone.eyebrow", { - number: quote.quoteNumber, - })} - -

- {t("portal.procurement.milestone.title")} -

- {quote.config.businessName && ( -

- {t("portal.procurement.milestone.preparedFor", { - company: quote.config.businessName, - })} -

- )} -

- {t("portal.procurement.milestone.description")} -

-
    - {quote.lineItems.map((li) => ( -
  • - {li.label} - - {li.kind === "INCLUDED" - ? t("portal.procurement.builder.included") - : money(li.amountMinor, quote.currency)} - -
  • - ))} -
-
- - {money(quote.annualNetMinor, quote.currency)} - {t("portal.procurement.milestone.perYear")} - - - {t("portal.procurement.milestone.tcv", { - value: money(quote.tcvMinor, quote.currency), - })} - -
-
- - - -
-
- ); -} - -/** The subscription-created step: pay/download the first invoice, or (demo) simulate payment. */ +/** The subscription-created step: pay or download the first invoice. */ export function PaymentStageCard({ invoiceUrl, invoicePdf, - busy, - onSimulate, }: { invoiceUrl?: string | null; invoicePdf?: string | null; - busy: boolean; - onSimulate: () => void; }) { const { t } = useTranslation(); return ( @@ -133,11 +47,6 @@ export function PaymentStageCard({ )} )} -
- -
); } diff --git a/frontend/editor/src/portal/components/procurement/QuoteBuilder.tsx b/frontend/editor/src/portal/components/procurement/QuoteBuilder.tsx index 1ba6016a4a..b392247563 100644 --- a/frontend/editor/src/portal/components/procurement/QuoteBuilder.tsx +++ b/frontend/editor/src/portal/components/procurement/QuoteBuilder.tsx @@ -15,12 +15,19 @@ import { import "@portal/views/Procurement.css"; const STEPS = ["volume", "plan", "details"] as const; -const TERM_DISCOUNT = [0, 0.05, 0.1, 0.12, 0.15]; // 1..5 years -const SLA_UPLIFT: Record = { - standard: 0, - priority: 0.15, - dedicated: 0.3, -}; +const TERM_DISCOUNT = [0, 0.03, 0.05, 0.06, 0.07]; // 1..5 years — meter-only discount (D71) +// Governance posture: the intensity (runs per PDF) fed to the committed-volume curve. +const POSTURES = [ + { intensity: 2, key: "essentials" }, + { intensity: 4, key: "governed" }, + { intensity: 7, key: "regulated" }, +] as const; +// PDF-size tiers (D93): a multiplier on the rate. Default Standard (×1.4). Mirrors the server. +const SIZE_TIERS = [ + { mult: 1.0, key: "compact" }, + { mult: 1.4, key: "standard" }, + { mult: 2.4, key: "heavy" }, +] as const; /** * The enterprise quote builder — volume → commitment & service → details. A client-side preview @@ -30,10 +37,13 @@ const SLA_UPLIFT: Record = { */ export function QuoteBuilder({ deployment, + seats = 0, initial, onGenerate, }: { deployment: string; + /** Seat count from the trial setup; seeds the users field + volume estimate on a fresh quote. */ + seats?: number; /** Seed the builder from an existing quote's config (re-editing a quote). */ initial?: QuoteConfigInput; /** Called with the priced DRAFT quote; the parent issues it as a Stripe Quote. */ @@ -43,17 +53,28 @@ export function QuoteBuilder({ const [step, setStep] = useState(0); const [cfg, setCfg] = useState( initial ?? { - volume: 1_000_000, - users: 0, + // Users-first: with no seats from the trial, leave volume empty so entering the team size + // auto-fills it (rather than pre-seeding a figure that hides the users → volume estimate). + volume: seats > 0 ? estimateVolume(seats) : 0, + users: Math.max(0, seats), + intensity: 4, // Governed — the default governance posture per the pricing alignment + sizeMult: 1.4, // Standard — the default PDF-size tier (D93) deployment, termYears: 3, serviceLevel: "priority", indemnification: false, training: false, qbr: false, - offlineLicense: false, - currency: "USD", businessName: "", + contactName: "", + contactEmail: "", + addressLine1: "", + addressLine2: "", + city: "", + region: "", + postalCode: "", + poNumber: "", + taxId: "", }, ); // A seeded quote carries a volume but no user count, so treat it as manually set. @@ -159,6 +180,36 @@ export function QuoteBuilder({ title={t("portal.procurement.builder.s2Title")} sub={t("portal.procurement.builder.s2Sub")} > + +
+ {POSTURES.map((p) => ( + set("intensity", p.intensity)} + /> + ))} +
+
+ + +
+ {SIZE_TIERS.map((s) => ( + set("sizeMult", s.mult)} + /> + ))} +
+
+
{[1, 2, 3, 4, 5].map((y) => ( @@ -224,12 +275,6 @@ export function QuoteBuilder({ sub={t("portal.procurement.builder.qbrSub")} onClick={() => set("qbr", !cfg.qbr)} /> - set("offlineLicense", !cfg.offlineLicense)} - />
@@ -241,31 +286,97 @@ export function QuoteBuilder({ title={t("portal.procurement.builder.s3Title")} sub={t("portal.procurement.builder.s3Sub")} > - +
+ + set("businessName", e.target.value)} + /> + + + set("contactName", e.target.value)} + /> + +
+ + set("contactEmail", e.target.value)} + /> + + set("businessName", e.target.value)} + value={cfg.addressLine1 ?? ""} + onChange={(e) => set("addressLine1", e.target.value)} + /> + + + set("addressLine2", e.target.value)} />
- - + + set("city", e.target.value)} + /> + + + set("region", e.target.value)} + /> + + + set("postalCode", e.target.value)} + /> + +
+
+ + set("poNumber", e.target.value)} + /> + + + set("taxId", e.target.value)} + />
+ + setS3ConfigOpen(false)} + title={t("portal.pipelines.composer.s3ModalTitle")} + footer={ +
+ +
+ } + > +
+ + setS3Field("bucket", e.target.value)} + /> + + + setS3Field("region", e.target.value)} + /> + + + setS3Field("prefix", e.target.value)} + /> + + + setS3Field("accessKeyId", e.target.value)} + /> + + + setS3Field("secretAccessKey", e.target.value)} + /> + + + setS3Field("endpoint", e.target.value)} + /> + +
+
); } From e4379184b549aa4668d93d3408ced4e962006d29 Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:05:46 +0100 Subject: [PATCH 05/34] fix(portal): translate policy category labels in PolicySummary (#6964) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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. --- frontend/editor/src/portal/components/PolicySummary.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/editor/src/portal/components/PolicySummary.tsx b/frontend/editor/src/portal/components/PolicySummary.tsx index 29d93cda3c..cffb822387 100644 --- a/frontend/editor/src/portal/components/PolicySummary.tsx +++ b/frontend/editor/src/portal/components/PolicySummary.tsx @@ -67,8 +67,8 @@ export function PolicySummary() { {policyIcon(entry.category.icon)}
- {entry.category.label} - {entry.category.desc} + {t(entry.category.label)} + {t(entry.category.desc)}
), @@ -92,7 +92,7 @@ export function PolicySummary() { render: ({ entry, state }) => ( {state === "active" - ? entry.config.summary + ? t(entry.config.summary) : t("portal.policySummary.noRule")} ), From b36f3e087542ee74654f58bddcb723dba5ffbb66 Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:16:57 +0100 Subject: [PATCH 06/34] 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. --- .../public/locales/en-US/translation.toml | 35 ---- .../editor/src/portal/components/AppShell.tsx | 8 +- .../editor/src/portal/components/Header.css | 191 ------------------ .../src/portal/components/Header.stories.tsx | 26 --- .../editor/src/portal/components/Header.tsx | 155 -------------- .../NotificationsDropdown.stories.tsx | 76 ------- .../components/NotificationsDropdown.tsx | 139 ------------- .../src/portal/components/PortalChrome.tsx | 2 - .../editor/src/portal/components/Sidebar.css | 34 ---- .../editor/src/portal/components/Sidebar.tsx | 34 ---- 10 files changed, 3 insertions(+), 697 deletions(-) delete mode 100644 frontend/editor/src/portal/components/Header.css delete mode 100644 frontend/editor/src/portal/components/Header.stories.tsx delete mode 100644 frontend/editor/src/portal/components/Header.tsx delete mode 100644 frontend/editor/src/portal/components/NotificationsDropdown.stories.tsx delete mode 100644 frontend/editor/src/portal/components/NotificationsDropdown.tsx diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 2cfc42475c..fe0b70a4eb 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -7182,26 +7182,6 @@ sources = "Sources" usage = "Usage & Billing" users = "Users" -[portal.notifications] -markAllRead = "Mark all read" -title = "Notifications" -viewAll = "View all" - -[portal.notifications.ariaLabel] -none = "Notifications, no unread" -unread_one = "Notifications, {{count}} unread" -unread_other = "Notifications, {{count}} unread" - -[portal.notifications.count] -allRead = "all read" -loading = "loading" -new_one = "{{count}} new" -new_other = "{{count}} new" - -[portal.notifications.empty] -description = "No new notifications." -title = "You're all caught up" - [portal.pipelines] subtitle = "Every automated document pipeline on the backend: an ordered chain of operations over a set of sources, run on a trigger. Click a row for its steps and sources." title = "Pipelines" @@ -7882,26 +7862,11 @@ admin = "Admin" [portal.settings.sections] account-link = "Account link" -[portal.shell.header] -accountFallback = "Account" -accountMenu = "Account menu" -darkMode = "Dark mode" -lightMode = "Light mode" -search = "Search" -searchPlaceholder = "Search…" -signOut = "Sign out" -switchToDark = "Switch to dark theme" -switchToLight = "Switch to light theme" - [portal.shell.sidebar] appEditor = "Editor" appProcessor = "Processor" brandSuffix = "Stirling Processor" -docsCount = "{{docs}} docs" linkAccount = "Link Stirling account" -planEditor = "Editor plan" -planEnterprise = "Enterprise plan" -planProcessor = "Processor plan" primaryNav = "Primary navigation" switchApp = "Switch app" diff --git a/frontend/editor/src/portal/components/AppShell.tsx b/frontend/editor/src/portal/components/AppShell.tsx index 606f021c0b..b3744d36bf 100644 --- a/frontend/editor/src/portal/components/AppShell.tsx +++ b/frontend/editor/src/portal/components/AppShell.tsx @@ -1,19 +1,17 @@ import type { ReactNode } from "react"; import { Sidebar } from "@portal/components/Sidebar"; -import { Header } from "@portal/components/Header"; import "@portal/components/AppShell.css"; /** - * Two-column layout: fixed-width sidebar on the left, sticky header + scrolling - * main column on the right. The Sidebar and Header read their state from - * context, so this shell stays prop-free. + * Two-column layout: fixed-width sidebar on the left, a scrolling main column on + * the right. The Sidebar reads its state from context, so this shell stays + * prop-free. */ export function AppShell({ children }: { children: ReactNode }) { return (
-
{children}
diff --git a/frontend/editor/src/portal/components/Header.css b/frontend/editor/src/portal/components/Header.css deleted file mode 100644 index d1c4ede717..0000000000 --- a/frontend/editor/src/portal/components/Header.css +++ /dev/null @@ -1,191 +0,0 @@ -.portal-header { - height: 3.25rem; - display: flex; - align-items: center; - gap: 0.75rem; - padding: 0 1rem; - background: var(--color-header-bg); - border-bottom: 1px solid var(--color-header-border); - position: sticky; - top: 0; - z-index: 10; -} - -.portal-header__left { - flex: 0 0 auto; - display: flex; - align-items: center; - gap: 0.5rem; - min-width: 12.5rem; -} - -.portal-header__breadcrumb { - font-size: 0.8125rem; - font-weight: 500; - color: var(--color-header-text); -} - -/* Search trigger */ -.portal-header__search { - flex: 0 1 20rem; - margin: 0 auto; - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.375rem 0.625rem; - background: var(--color-search-bg); - border: 1px solid var(--color-search-border); - border-radius: var(--radius-md); - color: var(--color-search-text); - font-size: 0.75rem; - transition: - border-color var(--motion-fast), - background var(--motion-fast); -} -.portal-header__search:hover { - border-color: var(--color-search-border-hover); -} - -/* The icon / placeholder / ⌘K live inside the shared Button's label, so the - label must fill the button for the placeholder's flex:1 to push ⌘K to the - far right (otherwise the label is content-width and everything left-packs). */ -.portal-header__search .mantine-Button-inner { - width: 100%; -} -.portal-header__search .mantine-Button-label { - flex: 1; - gap: 0.5rem; -} - -.portal-header__search-placeholder { - flex: 1 1 auto; - text-align: left; -} - -.portal-header__search-kbd { - font-family: var(--font-mono); - font-size: 0.6875rem; - padding: 0.0625rem 0.25rem; - border-radius: var(--radius-xs); - background: var(--color-bg-muted); - color: var(--color-text-4); -} - -/* Right cluster */ -.portal-header__right { - flex: 0 0 auto; - display: flex; - align-items: center; - gap: 0.375rem; -} - -.portal-header__icon-btn { - display: inline-flex; - align-items: center; - justify-content: center; - width: 2rem; - height: 2rem; - border-radius: var(--radius-md); - color: var(--color-bell-stroke, var(--color-text-3)); - position: relative; - transition: - background var(--motion-fast), - color var(--motion-fast); -} -.portal-header__icon-btn:hover { - background: var(--color-bg-hover); - color: var(--color-text-1); -} - -.portal-header__bell-dot { - position: absolute; - top: 0.375rem; - right: 0.4375rem; - width: 0.4375rem; - height: 0.4375rem; - border-radius: 50%; - background: var(--color-red); - border: 2px solid var(--color-header-bg); - box-sizing: content-box; -} - -/* Tier switcher */ -.portal-header__tier { - position: relative; -} - -.portal-header__tier-btn { - --sui-btn-py: 0.3125rem; - padding: 0.3125rem 0.625rem; - font-size: 0.75rem; - color: var(--color-text-2); - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius-md); - transition: - border-color var(--motion-fast), - background var(--motion-fast); -} -.portal-header__tier-btn:hover { - background: var(--color-bg-hover); - border-color: var(--color-border-hover); -} - -.portal-header__tier-dot { - width: 0.4375rem; - height: 0.4375rem; - border-radius: 50%; -} - -.portal-header__tier-label { - font-weight: 500; -} - -.portal-header__tier-menu { - position: absolute; - top: calc(100% + 0.25rem); - right: 0; - min-width: 12rem; - margin: 0; - padding: 0.25rem; - list-style: none; - background: var(--color-dropdown-bg); - border: 1px solid var(--color-dropdown-border); - border-radius: var(--radius-md); - box-shadow: var(--shadow-lg); - z-index: 20; - animation: fadeInUp var(--motion-enter) both; -} - -.portal-header__tier-option { - width: 100%; - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.4375rem 0.625rem; - font-size: 0.8125rem; - color: var(--color-text-2); - border-radius: var(--radius-sm); - text-align: left; -} -.portal-header__tier-option:hover { - background: var(--color-dropdown-hover); -} -.portal-header__tier-option.is-active { - background: var(--color-nav-active); - color: var(--color-nav-active-text); -} - -/* Avatar */ -.portal-header__avatar { - display: inline-flex; - align-items: center; - justify-content: center; - width: 2rem; - height: 2rem; - border-radius: 50%; - background: var(--grad-blue-btn); - color: #fff; - font-size: 0.8125rem; - font-weight: 600; -} diff --git a/frontend/editor/src/portal/components/Header.stories.tsx b/frontend/editor/src/portal/components/Header.stories.tsx deleted file mode 100644 index 25f2b9c2b5..0000000000 --- a/frontend/editor/src/portal/components/Header.stories.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { Header } from "@portal/components/Header"; - -const meta: Meta = { - title: "Portal/Shell/Header", - component: Header, - parameters: { layout: "fullscreen" }, - decorators: [ - (S) => ( -
- -
- Page body would render below the header. -
-
- ), - ], -}; -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const FreeTier: Story = { globals: { tier: "free" } }; -export const ProTier: Story = { globals: { tier: "pro" } }; -export const EnterpriseTier: Story = { globals: { tier: "enterprise" } }; diff --git a/frontend/editor/src/portal/components/Header.tsx b/frontend/editor/src/portal/components/Header.tsx deleted file mode 100644 index 317ef6040c..0000000000 --- a/frontend/editor/src/portal/components/Header.tsx +++ /dev/null @@ -1,155 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { ActionIcon, Avatar, Button, Dropdown } from "@app/ui"; -import { useAuth } from "@app/auth"; -import { useTheme } from "@portal/contexts/ThemeContext"; -import { useTier, TIER_INFO, type Tier } from "@portal/contexts/TierContext"; -import { useView } from "@portal/contexts/ViewContext"; -import { useUI } from "@portal/contexts/UIContext"; -import { - SearchIcon, - SunIcon, - MoonIcon, - ChevronDownIcon, -} from "@portal/components/icons"; -import { NotificationsDropdown } from "@portal/components/NotificationsDropdown"; -import "@portal/components/Header.css"; - -function ThemeToggle() { - const { theme, toggle } = useTheme(); - const { t } = useTranslation(); - return ( - - {theme === "light" ? : } - - ); -} - -function TierSwitcher() { - const { tier, setTier, isDerived } = useTier(); - const info = TIER_INFO[tier]; - // In the app the tier is derived from the real link/wallet state and can't - // be switched by hand; the dropdown only renders where the tier is pinned - // (Storybook / demo surfaces — see TierProvider's initialTier). - if (isDerived) return null; - return ( - - - - - - {(Object.keys(TIER_INFO) as Tier[]).map((id) => ( - setTier(id)} - leading={ - - } - > - {TIER_INFO[id].label} - - ))} - - - ); -} - -function UserMenu() { - const { t } = useTranslation(); - const { displayName, signOut } = useAuth(); - const name = - displayName ?? t("portal.shell.header.accountFallback", "Account"); - return ( - - - - - - - - {name} - void signOut()}> - {t("portal.shell.header.signOut", "Sign out")} - - - - ); -} - -export function Header() { - const { activeView } = useView(); - const { openSearch } = useUI(); - const { t } = useTranslation(); - return ( -
-
- - {t(`portal.nav.${activeView}`)} - -
- - - -
- - - - -
-
- ); -} diff --git a/frontend/editor/src/portal/components/NotificationsDropdown.stories.tsx b/frontend/editor/src/portal/components/NotificationsDropdown.stories.tsx deleted file mode 100644 index 1470f5e447..0000000000 --- a/frontend/editor/src/portal/components/NotificationsDropdown.stories.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { http, HttpResponse, delay } from "msw"; -import { NotificationsDropdown } from "@portal/components/NotificationsDropdown"; -import { NOTIFICATIONS } from "@portal/mocks/notifications"; - -const meta: Meta = { - title: "Portal/Header/NotificationsDropdown", - component: NotificationsDropdown, - parameters: { layout: "centered" }, - decorators: [ - (S) => ( -
- -
- ), - ], -}; -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Empty: Story = { - parameters: { - msw: { - handlers: [http.get("/v1/notifications", () => HttpResponse.json([]))], - }, - }, -}; - -export const Loading: Story = { - parameters: { - msw: { - handlers: [ - http.get("/v1/notifications", async () => { - await delay("infinite"); - return HttpResponse.json([]); - }), - ], - }, - }, -}; - -export const HighVolume: Story = { - parameters: { - msw: { - handlers: [ - http.get("/v1/notifications", () => { - const items = Array.from({ length: 24 }, (_, i) => ({ - ...NOTIFICATIONS[i % NOTIFICATIONS.length], - id: `n${i + 1}`, - })); - return HttpResponse.json(items); - }), - ], - }, - }, -}; - -export const NetworkError: Story = { - parameters: { - msw: { - handlers: [ - http.get("/v1/notifications", () => - HttpResponse.json({ error: "Service unavailable" }, { status: 503 }), - ), - ], - }, - }, -}; diff --git a/frontend/editor/src/portal/components/NotificationsDropdown.tsx b/frontend/editor/src/portal/components/NotificationsDropdown.tsx deleted file mode 100644 index 1401837e53..0000000000 --- a/frontend/editor/src/portal/components/NotificationsDropdown.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import { useState } from "react"; -import { useTranslation } from "react-i18next"; -import { ActionIcon, Button, Dropdown, EmptyState, Skeleton } from "@app/ui"; -import { BellIcon } from "@portal/components/icons"; -import { useAsync, useSectionFlags } from "@portal/hooks/useAsync"; -import { - fetchNotifications, - markAllNotificationsRead, - type Notification, - type NotificationCategory, -} from "@portal/api/notifications"; -import "@portal/components/NotificationsDropdown.css"; - -const CATEGORY_COLOUR: Record = { - pipeline: "var(--color-blue)", - deploy: "var(--color-green)", - billing: "var(--color-amber)", - audit: "var(--color-purple)", - agent: "var(--color-cat-insurance)", - doc: "var(--color-cat-healthcare)", -}; - -export function NotificationsDropdown() { - const { t } = useTranslation(); - const state = useAsync(() => fetchNotifications(), []); - const { data: items } = state; - const { isLoading } = useSectionFlags(state); - - // Optimistically clear on "mark all read"; revert if the request fails. - const [cleared, setCleared] = useState(false); - const visible = cleared ? [] : (items ?? []); - - async function onMarkAllRead() { - setCleared(true); - try { - await markAllNotificationsRead(); - } catch { - setCleared(false); - } - } - - const isEmpty = !isLoading && visible.length === 0; - const hasUnread = visible.length > 0; - - return ( - - - - - {hasUnread && ( - - )} - - - -
- - {t("portal.notifications.title")} - - {hasUnread ? ( - - {t("portal.notifications.count.new", { count: visible.length })} - - ) : isLoading ? ( - - {t("portal.notifications.count.loading")} - - ) : ( - - {t("portal.notifications.count.allRead")} - - )} -
- {isLoading && ( -
- - - - -
- )} - {isEmpty && ( - - )} - {!isLoading && !isEmpty && ( -
    - {visible.map((item) => ( -
  • - -
    -
    {item.title}
    -
    {item.description}
    -
    {item.time}
    -
    -
  • - ))} -
- )} -
- - -
-
-
- ); -} diff --git a/frontend/editor/src/portal/components/PortalChrome.tsx b/frontend/editor/src/portal/components/PortalChrome.tsx index 49172784fc..1262c84b8e 100644 --- a/frontend/editor/src/portal/components/PortalChrome.tsx +++ b/frontend/editor/src/portal/components/PortalChrome.tsx @@ -4,7 +4,6 @@ import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider"; import { ErrorBoundary } from "@portal/components/ErrorBoundary"; import { useUI } from "@portal/contexts/UIContext"; import { AppShell } from "@portal/components/AppShell"; -import { AssistantMount } from "@portal/components/AssistantMount"; import { SearchModal } from "@portal/components/SearchModal"; import { PortalSettingsHost } from "@portal/components/PortalSettingsHost"; import { ViewRouter } from "@portal/ViewRouter"; @@ -66,7 +65,6 @@ export function PortalChrome() { - diff --git a/frontend/editor/src/portal/components/Sidebar.css b/frontend/editor/src/portal/components/Sidebar.css index 62d6226151..256b0708dd 100644 --- a/frontend/editor/src/portal/components/Sidebar.css +++ b/frontend/editor/src/portal/components/Sidebar.css @@ -77,37 +77,3 @@ flex-direction: column; gap: 0.5rem; } - -.portal-sidebar__usage { - padding: 0.5rem 0.625rem; - font-size: 0.75rem; - color: var(--color-usage-text); -} - -.portal-sidebar__usage-line { - display: flex; - align-items: center; - justify-content: space-between; - gap: 0.5rem; -} - -.portal-sidebar__usage-value { - color: var(--color-usage-value); - font-weight: 500; -} - -.portal-sidebar__plan { - display: inline-flex; - align-items: center; - gap: 0.375rem; - color: var(--color-text-2); - font-weight: 500; -} - -.portal-sidebar__plan-dot { - width: 0.4375rem; - height: 0.4375rem; - border-radius: 50%; - background: var(--color-green); - box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-green) 30%, transparent); -} diff --git a/frontend/editor/src/portal/components/Sidebar.tsx b/frontend/editor/src/portal/components/Sidebar.tsx index 9bf7c8c373..356acc5349 100644 --- a/frontend/editor/src/portal/components/Sidebar.tsx +++ b/frontend/editor/src/portal/components/Sidebar.tsx @@ -3,12 +3,9 @@ import { AppSwitch } from "@app/components/shared/AppSwitch"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import { useView, type ViewId } from "@portal/contexts/ViewContext"; -import { useTier } from "@portal/contexts/TierContext"; import { useTheme } from "@portal/contexts/ThemeContext"; import { useUI } from "@portal/contexts/UIContext"; import { LinkAccountFooterItem } from "@portal/components/LinkAccountFooterItem"; -import { useAsync } from "@portal/hooks/useAsync"; -import { fetchFleetStats, type FleetStats } from "@portal/api/fleetStats"; import { EDITOR_URL, EDITOR_IS_SAME_APP } from "@portal/auth/editorUrl"; import markLight from "@app/assets/brand/modern-logo/StirlingPDFLogoNoTextLight.svg"; import markDark from "@app/assets/brand/modern-logo/StirlingPDFLogoNoTextDark.svg"; @@ -21,36 +18,6 @@ import { } from "@portal/components/sidebarGroups"; import "@portal/components/Sidebar.css"; -function UsageFooter() { - const { tier } = useTier(); - const { t } = useTranslation(); - // Real 30-day processed-PDF count from the fleet-usage endpoint (the same - // source as Home's status strip, so the two can't drift). null → "—". - const { data, loading } = useAsync(() => fetchFleetStats(), []); - const docs = loading ? undefined : (data?.pdfsProcessed ?? undefined); - - const planLabel = - tier === "free" - ? t("portal.shell.sidebar.planEditor", "Editor plan") - : tier === "pro" - ? t("portal.shell.sidebar.planProcessor", "Processor plan") - : t("portal.shell.sidebar.planEnterprise", "Enterprise plan"); - - return ( -
-
- - - {planLabel} - - - {docs != null ? t("portal.shell.sidebar.docsCount", { docs }) : "—"} - -
-
- ); -} - export function Sidebar() { const { activeView, setActiveView } = useView(); const { theme } = useTheme(); @@ -135,7 +102,6 @@ export function Sidebar() { icon={} onClick={() => openSettings()} /> - ); From b9a7f2083b6566155e526ec74e78ae356344ed91 Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:20:18 +0100 Subject: [PATCH 07/34] Portal: realign home hero to the simplified marketing card (#6956) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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" /> 01-aim-marketing-demo ## old hero 02-before-old-hero ## new screenshots 03-after-onboarding-card 04-after-deployed-header 05-after-install-modal-list 06-after-install-modal-docker + `docker run -d --name stirling-pdf -p 8080:8080 \\\n -v ./stirling-data:/configs \\\n stirlingtools/stirling-pdf:${tag}`; +const HELM = `helm repo add stirling-pdf https://stirling-tools.github.io/Stirling-PDF/ +helm repo update +helm install stirling-pdf stirling-pdf/stirling-pdf-chart \\\n --namespace stirling-pdf --create-namespace`; +const JAR_URL = "https://files.stirlingpdf.com/Stirling-PDF-with-login.jar"; +const JAR = + "java -Xmx2g -jar Stirling-PDF-with-login.jar\n# then open http://localhost:8080"; + +const GUIDES = { + windows: "https://docs.stirlingpdf.com/Installation/Windows%20Installation/", + mac: "https://docs.stirlingpdf.com/Installation/Mac%20Installation/", + linux: DOWNLOAD_URLS.LINUX_DOCS, + docker: "https://docs.stirlingpdf.com/Installation/Docker%20Install", + kubernetes: "https://docs.stirlingpdf.com/Installation/Kubernetes", + manual: DOWNLOAD_URLS.LINUX_DOCS, +} as const; + +type OptionId = keyof typeof GUIDES; +type DockerVariant = "latest" | "latest-fat" | "latest-ultra-lite"; + +const DESKTOP: OptionId[] = ["windows", "mac", "linux"]; +const SELF_HOSTED: OptionId[] = ["docker", "kubernetes", "manual"]; + +const ICONS: Record = { + windows: DownloadRounded, + mac: DownloadRounded, + linux: DownloadRounded, + docker: DnsRounded, + kubernetes: LayersRounded, + manual: TerminalRounded, +}; + +interface Props { + open: boolean; + onClose: () => void; +} + +function openUrl(url: string) { + window.open(url, "_blank", "noopener,noreferrer"); +} + +/** + * Install-the-editor modal. Lists desktop + self-hosted options; each opens a + * detail pane with a download button and/or copyable install command and a + * guide link. A completing action — clicking a download button or pressing Done + * — marks the getting-started "Download the editor" step complete (via + * {@link markEditorInstalled}); nothing else is persisted. + */ +export function DownloadEditorModal({ open, onClose }: Props) { + const { t } = useTranslation(); + const [selected, setSelected] = useState(null); + const [dockerVariant, setDockerVariant] = useState("latest"); + + const close = () => { + setSelected(null); + onClose(); + }; + + // A download or a Done press completes the "Download the editor" step. + const download = (url: string) => { + openUrl(url); + markEditorInstalled(); + }; + const done = () => { + markEditorInstalled(); + close(); + }; + + const guideLink = (id: keyof typeof GUIDES) => ( + + ); + + const note = (id: OptionId) => ( +

+ {t(`portal.home.download.${id}.note`)} +

+ ); + + function renderDetail(id: OptionId) { + switch (id) { + case "windows": + case "mac": { + const url = + id === "windows" ? DOWNLOAD_URLS.WINDOWS : DOWNLOAD_URLS.MAC; + return ( + <> + +

+ {t(`portal.home.download.${id}.altLabel`)} +

+ + {guideLink(id)} + + ); + } + case "linux": + return ( + <> + {note("linux")} + + + ); + case "docker": + return ( + <> + + value={dockerVariant} + onChange={setDockerVariant} + options={[ + { + label: t("portal.home.download.docker.variantStandard"), + value: "latest", + }, + { + label: t("portal.home.download.docker.variantFat"), + value: "latest-fat", + }, + { + label: t("portal.home.download.docker.variantLite"), + value: "latest-ultra-lite", + }, + ]} + /> + + {note("docker")} + {guideLink("docker")} + + ); + case "kubernetes": + return ( + <> + + {note("kubernetes")} + {guideLink("kubernetes")} + + ); + case "manual": + return ( + <> + +

+ {t("portal.home.download.manual.runLabel")} +

+ + {note("manual")} + {guideLink("manual")} + + ); + } + } + + const renderRow = (id: OptionId) => { + const Icon = ICONS[id]; + return ( + + ); + }; + + const detail = selected !== null; + + return ( + + + + + ) : ( + + ) + } + > + {detail ? ( +
{renderDetail(selected)}
+ ) : ( +
+

+ {t("portal.home.download.sectionDesktop")} +

+ {DESKTOP.map(renderRow)} +

+ {t("portal.home.download.sectionSelfHosted")} +

+ {SELF_HOSTED.map(renderRow)} +
+ )} +
+ ); +} diff --git a/frontend/editor/src/portal/components/EditorStatusCard.css b/frontend/editor/src/portal/components/EditorStatusCard.css index 313d283d6a..83426bae91 100644 --- a/frontend/editor/src/portal/components/EditorStatusCard.css +++ b/frontend/editor/src/portal/components/EditorStatusCard.css @@ -14,7 +14,8 @@ display: flex; align-items: center; gap: 1.25rem; - padding: 1.25rem 1.5rem; + padding: 1rem 1.25rem; + background: #16213e; } .portal-editor-hero__logo { @@ -51,7 +52,7 @@ .portal-editor-hero__name { font-size: 1.125rem; font-weight: 700; - color: var(--color-text-1); + color: #fff; margin-right: 0.125rem; } @@ -59,27 +60,21 @@ display: inline-flex; align-items: center; gap: 0.3125rem; - padding: 0.3125rem 0.75rem; + padding: 0.25rem 0.6875rem; border-radius: var(--radius-pill); - border: 1px solid var(--color-border-input); - background: var(--color-bg-code); - color: var(--color-text-2); + border: 1px solid rgba(255, 255, 255, 0.16); + background: rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.85); font-size: 0.71875rem; font-weight: 600; cursor: pointer; - box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06); - transition: - background var(--motion-fast), - box-shadow var(--motion-fast), - transform var(--motion-fast); + transition: background var(--motion-fast); } .portal-editor-hero__chip:hover { - background: var(--color-surface); - box-shadow: 0 2px 5px rgba(15, 23, 42, 0.1); - transform: translateY(-1px); + background: rgba(255, 255, 255, 0.16); } .portal-editor-hero__chip svg { - color: var(--color-text-4); + color: rgba(255, 255, 255, 0.6); } .portal-editor-hero__meta { @@ -88,19 +83,51 @@ gap: 0.5rem; flex-wrap: wrap; font-size: 0.75rem; - color: var(--color-text-5); + color: rgba(255, 255, 255, 0.5); } .portal-editor-hero__host { - color: var(--color-text-2); + color: rgba(255, 255, 255, 0.92); font-family: var(--font-mono); font-weight: 500; } .portal-editor-hero__meta-sep { - color: var(--color-border-input); + color: rgba(255, 255, 255, 0.3); } .portal-editor-hero__action { flex-shrink: 0; + display: flex; + align-items: center; + gap: 0.5rem; +} + +/* Icon-only actions (e.g. install) on the dark header. */ +.portal-editor-hero__icon-btn { + display: grid; + place-items: center; + width: 2.25rem; + height: 2.25rem; + flex-shrink: 0; + border-radius: var(--radius-md); + border: 1px solid rgba(255, 255, 255, 0.16); + background: rgba(255, 255, 255, 0.08); + color: #fff; + cursor: pointer; + transition: background var(--motion-fast); +} +.portal-editor-hero__icon-btn:hover { + background: rgba(255, 255, 255, 0.16); +} + +/* White CTA on the dark header, matching the marketing card. */ +.portal-editor-hero__action .portal-editor-hero__cta.sui-btn { + background: #ffffff; + border-color: #ffffff; + color: #16213e; +} +.portal-editor-hero__action .portal-editor-hero__cta.sui-btn:hover { + background: rgba(255, 255, 255, 0.88); + border-color: rgba(255, 255, 255, 0.88); } @media (max-width: 48rem) { diff --git a/frontend/editor/src/portal/components/EditorStatusCard.stories.tsx b/frontend/editor/src/portal/components/EditorStatusCard.stories.tsx index 84126547eb..9e73983026 100644 --- a/frontend/editor/src/portal/components/EditorStatusCard.stories.tsx +++ b/frontend/editor/src/portal/components/EditorStatusCard.stories.tsx @@ -2,6 +2,18 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { http, HttpResponse } from "msw"; import { EditorStatusCard } from "@portal/components/EditorStatusCard"; import { SetupChecklist } from "@portal/components/SetupChecklist"; +import type { OnboardingProgress } from "@portal/hooks/useOnboardingProgress"; + +const progress: OnboardingProgress = { + loading: false, + deployed: true, + editorDone: true, + policiesDone: true, + inviteDone: false, + policiesActive: 3, + policiesRecommended: 4, + allComplete: false, +}; const meta: Meta = { title: "Portal/Home/EditorStatusCard", @@ -25,7 +37,7 @@ export const Default: Story = {}; /** As it renders on the subscribed home: the setup checklist attached as the footer. */ export const WithSetupChecklist: Story = { args: { - footer: , + footer: , }, }; @@ -36,7 +48,7 @@ export const WithSetupChecklist: Story = { */ export const DeploymentUnavailable: Story = { args: { - footer: , + footer: , }, parameters: { msw: { diff --git a/frontend/editor/src/portal/components/EditorStatusCard.tsx b/frontend/editor/src/portal/components/EditorStatusCard.tsx index 9c97041260..8d3b7dcbcf 100644 --- a/frontend/editor/src/portal/components/EditorStatusCard.tsx +++ b/frontend/editor/src/portal/components/EditorStatusCard.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from "react"; -import { Fragment, useMemo } from "react"; +import { Fragment, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { Button, Skeleton } from "@app/ui"; import { useTier } from "@portal/contexts/TierContext"; @@ -11,10 +11,12 @@ import { type EditorInstance, } from "@portal/api/editorDeploy"; import { + DownloadIcon, ExternalLinkIcon, UsersIcon, UserPlusIcon, } from "@portal/components/icons"; +import { DownloadEditorModal } from "@portal/components/DownloadEditorModal"; import "@portal/components/EditorStatusCard.css"; /** The Stirling brand mark, drawn at the hero size. Decorative. */ @@ -73,6 +75,7 @@ export function EditorStatusCard({ footer, hideChips }: EditorStatusCardProps) { const { t } = useTranslation(); const { tier } = useTier(); const { setActiveView } = useView(); + const [installOpen, setInstallOpen] = useState(false); const { data, loading } = useAsync( () => fetchEditorDeployment(tier), [tier], @@ -138,26 +141,16 @@ export function EditorStatusCard({ footer, hideChips }: EditorStatusCardProps) { {t("portal.home.editor.name")} {!hideChips && ( - <> - - - + )}
@@ -174,8 +167,29 @@ export function EditorStatusCard({ footer, hideChips }: EditorStatusCardProps) {
+ {!hideChips && ( + + )} +
{footer &&
{footer}
} + + setInstallOpen(false)} + /> ); } diff --git a/frontend/editor/src/portal/components/HomeHero.tsx b/frontend/editor/src/portal/components/HomeHero.tsx index 4c8c460405..03bcc17ed0 100644 --- a/frontend/editor/src/portal/components/HomeHero.tsx +++ b/frontend/editor/src/portal/components/HomeHero.tsx @@ -3,25 +3,28 @@ import { useUI } from "@portal/contexts/UIContext"; import { WelcomeBanner } from "@portal/components/WelcomeBanner"; import { EditorStatusCard } from "@portal/components/EditorStatusCard"; import { SetupChecklist } from "@portal/components/SetupChecklist"; +import { useOnboardingProgress } from "@portal/hooks/useOnboardingProgress"; import { ControlledDealStatusHero } from "@portal/components/procurement/ProcurementBanner"; import { ProcurementFlow } from "@portal/components/procurement/ProcurementFlow"; import { useProcurement } from "@portal/components/procurement/useProcurement"; /** - * The Home hero, composed per tier with a procurement-aware footer: + * The Home hero, composed with a procurement-aware, progress-aware footer: * - * - free → marketing welcome banner - * - subscribed → deployed-Editor status card - * - enterprise → deployed-Editor status card (chips hidden; the deal hero owns invite) + * - no live deployment → welcome header (+ setup steps until complete) + * - deployment live → deployed-Editor status header (+ steps until complete) + * - onboarding complete → header only; the setup steps collapse away + * - enterprise → status header with chips hidden (the deal hero owns invite) * * The footer is the deal-status hero while a procurement deal is underway - * (procurement is a bolt-on to any tier), otherwise the setup checklist. The - * procurement takeover flow modals render alongside so the hero's actions can - * open them. + * (procurement is a bolt-on to any tier); otherwise the setup checklist, until + * every step is done — then it collapses to just the header, matching the + * deployed-status card. The procurement takeover modals render alongside. */ export function HomeHero({ tier }: { tier: Tier }) { const { openLinkModal } = useUI(); const procurement = useProcurement(); + const progress = useOnboardingProgress(); const dealActive = procurement.isLinked && procurement.started && !!procurement.data; @@ -32,18 +35,26 @@ export function HomeHero({ tier }: { tier: Tier }) { else openLinkModal(); }; + // Steps collapse once onboarding is complete; a live deal always keeps its + // hero. Otherwise the setup checklist carries the (progress-aware) steps. const footer = dealActive ? ( - ) : ( - + ) : progress.allComplete ? undefined : ( + ); + // The live-status header (EditorStatusCard) needs a real deployment to show; + // without one it renders nothing, so route to it only when actually deployed. + // Everything else — including a step completed via the local download flag — + // keeps the always-present welcome header, so the card never vanishes. + const showStatus = progress.deployed; + return ( <> - {tier === "free" ? ( - - ) : ( + {showStatus ? ( + ) : ( + )} diff --git a/frontend/editor/src/portal/components/SetupChecklist.css b/frontend/editor/src/portal/components/SetupChecklist.css index b49744774e..a19919e640 100644 --- a/frontend/editor/src/portal/components/SetupChecklist.css +++ b/frontend/editor/src/portal/components/SetupChecklist.css @@ -1,5 +1,5 @@ /* ──────────────────────────────────────────────────────────────────────── */ -/* "Finish setting up" checklist (welcome-hero footer) */ +/* Getting-started steps (home-hero body) */ /* ──────────────────────────────────────────────────────────────────────── */ .portal-setup { @@ -7,43 +7,6 @@ flex-direction: column; } -.portal-setup__head { - display: flex; - align-items: center; - justify-content: space-between; - padding: 0.6875rem 1.25rem 0.1875rem; -} - -.portal-setup__eyebrow { - font-size: 0.625rem; - font-weight: 700; - letter-spacing: 0.06em; - text-transform: uppercase; - color: var(--color-text-5); -} - -/* Progress counter doubles as the dismiss affordance. */ -.portal-setup__dismiss { - display: inline-flex; - align-items: center; - gap: 0.375rem; - padding: 0.1875rem 0.4375rem; - border: none; - border-radius: var(--radius-md); - background: transparent; - font-size: 0.6875rem; - font-weight: 600; - color: var(--color-text-5); - cursor: pointer; - transition: - color var(--motion-fast), - background var(--motion-fast); -} -.portal-setup__dismiss:hover { - color: var(--color-text-2); - background: var(--color-bg-hover); -} - .portal-setup__list { list-style: none; margin: 0; @@ -52,39 +15,46 @@ .portal-setup__row { display: grid; - grid-template-columns: auto 1fr auto; + grid-template-columns: auto 1fr; align-items: center; gap: 0.875rem; width: 100%; - padding: 0.875rem 1.25rem; + padding: 0.6875rem 1.25rem; border: none; border-top: 1px solid var(--color-border-light); background: transparent; text-align: left; + cursor: pointer; transition: background var(--motion-fast); } .portal-setup__item:first-child .portal-setup__row { border-top: none; } -.portal-setup__row.is-interactive { - cursor: pointer; -} -.portal-setup__row.is-interactive:hover { +.portal-setup__row:hover { background: var(--color-bg-hover); } -.portal-setup__row:disabled { - cursor: default; -} -.portal-setup__dot { - width: 0.5rem; - height: 0.5rem; - border-radius: 50%; +/* Numbered step marker */ +.portal-setup__num { + display: grid; + place-items: center; + width: 1.5rem; + height: 1.5rem; flex-shrink: 0; - background: var(--color-border-input); + border-radius: 50%; + border: 1px solid var(--color-border-input); + font-size: 0.75rem; + font-weight: 600; + color: var(--color-text-4); } -.portal-setup__dot.is-done { +/* Completed step: filled green check. */ +.portal-setup__num.is-done { + border-color: var(--color-green); background: var(--color-green); + color: #fff; +} +.portal-setup__row.is-done .portal-setup__text strong { + color: var(--color-text-2); } .portal-setup__text { @@ -93,52 +63,16 @@ min-width: 0; } .portal-setup__text strong { - font-size: 0.84375rem; + font-size: 0.875rem; font-weight: 600; color: var(--color-text-1); } .portal-setup__text span { - margin-top: 0.0625rem; font-size: 0.75rem; line-height: 1.4; color: var(--color-text-4); } -.portal-setup__chip { - flex-shrink: 0; - padding: 0.125rem 0.5625rem; - border-radius: var(--radius-pill); - font-size: 0.6875rem; - font-weight: 600; - color: var(--color-text-5); - background: var(--color-bg-hover); -} -.portal-setup__chip.is-done { - color: var(--color-green-dark); - background: var(--color-green-light); -} - -/* Loading skeleton */ -.portal-setup__row.is-loading { - border-top: 1px solid var(--color-border-light); -} -.portal-setup__skel { - height: 0.75rem; - width: 60%; - border-radius: var(--radius-sm); - background: var(--color-bg-muted); - animation: portalSetupPulse 1.2s ease-in-out infinite; -} -@keyframes portalSetupPulse { - 0%, - 100% { - opacity: 0.5; - } - 50% { - opacity: 1; - } -} - /* ── Enterprise upsell rung ── */ .portal-setup__enterprise { display: flex; @@ -146,7 +80,7 @@ justify-content: space-between; gap: 1rem; flex-wrap: wrap; - padding: 0.875rem 1.25rem; + padding: 0.75rem 1.25rem; border-top: 1px solid var(--color-border-light); background: linear-gradient( 90deg, diff --git a/frontend/editor/src/portal/components/SetupChecklist.stories.tsx b/frontend/editor/src/portal/components/SetupChecklist.stories.tsx index ba8014921d..78af75d188 100644 --- a/frontend/editor/src/portal/components/SetupChecklist.stories.tsx +++ b/frontend/editor/src/portal/components/SetupChecklist.stories.tsx @@ -1,11 +1,23 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { http, HttpResponse, delay } from "msw"; import { SetupChecklist } from "@portal/components/SetupChecklist"; +import type { OnboardingProgress } from "@portal/hooks/useOnboardingProgress"; + +const base: OnboardingProgress = { + loading: false, + deployed: false, + editorDone: false, + policiesDone: false, + inviteDone: false, + policiesActive: 0, + policiesRecommended: 6, + allComplete: false, +}; const meta: Meta = { title: "Portal/Home/SetupChecklist", component: SetupChecklist, parameters: { layout: "padded" }, + args: { progress: base }, decorators: [ (S) => (
= { export default meta; type Story = StoryObj; -/** Completion + counts derived from the live policies / sources MSW fixtures. */ -export const Default: Story = {}; +/** A fresh workspace — no step complete yet. */ +export const NotStarted: Story = {}; -/** Slow policy/source fetch: shows the loading skeleton rows. */ -export const Loading: Story = { - parameters: { - msw: { - handlers: [ - http.get("/api/v1/policies", async () => { - await delay(100000); - return HttpResponse.json([]); - }), - ], +/** Policies confirmed; editor + invite still open. */ +export const InProgress: Story = { + args: { + progress: { + ...base, + policiesDone: true, + policiesActive: 2, + policiesRecommended: 5, }, }, }; -/** A fresh workspace — no active policies or connected sources: every step reads "Not started". */ -export const NotStarted: Story = { - parameters: { - msw: { - handlers: [ - http.get("/api/v1/policies", () => HttpResponse.json([])), - http.get("/api/v1/policies/runs", () => HttpResponse.json([])), - http.get("/api/v1/sources", () => - HttpResponse.json({ kpis: [], sources: [] }), - ), - ], +/** Editor deployed + policies on; only the invite step remains. */ +export const AlmostDone: Story = { + args: { + progress: { + ...base, + editorDone: true, + policiesDone: true, + policiesActive: 4, + policiesRecommended: 3, }, }, }; diff --git a/frontend/editor/src/portal/components/SetupChecklist.tsx b/frontend/editor/src/portal/components/SetupChecklist.tsx index b2f2df3b42..d854beb771 100644 --- a/frontend/editor/src/portal/components/SetupChecklist.tsx +++ b/frontend/editor/src/portal/components/SetupChecklist.tsx @@ -1,17 +1,13 @@ -import { useMemo, useState } from "react"; +import { useState } from "react"; import { useTranslation } from "react-i18next"; import { Button } from "@app/ui"; import { useTier } from "@portal/contexts/TierContext"; -import { useView, type ViewId } from "@portal/contexts/ViewContext"; -import { useAsync } from "@portal/hooks/useAsync"; -import { fetchPolicies, type PoliciesResponse } from "@portal/api/policies"; -import { fetchSources, type SourcesResponse } from "@portal/api/sources"; -import { CloseIcon } from "@portal/components/icons"; +import { useView } from "@portal/contexts/ViewContext"; +import type { OnboardingProgress } from "@portal/hooks/useOnboardingProgress"; +import { DownloadEditorModal } from "@portal/components/DownloadEditorModal"; +import CheckRounded from "@mui/icons-material/CheckRounded"; import "@portal/components/SetupChecklist.css"; -/** Where the Stirling PDF Editor desktop app is downloaded. */ -const EDITOR_DOWNLOAD_URL = "https://stirling.com/download"; - /* ──────────────────────────────────────────────────────────────────────── */ /* Enterprise upsell rung */ /* ──────────────────────────────────────────────────────────────────────── */ @@ -59,173 +55,96 @@ function EnterpriseRung({ } /* ──────────────────────────────────────────────────────────────────────── */ -/* "Finish setting up" checklist */ +/* Getting-started steps */ /* ──────────────────────────────────────────────────────────────────────── */ -type StepAction = - | { kind: "external"; href: string } - | { kind: "navigate"; view: ViewId }; - interface Step { id: string; title: string; blurb: string; done: boolean; - /** Status chip shown once the step is done. */ - chip: string; - action: StepAction; + onClick: () => void; } /** - * Getting-started checklist, rendered as the attached footer of the home hero - * (welcome banner on free, deployed-Editor card on subscribed). Steps are a - * fixed do-this-then-that sequence with translatable copy; the completion - * state and counts are derived live from the org's real policies and sources - * (the same data the Policies / Sources pages show). The header doubles as a - * dismiss control; the Enterprise rung persists regardless. + * Numbered getting-started steps, rendered as the body of the home hero. Each + * row opens its in-app surface; a completed step (from {@link OnboardingProgress}) + * swaps its number for a check. When every step is done the parent collapses the + * hero to the deployed-status header and stops rendering this list entirely. */ export function SetupChecklist({ + progress, onStartEnterprise, }: { + progress: OnboardingProgress; /** Start the enterprise flow in place (opens the setup modal over Home). Falls back to * navigating to the procurement view when omitted (e.g. in isolated stories). */ onStartEnterprise?: () => void; -} = {}) { +}) { const { t } = useTranslation(); const { tier } = useTier(); const { setActiveView } = useView(); - const [dismissed, setDismissed] = useState(false); + const [downloadOpen, setDownloadOpen] = useState(false); - const state = useAsync<[PoliciesResponse, SourcesResponse]>( - () => Promise.all([fetchPolicies(), fetchSources()]), - [], - ); - const { data, loading } = state; - - const steps = useMemo(() => { - const [policies, sources] = data ?? []; - const active = policies?.summary.active ?? 0; - const recommended = Math.max( - 0, - (policies?.summary.categories ?? 0) - active, - ); - // A source counts as connected once it's set up and not disabled. - const connected = - sources?.sources.filter((s) => s.status !== "disabled").length ?? 0; - - return [ - { - id: "editor", - title: t("portal.home.onboarding.steps.editor.title"), - blurb: t("portal.home.onboarding.steps.editor.blurb"), - // "Deploy the desktop app" has no server signal — always an open task. - done: false, - chip: "", - action: { kind: "external", href: EDITOR_DOWNLOAD_URL }, - }, - { - id: "policies", - title: t("portal.home.onboarding.steps.policies.title"), - blurb: t("portal.home.onboarding.steps.policies.blurb", { - active, - recommended, - }), - done: active > 0, - chip: t("portal.home.onboarding.steps.policies.chip", { active }), - action: { kind: "navigate", view: "policies" }, - }, - { - id: "sources", - title: t("portal.home.onboarding.steps.sources.title"), - blurb: t("portal.home.onboarding.steps.sources.blurb", { connected }), - done: connected > 0, - chip: t("portal.home.onboarding.steps.sources.chip", { connected }), - action: { kind: "navigate", view: "sources" }, - }, - ]; - }, [data, t]); - - const doneCount = steps.filter((s) => s.done).length; - - function activate(step: Step) { - if (step.done) return; - if (step.action.kind === "external") { - window.open(step.action.href, "_blank", "noopener,noreferrer"); - } else { - setActiveView(step.action.view); - } - } + const steps: Step[] = [ + { + id: "editor", + title: t("portal.home.onboarding.steps.editor.title"), + blurb: t("portal.home.onboarding.steps.editor.blurb"), + done: progress.editorDone, + // Downloads are per-OS, so open the install picker rather than route away. + onClick: () => setDownloadOpen(true), + }, + { + id: "policies", + title: t("portal.home.onboarding.steps.policies.title"), + blurb: t("portal.home.onboarding.steps.policies.blurb", { + active: progress.policiesActive, + recommended: progress.policiesRecommended, + }), + done: progress.policiesDone, + onClick: () => setActiveView("policies"), + }, + { + id: "invite", + title: t("portal.home.onboarding.steps.invite.title"), + blurb: t("portal.home.onboarding.steps.invite.blurb"), + done: progress.inviteDone, + onClick: () => setActiveView("users"), + }, + ]; return (
- {loading && ( -
- {Array.from({ length: 3 }).map((_, i) => ( -
- -
-
-
-
- ))} -
- )} - - {!loading && !dismissed && ( - <> -
- - {t("portal.home.onboarding.title")} - +
    + {steps.map((s, i) => ( +
  1. -
- -
    - {steps.map((s) => ( -
  1. - -
  2. - ))} -
- - )} + + ))} + + + setDownloadOpen(false)} + />
); } diff --git a/frontend/editor/src/portal/components/WelcomeBanner.css b/frontend/editor/src/portal/components/WelcomeBanner.css index 87d284495f..5c63462754 100644 --- a/frontend/editor/src/portal/components/WelcomeBanner.css +++ b/frontend/editor/src/portal/components/WelcomeBanner.css @@ -1,461 +1,103 @@ /* ──────────────────────────────────────────────────────────────────────── */ -/* Free-tier welcome hero */ +/* Free-tier welcome hero — compact product header + steps */ /* ──────────────────────────────────────────────────────────────────────── */ .portal-welcome { border-radius: var(--radius-xl); - border: 1px solid var(--color-blue-border); + border: 1px solid var(--color-border-light); overflow: hidden; isolation: isolate; -} - -.portal-welcome__body { - position: relative; - display: flex; - align-items: stretch; - min-height: 22.5rem; - background: linear-gradient(135deg, #eaf2fb 0%, #eef3fb 50%, #f4f0fa 100%); -} - -[data-theme="dark"] .portal-welcome__body { - background: linear-gradient(135deg, #0e0e10 0%, #121214 50%, #161618 100%); -} - -/* ── Flowing gradient canvas: three radial blobs drifting at different - speeds for a slow, organic color flow. Purely decorative. ── */ -.portal-welcome__canvas { - position: absolute; - inset: -40%; - width: 180%; - height: 180%; - filter: blur(3.75rem); - pointer-events: none; - z-index: 0; -} - -.portal-welcome__blob { - position: absolute; - border-radius: 50%; -} - -.portal-welcome__blob--1 { - width: 60%; - height: 70%; - top: -10%; - left: 30%; - background: radial-gradient( - circle, - rgba(59, 130, 246, 0.35) 0%, - rgba(99, 102, 241, 0.2) 40%, - transparent 70% - ); - animation: portalHeroBlob1 5s ease-in-out infinite; -} - -.portal-welcome__blob--2 { - width: 55%; - height: 60%; - top: 10%; - right: -15%; - background: radial-gradient( - circle, - rgba(139, 92, 246, 0.4) 0%, - rgba(192, 132, 252, 0.2) 40%, - transparent 70% - ); - animation: portalHeroBlob2 7s ease-in-out infinite; -} - -.portal-welcome__blob--3 { - width: 50%; - height: 55%; - bottom: -15%; - left: 35%; - background: radial-gradient( - circle, - rgba(59, 130, 246, 0.25) 0%, - rgba(14, 165, 233, 0.15) 40%, - transparent 70% - ); - animation: portalHeroBlob3 9s ease-in-out infinite; -} - -[data-theme="dark"] .portal-welcome__blob--1 { - left: 20%; - background: radial-gradient( - circle, - rgba(96, 165, 250, 0.4) 0%, - rgba(99, 102, 241, 0.25) 40%, - transparent 70% - ); -} - -[data-theme="dark"] .portal-welcome__blob--2 { - background: radial-gradient( - circle, - rgba(167, 139, 250, 0.45) 0%, - rgba(192, 132, 252, 0.25) 40%, - transparent 70% - ); -} - -[data-theme="dark"] .portal-welcome__blob--3 { - left: 20%; - background: radial-gradient( - circle, - rgba(96, 165, 250, 0.3) 0%, - rgba(56, 189, 248, 0.18) 40%, - transparent 70% - ); -} - -@keyframes portalHeroBlob1 { - 0% { - transform: translate(0%, 0%) scale(1); - } - 33% { - transform: translate(18%, -25%) scale(1.15); - } - 66% { - transform: translate(-10%, 18%) scale(0.9); - } - 100% { - transform: translate(0%, 0%) scale(1); - } -} - -@keyframes portalHeroBlob2 { - 0% { - transform: translate(0%, 0%) scale(1); - } - 33% { - transform: translate(-20%, 15%) scale(1.1); - } - 66% { - transform: translate(15%, -20%) scale(1.15); - } - 100% { - transform: translate(0%, 0%) scale(1); - } -} - -@keyframes portalHeroBlob3 { - 0% { - transform: translate(0%, 0%) scale(1.05); - } - 33% { - transform: translate(22%, 10%) scale(0.9); - } - 66% { - transform: translate(-15%, -15%) scale(1.15); - } - 100% { - transform: translate(0%, 0%) scale(1.05); - } -} - -@media (prefers-reduced-motion: reduce) { - .portal-welcome__blob { - animation: none; - } -} - -/* ── Left column: copy + CTAs ── */ -.portal-welcome__text { - position: relative; - z-index: 1; - flex: 1 1 0; - min-width: 0; - display: flex; - flex-direction: column; - justify-content: center; - padding: 2.75rem 0 2.75rem 3rem; -} - -.portal-welcome__badge { - align-self: flex-start; - margin-bottom: 1rem; - padding: 0.3125rem 0.875rem; - border-radius: var(--radius-pill); - border: 1px solid var(--color-blue-border); background: var(--color-surface); - font-size: 0.6875rem; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--color-blue); } -.portal-welcome__title { - margin: 0 0 1.125rem; - font-size: 2.25rem; - font-weight: 700; - line-height: 1.2; - letter-spacing: -0.025em; - color: var(--color-text-1); -} - -.portal-welcome__title-accent { - color: var(--color-blue); -} - -.portal-welcome__sub { - margin: 0 0 1.75rem; - max-width: 32.5rem; - font-size: 1rem; - line-height: 1.6; - color: var(--color-text-3); -} - -.portal-welcome__cta { +/* ── Dark product header strip ── */ +.portal-welcome__header { display: flex; - gap: 0.75rem; align-items: center; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; + padding: 0.875rem 1.25rem; + background: #16213e; +} + +.portal-welcome__brand { + display: flex; + align-items: center; + gap: 0.875rem; + min-width: 0; +} + +.portal-welcome__mark { + display: grid; + place-items: center; + flex-shrink: 0; +} +.portal-welcome__mark img { + display: block; + height: 1.75rem; + width: auto; +} + +.portal-welcome__brand-text { + display: flex; + align-items: baseline; + gap: 0.625rem; + min-width: 0; flex-wrap: wrap; } -.portal-welcome__perks { - margin: 1.375rem 0 0; - font-size: 0.78125rem; - font-weight: 600; - letter-spacing: 0.01em; - color: var(--color-text-4); +.portal-welcome__product { + font-size: 1.125rem; + font-weight: 700; + letter-spacing: -0.01em; + color: #fff; } -/* ── Right column: editor-card visual ── */ -.portal-welcome__visual { - position: relative; - z-index: 1; - display: flex; - align-items: flex-end; - justify-content: flex-end; - flex: none; - padding-left: 1.5rem; -} - -@media (max-width: 60rem) { - .portal-welcome__visual { - display: none; - } - .portal-welcome__text { - padding: 2.5rem 2rem; - } -} - -/* ── Dark editor card ── */ -.portal-welcome__editor { - align-self: flex-end; - width: 38.75rem; - max-width: 100%; - margin-right: 2.5rem; - min-height: 18.75rem; - display: flex; - flex-direction: column; - overflow: hidden; - border-radius: 0.75rem 0.75rem 0 0; - border: 1px solid rgba(255, 255, 255, 0.08); - border-bottom: none; - background: rgba(15, 23, 42, 0.92); - backdrop-filter: blur(0.75rem); - box-shadow: - 0 0.5rem 2rem rgba(0, 0, 0, 0.25), - 0 0.125rem 0.5rem rgba(0, 0, 0, 0.15); -} - -.portal-welcome__editor-bar { - display: flex; - align-items: center; - gap: 0.75rem; - padding: 0.75rem 1rem; - border-bottom: 1px solid rgba(255, 255, 255, 0.06); - flex-shrink: 0; -} - -.portal-welcome__editor-dots { - display: flex; - gap: 0.375rem; - flex-shrink: 0; -} -.portal-welcome__editor-dots span { - width: 0.5rem; - height: 0.5rem; - border-radius: 50%; -} -.portal-welcome__editor-dots span:nth-child(1) { - background: #ef4444; -} -.portal-welcome__editor-dots span:nth-child(2) { - background: #f59e0b; -} -.portal-welcome__editor-dots span:nth-child(3) { - background: #22c55e; -} - -.portal-welcome__editor-file { - display: flex; - align-items: center; - gap: 0.5rem; - flex: 1; +.portal-welcome__stats { min-width: 0; - font-size: 0.6875rem; - color: rgba(255, 255, 255, 0.6); -} -.portal-welcome__editor-name { - font-weight: 600; - color: rgba(255, 255, 255, 0.9); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; -} -.portal-welcome__editor-sep { - color: rgba(255, 255, 255, 0.35); - flex-shrink: 0; -} -.portal-welcome__editor-pages { + font-size: 0.8125rem; color: rgba(255, 255, 255, 0.55); - flex-shrink: 0; -} -.portal-welcome__editor-page { - font-size: 0.6875rem; - color: rgba(255, 255, 255, 0.55); - flex-shrink: 0; } -.portal-welcome__editor-body { - display: flex; - align-items: stretch; - flex: 1; - min-height: 0; -} - -.portal-welcome__rail { - width: 2.5rem; - flex-shrink: 0; - display: flex; - flex-direction: column; - align-items: center; - gap: 0.25rem; - padding: 0.5rem 0; - color: rgba(255, 255, 255, 0.4); -} -.portal-welcome__editor-body > .portal-welcome__rail:first-child { - border-right: 1px solid rgba(255, 255, 255, 0.06); -} -.portal-welcome__editor-body > .portal-welcome__rail:last-child { - border-left: 1px solid rgba(255, 255, 255, 0.06); -} -.portal-welcome__rail-icon { +/* Header action group: icon buttons + the CTA. */ +.portal-welcome__actions { display: flex; align-items: center; - justify-content: center; - width: 1.625rem; - height: 1.625rem; - border-radius: 0.3125rem; + gap: 0.5rem; } -.portal-welcome__rail-icon:nth-child(2) { - background: rgba(96, 165, 250, 0.18); - color: rgba(147, 197, 253, 0.95); +.portal-welcome__icon-btn { + display: grid; + place-items: center; + width: 2.25rem; + height: 2.25rem; + flex-shrink: 0; + border-radius: var(--radius-md); + border: 1px solid rgba(255, 255, 255, 0.16); + background: rgba(255, 255, 255, 0.08); + color: #fff; + cursor: pointer; + transition: background var(--motion-fast); +} +.portal-welcome__icon-btn:hover { + background: rgba(255, 255, 255, 0.16); } -/* ── White document preview ── */ -.portal-welcome__doc { - flex: 1; - min-width: 0; - margin: 0.75rem 1rem 0; - padding: 0.875rem 1.125rem; - border-radius: 0.25rem 0.25rem 0 0; +/* White CTA on the dark header, matching the marketing card. */ +.portal-welcome__header .portal-welcome__cta.sui-btn { background: #ffffff; - color: #0f172a; - box-shadow: 0 0.25rem 1.125rem rgba(0, 0, 0, 0.22); + border-color: #ffffff; + color: #16213e; +} +.portal-welcome__header .portal-welcome__cta.sui-btn:hover { + background: rgba(255, 255, 255, 0.88); + border-color: rgba(255, 255, 255, 0.88); } -.portal-welcome__doc-eyebrow { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 0.5rem; - font-size: 0.5625rem; - font-weight: 700; - letter-spacing: 0.08em; - color: #dc2626; -} -.portal-welcome__doc-critical { - padding: 0.1875rem 0.5625rem; - border-radius: 0.1875rem; - background: #fee2e2; - letter-spacing: 0.06em; -} -.portal-welcome__doc-title { - margin-bottom: 0.5rem; - font-size: 0.9375rem; - font-weight: 700; - line-height: 1.3; - color: #0f172a; -} -.portal-welcome__doc-divider { - border-top: 1px solid #e8e6e1; - margin-bottom: 0.625rem; -} -.portal-welcome__doc-meta { - display: flex; - gap: 1.5rem; - margin-bottom: 0.75rem; - font-size: 0.625rem; - color: #0f172a; - font-weight: 600; -} -.portal-welcome__doc-meta-key { - color: #9b9a97; - font-weight: 400; -} -.portal-welcome__doc-heading { - margin-bottom: 0.375rem; - font-size: 0.625rem; - font-weight: 700; - letter-spacing: 0.06em; - color: #0f172a; -} -.portal-welcome__doc-body { - margin: 0 0 0.75rem; - font-size: 0.625rem; - line-height: 1.5; - color: #6b6b6b; -} -.portal-welcome__doc-finding { - display: flex; - align-items: center; - gap: 0.625rem; - padding: 0.4375rem 0.75rem; - border-radius: 0.1875rem; - font-size: 0.625rem; - font-weight: 500; -} -.portal-welcome__doc-finding--critical { - margin-bottom: 0.3125rem; - background: #fee2e2; - color: #7f1d1d; -} -.portal-welcome__doc-finding--high { - background: #fef3c7; - color: #92400e; -} -.portal-welcome__doc-dot { - width: 0.375rem; - height: 0.375rem; - border-radius: 50%; - flex-shrink: 0; -} -.portal-welcome__doc-finding--critical .portal-welcome__doc-dot { - background: #dc2626; -} -.portal-welcome__doc-finding--high .portal-welcome__doc-dot { - background: #d97706; -} - -/* ── Attached footer strip (setup checklist) ── */ +/* ── Steps + enterprise (setup checklist) sit directly under the header ── */ .portal-welcome__footer { - position: relative; - z-index: 2; - border-top: 1px solid var(--color-border-light); background: var(--color-surface); } diff --git a/frontend/editor/src/portal/components/WelcomeBanner.stories.tsx b/frontend/editor/src/portal/components/WelcomeBanner.stories.tsx index 0162d5f262..8dba4caf36 100644 --- a/frontend/editor/src/portal/components/WelcomeBanner.stories.tsx +++ b/frontend/editor/src/portal/components/WelcomeBanner.stories.tsx @@ -1,6 +1,18 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { WelcomeBanner } from "@portal/components/WelcomeBanner"; import { SetupChecklist } from "@portal/components/SetupChecklist"; +import type { OnboardingProgress } from "@portal/hooks/useOnboardingProgress"; + +const progress: OnboardingProgress = { + loading: false, + deployed: false, + editorDone: false, + policiesDone: true, + inviteDone: false, + policiesActive: 2, + policiesRecommended: 5, + allComplete: false, +}; const meta: Meta = { title: "Portal/Home/WelcomeBanner", @@ -24,6 +36,6 @@ export const Default: Story = {}; * checklist attached as the footer strip. */ export const WithSetupChecklist: Story = { args: { - footer: , + footer: , }, }; diff --git a/frontend/editor/src/portal/components/WelcomeBanner.tsx b/frontend/editor/src/portal/components/WelcomeBanner.tsx index ee64be1011..0d56e85730 100644 --- a/frontend/editor/src/portal/components/WelcomeBanner.tsx +++ b/frontend/editor/src/portal/components/WelcomeBanner.tsx @@ -1,129 +1,31 @@ import type { ReactNode } from "react"; +import { useState } from "react"; import { useTranslation } from "react-i18next"; import { Button } from "@app/ui"; import { useView } from "@portal/contexts/ViewContext"; import { EDITOR_URL } from "@portal/auth/editorUrl"; import { DownloadIcon, - UsersIcon, - EditorIcon, ExternalLinkIcon, - SearchIcon, - SourcesIcon, - PoliciesIcon, - SparklesIcon, - ComponentsIcon, - DocumentsIcon, - SettingsIcon, + UserPlusIcon, } from "@portal/components/icons"; +import { DownloadEditorModal } from "@portal/components/DownloadEditorModal"; +import markDark from "@app/assets/brand/modern-logo/StirlingPDFLogoNoTextDark.svg"; import "@portal/components/WelcomeBanner.css"; /* ──────────────────────────────────────────────────────────────────────── */ -/* Editor-card visual */ +/* Free-tier welcome hero */ /* */ -/* A decorative mock of the deployed PDF Editor reviewing a security */ -/* report. Purely presentational — hidden from assistive tech. */ -/* ──────────────────────────────────────────────────────────────────────── */ - -const RAIL_ICONS = [ - SearchIcon, - SourcesIcon, - PoliciesIcon, - EditorIcon, - ComponentsIcon, - SparklesIcon, - DocumentsIcon, - SettingsIcon, -] as const; - -function EditorRail() { - return ( -
- {RAIL_ICONS.map((Icon, i) => ( - - - - ))} -
- ); -} - -function WelcomeEditorCard() { - return ( -
-
-
- - - -
-
- - - Vulnerability Assessment Report CVE-2026-1847.pdf - - · - 18 pages -
- 1 / 18 -
- -
- -
-
- SECURITY ASSESSMENT - CRITICAL -
-
- Vulnerability Assessment Report CVE-2026-1847 -
-
-
- - - Assessment Date:{" "} - - February 2026 - - - - Classification:{" "} - - FOUO - -
-
EXECUTIVE SUMMARY
-

- This assessment identifies critical vulnerabilities in the target - environment and provides actionable remediation guidance prioritized - by risk severity. -

-
KEY FINDINGS
-
- 3 Critical - vulnerabilities identified -
-
- 7 High-severity issues - requiring attention -
-
- -
-
- ); -} - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Welcome banner (free-tier hero) */ +/* A compact product header — brand mark, "PDF Editor" + social-proof */ +/* stats, and a single "Open in browser" CTA — over the getting-started */ +/* steps (passed in as {@code footer}). Deliberately lean: the onboarding */ +/* steps, not marketing copy, are the point of the card. */ /* ──────────────────────────────────────────────────────────────────────── */ interface WelcomeBannerProps { /** - * Rendered as an attached footer strip inside the banner card (e.g. the - * "Finish setting up" checklist). Kept as a slot so the hero stays a pure - * presentational shell. + * The getting-started steps + enterprise rung, rendered inside the card + * below the header. Kept as a slot so the hero stays a presentational shell. */ footer?: ReactNode; } @@ -131,64 +33,65 @@ interface WelcomeBannerProps { export function WelcomeBanner({ footer }: WelcomeBannerProps) { const { t } = useTranslation(); const { setActiveView } = useView(); + const [installOpen, setInstallOpen] = useState(false); return (
-
-
-
-
-
-
- -
- - {t("portal.welcome.badge")} +
+
+ + -

- {t("portal.welcome.title")}{" "} - - {t("portal.welcome.titleAccent")} +
+ + {t("portal.welcome.productName")} + + + {t("portal.welcome.stats")} -

-

{t("portal.welcome.subtitle")}

-
- - -
-

{t("portal.welcome.perks")}

- -
- +
+ + +
{footer &&
{footer}
} + + setInstallOpen(false)} + />
); } diff --git a/frontend/editor/src/portal/components/billing/EnterpriseUpsell.tsx b/frontend/editor/src/portal/components/billing/EnterpriseUpsell.tsx index f27f62097e..3e6a9859dd 100644 --- a/frontend/editor/src/portal/components/billing/EnterpriseUpsell.tsx +++ b/frontend/editor/src/portal/components/billing/EnterpriseUpsell.tsx @@ -36,10 +36,7 @@ export function EnterpriseUpsell({ bare = false }: Props) {

diff --git a/frontend/editor/src/portal/hooks/useEditorInstalled.ts b/frontend/editor/src/portal/hooks/useEditorInstalled.ts new file mode 100644 index 0000000000..67c9ad63ce --- /dev/null +++ b/frontend/editor/src/portal/hooks/useEditorInstalled.ts @@ -0,0 +1,49 @@ +import { useEffect, useState } from "react"; + +/** + * A local "the user has grabbed the editor" flag for the getting-started + * download step. The deployment endpoint is the authoritative signal, but it + * only lights up once an instance reports in — so we also let the user's own + * action (clicking a download button / pressing Done in the install modal) mark + * the step complete immediately. Persisted so it survives reloads. + */ +const KEY = "stirling.portal.editorInstalled"; +const EVENT = "stirling:editor-installed"; + +export function markEditorInstalled(): void { + try { + localStorage.setItem(KEY, "1"); + } catch { + // Private mode / storage disabled — the step just won't stick; harmless. + } + window.dispatchEvent(new Event(EVENT)); +} + +export function useEditorInstalled(): boolean { + const [installed, setInstalled] = useState(() => { + try { + return localStorage.getItem(KEY) === "1"; + } catch { + return false; + } + }); + + useEffect(() => { + const sync = () => { + try { + setInstalled(localStorage.getItem(KEY) === "1"); + } catch { + // ignore + } + }; + // Same-tab mark fires EVENT; other tabs fire the native storage event. + window.addEventListener(EVENT, sync); + window.addEventListener("storage", sync); + return () => { + window.removeEventListener(EVENT, sync); + window.removeEventListener("storage", sync); + }; + }, []); + + return installed; +} diff --git a/frontend/editor/src/portal/hooks/useOnboardingProgress.ts b/frontend/editor/src/portal/hooks/useOnboardingProgress.ts new file mode 100644 index 0000000000..c46fa18fa0 --- /dev/null +++ b/frontend/editor/src/portal/hooks/useOnboardingProgress.ts @@ -0,0 +1,68 @@ +import { useTier } from "@portal/contexts/TierContext"; +import { useAsync } from "@portal/hooks/useAsync"; +import { useEditorInstalled } from "@portal/hooks/useEditorInstalled"; +import { fetchEditorDeployment } from "@portal/api/editorDeploy"; +import { fetchPolicies } from "@portal/api/policies"; +import { fetchUsers } from "@portal/api/users"; + +/** + * Getting-started completion, derived live from the org's real state. Drives + * both the per-step checks on the home hero's setup steps and the collapse to + * the deployed-status header once every step is done. + * + * Each fetch is independently best-effort: an endpoint that isn't served yet + * (e.g. editor-deployment on a bare backend) simply leaves its step incomplete + * rather than breaking the card. + */ +export interface OnboardingProgress { + loading: boolean; + /** A real deployment is reporting in (drives the live-status header). */ + deployed: boolean; + /** Download step done — a real deployment OR the user's own download/Done. */ + editorDone: boolean; + policiesDone: boolean; + /** More than just the admin on the team. */ + inviteDone: boolean; + policiesActive: number; + policiesRecommended: number; + allComplete: boolean; +} + +export function useOnboardingProgress(): OnboardingProgress { + const { tier } = useTier(); + const editorInstalled = useEditorInstalled(); + const { data, loading } = useAsync( + () => + Promise.all([ + fetchEditorDeployment(tier).catch(() => null), + fetchPolicies().catch(() => null), + fetchUsers(tier).catch(() => null), + ]), + [tier], + ); + + const [deploy, policies, users] = data ?? [null, null, null]; + + const deployed = (deploy?.instances.length ?? 0) > 0; + // Authoritative signal is a deployed instance; the user's own download/Done + // action marks it complete immediately via the persisted flag. + const editorDone = editorInstalled || deployed; + const policiesActive = policies?.summary.active ?? 0; + const policiesRecommended = Math.max( + 0, + (policies?.summary.categories ?? 0) - policiesActive, + ); + const policiesDone = policiesActive > 0; + const inviteDone = (users?.summary.totalMembers ?? 0) > 1; + + return { + loading, + deployed, + editorDone, + policiesDone, + inviteDone, + policiesActive, + policiesRecommended, + allComplete: editorDone && policiesDone && inviteDone, + }; +} diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index 5c3d20b94a..9793c60ac8 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -251,15 +251,18 @@ export default defineConfig( "no-restricted-syntax": ["error", ...mantineComponentImportRestrictions], }, }, - // TEMPORARY (same rationale as procurement above): the portal home hero - // reuses the same bespoke CSS-styled raw - - -
+ {!isEditor && ( +
+ + + +
+ )} ); } diff --git a/frontend/editor/src/portal/components/sources/SourceDetailPanel.tsx b/frontend/editor/src/portal/components/sources/SourceDetailPanel.tsx index f4ee827575..82fd8a5b1b 100644 --- a/frontend/editor/src/portal/components/sources/SourceDetailPanel.tsx +++ b/frontend/editor/src/portal/components/sources/SourceDetailPanel.tsx @@ -2,6 +2,7 @@ import { useTranslation } from "react-i18next"; import { Chip, StatTile } from "@app/ui"; import type { SourceView } from "@portal/api/sources"; import { Sparkline } from "@portal/components/sources/Sparkline"; +import { EDITOR_SOURCE_TYPE } from "@portal/components/sources/sourceTypes"; import "@portal/views/Sources.css"; interface SourceDetailPanelProps { @@ -20,8 +21,15 @@ export function SourceDetailPanel({ docSeries, }: SourceDetailPanelProps) { const { t } = useTranslation(); + const isEditor = source.type === EDITOR_SOURCE_TYPE; return (
+ {isEditor && ( +

+ {t("portal.sources.types.editor.description")} +

+ )} + {source.config.length > 0 && (
{source.config.map((row) => ( @@ -36,7 +44,11 @@ export function SourceDetailPanel({ {source.referencingPolicies.length === 0 ? (

- {t("portal.sources.detail.notReferenced")} + {t( + isEditor + ? "portal.sources.types.editor.noPolicies" + : "portal.sources.detail.notReferenced", + )}

) : (
diff --git a/frontend/editor/src/portal/components/sources/SourcesTable.tsx b/frontend/editor/src/portal/components/sources/SourcesTable.tsx index be66fa350e..dc6daf16aa 100644 --- a/frontend/editor/src/portal/components/sources/SourcesTable.tsx +++ b/frontend/editor/src/portal/components/sources/SourcesTable.tsx @@ -8,7 +8,10 @@ import { type TableColumn, } from "@app/ui"; import type { SourceStatus, SourceView } from "@portal/api/sources"; -import { sourceTypeMeta } from "@portal/components/sources/sourceTypes"; +import { + EDITOR_SOURCE_TYPE, + sourceTypeMeta, +} from "@portal/components/sources/sourceTypes"; import "@portal/views/Sources.css"; const STATUS_TONE: Record = { @@ -37,6 +40,9 @@ export function SourcesTable({ header: t("portal.sources.table.source"), render: (s) => { const meta = sourceTypeMeta(s.type); + // The editor is a system source with no instance name: label it from its type and drop + // the chip, which would just repeat the name. + const isEditor = s.type === EDITOR_SOURCE_TYPE; return (
- {s.name} - - {t(meta.labelKey)} - + {isEditor ? t(meta.labelKey) : s.name} + {!isEditor && ( + + {t(meta.labelKey)} + + )}
); diff --git a/frontend/editor/src/portal/components/sources/sourceTypes.ts b/frontend/editor/src/portal/components/sources/sourceTypes.ts index fb6057a183..a3f19b6b36 100644 --- a/frontend/editor/src/portal/components/sources/sourceTypes.ts +++ b/frontend/editor/src/portal/components/sources/sourceTypes.ts @@ -15,6 +15,13 @@ export interface SourceTypeMeta { accent: ChipAccent; } +/** + * The built-in editor source. It is virtual (never created/edited/deleted like a folder), always + * present in the list, and only tracks throughput - so rows of this type render without a config, + * a type chip, or edit/pause/delete actions. + */ +export const EDITOR_SOURCE_TYPE = "editor"; + const SOURCE_TYPE_META: Record = { folder: { labelKey: "portal.sources.types.folder.label", diff --git a/frontend/editor/src/portal/mocks/handlers/sources.ts b/frontend/editor/src/portal/mocks/handlers/sources.ts index 4839c17720..4f4dd0e807 100644 --- a/frontend/editor/src/portal/mocks/handlers/sources.ts +++ b/frontend/editor/src/portal/mocks/handlers/sources.ts @@ -144,14 +144,37 @@ function buildKpis(views: SourceView[]): SourceKpi[] { ]; } +/** The built-in editor source's demo throughput, mirroring the backend's per-team counters. */ +const editorDocs = { total: 8230, last24h: 42, last30d: 1680 }; + +/** The always-present editor row, pinned first and never editable/deletable. */ +function editorView(): SourceView { + return { + id: "editor", + name: "Editor", + type: "editor", + status: "active", + referenceCount: 2, + referencingPolicies: [ + { id: "pol_security", name: "Security Policy" }, + { id: "pol_redaction", name: "Redaction Policy" }, + ], + config: [], + docsTotal: editorDocs.total, + docs24h: editorDocs.last24h, + docs30d: editorDocs.last30d, + }; +} + function buildOverview(): SourcesResponse { - const views = store + const persisted = store .map((s) => toSourceView(s, refsFor(s.id))) .sort( (a, b) => b.referenceCount - a.referenceCount || a.name.localeCompare(b.name), ); - return { kpis: buildKpis(views), sources: views }; + // KPIs describe the configured connections; the built-in editor is pinned first but not counted. + return { kpis: buildKpis(persisted), sources: [editorView(), ...persisted] }; } export const sourcesHandlers = [ @@ -169,6 +192,9 @@ export const sourcesHandlers = [ http.get("/api/v1/sources/:id/document-counts", async ({ params }) => { await delay(120); + if (params.id === "editor") { + return HttpResponse.json(sampleDailySeries(editorDocs.last30d / 30)); + } const source = store.find((s) => s.id === params.id); if (!source) return new HttpResponse(null, { status: 404 }); return HttpResponse.json(docsFor(source.id).daily); @@ -195,6 +221,12 @@ export const sourcesHandlers = [ http.delete("/api/v1/sources/:id", async ({ params }) => { await delay(120); const id = String(params.id); + if (id === "editor") { + return HttpResponse.json( + { detail: "The editor is a built-in source and cannot be deleted" }, + { status: 400 }, + ); + } const source = store.find((s) => s.id === id); if (!source) return new HttpResponse(null, { status: 404 }); const refs = refsFor(id); diff --git a/frontend/editor/src/portal/views/PipelineBuilder.tsx b/frontend/editor/src/portal/views/PipelineBuilder.tsx index e82f0b380c..3b4c7d421d 100644 --- a/frontend/editor/src/portal/views/PipelineBuilder.tsx +++ b/frontend/editor/src/portal/views/PipelineBuilder.tsx @@ -51,6 +51,7 @@ import { import { clearProcessedHistory } from "@portal/api/policies"; import { availableOutputModes } from "@portal/components/pipelines/outputModes"; import { fetchSources, type SourceView } from "@portal/api/sources"; +import { EDITOR_SOURCE_TYPE } from "@portal/components/sources/sourceTypes"; import { useAsync } from "@portal/hooks/useAsync"; import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext"; import { humanizeOperation } from "@portal/components/pipelines/pipelineOperations"; @@ -172,7 +173,12 @@ export function PipelineBuilder() { [id], ); const sourcesState = useAsync( - async () => (await fetchSources()).sources, + // The editor is a built-in, client-driven source (it runs on editor upload, not as a pipeline + // input), so it's excluded from the sources a pipeline can pull from. + async () => + (await fetchSources()).sources.filter( + (source) => source.type !== EDITOR_SOURCE_TYPE, + ), [], ); const triggersState = useAsync( diff --git a/frontend/editor/src/portal/views/Sources.test.tsx b/frontend/editor/src/portal/views/Sources.test.tsx index 29b38f58a9..5e281d5604 100644 --- a/frontend/editor/src/portal/views/Sources.test.tsx +++ b/frontend/editor/src/portal/views/Sources.test.tsx @@ -122,6 +122,39 @@ describe("Sources view", () => { ).toBeInTheDocument(); }); + it("shows the editor as a built-in source with no edit, pause, or delete actions", async () => { + fetchSources.mockResolvedValue({ + kpis: [], + sources: [ + { + id: "editor", + name: "Editor", + type: "editor", + status: "active", + referenceCount: 1, + referencingPolicies: [{ id: "pol-1", name: "Redaction" }], + config: [], + docsTotal: 8230, + docs24h: 42, + docs30d: 1680, + }, + ], + } satisfies SourcesResponse); + + renderView(); + + // The editor row is labelled from its type (i18n keys are returned verbatim here). + fireEvent.click( + await screen.findByText("portal.sources.types.editor.label"), + ); + + // Detail opens, but none of the mutate actions are offered for the built-in source. + await screen.findByText("portal.sources.detail.documents"); + expect(screen.queryByText("portal.sources.detail.edit")).toBeNull(); + expect(screen.queryByText("portal.sources.detail.pause")).toBeNull(); + expect(screen.queryByText("portal.sources.detail.delete")).toBeNull(); + }); + it("pauses a source by re-saving it with enabled flipped off", async () => { fetchSources.mockResolvedValue(RESPONSE); fetchSource.mockResolvedValue({ From ece3562dc963c99dd614140311caceff5ad75d5e Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:28:33 +0100 Subject: [PATCH 09/34] Portal: team-scoped Free PDF Editors usage card for SaaS (#6924) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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. --- .../controller/api/FleetUsageController.java | 4 + .../PersistentAuditEventRepository.java | 21 +++ .../saas/usage/SaasFleetUsageController.java | 103 +++++++++++ .../usage/SaasFleetUsageControllerTest.java | 168 ++++++++++++++++++ .../editor/src/portal-saas/api/fleetStats.ts | 16 ++ frontend/editor/src/portal/api/fleetStats.ts | 6 +- 6 files changed, 315 insertions(+), 3 deletions(-) create mode 100644 app/saas/src/main/java/stirling/software/saas/usage/SaasFleetUsageController.java create mode 100644 app/saas/src/test/java/stirling/software/saas/usage/SaasFleetUsageControllerTest.java create mode 100644 frontend/editor/src/portal-saas/api/fleetStats.ts diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/FleetUsageController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/FleetUsageController.java index 57b74362ec..8f95920e5a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/FleetUsageController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/FleetUsageController.java @@ -4,6 +4,7 @@ import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.List; +import org.springframework.context.annotation.Profile; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; @@ -38,6 +39,9 @@ import stirling.software.proprietary.security.database.repository.UserRepository @PreAuthorize("hasRole('ADMIN')") @RequiredArgsConstructor @EnterpriseEndpoint +// Self-hosted only: counts are server-wide. On SaaS this endpoint is owned by the team-scoped +// SaasFleetUsageController (@Profile("saas")) so one backend can't leak another tenant's usage. +@Profile("!saas") public class FleetUsageController { private final PersistentAuditEventRepository auditRepository; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/repository/PersistentAuditEventRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/repository/PersistentAuditEventRepository.java index 7c0081dd7c..95075945ed 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/repository/PersistentAuditEventRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/repository/PersistentAuditEventRepository.java @@ -276,4 +276,25 @@ public interface PersistentAuditEventRepository extends JpaRepository :since") + long countByTypeInAndSourceAndPrincipalInAndTimestampAfter( + @Param("types") List types, + @Param("source") String source, + @Param("principals") List principals, + @Param("since") Instant since); + + @Query( + "SELECT COUNT(DISTINCT e.principal) FROM PersistentAuditEvent e " + + "WHERE e.source = :source AND e.type <> :excludeType " + + "AND e.principal IN :principals AND e.timestamp > :since") + long countDistinctPrincipalsBySourceExcludingTypeAndPrincipalInAfter( + @Param("source") String source, + @Param("excludeType") String excludeType, + @Param("principals") List principals, + @Param("since") Instant since); } diff --git a/app/saas/src/main/java/stirling/software/saas/usage/SaasFleetUsageController.java b/app/saas/src/main/java/stirling/software/saas/usage/SaasFleetUsageController.java new file mode 100644 index 0000000000..b25444af40 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/usage/SaasFleetUsageController.java @@ -0,0 +1,103 @@ +package stirling.software.saas.usage; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; + +import org.springframework.context.annotation.Profile; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.transaction.annotation.Transactional; +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.proprietary.audit.AuditLevel; +import stirling.software.proprietary.config.AuditConfigurationProperties; +import stirling.software.proprietary.model.TeamMembership; +import stirling.software.proprietary.model.api.usage.FleetUsageStats; +import stirling.software.proprietary.repository.PersistentAuditEventRepository; +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; +import stirling.software.saas.util.AuthenticationUtils; + +/** + * SaaS counterpart to {@code FleetUsageController}: the same "Free PDF Editors" figures, scoped to + * the caller's team (one SaaS backend serves many tenants, so the self-hosted server-wide variant + * is disabled here via {@code @Profile("!saas")}). + * + *
    + *
  • editorsDeployed — number of team members ({@code team_memberships}), not seat limits; + *
  • activeThisMonth — distinct members with a free-UI ("WEB", non-{@code UI_DATA}) audit event + * in the last 30 days, clamped to a subset of deployed; + *
  • pdfsProcessed — the team's cumulative free-UI PDF/file operations. + *
+ * + *

Team resolution + membership mirror {@code PaygWalletController}. Audit-derived figures are + * null (rendered "N/A") when EE auditing is below STANDARD. Cost is always $0 (client literal). + */ +@Slf4j +@RestController +@RequestMapping("/api/v1/usage") +@Profile("saas") +@RequiredArgsConstructor +public class SaasFleetUsageController { + + private static final List PDF_TYPES = List.of("PDF_PROCESS", "FILE_OPERATION"); + + private final UserRepository userRepository; + private final TeamMembershipRepository memberRepo; + private final PersistentAuditEventRepository auditRepository; + private final AuditConfigurationProperties auditConfig; + + @GetMapping("/fleet-stats") + @PreAuthorize("isAuthenticated()") + @Transactional(readOnly = true) + public ResponseEntity fleetStats(Authentication auth) { + User user; + try { + user = AuthenticationUtils.getCurrentUser(auth, userRepository); + } catch (SecurityException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + + List primary = memberRepo.findPrimaryMembership(user.getId()); + if (primary.isEmpty()) { + // Authenticated caller without a team — shouldn't happen post-migration; report an + // empty fleet rather than 500. + return ResponseEntity.ok(new FleetUsageStats(0L, null, null)); + } + Long teamId = primary.get(0).getTeam().getId(); + + List members = + memberRepo.findByTeamId(teamId).stream() + .map(m -> m.getUser().getUsername()) + .toList(); + Long deployed = (long) members.size(); + + // Guard the empty IN-list (invalid JPQL) as well as the audit-level gate. + boolean auditOn = !members.isEmpty() && auditConfig.isLevelEnabled(AuditLevel.STANDARD); + Instant since = Instant.now().minus(30, ChronoUnit.DAYS); + Long active = + auditOn + ? auditRepository + .countDistinctPrincipalsBySourceExcludingTypeAndPrincipalInAfter( + "WEB", "UI_DATA", members, since) + : null; + Long pdfs = + auditOn + ? auditRepository.countByTypeInAndSourceAndPrincipalInAndTimestampAfter( + PDF_TYPES, "WEB", members, Instant.EPOCH) + : null; + if (active != null && active > deployed) { + active = deployed; // active editors are a subset of those deployed + } + return ResponseEntity.ok(new FleetUsageStats(deployed, active, pdfs)); + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/usage/SaasFleetUsageControllerTest.java b/app/saas/src/test/java/stirling/software/saas/usage/SaasFleetUsageControllerTest.java new file mode 100644 index 0000000000..edd84e252a --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/usage/SaasFleetUsageControllerTest.java @@ -0,0 +1,168 @@ +package stirling.software.saas.usage; + +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.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Instant; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; + +import stirling.software.proprietary.audit.AuditLevel; +import stirling.software.proprietary.config.AuditConfigurationProperties; +import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.model.TeamMembership; +import stirling.software.proprietary.model.api.usage.FleetUsageStats; +import stirling.software.proprietary.repository.PersistentAuditEventRepository; +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; + +@ExtendWith(MockitoExtension.class) +class SaasFleetUsageControllerTest { + + @Mock private UserRepository userRepository; + @Mock private TeamMembershipRepository memberRepo; + @Mock private PersistentAuditEventRepository auditRepository; + @Mock private AuditConfigurationProperties auditConfig; + + private SaasFleetUsageController controller; + + @BeforeEach + void setUp() { + controller = + new SaasFleetUsageController( + userRepository, memberRepo, auditRepository, auditConfig); + } + + /** An Authentication whose principal is a User (AuthenticationUtils returns it directly). */ + private Authentication authFor(long userId) { + User user = mock(User.class); + when(user.getId()).thenReturn(userId); + Authentication auth = mock(Authentication.class); + when(auth.getPrincipal()).thenReturn(user); + return auth; + } + + private TeamMembership memberOf(long teamId, String username) { + // lenient: a member used only in the roster has its team.getId() stub unused, which strict + // stubbing would otherwise flag. + Team team = mock(Team.class); + lenient().when(team.getId()).thenReturn(teamId); + User u = mock(User.class); + lenient().when(u.getUsername()).thenReturn(username); + TeamMembership m = mock(TeamMembership.class); + lenient().when(m.getTeam()).thenReturn(team); + lenient().when(m.getUser()).thenReturn(u); + return m; + } + + @Test + @DisplayName("figures are scoped to the caller's team members") + void teamScopedFigures() { + Authentication auth = authFor(1L); + TeamMembership leader = memberOf(42L, "leader@acme.test"); + TeamMembership bob = memberOf(42L, "bob@acme.test"); + when(memberRepo.findPrimaryMembership(1L)).thenReturn(List.of(leader)); + when(memberRepo.findByTeamId(42L)).thenReturn(List.of(leader, bob)); + when(auditConfig.isLevelEnabled(AuditLevel.STANDARD)).thenReturn(true); + when(auditRepository.countDistinctPrincipalsBySourceExcludingTypeAndPrincipalInAfter( + eq("WEB"), eq("UI_DATA"), anyList(), any(Instant.class))) + .thenReturn(1L); + when(auditRepository.countByTypeInAndSourceAndPrincipalInAndTimestampAfter( + anyList(), eq("WEB"), anyList(), any(Instant.class))) + .thenReturn(88L); + + ResponseEntity res = controller.fleetStats(auth); + FleetUsageStats stats = res.getBody(); + + assertThat(stats).isNotNull(); + assertThat(stats.editorsDeployed()).isEqualTo(2L); + assertThat(stats.activeThisMonth()).isEqualTo(1L); + assertThat(stats.pdfsProcessed()).isEqualTo(88L); + verify(auditRepository) + .countByTypeInAndSourceAndPrincipalInAndTimestampAfter( + eq(List.of("PDF_PROCESS", "FILE_OPERATION")), + eq("WEB"), + eq(List.of("leader@acme.test", "bob@acme.test")), + any(Instant.class)); + } + + @Test + @DisplayName("audit-derived figures are null when auditing is below STANDARD") + void auditOffYieldsNulls() { + Authentication auth = authFor(1L); + TeamMembership leader = memberOf(42L, "leader@acme.test"); + when(memberRepo.findPrimaryMembership(1L)).thenReturn(List.of(leader)); + when(memberRepo.findByTeamId(42L)).thenReturn(List.of(leader)); + when(auditConfig.isLevelEnabled(AuditLevel.STANDARD)).thenReturn(false); + + FleetUsageStats stats = controller.fleetStats(auth).getBody(); + + assertThat(stats).isNotNull(); + assertThat(stats.editorsDeployed()).isEqualTo(1L); + assertThat(stats.activeThisMonth()).isNull(); + assertThat(stats.pdfsProcessed()).isNull(); + verify(auditRepository, never()) + .countByTypeInAndSourceAndPrincipalInAndTimestampAfter( + anyList(), any(), anyList(), any(Instant.class)); + } + + @Test + @DisplayName("active is clamped to deployed (a subset)") + void activeClampedToDeployed() { + Authentication auth = authFor(1L); + TeamMembership leader = memberOf(42L, "leader@acme.test"); + when(memberRepo.findPrimaryMembership(1L)).thenReturn(List.of(leader)); + when(memberRepo.findByTeamId(42L)).thenReturn(List.of(leader)); + when(auditConfig.isLevelEnabled(AuditLevel.STANDARD)).thenReturn(true); + when(auditRepository.countDistinctPrincipalsBySourceExcludingTypeAndPrincipalInAfter( + eq("WEB"), eq("UI_DATA"), anyList(), any(Instant.class))) + .thenReturn(5L); + when(auditRepository.countByTypeInAndSourceAndPrincipalInAndTimestampAfter( + anyList(), eq("WEB"), anyList(), any(Instant.class))) + .thenReturn(10L); + + FleetUsageStats stats = controller.fleetStats(auth).getBody(); + + assertThat(stats).isNotNull(); + assertThat(stats.activeThisMonth()).isEqualTo(1L); + } + + @Test + @DisplayName("a caller with no team gets an empty fleet, not a 500") + void noTeamReturnsEmpty() { + Authentication auth = authFor(1L); + when(memberRepo.findPrimaryMembership(1L)).thenReturn(List.of()); + + FleetUsageStats stats = controller.fleetStats(auth).getBody(); + + assertThat(stats).isNotNull(); + assertThat(stats.editorsDeployed()).isEqualTo(0L); + assertThat(stats.activeThisMonth()).isNull(); + assertThat(stats.pdfsProcessed()).isNull(); + } + + @Test + @DisplayName("an unauthenticated request is 401") + void unauthenticatedIs401() { + ResponseEntity res = controller.fleetStats(null); + + assertThat(res.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } +} diff --git a/frontend/editor/src/portal-saas/api/fleetStats.ts b/frontend/editor/src/portal-saas/api/fleetStats.ts new file mode 100644 index 0000000000..9ba701f4c6 --- /dev/null +++ b/frontend/editor/src/portal-saas/api/fleetStats.ts @@ -0,0 +1,16 @@ +import { apiClient } from "@portal/api/http"; +import type { FleetStats } from "@portal-proprietary/api/fleetStats"; + +export type { FleetStats }; + +/** + * SaaS build: fleet usage is team-scoped and served by the SaaS backend, so it is + * read via {@code apiClient.saas} — the admin's Supabase JWT, which the SaaS backend + * uses to resolve the caller's team. Shadows the self-hosted + * src/portal/api/fleetStats.ts (which reads the local backend server-wide). + */ +export function fetchFleetStats(signal?: AbortSignal): Promise { + return apiClient.saas.json("/api/v1/usage/fleet-stats", { + signal, + }); +} diff --git a/frontend/editor/src/portal/api/fleetStats.ts b/frontend/editor/src/portal/api/fleetStats.ts index b62526aacd..48bc247d84 100644 --- a/frontend/editor/src/portal/api/fleetStats.ts +++ b/frontend/editor/src/portal/api/fleetStats.ts @@ -4,9 +4,9 @@ import { apiClient } from "@portal/api/http"; * Free-editor fleet usage for the {@link FreePdfEditorsCard}. * * Self-hosted (this module) reads the local Stirling backend — the figures come - * from this instance's audit trail, filtered to free UI tool runs. A SaaS build - * shadows this module (src/saas/portal/api/fleetStats.ts) to read the - * team-scoped SaaS backend instead. + * from this instance's audit trail, filtered to free UI tool runs. The SaaS build + * shadows this module (src/portal-saas/api/fleetStats.ts) to read the team-scoped + * SaaS backend instead. * * Any field may be null when the backend can't compute it (e.g. EE auditing is * disabled); the card renders null as "N/A" rather than a misleading 0. From ce6abe6e2384724d04f6818c02124df111e27126 Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:38:22 +0100 Subject: [PATCH 10/34] PAYG: size-scaled units + per-input-file PDF count + run-id grouping (#6957) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../common/service/AutomationRunContext.java | 56 ++++++ .../common/service/InternalApiClient.java | 8 + .../api/pipeline/PipelineProcessor.java | 13 ++ .../policy/engine/PolicyEngine.java | 124 +++++++----- .../service/AiWorkflowService.java | 58 +++--- .../ai/controller/AiCreateController.java | 3 +- .../saas/payg/api/PaygWalletController.java | 61 ++++-- .../saas/payg/api/WalletSnapshotResponse.java | 16 +- .../saas/payg/charge/ChargeContext.java | 21 +- .../saas/payg/charge/JobChargeService.java | 42 +++- .../payg/filter/PaygChargeInterceptor.java | 33 +++- .../instance/InstanceUsageIngestService.java | 3 +- .../software/saas/payg/job/JobContext.java | 18 +- .../software/saas/payg/job/JobService.java | 54 ++++- .../software/saas/payg/job/ProcessingJob.java | 21 ++ .../lineage/DefaultHashLineageDetector.java | 15 ++ .../payg/lineage/HashLineageDetector.java | 11 ++ .../saas/payg/lineage/JobLineageStore.java | 13 ++ .../saas/payg/lineage/JpaJobLineageStore.java | 18 ++ .../repository/JobArtifactHashRepository.java | 23 +++ .../repository/WalletLedgerRepository.java | 42 +++- .../saas/payg/wallet/WalletLedgerEntry.java | 20 ++ .../saas/V38__payg_run_grouping_doc_count.sql | 39 ++++ .../payg/api/PaygWalletControllerTest.java | 24 ++- .../payg/api/WalletSnapshotResponseTest.java | 12 +- .../filter/PaygChargeInterceptorTest.java | 46 +++++ .../saas/payg/job/JobServiceTest.java | 38 +++- .../public/locales/en-US/translation.toml | 11 +- frontend/editor/src/cloud/hooks/useWallet.ts | 10 +- .../billing/PdfsProcessedCard.stories.tsx | 6 +- .../components/billing/PdfsProcessedCard.tsx | 187 +++++++++++------- .../billing/SpendThisMonthCard.stories.tsx | 5 + .../components/billing/SpendThisMonthCard.tsx | 12 ++ .../src/portal/components/billing/billing.css | 7 + .../components/billing/walletFixtures.ts | 8 + .../editor/src/proprietary/billing/types.ts | 9 + .../shared/FreeLimitReachedModal.test.tsx | 4 + .../editor/src/saas/hooks/walletDevPreview.ts | 6 + 38 files changed, 897 insertions(+), 200 deletions(-) create mode 100644 app/common/src/main/java/stirling/software/common/service/AutomationRunContext.java create mode 100644 app/saas/src/main/resources/db/migration/saas/V38__payg_run_grouping_doc_count.sql diff --git a/app/common/src/main/java/stirling/software/common/service/AutomationRunContext.java b/app/common/src/main/java/stirling/software/common/service/AutomationRunContext.java new file mode 100644 index 0000000000..9ef4cb0511 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/service/AutomationRunContext.java @@ -0,0 +1,56 @@ +package stirling.software.common.service; + +/** + * Thread-scoped correlation id for one automation run — a single pipeline, policy, or AI-workflow + * execution over its input file(s). + * + *

Automations dispatch each tool step as a separate internal loopback POST via {@link + * InternalApiClient}. The orchestrator opens a run scope around its dispatch loop; {@code + * InternalApiClient} reads {@link #current()} and stamps it on every sub-step request as {@link + * #RUN_ID_HEADER}. The SaaS PAYG interceptor uses that header so all sub-steps of ONE run group + * into a single charge, while two separate runs that happen to touch identical bytes stay + * distinct charges (the old content+time-window grouping merged them). + * + *

Sub-steps dispatch synchronously on the orchestrator's own thread (loopback {@code + * RestTemplate}), so this ThreadLocal is visible to {@code InternalApiClient}. The id then crosses + * to the receiving request thread via the HTTP header — never via this ThreadLocal. + * + *

No-op when the id is absent (a standalone tool call): the interceptor treats a missing run id + * as "its own charge", which is exactly what a one-off call should be. + */ +public final class AutomationRunContext { + + /** Header carrying the run id on internal sub-step dispatches. */ + public static final String RUN_ID_HEADER = "X-Stirling-Run-Id"; + + private static final ThreadLocal CURRENT = new ThreadLocal<>(); + + private AutomationRunContext() {} + + /** + * Opens a run scope on the current thread. Returns an {@link AutoCloseable} that restores the + * previously-active id (nesting-safe) — use in try-with-resources around the dispatch loop. + */ + public static Scope open(String runId) { + String previous = CURRENT.get(); + CURRENT.set(runId); + return () -> { + if (previous == null) { + CURRENT.remove(); + } else { + CURRENT.set(previous); + } + }; + } + + /** The run id active on this thread, or {@code null} when not inside a run scope. */ + public static String current() { + return CURRENT.get(); + } + + /** AutoCloseable whose {@link #close()} declares no checked exception. */ + public interface Scope extends AutoCloseable { + @Override + void close(); + } +} diff --git a/app/common/src/main/java/stirling/software/common/service/InternalApiClient.java b/app/common/src/main/java/stirling/software/common/service/InternalApiClient.java index 8df2d5e410..39e27457b3 100644 --- a/app/common/src/main/java/stirling/software/common/service/InternalApiClient.java +++ b/app/common/src/main/java/stirling/software/common/service/InternalApiClient.java @@ -111,6 +111,14 @@ public class InternalApiClient { // step inside a policy run must bill as AUTOMATION, not AI). Set unconditionally because // every caller of this dispatcher is an automation surface by design. headers.add(AUTOMATION_HEADER, "true"); + // Propagate the current automation run id (set by the orchestrator around its dispatch + // loop) so the PAYG interceptor groups every sub-step of this one run into a single charge, + // and never merges two separate runs that happen to touch identical bytes. Absent → the + // receiving call is treated as standalone. See AutomationRunContext. + String runId = AutomationRunContext.current(); + if (runId != null && !runId.isEmpty()) { + headers.add(AutomationRunContext.RUN_ID_HEADER, runId); + } // A no-file ai/tools call (e.g. create-pdf-from-html-agent) sends only string params, so // without this RestTemplate would use urlencoded instead of the multipart the controller diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineProcessor.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineProcessor.java index c57a43f27f..c4f1c76f5e 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineProcessor.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineProcessor.java @@ -10,6 +10,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Map.Entry; +import java.util.UUID; import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.Resource; @@ -27,6 +28,7 @@ import stirling.software.SPDF.model.PipelineConfig; import stirling.software.SPDF.model.PipelineOperation; import stirling.software.SPDF.model.PipelineResult; import stirling.software.SPDF.service.ApiDocService; +import stirling.software.common.service.AutomationRunContext; import stirling.software.common.service.InternalApiClient; import stirling.software.common.util.TempFileManager; import stirling.software.common.util.ZipExtractionUtils; @@ -71,6 +73,17 @@ public class PipelineProcessor { PipelineResult runPipelineAgainstFiles(List outputFiles, PipelineConfig config) throws Exception { + // One pipeline execution = one automation run. Scope a run id so every tool sub-step + // dispatched via InternalApiClient groups into a single charge on the SaaS billing side + // (see AutomationRunContext); pipeline steps run synchronously on this thread. + try (AutomationRunContext.Scope ignored = + AutomationRunContext.open(UUID.randomUUID().toString())) { + return runPipelineAgainstFilesInternal(outputFiles, config); + } + } + + private PipelineResult runPipelineAgainstFilesInternal( + List outputFiles, PipelineConfig config) throws Exception { PipelineResult result = new PipelineResult(); ByteArrayOutputStream logStream = new ByteArrayOutputStream(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java index 98a6f90096..cef0f29e6f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java @@ -21,6 +21,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.job.ResultFile; +import stirling.software.common.service.AutomationRunContext; import stirling.software.common.service.FileStorage; import stirling.software.common.service.InternalApiTimeoutException; import stirling.software.common.service.JobOwnershipService; @@ -199,63 +200,78 @@ public class PolicyEngine { PolicyProgressListener listener, CompletableFuture completion) { String runId = run.getRunId(); - try { - run.markRunning(); - PolicyExecutionResult result = - stepExecutor.execute(run.getDefinition(), inputs, listener); - OutputSpec output = run.getDefinition().output(); - List outputs = - sinkFor(output) - .deliver( - new OutputDelivery(runId, run.getPolicyId()), - result.files(), - output); - taskManager.setMultipleFileResults(runId, outputs); - taskManager.setComplete(runId); - run.complete(outputs); - } catch (PolicyInputRequiredException e) { - // Expected path: suspend rather than fail. Persist intermediates as fileIds so the run - // can resume after this worker thread is gone. - WaitState wait = suspend(e); - run.waitForInput(wait); - taskManager.addNote(runId, "Waiting for input: " + e.getMessage()); - } catch (InternalApiTimeoutException e) { - String message = toolTimeoutMessage(e); - log.error( - "Policy run {} timed out on {}: {}", - runId, - e.getEndpointPath(), - e.getMessage()); - run.fail(message); - taskManager.setError(runId, message); - } catch (RestClientResponseException e) { - // A downstream tool call returned an error status. When it's a structured entitlement - // response (401/402 with a JSON `error` sentinel), surface that code onto the run so - // the - // client can react — e.g. pop the usage-limit modal — instead of only seeing a generic - // failure. We don't interpret the code here (that would couple this module to the saas - // billing layer); we just pass it through for the client to map. Other statuses fall - // through to the generic failure below. - String code = DownstreamEntitlementError.extractCode(e); - if (code != null) { - log.info("Policy run {} blocked by downstream entitlement gate ({})", runId, code); - String message = "Usage limit reached"; - run.failWithCode(message, code, DownstreamEntitlementError.extractSubscribed(e)); - taskManager.setError(runId, message); - } else { - String message = "Policy run failed: " + e.getMessage(); - log.error("Policy run {} failed (downstream HTTP error)", runId, e); + // One policy run = one automation run. Scope the run id on this worker thread (the async + // hop already happened) so every tool sub-step dispatched via InternalApiClient groups into + // a single charge, and two separate policy runs on the same document stay distinct charges. + try (AutomationRunContext.Scope runScope = AutomationRunContext.open(runId)) { + try { + run.markRunning(); + PolicyExecutionResult result = + stepExecutor.execute(run.getDefinition(), inputs, listener); + OutputSpec output = run.getDefinition().output(); + List outputs = + sinkFor(output) + .deliver( + new OutputDelivery(runId, run.getPolicyId()), + result.files(), + output); + taskManager.setMultipleFileResults(runId, outputs); + taskManager.setComplete(runId); + run.complete(outputs); + } catch (PolicyInputRequiredException e) { + // Expected path: suspend rather than fail. Persist intermediates as fileIds so the + // run + // can resume after this worker thread is gone. + WaitState wait = suspend(e); + run.waitForInput(wait); + taskManager.addNote(runId, "Waiting for input: " + e.getMessage()); + } catch (InternalApiTimeoutException e) { + String message = toolTimeoutMessage(e); + log.error( + "Policy run {} timed out on {}: {}", + runId, + e.getEndpointPath(), + e.getMessage()); run.fail(message); taskManager.setError(runId, message); + } catch (RestClientResponseException e) { + // A downstream tool call returned an error status. When it's a structured + // entitlement + // response (401/402 with a JSON `error` sentinel), surface that code onto the run + // so + // the + // client can react — e.g. pop the usage-limit modal — instead of only seeing a + // generic + // failure. We don't interpret the code here (that would couple this module to the + // saas + // billing layer); we just pass it through for the client to map. Other statuses + // fall + // through to the generic failure below. + String code = DownstreamEntitlementError.extractCode(e); + if (code != null) { + log.info( + "Policy run {} blocked by downstream entitlement gate ({})", + runId, + code); + String message = "Usage limit reached"; + run.failWithCode( + message, code, DownstreamEntitlementError.extractSubscribed(e)); + taskManager.setError(runId, message); + } else { + String message = "Policy run failed: " + e.getMessage(); + log.error("Policy run {} failed (downstream HTTP error)", runId, e); + run.fail(message); + taskManager.setError(runId, message); + } + } catch (Exception e) { + String message = "Policy run failed: " + e.getMessage(); + log.error("Policy run {} failed", runId, e); + run.fail(message); + taskManager.setError(runId, message); + } finally { + // Always resolve so stream/await callers unblock. + completion.complete(run); } - } catch (Exception e) { - String message = "Policy run failed: " + e.getMessage(); - log.error("Policy run {} failed", runId, e); - run.fail(message); - taskManager.setError(runId, message); - } finally { - // Always resolve so stream/await callers unblock. - completion.complete(run); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java index 0e9460eca7..74e68d4602 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java @@ -7,6 +7,7 @@ import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.UUID; import java.util.stream.Collectors; import org.apache.commons.io.FilenameUtils; @@ -27,6 +28,7 @@ import lombok.Data; import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.service.AutomationRunContext; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.service.FileStorage; import stirling.software.common.service.InternalApiTimeoutException; @@ -156,33 +158,41 @@ public class AiWorkflowService { throws IOException { validateRequest(request); - // Key by opaque file id, not filename. Filenames aren't guaranteed unique across an - // upload (users can rotate the same 'scan.pdf' twice), and the engine identifies files - // by id in every response shape that asks Java to look a file up again. - Map filesById = new LinkedHashMap<>(); - List files = new ArrayList<>(); - for (AiWorkflowFileInput fileInput : request.getFileInputs()) { - MultipartFile multipartFile = fileInput.getFileInput(); - AiFile aiFile = - new AiFile( - fileIdStrategy.idFor(multipartFile), - multipartFile.getOriginalFilename()); - filesById.put(aiFile.getId(), multipartFile); - files.add(aiFile); - } + // One AI orchestration = one automation run. Scope a run id (on whichever thread runs + // orchestrate — request thread for sync, stream-executor for streaming) so every tool + // sub-step it dispatches via PolicyExecutor → InternalApiClient groups into one charge. + try (AutomationRunContext.Scope ignored = + AutomationRunContext.open(UUID.randomUUID().toString())) { - WorkflowTurnRequest initialRequest = new WorkflowTurnRequest(); - initialRequest.setUserMessage(request.getUserMessage().trim()); - initialRequest.setFiles(files); - initialRequest.setConversationHistory(new ArrayList<>(request.getConversationHistory())); - initialRequest.setEnabledEndpoints(endpointResolver.getEnabledEndpointUrls()); - listener.onProgress(AiWorkflowProgressEvent.of(AiWorkflowPhase.ANALYZING)); + // Key by opaque file id, not filename. Filenames aren't guaranteed unique across an + // upload (users can rotate the same 'scan.pdf' twice), and the engine identifies files + // by id in every response shape that asks Java to look a file up again. + Map filesById = new LinkedHashMap<>(); + List files = new ArrayList<>(); + for (AiWorkflowFileInput fileInput : request.getFileInputs()) { + MultipartFile multipartFile = fileInput.getFileInput(); + AiFile aiFile = + new AiFile( + fileIdStrategy.idFor(multipartFile), + multipartFile.getOriginalFilename()); + filesById.put(aiFile.getId(), multipartFile); + files.add(aiFile); + } - WorkflowState state = new WorkflowState.Pending(initialRequest); - while (state instanceof WorkflowState.Pending pending) { - state = advance(pending.request(), filesById, listener); + WorkflowTurnRequest initialRequest = new WorkflowTurnRequest(); + initialRequest.setUserMessage(request.getUserMessage().trim()); + initialRequest.setFiles(files); + initialRequest.setConversationHistory( + new ArrayList<>(request.getConversationHistory())); + initialRequest.setEnabledEndpoints(endpointResolver.getEnabledEndpointUrls()); + listener.onProgress(AiWorkflowProgressEvent.of(AiWorkflowPhase.ANALYZING)); + + WorkflowState state = new WorkflowState.Pending(initialRequest); + while (state instanceof WorkflowState.Pending pending) { + state = advance(pending.request(), filesById, listener); + } + return ((WorkflowState.Terminal) state).response(); } - return ((WorkflowState.Terminal) state).response(); } private WorkflowState advance( diff --git a/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateController.java b/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateController.java index 371060c032..28bbfbdea0 100644 --- a/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateController.java +++ b/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateController.java @@ -117,7 +117,8 @@ public class AiCreateController { user.getTeam().getId(), source, ProcessType.SINGLE_TOOL, - BillingCategory.AI); + BillingCategory.AI, + null); jobChargeService.chargeStandalone(ctx, 1); } catch (RuntimeException e) { log.warn( diff --git a/app/saas/src/main/java/stirling/software/saas/payg/api/PaygWalletController.java b/app/saas/src/main/java/stirling/software/saas/payg/api/PaygWalletController.java index 90616bea55..18da81ae2b 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/api/PaygWalletController.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/api/PaygWalletController.java @@ -173,7 +173,9 @@ public class PaygWalletController { int spend = clampToInt(snap.periodSpendUnits()); Integer limit = snap.periodCapUnits() != null ? clampToInt(snap.periodCapUnits()) : null; - CategoryBreakdown breakdown = buildBreakdown(teamId, snap.periodStart(), snap.periodEnd()); + BreakdownPair breakdowns = buildBreakdowns(teamId, snap.periodStart(), snap.periodEnd()); + UsageAnalytics analytics = + buildUsageAnalytics(teamId, snap.periodStart(), snap.periodEnd()); // Estimated bill = paid (Stripe-metered) docs this period × rate — the free portion was // already netted out at charge time, so this is the metered total, not spend − grant. @@ -203,30 +205,63 @@ public class PaygWalletController { noCap, billing.subscriptionId(), spend, - breakdown, + breakdowns.units(), members, - buildActivity(teamId)); + buildActivity(teamId), + breakdowns.docs(), + analytics.docsProcessed(), + analytics.uniquePdfs(), + analytics.sizeMultiplierPdfs()); return ResponseEntity.ok(body); } - private CategoryBreakdown buildBreakdown( + /** Per-category size-scaled units + input-file counts for the same window. */ + private record BreakdownPair(CategoryBreakdown units, CategoryBreakdown docs) {} + + /** Period usage analytics: total input files, unique PDFs, and size-multiplier files. */ + private record UsageAnalytics(int docsProcessed, int uniquePdfs, int sizeMultiplierPdfs) {} + + private BreakdownPair buildBreakdowns( Long teamId, LocalDateTime periodStart, LocalDateTime periodEnd) { - Map byCategory = new HashMap<>(); + Map units = new HashMap<>(); + Map docs = new HashMap<>(); for (Object[] row : - ledgerRepo.sumPeriodAmountByCategory( + ledgerRepo.sumPeriodByCategoryWithDocs( teamId, LedgerEntryType.DEBIT, periodStart, periodEnd)) { - if (row.length >= 2 - && row[0] instanceof BillingCategory cat - && row[1] instanceof Number n) { - byCategory.put(cat, n.longValue()); + if (row.length >= 3 && row[0] instanceof BillingCategory cat) { + if (row[1] instanceof Number u) { + units.put(cat, u.longValue()); + } + if (row[2] instanceof Number d) { + docs.put(cat, d.longValue()); + } } } + return new BreakdownPair(categoryBreakdown(units), categoryBreakdown(docs)); + } + + private static CategoryBreakdown categoryBreakdown(Map byCategory) { return new CategoryBreakdown( clampToInt(byCategory.getOrDefault(BillingCategory.API, 0L)), clampToInt(byCategory.getOrDefault(BillingCategory.AI, 0L)), clampToInt(byCategory.getOrDefault(BillingCategory.AUTOMATION, 0L))); } + private UsageAnalytics buildUsageAnalytics( + Long teamId, LocalDateTime periodStart, LocalDateTime periodEnd) { + List rows = + ledgerRepo.periodUsageAnalytics( + teamId, LedgerEntryType.DEBIT, periodStart, periodEnd); + Object[] row = rows.isEmpty() ? null : rows.get(0); + return new UsageAnalytics(analyticsInt(row, 0), analyticsInt(row, 1), analyticsInt(row, 2)); + } + + private static int analyticsInt(Object[] row, int idx) { + return row != null && row.length > idx && row[idx] instanceof Number n + ? clampToInt(n.longValue()) + : 0; + } + /** * Latest ledger entries shaped for the FE activity feed. DEBITs read as usage, REFUNDs as * credits-back; system entries without a category render as {@code other}. @@ -446,6 +481,10 @@ public class PaygWalletController { 0, new CategoryBreakdown(0, 0, 0), List.of(), - Collections.emptyList()); + Collections.emptyList(), + new CategoryBreakdown(0, 0, 0), + 0, + 0, + 0); } } diff --git a/app/saas/src/main/java/stirling/software/saas/payg/api/WalletSnapshotResponse.java b/app/saas/src/main/java/stirling/software/saas/payg/api/WalletSnapshotResponse.java index 0b29347c85..8147bd030c 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/api/WalletSnapshotResponse.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/api/WalletSnapshotResponse.java @@ -75,7 +75,21 @@ public record WalletSnapshotResponse( int spendUnitsThisPeriod, CategoryBreakdown categoryBreakdown, List members, - List recent) { + List recent, + CategoryBreakdown categoryDocs, + int docsProcessedThisPeriod, + int uniquePdfsThisPeriod, + int sizeMultiplierPdfsThisPeriod) { + + // The count dimension, kept distinct from units (which now scale with file size): + // categoryDocs — per-category INPUT-file counts (parallel to + // categoryBreakdown, + // which stays the size-scaled unit totals) + // docsProcessedThisPeriod — total input files processed this period (Σ doc_count) + // uniquePdfsThisPeriod — distinct input documents (a file hit by N operations counts + // once) + // sizeMultiplierPdfsThisPeriod— input files on charges where the size multiplier applied + // (units billed > input files) /** Per-category breakdown of {@code spendUnitsThisPeriod} for the in-app analytics widget. */ public record CategoryBreakdown(int api, int ai, int automation) {} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/charge/ChargeContext.java b/app/saas/src/main/java/stirling/software/saas/payg/charge/ChargeContext.java index f5914f8536..55553318dd 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/charge/ChargeContext.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/charge/ChargeContext.java @@ -14,13 +14,19 @@ import stirling.software.saas.payg.model.ProcessType; * the interceptor before this context is built. Manual UI tools never reach {@code openProcess} * (they short-circuit on {@link BillingCategory#BYPASSED}); any context constructed here therefore * carries one of {@code API}, {@code AI}, or {@code AUTOMATION}. + * + *

{@code runId} is the automation-run correlation id ({@code X-Stirling-Run-Id}) when this call + * is a sub-step of a pipeline / policy / AI-workflow run, else {@code null} (a standalone tool + * call). Lineage joins are scoped to a single run id: a null run id never joins (each standalone + * call is its own charge), and two separate runs never merge even on identical bytes. */ public record ChargeContext( Long ownerUserId, Long ownerTeamId, JobSource source, ProcessType processType, - BillingCategory billingCategory) { + BillingCategory billingCategory, + String runId) { public ChargeContext { if (ownerUserId == null) { @@ -36,4 +42,17 @@ public record ChargeContext( throw new IllegalArgumentException("billingCategory is required"); } } + + /** + * Convenience for callers with no automation-run context — a standalone tool call ({@code + * runId} = {@code null}, so it never lineage-joins and is always its own charge). + */ + public ChargeContext( + Long ownerUserId, + Long ownerTeamId, + JobSource source, + ProcessType processType, + BillingCategory billingCategory) { + this(ownerUserId, ownerTeamId, source, processType, billingCategory, null); + } } diff --git a/app/saas/src/main/java/stirling/software/saas/payg/charge/JobChargeService.java b/app/saas/src/main/java/stirling/software/saas/payg/charge/JobChargeService.java index ea3bb9cdab..b71caddbee 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/charge/JobChargeService.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/charge/JobChargeService.java @@ -113,7 +113,8 @@ public class JobChargeService { ctx.source(), ctx.processType(), policy.getId(), - stepLimit); + stepLimit, + ctx.runId()); List paths = inputs.stream().map(JobInput::path).toList(); JoinOrOpenResult result = jobService.joinOrOpen(jobCtx, paths); @@ -122,14 +123,23 @@ public class JobChargeService { return new ChargeOutcome(result.job().getId(), 0, ChargeOutcome.Disposition.JOINED); } + ProcessingJob job = result.job(); int units = computeUnits(inputs, policy); - result.job().setDocUnits(units); + job.setDocUnits(units); int freeUsed = consumeFreeGrant(ctx, units); - recordShadowRow(ctx, result.job().getId(), policy.getId(), units, freeUsed); - recordLedgerDebit(ctx, result.job().getId(), policy.getId(), units); + recordShadowRow(ctx, job.getId(), policy.getId(), units, freeUsed); + // doc_count + fingerprint were set on the fresh job by JobService.openFresh; carry them + // onto the ledger DEBIT so usage analytics query one table. + recordLedgerDebit( + ctx, + job.getId(), + policy.getId(), + units, + job.getDocCount(), + job.getDocumentFingerprint()); - return new ChargeOutcome(result.job().getId(), units, ChargeOutcome.Disposition.OPENED); + return new ChargeOutcome(job.getId(), units, ChargeOutcome.Disposition.OPENED); } /** @@ -164,12 +174,19 @@ public class JobChargeService { ctx.source(), ctx.processType(), policy.getId(), - stepLimit); + stepLimit, + ctx.runId()); ProcessingJob job = jobService.open(jobCtx, chargeUnits); int freeUsed = consumeFreeGrant(ctx, chargeUnits); recordShadowRow(ctx, job.getId(), policy.getId(), chargeUnits, freeUsed); - recordLedgerDebit(ctx, job.getId(), policy.getId(), chargeUnits); + recordLedgerDebit( + ctx, + job.getId(), + policy.getId(), + chargeUnits, + job.getDocCount(), + job.getDocumentFingerprint()); // Close immediately — nothing will lineage-join a standalone job — so the paid portion // meters via the same afterCommit hook + idempotency key as a normal process completion. @@ -216,7 +233,12 @@ public class JobChargeService { * Skipped for {@code BYPASSED} / uncategorised calls — manual UI work is never billed. */ private void recordLedgerDebit( - ChargeContext ctx, java.util.UUID jobId, Long policyId, int units) { + ChargeContext ctx, + java.util.UUID jobId, + Long policyId, + int units, + int docCount, + String documentFingerprint) { BillingCategory category = ctx.billingCategory(); if (category == null || category == BillingCategory.BYPASSED) { return; @@ -231,6 +253,10 @@ public class JobChargeService { entry.setReferenceId(jobId.toString()); entry.setPolicyId(policyId); entry.setBillingCategory(category); + // Count dimension + input fingerprint, denormalised from the job for usage analytics + // (PDFs processed, unique PDFs, size-multiplier average). + entry.setDocCount(docCount); + entry.setDocumentFingerprint(documentFingerprint); ledgerRepository.save(entry); } diff --git a/app/saas/src/main/java/stirling/software/saas/payg/filter/PaygChargeInterceptor.java b/app/saas/src/main/java/stirling/software/saas/payg/filter/PaygChargeInterceptor.java index eda04a4789..b758c89787 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/filter/PaygChargeInterceptor.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/filter/PaygChargeInterceptor.java @@ -33,6 +33,7 @@ import jakarta.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; import stirling.software.common.annotations.AutoJobPostMapping; +import stirling.software.common.service.AutomationRunContext; import stirling.software.common.util.TempFile; import stirling.software.common.util.TempFileManager; import stirling.software.proprietary.security.database.repository.UserRepository; @@ -284,13 +285,25 @@ public class PaygChargeInterceptor implements AsyncHandlerInterceptor { request.setAttribute(ATTR_INPUT_BYTES, totalInputBytes); request.setAttribute(ATTR_TOOL_ID, resolveToolId(request)); + // Automation-run correlation id, honoured ONLY from an internal automation dispatch. + // InternalApiClient stamps X-Stirling-Automation on every loopback sub-step alongside the + // run id, so a genuine pipeline / policy / AI run always carries both. A raw external + // request that sets X-Stirling-Run-Id on its own is ignored (each such call stays its own + // charge): otherwise an API caller could pin a constant run id to collapse separate + // same-content calls into one charge, defeating "charge per API call". Null → standalone. + String headerRunId = request.getHeader(AutomationRunContext.RUN_ID_HEADER); + String runId = + (hasAutomationHeader(request) && headerRunId != null && !headerRunId.isBlank()) + ? headerRunId + : null; ChargeContext ctx = new ChargeContext( currentUser.getId(), currentUser.getTeam() == null ? null : currentUser.getTeam().getId(), determineSource(request, auth), ProcessType.SINGLE_TOOL, - category); + category, + runId); ChargeOutcome outcome; try { @@ -498,9 +511,20 @@ public class PaygChargeInterceptor implements AsyncHandlerInterceptor { } } + /** + * True when the request carries the internal-dispatch marker InternalApiClient stamps on every + * loopback sub-step ({@code X-Stirling-Automation: true}). This is the trust boundary for both + * the AUTOMATION billing category and for honouring {@code X-Stirling-Run-Id}: an external + * caller can't group charges via a run id without also declaring itself automation (which + * changes its own billing category). + */ + private static boolean hasAutomationHeader(HttpServletRequest request) { + String header = request.getHeader(AUTOMATION_HEADER); + return header != null && "true".equalsIgnoreCase(header.trim()); + } + private static JobSource determineSource(HttpServletRequest request, Authentication auth) { - String automationHeader = request.getHeader(AUTOMATION_HEADER); - if (automationHeader != null && "true".equalsIgnoreCase(automationHeader.trim())) { + if (hasAutomationHeader(request)) { return JobSource.PIPELINE; } String desktopHeader = request.getHeader(DESKTOP_CLIENT_HEADER); @@ -527,8 +551,7 @@ public class PaygChargeInterceptor implements AsyncHandlerInterceptor { */ private static BillingCategory determineCategory( HandlerMethod handler, HttpServletRequest request, Authentication auth) { - String automationHeader = request.getHeader(AUTOMATION_HEADER); - if (automationHeader != null && "true".equalsIgnoreCase(automationHeader.trim())) { + if (hasAutomationHeader(request)) { return BillingCategory.AUTOMATION; } RequiresFeature ann = diff --git a/app/saas/src/main/java/stirling/software/saas/payg/instance/InstanceUsageIngestService.java b/app/saas/src/main/java/stirling/software/saas/payg/instance/InstanceUsageIngestService.java index 291a46880c..c672540870 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/instance/InstanceUsageIngestService.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/instance/InstanceUsageIngestService.java @@ -116,7 +116,8 @@ public class InstanceUsageIngestService { teamId, JobSource.LINKED_INSTANCE, ProcessType.SINGLE_TOOL, - category), + category, + null), units); } if (row == null) { diff --git a/app/saas/src/main/java/stirling/software/saas/payg/job/JobContext.java b/app/saas/src/main/java/stirling/software/saas/payg/job/JobContext.java index 7840effa26..a025bc5c57 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/job/JobContext.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/job/JobContext.java @@ -15,7 +15,8 @@ public record JobContext( JobSource source, ProcessType processType, Long policyId, - int stepLimit) { + int stepLimit, + String runId) { public JobContext { if (ownerUserId == null) { @@ -34,4 +35,19 @@ public record JobContext( throw new IllegalArgumentException("stepLimit must be > 0"); } } + + /** + * Convenience for callers with no automation-run context — a standalone tool call ({@code + * runId} = {@code null}, so {@code joinOrOpen} always opens a fresh process rather than + * lineage-joining). + */ + public JobContext( + Long ownerUserId, + Long ownerTeamId, + JobSource source, + ProcessType processType, + Long policyId, + int stepLimit) { + this(ownerUserId, ownerTeamId, source, processType, policyId, stepLimit, null); + } } diff --git a/app/saas/src/main/java/stirling/software/saas/payg/job/JobService.java b/app/saas/src/main/java/stirling/software/saas/payg/job/JobService.java index 5addb5b76d..ab4739be6b 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/job/JobService.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/job/JobService.java @@ -1,12 +1,16 @@ package stirling.software.saas.payg.job; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.time.Duration; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Comparator; import java.util.HashMap; +import java.util.HexFormat; import java.util.List; import java.util.Map; import java.util.Objects; @@ -97,8 +101,14 @@ public class JobService { signaturesByInput.put(input, detector.extractSignatures(input)); } + // Lineage joins are scoped to one automation run: a standalone call (no run id) never + // joins — each is its own charge — and within a run, matching still runs by content so a + // run's separate input files each open their own charge (a merge of N inputs = N charges), + // while a single file's chain of steps + its split outputs collapse into one. Optional bestMatch = - findBestMatch(ctx.ownerUserId(), inputs, signaturesByInput); + ctx.runId() == null + ? Optional.empty() + : findBestMatch(ctx.ownerUserId(), ctx.runId(), inputs, signaturesByInput); if (bestMatch.isPresent()) { ProcessingJob existing = @@ -212,10 +222,13 @@ public class JobService { } private Optional findBestMatch( - Long userId, List inputs, Map> signaturesByInput) { + Long userId, + String runId, + List inputs, + Map> signaturesByInput) { List matches = new ArrayList<>(inputs.size()); for (Path input : inputs) { - detector.detect(userId, signaturesByInput.get(input)).ifPresent(matches::add); + detector.detect(userId, runId, signaturesByInput.get(input)).ifPresent(matches::add); } return matches.stream().max(Comparator.comparing(LineageMatch::jobLastStepAt)); } @@ -252,6 +265,11 @@ public class JobService { fresh.setProcessType(ctx.processType()); fresh.setSource(ctx.source()); fresh.setPolicyId(ctx.policyId()); + fresh.setRunId(ctx.runId()); + // doc_count = number of input files (the count dimension). A merge (N inputs in one call) + // is N; a split (1 input) is 1; a standalone bookkeeping job (no inputs) is 1. + fresh.setDocCount(Math.max(1, signaturesByInput.size())); + fresh.setDocumentFingerprint(computeFingerprint(signaturesByInput)); fresh.setStepCount(1); LocalDateTime now = LocalDateTime.now(); fresh.setStartedAt(now); @@ -262,6 +280,36 @@ public class JobService { return new JoinOrOpenResult(saved, JoinOrOpenResult.Disposition.OPENED); } + /** + * Stable fingerprint of this job's input set — SHA-256 over the sorted union of the inputs' + * lineage storage keys. {@code COUNT(DISTINCT ...)} over these gives "unique PDFs processed". + * {@code null} when there are no inputs (a standalone bookkeeping job, e.g. an AI Create + * session) — such jobs still count toward doc_count but aren't a distinct input PDF. + */ + private static String computeFingerprint(Map> signaturesByInput) { + List keys = + signaturesByInput.values().stream() + .flatMap(Set::stream) + .map(LineageSignature::asStorageKey) + .distinct() + .sorted() + .toList(); + if (keys.isEmpty()) { + return null; + } + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + for (String key : keys) { + md.update(key.getBytes(StandardCharsets.UTF_8)); + md.update((byte) 0); + } + return HexFormat.of().formatHex(md.digest()); + } catch (NoSuchAlgorithmException e) { + // SHA-256 is guaranteed on every JRE; fall back to no fingerprint if it ever isn't. + return null; + } + } + private void recordAllInputs(UUID jobId, Map> signaturesByInput) { for (Set signatures : signaturesByInput.values()) { detector.record(jobId, signatures, ArtifactKind.INPUT); diff --git a/app/saas/src/main/java/stirling/software/saas/payg/job/ProcessingJob.java b/app/saas/src/main/java/stirling/software/saas/payg/job/ProcessingJob.java index d80bad16d4..08b73db6ac 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/job/ProcessingJob.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/job/ProcessingJob.java @@ -62,6 +62,27 @@ public class ProcessingJob implements Serializable { @Column(name = "doc_units", nullable = false) private Integer docUnits = 0; + /** + * Number of input files this charge represents — the count dimension, kept distinct from {@link + * #docUnits} (which scales with file size). A split (1 input → many outputs) stays 1; a merge + * (N inputs → 1 output) is N. Fixed at open; joined steps never change it. + * + *

The {@code columnDefinition} default keeps the ddl-auto ADD COLUMN safe on an + * already-populated {@code processing_job} (a bare {@code NOT NULL} add is rejected by Postgres + * on a non-empty table). + */ + @Column(name = "doc_count", nullable = false, columnDefinition = "integer not null default 1") + private Integer docCount = 1; + + /** + * Correlation id of the automation run that opened this job ({@code X-Stirling-Run-Id}), or + * {@code null} for a standalone tool call. Lineage joins are scoped to a single run id, so two + * separate runs never merge even on identical bytes; a null run id never joins (each standalone + * call is its own charge). + */ + @Column(name = "run_id", length = 64) + private String runId; + @Column(name = "step_count", nullable = false) private Integer stepCount = 0; diff --git a/app/saas/src/main/java/stirling/software/saas/payg/lineage/DefaultHashLineageDetector.java b/app/saas/src/main/java/stirling/software/saas/payg/lineage/DefaultHashLineageDetector.java index 082054a09f..c9b939b975 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/lineage/DefaultHashLineageDetector.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/lineage/DefaultHashLineageDetector.java @@ -73,6 +73,21 @@ public class DefaultHashLineageDetector implements HashLineageDetector { return store.findOpenJobForSignatures(userId, signatures, workflowWindow); } + @Override + public Optional detect( + Long userId, String runId, Set signatures) { + Objects.requireNonNull(userId, "userId"); + Objects.requireNonNull(signatures, "signatures"); + if (runId == null) { + // No run context → standalone call; never lineage-joins (each is its own charge). + return Optional.empty(); + } + if (signatures.isEmpty()) { + return Optional.empty(); + } + return store.findOpenJobForSignatures(userId, signatures, workflowWindow, runId); + } + @Override public void record(UUID jobId, Path file, ArtifactKind kind) throws IOException { Objects.requireNonNull(file, "file"); diff --git a/app/saas/src/main/java/stirling/software/saas/payg/lineage/HashLineageDetector.java b/app/saas/src/main/java/stirling/software/saas/payg/lineage/HashLineageDetector.java index 66559eaf40..2973ad8cf0 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/lineage/HashLineageDetector.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/lineage/HashLineageDetector.java @@ -50,6 +50,17 @@ public interface HashLineageDetector { */ Optional detect(Long userId, Set signatures); + /** + * Run-scoped detect: as {@link #detect(Long, Set)} but only matches open jobs belonging to the + * automation run {@code runId}, so lineage joins never cross runs (two separate runs on + * identical bytes stay distinct charges). The default ignores {@code runId} (for simple test + * doubles); the production detector overrides it to scope the store lookup. + */ + default Optional detect( + Long userId, String runId, Set signatures) { + return detect(userId, signatures); + } + /** * Same as {@link #record(UUID, Path, ArtifactKind)} but operating on pre-computed signatures. * Empty {@code signatures} is a no-op. diff --git a/app/saas/src/main/java/stirling/software/saas/payg/lineage/JobLineageStore.java b/app/saas/src/main/java/stirling/software/saas/payg/lineage/JobLineageStore.java index 7ab46d9bd0..c970da34f8 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/lineage/JobLineageStore.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/lineage/JobLineageStore.java @@ -34,6 +34,19 @@ public interface JobLineageStore { Optional findOpenJobForSignatures( Long userId, Set candidates, Duration workflowWindow); + /** + * Run-scoped variant: as {@link #findOpenJobForSignatures(Long, Set, Duration)} but + * additionally constrained to open jobs whose {@code run_id} equals {@code runId}. Lineage + * joins are scoped to a single automation run, so a pipeline's sub-steps group into one charge + * while two separate runs on identical bytes stay distinct. The default delegates to the + * unscoped lookup (for test doubles that don't model run ids); the production store overrides + * it with a filtered query. + */ + default Optional findOpenJobForSignatures( + Long userId, Set candidates, Duration workflowWindow, String runId) { + return findOpenJobForSignatures(userId, candidates, workflowWindow); + } + /** Deletes records created before {@code cutoff}. Returns the number of rows removed. */ int pruneOlderThan(Instant cutoff); } diff --git a/app/saas/src/main/java/stirling/software/saas/payg/lineage/JpaJobLineageStore.java b/app/saas/src/main/java/stirling/software/saas/payg/lineage/JpaJobLineageStore.java index 4da4cd0b52..09df6ed12f 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/lineage/JpaJobLineageStore.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/lineage/JpaJobLineageStore.java @@ -78,6 +78,24 @@ public class JpaJobLineageStore implements JobLineageStore { return matches.isEmpty() ? Optional.empty() : Optional.of(matches.get(0)); } + @Override + public Optional findOpenJobForSignatures( + Long userId, Set candidates, Duration workflowWindow, String runId) { + Objects.requireNonNull(userId, "userId"); + Objects.requireNonNull(candidates, "candidates"); + Objects.requireNonNull(workflowWindow, "workflowWindow"); + Objects.requireNonNull(runId, "runId"); + if (candidates.isEmpty()) { + return Optional.empty(); + } + List storageKeys = candidates.stream().map(LineageSignature::asStorageKey).toList(); + LocalDateTime since = LocalDateTime.now().minus(workflowWindow); + List matches = + hashRepository.findOpenJobsForSignaturesInRun( + userId, JobStatus.OPEN, since, storageKeys, runId, Limit.of(1)); + return matches.isEmpty() ? Optional.empty() : Optional.of(matches.get(0)); + } + @Override @Transactional public int pruneOlderThan(Instant cutoff) { diff --git a/app/saas/src/main/java/stirling/software/saas/payg/repository/JobArtifactHashRepository.java b/app/saas/src/main/java/stirling/software/saas/payg/repository/JobArtifactHashRepository.java index 0c3af74dbe..7b02dbaebc 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/repository/JobArtifactHashRepository.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/repository/JobArtifactHashRepository.java @@ -43,6 +43,29 @@ public interface JobArtifactHashRepository @Param("signatures") Collection signatures, Limit limit); + /** + * Run-scoped lineage lookup: as {@link #findOpenJobsForSignatures} but additionally requires + * {@code j.run_id = :runId}, so only jobs belonging to the same automation run can be joined. + */ + @Query( + "SELECT new stirling.software.saas.payg.lineage.LineageMatch(" + + " h.id.jobId, h.id.kind, j.lastStepAt)" + + " FROM JobArtifactHash h" + + " JOIN ProcessingJob j ON j.id = h.id.jobId" + + " WHERE j.ownerUserId = :userId" + + " AND j.status = :openStatus" + + " AND j.lastStepAt > :since" + + " AND j.runId = :runId" + + " AND h.id.contentHash IN :signatures" + + " ORDER BY j.lastStepAt DESC") + List findOpenJobsForSignaturesInRun( + @Param("userId") Long userId, + @Param("openStatus") JobStatus openStatus, + @Param("since") LocalDateTime since, + @Param("signatures") Collection signatures, + @Param("runId") String runId, + Limit limit); + /** Prunes rows older than {@code cutoff}; run from a scheduled task. */ @Modifying @Query("DELETE FROM JobArtifactHash h WHERE h.createdAt < :cutoff") diff --git a/app/saas/src/main/java/stirling/software/saas/payg/repository/WalletLedgerRepository.java b/app/saas/src/main/java/stirling/software/saas/payg/repository/WalletLedgerRepository.java index 095f6cb924..f307d54241 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/repository/WalletLedgerRepository.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/repository/WalletLedgerRepository.java @@ -18,14 +18,13 @@ public interface WalletLedgerRepository extends JpaRepository findTop20ByTeamIdOrderByIdDesc(Long teamId); /** - * Per-category debit totals over an arbitrary window, as positive units. Replaces the - * calendar-month {@code wallet_category_summary} view on the wallet endpoint — subscribed - * teams' billing windows are anchored to the Stripe subscription period, not month starts. Rows - * with {@code NULL} category (system entries) are excluded; BYPASSED never reaches the ledger - * by construction. + * Per-category debit totals with BOTH the size-scaled unit sum and the input-file count ({@code + * doc_count}) over a window. Rows: {@code [category, units, docs]}. Lets the wallet show, per + * category, "X PDFs · Y meter units" rather than conflating the two. */ @Query( - "SELECT e.billingCategory AS category, COALESCE(SUM(-e.amountUnits), 0) AS units" + "SELECT e.billingCategory AS category, COALESCE(SUM(-e.amountUnits), 0) AS units," + + " COALESCE(SUM(e.docCount), 0) AS docs" + " FROM WalletLedgerEntry e" + " WHERE e.teamId = :teamId" + " AND e.entryType = :entryType" @@ -33,7 +32,36 @@ public interface WalletLedgerRepository extends JpaRepository= :periodStart" + " AND e.occurredAt < :periodEnd" + " GROUP BY e.billingCategory") - List sumPeriodAmountByCategory( + List sumPeriodByCategoryWithDocs( + @Param("teamId") Long teamId, + @Param("entryType") LedgerEntryType entryType, + @Param("periodStart") LocalDateTime periodStart, + @Param("periodEnd") LocalDateTime periodEnd); + + /** + * Period usage analytics in one row: {@code [docsProcessed, uniquePdfs, sizeMultiplierPdfs]}. + * {@code docsProcessed} sums input-file counts; {@code uniquePdfs} counts distinct input + * fingerprints (a file hit by N operations counts once); {@code sizeMultiplierPdfs} sums the + * input files on charges where the size multiplier kicked in (units billed > input files). + * DEBIT + non-null category only. + * + *

Returns a single-element {@code List} (aggregate-only query → always one row). Declared as + * {@code List} rather than {@code Object[]}: Spring Data treats an {@code Object[]} + * return as a collection and hands back {@code Object[]{ row }}, so the caller would + * read the columns one level too deep — take {@code get(0)}. + */ + @Query( + "SELECT COALESCE(SUM(e.docCount), 0) AS docs," + + " COUNT(DISTINCT e.documentFingerprint) AS uniquePdfs," + + " COALESCE(SUM(CASE WHEN (-e.amountUnits) > e.docCount THEN e.docCount ELSE 0" + + " END), 0) AS sizeMultiplierPdfs" + + " FROM WalletLedgerEntry e" + + " WHERE e.teamId = :teamId" + + " AND e.entryType = :entryType" + + " AND e.billingCategory IS NOT NULL" + + " AND e.occurredAt >= :periodStart" + + " AND e.occurredAt < :periodEnd") + List periodUsageAnalytics( @Param("teamId") Long teamId, @Param("entryType") LedgerEntryType entryType, @Param("periodStart") LocalDateTime periodStart, diff --git a/app/saas/src/main/java/stirling/software/saas/payg/wallet/WalletLedgerEntry.java b/app/saas/src/main/java/stirling/software/saas/payg/wallet/WalletLedgerEntry.java index 4971e296f3..fab6647b2c 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/wallet/WalletLedgerEntry.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/wallet/WalletLedgerEntry.java @@ -74,6 +74,26 @@ public class WalletLedgerEntry implements Serializable { @Column(name = "policy_id") private Long policyId; + /** + * Number of input files this entry billed (the count dimension, distinct from size-scaled + * {@link #amountUnits}). Denormalised from {@code processing_job.doc_count} so usage analytics + * — "PDFs processed" — sum one table. Defaults to 1; system/aggregate entries may leave it 1. + * + *

The {@code columnDefinition} default keeps the ddl-auto ADD COLUMN safe on an + * already-populated {@code wallet_ledger} (a bare {@code NOT NULL} add is rejected by Postgres + * on a non-empty table). + */ + @Column(name = "doc_count", nullable = false, columnDefinition = "integer not null default 1") + private Integer docCount = 1; + + /** + * SHA-256 of this entry's input file set; {@code COUNT(DISTINCT ...)} over a period gives + * unique PDFs processed. {@code null} for aggregate/system entries (grants, linked-instance + * sync) that don't map to a single document set. + */ + @Column(name = "document_fingerprint", length = 64) + private String documentFingerprint; + @Column(name = "stripe_event_id", length = 128) private String stripeEventId; diff --git a/app/saas/src/main/resources/db/migration/saas/V38__payg_run_grouping_doc_count.sql b/app/saas/src/main/resources/db/migration/saas/V38__payg_run_grouping_doc_count.sql new file mode 100644 index 0000000000..bc1a4e0f16 --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V38__payg_run_grouping_doc_count.sql @@ -0,0 +1,39 @@ +-- PAYG size-scaled billing: run-scoped grouping + per-input-file counting. +-- +-- Two independent axes now live on a charge: +-- * doc_units — billing quantity, scales with file size (existing column) +-- * doc_count — number of INPUT files (the "unique PDFs" dimension); a split (1→many) stays 1, +-- a merge (N→1) is N. Fixed at open; joined steps never change it. +-- Plus run_id, the automation-run correlation id used to group a run's tool sub-steps into one +-- charge (replacing the old content+time-window grouping) and to keep two separate runs distinct. +-- +-- Everything is additive; no existing rows are modified, no columns dropped. + +-- ── processing_job ─────────────────────────────────────────────────────────── +ALTER TABLE processing_job ADD COLUMN IF NOT EXISTS run_id VARCHAR(64); +ALTER TABLE processing_job ADD COLUMN IF NOT EXISTS doc_count INTEGER NOT NULL DEFAULT 1; + +COMMENT ON COLUMN processing_job.run_id IS + 'Automation-run correlation id (X-Stirling-Run-Id); NULL for a standalone tool call. Lineage ' + 'joins are scoped to one run_id, so separate runs never merge even on identical bytes.'; +COMMENT ON COLUMN processing_job.doc_count IS + 'Number of input files this charge represents (the count dimension, distinct from size-scaled ' + 'doc_units). Split=1, merge=N. Fixed at open.'; + +-- ── wallet_ledger ──────────────────────────────────────────────────────────── +-- Denormalise the count dimension + input fingerprint onto the DEBIT row so usage analytics +-- (unique PDFs, per-category counts, size-multiplier average) query one table and survive +-- processing_job pruning. +ALTER TABLE wallet_ledger ADD COLUMN IF NOT EXISTS doc_count INTEGER NOT NULL DEFAULT 1; +ALTER TABLE wallet_ledger ADD COLUMN IF NOT EXISTS document_fingerprint VARCHAR(64); + +COMMENT ON COLUMN wallet_ledger.doc_count IS + 'Input-file count for this entry (mirrors processing_job.doc_count); summed for "PDFs processed".'; +COMMENT ON COLUMN wallet_ledger.document_fingerprint IS + 'SHA-256 of the entry''s input file set; COUNT(DISTINCT ...) gives unique PDFs. NULL for ' + 'aggregate/system entries (e.g. linked-instance sync).'; + +-- Distinct-PDF + size-multiplier queries scan by team + period. +CREATE INDEX IF NOT EXISTS idx_wallet_ledger_team_period_fp + ON wallet_ledger (team_id, occurred_at, document_fingerprint) + WHERE document_fingerprint IS NOT NULL; diff --git a/app/saas/src/test/java/stirling/software/saas/payg/api/PaygWalletControllerTest.java b/app/saas/src/test/java/stirling/software/saas/payg/api/PaygWalletControllerTest.java index 0e02c0ce50..1625116af0 100644 --- a/app/saas/src/test/java/stirling/software/saas/payg/api/PaygWalletControllerTest.java +++ b/app/saas/src/test/java/stirling/software/saas/payg/api/PaygWalletControllerTest.java @@ -127,9 +127,11 @@ class PaygWalletControllerTest { } private void stubEmptyLedgerReads(long teamId) { - when(ledgerRepo.sumPeriodAmountByCategory( + when(ledgerRepo.sumPeriodByCategoryWithDocs( eq(teamId), eq(LedgerEntryType.DEBIT), any(), any())) .thenReturn(List.of()); + when(ledgerRepo.periodUsageAnalytics(eq(teamId), eq(LedgerEntryType.DEBIT), any(), any())) + .thenReturn(List.of(new Object[] {0L, 0L, 0L})); when(ledgerRepo.findTop20ByTeamIdOrderByIdDesc(teamId)).thenReturn(List.of()); } @@ -187,12 +189,17 @@ class PaygWalletControllerTest { when(shadowRepo.sumPaidUnits(eq(99L), any(), any())).thenReturn(312L); when(billingService.estimateBillMinor(any(), eq(312L))).thenReturn(Optional.of(624L)); when(entitlementService.getSnapshot(99L)).thenReturn(snapshot(312L, 1250L)); - when(ledgerRepo.sumPeriodAmountByCategory(eq(99L), eq(LedgerEntryType.DEBIT), any(), any())) + // [category, units, docs]: units scale with size, docs = input-file count. + when(ledgerRepo.sumPeriodByCategoryWithDocs( + eq(99L), eq(LedgerEntryType.DEBIT), any(), any())) .thenReturn( List.of( - new Object[] {BillingCategory.API, 110L}, - new Object[] {BillingCategory.AI, 200L}, - new Object[] {BillingCategory.AUTOMATION, 2L})); + new Object[] {BillingCategory.API, 110L, 90L}, + new Object[] {BillingCategory.AI, 200L, 50L}, + new Object[] {BillingCategory.AUTOMATION, 2L, 2L})); + // [docsProcessed, uniquePdfs, sizeMultiplierPdfs] — single-row aggregate as List + when(ledgerRepo.periodUsageAnalytics(eq(99L), eq(LedgerEntryType.DEBIT), any(), any())) + .thenReturn(List.of(new Object[] {142L, 120L, 30L})); when(ledgerRepo.findTop20ByTeamIdOrderByIdDesc(99L)).thenReturn(List.of()); ResponseEntity resp = @@ -214,6 +221,13 @@ class PaygWalletControllerTest { assertThat(body.categoryBreakdown().api()).isEqualTo(110); assertThat(body.categoryBreakdown().ai()).isEqualTo(200); assertThat(body.categoryBreakdown().automation()).isEqualTo(2); + // Count dimension is surfaced separately from the size-scaled units. + assertThat(body.categoryDocs().api()).isEqualTo(90); + assertThat(body.categoryDocs().ai()).isEqualTo(50); + assertThat(body.categoryDocs().automation()).isEqualTo(2); + assertThat(body.docsProcessedThisPeriod()).isEqualTo(142); + assertThat(body.uniquePdfsThisPeriod()).isEqualTo(120); + assertThat(body.sizeMultiplierPdfsThisPeriod()).isEqualTo(30); assertThat(body.stripeSubscriptionId()).isEqualTo("sub_test_99"); // Member role → ledger never queried per-user. verify(ledgerRepo, never()).sumPeriodAmountForMember(any(), any(), any(), any(), any()); diff --git a/app/saas/src/test/java/stirling/software/saas/payg/api/WalletSnapshotResponseTest.java b/app/saas/src/test/java/stirling/software/saas/payg/api/WalletSnapshotResponseTest.java index 65d55ccb86..e8547de268 100644 --- a/app/saas/src/test/java/stirling/software/saas/payg/api/WalletSnapshotResponseTest.java +++ b/app/saas/src/test/java/stirling/software/saas/payg/api/WalletSnapshotResponseTest.java @@ -35,7 +35,11 @@ class WalletSnapshotResponseTest { /* spendUnitsThisPeriod= */ 12, new CategoryBreakdown(5, 4, 3), List.of(new MemberRow("u1", "Ann", "ann@example.com", 8)), - List.of(new ActivityRow(1L, "api", "API usage", "2026-06-02T10:00", 4))); + List.of(new ActivityRow(1L, "api", "API usage", "2026-06-02T10:00", 4)), + /* categoryDocs= */ new CategoryBreakdown(3, 2, 1), + /* docsProcessedThisPeriod= */ 6, + /* uniquePdfsThisPeriod= */ 5, + /* sizeMultiplierPdfsThisPeriod= */ 2); } @Test @@ -107,7 +111,11 @@ class WalletSnapshotResponseTest { 0, new CategoryBreakdown(0, 0, 0), List.of(), - List.of()); + List.of(), + new CategoryBreakdown(0, 0, 0), + 0, + 0, + 0); assertThat(free.billableLimit()).isNull(); assertThat(free.pricePerDocMinor()).isNull(); diff --git a/app/saas/src/test/java/stirling/software/saas/payg/filter/PaygChargeInterceptorTest.java b/app/saas/src/test/java/stirling/software/saas/payg/filter/PaygChargeInterceptorTest.java index 128c336c32..be402dd061 100644 --- a/app/saas/src/test/java/stirling/software/saas/payg/filter/PaygChargeInterceptorTest.java +++ b/app/saas/src/test/java/stirling/software/saas/payg/filter/PaygChargeInterceptorTest.java @@ -434,6 +434,52 @@ class PaygChargeInterceptorTest { .isEqualTo(stirling.software.saas.payg.model.JobSource.PIPELINE); } + @Test + void preHandle_runId_honouredOnlyWithAutomationHeader() throws Exception { + // An internal dispatch carries BOTH X-Stirling-Automation and X-Stirling-Run-Id, so the + // run id flows onto the ChargeContext (sub-steps of one run group into a single charge). + authenticateWithApiKey(makeUser(7L, 42L)); + UUID jobId = UUID.randomUUID(); + when(chargeService.openProcess(any(), anyList())) + .thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED)); + org.mockito.ArgumentCaptor ctxCaptor = + org.mockito.ArgumentCaptor.forClass( + stirling.software.saas.payg.charge.ChargeContext.class); + + MockMultipartHttpServletRequest req = newMultipart(); + req.addFile(new MockMultipartFile("file", "x.pdf", "application/pdf", "abc".getBytes())); + req.addHeader("X-Stirling-Automation", "true"); + req.addHeader("X-Stirling-Run-Id", "run-abc"); + + interceptor.preHandle(req, new MockHttpServletResponse(), handlerMethodForFakeController()); + + verify(chargeService).openProcess(ctxCaptor.capture(), anyList()); + assertThat(ctxCaptor.getValue().runId()).isEqualTo("run-abc"); + } + + @Test + void preHandle_runIdWithoutAutomationHeader_isIgnored() throws Exception { + // A raw external API call that sets X-Stirling-Run-Id on its own must NOT be able to group + // charges — the run id is dropped so each call stays its own charge ("charge per API + // call"). + authenticateWithApiKey(makeUser(7L, 42L)); + UUID jobId = UUID.randomUUID(); + when(chargeService.openProcess(any(), anyList())) + .thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED)); + org.mockito.ArgumentCaptor ctxCaptor = + org.mockito.ArgumentCaptor.forClass( + stirling.software.saas.payg.charge.ChargeContext.class); + + MockMultipartHttpServletRequest req = newMultipart(); + req.addFile(new MockMultipartFile("file", "x.pdf", "application/pdf", "abc".getBytes())); + req.addHeader("X-Stirling-Run-Id", "run-spoofed"); + + interceptor.preHandle(req, new MockHttpServletResponse(), handlerMethodForFakeController()); + + verify(chargeService).openProcess(ctxCaptor.capture(), anyList()); + assertThat(ctxCaptor.getValue().runId()).isNull(); + } + // --- BillingCategory categorisation + bypass fast-path ------------------------------------- @Test diff --git a/app/saas/src/test/java/stirling/software/saas/payg/job/JobServiceTest.java b/app/saas/src/test/java/stirling/software/saas/payg/job/JobServiceTest.java index ca7033405a..880a52da54 100644 --- a/app/saas/src/test/java/stirling/software/saas/payg/job/JobServiceTest.java +++ b/app/saas/src/test/java/stirling/software/saas/payg/job/JobServiceTest.java @@ -183,6 +183,40 @@ class JobServiceTest { assertThat(detector.recorded(result.job().getId(), input, ArtifactKind.INPUT)).isTrue(); } + @Test + void joinOrOpen_nullRunId_neverJoins(@TempDir Path tmp) throws IOException { + // A standalone call (no run id) opens its own charge even when content matches an open + // job — run-scoped grouping only joins within the same automation run. + UUID existingId = UUID.randomUUID(); + Path input = givenFile(tmp, "in.bin"); + detector.willMatch( + input, new LineageMatch(existingId, ArtifactKind.INPUT, LocalDateTime.now())); + + JobContext standalone = + new JobContext( + 42L, 100L, JobSource.API, ProcessType.SINGLE_TOOL, 1L, 10); // null runId + JoinOrOpenResult result = service.joinOrOpen(standalone, List.of(input)); + + assertThat(result.disposition()).isEqualTo(JoinOrOpenResult.Disposition.OPENED); + verify(jobRepo, never()).findById(existingId); + } + + @Test + void joinOrOpen_opened_setsRunIdAndDocCountAndFingerprint(@TempDir Path tmp) + throws IOException { + // Two input files, no match → one fresh charge with doc_count = input-file count (2), + // the run id stamped, and a non-null fingerprint (used for unique-PDF counting). + Path a = givenFile(tmp, "a.bin"); + Path b = givenFile(tmp, "b.bin"); + + JoinOrOpenResult result = service.joinOrOpen(ctx(42L, 100L, 10), List.of(a, b)); + + assertThat(result.disposition()).isEqualTo(JoinOrOpenResult.Disposition.OPENED); + assertThat(result.job().getDocCount()).isEqualTo(2); + assertThat(result.job().getRunId()).isEqualTo("test-run"); + assertThat(result.job().getDocumentFingerprint()).isNotNull(); + } + @Test void joinOrOpen_emptyInputs_throws() { assertThatThrownBy(() -> service.joinOrOpen(ctx(42L, 100L, 10), List.of())) @@ -297,8 +331,10 @@ class JobServiceTest { // --- helpers -------------------------------------------------------------------------------- private static JobContext ctx(long userId, long teamId, int stepLimit) { + // Lineage joins are scoped to an automation run, so the join scenarios below run inside a + // run id. The standalone (null run id) path is covered by joinOrOpen_nullRunId_neverJoins. return new JobContext( - userId, teamId, JobSource.WEB, ProcessType.SINGLE_TOOL, 1L, stepLimit); + userId, teamId, JobSource.WEB, ProcessType.SINGLE_TOOL, 1L, stepLimit, "test-run"); } private static ProcessingJob openJob( diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 29792b0162..2244b6acdc 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -6315,18 +6315,21 @@ managedTitle = "Managed in Stripe" update = "Update" [portal.billing.pdfsProcessed] -emptyPeriod = "No metered processing yet this period." +emptyPeriod = "No processing yet this period." eyebrow = "PDFs processed this period" legendValue_one = "{{formatted}} PDFs" legendValue_other = "{{formatted}} PDFs" -segbarAriaLabel = "Metered PDFs split by category" +segbarAriaLabel = "PDFs split by category" segmentAgentsDesc = "AI agent actions" segmentAgentsLabel = "Agents" segmentApiDesc = "Direct API requests" segmentApiLabel = "API" segmentAutomationDesc = "Automations & pipelines" segmentAutomationLabel = "Automation" -unit = "metered PDFs" +sizeMultiplier = "{{formatted}} PDFs used a size multiplier" +summary = "{{unique}} unique · {{units}} meter units · {{avg}} avg per PDF" +summaryNoRate = "{{unique}} unique · {{units}} meter units" +unit = "PDFs" [portal.billing.spendLimit] adjustLimit = "Adjust limit" @@ -6355,6 +6358,8 @@ label = "Projected to exceed." [portal.billing.spendThisMonth] eyebrow = "Spend this month" +freeRemaining_one = "{{formatted}} free PDF remaining" +freeRemaining_other = "{{formatted}} free PDFs remaining" processed_one = "{{formattedCount}} PDF processed." processed_other = "{{formattedCount}} PDFs processed." processedWithRate_one = "{{formattedCount}} PDF processed, at {{rate}} each." diff --git a/frontend/editor/src/cloud/hooks/useWallet.ts b/frontend/editor/src/cloud/hooks/useWallet.ts index 07e862fb8c..0641535946 100644 --- a/frontend/editor/src/cloud/hooks/useWallet.ts +++ b/frontend/editor/src/cloud/hooks/useWallet.ts @@ -133,7 +133,10 @@ function reuseIfEqual(prev: Wallet | null, next: Wallet): Wallet { prev.capUsd !== next.capUsd || prev.noCap !== next.noCap || prev.stripeSubscriptionId !== next.stripeSubscriptionId || - prev.spendUnitsThisPeriod !== next.spendUnitsThisPeriod + prev.spendUnitsThisPeriod !== next.spendUnitsThisPeriod || + prev.docsProcessedThisPeriod !== next.docsProcessedThisPeriod || + prev.uniquePdfsThisPeriod !== next.uniquePdfsThisPeriod || + prev.sizeMultiplierPdfsThisPeriod !== next.sizeMultiplierPdfsThisPeriod ) { return next; } @@ -143,7 +146,10 @@ function reuseIfEqual(prev: Wallet | null, next: Wallet): Wallet { if ( prev.categoryBreakdown.api !== next.categoryBreakdown.api || prev.categoryBreakdown.ai !== next.categoryBreakdown.ai || - prev.categoryBreakdown.automation !== next.categoryBreakdown.automation + prev.categoryBreakdown.automation !== next.categoryBreakdown.automation || + prev.categoryDocs.api !== next.categoryDocs.api || + prev.categoryDocs.ai !== next.categoryDocs.ai || + prev.categoryDocs.automation !== next.categoryDocs.automation ) { return next; } diff --git a/frontend/editor/src/portal/components/billing/PdfsProcessedCard.stories.tsx b/frontend/editor/src/portal/components/billing/PdfsProcessedCard.stories.tsx index 6f4c46641c..d7da10398b 100644 --- a/frontend/editor/src/portal/components/billing/PdfsProcessedCard.stories.tsx +++ b/frontend/editor/src/portal/components/billing/PdfsProcessedCard.stories.tsx @@ -28,7 +28,7 @@ export const WithUnsynced: Story = { }, }; -/** Nothing metered yet this period — the split hides. */ +/** Nothing processed yet this period — the split + summary hide. */ export const Empty: Story = { args: { wallet: { @@ -36,6 +36,10 @@ export const Empty: Story = { billableUsed: 0, spendUnitsThisPeriod: 0, categoryBreakdown: { api: 0, ai: 0, automation: 0 }, + categoryDocs: { api: 0, ai: 0, automation: 0 }, + docsProcessedThisPeriod: 0, + uniquePdfsThisPeriod: 0, + sizeMultiplierPdfsThisPeriod: 0, }, }, }; diff --git a/frontend/editor/src/portal/components/billing/PdfsProcessedCard.tsx b/frontend/editor/src/portal/components/billing/PdfsProcessedCard.tsx index 00d9806e6a..794ac39ae5 100644 --- a/frontend/editor/src/portal/components/billing/PdfsProcessedCard.tsx +++ b/frontend/editor/src/portal/components/billing/PdfsProcessedCard.tsx @@ -1,19 +1,21 @@ import { useTranslation } from "react-i18next"; import { Card } from "@app/ui"; +import { formatMinor } from "@app/billing"; import type { Wallet, WalletCategoryBreakdown } from "@portal/api/billing"; import type { LocalUsage } from "@portal/api/link"; /** - * "PDFs processed this period" headline + a stacked split of where the metered - * PDFs went. The split reuses the wallet's existing {@code categoryBreakdown} - * (API / Agents / Automation — the same buckets the entitlement service tracks; - * the "AI" bucket surfaces as "Agents" here). Real data only: the bar hides when - * nothing metered has run yet. + * "PDFs processed this period" headline (the input-file count) plus a summary line + * separating that count from the size-scaled meter units, and a stacked split of + * where the PDFs went by category (API / Agents / Automation — the "AI" bucket + * surfaces as "Agents" here), driven by the wallet's per-category {@code categoryDocs} + * counts. Real data only: everything hides when nothing has run this period. * - *

When a linked instance has accrued usage SaaS hasn't billed yet ({@code - * unsynced}), it's folded into the headline + split so "current usage" reflects - * work done since the last daily sync. The synced-vs-pending split is an internal - * detail the customer doesn't need, so it's not surfaced — just the combined total. + *

Instance-local usage a linked instance has accrued but SaaS hasn't billed yet + * ({@code unsynced}) is units-only, so it folds into the meter-units figure (and the + * avg-per-PDF that derives from it) but NOT the PDF count or the per-category split, + * which reflect synced processing. The synced-vs-pending distinction is an internal + * detail, so only the combined unit total is surfaced. */ const SEGMENTS: ReadonlyArray<{ key: keyof WalletCategoryBreakdown; @@ -57,17 +59,31 @@ export function PdfsProcessedCard({ unsynced?: LocalUsage | null; }) { const { t } = useTranslation(); - // Fold instance-local unsynced usage into both the headline and the split, so - // the card shows synced + not-yet-billed work as a single current-usage figure. - const pending = unsynced?.totalUnsyncedUnits ?? 0; - const base = wallet.categoryBreakdown; - const b: WalletCategoryBreakdown = { - api: base.api + (unsynced?.apiUnsyncedUnits ?? 0), - ai: base.ai + (unsynced?.aiUnsyncedUnits ?? 0), - automation: base.automation + (unsynced?.automationUnsyncedUnits ?? 0), - }; - const total = b.api + b.ai + b.automation; - const headline = wallet.billableUsed + pending; + // The count dimension (PDFs) is shown separately from the size-scaled meter + // units. Instance-local unsynced usage (combined-billing) is units-only, so it + // folds into the meter-units figure; the PDF count reflects synced processing. + const pendingUnits = unsynced?.totalUnsyncedUnits ?? 0; + const docs = wallet.docsProcessedThisPeriod; + const uniquePdfs = wallet.uniquePdfsThisPeriod; + const meterUnits = wallet.spendUnitsThisPeriod + pendingUnits; + const sizeMultiplierPdfs = wallet.sizeMultiplierPdfsThisPeriod; + + // Per-category PDF counts drive the split ("this many PDFs ran automation / AI / + // API"); units are surfaced in the aggregate summary line, not per bucket. + const perDocs: WalletCategoryBreakdown = wallet.categoryDocs; + const totalDocs = perDocs.api + perDocs.ai + perDocs.automation; + + // Average cost per PDF in minor currency units — meter units × the per-unit rate, + // spread over the input files processed. Shown only when the rate is known (free-tier + // and unknown-price snapshots omit the term rather than imply $0.00). + const rate = wallet.pricePerDocMinor; + const showAvgCost = docs > 0 && rate != null; + const avgCostMinor = + rate != null && docs > 0 ? (meterUnits / docs) * rate : 0; + + // Something ran once there are either counted PDFs or metered units (instance-local + // unsynced usage is units-only, so it keeps the card out of the empty state). + const hasActivity = docs > 0 || meterUnits > 0; return ( @@ -78,66 +94,99 @@ export function PdfsProcessedCard({ )}

- - {headline.toLocaleString()} - + {docs.toLocaleString()} - {t("portal.billing.pdfsProcessed.unit", "metered PDFs")} + {t("portal.billing.pdfsProcessed.unit", "PDFs")}
- {total > 0 ? ( + {hasActivity ? ( <> -
- {SEGMENTS.map((s) => - b[s.key] > 0 ? ( - - ) : null, - )} -
-
- {SEGMENTS.map((s) => ( -
- - - {t(s.labelKey, s.labelDefault)} - - - {t( - "portal.billing.pdfsProcessed.legendValue", - "{{formatted}} PDFs", - { - count: b[s.key], - formatted: b[s.key].toLocaleString(), - }, - )} - - - {t(s.descKey, s.descDefault)} - +

+ {showAvgCost + ? t( + "portal.billing.pdfsProcessed.summary", + "{{unique}} unique · {{units}} meter units · {{avg}} avg per PDF", + { + unique: uniquePdfs.toLocaleString(), + units: meterUnits.toLocaleString(), + avg: formatMinor(avgCostMinor, wallet.currency), + }, + ) + : t( + "portal.billing.pdfsProcessed.summaryNoRate", + "{{unique}} unique · {{units}} meter units", + { + unique: uniquePdfs.toLocaleString(), + units: meterUnits.toLocaleString(), + }, + )} +

+ {totalDocs > 0 ? ( + <> +
+ {SEGMENTS.map((s) => + perDocs[s.key] > 0 ? ( + + ) : null, + )}
- ))} -
+
+ {SEGMENTS.map((s) => ( +
+ + + {t(s.labelKey, s.labelDefault)} + + + {t( + "portal.billing.pdfsProcessed.legendValue", + "{{formatted}} PDFs", + { + count: perDocs[s.key], + formatted: perDocs[s.key].toLocaleString(), + }, + )} + + + {t(s.descKey, s.descDefault)} + +
+ ))} +
+ + ) : null} + {sizeMultiplierPdfs > 0 ? ( +

+ {t( + "portal.billing.pdfsProcessed.sizeMultiplier", + "{{formatted}} PDFs used a size multiplier", + { formatted: sizeMultiplierPdfs.toLocaleString() }, + )} +

+ ) : null} ) : (

{t( "portal.billing.pdfsProcessed.emptyPeriod", - "No metered processing yet this period.", + "No processing yet this period.", )}

)} diff --git a/frontend/editor/src/portal/components/billing/SpendThisMonthCard.stories.tsx b/frontend/editor/src/portal/components/billing/SpendThisMonthCard.stories.tsx index a47b3df65b..af1ed1faa0 100644 --- a/frontend/editor/src/portal/components/billing/SpendThisMonthCard.stories.tsx +++ b/frontend/editor/src/portal/components/billing/SpendThisMonthCard.stories.tsx @@ -13,3 +13,8 @@ type Story = StoryObj; /** Actual spend + the Enterprise upsell tacked onto the foot. */ export const Default: Story = { args: { wallet: subscribedWallet } }; + +/** Subscribed but still holding leftover lifetime free grant — shows the free-remaining note. */ +export const WithFreeRemaining: Story = { + args: { wallet: { ...subscribedWallet, freeRemaining: 380 } }, +}; diff --git a/frontend/editor/src/portal/components/billing/SpendThisMonthCard.tsx b/frontend/editor/src/portal/components/billing/SpendThisMonthCard.tsx index d4a969ae0c..10aebbe32f 100644 --- a/frontend/editor/src/portal/components/billing/SpendThisMonthCard.tsx +++ b/frontend/editor/src/portal/components/billing/SpendThisMonthCard.tsx @@ -46,6 +46,18 @@ export function SpendThisMonthCard({ wallet }: { wallet: Wallet }) { }, )}

+ {wallet.freeRemaining > 0 ? ( +

+ {t( + "portal.billing.spendThisMonth.freeRemaining", + "{{formatted}} free PDFs remaining", + { + count: wallet.freeRemaining, + formatted: wallet.freeRemaining.toLocaleString(), + }, + )} +

+ ) : null}
diff --git a/frontend/editor/src/portal/components/billing/billing.css b/frontend/editor/src/portal/components/billing/billing.css index 63ea82d748..86e9a3ff94 100644 --- a/frontend/editor/src/portal/components/billing/billing.css +++ b/frontend/editor/src/portal/components/billing/billing.css @@ -116,6 +116,13 @@ margin: 0 0 1rem; } +/* Free grant still offsetting spend — reads as a positive credit note, pulled up + under the "PDFs processed" line. */ +.portal-billing__free-remaining { + color: var(--color-green-600, var(--color-green)); + margin-top: -0.5rem; +} + .portal-billing__skeleton { display: flex; flex-direction: column; diff --git a/frontend/editor/src/portal/components/billing/walletFixtures.ts b/frontend/editor/src/portal/components/billing/walletFixtures.ts index c751f1269a..cd754af3fc 100644 --- a/frontend/editor/src/portal/components/billing/walletFixtures.ts +++ b/frontend/editor/src/portal/components/billing/walletFixtures.ts @@ -19,6 +19,10 @@ export const freeWallet: Wallet = { stripeSubscriptionId: null, spendUnitsThisPeriod: 120, categoryBreakdown: { api: 40, ai: 30, automation: 50 }, + categoryDocs: { api: 30, ai: 20, automation: 40 }, + docsProcessedThisPeriod: 90, + uniquePdfsThisPeriod: 84, + sizeMultiplierPdfsThisPeriod: 12, members: [], recent: [], }; @@ -42,6 +46,10 @@ export const subscribedWallet: Wallet = { stripeSubscriptionId: "sub_123", spendUnitsThisPeriod: 2250, categoryBreakdown: { api: 900, ai: 600, automation: 750 }, + categoryDocs: { api: 700, ai: 450, automation: 600 }, + docsProcessedThisPeriod: 1750, + uniquePdfsThisPeriod: 1600, + sizeMultiplierPdfsThisPeriod: 320, members: [ { userId: "u1", diff --git a/frontend/editor/src/proprietary/billing/types.ts b/frontend/editor/src/proprietary/billing/types.ts index 83d0c66518..df0caa5e39 100644 --- a/frontend/editor/src/proprietary/billing/types.ts +++ b/frontend/editor/src/proprietary/billing/types.ts @@ -60,7 +60,16 @@ export interface Wallet { noCap: boolean; stripeSubscriptionId: string | null; spendUnitsThisPeriod: number; + /** Per-category size-scaled meter units (billing quantity). */ categoryBreakdown: WalletCategoryBreakdown; + /** Per-category INPUT-file counts — the count dimension, distinct from the units above. */ + categoryDocs: WalletCategoryBreakdown; + /** Total input files processed this period (Σ doc_count). */ + docsProcessedThisPeriod: number; + /** Distinct input documents this period — a file hit by N operations counts once. */ + uniquePdfsThisPeriod: number; + /** Input files on charges where the size multiplier applied (units billed > input files). */ + sizeMultiplierPdfsThisPeriod: number; /** Populated for the leader view; empty for members / single-seat tenants. */ members: WalletMember[]; recent: WalletActivityRow[]; diff --git a/frontend/editor/src/saas/components/shared/FreeLimitReachedModal.test.tsx b/frontend/editor/src/saas/components/shared/FreeLimitReachedModal.test.tsx index 7eac7b4d83..d70d807b25 100644 --- a/frontend/editor/src/saas/components/shared/FreeLimitReachedModal.test.tsx +++ b/frontend/editor/src/saas/components/shared/FreeLimitReachedModal.test.tsx @@ -27,6 +27,10 @@ const wallet: Wallet = { stripeSubscriptionId: null, spendUnitsThisPeriod: 0, categoryBreakdown: { api: 0, ai: 0, automation: 0 }, + categoryDocs: { api: 0, ai: 0, automation: 0 }, + docsProcessedThisPeriod: 0, + uniquePdfsThisPeriod: 0, + sizeMultiplierPdfsThisPeriod: 0, members: [], recent: [], }; diff --git a/frontend/editor/src/saas/hooks/walletDevPreview.ts b/frontend/editor/src/saas/hooks/walletDevPreview.ts index e62ec333d5..1529058f15 100644 --- a/frontend/editor/src/saas/hooks/walletDevPreview.ts +++ b/frontend/editor/src/saas/hooks/walletDevPreview.ts @@ -67,6 +67,12 @@ function buildDevPreviewWallet(role: WalletRole): Wallet { noCap: false, stripeSubscriptionId: subscribed ? "sub_devpreview" : null, spendUnitsThisPeriod: 62, + // Count dimension (illustrative): input files processed vs the size-scaled + // meter units above — a few large PDFs pushed some charges past 1 unit. + docsProcessedThisPeriod: 50, + uniquePdfsThisPeriod: 48, + sizeMultiplierPdfsThisPeriod: 8, + categoryDocs: { api: 18, ai: 14, automation: 18 }, // Wave 1 backend (PR #6574) returns a per-category breakdown so the // hero panel can split AI / automation / API. Use realistic but // tier-distinguishable mock values so the dev preview shows a From d06a367b87f2ebcf4d5cc7ed53543878272e8662 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:23:00 +0100 Subject: [PATCH 11/34] =?UTF-8?q?SaaS=20role-based=20login=20landing=20(te?= =?UTF-8?q?am=20leads=20=E2=86=92=20Processor)=20(#6960)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/editor/.env.saas | 9 + .../public/locales/en-US/translation.toml | 6 + .../src/core/services/preferencesService.ts | 7 + frontend/editor/src/proprietary/App.tsx | 2 + .../components/LoginLandingRedirect.test.tsx | 180 ++++++++++++++++++ .../components/LoginLandingRedirect.tsx | 120 ++++++++++++ .../shared/config/GeneralWithLoginLanding.tsx | 19 ++ .../config/LoginLandingSetting.test.tsx | 101 ++++++++++ .../shared/config/LoginLandingSetting.tsx | 84 ++++++++ .../shared/config/configNavSections.tsx | 4 +- .../src/proprietary/routes/AuthCallback.tsx | 4 + .../editor/src/proprietary/routes/Login.tsx | 25 ++- .../proprietary/utils/loginLanding.test.ts | 150 +++++++++++++++ .../src/proprietary/utils/loginLanding.ts | 126 ++++++++++++ frontend/editor/src/saas/App.tsx | 2 + .../shared/config/saasConfigNavSections.tsx | 5 +- .../editor/src/saas/routes/AuthCallback.tsx | 12 +- frontend/editor/src/saas/routes/Login.tsx | 6 +- frontend/editor/vite-env.d.ts | 3 + 19 files changed, 853 insertions(+), 12 deletions(-) create mode 100644 frontend/editor/src/proprietary/components/LoginLandingRedirect.test.tsx create mode 100644 frontend/editor/src/proprietary/components/LoginLandingRedirect.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/config/GeneralWithLoginLanding.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/config/LoginLandingSetting.test.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/config/LoginLandingSetting.tsx create mode 100644 frontend/editor/src/proprietary/utils/loginLanding.test.ts create mode 100644 frontend/editor/src/proprietary/utils/loginLanding.ts diff --git a/frontend/editor/.env.saas b/frontend/editor/.env.saas index ee182fe99c..0314c59fa4 100644 --- a/frontend/editor/.env.saas +++ b/frontend/editor/.env.saas @@ -10,3 +10,12 @@ VITE_USERBACK_TOKEN= # Dev-only auth bypass for localhost. Subpath comes from RUN_SUBPATH (build-time). VITE_DEV_BYPASS_AUTH=false + +# Login landing mode - soft-release flag for the processor (portal): +# dynamic = role-based landing: team leads -> processor, members -> editor, +# with a per-user override in Settings > General. Default. +# editor = every user lands on the editor after login. The processor stays +# reachable via the app switcher, but no one is auto-routed to it and +# the per-user landing setting is hidden. Soft-release escape hatch. +# Set to `editor` (here or via the build env) to hold the processor back. +VITE_LOGIN_LANDING_MODE=dynamic diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 2244b6acdc..d9ed967d62 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -8937,6 +8937,12 @@ intro = "Enable user authentication, team management, and workspace features for learnMore = "Learn more in documentation" title = "For System Administrators" +[settings.general.loginLanding] +description = "Choose which app opens when you sign in to Stirling Cloud." +editor = "Editor" +processor = "Processor" +title = "After signing in" + [settings.general.mode] fullscreen = "Fullscreen" sidebar = "Sidebar" diff --git a/frontend/editor/src/core/services/preferencesService.ts b/frontend/editor/src/core/services/preferencesService.ts index bba1eadc22..a378136665 100644 --- a/frontend/editor/src/core/services/preferencesService.ts +++ b/frontend/editor/src/core/services/preferencesService.ts @@ -21,12 +21,18 @@ export type ViewerZoomSetting = | "150" | "200"; +// SaaS-only: which app a team lead lands on after signing in. Members can't +// reach the processor, so this never applies to them. +export type LoginLandingView = "processor" | "editor"; + export interface UserPreferences { autoUnzip: boolean; autoUnzipFileLimit: number; defaultToolPanelMode: ToolPanelMode; defaultStartupView: StartupView; defaultViewerZoom: ViewerZoomSetting; + // SaaS-only: team lead's post-login landing app (processor vs editor). + loginLandingView: LoginLandingView; theme: ThemeMode; toolPanelModePromptSeen: boolean; hasSelectedToolPanelMode: boolean; @@ -46,6 +52,7 @@ export const DEFAULT_PREFERENCES: UserPreferences = { defaultToolPanelMode: DEFAULT_TOOL_PANEL_MODE, defaultStartupView: "tools", defaultViewerZoom: "auto", + loginLandingView: "processor", theme: "system", toolPanelModePromptSeen: false, hasSelectedToolPanelMode: false, diff --git a/frontend/editor/src/proprietary/App.tsx b/frontend/editor/src/proprietary/App.tsx index 969f143959..e98064ebc8 100644 --- a/frontend/editor/src/proprietary/App.tsx +++ b/frontend/editor/src/proprietary/App.tsx @@ -17,6 +17,7 @@ import Onboarding from "@app/components/onboarding/Onboarding"; import WatchedFoldersRegistration from "@app/components/watchedFolders/WatchedFoldersRegistration"; import { WATCHED_FOLDERS_ENABLED } from "@app/constants/featureFlags"; import { getAdminRouteExtensions } from "@app/routes/adminRouteExtensions"; +import { LoginLandingRedirect } from "@app/components/LoginLandingRedirect"; // Import global styles import "@app/styles/tailwind.css"; @@ -78,6 +79,7 @@ export default function App() { element={ + } /> } /> diff --git a/frontend/editor/src/proprietary/components/LoginLandingRedirect.test.tsx b/frontend/editor/src/proprietary/components/LoginLandingRedirect.test.tsx new file mode 100644 index 0000000000..dd121ac77a --- /dev/null +++ b/frontend/editor/src/proprietary/components/LoginLandingRedirect.test.tsx @@ -0,0 +1,180 @@ +import { StrictMode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter, useLocation } from "react-router-dom"; + +// Mutable holders driven per-test; read at call time by the mocks below. +const h = vi.hoisted(() => ({ + auth: { session: null as unknown, isAnonymous: false }, + prefs: { loginLandingView: "processor" as "processor" | "editor" }, + get: vi.fn(), +})); + +vi.mock("@app/services/apiClient", () => ({ default: { get: h.get } })); +vi.mock("@app/auth/UseSession", () => ({ useAuth: () => h.auth })); +vi.mock("@app/contexts/PreferencesContext", () => ({ + usePreferences: () => ({ preferences: h.prefs, updatePreference: vi.fn() }), +})); + +import { LoginLandingRedirect } from "@app/components/LoginLandingRedirect"; +import { + hasLoginLandingPending, + markLoginLandingPending, +} from "@app/utils/loginLanding"; + +function httpError(status: number) { + return Object.assign(new Error("http"), { response: { status } }); +} + +// Configure the two backend endpoints. teamMy === "404" simulates self-hosted. +function backend(opts: { + role: string; + portalAccess: boolean; + teamMy: unknown[] | "404"; +}) { + h.get.mockImplementation((url: string) => { + if (url === "/api/v1/auth/me") { + return Promise.resolve({ + data: { user: { role: opts.role, portalAccess: opts.portalAccess } }, + }); + } + if (url === "/api/v1/team/my") { + return opts.teamMy === "404" + ? Promise.reject(httpError(404)) + : Promise.resolve({ data: opts.teamMy }); + } + return Promise.resolve({ data: {} }); + }); +} + +function LocationProbe() { + return
{useLocation().pathname}
; +} + +function renderAt(pathname = "/", strict = false) { + const tree = ( + + + + + ); + return render(strict ? {tree} : tree); +} + +const SIGNED_IN = { session: { user: { id: "u1" } }, isAnonymous: false }; + +beforeEach(() => { + window.sessionStorage.clear(); + vi.stubEnv("VITE_INCLUDE_PORTAL", "true"); + vi.stubEnv("VITE_LOGIN_LANDING_MODE", "dynamic"); + h.auth = { ...SIGNED_IN }; + h.prefs = { loginLandingView: "processor" }; + h.get.mockReset(); +}); + +afterEach(() => vi.unstubAllEnvs()); + +describe("LoginLandingRedirect", () => { + it("self-hosted admin (no /team/my, portalAccess) → processor", async () => { + backend({ role: "ROLE_ADMIN", portalAccess: true, teamMy: "404" }); + markLoginLandingPending(); + renderAt("/"); + await waitFor(() => + expect(screen.getByTestId("pathname").textContent).toBe("/processor"), + ); + expect(hasLoginLandingPending()).toBe(false); + }); + + it("self-hosted member (no /team/my, no portalAccess) → editor", async () => { + backend({ role: "USER", portalAccess: false, teamMy: "404" }); + markLoginLandingPending(); + renderAt("/"); + await waitFor(() => expect(hasLoginLandingPending()).toBe(false)); + expect(screen.getByTestId("pathname").textContent).toBe("/"); + }); + + it("saas real team lead → processor", async () => { + backend({ + role: "USER", + portalAccess: true, + teamMy: [{ isLeader: true, isPersonal: false }], + }); + markLoginLandingPending(); + renderAt("/"); + await waitFor(() => + expect(screen.getByTestId("pathname").textContent).toBe("/processor"), + ); + }); + + it("saas member → editor", async () => { + backend({ + role: "USER", + portalAccess: true, + teamMy: [ + { isLeader: true, isPersonal: true }, + { isLeader: false, isPersonal: false }, + ], + }); + markLoginLandingPending(); + renderAt("/"); + await waitFor(() => expect(hasLoginLandingPending()).toBe(false)); + expect(screen.getByTestId("pathname").textContent).toBe("/"); + }); + + it("still redirects under StrictMode double-invoke", async () => { + backend({ role: "ROLE_ADMIN", portalAccess: true, teamMy: "404" }); + markLoginLandingPending(); + renderAt("/", true); + await waitFor(() => + expect(screen.getByTestId("pathname").textContent).toBe("/processor"), + ); + }); + + it("does not fetch when a user opted into the editor", async () => { + backend({ role: "ROLE_ADMIN", portalAccess: true, teamMy: "404" }); + h.prefs = { loginLandingView: "editor" }; + markLoginLandingPending(); + renderAt("/"); + await Promise.resolve(); + expect(h.get).not.toHaveBeenCalled(); + expect(screen.getByTestId("pathname").textContent).toBe("/"); + expect(hasLoginLandingPending()).toBe(false); + }); + + it("does nothing in editor mode (soft release)", async () => { + vi.stubEnv("VITE_LOGIN_LANDING_MODE", "editor"); + backend({ role: "ROLE_ADMIN", portalAccess: true, teamMy: "404" }); + markLoginLandingPending(); + renderAt("/"); + await Promise.resolve(); + expect(h.get).not.toHaveBeenCalled(); + expect(screen.getByTestId("pathname").textContent).toBe("/"); + }); + + it("does nothing without the fresh-login flag", async () => { + backend({ role: "ROLE_ADMIN", portalAccess: true, teamMy: "404" }); + renderAt("/"); + await Promise.resolve(); + expect(h.get).not.toHaveBeenCalled(); + expect(screen.getByTestId("pathname").textContent).toBe("/"); + }); + + it("waits on auth routes and keeps the flag", async () => { + backend({ role: "ROLE_ADMIN", portalAccess: true, teamMy: "404" }); + markLoginLandingPending(); + renderAt("/login"); + await Promise.resolve(); + expect(h.get).not.toHaveBeenCalled(); + expect(hasLoginLandingPending()).toBe(true); + }); + + it("ignores anonymous sessions", async () => { + h.auth = { session: { user: { id: "anon" } }, isAnonymous: true }; + backend({ role: "ROLE_ADMIN", portalAccess: true, teamMy: "404" }); + markLoginLandingPending(); + renderAt("/"); + await Promise.resolve(); + expect(h.get).not.toHaveBeenCalled(); + expect(screen.getByTestId("pathname").textContent).toBe("/"); + }); +}); diff --git a/frontend/editor/src/proprietary/components/LoginLandingRedirect.tsx b/frontend/editor/src/proprietary/components/LoginLandingRedirect.tsx new file mode 100644 index 0000000000..82dd48ed5a --- /dev/null +++ b/frontend/editor/src/proprietary/components/LoginLandingRedirect.tsx @@ -0,0 +1,120 @@ +import { useEffect, useState } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; +import { useAuth } from "@app/auth/UseSession"; +import { usePreferences } from "@app/contexts/PreferencesContext"; +import { isAuthRoute } from "@app/constants/routes"; +import { PORTAL_BASENAME } from "@app/routes/portalBasename"; +import { LoadingFallback } from "@app/components/shared/LoadingFallback"; +import { Z_INDEX_SIGN_IN_MODAL } from "@app/styles/zIndex"; +import { + consumeLoginLandingPending, + fetchLandsOnProcessor, + hasLoginLandingPending, + isPortalAvailable, + loginLandingMode, +} from "@app/utils/loginLanding"; + +/** + * On a fresh sign-in (any flavor), sends processor users to the processor and + * everyone else to the editor. Fires once per login, guarded by a sessionStorage + * flag set at login and consumed only once the destination is decided, so it + * never hijacks later in-session navigation. Gated by the VITE_LOGIN_LANDING_MODE + * soft-release flag ("dynamic" to enable). + * + * The decision (see fetchLandsOnProcessor) is driven by the shared /api/v1/auth/me + * so self-hosted and SaaS share one code path. While the lookup is in flight for + * a would-be processor user, a full-screen loader is shown so the editor never + * flashes before the redirect resolves. + * + * Mounted once (in AppProviders) for every flavor; not on the portal route-set, + * which is a separate top-level route. + */ +export function LoginLandingRedirect() { + const navigate = useNavigate(); + const location = useLocation(); + const { session, isAnonymous } = useAuth(); + const { preferences } = usePreferences(); + // A settled, non-anonymous session. Depend on this boolean rather than the + // session object so the effect - and its in-flight lookup - is not torn down + // by the identity churn of setSession() firing on every auth event. + const isSignedIn = !!session && !isAnonymous; + const landingView = preferences.loginLandingView; + const [resolving, setResolving] = useState(false); + + // One-time config log so a live instance reveals the silent build gates + // (soft-release mode off, or portal not bundled) even before any login. + useEffect(() => { + console.debug("[login-landing] config", { + mode: loginLandingMode(), + portalAvailable: isPortalAvailable(), + basename: PORTAL_BASENAME, + }); + }, []); + + useEffect(() => { + // Soft-release flag: outside "dynamic" nobody is auto-routed to the processor. + if (loginLandingMode() !== "dynamic") return; + // The fresh-login flag is the single source of truth for "once per login". It + // is consumed only at the decision below, so a re-run before then just retries + // (StrictMode double-invoke, or a dependency change mid-lookup) instead of + // dropping the redirect with the flag already spent. + if (!hasLoginLandingPending()) return; + console.debug("[login-landing] pending", { + isSignedIn, + onAuthRoute: isAuthRoute(location.pathname), + portalAvailable: isPortalAvailable(), + landingView, + path: location.pathname, + }); + if (!isSignedIn) return; + // Let the normal post-login navigation settle off the auth pages first. + if (isAuthRoute(location.pathname)) return; + + let active = true; + const settle = (goToProcessor: boolean) => { + // Ignore a stale attempt cancelled by a re-run; the live run will decide. + if (!active) return; + consumeLoginLandingPending(); + setResolving(false); + if (goToProcessor) navigate(PORTAL_BASENAME, { replace: true }); + }; + + // A user who chose "editor" opts out, and no processor to route to - + // decide synchronously, no lookup needed. + if (landingView === "editor" || !isPortalAvailable()) { + settle(false); + return; + } + + setResolving(true); + void fetchLandsOnProcessor().then((goToProcessor) => { + console.debug("[login-landing] decision", { goToProcessor }); + settle(goToProcessor); + }); + + return () => { + active = false; + setResolving(false); + }; + }, [isSignedIn, landingView, location.pathname, navigate]); + + // Cover the editor while a would-be-processor lookup resolves, so a lead never + // sees the editor flash before being sent to the processor. + if (resolving) { + return ( +
+ +
+ ); + } + return null; +} + +export default LoginLandingRedirect; diff --git a/frontend/editor/src/proprietary/components/shared/config/GeneralWithLoginLanding.tsx b/frontend/editor/src/proprietary/components/shared/config/GeneralWithLoginLanding.tsx new file mode 100644 index 0000000000..ad04cce95e --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/config/GeneralWithLoginLanding.tsx @@ -0,0 +1,19 @@ +import type { ComponentProps } from "react"; +import { Stack } from "@mantine/core"; +import GeneralSection from "@app/components/shared/config/configSections/GeneralSection"; +import { LoginLandingSetting } from "@app/components/shared/config/LoginLandingSetting"; + +type GeneralSectionProps = ComponentProps; + +/** + * Core General settings plus the shared login-landing control. Used by every + * flavor's config nav so the setting is not duplicated per flavor. + */ +export default function GeneralWithLoginLanding(props: GeneralSectionProps) { + return ( + + + + + ); +} diff --git a/frontend/editor/src/proprietary/components/shared/config/LoginLandingSetting.test.tsx b/frontend/editor/src/proprietary/components/shared/config/LoginLandingSetting.test.tsx new file mode 100644 index 0000000000..31d45c0d45 --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/config/LoginLandingSetting.test.tsx @@ -0,0 +1,101 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { MantineProvider } from "@mantine/core"; + +const h = vi.hoisted(() => ({ + prefs: { loginLandingView: "processor" as "processor" | "editor" }, + update: vi.fn(), + get: vi.fn(), +})); + +vi.mock("@app/services/apiClient", () => ({ default: { get: h.get } })); +vi.mock("@app/contexts/PreferencesContext", () => ({ + usePreferences: () => ({ preferences: h.prefs, updatePreference: h.update }), +})); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (_key: string, fallback?: string) => fallback ?? _key, + i18n: { changeLanguage: vi.fn() }, + }), +})); + +import { LoginLandingSetting } from "@app/components/shared/config/LoginLandingSetting"; + +function httpError(status: number) { + return Object.assign(new Error("http"), { response: { status } }); +} + +// Self-hosted admin (portalAccess true, no /team/my) → eligible. +function eligibleBackend() { + h.get.mockImplementation((url: string) => { + if (url === "/api/v1/auth/me") { + return Promise.resolve({ + data: { user: { role: "ROLE_ADMIN", portalAccess: true } }, + }); + } + return Promise.reject(httpError(404)); + }); +} + +// Self-hosted member (no portalAccess) → not eligible. +function memberBackend() { + h.get.mockImplementation((url: string) => { + if (url === "/api/v1/auth/me") { + return Promise.resolve({ + data: { user: { role: "USER", portalAccess: false } }, + }); + } + return Promise.reject(httpError(404)); + }); +} + +function renderSetting() { + return render( + + + , + ); +} + +beforeEach(() => { + vi.stubEnv("VITE_INCLUDE_PORTAL", "true"); + vi.stubEnv("VITE_LOGIN_LANDING_MODE", "dynamic"); + h.prefs = { loginLandingView: "processor" }; + h.update.mockReset(); + h.get.mockReset(); +}); + +afterEach(() => vi.unstubAllEnvs()); + +describe("LoginLandingSetting", () => { + it("shows the control for a processor user", async () => { + eligibleBackend(); + renderSetting(); + expect(await screen.findByText("After signing in")).toBeInTheDocument(); + expect(screen.getByText("Processor")).toBeInTheDocument(); + expect(screen.getByText("Editor")).toBeInTheDocument(); + }); + + it("renders nothing for a member", async () => { + memberBackend(); + renderSetting(); + await waitFor(() => expect(h.get).toHaveBeenCalled()); + await Promise.resolve(); + expect(screen.queryByText("After signing in")).not.toBeInTheDocument(); + }); + + it("renders nothing in editor mode (soft release)", () => { + vi.stubEnv("VITE_LOGIN_LANDING_MODE", "editor"); + eligibleBackend(); + renderSetting(); + expect(screen.queryByText("After signing in")).not.toBeInTheDocument(); + }); + + it("renders nothing when the portal is not bundled", () => { + vi.stubEnv("VITE_INCLUDE_PORTAL", ""); + vi.stubEnv("DEV", false); + eligibleBackend(); + renderSetting(); + expect(screen.queryByText("After signing in")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/editor/src/proprietary/components/shared/config/LoginLandingSetting.tsx b/frontend/editor/src/proprietary/components/shared/config/LoginLandingSetting.tsx new file mode 100644 index 0000000000..e4c510458d --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/config/LoginLandingSetting.tsx @@ -0,0 +1,84 @@ +import { useEffect, useState } from "react"; +import { Paper, Text } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { SegmentedControl } from "@app/ui/SegmentedControl"; +import { usePreferences } from "@app/contexts/PreferencesContext"; +import type { LoginLandingView } from "@app/services/preferencesService"; +import { + fetchLandsOnProcessor, + isPortalAvailable, + loginLandingMode, +} from "@app/utils/loginLanding"; + +/** + * Processor-user preference: where to land after signing in (processor vs + * editor). Shown only to users who default to the processor (see + * fetchLandsOnProcessor); hidden for members and solo users. Shared by all + * flavors. + */ +export function LoginLandingSetting() { + const { t } = useTranslation(); + const { preferences, updatePreference } = usePreferences(); + const [eligible, setEligible] = useState(false); + + // Only look up eligibility when the control could actually show; skip the + // request entirely in soft-release / no-portal builds. + const active = loginLandingMode() === "dynamic" && isPortalAvailable(); + useEffect(() => { + if (!active) return; + let cancelled = false; + void fetchLandsOnProcessor().then((v) => { + if (!cancelled) setEligible(v); + }); + return () => { + cancelled = true; + }; + }, [active]); + + if (!active || !eligible) { + return null; + } + + return ( + +
+
+ + {t("settings.general.loginLanding.title", "After signing in")} + + + {t( + "settings.general.loginLanding.description", + "Choose which app opens when you sign in.", + )} + +
+ + updatePreference("loginLandingView", val as LoginLandingView) + } + options={[ + { + label: t("settings.general.loginLanding.processor", "Processor"), + value: "processor", + }, + { + label: t("settings.general.loginLanding.editor", "Editor"), + value: "editor", + }, + ]} + /> +
+
+ ); +} + +export default LoginLandingSetting; diff --git a/frontend/editor/src/proprietary/components/shared/config/configNavSections.tsx b/frontend/editor/src/proprietary/components/shared/config/configNavSections.tsx index 23856929a5..f9c19f8a7b 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configNavSections.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configNavSections.tsx @@ -22,7 +22,7 @@ import AdminUsageSection from "@app/components/shared/config/configSections/Admi import AdminStorageSharingSection from "@app/components/shared/config/configSections/AdminStorageSharingSection"; import ApiKeys from "@app/components/shared/config/configSections/ApiKeys"; import AccountSection from "@app/components/shared/config/configSections/AccountSection"; -import GeneralSection from "@app/components/shared/config/configSections/GeneralSection"; +import GeneralWithLoginLanding from "@app/components/shared/config/GeneralWithLoginLanding"; /** * Hook version of proprietary config nav sections with proper i18n support @@ -50,7 +50,7 @@ export const useConfigNavSections = ( if (preferencesSection) { preferencesSection.items = preferencesSection.items.map((item) => item.key === "general" - ? { ...item, component: } + ? { ...item, component: } : item, ); diff --git a/frontend/editor/src/proprietary/routes/AuthCallback.tsx b/frontend/editor/src/proprietary/routes/AuthCallback.tsx index 928e277728..f0e90f5ed0 100644 --- a/frontend/editor/src/proprietary/routes/AuthCallback.tsx +++ b/frontend/editor/src/proprietary/routes/AuthCallback.tsx @@ -4,6 +4,7 @@ import { consumePostLoginRedirectPath, springAuth, } from "@app/auth/spring/springAuthClient"; +import { markLoginLandingPending } from "@app/utils/loginLanding"; import { handleAuthCallbackSuccess } from "@app/extensions/authCallback"; import styles from "@app/routes/AuthCallback.module.css"; @@ -78,6 +79,9 @@ export default function AuthCallback() { await new Promise((resolve) => setTimeout(resolve, 100)); const target = consumePostLoginRedirectPath() ?? "/"; + // Fresh OAuth/SSO login with no explicit destination: let the role-based + // landing route processor users. + if (target === "/") markLoginLandingPending(); console.info( `[AuthCallback] Authenticated ${data.session.user.username} in ${elapsed()}, navigating to ${target}`, ); diff --git a/frontend/editor/src/proprietary/routes/Login.tsx b/frontend/editor/src/proprietary/routes/Login.tsx index 05737d9ba7..a59072d5f4 100644 --- a/frontend/editor/src/proprietary/routes/Login.tsx +++ b/frontend/editor/src/proprietary/routes/Login.tsx @@ -8,6 +8,7 @@ import { import { Text, Stack, Alert } from "@mantine/core"; import { Button } from "@app/ui/Button"; import { setPostLoginRedirectPath } from "@app/auth/spring/springAuthClient"; +import { markLoginLandingPending } from "@app/utils/loginLanding"; import { useAuth } from "@app/auth/UseSession"; import { useAppConfig } from "@app/contexts/AppConfigContext"; import { useTranslation } from "react-i18next"; @@ -57,6 +58,9 @@ export default function Login() { backendProbe.loginDisabled === true || _enableLogin === false; const autoLoginAttempted = useRef(false); const autoLoginErrorRecorded = useRef(false); + // True once we've observed a signed-out state on this page, so we can tell a + // fresh login (arrived signed-out, then signed in) from an already-authed visit. + const sawSignedOutRef = useRef(false); const AUTO_LOGIN_ATTEMPTS_KEY = "stirling_sso_auto_login_attempts"; const AUTO_LOGIN_ERRORS_KEY = "stirling_sso_auto_login_errors"; @@ -197,13 +201,22 @@ export default function Login() { // Redirect immediately if user has valid session (JWT already validated by AuthProvider) useEffect(() => { - if (!loading && session) { - const returnPath = resolveReturnPath(); - console.debug("[Login] User already authenticated, redirecting to home", { - returnPath, - }); - navigate(returnPath || "/", { replace: true }); + if (loading) return; + if (!session) { + sawSignedOutRef.current = true; + return; } + const returnPath = resolveReturnPath(); + // Fresh form login (we were signed out on this page) with no explicit + // destination: let the role-based landing route processor users. An + // already-authed visit to /login never sets the flag. + if (sawSignedOutRef.current && !returnPath) { + markLoginLandingPending(); + } + console.debug("[Login] User already authenticated, redirecting to home", { + returnPath, + }); + navigate(returnPath || "/", { replace: true }); }, [session, loading, navigate, location.state, searchParams]); // If backend reports login is disabled, redirect to home (anonymous mode) diff --git a/frontend/editor/src/proprietary/utils/loginLanding.test.ts b/frontend/editor/src/proprietary/utils/loginLanding.test.ts new file mode 100644 index 0000000000..43a33e1a0d --- /dev/null +++ b/frontend/editor/src/proprietary/utils/loginLanding.test.ts @@ -0,0 +1,150 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const h = vi.hoisted(() => ({ get: vi.fn() })); +vi.mock("@app/services/apiClient", () => ({ default: { get: h.get } })); + +import { + LOGIN_LANDING_PENDING_KEY, + consumeLoginLandingPending, + fetchLandsOnProcessor, + hasLoginLandingPending, + isPortalAvailable, + leadsRealTeam, + loginLandingMode, + markLoginLandingPending, + type LandingTeam, +} from "@app/utils/loginLanding"; + +function team(o: Partial): LandingTeam { + return { isLeader: false, isPersonal: false, ...o }; +} + +// Axios-error-shaped plain object (not an Error instance) so the harness's +// uncaught-Error tracking doesn't flag the rejection that fetchLandsOnProcessor +// deliberately catches. +function httpError(status: number) { + return { isAxiosError: true, message: "http", response: { status } }; +} + +function mockMe(role: string | null, portalAccess: boolean) { + return { data: { user: { role, portalAccess } } }; +} + +describe("leadsRealTeam", () => { + it("is true only for a non-personal led team", () => { + expect(leadsRealTeam([team({ isLeader: true, isPersonal: false })])).toBe( + true, + ); + expect(leadsRealTeam([team({ isLeader: false, isPersonal: false })])).toBe( + false, + ); + expect(leadsRealTeam([team({ isLeader: true, isPersonal: true })])).toBe( + false, + ); + expect(leadsRealTeam([])).toBe(false); + }); +}); + +describe("login-landing pending flag", () => { + beforeEach(() => window.sessionStorage.clear()); + + it("marks, peeks, and consumes once", () => { + expect(hasLoginLandingPending()).toBe(false); + markLoginLandingPending(); + expect(window.sessionStorage.getItem(LOGIN_LANDING_PENDING_KEY)).toBe("1"); + expect(hasLoginLandingPending()).toBe(true); + expect(consumeLoginLandingPending()).toBe(true); + expect(hasLoginLandingPending()).toBe(false); + expect(consumeLoginLandingPending()).toBe(false); + }); +}); + +describe("loginLandingMode", () => { + afterEach(() => vi.unstubAllEnvs()); + + it("defaults to dynamic unless explicitly 'editor'", () => { + vi.stubEnv("VITE_LOGIN_LANDING_MODE", ""); + expect(loginLandingMode()).toBe("dynamic"); + vi.stubEnv("VITE_LOGIN_LANDING_MODE", "editor"); + expect(loginLandingMode()).toBe("editor"); + vi.stubEnv("VITE_LOGIN_LANDING_MODE", "whatever"); + expect(loginLandingMode()).toBe("dynamic"); + }); +}); + +describe("isPortalAvailable", () => { + afterEach(() => vi.unstubAllEnvs()); + + it("is true when VITE_INCLUDE_PORTAL is set", () => { + vi.stubEnv("VITE_INCLUDE_PORTAL", "true"); + expect(isPortalAvailable()).toBe(true); + }); +}); + +describe("fetchLandsOnProcessor", () => { + beforeEach(() => h.get.mockReset()); + + // fetchLandsOnProcessor calls /me first, then /team/my. mockRejectedValueOnce + // is vitest's rejection helper (tracks the rejection so it isn't flagged). + it("self-hosted (no /team/my): uses portalAccess = true", async () => { + h.get + .mockResolvedValueOnce(mockMe("USER", true)) + .mockRejectedValueOnce(httpError(404)); + expect(await fetchLandsOnProcessor()).toBe(true); + }); + + it("self-hosted (no /team/my): portalAccess false → editor", async () => { + h.get + .mockResolvedValueOnce(mockMe("USER", false)) + .mockRejectedValueOnce(httpError(404)); + expect(await fetchLandsOnProcessor()).toBe(false); + }); + + it("saas: admin → processor even with only a personal team", async () => { + h.get.mockImplementation((url: string) => { + if (url === "/api/v1/auth/me") + return Promise.resolve(mockMe("ROLE_ADMIN", true)); + return Promise.resolve({ + data: [team({ isLeader: true, isPersonal: true })], + }); + }); + expect(await fetchLandsOnProcessor()).toBe(true); + }); + + it("saas: non-admin real lead → processor", async () => { + h.get.mockImplementation((url: string) => { + if (url === "/api/v1/auth/me") + return Promise.resolve(mockMe("USER", true)); + return Promise.resolve({ + data: [team({ isLeader: true, isPersonal: false })], + }); + }); + expect(await fetchLandsOnProcessor()).toBe(true); + }); + + it("saas: member → editor (ignores polluted portalAccess)", async () => { + h.get.mockImplementation((url: string) => { + if (url === "/api/v1/auth/me") + return Promise.resolve(mockMe("USER", true)); + return Promise.resolve({ + data: [ + team({ isLeader: true, isPersonal: true }), + team({ isLeader: false, isPersonal: false }), + ], + }); + }); + expect(await fetchLandsOnProcessor()).toBe(false); + }); + + it("editor when /me fails", async () => { + h.get.mockRejectedValueOnce(httpError(401)); + expect(await fetchLandsOnProcessor()).toBe(false); + }); + + it("editor when /team/my fails with a non-404 (ambiguous)", async () => { + h.get + .mockResolvedValueOnce(mockMe("USER", true)) + .mockRejectedValueOnce(httpError(500)); + expect(await fetchLandsOnProcessor()).toBe(false); + }); +}); diff --git a/frontend/editor/src/proprietary/utils/loginLanding.ts b/frontend/editor/src/proprietary/utils/loginLanding.ts new file mode 100644 index 0000000000..ee36e4715e --- /dev/null +++ b/frontend/editor/src/proprietary/utils/loginLanding.ts @@ -0,0 +1,126 @@ +import { isAdminRole } from "@app/auth/roles"; +import apiClient from "@app/services/apiClient"; + +/** + * Role-based login landing, shared by every flavor (self-hosted + SaaS). + * + * On a fresh sign-in, users who can use the processor (portal) land there; + * everyone else lands on the editor. The decision is driven by the shared + * `/api/v1/auth/me` endpoint so there is a single code path for all flavors: + * + * - Self-hosted: `portalAccess` from `/me` (admin, ACL grant, or team owner) is + * the clean signal - there are no personal teams, and `/api/v1/team/my` does + * not exist (404). + * - SaaS: every user leads their own personal team, so `portalAccess`/`teamLead` + * are true for everyone and useless. SaaS additionally exposes + * `/api/v1/team/my`, so there we require admin, or leadership of a NON-personal + * team, which excludes members and solo/personal users. + */ + +// sessionStorage flag set at a genuine fresh login and consumed once when the +// user lands, so the redirect never hijacks later in-session navigation (e.g. +// switching back to the editor from the processor). +export const LOGIN_LANDING_PENDING_KEY = "stirling_login_landing_pending"; + +export type LoginLandingMode = "editor" | "dynamic"; + +/** Minimal shape of a `/api/v1/team/my` row that the decision needs. */ +export interface LandingTeam { + isLeader: boolean; + isPersonal: boolean; +} + +/** A user "leads a team" (→ processor) only if they lead a non-personal team. */ +export function leadsRealTeam(teams: LandingTeam[]): boolean { + return teams.some((team) => team.isLeader && !team.isPersonal); +} + +// The processor/portal route-set is only bundled in some builds (mirrors +// adminRouteExtensions); redirecting to it otherwise would 404 to the editor. +export function isPortalAvailable(): boolean { + return import.meta.env.VITE_INCLUDE_PORTAL === "true" || import.meta.env.DEV; +} + +/** + * Build flag (VITE_LOGIN_LANDING_MODE) gating the whole role-based landing: + * - "dynamic" (default): processor users land on the processor, everyone else on + * the editor, with a per-user override in Settings. + * - "editor": everyone lands on the editor - the soft-release escape hatch. The + * processor is still reachable via the app switcher, but no one is auto-routed + * to it and the per-user setting stays hidden. + */ +export function loginLandingMode(): LoginLandingMode { + return import.meta.env.VITE_LOGIN_LANDING_MODE === "editor" + ? "editor" + : "dynamic"; +} + +/** Flag a genuine fresh login so the landing redirect fires exactly once. */ +export function markLoginLandingPending(): void { + try { + window.sessionStorage.setItem(LOGIN_LANDING_PENDING_KEY, "1"); + } catch { + // sessionStorage unavailable (private mode / SSR): skip the one-time redirect. + } +} + +/** Whether a fresh-login redirect is still pending (non-destructive peek). */ +export function hasLoginLandingPending(): boolean { + try { + return window.sessionStorage.getItem(LOGIN_LANDING_PENDING_KEY) === "1"; + } catch { + return false; + } +} + +/** Clear the pending flag; returns whether it was set. */ +export function consumeLoginLandingPending(): boolean { + try { + const pending = + window.sessionStorage.getItem(LOGIN_LANDING_PENDING_KEY) === "1"; + if (pending) window.sessionStorage.removeItem(LOGIN_LANDING_PENDING_KEY); + return pending; + } catch { + return false; + } +} + +interface MeUser { + role?: string; + portalAccess?: boolean; +} + +/** + * Whether the signed-in user should land on the processor. One decision for all + * flavors: fetch the shared `/api/v1/auth/me`, then branch on whether + * `/api/v1/team/my` exists (SaaS) or 404s (self-hosted). Any failure defaults to + * the editor (safe). Best-effort - callers treat a thrown/false result as editor. + */ +export async function fetchLandsOnProcessor(): Promise { + let user: MeUser | undefined; + try { + const me = await apiClient.get<{ user?: MeUser }>("/api/v1/auth/me", { + suppressErrorToast: true, + }); + user = me.data?.user; + } catch { + return false; // not authenticated / unreachable → stay on the editor + } + if (!user) return false; + + try { + const teams = await apiClient.get("/api/v1/team/my", { + suppressErrorToast: true, + }); + // SaaS: precise per-team data lets us exclude personal-team-only "leaders". + return isAdminRole(user.role) || leadsRealTeam(teams.data ?? []); + } catch (e) { + const status = (e as { response?: { status?: number } })?.response?.status; + if (status === 404) { + // Self-hosted: no /team/my. portalAccess (admin / grant / team owner) is + // the clean signal there. + return user.portalAccess === true; + } + return false; // ambiguous lookup failure → stay on the editor + } +} diff --git a/frontend/editor/src/saas/App.tsx b/frontend/editor/src/saas/App.tsx index 59de137d32..1da92bd190 100644 --- a/frontend/editor/src/saas/App.tsx +++ b/frontend/editor/src/saas/App.tsx @@ -21,6 +21,7 @@ import { getAdminRouteExtensions } from "@app/routes/adminRouteExtensions"; import OnboardingBootstrap from "@app/components/OnboardingBootstrap"; import SignupRequiredBootstrap from "@app/components/SignupRequiredBootstrap"; import UsageLimitModalHost from "@app/components/UsageLimitModalHost"; +import { LoginLandingRedirect } from "@app/components/LoginLandingRedirect"; // Import global styles import "@app/styles/tailwind.css"; @@ -94,6 +95,7 @@ export default function App() { > + } /> } /> diff --git a/frontend/editor/src/saas/components/shared/config/saasConfigNavSections.tsx b/frontend/editor/src/saas/components/shared/config/saasConfigNavSections.tsx index deea98d2bc..d3165c77a7 100644 --- a/frontend/editor/src/saas/components/shared/config/saasConfigNavSections.tsx +++ b/frontend/editor/src/saas/components/shared/config/saasConfigNavSections.tsx @@ -6,6 +6,7 @@ import { } from "@core/components/shared/config/configNavSections"; import HotkeysSection from "@app/components/shared/config/configSections/HotkeysSection"; import GeneralSection from "@app/components/shared/config/configSections/GeneralSection"; +import GeneralWithLoginLanding from "@app/components/shared/config/GeneralWithLoginLanding"; import PasswordSecurity from "@app/components/shared/config/configSections/PasswordSecurity"; import ApiKeys from "@app/components/shared/config/configSections/ApiKeys"; import McpSection from "@app/components/shared/config/configSections/McpSection"; @@ -210,7 +211,9 @@ export function createSaasConfigNavSections( item.key === "general" ? { ...item, - component: , + component: ( + + ), } : item, ), diff --git a/frontend/editor/src/saas/routes/AuthCallback.tsx b/frontend/editor/src/saas/routes/AuthCallback.tsx index ee16d8ce36..eeeac1ff33 100644 --- a/frontend/editor/src/saas/routes/AuthCallback.tsx +++ b/frontend/editor/src/saas/routes/AuthCallback.tsx @@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom"; import { supabase } from "@app/auth/supabase"; import { Button } from "@app/ui/Button"; import { withBasePath } from "@app/constants/app"; +import { markLoginLandingPending } from "@app/utils/loginLanding"; interface CallbackState { status: "processing" | "success" | "error"; @@ -122,10 +123,17 @@ export default function AuthCallback() { } } - // Redirect to the intended destination - const destination = next.startsWith("/") ? next : "/"; + // Redirect to the intended destination. Reject protocol-relative + // "//host" values (same guard as Login's `next`) so a crafted callback + // URL can't bounce the user off-origin after sign-in. + const destination = + next.startsWith("/") && !next.startsWith("//") ? next : "/"; console.log("[Auth Callback Debug] Redirecting to:", destination); + // Fresh OAuth / magic-link login with no explicit destination: let the + // role-based landing redirect route team leads to the processor. + if (destination === "/") markLoginLandingPending(); + setTimeout(() => navigate(destination, { replace: true }), 1500); } catch (err) { console.error("[Auth Callback Debug] Unexpected error:", err); diff --git a/frontend/editor/src/saas/routes/Login.tsx b/frontend/editor/src/saas/routes/Login.tsx index dcf7b454a2..5a8e135450 100644 --- a/frontend/editor/src/saas/routes/Login.tsx +++ b/frontend/editor/src/saas/routes/Login.tsx @@ -20,6 +20,7 @@ import ErrorMessage from "@app/auth/ui/ErrorMessage"; import EmailPasswordForm from "@app/routes/login/EmailPasswordForm"; import OAuthButtons from "@app/routes/login/OAuthButtons"; import LoggedInState from "@app/routes/login/LoggedInState"; +import { markLoginLandingPending } from "@app/utils/loginLanding"; import loginHeader from "@app/assets/brand/modern-logo/LoginLightModeHeader.svg"; export default function Login() { @@ -166,7 +167,10 @@ export default function Login() { setError(error.message); } else if (data.user) { console.log("[Login] Email sign in successful"); - // User will be redirected by the auth state change + // Fresh login with no explicit destination: let the role-based landing + // redirect route team leads to the processor. User is redirected by the + // auth state change. + if (!nextPath) markLoginLandingPending(); } } catch (err) { console.error("[Login] Unexpected error]:", err); diff --git a/frontend/editor/vite-env.d.ts b/frontend/editor/vite-env.d.ts index f690cf1b3e..e9fb24e496 100644 --- a/frontend/editor/vite-env.d.ts +++ b/frontend/editor/vite-env.d.ts @@ -17,6 +17,9 @@ interface ImportMetaEnv { // SaaS only (.env.saas) readonly VITE_USERBACK_TOKEN: string; readonly VITE_DEV_BYPASS_AUTH: string; + /** Role-based login landing: default "dynamic" (team leads → processor, + * members → editor); set to "editor" to keep everyone on the editor. */ + readonly VITE_LOGIN_LANDING_MODE: string; // Desktop only (.env.desktop) readonly VITE_DESKTOP_BACKEND_URL: string; From 532a80211f9ffd1f28735d33b89220c90d0059e0 Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:06:48 +0100 Subject: [PATCH 12/34] Test: pin ADMINS_AND_TEAM_LEADS default scoping to the owning team (#6966) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What this does Adds one test to `ResourceAccessServiceTest`: a foreign team's lead is **denied** on a team-owned resource under the `ADMINS_AND_TEAM_LEADS` default policy, even when an unscoped `isAnyTeamLeader` check would admit them (stubbed `lenient()` to `true` precisely so the test fails if the scoped path ever consults it again). ## Why Main is already correct here — no behaviour changes in this PR. #6913 landed the scoped implementation (`matchesTeamLeadDefault`: ownerless portal → `isAnyTeamLeader`, team-owned → `isLeaderOfTeam`), which superseded #6893. The only piece not carried over was #6893's boundary test, so the cross-team scoping isn't currently pinned by any test. This adds that pin as cheap insurance for future refactors. Verified the test does its job: it passes on main as-is, and fails if the scoped check is swapped back to the unscoped one. ## Test plan - `:proprietary:test --tests "stirling.software.proprietary.access.service.ResourceAccessServiceTest"` — green - Spotless applied Closes the loop on #6893. --- .../service/ResourceAccessServiceTest.java | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/access/service/ResourceAccessServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/access/service/ResourceAccessServiceTest.java index baa6755493..c63e6b8e47 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/access/service/ResourceAccessServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/access/service/ResourceAccessServiceTest.java @@ -2,6 +2,7 @@ package stirling.software.proprietary.access.service; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.when; import java.lang.reflect.Field; @@ -258,6 +259,24 @@ class ResourceAccessServiceTest { .isFalse(); } + @Test + void teamLeadDefaultOnTeamResourceDeniesForeignTeamsLead() { + // Team-owned resource: the default admits only the owning team's leads. A lead of + // some other team must be denied even though an unscoped "is any team leader" + // check would admit them (lenient stub: the scoped path must never consult it). + stubGrants(); + User foreignLead = user(6); + lenient().when(teamLeadLookup.isAnyTeamLeader(foreignLead)).thenReturn(true); + assertThat( + service.canUseResource( + TYPE, + RID, + PrincipalRef.team(7L), + DefaultAccessPolicy.ADMINS_AND_TEAM_LEADS, + foreignLead)) + .isFalse(); + } + // ---- portal convenience (default policy ADMINS_AND_TEAM_LEADS) ---- @Test From c06657c8f920fc50d775102c863beddf42807db5 Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:24:16 +0100 Subject: [PATCH 13/34] Match external-link tool buttons to normal tool button size (#6974) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Screenshot 2026-07-10 at 5 01 40 PM --- .../editor/src/core/components/tools/toolPicker/ToolButton.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/editor/src/core/components/tools/toolPicker/ToolButton.tsx b/frontend/editor/src/core/components/tools/toolPicker/ToolButton.tsx index 209fdb5529..78c573bb38 100644 --- a/frontend/editor/src/core/components/tools/toolPicker/ToolButton.tsx +++ b/frontend/editor/src/core/components/tools/toolPicker/ToolButton.tsx @@ -265,7 +265,7 @@ const ToolButton: React.FC = ({ variant="tertiary" accent="neutral" size="sm" - p="sm" + p="none" fullWidth justify="start" className="tool-button" From 863cad22bdf93686957f26512e647ebc9911cea7 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Fri, 10 Jul 2026 17:24:25 +0100 Subject: [PATCH 14/34] Fix policy running of Redact (#6972) # Description of Changes Policies can currently throw when calling redact: image 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. --- .../api/security/RedactController.java | 10 ++++-- .../security/RedactControllerMoreTest.java | 12 +++++++ .../hooks/tools/shared/toolAutomation.test.ts | 36 +++++++++++++++++++ .../core/hooks/tools/shared/toolAutomation.ts | 25 +++++++++++++ .../components/policies/PolicySetupWizard.tsx | 32 ++++++++++++----- 5 files changed, 105 insertions(+), 10 deletions(-) diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactController.java index 127b436306..ece149a820 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactController.java @@ -136,11 +136,17 @@ public class RedactController { + "Users can provide text patterns to redact, with options for regex and whole word matching. " + "Input:PDF Output:PDF Type:SISO") public ResponseEntity redactPdf(@ModelAttribute RedactPdfRequest request) { - String[] listOfText = request.getListOfText().split("\n"); + String rawListOfText = request.getListOfText(); boolean useRegex = Boolean.TRUE.equals(request.getUseRegex()); boolean wholeWordSearchBool = Boolean.TRUE.equals(request.getWholeWordSearch()); - if (listOfText.length == 0 || (listOfText.length == 1 && listOfText[0].trim().isEmpty())) { + if (rawListOfText == null || rawListOfText.trim().isEmpty()) { + throw ExceptionUtils.createIllegalArgumentException( + "error.redaction.no.patterns", "No text patterns provided for redaction"); + } + + String[] listOfText = rawListOfText.split("\n"); + if (listOfText.length == 1 && listOfText[0].trim().isEmpty()) { throw ExceptionUtils.createIllegalArgumentException( "error.redaction.no.patterns", "No text patterns provided for redaction"); } diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/RedactControllerMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/RedactControllerMoreTest.java index 6eafecc1a5..150a2355de 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/RedactControllerMoreTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/RedactControllerMoreTest.java @@ -299,6 +299,18 @@ class RedactControllerMoreTest { verify(pdfDocumentFactory, never()).load(any(MultipartFile.class)); } + @Test + @DisplayName("null listOfText throws an illegal-argument error before any load") + void nullPatternsThrows() throws Exception { + RedactPdfRequest request = new RedactPdfRequest(); + request.setFileInput(pdfFile(new byte[] {1, 2, 3})); + request.setListOfText(null); + + assertThatThrownBy(() -> controller.redactPdf(request)) + .isInstanceOf(RuntimeException.class); + verify(pdfDocumentFactory, never()).load(any(MultipartFile.class)); + } + @Test @DisplayName("null file input is reported as a failure") void nullFileThrows() { diff --git a/frontend/editor/src/core/hooks/tools/shared/toolAutomation.test.ts b/frontend/editor/src/core/hooks/tools/shared/toolAutomation.test.ts index 03b85798d8..7caf6ee120 100644 --- a/frontend/editor/src/core/hooks/tools/shared/toolAutomation.test.ts +++ b/frontend/editor/src/core/hooks/tools/shared/toolAutomation.test.ts @@ -13,6 +13,7 @@ import { import { deserializeToolStep, getExecutableTools, + serializeStepFromEndpoint, serializeToolStep, stepRequiresUpload, type WorkingToolStep, @@ -198,6 +199,41 @@ describe("serialize/deserialize round-trip", () => { }); }); +describe("serializeStepFromEndpoint", () => { + test("maps a wizard step's UI params to the backend contract, filling defaults", () => { + // The shape the policy setup wizard holds: an endpoint plus UI-shaped params + // (redact's `wordsToRedact`), with several fields left to their defaults. + const api = serializeStepFromEndpoint( + "/api/v1/security/auto-redact", + { mode: "automatic", useRegex: true, wordsToRedact: ["ssn", "card"] }, + dynamicRegistry, + ); + + expect(api.operation).toBe("/api/v1/security/auto-redact"); + // wordsToRedact -> listOfText (the field the backend actually reads), and the + // frontend-only `mode` is dropped. + expect(api.parameters).toMatchObject({ listOfText: "ssn\ncard" }); + expect(api.parameters).not.toHaveProperty("wordsToRedact"); + expect(api.parameters).not.toHaveProperty("mode"); + // Fields the wizard never set still get their defaults so the body is complete. + expect(api.parameters).toHaveProperty("wholeWordSearch"); + expect(api.parameters).toHaveProperty("customPadding"); + }); + + test("passes an unmapped endpoint's params through unchanged", () => { + expect( + serializeStepFromEndpoint( + "/api/v1/unknown/thing", + { keep: true }, + dynamicRegistry, + ), + ).toEqual({ + operation: "/api/v1/unknown/thing", + parameters: { keep: true }, + }); + }); +}); + describe("stepRequiresUpload", () => { const step = (params: Record): WorkingToolStep => ({ toolId: "compress" as ToolId, diff --git a/frontend/editor/src/core/hooks/tools/shared/toolAutomation.ts b/frontend/editor/src/core/hooks/tools/shared/toolAutomation.ts index d567a1dad1..2685881bf4 100644 --- a/frontend/editor/src/core/hooks/tools/shared/toolAutomation.ts +++ b/frontend/editor/src/core/hooks/tools/shared/toolAutomation.ts @@ -197,6 +197,31 @@ export function serializeToolStep( return { operation, parameters }; } +/** + * Serialize a step held as an endpoint path plus frontend-shaped params - the form the policy setup + * wizard keeps, where params match the tool's UI shape (e.g. redact's `wordsToRedact`) rather than + * the backend contract - into the backend step contract, mapping params through the tool's + * `toApiParams` (merged over its defaults, so fields the wizard never set still get their defaults). + * The endpoint maps to a tool by path, so this works for dynamic-endpoint tools whose config + * endpoint is a function. Endpoints that map to no known tool pass through unchanged. + */ +export function serializeStepFromEndpoint( + operation: string, + params: ErasedToolParams, + registry: Partial, +): ToolApiStep { + const match = findToolByEndpoint({ operation, parameters: params }, registry); + const config = match?.[1].operationConfig; + if (!config) return { operation, parameters: params }; + const merged = { ...(config.defaultParameters ?? {}), ...params }; + return { + operation: resolveEndpoint(config, merged) ?? operation, + parameters: config.toApiParams + ? (config.toApiParams(merged) as Record) + : {}, + }; +} + /** * Find the registry tool for a stored step's endpoint: exact match for static endpoints, else * membership in a dynamic tool's declared `endpoints` set (replaying its function can't recover a diff --git a/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx b/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx index e87d25dc9e..9dada09290 100644 --- a/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx +++ b/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx @@ -19,7 +19,11 @@ import { type PipelineStep, type PolicySetupResult, } from "@portal/api/policies"; -import type { ToolRegistryEntry } from "@app/data/toolsTaxonomy"; +import type { ToolRegistry, ToolRegistryEntry } from "@app/data/toolsTaxonomy"; +import { + deserializeToolStep, + serializeStepFromEndpoint, +} from "@app/hooks/tools/shared/toolAutomation"; import { fetchSources } from "@portal/api/sources"; import { useAsync } from "@portal/hooks/useAsync"; import { PolicyFieldRow } from "@portal/components/policies/PolicyFieldRow"; @@ -121,7 +125,10 @@ const CAPABILITY_META: Record< }, }; -function seedTools(entry: CatalogueEntry): ToolState[] { +function seedTools( + entry: CatalogueEntry, + registry: Partial, +): ToolState[] { const savedSteps = entry.policy?.steps ?? []; const savedByOp = new Map(savedSteps.map((s) => [s.operation, s])); // Always use defaultOperations as the canonical list so tools added after a @@ -135,7 +142,12 @@ function seedTools(entry: CatalogueEntry): ToolState[] { : savedSteps.length > 0 ? false : !DISABLED_BY_DEFAULT.has(s.operation), - parameters: saved?.parameters ?? s.parameters, + // Saved steps are in the backend contract shape; map them back to the UI + // shape the config controls edit (e.g. `listOfText` -> `wordsToRedact`). + // Presets are already authored in the UI shape, so use them as-is. + parameters: saved + ? deserializeToolStep(saved, registry).params + : s.parameters, }; }); } @@ -190,7 +202,9 @@ function PolicySetupWizardBody({ const isEdit = policy != null; const [step, setStep] = useState("workflow"); - const [tools, setTools] = useState(() => seedTools(entry)); + const [tools, setTools] = useState(() => + seedTools(entry, toolRegistry), + ); const [fieldValues, setFieldValues] = useState(() => resolveFieldValues(entry), ); @@ -263,10 +277,12 @@ function PolicySetupWizardBody({ } setError(null); setSubmitting(true); - const steps: PipelineStep[] = enabledTools.map((tl) => ({ - operation: tl.operation, - parameters: tl.parameters, - })); + // Map each tool's UI-shaped params (e.g. redact's `wordsToRedact`) into the + // backend step contract (e.g. `listOfText`) via its `toApiParams`; saving the + // UI shape verbatim would drop those fields and the step would run with none. + const steps: PipelineStep[] = enabledTools.map((tl) => + serializeStepFromEndpoint(tl.operation, tl.parameters, toolRegistry), + ); try { await onSubmit(entry, { fieldValues, From 99a5f2a1bcccae7edd255ec5163781363ce38f0a Mon Sep 17 00:00:00 2001 From: Ludy Date: Sat, 11 Jul 2026 13:39:05 +0200 Subject: [PATCH 15/34] chore(ci): include saas module in GitHub file path configuration (#6980) --- .github/config/.files.yaml | 10 +++++----- .github/labeler-config-srvaroa.yml | 7 +++++++ 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/.github/config/.files.yaml b/.github/config/.files.yaml index 5e85976937..905f2feed9 100644 --- a/.github/config/.files.yaml +++ b/.github/config/.files.yaml @@ -1,12 +1,12 @@ build: &build - build.gradle - - app/(common|core|proprietary)/build.gradle + - app/(common|core|proprietary|saas)/build.gradle - Taskfile.yml - .taskfiles/backend.yml openapi: &openapi - *build - - app/(common|core|proprietary)/src/main/java/** + - app/(common|core|proprietary|saas)/src/main/java/** docker-base: &docker-base - docker/base/Dockerfile @@ -23,9 +23,9 @@ docker: &docker - *docker-base project: &project - - app/(common|core|proprietary)/src/(main|test)/java/** + - app/(common|core|proprietary|saas)/src/(main|test)/java/** - *build - - "app/(common|core|proprietary)/src/(main|test)/resources/**/!(messages_*.properties|*.md)*" + - "app/(common|core|proprietary|saas)/src/(main|test)/resources/**/!(messages_*.properties|*.md)*" - exampleYmlFiles/** - gradle/** - libs/** @@ -82,7 +82,7 @@ tauri: &tauri # tool surfaces it generates models from. engine: &engine - engine/** - - app/(common|core|proprietary)/src/main/java/** + - app/(common|core|proprietary|saas)/src/main/java/** - .github/workflows/ai-engine.yml - Taskfile.yml - .taskfiles/engine.yml diff --git a/.github/labeler-config-srvaroa.yml b/.github/labeler-config-srvaroa.yml index 9eb38bde3d..ad3b431e57 100644 --- a/.github/labeler-config-srvaroa.yml +++ b/.github/labeler-config-srvaroa.yml @@ -63,6 +63,7 @@ labels: files: - 'app/core/src/main/resources/static/.*' - 'app/proprietary/src/main/resources/static/.*' + - 'app/saas/src/main/resources/static/.*' - 'frontend/**' - 'frontend/.*' - 'frontend/**/.*' @@ -83,6 +84,7 @@ labels: - 'app/common/src/main/java/.*.java' - 'app/proprietary/src/main/java/.*.java' - 'app/core/src/main/java/.*.java' + - 'app/saas/src/main/java/.*.java' - label: 'Back End' files: @@ -90,6 +92,9 @@ labels: - 'app/core/src/main/java/stirling/software/SPDF/controller/.*' - 'app/core/src/main/resources/settings.yml.template' - 'app/core/src/main/resources/application.properties' + - 'app/proprietary/src/main/resources/application-proprietary.properties' + - 'app/saas/src/main/resources/application-dev.properties' + - 'app/saas/src/main/resources/application-saas.properties' - 'app/core/src/main/resources/banner.txt' - 'app/core/src/main/resources/static/python/png_to_webp.py' - 'app/core/src/main/resources/static/python/split_photos.py' @@ -153,6 +158,7 @@ labels: - 'app/common/src/test/.*' - 'app/proprietary/src/test/.*' - 'app/core/src/test/.*' + - 'app/saas/src/test/.*' - 'testing/.*' - '.github/workflows/scorecards.yml' - 'exampleYmlFiles/test_cicd.yml' @@ -171,3 +177,4 @@ labels: - 'app/common/build.gradle' - 'app/proprietary/build.gradle' - 'app/core/build.gradle' + - 'app/saas/build.gradle' From 142544c9af332d317ac12211cef8d7987bfb8b05 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:40:55 +0100 Subject: [PATCH 16/34] Replace portal sidebar brand text with Stirling Processor wordmark (#6978) --- .../StirlingProcessorLogoBlackText.svg | 1 + .../StirlingProcessorLogoWhiteText.svg | 1 + .../editor/src/portal/components/Sidebar.css | 19 ++++--------------- .../editor/src/portal/components/Sidebar.tsx | 19 +++++++------------ 4 files changed, 13 insertions(+), 27 deletions(-) create mode 100644 frontend/editor/src/core/assets/brand/modern-logo/StirlingProcessorLogoBlackText.svg create mode 100644 frontend/editor/src/core/assets/brand/modern-logo/StirlingProcessorLogoWhiteText.svg diff --git a/frontend/editor/src/core/assets/brand/modern-logo/StirlingProcessorLogoBlackText.svg b/frontend/editor/src/core/assets/brand/modern-logo/StirlingProcessorLogoBlackText.svg new file mode 100644 index 0000000000..908a59c3a7 --- /dev/null +++ b/frontend/editor/src/core/assets/brand/modern-logo/StirlingProcessorLogoBlackText.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/editor/src/core/assets/brand/modern-logo/StirlingProcessorLogoWhiteText.svg b/frontend/editor/src/core/assets/brand/modern-logo/StirlingProcessorLogoWhiteText.svg new file mode 100644 index 0000000000..77b6475ebf --- /dev/null +++ b/frontend/editor/src/core/assets/brand/modern-logo/StirlingProcessorLogoWhiteText.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/editor/src/portal/components/Sidebar.css b/frontend/editor/src/portal/components/Sidebar.css index 256b0708dd..98a9b63ec3 100644 --- a/frontend/editor/src/portal/components/Sidebar.css +++ b/frontend/editor/src/portal/components/Sidebar.css @@ -20,24 +20,13 @@ border-bottom: 1px solid var(--color-sidebar-divider); } -/* Stirling brand mark (theme-switched in Sidebar.tsx) + product wordmark */ -.portal-sidebar__brand { - display: inline-flex; - align-items: center; - gap: 0.4rem; -} -.portal-sidebar__brand-mark { +/* Stirling Processor wordmark (theme-switched in Sidebar.tsx); matches the + editor's 22px wordmark so the two apps read as one brand. */ +.portal-sidebar__wordmark { height: 1.375rem; width: auto; display: block; -} -.portal-sidebar__logo-suffix { - font-family: var(--font-brand); - font-size: 1.0625rem; - font-weight: 700; - color: var(--color-text-1); - letter-spacing: 0.01em; - white-space: nowrap; + flex-shrink: 0; } /* App switcher (down-arrow → Portal / Editor); button and menu styling live diff --git a/frontend/editor/src/portal/components/Sidebar.tsx b/frontend/editor/src/portal/components/Sidebar.tsx index 356acc5349..6e21d8b8d2 100644 --- a/frontend/editor/src/portal/components/Sidebar.tsx +++ b/frontend/editor/src/portal/components/Sidebar.tsx @@ -7,8 +7,8 @@ import { useTheme } from "@portal/contexts/ThemeContext"; import { useUI } from "@portal/contexts/UIContext"; import { LinkAccountFooterItem } from "@portal/components/LinkAccountFooterItem"; import { EDITOR_URL, EDITOR_IS_SAME_APP } from "@portal/auth/editorUrl"; -import markLight from "@app/assets/brand/modern-logo/StirlingPDFLogoNoTextLight.svg"; -import markDark from "@app/assets/brand/modern-logo/StirlingPDFLogoNoTextDark.svg"; +import wordmarkLight from "@app/assets/brand/modern-logo/StirlingProcessorLogoBlackText.svg"; +import wordmarkDark from "@app/assets/brand/modern-logo/StirlingProcessorLogoWhiteText.svg"; import { SettingsIcon } from "@portal/components/icons"; import { GROUP_PRIMARY, @@ -61,16 +61,11 @@ export function Sidebar() { aria-label={t("portal.shell.sidebar.primaryNav")} >
- - Stirling - - {t("portal.shell.sidebar.brandSuffix")} - - + {t("portal.shell.sidebar.brandSuffix")} Date: Sat, 11 Jul 2026 12:41:48 +0100 Subject: [PATCH 17/34] Feature/onboarding updates for policies and portal (#6926) --- .../public/locales/en-US/translation.toml | 31 +- .../onboarding/SaasOnboardingModal.tsx | 171 ++------ .../components/onboarding/saasFlowResolver.ts | 37 +- .../onboarding/saasOnboardingFlowConfig.ts | 44 +- .../onboarding/useSaasOnboardingState.ts | 5 +- .../src/core/components/FileManager.tsx | 1 + .../InitialOnboardingModal.module.css | 187 ++++++++- .../InitialOnboardingModal/renderButtons.tsx | 32 +- .../core/components/onboarding/Onboarding.tsx | 201 +++------ .../OnboardingModalSlide.stories.tsx | 208 ++++++++++ .../onboarding/OnboardingModalSlide.tsx | 231 +++-------- .../onboarding/OnboardingSlideShell.tsx | 226 ++++++++++ .../components/onboarding/OnboardingTour.tsx | 32 +- .../onboarding/StaticOnboardingSlide.tsx | 67 +++ .../components/onboarding/adminStepsConfig.ts | 389 ++++++++++-------- .../onboarding/onboardingFlowConfig.ts | 80 ++-- .../onboarding/onboardingSlideTypes.ts | 65 +++ .../orchestrator/onboardingConfig.ts | 23 +- .../orchestrator/onboardingStorage.ts | 79 ++++ .../orchestrator/useOnboardingOrchestrator.ts | 29 +- .../onboarding/slides/DesktopInstallTitle.tsx | 49 ++- .../onboarding/slides/ProcessorIntroSlide.tsx | 30 ++ .../components/onboarding/tourRegistry.ts | 105 +++++ .../components/onboarding/userStepsConfig.ts | 20 +- .../core/components/shared/AppConfigModal.tsx | 2 +- .../core/components/shared/FileSidebar.tsx | 4 + .../core/components/shared/LandingPage.css | 2 +- .../core/components/shared/LandingPage.tsx | 7 - .../components/shared/LoadingFallback.tsx | 4 +- .../shared/SidebarChecklistSlot.tsx | 13 + frontend/editor/src/core/constants/events.ts | 5 +- frontend/editor/src/core/pages/HomePage.tsx | 17 +- .../core/tests/stubbed/main-dashboard.spec.ts | 37 -- .../src/core/utils/settingsNavigation.ts | 7 + .../DesktopSaasOnboardingBootstrap.tsx | 73 +--- .../src/portal/components/LoginScreen.tsx | 30 +- .../saas/components/OnboardingBootstrap.tsx | 33 +- .../onboarding/OnboardingChecklist.module.css | 168 ++++++++ .../onboarding/OnboardingChecklist.tsx | 293 +++++++++++++ .../components/onboarding/OnboardingTour.tsx | 117 +++++- .../saas/components/shared/AppConfigModal.tsx | 18 +- .../shared/SidebarChecklistSlot.tsx | 13 + .../shared/config/saasConfigNavSections.tsx | 42 +- frontend/editor/src/saas/utils/appSettings.ts | 13 +- 44 files changed, 2308 insertions(+), 932 deletions(-) create mode 100644 frontend/editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx create mode 100644 frontend/editor/src/core/components/onboarding/OnboardingSlideShell.tsx create mode 100644 frontend/editor/src/core/components/onboarding/StaticOnboardingSlide.tsx create mode 100644 frontend/editor/src/core/components/onboarding/onboardingSlideTypes.ts create mode 100644 frontend/editor/src/core/components/onboarding/slides/ProcessorIntroSlide.tsx create mode 100644 frontend/editor/src/core/components/onboarding/tourRegistry.ts create mode 100644 frontend/editor/src/core/components/shared/SidebarChecklistSlot.tsx create mode 100644 frontend/editor/src/saas/components/onboarding/OnboardingChecklist.module.css create mode 100644 frontend/editor/src/saas/components/onboarding/OnboardingChecklist.tsx create mode 100644 frontend/editor/src/saas/components/shared/SidebarChecklistSlot.tsx diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index d9ed967d62..d323a5a5f9 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -4647,7 +4647,6 @@ welcomeTitle = "You've been invited!" [landing] addFiles = "Add Files" -heroSubtitle = "Drop in or add an existing PDF to get started." mobileUpload = "Upload from Mobile" openFromComputer = "Open from computer" uploadFromComputer = "Upload from computer" @@ -4962,11 +4961,11 @@ text = "Decide how you want the text output formatted:" title = "Output" [onboarding] -activeFiles = "The Active Files view shows all of the PDFs you have loaded into the tool, and allows you to select which ones to process." +activeFiles = "The Active Files view shows all of the PDFs you have loaded into the tool." allTools = "This is the Tools panel, where you can browse and select from all available PDF tools." close = "Close" cropSettings = "Now that we've selected the file we want crop, we can configure the Crop tool to choose the area that we want to crop the PDF to." -fileCheckbox = "Clicking one of the files selects it for processing. You can select multiple files for batch operations." +fileCheckbox = "Files on the workbench are selected for processing. You can select multiple files for batch operations using the left files sidebar." fileReplacement = "The modified file will replace the original file in the Workbench automatically, allowing you to easily run it through more tools." filesButton = "The Files button on the Quick Access bar allows you to upload PDFs to use the tools on." fileSources = "You can upload new files or access recent files from here. For the tour, we'll just use a sample file." @@ -4976,6 +4975,7 @@ pinButton = "You can use the Pin button if you'd rather your fi results = "After the tool has finished running, the Review step will show a preview of the results in this panel, and allow you to undo the operation or download the file. " runButton = "Once the tool has been configured, this button allows you to run the tool on all the selected PDFs." selectCropTool = "Let's select the Crop tool to demonstrate how to use one of the tools." +stepOf = "Step {{current}} of {{total}}" toolInterface = "This is the Crop tool interface. As you can see, there's not much there because we haven't added any PDF files to work with yet." workbench = "This is the Workbench - the main area where you view and edit your PDFs." wrapUp = "You're all set! You can replay this tour anytime - just open Settings and find it here in the Tours section under Help." @@ -4988,6 +4988,26 @@ showMeAround = "Show me around" skipForNow = "Skip for now" skipTheTour = "Skip the tour" +[onboarding.checklist] +dismiss = "Dismiss" +title = "Set up Stirling PDF" + +[onboarding.checklist.downloadDesktop] +description = "Run Stirling natively on your machine" +title = "Download Stirling for Desktop" + +[onboarding.checklist.inviteTeam] +description = "Collaborate with your team" +title = "Invite team members" + +[onboarding.checklist.shareAnalytics] +description = "Help improve Stirling" +title = "Share anonymous usage data" + +[onboarding.checklist.takeTour] +description = "See how Stirling works in a quick walkthrough" +title = "Take the tour" + [onboarding.desktopInstall] body = "Stirling works best as a desktop app. You can use it offline, access documents faster, and make edits locally on your computer." selectOs = "Select operating system" @@ -5005,6 +5025,11 @@ adminTitle = "Admin Overview" userBody = "Invite teammates, assign roles, and keep your documents organized in one secure workspace. Enable login mode whenever you're ready to grow beyond solo use." userTitle = "Plan Overview" +[onboarding.processorIntro] +body = "Stirling now runs Policies — automated rules that classify, secure, and process every document as it arrives. Set them up and monitor runs in the Processor." +cta = "Check out the Processor" +title = "Check out the Stirling Processor" + [onboarding.saas.freeEditor] freeLine = "The editor is now completely free." premium = "We've added loads of new features, including Policies and Agent Chat." diff --git a/frontend/editor/src/cloud/components/onboarding/SaasOnboardingModal.tsx b/frontend/editor/src/cloud/components/onboarding/SaasOnboardingModal.tsx index 5aa52034a4..48d2b3087f 100644 --- a/frontend/editor/src/cloud/components/onboarding/SaasOnboardingModal.tsx +++ b/frontend/editor/src/cloud/components/onboarding/SaasOnboardingModal.tsx @@ -1,15 +1,16 @@ -import React from "react"; -import { Modal, Stack } from "@mantine/core"; import BoltRoundedIcon from "@mui/icons-material/BoltRounded"; import GroupAddRoundedIcon from "@mui/icons-material/GroupAddRounded"; import { useTranslation } from "react-i18next"; -import AnimatedSlideBackground from "@app/components/onboarding/slides/AnimatedSlideBackground"; -import OnboardingStepper from "@app/components/onboarding/OnboardingStepper"; -import { renderButtons } from "@app/components/onboarding/renderButtons"; -import styles from "@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css"; +import OnboardingSlideShell, { + ShellHero, + type ShellButton, +} from "@app/components/onboarding/OnboardingSlideShell"; import { useSaasOnboardingState } from "@app/components/onboarding/useSaasOnboardingState"; -import { BASE_PATH } from "@app/constants/app"; -import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex"; +import { + type SlideId, + type ButtonAction, + type ButtonDefinition, +} from "@app/components/onboarding/saasOnboardingFlowConfig"; interface SaasOnboardingModalProps { opened: boolean; @@ -20,6 +21,7 @@ interface SaasOnboardingModalProps { * false (slide shown) so the web (saas) flow is unchanged. */ hideDesktopInstall?: boolean; + slideIds?: SlideId[]; } export default function SaasOnboardingModal(props: SaasOnboardingModalProps) { @@ -39,130 +41,45 @@ export default function SaasOnboardingModal(props: SaasOnboardingModalProps) { handleButtonAction, } = flow; - const renderHero = () => { - if (slideDefinition.hero.type === "dual-icon") { - return ( -
-
- Stirling icon -
-
- ); - } - - if (slideDefinition.hero.type === "logo") { - return ( - Stirling logo - ); - } - - return ( -
- {slideDefinition.hero.type === "bolt" && ( - - )} - {slideDefinition.hero.type === "team" && ( - - )} -
+ const heroType = slideDefinition.hero.type; + const hero = + heroType === "dual-icon" || heroType === "logo" ? ( + + ) : ( + + {heroType === "bolt" && } + {heroType === "team" && } + ); + + const resolveLabel = (button: ButtonDefinition) => { + const label = button.label ?? ""; + if (!label) return ""; + const fallback = label.split(".").pop() || label; + return t(label, fallback); }; + const buttons: ShellButton[] = slideDefinition.buttons.map((button) => ({ + key: button.key, + back: button.type === "icon", + label: resolveLabel(button), + primary: (button.variant ?? "secondary") === "primary", + action: button.action, + disabled: button.disabledWhen?.(flowState) ?? false, + })); + return ( - handleButtonAction(action as ButtonAction)} onClose={props.onClose} - closeOnClickOutside={false} - centered - size="lg" - radius="lg" - withCloseButton={false} - zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE} - styles={{ - body: { padding: 0 }, - content: { - overflow: "hidden", - border: "none", - background: "var(--bg-surface)", - maxHeight: "90vh", - display: "flex", - flexDirection: "column", - }, - }} - > - -
- -
- {renderHero()} -
-
- -
- -
- {currentSlide.title} -
- -
-
- {currentSlide.body} -
- -
- - - -
- {renderButtons({ - slideDefinition, - flowState, - onAction: handleButtonAction, - t, - })} -
-
-
-
-
+ /> ); } diff --git a/frontend/editor/src/cloud/components/onboarding/saasFlowResolver.ts b/frontend/editor/src/cloud/components/onboarding/saasFlowResolver.ts index b6fb199730..436a9b4bba 100644 --- a/frontend/editor/src/cloud/components/onboarding/saasFlowResolver.ts +++ b/frontend/editor/src/cloud/components/onboarding/saasFlowResolver.ts @@ -1,4 +1,8 @@ import { SlideId } from "@app/components/onboarding/saasOnboardingFlowConfig"; +import { + resolveFlowIds, + type FlowStep, +} from "@app/components/onboarding/onboardingSlideTypes"; export interface SaasFlowInputs { /** Free-tier wallet with one-time allowance remaining — show the usage meter. */ @@ -13,22 +17,23 @@ export interface SaasFlowInputs { hideDesktopInstall?: boolean; } +// The SaaS flow as data: free-editor and desktop-install bookend it; the usage +// meter and team slides slot in when their conditions hold. This is the same +// "flow = the steps that apply, in order" shape the core flow uses, resolved +// through the shared {@link resolveFlowIds} helper. +const SAAS_FLOW: FlowStep[] = [ + { id: "free-editor", when: () => true }, + { id: "usage", when: (input) => input.showUsageSlide }, + { id: "team", when: (input) => input.showTeamSlide }, + { id: "desktop-install", when: (input) => !input.hideDesktopInstall }, +]; + /** - * Resolves the SaaS onboarding slide sequence. The free-editor pitch and - * desktop install bookend the flow; the usage meter and team slides slot in - * when their conditions hold. When {@link SaasFlowInputs.hideDesktopInstall} is - * set, the closing desktop-install slide is dropped (used by the desktop app, - * which has no reason to pitch its own download). + * Resolves the SaaS onboarding slide sequence. When + * {@link SaasFlowInputs.hideDesktopInstall} is set, the closing desktop-install + * slide is dropped (used by the desktop app, which has no reason to pitch its + * own download). */ -export function resolveSaasFlow({ - showUsageSlide, - showTeamSlide, - hideDesktopInstall = false, -}: SaasFlowInputs): SlideId[] { - return [ - "free-editor", - ...(showUsageSlide ? (["usage"] as const) : []), - ...(showTeamSlide ? (["team"] as const) : []), - ...(hideDesktopInstall ? [] : (["desktop-install"] as const)), - ]; +export function resolveSaasFlow(inputs: SaasFlowInputs): SlideId[] { + return resolveFlowIds(SAAS_FLOW, inputs); } diff --git a/frontend/editor/src/cloud/components/onboarding/saasOnboardingFlowConfig.ts b/frontend/editor/src/cloud/components/onboarding/saasOnboardingFlowConfig.ts index 91af54611b..78dc40b829 100644 --- a/frontend/editor/src/cloud/components/onboarding/saasOnboardingFlowConfig.ts +++ b/frontend/editor/src/cloud/components/onboarding/saasOnboardingFlowConfig.ts @@ -2,7 +2,14 @@ import FreeEditorSlide from "@app/components/onboarding/slides/FreeEditorSlide"; import UsageSnapshotSlide from "@app/components/onboarding/slides/UsageSnapshotSlide"; import TeamSlide from "@app/components/onboarding/slides/TeamSlide"; import DesktopInstallSlide from "@app/components/onboarding/slides/DesktopInstallSlide"; -import { SlideConfig } from "@app/types/types"; +import type { + OSOption, + ButtonDefinition as ButtonDefinitionBase, + HeroDefinition as HeroDefinitionBase, + SlideDefinition as SlideDefinitionBase, +} from "@app/components/onboarding/onboardingSlideTypes"; + +export type { OSOption }; export type SlideId = "free-editor" | "usage" | "team" | "desktop-install"; @@ -12,12 +19,6 @@ export type ButtonAction = "next" | "prev" | "close" | "download-selected"; export type FlowState = Record; -export interface OSOption { - label: string; - url: string; - value: string; -} - export interface SlideFactoryParams { osLabel: string; osUrl: string; @@ -25,27 +26,17 @@ export interface SlideFactoryParams { onDownloadUrlChange?: (url: string) => void; } -export interface HeroDefinition { - type: HeroType; -} +export type HeroDefinition = HeroDefinitionBase; -export interface ButtonDefinition { - key: string; - type: "button" | "icon"; - label?: string; - icon?: "chevron-left"; - variant?: "primary" | "secondary" | "default"; - group: "left" | "right"; - action: ButtonAction; - disabledWhen?: (state: FlowState) => boolean; -} +export type ButtonDefinition = ButtonDefinitionBase; -export interface SlideDefinition { - id: SlideId; - createSlide: (params: SlideFactoryParams) => SlideConfig; - hero: HeroDefinition; - buttons: ButtonDefinition[]; -} +export type SlideDefinition = SlideDefinitionBase< + SlideId, + ButtonAction, + FlowState, + HeroType, + SlideFactoryParams +>; const BACK_BUTTON: ButtonDefinition = { key: "back", @@ -95,7 +86,6 @@ export const SLIDE_DEFINITIONS: Record = { DesktopInstallSlide({ osLabel, osUrl, osOptions, onDownloadUrlChange }), hero: { type: "dual-icon" }, buttons: [ - { ...BACK_BUTTON, key: "desktop-back" }, { key: "desktop-skip", type: "button", diff --git a/frontend/editor/src/cloud/components/onboarding/useSaasOnboardingState.ts b/frontend/editor/src/cloud/components/onboarding/useSaasOnboardingState.ts index 0b76c38a8f..22f41ec12d 100644 --- a/frontend/editor/src/cloud/components/onboarding/useSaasOnboardingState.ts +++ b/frontend/editor/src/cloud/components/onboarding/useSaasOnboardingState.ts @@ -31,12 +31,14 @@ interface UseSaasOnboardingStateProps { * (slide shown) so the web (saas) flow is unchanged. */ hideDesktopInstall?: boolean; + slideIds?: SlideId[]; } export function useSaasOnboardingState({ opened, onClose, hideDesktopInstall = false, + slideIds, }: UseSaasOnboardingStateProps): UseSaasOnboardingStateResult | null { const { loading } = useAuth(); const { wallet } = useWallet(); @@ -89,8 +91,9 @@ export function useSaasOnboardingState({ const flowSlideIds = useMemo( () => + slideIds ?? resolveSaasFlow({ showUsageSlide, showTeamSlide, hideDesktopInstall }), - [showUsageSlide, showTeamSlide, hideDesktopInstall], + [slideIds, showUsageSlide, showTeamSlide, hideDesktopInstall], ); const totalSteps = flowSlideIds.length; const maxIndex = Math.max(totalSteps - 1, 0); diff --git a/frontend/editor/src/core/components/FileManager.tsx b/frontend/editor/src/core/components/FileManager.tsx index 16cded856c..d05d05ea2b 100644 --- a/frontend/editor/src/core/components/FileManager.tsx +++ b/frontend/editor/src/core/components/FileManager.tsx @@ -198,6 +198,7 @@ const FileManager: React.FC = ({ selectedTool }) => { }} >
btn.group === "left", + // Back/icon buttons anchor the left edge; all text actions (skip + primary) + // cluster together on the right, matching the onboarding card layout. + const backButtons = slideDefinition.buttons.filter( + (btn) => btn.type === "icon", ); - const rightButtons = slideDefinition.buttons.filter( - (btn) => btn.group === "right", + const actionButtons = slideDefinition.buttons.filter( + (btn) => btn.type !== "icon", ); const resolveButtonLabel = (button: ButtonDefinition) => { @@ -75,7 +77,7 @@ export function SlideButtons({ ); } - const variant = button.variant ?? "secondary"; + const isPrimary = (button.variant ?? "secondary") === "primary"; const label = resolveButtonLabel(button); return ( @@ -83,28 +85,24 @@ export function SlideButtons({ key={button.key} onClick={() => onAction(button.action)} disabled={disabled} - variant={variant === "primary" ? "primary" : "secondary"} - accent={ - button.accent ?? (variant === "primary" ? "default" : "neutral") - } + variant={isPrimary ? "primary" : "quiet"} + accent={button.accent ?? (isPrimary ? "default" : "neutral")} > {label} ); }; - if (leftButtons.length === 0) { - return {rightButtons.map(renderButton)}; - } + const actions = {actionButtons.map(renderButton)}; - if (rightButtons.length === 0) { - return {leftButtons.map(renderButton)}; + if (backButtons.length === 0) { + return {actions}; } return ( - - {leftButtons.map(renderButton)} - {rightButtons.map(renderButton)} + + {backButtons.map(renderButton)} + {actions} ); } diff --git a/frontend/editor/src/core/components/onboarding/Onboarding.tsx b/frontend/editor/src/core/components/onboarding/Onboarding.tsx index 86976784e9..90fde8f79f 100644 --- a/frontend/editor/src/core/components/onboarding/Onboarding.tsx +++ b/frontend/editor/src/core/components/onboarding/Onboarding.tsx @@ -11,6 +11,7 @@ import OnboardingTour, { type CloseArgs, } from "@app/components/onboarding/OnboardingTour"; import OnboardingModalSlide from "@app/components/onboarding/OnboardingModalSlide"; +import StaticOnboardingSlide from "@app/components/onboarding/StaticOnboardingSlide"; import { useServerLicenseRequest, useTourRequest, @@ -24,9 +25,7 @@ import { import ToolPanelModePrompt from "@app/components/tools/ToolPanelModePrompt"; import { useTourOrchestration } from "@app/contexts/TourOrchestrationContext"; import { useAdminTourOrchestration } from "@app/contexts/AdminTourOrchestrationContext"; -import { createUserStepsConfig } from "@app/components/onboarding/userStepsConfig"; -import { createAdminStepsConfig } from "@app/components/onboarding/adminStepsConfig"; -import { createWhatsNewStepsConfig } from "@app/components/onboarding/whatsNewStepsConfig"; +import { getTourSteps } from "@app/components/onboarding/tourRegistry"; import { removeAllGlows } from "@app/components/onboarding/tourGlow"; import { useFilesModalContext } from "@app/contexts/FilesModalContext"; import { useServerExperience } from "@app/hooks/useServerExperience"; @@ -180,6 +179,10 @@ export default function Onboarding() { setIsTourOpen(true); break; } + case "open-processor": + actions.complete(); + navigate("/portal"); + break; case "skip-to-license": actions.complete(); break; @@ -220,77 +223,25 @@ export default function Onboarding() { const tourOrch = useTourOrchestration(); const adminTourOrch = useAdminTourOrchestration(); - const userStepsConfig = useMemo( + const tourSteps = useMemo( () => - createUserStepsConfig({ + getTourSteps(runtimeState.tourType, { t, - actions: { - saveWorkbenchState: tourOrch.saveWorkbenchState, - closeFilesModal, - backToAllTools: tourOrch.backToAllTools, - selectCropTool: tourOrch.selectCropTool, - loadSampleFile: tourOrch.loadSampleFile, - switchToActiveFiles: tourOrch.switchToActiveFiles, - pinFile: tourOrch.pinFile, - revealFileCardHoverMenu: tourOrch.revealFileCardHoverMenu, - modifyCropSettings: tourOrch.modifyCropSettings, - executeTool: tourOrch.executeTool, - openFilesModal, - openSettingsHelpSection: () => - adminTourOrch.navigateToSection("help"), - }, + workbench: tourOrch, + admin: adminTourOrch, + openFilesModal, + closeFilesModal, }), - [t, tourOrch, adminTourOrch, closeFilesModal, openFilesModal], + [ + runtimeState.tourType, + t, + tourOrch, + adminTourOrch, + openFilesModal, + closeFilesModal, + ], ); - const whatsNewStepsConfig = useMemo( - () => - createWhatsNewStepsConfig({ - t, - actions: { - saveWorkbenchState: tourOrch.saveWorkbenchState, - closeFilesModal, - backToAllTools: tourOrch.backToAllTools, - openFilesModal, - loadSampleFile: tourOrch.loadSampleFile, - switchToViewer: tourOrch.switchToViewer, - switchToPageEditor: tourOrch.switchToPageEditor, - switchToActiveFiles: tourOrch.switchToActiveFiles, - }, - }), - [t, tourOrch, closeFilesModal, openFilesModal], - ); - - const adminStepsConfig = useMemo( - () => - createAdminStepsConfig({ - t, - actions: { - saveAdminState: adminTourOrch.saveAdminState, - openConfigModal: adminTourOrch.openConfigModal, - navigateToSection: adminTourOrch.navigateToSection, - scrollNavToSection: adminTourOrch.scrollNavToSection, - }, - }), - [t, adminTourOrch], - ); - - const tourSteps = useMemo(() => { - switch (runtimeState.tourType) { - case "admin": - return Object.values(adminStepsConfig); - case "whatsnew": - return Object.values(whatsNewStepsConfig); - default: - return Object.values(userStepsConfig); - } - }, [ - adminStepsConfig, - runtimeState.tourType, - userStepsConfig, - whatsNewStepsConfig, - ]); - useEffect(() => { if (externalTourRequested) { actions.updateRuntimeState({ tourType: requestedTourType }); @@ -427,26 +378,18 @@ export default function Onboarding() { return null; } - // Show analytics modal before onboarding if needed + // Interrupt slides shown outside the normal step flow. Precedence is + // preserved by evaluation order: analytics consent → first login → MFA → + // external license notice. if (showAnalyticsModal) { - const slideDefinition = SLIDE_DEFINITIONS["analytics-choice"]; - const slideContent = slideDefinition.createSlide({ - osLabel: "", - osUrl: "", - selectedRole: null, - onRoleSelect: () => {}, - analyticsError, - analyticsLoading, - }); - return ( - {}} // No skip allowed + allowDismiss={false} onAction={async (action) => { if (action === "enable-analytics") { await handleAnalyticsChoice(true); @@ -454,102 +397,72 @@ export default function Onboarding() { await handleAnalyticsChoice(false); } }} - allowDismiss={false} /> ); } if (firstLoginModalOpen) { - const baseSlideDefinition = SLIDE_DEFINITIONS["first-login"]; - const slideContent = baseSlideDefinition.createSlide({ - osLabel: "", - osUrl: "", - selectedRole: null, - onRoleSelect: () => {}, - firstLoginUsername: runtimeState.firstLoginUsername, - onPasswordChanged: handlePasswordChanged, - usingDefaultCredentials: runtimeState.usingDefaultCredentials, - }); - return ( - {}} - onAction={async (action) => { + allowDismiss={false} + onAction={(action) => { if (action === "complete-close") { handlePasswordChanged(); } }} - allowDismiss={false} /> ); } if (mfaModalOpen) { console.log("[Onboarding] Rendering MFA setup modal slide."); - const baseSlideDefinition = SLIDE_DEFINITIONS["mfa-setup"]; - const slideContent = baseSlideDefinition.createSlide({ - osLabel: "", - osUrl: "", - selectedRole: null, - onRoleSelect: () => {}, - onMfaSetupComplete: handleMfaSetupComplete, - }); - return ( - {}} - onAction={async (action) => { + allowDismiss={false} + onAction={(action) => { if (action === "complete-close") { handleMfaSetupComplete(); } }} - allowDismiss={false} /> ); } if (showLicenseSlide) { - const baseSlideDefinition = SLIDE_DEFINITIONS["server-license"]; - // Remove back button for external license notice - const slideDefinition = { - ...baseSlideDefinition, - buttons: baseSlideDefinition.buttons.filter( - (btn) => btn.key !== "license-back", - ), - }; const effectiveLicenseNotice = externalLicenseNotice || runtimeState.licenseNotice; - const slideContent = slideDefinition.createSlide({ - osLabel: "", - osUrl: "", - osOptions: [], - onDownloadUrlChange: () => {}, - selectedRole: null, - onRoleSelect: () => {}, - licenseNotice: effectiveLicenseNotice, - loginEnabled: serverExperience.loginEnabled, - }); - return ( - + buttons.filter((btn) => btn.key !== "license-back") + } runtimeState={{ ...runtimeState, licenseNotice: effectiveLicenseNotice, }} - modalSlideCount={1} - currentModalSlideIndex={0} + params={{ + osOptions: [], + onDownloadUrlChange: () => {}, + licenseNotice: effectiveLicenseNotice, + loginEnabled: serverExperience.loginEnabled, + }} onSkip={closeLicenseSlide} onAction={(action) => { if (action === "see-plans") { diff --git a/frontend/editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx b/frontend/editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx new file mode 100644 index 0000000000..f493051403 --- /dev/null +++ b/frontend/editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx @@ -0,0 +1,208 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +// The shared preview only loads the portal tokens; the onboarding modal reads +// the editor theme tokens (--bg-surface, --onboarding-title, …), so load them +// here or the modal surface renders transparent over the dark overlay. +import "@app/styles/theme.css"; +import OnboardingModalSlide from "@app/components/onboarding/OnboardingModalSlide"; +import { + SLIDE_DEFINITIONS, + type SlideId, + type SlideFactoryParams, +} from "@app/components/onboarding/onboardingFlowConfig"; +import { + DEFAULT_RUNTIME_STATE, + type OnboardingRuntimeState, +} from "@app/components/onboarding/orchestrator/onboardingConfig"; + +/** + * Every onboarding modal slide, rendered through the real + * {@link OnboardingModalSlide} + {@link SLIDE_DEFINITIONS} so design changes + * here reflect the production flow. Each story is one slide (or a meaningful + * variant of one), including its hero, stepper and action buttons. + */ + +// Sensible defaults for every slide factory; individual stories override the +// few fields their slide actually reads. +const BASE_PARAMS: SlideFactoryParams = { + osLabel: "macOS (Apple Silicon)", + osUrl: "#", + osOptions: [ + { label: "macOS (Apple Silicon)", url: "#", value: "mac-arm" }, + { label: "macOS (Intel)", url: "#", value: "mac-intel" }, + { label: "Windows", url: "#", value: "windows" }, + { label: "Linux", url: "#", value: "linux" }, + ], + onDownloadUrlChange: () => {}, + selectedRole: null, + onRoleSelect: () => {}, + licenseNotice: { + totalUsers: null, + freeTierLimit: 5, + isOverLimit: false, + requiresLicense: false, + }, + loginEnabled: true, + firstLoginUsername: "admin", + onPasswordChanged: () => {}, + usingDefaultCredentials: false, + analyticsError: null, + analyticsLoading: false, + onMfaSetupComplete: () => {}, +}; + +interface SlideStageProps { + slideId: SlideId; + params?: Partial; + runtime?: Partial; + allowDismiss?: boolean; + /** + * Total steps in the flow. Defaults to 1 → a single standalone card with no + * progress bar or step pill (how the SaaS checklist items render). Set > 1 + * only to demonstrate the stepped-flow treatment. + */ + stepCount?: number; + /** 0-based active step, used only when stepCount > 1. */ + stepIndex?: number; +} + +function SlideStage({ + slideId, + params, + runtime, + allowDismiss = true, + stepCount = 1, + stepIndex = 0, +}: SlideStageProps) { + const merged: SlideFactoryParams = { ...BASE_PARAMS, ...params }; + // Live role selection so the SecurityCheck dropdown + its gated "Next" button + // behave the same way they do in the real flow. + const [selectedRole, setSelectedRole] = useState(merged.selectedRole); + + const definition = SLIDE_DEFINITIONS[slideId]; + const slideContent = definition.createSlide({ + ...merged, + selectedRole, + onRoleSelect: setSelectedRole, + }); + + const runtimeState: OnboardingRuntimeState = { + ...DEFAULT_RUNTIME_STATE, + ...runtime, + selectedRole, + licenseNotice: merged.licenseNotice ?? DEFAULT_RUNTIME_STATE.licenseNotice, + }; + + return ( + {}} + onAction={() => {}} + allowDismiss={allowDismiss} + /> + ); +} + +const meta: Meta = { + title: "Onboarding/Modal Slides", + component: SlideStage, + parameters: { layout: "fullscreen" }, +}; +export default meta; + +type Story = StoryObj; + +/** "Welcome to Stirling" — the V2 intro slide (rocket hero). */ +export const Welcome: Story = { args: { slideId: "welcome" } }; + +/** The only stepped example: a multi-step flow shows the step pill + progress + * bar. Every other story is a standalone single card (no steps). */ +export const SteppedFlowExample: Story = { + args: { slideId: "admin-overview", stepCount: 9, stepIndex: 4 }, +}; + +/** Force a password change on first login (user types current + new). */ +export const FirstLogin: Story = { args: { slideId: "first-login" } }; + +/** First login when the account is still on the default `stirling` password — + * the current-password field is hidden. */ +export const FirstLoginDefaultCredentials: Story = { + args: { + slideId: "first-login", + params: { usingDefaultCredentials: true }, + }, +}; + +/** Desktop app download prompt with an OS picker (dual-icon hero). */ +export const DesktopInstall: Story = { args: { slideId: "desktop-install" } }; + +/** Role confirmation — the "Next" button stays disabled until a role is picked. */ +export const SecurityCheck: Story = { args: { slideId: "security-check" } }; + +/** Admin overview with login mode already enabled (diamond hero). */ +export const AdminOverviewLoginEnabled: Story = { + args: { + slideId: "admin-overview", + params: { loginEnabled: true }, + }, +}; + +/** Admin overview before login mode is enabled — different body copy. */ +export const AdminOverviewLoginDisabled: Story = { + args: { + slideId: "admin-overview", + params: { loginEnabled: false }, + }, +}; + +/** Server license, within the free tier. */ +export const ServerLicense: Story = { + args: { + slideId: "server-license", + params: { + licenseNotice: { + totalUsers: 3, + freeTierLimit: 5, + isOverLimit: false, + requiresLicense: false, + }, + }, + }, +}; + +/** Server license, over the free-tier seat limit — "Upgrade now" CTA. */ +export const ServerLicenseOverLimit: Story = { + args: { + slideId: "server-license", + params: { + licenseNotice: { + totalUsers: 12, + freeTierLimit: 5, + isOverLimit: true, + requiresLicense: true, + }, + }, + }, +}; + +/** Quick tour offer before dropping the user into the tools. */ +export const TourOverview: Story = { args: { slideId: "tour-overview" } }; + +/** Opt-in analytics choice (analytics hero). */ +export const AnalyticsChoice: Story = { args: { slideId: "analytics-choice" } }; + +/** Analytics choice showing an error banner (e.g. save failed). */ +export const AnalyticsChoiceError: Story = { + args: { + slideId: "analytics-choice", + params: { analyticsError: "Couldn't save your analytics preference." }, + }, +}; + +/** Two-factor setup (fetches a QR on mount; shows its error state without a + * backend in Storybook). */ +export const MfaSetup: Story = { args: { slideId: "mfa-setup" } }; diff --git a/frontend/editor/src/core/components/onboarding/OnboardingModalSlide.tsx b/frontend/editor/src/core/components/onboarding/OnboardingModalSlide.tsx index b8135739bd..7c54713665 100644 --- a/frontend/editor/src/core/components/onboarding/OnboardingModalSlide.tsx +++ b/frontend/editor/src/core/components/onboarding/OnboardingModalSlide.tsx @@ -1,29 +1,26 @@ /** - * OnboardingModalSlide Component + * OnboardingModalSlide * - * Renders a single modal slide in the onboarding flow. - * Handles the hero image, content, stepper, and button actions. + * Editor-flow adapter over the shared {@link OnboardingSlideShell}: maps a + * SLIDE_DEFINITIONS entry (hero + buttons) and its resolved content onto the + * shell so the editor onboarding renders the same card as every other flow. */ -import React from "react"; -import { Modal, Stack } from "@mantine/core"; import { useTranslation } from "react-i18next"; -import { ActionIcon } from "@app/ui/ActionIcon"; import DiamondOutlinedIcon from "@mui/icons-material/DiamondOutlined"; import type { SlideDefinition, ButtonAction, + ButtonDefinition, } from "@app/components/onboarding/onboardingFlowConfig"; import type { OnboardingRuntimeState } from "@app/components/onboarding/orchestrator/onboardingConfig"; import type { SlideConfig } from "@app/types/types"; -import AnimatedSlideBackground from "@app/components/onboarding/slides/AnimatedSlideBackground"; -import OnboardingStepper from "@app/components/onboarding/OnboardingStepper"; -import { SlideButtons } from "@app/components/onboarding/InitialOnboardingModal/renderButtons"; +import OnboardingSlideShell, { + ShellHero, + type ShellButton, +} from "@app/components/onboarding/OnboardingSlideShell"; import LocalIcon from "@app/components/shared/LocalIcon"; -import { BASE_PATH } from "@app/constants/app"; -import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex"; -import styles from "@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css"; interface OnboardingModalSlideProps { slideDefinition: SlideDefinition; @@ -36,6 +33,13 @@ interface OnboardingModalSlideProps { allowDismiss?: boolean; } +const HERO_ICON: Record = { + rocket: "rocket-launch", + shield: "verified-user-outline", + lock: "lock-outline", + analytics: "analytics", +}; + export default function OnboardingModalSlide({ slideDefinition, slideContent, @@ -47,165 +51,60 @@ export default function OnboardingModalSlide({ allowDismiss = true, }: OnboardingModalSlideProps) { const { t } = useTranslation(); - const renderHero = () => { - if (slideDefinition.hero.type === "dual-icon") { - return ( -
-
- Stirling icon -
-
- ); - } + const { licenseNotice } = runtimeState; + const flowState = { selectedRole: runtimeState.selectedRole }; - return ( -
- {slideDefinition.hero.type === "rocket" && ( - - )} - {slideDefinition.hero.type === "shield" && ( - - )} - {slideDefinition.hero.type === "lock" && ( - - )} - {slideDefinition.hero.type === "analytics" && ( - - )} - {slideDefinition.hero.type === "diamond" && ( - - )} - {slideDefinition.hero.type === "logo" && ( - Stirling logo - )} -
+ const heroType = slideDefinition.hero.type; + const hero = + heroType === "dual-icon" || heroType === "logo" ? ( + + ) : ( + + {heroType === "diamond" ? ( + + ) : HERO_ICON[heroType] ? ( + + ) : null} + ); + + const resolveLabel = (button: ButtonDefinition) => { + if ( + button.type === "button" && + slideDefinition.id === "server-license" && + button.action === "see-plans" && + licenseNotice.isOverLimit + ) { + return t("onboarding.serverLicense.upgrade", "Upgrade now →"); + } + const label = button.label ?? ""; + if (!label) return ""; + const fallback = label.split(".").pop() || label; + return t(label, fallback); }; + const buttons: ShellButton[] = slideDefinition.buttons.map((button) => ({ + key: button.key, + back: button.type === "icon", + label: resolveLabel(button), + primary: (button.variant ?? "secondary") === "primary", + accent: button.accent, + action: button.action, + disabled: button.disabledWhen?.(flowState) ?? false, + })); + return ( - onAction(action as ButtonAction)} onClose={onSkip} - closeOnClickOutside={false} - closeOnEscape={allowDismiss} - centered - size="lg" - radius="lg" - withCloseButton={false} - zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE} - styles={{ - body: { padding: 0, maxHeight: "90vh", overflow: "hidden" }, - content: { - overflow: "hidden", - border: "none", - background: "var(--bg-surface)", - maxHeight: "90vh", - }, - }} - > - -
- - {allowDismiss && ( - - - - )} -
- {renderHero()} -
-
- -
- -
- {slideContent.title} -
- -
-
- {slideContent.body} -
- -
- - {modalSlideCount > 1 && ( - - )} - -
- -
-
-
-
-
+ allowDismiss={allowDismiss} + /> ); } diff --git a/frontend/editor/src/core/components/onboarding/OnboardingSlideShell.tsx b/frontend/editor/src/core/components/onboarding/OnboardingSlideShell.tsx new file mode 100644 index 0000000000..3a8a2a57e4 --- /dev/null +++ b/frontend/editor/src/core/components/onboarding/OnboardingSlideShell.tsx @@ -0,0 +1,226 @@ +import type { ReactNode } from "react"; +import { Modal } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { ActionIcon } from "@app/ui/ActionIcon"; +import { Button, type ButtonAccent } from "@app/ui/Button"; +import ChevronLeftIcon from "@mui/icons-material/ChevronLeft"; +import LocalIcon from "@app/components/shared/LocalIcon"; +import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex"; +import stirlingMark from "@app/assets/brand/modern-logo/logo512.png"; +import styles from "@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css"; + +/** A footer button. `action` is an opaque string handled by the caller. */ +export interface ShellButton { + key: string; + /** Chevron-left icon button (a back control) instead of a labelled button. */ + back?: boolean; + label?: string; + /** Filled primary (blue) vs. quiet text button. */ + primary?: boolean; + /** Accent override for a primary button (e.g. "premium"). */ + accent?: ButtonAccent; + action: string; + disabled?: boolean; +} + +export interface OnboardingSlideShellProps { + opened?: boolean; + /** Hero art node — use {@link ShellHero} to render the app mark or a glyph. */ + hero: ReactNode; + slideKey: string; + title: ReactNode; + body: ReactNode; + stepIndex: number; + stepCount: number; + buttons: ShellButton[]; + onAction: (action: string) => void; + onClose: () => void; + allowDismiss?: boolean; +} + +/** + * Hero art for the inset panel. `appIcon` renders the Stirling app mark + * directly; otherwise the children glyph sits inside a soft white tile. + */ +export function ShellHero({ + appIcon = false, + children, +}: { + appIcon?: boolean; + children?: ReactNode; +}) { + if (appIcon) { + return ( + Stirling + ); + } + return
{children}
; +} + +/** + * Shared onboarding slide chrome: branded header + step progress, an inset + * hero panel, left-aligned title/body, and a right-aligned action footer. + * Generic over button actions so every flow (editor, SaaS, portal) renders + * the same card. + */ +export default function OnboardingSlideShell({ + opened = true, + hero, + slideKey, + title, + body, + stepIndex, + stepCount, + buttons, + onAction, + onClose, + allowDismiss = true, +}: OnboardingSlideShellProps) { + const { t } = useTranslation(); + const showProgress = stepCount > 1; + + // Back/icon buttons anchor the left; text actions cluster on the right. + // A back control can't do anything on the first slide, so hide it there. + const backButtons = stepIndex === 0 ? [] : buttons.filter((b) => b.back); + const actionButtons = buttons.filter((b) => !b.back); + + const renderButton = (button: ShellButton) => ( + + ); + + const actions = ( +
{actionButtons.map(renderButton)}
+ ); + + return ( + +
+
+
+ + Stirling +
+
+ {showProgress && ( + + {t("onboarding.stepOf", "Step {{current}} of {{total}}", { + current: stepIndex + 1, + total: stepCount, + })} + + )} + {allowDismiss && ( + + + + )} +
+
+ + {showProgress && ( +
+ {Array.from({ length: stepCount }, (_, index) => ( + + ))} +
+ )} + +
+ +
+
+
+ {hero} +
+
+ +
+ {title} +
+ +
+ {body} + +
+ +
+ {backButtons.length === 0 ? ( +
{actions}
+ ) : ( +
+
+ {backButtons.map((button) => ( + onAction(button.action)} + variant="tertiary" + accent="neutral" + disabled={button.disabled} + aria-label={t("onboarding.buttons.back", "Back")} + > + + + ))} +
+ {actions} +
+ )} +
+
+
+ + ); +} diff --git a/frontend/editor/src/core/components/onboarding/OnboardingTour.tsx b/frontend/editor/src/core/components/onboarding/OnboardingTour.tsx index c38d9f952b..f9c66385d6 100644 --- a/frontend/editor/src/core/components/onboarding/OnboardingTour.tsx +++ b/frontend/editor/src/core/components/onboarding/OnboardingTour.tsx @@ -6,7 +6,7 @@ * when the tour is open but onboarding is inactive. */ -import React from "react"; +import React, { type ReactNode } from "react"; import { TourProvider, useTour, type StepType } from "@reactour/tour"; import { ActionIcon } from "@app/ui/ActionIcon"; import ArrowForwardIcon from "@mui/icons-material/ArrowForward"; @@ -15,6 +15,19 @@ import CheckIcon from "@mui/icons-material/Check"; import type { TFunction } from "i18next"; import i18n from "@app/i18n"; +/** + * Renders tour copy that uses for emphasis as real React elements, + * so there is no raw-HTML injection surface (avoids dangerouslySetInnerHTML). + * Any other markup is shown as plain text. + */ +export function renderTourContent(content: string): ReactNode { + return content.split(/(.*?<\/strong>)/g).map((part, index) => { + const match = part.match(/^(.*?)<\/strong>$/); + if (match) return {match[1]}; + return {part}; + }); +} + /** * TourContent - Controls the tour visibility * Syncs the forceOpen prop with the reactour tour state. @@ -49,12 +62,14 @@ interface CloseArgs { interface OnboardingTourProps { tourSteps: StepType[]; - tourType: "admin" | "tools" | "whatsnew"; + // Open tour id (see tourRegistry.ts); "admin" gets the dark mask. + tourType: string; isRTL: boolean; t: TFunction; isOpen: boolean; onAdvance: (args: AdvanceArgs) => void; onClose: (args: CloseArgs) => void; + dimBackground?: boolean; } export default function OnboardingTour({ @@ -65,6 +80,7 @@ export default function OnboardingTour({ isOpen, onAdvance, onClose, + dimBackground = true, }: OnboardingTourProps) { if (!isOpen) return null; @@ -99,6 +115,11 @@ export default function OnboardingTour({ boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)", maxWidth: "400px", }), + maskWrapper: (base) => ({ + ...base, + // 0 = no dim: the page stays fully visible behind the popover. + opacity: dimBackground ? 0.7 : 0, + }), maskArea: (base) => ({ ...base, rx: 8, @@ -162,10 +183,9 @@ export default function OnboardingTour({ ), Content: ({ content }: { content: string }) => ( -
+
+ {renderTourContent(content)} +
), }} > diff --git a/frontend/editor/src/core/components/onboarding/StaticOnboardingSlide.tsx b/frontend/editor/src/core/components/onboarding/StaticOnboardingSlide.tsx new file mode 100644 index 0000000000..2ba340f47e --- /dev/null +++ b/frontend/editor/src/core/components/onboarding/StaticOnboardingSlide.tsx @@ -0,0 +1,67 @@ +import OnboardingModalSlide from "@app/components/onboarding/OnboardingModalSlide"; +import { + SLIDE_DEFINITIONS, + type ButtonAction, + type ButtonDefinition, + type SlideFactoryParams, + type SlideId, +} from "@app/components/onboarding/onboardingFlowConfig"; +import type { OnboardingRuntimeState } from "@app/components/onboarding/orchestrator/onboardingConfig"; + +interface StaticOnboardingSlideProps { + slideId: SlideId; + runtimeState: OnboardingRuntimeState; + onSkip: () => void; + onAction: (action: ButtonAction) => void; + allowDismiss?: boolean; + /** Slide-specific createSlide params layered over the empty defaults. */ + params?: Partial; + /** Optional transform of the definition's buttons (e.g. drop a back button). */ + transformButtons?: (buttons: ButtonDefinition[]) => ButtonDefinition[]; +} + +/** + * Renders a single onboarding slide outside the normal step flow — the + * "interrupt" modals (analytics consent, first-login, MFA, external license + * notice). Each of these was previously an inline early-return in Onboarding + * that duplicated the same createSlide + OnboardingModalSlide boilerplate. + * + * Rendered as its own component (keyed by slideId at the call site) so the + * slide's internal hooks live in an isolated, stable scope rather than the + * parent's, which avoids hook-order churn when the active interrupt changes. + */ +export default function StaticOnboardingSlide({ + slideId, + runtimeState, + onSkip, + onAction, + allowDismiss, + params, + transformButtons, +}: StaticOnboardingSlideProps) { + const base = SLIDE_DEFINITIONS[slideId]; + const definition = transformButtons + ? { ...base, buttons: transformButtons(base.buttons) } + : base; + + const slideContent = definition.createSlide({ + osLabel: "", + osUrl: "", + selectedRole: null, + onRoleSelect: () => {}, + ...params, + }); + + return ( + + ); +} diff --git a/frontend/editor/src/core/components/onboarding/adminStepsConfig.ts b/frontend/editor/src/core/components/onboarding/adminStepsConfig.ts index 1d8083201c..cff95cb63c 100644 --- a/frontend/editor/src/core/components/onboarding/adminStepsConfig.ts +++ b/frontend/editor/src/core/components/onboarding/adminStepsConfig.ts @@ -33,6 +33,165 @@ interface CreateAdminStepsConfigArgs { actions: AdminStepActions; } +// Delay before applying glow so the target section has mounted after navigation. +const GLOW_DELAY_MS = 100; + +/** + * Declarative spec for an admin tour step. Most steps do the same thing — + * clear existing glows, navigate to a settings section, then glow a set of nav + * items — so that behaviour is expressed as data (`section` + `glow`) and the + * runner below turns it into the reactour enter/after hooks. This replaces the + * removeGlow→navigate→setTimeout→addGlow block that was previously copy-pasted + * into every step. + */ +interface AdminStepSpec { + step: AdminTourStep; + selector: string; + contentKey: string; + contentDefault: string; + position: StepType["position"]; + padding?: number; + highlightedSelectors?: string[]; + /** Navigate to this settings section on enter (clears glows first). */ + section?: string; + /** Selectors to glow shortly after navigating to `section`. */ + glow?: string[]; + /** Clear glows on enter without navigating (settings-overview step). */ + clearGlowOnEnter?: boolean; + /** Save admin/workbench state on enter (first step). */ + saveStateOnEnter?: boolean; + /** Open the config modal after this step (config-button step). */ + openConfigAfter?: boolean; + /** Scroll the settings nav to this section after this step. */ + scrollToAfter?: string; + /** Wait for the step's own selector to be present + highlightable on enter. */ + waitForSelectorOnEnter?: boolean; +} + +const NAV = { + people: [ + '[data-tour="admin-people-nav"]', + '[data-tour="admin-teams-nav"]', + '[data-tour="settings-content-area"]', + ], + adminGeneral: [ + '[data-tour="admin-adminGeneral-nav"]', + '[data-tour="admin-adminFeatures-nav"]', + '[data-tour="admin-adminEndpoints-nav"]', + '[data-tour="settings-content-area"]', + ], + adminDatabase: [ + '[data-tour="admin-adminDatabase-nav"]', + '[data-tour="settings-content-area"]', + ], + adminConnections: [ + '[data-tour="admin-adminConnections-nav"]', + '[data-tour="settings-content-area"]', + ], + adminTools: [ + '[data-tour="admin-adminAudit-nav"]', + '[data-tour="admin-adminUsage-nav"]', + '[data-tour="settings-content-area"]', + ], +} as const; + +const ADMIN_STEP_SPECS: AdminStepSpec[] = [ + { + step: AdminTourStep.WELCOME, + selector: '[data-tour="config-button"]', + contentKey: "adminOnboarding.welcome", + contentDefault: + "Welcome to the Admin Tour! Let's explore the powerful enterprise features and settings available to system administrators.", + position: "right", + saveStateOnEnter: true, + }, + { + step: AdminTourStep.CONFIG_BUTTON, + selector: '[data-tour="config-button"]', + contentKey: "adminOnboarding.configButton", + contentDefault: + "Open Settings to access all system configuration and administrative controls.", + position: "right", + openConfigAfter: true, + }, + { + step: AdminTourStep.SETTINGS_OVERVIEW, + selector: ".modal-nav", + contentKey: "adminOnboarding.settingsOverview", + contentDefault: + "This is the Settings Panel. Admin settings are organised by category for easy navigation.", + position: "right", + padding: 0, + clearGlowOnEnter: true, + }, + { + step: AdminTourStep.TEAMS_AND_USERS, + selector: '[data-tour="admin-people-nav"]', + contentKey: "adminOnboarding.teamsAndUsers", + contentDefault: + "Manage Teams and individual users here. You can invite new users via email, shareable links, or create custom accounts for them yourself.", + position: "right", + section: "people", + glow: [...NAV.people], + highlightedSelectors: [...NAV.people], + }, + { + step: AdminTourStep.SYSTEM_CUSTOMIZATION, + selector: '[data-tour="admin-adminGeneral-nav"]', + contentKey: "adminOnboarding.systemCustomization", + contentDefault: + "We have extensive ways to customise the UI: System Settings let you change the app name and languages, Features allows server certificate management, and Endpoints lets you enable or disable specific tools for your users.", + position: "right", + section: "adminGeneral", + glow: [...NAV.adminGeneral], + highlightedSelectors: [...NAV.adminGeneral], + }, + { + step: AdminTourStep.DATABASE_SECTION, + selector: '[data-tour="admin-adminDatabase-nav"]', + contentKey: "adminOnboarding.databaseSection", + contentDefault: + "For advanced production environments, we have settings to allow external database hookups so you can integrate with your existing infrastructure.", + position: "right", + section: "adminDatabase", + glow: [...NAV.adminDatabase], + highlightedSelectors: [...NAV.adminDatabase], + }, + { + step: AdminTourStep.CONNECTIONS_SECTION, + selector: '[data-tour="admin-adminConnections-nav"]', + contentKey: "adminOnboarding.connectionsSection", + contentDefault: + "The Connections section supports various login methods including custom SSO and SAML providers like Google and GitHub, plus email integrations for notifications and communications.", + position: "right", + section: "adminConnections", + glow: [...NAV.adminConnections], + highlightedSelectors: [...NAV.adminConnections], + scrollToAfter: "adminAudit", + }, + { + step: AdminTourStep.ADMIN_TOOLS, + selector: '[data-tour="admin-adminAudit-nav"]', + contentKey: "adminOnboarding.adminTools", + contentDefault: + "Finally, we have advanced administration tools like Auditing to track system activity and Usage Analytics to monitor how your users interact with the platform.", + position: "right", + section: "adminAudit", + glow: [...NAV.adminTools], + highlightedSelectors: [...NAV.adminTools], + }, + { + step: AdminTourStep.WRAP_UP, + selector: '[data-tour="admin-help-nav"]', + contentKey: "adminOnboarding.wrapUp", + contentDefault: + "That's the admin tour! You've seen the enterprise features that make Stirling PDF a powerful, customisable solution for organisations. You can replay it anytime — just open Settings and find it here in the Tours section under Help.", + position: "right", + section: "help", + waitForSelectorOnEnter: true, + }, +]; + export function createAdminStepsConfig({ t, actions, @@ -44,183 +203,57 @@ export function createAdminStepsConfig({ scrollNavToSection, } = actions; - return { - [AdminTourStep.WELCOME]: { - selector: '[data-tour="config-button"]', - content: t( - "adminOnboarding.welcome", - "Welcome to the Admin Tour! Let's explore the powerful enterprise features and settings available to system administrators.", - ), - position: "right", - padding: 10, - action: () => { - saveAdminState(); - }, - }, - [AdminTourStep.CONFIG_BUTTON]: { - selector: '[data-tour="config-button"]', - content: t( - "adminOnboarding.configButton", - "Open Settings to access all system configuration and administrative controls.", - ), - position: "right", - padding: 10, - actionAfter: () => { - openConfigModal(); - }, - }, - [AdminTourStep.SETTINGS_OVERVIEW]: { - selector: ".modal-nav", - content: t( - "adminOnboarding.settingsOverview", - "This is the Settings Panel. Admin settings are organised by category for easy navigation.", - ), - position: "right", - padding: 0, - action: () => { - removeAllGlows(); - }, - }, - [AdminTourStep.TEAMS_AND_USERS]: { - selector: '[data-tour="admin-people-nav"]', - highlightedSelectors: [ - '[data-tour="admin-people-nav"]', - '[data-tour="admin-teams-nav"]', - '[data-tour="settings-content-area"]', - ], - content: t( - "adminOnboarding.teamsAndUsers", - "Manage Teams and individual users here. You can invite new users via email, shareable links, or create custom accounts for them yourself.", - ), - position: "right", - padding: 10, - action: () => { - removeAllGlows(); - navigateToSection("people"); - setTimeout(() => { - addGlowToElements([ - '[data-tour="admin-people-nav"]', - '[data-tour="admin-teams-nav"]', - '[data-tour="settings-content-area"]', - ]); - }, 100); - }, - }, - [AdminTourStep.SYSTEM_CUSTOMIZATION]: { - selector: '[data-tour="admin-adminGeneral-nav"]', - highlightedSelectors: [ - '[data-tour="admin-adminGeneral-nav"]', - '[data-tour="admin-adminFeatures-nav"]', - '[data-tour="admin-adminEndpoints-nav"]', - '[data-tour="settings-content-area"]', - ], - content: t( - "adminOnboarding.systemCustomization", - "We have extensive ways to customise the UI: System Settings let you change the app name and languages, Features allows server certificate management, and Endpoints lets you enable or disable specific tools for your users.", - ), - position: "right", - padding: 10, - action: () => { - removeAllGlows(); - navigateToSection("adminGeneral"); - setTimeout(() => { - addGlowToElements([ - '[data-tour="admin-adminGeneral-nav"]', - '[data-tour="admin-adminFeatures-nav"]', - '[data-tour="admin-adminEndpoints-nav"]', - '[data-tour="settings-content-area"]', - ]); - }, 100); - }, - }, - [AdminTourStep.DATABASE_SECTION]: { - selector: '[data-tour="admin-adminDatabase-nav"]', - highlightedSelectors: [ - '[data-tour="admin-adminDatabase-nav"]', - '[data-tour="settings-content-area"]', - ], - content: t( - "adminOnboarding.databaseSection", - "For advanced production environments, we have settings to allow external database hookups so you can integrate with your existing infrastructure.", - ), - position: "right", - padding: 10, - action: () => { - removeAllGlows(); - navigateToSection("adminDatabase"); - setTimeout(() => { - addGlowToElements([ - '[data-tour="admin-adminDatabase-nav"]', - '[data-tour="settings-content-area"]', - ]); - }, 100); - }, - }, - [AdminTourStep.CONNECTIONS_SECTION]: { - selector: '[data-tour="admin-adminConnections-nav"]', - highlightedSelectors: [ - '[data-tour="admin-adminConnections-nav"]', - '[data-tour="settings-content-area"]', - ], - content: t( - "adminOnboarding.connectionsSection", - "The Connections section supports various login methods including custom SSO and SAML providers like Google and GitHub, plus email integrations for notifications and communications.", - ), - position: "right", - padding: 10, - action: () => { - removeAllGlows(); - navigateToSection("adminConnections"); - setTimeout(() => { - addGlowToElements([ - '[data-tour="admin-adminConnections-nav"]', - '[data-tour="settings-content-area"]', - ]); - }, 100); - }, - actionAfter: async () => { - await scrollNavToSection("adminAudit"); - }, - }, - [AdminTourStep.ADMIN_TOOLS]: { - selector: '[data-tour="admin-adminAudit-nav"]', - highlightedSelectors: [ - '[data-tour="admin-adminAudit-nav"]', - '[data-tour="admin-adminUsage-nav"]', - '[data-tour="settings-content-area"]', - ], - content: t( - "adminOnboarding.adminTools", - "Finally, we have advanced administration tools like Auditing to track system activity and Usage Analytics to monitor how your users interact with the platform.", - ), - position: "right", - padding: 10, - action: () => { - removeAllGlows(); - navigateToSection("adminAudit"); - setTimeout(() => { - addGlowToElements([ - '[data-tour="admin-adminAudit-nav"]', - '[data-tour="admin-adminUsage-nav"]', - '[data-tour="settings-content-area"]', - ]); - }, 100); - }, - }, - [AdminTourStep.WRAP_UP]: { - selector: '[data-tour="admin-help-nav"]', - content: t( - "adminOnboarding.wrapUp", - "That's the admin tour! You've seen the enterprise features that make Stirling PDF a powerful, customisable solution for organisations. You can replay it anytime — just open Settings and find it here in the Tours section under Help.", - ), - position: "right", - padding: 10, - action: async () => { - removeAllGlows(); - navigateToSection("help"); - await waitForElement('[data-tour="admin-help-nav"]', 5000); - await waitForHighlightable('[data-tour="admin-help-nav"]', 5000); - }, - }, + const build = (spec: AdminStepSpec): StepType => { + const step: StepType = { + selector: spec.selector, + content: t(spec.contentKey, spec.contentDefault), + position: spec.position, + padding: spec.padding ?? 10, + }; + if (spec.highlightedSelectors) { + step.highlightedSelectors = spec.highlightedSelectors; + } + + const hasEnter = + spec.saveStateOnEnter || + spec.clearGlowOnEnter || + !!spec.section || + spec.waitForSelectorOnEnter; + if (hasEnter) { + step.action = async () => { + if (spec.saveStateOnEnter) saveAdminState(); + if (spec.clearGlowOnEnter) removeAllGlows(); + if (spec.section) { + removeAllGlows(); + navigateToSection(spec.section); + if (spec.glow) { + const glow = spec.glow; + setTimeout(() => addGlowToElements(glow), GLOW_DELAY_MS); + } + } + if (spec.waitForSelectorOnEnter) { + await waitForElement(spec.selector, 5000); + await waitForHighlightable(spec.selector, 5000); + } + }; + } + + const hasAfter = spec.openConfigAfter || !!spec.scrollToAfter; + if (hasAfter) { + step.actionAfter = async () => { + if (spec.openConfigAfter) openConfigModal(); + if (spec.scrollToAfter) await scrollNavToSection(spec.scrollToAfter); + }; + } + + return step; }; + + return ADMIN_STEP_SPECS.reduce( + (acc, spec) => { + acc[spec.step] = build(spec); + return acc; + }, + {} as Record, + ); } diff --git a/frontend/editor/src/core/components/onboarding/onboardingFlowConfig.ts b/frontend/editor/src/core/components/onboarding/onboardingFlowConfig.ts index 6eb843f4a5..167caac6a4 100644 --- a/frontend/editor/src/core/components/onboarding/onboardingFlowConfig.ts +++ b/frontend/editor/src/core/components/onboarding/onboardingFlowConfig.ts @@ -1,4 +1,5 @@ import WelcomeSlide from "@app/components/onboarding/slides/WelcomeSlide"; +import ProcessorIntroSlide from "@app/components/onboarding/slides/ProcessorIntroSlide"; import DesktopInstallSlide from "@app/components/onboarding/slides/DesktopInstallSlide"; import SecurityCheckSlide from "@app/components/onboarding/slides/SecurityCheckSlide"; import PlanOverviewSlide from "@app/components/onboarding/slides/PlanOverviewSlide"; @@ -7,12 +8,20 @@ import FirstLoginSlide from "@app/components/onboarding/slides/FirstLoginSlide"; import TourOverviewSlide from "@app/components/onboarding/slides/TourOverviewSlide"; import AnalyticsChoiceSlide from "@app/components/onboarding/slides/AnalyticsChoiceSlide"; import MFASetupSlide from "@app/components/onboarding/slides/MFASetupSlide"; -import { SlideConfig, LicenseNotice } from "@app/types/types"; -import type { ButtonAccent } from "@app/ui/Button"; +import { LicenseNotice } from "@app/types/types"; +import type { + OSOption, + ButtonDefinition as ButtonDefinitionBase, + HeroDefinition as HeroDefinitionBase, + SlideDefinition as SlideDefinitionBase, +} from "@app/components/onboarding/onboardingSlideTypes"; + +export type { OSOption }; export type SlideId = | "first-login" | "welcome" + | "processor-intro" | "desktop-install" | "security-check" | "admin-overview" @@ -40,6 +49,7 @@ export type ButtonAction = | "launch-admin" | "launch-tools" | "launch-auto" + | "open-processor" | "see-plans" | "skip-to-license" | "skip-tour" @@ -50,12 +60,6 @@ export interface FlowState { selectedRole: "admin" | "user" | null; } -export interface OSOption { - label: string; - url: string; - value: string; -} - export interface SlideFactoryParams { osLabel: string; osUrl: string; @@ -74,29 +78,17 @@ export interface SlideFactoryParams { onMfaSetupComplete?: () => void; } -export interface HeroDefinition { - type: HeroType; -} +export type HeroDefinition = HeroDefinitionBase; -export interface ButtonDefinition { - key: string; - type: "button" | "icon"; - label?: string; - icon?: "chevron-left"; - variant?: "primary" | "secondary" | "default"; - /** Accent for the shared Button; defaults to neutral. */ - accent?: ButtonAccent; - group: "left" | "right"; - action: ButtonAction; - disabledWhen?: (state: FlowState) => boolean; -} +export type ButtonDefinition = ButtonDefinitionBase; -export interface SlideDefinition { - id: SlideId; - createSlide: (params: SlideFactoryParams) => SlideConfig; - hero: HeroDefinition; - buttons: ButtonDefinition[]; -} +export type SlideDefinition = SlideDefinitionBase< + SlideId, + ButtonAction, + FlowState, + HeroType, + SlideFactoryParams +>; export const SLIDE_DEFINITIONS: Record = { "first-login": { @@ -129,6 +121,36 @@ export const SLIDE_DEFINITIONS: Record = { }, ], }, + "processor-intro": { + id: "processor-intro", + createSlide: () => ProcessorIntroSlide(), + hero: { type: "diamond" }, + buttons: [ + { + key: "processor-back", + type: "icon", + icon: "chevron-left", + group: "left", + action: "prev", + }, + { + key: "processor-skip", + type: "button", + label: "onboarding.buttons.skipForNow", + variant: "secondary", + group: "left", + action: "next", + }, + { + key: "processor-open", + type: "button", + label: "onboarding.processorIntro.cta", + variant: "primary", + group: "right", + action: "open-processor", + }, + ], + }, "desktop-install": { id: "desktop-install", createSlide: ({ osLabel, osUrl, osOptions, onDownloadUrlChange }) => diff --git a/frontend/editor/src/core/components/onboarding/onboardingSlideTypes.ts b/frontend/editor/src/core/components/onboarding/onboardingSlideTypes.ts new file mode 100644 index 0000000000..23fe6cc6c9 --- /dev/null +++ b/frontend/editor/src/core/components/onboarding/onboardingSlideTypes.ts @@ -0,0 +1,65 @@ +import type { SlideConfig } from "@app/types/types"; +import type { ButtonAccent } from "@app/ui/Button"; + +/** + * Shared shape for onboarding slide flows. Both the core/editor flow and the + * SaaS flow describe the same thing — a sequence of slides, each with a hero, + * body content, and a row of buttons — so the structure lives here once and is + * parameterised per flow by its own action/hero/state unions. Previously these + * interfaces were copy-pasted into each flow config. + */ + +export interface OSOption { + label: string; + url: string; + value: string; +} + +export interface ButtonDefinition { + key: string; + type: "button" | "icon"; + label?: string; + icon?: "chevron-left"; + variant?: "primary" | "secondary" | "default"; + /** Accent for the shared Button; defaults to neutral. */ + accent?: ButtonAccent; + group: "left" | "right"; + action: Action; + disabledWhen?: (state: State) => boolean; +} + +export interface HeroDefinition { + type: Hero; +} + +export interface SlideDefinition< + Id extends string, + Action extends string, + State, + Hero extends string, + Params, +> { + id: Id; + createSlide: (params: Params) => SlideConfig; + hero: HeroDefinition; + buttons: ButtonDefinition[]; +} + +/** A single entry in a conditionally-resolved flow. */ +export interface FlowStep { + id: Id; + /** Included in the resolved flow only when this returns true. */ + when: (ctx: Ctx) => boolean; +} + +/** + * Resolves an ordered flow to the ids whose `when` predicate holds for `ctx`. + * This is the one operation both flows share: "the flow is the steps that apply + * right now, in order". + */ +export function resolveFlowIds( + steps: FlowStep[], + ctx: Ctx, +): Id[] { + return steps.filter((step) => step.when(ctx)).map((step) => step.id); +} diff --git a/frontend/editor/src/core/components/onboarding/orchestrator/onboardingConfig.ts b/frontend/editor/src/core/components/onboarding/orchestrator/onboardingConfig.ts index 6e10ec97f3..9b8ba965c8 100644 --- a/frontend/editor/src/core/components/onboarding/orchestrator/onboardingConfig.ts +++ b/frontend/editor/src/core/components/onboarding/orchestrator/onboardingConfig.ts @@ -1,6 +1,7 @@ export type OnboardingStepId = | "first-login" | "welcome" + | "processor-intro" | "desktop-install" | "security-check" | "admin-overview" @@ -15,7 +16,8 @@ export type OnboardingStepType = "modal-slide" | "tool-prompt"; export interface OnboardingRuntimeState { selectedRole: "admin" | "user" | null; tourRequested: boolean; - tourType: "admin" | "tools" | "whatsnew"; + // Open key into the tour registry (see tourRegistry.ts). + tourType: string; isDesktopApp: boolean; desktopSlideEnabled: boolean; analyticsNotConfigured: boolean; @@ -44,6 +46,7 @@ export interface OnboardingStep { slideId?: | "first-login" | "welcome" + | "processor-intro" | "desktop-install" | "security-check" | "admin-overview" @@ -88,6 +91,13 @@ export const ONBOARDING_STEPS: OnboardingStep[] = [ // Desktop has its own onboarding modal (DesktopOnboardingModal) condition: (ctx) => !ctx.isDesktopApp, }, + { + id: "processor-intro", + type: "modal-slide", + slideId: "processor-intro", + // Admins can manage policies in the portal/processor; regular users can't. + condition: (ctx) => ctx.effectiveIsAdmin, + }, { id: "admin-overview", type: "modal-slide", @@ -100,17 +110,6 @@ export const ONBOARDING_STEPS: OnboardingStep[] = [ slideId: "desktop-install", condition: (ctx) => !ctx.isDesktopApp && ctx.desktopSlideEnabled, }, - { - id: "security-check", - type: "modal-slide", - slideId: "security-check", - condition: () => false, - }, - { - id: "tool-layout", - type: "tool-prompt", - condition: () => false, - }, { id: "tour-overview", type: "modal-slide", diff --git a/frontend/editor/src/core/components/onboarding/orchestrator/onboardingStorage.ts b/frontend/editor/src/core/components/onboarding/orchestrator/onboardingStorage.ts index 73f45cb1ea..084761849d 100644 --- a/frontend/editor/src/core/components/onboarding/orchestrator/onboardingStorage.ts +++ b/frontend/editor/src/core/components/onboarding/orchestrator/onboardingStorage.ts @@ -2,6 +2,85 @@ const STORAGE_PREFIX = "onboarding"; const TOURS_TOOLTIP_KEY = `${STORAGE_PREFIX}::tours-tooltip-shown`; const ONBOARDING_COMPLETED_KEY = `${STORAGE_PREFIX}::completed`; +// Per-flow persistence lives under a single namespace so completion state +// composes across build flavors and, in future, checklist-style flows that +// track individual step completion (see setStepDone / getFlowProgress). +const flowSeenKey = (flowId: string) => + `${STORAGE_PREFIX}::flow::${flowId}::seen`; +const flowProgressKey = (flowId: string) => + `${STORAGE_PREFIX}::flow::${flowId}::progress`; + +function readJson(key: string, fallback: T): T { + if (typeof window === "undefined") return fallback; + try { + const raw = localStorage.getItem(key); + return raw ? (JSON.parse(raw) as T) : fallback; + } catch { + return fallback; + } +} + +function writeJson(key: string, value: unknown): void { + if (typeof window === "undefined") return; + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch (error) { + console.error(`[onboardingStorage] Error writing ${key}:`, error); + } +} + +/** Whether a named flow (e.g. "saas", "portal") has been seen/dismissed. */ +export function hasSeenFlow(flowId: string): boolean { + if (typeof window === "undefined") return false; + try { + return localStorage.getItem(flowSeenKey(flowId)) === "true"; + } catch { + return false; + } +} + +/** Marks a named flow as seen so it does not reappear. */ +export function markFlowSeen(flowId: string): void { + if (typeof window === "undefined") return; + try { + localStorage.setItem(flowSeenKey(flowId), "true"); + } catch (error) { + console.error(`[onboardingStorage] Error marking flow "${flowId}":`, error); + } +} + +/** Completed step ids for a checklist-style flow, in completion order. */ +export function getFlowProgress(flowId: string): string[] { + const value = readJson(flowProgressKey(flowId), []); + return Array.isArray(value) ? value : []; +} + +/** Whether a single step within a flow has been completed. */ +export function isStepDone(flowId: string, stepId: string): boolean { + return getFlowProgress(flowId).includes(stepId); +} + +/** Records a single step within a flow as done (idempotent). */ +export function setStepDone(flowId: string, stepId: string): void { + const progress = getFlowProgress(flowId); + if (progress.includes(stepId)) return; + writeJson(flowProgressKey(flowId), [...progress, stepId]); +} + +/** Clears both the seen flag and step progress for a flow. */ +export function resetFlow(flowId: string): void { + if (typeof window === "undefined") return; + try { + localStorage.removeItem(flowSeenKey(flowId)); + localStorage.removeItem(flowProgressKey(flowId)); + } catch (error) { + console.error( + `[onboardingStorage] Error resetting flow "${flowId}":`, + error, + ); + } +} + export function isOnboardingCompleted(): boolean { if (typeof window === "undefined") return false; try { diff --git a/frontend/editor/src/core/components/onboarding/orchestrator/useOnboardingOrchestrator.ts b/frontend/editor/src/core/components/onboarding/orchestrator/useOnboardingOrchestrator.ts index 44052636d1..d0ec8a66b5 100644 --- a/frontend/editor/src/core/components/onboarding/orchestrator/useOnboardingOrchestrator.ts +++ b/frontend/editor/src/core/components/onboarding/orchestrator/useOnboardingOrchestrator.ts @@ -41,13 +41,10 @@ function getInitialRuntimeState( try { const tourRequested = sessionStorage.getItem(SESSION_TOUR_REQUESTED) === "true"; - const sessionTourType = sessionStorage.getItem(SESSION_TOUR_TYPE); + // Any stored tour id is accepted (validated against the registry at render); + // fall back to the default tour type when absent. const tourType = - sessionTourType === "admin" || - sessionTourType === "tools" || - sessionTourType === "whatsnew" - ? sessionTourType - : "whatsnew"; + sessionStorage.getItem(SESSION_TOUR_TYPE) ?? baseState.tourType; const selectedRole = sessionStorage.getItem(SESSION_SELECTED_ROLE) as | "admin" | "user" @@ -183,6 +180,7 @@ export function useOnboardingOrchestrator( ); const [isPaused, setIsPaused] = useState(false); const [isInitialized, setIsInitialized] = useState(false); + const [manuallyStarted, setManuallyStarted] = useState(false); const [currentStepIndex, setCurrentStepIndex] = useState(-1); const migrationDone = useRef(false); const initialIndexSet = useRef(false); @@ -289,23 +287,8 @@ export function useOnboardingOrchestrator( useEffect(() => { if (configLoading || !adminStatusResolved) return; - // If there are no steps to show, mark initialized/completed baseline - if (activeFlow.length === 0) { - setCurrentStepIndex(0); - initialIndexSet.current = true; - return; - } - - // If onboarding has been completed, don't show it - if (isOnboardingCompleted()) { - setCurrentStepIndex(activeFlow.length); - initialIndexSet.current = true; - return; - } - - // Start from the beginning if (!initialIndexSet.current) { - setCurrentStepIndex(0); + setCurrentStepIndex(activeFlow.length); initialIndexSet.current = true; } }, [activeFlow, configLoading, adminStatusResolved]); @@ -326,6 +309,7 @@ export function useOnboardingOrchestrator( !isPaused && !isComplete && isInitialized && + manuallyStarted && currentStep !== null; const isLoading = configLoading || @@ -389,6 +373,7 @@ export function useOnboardingOrchestrator( if (index !== -1) { setCurrentStepIndex(index); setIsPaused(false); + setManuallyStarted(true); } }, [activeFlow], diff --git a/frontend/editor/src/core/components/onboarding/slides/DesktopInstallTitle.tsx b/frontend/editor/src/core/components/onboarding/slides/DesktopInstallTitle.tsx index 97329db5d7..efb95b95bb 100644 --- a/frontend/editor/src/core/components/onboarding/slides/DesktopInstallTitle.tsx +++ b/frontend/editor/src/core/components/onboarding/slides/DesktopInstallTitle.tsx @@ -17,6 +17,41 @@ interface DesktopInstallTitleProps { onDownloadUrlChange?: (url: string) => void; } +/** Brand marks (simple-icons paths) so each option shows its real OS logo. */ +const OS_ICON_PATHS: Record = { + apple: + "M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701", + windows: + "M0 3.449L9.75 2.1v9.451H0m10.949-9.602L24 0v11.4H10.949M0 12.6h9.75v9.451L0 20.699M10.949 12.6H24V24l-12.9-1.801", + linux: + "M14.62 8.35c-.18.11-.4.28-.66.35-.24.08-.5.16-.72.16-.4 0-.72-.13-.98-.28-.16-.09-.29-.19-.4-.28l-.08-.06a.29.29 0 0 0-.34.02.28.28 0 0 0-.05.4c.02.02.16.16.4.3.24.16.6.32 1.05.32.34 0 .66-.08.94-.18.28-.1.5-.22.68-.34.42-.28.68-.6.68-.6a.28.28 0 0 0-.06-.4.29.29 0 0 0-.4.06s-.2.26-.53.45zM9.36 6.6c.35 0 .63.32.63.72s-.28.72-.63.72c-.35 0-.63-.32-.63-.72s.28-.72.63-.72zm5.3 0c.35 0 .63.32.63.72s-.28.72-.63.72c-.35 0-.63-.32-.63-.72s.28-.72.63-.72zM12 0C6.9 0 4.28 3.98 4.34 7.66c.06 3.68-.9 4.98-1.72 6.28C1.8 15.24.9 16.4.9 18.1c0 .96.4 1.6 1 2 .6.4 1.36.5 2.1.6.74.1 1.46.2 2 .5.54.3.9.8 1.7.9.4.06.86 0 1.3-.2.44-.2.86-.54 1.16-1.06h1.68c.3.52.72.86 1.16 1.06.44.2.9.26 1.3.2.8-.1 1.16-.6 1.7-.9.54-.3 1.26-.4 2-.5.74-.1 1.5-.2 2.1-.6.6-.4 1-1.04 1-2 0-1.7-.9-2.86-1.72-4.16-.82-1.3-1.78-2.6-1.72-6.28C19.72 3.98 17.1 0 12 0z", +}; + +/** Map an OS option to its brand-icon key from its value/label. */ +function osIconKey(option: OSOption): string | null { + const text = `${option.value} ${option.label}`.toLowerCase(); + if (/(mac|apple|osx|darwin)/.test(text)) return "apple"; + if (/win/.test(text)) return "windows"; + if (/linux/.test(text)) return "linux"; + return null; +} + +function OsIcon({ os }: { os: string }) { + const d = OS_ICON_PATHS[os]; + if (!d) return null; + return ( + + ); +} + export const DesktopInstallTitle: React.FC = ({ osLabel, osUrl, @@ -51,7 +86,7 @@ export const DesktopInstallTitle: React.FC = ({ // If only one option or no options, don't show dropdown if (osOptions.length <= 1) { - return
{title}
; + return
{title}
; } return ( @@ -59,13 +94,12 @@ export const DesktopInstallTitle: React.FC = ({ style={{ display: "flex", alignItems: "center", - justifyContent: "center", gap: "0.5rem", width: "100%", }} > {title} - + = ({ {osOptions.map((option) => { const isSelected = option.url === selectedOsUrl; + const iconKey = osIconKey(option); return ( handleOsSelect(option)} + leftSection={iconKey ? : undefined} style={{ backgroundColor: isSelected - ? "light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-8))" + ? "var(--bg-muted, #f1f5f9)" : "transparent", - color: isSelected - ? "light-dark(var(--mantine-color-blue-9), var(--mantine-color-white))" - : "inherit", + color: "var(--onboarding-title, #0f172a)", + fontWeight: isSelected ? 600 : 500, }} > {option.label} diff --git a/frontend/editor/src/core/components/onboarding/slides/ProcessorIntroSlide.tsx b/frontend/editor/src/core/components/onboarding/slides/ProcessorIntroSlide.tsx new file mode 100644 index 0000000000..ed910ca48a --- /dev/null +++ b/frontend/editor/src/core/components/onboarding/slides/ProcessorIntroSlide.tsx @@ -0,0 +1,30 @@ +import { Trans } from "react-i18next"; +import { SlideConfig } from "@app/types/types"; +import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig"; + +const ProcessorIntroBody = () => ( + + }} + defaults="Stirling now runs Policies — automated rules that classify, secure, and process every document as it arrives. Set them up and monitor runs in the Processor." + /> + +); + +export default function ProcessorIntroSlide(): SlideConfig { + return { + key: "processor-intro", + title: ( + + ), + body: , + background: { + gradientStops: ["#2563EB", "#7C3AED"], + circles: UNIFIED_CIRCLE_CONFIG, + }, + }; +} diff --git a/frontend/editor/src/core/components/onboarding/tourRegistry.ts b/frontend/editor/src/core/components/onboarding/tourRegistry.ts new file mode 100644 index 0000000000..8213e5006e --- /dev/null +++ b/frontend/editor/src/core/components/onboarding/tourRegistry.ts @@ -0,0 +1,105 @@ +import type { StepType } from "@reactour/tour"; +import type { TFunction } from "i18next"; +import type { useTourOrchestration } from "@app/contexts/TourOrchestrationContext"; +import type { useAdminTourOrchestration } from "@app/contexts/AdminTourOrchestrationContext"; +import { createAdminStepsConfig } from "@app/components/onboarding/adminStepsConfig"; +import { createUserStepsConfig } from "@app/components/onboarding/userStepsConfig"; +import { createWhatsNewStepsConfig } from "@app/components/onboarding/whatsNewStepsConfig"; + +type WorkbenchTourActions = ReturnType; +type AdminTourActions = ReturnType; + +/** + * Everything a tour's step builder might need, resolved once by the onboarding + * component from the two orchestration contexts. Each tour picks what it uses. + */ +export interface TourBuildContext { + t: TFunction; + workbench: WorkbenchTourActions; + admin: AdminTourActions; + openFilesModal: () => void; + closeFilesModal: () => void; +} + +export interface TourDefinition { + id: string; + build: (ctx: TourBuildContext) => StepType[]; +} + +/** + * Registry of guided tours. Adding a tour is a single entry here — no changes + * to the onboarding component's rendering. Tour ids are open strings so future + * builds/features can register their own without editing a central union. + */ +export const TOUR_REGISTRY: Record = { + admin: { + id: "admin", + build: ({ t, admin }) => + Object.values( + createAdminStepsConfig({ + t, + actions: { + saveAdminState: admin.saveAdminState, + openConfigModal: admin.openConfigModal, + navigateToSection: admin.navigateToSection, + scrollNavToSection: admin.scrollNavToSection, + }, + }), + ), + }, + tools: { + id: "tools", + build: ({ t, workbench, admin, openFilesModal, closeFilesModal }) => + Object.values( + createUserStepsConfig({ + t, + actions: { + saveWorkbenchState: workbench.saveWorkbenchState, + closeFilesModal, + backToAllTools: workbench.backToAllTools, + selectCropTool: workbench.selectCropTool, + loadSampleFile: workbench.loadSampleFile, + switchToActiveFiles: workbench.switchToActiveFiles, + pinFile: workbench.pinFile, + revealFileCardHoverMenu: workbench.revealFileCardHoverMenu, + modifyCropSettings: workbench.modifyCropSettings, + executeTool: workbench.executeTool, + openFilesModal, + openSettingsHelpSection: () => admin.navigateToSection("help"), + }, + }), + ), + }, + whatsnew: { + id: "whatsnew", + build: ({ t, workbench, openFilesModal, closeFilesModal }) => + Object.values( + createWhatsNewStepsConfig({ + t, + actions: { + saveWorkbenchState: workbench.saveWorkbenchState, + closeFilesModal, + backToAllTools: workbench.backToAllTools, + openFilesModal, + loadSampleFile: workbench.loadSampleFile, + switchToViewer: workbench.switchToViewer, + switchToPageEditor: workbench.switchToPageEditor, + switchToActiveFiles: workbench.switchToActiveFiles, + }, + }), + ), + }, +}; + +/** Default tour when a requested id is unknown or unset. */ +export const DEFAULT_TOUR_TYPE = "whatsnew"; + +/** Resolves a tour's steps, falling back to the default tour for unknown ids. */ +export function getTourSteps( + tourType: string, + ctx: TourBuildContext, +): StepType[] { + const definition = + TOUR_REGISTRY[tourType] ?? TOUR_REGISTRY[DEFAULT_TOUR_TYPE]; + return definition.build(ctx); +} diff --git a/frontend/editor/src/core/components/onboarding/userStepsConfig.ts b/frontend/editor/src/core/components/onboarding/userStepsConfig.ts index e8a52803da..85281e712e 100644 --- a/frontend/editor/src/core/components/onboarding/userStepsConfig.ts +++ b/frontend/editor/src/core/components/onboarding/userStepsConfig.ts @@ -106,7 +106,7 @@ export function createUserStepsConfig({ actionAfter: () => openFilesModal(), }, [TourStep.FILE_SOURCES]: { - selector: '[data-tour="file-sources"]', + selector: '[data-tour="files-modal"]', content: t( "onboarding.fileSources", "You can upload new files or access recent files from here. For the tour, we'll just use a sample file.", @@ -114,8 +114,8 @@ export function createUserStepsConfig({ position: "right", padding: 0, action: async () => { - await waitForElement('[data-tour="file-sources"]', 5000); - await waitForHighlightable('[data-tour="file-sources"]', 5000); + await waitForElement('[data-tour="files-modal"]', 5000); + await waitForHighlightable('[data-tour="files-modal"]', 5000); }, actionAfter: () => { loadSampleFile(); @@ -135,7 +135,7 @@ export function createUserStepsConfig({ selector: '[data-tour="workbench"]', content: t( "onboarding.activeFiles", - "The Active Files view shows all of the PDFs you have loaded into the tool, and allows you to select which ones to process.", + "The Active Files view shows all of the PDFs you have loaded into the tool.", ), position: "center", padding: 0, @@ -145,7 +145,7 @@ export function createUserStepsConfig({ selector: '[data-tour="file-card-checkbox"]', content: t( "onboarding.fileCheckbox", - "Clicking one of the files selects it for processing. You can select multiple files for batch operations.", + "Files on the workbench are selected for processing. You can select multiple files for batch operations using the left files sidebar.", ), position: "top", padding: 10, @@ -200,17 +200,17 @@ export function createUserStepsConfig({ actionAfter: () => pinFile(), }, [TourStep.WRAP_UP]: { - selector: '[data-tour="admin-help-nav"]', + selector: '[data-tour="settings-modal"]', content: t( "onboarding.wrapUp", "You're all set! You can replay this tour anytime — just open Settings and find it here in the Tours section under Help.", ), - position: "right", - padding: 10, + position: "center", + padding: 0, action: async () => { openSettingsHelpSection(); - await waitForElement('[data-tour="admin-help-nav"]', 5000); - await waitForHighlightable('[data-tour="admin-help-nav"]', 5000); + await waitForElement('[data-tour="settings-modal"]', 5000); + await waitForHighlightable('[data-tour="settings-modal"]', 5000); }, }, }; diff --git a/frontend/editor/src/core/components/shared/AppConfigModal.tsx b/frontend/editor/src/core/components/shared/AppConfigModal.tsx index 485403c512..83f7342a74 100644 --- a/frontend/editor/src/core/components/shared/AppConfigModal.tsx +++ b/frontend/editor/src/core/components/shared/AppConfigModal.tsx @@ -266,7 +266,7 @@ const AppConfigModalInner: React.FC = ({ styles={{ content: { overflowY: "hidden", overscrollBehavior: "none" } }} removeScrollProps={{ shards: [COOKIE_CONSENT_SCROLL_SHARD] }} > -
+
{/* Left navigation */}
( onConfirm={handleConfirmSidebarDelete} /> + {/* Getting-started checklist, floating above the footer (SaaS only). */} + + {/* Bottom bar: user name + settings */} {

{t("landing.workbenchEmptyStateHero", "Drop a PDF anywhere")}

-

- {t( - "landing.heroSubtitle", - "Drop in or add an existing PDF to get started.", - )} -

- void handleNativeUploadClick()} diff --git a/frontend/editor/src/core/components/shared/LoadingFallback.tsx b/frontend/editor/src/core/components/shared/LoadingFallback.tsx index dced852150..c861fd04b0 100644 --- a/frontend/editor/src/core/components/shared/LoadingFallback.tsx +++ b/frontend/editor/src/core/components/shared/LoadingFallback.tsx @@ -10,7 +10,9 @@ export function LoadingFallback() { alignItems: "center", height: "100vh", fontSize: "18px", - color: "#666", + // Theme-aware so the splash follows light/dark instead of forcing white. + backgroundColor: "var(--mantine-color-body)", + color: "var(--mantine-color-text)", }} > Loading... diff --git a/frontend/editor/src/core/components/shared/SidebarChecklistSlot.tsx b/frontend/editor/src/core/components/shared/SidebarChecklistSlot.tsx new file mode 100644 index 0000000000..d9844123d6 --- /dev/null +++ b/frontend/editor/src/core/components/shared/SidebarChecklistSlot.tsx @@ -0,0 +1,13 @@ +export interface SidebarChecklistSlotProps { + /** Whether the sidebar is collapsed to its narrow rail. */ + collapsed?: boolean; +} + +/** + * Extension point for a getting-started checklist that floats above the + * sidebar footer. Core renders nothing; builds that offer onboarding (SaaS) + * shadow this file to provide the real checklist. + */ +export function SidebarChecklistSlot(_props: SidebarChecklistSlotProps) { + return null; +} diff --git a/frontend/editor/src/core/constants/events.ts b/frontend/editor/src/core/constants/events.ts index aa93ebcfaf..f653e987b8 100644 --- a/frontend/editor/src/core/constants/events.ts +++ b/frontend/editor/src/core/constants/events.ts @@ -29,7 +29,10 @@ export interface UpgradeBannerAlertPayload { freeTierLimit?: number; } -export type TourType = "admin" | "tools" | "whatsnew"; +// Open string keyed into the tour registry (see tourRegistry.ts). Kept as a +// named alias so intent is clear at call sites; known ids are "admin", "tools", +// "whatsnew" but builds/features may register their own. +export type TourType = string; export interface StartTourPayload { tourType: TourType; diff --git a/frontend/editor/src/core/pages/HomePage.tsx b/frontend/editor/src/core/pages/HomePage.tsx index c1b4f572bc..cd0a838421 100644 --- a/frontend/editor/src/core/pages/HomePage.tsx +++ b/frontend/editor/src/core/pages/HomePage.tsx @@ -107,6 +107,19 @@ export default function HomePage() { setConfigModalOpen(isSettings); }, [location.pathname]); + useEffect(() => { + const handler = () => setConfigModalOpen(true); + window.addEventListener("appConfig:open", handler); + return () => window.removeEventListener("appConfig:open", handler); + }, []); + + const handleCloseConfig = useCallback(() => { + setConfigModalOpen(false); + if (location.pathname.startsWith("/settings")) { + navigate("/", { replace: true }); + } + }, [location.pathname, navigate]); + const { activeFiles } = useFileContext(); const navigationState = useNavigationState(); const { actions } = useNavigationActions(); @@ -477,7 +490,7 @@ export default function HomePage() { setConfigModalOpen(false)} + onClose={handleCloseConfig} />
) : ( @@ -526,7 +539,7 @@ export default function HomePage() { setConfigModalOpen(false)} + onClose={handleCloseConfig} /> )} diff --git a/frontend/editor/src/core/tests/stubbed/main-dashboard.spec.ts b/frontend/editor/src/core/tests/stubbed/main-dashboard.spec.ts index db715220d3..c66f392fa1 100644 --- a/frontend/editor/src/core/tests/stubbed/main-dashboard.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/main-dashboard.spec.ts @@ -1,5 +1,4 @@ import { test, expect } from "@app/tests/helpers/stub-test-base"; -import { mockAppApis, seedCookieConsent } from "@app/tests/helpers/api-stubs"; import { openSettings } from "@app/tests/helpers/ui-helpers"; test.describe("2. Main Dashboard / Home Page", () => { @@ -113,40 +112,4 @@ test.describe("2. Main Dashboard / Home Page", () => { }); }); }); - - test.describe("2.5 Dashboard - Welcome Dialog for fresh users", () => { - test("should show welcome dialog when onboarding flags are unset", async ({ - browser, - }) => { - // Fresh context — no localStorage flags, so the onboarding modal should appear. - const context = await browser.newContext({ - viewport: { width: 1920, height: 1080 }, - }); - const page = await context.newPage(); - - await seedCookieConsent(page); - await mockAppApis(page); - await page.goto("/"); - - const welcomeDialog = page.getByText(/welcome/i).first(); - await expect(welcomeDialog).toBeVisible({ timeout: 10000 }); - - for (let i = 0; i < 5; i++) { - const hasOverlay = await page - .locator(".mantine-Modal-overlay, .mantine-Overlay-root") - .first() - .isVisible() - .catch(() => false); - if (!hasOverlay) break; - await page.keyboard.press("Escape"); - await page.waitForTimeout(500); - } - - await expect(page.getByPlaceholder(/search/i).first()).toBeVisible({ - timeout: 10000, - }); - - await context.close(); - }); - }); }); diff --git a/frontend/editor/src/core/utils/settingsNavigation.ts b/frontend/editor/src/core/utils/settingsNavigation.ts index fddd9ab5bc..a4ca4248dd 100644 --- a/frontend/editor/src/core/utils/settingsNavigation.ts +++ b/frontend/editor/src/core/utils/settingsNavigation.ts @@ -8,6 +8,13 @@ export function navigateToSettings(section: NavKey) { window.dispatchEvent(new PopStateEvent("popstate")); } +export function openSettings(section: NavKey) { + window.history.pushState({}, "", withBasePath(`/settings/${section}`)); + window.dispatchEvent( + new CustomEvent("appConfig:open", { detail: { section } }), + ); +} + /** URL for a settings section (subpath-aware). */ export function getSettingsUrl(section: NavKey): string { return withBasePath(`/settings/${section}`); diff --git a/frontend/editor/src/desktop/components/DesktopSaasOnboardingBootstrap.tsx b/frontend/editor/src/desktop/components/DesktopSaasOnboardingBootstrap.tsx index eef035bbd0..d667b20714 100644 --- a/frontend/editor/src/desktop/components/DesktopSaasOnboardingBootstrap.tsx +++ b/frontend/editor/src/desktop/components/DesktopSaasOnboardingBootstrap.tsx @@ -1,74 +1,19 @@ -import { useEffect, useState } from "react"; -import { useAuth } from "@app/auth/UseSession"; -import SaasOnboardingModal from "@app/components/onboarding/SaasOnboardingModal"; - /** * Desktop bootstrap for the SaaS product onboarding. * - * Mirrors saas's {@code OnboardingBootstrap}: once a SaaS user has signed in we - * show the shared cloud {@link SaasOnboardingModal} (free-editor pitch → usage - * meter → team), reusing the exact same flow as the web app. The closing - * "download desktop" slide is dropped via {@code hideDesktopInstall} — this IS - * the desktop app, so pitching its own download makes no sense. + * First-load auto-display is disabled: the shared cloud {@link SaasOnboardingModal} + * no longer appears automatically on first sign-in. This mirrors the web SaaS + * {@code OnboardingBootstrap}, which no longer auto-opens either. The modal + * component is retained for explicit/manual triggering; nothing opens it here. * - * Differences from the saas bootstrap: - * - The desktop {@code useAuth} (proprietary) exposes no pro/wallet fields; the - * cloud flow reads the live wallet itself to decide which slides to show, so - * we just wait for a non-anonymous signed-in user. - * - Gated on {@code connectionMode === "saas"} so it never fires in local or - * self-hosted mode (where there is no SaaS wallet/team to onboard). - * - * Shown once per device, gated by localStorage (same key the saas web flow uses - * so a user who onboarded on the web isn't re-onboarded — they are independent - * stores, but the key/intent is shared). + * The props signature is preserved so the AppProviders wiring is unaffected. */ -const STORAGE_KEY = "saas_onboarding_seen"; - interface DesktopSaasOnboardingBootstrapProps { connectionMode: "saas" | "selfhosted" | "local" | null; } -export function DesktopSaasOnboardingBootstrap({ - connectionMode, -}: DesktopSaasOnboardingBootstrapProps) { - const { user, loading } = useAuth(); - const [showModal, setShowModal] = useState(false); - - const isSignedInSaasUser = - connectionMode === "saas" && - !loading && - !!user && - user.is_anonymous !== true; - - useEffect(() => { - if (!isSignedInSaasUser) return; - let seen = false; - try { - seen = localStorage.getItem(STORAGE_KEY) === "true"; - } catch { - // localStorage unavailable — fail open and show onboarding. - } - if (!seen) { - setShowModal(true); - } - }, [isSignedInSaasUser]); - - const handleClose = () => { - try { - localStorage.setItem(STORAGE_KEY, "true"); - } catch { - // localStorage unavailable — best-effort; the in-memory flag still hides it. - } - setShowModal(false); - }; - - if (!showModal) return null; - - return ( - - ); +export function DesktopSaasOnboardingBootstrap( + _props: DesktopSaasOnboardingBootstrapProps, +) { + return null; } diff --git a/frontend/editor/src/portal/components/LoginScreen.tsx b/frontend/editor/src/portal/components/LoginScreen.tsx index 7abf518a0c..f16553d56d 100644 --- a/frontend/editor/src/portal/components/LoginScreen.tsx +++ b/frontend/editor/src/portal/components/LoginScreen.tsx @@ -1,19 +1,23 @@ -import { useEffect, useMemo } from "react"; +import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { AuthShell } from "@app/auth/ui/AuthShell"; import LoginRightCarousel from "@app/auth/ui/LoginRightCarousel"; import { buildDefaultLoginSlides } from "@app/auth/ui/loginSlides"; import SpringLoginForm from "@app/auth/ui/SpringLoginForm"; import { useSpringLogin } from "@app/auth/ui/useSpringLogin"; +import { withBasePath } from "@app/constants/app"; import "@app/auth/ui/auth-theme.css"; import "@app/auth/ui/auth.css"; import loginHeader from "@app/assets/brand/modern-logo/LoginLightModeHeader.svg"; /** - * Full-screen login shown by the portal's auth gate. Renders the same screen as - * the editor: the shared AuthShell + carousel, with the form body and Spring - * auth wiring from @app/auth/ui (SpringLoginForm + useSpringLogin). The gate - * handles "already logged in", so this only needs to collect credentials. + * Full-screen login shown by the portal's auth gate. Renders the shared + * AuthShell + carousel with the Spring form/auth wiring from @app/auth/ui. + * + * It follows the user's light/dark theme (AuthShell is theme-aware — the same + * screen the editor login uses; passing both logo variants keeps the header + * readable in either mode). The gate handles "already logged in", so this only + * needs to collect credentials. */ export function LoginScreen() { const { t } = useTranslation(); @@ -23,16 +27,6 @@ export function LoginScreen() { [t], ); - // Auth pages render in light mode (the shared screen uses light-only tokens). - useEffect(() => { - const html = document.documentElement; - const previous = html.getAttribute("data-mantine-color-scheme"); - html.setAttribute("data-mantine-color-scheme", "light"); - return () => { - if (previous) html.setAttribute("data-mantine-color-scheme", previous); - }; - }, []); - return ( } > - + ); } diff --git a/frontend/editor/src/saas/components/OnboardingBootstrap.tsx b/frontend/editor/src/saas/components/OnboardingBootstrap.tsx index 27ae92b16f..7eccaa2305 100644 --- a/frontend/editor/src/saas/components/OnboardingBootstrap.tsx +++ b/frontend/editor/src/saas/components/OnboardingBootstrap.tsx @@ -1,35 +1,21 @@ -import { useEffect, useState } from "react"; +import { useEffect } from "react"; import { usePreferences } from "@app/contexts/PreferencesContext"; import { useOnboarding } from "@app/contexts/OnboardingContext"; -import { useAuth } from "@app/auth/UseSession"; -import SaasOnboardingModal from "@app/components/onboarding/SaasOnboardingModal"; -const STORAGE_KEY = "saas_onboarding_seen"; const ONBOARDING_SESSION_BLOCK_KEY = "stirling-onboarding-session-active"; /** - * SaaS-only bootstrap to clear deferred tour requests, mark tool panel prompt as completed, - * and show SaaS-specific onboarding on first login. + * SaaS-only bootstrap to clear deferred tour requests and mark the tool panel + * prompt / core intro onboarding as completed. + * + * First-load auto-display is disabled: the SaaS onboarding modal no longer + * appears on first login. The modal component (SaasOnboardingModal) is retained + * for explicit/manual triggering, but nothing opens it automatically here. */ export default function OnboardingBootstrap() { const { preferences, updatePreference } = usePreferences(); const { clearPendingTourRequest, setStartAfterToolModeSelection } = useOnboarding(); - const { user, loading } = useAuth(); - const [showModal, setShowModal] = useState(false); - - // Show the onboarding modal once on first login, after the user has loaded. - useEffect(() => { - const hasSeenOnboarding = localStorage.getItem(STORAGE_KEY) === "true"; - if (user && !hasSeenOnboarding && !loading && !showModal) { - setShowModal(true); - } - }, [user, loading, showModal]); - - const handleClose = () => { - localStorage.setItem(STORAGE_KEY, "true"); - setShowModal(false); - }; // Keep existing logic to disable core onboarding flags useEffect(() => { @@ -69,8 +55,5 @@ export default function OnboardingBootstrap() { setStartAfterToolModeSelection, ]); - // Only render modal when it should be shown to avoid running hooks unnecessarily - return showModal ? ( - - ) : null; + return null; } diff --git a/frontend/editor/src/saas/components/onboarding/OnboardingChecklist.module.css b/frontend/editor/src/saas/components/onboarding/OnboardingChecklist.module.css new file mode 100644 index 0000000000..344743473a --- /dev/null +++ b/frontend/editor/src/saas/components/onboarding/OnboardingChecklist.module.css @@ -0,0 +1,168 @@ +.card { + margin: 0.5rem; + padding: 0.625rem 0.75rem 0.6875rem; + background: var(--mantine-color-body); + border: 1px solid + color-mix(in srgb, var(--mantine-color-default-border) 45%, transparent); + border-radius: 0.625rem; + box-shadow: none; +} + +.titleGroup { + display: flex; + align-items: center; + gap: 0.375rem; + min-width: 0; +} + +.logo { + width: 1.125rem; + height: 1.125rem; + flex-shrink: 0; + border-radius: 0.25rem; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + cursor: pointer; + user-select: none; +} + +.title { + font-size: 0.8125rem; + font-weight: 600; + color: var(--mantine-color-text); + line-height: 1.2; +} + +.headerRight { + display: flex; + align-items: center; + gap: 0.125rem; + flex-shrink: 0; +} + +.progressCount { + font-size: 0.75rem; + color: var(--mantine-color-dimmed); + font-variant-numeric: tabular-nums; +} + +.chevron { + font-size: 0.95rem !important; + color: var(--mantine-color-dimmed); +} + +.closeButton { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 2px; + border: none; + background: transparent; + color: var(--mantine-color-dimmed); + border-radius: 0.375rem; + cursor: pointer; +} + +.closeButton:hover { + background: var(--mantine-color-default-hover); + color: var(--mantine-color-text); +} + +.closeIcon { + font-size: 0.95rem !important; +} + +.completeIcon { + font-size: 1.05rem !important; + color: var(--mantine-color-blue-6); +} + +.progressTrack { + margin-top: 0.5rem; + height: 4px; + border-radius: 999px; + background: var(--mantine-color-default-border); + overflow: hidden; +} + +.progressFill { + height: 100%; + border-radius: 999px; + background: var(--mantine-color-blue-6); + transition: width 0.25s ease; +} + +.items { + display: flex; + flex-direction: column; + margin-top: 0.5rem; +} + +.item { + display: flex; + align-items: flex-start; + gap: 0.5rem; + padding: 0.375rem 0.25rem; + border: none; + background: transparent; + border-radius: 0.375rem; + text-align: left; + cursor: pointer; + width: 100%; +} + +.item:hover { + background: var(--mantine-color-default-hover); +} + +.itemIcon { + display: inline-flex; + flex-shrink: 0; + margin-top: 1px; +} + +.checkDone { + font-size: 1.05rem !important; + color: var(--mantine-color-blue-6); +} + +.checkTodo { + font-size: 1.05rem !important; + color: var(--mantine-color-default-border); +} + +.itemText { + display: flex; + flex-direction: column; + gap: 0.0625rem; + min-width: 0; +} + +.itemTitle { + font-size: 0.8125rem; + font-weight: 500; + color: var(--mantine-color-text); + line-height: 1.25; +} + +.itemTitleDone { + text-decoration: line-through; + color: var(--mantine-color-dimmed); + font-weight: 400; +} + +.itemDescription { + font-size: 0.75rem; + color: var(--mantine-color-dimmed); + line-height: 1.25; +} + +.itemDescriptionDone { + text-decoration: line-through; + opacity: 0.75; +} diff --git a/frontend/editor/src/saas/components/onboarding/OnboardingChecklist.tsx b/frontend/editor/src/saas/components/onboarding/OnboardingChecklist.tsx new file mode 100644 index 0000000000..6f2eeade9a --- /dev/null +++ b/frontend/editor/src/saas/components/onboarding/OnboardingChecklist.tsx @@ -0,0 +1,293 @@ +import { useCallback, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import CheckCircleIcon from "@mui/icons-material/CheckCircle"; +import RadioButtonUncheckedIcon from "@mui/icons-material/RadioButtonUnchecked"; +import CloseIcon from "@mui/icons-material/Close"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import ExpandLessIcon from "@mui/icons-material/ExpandLess"; +import { useAuth } from "@app/auth/UseSession"; +import SaasOnboardingModal from "@app/components/onboarding/SaasOnboardingModal"; +import StaticOnboardingSlide from "@app/components/onboarding/StaticOnboardingSlide"; +import { DEFAULT_RUNTIME_STATE } from "@app/components/onboarding/orchestrator/onboardingConfig"; +import { + getFlowProgress, + hasSeenFlow, + markFlowSeen, + setStepDone, +} from "@app/components/onboarding/orchestrator/onboardingStorage"; +import { openAppSettings } from "@app/utils/appSettings"; +import { requestStartTour } from "@app/constants/events"; +import apiClient from "@app/services/apiClient"; +import stirlingMark from "@app/assets/brand/modern-logo/logo512.png"; +import styles from "@app/components/onboarding/OnboardingChecklist.module.css"; + +const FLOW_ID = "saas-checklist"; +const STEP_DOWNLOAD_DESKTOP = "download-desktop"; +const STEP_INVITE_TEAM = "invite-team"; +const STEP_TAKE_TOUR = "take-tour"; +const STEP_SHARE_ANALYTICS = "share-analytics"; + +interface ChecklistItem { + id: string; + titleKey: string; + titleFallback: string; + descriptionKey: string; + descriptionFallback: string; + onClick: () => void; +} + +/** + * SaaS-only getting-started checklist that floats above the sidebar footer for + * new users. Progress is persisted per step via the shared onboarding store, so + * completed items stay ticked across reloads. Dismissing it (the X) hides it + * permanently for the user. + */ +export function OnboardingChecklist() { + const { t } = useTranslation(); + const { isAnonymous, loading } = useAuth(); + + const [dismissed, setDismissed] = useState(() => hasSeenFlow(FLOW_ID)); + const [done, setDone] = useState(() => getFlowProgress(FLOW_ID)); + const [expanded, setExpanded] = useState(true); + const [downloadOpen, setDownloadOpen] = useState(false); + const [analyticsOpen, setAnalyticsOpen] = useState(false); + + const markDone = useCallback((stepId: string) => { + setStepDone(FLOW_ID, stepId); + setDone((prev) => (prev.includes(stepId) ? prev : [...prev, stepId])); + }, []); + + const handleInviteTeam = useCallback(() => { + // Open the settings modal on the Teams section without touching the URL + // (event-driven open + navigate; no /settings/teams pushState). + openAppSettings("teams"); + markDone(STEP_INVITE_TEAM); + }, [markDone]); + + const handleTakeTour = useCallback(() => { + // Always the user (tools) walkthrough, regardless of admin/user role. The + // editor's onboarding listens for this event and drives the tour overlay. + requestStartTour("tools"); + markDone(STEP_TAKE_TOUR); + }, [markDone]); + + const items: ChecklistItem[] = useMemo( + () => [ + { + id: STEP_DOWNLOAD_DESKTOP, + titleKey: "onboarding.checklist.downloadDesktop.title", + titleFallback: "Download Stirling for Desktop", + descriptionKey: "onboarding.checklist.downloadDesktop.description", + descriptionFallback: "Run Stirling natively on your machine", + onClick: () => setDownloadOpen(true), + }, + { + id: STEP_INVITE_TEAM, + titleKey: "onboarding.checklist.inviteTeam.title", + titleFallback: "Invite team members", + descriptionKey: "onboarding.checklist.inviteTeam.description", + descriptionFallback: "Collaborate with your team", + onClick: handleInviteTeam, + }, + { + id: STEP_TAKE_TOUR, + titleKey: "onboarding.checklist.takeTour.title", + titleFallback: "Take the tour", + descriptionKey: "onboarding.checklist.takeTour.description", + descriptionFallback: "See how Stirling works in a quick walkthrough", + onClick: handleTakeTour, + }, + { + id: STEP_SHARE_ANALYTICS, + titleKey: "onboarding.checklist.shareAnalytics.title", + titleFallback: "Share anonymous usage data", + descriptionKey: "onboarding.checklist.shareAnalytics.description", + descriptionFallback: "Help improve Stirling", + onClick: () => setAnalyticsOpen(true), + }, + ], + [handleInviteTeam, handleTakeTour], + ); + + const doneCount = items.filter((item) => done.includes(item.id)).length; + const total = items.length; + const allDone = total > 0 && doneCount === total; + + const handleDismiss = useCallback(() => { + markFlowSeen(FLOW_ID); + setDismissed(true); + }, []); + + // Both "skip" and "download" in the reused slide close the modal, and either + // one should complete the task. + const handleDownloadClose = useCallback(() => { + markDone(STEP_DOWNLOAD_DESKTOP); + setDownloadOpen(false); + }, [markDone]); + + const closeAnalytics = useCallback(() => { + markDone(STEP_SHARE_ANALYTICS); + setAnalyticsOpen(false); + }, [markDone]); + + const handleAnalyticsAction = useCallback( + (action: string) => { + if (action === "enable-analytics" || action === "disable-analytics") { + const formData = new FormData(); + formData.append( + "enabled", + action === "enable-analytics" ? "true" : "false", + ); + void apiClient + .post("/api/v1/settings/update-enable-analytics", formData) + .catch((error) => { + console.error( + "[OnboardingChecklist] analytics update failed", + error, + ); + }); + } + closeAnalytics(); + }, + [closeAnalytics], + ); + + if (loading || isAnonymous || dismissed) { + return null; + } + + return ( + <> +
+
setExpanded((v) => !v)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + setExpanded((v) => !v); + } + }} + > + + + + {t("onboarding.checklist.title", "Set up Stirling PDF")} + + + + + {doneCount} / {total} + + {expanded ? ( + + ) : ( + + )} + { + e.stopPropagation(); + handleDismiss(); + }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + e.stopPropagation(); + handleDismiss(); + } + }} + > + {allDone ? ( + + ) : ( + + )} + + +
+ +
+
+
+ + {expanded && ( +
+ {items.map((item) => { + const isDone = done.includes(item.id); + return ( +
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + item.onClick(); + } + }} + > + + {isDone ? ( + + ) : ( + + )} + + + + {t(item.titleKey, item.titleFallback)} + + + {t(item.descriptionKey, item.descriptionFallback)} + + +
+ ); + })} +
+ )} +
+ + + + {analyticsOpen && ( + + )} + + ); +} diff --git a/frontend/editor/src/saas/components/onboarding/OnboardingTour.tsx b/frontend/editor/src/saas/components/onboarding/OnboardingTour.tsx index 00d65af669..05d68f9dd2 100644 --- a/frontend/editor/src/saas/components/onboarding/OnboardingTour.tsx +++ b/frontend/editor/src/saas/components/onboarding/OnboardingTour.tsx @@ -1,7 +1,118 @@ /** - * SaaS stub — core tour system is suppressed in SaaS. - * SaaS uses SaasOnboardingModal instead. + * SaaS tour runner. + * + * The core onboarding orchestrator (which normally drives the guided tour) is + * not mounted in SaaS — SaaS uses SaasOnboardingModal for onboarding instead. + * So this component listens for the shared start-tour event (e.g. the + * getting-started checklist's "Take the tour") and drives the shared reactour + * presentation directly. + * + * Only the user "tools" walkthrough is offered in SaaS; there is no admin tour + * here. The admin orchestration context is still resolved because the tools + * tour's step builder uses it to open the settings Help section. */ + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { type StepType } from "@reactour/tour"; +import ReactourTour, { + type AdvanceArgs, + type CloseArgs, +} from "@core/components/onboarding/OnboardingTour"; +import { getTourSteps } from "@app/components/onboarding/tourRegistry"; +import { useTourRequest } from "@app/components/onboarding/useOnboardingEffects"; +import { useTourOrchestration } from "@app/contexts/TourOrchestrationContext"; +import { useAdminTourOrchestration } from "@app/contexts/AdminTourOrchestrationContext"; +import { useFilesModalContext } from "@app/contexts/FilesModalContext"; +import { dispatchTourState } from "@app/constants/events"; +import { removeAllGlows } from "@app/components/onboarding/tourGlow"; +import "@core/components/onboarding/OnboardingTour.css"; + export default function OnboardingTour() { - return null; + const { t } = useTranslation(); + const workbench = useTourOrchestration(); + const admin = useAdminTourOrchestration(); + const { openFilesModal, closeFilesModal } = useFilesModalContext(); + const { tourRequested, requestedTourType, clearTourRequest } = + useTourRequest(); + + const [isOpen, setIsOpen] = useState(false); + const [tourType, setTourType] = useState("tools"); + + const isRTL = + typeof document !== "undefined" + ? document.documentElement.dir === "rtl" + : false; + + // Let the rest of the app know a tour is running (e.g. to hide cookie consent). + useEffect(() => dispatchTourState(isOpen), [isOpen]); + + // Open on request (the checklist dispatches "tools"). + useEffect(() => { + if (tourRequested) { + setTourType(requestedTourType); + setIsOpen(true); + clearTourRequest(); + } + }, [tourRequested, requestedTourType, clearTourRequest]); + + useEffect(() => { + if (!isOpen) removeAllGlows(); + return () => removeAllGlows(); + }, [isOpen]); + + const tourSteps = useMemo( + () => + getTourSteps(tourType, { + t, + workbench, + admin, + openFilesModal, + closeFilesModal, + }), + [tourType, t, workbench, admin, openFilesModal, closeFilesModal], + ); + + const finishTour = useCallback(() => { + setIsOpen(false); + void workbench.restoreWorkbenchState(); + }, [workbench]); + + const handleAdvance = useCallback( + (args: AdvanceArgs) => { + const { + setCurrentStep, + currentStep, + steps, + setIsOpen: setReactourOpen, + } = args; + if (steps && currentStep === steps.length - 1) { + setReactourOpen(false); + finishTour(); + } else if (steps) { + setCurrentStep((s) => (s === steps.length - 1 ? 0 : s + 1)); + } + }, + [finishTour], + ); + + const handleClose = useCallback( + (args: CloseArgs) => { + args.setIsOpen(false); + finishTour(); + }, + [finishTour], + ); + + return ( + + ); } diff --git a/frontend/editor/src/saas/components/shared/AppConfigModal.tsx b/frontend/editor/src/saas/components/shared/AppConfigModal.tsx index 7d36b40ee4..7b579f3c43 100644 --- a/frontend/editor/src/saas/components/shared/AppConfigModal.tsx +++ b/frontend/editor/src/saas/components/shared/AppConfigModal.tsx @@ -3,12 +3,14 @@ import { Modal, Text } from "@mantine/core"; import { Button } from "@app/ui/Button"; import { ActionIcon } from "@app/ui/ActionIcon"; import { useMediaQuery } from "@mantine/hooks"; +import { useLocation } from "react-router-dom"; import { useAuth } from "@app/auth/UseSession"; import { isUserAnonymous } from "@app/auth/supabase"; import { useTranslation } from "react-i18next"; import LocalIcon from "@app/components/shared/LocalIcon"; import Overview from "@app/components/shared/config/configSections/Overview"; import { createSaasConfigNavSections } from "@app/components/shared/config/saasConfigNavSections"; +import { consumePendingSettingsNav } from "@app/utils/appSettings"; import { NavKey, type ConfigNavSection, @@ -46,6 +48,15 @@ const AppConfigModal: React.FC = ({ const [confirmOpen, setConfirmOpen] = useState(false); const [active, setActive] = useState("overview"); const [notice, setNotice] = useState(null); + const location = useLocation(); + + // The modal mounts lazily on first open, so a synchronous `appConfig:navigate` + // dispatched by the opener can arrive before the listener below is attached. + // Consume any section stashed by openAppSettings on mount to land on it. + useEffect(() => { + const pending = consumePendingSettingsNav(); + if (pending) setActive(pending); + }, []); // Check if user can access billing features (non-anonymous users only) const isAnonymous = user ? isUserAnonymous(user) : false; @@ -76,13 +87,13 @@ const AppConfigModal: React.FC = ({ setActive(initialSection); return; } - const match = stripBasePath(window.location.pathname).match( + const match = stripBasePath(location.pathname).match( /^\/settings\/([^/?#]+)/, ); if (match) { setActive(match[1] as NavKey); } - }, [opened, initialSection]); + }, [opened, initialSection, location.pathname]); // Listen for notice updates (e.g., "Not enough credits..." next to Plan title) useEffect(() => { @@ -188,7 +199,7 @@ const AppConfigModal: React.FC = ({ closeOnEscape={!overlayActive} closeOnClickOutside={!overlayActive} > -
+
{/* Left navigation */}
= ({ return (
setActive(item.key)} className={`modal-nav-item ${isMobile ? "mobile" : ""}`} style={{ diff --git a/frontend/editor/src/saas/components/shared/SidebarChecklistSlot.tsx b/frontend/editor/src/saas/components/shared/SidebarChecklistSlot.tsx new file mode 100644 index 0000000000..84dea6ccde --- /dev/null +++ b/frontend/editor/src/saas/components/shared/SidebarChecklistSlot.tsx @@ -0,0 +1,13 @@ +import { type SidebarChecklistSlotProps } from "@core/components/shared/SidebarChecklistSlot"; +export { type SidebarChecklistSlotProps }; + +import { OnboardingChecklist } from "@app/components/onboarding/OnboardingChecklist"; + +/** + * SaaS getting-started checklist, floating above the sidebar footer. Hidden + * when the sidebar is collapsed to its narrow rail. + */ +export function SidebarChecklistSlot({ collapsed }: SidebarChecklistSlotProps) { + if (collapsed) return null; + return ; +} diff --git a/frontend/editor/src/saas/components/shared/config/saasConfigNavSections.tsx b/frontend/editor/src/saas/components/shared/config/saasConfigNavSections.tsx index d3165c77a7..34a4972ce1 100644 --- a/frontend/editor/src/saas/components/shared/config/saasConfigNavSections.tsx +++ b/frontend/editor/src/saas/components/shared/config/saasConfigNavSections.tsx @@ -10,6 +10,7 @@ import GeneralWithLoginLanding from "@app/components/shared/config/GeneralWithLo import PasswordSecurity from "@app/components/shared/config/configSections/PasswordSecurity"; import ApiKeys from "@app/components/shared/config/configSections/ApiKeys"; import McpSection from "@app/components/shared/config/configSections/McpSection"; +import HelpSection from "@app/components/shared/config/configSections/HelpSection"; import LegalSection from "@app/components/shared/config/configSections/LegalSection"; import { createCloudBillingSection, @@ -22,6 +23,8 @@ interface CreateSaasConfigNavSectionsOptions { isDev?: boolean; isAnonymous?: boolean; t: TFunction<"translation", undefined>; + /** Close the settings modal — the Help tours need it to start the tour. */ + onRequestClose?: () => void; } function ensurePreferencesSection( @@ -141,6 +144,37 @@ function appendMcpSection( ); } +function appendHelpSection( + sections: ConfigNavSection[], + t: TFunction<"translation", undefined>, + onRequestClose: () => void, +): ConfigNavSection[] { + const hasHelp = sections.some((section) => + section.items.some((item) => item.key === "help"), + ); + + if (hasHelp) { + return sections; + } + + return [ + ...sections, + { + title: t("settings.help.title", "Help"), + items: [ + { + key: "help" as const, + label: t("settings.help.label", "Tours"), + icon: "help-rounded", + component: ( + + ), + }, + ], + }, + ]; +} + // Legal links (privacy policy, terms, etc.). Shown to anonymous users too — // it's public information. function appendLegalSection( @@ -174,7 +208,12 @@ function appendLegalSection( export function createSaasConfigNavSections( Overview: OverviewComponent, onLogoutClick: () => void, - { isDev = false, isAnonymous = false, t }: CreateSaasConfigNavSectionsOptions, + { + isDev = false, + isAnonymous = false, + t, + onRequestClose = () => {}, + }: CreateSaasConfigNavSectionsOptions, ): ConfigNavSection[] { const baseSections = createCoreConfigNavSections(false, false, false); @@ -230,6 +269,7 @@ export function createSaasConfigNavSections( sections = appendBillingSection(sections, t); } + sections = appendHelpSection(sections, t, onRequestClose); sections = appendLegalSection(sections, t); if (isDev) { diff --git a/frontend/editor/src/saas/utils/appSettings.ts b/frontend/editor/src/saas/utils/appSettings.ts index 83f41940a4..989e671a0a 100644 --- a/frontend/editor/src/saas/utils/appSettings.ts +++ b/frontend/editor/src/saas/utils/appSettings.ts @@ -3,14 +3,25 @@ import type { NavKey } from "@app/components/shared/config/types"; +let pendingNavKey: NavKey | null = null; + +/** Read and clear the section a caller asked to open the modal on (if any). */ +export function consumePendingSettingsNav(): NavKey | null { + const key = pendingNavKey; + pendingNavKey = null; + return key; +} + export function openAppSettings(targetKey?: NavKey, notice?: string) { try { const detail: { key?: NavKey; notice?: string } = {}; if (targetKey) detail.key = targetKey; if (notice) detail.notice = notice; + // Stash the target so a not-yet-mounted (lazy) modal starts on it. + if (targetKey) pendingNavKey = targetKey; // Ask the UI to open the App Config modal window.dispatchEvent(new CustomEvent("appConfig:open", { detail })); - // If a specific section is requested, navigate there once modal mounts + // Navigate there too — handles the case where the modal is already mounted. if (targetKey) { window.dispatchEvent( new CustomEvent("appConfig:navigate", { detail: { key: targetKey } }), From fd81bf4cf8c9ce72d388ff9f6134f5254f6d78e7 Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:44:19 +0100 Subject: [PATCH 18/34] Tighten whitespace between search bar and tool list (#6977) --- .taskfiles/frontend.yml | 6 +++ .../src/core/components/tools/ToolPicker.tsx | 2 +- .../src/core/utils/applyDevWorktreeLabel.ts | 39 +++++++++++++++++++ frontend/editor/src/index.tsx | 3 ++ frontend/editor/vite-env.d.ts | 7 ++++ frontend/editor/vite.config.ts | 11 +++++- 6 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 frontend/editor/src/core/utils/applyDevWorktreeLabel.ts diff --git a/.taskfiles/frontend.yml b/.taskfiles/frontend.yml index 4f87226f93..b402df7d1b 100644 --- a/.taskfiles/frontend.yml +++ b/.taskfiles/frontend.yml @@ -80,6 +80,12 @@ tasks: OPEN: '{{.OPEN | default ""}}' env: BACKEND_URL: '{{.BACKEND_URL}}' + # Dev-only browser-tab label so concurrent worktrees are distinguishable. + # Only the worktree folder basename (e.g. "wt1") is exposed — never the + # full path, hostname, or user. Consumed at dev-serve time by vite.config + # and dropped from production builds. + STIRLING_DEV_LABEL: + sh: basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)" cmds: - npx vite editor --mode {{.MODE}} --port {{.PORT}}{{if .OPEN}} --open{{end}} diff --git a/frontend/editor/src/core/components/tools/ToolPicker.tsx b/frontend/editor/src/core/components/tools/ToolPicker.tsx index 27b0caf7d5..779e945b8c 100644 --- a/frontend/editor/src/core/components/tools/ToolPicker.tsx +++ b/frontend/editor/src/core/components/tools/ToolPicker.tsx @@ -197,7 +197,7 @@ const ToolPicker = ({ ) : ( <> {/* All-tools view: favourites + recommended + all subcategories. */} - + {favoriteToolItems.length > 0 && (
diff --git a/frontend/editor/src/core/utils/applyDevWorktreeLabel.ts b/frontend/editor/src/core/utils/applyDevWorktreeLabel.ts new file mode 100644 index 0000000000..f1c0164e27 --- /dev/null +++ b/frontend/editor/src/core/utils/applyDevWorktreeLabel.ts @@ -0,0 +1,39 @@ +/** + * Prefixes the browser tab title with the current worktree name during local + * development so multiple concurrently-running worktrees (e.g. wt1, wt2, spdf1) + * are distinguishable at a glance instead of all showing "Stirling PDF". + * + * The label is injected as a build-time constant by vite.config, sourced from + * the top-level dev tasks. It is an empty string in production builds, so this + * whole feature compiles away to a no-op outside `vite` dev-serve. + */ + +const LABEL = + typeof __DEV_WORKTREE_LABEL__ === "string" ? __DEV_WORKTREE_LABEL__ : ""; + +export function applyDevWorktreeLabel(): void { + if (!LABEL || typeof document === "undefined") { + return; + } + + const prefix = `[${LABEL}] `; + + const ensurePrefixed = () => { + if (!document.title.startsWith(prefix)) { + // The app rewrites document.title on route/tool changes; re-apply the + // prefix on top of whatever the app just set. + document.title = prefix + document.title; + } + }; + + ensurePrefixed(); + + const titleEl = document.querySelector("title"); + if (titleEl) { + new MutationObserver(ensurePrefixed).observe(titleEl, { + childList: true, + characterData: true, + subtree: true, + }); + } +} diff --git a/frontend/editor/src/index.tsx b/frontend/editor/src/index.tsx index d776918bd1..6ee0f6df51 100644 --- a/frontend/editor/src/index.tsx +++ b/frontend/editor/src/index.tsx @@ -14,9 +14,12 @@ import { BrowserRouter } from "react-router-dom"; import App from "@app/App"; import "@app/i18n"; // Initialize i18next import { BASE_PATH } from "@app/constants/app"; +import { applyDevWorktreeLabel } from "@app/utils/applyDevWorktreeLabel"; import { startEagerWasmCompilation } from "@app/services/wasmPrecompiler"; +applyDevWorktreeLabel(); + if (typeof window !== "undefined") { const scheduleCompilation = () => { if (typeof requestIdleCallback === "function") { diff --git a/frontend/editor/vite-env.d.ts b/frontend/editor/vite-env.d.ts index e9fb24e496..a410ba3bc7 100644 --- a/frontend/editor/vite-env.d.ts +++ b/frontend/editor/vite-env.d.ts @@ -32,3 +32,10 @@ interface ImportMetaEnv { interface ImportMeta { readonly env: ImportMetaEnv; } + +/** + * Dev-only worktree folder basename injected by vite.config at dev-serve time + * (empty string in production builds). Used to prefix the browser tab title so + * concurrent worktrees are distinguishable. + */ +declare const __DEV_WORKTREE_LABEL__: string; diff --git a/frontend/editor/vite.config.ts b/frontend/editor/vite.config.ts index 059f98332e..b5733bfe02 100644 --- a/frontend/editor/vite.config.ts +++ b/frontend/editor/vite.config.ts @@ -182,7 +182,13 @@ const TSCONFIG_MAP: Record = { prototypes: "./tsconfig.prototypes.vite.json", }; -export default defineConfig(async ({ mode }) => { +export default defineConfig(async ({ mode, command }) => { + // Dev-only browser-tab label (worktree folder basename) surfaced by the + // top-level dev tasks so concurrent worktrees have distinguishable tabs. + // Only injected during `vite` (dev serve) — never baked into a production + // build — and carries only the folder name, no path/host/user info. + const devWorktreeLabel = + command === "serve" ? (process.env.STIRLING_DEV_LABEL ?? "") : ""; // Load env files relative to this config (frontend/editor/), regardless of // where the build was invoked from. The previous `process.cwd()` worked when // this file lived at frontend/, but after the editor was moved under @@ -250,6 +256,9 @@ export default defineConfig(async ({ mode }) => { }; return { + define: { + __DEV_WORKTREE_LABEL__: JSON.stringify(devWorktreeLabel), + }, plugins: [ react(), ...(runSubpath ? [subpathBareRedirectPlugin(runSubpath)] : []), From 5944cd106bff9d1a25c122c134190b885ba7a1b5 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:46:44 +0100 Subject: [PATCH 19/34] Portal audit: label policy runs by their policy, flag automation sub-steps (#6937) --- .../common/service/InternalApiClient.java | 25 +++ .../proprietary/audit/AuditAspect.java | 3 + .../proprietary/audit/AuditContext.java | 18 +++ .../audit/ControllerAuditAspect.java | 4 + .../model/api/audit/InfraAuditSummary.java | 3 + .../policy/controller/PolicyController.java | 33 ++++ .../policy/engine/PolicyEngine.java | 19 ++- .../PersistentAuditEventRepository.java | 14 ++ .../proprietary/service/AuditService.java | 69 ++++++++ .../service/PortalAuditReadService.java | 17 +- .../service/PortalDocumentsService.java | 22 ++- .../service/PortalInfraAuditService.java | 79 +++++++++- .../proprietary/service/AuditServiceTest.java | 52 ++++++ .../service/PortalAuditReadServiceTest.java | 73 +++++++++ .../service/PortalDocumentsServiceTest.java | 73 +++++++++ .../service/PortalInfraAuditServiceTest.java | 149 ++++++++++++++++++ .../public/locales/en-US/translation.toml | 4 +- frontend/editor/src/portal/api/documents.ts | 5 +- .../editor/src/portal/api/infrastructure.ts | 2 + .../components/infrastructure/AuditTab.tsx | 9 +- .../components/infrastructure/infraFormat.ts | 2 + .../editor/src/portal/mocks/infrastructure.ts | 1 + 22 files changed, 658 insertions(+), 18 deletions(-) create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditContext.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/service/PortalAuditReadServiceTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/service/PortalDocumentsServiceTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/service/PortalInfraAuditServiceTest.java diff --git a/app/common/src/main/java/stirling/software/common/service/InternalApiClient.java b/app/common/src/main/java/stirling/software/common/service/InternalApiClient.java index 39e27457b3..63d577ca85 100644 --- a/app/common/src/main/java/stirling/software/common/service/InternalApiClient.java +++ b/app/common/src/main/java/stirling/software/common/service/InternalApiClient.java @@ -8,6 +8,7 @@ import java.nio.file.Files; import java.time.Duration; import java.util.regex.Pattern; +import org.slf4j.MDC; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.env.Environment; import org.springframework.core.io.FileSystemResource; @@ -60,6 +61,17 @@ public class InternalApiClient { */ public static final String AUTOMATION_HEADER = "X-Stirling-Automation"; + /** + * Header carrying the parent policy's name onto each sub-step dispatch, read from MDC key + * {@link #POLICY_NAME_MDC_KEY} (set by the policy runner on the worker thread). Lets the audit + * layer attribute a tool step to the policy that ran it, instead of showing it as a bare direct + * call. + */ + public static final String POLICY_NAME_HEADER = "X-Stirling-Policy-Name"; + + /** MDC key the policy runner stamps with the running policy's name; forwarded as a header. */ + public static final String POLICY_NAME_MDC_KEY = "auditPolicyName"; + private final ServletContext servletContext; private final UserServiceInterface userService; private final TempFileManager tempFileManager; @@ -120,6 +132,19 @@ public class InternalApiClient { headers.add(AutomationRunContext.RUN_ID_HEADER, runId); } + // Forward the parent policy name (set in MDC by the policy runner) so the audited sub-step + // ties back to its policy. Single-line, length-capped: it becomes an HTTP header value. + String policyName = MDC.get(POLICY_NAME_MDC_KEY); + if (policyName != null && !policyName.isBlank()) { + String safe = policyName.replaceAll("[\\r\\n]", " ").trim(); + if (safe.length() > 200) { + safe = safe.substring(0, 200); + } + if (!safe.isEmpty()) { + headers.add(POLICY_NAME_HEADER, safe); + } + } + // A no-file ai/tools call (e.g. create-pdf-from-html-agent) sends only string params, so // without this RestTemplate would use urlencoded instead of the multipart the controller // expects. File-bearing calls get the right multipart content-type from RestTemplate. diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditAspect.java b/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditAspect.java index f42a0f29c1..12b2787b75 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditAspect.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditAspect.java @@ -142,6 +142,9 @@ public class AuditAspect { auditService.addTimingData( auditData, startTime, resp, auditedAnnotation.level(), isHttpRequest); + // Merge controller-set policy context + the internal-automation marker onto the event. + auditService.addAutomationContext(auditData, req); + // Resolve the event type based on annotation and context String httpMethod = null; String path = null; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditContext.java b/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditContext.java new file mode 100644 index 0000000000..650727f5ef --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditContext.java @@ -0,0 +1,18 @@ +package stirling.software.proprietary.audit; + +/** + * Request-scoped keys a controller can set to enrich its own audit event with context the generic + * aspect can't infer from the HTTP request alone (e.g. the policy a pipeline run belongs to). The + * aspect copies these into the audit data in its {@code finally} block, after the controller body + * has run. See {@code AuditService#addAutomationContext}. + */ +public final class AuditContext { + + /** Request attribute: the name of the policy/pipeline a run executes. */ + public static final String REQ_ATTR_POLICY_NAME = "stirling.audit.policyName"; + + /** Request attribute: the ordered tool endpoint paths a run executes. */ + public static final String REQ_ATTR_POLICY_STEPS = "stirling.audit.policySteps"; + + private AuditContext() {} +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/audit/ControllerAuditAspect.java b/app/proprietary/src/main/java/stirling/software/proprietary/audit/ControllerAuditAspect.java index c59c6f3721..2f75b755e0 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/audit/ControllerAuditAspect.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/audit/ControllerAuditAspect.java @@ -205,6 +205,10 @@ public class ControllerAuditAspect { // Call auditService but with isHttpRequest=true to skip additional timing auditService.addTimingData(data, start, resp, level, true); + // Merge controller-set policy context + the internal-automation marker (set after + // the body ran, so it must happen here rather than with the pre-proceed HTTP data). + auditService.addAutomationContext(data, req); + // Resolve the event type using the unified method AuditEventType eventType = auditService.resolveEventType( diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/audit/InfraAuditSummary.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/audit/InfraAuditSummary.java index c924218e3a..835344e81d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/audit/InfraAuditSummary.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/audit/InfraAuditSummary.java @@ -17,6 +17,9 @@ public class InfraAuditSummary { @Schema(description = "Total events in the returned window", example = "40") private int totalEvents; + @Schema(description = "Policy-run events", example = "3") + private int policy; + @Schema(description = "Processing-category events", example = "24") private int processing; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java index 3c9b56128a..e2c89f6a54 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java @@ -23,6 +23,8 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.server.ResponseStatusException; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; @@ -32,6 +34,7 @@ import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -42,6 +45,7 @@ import stirling.software.common.model.job.JobResponse; import stirling.software.common.service.JobOwnershipService; import stirling.software.common.util.TempFile; import stirling.software.common.util.TempFileManager; +import stirling.software.proprietary.audit.AuditContext; import stirling.software.proprietary.policy.config.PolicyAccessGuard; import stirling.software.proprietary.policy.config.PolicyManagementAuthority; import stirling.software.proprietary.policy.engine.PolicyRunHandle; @@ -52,6 +56,7 @@ import stirling.software.proprietary.policy.engine.SweepOutcome; import stirling.software.proprietary.policy.ledger.ProcessedLedger; import stirling.software.proprietary.policy.model.OutputSpec; import stirling.software.proprietary.policy.model.PipelineDefinition; +import stirling.software.proprietary.policy.model.PipelineStep; import stirling.software.proprietary.policy.model.Policy; import stirling.software.proprietary.policy.model.PolicyInputs; import stirling.software.proprietary.policy.model.PolicyRun; @@ -113,6 +118,7 @@ public class PolicyController { @RequestPart("json") PipelineDefinition definition, @Valid @ModelAttribute PolicyRunFiles files) throws IOException { + stampPolicyAudit(definition); requireRunnable(definition); PolicyInputs inputs = toInputs(files); PolicyRunHandle handle = @@ -132,6 +138,7 @@ public class PolicyController { @RequestPart("json") PipelineDefinition definition, @Valid @ModelAttribute PolicyRunFiles files) throws IOException { + stampPolicyAudit(definition); requireRunnable(definition); PolicyInputs inputs = toInputs(files); @@ -464,6 +471,7 @@ public class PolicyController { () -> new ResponseStatusException( HttpStatus.NOT_FOUND, "No policy: " + policyId)); + stampPolicyAudit(policy.toDefinition()); PolicyInputs inputs = toInputs(files); String runId = policyRunner.runWith(policy, inputs, PolicyProgressListener.NOOP).runId(); return ResponseEntity.accepted().body(new JobResponse<>(true, runId, null)); @@ -497,6 +505,31 @@ public class PolicyController { } } + /** + * Stamp this run's policy name and step endpoints onto the current request so the audit aspect + * can label the event as the policy it ran (rather than the generic {@code /run} endpoint) and + * record which tools it executed. No-op outside a web request. + */ + private static void stampPolicyAudit(PipelineDefinition definition) { + if (definition == null + || !(RequestContextHolder.getRequestAttributes() + instanceof ServletRequestAttributes attrs)) { + return; + } + HttpServletRequest request = attrs.getRequest(); + if (definition.name() != null && !definition.name().isBlank()) { + request.setAttribute(AuditContext.REQ_ATTR_POLICY_NAME, definition.name()); + } + List steps = + definition.steps().stream() + .map(PipelineStep::operation) + .filter(op -> op != null && !op.isBlank()) + .toList(); + if (!steps.isEmpty()) { + request.setAttribute(AuditContext.REQ_ATTR_POLICY_STEPS, steps); + } + } + /** * Ad-hoc runs (AI / one-off pipelines) are still editor activity, so their supplied documents * feed the same virtual editor source as stored editor policies, counted against the caller's diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java index cef0f29e6f..d54caedd14 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java @@ -23,6 +23,7 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.job.ResultFile; import stirling.software.common.service.AutomationRunContext; import stirling.software.common.service.FileStorage; +import stirling.software.common.service.InternalApiClient; import stirling.software.common.service.InternalApiTimeoutException; import stirling.software.common.service.JobOwnershipService; import stirling.software.common.service.JobQueue; @@ -149,6 +150,7 @@ public class PolicyEngine { runAsPrincipal( billingPrincipal, fileOwner, + definition.name(), () -> runToCompletion(run, inputs, tracking, completion)); // One admission unit per run; steps run synchronously within it, so this gates heavy work @@ -377,18 +379,26 @@ public class PolicyEngine { * dispatch attributes (and charges) usage to that user. A null/blank principal runs as-is. * Restores the previous MDC value afterward (defensive — worker threads aren't pooled). */ - private static void runAsPrincipal(String billingPrincipal, String fileOwner, Runnable body) { + private static void runAsPrincipal( + String billingPrincipal, String fileOwner, String policyName, Runnable body) { // Billing identity (MDC auditPrincipal) and output-file ownership (JobContext owner) are // set // independently: usage is charged to billingPrincipal, but stored output files are owned by // fileOwner — the user who triggered an org-wide policy — so they can fetch their results. // Either may be null (e.g. login disabled, or a trigger-fired run); each is applied only - // when present and restored afterward (defensive — worker threads aren't pooled). + // when present and restored afterward (defensive — worker threads aren't pooled). The + // policy + // name rides MDC too so each tool step's loopback dispatch (InternalApiClient) can forward + // it as a header, letting the audit tie the step back to its policy. String previousPrincipal = MDC.get(AUDIT_PRINCIPAL_MDC_KEY); + String previousPolicyName = MDC.get(InternalApiClient.POLICY_NAME_MDC_KEY); String previousOwner = JobContext.getOwner(); if (billingPrincipal != null && !billingPrincipal.isBlank()) { MDC.put(AUDIT_PRINCIPAL_MDC_KEY, billingPrincipal); } + if (policyName != null && !policyName.isBlank()) { + MDC.put(InternalApiClient.POLICY_NAME_MDC_KEY, policyName); + } if (fileOwner != null && !fileOwner.isBlank()) { JobContext.setOwner(fileOwner); } @@ -400,6 +410,11 @@ public class PolicyEngine { } else { MDC.remove(AUDIT_PRINCIPAL_MDC_KEY); } + if (previousPolicyName != null) { + MDC.put(InternalApiClient.POLICY_NAME_MDC_KEY, previousPolicyName); + } else { + MDC.remove(InternalApiClient.POLICY_NAME_MDC_KEY); + } JobContext.setOwner(previousOwner); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/repository/PersistentAuditEventRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/repository/PersistentAuditEventRepository.java index 95075945ed..e140b8f5c8 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/repository/PersistentAuditEventRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/repository/PersistentAuditEventRepository.java @@ -184,6 +184,20 @@ public interface PersistentAuditEventRepository extends JpaRepository findByPrincipalIn( @Param("principals") List principals, Pageable pageable); + // Noise-excluding scans for the portal: keep the page window full of meaningful events so the + // list doesn't shrink as read/polling noise (UI_DATA/HTTP_REQUEST) grows in the recent window. + @Query("SELECT e FROM PersistentAuditEvent e WHERE e.type NOT IN :excludedTypes") + Page findByTypeNotIn( + @Param("excludedTypes") List excludedTypes, Pageable pageable); + + @Query( + "SELECT e FROM PersistentAuditEvent e WHERE e.type NOT IN :excludedTypes AND e.principal" + + " IN :principals") + Page findByTypeNotInAndPrincipalIn( + @Param("excludedTypes") List excludedTypes, + @Param("principals") List principals, + Pageable pageable); + @Query( "SELECT e FROM PersistentAuditEvent e WHERE e.type IN :types AND e.timestamp BETWEEN :startDate AND :endDate") Page findByTypeInAndTimestampBetween( diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/AuditService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/AuditService.java index a56ece5285..2fee8b5c7c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/AuditService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/AuditService.java @@ -37,9 +37,11 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.api.PDFFile; import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.service.InternalApiClient; import stirling.software.common.util.RegexPatternUtils; import stirling.software.common.util.RequestUriUtils; import stirling.software.proprietary.accountlink.BillableOperationClassifier; +import stirling.software.proprietary.audit.AuditContext; import stirling.software.proprietary.audit.AuditEventType; import stirling.software.proprietary.audit.AuditLevel; import stirling.software.proprietary.audit.Audited; @@ -469,6 +471,73 @@ public class AuditService { } } + /** + * Merge controller-supplied context into the audit data, read in the aspect's {@code finally} + * after the controller body has run. A policy run stamps its name and step endpoints as request + * attributes ({@link AuditContext}); the internal loopback dispatch that executes each pipeline + * step carries the automation marker header ({@link InternalApiClient#AUTOMATION_HEADER}). + * Surfacing these lets the portal label a run as its policy instead of the raw {@code /run} + * endpoint, and flag its sub-steps as automation rather than direct user actions. + * + * @param data The existing audit data map + * @param req The current request, or null when not in a web context + */ + public void addAutomationContext(Map data, HttpServletRequest req) { + if (req == null) { + return; + } + Object policyName = req.getAttribute(AuditContext.REQ_ATTR_POLICY_NAME); + if (policyName == null) { + // A pipeline step's loopback dispatch carries its parent policy name as a header, so + // the + // step audit ties back to the policy that ran it (not a bare direct call). + String header = req.getHeader(InternalApiClient.POLICY_NAME_HEADER); + if (header != null && !header.isBlank()) { + policyName = header; + } + } + if (policyName != null) { + // Both sources are caller-controlled (the header is spoofable and, unlike the sender in + // InternalApiClient, uncapped); strip newlines and cap before persisting to audit JSON. + String safe = capLabel(String.valueOf(policyName)); + if (!safe.isEmpty()) { + data.put("policyName", safe); + } + } + Object steps = req.getAttribute(AuditContext.REQ_ATTR_POLICY_STEPS); + if (steps instanceof List list && !list.isEmpty()) { + // Caller-supplied and unbounded; cap count and each entry so a crafted run can't + // bloat the audit JSON the portal cache loads and parses in bulk. + List safeSteps = + list.stream() + .limit(MAX_POLICY_STEPS) + .map(s -> capLabel(String.valueOf(s))) + .filter(s -> !s.isEmpty()) + .toList(); + if (!safeSteps.isEmpty()) { + data.put("policySteps", safeSteps); + } + } + if ("true".equalsIgnoreCase(req.getHeader(InternalApiClient.AUTOMATION_HEADER))) { + data.put("automation", Boolean.TRUE); + } + } + + /** Max characters kept for a persisted policy label; mirrors the InternalApiClient send cap. */ + private static final int MAX_LABEL_LEN = 200; + + /** Max step endpoints kept on a run's audit event; the portal only shows the first few. */ + private static final int MAX_POLICY_STEPS = 50; + + /** Single-line, length-capped label safe to persist to audit JSON and render in the portal. */ + private static String capLabel(String value) { + if (value == null) { + return ""; + } + String safe = value.replaceAll("[\\r\\n]", " ").trim(); + return safe.length() > MAX_LABEL_LEN ? safe.substring(0, MAX_LABEL_LEN) : safe; + } + /** * Add method arguments to the audit data * diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/PortalAuditReadService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/PortalAuditReadService.java index 1678cd095f..5a94e9d2b1 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/PortalAuditReadService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/PortalAuditReadService.java @@ -9,6 +9,7 @@ import org.springframework.stereotype.Service; import lombok.RequiredArgsConstructor; +import stirling.software.proprietary.audit.AuditEventType; import stirling.software.proprietary.audit.PortalAuditEventRow; import stirling.software.proprietary.model.security.PersistentAuditEvent; import stirling.software.proprietary.repository.PersistentAuditEventRepository; @@ -24,12 +25,21 @@ public class PortalAuditReadService { /** Newest rows to scan; each surface filters this down to what it shows. */ private static final int SCAN_LIMIT = 400; + /** + * Read/polling noise excluded at the query level so the scan window stays full of meaningful + * events. Otherwise a busy scope's recent rows fill with these and the visible list shrinks as + * traffic grows - the "audit getting smaller over time" a user would see. No portal surface + * shows these types anyway (the infra tab and documents feed both drop them). + */ + private static final List NOISE_TYPES = + List.of(AuditEventType.UI_DATA.name(), AuditEventType.HTTP_REQUEST.name()); + private final PersistentAuditEventRepository auditRepository; /** Recent whole-server events (admins). */ @Cacheable(value = CACHE_NAME, key = "'server'") public List serverEvents() { - return toRows(auditRepository.findAll(recentPage()).getContent()); + return toRows(auditRepository.findByTypeNotIn(NOISE_TYPES, recentPage()).getContent()); } /** Recent events by the given principals (team scope). Empty principals yield an empty list. */ @@ -38,7 +48,10 @@ public class PortalAuditReadService { if (principals.isEmpty()) { return List.of(); } - return toRows(auditRepository.findByPrincipalIn(principals, recentPage()).getContent()); + return toRows( + auditRepository + .findByTypeNotInAndPrincipalIn(NOISE_TYPES, principals, recentPage()) + .getContent()); } private static PageRequest recentPage() { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/PortalDocumentsService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/PortalDocumentsService.java index 8784d5c5b6..fcae36f61b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/PortalDocumentsService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/PortalDocumentsService.java @@ -61,8 +61,17 @@ public class PortalDocumentsService { continue; } String path = asString(data.get("path")); - String source = sourceLabel(asString(data.get("__origin"))); - String product = "API integration".equals(source) ? "API" : "Editor"; + // Pipeline steps run over an internal loopback (API-key auth), so origin alone reads as + // "API". The automation marker distinguishes a policy-run step from real API traffic. + boolean automation = isAutomation(data); + String policyName = asString(data.get("policyName")); + String source = + automation + ? (policyName != null && !policyName.isBlank() + ? "Policy: " + policyName + : "Policy automation") + : sourceLabel(asString(data.get("__origin"))); + String product = automation ? "Automation" : productLabel(source); String action = prettyTool(path); boolean failed = isFailure(data); Instant ts = event.timestamp(); @@ -174,6 +183,15 @@ public class PortalDocumentsService { return "Web upload"; } + private static String productLabel(String source) { + return "API integration".equals(source) ? "API" : "Editor"; + } + + private static boolean isAutomation(Map data) { + Object v = data.get("automation"); + return Boolean.TRUE.equals(v) || "true".equalsIgnoreCase(String.valueOf(v)); + } + 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")) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/PortalInfraAuditService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/PortalInfraAuditService.java index 73d45800a8..c47df3649f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/PortalInfraAuditService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/PortalInfraAuditService.java @@ -54,6 +54,7 @@ public class PortalInfraAuditService { .limit(RETURN_LIMIT) .toList(); + int policy = (int) events.stream().filter(e -> "policy".equals(e.getCategory())).count(); int processing = (int) events.stream().filter(e -> "processing".equals(e.getCategory())).count(); int elevation = @@ -63,6 +64,7 @@ public class PortalInfraAuditService { InfraAuditSummary summary = InfraAuditSummary.builder() .totalEvents(events.size()) + .policy(policy) .processing(processing) .elevation(elevation) .config(config) @@ -84,20 +86,32 @@ public class PortalInfraAuditService { private InfraAuditEventDto toDto(PortalAuditEventRow event) { Map data = parseData(event); String path = asString(data.get("path")); - String category = categoryFor(event.type(), path); + String policyName = asString(data.get("policyName")); + boolean automation = isAutomation(data); + // Classify a dispatch by its real run-path URI, not a policyName: the latter can be spoofed + // via the X-Stirling-Policy-Name header to make a direct call pose as a policy row. + boolean policyDispatch = isPolicyRunPath(path) && !automation; + // A dispatch is its own "policy" category so the UI badges it as a policy run, not a + // generic processing op; its internal steps keep their real tool category. + String category = policyDispatch ? "policy" : 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)) + .action(actionFor(event.type(), path, policyName, automation)) .actor(event.principal()) - .target(targetFor(category, path, data)) + .target(targetFor(category, path, data, policyDispatch)) .status(statusFor(event.type(), category, data)) .latencyMs(asLong(data.get("latencyMs"))) .build(); } + private static boolean isAutomation(Map data) { + Object v = data.get("automation"); + return Boolean.TRUE.equals(v) || "true".equalsIgnoreCase(String.valueOf(v)); + } + private Map parseData(PortalAuditEventRow event) { if (event.data() == null || event.data().isEmpty()) { return Map.of(); @@ -140,7 +154,37 @@ public class PortalInfraAuditService { || p.contains("redact"); } - private static String actionFor(String type, String path) { + /** + * Label for a row. A genuine policy dispatch (a {@code /policies/.../run} request) shows the + * policy it ran; an internal pipeline step (automation marker) is flagged so it isn't read as a + * direct action. The name is only shown as the action on a real run URI, so a spoofed + * X-Stirling-Policy-Name header on a direct tool call can't overwrite its true action. + */ + private static String actionFor( + String type, String path, String policyName, boolean automation) { + if (!automation && isPolicyRunPath(path)) { + // A run with no name (ad-hoc pipeline) still reads better than the "run" endpoint. + return policyName != null ? policyName : "Policy run"; + } + String base = baseActionFor(type, path); + if (automation) { + return policyName != null + ? base + " (policy: " + policyName + ")" + : base + " (automation)"; + } + return base; + } + + /** + * The pipeline-run endpoints: {@code /policies/run}, {@code /run/stream}, {@code /{id}/run}. + */ + private static boolean isPolicyRunPath(String path) { + return path != null + && path.contains("/policies/") + && (path.endsWith("/run") || path.endsWith("/run/stream")); + } + + private static String baseActionFor(String type, String path) { AuditEventType t = AuditEventType.fromString(type); if (t == null) { return prettyTool(path); @@ -191,7 +235,32 @@ public class PortalInfraAuditService { return sb.isEmpty() ? "PDF operation" : sb.toString(); } - private static String targetFor(String category, String path, Map data) { + /** "Auto Redact, Compress PDF" from the run's step endpoints; first three, then "+N more". */ + private static String prettyStepList(Object steps) { + if (!(steps instanceof List list) || list.isEmpty()) { + return null; + } + int shown = Math.min(3, list.size()); + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < shown; i++) { + if (i > 0) { + sb.append(", "); + } + sb.append(prettyTool(asString(list.get(i)))); + } + if (list.size() > shown) { + sb.append(" +").append(list.size() - shown).append(" more"); + } + return sb.toString(); + } + + private static String targetFor( + String category, String path, Map data, boolean policyDispatch) { + if (policyDispatch) { + // The run touches no single file at this level; show the tools the policy runs instead. + String steps = prettyStepList(data.get("policySteps")); + return steps != null ? steps : "Pipeline"; + } if ("auth".equals(category)) { // Auth events don't act on a resource; the session is the closest thing. return "Web session"; diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/service/AuditServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/service/AuditServiceTest.java index a90fde6b85..c2d14d1307 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/service/AuditServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/AuditServiceTest.java @@ -36,6 +36,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.AuditContext; import stirling.software.proprietary.audit.AuditEventType; import stirling.software.proprietary.audit.AuditLevel; import stirling.software.proprietary.audit.Audited; @@ -718,6 +719,57 @@ class AuditServiceTest { } } + @Nested + @DisplayName("addAutomationContext") + class AddAutomationContext { + + @Test + @DisplayName("caps and strips newlines from a spoofable policy-name header") + void capsPolicyNameHeader() { + MockHttpServletRequest req = + new MockHttpServletRequest("POST", "/api/v1/misc/compress-pdf"); + req.addHeader(InternalApiClient.POLICY_NAME_HEADER, "evil\r\nname" + "x".repeat(500)); + Map data = new HashMap<>(); + + service.addAutomationContext(data, req); + + String stored = (String) data.get("policyName"); + assertThat(stored).hasSize(200); + assertThat(stored).doesNotContain("\r").doesNotContain("\n"); + } + + @Test + @DisplayName("caps the number of persisted policy steps") + void capsPolicySteps() { + MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/policies/run"); + req.setAttribute( + AuditContext.REQ_ATTR_POLICY_STEPS, + java.util.stream.IntStream.range(0, 100) + .mapToObj(i -> "/api/v1/misc/step-" + i) + .toList()); + Map data = new HashMap<>(); + + service.addAutomationContext(data, req); + + @SuppressWarnings("unchecked") + java.util.List stored = (java.util.List) data.get("policySteps"); + assertThat(stored).hasSize(50); + } + + @Test + @DisplayName("marks automation from the header") + void marksAutomationHeader() { + MockHttpServletRequest req = + new MockHttpServletRequest("POST", "/api/v1/misc/compress-pdf"); + req.addHeader(InternalApiClient.AUTOMATION_HEADER, "true"); + Map data = new HashMap<>(); + + service.addAutomationContext(data, req); + + assertThat(data).containsEntry("automation", Boolean.TRUE); + } + } + // ===== helpers ===== private ProceedingJoinPoint joinPoint() { diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/service/PortalAuditReadServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/service/PortalAuditReadServiceTest.java new file mode 100644 index 0000000000..b357fbc2e3 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/PortalAuditReadServiceTest.java @@ -0,0 +1,73 @@ +package stirling.software.proprietary.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +import stirling.software.proprietary.model.security.PersistentAuditEvent; +import stirling.software.proprietary.repository.PersistentAuditEventRepository; + +/** + * The scan must drop read/polling noise (UI_DATA/HTTP_REQUEST) at the query level. Otherwise a busy + * scope's recent rows fill with noise and the visible audit list shrinks as traffic grows. + */ +@ExtendWith(MockitoExtension.class) +class PortalAuditReadServiceTest { + + @Mock private PersistentAuditEventRepository repo; + + @InjectMocks private PortalAuditReadService service; + + private static Page emptyPage() { + return new PageImpl<>(List.of()); + } + + @Test + void serverScanExcludesReadNoiseAndNeverPullsEverything() { + when(repo.findByTypeNotIn(anyList(), any(Pageable.class))).thenReturn(emptyPage()); + + service.serverEvents(); + + @SuppressWarnings("unchecked") + ArgumentCaptor> excluded = ArgumentCaptor.forClass(List.class); + verify(repo).findByTypeNotIn(excluded.capture(), any(Pageable.class)); + assertThat(excluded.getValue()).contains("UI_DATA", "HTTP_REQUEST"); + verify(repo, never()).findAll(any(Pageable.class)); + } + + @Test + void teamScanExcludesReadNoiseForTheTeamPrincipals() { + when(repo.findByTypeNotInAndPrincipalIn(anyList(), anyList(), any(Pageable.class))) + .thenReturn(emptyPage()); + + service.scopedEvents("team:1", List.of("a@acme.com", "b@acme.com")); + + @SuppressWarnings("unchecked") + ArgumentCaptor> excluded = ArgumentCaptor.forClass(List.class); + verify(repo) + .findByTypeNotInAndPrincipalIn(excluded.capture(), anyList(), any(Pageable.class)); + assertThat(excluded.getValue()).contains("UI_DATA", "HTTP_REQUEST"); + } + + @Test + void emptyTeamPrincipalsShortCircuitToNoQuery() { + assertThat(service.scopedEvents("team:1", List.of())).isEmpty(); + verify(repo, never()) + .findByTypeNotInAndPrincipalIn(anyList(), anyList(), any(Pageable.class)); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/service/PortalDocumentsServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/service/PortalDocumentsServiceTest.java new file mode 100644 index 0000000000..1afc0af4a4 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/PortalDocumentsServiceTest.java @@ -0,0 +1,73 @@ +package stirling.software.proprietary.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import java.time.Instant; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import stirling.software.proprietary.audit.AuditEventType; +import stirling.software.proprietary.audit.PortalAuditEventRow; +import stirling.software.proprietary.model.api.documents.PortalReviewDocumentDto; + +import tools.jackson.databind.json.JsonMapper; + +/** + * The Documents feed showed a policy run's internal steps as ordinary "API" traffic. They should + * read as automation so a policy run isn't confused with a customer's own API integration. + */ +@ExtendWith(MockitoExtension.class) +class PortalDocumentsServiceTest { + + @Mock private PortalAuditReadService auditReadService; + + private PortalDocumentsService service; + + @BeforeEach + void setUp() { + service = new PortalDocumentsService(auditReadService, JsonMapper.builder().build()); + } + + private static PortalAuditEventRow row(long id, String data) { + return new PortalAuditEventRow( + id, "con.yoh13@gmail.com", AuditEventType.PDF_PROCESS.name(), data, Instant.now()); + } + + private PortalReviewDocumentDto onlyDoc(String data) { + when(auditReadService.serverEvents()).thenReturn(List.of(row(1L, data))); + return service.serverDocuments().getDocuments().get(0); + } + + @Test + void policyStepDocumentIsLabelledAutomation() { + PortalReviewDocumentDto doc = + onlyDoc( + "{\"path\":\"/api/v1/security/auto-redact\",\"automation\":true," + + "\"__origin\":\"API\",\"files\":[{\"name\":\"mushroom life.pdf\"," + + "\"type\":\"application/pdf\"}],\"statusCode\":200}"); + + assertThat(doc.getName()).isEqualTo("mushroom life.pdf"); + assertThat(doc.getAction()).isEqualTo("Auto Redact"); + // Was product "API" (loopback used the API key); now clearly a policy automation step. + assertThat(doc.getProduct()).isEqualTo("Automation"); + assertThat(doc.getSource()).isEqualTo("Policy automation"); + } + + @Test + void directApiDocumentStaysApi() { + PortalReviewDocumentDto doc = + onlyDoc( + "{\"path\":\"/api/v1/misc/compress-pdf\",\"__origin\":\"API\"," + + "\"files\":[{\"name\":\"a.pdf\",\"type\":\"application/pdf\"}]," + + "\"statusCode\":200}"); + + assertThat(doc.getProduct()).isEqualTo("API"); + assertThat(doc.getSource()).isEqualTo("API integration"); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/service/PortalInfraAuditServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/service/PortalInfraAuditServiceTest.java new file mode 100644 index 0000000000..8d225367f3 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/PortalInfraAuditServiceTest.java @@ -0,0 +1,149 @@ +package stirling.software.proprietary.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import java.time.Instant; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import stirling.software.proprietary.audit.AuditEventType; +import stirling.software.proprietary.audit.PortalAuditEventRow; +import stirling.software.proprietary.model.api.audit.InfraAuditEventDto; + +import tools.jackson.databind.json.JsonMapper; + +/** + * The Infrastructure → Audit tab reproduces the reported bug: a policy run showed as a bare "Run" + * row plus a separate "Auto Redact" row with no hint either belonged to a policy. + */ +@ExtendWith(MockitoExtension.class) +class PortalInfraAuditServiceTest { + + @Mock private PortalAuditReadService auditReadService; + + private PortalInfraAuditService service; + + @BeforeEach + void setUp() { + service = new PortalInfraAuditService(auditReadService, JsonMapper.builder().build()); + } + + private static PortalAuditEventRow row(long id, String data) { + return new PortalAuditEventRow( + id, + "con.yoh13@gmail.com", + AuditEventType.PDF_PROCESS.name(), + data, + Instant.parse("2026-07-09T10:18:24Z")); + } + + private InfraAuditEventDto onlyEvent(String data) { + when(auditReadService.serverEvents()).thenReturn(List.of(row(1L, data))); + return service.serverAuditLog().getEvents().get(0); + } + + @Test + void policyDispatchShowsPolicyNameAndTheToolsItRuns() { + String data = + "{\"path\":\"/api/v1/policies/run\",\"policyName\":\"Redaction\"," + + "\"policySteps\":[\"/api/v1/security/auto-redact\"," + + "\"/api/v1/misc/compress-pdf\"],\"statusCode\":202," + + "\"latencyMs\":5}"; + when(auditReadService.serverEvents()).thenReturn(List.of(row(1L, data))); + + var resp = service.serverAuditLog(); + InfraAuditEventDto e = resp.getEvents().get(0); + + // Was "Run" / "Run" under "processing"; now names the policy, lists what it ran, and + // badges as its own "policy" category with its own summary count. + assertThat(e.getAction()).isEqualTo("Redaction"); + assertThat(e.getCategory()).isEqualTo("policy"); + assertThat(e.getTarget()).isEqualTo("Auto Redact, Compress PDF"); + assertThat(resp.getSummary().getPolicy()).isEqualTo(1); + } + + @Test + void internalPipelineStepIsFlaggedAsAutomation() { + InfraAuditEventDto e = + onlyEvent( + "{\"path\":\"/api/v1/security/auto-redact\",\"automation\":true," + + "\"files\":[{\"name\":\"mushroom life.pdf\"}]," + + "\"statusCode\":200,\"latencyMs\":300}"); + + // Was a bare "Auto Redact" indistinguishable from a direct user action. + assertThat(e.getAction()).isEqualTo("Auto Redact (automation)"); + assertThat(e.getCategory()).isEqualTo("security"); + assertThat(e.getTarget()).isEqualTo("mushroom life.pdf"); + } + + @Test + void internalStepCarryingItsPolicyNameLinksBackToThePolicy() { + InfraAuditEventDto e = + onlyEvent( + "{\"path\":\"/api/v1/security/auto-redact\",\"automation\":true," + + "\"policyName\":\"Redaction demo\",\"files\":[{\"name\":" + + "\"demo.pdf\"}],\"statusCode\":200}"); + + // The forwarded policy name makes the step's origin unmistakable. + assertThat(e.getAction()).isEqualTo("Auto Redact (policy: Redaction demo)"); + assertThat(e.getCategory()).isEqualTo("security"); + assertThat(e.getTarget()).isEqualTo("demo.pdf"); + } + + @Test + void adHocRunWithoutNameStillReadsAsPolicyRun() { + InfraAuditEventDto e = + onlyEvent( + "{\"path\":\"/api/v1/policies/run/stream\",\"statusCode\":200," + + "\"latencyMs\":4}"); + + assertThat(e.getAction()).isEqualTo("Policy run"); + assertThat(e.getTarget()).isEqualTo("Pipeline"); + } + + @Test + void directToolRunIsUnchanged() { + InfraAuditEventDto e = + onlyEvent( + "{\"path\":\"/api/v1/misc/compress-pdf\"," + + "\"files\":[{\"name\":\"a.pdf\"}],\"statusCode\":200," + + "\"latencyMs\":100}"); + + assertThat(e.getAction()).isEqualTo("Compress PDF"); + assertThat(e.getCategory()).isEqualTo("processing"); + assertThat(e.getTarget()).isEqualTo("a.pdf"); + } + + /** + * A spoofed X-Stirling-Policy-Name header lands in audit data as policyName on a direct tool + * call (no automation marker, non-run path). It must NOT flip the row into a "policy" dispatch + * that overwrites the real action and hides the affected file - only a real /policies/.../run + * URI does that. + */ + @Test + void forgedPolicyNameOnDirectCallCannotMaskTheRealAction() { + when(auditReadService.serverEvents()) + .thenReturn( + List.of( + row( + 1L, + "{\"path\":\"/api/v1/security/remove-password\"," + + "\"policyName\":\"Daily cleanup\",\"files\":" + + "[{\"name\":\"secret.pdf\"}],\"statusCode\":200}"))); + + var resp = service.serverAuditLog(); + InfraAuditEventDto e = resp.getEvents().get(0); + + // Real op and file stay visible; the forged name does not become the action or category. + assertThat(e.getAction()).isEqualTo("Remove Password"); + assertThat(e.getCategory()).isEqualTo("security"); + assertThat(e.getTarget()).isEqualTo("secret.pdf"); + assertThat(resp.getSummary().getPolicy()).isEqualTo(0); + } +} diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index d323a5a5f9..ac1ab2c9e7 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -6955,6 +6955,7 @@ all = "All" auth = "Auth" config = "Config" elevation = "Elevation" +policy = "Policy" processing = "Processing" security = "Security" @@ -6964,7 +6965,7 @@ title = "You don't have access" [portal.infrastructure.audit.metrics] config = "Config" -elevation = "Elevation" +policy = "Policy" processing = "Processing" totalEvents = "Total events · 24h" @@ -6972,6 +6973,7 @@ totalEvents = "Total events · 24h" auth = "Auth" config = "Config" elevation = "Elevation" +policy = "Policy" processing = "Processing" security = "Security" diff --git a/frontend/editor/src/portal/api/documents.ts b/frontend/editor/src/portal/api/documents.ts index cbb109ec30..9e75f40ec7 100644 --- a/frontend/editor/src/portal/api/documents.ts +++ b/frontend/editor/src/portal/api/documents.ts @@ -4,8 +4,8 @@ import type { Tier } from "@portal/contexts/TierContext"; export type DocumentStatus = "processed" | "flagged" | "in-review" | "error"; -/** Which Stirling product ran the operation. */ -export type ProductType = "API" | "Editor"; +/** Which Stirling product ran the operation. "Automation" = a policy/pipeline run's tool step. */ +export type ProductType = "API" | "Editor" | "Automation"; /** A single field pulled out of the document by extraction. */ export interface Extraction { @@ -98,6 +98,7 @@ export const DOCUMENT_STATUS_TONE: Record = { export const PRODUCT_CHIP_TONE: Record = { API: "brand", Editor: "success", + Automation: "warning", }; /** Classification chip accent: danger when unclassified, warning when it needs a look. */ diff --git a/frontend/editor/src/portal/api/infrastructure.ts b/frontend/editor/src/portal/api/infrastructure.ts index 073409e6c6..d624f67e03 100644 --- a/frontend/editor/src/portal/api/infrastructure.ts +++ b/frontend/editor/src/portal/api/infrastructure.ts @@ -169,6 +169,7 @@ export type AuditCategory = | "auth" | "config" | "elevation" + | "policy" | "processing" | "security"; @@ -187,6 +188,7 @@ export interface AuditEvent { export interface AuditSummary { totalEvents: number; + policy: number; processing: number; elevation: number; config: number; diff --git a/frontend/editor/src/portal/components/infrastructure/AuditTab.tsx b/frontend/editor/src/portal/components/infrastructure/AuditTab.tsx index 07bff185da..b27d57995e 100644 --- a/frontend/editor/src/portal/components/infrastructure/AuditTab.tsx +++ b/frontend/editor/src/portal/components/infrastructure/AuditTab.tsx @@ -46,6 +46,7 @@ export function AuditTab() { key: "elevation", label: t("portal.infrastructure.audit.filters.elevation"), }, + { key: "policy", label: t("portal.infrastructure.audit.filters.policy") }, { key: "processing", label: t("portal.infrastructure.audit.filters.processing"), @@ -146,12 +147,12 @@ export function AuditTab() { value={data.summary.totalEvents.toLocaleString()} /> = { auth: "portal.infrastructure.auditCatLabel.auth", config: "portal.infrastructure.auditCatLabel.config", elevation: "portal.infrastructure.auditCatLabel.elevation", + policy: "portal.infrastructure.auditCatLabel.policy", processing: "portal.infrastructure.auditCatLabel.processing", security: "portal.infrastructure.auditCatLabel.security", }; @@ -129,6 +130,7 @@ export const AUDIT_CAT_TONE: Record = { auth: "info", config: "neutral", elevation: "purple", + policy: "purple", processing: "success", security: "warning", }; diff --git a/frontend/editor/src/portal/mocks/infrastructure.ts b/frontend/editor/src/portal/mocks/infrastructure.ts index 85aac3eeea..412e6133bf 100644 --- a/frontend/editor/src/portal/mocks/infrastructure.ts +++ b/frontend/editor/src/portal/mocks/infrastructure.ts @@ -901,6 +901,7 @@ export function auditLogFor(tier: Tier): AuditLogResponse { // (PortalInfraAuditService) computes it - so mocks and real data reconcile. const summary: AuditSummary = { totalEvents: events.length, + policy: events.filter((e) => e.category === "policy").length, processing: events.filter((e) => e.category === "processing").length, elevation: events.filter((e) => e.category === "elevation").length, config: events.filter((e) => e.category === "config").length, From fe3337833351c6532ecc32431932c023dc9e7d02 Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:47:04 +0100 Subject: [PATCH 20/34] feat(portal): set a spend cap during PAYG checkout (two-step modal) (#6970) --- .../public/locales/en-US/translation.toml | 22 +- .../components/billing/FreePlanView.tsx | 2 + .../billing/StripeCheckoutModal.stories.tsx | 44 ++++ .../billing/StripeCheckoutModal.tsx | 229 ++++++++++++++---- .../src/portal/components/billing/billing.css | 94 +++++++ .../editor/src/portal/components/icons.tsx | 9 + 6 files changed, 347 insertions(+), 53 deletions(-) create mode 100644 frontend/editor/src/portal/components/billing/StripeCheckoutModal.stories.tsx diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index ac1ab2c9e7..ca93b97d33 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -6253,14 +6253,29 @@ redirectingToEditor = "Redirecting to the editor..." [portal.billing.checkout] noClientSecret = "Edge function returned no client_secret." +stepCount = "Step {{step}} of 2" subtitle = "Add a card to keep going past your free Editor-plan grant. Stripe handles the rest." -title = "Turn on the Processor plan" +title = "Add a payment method" [portal.billing.checkout.activationSlow] body = "Your payment succeeded, but activation is taking a little longer than usual. It'll switch on automatically - close this and it'll appear here shortly." close = "Close" title = "Almost there" +[portal.billing.checkout.cap] +back = "Back" +continue = "Continue to payment" +error = "Couldn't set your spend limit" +finePrint = "Invoices post on the 1st. Cancel anytime and revert to the Editor plan; your policies and history stay intact." +note = "Processing pauses if you reach it. You're never billed past this, and you can change it any time." +subtitle = "You're billed only for PDFs you process past your first 500 free, never for seats — so your ceiling is yours from day one." +title = "Set your spend limit" + +[portal.billing.checkout.card] +fields = "Card number · MM / YY · CVC · ZIP" +label = "Card details" +note = "Card details collected by Stripe. Stirling never stores PAN or CVC." + [portal.billing.checkout.error] title = "Couldn't start checkout" @@ -6269,11 +6284,6 @@ body = "Your payment went through. We're switching on metered processing across hint = "Please keep this window open." title = "Activating your Processor plan..." -[portal.billing.checkout.notConfigured] -bodyAfter = "in the portal env to enable in-app checkout." -bodyBefore = "Set" -title = "Stripe not configured" - [portal.billing.enterpriseUpsell] cta = "Explore Enterprise" description = "Committed volume discounts, air-gapped deployment, custom MSA and security reviews, and 3rd-party distributor partnerships." diff --git a/frontend/editor/src/portal/components/billing/FreePlanView.tsx b/frontend/editor/src/portal/components/billing/FreePlanView.tsx index 0192d5761e..a2027ff756 100644 --- a/frontend/editor/src/portal/components/billing/FreePlanView.tsx +++ b/frontend/editor/src/portal/components/billing/FreePlanView.tsx @@ -129,6 +129,8 @@ export function FreePlanView({ wallet, unsynced, onSubscribed }: Props) { onClose={() => setModalOpen(false)} teamId={wallet.teamId} currency={currency} + pricePerDocMinor={wallet.pricePerDocMinor} + initialCapUsd={wallet.capUsd} onComplete={() => onSubscribed?.() ?? Promise.resolve(false)} /> )} diff --git a/frontend/editor/src/portal/components/billing/StripeCheckoutModal.stories.tsx b/frontend/editor/src/portal/components/billing/StripeCheckoutModal.stories.tsx new file mode 100644 index 0000000000..32d0695256 --- /dev/null +++ b/frontend/editor/src/portal/components/billing/StripeCheckoutModal.stories.tsx @@ -0,0 +1,44 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { http, HttpResponse } from "msw"; +import { StripeCheckoutModal } from "@portal/components/billing/StripeCheckoutModal"; +import "@portal/components/billing/billing.css"; + +/** + * Two-step "turn on the Processor" flow. Step 1 sets the monthly spend cap + * (its own page, since Stripe owns the card iframe on step 2). Storybook has no + * Stripe key, so step 2 renders the card placeholder rather than the live + * embedded form. + */ +const meta: Meta = { + title: "Portal/Billing/StripeCheckoutModal", + component: StripeCheckoutModal, + args: { + open: true, + onClose: () => console.log("close"), + teamId: 1, + currency: "usd", + pricePerDocMinor: 1, + initialCapUsd: 100, + onComplete: () => Promise.resolve(true), + }, + // Let the cap step's "Continue" succeed so the flow can advance to payment. + parameters: { + layout: "fullscreen", + msw: { + handlers: [ + http.patch( + "http://saas.mock/api/v1/payg/cap", + () => new HttpResponse(null, { status: 204 }), + ), + ], + }, + }, +}; +export default meta; +type Story = StoryObj; + +/** Step 1 — set the monthly spend limit before adding a card. */ +export const SetCap: Story = {}; + +/** Step 1 seeded with no cap (uncapped) selected. */ +export const Uncapped: Story = { args: { initialCapUsd: null } }; diff --git a/frontend/editor/src/portal/components/billing/StripeCheckoutModal.tsx b/frontend/editor/src/portal/components/billing/StripeCheckoutModal.tsx index 7e8e55e70a..65d32ed8ea 100644 --- a/frontend/editor/src/portal/components/billing/StripeCheckoutModal.tsx +++ b/frontend/editor/src/portal/components/billing/StripeCheckoutModal.tsx @@ -1,16 +1,19 @@ import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Banner, Button, Modal, Skeleton, Spinner } from "@app/ui"; +import { SpendCapControl } from "@app/billing"; import { EmbeddedCheckout, EmbeddedCheckoutProvider, } from "@stripe/react-stripe-js"; import type { Stripe } from "@stripe/stripe-js"; +import { updateCap } from "@portal/api/billing"; import { createCheckoutSession, getStripePublishableKey, type SaasCurrency, } from "@portal/billing/stripe"; +import { LockIcon } from "@portal/components/icons"; interface Props { open: boolean; @@ -19,27 +22,34 @@ interface Props { teamId: number; /** "usd" | "eur" | "gbp" — the SaaS PAYG offering's supported set. */ currency: SaasCurrency; + /** Per-document rate in minor units, for the cap→PDF estimate on step 1. */ + pricePerDocMinor?: number | null; + /** Cap to seed the spend-limit step with; defaults to $100/mo. */ + initialCapUsd?: number | null; /** Optional billing email prefill (Stripe locks the field when set). */ billingOwnerEmail?: string; /** * Fired when Stripe (or the mock continue button) signals payment success. * Runs the caller's activation flow (poll the wallet until the subscription * webhook lands) and resolves {@code true} once subscribed, {@code false} if - * it's taking longer than the poll window. The modal stays open and - * non-dismissable while this runs, so the admin watches activation through - * instead of the modal vanishing and needing a manual refresh. + * it's taking longer than the poll window. */ onComplete: () => Promise; } +/** Preset monthly caps offered on the spend-limit step (major currency units). */ +const CAP_PRESETS = [50, 100, 250, 1000] as const; +const DEFAULT_CAP_USD = 100; + /** - * Embedded Stripe Checkout, matching the SaaS web app's PAYG sign-up UX. We - * fetch a {@code client_secret} from the SaaS Supabase edge function then - * mount <EmbeddedCheckoutProvider> inline — no full-page redirect, the - * admin stays in the portal. + * Two-step "turn on the Processor" flow. Stripe's embedded Checkout owns the card + * form in its own iframe, so we can't add our own fields to it — instead the cap + * lives on step 1 (its own page) and the embedded card form on step 2. The chosen + * ceiling is applied before payment so "you're never billed past it" is true from + * the first processed PDF. * - * If the team is already subscribed the edge function short-circuits to a - * Stripe Customer Portal URL; we open it in a new tab and close the modal. + * If the team is already subscribed the edge function short-circuits to a Stripe + * Customer Portal URL; we open it in a new tab and close. */ let stripePromise: Promise | null = null; function loadStripeOnce(pk: string): Promise { @@ -49,6 +59,22 @@ function loadStripeOnce(pk: string): Promise { return stripePromise; } +/** Two-segment progress header: step 1 = spend limit, step 2 = payment. */ +function StepProgress({ step }: { step: 1 | 2 }) { + const { t } = useTranslation(); + return ( +
+
+ + = 2 ? "is-done" : ""} /> +
+ + {t("portal.billing.checkout.stepCount", "Step {{step}} of 2", { step })} + +
+ ); +} + /** Payment done, waiting for the subscription webhook to activate the plan. */ function CheckoutFinalizing() { const { t } = useTranslation(); @@ -98,11 +124,45 @@ function CheckoutActivationSlow({ onClose }: { onClose: () => void }) { ); } +/** + * Placeholder for the card form when no Stripe publishable key is configured + * (Storybook / preview / mis-config). Mirrors the real embedded form's framing + * so the step reads correctly without mounting Stripe. + */ +function CardPlaceholder() { + const { t } = useTranslation(); + return ( +
+
+ {t("portal.billing.checkout.card.label", "Card details")} + Stripe +
+
+ + + {t( + "portal.billing.checkout.card.fields", + "Card number · MM / YY · CVC · ZIP", + )} + +
+

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

+
+ ); +} + export function StripeCheckoutModal({ open, onClose, teamId, currency, + pricePerDocMinor, + initialCapUsd, billingOwnerEmail, onComplete, }: Props) { @@ -110,11 +170,17 @@ export function StripeCheckoutModal({ const [clientSecret, setClientSecret] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - // "checkout" = Stripe form; "finalizing" = payment done, waiting for the plan to activate - // (non-dismissable); "activationSlow" = webhook lagging past the poll window (dismissable). + // "cap" = spend-limit step; "checkout" = Stripe card form; "finalizing" = + // payment done, waiting for activation (non-dismissable); "activationSlow" = + // webhook lagging past the poll window (dismissable). const [phase, setPhase] = useState< - "checkout" | "finalizing" | "activationSlow" - >("checkout"); + "cap" | "checkout" | "finalizing" | "activationSlow" + >("cap"); + const [capUsd, setCapUsd] = useState( + initialCapUsd ?? DEFAULT_CAP_USD, + ); + const [capBusy, setCapBusy] = useState(false); + const [capError, setCapError] = useState(null); const mounted = useRef(true); useEffect(() => { mounted.current = true; @@ -125,16 +191,24 @@ export function StripeCheckoutModal({ const publishableKey = getStripePublishableKey(); - // Mint the checkout session whenever the modal opens for a fresh team/currency. + // Reset to the spend-limit step whenever the modal closes, so re-opening starts fresh. useEffect(() => { if (!open) { - // Reset on close so re-opening fetches a fresh session + starts at checkout. + setPhase("cap"); setClientSecret(null); setError(null); setLoading(true); - setPhase("checkout"); - return; + setCapUsd(initialCapUsd ?? DEFAULT_CAP_USD); + setCapBusy(false); + setCapError(null); } + }, [open, initialCapUsd]); + + // Mint the checkout session only once the user has set a cap and advanced to + // the payment step — not on open — so we don't create a session they may abandon + // on step 1, and so the cap is applied first. + useEffect(() => { + if (!open || phase !== "checkout" || !publishableKey) return; let cancelled = false; setLoading(true); setError(null); @@ -148,8 +222,6 @@ export function StripeCheckoutModal({ .then((session) => { if (cancelled) return; if (session.alreadySubscribed && session.redirectUrl) { - // Team's already on PAYG — bounce them to the management portal - // instead of mounting a checkout iframe with no secret. window.open(session.redirectUrl, "_blank", "noopener,noreferrer"); onClose(); return; @@ -166,9 +238,7 @@ export function StripeCheckoutModal({ setClientSecret(session.clientSecret); }) .catch((e) => { - if (!cancelled) { - setError(e instanceof Error ? e.message : String(e)); - } + if (!cancelled) setError(e instanceof Error ? e.message : String(e)); }) .finally(() => { if (!cancelled) setLoading(false); @@ -176,14 +246,37 @@ export function StripeCheckoutModal({ return () => { cancelled = true; }; - }, [open, teamId, currency, billingOwnerEmail, onClose, t]); + }, [ + open, + phase, + publishableKey, + teamId, + currency, + billingOwnerEmail, + onClose, + t, + ]); const stripe = publishableKey ? loadStripeOnce(publishableKey) : null; const canRender = Boolean(stripe && clientSecret); - // Payment succeeded — hold the modal open and run the caller's activation poll instead of - // closing. The parent swaps to the subscribed view on success (unmounting us); a lagging - // webhook drops us into the dismissable "almost there" state. + // Apply the chosen ceiling, then advance to payment. Applying it up front keeps + // "you're never billed past it" true from the first processed PDF. + async function handleContinue() { + setCapBusy(true); + setCapError(null); + try { + await updateCap(capUsd); + if (mounted.current) setPhase("checkout"); + } catch (e) { + if (mounted.current) { + setCapError(e instanceof Error ? e.message : String(e)); + } + } finally { + if (mounted.current) setCapBusy(false); + } + } + function handleStripeComplete() { setPhase("finalizing"); onComplete() @@ -195,13 +288,26 @@ export function StripeCheckoutModal({ }); } - // Block dismissal while activation is in flight so a half-finished flow can't be abandoned; - // the X, backdrop, and Escape all route through this. + // Block dismissal while activation is in flight so a half-finished flow can't be abandoned. const dismissable = phase !== "finalizing"; const handleClose = () => { if (dismissable) onClose(); }; + const onCapStep = phase === "cap"; + const title = onCapStep + ? t("portal.billing.checkout.cap.title", "Set your spend limit") + : t("portal.billing.checkout.title", "Add a payment method"); + const subtitle = onCapStep + ? t( + "portal.billing.checkout.cap.subtitle", + "You're billed only for PDFs you process past your first 500 free, never for seats — so your ceiling is yours from day one.", + ) + : t( + "portal.billing.checkout.subtitle", + "Add a card to keep going past your free Editor-plan grant. Stripe handles the rest.", + ); + return ( {phase === "finalizing" && } - {phase === "activationSlow" && ( )} - {phase === "checkout" && ( - <> - {!publishableKey && ( + {phase === "cap" && ( +
+ + +

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

+ {capError && ( - {t("portal.billing.checkout.notConfigured.bodyBefore", "Set")}{" "} - VITE_STRIPE_PUBLISHABLE_KEY{" "} - {t( - "portal.billing.checkout.notConfigured.bodyAfter", - "in the portal env to enable in-app checkout.", - )} + {capError} )} +
+ + +
+
+ )} + + {phase === "checkout" && ( +
+ + {!publishableKey && } {publishableKey && error && ( )} - +
)}
); diff --git a/frontend/editor/src/portal/components/billing/billing.css b/frontend/editor/src/portal/components/billing/billing.css index 86e9a3ff94..1809a4f80c 100644 --- a/frontend/editor/src/portal/components/billing/billing.css +++ b/frontend/editor/src/portal/components/billing/billing.css @@ -1045,3 +1045,97 @@ .portal-billing__projection strong { color: var(--color-amber, #d97706); } + +/* ── Checkout modal: 2-step (spend limit → payment) ──────────────────── */ +.portal-billing__checkout-cap, +.portal-billing__checkout-pay { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.portal-billing__checkout-progress { + display: flex; + align-items: center; + gap: 0.75rem; +} +.portal-billing__checkout-steps { + display: flex; + gap: 0.375rem; + flex: 1; +} +.portal-billing__checkout-steps span { + height: 4px; + flex: 1; + border-radius: var(--radius-pill); + background: var(--color-border); +} +.portal-billing__checkout-steps span.is-done { + background: var(--color-blue); +} +.portal-billing__checkout-stepcount { + font-size: 0.75rem; + font-weight: 600; + color: var(--color-text-5); + white-space: nowrap; +} + +.portal-billing__checkout-finePrint { + margin: 0; + font-size: 0.75rem; + line-height: 1.55; + color: var(--color-text-5); +} +.portal-billing__checkout-cap-actions { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 0.25rem; +} + +/* Card-form placeholder shown when Stripe isn't configured (Storybook / preview). */ +.portal-billing__card-placeholder { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 1rem; + border: 1px solid var(--color-border-input); + border-radius: var(--radius-md); + background: var(--color-surface); +} +.portal-billing__card-placeholder-head { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 0.75rem; + font-weight: 600; + color: var(--color-text-2); +} +.portal-billing__card-placeholder-badge { + font-size: 0.625rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--color-text-5); + background: var(--color-bg-code); + padding: 0.125rem 0.375rem; + border-radius: var(--radius-sm); +} +.portal-billing__card-placeholder-field { + display: flex; + align-items: center; + gap: 0.625rem; + padding: 0.75rem 0.875rem; + background: var(--color-bg-subtle); + border: 1px solid var(--color-border-light); + border-radius: var(--radius-sm); + font-family: var(--font-mono); + font-size: 0.8125rem; + color: var(--color-text-5); +} +.portal-billing__card-placeholder-note { + margin: 0; + font-size: 0.6875rem; + line-height: 1.5; + color: var(--color-text-5); +} diff --git a/frontend/editor/src/portal/components/icons.tsx b/frontend/editor/src/portal/components/icons.tsx index c5e93b8f08..8f8609648c 100644 --- a/frontend/editor/src/portal/components/icons.tsx +++ b/frontend/editor/src/portal/components/icons.tsx @@ -273,6 +273,15 @@ export function LinkIcon(props: IconProps) { ); } +export function LockIcon(props: IconProps) { + return ( + + + + + ); +} + export function UserPlusIcon(props: IconProps) { return ( From 38d06d3104f355337455238cf73d5c24aac7fa1a Mon Sep 17 00:00:00 2001 From: James Brunton Date: Sat, 11 Jul 2026 12:47:29 +0100 Subject: [PATCH 21/34] Make sqlite backend more resilient when using multiple runners (#6971) --- engine/src/stirling/documents/sqlite_vec_store.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/engine/src/stirling/documents/sqlite_vec_store.py b/engine/src/stirling/documents/sqlite_vec_store.py index 9346623314..5f909a4bcd 100644 --- a/engine/src/stirling/documents/sqlite_vec_store.py +++ b/engine/src/stirling/documents/sqlite_vec_store.py @@ -15,6 +15,10 @@ from stirling.documents.store import Document, DocumentStore, SearchResult, Stor from stirling.models import OwnerId, PrincipalId _READ_PERMISSION = "read" +# SQLite defaults to failing immediately (busy_timeout=0) when it can't grab the +# write lock. With multiple worker processes opening the same file, they collide on +# startup schema-init and get "database is locked". Wait for the lock instead. +_BUSY_TIMEOUT_MS = 5000 # sqlite stores TIMESTAMP as TEXT. We normalise to UTC ISO 8601 ``YYYY-MM-DD HH:MM:SS`` # so lexicographic comparison against ``datetime('now')`` matches chronological order. _SQLITE_DATETIME_FMT = "%Y-%m-%d %H:%M:%S" @@ -52,6 +56,8 @@ class SqliteVecStore(DocumentStore): # Required so cascade deletes from documents_meta clean up child tables. conn.execute("PRAGMA foreign_keys=ON") if self._db_path is not None: + # Set before the WAL switch below: that pragma also takes the lock. + conn.execute(f"PRAGMA busy_timeout={_BUSY_TIMEOUT_MS}") conn.execute("PRAGMA journal_mode=WAL") self._conn = conn From cb0f335e8a0e8c59f0ef9d0f981f3366ffbf085f Mon Sep 17 00:00:00 2001 From: "imgbot[bot]" <31301654+imgbot[bot]@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:48:53 +0100 Subject: [PATCH 22/34] [ImgBot] Optimize images (#6588) Co-authored-by: ImgBotApp Signed-off-by: ImgBotApp --- .../main/resources/static/api-wordmark.svg | 5 +---- .../resources/static/images/redact-auto.svg | 19 +----------------- .../main/resources/static/images/rename.svg | 14 +------------ .../static/images/stirling-logo-white.png | Bin 6254 -> 5441 bytes .../public/og_images/add-attachments.png | Bin 209632 -> 113694 bytes .../editor/public/og_images/add-image.png | Bin 180988 -> 102250 bytes .../public/og_images/add-page-numbers.png | Bin 206341 -> 114324 bytes .../editor/public/og_images/add-password.png | Bin 195509 -> 110049 bytes .../editor/public/og_images/add-stamp.png | Bin 200688 -> 110583 bytes frontend/editor/public/og_images/add-text.png | Bin 183390 -> 101342 bytes .../editor/public/og_images/add-watermark.png | Bin 205171 -> 109442 bytes .../og_images/adjust-colors-contrast.png | Bin 208846 -> 113680 bytes .../og_images/adjust-page-size-scale.png | Bin 212387 -> 115899 bytes frontend/editor/public/og_images/annotate.png | Bin 193038 -> 108386 bytes .../public/og_images/auto-rename-pdf-file.png | Bin 213176 -> 115570 bytes frontend/editor/public/og_images/automate.png | Bin 209984 -> 116319 bytes .../public/og_images/booklet-imposition.png | Bin 234600 -> 125048 bytes frontend/editor/public/og_images/certSign.png | Bin 201127 -> 109909 bytes .../public/og_images/change-metadata.png | Bin 209866 -> 113948 bytes .../public/og_images/change-permissions.png | Bin 206520 -> 112514 bytes frontend/editor/public/og_images/compare.png | Bin 190672 -> 106708 bytes frontend/editor/public/og_images/compress.png | Bin 179168 -> 102198 bytes frontend/editor/public/og_images/convert.png | Bin 172917 -> 99554 bytes frontend/editor/public/og_images/cropPdf.png | Bin 189970 -> 103445 bytes .../og_images/detect-split-scanned-photos.png | Bin 214603 -> 119096 bytes .../editor/public/og_images/dev-airgapped.png | Bin 194340 -> 106635 bytes frontend/editor/public/og_images/dev-api.png | Bin 165062 -> 93755 bytes .../public/og_images/dev-folder-scanning.png | Bin 209920 -> 114031 bytes .../editor/public/og_images/dev-sso-guide.png | Bin 168599 -> 97289 bytes .../og_images/edit-table-of-contents.png | Bin 213267 -> 117864 bytes .../public/og_images/extract-images.png | Bin 196330 -> 106153 bytes .../editor/public/og_images/extract-pages.png | Bin 186906 -> 106693 bytes frontend/editor/public/og_images/flatten.png | Bin 184992 -> 104571 bytes .../editor/public/og_images/form-fill.png | Bin 188294 -> 105041 bytes .../public/og_images/get-all-info-on-pdf.png | Bin 208688 -> 111559 bytes frontend/editor/public/og_images/home.png | Bin 69048 -> 55763 bytes .../editor/public/og_images/mergePdfs.png | Bin 183542 -> 102388 bytes .../public/og_images/multi-page-layout.png | Bin 215133 -> 115945 bytes .../editor/public/og_images/multi-tool.png | Bin 189517 -> 104796 bytes frontend/editor/public/og_images/ocr.png | Bin 197234 -> 107870 bytes .../editor/public/og_images/overlay-pdfs.png | Bin 157572 -> 93168 bytes .../public/og_images/pdf-text-editor.png | Bin 228097 -> 121972 bytes frontend/editor/public/og_images/read.png | Bin 215508 -> 115154 bytes frontend/editor/public/og_images/redact.png | Bin 190259 -> 107669 bytes .../public/og_images/remove-annotations.png | Bin 209391 -> 114862 bytes .../public/og_images/remove-blank-pages.png | Bin 206916 -> 112294 bytes .../og_images/remove-certificate-sign.png | Bin 206007 -> 114444 bytes .../editor/public/og_images/remove-image.png | Bin 193946 -> 107617 bytes .../public/og_images/remove-password.png | Bin 207870 -> 112087 bytes frontend/editor/public/og_images/remove.png | Bin 191904 -> 108086 bytes .../public/og_images/reorganize-pages.png | Bin 223608 -> 120033 bytes frontend/editor/public/og_images/repair.png | Bin 178739 -> 100535 bytes .../og_images/replace-and-invert-color.png | Bin 206279 -> 112179 bytes frontend/editor/public/og_images/rotate.png | Bin 164562 -> 95396 bytes frontend/editor/public/og_images/sanitize.png | Bin 191237 -> 105748 bytes .../public/og_images/scanner-effect.png | Bin 188794 -> 106373 bytes .../public/og_images/show-javascript.png | Bin 203055 -> 112205 bytes frontend/editor/public/og_images/sign.png | Bin 180839 -> 101688 bytes .../public/og_images/single-large-page.png | Bin 215947 -> 117599 bytes frontend/editor/public/og_images/split.png | Bin 176772 -> 98269 bytes .../editor/public/og_images/timestamp-pdf.png | Bin 216333 -> 116637 bytes .../public/og_images/unlock-pdf-forms.png | Bin 209978 -> 114552 bytes .../og_images/validate-pdf-signature.png | Bin 217605 -> 119602 bytes .../scripts/og-assets/stirling-lockup.png | Bin 7647 -> 7567 bytes .../editor/src-tauri/icons/128x128@2x.png | Bin 19937 -> 18709 bytes .../src-tauri/icons/Square142x142Logo.png | Bin 10631 -> 10582 bytes .../src-tauri/icons/Square150x150Logo.png | Bin 11218 -> 11126 bytes .../src-tauri/icons/Square284x284Logo.png | Bin 23078 -> 21919 bytes .../src-tauri/icons/Square310x310Logo.png | Bin 25392 -> 24310 bytes .../mipmap-hdpi/ic_launcher_foreground.png | Bin 12297 -> 12168 bytes .../mipmap-xhdpi/ic_launcher_foreground.png | Bin 17073 -> 16683 bytes .../android/mipmap-xxhdpi/ic_launcher.png | Bin 10841 -> 10734 bytes .../mipmap-xxhdpi/ic_launcher_foreground.png | Bin 26793 -> 25352 bytes .../mipmap-xxhdpi/ic_launcher_round.png | Bin 10841 -> 10734 bytes .../android/mipmap-xxxhdpi/ic_launcher.png | Bin 14981 -> 14742 bytes .../mipmap-xxxhdpi/ic_launcher_foreground.png | Bin 36416 -> 34947 bytes .../mipmap-xxxhdpi/ic_launcher_round.png | Bin 14981 -> 14742 bytes frontend/editor/src-tauri/icons/icon.png | Bin 19631 -> 8987 bytes .../src-tauri/icons/ios/AppIcon-40x40@3x.png | Bin 8295 -> 8228 bytes .../src-tauri/icons/ios/AppIcon-512@2x.png | Bin 113791 -> 110816 bytes .../src-tauri/icons/ios/AppIcon-60x60@2x.png | Bin 8295 -> 8228 bytes .../src-tauri/icons/ios/AppIcon-60x60@3x.png | Bin 12622 -> 12346 bytes .../src-tauri/icons/ios/AppIcon-76x76@2x.png | Bin 10586 -> 10422 bytes .../icons/ios/AppIcon-83.5x83.5@2x.png | Bin 11649 -> 11391 bytes images/custom-home-dark.png | Bin 467037 -> 294906 bytes images/custom-home.png | Bin 556852 -> 337411 bytes images/home-light.png | Bin 57696 -> 48932 bytes images/home.png | Bin 100490 -> 71416 bytes 88 files changed, 3 insertions(+), 35 deletions(-) diff --git a/app/core/src/main/resources/static/api-wordmark.svg b/app/core/src/main/resources/static/api-wordmark.svg index deac1ee165..3658102f90 100644 --- a/app/core/src/main/resources/static/api-wordmark.svg +++ b/app/core/src/main/resources/static/api-wordmark.svg @@ -1,4 +1 @@ - - - - + \ No newline at end of file diff --git a/app/core/src/main/resources/static/images/redact-auto.svg b/app/core/src/main/resources/static/images/redact-auto.svg index 5c87b738ef..7c29b0fc4b 100644 --- a/app/core/src/main/resources/static/images/redact-auto.svg +++ b/app/core/src/main/resources/static/images/redact-auto.svg @@ -1,18 +1 @@ - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/app/core/src/main/resources/static/images/rename.svg b/app/core/src/main/resources/static/images/rename.svg index 6b19bec208..97946a086d 100644 --- a/app/core/src/main/resources/static/images/rename.svg +++ b/app/core/src/main/resources/static/images/rename.svg @@ -1,13 +1 @@ - - - - rename - - - - - - - - - +rename \ No newline at end of file diff --git a/app/core/src/main/resources/static/images/stirling-logo-white.png b/app/core/src/main/resources/static/images/stirling-logo-white.png index 8018b0771c8b7308639a4caf98ddcd5d65ee8fa8..c66dcbf3f0aa3c1a7180180ee8b6c7fb53c2e65f 100644 GIT binary patch delta 5399 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
@@ -67,7 +67,7 @@ export function PolicyFieldRow({ value={typeof value === "string" ? value : ""} options={(field.options ?? []).map((o) => ({ value: o, - label: t(`policies.fieldOption.${field.key}.${o}`, o), + label: t(`policyOption.${o}`, o), }))} onChange={(value) => onChange(value ?? "")} /> diff --git a/frontend/editor/src/proprietary/data/policyDefinitions.tsx b/frontend/editor/src/proprietary/data/policyDefinitions.tsx index 1b8eacf931..3842b1885a 100644 --- a/frontend/editor/src/proprietary/data/policyDefinitions.tsx +++ b/frontend/editor/src/proprietary/data/policyDefinitions.tsx @@ -164,15 +164,15 @@ export const POLICY_CONFIG: Record = { label: "Min confidence", key: "minConfidence", type: "select", - value: "80%", - options: ["60%", "70%", "80%", "90%", "95%"], + value: "p80", + options: ["p60", "p70", "p80", "p90", "p95"], }, { label: "Below threshold", key: "belowThreshold", type: "select", - value: "Flag for review", - options: ["Flag for review", "Route to bucket", "Hold"], + value: "flagForReview", + options: ["flagForReview", "routeToBucket", "hold"], }, ], }, @@ -237,27 +237,27 @@ export const POLICY_CONFIG: Record = { label: "Frameworks", key: "frameworks", type: "chips", - value: ["HIPAA"], + value: ["hipaa"], options: [ - "HIPAA", - "GDPR", - "SOC 2", - "FedRAMP", - "PCI DSS", - "CCPA", - "ISO 27001", + "hipaa", + "gdpr", + "soc2", + "fedramp", + "pciDss", + "ccpa", + "iso27001", ], }, { label: "When non-compliant", key: "onViolation", type: "select", - value: "Flag for review", + value: "flagForReview", options: [ - "Flag for review", - "Block export", - "Auto-redact PHI", - "Quarantine document", + "flagForReview", + "blockExport", + "autoRedactPhi", + "quarantineDocument", ], }, { label: "Audit trail", key: "auditTrail", type: "toggle", value: true }, @@ -275,8 +275,8 @@ export const POLICY_CONFIG: Record = { label: "Destination", key: "destination", type: "select", - value: "Documents", - options: ["Documents", "S3 bucket", "SharePoint", "Webhook"], + value: "documents", + options: ["documents", "s3Bucket", "sharePoint", "webhook"], }, { label: "Webhook URL", key: "webhookUrl", type: "text", value: "" }, { label: "Notify on route", key: "notify", type: "toggle", value: false }, @@ -293,15 +293,21 @@ export const POLICY_CONFIG: Record = { label: "Keep for", key: "keepFor", type: "select", - value: "7 years", - options: ["30 days", "1 year", "3 years", "7 years", "Indefinite"], + value: "sevenYears", + options: [ + "thirtyDays", + "oneYear", + "threeYears", + "sevenYears", + "indefinite", + ], }, { label: "Archive after", key: "archiveAfter", type: "select", - value: "Never", - options: ["30 days", "90 days", "1 year", "Never"], + value: "never", + options: ["thirtyDays", "ninetyDays", "oneYear", "never"], }, { label: "Immutable hold", @@ -355,12 +361,12 @@ export const POLICY_SOURCES: PolicySource[] = [ /** Document types selectable when narrowing scope (gated behind classification). */ export const POLICY_DOC_TYPES: string[] = [ - "Contracts", - "Invoices", - "Tax documents", - "HR records", - "Insurance", - "Medical / PHI", - "Legal filings", - "Financial reports", + "contracts", + "invoices", + "taxDocuments", + "hrRecords", + "insurance", + "medicalPhi", + "legalFilings", + "financialReports", ]; From c500c2fae7a9c1252e0ac0fef6d91c50edf6aeff Mon Sep 17 00:00:00 2001 From: Ludy Date: Sun, 12 Jul 2026 07:33:09 +0200 Subject: [PATCH 29/34] fix(desktop): preserve RGBA format for Tauri app icon (#6990) # Description of Changes - Replaced the Tauri application icon with an RGBA-formatted PNG. - Added a root `.imgbotconfig` that excludes the Tauri icon from automatic image optimization. - Fixed the `desktop:test` compilation failure caused by `tauri::generate_context!()` rejecting the previous non-RGBA icon. - Prevented ImgBot from potentially converting the icon back to an unsupported indexed PNG while optimizing its file size. - Verified that the current icon uses PNG Color Type 6 (`Truecolour with alpha`). ```sh [desktop:test] error: proc macro panicked [desktop:test] --> src/lib.rs:202:12 [desktop:test] | [desktop:test] 202 | .build(tauri::generate_context!()) [desktop:test] | ^^^^^^^^^^^^^^^^^^^^^^^^^^ [desktop:test] | [desktop:test] = help: message: icon /Users/runner/work/Stirling-PDF/Stirling-PDF/frontend/editor/src-tauri/icons/icon.png is not RGBA [desktop:test] [desktop:test] error: could not compile `***-pdf` (lib) due to 1 previous error [desktop:test] warning: build failed, waiting for other jobs to finish... [desktop:test] error: could not compile `***-pdf` (lib test) due to 1 previous error task: Failed to run task "desktop:test": exit status 101 Error: exit status 101 ``` --- ## 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. --- .imgbotconfig | 5 +++++ frontend/editor/src-tauri/icons/icon.png | Bin 8987 -> 19631 bytes 2 files changed, 5 insertions(+) create mode 100644 .imgbotconfig diff --git a/.imgbotconfig b/.imgbotconfig new file mode 100644 index 0000000000..720b938475 --- /dev/null +++ b/.imgbotconfig @@ -0,0 +1,5 @@ +{ + "ignoredFiles": [ + "frontend/editor/src-tauri/icons/icon.png" + ] +} diff --git a/frontend/editor/src-tauri/icons/icon.png b/frontend/editor/src-tauri/icons/icon.png index 05069d088cf0471cfeea501ba1b424246343973b..5819d1b89dfa00cc6642d88cdafda58a8fc641d1 100644 GIT binary patch literal 19631 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 literal 8987 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 SD>5)JFnGH9xvX Date: Sun, 12 Jul 2026 11:16:55 +0200 Subject: [PATCH 30/34] fix(i18n): localize hardcoded frontend text in English and German (#6993) --- .../public/locales/de-DE/translation.toml | 42 ++++++++++++ .../public/locales/en-US/translation.toml | 42 ++++++++++++ .../onboarding/slides/MFASetupSlide.tsx | 67 ++++++++++++++----- .../tools/addStamp/StampPreview.tsx | 6 +- .../components/viewer/BookmarkSidebar.tsx | 32 +++++++-- .../core/components/viewer/LayerSidebar.tsx | 6 +- .../components/viewer/ThumbnailSidebar.tsx | 5 +- frontend/editor/src/core/tools/SwaggerUI.tsx | 8 ++- .../src/core/tools/formFill/FormFill.tsx | 18 +++-- frontend/editor/src/core/ui/CodeBlock.tsx | 4 +- .../proprietary/routes/AuthCallback.test.tsx | 2 +- .../src/proprietary/routes/AuthCallback.tsx | 45 +++++++++++-- .../editor/src/proprietary/routes/Landing.tsx | 4 +- .../saas/components/shared/AppConfigModal.tsx | 13 ++-- .../shared/charts/StackedBarChart.tsx | 4 +- .../shared/config/ProfilePictureCropper.tsx | 2 +- 16 files changed, 245 insertions(+), 55 deletions(-) diff --git a/frontend/editor/public/locales/de-DE/translation.toml b/frontend/editor/public/locales/de-DE/translation.toml index 2d50d8aa53..29efeca137 100644 --- a/frontend/editor/public/locales/de-DE/translation.toml +++ b/frontend/editor/public/locales/de-DE/translation.toml @@ -356,6 +356,7 @@ text = "Diese Berechtigungen kontrollieren, was Benutzer mit der PDF machen kön title = "Berechtigungen ändern" [addStamp] +preview = "Stempelvorschau" tags = "stamp,mark,seal,approved,rejected,confidential,stamp tool,rubber stamp,date stamp,approval stamp,received,void,copy,original" [AddStampRequest] @@ -1842,6 +1843,15 @@ export = "Export" accessDenied = "Zugriff verweigert" insufficientPermissions = "Sie haben keine Berechtigung, diese Aktion auszuführen." +[auth.callback] +completing = "Authentifizierung wird abgeschlossen" +invalidToken = "OAuth-Anmeldung fehlgeschlagen – ungültiges Token." +missingToken = "OAuth-Anmeldung fehlgeschlagen – kein Token empfangen." +oauthFailed = "OAuth-Anmeldung fehlgeschlagen. Bitte versuchen Sie es erneut." +pleaseWait = "Bitte warten Sie, während wir Ihre Anmeldung abschließen." +signedOut = "Sie wurden abgemeldet. Bitte melden Sie sich erneut an." +windowMayClose = "Sie können dieses Fenster schließen, sobald der Vorgang abgeschlossen ist." + [auth.displayName] guest = "Gast" user = "Benutzer" @@ -2824,6 +2834,7 @@ next = "Weiter" preview = "Vorschau" previous = "Zurück" refresh = "Aktualisieren" +remaining = "Verbleibend" retry = "Wiederholen" save = "Save" @@ -4072,10 +4083,17 @@ discord = "Discord" issues = "GitHub" [formFill] +allSaved = "Alles gespeichert" +extractCsvError = "CSV konnte nicht extrahiert werden" +extractXlsxError = "XLSX konnte nicht extrahiert werden" flattenAfterFilling = "Nach dem Ausfüllen ebnen" +requiredAbbreviation = "erf." +requiredFieldsError = "Bitte füllen Sie alle Pflichtfelder aus" rescanFields = "Felder erneut scannen" rescanFormFields = "Formularfelder erneut scannen" save = "Speichern" +saveShortcut = "Strg+S zum Speichern" +unsavedChanges = "Nicht gespeicherte Änderungen" [formFill.sidebar] close = "Seitenleiste schließen" @@ -4996,7 +5014,21 @@ titleWithOs = "Herunterladen für {{osLabel}}" [onboarding.mfa] authenticationCode = "Authentifizierungscode" +description = "Schützen Sie Ihr Konto, indem Sie eine Authenticator-App verknüpfen. Scannen Sie den QR-Code oder geben Sie den Einrichtungsschlüssel ein und bestätigen Sie anschließend den sechsstelligen Code." +enable = "MFA aktivieren" +enableError = "Die Multi-Faktor-Authentifizierung konnte nicht aktiviert werden. Prüfen Sie den Code und versuchen Sie es erneut." +enterCode = "Geben Sie den sechsstelligen Code aus Ihrer App ein." +enterCodeError = "Geben Sie den Authentifizierungscode ein, um fortzufahren." +logout = "Abmelden" +openAuthenticator = "Öffnen Sie Google Authenticator, Authy oder 1Password." qrCodeLoading = "Ihr QR-Code wird erstellt…" +regenerateQrCode = "QR-Code neu erstellen" +scanQrCode = "Scannen Sie den QR-Code oder geben Sie den unten stehenden Einrichtungsschlüssel ein." +setupError = "Die Einrichtung der Zwei-Faktor-Authentifizierung konnte nicht gestartet werden. Bitte versuchen Sie es erneut." +setupKey = "Einrichtungsschlüssel (manuelle Eingabe)" +stepByStep = "Schritt für Schritt" +success = "MFA wurde aktiviert. Sie können jetzt fortfahren." +title = "Einrichtung der Multi-Faktor-Authentifizierung" [onboarding.planOverview] adminBodyLoginDisabled = "Sobald Sie den Login-Modus aktivieren, können Sie Nutzer verwalten, Einstellungen konfigurieren und die Servergesundheit überwachen. Die ersten {{freeTierLimit}} Personen auf Ihrem Server nutzen Stirling kostenlos." @@ -9110,6 +9142,11 @@ security = "Sicherheit" telegram = "Telegram" title = "Sicherheit & Authentifizierung" +[settings.signOut] +confirm = "Möchten Sie sich wirklich abmelden?" +submit = "Abmelden" +title = "Abmelden" + [settings.team] title = "Team" @@ -9726,6 +9763,9 @@ uploadButton = "Auf Server hochladen" title = "Stirling-PDF Survey" [swagger] +clickHere = "hier klicken" +fallback = "Falls die Seite nicht automatisch geöffnet wurde," +opening = "Swagger UI wird in einem neuen Tab geöffnet..." tags = "API,Dokumentation,Swagger,Endpunkte,Entwicklung" title = "API-Dokumentation" @@ -10281,9 +10321,11 @@ title = "Attachments" [viewer.bookmarks] bookmarkTitle = "Lesezeichentitel" +bookmarkTitleRequired = "Ein Lesezeichentitel ist erforderlich" closeSidebar = "Seitenleiste für Lesezeichen schließen" collapseAll = "Alle Lesezeichen einklappen" expandAll = "Alle Lesezeichen ausklappen" +searchPlaceholder = "Lesezeichen durchsuchen" [viewer.comments] addComment = "Kommentar hinzufügen" diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 3c25732878..9663478000 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -356,6 +356,7 @@ text = "These permissions control what users can do with the PDF. Most effective title = "Change Permissions" [addStamp] +preview = "Preview Stamp" tags = "stamp,mark,seal,approved,rejected,confidential,stamp tool,rubber stamp,date stamp,approval stamp,received,void,copy,original" [AddStampRequest] @@ -1842,6 +1843,15 @@ export = "Export" accessDenied = "Access Denied" insufficientPermissions = "You do not have permission to perform this action." +[auth.callback] +completing = "Completing authentication" +invalidToken = "OAuth login failed - invalid token." +missingToken = "OAuth login failed - no token received." +oauthFailed = "OAuth login failed. Please try again." +pleaseWait = "Please wait while we finish signing you in." +signedOut = "You have been signed out. Please sign in again." +windowMayClose = "You can close this window once it completes." + [auth.displayName] guest = "Guest" user = "User" @@ -2824,6 +2834,7 @@ next = "Next" preview = "Preview" previous = "Previous" refresh = "Refresh" +remaining = "Remaining" retry = "Retry" save = "Save" @@ -4072,10 +4083,17 @@ discord = "Discord" issues = "GitHub" [formFill] +allSaved = "All saved" +extractCsvError = "Failed to extract CSV" +extractXlsxError = "Failed to extract XLSX" flattenAfterFilling = "Flatten after filling" +requiredAbbreviation = "req" +requiredFieldsError = "Please fill in all required fields" rescanFields = "Re-scan fields" rescanFormFields = "Re-scan form fields" save = "Save" +saveShortcut = "Ctrl+S to save" +unsavedChanges = "Unsaved changes" [formFill.sidebar] close = "Close sidebar" @@ -5016,7 +5034,21 @@ titleWithOs = "Download for {{osLabel}}" [onboarding.mfa] authenticationCode = "Authentication code" +description = "Secure your account by linking an authenticator app. Scan the QR code or enter the setup key, then confirm the 6-digit code to finish." +enable = "Enable MFA" +enableError = "Unable to enable two-factor authentication. Check the code and try again." +enterCode = "Enter the 6-digit code from your app." +enterCodeError = "Enter the authentication code to continue." +logout = "Logout" +openAuthenticator = "Open Google Authenticator, Authy, or 1Password." qrCodeLoading = "Generating your QR code…" +regenerateQrCode = "Regenerate QR code" +scanQrCode = "Scan the QR code or enter the setup key below." +setupError = "Unable to start two-factor setup. Please try again." +setupKey = "Setup key (manual entry)" +stepByStep = "Step-by-step" +success = "MFA has been enabled. You can now continue." +title = "Multi-Factor Authentication Setup" [onboarding.planOverview] adminBodyLoginDisabled = "Once you enable login mode, you can manage users, configure settings, and monitor server health. The first {{freeTierLimit}} people on your server get to use Stirling free of charge." @@ -9178,6 +9210,11 @@ security = "Security" telegram = "Telegram" title = "Security & Authentication" +[settings.signOut] +confirm = "Are you sure you want to sign out?" +submit = "Sign out" +title = "Sign out" + [settings.team] title = "Team" @@ -9794,6 +9831,9 @@ uploadButton = "Upload to Server" title = "Stirling-PDF Survey" [swagger] +clickHere = "click here" +fallback = "If it didn't open automatically," +opening = "Opening Swagger UI in a new tab..." tags = "api,documentation,swagger,endpoints,development" title = "API Documentation" @@ -10362,9 +10402,11 @@ title = "Attachments" [viewer.bookmarks] bookmarkTitle = "Bookmark title" +bookmarkTitleRequired = "Bookmark title is required" closeSidebar = "Close bookmarks sidebar" collapseAll = "Collapse all bookmarks" expandAll = "Expand all bookmarks" +searchPlaceholder = "Search bookmarks" [viewer.comments] addComment = "Add comment" diff --git a/frontend/editor/src/core/components/onboarding/slides/MFASetupSlide.tsx b/frontend/editor/src/core/components/onboarding/slides/MFASetupSlide.tsx index 4abd09b0e6..41c8308f2b 100644 --- a/frontend/editor/src/core/components/onboarding/slides/MFASetupSlide.tsx +++ b/frontend/editor/src/core/components/onboarding/slides/MFASetupSlide.tsx @@ -26,6 +26,7 @@ import LocalIcon from "@app/components/shared/LocalIcon"; import { BASE_PATH, withBasePath } from "@app/constants/app"; import styles from "@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css"; import { MfaSetupResponse } from "@app/responses/Mfa/MfaResponse"; +import i18n from "@app/i18n"; interface MFASetupSlideProps { onMfaSetupComplete?: () => void; @@ -62,12 +63,15 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) { const axiosError = err as { response?: { data?: { error?: string } } }; setMfaError( axiosError.response?.data?.error || - "Unable to start two-factor setup. Please try again.", + t( + "onboarding.mfa.setupError", + "Unable to start two-factor setup. Please try again.", + ), ); } finally { setMfaLoading(false); } - }, []); + }, [t]); useEffect(() => { setupCompleteRef.current = setupComplete; @@ -96,7 +100,12 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) { event.preventDefault(); if (!mfaSetupCode.trim()) { - setMfaError("Enter the authentication code to continue."); + setMfaError( + t( + "onboarding.mfa.enterCodeError", + "Enter the authentication code to continue.", + ), + ); return; } @@ -110,13 +119,16 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) { const axiosError = err as { response?: { data?: { error?: string } } }; setMfaError( axiosError.response?.data?.error || - "Unable to enable two-factor authentication. Check the code and try again.", + t( + "onboarding.mfa.enableError", + "Unable to enable two-factor authentication. Check the code and try again.", + ), ); } finally { setSubmitting(false); } }, - [mfaSetupCode, onMfaSetupComplete], + [mfaSetupCode, onMfaSetupComplete, t], ); const isReady = Boolean(mfaSetupData); @@ -138,15 +150,30 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) { - Step-by-step + {t("onboarding.mfa.stepByStep", "Step-by-step")}
    -
  1. Open Google Authenticator, Authy, or 1Password.
  2. -
  3. Scan the QR code or enter the setup key below.
  4. -
  5. Enter the 6-digit code from your app.
  6. +
  7. + {t( + "onboarding.mfa.openAuthenticator", + "Open Google Authenticator, Authy, or 1Password.", + )} +
  8. +
  9. + {t( + "onboarding.mfa.scanQrCode", + "Scan the QR code or enter the setup key below.", + )} +
  10. +
  11. + {t( + "onboarding.mfa.enterCode", + "Enter the 6-digit code from your app.", + )} +
- Setup key (manual entry) + {t("onboarding.mfa.setupKey", "Setup key (manual entry)")} - Secure your account by linking an authenticator app. Scan the QR - code or enter the setup key, then confirm the 6-digit code to - finish. + {t( + "onboarding.mfa.description", + "Secure your account by linking an authenticator app. Scan the QR code or enter the setup key, then confirm the 6-digit code to finish.", + )} {mfaError && ( @@ -215,7 +243,7 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) { onClick={fetchMfaSetup} disabled={mfaLoading || submitting || setupComplete} > - Regenerate QR code + {t("onboarding.mfa.regenerateQrCode", "Regenerate QR code")} {setupComplete && ( - MFA has been enabled. You can now continue. + {t( + "onboarding.mfa.success", + "MFA has been enabled. You can now continue.", + )} )} @@ -249,7 +280,7 @@ export default function MFASetupSlide({ }: MFASetupSlideProps = {}): SlideConfig { return { key: "mfa-setup-slide", - title: "Multi-Factor Authentication Setup", + title: i18n.t("onboarding.mfa.title", "Multi-Factor Authentication Setup"), body: , background: { gradientStops: ["#059669", "#0891B2"], // Green to teal - security/trust colors diff --git a/frontend/editor/src/core/components/tools/addStamp/StampPreview.tsx b/frontend/editor/src/core/components/tools/addStamp/StampPreview.tsx index 88f2d72f6c..9311392beb 100644 --- a/frontend/editor/src/core/components/tools/addStamp/StampPreview.tsx +++ b/frontend/editor/src/core/components/tools/addStamp/StampPreview.tsx @@ -12,6 +12,7 @@ import { import styles from "@app/components/tools/addStamp/StampPreview.module.css"; import { PrivateContent } from "@app/components/shared/PrivateContent"; import { Button } from "@app/ui/Button"; +import { useTranslation } from "react-i18next"; type Props = { parameters: AddStampParameters; @@ -29,6 +30,7 @@ export default function StampPreview({ file, showQuickGrid, }: Props) { + const { t } = useTranslation(); const containerRef = useRef(null); const [containerSize, setContainerSize] = useState<{ width: number; @@ -366,7 +368,9 @@ export default function StampPreview({
diff --git a/frontend/editor/src/proprietary/routes/AuthCallback.test.tsx b/frontend/editor/src/proprietary/routes/AuthCallback.test.tsx index 61b676e786..53b838a0d3 100644 --- a/frontend/editor/src/proprietary/routes/AuthCallback.test.tsx +++ b/frontend/editor/src/proprietary/routes/AuthCallback.test.tsx @@ -262,6 +262,6 @@ describe("AuthCallback", () => { , ); - expect(getByText("Completing authentication")).toBeInTheDocument(); + expect(getByText("auth.callback.completing")).toBeInTheDocument(); }); }); diff --git a/frontend/editor/src/proprietary/routes/AuthCallback.tsx b/frontend/editor/src/proprietary/routes/AuthCallback.tsx index f0e90f5ed0..5d09776df4 100644 --- a/frontend/editor/src/proprietary/routes/AuthCallback.tsx +++ b/frontend/editor/src/proprietary/routes/AuthCallback.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef } from "react"; import { useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; import { consumePostLoginRedirectPath, springAuth, @@ -7,6 +8,7 @@ import { import { markLoginLandingPending } from "@app/utils/loginLanding"; import { handleAuthCallbackSuccess } from "@app/extensions/authCallback"; import styles from "@app/routes/AuthCallback.module.css"; +import i18n from "@app/i18n"; /** * OAuth Callback Handler @@ -16,6 +18,7 @@ import styles from "@app/routes/AuthCallback.module.css"; * We extract it, store in localStorage, and redirect to the home page. */ export default function AuthCallback() { + const { t } = useTranslation(); const navigate = useNavigate(); const processingRef = useRef(false); @@ -31,7 +34,12 @@ export default function AuthCallback() { ) { navigate("/login", { replace: true, - state: { error: "You have been signed out. Please sign in again." }, + state: { + error: i18n.t( + "auth.callback.signedOut", + "You have been signed out. Please sign in again.", + ), + }, }); return; } @@ -50,7 +58,12 @@ export default function AuthCallback() { ); navigate("/login", { replace: true, - state: { error: "OAuth login failed - no token received." }, + state: { + error: i18n.t( + "auth.callback.missingToken", + "OAuth login failed - no token received.", + ), + }, }); return; } @@ -67,7 +80,12 @@ export default function AuthCallback() { localStorage.removeItem("stirling_jwt"); navigate("/login", { replace: true, - state: { error: "OAuth login failed - invalid token." }, + state: { + error: i18n.t( + "auth.callback.invalidToken", + "OAuth login failed - invalid token.", + ), + }, }); return; } @@ -93,7 +111,12 @@ export default function AuthCallback() { ); navigate("/login", { replace: true, - state: { error: "OAuth login failed. Please try again." }, + state: { + error: i18n.t( + "auth.callback.oauthFailed", + "OAuth login failed. Please try again.", + ), + }, }); } }; @@ -105,12 +128,20 @@ export default function AuthCallback() {
...
-
Completing authentication
+
+ {t("auth.callback.completing", "Completing authentication")} +
- Please wait while we finish signing you in. + {t( + "auth.callback.pleaseWait", + "Please wait while we finish signing you in.", + )}
- You can close this window once it completes. + {t( + "auth.callback.windowMayClose", + "You can close this window once it completes.", + )}
diff --git a/frontend/editor/src/proprietary/routes/Landing.tsx b/frontend/editor/src/proprietary/routes/Landing.tsx index d22acbba56..c377df1794 100644 --- a/frontend/editor/src/proprietary/routes/Landing.tsx +++ b/frontend/editor/src/proprietary/routes/Landing.tsx @@ -109,7 +109,9 @@ export default function Landing() { >
-
Loading...
+
+ {t("common.loading", "Loading...")} +
-
Preview Stamp
+
+ {t("addStamp.preview", "Preview Stamp")} +
{ const title = newBookmarkTitle.trim(); if (!title) { - setAddBookmarkError("Bookmark title is required"); + setAddBookmarkError( + t( + "viewer.bookmarks.bookmarkTitleRequired", + "Bookmark title is required", + ), + ); return; } // Resolve the file the viewer is currently displaying. activeFileId @@ -695,7 +700,10 @@ export const BookmarkSidebar = ({ "viewer.bookmarks.expandAll", "Expand all bookmarks", )} - title="Expand all" + title={t( + "viewer.bookmarks.expandAll", + "Expand all bookmarks", + )} > @@ -746,7 +760,10 @@ export const BookmarkSidebar = ({ > setSearchTerm(event.currentTarget.value)} leftSection={ @@ -839,7 +856,10 @@ export const BookmarkSidebar = ({ @@ -360,7 +360,7 @@ export function LayerSidebar({ onClick={hideAll} disabled={allHidden || isApplying} aria-label={t("viewer.layers.hideAll", "Hide all layers")} - title="Hide all" + title={t("viewer.layers.hideAll", "Hide all layers")} > @@ -372,7 +372,7 @@ export function LayerSidebar({ size="sm" onClick={toggleLayerSidebar} aria-label={t("viewer.layers.closeSidebar", "Close layers sidebar")} - title="Close layers" + title={t("viewer.layers.closeSidebar", "Close layers sidebar")} > diff --git a/frontend/editor/src/core/components/viewer/ThumbnailSidebar.tsx b/frontend/editor/src/core/components/viewer/ThumbnailSidebar.tsx index 3fb62df970..6b195c4485 100644 --- a/frontend/editor/src/core/components/viewer/ThumbnailSidebar.tsx +++ b/frontend/editor/src/core/components/viewer/ThumbnailSidebar.tsx @@ -193,7 +193,10 @@ export function ThumbnailSidebar({ "viewer.thumbnails.closeSidebar", "Close thumbnails sidebar", )} - title="Close thumbnails" + title={t( + "viewer.thumbnails.closeSidebar", + "Close thumbnails sidebar", + )} > diff --git a/frontend/editor/src/core/tools/SwaggerUI.tsx b/frontend/editor/src/core/tools/SwaggerUI.tsx index 08a878b4c5..4ee6faf11d 100644 --- a/frontend/editor/src/core/tools/SwaggerUI.tsx +++ b/frontend/editor/src/core/tools/SwaggerUI.tsx @@ -1,8 +1,10 @@ import React, { useEffect } from "react"; import { BaseToolProps } from "@app/types/tool"; import { withBasePath } from "@app/constants/app"; +import { useTranslation } from "react-i18next"; const SwaggerUI: React.FC = () => { + const { t } = useTranslation(); useEffect(() => { // Redirect to Swagger UI window.open(withBasePath("/swagger-ui/5.21.0/index.html"), "_blank"); @@ -10,15 +12,15 @@ const SwaggerUI: React.FC = () => { return ( diff --git a/frontend/editor/src/core/tools/formFill/FormFill.tsx b/frontend/editor/src/core/tools/formFill/FormFill.tsx index a99f990f33..bf1a8f5fe6 100644 --- a/frontend/editor/src/core/tools/formFill/FormFill.tsx +++ b/frontend/editor/src/core/tools/formFill/FormFill.tsx @@ -204,7 +204,7 @@ const FormFill = (_props: BaseToolProps) => { setTimeout(() => URL.revokeObjectURL(url), 250); } catch (err) { console.error("[FormFill] CSV extraction failed:", err); - setSaveError("Failed to extract CSV"); + setSaveError(t("formFill.extractCsvError", "Failed to extract CSV")); } finally { setExtracting(false); } @@ -223,7 +223,7 @@ const FormFill = (_props: BaseToolProps) => { setTimeout(() => URL.revokeObjectURL(url), 250); } catch (err) { console.error("[FormFill] XLSX extraction failed:", err); - setSaveError("Failed to extract XLSX"); + setSaveError(t("formFill.extractXlsxError", "Failed to extract XLSX")); } finally { setExtracting(false); } @@ -246,7 +246,9 @@ const FormFill = (_props: BaseToolProps) => { if (!currentFile || !isStirlingFile(currentFile)) return; if (!validateForm()) { - setSaveError("Please fill in all required fields"); + setSaveError( + t("formFill.requiredFieldsError", "Please fill in all required fields"), + ); return; } @@ -641,7 +643,9 @@ const FormFill = (_props: BaseToolProps) => { {field.label || field.name} {field.required && ( - req + + {t("formFill.requiredAbbreviation", "req")} + )}
@@ -683,10 +687,10 @@ const FormFill = (_props: BaseToolProps) => { )} {formState.isDirty || flattenChanged - ? "Unsaved changes" - : "All saved"} + ? t("formFill.unsavedChanges", "Unsaved changes") + : t("formFill.allSaved", "All saved")} - Ctrl+S to save + {t("formFill.saveShortcut", "Ctrl+S to save")}
)} diff --git a/frontend/editor/src/core/ui/CodeBlock.tsx b/frontend/editor/src/core/ui/CodeBlock.tsx index 1a05b23e4f..cff078057d 100644 --- a/frontend/editor/src/core/ui/CodeBlock.tsx +++ b/frontend/editor/src/core/ui/CodeBlock.tsx @@ -1,5 +1,6 @@ import { useState, type ReactNode } from "react"; import { Button } from "@app/ui/Button"; +import { useTranslation } from "react-i18next"; import "@app/ui/CodeBlock.css"; export type CodeLang = @@ -33,6 +34,7 @@ export function CodeBlock({ maxHeight = 400, className, }: CodeBlockProps) { + const { t } = useTranslation(); const [copied, setCopied] = useState(false); async function copy() { @@ -64,7 +66,7 @@ export function CodeBlock({ onClick={copy} aria-label="Copy code" > - {copied ? "Copied" : "Copy"} + {copied ? t("common.copied", "Copied!") : t("common.copy", "Copy")} )}
); diff --git a/frontend/editor/src/saas/components/shared/AppConfigModal.tsx b/frontend/editor/src/saas/components/shared/AppConfigModal.tsx index 7b579f3c43..32def0b196 100644 --- a/frontend/editor/src/saas/components/shared/AppConfigModal.tsx +++ b/frontend/editor/src/saas/components/shared/AppConfigModal.tsx @@ -302,15 +302,20 @@ const AppConfigModal: React.FC = ({ setConfirmOpen(false)} - title="Sign out" + title={t("settings.signOut.title", "Sign out")} centered zIndex={Z_INDEX_OVER_SETTINGS_MODAL} >
- Are you sure you want to sign out? + + {t( + "settings.signOut.confirm", + "Are you sure you want to sign out?", + )} +
diff --git a/frontend/editor/src/saas/components/shared/charts/StackedBarChart.tsx b/frontend/editor/src/saas/components/shared/charts/StackedBarChart.tsx index 589eda3d0f..b1ba0ed3ac 100644 --- a/frontend/editor/src/saas/components/shared/charts/StackedBarChart.tsx +++ b/frontend/editor/src/saas/components/shared/charts/StackedBarChart.tsx @@ -13,6 +13,7 @@ import { createScale, } from "@app/components/shared/charts/utils/d3Utils"; import "@app/components/shared/charts/StackedBarChart.css"; +import { useTranslation } from "react-i18next"; export default function StackedBarChart({ fractions, @@ -26,6 +27,7 @@ export default function StackedBarChart({ animationDurationMs = 900, ariaLabel, }: StackedBarChartProps) { + const { t } = useTranslation(); const containerRef = useRef(null); const tooltipRef = useRef(null); const hasAnimatedRef = useRef(false); @@ -309,7 +311,7 @@ export default function StackedBarChart({ outline: "1px solid var(--api-keys-card-border)", }} /> - Remaining + {t("common.remaining", "Remaining")} )} diff --git a/frontend/editor/src/saas/components/shared/config/ProfilePictureCropper.tsx b/frontend/editor/src/saas/components/shared/config/ProfilePictureCropper.tsx index 8230184a2c..0604307b32 100644 --- a/frontend/editor/src/saas/components/shared/config/ProfilePictureCropper.tsx +++ b/frontend/editor/src/saas/components/shared/config/ProfilePictureCropper.tsx @@ -144,7 +144,7 @@ export const ProfilePictureCropper: React.FC = ({ > {error && ( - + {error} )} From d4edff90596d27721c19a585ae8d83ec37ad7897 Mon Sep 17 00:00:00 2001 From: Ludy Date: Sun, 12 Jul 2026 23:33:09 +0200 Subject: [PATCH 31/34] fix(temp-files): prevent cleanup of active registered directories (#7006) --- .../service/TempFileCleanupService.java | 19 +++++++++++++++++- .../software/common/util/TempFileManager.java | 1 + .../common/util/TempFileRegistry.java | 12 +++++++++++ .../TempFileCleanupServiceMoreTest.java | 20 ++++++++++++++++++- 4 files changed, 50 insertions(+), 2 deletions(-) diff --git a/app/common/src/main/java/stirling/software/common/service/TempFileCleanupService.java b/app/common/src/main/java/stirling/software/common/service/TempFileCleanupService.java index 81b45aed09..438481c212 100644 --- a/app/common/src/main/java/stirling/software/common/service/TempFileCleanupService.java +++ b/app/common/src/main/java/stirling/software/common/service/TempFileCleanupService.java @@ -144,8 +144,10 @@ public class TempFileCleanupService { int directoriesDeletedCount = 0; for (Path directory : registry.getTempDirectories()) { try { - if (Files.exists(directory)) { + if (Files.exists(directory) + && shouldDeleteRegisteredDirectory(directory, maxAgeMillis)) { GeneralUtils.deleteDirectory(directory); + registry.unregisterDirectory(directory); directoriesDeletedCount++; log.debug("Cleaned up temporary directory: {}", directory); } @@ -275,6 +277,21 @@ public class TempFileCleanupService { return totalDeletedCount.get(); } + private boolean shouldDeleteRegisteredDirectory(Path directory, long maxAgeMillis) { + if (maxAgeMillis <= 0) { + return true; + } + + try { + long currentTime = System.currentTimeMillis(); + long lastModified = Files.getLastModifiedTime(directory).toMillis(); + return (currentTime - lastModified) > maxAgeMillis; + } catch (IOException e) { + log.debug("Could not check directory age, skipping cleanup: {}", directory, e); + return false; + } + } + /** Get the system temp directory path based on configuration or system property. */ private Path getSystemTempPath() { String systemTempDir = diff --git a/app/common/src/main/java/stirling/software/common/util/TempFileManager.java b/app/common/src/main/java/stirling/software/common/util/TempFileManager.java index ff31975837..0d977e7d06 100644 --- a/app/common/src/main/java/stirling/software/common/util/TempFileManager.java +++ b/app/common/src/main/java/stirling/software/common/util/TempFileManager.java @@ -155,6 +155,7 @@ public class TempFileManager { if (directory != null && Files.isDirectory(directory)) { try { GeneralUtils.deleteDirectory(directory); + registry.unregisterDirectory(directory); log.debug("Deleted temp directory: {}", directory.toString()); } catch (IOException e) { log.warn("Failed to delete temp directory: {}", directory.toString(), e); diff --git a/app/common/src/main/java/stirling/software/common/util/TempFileRegistry.java b/app/common/src/main/java/stirling/software/common/util/TempFileRegistry.java index ab1304f5e4..84a3c12272 100644 --- a/app/common/src/main/java/stirling/software/common/util/TempFileRegistry.java +++ b/app/common/src/main/java/stirling/software/common/util/TempFileRegistry.java @@ -85,6 +85,18 @@ public class TempFileRegistry { return directory; } + /** + * Unregister a temporary directory from the registry. + * + * @param directory The directory to unregister + */ + public void unregisterDirectory(Path directory) { + if (directory != null) { + tempDirectories.remove(directory); + log.debug("Unregistered temp directory: {}", directory.toString()); + } + } + /** * Register a third-party temporary file that requires special handling. * diff --git a/app/common/src/test/java/stirling/software/common/service/TempFileCleanupServiceMoreTest.java b/app/common/src/test/java/stirling/software/common/service/TempFileCleanupServiceMoreTest.java index 802dc2eace..cfbe950574 100644 --- a/app/common/src/test/java/stirling/software/common/service/TempFileCleanupServiceMoreTest.java +++ b/app/common/src/test/java/stirling/software/common/service/TempFileCleanupServiceMoreTest.java @@ -176,11 +176,13 @@ class TempFileCleanupServiceMoreTest { class ScheduledCleanup { @Test - @DisplayName("deletes registered temp directories and reports counts") + @DisplayName("deletes stale registered temp directories and reports counts") void deletesRegisteredDirectories() throws IOException { when(tempFileManager.cleanupOldTempFiles(anyLong())).thenReturn(2); Path regDir = Files.createDirectories(tempDir.resolve("registeredDir")); Files.createFile(regDir.resolve("inside.txt")); + Files.setLastModifiedTime( + regDir, FileTime.fromMillis(System.currentTimeMillis() - 2L * 60 * 60 * 1000)); Set dirs = new HashSet<>(); dirs.add(regDir); when(registry.getTempDirectories()).thenReturn(dirs); @@ -193,6 +195,22 @@ class TempFileCleanupServiceMoreTest { verify(tempFileManager).cleanupOldTempFiles(anyLong()); } + @Test + @DisplayName("keeps a fresh registered temp directory") + void keepsFreshRegisteredDirectory() throws IOException { + when(tempFileManager.cleanupOldTempFiles(anyLong())).thenReturn(0); + Path regDir = Files.createDirectories(tempDir.resolve("freshRegisteredDir")); + Files.createFile(regDir.resolve("inside.txt")); + Set dirs = new HashSet<>(); + dirs.add(regDir); + when(registry.getTempDirectories()).thenReturn(dirs); + lenient().when(registry.contains(any(File.class))).thenReturn(false); + + withIsolatedUserHome(cleanupService::scheduledCleanup); + + assertThat(Files.exists(regDir)).isTrue(); + } + @Test @DisplayName("skips a registered directory that no longer exists") void skipsMissingRegisteredDirectory() { From c1e68c27c57c844442556e15398772ca5116ff04 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Mon, 13 Jul 2026 00:55:43 +0100 Subject: [PATCH 32/34] Portal Documents: SVG lock and timer icons (#7000) # Description of Changes Replaces the emoji lock and timer icons in the document queue and extraction views with stroke SVG icons. Part of a portal (processor) UI-consistency pass, split into small focused PRs. ## Before / after --- ## 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) - [x] 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 - [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. --- .../src/portal/components/documents/DocumentExtractions.tsx | 3 ++- .../src/portal/components/documents/ElevationBanner.tsx | 6 ++++-- .../src/portal/components/documents/ReviewQueueTable.tsx | 3 ++- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/frontend/editor/src/portal/components/documents/DocumentExtractions.tsx b/frontend/editor/src/portal/components/documents/DocumentExtractions.tsx index e43076dae3..3c6de80c93 100644 --- a/frontend/editor/src/portal/components/documents/DocumentExtractions.tsx +++ b/frontend/editor/src/portal/components/documents/DocumentExtractions.tsx @@ -1,4 +1,5 @@ import { useTranslation } from "react-i18next"; +import LockRounded from "@mui/icons-material/LockRounded"; import { StatusBadge, Table, type TableColumn } from "@app/ui"; import { type Extraction, type ReviewDocument } from "@portal/api/documents"; import { @@ -55,7 +56,7 @@ export function DocumentExtractions({ return (
- 🔒 +

{t("portal.documents.extractions.masked")} diff --git a/frontend/editor/src/portal/components/documents/ElevationBanner.tsx b/frontend/editor/src/portal/components/documents/ElevationBanner.tsx index 429cd606cb..67a26ad1c2 100644 --- a/frontend/editor/src/portal/components/documents/ElevationBanner.tsx +++ b/frontend/editor/src/portal/components/documents/ElevationBanner.tsx @@ -1,4 +1,6 @@ import { useTranslation } from "react-i18next"; +import ScheduleRounded from "@mui/icons-material/ScheduleRounded"; +import LockRounded from "@mui/icons-material/LockRounded"; import { Banner, Button } from "@app/ui"; import { formatCountdown } from "@portal/components/documents/format"; @@ -26,7 +28,7 @@ export function ElevationBanner({ return ( ⏱} + icon={} title={t("portal.documents.elevation.active.title", { time: formatCountdown(secondsLeft), })} @@ -42,7 +44,7 @@ export function ElevationBanner({ return ( 🔒} + icon={} title={t("portal.documents.elevation.gated.title")} description={ fourEyes diff --git a/frontend/editor/src/portal/components/documents/ReviewQueueTable.tsx b/frontend/editor/src/portal/components/documents/ReviewQueueTable.tsx index dffa7305d7..bff13d0c4f 100644 --- a/frontend/editor/src/portal/components/documents/ReviewQueueTable.tsx +++ b/frontend/editor/src/portal/components/documents/ReviewQueueTable.tsx @@ -1,5 +1,6 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; +import LockRounded from "@mui/icons-material/LockRounded"; import { Button, Chip, StatusBadge, Table, type TableColumn } from "@app/ui"; import { classificationTone, @@ -75,7 +76,7 @@ export function ReviewQueueTable({ title={t("portal.documents.table.sensitiveTitle")} aria-label={t("portal.documents.table.sensitiveLabel")} > - 🔒 + )}

From 52358c5bf906d235789340f366985151095c3f1e Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Mon, 13 Jul 2026 00:55:54 +0100 Subject: [PATCH 33/34] Portal Agent Builder: SVG upload icon (#7001) # Description of Changes Replaces the upload glyph in the agent bootstrap dialog with a proper SVG icon. Part of a portal (processor) UI-consistency pass, split into small focused PRs. ## Before / after --- ## 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) - [x] 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 - [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. --- .../src/portal/components/agent-builder/BootstrapDialog.tsx | 3 ++- frontend/editor/src/portal/views/AgentBuilder.tsx | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/editor/src/portal/components/agent-builder/BootstrapDialog.tsx b/frontend/editor/src/portal/components/agent-builder/BootstrapDialog.tsx index a6b28688e6..27b076eb69 100644 --- a/frontend/editor/src/portal/components/agent-builder/BootstrapDialog.tsx +++ b/frontend/editor/src/portal/components/agent-builder/BootstrapDialog.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; +import FileUploadRounded from "@mui/icons-material/FileUploadRounded"; import { Button, Modal } from "@app/ui"; import "@portal/views/AgentBuilder.css"; @@ -58,7 +59,7 @@ export function BootstrapDialog({ open, onClose }: BootstrapDialogProps) { onChange={(e) => setFileName(e.target.files?.[0]?.name ?? null)} /> - ⇪ + {fileName ?? t("portal.agentBuilder.bootstrap.dropzoneText")} diff --git a/frontend/editor/src/portal/views/AgentBuilder.tsx b/frontend/editor/src/portal/views/AgentBuilder.tsx index efa319c146..30a8f5e14e 100644 --- a/frontend/editor/src/portal/views/AgentBuilder.tsx +++ b/frontend/editor/src/portal/views/AgentBuilder.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; +import FileUploadRounded from "@mui/icons-material/FileUploadRounded"; import { Button, EmptyState, Skeleton } from "@app/ui"; import { useTier } from "@portal/contexts/TierContext"; import { useAsync, useSectionFlags } from "@portal/hooks/useAsync"; @@ -41,7 +42,7 @@ export function AgentBuilder() {
From a84b375f5df5fda340cc75b79747141d77775634 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Mon, 13 Jul 2026 00:56:07 +0100 Subject: [PATCH 34/34] Portal Pipelines: SVG pipeline icon (#7002) # Description of Changes Replaces the chain glyph in the pipelines table with a proper pipeline icon. Part of a portal (processor) UI-consistency pass, split into small focused PRs. ## Before / after --- ## 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) - [x] 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 - [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. --- .../editor/src/portal/components/pipelines/PipelinesTable.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/editor/src/portal/components/pipelines/PipelinesTable.tsx b/frontend/editor/src/portal/components/pipelines/PipelinesTable.tsx index 42550e28a4..38c46547ca 100644 --- a/frontend/editor/src/portal/components/pipelines/PipelinesTable.tsx +++ b/frontend/editor/src/portal/components/pipelines/PipelinesTable.tsx @@ -1,5 +1,6 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; +import AccountTreeRounded from "@mui/icons-material/AccountTreeRounded"; import ChevronRightRoundedIcon from "@mui/icons-material/ChevronRightRounded"; import { Chip, @@ -31,7 +32,7 @@ export function PipelinesTable({ pipelines, onRowClick }: PipelinesTableProps) { render: (p) => (
- ⛓ +
{p.name}