Fix SaaS issues (#6694)

# Description of Changes

Fixes several SaaS issues,  was integration branch for saas release

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

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

---------

Co-authored-by: Claude Opus 4.8 <[email protected]>
Co-authored-by: James Brunton <[email protected]>
Co-authored-by: Reece Browne <[email protected]>
Co-authored-by: ConnorYoh <[email protected]>
Co-authored-by: Reece <[email protected]>
Co-authored-by: EthanHealy01 <[email protected]>
Co-authored-by: Ludy <[email protected]>
This commit is contained in:
Anthony Stirling
2026-06-16 17:16:07 +01:00
committed by GitHub
co-authored by Claude Opus 4.8 James Brunton Reece Browne ConnorYoh Reece EthanHealy01 Ludy
415 changed files with 29552 additions and 5855 deletions
+19 -3
View File
@@ -1,12 +1,14 @@
import { resolve } from "node:path";
import type { StorybookConfig } from "@storybook/react-vite";
import tsconfigPaths from "vite-tsconfig-paths";
/**
* Storybook 9 ships essentials, interactions, and docs as built-ins, so the
* addon list is just the extras we want: theme switching + a11y auditing.
*
* Story files live next to their components in shared/ and portal/src/.
* MDX docs pages live in portal/src/docs/.
* Story files live next to their components in shared/, portal/src/, and
* editor/src/ — the design system is shared by BOTH apps, so both surface
* their stories here. MDX docs pages live in portal/src/docs/.
*/
const config: StorybookConfig = {
stories: [
@@ -14,6 +16,7 @@ const config: StorybookConfig = {
"../portal/src/**/*.stories.@(ts|tsx)",
"../shared/**/*.mdx",
"../shared/**/*.stories.@(ts|tsx)",
"../editor/src/**/*.stories.@(ts|tsx)",
],
addons: ["@storybook/addon-themes", "@storybook/addon-a11y"],
framework: {
@@ -28,13 +31,26 @@ const config: StorybookConfig = {
staticDirs: ["../portal/public"],
viteFinal: async (config) => {
// Wire @portal/* and @shared/* aliases directly on the Storybook bundler so
// story imports resolve without needing the portal's vite config.
// portal story imports resolve without needing the portal's vite config.
config.resolve = config.resolve ?? {};
config.resolve.alias = {
...(config.resolve.alias ?? {}),
"@portal": resolve(__dirname, "../portal/src"),
"@shared": resolve(__dirname, "../shared"),
};
// Editor stories import via @app/* (proprietary→core fallback), @core/* and
// @proprietary/*. Resolve them exactly the way the editor's own build does —
// through vite-tsconfig-paths against the proprietary vite tsconfig — so the
// shared Storybook can host editor components without duplicating the alias
// map here.
config.plugins = config.plugins ?? [];
config.plugins.push(
tsconfigPaths({
projects: [
resolve(__dirname, "../editor/tsconfig.proprietary.vite.json"),
],
}),
);
return config;
},
};
+1 -4
View File
@@ -8,8 +8,5 @@
# Userback feedback widget — leave blank to disable
VITE_USERBACK_TOKEN=
# URL subpath prefix for SaaS deployments (e.g. "app" if serving at /app/) — leave blank for root
VITE_RUN_SUBPATH=
# Development-only auth bypass — allows unauthenticated access on localhost in dev mode
# Dev-only auth bypass for localhost. Subpath comes from RUN_SUBPATH (build-time).
VITE_DEV_BYPASS_AUTH=false
@@ -20,17 +20,18 @@
--cc-separator-border-color: #e0e0e0;
--cc-toggle-on-bg: #007bff;
--cc-toggle-off-bg: #667481;
--cc-toggle-on-knob-bg: #ffffff;
--cc-toggle-off-knob-bg: #ffffff;
/* Toggle colors mirror Mantine Switch (light scheme) */
--cc-toggle-on-bg: var(--mantine-primary-color-filled, #007bff);
--cc-toggle-off-bg: var(--mantine-color-gray-3, #dee2e6);
--cc-toggle-on-knob-bg: var(--mantine-color-white, #ffffff);
--cc-toggle-off-knob-bg: var(--mantine-color-white, #ffffff);
--cc-toggle-enabled-icon-color: #ffffff;
--cc-toggle-disabled-icon-color: #ffffff;
--cc-toggle-readonly-bg: #f1f3f4;
--cc-toggle-readonly-knob-bg: #79747e;
--cc-toggle-readonly-knob-icon-color: #f1f3f4;
--cc-toggle-readonly-bg: var(--mantine-color-disabled, #f1f3f4);
--cc-toggle-readonly-knob-bg: var(--mantine-color-gray-0, #f8f9fa);
--cc-toggle-readonly-knob-icon-color: transparent;
--cc-section-category-border: #e0e0e0;
@@ -69,17 +70,18 @@
--cc-separator-border-color: #555555;
--cc-toggle-on-bg: #4dabf7;
--cc-toggle-off-bg: #667481;
--cc-toggle-on-knob-bg: #2d2d2d;
--cc-toggle-off-knob-bg: #2d2d2d;
/* Toggle colors mirror Mantine Switch (dark scheme) */
--cc-toggle-on-bg: var(--mantine-primary-color-filled, #4dabf7);
--cc-toggle-off-bg: var(--mantine-color-dark-5, #555555);
--cc-toggle-on-knob-bg: var(--mantine-color-white, #ffffff);
--cc-toggle-off-knob-bg: var(--mantine-color-white, #ffffff);
--cc-toggle-enabled-icon-color: #2d2d2d;
--cc-toggle-disabled-icon-color: #2d2d2d;
--cc-toggle-readonly-bg: #555555;
--cc-toggle-readonly-knob-bg: #8e8e8e;
--cc-toggle-readonly-knob-icon-color: #555555;
--cc-toggle-readonly-bg: var(--mantine-color-disabled, #555555);
--cc-toggle-readonly-knob-bg: var(--mantine-color-dark-3, #8e8e8e);
--cc-toggle-readonly-knob-icon-color: transparent;
--cc-section-category-border: #555555;
@@ -176,9 +178,16 @@
color: var(--cc-primary-color) !important;
}
/* Lower z-index so cookie banner appears behind onboarding modals */
/* Banner sits above the chat FAB but behind all modals and onboarding; value
is Z_INDEX_COOKIE_CONSENT_BANNER, set as this variable by useCookieConsent */
#cc-main {
z-index: 100 !important;
z-index: var(--z-index-cookie-consent) !important;
}
/* Preferences dialog sits above the settings modal it opens from; value is
Z_INDEX_COOKIE_PREFERENCES_MODAL, set as this variable by useCookieConsent */
.show--preferences #cc-main {
z-index: var(--z-index-cookie-preferences) !important;
}
/* Ensure consent modal text is visible in both themes */
@@ -203,3 +212,63 @@
#cc-main .cm__link {
color: var(--cc-primary-color) !important;
}
/* ── Category toggles restyled to match Mantine Switch (size sm) ──────────
Mantine sm metrics: 38×20 track, 14px plain thumb, 2.5px inline padding,
150ms ease transitions, no icon inside the thumb. Colors come from the
--cc-toggle-* variables above, which point at the Mantine palette. */
#cc-main .section__toggle,
#cc-main .section__toggle-wrapper,
#cc-main .toggle__icon,
#cc-main .toggle__label {
width: 38px !important;
height: 20px !important;
border-radius: 1000px !important;
}
/* Track: flat fill, no outline ring or border */
#cc-main .toggle__icon {
border: none !important;
box-shadow: none !important;
transition: background-color 150ms ease !important;
}
#cc-main .section__toggle:checked ~ .toggle__icon {
border: none !important;
box-shadow: none !important;
}
/* Always-enabled categories = Mantine disabled switch (must out-prioritise
the !important checked-track rule above) */
#cc-main .section__toggle:checked:disabled ~ .toggle__icon {
background: var(--cc-toggle-readonly-bg) !important;
border: none !important;
box-shadow: none !important;
}
#cc-main .section__toggle:disabled {
cursor: not-allowed !important;
}
/* Thumb: small plain circle, vertically centred, no drop shadow */
#cc-main .toggle__icon-circle {
width: 14px !important;
height: 14px !important;
top: 3px !important;
left: 2.5px !important;
box-shadow: none !important;
transition:
transform 150ms ease,
background-color 150ms ease !important;
}
/* Checked thumb travel: 38 14 2.5 = 21.5px end position */
#cc-main .section__toggle:checked ~ .toggle__icon .toggle__icon-circle {
transform: translateX(19px) !important;
}
/* Mantine switches have no check/cross glyph inside the thumb */
#cc-main .toggle__icon-on,
#cc-main .toggle__icon-off {
display: none !important;
}
File diff suppressed because it is too large Load Diff
@@ -50,12 +50,9 @@ help = "Help"
imgPrompt = "Select Image(s)"
incorrectPasswordMessage = "Current password is incorrect."
info = "Info"
insufficientCredits = "Insufficient credits. Required: {{requiredCredits}}, Available: {{currentBalance}}, Shortfall: {{shortfall}}"
invalidUndoData = "Cannot undo: invalid operation data"
keepWorking = "Keep Working"
loading = "Loading..."
loadingCredits = "Checking credits..."
loadingProStatus = "Checking subscription status..."
logOut = "Log out"
marginTooltip = "Distance between the page number and the edge of the page."
moreOptions = "More Options"
@@ -66,7 +63,6 @@ noFileSelected = "No file loaded. Please upload one."
noFilesToUndo = "Cannot undo: no files were processed in the last operation"
noOperationToUndo = "No operation to undo"
nothingToUndo = "Nothing to undo"
noticeTopUpOrPlan = "Not enough credits, please top up or upgrade to a plan"
noValidFiles = "No valid files to process"
oops = "Oops!"
openInNewWindow = "Open in new window"
@@ -1134,6 +1130,10 @@ apikeyNote = "Clients send a Stirling API key in the X-API-KEY header (or Author
description = "Expose Stirling's PDF tools and AI agents to MCP clients over an OAuth-protected endpoint."
title = "MCP Server"
[admin.settings.mcp.acceptedAudiences]
description = "Extra token audience values accepted besides the Resource ID (comma or space separated). Leave blank for strict RFC 8707. Needed for IdPs that cannot mint resource audiences - e.g. Supabase's OAuth server always issues aud=authenticated."
label = "Additional accepted audiences (optional)"
[admin.settings.mcp.allowedOps]
description = "If set, ONLY these operation ids are exposed (an allow-list; comma or space separated). Leave blank to expose all enabled tools except any blocked below."
label = "Allowed tools"
@@ -1575,30 +1575,7 @@ admin = "Admin"
title = "User Control Settings"
[agents]
auto_redaction_description = "Redact PII automatically"
auto_redaction_name = "Auto Redaction"
back_to_tools = "Back to tools"
coming_soon = "Coming soon"
compliance_description = "Audit documents for compliance"
compliance_name = "Compliance Check"
data_extraction_description = "Extract tables & structured data"
data_extraction_name = "Data Extraction"
doc_summary_description = "Summarize long documents"
doc_summary_name = "Summarizer"
form_filler_description = "Fill PDF forms intelligently"
form_filler_name = "Form Filler"
pdf_to_markdown_description = "Convert PDFs to clean Markdown"
pdf_to_markdown_name = "PDF to Markdown"
section_title = "Agents"
show_less = "Show less"
start_chat = "Start chatting"
stirling_description = "Your general-purpose PDF assistant"
stirling_full_name = "Stirling General Agent"
stirling_long_description = "General purpose PDF assistant that can run tools, create PDFs and extract insights from your documents."
stirling_name = "Stirling"
stirling_running = "Running..."
stirling_tooltip = "Stirling agent"
view_all = "View all agents"
[analytics]
learnMore = "Learn more about our analytics"
@@ -2678,9 +2655,6 @@ title = "Change Permissions"
[changePermissions.tooltip.warning]
text = "To make these permissions unchangeable, use the Add Password tool to set an owner password."
[chat]
resize = "Resize chat panel"
[chat.actions]
copy = "Copy message"
@@ -2695,6 +2669,7 @@ clearChat = "Clear chat"
[chat.input]
placeholder = "What do you want to do?"
send = "Send message"
disclaimer = "AI can make mistakes. Be sure to verify the output before sharing."
[chat.progress]
analyzing = "Analyzing your request..."
@@ -2729,8 +2704,6 @@ mergeMany = "Merge these {{count}} documents into 1"
moreFiles = "+{{count}} more"
openFromComputer = "Open from computer"
removeFile = "Remove {{name}}"
rotateMany = "Rotate these documents"
rotateOne = "Rotate this document"
splitOne = "Split this document"
[chat.responses]
@@ -2741,6 +2714,7 @@ need_clarification = "Could you clarify your request?"
not_found = "I couldn't find the requested information."
processing = "Processing ({{outcome}})..."
unsupported_capability = "Unsupported capability: {{capability}}"
usage_limit_reached = "You've reached your usage limit. Check your plan options to keep going."
[chat.toolsUsed]
summary = "Ran {{count}} tools"
@@ -2767,7 +2741,6 @@ copy = "Copy"
done = "Done"
error = "Error"
expand = "Expand"
learnMore = "Learn more"
loading = "Loading..."
next = "Next"
operation = "this operation"
@@ -2966,6 +2939,7 @@ tags = "squish,small,tiny"
[config]
plan = "Plan"
team = "Team"
[config.account.overview]
confirmDelete = "Delete My Account"
@@ -3061,6 +3035,7 @@ warning = "⚠️ Warning: This action will generate new API keys and make your
[config.mcp]
description = "Model Context Protocol (MCP) lets AI assistants like Claude use your Stirling PDF tools directly. Connect a client once and your assistant can convert, edit, secure and process documents on your behalf."
guestInfo = "Guest users can't connect MCP clients. Create an account to use the MCP server and let your AI assistant run Stirling PDF tools on your behalf."
navLabel = "MCP Server"
tip = "Every action your assistant runs is performed as your account and counts toward your usage, just like using Stirling PDF directly."
title = "MCP Server"
@@ -3082,14 +3057,6 @@ addTo = "Add to"
hint = "Pick your client, paste the snippet into the file shown, then restart it. You'll sign in with your Stirling account on first use - no keys to copy."
title = "Connect your AI assistant"
[config.mcp.tools]
ai = "AI"
convert = "Convert"
misc = "Misc"
pages = "Pages"
security = "Security"
title = "What your assistant can do"
[config.overview]
description = "Current application settings and configuration details."
error = "Error"
@@ -3787,6 +3754,7 @@ selectFile = "Select file {{name}}"
shareDisabledHint = "File sharing isn't enabled on this server. Ask your admin to enable it."
shareManage = "Manage sharing"
showDetails = "Show details"
signInRequired = "Sign in to use cloud storage."
summary = "{{count}} items"
tree = "Folders"
upload = "Upload"
@@ -4750,6 +4718,34 @@ title = "Authentication Failed"
message = "You can close this window and return to Stirling PDF."
title = "Authentication Successful"
[oauthConsent]
approve = "Allow access"
approving = "Allowing..."
decisionFailed = "Could not submit your decision. Please try again."
deny = "Deny"
denying = "Denying..."
loadFailed = "This authorization request is invalid or has expired. Close this tab and try connecting again from the app."
loading = "Loading authorization request..."
missingId = "Missing authorization request. Start the connection from your app and try again."
redirecting = "Returning you to the app..."
requesting = "{{app}} wants to access your Stirling PDF account"
scopesIntro = "This will allow {{app}} to:"
signedInAs = "Signed in as {{name}}"
signInButton = "Sign in to continue"
signInPrompt = "Sign in to your Stirling PDF account to continue connecting the app."
title = "Authorize access"
unknownApp = "A third-party application"
[oauthConsent.access]
actAsYou = "Act as you - everything {{app}} does runs under your account and counts towards your usage"
tools = "Use your Stirling PDF tools on your behalf - convert, edit, sign, secure and process your documents"
[oauthConsent.scope]
email = "See your email address"
openid = "Confirm your identity"
phone = "See your phone number"
profile = "See your basic profile information"
[ocr]
desc = "Cleanup scans and detects text from images within a PDF and re-adds it as text."
tags = "recognition,text,image,scan,read,identify,detection,editable"
@@ -4867,15 +4863,6 @@ body = "Stirling works best as a desktop app. You can use it offline, access doc
title = "Download"
titleWithOs = "Download for {{osLabel}}"
[onboarding.freeTrial]
afterTrialWithoutPayment = "After your trial ends, you'll continue with our free tier. Add a payment method to keep Pro access."
afterTrialWithPayment = "Your Pro subscription will start automatically when the trial ends."
body = "You have full access to Stirling PDF Pro features during your trial. Enjoy unlimited conversions, larger file sizes, and priority processing."
daysRemaining = "{{days}} days remaining"
daysRemainingSingular = "{{days}} day remaining"
title = "Your 30-Day Pro Trial"
trialEnds = "Trial ends {{date}}"
[onboarding.mfa]
authenticationCode = "Authentication code"
qrCodeLoading = "Generating your QR code…"
@@ -4887,6 +4874,22 @@ 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.saas.freeEditor]
freeLine = "The editor is now <free>completely free</free>."
premium = "We've added loads of new features, including <strong>Policies</strong> and <strong>Agent Chat</strong>."
title = "Welcome to Stirling"
[onboarding.saas.team]
addButton = "Add"
createBody = "Work on documents together: teammates share files, automations and your plan. Add the first member by email to create your team."
createTitle = "Create your team"
inviteBody = "Everyone on your team shares files, automations and your plan. Add teammates by email and they'll get an invite."
inviteTitle = "Invite members to your team"
[onboarding.saas.usage]
body = "Automations, AI and API requests draw from your free allowance. Manual editing never counts against it."
title = "Your free Processor allowance"
[onboarding.securityCheck]
message = "The application has undergone significant changes recently. Your server admin's attention may be required. Please confirm your role to continue."
roleAdmin = "Admin"
@@ -5157,6 +5160,208 @@ title = "Page Ranges"
bullet1 = "<strong>all</strong> → selects all pages"
title = "Special Keywords"
[payg.activity]
docs = "docs"
empty = "No billable activity yet this period."
subtitle = "Only AI and automation draw from your budget. Everyday tools are free and aren't listed here."
title = "Recent billable activity"
[payg.cap]
amount = "Cap amount"
custom = "Custom"
docsEstimate = "≈ {{docs}} processed PDFs / month"
docsRate = "at {{rate}} / PDF"
noCapDesc = "Usage is billed without an upper limit. You can re-enable a cap at any time."
noCapLabel = "No cap"
noneShort = "No cap"
perMonth = "/ month"
save = "Update cap"
subtitle = "Set your maximum spend. We convert this to PDFs using the current price tier."
title = "Monthly spending cap"
[payg.checkout]
connecting = "Connecting to Stripe…"
errorTitle = "Stripe error"
[payg.checkout.error]
startFailed = "Couldn't start checkout session"
[payg.checkout.mock]
backend = "Backend is in mock mode; no real Stripe session was created."
continue = "Continue with mock subscription"
noKey = "VITE_STRIPE_PUBLISHABLE_KEY is unset. Real iframe mounts here once configured."
title = "Stripe Embedded Checkout (mock mode)"
[payg.confirm]
body = "Your team can now run automation, AI, and API operations beyond your 500 free PDFs."
capValue = "{{symbol}}{{amount}} / month"
noCap = "No cap"
note = "You can change your cap, cancel, or open the Stripe customer portal any time from this page."
summaryLabel = "Monthly ceiling"
title = "Welcome to the Processor plan"
[payg.docHelp]
billable = "Only automation runs, AI tools, and API calls count. Manual tools in the editor are always free."
chains = "Running the same file through several steps of one automation counts it once, not once per step."
perFile = "Each file you process counts as one PDF. Very long or very large files can count as more than one."
refunds = "If a job fails on its first step, the PDF is credited back automatically."
toggle = "What counts as a PDF?"
[payg.error]
body = "We couldn't reach the billing service. Refresh the page to try again."
title = "Couldn't load your plan"
[payg.free.cta]
benefit1Body = "chain tools, schedule runs, batch process"
benefit1Title = "Automation pipelines"
benefit2Body = "summarize, classify, redact, AI-OCR"
benefit2Title = "AI tools"
benefit3Body = "call any Stirling endpoint programmatically"
benefit3Title = "API access"
button = "Turn on Processor →"
reassurance = "No minimum · Set a $0 cap to test · Cancel anytime"
subtitle = "Keep going past your 500 free PDFs with automation, AI, and the API. Set a monthly ceiling, so you stay in control."
title = "Turn on the Processor plan"
[payg.free.editor]
eyebrow = "Editor plan · Always free"
[payg.free.header]
freeBody = "View, edit, merge, split, sign, watermark, compress, convert and manual OCR — as much as you want, no matter where you trigger it."
freeTitle = "Unlimited PDF editing"
[payg.free.hero]
capSuffix = "/ {{limit}} free PDFs"
metaCategories = "Automation · AI · API requests"
[payg.free.member]
ownerOnly = "Only your team owner can turn on Processor. Manual tools stay free for you to use as much as you like."
[payg.free.proc]
eyebrow = "Processor plan · metered"
[payg.free.state]
approachingLimit = "Approaching limit"
limitReached = "Limit reached"
plentyLeft = "Plenty left"
[payg.gates]
ai = "AI tools (AI Create, suggestions, AI-OCR)"
automation = "Automations & pipelines"
client = "Browser-only tools (viewer, page editor, file management)"
offsite = "Server tools (compress, OCR, convert, watermark…)"
pauses = "pauses at cap"
title = "What happens when the cap is reached"
[payg.header]
eyebrow = "Processor plan · {{start}} {{end}}"
freeBody = "View, edit, merge, split, sign, watermark, compress, convert and manual OCR — as much as you want, no matter where you trigger it."
freeLabel = "Always free"
freeTitle = "Unlimited PDF editing"
meterBody = "{{limit}} free PDFs to start, then billed per PDF up to your cap."
meterLabel = "Metered"
meterTitle = "Automation · AI · API"
[payg.member]
askLeader = "Only your team owner can change the cap."
[payg.members]
docs = "PDFs"
subtitle = "Billable PDFs each teammate has processed this period."
title = "Team member usage"
[payg.role]
leader = "Team owner"
member = "Member"
[payg.signupRequired]
body = "Stirling PDF gives every signed-up account 500 free operations, enough to keep most workflows humming without paying a cent. You're currently using Stirling as a guest, which doesn't include billable tools like AI, automations, or hosted processing."
cancel = "Not now"
cta = "Sign up free"
subtext = "Creating an account is free and takes a few seconds. No credit card required."
title = "Sign up to use {{category}}"
[payg.signupRequired.category]
ai = "AI features"
api = "this tool"
automation = "automations"
default = "this feature"
[payg.spendCapMeter]
capSuffix = "/ {{amount}} cap"
metaCategories = "Automation · AI · API spend"
resets = "Resets each billing period"
[payg.state]
degraded = "Cap reached"
full = "Healthy"
warned = "Approaching cap"
[payg.stripe]
open = "Open billing portal"
subtitle = "Receipts, invoices, payment method, billing currency."
title = "Manage billing in Stripe"
[payg.stripe.toast.unavailable]
body = "Billing portal isn't available right now. Try again in a moment."
title = "Billing portal unavailable"
[payg.upgrade]
backAria = "Back"
closeAria = "Close"
[payg.upgrade.button]
cancel = "Cancel"
continue = "Continue →"
finish = "Finish"
[payg.upgrade.cap]
help = "We'll never charge above this. Set $0 if you want to keep everything free while testing."
title = "Set your monthly spend ceiling"
usdNote = "Estimated in USD. You can adjust your cap any time after subscribing, in your own currency."
[payg.upgrade.checkout]
capValue = "{{symbol}}{{amount}} / month"
edit = "Edit"
help = "Stripe handles your card details. Stirling never sees them."
loading = "Loading checkout…"
noCap = "No cap"
title = "Add your payment method"
[payg.upgrade.help]
aiBody = "summarize, classify, redact, AI-OCR"
aiTitle = "AI tools"
apiBody = "programmatic access to any Stirling endpoint"
apiTitle = "API calls"
automationBody = "chained tools or scheduled runs that don't need clicks"
automationTitle = "Automation pipelines"
footnote = "Manual tools (viewing, editing, merging, splitting, watermarking, compressing, manual OCR) are always free, even past 500. The distinction is the type of work, not where you click."
title = "What we count toward billing"
[payg.upgrade.promise]
body = "You only pay for automation pipelines, AI tools, and API calls, the work that goes beyond a single click. Edit, merge, split, sign, compress as much as you want, no charge."
highlight = "Manual tools stay free, always."
[payg.upgrade.steps]
cap = "Set monthly ceiling"
payment = "Add payment method"
[payg.upgrade.title]
confirm = "You're subscribed"
default = "Upgrade to Processor plan"
[payg.usage]
breakdown = "AI {{ai}} • Automation {{automation}} • API {{api}}"
capLine = "{{cap}}/mo cap"
estBill = "≈ {{amount}} so far this period"
firstFree = "First {{free}} free"
noCap = "No monthly cap"
ofLimitProcessed = "/ {{limit}} PDFs processed"
processed = "PDFs processed"
resetsIn = "Resets in {{days}} days"
resetsTomorrow = "Resets tomorrow"
thisPeriod = "This billing period"
[payment]
autoClose = "This window will close automatically..."
canCloseWindow = "You can now close this window."
@@ -5480,28 +5685,17 @@ manage = "Manage"
perMonth = "/month"
perSeat = "/seat"
popular = "Popular"
purchase = "Purchase"
selectCredits = "Select Credit Amount"
selectPlan = "Select Plan"
showComparison = "Compare All Features"
totalCost = "Total Cost"
upgrade = "Upgrade"
withServer = "+ Server Plan"
[plan.activePlan]
subtitle = "Your current subscription details"
title = "Active Plan"
[plan.api]
large = "5,000 Credits"
medium = "1,000 Credits"
small = "500 Credits"
xsmall = "100 Credits"
[plan.apiPackages]
subtitle = "Purchase API credits for your applications"
title = "API Credit Packages"
[plan.availablePlans]
loadError = "Unable to load plan pricing. Using default values."
subtitle = "Choose the plan that fits your needs"
@@ -5553,6 +5747,12 @@ highlight3 = "Community support"
included = "Included"
name = "Free"
[plan.freeLimit]
cta = "View Processor Plan"
dismiss = "Maybe Later"
message = "That's your whole free allowance for automation, AI and the API. Seriously impressive! Keep the momentum going for just pennies a day."
title = "Woah, {{total}} PDFs Processed!"
[plan.highlights]
advancedIntegrations = "Advanced integrations"
allBasicFeatures = "All basic features"
@@ -5584,6 +5784,12 @@ highlight2 = "Advanced PDF tools"
highlight3 = "No watermarks"
name = "Pro"
[plan.spendCap]
cta = "View Spending Limit"
dismiss = "Not Now"
message = "You've made the most of this month's cap. That's a load of automation, AI and API work! Bump it up whenever you like to keep going."
title = "You're on a Roll!"
[plan.static]
activateLicense = "Activate Your License"
contactToUpgrade = "Contact us to upgrade or customize your plan"
@@ -5612,18 +5818,115 @@ successMessage = "Your license has been successfully activated. You can now clos
name = "Team"
[plan.trial]
badge = "Trial"
continueWithFree = "Continue with Free"
daysRemaining = "Your trial ends in {{days}} days"
endDate = "Expires: {{date}}"
expired = "Your Trial Has Ended"
expiredMessage = "Your 30-day Pro trial has expired. Subscribe to Pro to continue accessing premium features, or continue with our free tier."
freeTierLimitations = "Free tier includes basic PDF tools with usage limits."
message = ""
subscribe = "Subscribe to Pro"
subscribeToPro = "Subscribe to Pro"
subscriptionScheduled = "Subscription scheduled - starts {{date}}"
title = "Free Trial Active"
[policies]
deleteConfirmBody = "This removes the policy and its workflow. Documents already processed are not affected."
deleteConfirmTitle = "Delete {{label}} policy?"
[policies.catalog]
compliance = "Compliance"
ingestion = "Ingestion"
retention = "Retention"
routing = "Routing"
security = "Security"
[policies.detail]
editSettings = "Edit Settings"
enforces = "Enforces"
managedByOrg = "Managed by your organization. Contact a team leader to change this policy."
noActivityDescription = "Documents will appear here once this policy runs."
noActivityTitle = "No activity yet"
onEveryUpload = "On every upload"
originalsNote = "Originals stay untouched • Enforced version saved alongside"
pause = "Pause"
recentActivity = "Recent Activity"
resume = "Resume"
retry = "Retry"
showLess = "Show less"
showMore = "Show more"
statActive = "Active"
statDataProcessed = "Data processed"
statDocsEnforced = "Docs enforced"
statusActive = "Active"
statusPaused = "Paused"
[policies.fields]
selectedCount = "{{count}} selected"
[policies.pii]
fieldLabel = "PII to redact"
placeholder = "Select PII types"
[policies.sidebar]
activeCount = "{{count}} active"
infoAriaLabel = "What is a policy?"
infoTooltip = "A policy is a fixed set of tools that runs automatically whenever it's triggered — for example when a new document arrives — enforcing rules like redacting PII with no manual steps."
loading = "Loading…"
railAriaLabel = "{{label}} policy — {{status}}"
railSuffixActive = " (Active)"
railSuffixPaused = " (Paused)"
setUp = "Set up"
title = "Policies"
upgradeToEnterprise = "Upgrade to enterprise"
[policies.status]
active = "Active"
paused = "Paused"
setup = "Set up"
[policies.toolConfig]
enableAriaLabel = "Enable {{tool}}"
infoAriaLabel = "What does {{tool}} do?"
[policies.wizard]
allDocTypesDescription = "Enable the Classification policy to filter by document type."
allDocTypesTitle = "All document types"
back = "Back"
builderDesc = "Build the sequence of tools this policy runs on each document."
clear = "Clear"
continue = "Continue"
docTypesLabel = "Document types"
edit = "Edit"
editTitle = "Edit {{label}} Policy"
enablePolicy = "Enable Policy"
filenameAutoNumber = "Auto-number"
filenamePositionAria = "Filename position"
filenamePrefix = "Prefix"
filenameSuffix = "Suffix"
filenameTextAria = "Filename text"
filenameTextPlaceholder = "Text to add (optional)"
lockedDescription = "Contact a team leader to change this policy."
lockedTitle = "Managed by your organization"
maxRetriesLabel = "Max retries"
noToolsError = "Add at least one configured tool to the workflow first."
outputAsLabel = "Output as"
outputFilenameSubhead = "Output filename"
outputModeAria = "Output mode"
outputNewFile = "New file"
outputNewVersion = "New version"
outputRetriesLabel = "Output & retries"
outputSubhead = "Output"
retryDelayAria = "Retry delay minutes"
retryDelayLabel = "Retry delay (min)"
runOnExport = "Export"
runOnLabel = "Run on"
runOnSubhead = "Run on"
runOnUpload = "Upload"
saveChanges = "Save Changes"
saveError = "Couldn't save the policy. Please try again."
setupClassification = "Set up Classification"
setupTitle = "Set up {{label}} Policy"
sourcesDesc = "Choose where this policy runs and which document types it applies to."
sourcesLabel = "Sources"
stepOf = "Step {{step}} of {{total}}"
toolChainDesc = "Configure the tools this policy runs on each document."
typesSelected = "{{count}} types selected"
[printFile]
title = "Print File"
@@ -6568,6 +6871,18 @@ title = "Keyboard Shortcuts"
mac = "Include ⌘ (Command), ⌥ (Option), or another modifier in your shortcut."
windows = "Include Ctrl, Alt, or another modifier in your shortcut."
[settings.legal]
label = "Legal"
title = "Legal"
[settings.legal.cookiePreferences]
description = "Review or change your cookie consent choices."
manage = "Manage"
[settings.legal.documents]
description = "Policies and legal information for this service."
title = "Legal Documents"
[settings.licensingAnalytics]
audit = "Audit"
plan = "Plan"
@@ -7256,7 +7571,6 @@ updateButton = "Update on Server"
uploadButton = "Upload to Server"
[survey]
nav = "Survey"
title = "Stirling-PDF Survey"
[swagger]
@@ -7289,28 +7603,6 @@ removeError = "Failed to remove member"
renameError = "Failed to rename team"
renameSuccess = "Team renamed successfully"
[team.features]
badge = "Team Features"
subtitle = "Collaborate with your team"
title = "Team Collaboration"
viewPlans = "View Plans"
[team.features.billing]
description = "Manage billing and subscriptions"
title = "Billing Management"
[team.features.credits]
description = "Shared credit pool for all members"
title = "Shared Credits"
[team.features.dashboard]
description = "View team activity and usage"
title = "Team Dashboard"
[team.features.invite]
description = "Add members to your team"
title = "Invite Members"
[team.invitationBanner]
acceptButton = "Accept"
message = "has invited you to join"
@@ -7332,11 +7624,6 @@ remove = "Remove"
roleColumn = "Role"
title = "Team Members"
[team.upgrade]
button = "Upgrade to Team"
description = "Unlock team collaboration features"
title = "Upgrade to Team Plan"
[textAlign]
center = "Center"
left = "Left"
+5 -4
View File
@@ -17,8 +17,9 @@ import "@app/styles/index.css";
// Import file ID debugging helpers (development only)
import "@app/utils/fileIdSafety";
// Minimal providers for mobile scanner - no API calls, no authentication
function MobileScannerProviders({ children }: { children: React.ReactNode }) {
// Minimal providers for the public, no-auth mobile-scanner page - no API
// calls, no authentication
function PublicRouteProviders({ children }: { children: React.ReactNode }) {
return (
<PreferencesProvider>
<RainbowThemeProvider>{children}</RainbowThemeProvider>
@@ -34,9 +35,9 @@ export default function App() {
<Route
path="/mobile-scanner"
element={
<MobileScannerProviders>
<PublicRouteProviders>
<MobileScannerPage />
</MobileScannerProviders>
</PublicRouteProviders>
}
/>
@@ -14,6 +14,14 @@ export interface AuthContextType {
* should treat the resulting string as opaque display text.
*/
displayName: string | null;
/**
* Whether the current session is an anonymous / guest one. Each layer
* derives this from its own native user shape (Supabase `is_anonymous` in
* SaaS, the Spring anonymous flag in proprietary). Always `false` in core
* OSS, which has no auth context. Consumers use it to gate account-only
* actions (cloud folders, MCP) without reaching into a layer-specific user.
*/
isAnonymous: boolean;
loading: boolean;
error: Error | null;
signOut: () => Promise<void>;
@@ -29,6 +37,7 @@ export function useAuth(): AuthContextType {
session: null,
user: null,
displayName: null,
isAnonymous: false,
loading: false,
error: null,
signOut: async () => {},
@@ -1,43 +0,0 @@
/**
* Core stubs for the right-rail Agents UI.
*
* The real implementations live in {@code proprietary/components/agents/AgentsPanel.tsx}
* and shadow these stubs via the {@code @app/*} alias cascade when the proprietary
* build is active. Core builds render nothing, so the right rail collapses to the
* tool list unchanged.
*/
/** Whether the right rail should reserve space for agents UI. False in core. */
export function useAgentsEnabled(): boolean {
return false;
}
/**
* Whether the agent chat panel is currently open. Core builds have no chat,
* so this always returns false. Proprietary builds bridge to the ChatContext.
*/
export function useAgentChatOpen(): boolean {
return false;
}
/** Inline "Agents" section rendered above the tool list in {@code ToolPicker}. */
export function AgentsSection() {
return null;
}
/**
* Icon-only agent button rendered in the collapsed (minimised) right rail.
* Returns null in core; proprietary renders the Stirling agent shortcut.
*/
export function AgentsCollapsedButton(_props: { onExpand: () => void }) {
return null;
}
/**
* Agents card rendered inside the fullscreen tool picker. Matches the visual
* language of the fullscreen category cards (gradient border, title, items).
* Returns null in core; proprietary renders the Stirling agent.
*/
export function AgentsFullscreenSection() {
return null;
}
@@ -7,12 +7,9 @@
export function useChat() {
return {
messages: [] as never[],
isOpen: false,
isLoading: false,
progress: null,
progressLog: [] as never[],
toggleOpen: () => {},
setOpen: (_open: boolean) => {},
sendMessage: async (_content: string) => {},
cancelMessage: () => {},
clearChat: () => {},
@@ -17,7 +17,7 @@ import AddFileCard from "@app/components/fileEditor/AddFileCard";
import FilePickerModal from "@app/components/shared/FilePickerModal";
import { FileId, StirlingFile } from "@app/types/fileContext";
import { alert } from "@app/components/toast";
import { downloadFile } from "@app/services/downloadService";
import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
interface FileEditorProps {
@@ -256,6 +256,7 @@ const FileEditor = ({
data: file,
filename: file.name,
localPath: record.localFilePath,
fileId,
});
console.log("[FileEditor] Download complete, checking dirty state:", {
localFilePath: record.localFilePath,
@@ -36,7 +36,7 @@ import ToolChain from "@app/components/shared/ToolChain";
import HoverActionMenu, {
HoverAction,
} from "@app/components/shared/HoverActionMenu";
import { downloadFile } from "@app/services/downloadService";
import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy";
import { PrivateContent } from "@app/components/shared/PrivateContent";
import UploadToServerModal from "@app/components/shared/UploadToServerModal";
import ShareFileModal from "@app/components/shared/ShareFileModal";
@@ -248,6 +248,7 @@ const FileEditorThumbnail = ({
data: fileToSave,
filename: file.name,
localPath: file.localFilePath,
fileId: file.id,
});
if (!result.cancelled && result.savedPath) {
fileActions.updateStirlingFileStub(file.id, {
@@ -2,6 +2,7 @@ import React, { useCallback, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { ActionIcon, Button, Checkbox, Menu, Tooltip } from "@mantine/core";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
import FolderIcon from "@mui/icons-material/Folder";
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile";
@@ -17,6 +18,7 @@ import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder";
import { FileId } from "@app/types/file";
import { FolderId, FolderRecord, ROOT_FOLDER_ID } from "@app/types/folder";
import { useFolders } from "@app/contexts/FolderContext";
import { usePolicyFileBadges } from "@app/hooks/usePolicyFileBadges";
import { StirlingFileStub } from "@app/types/fileContext";
import { formatFileSize, getFileDate } from "@app/utils/fileUtils";
import {
@@ -572,6 +574,31 @@ function FolderCard({
);
}
/** Shield badges for the policies that have run on a file. */
function PolicyBadges({ fileId }: { fileId: string }) {
const badges = usePolicyFileBadges().get(fileId) ?? [];
if (badges.length === 0) return null;
return (
<span className="files-page-policy-badges" data-no-select>
{badges.slice(0, 3).map((policy) => (
<Tooltip
key={policy.id}
label={`${policy.name} policy ran on this file`}
withArrow
position="top"
>
<span
className="files-page-policy-badge"
style={{ color: policy.accentColor }}
>
<ShieldOutlinedIcon sx={{ fontSize: "0.7rem" }} />
</span>
</Tooltip>
))}
</span>
);
}
interface FileCardProps {
file: StirlingFileStub;
isSelected: boolean;
@@ -731,6 +758,7 @@ function FileCard({
<span>{fileSize}</span>
<span>·</span>
<span>{fileDate}</span>
<PolicyBadges fileId={file.id as string} />
</div>
</div>
<div className="files-page-card-actions">
@@ -1315,6 +1343,7 @@ function FileRow({
)}
</span>
<FileOriginBadge origin={getFileOrigin(file)} compact />
<PolicyBadges fileId={file.id as string} />
{isInWorkspace && (
<span className="files-page-row-open-pill">
<span className="files-page-card-open-dot" />
@@ -34,6 +34,8 @@ import CloudUploadIcon from "@mui/icons-material/CloudUpload";
import KeyboardArrowRightIcon from "@mui/icons-material/KeyboardArrowRight";
import RefreshIcon from "@mui/icons-material/Refresh";
import { stripBasePath } from "@app/constants/app";
import { useAuth } from "@app/auth/UseSession";
import { useSharingEnabled } from "@app/hooks/useSharingEnabled";
import { useFolders } from "@app/contexts/FolderContext";
import { useFileActions } from "@app/contexts/file/fileHooks";
@@ -106,17 +108,27 @@ export default function FileManagerView() {
const isMobile = useIsMobile();
const isMobileUploadAvailable =
Boolean(appConfig?.enableMobileScanner) && !isMobile;
// Guests (anonymous sessions) have no server-side storage, so every cloud
// action is account-only. Rather than let the click fire a guaranteed 401
// (which surfaced as an error toast), we disable the control and explain why
// on hover - the same affordance the storage-disabled / wrong-tab gates use.
const { isAnonymous } = useAuth();
const signInRequiredReason = isAnonymous
? t("filesPage.signInRequired", "Sign in to use cloud storage.")
: null;
// Server storage gate; mirrors ConfigController's storageEnabled
// (enableLogin && storage.isEnabled). When off, Save-to-server stays
// visible but disabled with an explanatory tooltip (discoverability beats
// hiding - mirrors the New folder / Manage sharing gates in this view).
const uploadEnabled = appConfig?.storageEnabled === true;
const saveToServerDisabledReason: string | null = uploadEnabled
? null
: t(
"filesPage.saveToServerDisabledHint",
"Saving to the server isn't enabled on this server. Ask your admin to enable it.",
);
const saveToServerDisabledReason: string | null =
signInRequiredReason ??
(uploadEnabled
? null
: t(
"filesPage.saveToServerDisabledHint",
"Saving to the server isn't enabled on this server. Ask your admin to enable it.",
));
const [mobileUploadModalOpen, setMobileUploadModalOpen] = useState(false);
const { actions: navActions } = useNavigationActions();
const { requestNavigation } = useNavigationGuard();
@@ -190,10 +202,11 @@ export default function FileManagerView() {
// Push folder selection into the URL while still on /files.
useEffect(() => {
if (!window.location.pathname.startsWith("/files")) return;
const stripped = stripBasePath(window.location.pathname);
if (!stripped.startsWith("/files")) return;
const target =
currentFolderId === null ? "/files" : `/files/${currentFolderId}`;
if (window.location.pathname !== target) {
if (stripped !== target) {
navigate(target, { replace: true });
}
}, [currentFolderId, navigate]);
@@ -803,6 +816,11 @@ export default function FileManagerView() {
// null = New folder actionable; string = disabled tooltip reason.
const newFolderDisabledReason: string | null = useMemo(() => {
// Guests can't use cloud folders at all - say so before any tab/storage
// hint, since switching tabs wouldn't help them.
if (signInRequiredReason) {
return signInRequiredReason;
}
if (currentTab === "local") {
return t(
"filesPage.localFoldersUnavailable",
@@ -826,7 +844,7 @@ export default function FileManagerView() {
);
}
return null;
}, [currentTab, folders.serverReachable, t]);
}, [signInRequiredReason, currentTab, folders.serverReachable, t]);
return (
<div className="files-page" ref={dropZoneRef}>
@@ -893,14 +911,17 @@ export default function FileManagerView() {
/>
<div className="files-page-header-actions">
<Tooltip
label={t("filesPage.refresh", "Refresh from server")}
label={
signInRequiredReason ??
t("filesPage.refresh", "Refresh from server")
}
withinPortal
>
<ActionIcon
variant="default"
size="md"
loading={refreshing}
disabled={refreshing}
disabled={refreshing || Boolean(signInRequiredReason)}
aria-busy={refreshing}
onClick={handleRefresh}
aria-label={t("filesPage.refresh", "Refresh from server")}
@@ -484,6 +484,24 @@
gap: 0.4rem;
}
/* Policy activity badges (a shield per policy that has run on the file). */
.files-page-policy-badges {
display: inline-flex;
align-items: center;
gap: 3px;
flex-shrink: 0;
}
.files-page-policy-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 15px;
height: 15px;
border-radius: 4px;
/* `color` set inline to the policy accent; tint follows it. */
background: color-mix(in srgb, currentColor 16%, transparent);
}
/* Parent-folder breadcrumb shown on cards/rows during recursive search so
the user can tell which folder each hit lives in without navigating. */
.files-page-card-path {
@@ -11,11 +11,11 @@ import {
import { isBaseWorkbench } from "@app/types/workbench";
import { VIEWER_SUPPORTED_EXTENSIONS } from "@app/utils/fileUtils";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useCookieConsent } from "@app/hooks/useCookieConsent";
import styles from "@app/components/layout/Workbench.module.css";
import WorkbenchBar from "@app/components/shared/WorkbenchBar";
import LandingPage from "@app/components/shared/LandingPage";
import Footer from "@app/components/shared/Footer";
import DismissAllErrorsButton from "@app/components/shared/DismissAllErrorsButton";
import { ChatFAB } from "@app/components/chat/ChatFAB";
@@ -37,6 +37,10 @@ export default function Workbench() {
const { isRainbowMode } = useRainbowThemeContext();
const { config } = useAppConfig();
// The consent banner used to be initialised by the footer; the legal links
// now live in Settings → Legal, so the workbench owns the banner lifecycle.
useCookieConsent({ analyticsEnabled: config?.enableAnalytics === true });
// Use context-based hooks to eliminate all prop drilling
const { selectors } = useFileState();
const { workbench: currentView } = useNavigationState();
@@ -252,15 +256,6 @@ export default function Workbench() {
{renderMainContent()}
</Suspense>
</Box>
<Footer
analyticsEnabled={config?.enableAnalytics === true}
termsAndConditions={config?.termsAndConditions}
privacyPolicy={config?.privacyPolicy}
cookiePolicy={config?.cookiePolicy}
impressum={config?.impressum}
accessibilityStatement={config?.accessibilityStatement}
/>
</Box>
);
}
@@ -34,8 +34,8 @@
}
.standaloneIcon {
width: 96px;
height: 96px;
width: 80px;
height: 80px;
object-fit: contain;
animation: heroLogoScale 0.25s ease forwards;
}
@@ -124,8 +124,6 @@
align-items: flex-start;
justify-content: center;
animation: heroLogoEnter 0.25s ease forwards;
position: relative;
top: 1rem;
}
.iconWrapper {
@@ -175,8 +173,8 @@
}
.downloadIcon {
width: 96px;
height: 96px;
width: 80px;
height: 80px;
object-fit: contain;
animation: heroLogoScale 0.25s ease forwards;
}
@@ -205,6 +203,7 @@
}
.bodyCopy {
text-align: center;
opacity: 0;
transform: translateX(24px);
animation: bodySlideIn 0.25s ease forwards;
@@ -1,7 +1,7 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { SlideConfig } from "@app/types/types";
import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
import { UNIFIED_LIGHT_BACKGROUND } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
import {
DesktopInstallTitle,
type OSOption,
@@ -47,9 +47,6 @@ export default function DesktopInstallSlide({
),
body: <DesktopInstallBody />,
downloadUrl: osUrl,
background: {
gradientStops: ["#2563EB", "#0EA5E9"],
circles: UNIFIED_CIRCLE_CONFIG,
},
background: UNIFIED_LIGHT_BACKGROUND,
};
}
@@ -1,4 +1,7 @@
import { AnimatedCircleConfig } from "@app/types/types";
import {
AnimatedCircleConfig,
AnimatedSlideBackgroundProps,
} from "@app/types/types";
/**
* Unified circle background configuration used across all onboarding slides.
@@ -27,3 +30,36 @@ export const UNIFIED_CIRCLE_CONFIG: AnimatedCircleConfig[] = [
offsetY: 18,
},
];
/**
* Build a light slide background in a slide-specific accent colour: a white
* hero fading into a pale horizon tint, with the unified sphere geometry
* glowing in the accent on each sphere's inner edge.
*/
export function createLightSlideBackground(
accentRgb: [number, number, number],
horizon: string,
): AnimatedSlideBackgroundProps {
const [r, g, b] = accentRgb;
const sphereColors = [
`linear-gradient(45deg, rgba(${r}, ${g}, ${b}, 0.04), rgba(${r}, ${g}, ${b}, 0.3))`,
`linear-gradient(225deg, rgba(${r}, ${g}, ${b}, 0.04), rgba(${r}, ${g}, ${b}, 0.26))`,
];
return {
gradientStops: ["#FFFFFF", horizon],
circles: UNIFIED_CIRCLE_CONFIG.map((circle, index) => ({
...circle,
color: sphereColors[index],
})),
tone: "light",
};
}
/**
* Default light slide background: white with a light blue horizon and
* blue-glow spheres.
*/
export const UNIFIED_LIGHT_BACKGROUND = createLightSlideBackground(
[37, 99, 235],
"#DBEAFE",
);
@@ -23,7 +23,7 @@ import { FileId } from "@app/types/file";
import { PrivateContent } from "@app/components/shared/PrivateContent";
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
import { useFileActionIcons } from "@app/hooks/useFileActionIcons";
import { downloadFile } from "@app/services/downloadService";
import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy";
interface FileItem {
id: FileId;
@@ -98,6 +98,7 @@ const FileThumbnail = ({
void downloadFile({
data: maybeFile,
filename: maybeFile.name || file.name || "download",
fileId: file.id,
});
return;
}
@@ -0,0 +1,38 @@
/**
* Core stubs for the right-rail Policies UI.
*
* The real implementations live in {@code proprietary/components/policies/PoliciesSidebar.tsx}
* and shadow these stubs via the {@code @app/*} alias cascade when the proprietary
* build is active. Core builds render nothing, so the right rail shows only the
* tool list unchanged.
*/
import type { ReactNode } from "react";
/** Whether the right rail should host the Policies section. False in core. */
export function usePoliciesEnabled(): boolean {
return false;
}
/**
* Whether a policy is open (its detail should take over the rail). Always false
* in core; proprietary bridges to the policy-selection store.
*/
export function usePolicyDetailActive(): boolean {
return false;
}
/** Collapsible policy list rendered above the Tools section. Null in core. */
export function PoliciesSection(_props: { leadingControl?: ReactNode } = {}) {
return null;
}
/** Open-policy detail/wizard/settings that replaces the tool area. Null in core. */
export function PolicyDetailTakeover() {
return null;
}
/** Collapsed-rail policy icons. Null in core; proprietary renders the rail. */
export function PoliciesCollapsedButton(_props: { onExpand: () => void }) {
return null;
}
@@ -0,0 +1,11 @@
/**
* Core stub for the policy auto-run controller.
*
* The real implementation lives in
* {@code proprietary/components/policies/PolicyAutoRunController.tsx} and shadows
* this stub via the {@code @app/*} alias cascade in the proprietary build. Core
* builds have no Policies feature, so this renders nothing and runs no policies.
*/
export function PolicyAutoRunController() {
return null;
}
@@ -12,6 +12,7 @@ import LocalIcon from "@app/components/shared/LocalIcon";
import { useConfigNavSections } from "@app/components/shared/config/configNavSections";
import { NavKey, VALID_NAV_KEYS } from "@app/components/shared/config/types";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { COOKIE_CONSENT_SCROLL_SHARD } from "@app/hooks/useCookieConsent";
import "@app/components/shared/AppConfigModal.css";
import { useIsMobile } from "@app/hooks/useIsMobile";
import {
@@ -24,6 +25,7 @@ import {
useUnsavedChanges,
} from "@app/contexts/UnsavedChangesContext";
import { SettingsSearchBar } from "@app/components/shared/config/SettingsSearchBar";
import { stripBasePath, withBasePath } from "@app/constants/app";
interface AppConfigModalProps {
opened: boolean;
@@ -96,13 +98,14 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
const switchSection = useCallback(
(key: NavKey) => {
setActive(key);
const alreadyInSettings =
window.location.pathname.startsWith("/settings");
const alreadyInSettings = stripBasePath(
window.location.pathname,
).startsWith("/settings");
if (alreadyInSettings) {
window.history.replaceState(
window.history.state,
"",
`/settings/${key}`,
withBasePath(`/settings/${key}`),
);
} else {
navigate(`/settings/${key}`);
@@ -214,6 +217,7 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
padding={0}
fullScreen={isMobile}
styles={{ content: { overflowY: "hidden", overscrollBehavior: "none" } }}
removeScrollProps={{ shards: [COOKIE_CONSENT_SCROLL_SHARD] }}
>
<div className="modal-container">
{/* Left navigation */}
@@ -396,6 +396,19 @@
justify-content: center;
flex-shrink: 0;
user-select: none;
overflow: hidden;
}
/* No colored disc behind an actual photo; keep it for the initials fallback. */
.file-sidebar-bottom-avatar--picture {
background-color: transparent;
}
.file-sidebar-bottom-avatar-img {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
}
.file-sidebar-bottom-name {
@@ -20,6 +20,7 @@ import {
import { useViewer } from "@app/contexts/ViewerContext";
import { useFileHandler } from "@app/hooks/useFileHandler";
import { useAuth } from "@app/auth/UseSession";
import { useProfilePictureUrl } from "@app/hooks/useProfilePictureUrl";
import {
useIndexedDB,
useIndexedDBRevision,
@@ -41,6 +42,7 @@ import type { FileId } from "@app/types/file";
import { FileItem } from "@app/components/shared/FileSidebarFileItem";
import { useFolderMembership } from "@app/hooks/useFolderMembership";
import { useAllWatchedFolders } from "@app/hooks/useAllWatchedFolders";
import { usePolicyFileBadges } from "@app/hooks/usePolicyFileBadges";
import {
setWatchedFolderDraggedFileIds,
clearWatchedFolderDraggedFileIds,
@@ -130,6 +132,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
// the workbench). The same map drives the per-file membership dots.
const folderMembership = useFolderMembership();
const allFolders = useAllWatchedFolders();
const policyFileBadges = usePolicyFileBadges();
const folderById = useMemo(
() => new Map(allFolders.map((f) => [f.id, f])),
[allFolders],
@@ -184,6 +187,11 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
const displayName =
authDisplayName ?? accountUsername ?? t("auth.displayName.user", "User");
const profilePictureUrl = useProfilePictureUrl();
const [pictureFailed, setPictureFailed] = useState(false);
useEffect(() => setPictureFailed(false), [profilePictureUrl]);
const showProfilePicture = !!profilePictureUrl && !pictureFailed;
useEffect(() => {
if (!config?.enableLogin) {
setAccountUsername(null);
@@ -886,6 +894,9 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
onDragStart={handleWatchedFolderDragStart}
folders={memberFolders}
onFolderClick={openWatchedFolder}
policies={
policyFileBadges.get(stub.id as string) ?? []
}
/>
);
})}
@@ -938,10 +949,21 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
style={onOpenSettings ? { cursor: "pointer" } : undefined}
>
<div
className="file-sidebar-bottom-avatar"
className={`file-sidebar-bottom-avatar${
showProfilePicture ? " file-sidebar-bottom-avatar--picture" : ""
}`}
aria-label={displayName}
>
{displayName.charAt(0).toUpperCase()}
{showProfilePicture ? (
<img
src={profilePictureUrl}
alt=""
className="file-sidebar-bottom-avatar-img"
onError={() => setPictureFailed(true)}
/>
) : (
displayName.charAt(0).toUpperCase()
)}
</div>
{!collapsed && (
<span className="file-sidebar-bottom-name sidebar-content-fade">
@@ -36,6 +36,32 @@
background-color: rgba(59, 130, 246, 0.06);
}
/* A policy-enforced file glows in that policy's accent colour so it's obvious
the file has had a policy applied: it pulses a few times to catch the eye and
then fades out (no lingering glow). */
.file-sidebar-file-item.policy-enforced {
animation: policy-glow-fade 4.5s ease-in-out forwards;
}
@keyframes policy-glow-fade {
0%,
24%,
48% {
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--policy-glow) 30%, transparent),
0 0 6px -2px var(--policy-glow);
}
12%,
36%,
60% {
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--policy-glow) 75%, transparent),
0 0 16px 0 var(--policy-glow);
}
100% {
box-shadow: 0 0 0 0 transparent;
}
}
.file-sidebar-file-item.selected {
background-color: rgba(59, 130, 246, 0.12);
}
@@ -134,6 +160,25 @@
text-overflow: ellipsis;
}
/* ---- Policy activity badges (a shield per policy that has run on the file) ---- */
.file-sidebar-policy-badges {
display: inline-flex;
align-items: center;
gap: 3px;
flex-shrink: 0;
}
.file-sidebar-policy-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 15px;
height: 15px;
border-radius: 4px;
/* `color` is set inline to the policy's accent; the tint follows it. */
color: var(--text-secondary);
background: color-mix(in srgb, currentColor 16%, transparent);
}
/* ---- Folder membership tags ---- */
.file-sidebar-folder-tags {
display: flex;
@@ -4,6 +4,7 @@ import { Tooltip } from "@mantine/core";
import { useTranslation } from "react-i18next";
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
import VisibilityOffOutlinedIcon from "@mui/icons-material/VisibilityOffOutlined";
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
import type { FileId } from "@app/types/file";
import { FileDocIcon } from "@app/components/shared/FileDocIcon";
import { getFileDocVariant } from "@app/components/shared/filePreview/getFileTypeIcon";
@@ -125,6 +126,17 @@ export interface FileItemFolderRef {
accentColor: string;
}
/** A policy that has run on this file, used for the activity badges. */
export interface FileItemPolicyRef {
id: string;
name: string;
/** CSS colour for the badge (matches the policy's accent). */
accentColor: string;
/** True only just after the policy was applied — drives the one-off glow, so
* it doesn't replay on every reload of an already-enforced file. */
recent: boolean;
}
export interface FileItemProps {
fileId: FileId;
name: string;
@@ -143,9 +155,12 @@ export interface FileItemProps {
folders?: FileItemFolderRef[];
/** Clicking a membership dot opens that folder. */
onFolderClick?: (folderId: string) => void;
/** Policies that have run on this file — rendered as small shield badges. */
policies?: FileItemPolicyRef[];
}
const MAX_VISIBLE_FOLDER_TAGS = 2;
const MAX_VISIBLE_POLICY_BADGES = 3;
export function FileItem({
fileId,
@@ -162,6 +177,7 @@ export function FileItem({
onDragStart,
folders = [],
onFolderClick,
policies = [],
}: FileItemProps) {
const { t } = useTranslation();
const ext = getFileExtension(name);
@@ -188,6 +204,9 @@ export function FileItem({
const handleMouseLeave = useCallback(() => setHoverRect(null), []);
// A just-applied policy (recent run) drives the one-off row glow.
const recentPolicy = policies.find((p) => p.recent);
// Reactive: tooltip appears as soon as both hover rect and thumbnail are ready
const thumbPos =
hoverRect && resolvedThumbnail
@@ -201,7 +220,14 @@ export function FileItem({
<>
<div
ref={itemRef}
className={`file-sidebar-file-item${isSelected ? " selected" : ""}${isActive ? " active" : ""}${isViewedInViewer ? " viewed" : ""}`}
className={`file-sidebar-file-item${isSelected ? " selected" : ""}${isActive ? " active" : ""}${isViewedInViewer ? " viewed" : ""}${recentPolicy ? " policy-enforced" : ""}`}
style={
recentPolicy
? ({
"--policy-glow": recentPolicy.accentColor,
} as React.CSSProperties)
: undefined
}
onClick={() => onClick(fileId)}
draggable={draggable}
onDragStart={
@@ -237,6 +263,25 @@ export function FileItem({
{dateLabel && typeLabel ? " · " : ""}
{typeLabel}
</span>
{policies.length > 0 && (
<span className="file-sidebar-policy-badges" data-no-select>
{policies.slice(0, MAX_VISIBLE_POLICY_BADGES).map((policy) => (
<Tooltip
key={policy.id}
label={`${policy.name} policy ran on this file`}
withArrow
position="top"
>
<span
className="file-sidebar-policy-badge"
style={{ color: policy.accentColor }}
>
<ShieldOutlinedIcon sx={{ fontSize: "0.7rem" }} />
</span>
</Tooltip>
))}
</span>
)}
</span>
{folders.length > 0 && (
<span className="file-sidebar-folder-tags" data-no-select>
@@ -77,15 +77,6 @@ export default function Footer({
color: forceLightMode ? "#495057" : undefined,
}}
>
<a
className="footer-link px-3"
id="survey"
target="_blank"
rel="noopener noreferrer"
href="https://stirlingpdf.info/s/cm28y3niq000o56dv7liv8wsu"
>
{t("survey.nav", "Survey")}
</a>
<a
className="footer-link px-3"
target="_blank"
@@ -8,7 +8,8 @@ import ErrorRoundedIcon from "@mui/icons-material/ErrorRounded";
import CheckRoundedIcon from "@mui/icons-material/CheckRounded";
import WarningRoundedIcon from "@mui/icons-material/WarningRounded";
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from "@app/styles/zIndex";
import { withBasePath } from "@app/constants/app";
import { BASE_PATH } from "@app/constants/app";
import { buildMobileScannerUrl } from "@app/utils/mobileScannerUrl";
import { convertImageToPdf, isImageFile } from "@app/utils/imageToPdfUtils";
import apiClient from "@app/services/apiClient";
@@ -83,11 +84,16 @@ export default function MobileUploadModal({
const timerIntervalRef = useRef<number | null>(null);
const processedFiles = useRef<Set<string>>(new Set());
// Use configured frontendUrl if set, otherwise use current origin
// Combine with base path and mobile-scanner route
const baseUrl = localStorage.getItem("server_url") || "";
const frontendUrl = baseUrl || config?.frontendUrl || window.location.origin;
const mobileUrl = `${frontendUrl}${withBasePath("/mobile-scanner")}?session=${sessionId}`;
// Build the QR-code URL the phone opens. It must land on the public
// /mobile-scanner route under the app's base path, otherwise the phone hits
// the auth-gated catch-all route and is bounced to the login page.
const mobileUrl = buildMobileScannerUrl({
configuredUrl:
localStorage.getItem("server_url") || config?.frontendUrl || "",
sessionId,
origin: window.location.origin,
basePath: BASE_PATH,
});
// Create session on backend
const createSession = useCallback(
@@ -1,4 +1,4 @@
import { createContext, useContext, ReactNode } from "react";
import { createContext, useContext, useEffect, ReactNode } from "react";
import { MantineProvider } from "@mantine/core";
import { useRainbowTheme } from "@app/hooks/useRainbowTheme";
import { mantineTheme } from "@app/theme/mantineTheme";
@@ -7,6 +7,10 @@ import { ToastProvider } from "@app/components/toast";
import ToastRenderer from "@app/components/toast/ToastRenderer";
import { ToastPortalBinder } from "@app/components/toast";
import type { ThemeMode } from "@app/constants/theme";
// SUI shared design-system tokens (used by @shared/components). Additive — its
// var names don't clash with the editor's own theme.css. The effect below
// bridges Mantine's color scheme to the `data-theme` attribute SUI keys on.
import "@shared/tokens/tokens.css";
interface RainbowThemeContextType {
themeMode: ThemeMode;
@@ -40,6 +44,16 @@ export function RainbowThemeProvider({ children }: RainbowThemeProviderProps) {
const mantineColorScheme =
rainbowTheme.themeMode === "rainbow" ? "dark" : rainbowTheme.themeMode;
// Bridge the resolved scheme to the `data-theme` attribute SUI's tokens.css
// keys its dark palette on (the editor otherwise only sets Mantine's
// data-mantine-color-scheme), so @shared/components theme correctly.
useEffect(() => {
document.documentElement.setAttribute(
"data-theme",
mantineColorScheme === "dark" ? "dark" : "light",
);
}, [mantineColorScheme]);
return (
<RainbowThemeContext.Provider value={rainbowTheme}>
<MantineProvider
@@ -29,7 +29,7 @@ import { ViewerContext, useViewer } from "@app/contexts/ViewerContext";
import { WorkbenchType, isBaseWorkbench } from "@app/types/workbench";
import { Tooltip } from "@app/components/shared/Tooltip";
import LocalIcon from "@app/components/shared/LocalIcon";
import { downloadFile } from "@app/services/downloadService";
import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy";
import {
WorkbenchBarButtonConfig,
WorkbenchBarRenderContext,
@@ -150,6 +150,7 @@ export default function WorkbenchBar({
data: new Blob([buffer], { type: "application/pdf" }),
filename: fileToExport.name,
localPath: forceNewFile ? undefined : stub?.localFilePath,
fileId: stub?.id,
});
if (!forceNewFile && !result.cancelled && stub && result.savedPath) {
fileActions.updateStirlingFileStub(stub.id, {
@@ -179,6 +180,7 @@ export default function WorkbenchBar({
data: file,
filename: file.name,
localPath: forceNewFile ? undefined : stub?.localFilePath,
fileId: stub?.id,
});
if (result.cancelled) continue;
if (!forceNewFile && stub && result.savedPath) {
@@ -4,6 +4,7 @@ import { NavKey } from "@app/components/shared/config/types";
import HotkeysSection from "@app/components/shared/config/configSections/HotkeysSection";
import GeneralSection from "@app/components/shared/config/configSections/GeneralSection";
import HelpSection from "@app/components/shared/config/configSections/HelpSection";
import LegalSection from "@app/components/shared/config/configSections/LegalSection";
export interface ConfigNavItem {
key: NavKey;
@@ -70,6 +71,17 @@ export const useConfigNavSections = (
},
],
},
{
title: t("settings.legal.title", "Legal"),
items: [
{
key: "legal",
label: t("settings.legal.label", "Legal"),
icon: "gavel-rounded",
component: <LegalSection />,
},
],
},
];
return sections;
@@ -0,0 +1,143 @@
import React from "react";
import { Anchor, Button, Group, Paper, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useFooterInfo } from "@app/hooks/useFooterInfo";
import { useCookieConsent } from "@app/hooks/useCookieConsent";
interface LegalLink {
key: string;
label: string;
href: string;
}
const LegalSection: React.FC = () => {
const { t } = useTranslation();
const { config } = useAppConfig();
const { footerInfo } = useFooterInfo();
const analyticsEnabled =
config?.enableAnalytics ?? footerInfo?.analyticsEnabled ?? false;
const privacyPolicy = config?.privacyPolicy ?? footerInfo?.privacyPolicy;
const termsAndConditions =
config?.termsAndConditions ?? footerInfo?.termsAndConditions;
const accessibilityStatement =
config?.accessibilityStatement ?? footerInfo?.accessibilityStatement;
const cookiePolicy = config?.cookiePolicy ?? footerInfo?.cookiePolicy;
const impressum = config?.impressum ?? footerInfo?.impressum;
const { showCookiePreferences } = useCookieConsent({
analyticsEnabled: analyticsEnabled === true,
});
const isValidLink = (link?: string) => link && link.trim().length > 0;
const legalLinks: LegalLink[] = [
{
key: "privacy",
label: t("legal.privacy", "Privacy Policy"),
href: isValidLink(privacyPolicy)
? privacyPolicy!
: "https://www.stirling.com/privacy",
},
{
key: "terms",
label: t("legal.terms", "Terms and Conditions"),
href: isValidLink(termsAndConditions)
? termsAndConditions!
: "https://www.stirling.com/terms",
},
...(isValidLink(accessibilityStatement)
? [
{
key: "accessibility",
label: t("legal.accessibility", "Accessibility"),
href: accessibilityStatement!,
},
]
: []),
...(isValidLink(cookiePolicy)
? [
{
key: "cookie",
label: t("legal.cookie", "Cookie Policy"),
href: cookiePolicy!,
},
]
: []),
...(isValidLink(impressum)
? [
{
key: "impressum",
label: t("legal.impressum", "Impressum"),
href: impressum!,
},
]
: []),
];
const renderLink = (link: LegalLink) => (
<Anchor
key={link.key}
href={link.href}
target="_blank"
rel="noopener noreferrer"
size="sm"
>
<Group gap={6} wrap="nowrap">
{link.label}
<LocalIcon icon="open-in-new-rounded" width="0.9rem" height="0.9rem" />
</Group>
</Anchor>
);
return (
<Stack gap="lg">
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<div>
<Text fw={600} size="sm">
{t("settings.legal.documents.title", "Legal Documents")}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
"settings.legal.documents.description",
"Policies and legal information for this service.",
)}
</Text>
</div>
<Stack gap="sm">{legalLinks.map(renderLink)}</Stack>
</Stack>
</Paper>
{analyticsEnabled === true && (
<Paper withBorder p="md" radius="md">
<Group justify="space-between" align="center">
<div>
<Text fw={600} size="sm">
{t("legal.showCookieBanner", "Cookie Preferences")}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
"settings.legal.cookiePreferences.description",
"Review or change your cookie consent choices.",
)}
</Text>
</div>
<Button
variant="default"
size="sm"
id="cookieBanner"
onClick={showCookiePreferences}
>
{t("settings.legal.cookiePreferences.manage", "Manage")}
</Button>
</Group>
</Paper>
)}
</Stack>
);
};
export default LegalSection;
@@ -31,6 +31,8 @@ export const VALID_NAV_KEYS = [
"adminStorageSharing",
"adminMcp",
"help",
"legal",
"payg",
] as const;
// Derive the type from the array
@@ -51,6 +51,27 @@
pointer-events: auto;
}
/* A toast tied to a policy pulses a glow in that policy's accent (var set
inline as --toast-glow), so it's obvious which policy is being applied. */
.toast-item--glow {
animation: toast-glow-pulse 1.8s ease-in-out infinite;
}
@keyframes toast-glow-pulse {
0%,
100% {
box-shadow:
var(--shadow-lg),
0 0 0 1px color-mix(in srgb, var(--toast-glow) 35%, transparent),
0 0 10px -2px var(--toast-glow);
}
50% {
box-shadow:
var(--shadow-lg),
0 0 0 1px color-mix(in srgb, var(--toast-glow) 80%, transparent),
0 0 20px 0 var(--toast-glow);
}
}
/* Toast Alert Type Colors */
.toast-item--success {
background: var(--color-green-100);
@@ -1,3 +1,4 @@
import type { CSSProperties } from "react";
import { useTranslation } from "react-i18next";
import { useToast } from "@app/components/toast/ToastContext";
import { ToastInstance, ToastLocation } from "@app/components/toast/types";
@@ -64,7 +65,16 @@ export default function ToastRenderer() {
<div key={loc} className={`toast-container ${locationToClass[loc]}`}>
{grouped[loc].map((t) => {
return (
<div key={t.id} role="status" className={getToastItemClass(t)}>
<div
key={t.id}
role="status"
className={`${getToastItemClass(t)}${t.glowColor ? " toast-item--glow" : ""}`}
style={
t.glowColor
? ({ "--toast-glow": t.glowColor } as CSSProperties)
: undefined
}
>
{/* Top row: Icon + Title + Controls */}
<div className="toast-header">
{/* Icon */}
@@ -25,6 +25,10 @@ export interface ToastOptions {
id?: string;
/** If true, show chevron and collapse/expand animation. Defaults to true. */
expandable?: boolean;
/** Optional accent colour (any CSS colour, e.g. a `var(--color-)`). When set,
* the toast pulses a glow in this colour used to tie a toast to a policy's
* accent. */
glowColor?: string;
}
export interface ToastInstance extends Omit<
@@ -3,11 +3,6 @@ import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { useIsMobile } from "@app/hooks/useIsMobile";
import { usePreferences } from "@app/contexts/PreferencesContext";
import { useWorkbenchBar } from "@app/contexts/WorkbenchBarContext";
import {
AgentsFullscreenSection,
useAgentChatOpen,
useAgentsEnabled,
} from "@app/components/agents/AgentsPanel";
import FullscreenToolSurface from "@app/components/tools/FullscreenToolSurface";
import { ToolId } from "@app/types/toolId";
import type { ToolPanelGeometry } from "@app/hooks/tools/useToolPanelGeometry";
@@ -16,13 +11,11 @@ import type { ToolPanelGeometry } from "@app/hooks/tools/useToolPanelGeometry";
export function useIsFullscreenExpanded(): boolean {
const { toolPanelMode, leftPanelView, readerMode } = useToolWorkflow();
const isMobile = useIsMobile();
const agentChatOpen = useAgentChatOpen();
return (
toolPanelMode === "fullscreen" &&
leftPanelView === "toolPicker" &&
!isMobile &&
!readerMode &&
!agentChatOpen
!readerMode
);
}
@@ -50,8 +43,6 @@ export function FullscreenToolPanel({ geometry }: FullscreenToolPanelProps) {
handleToolSelect,
} = useToolWorkflow();
const isMobile = useIsMobile();
const agentsEnabled = useAgentsEnabled();
const agentChatOpen = useAgentChatOpen();
const { setAllButtonsDisabled } = useWorkbenchBar();
const { preferences, updatePreference } = usePreferences();
@@ -59,8 +50,7 @@ export function FullscreenToolPanel({ geometry }: FullscreenToolPanelProps) {
toolPanelMode === "fullscreen" &&
leftPanelView === "toolPicker" &&
!isMobile &&
!readerMode &&
!agentChatOpen;
!readerMode;
useEffect(() => {
setAllButtonsDisabled(fullscreenExpanded);
@@ -94,9 +84,6 @@ export function FullscreenToolPanel({ geometry }: FullscreenToolPanelProps) {
}
onExitFullscreenMode={() => setToolPanelMode("sidebar")}
geometry={geometry}
agentsSlot={
agentsEnabled && !searchQuery ? <AgentsFullscreenSection /> : null
}
/>
);
}
@@ -25,8 +25,6 @@ interface FullscreenToolSurfaceProps {
onToggleDescriptions: () => void;
onExitFullscreenMode: () => void;
geometry: ToolPanelGeometry | null;
/** Optional agents block rendered above the tool list. */
agentsSlot?: React.ReactNode;
}
const FullscreenToolSurface = ({
@@ -41,7 +39,6 @@ const FullscreenToolSurface = ({
onToggleDescriptions,
onExitFullscreenMode: _onExitFullscreenMode,
geometry,
agentsSlot,
}: FullscreenToolSurfaceProps) => {
const { t } = useTranslation();
const surfaceRef = useRef<HTMLDivElement>(null);
@@ -92,9 +89,6 @@ const FullscreenToolSurface = ({
className="tool-panel__fullscreen-scroll"
offsetScrollbars
>
{agentsSlot && (
<div className="tool-panel__fullscreen-agents">{agentsSlot}</div>
)}
<FullscreenToolList
filteredTools={filteredTools}
searchQuery={searchQuery}
@@ -1,4 +1,4 @@
import { useMemo, useState, useRef, useCallback } from "react";
import { useMemo, useState } from "react";
import { ActionIcon } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider";
@@ -9,12 +9,13 @@ import { useIsMobile } from "@app/hooks/useIsMobile";
import ToolPanel from "@app/components/tools/ToolPanel";
import ToolSearch from "@app/components/tools/toolPicker/ToolSearch";
import {
AgentsCollapsedButton,
AgentsSection,
useAgentsEnabled,
} from "@app/components/agents/AgentsPanel";
import { useChat } from "@app/components/chat/ChatContext";
import { ChatPanel } from "@app/components/chat/ChatPanel";
PoliciesCollapsedButton,
PoliciesSection,
PolicyDetailTakeover,
usePoliciesEnabled,
usePolicyDetailActive,
} from "@app/components/policies/PoliciesSidebar";
import { PolicyAutoRunController } from "@app/components/policies/PolicyAutoRunController";
import { useFavoriteToolItems } from "@app/hooks/tools/useFavoriteToolItems";
import { useToolSections } from "@app/hooks/useToolSections";
import type { SubcategoryGroup } from "@app/hooks/useToolSections";
@@ -33,16 +34,12 @@ import {
import { useToolPanelGeometry } from "@app/hooks/tools/useToolPanelGeometry";
import "@app/components/tools/ToolPanel.css";
const DEFAULT_CHAT_WIDTH_PX = 18.5 * 16; // 18.5rem in px
const MIN_CHAT_WIDTH_PX = 240;
const MAX_CHAT_WIDTH_PX = 720;
/**
* Right-side rail wrapping the tool panel.
*
* Owns the rail-level concerns: collapse/expand chrome, the AGENTS top label,
* the collapsed strip (agent button + favourite/recommended icon shortcuts),
* and the agents chat overlay. Fullscreen takeover lives in FullscreenToolPanel.
* Owns the rail-level concerns: collapse/expand chrome and the collapsed strip
* (favourite/recommended icon shortcuts). Fullscreen takeover lives in
* FullscreenToolPanel.
*/
export default function RightSidebar() {
const { t } = useTranslation();
@@ -69,7 +66,8 @@ export default function RightSidebar() {
favoriteTools,
} = useToolWorkflow();
const agentsEnabled = useAgentsEnabled();
const policiesEnabled = usePoliciesEnabled();
const rawPolicyDetailActive = usePolicyDetailActive();
const fullscreenExpanded = useIsFullscreenExpanded();
const fullscreenGeometry = useToolPanelGeometry({
enabled: fullscreenExpanded,
@@ -91,57 +89,6 @@ export default function RightSidebar() {
const [allToolsView, setAllToolsView] = useState(false);
const { isOpen: isChatOpen, setOpen: setChatOpen } = useChat();
const [chatWidthPx, setChatWidthPx] = useState(DEFAULT_CHAT_WIDTH_PX);
const [isChatDragging, setIsChatDragging] = useState(false);
const chatDragState = useRef<{ startX: number; startWidth: number } | null>(
null,
);
const handleChatClose = useCallback(() => {
withViewTransition(() => setChatOpen(false));
setChatWidthPx(DEFAULT_CHAT_WIDTH_PX);
}, [setChatOpen]);
const handleResizeChatPointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
e.preventDefault();
chatDragState.current = { startX: e.clientX, startWidth: chatWidthPx };
setIsChatDragging(true);
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
const onMove = (ev: PointerEvent) => {
if (!chatDragState.current) return;
const delta = chatDragState.current.startX - ev.clientX;
setChatWidthPx(
Math.max(
MIN_CHAT_WIDTH_PX,
Math.min(
MAX_CHAT_WIDTH_PX,
chatDragState.current.startWidth + delta,
),
),
);
};
const cleanup = () => {
chatDragState.current = null;
setIsChatDragging(false);
document.body.style.removeProperty("cursor");
document.body.style.removeProperty("user-select");
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", cleanup);
window.removeEventListener("pointercancel", cleanup);
};
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", cleanup);
window.addEventListener("pointercancel", cleanup);
},
[chatWidthPx],
);
const handleShowAllTools = () => {
withViewTransition(() => setAllToolsView(true));
};
@@ -153,15 +100,34 @@ export default function RightSidebar() {
});
};
// Opening a policy (e.g. from the collapsed rail) lands the rail in the clean
// default tool-picker view — the only view the policy takeover renders in — so
// it never collides with an open tool or the all-tools/search view.
const handleOpenPolicy = () => {
withViewTransition(() => {
if (readerMode) setReaderMode(false);
setLeftPanelView("toolPicker");
if (!sidebarsVisible) setSidebarsVisible(true);
setAllToolsView(false);
setSearchQuery("");
});
};
// The header shows [back] [search] when we have somewhere to go back to —
// i.e. the user is in a specific tool, or already in the all-tools/search view.
const inToolView = leftPanelView !== "toolPicker";
// Show X (close) button only when there's somewhere to go back to.
const showCloseButton = inToolView || allToolsView;
// Show search input whenever there's a close button, or when agents are off and
// we're in the default tool-picker view (search filters the full list inline).
// Policies sit above the tool list in the default tool-picker view.
const showPolicies =
policiesEnabled && !allToolsView && leftPanelView === "toolPicker";
// When Policies are shown, the search moves OUT of the header to sit between
// the Policies and Tools sections (separating them); otherwise it stays in the
// header. Show the header search when there's a close button, or in the
// default tool-picker view.
const showInlineSearch = showPolicies && !showCloseButton;
const showHeaderSearch =
showCloseButton || (!agentsEnabled && leftPanelView === "toolPicker");
!showInlineSearch && (showCloseButton || leftPanelView === "toolPicker");
const handleHeaderBack = () => {
if (inToolView) {
@@ -194,18 +160,20 @@ export default function RightSidebar() {
? (toolRegistry[selectedToolKey as ToolId] ?? null)
: null;
// Agents header + section is hidden when:
// - the AI engine is off, or
// - the user is in the all-tools view, or
// - a specific tool is being rendered (leftPanelView ≠ "toolPicker").
const showAgents =
agentsEnabled && !allToolsView && leftPanelView === "toolPicker";
// The detail takeover replaces the tool list ONLY in the same default view —
// never over an open tool or the all-tools view (which must keep priority).
// A lingering selection is harmless: it stays hidden behind a tool and the
// list/takeover reappears on return to the picker (as in the prototype).
const policyDetailActive = rawPolicyDetailActive && showPolicies;
// The rail widens when a policy detail takes it over — the tool list is fine
// at 18.5rem, but the policy detail/wizard/settings need more breathing room.
const expandedWidth = policyDetailActive ? "25rem" : "18.5rem";
const computedWidth = () => {
if (isMobile) return "100%";
if (isChatOpen) return `${chatWidthPx}px`;
if (!isPanelVisible) return "3.5rem";
return "18.5rem";
return expandedWidth;
};
// Collapsed rail: show favourites + recommended tools as icons.
@@ -239,39 +207,17 @@ export default function RightSidebar() {
ref={toolPanelRef}
data-sidebar="tool-panel"
data-tour={fullscreenExpanded ? undefined : "tool-panel"}
className={`tool-panel flex flex-col ${fullscreenExpanded ? "tool-panel--fullscreen-active" : isChatOpen ? "" : "overflow-hidden"} bg-[var(--bg-toolbar)] border-l border-[var(--border-subtle)] transition-all duration-300 ease-out ${
className={`tool-panel flex flex-col ${fullscreenExpanded ? "tool-panel--fullscreen-active" : "overflow-hidden"} bg-[var(--bg-toolbar)] border-l border-[var(--border-subtle)] transition-all duration-300 ease-out ${
isRainbowMode ? rainbowStyles.rainbowPaper : ""
} ${isMobile ? "h-full border-r-0" : "h-screen"} ${fullscreenExpanded ? "tool-panel--fullscreen" : ""}`}
style={{
width: computedWidth(),
padding: "0",
...(isChatDragging ? { transition: "none" } : {}),
}}
>
{!fullscreenExpanded && isChatOpen && (
<div
style={{
height: "100%",
width: "100%",
display: "flex",
flexDirection: "column",
}}
>
<div
className="agents-takeover__resize-handle"
onPointerDown={handleResizeChatPointerDown}
role="separator"
aria-label={t("chat.resize", "Resize chat panel")}
aria-orientation="vertical"
/>
<ChatPanel
onBack={handleChatClose}
backLabel={t("agents.back_to_tools", "Back to tools")}
/>
</div>
)}
{!fullscreenExpanded && !isChatOpen && !isPanelVisible && !isMobile && (
{/* Headless: enforces enabled policies on every uploaded file. */}
{policiesEnabled && <PolicyAutoRunController />}
{!fullscreenExpanded && !isPanelVisible && !isMobile && (
<div className="tool-panel__collapsed-strip">
<div className="tool-panel__collapsed-top">
<ActionIcon
@@ -279,15 +225,15 @@ export default function RightSidebar() {
color="gray.4"
radius="xl"
size="md"
className="tool-panel__expand-btn"
className="tool-panel__expand-btn tool-panel__toggle-vt"
onClick={handleExpand}
aria-label={t("toolPanel.expand", "Expand panel")}
>
<ChevronLeftIcon sx={{ fontSize: "1.1rem" }} />
</ActionIcon>
<AgentsCollapsedButton onExpand={handleExpand} />
</div>
<div className="tool-panel__collapsed-divider" />
<PoliciesCollapsedButton onExpand={handleOpenPolicy} />
<div className="tool-panel__collapsed-tools">
{collapsedRailItems.map(({ id, tool }) => (
<AppTooltip
@@ -315,7 +261,7 @@ export default function RightSidebar() {
</div>
)}
{!fullscreenExpanded && !isChatOpen && isPanelVisible && (
{!fullscreenExpanded && isPanelVisible && (
<div
/* Fixed width matches the expanded panel width so the inner content is
laid out at its final size from the moment it mounts. The outer
@@ -326,84 +272,114 @@ export default function RightSidebar() {
opacity: 1,
transition: "opacity 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)",
height: "100%",
width: isMobile ? "100%" : "18.5rem",
width: isMobile ? "100%" : expandedWidth,
flexShrink: 0,
display: "flex",
flexDirection: "column",
}}
>
<div className="tool-panel__compact-header">
{activeTool ? (
<div
className="tool-panel__active-tool-pill"
aria-label={activeTool.name}
>
<span className="tool-panel__active-tool-pill-icon">
<ToolIcon
icon={activeTool.icon}
marginRight="0"
color="var(--mantine-color-blue-filled)"
/>
</span>
<span className="tool-panel__active-tool-pill-label">
{activeTool.name}
</span>
</div>
) : showHeaderSearch ? (
<div className="tool-panel__compact-header-search">
<ToolSearch
value={searchQuery}
onChange={handleHeaderSearchChange}
toolRegistry={toolRegistry}
mode="filter"
autoFocus={allToolsView && !inToolView}
{policyDetailActive ? (
<div className="pol-takeover">
<PolicyDetailTakeover />
</div>
) : (
<>
{!showPolicies && (
<div className="tool-panel__compact-header">
{activeTool ? (
<div
className="tool-panel__active-tool-pill"
aria-label={activeTool.name}
>
<span className="tool-panel__active-tool-pill-icon">
<ToolIcon
icon={activeTool.icon}
marginRight="0"
color="var(--mantine-color-blue-filled)"
/>
</span>
<span className="tool-panel__active-tool-pill-label">
{activeTool.name}
</span>
</div>
) : showHeaderSearch ? (
<div className="tool-panel__compact-header-search">
<ToolSearch
value={searchQuery}
onChange={handleHeaderSearchChange}
toolRegistry={toolRegistry}
mode="filter"
autoFocus={allToolsView && !inToolView}
/>
</div>
) : null}
{showCloseButton ? (
<ActionIcon
variant="subtle"
color="gray"
radius="xl"
size="md"
onClick={handleHeaderBack}
aria-label={
inToolView
? t("toolPanel.backToAllTools", "Back to all tools")
: t("toolPanel.goBack", "Go back")
}
className="tool-panel__expand-btn"
>
<CloseIcon sx={{ fontSize: "1.1rem" }} />
</ActionIcon>
) : (
<ActionIcon
variant="outline"
radius="xl"
size="md"
onClick={handleCollapse}
aria-label={t("toolPanel.collapse", "Collapse panel")}
className="tool-panel__expand-btn tool-panel__toggle-vt"
>
<ChevronRightIcon sx={{ fontSize: "1.1rem" }} />
</ActionIcon>
)}
</div>
)}
{showPolicies && (
<PoliciesSection
leadingControl={
<ActionIcon
variant="outline"
radius="xl"
size="md"
onClick={handleCollapse}
aria-label={t("toolPanel.collapse", "Collapse panel")}
className="tool-panel__expand-btn tool-panel__toggle-vt"
>
<ChevronRightIcon sx={{ fontSize: "1.1rem" }} />
</ActionIcon>
}
/>
</div>
) : (
showAgents && (
<span className="tool-panel__section-label">
{t("agents.section_title", "Agents")}
</span>
)
)}
{showCloseButton ? (
<ActionIcon
variant="subtle"
color="gray"
radius="xl"
size="md"
onClick={handleHeaderBack}
aria-label={
inToolView
? t("toolPanel.backToAllTools", "Back to all tools")
: t("toolPanel.goBack", "Go back")
}
className="tool-panel__expand-btn"
>
<CloseIcon sx={{ fontSize: "1.1rem" }} />
</ActionIcon>
) : (
<ActionIcon
variant="outline"
radius="xl"
size="md"
onClick={handleCollapse}
aria-label={t("toolPanel.collapse", "Collapse panel")}
className="tool-panel__expand-btn"
>
<ChevronRightIcon sx={{ fontSize: "1.1rem" }} />
</ActionIcon>
)}
</div>
)}
{showAgents && <AgentsSection />}
{showInlineSearch && (
<div className="tool-panel__between-search">
<ToolSearch
value={searchQuery}
onChange={handleHeaderSearchChange}
toolRegistry={toolRegistry}
mode="filter"
/>
</div>
)}
<ToolPanel
allToolsView={allToolsView}
onShowAllTools={handleShowAllTools}
onToolSelect={handleToolSelectWithTransition}
compact={agentsEnabled && !allToolsView}
/>
<ToolPanel
allToolsView={allToolsView}
onShowAllTools={handleShowAllTools}
onToolSelect={handleToolSelectWithTransition}
compact={false}
/>
</>
)}
</div>
)}
@@ -201,6 +201,13 @@
border-color: var(--border-subtle) !important;
}
/* The collapse/expand toggle keeps a stable identity across the collapsed strip
and the expanded header, so a view transition translates it between the two
(same size) instead of cross-fading/scaling it ("big then small"). */
.tool-panel__toggle-vt {
view-transition-name: sidebar-toggle;
}
.tool-panel__expand-btn svg {
color: var(--text-secondary) !important;
}
@@ -271,6 +278,22 @@
width: 100%;
}
/* Search that separates the Policies section from the Tools list below it. */
.tool-panel__between-search {
padding: 0.5rem 0.75rem 0.25rem;
border-top: 1px solid var(--border-subtle, var(--color-border));
}
.tool-panel__between-search .search-input-container {
width: 100%;
margin-bottom: 0;
}
/* Slightly recessed search field so it reads as distinct from the panel. */
.tool-panel__between-search input {
background-color: var(--bg-muted);
}
.tool-panel__active-tool-pill {
display: inline-flex;
align-items: center;
@@ -362,15 +385,6 @@
}
}
.tool-panel__section-label {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
line-height: 1;
}
.tool-panel__mode-toggle {
transition: transform 0.2s ease;
}
@@ -832,33 +846,6 @@
gap: 0.75rem;
}
/* Agents card lives in its own column above the tool grid. It uses the same
.tool-panel__fullscreen-group base styles but with a colourful gradient
border so it stands out as a distinct surface (and doesn't blend into the
neighbouring category cards). */
.tool-panel__fullscreen-agents {
padding: 1.5rem 1.75rem 0;
}
.tool-panel__fullscreen-group--agents {
position: relative;
background:
linear-gradient(var(--fullscreen-bg-group), var(--fullscreen-bg-group))
padding-box,
linear-gradient(
135deg,
var(--mantine-color-blue-6) 0%,
var(--mantine-color-violet-6) 45%,
var(--mantine-color-pink-6) 100%
)
border-box;
border: 1.5px solid transparent;
}
.tool-panel__fullscreen-section-icon--agents {
color: var(--mantine-color-blue-6);
}
@keyframes tool-panel-fullscreen-slide-in {
from {
transform: translateX(6%) scaleX(0.85);
@@ -21,12 +21,15 @@ interface AddWatermarkSingleStepSettingsProps {
value: AddWatermarkParameters[K],
) => void;
disabled?: boolean;
/** When false, hide the "Flatten PDF pages to images" option (e.g. in policies). */
showFlatten?: boolean;
}
const AddWatermarkSingleStepSettings = ({
parameters,
onParameterChange,
disabled = false,
showFlatten = true,
}: AddWatermarkSingleStepSettingsProps) => {
return (
<Stack gap="lg">
@@ -69,6 +72,7 @@ const AddWatermarkSingleStepSettings = ({
parameters={parameters}
onParameterChange={onParameterChange}
disabled={disabled}
showFlatten={showFlatten}
/>
)}
</Stack>
@@ -10,12 +10,15 @@ interface WatermarkFormattingProps {
value: AddWatermarkParameters[K],
) => void;
disabled?: boolean;
/** When false, hide the "Flatten PDF pages to images" option (e.g. in policies). */
showFlatten?: boolean;
}
const WatermarkFormatting = ({
parameters,
onParameterChange,
disabled = false,
showFlatten = true,
}: WatermarkFormattingProps) => {
const { t } = useTranslation();
@@ -95,17 +98,19 @@ const WatermarkFormatting = ({
</Group>
{/* Advanced Options */}
<Checkbox
label={t(
"watermark.settings.convertToImage",
"Flatten PDF pages to images",
)}
checked={parameters.convertPDFToImage}
onChange={(event) =>
onParameterChange("convertPDFToImage", event.currentTarget.checked)
}
disabled={disabled}
/>
{showFlatten && (
<Checkbox
label={t(
"watermark.settings.convertToImage",
"Flatten PDF pages to images",
)}
checked={parameters.convertPDFToImage}
onChange={(event) =>
onParameterChange("convertPDFToImage", event.currentTarget.checked)
}
disabled={disabled}
/>
)}
</Stack>
);
};
@@ -18,7 +18,7 @@ import {
useNavigationState,
useNavigationGuard,
} from "@app/contexts/NavigationContext";
import { BASE_PATH, withBasePath } from "@app/constants/app";
import { stripBasePath, withBasePath } from "@app/constants/app";
import { useRedaction, useRedactionMode } from "@app/contexts/RedactionContext";
import TextFieldsIcon from "@mui/icons-material/TextFields";
import StraightenIcon from "@mui/icons-material/Straighten";
@@ -73,17 +73,10 @@ export function useViewerWorkbenchBarButtons(
});
}, [registerImmediatePanUpdate]);
const stripBasePath = useCallback((path: string) => {
if (BASE_PATH && path.startsWith(BASE_PATH)) {
return path.slice(BASE_PATH.length) || "/";
}
return path;
}, []);
const isAnnotationsPath = useCallback(() => {
const cleanPath = stripBasePath(window.location.pathname).toLowerCase();
return cleanPath === "/annotations" || cleanPath.endsWith("/annotations");
}, [stripBasePath]);
}, []);
const [isAnnotationsActive, setIsAnnotationsActive] = useState<boolean>(() =>
isAnnotationsPath(),
+10
View File
@@ -38,3 +38,13 @@ export const absoluteWithBasePath = (path: string): string => {
const clean = path.startsWith("/") ? path : `/${path}`;
return `${window.location.origin}${BASE_PATH}${clean}`;
};
/** Strip BASE_PATH prefix so route comparisons work under any subpath deploy. */
export const stripBasePath = (pathname: string): string => {
if (!BASE_PATH) return pathname;
if (pathname === BASE_PATH) return "/";
if (pathname.startsWith(`${BASE_PATH}/`)) {
return pathname.slice(BASE_PATH.length);
}
return pathname;
};
@@ -4,11 +4,18 @@
*/
/**
* Watched Folders (a.k.a. Watched Folders). Disabled for now gates both the
* custom workbench registration (seeding, URL sync, view) and the sidebar
* entry point, so the feature is unreachable from the UI. Flip to `true` to
* restore access.
* Watched Folders. Disabled for now gates both the custom workbench
* registration (seeding, URL sync, view) and the sidebar entry point, so the
* feature is unreachable from the UI. Flip to `true` to restore access.
*/
// Annotated as `boolean` (not the literal `false`) so call sites aren't treated
// as constant/unreachable conditions by the type checker and linter.
export const WATCHED_FOLDERS_ENABLED: boolean = false;
/**
* Policies a proprietary, automation-backed feature (like Watched Folders but
* backend-driven, with non-folder triggers). The implementation lives under
* `proprietary/`; this core value stays `false` so the shared sidebar entry
* never appears in the open-source build.
*/
export const POLICIES_ENABLED: boolean = false;
@@ -14,6 +14,7 @@ export const AUTH_ROUTES = [
"/invite",
"/forgot-password",
"/reset-password",
"/oauth/consent",
];
/**
@@ -25,6 +25,7 @@ import {
import { useFileActions } from "@app/contexts/file/fileHooks";
import { useFolders } from "@app/contexts/FolderContext";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useAuth } from "@app/auth/UseSession";
/** View-toggle modes; tuple keeps the union and iterator in sync. */
export const FILES_PAGE_VIEW_MODES = ["grid", "list"] as const;
@@ -140,6 +141,7 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) {
const folders = useFolders();
const { actions: fileActions } = useFileActions();
const { config: appConfig } = useAppConfig();
const { isAnonymous } = useAuth();
const [allFiles, setAllFiles] = useState<StirlingFileStub[]>([]);
const [loading, setLoading] = useState(true);
@@ -166,6 +168,7 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) {
const merged = await reconcileServerFiles(localLeaf, {
storageEnabled,
shareLinksEnabled,
isAnonymous,
});
// Drop the merged result if a newer refresh has already started -
// otherwise its stale snapshot will clobber the newer one's state.
@@ -181,7 +184,7 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) {
// Only the latest refresh should clear the loading state.
if (gen === refreshGenRef.current) setLoading(false);
}
}, [setFoldersError, storageEnabled, shareLinksEnabled]);
}, [setFoldersError, storageEnabled, shareLinksEnabled, isAnonymous]);
useEffect(() => {
void refresh();
@@ -1,6 +1,7 @@
import React from "react";
import { describe, expect, test, vi, beforeEach } from "vitest";
import { render, screen, waitFor, act } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { FolderProvider, useFolders } from "@app/contexts/FolderContext";
import { createFolderId, FolderId, FolderRecord } from "@app/types/folder";
@@ -38,6 +39,31 @@ vi.mock("@app/services/folderSyncService", () => ({
},
}));
// FolderProvider only pulls from the server for a confirmed, non-anonymous
// user (guests have no cloud storage). Mock useAuth as a signed-in user so the
// pull runs; the guest-skip path is covered by its own test below.
const { mockAuth } = vi.hoisted(() => ({
mockAuth: {
user: { id: "test-user", is_anonymous: false } as Record<
string,
unknown
> | null,
isAnonymous: false,
},
}));
vi.mock("@app/auth/UseSession", () => ({
useAuth: () => ({
user: mockAuth.user,
isAnonymous: mockAuth.isAnonymous,
session: null,
displayName: null,
loading: false,
error: null,
signOut: vi.fn(),
refreshSession: vi.fn(),
}),
}));
// Stateful IDB mock - the revision-driven refresh re-reads getAllFolders
// after every state change, so a stateless [] mock would clobber pull results.
const { mockIdb } = vi.hoisted(() => ({
@@ -110,9 +136,11 @@ function axiosError(status: number, message = "rejected"): Error {
async function renderAndWaitForPull(): Promise<void> {
render(
<FolderProvider>
<Probe />
</FolderProvider>,
<MemoryRouter>
<FolderProvider>
<Probe />
</FolderProvider>
</MemoryRouter>,
);
// The pull is fired from a mount effect; wait until it has resolved by
// observing that `mockList` was called at least once and a tick has
@@ -130,6 +158,9 @@ describe("FolderContext sync-banner gating", () => {
mockList.mockReset();
mockUpdate.mockReset();
mockDelete.mockReset();
// Default each test to a signed-in, non-anonymous user so the pull runs.
mockAuth.user = { id: "test-user", is_anonymous: false };
mockAuth.isAnonymous = false;
});
test("401 (unauthorized) does NOT surface a banner", async () => {
@@ -181,6 +212,28 @@ describe("FolderContext sync-banner gating", () => {
expect(screen.getByTestId("error").textContent).toBe("<null>");
expect(screen.getByTestId("reachable").textContent).toBe("false");
});
test("guest (anonymous) session does NOT pull from the server", async () => {
// Guests have no cloud storage; pulling would 401 and (historically)
// surface a toast. The provider must make no folder request at all.
mockAuth.user = { id: "guest", is_anonymous: true };
mockAuth.isAnonymous = true;
mockList.mockResolvedValue([]);
render(
<MemoryRouter>
<FolderProvider>
<Probe />
</FolderProvider>
</MemoryRouter>,
);
// Let mount effects run; the pull must never fire.
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(mockList).not.toHaveBeenCalled();
expect(screen.getByTestId("reachable").textContent).toBe("false");
});
});
/** Per-folder mutation 404 = silent cleanup (drop subtree, no banner, pull). */
@@ -191,6 +244,9 @@ describe("FolderContext stale-folder 404 cleanup", () => {
mockDelete.mockReset();
// Reset the stateful IDB mock so each test starts with an empty cache.
mockIdb.folders = [];
// Signed-in, non-anonymous user so pullFromServer runs.
mockAuth.user = { id: "test-user", is_anonymous: false };
mockAuth.isAnonymous = false;
});
function makeFolder(name: string, parentFolderId: FolderId | null = null) {
@@ -241,9 +297,11 @@ describe("FolderContext stale-folder 404 cleanup", () => {
mockList.mockResolvedValueOnce(initial);
const apiRef: { current: ProbeApi | null } = { current: null };
render(
<FolderProvider>
<ApiProbe onReady={(api) => (apiRef.current = api)} />
</FolderProvider>,
<MemoryRouter>
<FolderProvider>
<ApiProbe onReady={(api) => (apiRef.current = api)} />
</FolderProvider>
</MemoryRouter>,
);
await waitFor(() =>
expect(screen.getByTestId("count").textContent).toBe(
@@ -38,6 +38,9 @@ import {
} from "@app/types/folder";
import { useIndexedDB } from "@app/contexts/IndexedDBContext";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useAuth } from "@app/auth/UseSession";
import { useLocation } from "react-router-dom";
import { isAuthRoute } from "@app/constants/routes";
interface FolderContextValue {
folders: FolderRecord[];
@@ -363,10 +366,28 @@ export function FolderProvider({ children }: FolderProviderProps) {
// guaranteed-to-403 round-trip per session.
const { config: appConfig } = useAppConfig();
const storageBackedByServer = appConfig?.storageEnabled === true;
// Skip server pulls on auth routes: FolderProvider is mounted globally and
// /login has no session yet, so the pull would be a guaranteed 401.
const location = useLocation();
const onAuthRoute = isAuthRoute(location.pathname);
// Guests (anonymous sessions) have no server-side storage, so a pull is a
// guaranteed 401 - skip it once we know the session is anonymous. This is
// what keeps the "must sign in" affordance toast-free: with no folder request
// fired, there's no error to surface. We gate on `isAnonymous` (which is
// false for a real signed-in user) rather than the auth `loading` flag, which
// can stay true for an authenticated session and would otherwise block the
// pull for legitimate users.
const { user, isAnonymous } = useAuth();
useEffect(() => {
if (!storageBackedByServer) return;
// Only pull once we have a confirmed, non-anonymous user. Skipping while
// `user` is still null avoids a stray 401 in the brief window before an
// anonymous session resolves (at which point `isAnonymous` flips true and
// keeps us out). `isAnonymous` alone isn't enough because it defaults false
// before the session loads; the auth `loading` flag is unreliable (it can
// stay true for a valid signed-in session), so we key off the user object.
if (!storageBackedByServer || onAuthRoute || !user || isAnonymous) return;
void pullFromServer();
}, [pullFromServer, storageBackedByServer]);
}, [pullFromServer, storageBackedByServer, onAuthRoute, user, isAnonymous]);
const foldersById = useMemo(() => {
const map = new Map<FolderId, FolderRecord>();
@@ -0,0 +1,113 @@
import { describe, it, expect } from "vitest";
import {
fileContextReducer,
initialFileContextState,
} from "@app/contexts/file/FileReducer";
import type {
FileContextState,
StirlingFileStub,
} from "@app/types/fileContext";
import type { FileId } from "@app/types/file";
function stub(
id: string,
overrides: Partial<StirlingFileStub> = {},
): StirlingFileStub {
return {
id: id as FileId,
name: `${id}.pdf`,
type: "application/pdf",
size: 1,
lastModified: 0,
isLeaf: true,
originalFileId: id,
versionNumber: 1,
...overrides,
};
}
function stateWith(stubs: StirlingFileStub[]): FileContextState {
return {
...initialFileContextState,
files: {
ids: stubs.map((s) => s.id),
byId: Object.fromEntries(stubs.map((s) => [s.id, s])),
},
};
}
describe("fileContextReducer — derivedFromTool provenance", () => {
it("ADD_FILES leaves uploads unmarked (a genuine upload is not tool-derived)", () => {
const next = fileContextReducer(initialFileContextState, {
type: "ADD_FILES",
payload: { stirlingFileStubs: [stub("a")] },
});
expect(next.files.byId["a" as FileId].derivedFromTool).toBeUndefined();
});
it("CONSUME_FILES marks every output as tool-derived", () => {
// An upload "a" is consumed by a tool producing an independent artifact "b"
// (version metadata identical to an upload — only the flag distinguishes it).
const next = fileContextReducer(stateWith([stub("a")]), {
type: "CONSUME_FILES",
payload: {
inputFileIds: ["a" as FileId],
outputStirlingFileStubs: [stub("b")],
},
});
expect(next.files.byId["b" as FileId].derivedFromTool).toBe(true);
// Provenance: "b" records the input it derived from.
expect(next.files.byId["b" as FileId].sourceFileIds).toEqual(["a"]);
expect(next.files.byId["a" as FileId]).toBeUndefined(); // input consumed
});
it("CONSUME_FILES accumulates sourceFileIds transitively", () => {
// "b" already derived from "a"; consuming "b" → "c" carries both, so the
// badge still resolves after the intermediate "b" is gone (e.g. split).
const start = stateWith([stub("b", { sourceFileIds: ["a" as FileId] })]);
const next = fileContextReducer(start, {
type: "CONSUME_FILES",
payload: {
inputFileIds: ["b" as FileId],
outputStirlingFileStubs: [stub("c")],
},
});
expect(next.files.byId["c" as FileId].sourceFileIds).toEqual(["b", "a"]);
});
it("CONSUME_FILES with multiple inputs (merge) records all sources", () => {
const start = stateWith([stub("a"), stub("b")]);
const next = fileContextReducer(start, {
type: "CONSUME_FILES",
payload: {
inputFileIds: ["a" as FileId, "b" as FileId],
outputStirlingFileStubs: [stub("merged")],
},
});
expect(next.files.byId["merged" as FileId].sourceFileIds).toEqual([
"a",
"b",
]);
});
it("UNDO_CONSUME_FILES restores the original upload without mislabelling it", () => {
// Reverses the swap above: the original upload "a" comes back through the
// same helper, but must NOT be flagged tool-derived.
const consumed = fileContextReducer(stateWith([stub("a")]), {
type: "CONSUME_FILES",
payload: {
inputFileIds: ["a" as FileId],
outputStirlingFileStubs: [stub("b")],
},
});
const undone = fileContextReducer(consumed, {
type: "UNDO_CONSUME_FILES",
payload: {
inputStirlingFileStubs: [stub("a")],
outputFileIds: ["b" as FileId],
},
});
expect(undone.files.byId["a" as FileId].derivedFromTool).toBeUndefined();
expect(undone.files.byId["b" as FileId]).toBeUndefined(); // output removed
});
});
@@ -288,7 +288,33 @@ export function fileContextReducer(
case "CONSUME_FILES": {
const { inputFileIds, outputStirlingFileStubs } = action.payload;
return processFileSwap(state, inputFileIds, outputStirlingFileStubs);
// Transitive provenance: the outputs derive from these inputs AND from
// whatever those inputs themselves derived from. Accumulating the closure
// (rather than just the immediate inputs) means a policy badge still
// resolves after an intermediate edit has been consumed and removed —
// e.g. redact → edit → split still tags each split part. Captured before
// the inputs are swapped out below.
const sourceFileIds = Array.from(
new Set(
inputFileIds.flatMap((id) => [
id,
...(state.files.byId[id]?.sourceFileIds ?? []),
]),
),
);
// Mark every consume output as tool-produced (the single chokepoint for
// both versioned edits and independent artifacts like convert/split/merge)
// and stamp its provenance. Tag here, not in processFileSwap, so
// UNDO_CONSUME (which restores the original inputs through the same helper)
// doesn't mislabel real uploads.
const taggedOutputs = outputStirlingFileStubs.map((stub) => ({
...stub,
derivedFromTool: true,
sourceFileIds,
}));
return processFileSwap(state, inputFileIds, taggedOutputs);
}
case "UNDO_CONSUME_FILES": {
@@ -17,7 +17,9 @@ export function useAllWatchedFolders(): WatchedFolder[] {
useEffect(() => {
const load = async () => {
try {
setFolders(await watchedFolderStorage.getAllFolders());
// Policy-owned folders are managed by Policies, not shown here.
const all = await watchedFolderStorage.getAllFolders();
setFolders(all.filter((f) => !f.policyCategoryId));
} catch (err) {
console.error("Failed to load smart folders:", err);
}
@@ -2,6 +2,10 @@ import { useEffect, useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { BASE_PATH } from "@app/constants/app";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import {
Z_INDEX_COOKIE_CONSENT_BANNER,
Z_INDEX_COOKIE_PREFERENCES_MODAL,
} from "@app/styles/zIndex";
import { TOUR_STATE_EVENT, type TourStatePayload } from "@app/constants/events";
import { getCookieConsentOverrides } from "@app/extensions/cookieConsentConfig";
@@ -11,6 +15,8 @@ declare global {
run: (config: any) => void;
show: (show?: boolean) => void;
hide: () => void;
showPreferences: () => void;
hidePreferences: () => void;
getCookie: (name?: string) => any;
acceptedCategory: (category: string) => boolean;
acceptedService: (serviceName: string, category: string) => boolean;
@@ -23,6 +29,16 @@ interface CookieConsentConfig {
forceLightMode?: boolean;
}
// Shard so Mantine's scroll-lock doesn't swallow events on the consent dialog;
// lazy because #cc-main only exists post-load.
export const COOKIE_CONSENT_SCROLL_SHARD = {
get current(): HTMLElement | null {
return typeof document === "undefined"
? null
: document.getElementById("cc-main");
},
};
export const useCookieConsent = ({
analyticsEnabled = false,
forceLightMode = false,
@@ -34,16 +50,26 @@ export const useCookieConsent = ({
useEffect(() => {
if (!analyticsEnabled) return;
// Bridge the layering constants to the static cookie-consent stylesheets
document.documentElement.style.setProperty(
"--z-index-cookie-consent",
String(Z_INDEX_COOKIE_CONSENT_BANNER),
);
document.documentElement.style.setProperty(
"--z-index-cookie-preferences",
String(Z_INDEX_COOKIE_PREFERENCES_MODAL),
);
const mainCSS = document.createElement("link");
mainCSS.rel = "stylesheet";
mainCSS.href = `${BASE_PATH}css/cookieconsent.css`;
mainCSS.href = `${BASE_PATH}/css/cookieconsent.css`;
if (!document.querySelector(`link[href="${mainCSS.href}"]`)) {
document.head.appendChild(mainCSS);
}
const customCSS = document.createElement("link");
customCSS.rel = "stylesheet";
customCSS.href = `${BASE_PATH}css/cookieconsentCustomisation.css`;
customCSS.href = `${BASE_PATH}/css/cookieconsentCustomisation.css`;
if (!document.querySelector(`link[href="${customCSS.href}"]`)) {
document.head.appendChild(customCSS);
}
@@ -57,7 +83,7 @@ export const useCookieConsent = ({
}
const script = document.createElement("script");
script.src = `${BASE_PATH}js/thirdParty/cookieconsent.umd.js`;
script.src = `${BASE_PATH}/js/thirdParty/cookieconsent.umd.js`;
script.onload = () => {
setTimeout(() => {
const detectTheme = () => {
@@ -391,7 +417,10 @@ export const useCookieConsent = ({
const showCookiePreferences = useCallback(() => {
if (isInitialized && window.CookieConsent) {
window.CookieConsent?.show(true);
// Open the detailed preferences dialog directly (not the consent
// banner) — it gets the `.show--preferences` class, which the CSS
// raises above the settings modal.
window.CookieConsent?.showPreferences();
}
}, [isInitialized]);
@@ -128,10 +128,14 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
"[useEndpointConfig] Fetching all endpoint statuses from server",
);
// Fetch all endpoints at once - no query params needed
// Fetch all endpoints at once; auto-fires on app load, so a 401 must
// fail silently instead of triggering the global login redirect.
const response = await apiClient.get<
Record<string, EndpointAvailabilityDetails>
>(`/api/v1/config/endpoints-availability`);
>(`/api/v1/config/endpoints-availability`, {
suppressErrorToast: true,
skipAuthRedirect: true,
});
// Populate global cache with all results
Object.entries(response.data).forEach(([endpoint, details]) => {
@@ -0,0 +1,10 @@
import type { FileItemPolicyRef } from "@app/components/shared/FileSidebarFileItem";
/**
* Policies that have run on each file, keyed by fileId drives the shield
* badges in the file sidebar. Empty in core; the proprietary build shadows this
* with an implementation backed by the policy run store.
*/
export function usePolicyFileBadges(): Map<string, FileItemPolicyRef[]> {
return new Map();
}
@@ -0,0 +1,6 @@
/**
* Core stub no profile picture source; auth-aware layers override this.
*/
export function useProfilePictureUrl(): string | null {
return null;
}
@@ -21,6 +21,10 @@ import UploadRoundedIcon from "@mui/icons-material/UploadRounded";
import AddPhotoAlternateRoundedIcon from "@mui/icons-material/AddPhotoAlternateRounded";
import CheckCircleRoundedIcon from "@mui/icons-material/CheckCircleRounded";
import { loadJscanify } from "@app/utils/loadJscanify";
import apiClient from "@app/services/apiClient";
// Use the configured API base (e.g. api.stirling.com), not the page origin.
const API_BASE = (apiClient.defaults.baseURL ?? "").replace(/\/+$/, "");
/**
* MobileScannerPage
@@ -83,7 +87,7 @@ export default function MobileScannerPage() {
try {
const response = await fetch(
`/api/v1/mobile-scanner/validate-session/${sessionId}`,
`${API_BASE}/api/v1/mobile-scanner/validate-session/${sessionId}`,
);
if (response.ok) {
@@ -841,7 +845,7 @@ export default function MobileScannerPage() {
});
const uploadResponse = await fetch(
`/api/v1/mobile-scanner/upload/${sessionId}`,
`${API_BASE}/api/v1/mobile-scanner/upload/${sessionId}`,
{
method: "POST",
body: formData,
@@ -28,8 +28,11 @@ export const accountService = {
* This is a public endpoint - doesn't require authentication
*/
async getLoginPageData(): Promise<LoginPageData> {
// Also auto-called by onboarding when a stale stirling_jwt exists; a 401
// must never trigger the global login redirect.
const response = await apiClient.get<LoginPageData>(
"/api/v1/proprietary/ui-data/login",
{ suppressErrorToast: true, skipAuthRedirect: true },
);
return response.data;
},
@@ -13,7 +13,7 @@ export function setupApiInterceptors(client: AxiosInstance): void {
);
}
/** Auth headers for raw fetch() calls (SSE streams, etc.). Proprietary overrides with JWT + XSRF. */
export function getAuthHeaders(): Record<string, string> {
/** Auth headers for raw fetch() calls — empty in core; proprietary/SaaS override. */
export async function getAuthHeaders(): Promise<Record<string, string>> {
return {};
}
@@ -2,6 +2,9 @@ export interface DownloadRequest {
data: Blob | File;
filename: string;
localPath?: string;
/** Workspace fileId of the file being exported, when known. Lets export-time
* policy enforcement version the in-editor file (not just the download). */
fileId?: string;
}
export interface DownloadResult {
@@ -0,0 +1,29 @@
/**
* Export entry points call {@link downloadFileWithPolicy} instead of
* {@link downloadFile} so any "export"-triggered policy enforces on the file
* before it's downloaded. The enforcement itself is proprietary (a no-op in the
* core build via the `@app/services/policyExport` stub), and never hard-blocks:
* on failure the original file is downloaded.
*/
import {
downloadFile,
type DownloadRequest,
type DownloadResult,
} from "@app/services/downloadService";
import { enforceExportPolicies } from "@app/services/policyExport";
export async function downloadFileWithPolicy(
request: DownloadRequest,
): Promise<DownloadResult> {
// enforceExportPolicies only touches PDFs and is a no-op without an active
// export policy, so non-PDF / non-policy downloads pass straight through.
const input =
request.data instanceof File
? request.data
: new File([request.data], request.filename, {
type: request.data.type,
});
const [enforced] = await enforceExportPolicies([input], [request.fileId]);
return downloadFile({ ...request, data: enforced ?? request.data });
}
@@ -0,0 +1,56 @@
import { describe, it, expect } from "vitest";
import {
legacyDerivedFromTool,
type StoredStirlingFileRecord,
} from "@app/services/fileStorage";
import type { FileId } from "@app/types/file";
/**
* Backfill of `derivedFromTool` for files persisted before the field existed
* (old users' IndexedDB). New records always carry an explicit flag, so this
* helper only governs pre-upgrade records.
*/
function record(
overrides: Partial<StoredStirlingFileRecord>,
): StoredStirlingFileRecord {
return {
id: "f" as FileId,
fileId: "f" as FileId,
quickKey: "k",
name: "f.pdf",
type: "application/pdf",
size: 1,
lastModified: 0,
isLeaf: true,
originalFileId: "f",
versionNumber: 1,
data: new ArrayBuffer(0),
...overrides,
} as StoredStirlingFileRecord;
}
describe("legacyDerivedFromTool — IndexedDB backfill for pre-upgrade files", () => {
it("flags a legacy versioned edit (has tool history)", () => {
expect(
legacyDerivedFromTool(
record({ toolHistory: [{ toolId: "compress" as any, timestamp: 0 }] }),
),
).toBe(true);
});
it("flags a legacy file past its first version", () => {
expect(legacyDerivedFromTool(record({ versionNumber: 2 }))).toBe(true);
});
it("flags a legacy file with a parent", () => {
expect(legacyDerivedFromTool(record({ parentFileId: "p" as FileId }))).toBe(
true,
);
});
it("leaves a clean legacy root unflagged — treated as an upload (safe default for enforcement)", () => {
// A genuine upload AND a legacy independent artifact (convert/split/merge)
// both look like this; old data can't tell them apart, so we enforce.
expect(legacyDerivedFromTool(record({}))).toBeUndefined();
});
});
@@ -38,6 +38,25 @@ export interface StorageStats {
quota?: number;
}
/**
* Best-effort provenance for records persisted before `derivedFromTool`
* existed. A version chain (a tool history, a version past the first, or a
* parent) is unambiguously a tool output, so flag it. Legacy independent
* artifacts (convert/split/merge) recorded none of that and are
* indistinguishable from uploads in old data they stay unflagged, which for
* an enforcement feature is the safe default (enforce rather than silently
* skip). New records always carry an explicit flag, so this only fires for
* pre-existing files on first read after upgrade.
*/
export function legacyDerivedFromTool(
record: StoredStirlingFileRecord,
): boolean | undefined {
if ((record.toolHistory?.length ?? 0) > 0) return true;
if ((record.versionNumber ?? 1) > 1) return true;
if (record.parentFileId != null) return true;
return undefined;
}
class FileStorageService {
private readonly dbConfig = DATABASE_CONFIGS.FILES;
private readonly storeName = "files";
@@ -124,6 +143,8 @@ class FileStorageService {
originalFileId: stub.originalFileId ?? stirlingFile.fileId,
parentFileId: stub.parentFileId ?? undefined,
toolHistory: stub.toolHistory ?? [],
derivedFromTool: stub.derivedFromTool ?? false,
sourceFileIds: stub.sourceFileIds,
// Folder organisation (root when null)
folderId: stub.folderId ?? null,
@@ -247,6 +268,9 @@ class FileStorageService {
originalFileId: record.originalFileId,
parentFileId: record.parentFileId,
toolHistory: record.toolHistory,
derivedFromTool:
record.derivedFromTool ?? legacyDerivedFromTool(record),
sourceFileIds: record.sourceFileIds,
folderId: record.folderId ?? null,
createdAt: record.createdAt || Date.now(),
};
@@ -303,6 +327,9 @@ class FileStorageService {
originalFileId: record.originalFileId || record.id,
parentFileId: record.parentFileId,
toolHistory: record.toolHistory || [],
derivedFromTool:
record.derivedFromTool ?? legacyDerivedFromTool(record),
sourceFileIds: record.sourceFileIds,
folderId: record.folderId ?? null,
createdAt: record.createdAt || Date.now(),
});
@@ -391,6 +418,9 @@ class FileStorageService {
originalFileId: record.originalFileId || record.id,
parentFileId: record.parentFileId,
toolHistory: record.toolHistory || [],
derivedFromTool:
record.derivedFromTool ?? legacyDerivedFromTool(record),
sourceFileIds: record.sourceFileIds,
folderId: record.folderId ?? null,
createdAt: record.createdAt || Date.now(),
});
@@ -63,6 +63,12 @@ interface AccessedShareLinkResponse {
export interface ReconcileOptions {
storageEnabled: boolean;
shareLinksEnabled: boolean;
/**
* Guests (anonymous sessions) have no cloud library; pulling it just 401s and
* surfaces a "sign in to load cloud files" toast. Skip the server pull for
* them and fall back to locally-cached files only.
*/
isAnonymous?: boolean;
}
function normalizeServerFileName(fileName: string | undefined | null): string {
@@ -108,7 +114,7 @@ export async function reconcileServerFiles(
localStubs: StirlingFileStub[],
opts: ReconcileOptions,
): Promise<StirlingFileStub[]> {
if (!opts.storageEnabled) {
if (!opts.storageEnabled || opts.isAnonymous) {
return localStubs;
}
@@ -62,8 +62,14 @@ function toFolderRecord(dto: ServerFolder): FolderRecord {
export const folderSyncService = {
async list(): Promise<FolderRecord[]> {
// Auto-fired by FolderProvider on every load; a persistent 401 must fail
// silently here or the global handler redirects to /login and loops.
const response = await apiClient.get<ServerFolder[]>(
"/api/v1/storage/folders",
{
suppressErrorToast: true,
skipAuthRedirect: true,
},
);
return (response.data ?? []).map(toFolderRecord);
},
@@ -11,6 +11,7 @@ import {
clampText,
extractAxiosErrorMessage,
} from "@app/services/httpErrorUtils";
import { withBasePath } from "@app/constants/app";
// Module-scoped state to reduce global variable usage
const recentSpecialByEndpoint: Record<string, number> = {};
@@ -45,6 +46,51 @@ function stashPostLoginRedirect(path: string): void {
}
}
// Loop breaker: a second 401 redirect within this window means the login page
// bounced us back with a live session — redirecting again would loop forever.
const LOGIN_REDIRECT_THROTTLE_KEY = "stirling_last_401_redirect";
const LOGIN_REDIRECT_THROTTLE_MS = 10_000;
function loginRedirectRecentlyFired(): boolean {
try {
const last = Number(
window.sessionStorage.getItem(LOGIN_REDIRECT_THROTTLE_KEY),
);
return (
Number.isFinite(last) && Date.now() - last < LOGIN_REDIRECT_THROTTLE_MS
);
} catch {
return false;
}
}
function markLoginRedirectFired(): void {
try {
window.sessionStorage.setItem(
LOGIN_REDIRECT_THROTTLE_KEY,
String(Date.now()),
);
} catch {
// sessionStorage unavailable — fail open
}
}
// Reset the throttle when the user establishes a fresh session via interactive
// login. Otherwise a genuine expiry that happens within the throttle window of
// the redirect that sent them to /login would be wrongly suppressed, leaving
// them on a page with silently failing requests. Login dispatches
// "jwt-available"; token refresh does not (it fires "TOKEN_REFRESHED"), so
// refresh-driven redirect churn is still dampened by the throttle.
if (typeof window !== "undefined") {
window.addEventListener("jwt-available", () => {
try {
window.sessionStorage.removeItem(LOGIN_REDIRECT_THROTTLE_KEY);
} catch {
// sessionStorage unavailable - nothing to clear
}
});
}
/**
* Handles HTTP errors with toast notifications and file error broadcasting
* Returns true if the error should be suppressed (deduplicated), false otherwise
@@ -70,6 +116,13 @@ export async function handleHttpError(error: any): Promise<boolean> {
// If not on auth page, redirect to login with expired session message
if (!isAuthPage && !skipAuthRedirect) {
if (loginRedirectRecentlyFired()) {
console.warn(
"[httpErrorHandler] 401 redirect already fired moments ago — suppressing repeat to avoid a login loop:",
error?.config?.url,
);
return true;
}
console.debug("[httpErrorHandler] 401 detected, redirecting to login");
// Spring 302-strips the ?from= query from /login, so stash the return
// path in sessionStorage (AuthCallback reads it after SSO round-trip).
@@ -82,7 +135,8 @@ export async function handleHttpError(error: any): Promise<boolean> {
// ignore storage access failures
}
const expiredPrefix = hadStoredJwt ? "expired=true&" : "";
window.location.href = `/login?${expiredPrefix}from=${encodeURIComponent(currentLocation)}`;
markLoginRedirectFired();
window.location.href = `${withBasePath("/login")}?${expiredPrefix}from=${encodeURIComponent(currentLocation)}`;
return true; // Suppress toast since we're redirecting
}
@@ -7,7 +7,7 @@ import {
setPageRotation,
addNewPage,
} from "@app/services/pdfiumService";
import { downloadFile } from "@app/services/downloadService";
import { downloadFileWithPolicy } from "@app/services/exportWithPolicy";
import { PDFDocument, PDFPage } from "@app/types/pageEditor";
// A4 dimensions in PDF points (72 dpi)
@@ -259,10 +259,10 @@ export class PDFExportService {
}
/**
* Download a single file
* Download a single file, applying any export-triggered policy first.
*/
downloadFile(blob: Blob, filename: string): void {
void downloadFile({ data: blob, filename });
void downloadFileWithPolicy({ data: blob, filename });
}
/**
@@ -0,0 +1,12 @@
/**
* Open-source stub for export-time policy enforcement. Policies are a proprietary
* feature, so in the core build there's nothing to enforce this returns the
* files unchanged. The proprietary build shadows this module via the `@app/*`
* alias with the real implementation.
*/
export async function enforceExportPolicies(
files: File[],
_fileIds?: (string | undefined)[],
): Promise<File[]> {
return files;
}
@@ -26,6 +26,11 @@ export const Z_INDEX_DRAG_BADGE = 1001;
// Modal that appears on top of config modal (e.g., restart confirmation, update modal)
export const Z_INDEX_OVER_CONFIG_MODAL = 2000;
// Cookie-consent banner — above the chat FAB (1050), below all modals and onboarding; reaches CSS via --z-index-cookie-consent
export const Z_INDEX_COOKIE_CONSENT_BANNER = 1060;
// Cookie-consent preferences dialog — above the config modal it opens from; reaches CSS via --z-index-cookie-preferences
export const Z_INDEX_COOKIE_PREFERENCES_MODAL = 1450;
// Sign-in modal — must appear above all app UI including config and analytics modals
export const Z_INDEX_SIGN_IN_MODAL = 9000;
@@ -183,7 +183,7 @@ export async function mockAppApis(
// Current user — anonymous by default, configurable for authenticated flows
await page.route("**/api/v1/auth/me", (route: Route) =>
route.fulfill({ json: user }),
route.fulfill({ json: { user } }),
);
// Tool availability — every tool enabled unless overridden
@@ -248,6 +248,14 @@ export async function mockAppApis(
await page.route("**/api/v1/info/wau", (route: Route) =>
route.fulfill({ json: { count: 0 } }),
);
// Policies (proprietary): the reconcile fires GET /api/v1/policies on app
// load. Return an empty list so the stubbed (backend-free) env stays clean —
// no failed request polluting the console, and the auto-run controller has
// nothing to dispatch.
await page.route("**/api/v1/policies", (route: Route) =>
route.fulfill({ json: [] }),
);
}
/**
@@ -95,8 +95,23 @@ test.describe("1. Authentication and Login", () => {
await page.goto("/merge");
await page.waitForLoadState("domcontentloaded");
// Step 1-2: Invalidate session by clearing cookies and localStorage JWT,
// then re-add the cookie consent cookie so the banner doesn't block after redirect
// Simulate a genuinely expired session by forcing the token-refresh
// endpoint to fail. Clearing client state alone is not enough: the SPA's
// auth client refreshes a fresh JWT from a surviving server-side
// credential and re-authenticates, churning between /merge, / and /login
// instead of settling on the login page. A 401 here is what a real
// expired/revoked session looks like to the client.
await page.route("**/api/v1/auth/refresh", (route) =>
route.fulfill({
status: 401,
contentType: "application/json",
body: JSON.stringify({ error: "session_expired" }),
}),
);
// Invalidate the client session: clear cookies + the stored JWT, then
// re-add the cookie-consent cookie so the banner doesn't block the login
// page after redirect.
await page.context().clearCookies();
await page.evaluate(() => {
localStorage.removeItem("stirling_jwt");
@@ -116,20 +131,28 @@ test.describe("1. Authentication and Login", () => {
},
]);
// Full page reload forces the SPA to re-check auth with the backend
// Full page reload forces the SPA to re-check auth with the backend.
await page.reload({ waitUntil: "domcontentloaded" });
// Step 3: Verify the user is redirected to the login page
await expect(page).toHaveURL(/\/login/, { timeout: 15000 });
// With refresh failing, the SPA settles on the login page. Wait for the
// login form to render (a concrete signal the redirect landed) and then
// confirm the URL, rather than racing a URL poll against the bootstrap.
await page
.locator("#email")
.waitFor({ state: "visible", timeout: 30000 });
await expect(page).toHaveURL(/\/login/);
// Step 5: Log in with valid credentials
// Stop forcing refresh failures so the fresh login below behaves normally.
await page.unroute("**/api/v1/auth/refresh");
// Log back in with valid credentials.
await page.locator("#email").fill("admin");
await page.locator("#password").fill("adminadmin");
await page.locator('button[type="submit"]').click();
// Step 6: Verify the user is redirected back to /merge or home.
// Any non-/login URL is acceptable the app may route to the original
// page (/merge) or to the dashboard (/), both are valid post-login states.
// Verify the user is redirected back off the login page. Any non-/login
// URL is acceptable: the app may route to the original page (/merge) or
// to the dashboard (/), both are valid post-login states.
await page.waitForURL((url) => !url.pathname.includes("/login"), {
timeout: 15000,
});
@@ -1,103 +1,107 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import { openSettings } from "@app/tests/helpers/ui-helpers";
test.describe("18. Cookie Preferences", () => {
test.describe("18.1 Cookie Banner", () => {
test("should open and configure cookie preferences from footer", async ({
test("should open and configure cookie preferences from Settings → Legal", async ({
page,
}) => {
// Step 1: Locate the "Cookie Preferences" button in the footer
const cookieButton = page
.locator(
'#cookieBanner, button:has-text("Preferensi Cookie"), button:has-text("Cookie Preferences")',
)
.first();
// Analytics must be enabled for the Cookie Preferences button to render
await page.route("**/api/v1/ui-data/footer-info", (route) =>
route.fulfill({ json: { analyticsEnabled: true } }),
);
await page.goto("/");
await page.waitForTimeout(1000);
const isVisible = await cookieButton.isVisible().catch(() => false);
if (!isVisible) {
test.skip(
true,
"Cookie Preferences button not visible — analytics may be disabled",
);
return;
}
// Step 1: The "Cookie Preferences" button lives in Settings → Legal
await openSettings(page);
const legalNav = page.locator('[data-tour="admin-legal-nav"]').first();
await expect(legalNav).toBeVisible({ timeout: 5000 });
await legalNav.click();
await expect(cookieButton).toBeVisible();
const cookieButton = page.locator("#cookieBanner").first();
await expect(cookieButton).toBeVisible({ timeout: 10000 });
// Step 2: Click the Cookie Preferences button
await cookieButton.click({ force: true });
// The consent library lazy-loads when the Legal section mounts
await page.waitForFunction(
() => (window as unknown as { CookieConsent?: unknown }).CookieConsent,
{ timeout: 10000 },
);
await page.waitForTimeout(500);
// Step 3: Verify the cookie consent dialog opens
// The CookieConsent library renders inside #cc-main
// Step 2: Click the button — it opens the detailed preferences dialog
// directly. No force: the click must land with the settings modal open,
// proving the dialog stacks above it.
await cookieButton.click();
// Step 3: Verify the preferences dialog opens inside #cc-main
const ccMain = page.locator("#cc-main");
const consentDialog = ccMain.getByRole("dialog").first();
await expect(consentDialog).toBeVisible({ timeout: 5000 });
const prefsDialog = ccMain.locator(".pm").first();
await expect(prefsDialog).toBeVisible({ timeout: 5000 });
// Step 4: Verify options are available
// The initial consent view shows: "Oke", "Tidak, terima kasih", "Kelola preferensi"
const okeBtn = ccMain
.locator('button:has-text("Oke"), button:has-text("OK")')
.first();
const noThanksBtn = ccMain
.locator(
'button:has-text("Tidak, terima kasih"), button:has-text("No Thanks")',
)
.first();
const manageBtn = ccMain
.locator(
'button:has-text("Kelola preferensi"), button:has-text("Manage preferences")',
)
// Step 4: Verify the dialog sits above the settings modal
// (Z_INDEX_CONFIG_MODAL = 1400)
const zIndex = await page.evaluate(() => {
const el = document.querySelector("#cc-main");
return el ? Number(getComputedStyle(el).zIndex) : -1;
});
expect(zIndex).toBeGreaterThan(1400);
// Step 5: Verify the preferences panel shows cookie categories
const necessaryCategory = ccMain
.locator("text=/Strictly Necessary|necessary/i")
.first();
const analyticsCategory = ccMain.locator("text=/Analytics/i").first();
await expect(necessaryCategory.or(analyticsCategory).first()).toBeVisible(
{ timeout: 3000 },
);
const hasOke = await okeBtn.isVisible().catch(() => false);
const hasNoThanks = await noThanksBtn.isVisible().catch(() => false);
const hasManage = await manageBtn.isVisible().catch(() => false);
expect(hasOke || hasNoThanks || hasManage).toBe(true);
// Step 5: Click "Kelola preferensi" to open the detailed preferences panel
if (hasManage) {
await manageBtn.click();
await page.waitForTimeout(500);
// Verify the preferences panel shows cookie categories
const necessaryCategory = ccMain
.locator(
"text=/Cookie yang Sangat Diperlukan|Strictly Necessary|necessary/i",
)
.first();
const analyticsCategory = ccMain
.locator("text=/Analitik|Analytics/i")
.first();
await expect(
necessaryCategory.or(analyticsCategory).first(),
).toBeVisible({ timeout: 3000 });
// Click "Terima semua" (Accept all) or "Simpan preferensi" (Save preferences) to close
const acceptAllBtn = ccMain
.locator(
'button:has-text("Terima semua"), button:has-text("Accept all")',
)
.first();
const savePrefBtn = ccMain
.locator(
'button:has-text("Simpan preferensi"), button:has-text("Save preferences")',
)
.first();
if (await acceptAllBtn.isVisible().catch(() => false)) {
await acceptAllBtn.click();
} else if (await savePrefBtn.isVisible().catch(() => false)) {
await savePrefBtn.click();
}
} else if (hasOke) {
await okeBtn.click();
} else if (hasNoThanks) {
await noThanksBtn.click();
// Step 6: Expand all sections and verify the dialog body wheel-scrolls
// while the settings modal is open (regression: the modal's
// react-remove-scroll lock swallowed wheel events on the dialog)
const expanders = ccMain.locator(
".pm__section--expandable .pm__section-title",
);
const expanderCount = await expanders.count();
for (let i = 0; i < expanderCount; i++) {
await expanders.nth(i).click();
}
const dialogBody = ccMain.locator(".pm__body").first();
const canScroll = await dialogBody.evaluate(
(el) => el.scrollHeight > el.clientHeight,
);
if (canScroll) {
const box = await dialogBody.boundingBox();
await page.mouse.move(
box!.x + box!.width / 2,
box!.y + box!.height / 2,
);
await page.mouse.wheel(0, 300);
await expect
.poll(() => dialogBody.evaluate((el) => el.scrollTop), {
timeout: 3000,
})
.toBeGreaterThan(0);
await dialogBody.evaluate((el) => {
el.scrollTop = 0;
});
}
// Step 6: Verify the dialog is dismissed
await expect(consentDialog).toBeHidden({ timeout: 5000 });
// Step 7: Accept all (or save) to close — again without force
const acceptAllBtn = ccMain
.locator('button:has-text("Accept all")')
.first();
const savePrefBtn = ccMain
.locator('button:has-text("Save preferences")')
.first();
if (await acceptAllBtn.isVisible().catch(() => false)) {
await acceptAllBtn.click();
} else {
await savePrefBtn.click();
}
// Step 8: Verify the dialog is dismissed
await expect(prefsDialog).toBeHidden({ timeout: 5000 });
});
});
});
@@ -141,7 +141,14 @@ async function settle(page: Page, ms = 350): Promise<void> {
}
test.describe("Files page screenshots", () => {
test.use({ autoGoto: false, viewport: { width: 1600, height: 900 } });
// Seed a logged-in session: the cloud-folder surfaces (move-dialog
// create-folder, the seeded "Reports" folder) only render once a confirmed,
// non-anonymous user triggers the folder pull (see FolderContext gating).
test.use({
autoGoto: false,
viewport: { width: 1600, height: 900 },
seedJwt: true,
});
test("01_empty_state_ctas", async ({ page }) => {
await stubStorageApis(page);
@@ -539,7 +539,10 @@ test.describe("Files page", () => {
});
test.describe("Move dialog inline create-folder", () => {
test.use({ autoGoto: false });
// The inline create-folder affordance is gated on `serverReachable`, which
// only flips true once a confirmed, non-anonymous user triggers the folder
// pull (see FolderContext). Seed a JWT so the stubbed session is logged-in.
test.use({ autoGoto: false, seedJwt: true });
test("Move dialog shows Create new folder affordance", async ({ page }) => {
await stubStorageApis(page);
@@ -1,5 +1,6 @@
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", () => {
test.beforeEach(async ({ page }) => {
@@ -87,33 +88,29 @@ test.describe("2. Main Dashboard / Home Page", () => {
});
});
test.describe("2.4 Dashboard - Footer Links", () => {
test("should display footer links with correct URLs", async ({ page }) => {
await expect(page.getByText("Survey").first()).toBeVisible({
timeout: 10000,
});
test.describe("2.4 Dashboard - Legal Links", () => {
test("legal links live in Settings → Legal, not in a footer", async ({
page,
}) => {
await expect(
page.locator('[data-testid="config-button"]').first(),
).toBeVisible({ timeout: 10000 });
// The dashboard footer (survey + legal links) was removed
await expect(page.locator(".footer-link")).toHaveCount(0);
await expect(page.getByText("Survey")).toHaveCount(0);
await openSettings(page);
const legalNav = page.locator('[data-tour="admin-legal-nav"]').first();
await expect(legalNav).toBeVisible({ timeout: 10000 });
await legalNav.click();
await expect(page.getByText("Privacy Policy").first()).toBeVisible({
timeout: 10000,
});
await expect(page.getByText(/Terms/i).first()).toBeVisible({
timeout: 10000,
});
await expect(page.getByText("Discord").first()).toBeVisible({
timeout: 10000,
});
await expect(page.getByText("GitHub").first()).toBeVisible({
timeout: 10000,
});
const githubLink = page
.locator('a[href*="github.com/Stirling-Tools/Stirling-PDF"]')
.first();
await expect(githubLink).toBeVisible();
const discordLink = page
.locator('a[href*="discord.gg/Cn8pWhQRxZ"]')
.first();
await expect(discordLink).toBeVisible();
});
});
@@ -0,0 +1,68 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
/**
* Policy editing is admin-only (mirrors the backend's enforcement on the
* save/delete endpoints). The frontend gate is `canConfigure = !enableLogin ||
* isAdmin`, surfaced through `usePolicies()`:
* - login enabled + non-admin read-only: the setup wizard shows the
* "Managed by your organization" locked state instead of the steps.
* - login enabled + admin full setup flow ("Step 1 of 2").
* - login disabled (single-user/desktop) open to the local operator.
*
* Backend-free: the app-config (`enableLogin`/`isAdmin`) and the empty policy
* list are stubbed via `mockAppApis`, so this asserts the gating wiring without
* a live Spring Boot server. "Security" is the only non-coming-soon policy, so
* it's the one we open.
*/
const LOCKED_TITLE = "Managed by your organization";
const LOCKED_DESC = "Contact a team leader to change this policy.";
/** Open the Security policy from the right-sidebar Policies list. */
async function openSecurityPolicy(page: import("@playwright/test").Page) {
const row = page.locator("button.pol-row").filter({ hasText: "Security" });
await expect(row).toBeVisible({ timeout: 15_000 });
await row.click();
// The wizard header confirms we opened the right policy in either state.
await expect(page.getByText("Set up Security Policy")).toBeVisible();
}
test.describe("Policy editing gate — non-admin (login on)", () => {
test.use({
stubOptions: { enableLogin: true, isAdmin: false },
seedJwt: true,
});
test("non-admin gets the read-only locked state", async ({ page }) => {
await openSecurityPolicy(page);
await expect(page.getByText(LOCKED_TITLE)).toBeVisible();
await expect(page.getByText(LOCKED_DESC)).toBeVisible();
// The editable flow must NOT be reachable.
await expect(page.getByText(/Step \d+ of \d+/)).toHaveCount(0);
});
});
test.describe("Policy editing gate — admin (login on)", () => {
test.use({
stubOptions: { enableLogin: true, isAdmin: true },
seedJwt: true,
});
test("admin can reach the setup wizard", async ({ page }) => {
await openSecurityPolicy(page);
await expect(page.getByText(/Step \d+ of \d+/)).toBeVisible();
await expect(page.getByText(LOCKED_TITLE)).toHaveCount(0);
});
});
test.describe("Policy editing gate — single-user (login off)", () => {
test.use({ stubOptions: { enableLogin: false, isAdmin: false } });
test("local operator can reach the setup wizard with no admin role", async ({
page,
}) => {
await openSecurityPolicy(page);
await expect(page.getByText(/Step \d+ of \d+/)).toBeVisible();
await expect(page.getByText(LOCKED_TITLE)).toHaveCount(0);
});
});
@@ -4,9 +4,9 @@ import { test, expect } from "@app/tests/helpers/stub-test-base";
* Verifies that the Stripe SDK (@stripe/stripe-js, @stripe/react-stripe-js,
* and the js.stripe.com remote script) is NOT fetched on cold page loads
* only when the checkout modal actually mounts. The proprietary
* CheckoutProvider, the SaaS TrialStatusBanner, and the SaaS Plan settings
* page all gate the modal behind React.lazy + a conditional render so the
* Stripe chunk lives in its own async bundle.
* CheckoutProvider and the SaaS Plan settings page gate the modal behind
* React.lazy + a conditional render so the Stripe chunk lives in its own
* async bundle.
*/
const STRIPE_URL_FRAGMENTS = [
@@ -150,6 +150,16 @@ export const mantineTheme = createTheme({
},
},
},
Code: {
styles: {
root: {
backgroundColor: "var(--color-gray-100)",
color: "var(--text-primary)",
},
},
},
Textarea: {
styles: (_theme: MantineTheme) => ({
input: {
@@ -20,6 +20,7 @@ export interface AppConfig {
enableDesktopInstallSlide?: boolean;
premiumEnabled?: boolean;
premiumKey?: string;
paygEnabled?: boolean;
termsAndConditions?: string;
privacyPolicy?: string;
cookiePolicy?: string;
+24
View File
@@ -37,6 +37,30 @@ export interface BaseFileMetadata {
parentFileId?: FileId; // Immediate parent file ID
toolHistory?: ToolOperation[]; // Tool chain for history tracking
/**
* True if this file was produced by a tool/automation in-app (any
* `consumeFiles` output a versioned edit OR an independent artifact like a
* convert/split/merge result) rather than entering the system as a genuine
* upload. Set at the consume chokepoint so it covers both kinds, including
* independent artifacts whose version metadata is otherwise indistinguishable
* from a fresh upload. Persisted so the distinction survives a reload.
* Used by input-mode (upload) policy auto-run to enforce only on real uploads.
*/
derivedFromTool?: boolean;
/**
* Transitive set of fileIds this file was derived from the inputs of the
* tool operation that produced it, plus those inputs' own `sourceFileIds`.
* Recorded at the `consumeFiles` boundary, the only place that knows the
* inputoutput mapping. Unlike `parentFileId` (the version chain) this is a
* pure provenance link, so it covers independent artifacts split (1N),
* merge (N1), convert that intentionally have no parent. Being transitive,
* it survives an intermediate edit being consumed/removed. Persisted; used so
* a policy badge follows a document onto everything derived from it. Legacy
* files predate it (the link was never recorded) and stay empty.
*/
sourceFileIds?: FileId[];
/**
* The cloud folder this file lives in. Semantics:
* - `remoteStorageId == null` file is local-only; folderId MUST be null.
+2
View File
@@ -16,6 +16,8 @@ export interface AnimatedCircleConfig {
export interface AnimatedSlideBackgroundProps {
gradientStops: [string, string];
circles: AnimatedCircleConfig[];
/** Overall background tone; controls on top of the hero (e.g. close button) adapt to stay visible. Defaults to "dark". */
tone?: "light" | "dark";
}
export interface SlideConfig {
@@ -25,6 +25,9 @@ export interface WatchedFolder {
hasOutputDirectory?: boolean; // true when a local FS output folder is configured
/** Where input files come from. Default: 'idb' (dropped/sidebar files stay in browser). */
inputSource?: "idb" | "local-folder";
/** Set when this folder is the backing store for a Policy (its category id).
* Policy-owned folders are filtered out of the Watched Folders UI. */
policyCategoryId?: string;
}
export interface FolderFileMetadata {
@@ -2,6 +2,8 @@ import { StirlingFileStub } from "@app/types/fileContext";
import { fileStorage } from "@app/services/fileStorage";
import { zipFileService } from "@app/services/zipFileService";
import { downloadFile } from "@app/services/downloadService";
import { downloadFileWithPolicy } from "@app/services/exportWithPolicy";
import { enforceExportPolicies } from "@app/services/policyExport";
/**
* Downloads a blob as a file using browser download API
@@ -27,10 +29,11 @@ export async function downloadFileFromStorage(
throw new Error(`File "${file.name}" not found in storage`);
}
await downloadFile({
await downloadFileWithPolicy({
data: stirlingFile,
filename: stirlingFile.name,
localPath: file.localFilePath,
fileId: file.id,
});
}
@@ -59,15 +62,15 @@ export async function downloadFilesAsZip(
throw new Error("No files provided for ZIP download");
}
// Convert stored files to File objects
// Convert stored files to File objects (tracking ids so export policies can
// version the in-editor file).
const filesToZip: File[] = [];
const fileIds: (string | undefined)[] = [];
for (const fileWithUrl of files) {
const lookupKey = fileWithUrl.id;
const stirlingFile = await fileStorage.getStirlingFile(lookupKey);
const stirlingFile = await fileStorage.getStirlingFile(fileWithUrl.id);
if (stirlingFile) {
// StirlingFile is already a File object!
filesToZip.push(stirlingFile);
fileIds.push(fileWithUrl.id);
}
}
@@ -75,6 +78,9 @@ export async function downloadFilesAsZip(
throw new Error("No valid files found in storage for ZIP download");
}
// Enforce any export-triggered policy on each PDF before they're zipped.
const enforced = await enforceExportPolicies(filesToZip, fileIds);
// Generate default filename if not provided
const finalZipFilename =
zipFilename ||
@@ -82,7 +88,7 @@ export async function downloadFilesAsZip(
// Create and download ZIP
const { zipFile } = await zipFileService.createZipFromFiles(
filesToZip,
enforced,
finalZipFilename,
);
await downloadFile({ data: zipFile, filename: finalZipFilename });
@@ -125,7 +131,7 @@ export async function downloadFiles(
* @param filename - Optional custom filename
*/
export function downloadFileObject(file: File, filename?: string): void {
void downloadFile({ data: file, filename: filename || file.name });
void downloadFileWithPolicy({ data: file, filename: filename || file.name });
}
/**
@@ -0,0 +1,45 @@
import { describe, it, expect } from "vitest";
import {
scoreMatch,
minScoreForQuery,
isFuzzyMatch,
} from "@app/utils/fuzzySearch";
describe("scoreMatch", () => {
it("scores substring matches highest", () => {
expect(scoreMatch("rota", "Rotate")).toBeGreaterThanOrEqual(90);
expect(scoreMatch("priv", "private")).toBeGreaterThanOrEqual(
minScoreForQuery("priv"),
);
});
it("tolerates small typos", () => {
expect(isFuzzyMatch("rotaet", "rotate")).toBe(true);
expect(isFuzzyMatch("comprss", "compress")).toBe(true);
// Typo inside one token of a multi-word target
expect(isFuzzyMatch("watermrk", "Add Watermark")).toBe(true);
});
it("rejects unrelated words that share half their letters", () => {
// Unrelated words can share many letters (rotate vs update is edit
// distance 3 of 6) but must not be treated as a fuzzy match.
expect(isFuzzyMatch("rotate", "update")).toBe(false);
expect(isFuzzyMatch("rotate", "create")).toBe(false);
expect(isFuzzyMatch("rotate", "private")).toBe(false);
expect(isFuzzyMatch("rotate", "isolated")).toBe(false);
expect(isFuzzyMatch("rotate", "automate")).toBe(false);
expect(isFuzzyMatch("rotate", "annotate")).toBe(false);
expect(isFuzzyMatch("rotat", "protect")).toBe(false);
expect(isFuzzyMatch("rotat", "redact")).toBe(false);
expect(isFuzzyMatch("rotat", "contrast")).toBe(false);
expect(isFuzzyMatch("rotat", "annotate")).toBe(false);
});
});
describe("minScoreForQuery", () => {
it("does not loosen the threshold for long queries", () => {
expect(minScoreForQuery("rot")).toBe(40);
expect(minScoreForQuery("rotate")).toBe(30);
expect(minScoreForQuery("orientation")).toBe(30);
});
});
+17 -10
View File
@@ -63,20 +63,27 @@ export function scoreMatch(queryRaw: string, targetRaw: string): number {
}
}
const distance = levenshtein(
query,
target.length > 64 ? target.slice(0, 64) : target,
);
const maxLen = Math.max(query.length, target.length, 1);
const similarity = 1 - distance / maxLen; // 0..1
return Math.floor(similarity * 60); // scale below substring scores
// Levenshtein fallback is for typos only: allow a small absolute number of
// edits against the whole target or any single token. Relative similarity is
// not enough here; half the letters of an unrelated word can match (e.g.
// "rotate" vs "update" is edit distance 3 of 6).
const maxEdits = query.length <= 4 ? 1 : 2;
let best = 0;
for (const candidate of [target, ...tokens]) {
if (Math.abs(query.length - candidate.length) > maxEdits) continue;
const distance = levenshtein(query, candidate);
if (distance > maxEdits) continue;
const maxLen = Math.max(query.length, candidate.length, 1);
const similarity = 1 - distance / maxLen; // 0..1
const score = Math.floor(similarity * 60); // scale below substring scores
if (score > best) best = score;
}
return best;
}
export function minScoreForQuery(query: string): number {
const len = normalizeText(query).length;
if (len <= 3) return 40;
if (len <= 6) return 30;
return 25;
return len <= 3 ? 40 : 30;
}
// Decide if a target matches a query based on a threshold
@@ -1,3 +1,5 @@
import { withBasePath } from "@app/constants/app";
declare global {
interface Window {
cv?: any;
@@ -5,8 +7,9 @@ declare global {
}
}
const OPENCV_SRC = "/vendor/jscanify/opencv.js";
const JSCANIFY_SRC = "/vendor/jscanify/jscanify.js";
// Served under the app's base path (handles sub-path deploys like /app).
const OPENCV_SRC = withBasePath("/vendor/jscanify/opencv.js");
const JSCANIFY_SRC = withBasePath("/vendor/jscanify/jscanify.js");
let loadPromise: Promise<void> | null = null;
@@ -0,0 +1,115 @@
/**
* Unit tests for buildMobileScannerUrl.
*
* Regression guard: the SaaS frontend is served under a base path (e.g.
* `/app`), and `/mobile-scanner` is a public route that only matches under that
* base path. The backend advertises `frontendUrl` as a bare origin (no
* subpath), so the generated QR URL must still carry the base path. Dropping it
* sent phones to the auth-gated catch-all route / login page.
*/
import { describe, test, expect } from "vitest";
import { buildMobileScannerUrl } from "@app/utils/mobileScannerUrl";
const sessionId = "abc-123";
describe("buildMobileScannerUrl", () => {
test("origin-only frontendUrl keeps the app base path (SaaS web regression)", () => {
expect(
buildMobileScannerUrl({
configuredUrl: "https://app.stirlingpdf.com",
sessionId,
origin: "https://app.stirlingpdf.com",
basePath: "/app",
}),
).toBe("https://app.stirlingpdf.com/app/mobile-scanner?session=abc-123");
});
test("origin-only frontendUrl with a trailing slash keeps the app base path", () => {
expect(
buildMobileScannerUrl({
configuredUrl: "https://app.stirlingpdf.com/",
sessionId,
origin: "https://app.stirlingpdf.com",
basePath: "/app",
}),
).toBe("https://app.stirlingpdf.com/app/mobile-scanner?session=abc-123");
});
test("configured URL that already carries the subpath is used verbatim (no doubled base)", () => {
expect(
buildMobileScannerUrl({
configuredUrl: "https://app.stirlingpdf.com/app",
sessionId,
origin: "https://elsewhere.example",
basePath: "/app",
}),
).toBe("https://app.stirlingpdf.com/app/mobile-scanner?session=abc-123");
});
test("configured URL subpath with trailing slash is normalized", () => {
expect(
buildMobileScannerUrl({
configuredUrl: "https://host.example/app/",
sessionId,
origin: "https://host.example",
basePath: "",
}),
).toBe("https://host.example/app/mobile-scanner?session=abc-123");
});
test("no base path and no configured URL uses the current origin", () => {
expect(
buildMobileScannerUrl({
configuredUrl: "",
sessionId,
origin: "http://localhost:5173",
basePath: "",
}),
).toBe("http://localhost:5173/mobile-scanner?session=abc-123");
});
test("empty configured URL falls back to origin + base path", () => {
expect(
buildMobileScannerUrl({
configuredUrl: " ",
sessionId,
origin: "https://app.stirlingpdf.com",
basePath: "/app",
}),
).toBe("https://app.stirlingpdf.com/app/mobile-scanner?session=abc-123");
});
test("custom port (LAN/desktop) is preserved", () => {
expect(
buildMobileScannerUrl({
configuredUrl: "http://192.168.1.50:8080",
sessionId,
origin: "http://localhost:8080",
basePath: "",
}),
).toBe("http://192.168.1.50:8080/mobile-scanner?session=abc-123");
});
test("invalid configured URL falls back to the current origin + base path", () => {
expect(
buildMobileScannerUrl({
configuredUrl: "not a url",
sessionId,
origin: "https://app.stirlingpdf.com",
basePath: "/app",
}),
).toBe("https://app.stirlingpdf.com/app/mobile-scanner?session=abc-123");
});
test("non-http(s) configured URL is ignored (no javascript: injection)", () => {
expect(
buildMobileScannerUrl({
configuredUrl: "javascript:alert(1)",
sessionId,
origin: "https://app.stirlingpdf.com",
basePath: "/app",
}),
).toBe("https://app.stirlingpdf.com/app/mobile-scanner?session=abc-123");
});
});
@@ -0,0 +1,47 @@
/**
* Build the URL a phone opens (via the QR code) to reach the SPA's
* `/mobile-scanner` route.
*
* That route is a public, top-level route. It lives under the app's base path,
* which is the router's `basename`. If the generated URL omits the base path,
* the phone loads a path the router can't match, falls through to the
* auth-gated catch-all route, and gets bounced to the login page. So the base
* path must always be present.
*
* A configured `server_url`/`frontendUrl` supplies the host the phone should
* reach (desktop / LAN / reverse proxy):
* - origin only (no subpath): apply the app's base path. The backend's
* `resolveFrontendUrl` advertises a bare origin with no subpath, so this is
* the common SaaS web case (frontend served under e.g. `/app`).
* - already carries a subpath: it points at the target SPA's base directly,
* so use it verbatim and do not add the base path again (no doubled base).
*
* With no usable configured URL, fall back to the current origin + base path.
*/
export function buildMobileScannerUrl(params: {
configuredUrl: string;
sessionId: string;
origin: string;
basePath: string;
}): string {
const { configuredUrl, sessionId, origin, basePath } = params;
const query = `?session=${sessionId}`;
const route = `${basePath}/mobile-scanner`;
const trimmed = configuredUrl.trim();
if (trimmed) {
try {
const parsed = new URL(trimmed);
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
const subpath = parsed.pathname.replace(/\/+$/, "");
return subpath
? `${parsed.origin}${subpath}/mobile-scanner${query}`
: `${parsed.origin}${route}${query}`;
}
} catch {
// invalid configured URL — fall through to the current-origin default
}
}
return `${origin}${route}${query}`;
}
@@ -1,53 +1,21 @@
import { NavKey } from "@app/components/shared/config/types";
import { stripBasePath, withBasePath } from "@app/constants/app";
/**
* Navigate to a specific settings section
*
* @param section - The settings section key to navigate to
*
* @example
* // Navigate to People section
* navigateToSettings('people');
*
* // Navigate to Admin Premium section
* navigateToSettings('adminPremium');
*/
/** Push the URL for a settings section and notify listeners. */
export function navigateToSettings(section: NavKey) {
const basePath = window.location.pathname.split("/settings")[0] || "";
const newPath = `${basePath}/settings/${section}`;
const newPath = withBasePath(`/settings/${section}`);
window.history.pushState({}, "", newPath);
// Trigger a popstate event to notify components
window.dispatchEvent(new PopStateEvent("popstate"));
}
/**
* Get the URL path for a settings section
* Useful for creating links
*
* @param section - The settings section key
* @returns The URL path for the settings section
*
* @example
* <a href={getSettingsUrl('people')}>Go to People Settings</a>
* // Returns: "/settings/people"
*/
/** URL for a settings section (subpath-aware). */
export function getSettingsUrl(section: NavKey): string {
return `/settings/${section}`;
return withBasePath(`/settings/${section}`);
}
/**
* Check if currently viewing a settings section
*
* @param section - Optional section key to check for specific section
* @returns True if in settings (and matching specific section if provided)
*/
/** Whether the current URL is in /settings (optionally a specific section). */
export function isInSettings(section?: NavKey): boolean {
const pathname = window.location.pathname;
if (!section) {
return pathname.startsWith("/settings");
}
const pathname = stripBasePath(window.location.pathname);
if (!section) return pathname.startsWith("/settings");
return pathname === `/settings/${section}`;
}
@@ -0,0 +1,81 @@
import { describe, it, expect } from "vitest";
import { filterToolRegistryByQuery } from "@app/utils/toolSearch";
import {
ToolCategoryId,
SubcategoryId,
ToolRegistry,
ToolRegistryEntry,
} from "@app/data/toolsTaxonomy";
function makeEntry(name: string, tags: string): ToolRegistryEntry {
return {
icon: null,
name,
component: null,
description: "",
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.GENERAL,
automationSettings: null,
synonyms: tags.split(","),
};
}
// Real names and tags from the en-GB translations, chosen because they share
// letters with "rotat"/"rotate" and stress the partial-query filtering.
const registry: Partial<ToolRegistry> = {
rotate: makeEntry(
"Rotate",
"turn,flip,orient,rotate,orientation,landscape,portrait,90 degrees,180 degrees,clockwise,anticlockwise,counter-clockwise,fix orientation",
),
addPassword: makeEntry(
"Add Password",
"encrypt,password,lock,secure,protect,security,encryption,safeguard,confidential,private,restrict access",
),
changeMetadata: makeEntry(
"Change Metadata",
"edit,modify,update,metadata,properties,document properties,author,title,subject,keywords,creator,producer,info,document info,file properties",
),
scannerEffect: makeEntry(
"Scanner Effect",
"scan,simulate,create,fake scan,look scanned,scanner effect,make look scanned,photocopy effect,simulate scanner,realistic scan",
),
adjustContrast: makeEntry(
"Adjust Colours/Contrast",
"contrast,brightness,saturation,adjust colors,color correction,enhance,lighten,darken,improve quality,color balance,hue,vibrance",
),
annotate: makeEntry(
"Annotate",
"annotate,highlight,draw,markup,comment,notes,review,redline,feedback,markup tools,sticky notes,shapes,arrows,text box,freehand",
),
redact: makeEntry(
"Redact",
"censor,blackout,hide,redact,redaction,black out,block out,remove sensitive,hide text,privacy,confidential,GDPR,PII,sensitive data,permanently remove,cover up,legal redaction",
),
compare: makeEntry(
"Compare",
"difference,compare,diff,compare PDFs,compare documents,find differences,show differences,changes,what changed,track changes,revisions,version compare,side by side,contrast,delta",
),
};
function idsFor(query: string): string[] {
return filterToolRegistryByQuery(registry, query).map(({ item: [id] }) => id);
}
describe("filterToolRegistryByQuery", () => {
it("returns everything for an empty query", () => {
expect(idsFor("")).toHaveLength(Object.keys(registry).length);
});
it("matches tools by tag substring", () => {
expect(idsFor("protect")).toEqual(["addPassword"]);
expect(idsFor("orientation")).toEqual(["rotate"]);
});
it("only returns Rotate for every prefix of 'rotate'", () => {
// Prefixes of "rotate" must not leak tools that merely share letters
// (Redact, Annotate, Compare, Adjust Colours/Contrast, etc.).
expect(idsFor("rota")).toEqual(["rotate"]);
expect(idsFor("rotat")).toEqual(["rotate"]);
expect(idsFor("rotate")).toEqual(["rotate"]);
});
});
@@ -390,12 +390,11 @@ self.addEventListener(
let compDoc: PDFDocumentProxy | null = null;
try {
// `CanvasFactory`/`FilterFactory` are supported at runtime but not declared on legacy types.
// pdfjs-dist 5.x renamed the option to `CanvasFactory` (capital C); without a FilterFactory
// the default DOMFilterFactory crashes in a worker calling document.createElementNS.
// Resolve CMap/standard-font URLs against the worker's own origin. Vite copies these
// directories from pdfjs-dist at build time via viteStaticCopy (see vite.config.ts).
const assetsBase = new URL("/pdfjs/", self.location.origin).toString();
// Resolve pdfjs CMap/standard-font URLs against the worker's origin + subpath.
const assetsBase = new URL(
`${import.meta.env.BASE_URL}pdfjs/`,
self.location.origin,
).toString();
const loaderOpts = (data: ArrayBuffer) =>
({
data,
@@ -68,12 +68,16 @@ export const useConfigNavSections = (
],
};
// In local mode only show Preferences + Connection Mode — everything else
// requires a server and will 500 or show irrelevant admin UI.
// In local mode only show Preferences + Connection Mode + Legal — everything
// else requires a server and will 500 or show irrelevant admin UI.
if (isLocalMode) {
const result: ConfigNavSection[] = [];
if (sections.length > 0) result.push(sections[0]);
result.push(connectionModeSection);
const legalSection = sections.find((section) =>
section.items.some((item) => item.key === "legal"),
);
if (legalSection) result.push(legalSection);
return result;
}
@@ -10,15 +10,9 @@ import {
Badge,
ActionIcon,
Menu,
List,
ThemeIcon,
Modal,
CloseButton,
Anchor,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useSaaSTeam } from "@app/contexts/SaaSTeamContext";
import { useSaaSBilling } from "@app/contexts/SaasBillingContext";
import LocalIcon from "@app/components/shared/LocalIcon";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
import apiClient from "@app/services/apiClient";
@@ -43,15 +37,10 @@ export function SaaSTeamsSection() {
refreshTeams,
} = useSaaSTeam();
// Check Pro status via billing context
const { tier } = useSaaSBilling();
const isPro = tier !== "free";
const [inviteEmail, setInviteEmail] = useState("");
const [inviting, setInviting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [featuresModalOpened, setFeaturesModalOpened] = useState(false);
// Team rename state
const [isEditingName, setIsEditingName] = useState(false);
@@ -71,12 +60,6 @@ export function SaaSTeamsSection() {
return () => clearInterval(interval);
}, []); // Only run on mount/unmount
const navigateToPlan = () => {
window.dispatchEvent(
new CustomEvent("appConfig:navigate", { detail: { key: "planBilling" } }),
);
};
const handleInvite = async (e: React.FormEvent) => {
e.preventDefault();
if (!inviteEmail.trim()) return;
@@ -321,156 +304,6 @@ export function SaaSTeamsSection() {
</Group>
</div>
{/* Upgrade Banner for Free Users */}
{isPersonalTeam && !isPro && (
<Alert
color="blue"
icon={<LocalIcon icon="info" width={16} height={16} />}
>
<Group justify="space-between" align="center">
<div>
<Text fw={500} size="sm">
{t(
"team.upgrade.title",
"Upgrade to Pro to unlock team features",
)}
</Text>
<Text size="xs" c="dimmed" mt={2}>
{t(
"team.upgrade.description",
"Invite members, share credits, and more.",
)}{" "}
<Anchor
size="xs"
onClick={() => setFeaturesModalOpened(true)}
style={{ cursor: "pointer" }}
>
{t("common.learnMore", "Learn more")}
</Anchor>
</Text>
</div>
<Button size="sm" variant="light" onClick={navigateToPlan}>
{t("team.upgrade.button", "Upgrade to Pro")}
</Button>
</Group>
</Alert>
)}
{/* Team Features Modal */}
<Modal
opened={featuresModalOpened}
onClose={() => setFeaturesModalOpened(false)}
size="md"
centered
padding="xl"
withCloseButton={false}
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<div style={{ position: "relative" }}>
<CloseButton
onClick={() => setFeaturesModalOpened(false)}
size="lg"
style={{
position: "absolute",
top: -8,
right: -8,
zIndex: 1,
}}
/>
<Stack gap="lg" pt="md">
{/* Header */}
<Stack gap="md" align="center">
<Badge size="lg" color="violet" variant="filled">
{t("team.features.badge", "PRO FEATURE")}
</Badge>
<Text size="xl" fw={700} ta="center">
{t("team.features.title", "Team Collaboration")}
</Text>
<Text size="sm" c="dimmed" ta="center">
{t(
"team.features.subtitle",
"Upgrade to Pro and unlock powerful team features",
)}
</Text>
</Stack>
{/* Features List */}
<List
spacing="md"
size="sm"
icon={
<ThemeIcon color="violet" size={24} radius="xl" variant="light">
<LocalIcon icon="check" width={14} height={14} />
</ThemeIcon>
}
>
<List.Item>
<Text fw={500}>
{t("team.features.invite.title", "Invite team members")}
</Text>
<Text size="xs" c="dimmed">
{t(
"team.features.invite.description",
"Add unlimited users with additional seat purchases",
)}
</Text>
</List.Item>
<List.Item>
<Text fw={500}>
{t(
"team.features.credits.title",
"Share credits across your team",
)}
</Text>
<Text size="xs" c="dimmed">
{t(
"team.features.credits.description",
"Pool resources for collaborative work",
)}
</Text>
</List.Item>
<List.Item>
<Text fw={500}>
{t(
"team.features.dashboard.title",
"Team management dashboard",
)}
</Text>
<Text size="xs" c="dimmed">
{t(
"team.features.dashboard.description",
"Control permissions, monitor usage, and manage members",
)}
</Text>
</List.Item>
<List.Item>
<Text fw={500}>
{t("team.features.billing.title", "Centralized billing")}
</Text>
<Text size="xs" c="dimmed">
{t(
"team.features.billing.description",
"One invoice for all team seats and usage",
)}
</Text>
</List.Item>
</List>
{/* CTA Button */}
<Button
size="md"
fullWidth
onClick={() => {
setFeaturesModalOpened(false);
navigateToPlan();
}}
>
{t("team.features.viewPlans", "View Pro Plans")}
</Button>
</Stack>
</div>
</Modal>
{/* Error/Success Messages */}
{error && (
<Alert color="red" onClose={() => setError(null)} withCloseButton>
@@ -484,8 +317,8 @@ export function SaaSTeamsSection() {
</Alert>
)}
{/* Invite Members (Pro Users) */}
{isTeamLeader && isPro && (
{/* Invite Members */}
{isTeamLeader && (
<div>
<Text fw={600} size="md" mb="sm">
{t("team.invite.title", "Invite Team Member")}
+7 -6
View File
@@ -26,8 +26,9 @@ import "@app/styles/auth-theme.css";
// Import file ID debugging helpers (development only)
import "@app/utils/fileIdSafety";
// Minimal providers for mobile scanner - no API calls, no authentication
function MobileScannerProviders({ children }: { children: React.ReactNode }) {
// Minimal providers for public, no-auth pages (mobile scanner, participant
// signing) - no API calls, no authentication
function PublicRouteProviders({ children }: { children: React.ReactNode }) {
return (
<PreferencesProvider>
<RainbowThemeProvider>{children}</RainbowThemeProvider>
@@ -50,9 +51,9 @@ export default function App() {
<Route
path="/mobile-scanner"
element={
<MobileScannerProviders>
<PublicRouteProviders>
<MobileScannerPage />
</MobileScannerProviders>
</PublicRouteProviders>
}
/>
@@ -60,9 +61,9 @@ export default function App() {
<Route
path="/workflow/sign/:token"
element={
<MobileScannerProviders>
<PublicRouteProviders>
<ParticipantViewPage />
</MobileScannerProviders>
</PublicRouteProviders>
}
/>
@@ -10,6 +10,7 @@ import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { springAuth } from "@app/auth/springAuthClient";
import { clearPlatformAuthOnLoginInit } from "@app/extensions/authSessionCleanup";
import { stripBasePath } from "@app/constants/app";
import type {
Session,
User,
@@ -33,6 +34,8 @@ interface AuthContextType {
* consumers can fall back to whatever makes sense.
*/
displayName: string | null;
/** Whether the current session is an anonymous (login-disabled) one. */
isAnonymous: boolean;
loading: boolean;
error: AuthError | null;
signOut: () => Promise<void>;
@@ -60,6 +63,7 @@ const AuthContext = createContext<AuthContextType>({
session: null,
user: null,
displayName: null,
isAnonymous: false,
loading: true,
error: null,
signOut: async () => {},
@@ -167,7 +171,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
// Clear any platform-specific cached auth on login page init.
if (
typeof window !== "undefined" &&
window.location.pathname.startsWith("/login")
stripBasePath(window.location.pathname).startsWith("/login")
) {
await clearPlatformAuthOnLoginInit();
}
@@ -291,6 +295,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
session,
user,
displayName: deriveDisplayName(user, t),
isAnonymous: user?.is_anonymous === true,
loading,
error,
signOut,
@@ -16,11 +16,11 @@ export function AppProviders({
appConfigProviderProps,
}: AppProvidersProps) {
return (
<CoreAppProviders
appConfigRetryOptions={appConfigRetryOptions}
appConfigProviderProps={appConfigProviderProps}
>
<AuthProvider>
<AuthProvider>
<CoreAppProviders
appConfigRetryOptions={appConfigRetryOptions}
appConfigProviderProps={appConfigProviderProps}
>
<LicenseProvider>
<UpdateSeatsProvider>
<ServerExperienceProvider>
@@ -31,7 +31,7 @@ export function AppProviders({
</ServerExperienceProvider>
</UpdateSeatsProvider>
</LicenseProvider>
</AuthProvider>
</CoreAppProviders>
</CoreAppProviders>
</AuthProvider>
);
}
@@ -1,483 +0,0 @@
/* ─── Chat takeover ─────────────────────────────────────────────────────── */
.agents-takeover {
position: fixed;
right: 0;
top: 0;
bottom: 0;
display: flex;
flex-direction: column;
background: var(--bg-toolbar);
border-left: 1px solid var(--border-subtle);
view-transition-name: agents-rail;
}
.agents-takeover__resize-handle {
position: absolute;
left: -3px;
top: 0;
bottom: 0;
width: 7px;
cursor: col-resize;
z-index: 10;
touch-action: none;
}
.agents-takeover__resize-handle::after {
content: "";
position: absolute;
left: 3px;
top: 0;
bottom: 0;
width: 1px;
background: transparent;
transition:
background 150ms ease,
width 150ms ease,
left 150ms ease;
}
.agents-takeover__resize-handle:hover::after {
left: 2px;
width: 3px;
background: var(--mantine-color-blue-4);
opacity: 0.6;
}
/* ─── Sidebar agents section ─────────────────────────────────────────────── */
.agents-section {
padding: 0.5rem 0.75rem 0.875rem;
border-bottom: 1px solid var(--border-subtle);
background: var(--bg-toolbar);
flex-shrink: 0;
view-transition-name: agents-rail;
}
/* Hero button — real Stirling agent */
.agent-button {
display: block;
width: 100%;
padding: 0.75rem;
border: 1px solid
color-mix(in srgb, var(--mantine-color-blue-5) 40%, var(--border-subtle));
border-radius: 0.625rem;
background: linear-gradient(
135deg,
color-mix(
in srgb,
var(--mantine-color-blue-6) 6%,
var(--mantine-color-body)
)
0%,
var(--mantine-color-body) 100%
);
transition:
background 150ms ease-out,
border-color 150ms ease-out;
}
.agent-button:hover {
border-color: color-mix(
in srgb,
var(--mantine-color-blue-5) 65%,
var(--border-subtle)
);
background: linear-gradient(
135deg,
color-mix(
in srgb,
var(--mantine-color-blue-6) 12%,
var(--mantine-color-default-hover)
)
0%,
var(--mantine-color-default-hover) 100%
);
}
.agent-button__logo {
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--mantine-color-blue-filled);
flex-shrink: 0;
position: relative;
}
/* Coming-soon agent rows */
.agents-sidebar-list {
display: flex;
flex-direction: column;
gap: 0.375rem;
margin-top: 0.375rem;
}
.agent-button--coming-soon {
display: block;
width: 100%;
padding: 0.75rem;
border: 1px solid var(--border-subtle);
border-radius: 0.625rem;
background: var(--mantine-color-body);
cursor: not-allowed !important;
opacity: 0.65;
transition:
opacity 180ms ease-out,
transform 180ms ease-out,
border-color 120ms ease-out;
}
@starting-style {
.agent-button--coming-soon {
opacity: 0;
transform: translateY(5px);
}
}
.agent-button--coming-soon:hover {
opacity: 0.85;
border-color: color-mix(
in srgb,
var(--mantine-color-gray-4) 60%,
var(--border-subtle)
);
background: var(--mantine-color-default-hover);
}
.agent-button__icon-plain {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
color: var(--mantine-color-dimmed);
flex-shrink: 0;
}
.agents-view-all {
all: unset;
display: block;
width: 100%;
margin-top: 0.375rem;
padding: 0.25rem 0.625rem;
font-size: 0.75rem;
font-weight: 500;
color: var(--mantine-color-dimmed);
text-align: center;
cursor: pointer;
opacity: 0.7;
border-radius: 0.375rem;
transition: opacity 120ms ease-out;
box-sizing: border-box;
}
.agents-view-all:hover {
opacity: 1;
}
/* Dark mode — sidebar */
[data-mantine-color-scheme="dark"] .agent-button--coming-soon {
background: transparent;
border-color: color-mix(
in srgb,
var(--mantine-color-dark-4) 60%,
transparent
);
}
[data-mantine-color-scheme="dark"] .agent-button--coming-soon:hover {
border-color: var(--mantine-color-dark-3);
background: var(--mantine-color-dark-6);
}
[data-mantine-color-scheme="dark"] .agent-button {
border-color: color-mix(
in srgb,
var(--mantine-color-blue-7) 55%,
var(--border-subtle)
);
background: linear-gradient(
135deg,
color-mix(in srgb, var(--mantine-color-blue-9) 35%, transparent) 0%,
transparent 100%
);
}
[data-mantine-color-scheme="dark"] .agent-button:hover {
border-color: color-mix(
in srgb,
var(--mantine-color-blue-5) 60%,
var(--border-subtle)
);
background: linear-gradient(
135deg,
color-mix(
in srgb,
var(--mantine-color-blue-8) 45%,
rgba(255, 255, 255, 0.04)
)
0%,
rgba(255, 255, 255, 0.04) 100%
);
}
[data-mantine-color-scheme="dark"] .agent-button__logo {
color: var(--mantine-color-blue-3, var(--mantine-color-blue-filled));
}
/* ─── Collapsed-rail agent button ────────────────────────────────────────── */
.agents-collapsed-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
padding: 0;
border: none;
border-radius: 0.5rem;
background: transparent;
color: var(--mantine-color-blue-filled);
cursor: pointer;
transition: background 120ms ease-out;
position: relative;
}
.agents-collapsed-btn:hover {
background: var(--mantine-color-default-hover);
}
/* ─── Running / in-progress status dot ──────────────────────────────────── */
.agent-status-dot {
position: absolute;
bottom: 0;
right: 0;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--mantine-color-blue-5);
border: 1.5px solid var(--mantine-color-body);
animation: agent-dot-pulse 2.4s ease-in-out infinite;
pointer-events: none;
}
@keyframes agent-dot-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.45;
}
}
/* Blue outline on hero Stirling button when agent is running */
.agent-button.agent-button--running {
border-color: color-mix(
in srgb,
var(--mantine-color-blue-5) 60%,
var(--border-subtle)
);
}
[data-mantine-color-scheme="dark"] .agent-button.agent-button--running {
border-color: color-mix(
in srgb,
var(--mantine-color-blue-4) 55%,
var(--border-subtle)
);
}
@media (prefers-reduced-motion: reduce) {
.agent-status-dot {
animation: none;
}
}
/* ─── Fullscreen hero card ───────────────────────────────────────────────── */
/*
* .tool-panel__fullscreen-group--agents gives the gradient border via the
* padding-box / border-box trick. We only override the padding-box with a
* very light tint so the card doesn't read as a colourful surface.
*/
.agents-hero {
padding: 1.25rem 1.5rem 1.5rem;
border-radius: 1rem;
background:
linear-gradient(
125deg,
color-mix(in srgb, var(--mantine-color-blue-1) 35%, white) 0%,
color-mix(in srgb, var(--mantine-color-violet-1) 45%, white) 50%,
color-mix(in srgb, var(--mantine-color-pink-0) 35%, white) 100%
)
padding-box,
linear-gradient(
135deg,
var(--mantine-color-blue-6) 0%,
var(--mantine-color-violet-6) 45%,
var(--mantine-color-pink-6) 100%
)
border-box;
}
/* Body: left CTA + right grid */
.agents-hero__body {
display: flex;
gap: 2rem;
align-items: flex-start;
}
/* ── Left CTA — plain content, only the button inside is interactive ── */
.agents-hero__cta {
display: flex;
flex-direction: column;
gap: 0;
flex: 0 0 18rem;
box-sizing: border-box;
padding: 0.25rem 0;
}
.agents-hero__cta-logo {
color: var(--mantine-color-blue-filled);
margin-bottom: 0.625rem;
}
.agents-hero__cta-headline {
font-size: 1.375rem;
}
.agents-hero__cta-btn {
all: unset;
margin-top: 1.25rem;
display: inline-flex;
align-items: center;
padding: 0.4375rem 1rem;
border-radius: 0.5rem;
background: var(--mantine-color-blue-6);
color: white;
font-size: 0.8125rem;
font-weight: 600;
line-height: 1;
width: fit-content;
cursor: pointer;
transition: background 0.15s ease;
}
.agents-hero__cta-btn:hover {
background: var(--mantine-color-blue-7);
}
.agents-hero__cta-btn:focus-visible {
outline: 2px solid var(--mantine-color-blue-5);
outline-offset: 3px;
border-radius: 0.5rem;
}
/* ── Right 2×3 grid — sizes to content, pushed to the right ── */
.agents-hero__grid {
flex: 0 0 auto;
margin-left: auto;
display: grid;
/* auto columns: each column = widest item in that column */
grid-template-columns: repeat(2, auto);
gap: 0.625rem;
align-content: start;
align-self: start;
}
/* Solid white cards — no opacity so the white is opaque */
.agents-hero__grid-item {
all: unset;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 0.875rem;
border: 1px solid rgba(0, 0, 0, 0.07);
border-radius: 0.75rem;
background: var(--mantine-color-body);
cursor: not-allowed;
box-sizing: border-box;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.agents-hero__grid-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--mantine-color-blue-5);
}
.agents-hero__grid-body {
min-width: 0;
flex: 1;
}
/* ── Dark mode — fullscreen ── */
[data-mantine-color-scheme="dark"] .agents-hero {
background:
linear-gradient(
125deg,
color-mix(in srgb, var(--mantine-color-blue-9) 55%, #1a1b2e) 0%,
color-mix(in srgb, var(--mantine-color-violet-9) 45%, #1a1b2e) 50%,
color-mix(in srgb, var(--mantine-color-pink-9) 40%, #1a1b2e) 100%
)
padding-box,
linear-gradient(
135deg,
var(--mantine-color-blue-6) 0%,
var(--mantine-color-violet-6) 45%,
var(--mantine-color-pink-6) 100%
)
border-box;
}
[data-mantine-color-scheme="dark"] .agents-hero__cta-logo {
color: var(--mantine-color-blue-3);
}
[data-mantine-color-scheme="dark"] .agents-hero__grid-item {
background: var(--fullscreen-bg-item);
border-color: var(--fullscreen-border-subtle-70);
box-shadow: none;
}
[data-mantine-color-scheme="dark"] .agents-hero__grid-icon {
color: var(--mantine-color-blue-3);
}
/* ─── agents-rail view transition — slide up ─────────────────────────────── */
::view-transition-old(agents-rail) {
animation: vt-agents-out 150ms ease-out forwards;
}
::view-transition-new(agents-rail) {
animation: vt-agents-in 260ms cubic-bezier(0.22, 0.61, 0.36, 1) forwards;
}
@keyframes vt-agents-out {
to {
opacity: 0;
}
}
@keyframes vt-agents-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
::view-transition-old(agents-rail),
::view-transition-new(agents-rail) {
animation-duration: 1ms !important;
}
}
@@ -1,275 +0,0 @@
import { useState } from "react";
import type { ElementType } from "react";
import { Box, Group, Text, UnstyledButton } from "@mantine/core";
import TableChartRoundedIcon from "@mui/icons-material/TableChartRounded";
import SummarizeRoundedIcon from "@mui/icons-material/SummarizeRounded";
import VisibilityOffRoundedIcon from "@mui/icons-material/VisibilityOffRounded";
import GavelRoundedIcon from "@mui/icons-material/GavelRounded";
import AssignmentRoundedIcon from "@mui/icons-material/AssignmentRounded";
import CodeRoundedIcon from "@mui/icons-material/CodeRounded";
import { useTranslation } from "react-i18next";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useChat } from "@app/components/chat/ChatContext";
import { Tooltip as AppTooltip } from "@app/components/shared/Tooltip";
import { StirlingLogoOutline } from "@app/components/agents/StirlingLogoOutline";
import { withViewTransition } from "@app/utils/viewTransition";
import "@app/components/agents/AgentsPanel.css";
interface ComingSoonAgent {
id: string;
nameKey: string;
descriptionKey: string;
Icon: ElementType;
}
const COMING_SOON_AGENTS: ComingSoonAgent[] = [
{
id: "data-extraction",
nameKey: "agents.data_extraction_name",
descriptionKey: "agents.data_extraction_description",
Icon: TableChartRoundedIcon,
},
{
id: "doc-summary",
nameKey: "agents.doc_summary_name",
descriptionKey: "agents.doc_summary_description",
Icon: SummarizeRoundedIcon,
},
{
id: "auto-redaction",
nameKey: "agents.auto_redaction_name",
descriptionKey: "agents.auto_redaction_description",
Icon: VisibilityOffRoundedIcon,
},
{
id: "compliance",
nameKey: "agents.compliance_name",
descriptionKey: "agents.compliance_description",
Icon: GavelRoundedIcon,
},
{
id: "form-filler",
nameKey: "agents.form_filler_name",
descriptionKey: "agents.form_filler_description",
Icon: AssignmentRoundedIcon,
},
{
id: "pdf-to-markdown",
nameKey: "agents.pdf_to_markdown_name",
descriptionKey: "agents.pdf_to_markdown_description",
Icon: CodeRoundedIcon,
},
];
export function useAgentsEnabled(): boolean {
const { config } = useAppConfig();
return Boolean(config?.aiEngineEnabled);
}
export function useAgentChatOpen(): boolean {
const { isOpen } = useChat();
return isOpen;
}
const PREVIEW_COUNT = 3;
/** Sidebar agents section — Stirling as hero CTA, coming-soon agents below. */
export function AgentsSection() {
const { t } = useTranslation();
const { isOpen, setOpen, isLoading } = useChat();
const enabled = useAgentsEnabled();
const [showAll, setShowAll] = useState(false);
if (!enabled || isOpen) return null;
const comingSoonLabel = t("agents.coming_soon", "Coming soon");
const visibleAgents = showAll
? COMING_SOON_AGENTS
: COMING_SOON_AGENTS.slice(0, PREVIEW_COUNT);
return (
<Box className="agents-section" w="100%">
{/* Main Stirling agent — real and clickable */}
<UnstyledButton
className={`agent-button agent-button--hero${isLoading ? " agent-button--running" : ""}`}
onClick={() => withViewTransition(() => setOpen(true))}
aria-label={t("agents.stirling_name", "Stirling")}
>
<Group gap="sm" wrap="nowrap" align="center">
<Box className="agent-button__logo">
<StirlingLogoOutline size={28} />
{isLoading && <span className="agent-status-dot" />}
</Box>
<Box style={{ minWidth: 0, flex: 1 }}>
<Text size="sm" fw={600} truncate>
{t("agents.stirling_name", "Stirling")}
</Text>
<Text size="xs" c="dimmed" truncate>
{isLoading
? t("agents.stirling_running", "Running...")
: t(
"agents.stirling_description",
"Your general-purpose PDF assistant",
)}
</Text>
</Box>
</Group>
</UnstyledButton>
{/* Coming-soon agents */}
<div className="agents-sidebar-list">
{visibleAgents.map(({ id, nameKey, descriptionKey, Icon }) => (
<AppTooltip
key={id}
content={comingSoonLabel}
position="left"
arrow
delay={0}
>
<UnstyledButton
className="agent-button agent-button--coming-soon"
aria-disabled="true"
tabIndex={-1}
>
<Group gap="sm" wrap="nowrap" align="center">
<Box className="agent-button__icon-plain">
<Icon sx={{ fontSize: "1.1rem" }} />
</Box>
<Box style={{ minWidth: 0, flex: 1 }}>
<Text size="sm" fw={500} truncate>
{t(nameKey)}
</Text>
<Text size="xs" c="dimmed" truncate>
{t(descriptionKey)}
</Text>
</Box>
</Group>
</UnstyledButton>
</AppTooltip>
))}
</div>
{!showAll ? (
<button
type="button"
className="agents-view-all"
onClick={() => withViewTransition(() => setShowAll(true))}
>
{t("agents.view_all", "View all agents")}
</button>
) : (
<button
type="button"
className="agents-view-all"
onClick={() => withViewTransition(() => setShowAll(false))}
>
{t("agents.show_less", "Show less")}
</button>
)}
</Box>
);
}
/** Icon-only agent button in the collapsed (minimised) right rail. */
export function AgentsCollapsedButton({ onExpand }: { onExpand: () => void }) {
const { t } = useTranslation();
const { setOpen, isLoading } = useChat();
const enabled = useAgentsEnabled();
if (!enabled) return null;
const label = t("agents.stirling_tooltip", "Stirling agent");
return (
<AppTooltip content={label} position="left" arrow delay={300}>
<UnstyledButton
onClick={() => {
onExpand();
setOpen(true);
}}
aria-label={label}
className="agents-collapsed-btn"
>
<StirlingLogoOutline size={22} />
{isLoading && <span className="agent-status-dot" />}
</UnstyledButton>
</AppTooltip>
);
}
/**
* Fullscreen hero card Stirling CTA on the left, 2×3 coming-soon grid on
* the right. Matches the gradient border of the other fullscreen category cards.
*/
export function AgentsFullscreenSection() {
const { t } = useTranslation();
const { isOpen, setOpen } = useChat();
const enabled = useAgentsEnabled();
if (!enabled || isOpen) return null;
const comingSoonLabel = t("agents.coming_soon", "Coming soon");
return (
<section
className="agents-hero tool-panel__fullscreen-group--agents"
aria-label={t("agents.section_title", "Agents")}
>
<div className="agents-hero__body">
{/* Left: Stirling content — only the button is interactive */}
<div className="agents-hero__cta">
<div className="agents-hero__cta-logo">
<StirlingLogoOutline size={36} />
</div>
<Text className="agents-hero__cta-headline" fw={700} lh={1.2} mt="xs">
{t("agents.stirling_full_name", "Stirling General Agent")}
</Text>
<Text size="sm" c="dimmed" mt={8} lh={1.55}>
{t(
"agents.stirling_long_description",
"General purpose PDF assistant that can run tools, create PDFs and extract insights from your documents.",
)}
</Text>
<button
type="button"
className="agents-hero__cta-btn"
onClick={() => withViewTransition(() => setOpen(true))}
>
{t("agents.start_chat", "Start chatting")}
</button>
</div>
{/* Right: 2×3 grid of coming-soon agents */}
<div className="agents-hero__grid">
{COMING_SOON_AGENTS.map(({ id, nameKey, descriptionKey, Icon }) => (
<AppTooltip
key={id}
content={comingSoonLabel}
position="top"
arrow
delay={0}
>
<button
type="button"
className="agents-hero__grid-item"
aria-disabled="true"
>
<span className="agents-hero__grid-icon">
<Icon sx={{ fontSize: "1rem" }} />
</span>
<div className="agents-hero__grid-body">
<Text size="sm" fw={500} truncate>
{t(nameKey)}
</Text>
<Text size="xs" c="dimmed" truncate>
{t(descriptionKey)}
</Text>
</div>
</button>
</AppTooltip>
))}
</div>
</div>
</section>
);
}
@@ -10,7 +10,9 @@ import { useTranslation } from "react-i18next";
import { generateId } from "@app/utils/generateId";
import { useAllFiles, useFileActions } from "@app/contexts/FileContext";
import apiClient from "@app/services/apiClient";
import { getApiBaseUrl } from "@app/services/apiClientConfig";
import { getAuthHeaders } from "@app/services/apiClientSetup";
import { dispatchPaygLimitReached } from "@app/services/usageLimitBridge";
import { createChildStub } from "@app/contexts/file/fileActions";
import {
createNewStirlingFileStub,
@@ -178,11 +180,29 @@ interface AiWorkflowResponse {
fileId?: string;
fileName?: string;
contentType?: string;
/**
* Structured error code when a tool call inside the workflow was blocked (e.g.
* PAYG_LIMIT_REACHED / FEATURE_DEGRADED). Present instead of a raw failure reason so the
* client can pop the usage-limit modal. See {@link isPaygLimitCode}.
*/
errorCode?: string;
/** From the blocking 402: true → over spending cap, false/absent → free allowance spent. */
errorSubscribed?: boolean;
}
/**
* Usage-limit sentinels the agent can surface (matching the saas EntitlementGuard / apiClient
* interceptor). When one of these is the result's errorCode, we open the usage-limit modal rather
* than render the failure as chat text.
*/
const PAYG_LIMIT_CODES = new Set(["PAYG_LIMIT_REACHED", "FEATURE_DEGRADED"]);
function isPaygLimitCode(code: string | null | undefined): boolean {
return code != null && PAYG_LIMIT_CODES.has(code);
}
interface ChatState {
messages: ChatMessage[];
isOpen: boolean;
isLoading: boolean;
progress: AiWorkflowProgress | null;
/** Ordered log of every progress event in the current request. UI shows the last N entries. */
@@ -199,8 +219,6 @@ type ChatAction =
| { type: "SET_LOADING"; loading: boolean }
| { type: "SET_PROGRESS"; progress: AiWorkflowProgress | null }
| { type: "APPEND_PROGRESS"; progress: AiWorkflowProgress }
| { type: "TOGGLE_OPEN" }
| { type: "SET_OPEN"; open: boolean }
| { type: "CLEAR" };
function chatReducer(state: ChatState, action: ChatAction): ChatState {
@@ -230,10 +248,6 @@ function chatReducer(state: ChatState, action: ChatAction): ChatState {
action.progress,
],
};
case "TOGGLE_OPEN":
return { ...state, isOpen: !state.isOpen };
case "SET_OPEN":
return { ...state, isOpen: action.open };
case "CLEAR":
return {
...state,
@@ -362,13 +376,10 @@ async function consumeSSEStream(
interface ChatContextValue {
messages: ChatMessage[];
isOpen: boolean;
isLoading: boolean;
progress: AiWorkflowProgress | null;
/** Ordered log of every progress event for the current in-flight request. */
progressLog: AiWorkflowProgress[];
toggleOpen: () => void;
setOpen: (open: boolean) => void;
sendMessage: (content: string) => Promise<void>;
cancelMessage: () => void;
/** Abort any in-flight request and reset the chat to an empty conversation. */
@@ -379,7 +390,6 @@ const ChatContext = createContext<ChatContextValue | null>(null);
const initialState: ChatState = {
messages: [],
isOpen: false,
isLoading: false,
progress: null,
progressLog: [],
@@ -465,11 +475,6 @@ export function ChatProvider({ children }: { children: ReactNode }) {
[fileActions, downloadFile],
);
const toggleOpen = useCallback(() => dispatch({ type: "TOGGLE_OPEN" }), []);
const setOpen = useCallback(
(open: boolean) => dispatch({ type: "SET_OPEN", open }),
[],
);
const cancelMessage = useCallback(() => {
abortRef.current?.abort();
}, []);
@@ -514,28 +519,59 @@ export function ChatProvider({ children }: { children: ReactNode }) {
formData.append(`conversationHistory[${i}].role`, message.role);
formData.append(`conversationHistory[${i}].content`, message.content);
});
const response = await fetch("/api/v1/ai/orchestrate/stream", {
method: "POST",
body: formData,
headers: getAuthHeaders(),
credentials: "include",
signal: controller.signal,
});
const response = await fetch(
`${getApiBaseUrl()}/api/v1/ai/orchestrate/stream`,
{
method: "POST",
body: formData,
headers: await getAuthHeaders(),
credentials: "include",
signal: controller.signal,
},
);
if (!response.ok) {
let detail: string | undefined;
let limitHandled = false;
try {
const body = await response.json();
detail =
body?.message ??
body?.detail ??
body?.error ??
(Array.isArray(body?.errors)
? body.errors[0]?.message
: undefined);
const code = typeof body?.error === "string" ? body.error : null;
// A 402 carrying a usage-limit sentinel means the agent call itself was gated.
// Fire the usage-limit modal (free → subscribe, subscribed → raise cap) and show a
// brief line below — not a generic "engine failed" error.
if (response.status === 402 && isPaygLimitCode(code)) {
dispatchPaygLimitReached(
typeof body?.subscribed === "boolean" ? body.subscribed : null,
);
limitHandled = true;
} else {
detail =
body?.message ??
body?.detail ??
body?.error ??
(Array.isArray(body?.errors)
? body.errors[0]?.message
: undefined);
}
} catch {
// non-JSON body — ignore
}
if (limitHandled) {
dispatch({ type: "SET_PROGRESS", progress: null });
dispatch({
type: "ADD_MESSAGE",
message: {
id: generateId(),
role: ChatRole.ASSISTANT,
content: t(
"chat.responses.usage_limit_reached",
"You've reached your usage limit. Check your plan options to keep going.",
),
timestamp: Date.now(),
},
});
return;
}
throw new Error(
detail ?? `AI engine request failed: ${response.status}`,
);
@@ -565,7 +601,20 @@ export function ChatProvider({ children }: { children: ReactNode }) {
onResult: (data) => {
receivedResult = true;
dispatch({ type: "SET_PROGRESS", progress: null });
const replyContent = formatWorkflowResponse(data, t);
// The agent's tool calls run server-side, so a usage-limit 402 surfaces here on the
// result (not via the apiClient interceptor that pops the modal for direct calls).
// Fire the matching modal and replace the raw "tool failed: 402…" reason with a
// brief, non-alarming line.
const isLimit = isPaygLimitCode(data.errorCode);
if (isLimit) {
dispatchPaygLimitReached(data.errorSubscribed ?? null);
}
const replyContent = isLimit
? t(
"chat.responses.usage_limit_reached",
"You've reached your usage limit. Check your plan options to keep going.",
)
: formatWorkflowResponse(data, t);
dispatch({
type: "ADD_MESSAGE",
message: {
@@ -649,12 +698,9 @@ export function ChatProvider({ children }: { children: ReactNode }) {
<ChatContext.Provider
value={{
messages: state.messages,
isOpen: state.isOpen,
isLoading: state.isLoading,
progress: state.progress,
progressLog: state.progressLog,
toggleOpen,
setOpen,
sendMessage,
cancelMessage,
clearChat,
@@ -12,7 +12,7 @@
.chat-fab-trigger {
position: absolute;
right: 16px;
bottom: calc(var(--footer-height, 2rem) + 16px);
bottom: 16px;
pointer-events: auto;
opacity: 1;
/* Include box-shadow so the ChatFABButton hover shadow still animates */

Some files were not shown because too many files have changed in this diff Show More