From 5b5e9220697a3d80cadde04df5e4ec4e5fac79cf Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:57:15 +0000 Subject: [PATCH 01/27] Make the upgrade banner neutral instead of gradient purple (#7696) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What The `promo` banner tone was a full-bleed `indigo-500 → purple-500` gradient with white text and a black drop-shadow on the CTA. It was the only saturated fill in the app, and against the warm neutral palette it read as a foreign object above the workbench. The bar is now app chrome: | | Before | After | |---|---|---| | Background | 135° indigo→purple gradient | `--c-bg-raised` | | Border | `transparent` | `--c-border-subtle` hairline | | Icon | white glyph, no container | neutral glyph in a `--c-surface-sunken` chip | | Text | forced white | `--c-text` / `--c-text-muted` | | CTA | `premium` accent (violet gradient) | `default` accent (same primary button as the rest of the app) | Before Screenshot 2026-08-27 at 4 49 00 PM After Screenshot 2026-08-27 at 4 48 30 PM Only caller is the friendly variant of `UpgradeBanner` (self-hosted, under the free-tier user limit). ## Notes - **No new theme tokens.** Every value is an existing `--c-*` semantic token, so light and dark both follow automatically with no per-theme overrides. - The `premium` accent itself is untouched, so the upgrade CTAs in `OfflineActivationCard` and `PairingPanel` are unaffected. - `--c-hue-indigo` / `--c-hue-purple` are still used by `SaaSOnboardingSlides`, `PaygFree` and `UpgradeModal`, so no tokens are orphaned. - Deleted comments describe rules that no longer exist (the gradient, the white-on-gradient text overrides, the CTA shadow). No new comments added. ## Verification - `task frontend:check:all` passes (typecheck, oxlint, all four theme linters, stylelint, format, tests, build, storybook build). - `task frontend:storybook:a11y:changed` passes light and dark: 7 AppBanner stories, 0 violations. Both a11y baselines are empty, so this is zero known violations rather than a baselined pass. - Checked in Storybook under **Shared / AppBanner → All Top Bars**, which renders every top bar the app can show side by side, in both themes. --- .../src/core/components/shared/AppBanner.css | 31 ++++++++----------- .../src/core/components/shared/AppBanner.tsx | 2 +- 2 files changed, 14 insertions(+), 19 deletions(-) diff --git a/frontend/editor/src/core/components/shared/AppBanner.css b/frontend/editor/src/core/components/shared/AppBanner.css index b3f3f9343a..8151fe916a 100644 --- a/frontend/editor/src/core/components/shared/AppBanner.css +++ b/frontend/editor/src/core/components/shared/AppBanner.css @@ -24,17 +24,10 @@ --app-banner-icon: var(--c-accent-fg, var(--c-primary)); } -/* The one bar meant to pop, so it takes the feature gradient rather than a tint. - Fixed hues by design — it doesn't follow the chosen accent. */ .app-banner--promo { - --app-banner-bg: linear-gradient( - 135deg, - var(--c-hue-indigo) 0%, - var(--c-hue-purple) 100% - ); - --app-banner-border: transparent; - --app-banner-icon: var(--color-text-on-accent); - color: var(--color-text-on-accent); + --app-banner-bg: var(--c-bg-raised); + --app-banner-border: var(--c-border-subtle); + --app-banner-icon: var(--c-text-muted); } .app-banner--warning { @@ -86,16 +79,18 @@ font-size: 0.75rem; } -/* On the gradient everything is white; muted grey would disappear. */ -.app-banner--promo .app-banner__message, -.app-banner--promo .app-banner__actions .sui-btn--tertiary, -.app-banner--promo .app-banner__actions .sui-ai { - color: var(--color-text-on-accent); +.app-banner--promo .app-banner__icon { + width: 1.75rem; + height: 1.75rem; + justify-content: center; + border-radius: var(--radius-md); + background: var(--c-surface-sunken); + box-shadow: inset 0 0 0 1px var(--c-border-subtle); } -/* Lifts the premium CTA off the gradient it sits on. */ -.app-banner--promo .app-banner__actions .sui-btn--primary { - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25); +.app-banner--promo.app-banner--compact .app-banner__icon { + width: 1.5rem; + height: 1.5rem; } .app-banner__actions { diff --git a/frontend/editor/src/core/components/shared/AppBanner.tsx b/frontend/editor/src/core/components/shared/AppBanner.tsx index ee0ba03741..79ff268dd2 100644 --- a/frontend/editor/src/core/components/shared/AppBanner.tsx +++ b/frontend/editor/src/core/components/shared/AppBanner.tsx @@ -11,7 +11,7 @@ export type AppBannerTone = "info" | "promo" | "warning" | "danger"; /** Tone decides the button too, so the CTA can't drift from the bar it sits on. */ const TONE_BUTTON = { info: { variant: "secondary", accent: "default" }, - promo: { variant: "primary", accent: "premium" }, + promo: { variant: "primary", accent: "default" }, warning: { variant: "primary", accent: "warning" }, danger: { variant: "primary", accent: "danger" }, } as const; From cbe3ef8f69240eb4ddce063b3f85c9fafe7a9bda Mon Sep 17 00:00:00 2001 From: Ludy Date: Thu, 27 Aug 2026 16:59:58 +0000 Subject: [PATCH 02/27] fix: validate frontend dependency installation (#7625) # Description of Changes The current check only verifies the existence of the `node_modules` directory. After an incomplete or corrupted installation, this can lead to the task being incorrectly marked as complete. `npm ls --depth=0` instead checks whether the direct frontend dependencies are actually installed and consistent. This reliably detects and automatically repairs corrupted installations. --- ## 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. --- .github/labeler-config-srvaroa.yml | 1 + .taskfiles/frontend.yml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/labeler-config-srvaroa.yml b/.github/labeler-config-srvaroa.yml index bd1947d649..ea9613ea20 100644 --- a/.github/labeler-config-srvaroa.yml +++ b/.github/labeler-config-srvaroa.yml @@ -67,6 +67,7 @@ labels: - 'frontend/**' - 'frontend/.*' - 'frontend/**/.*' + - '.taskfiles/frontend.yml' - label: 'Tauri' files: diff --git a/.taskfiles/frontend.yml b/.taskfiles/frontend.yml index 7867315037..9727538961 100644 --- a/.taskfiles/frontend.yml +++ b/.taskfiles/frontend.yml @@ -23,7 +23,7 @@ tasks: - package-lock.json - package.json status: - - test -d node_modules + - npm ls --depth=0 env: CI: '{{ .CI | default "false" }}' From 4e46ba3b5a98cd4dd73ef4b898ba78d7536a0678 Mon Sep 17 00:00:00 2001 From: Ludy Date: Thu, 27 Aug 2026 17:16:24 +0000 Subject: [PATCH 03/27] chore: prevent duplicate Dependabot Gradle PRs (#7657) ## Description of Changes - Removed overlapping Gradle subdirectory entries from .github/dependabot.yml. - Dependabot now monitors the root Gradle project through /. - Prevents duplicate pull requests for dependencies declared in Gradle subprojects. Closes: Not applicable --- ## Checklist ### General - [ ] I have read the Contribution Guidelines - [ ] I have read the Stirling-PDF Developer Guide (if applicable) - [x] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant documentation (if applicable) - [ ] I have read the translation tag documentation (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos are attached ### Testing (if applicable) - [ ] I have tested my changes locally --- .github/dependabot.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 883c4f7f46..4ed61e37ec 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,11 +8,6 @@ updates: - package-ecosystem: "gradle" # See documentation for possible values directories: - "/" # Location of package manifests - - "/app/common" - - "/app/core" - - "/app/proprietary" - - "/app/saas" - - "/buildSrc" schedule: interval: "weekly" cooldown: From 97c0ccf58214fe502c0f93ac859d264dc58c3c00 Mon Sep 17 00:00:00 2001 From: brios <127139797+balazs-szucs@users.noreply.github.com> Date: Thu, 27 Aug 2026 19:31:54 +0200 Subject: [PATCH 04/27] refactor(deps): optimize dependency footprints, and add lazy initialization with platform-specific JPDFium bundling (#7620) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- app/common/build.gradle | 39 +++++++++++++++---- app/core/build.gradle | 16 +++++++- .../api/security/RedactController.java | 4 ++ .../api/security/TextRedactionService.java | 4 ++ build.gradle | 10 ++++- 5 files changed, 62 insertions(+), 11 deletions(-) diff --git a/app/common/build.gradle b/app/common/build.gradle index 8af68bcb76..8ac1dfe0a9 100644 --- a/app/common/build.gradle +++ b/app/common/build.gradle @@ -3,6 +3,10 @@ bootRun { enabled = false } dependencies { + // Security-hardening utilities (zip-slip, SSRF, filename sanitization, command injection). + // Declared as api here so core + proprietary (which depend on common) get it transitively, + // keeping it off modules that don't need it (e.g. saas). + api 'io.github.pixee:java-security-toolkit:1.2.3' api "com.google.guava:guava:${guavaVersion}" api 'org.springframework.boot:spring-boot-starter-webmvc' api 'org.springframework.boot:spring-boot-starter-aspectj' @@ -22,7 +26,10 @@ dependencies { api "org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3" // Simple Java Mail for EML/MSG parsing (replaces direct Angus Mail usage) api 'org.simplejavamail:simple-java-mail:9.3.2' - api 'org.simplejavamail:outlook-module:9.3.2' // MSG file support + // MSG file support; exclude commons-math3 (only HSSF/formula needs it, MSG parsing doesn't) + api('org.simplejavamail:outlook-module:9.3.2') { + exclude group: 'org.apache.commons', module: 'commons-math3' + } api 'jakarta.mail:jakarta.mail-api:2.1.5' runtimeOnly 'org.eclipse.angus:angus-mail:2.0.5' @@ -36,12 +43,30 @@ dependencies { api "com.stirling:jpdfium:${jpdfiumVersion}" - // -PjpdfiumPlatforms=all|none| - // 'none' skips natives entirely (windows-arm64 builds, until JPDFium ships that platform). - def jpdfiumPlatformsProp = (project.findProperty('jpdfiumPlatforms') ?: 'all').toString().trim() - def jpdfiumAllPlatforms = ['linux-x64', 'linux-arm64', 'darwin-x64', 'darwin-arm64', 'windows-x64'] + // -PjpdfiumPlatforms=auto|all|none| (windows-arm64 natives not published yet) + def jpdfiumPlatformsProp = (project.findProperty('jpdfiumPlatforms') ?: 'auto').toString().trim() + def jpdfiumAllPlatforms = ['linux-x64', 'linux-arm64', 'linux-musl-x64', 'linux-musl-arm64', 'darwin-x64', 'darwin-arm64', 'windows-x64'] def jpdfiumPlatforms - if (jpdfiumPlatformsProp == 'all') { + if (jpdfiumPlatformsProp == 'auto') { + def osName = System.getProperty('os.name').toLowerCase() + def osArch = System.getProperty('os.arch').toLowerCase() + def isArm64 = osArch.contains('aarch64') || osArch.contains('arm64') + if (osName.contains('linux')) { + jpdfiumPlatforms = isArm64 ? ['linux-arm64'] : ['linux-x64'] + } else if (osName.contains('mac')) { + jpdfiumPlatforms = isArm64 ? ['darwin-arm64'] : ['darwin-x64'] + } else if (osName.contains('win')) { + if (isArm64) { + logger.lifecycle("JPDFium natives are not available for windows-arm64; set -PjpdfiumPlatforms=none to skip bundling natives.") + jpdfiumPlatforms = [] + } else { + jpdfiumPlatforms = ['windows-x64'] + } + } else { + // Fallback: bundle all platforms when host can't be determined + jpdfiumPlatforms = jpdfiumAllPlatforms + } + } else if (jpdfiumPlatformsProp == 'all') { jpdfiumPlatforms = jpdfiumAllPlatforms } else if (jpdfiumPlatformsProp == 'none') { jpdfiumPlatforms = [] @@ -51,7 +76,7 @@ dependencies { def jpdfiumInvalid = jpdfiumPlatforms.findAll { !jpdfiumAllPlatforms.contains(it) } if (jpdfiumInvalid) { throw new GradleException("Unknown jpdfiumPlatforms value(s): ${jpdfiumInvalid.join(', ')}. " + - "Valid: ${jpdfiumAllPlatforms.join(', ')}, 'all' or 'none'.") + "Valid: ${jpdfiumAllPlatforms.join(', ')}, 'auto', 'all' or 'none'.") } logger.lifecycle("JPDFium native platforms: ${jpdfiumPlatforms ? jpdfiumPlatforms.join(', ') : 'none'}") jpdfiumPlatforms.each { platform -> diff --git a/app/core/build.gradle b/app/core/build.gradle index 76828445a3..0e1533b6e8 100644 --- a/app/core/build.gradle +++ b/app/core/build.gradle @@ -62,8 +62,16 @@ dependencies { // CVE-2022-25647: Explicit gson to prevent unsafe deserialization (tabula would pull 2.8.7) implementation "com.google.code.gson:gson:${gsonVersion}" implementation 'org.apache.pdfbox:jbig2-imageio:3.0.5' - implementation 'com.opencsv:opencsv:5.12.0' // https://mvnrepository.com/artifact/com.opencsv/opencsv - implementation 'org.apache.poi:poi-ooxml:5.5.1' + // OpenCSV: Stirling-PDF only uses CSVWriter, not the opencsv-bean module. + // Exclude commons-beanutils + commons-collections. + implementation('com.opencsv:opencsv:5.12.0') { + exclude group: 'commons-beanutils', module: 'commons-beanutils' + exclude group: 'commons-collections', module: 'commons-collections' + } + // POI: only XSSF (modern Excel) is used, not HSSF/FormulaEvaluator which need commons-math3. + implementation('org.apache.poi:poi-ooxml:5.5.1') { + exclude group: 'org.apache.commons', module: 'commons-math3' + } // Batik only bridge module needed (transitively pulls anim, gvt, util, css, dom, svg-dom) // Replaces batik-all which included unused codec, svggen, transcoder, script modules @@ -129,6 +137,10 @@ bootJar { exclude 'META-INF/*.RSA' exclude 'META-INF/*.EC' + // Exclude source maps from production JAR, dev-only debugging artifacts, not needed at runtime + exclude 'static/pdfjs-legacy/**/*.map' + exclude 'static/**/*.map' + manifest { attributes( 'Implementation-Title': 'Stirling-PDF', diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactController.java index 7a186235cd..1c694da2b0 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactController.java @@ -221,6 +221,10 @@ public class RedactController { .normalizeFonts(false) .fixToUnicode(false) .glyphAware(true) + .ligatureAware(true) + .bidiAware(true) + .graphemeSafe(true) + .sanitizeStructure(false) // WIP/Experimental API .redactMetadata(true) .build(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/TextRedactionService.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/TextRedactionService.java index 9cf4e6c700..c0b74f5428 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/TextRedactionService.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/TextRedactionService.java @@ -110,6 +110,10 @@ class TextRedactionService { .fixToUnicode(false) .repairWidths(false) .glyphAware(true) + .ligatureAware(true) + .bidiAware(true) + .graphemeSafe(true) + .sanitizeStructure(false) .build(); try (PdfDocument checkDoc = PdfDocument.open(tempIn.toPath())) { diff --git a/build.gradle b/build.gradle index 6382e75c12..ffaba65377 100644 --- a/build.gradle +++ b/build.gradle @@ -42,7 +42,7 @@ ext { bucket4jVersion = "8.19.0" archunitVersion = "1.4.2" batikVersion = "1.19" - jpdfiumVersion = "1.0.4" + jpdfiumVersion = "1.1.3" jwtVersion = "0.13.0" awsSdkVersion = "2.51.3" jschVersion = "2.28.6" @@ -265,7 +265,6 @@ subprojects { dependencies { implementation 'org.springframework.boot:spring-boot-starter-actuator' - implementation 'io.github.pixee:java-security-toolkit:1.2.3' //tmp for security bumps implementation "ch.qos.logback:logback-core:$logback" @@ -543,6 +542,13 @@ subprojects { } } + // Lazy initialization defers bean creation until first use, + // reducing dev-mode RSS significantly (heap drops ~40-60%). + // Enable with: ./gradlew bootRun -PlazyInit=true + if (rootProject.findProperty('lazyInit') == 'true') { + runtimeArgs.add("-Dspring.main.lazy-initialization=true") + logger.lifecycle("Lazy initialization enabled (-PlazyInit=true)") + } jvmArgs = runtimeArgs } } From 51835a7b5e8ac2f058aa7cee4e29906c94823afc Mon Sep 17 00:00:00 2001 From: jayakrishna <71440165+NGU-152002@users.noreply.github.com> Date: Thu, 27 Aug 2026 23:04:43 +0530 Subject: [PATCH 05/27] fix: clean up Add Stamp image preview blob URLs (#6779) Co-authored-by: James Brunton --- .../core/components/tools/addStamp/StampSetupSettings.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/frontend/editor/src/core/components/tools/addStamp/StampSetupSettings.tsx b/frontend/editor/src/core/components/tools/addStamp/StampSetupSettings.tsx index 4c7ee96c5b..25e2bc2243 100644 --- a/frontend/editor/src/core/components/tools/addStamp/StampSetupSettings.tsx +++ b/frontend/editor/src/core/components/tools/addStamp/StampSetupSettings.tsx @@ -19,6 +19,7 @@ import { AddStampParameters } from "@app/components/tools/addStamp/useAddStampPa import ButtonSelector from "@app/components/shared/ButtonSelector"; import styles from "@app/components/tools/addStamp/StampPreview.module.css"; import { getDefaultFontSizeForAlphabet } from "@app/components/tools/addStamp/StampPreviewUtils"; +import { useFileWithUrl } from "@app/hooks/useFileWithUrl"; import { Z_INDEX_AUTOMATE_DROPDOWN } from "@app/styles/zIndex"; const STAMP_TEMPLATES = [ @@ -209,6 +210,9 @@ const StampSetupSettings = ({ filename, }: StampSetupSettingsProps) => { const { t } = useTranslation(); + const stampImageWithUrl = useFileWithUrl( + parameters.stampType === "image" ? (parameters.stampImage ?? null) : null, + ); return ( @@ -679,10 +683,10 @@ const StampSetupSettings = ({ > {t("chooseFile", "Choose File")} - {parameters.stampImage && ( + {parameters.stampImage && stampImageWithUrl && ( Selected stamp image From d4b1862654b4ad92414dc0a0ce25f8370bb459b5 Mon Sep 17 00:00:00 2001 From: Andrei Blaj <7049755+andreiblaj@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:40:26 +0300 Subject: [PATCH 06/27] feat(ocr): add rotatePages option for automatic page orientation correction (#6697) Co-authored-by: Andrei Blaj Signed-off-by: Andrei Blaj --- .../software/SPDF/controller/api/misc/OCRController.java | 7 +++++++ .../SPDF/model/api/misc/ProcessPdfWithOcrRequest.java | 5 +++++ 2 files changed, 12 insertions(+) diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/OCRController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/OCRController.java index 4803184a33..e10b86a866 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/OCRController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/OCRController.java @@ -114,6 +114,7 @@ public class OCRController { List selectedLanguages = request.getLanguages(); boolean sidecar = request.isSidecar(); Boolean deskew = request.isDeskew(); + Boolean rotatePages = request.isRotatePages(); Boolean clean = request.isClean(); Boolean cleanFinal = request.isCleanFinal(); String ocrType = request.getOcrType(); @@ -154,6 +155,7 @@ public class OCRController { selectedLanguages, sidecar, deskew, + rotatePages, clean, cleanFinal, ocrType, @@ -236,6 +238,7 @@ public class OCRController { List selectedLanguages, Boolean sidecar, Boolean deskew, + Boolean rotatePages, Boolean clean, Boolean cleanFinal, String ocrType, @@ -268,6 +271,10 @@ public class OCRController { if (deskew != null && deskew) { command.add("--deskew"); } + if (rotatePages != null && rotatePages) { + // Tesseract OSD-based automatic page orientation correction (90/180/270) + command.add("--rotate-pages"); + } if (clean != null && clean) { command.add("--clean"); } diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/misc/ProcessPdfWithOcrRequest.java b/app/core/src/main/java/stirling/software/SPDF/model/api/misc/ProcessPdfWithOcrRequest.java index 2955d7160f..daa6930412 100644 --- a/app/core/src/main/java/stirling/software/SPDF/model/api/misc/ProcessPdfWithOcrRequest.java +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/misc/ProcessPdfWithOcrRequest.java @@ -25,6 +25,11 @@ public class ProcessPdfWithOcrRequest extends PDFFile { @Schema(description = "Deskew the input file if set to true") private boolean deskew; + @Schema( + description = + "Auto-correct page orientation (90/180/270) using Tesseract OSD if set to true") + private boolean rotatePages; + @Schema(description = "Clean the input file if set to true") private boolean clean; From be130282098c01d15591fc20af3532c136c11172 Mon Sep 17 00:00:00 2001 From: Ludy Date: Thu, 27 Aug 2026 19:41:40 +0200 Subject: [PATCH 07/27] chore(logging): enable gzipped log rotation and adjust test logging (#7648) --- app/core/.gitignore | 1 + app/core/src/main/resources/logback.xml | 10 ++++++---- build.gradle | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/app/core/.gitignore b/app/core/.gitignore index 7d9dd62931..c207c6c09a 100644 --- a/app/core/.gitignore +++ b/app/core/.gitignore @@ -106,6 +106,7 @@ SwaggerDoc.json # Log file *.log +*.log.gz # BlueJ files *.ctxt diff --git a/app/core/src/main/resources/logback.xml b/app/core/src/main/resources/logback.xml index c0779735ae..ebdeeda64b 100644 --- a/app/core/src/main/resources/logback.xml +++ b/app/core/src/main/resources/logback.xml @@ -16,8 +16,9 @@ %d %p %c{1} [%thread] %m%n - ${LOG_PATH}/auth-%d{yyyy-MM-dd}.log - 1 + ${LOG_PATH}/auth-%d{yyyy-MM-dd}.log.gz + 7 + 64MB @@ -28,8 +29,9 @@ %d %p %c{1} [%thread] %m%n - ${LOG_PATH}/info-%d{yyyy-MM-dd}.log - 1 + ${LOG_PATH}/info-%d{yyyy-MM-dd}.log.gz + 7 + 256MB diff --git a/build.gradle b/build.gradle index ffaba65377..fdc59f3680 100644 --- a/build.gradle +++ b/build.gradle @@ -306,7 +306,7 @@ subprojects { systemProperty 'apple.awt.UIElement', 'true' testLogging { - events "started", "failed" + events "skipped", "failed" showExceptions = true showCauses = true showStackTraces = true From f71b0247dafba7d266dcc3d0f8e4dcffdfbbbda3 Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Thu, 27 Aug 2026 23:15:39 +0100 Subject: [PATCH 08/27] Quick access bar and old school sidebars (#7695) --- .../public/locales/en-US/translation.toml | 19 +- frontend/editor/src/core/App.tsx | 28 +-- .../fileManager/FileSourceButtons.tsx | 2 +- .../components/filesPage/FolderTreePanel.tsx | 2 +- .../filesPage/filesPageReturnRoute.ts | 2 +- .../src/core/components/layout/AppFrame.css | 18 ++ .../src/core/components/layout/AppFrame.tsx | 22 ++ .../core/components/layout/NoAppChrome.tsx | 8 + .../components/layout/Workbench.module.css | 6 +- .../src/core/components/layout/Workbench.tsx | 30 ++- .../core/components/layout/WorkspaceFrame.css | 16 ++ .../notifications/NotificationBell.css | 8 +- .../notifications/NotificationBell.tsx | 119 ++--------- .../notifications/NotificationPanel.tsx | 135 ++++++++++++ .../src/core/components/shared/AppSwitch.tsx | 7 +- .../core/components/shared/AppSwitcher.tsx | 22 -- .../core/components/shared/BrandSwitcher.css | 15 -- .../shared/BrandSwitcher.stories.tsx | 16 -- .../core/components/shared/BrandSwitcher.tsx | 57 ----- .../src/core/components/shared/BrandTile.tsx | 29 +++ .../core/components/shared/FileSidebar.css | 25 ++- .../core/components/shared/FileSidebar.tsx | 60 +++--- .../core/components/shared/SidebarHeader.tsx | 33 +++ .../components/shared/SidebarToggleButton.tsx | 36 ++++ .../src/core/components/shared/Tooltip.tsx | 47 ++-- .../core/components/shared/WorkbenchBar.css | 22 +- .../core/components/shared/WorkbenchBar.tsx | 20 +- .../components/shared/navFooter/NavFooter.tsx | 87 ++++---- .../shared/quickNav/QuickNavBrand.tsx | 28 +++ .../shared/quickNav/QuickNavHostBridge.tsx | 85 ++++++++ .../shared/quickNav/QuickNavRail.css | 173 +++++++++++++++ .../shared/quickNav/QuickNavRailAccount.css | 29 +++ .../shared/quickNav/QuickNavRailAccount.tsx | 45 ++++ .../shared/quickNav/QuickNavRailBase.test.tsx | 123 +++++++++++ .../shared/quickNav/QuickNavRailBase.tsx | 101 +++++++++ .../shared/quickNav/QuickNavRailContainer.css | 38 ++++ .../shared/quickNav/QuickNavRailContainer.tsx | 83 ++++++++ .../shared/quickNav/QuickNavRailHost.tsx | 178 ++++++++++++++++ .../QuickNavRailNotifications.test.tsx | 88 ++++++++ .../quickNav/QuickNavRailNotifications.tsx | 51 +++++ .../quickNav/useQuickNavToolReasons.test.tsx | 139 ++++++++++++ .../shared/quickNav/useQuickNavToolReasons.ts | 131 ++++++++++++ .../core/components/tools/RightSidebar.tsx | 7 +- .../src/core/components/tools/ToolPanel.css | 8 +- .../viewer/useViewerWorkbenchBarButtons.tsx | 55 ++--- .../contexts/QuickNavHostContext.test.tsx | 128 +++++++++++ .../src/core/contexts/QuickNavHostContext.tsx | 201 ++++++++++++++++++ .../src/core/contexts/SidebarContext.tsx | 5 + .../src/core/contexts/ToolWorkflowContext.tsx | 2 +- frontend/editor/src/core/pages/HomePage.tsx | 191 ++++++++++++++--- frontend/editor/src/core/routes/hasPortal.ts | 2 + .../live/viewer-sidebar-add-buttons.spec.ts | 3 +- .../viewer-sidebar-add-buttons.spec.ts | 3 +- .../stubbed/workbench-session-restore.spec.ts | 10 +- frontend/editor/src/core/theme/colors.css | 2 +- frontend/editor/src/core/theme/dimensions.css | 7 +- frontend/editor/src/core/ui/NavSurface.tsx | 4 +- .../src/core/utils/homePageNavigation.ts | 9 +- .../src/core/utils/pendingReaderMode.ts | 13 ++ .../src/core/utils/viewTransition.test.ts | 63 ++++++ .../editor/src/core/utils/viewTransition.ts | 17 +- .../desktop/components/shared/AppSwitcher.tsx | 18 -- .../editor/src/desktop/routes/hasPortal.ts | 2 + .../editor/src/portal/components/AppShell.tsx | 22 +- .../portal/components/EditorStatusCard.tsx | 26 +-- .../src/portal/components/PortalSearchBar.css | 3 +- .../editor/src/portal/components/Sidebar.css | 105 +++++---- .../editor/src/portal/components/Sidebar.tsx | 47 +--- frontend/editor/src/proprietary/App.tsx | 80 ++++--- .../components/shared/AppSwitcher.tsx | 35 --- .../proprietary/data/processorEntitySearch.ts | 10 +- .../proprietary/data/processorSearchIndex.ts | 10 +- .../editor/src/proprietary/routes/Landing.tsx | 4 + .../routes/adminRouteExtensions.tsx | 8 +- .../src/proprietary/routes/hasPortal.ts | 3 + frontend/editor/src/saas/App.tsx | 97 +++++---- 76 files changed, 2665 insertions(+), 718 deletions(-) create mode 100644 frontend/editor/src/core/components/layout/AppFrame.css create mode 100644 frontend/editor/src/core/components/layout/AppFrame.tsx create mode 100644 frontend/editor/src/core/components/layout/NoAppChrome.tsx create mode 100644 frontend/editor/src/core/components/layout/WorkspaceFrame.css create mode 100644 frontend/editor/src/core/components/notifications/NotificationPanel.tsx delete mode 100644 frontend/editor/src/core/components/shared/AppSwitcher.tsx delete mode 100644 frontend/editor/src/core/components/shared/BrandSwitcher.css delete mode 100644 frontend/editor/src/core/components/shared/BrandSwitcher.stories.tsx delete mode 100644 frontend/editor/src/core/components/shared/BrandSwitcher.tsx create mode 100644 frontend/editor/src/core/components/shared/BrandTile.tsx create mode 100644 frontend/editor/src/core/components/shared/SidebarHeader.tsx create mode 100644 frontend/editor/src/core/components/shared/SidebarToggleButton.tsx create mode 100644 frontend/editor/src/core/components/shared/quickNav/QuickNavBrand.tsx create mode 100644 frontend/editor/src/core/components/shared/quickNav/QuickNavHostBridge.tsx create mode 100644 frontend/editor/src/core/components/shared/quickNav/QuickNavRail.css create mode 100644 frontend/editor/src/core/components/shared/quickNav/QuickNavRailAccount.css create mode 100644 frontend/editor/src/core/components/shared/quickNav/QuickNavRailAccount.tsx create mode 100644 frontend/editor/src/core/components/shared/quickNav/QuickNavRailBase.test.tsx create mode 100644 frontend/editor/src/core/components/shared/quickNav/QuickNavRailBase.tsx create mode 100644 frontend/editor/src/core/components/shared/quickNav/QuickNavRailContainer.css create mode 100644 frontend/editor/src/core/components/shared/quickNav/QuickNavRailContainer.tsx create mode 100644 frontend/editor/src/core/components/shared/quickNav/QuickNavRailHost.tsx create mode 100644 frontend/editor/src/core/components/shared/quickNav/QuickNavRailNotifications.test.tsx create mode 100644 frontend/editor/src/core/components/shared/quickNav/QuickNavRailNotifications.tsx create mode 100644 frontend/editor/src/core/components/shared/quickNav/useQuickNavToolReasons.test.tsx create mode 100644 frontend/editor/src/core/components/shared/quickNav/useQuickNavToolReasons.ts create mode 100644 frontend/editor/src/core/contexts/QuickNavHostContext.test.tsx create mode 100644 frontend/editor/src/core/contexts/QuickNavHostContext.tsx create mode 100644 frontend/editor/src/core/routes/hasPortal.ts create mode 100644 frontend/editor/src/core/utils/pendingReaderMode.ts create mode 100644 frontend/editor/src/core/utils/viewTransition.test.ts delete mode 100644 frontend/editor/src/desktop/components/shared/AppSwitcher.tsx create mode 100644 frontend/editor/src/desktop/routes/hasPortal.ts delete mode 100644 frontend/editor/src/proprietary/components/shared/AppSwitcher.tsx create mode 100644 frontend/editor/src/proprietary/routes/hasPortal.ts diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 8271d66dfc..378157818c 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -4141,7 +4141,6 @@ mobileShort = "Mobile" mobileUpload = "Mobile Upload" mobileUploadNotAvailable = "Mobile upload not enabled" moreOptions = "More options" -myFiles = "My Files" nextFile = "Next file" noFiles = "No files available" noFilesFound = "No files found matching your search" @@ -4242,9 +4241,9 @@ duplicateFailed = "Could not duplicate file" expand = "Expand sidebar" googleDrive = "Google Drive" googleDriveDisabled = "Google Drive is not configured" -leaveMyFiles = "Leave My Files" +leaveMyFiles = "Leave File library" library = "PDF Library" -myFiles = "My Files" +myFiles = "File library" noFiles = "No files yet" openFileManager = "Browse all files & folders" openFromComputer = "Open from computer" @@ -4287,7 +4286,7 @@ addToWorkspaceCount = "Add {{count}} to workspace" allFiles = "All files" back = "Back" backToFolder = "Back to {{folder}}" -backToMyFiles = "Back to My Files" +backToMyFiles = "Back to File library" breadcrumbs = "Folder path" bulkActions = "Actions" cancel = "Cancel" @@ -4339,7 +4338,6 @@ localFoldersUnavailable = "Folders are cloud-only - save a file to the cloud to moveSkippedRemote_one = "{{count}} file couldn't be moved on the server (no permission or already deleted)." moveSkippedRemote_other = "{{count}} files couldn't be moved on the server (no permission or already deleted)." moveTo = "Move to…" -myFiles = "My Files" newFolder = "New folder" newFolderStorageDisabled = "Server folder storage isn't enabled. Ask your admin to turn it on." newFolderTabUnavailable = "Switch to All or Cloud to create folders." @@ -9173,7 +9171,6 @@ appEditor = "Editor" appProcessor = "Processor" linkAccount = "Link Stirling account" primaryNav = "Primary navigation" -switchApp = "Switch app" [portal.shell.topbar] closeNav = "Close navigation" @@ -9652,6 +9649,16 @@ automate = "Automate" config = "Config" files = "Files" +[quickNav] +editor = "Editor" +home = "Stirling" +invite = "Invite" +landmark = "Quick navigation" +noProcessorAccess = "Ask an admin for processor access" +notifications = "Notifications" +processor = "Processor" +reader = "Reader" + [read] tags = "view,open,display,read,viewer,PDF viewer,PDF reader,open PDF,view PDF,display PDF,preview,browse" diff --git a/frontend/editor/src/core/App.tsx b/frontend/editor/src/core/App.tsx index 81db0564b8..c51c1fb85d 100644 --- a/frontend/editor/src/core/App.tsx +++ b/frontend/editor/src/core/App.tsx @@ -1,6 +1,7 @@ import { Suspense, lazy } from "react"; import { Routes, Route } from "react-router-dom"; import { AppProviders } from "@app/components/AppProviders"; +import { AppFrame } from "@app/components/layout/AppFrame"; import { AppLayout } from "@app/components/AppLayout"; import { LoadingFallback } from "@app/components/shared/LoadingFallback"; import { ThemeProvider } from "@app/components/shared/ThemeProvider"; @@ -53,18 +54,21 @@ export default function App() { } /> - {/* All other routes need AppProviders for backend integration */} - - - - - - - } - /> + {/* The app, under a shared frame so the rail renders once outside it. */} + }> + {/* All other routes need AppProviders for backend integration */} + + + + + + + } + /> + ); diff --git a/frontend/editor/src/core/components/fileManager/FileSourceButtons.tsx b/frontend/editor/src/core/components/fileManager/FileSourceButtons.tsx index 6ac3e3c91e..20ebaa191e 100644 --- a/frontend/editor/src/core/components/fileManager/FileSourceButtons.tsx +++ b/frontend/editor/src/core/components/fileManager/FileSourceButtons.tsx @@ -173,7 +173,7 @@ const FileSourceButtons: React.FC = ({ mb="xs" style={{ paddingLeft: "1rem" }} > - {t("fileManager.myFiles", "My Files")} + {t("fileSidebar.myFiles", "File library")} {buttons} diff --git a/frontend/editor/src/core/components/filesPage/FolderTreePanel.tsx b/frontend/editor/src/core/components/filesPage/FolderTreePanel.tsx index eb7f19170c..b56915b757 100644 --- a/frontend/editor/src/core/components/filesPage/FolderTreePanel.tsx +++ b/frontend/editor/src/core/components/filesPage/FolderTreePanel.tsx @@ -121,7 +121,7 @@ export function FolderTreePanel({ active }: FolderTreePanelProps) {
- {t("filesPage.myFiles", "My Files")} + {t("fileSidebar.myFiles", "File library")}
diff --git a/frontend/editor/src/core/components/filesPage/filesPageReturnRoute.ts b/frontend/editor/src/core/components/filesPage/filesPageReturnRoute.ts index 5e6292d62f..69a1bc1c53 100644 --- a/frontend/editor/src/core/components/filesPage/filesPageReturnRoute.ts +++ b/frontend/editor/src/core/components/filesPage/filesPageReturnRoute.ts @@ -1,6 +1,6 @@ /** * Stores the route the user came from when they open files into the - * workbench from My Files. Lets the workbench show a "Back to My Files" + * workbench from the file library. Lets the workbench show a "Back to File library" * affordance and return to the exact folder they were browsing. * * Persisted in sessionStorage so a hard reload keeps the return path diff --git a/frontend/editor/src/core/components/layout/AppFrame.css b/frontend/editor/src/core/components/layout/AppFrame.css new file mode 100644 index 0000000000..0cc20b30c6 --- /dev/null +++ b/frontend/editor/src/core/components/layout/AppFrame.css @@ -0,0 +1,18 @@ +/* ========== APP FRAME ========== */ +/* The rail's column, then whichever app is mounted, so a switch changes only the app. */ +.app-frame { + display: flex; + height: 100vh; + height: 100dvh; /* track mobile browser chrome */ + overflow: hidden; + background-color: var(--c-bg); +} + +/* min-width: 0 so the app shrinks instead of forcing the frame past the window. */ +.app-frame__content { + flex: 1; + min-width: 0; + height: 100%; +} + +/* The rail hides itself below the mobile breakpoint - see QuickNavRailContainer.css. */ diff --git a/frontend/editor/src/core/components/layout/AppFrame.tsx b/frontend/editor/src/core/components/layout/AppFrame.tsx new file mode 100644 index 0000000000..38fa92ecba --- /dev/null +++ b/frontend/editor/src/core/components/layout/AppFrame.tsx @@ -0,0 +1,22 @@ +import { Suspense } from "react"; +import { Outlet } from "react-router-dom"; +import { LoadingFallback } from "@app/components/shared/LoadingFallback"; +import { QuickNavHostProvider } from "@app/contexts/QuickNavHostContext"; +import { QuickNavRailHost } from "@app/components/shared/quickNav/QuickNavRailHost"; +import "@app/components/layout/AppFrame.css"; + +/** The rail renders once outside both apps; Suspense sits inside it, not above. */ +export function AppFrame() { + return ( + +
+ +
+ }> + + +
+
+
+ ); +} diff --git a/frontend/editor/src/core/components/layout/NoAppChrome.tsx b/frontend/editor/src/core/components/layout/NoAppChrome.tsx new file mode 100644 index 0000000000..04416c02f1 --- /dev/null +++ b/frontend/editor/src/core/components/layout/NoAppChrome.tsx @@ -0,0 +1,8 @@ +import { Outlet } from "react-router-dom"; +import { useSuppressQuickNavRail } from "@app/contexts/QuickNavHostContext"; + +/** Pages that aren't the app: inside the frame for its providers, but with no rail. */ +export function NoAppChrome() { + useSuppressQuickNavRail(); + return ; +} diff --git a/frontend/editor/src/core/components/layout/Workbench.module.css b/frontend/editor/src/core/components/layout/Workbench.module.css index dd2b4a12bd..22d6fdc43c 100644 --- a/frontend/editor/src/core/components/layout/Workbench.module.css +++ b/frontend/editor/src/core/components/layout/Workbench.module.css @@ -12,10 +12,8 @@ .workbenchBarReopenTab { position: absolute; top: 100%; - /* Right-align with the retract handle inside the bar: the bar's right - margin (--nav-gutter) + 1px border + 8px bar padding + the handle's own - 6px inset. */ - right: calc(var(--nav-gutter) + 15px); + /* Aligns with the retract handle: 8px bar padding plus its own 6px inset. */ + right: 14px; display: flex; align-items: center; justify-content: center; diff --git a/frontend/editor/src/core/components/layout/Workbench.tsx b/frontend/editor/src/core/components/layout/Workbench.tsx index 903c552fd2..0cac20e574 100644 --- a/frontend/editor/src/core/components/layout/Workbench.tsx +++ b/frontend/editor/src/core/components/layout/Workbench.tsx @@ -1,4 +1,4 @@ -import { useState, Suspense, lazy } from "react"; +import { useState, useEffect, useRef, Suspense, lazy } from "react"; import { useTranslation } from "react-i18next"; import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; import { Box, Loader, Center, Stack, Text } from "@mantine/core"; @@ -15,6 +15,7 @@ import { VIEWER_SUPPORTED_EXTENSIONS } from "@app/utils/fileUtils"; import { useAppConfig } from "@app/contexts/AppConfigContext"; import { useSigningOverlay } from "@app/contexts/SigningOverlayContext"; import { useCookieConsent } from "@app/hooks/useCookieConsent"; +import { useIsPhone } from "@app/hooks/useIsMobile"; import styles from "@app/components/layout/Workbench.module.css"; import WorkbenchBar from "@app/components/shared/WorkbenchBar"; @@ -58,10 +59,13 @@ export default function Workbench() { setPageEditorFunctions, setSidebarsVisible, customWorkbenchViews, + readerMode, } = useToolWorkflow(); const { handleToolSelect } = useToolWorkflow(); const { overlay: signingOverlay } = useSigningOverlay(); + // Below this width the rail, and the bell it carries, is gone. + const isPhone = useIsPhone(); // Get navigation state - this is the source of truth const { selectedTool: selectedToolId } = useNavigationState(); @@ -92,8 +96,20 @@ export default function Workbench() { !isBaseWorkbench(currentView) || // Shared signing drives the viewer from the sidebar with no file in context. (currentView === "viewer" && !!signingOverlay?.file); - const showWorkbenchBar = topControlsAvailable && hasWorkbenchContent; - const showFloatingSearch = topControlsAvailable && !hasWorkbenchContent; + // Reading hides the bar; the rail's Reader entry is the way back. + const showWorkbenchBar = + topControlsAvailable && hasWorkbenchContent && !readerMode; + const showFloatingSearch = + topControlsAvailable && !hasWorkbenchContent && !readerMode; + + // On the transition, so reading sets the toolbar's start state without locking it. + const prevReaderModeRef = useRef(readerMode); + useEffect(() => { + if (readerMode !== prevReaderModeRef.current) { + setViewerToolbarCollapsed(readerMode); + prevReaderModeRef.current = readerMode; + } + }, [readerMode]); const handlePreviewClose = () => { setPreviewFile(null); @@ -126,7 +142,7 @@ export default function Workbench() { } } - // The "My Files" workbench is available regardless of whether files are + // The file-library workbench is available regardless of whether files are // currently loaded into the workbench - it lives on top of the IDB store. if (currentView === "myFiles") { return ; @@ -249,10 +265,8 @@ export default function Workbench() { data-tour="workbench" style={{ backgroundColor: "var(--c-bg)", minWidth: 0 }} > - {/* The bell normally rides in the workbench bar. Wherever that bar is not shown - My Files, - an empty workbench, a custom view without top controls - it gets its own corner, rather - than those being the places a user cannot see that something of theirs failed. */} - {!showWorkbenchBar && ( + {/* Phone only: above that the rail carries the bell, and here no bar does. */} + {isPhone && !showWorkbenchBar && (
diff --git a/frontend/editor/src/core/components/layout/WorkspaceFrame.css b/frontend/editor/src/core/components/layout/WorkspaceFrame.css new file mode 100644 index 0000000000..05cabe95f9 --- /dev/null +++ b/frontend/editor/src/core/components/layout/WorkspaceFrame.css @@ -0,0 +1,16 @@ +/* ========== WORKSPACE FRAME ========== */ +/* Rail and sidebar side by side, full height. Shared by both apps. */ +.workspace-frame { + display: flex; + height: 100%; + flex-shrink: 0; + background-color: var(--c-bg); +} + +/* On mobile the sidebar is a fixed drawer, so the frame stops laying out. */ +@media (max-width: 48rem) { + .workspace-frame { + display: block; + height: auto; + } +} diff --git a/frontend/editor/src/core/components/notifications/NotificationBell.css b/frontend/editor/src/core/components/notifications/NotificationBell.css index 9352f6bef8..46367a0b2b 100644 --- a/frontend/editor/src/core/components/notifications/NotificationBell.css +++ b/frontend/editor/src/core/components/notifications/NotificationBell.css @@ -10,7 +10,7 @@ position: relative; padding: var(--sp-2, 0.5rem); border: none; - border-radius: var(--radius-md, 0.375rem); + border-radius: var(--radius-md); background: transparent; color: var(--c-text-muted); cursor: pointer; @@ -48,6 +48,12 @@ box-shadow: 0 10px 30px rgb(0 0 0 / 25%); } +/* The rail's bell is at the foot of a full-height column, so its panel rises beside it. */ +.notification-bell__panel--rail { + inset-inline-start: calc(var(--nav-rail-w) + var(--nav-gutter)); + inset-block-end: var(--nav-gutter); +} + .notification-bell__heading { margin: 0 0 var(--sp-2, 0.5rem); font-size: 0.875rem; diff --git a/frontend/editor/src/core/components/notifications/NotificationBell.tsx b/frontend/editor/src/core/components/notifications/NotificationBell.tsx index f2ac1e0a3a..def06eed8d 100644 --- a/frontend/editor/src/core/components/notifications/NotificationBell.tsx +++ b/frontend/editor/src/core/components/notifications/NotificationBell.tsx @@ -1,27 +1,15 @@ -import { - Fragment, - useEffect, - useId, - useLayoutEffect, - useRef, - useState, -} from "react"; +import { useLayoutEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { BellIcon, Button } from "@app/ui"; -import DividerWithText from "@app/components/shared/DividerWithText"; import { useNotifications } from "@app/hooks/useNotifications"; import { useNotificationActions } from "@app/components/notifications/notificationActions"; -import { NotificationItem } from "@app/components/notifications/NotificationItem"; +import { NotificationPanel } from "@app/components/notifications/NotificationPanel"; import { useNotificationsAvailable } from "@app/components/notifications/useNotificationsAvailable"; import "@app/components/notifications/NotificationBell.css"; -/** - * Renders whatever the server sends without knowing which subsystem produced it or what its actions - * mean, so a new source or failure kind needs no change here. In core because both shells mount it. - */ +/** For the narrow layouts where the rail, which carries the bell, is off screen. */ export function NotificationBell() { - // A build with no notifications API gets no bell at all, rather than one that polls a - // nonexistent endpoint forever to show nothing. + // No API means no bell at all, rather than one polling an endpoint that isn't there. const available = useNotificationsAvailable(); if (!available) return null; return ; @@ -29,14 +17,10 @@ export function NotificationBell() { function MountedNotificationBell() { const { t } = useTranslation(); - const { notifications, unreadCount, documentStateFor, markAllSeen } = - useNotifications(); + const { unreadCount } = useNotifications(); const registry = useNotificationActions(); const [open, setOpen] = useState(false); const container = useRef(null); - const headingId = useId(); - // Where the new ones stop, frozen when the panel opens (opening marks everything read). - const [firstSeenId, setFirstSeenId] = useState(null); // Viewport-fixed, because the workbench bar clips its own overflow. const [anchor, setAnchor] = useState<{ top: number; right: number } | null>( null, @@ -61,54 +45,17 @@ function MountedNotificationBell() { }; }, [open]); - // Opening marks them read, not closing: waiting would leave the badge lit while they read. - const toggle = () => { - setOpen((wasOpen) => { - if (!wasOpen) { - // Before marking, or there is nothing left to read. - setFirstSeenId(notifications[unreadCount]?.id ?? null); - markAllSeen(); - } - return !wasOpen; - }); - }; - - /** - * How many count as new. No boundary id means all of them were; one that has since left the list - * leaves nothing to divide on, so it reads as none rather than guessing at a row. - */ - const boundaryIndex = firstSeenId - ? notifications.findIndex((notification) => notification.id === firstSeenId) - : notifications.length; - const dividedAt = Math.max(0, boundaryIndex); - - useEffect(() => { - if (!open) return; - const closeOnOutside = (event: MouseEvent) => { - const target = event.target as HTMLElement; - if (!container.current?.contains(target)) setOpen(false); - }; - const closeOnEscape = (event: KeyboardEvent) => { - if (event.key === "Escape") setOpen(false); - }; - document.addEventListener("mousedown", closeOnOutside); - document.addEventListener("keydown", closeOnEscape); - return () => { - document.removeEventListener("mousedown", closeOnOutside); - document.removeEventListener("keydown", closeOnEscape); - }; - }, [open]); - return (
{open && ( -
setOpen(false)} + registry={registry} style={anchor ? { top: anchor.top, right: anchor.right } : undefined} - > -

- {t("notifications.title", "Notifications")} -

- - {notifications.length === 0 ? ( -

- {t("notifications.empty", "Nothing to report.")} -

- ) : ( -
    - {notifications.map((notification, index) => ( - - {index === 0 && dividedAt > 0 && ( -
  • - -
  • - )} - {/* Only with something on both sides: a lone "Earlier" over everything says - nothing the empty badge has not. */} - {index === dividedAt && dividedAt > 0 && ( -
  • - -
  • - )} - setOpen(false)} - /> -
    - ))} -
- )} -
+ /> )}
); diff --git a/frontend/editor/src/core/components/notifications/NotificationPanel.tsx b/frontend/editor/src/core/components/notifications/NotificationPanel.tsx new file mode 100644 index 0000000000..f3ff0c621e --- /dev/null +++ b/frontend/editor/src/core/components/notifications/NotificationPanel.tsx @@ -0,0 +1,135 @@ +import { Fragment, useEffect, useId, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import DividerWithText from "@app/components/shared/DividerWithText"; +import { useNotifications } from "@app/hooks/useNotifications"; +import type { ClientActionRegistry } from "@app/components/notifications/notificationActions"; +import { NotificationItem } from "@app/components/notifications/NotificationItem"; +import "@app/components/notifications/NotificationBell.css"; + +/** Named so a trigger in another tree can point at it with aria-controls. */ +export const NOTIFICATIONS_PANEL_ID = "quick-nav-notifications-panel"; + +export interface NotificationPanelProps { + onClose: () => void; + id?: string; + /** Passed in: its document handover has to run whether the panel is open or not. */ + registry: ClientActionRegistry; + style?: React.CSSProperties; + className?: string; +} + +/** Mounted only while open, since mounting is what marks everything read. */ +export function NotificationPanel({ + onClose, + registry, + id, + style, + className, +}: NotificationPanelProps) { + const { t } = useTranslation(); + const { notifications, unreadCount, documentStateFor, markAllSeen } = + useNotifications(); + const panel = useRef(null); + const headingId = useId(); + // Frozen on open, since opening marks them all read. + const [firstSeenId, setFirstSeenId] = useState(null); + + // On mount, not on close: waiting leaves the badge lit while they read. + const marked = useRef(false); + useEffect(() => { + if (marked.current) return; + marked.current = true; + // Before marking, or there is nothing left to divide on. + setFirstSeenId(notifications[unreadCount]?.id ?? null); + markAllSeen(); + }, [notifications, unreadCount, markAllSeen]); + + // No boundary means all were new; one that has left the list means none. + const boundaryIndex = firstSeenId + ? notifications.findIndex((notification) => notification.id === firstSeenId) + : notifications.length; + const dividedAt = Math.max(0, boundaryIndex); + + // Focus goes back to the opener only if it is still inside the panel on close. + useEffect(() => { + const opener = document.activeElement as HTMLElement | null; + panel.current?.focus(); + return () => { + if (panel.current?.contains(document.activeElement)) opener?.focus(); + }; + }, []); + + useEffect(() => { + const closeOnOutside = (event: MouseEvent) => { + const target = event.target as HTMLElement; + if (panel.current?.contains(target)) return; + // A trigger closes this itself; counting it as outside would reopen it. + if (target.closest?.("[data-notifications-trigger]")) return; + onClose(); + }; + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") onClose(); + }; + document.addEventListener("mousedown", closeOnOutside); + document.addEventListener("keydown", closeOnEscape); + return () => { + document.removeEventListener("mousedown", closeOnOutside); + document.removeEventListener("keydown", closeOnEscape); + }; + }, [onClose]); + + return ( + + ); +} diff --git a/frontend/editor/src/core/components/shared/AppSwitch.tsx b/frontend/editor/src/core/components/shared/AppSwitch.tsx index 71d35ed5ad..fbbe85ccdb 100644 --- a/frontend/editor/src/core/components/shared/AppSwitch.tsx +++ b/frontend/editor/src/core/components/shared/AppSwitch.tsx @@ -11,12 +11,7 @@ interface AppSwitchMenuItemsProps { onSwitch: (app: AppSwitchTarget) => void; } -/** - * The editor / processor items for the app-switch menu. Rendered inside the - * BrandSwitcher's logo dropdown, which both apps use as their switcher. The - * mark is the shared , which recolours itself from the theme - * tokens, so no colour-scheme prop needs threading down here. - */ +/** The editor / processor items for an app-switch menu. */ export function AppSwitchMenuItems({ current, onSwitch, diff --git a/frontend/editor/src/core/components/shared/AppSwitcher.tsx b/frontend/editor/src/core/components/shared/AppSwitcher.tsx deleted file mode 100644 index aaf55148ef..0000000000 --- a/frontend/editor/src/core/components/shared/AppSwitcher.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Logo } from "@app/ui/Logo"; - -export interface AppSwitcherProps { - /** Icon-only brand mark for the collapsed rail. */ - collapsed?: boolean; -} - -/** - * Sidebar brand header. Core has no admin portal to switch to, so it just - * shows the Stirling logo. Builds that bundle the portal (proprietary/saas) - * shadow this with a version whose logo doubles as the editor⇄processor - * switcher. - */ -export function AppSwitcher({ collapsed }: AppSwitcherProps) { - return ( - - ); -} diff --git a/frontend/editor/src/core/components/shared/BrandSwitcher.css b/frontend/editor/src/core/components/shared/BrandSwitcher.css deleted file mode 100644 index dc1659ea15..0000000000 --- a/frontend/editor/src/core/components/shared/BrandSwitcher.css +++ /dev/null @@ -1,15 +0,0 @@ -/* Logo + app-switch dropdown, shared between the editor and the processor. - The logo itself is the trigger (its mark morphs into a chevron on hover). */ -.sui-brand-switcher { - display: flex; - align-items: center; - flex: 1; - min-width: 0; -} - -/* Tighten the ghost-button padding so the lockup sits flush like a plain logo, - and negative-margin it back so the hover surface still extends past the text. */ -.sui-brand-switcher__trigger.sui-btn { - --button-padding-x: 0.375rem; - margin-inline: -0.375rem; -} diff --git a/frontend/editor/src/core/components/shared/BrandSwitcher.stories.tsx b/frontend/editor/src/core/components/shared/BrandSwitcher.stories.tsx deleted file mode 100644 index 92deb518b2..0000000000 --- a/frontend/editor/src/core/components/shared/BrandSwitcher.stories.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { BrandSwitcher } from "@app/components/shared/BrandSwitcher"; - -const meta: Meta = { - title: "Brand/BrandSwitcher", - component: BrandSwitcher, - parameters: { layout: "centered" }, - args: { current: "processor", onSwitch: () => {} }, - argTypes: { - current: { control: "inline-radio", options: ["editor", "processor"] }, - }, -}; -export default meta; -type Story = StoryObj; - -export const Playground: Story = {}; diff --git a/frontend/editor/src/core/components/shared/BrandSwitcher.tsx b/frontend/editor/src/core/components/shared/BrandSwitcher.tsx deleted file mode 100644 index 474173fee2..0000000000 --- a/frontend/editor/src/core/components/shared/BrandSwitcher.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { useState } from "react"; -import { useTranslation } from "react-i18next"; -import { Button, Dropdown } from "@app/ui"; -import { Logo } from "@app/ui/Logo"; -import { BrandMark } from "@app/components/shared/BrandMark"; -import { - AppSwitchMenuItems, - type AppSwitchTarget, -} from "@app/components/shared/AppSwitch"; -import "@app/components/shared/BrandSwitcher.css"; - -interface BrandSwitcherProps { - /** The app this is rendered in (shown active in the menu). */ - current: AppSwitchTarget; - /** Called with the selected app (only for the non-current one). */ - onSwitch: (app: AppSwitchTarget) => void; - /** Icon-only: drop the wordmark, keep the morphing mark as the trigger. */ - collapsed?: boolean; - className?: string; -} - -/** - * Brand lockup that doubles as the editor⇄processor switcher. The whole logo - * is the dropdown trigger: on hover / focus / open the mark morphs into a - * downward chevron (see BrandMark), so no separate chevron button is needed. - * Shared so the editor and the processor present one identical header. - */ -export function BrandSwitcher({ - current, - onSwitch, - collapsed = false, - className, -}: BrandSwitcherProps) { - const { t } = useTranslation(); - const [open, setOpen] = useState(false); - - return ( -
- - - - - - - - -
- ); -} diff --git a/frontend/editor/src/core/components/shared/BrandTile.tsx b/frontend/editor/src/core/components/shared/BrandTile.tsx new file mode 100644 index 0000000000..e8ccba6db8 --- /dev/null +++ b/frontend/editor/src/core/components/shared/BrandTile.tsx @@ -0,0 +1,29 @@ +interface BrandTileProps { + /** CSS length. Omit to let the caller's CSS size it. */ + size?: string; + className?: string; +} + +/** The mark in a rounded square. Decorative: call sites carry the accessible name. */ +export function BrandTile({ size, className }: BrandTileProps) { + return ( + + + + + + ); +} diff --git a/frontend/editor/src/core/components/shared/FileSidebar.css b/frontend/editor/src/core/components/shared/FileSidebar.css index 2347d9a2b2..76db9deab4 100644 --- a/frontend/editor/src/core/components/shared/FileSidebar.css +++ b/frontend/editor/src/core/components/shared/FileSidebar.css @@ -1,7 +1,9 @@ /* ========== FILE SIDEBAR ========== */ .file-sidebar { - background-color: var(--c-bg); + /* One solid panel, with a rule only on the workbench side, so it frames the document. */ + background-color: var(--c-surface); + border-inline-end: 1px solid var(--c-border-subtle); display: flex; flex-direction: column; height: 100%; @@ -37,12 +39,19 @@ gap: 0.5rem; } -/* ---- Brand header (logo / editor⇄processor switcher) ---- */ -.file-sidebar-brand { +/* Flattened here; two classes deep to beat .sui-nav-surface without relying on order. */ +.file-sidebar .sui-nav-surface { + background: transparent; + border: 0; + border-radius: 0; +} + +/* ---- Header row (wordmark + collapse toggle) ---- */ +.file-sidebar-header { display: flex; align-items: center; - min-height: 40px; - padding: 0 0.375rem; + min-height: var(--nav-header-h); + padding: 0 var(--nav-gutter); flex-shrink: 0; } @@ -51,9 +60,9 @@ flex-shrink: 0; } -.file-sidebar[data-collapsed="true"] .file-sidebar-brand { - flex-direction: column; - gap: 0.25rem; +/* Collapsed the row holds only the toggle, so centre it. */ +.file-sidebar[data-collapsed="true"] .file-sidebar-header { + justify-content: center; padding: 0; } .file-sidebar[data-collapsed="true"] .file-sidebar-collapse-toggle { diff --git a/frontend/editor/src/core/components/shared/FileSidebar.tsx b/frontend/editor/src/core/components/shared/FileSidebar.tsx index f9337bd880..1dcf4a7301 100644 --- a/frontend/editor/src/core/components/shared/FileSidebar.tsx +++ b/frontend/editor/src/core/components/shared/FileSidebar.tsx @@ -24,7 +24,6 @@ import { useViewer } from "@app/contexts/ViewerContext"; import { useFileHandler } from "@app/hooks/useFileHandler"; import { useAccountIdentity } from "@app/hooks/useAccountIdentity"; import { useFreeCreditsSummary } from "@app/hooks/useFreeCreditsSummary"; -import { useOtherAppSwitch } from "@app/hooks/useOtherAppSwitch"; import { useOpenPlan } from "@app/hooks/useOpenPlan"; import { NavFooter } from "@app/components/shared/navFooter/NavFooter"; import { @@ -32,8 +31,7 @@ import { useIndexedDBRevision, } from "@app/contexts/IndexedDBContext"; import { GoogleDriveIcon } from "@app/components/shared/CloudStorageIcons"; -import { AppSwitcher } from "@app/components/shared/AppSwitcher"; -import { SidebarToggleIcon } from "@app/components/shared/SidebarToggleIcon"; +import { SidebarHeader } from "@app/components/shared/SidebarHeader"; import type { StirlingFileStub } from "@app/types/fileContext"; import FolderOpenIcon from "@mui/icons-material/FolderOpen"; import FolderSpecialIcon from "@mui/icons-material/FolderSpecial"; @@ -78,8 +76,9 @@ import { WATCHED_FOLDERS_ENABLED } from "@app/constants/featureFlags"; import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; import "@app/components/shared/FileSidebar.css"; -const COLLAPSED_WIDTH = "3.5rem"; -const EXPANDED_WIDTH = "16.25rem"; // ~260px +// Shared with the processor sidebar via tokens, so the two cannot drift. +const COLLAPSED_WIDTH = "var(--sidebar-collapsed-w)"; +const EXPANDED_WIDTH = "var(--sidebar-w)"; // Inlined to avoid a circular import with WatchedFoldersRegistration. const WATCHED_FOLDER_VIEW_ID = "watchedFolder"; @@ -98,9 +97,11 @@ export interface FileSidebarProps { collapsed?: boolean; onToggleCollapse?: () => void; onOpenSettings?: () => void; - /** Accessible name override for the toggle button. */ + /** The quick nav rail owns the account control, so the footer drops its own row. */ + accountHoisted?: boolean; + /** Accessible name override for the collapse toggle. */ toggleAriaLabel?: string; - /** Icon override for the toggle button (e.g. back-arrow on /files). */ + /** Icon override for the collapse toggle (e.g. back-arrow on /files). */ toggleIcon?: React.ReactNode; /** Override the Open-from-computer handler (e.g. upload to /files folder). */ onUploadFiles?: (files: File[]) => void | Promise; @@ -155,11 +156,12 @@ const FileSidebar = forwardRef( collapsed = false, onToggleCollapse, onOpenSettings, + accountHoisted = false, + toggleAriaLabel, + toggleIcon, onUploadFiles, onPickGoogleDriveFiles, extraAction, - toggleAriaLabel, - toggleIcon, }, ref, ) { @@ -249,7 +251,6 @@ const FileSidebar = forwardRef( const { displayName, profilePictureUrl, isAnonymous } = useAccountIdentity(); const credits = useFreeCreditsSummary(); - const otherApp = useOtherAppSwitch(); const openPlan = useOpenPlan(); // Leaf files = user-visible files (excludes intermediate tool outputs) @@ -943,25 +944,12 @@ const FileSidebar = forwardRef(
)}
-
- - {onToggleCollapse && ( - onToggleCollapse()} - aria-label={ - toggleAriaLabel ?? - (collapsed - ? t("fileSidebar.expand", "Expand sidebar") - : t("fileSidebar.collapse", "Collapse sidebar")) - } - > - {toggleIcon ?? } - - )} -
+ {/* Box 1 — top controls (open / my files / cloud). No title. File search lives in the global super search (top bar), not here. */} @@ -984,7 +972,7 @@ const FileSidebar = forwardRef( {/* Tooltips only fire when collapsed - when expanded the visible text label below already identifies each row, so a tooltip would just flash a duplicate. Distinct icons (UploadFile for - "Open from computer" vs FolderOpen for "My Files") so the + "Open from computer" vs FolderOpen for "File library") so the collapsed rail isn't two identical folder icons either. */} ( onClick={() => { // "Open from computer" goes straight to the native OS file // picker. The full file manager (recent + drives + folders) - // is reachable via "My Files" below. + // is reachable via "File library" below. nativeFileInputRef.current?.click(); }} role="button" @@ -1080,7 +1068,7 @@ const FileSidebar = forwardRef( )} ( }} role="button" tabIndex={0} - aria-label={t("fileSidebar.myFiles", "My Files")} + aria-label={t("fileSidebar.myFiles", "File library")} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); @@ -1105,7 +1093,7 @@ const FileSidebar = forwardRef( {!collapsed && ( - {t("fileSidebar.myFiles", "My Files")} + {t("fileSidebar.myFiles", "File library")} )}
@@ -1370,15 +1358,15 @@ const FileSidebar = forwardRef( {/* Getting-started checklist, floating above the footer (SaaS only). */} - {/* Box 3 — the shared footer: credits, app switch, account row. */} + {/* Box 3 — the shared footer: credits, plan, and the account row unless hoisted. */} diff --git a/frontend/editor/src/core/components/shared/SidebarHeader.tsx b/frontend/editor/src/core/components/shared/SidebarHeader.tsx new file mode 100644 index 0000000000..1a2befa074 --- /dev/null +++ b/frontend/editor/src/core/components/shared/SidebarHeader.tsx @@ -0,0 +1,33 @@ +import { Logo } from "@app/ui/Logo"; +import { SidebarToggleButton } from "@app/components/shared/SidebarToggleButton"; + +export interface SidebarHeaderProps { + collapsed?: boolean; + onToggleCollapse?: () => void; + toggleAriaLabel?: string; + toggleIcon?: React.ReactNode; + className?: string; +} + +/** The wordmark and the collapse toggle; the brand mark sits in the rail beside it. */ +export function SidebarHeader({ + collapsed, + onToggleCollapse, + toggleAriaLabel, + toggleIcon, + className, +}: SidebarHeaderProps) { + return ( +
+ {!collapsed && } + {onToggleCollapse && ( + + )} +
+ ); +} diff --git a/frontend/editor/src/core/components/shared/SidebarToggleButton.tsx b/frontend/editor/src/core/components/shared/SidebarToggleButton.tsx new file mode 100644 index 0000000000..3a1345360b --- /dev/null +++ b/frontend/editor/src/core/components/shared/SidebarToggleButton.tsx @@ -0,0 +1,36 @@ +import { useTranslation } from "react-i18next"; +import { ActionIcon } from "@app/ui/ActionIcon"; +import { SidebarToggleIcon } from "@app/components/shared/SidebarToggleIcon"; + +export interface SidebarToggleButtonProps { + collapsed?: boolean; + onToggle: () => void; + ariaLabel?: string; + icon?: React.ReactNode; +} + +/** Opens and closes the sidebar; on /files the caller swaps in a back arrow. */ +export function SidebarToggleButton({ + collapsed, + onToggle, + ariaLabel, + icon, +}: SidebarToggleButtonProps) { + const { t } = useTranslation(); + return ( + onToggle()} + aria-label={ + ariaLabel ?? + (collapsed + ? t("fileSidebar.expand", "Expand sidebar") + : t("fileSidebar.collapse", "Collapse sidebar")) + } + > + {icon ?? } + + ); +} diff --git a/frontend/editor/src/core/components/shared/Tooltip.tsx b/frontend/editor/src/core/components/shared/Tooltip.tsx index 55c06a1533..b8128bf637 100644 --- a/frontend/editor/src/core/components/shared/Tooltip.tsx +++ b/frontend/editor/src/core/components/shared/Tooltip.tsx @@ -13,7 +13,7 @@ import { addEventListenerWithCleanup } from "@app/utils/genericUtils"; import { useTooltipPosition } from "@app/hooks/useTooltipPosition"; import { TooltipTip } from "@app/types/tips"; import { TooltipContent } from "@app/components/shared/tooltip/TooltipContent"; -import { useSidebarContext } from "@app/contexts/SidebarContext"; +import { useOptionalSidebarContext } from "@app/contexts/SidebarContext"; import { useLogoAssets } from "@app/hooks/useLogoAssets"; import styles from "@app/components/shared/tooltip/Tooltip.module.css"; import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex"; @@ -59,6 +59,29 @@ export interface TooltipProps { showCloseButton?: boolean; } +/** Split out so only tooltips with a header need the logo and the providers behind it. */ +function TooltipHeader({ + header, +}: { + header: NonNullable; +}) { + const { tooltipLogo } = useLogoAssets(); + return ( +
+
+ {header.logo || ( + Stirling PDF + )} +
+ {header.title} +
+ ); +} + export const Tooltip: React.FC = ({ sidebarTooltip = false, position, @@ -85,7 +108,6 @@ export const Tooltip: React.FC = ({ const { t } = useTranslation(); const [internalOpen, setInternalOpen] = useState(false); const [isPinned, setIsPinned] = useState(false); - const { tooltipLogo } = useLogoAssets(); const triggerRef = useRef(null); const tooltipRef = useRef(null); @@ -105,9 +127,9 @@ export const Tooltip: React.FC = ({ }, []); // Always call the hook unconditionally to satisfy React's rules of hooks. - // The context is only used when sidebarTooltip is true. - const sidebarContextValue = useSidebarContext(); - const sidebarContext = sidebarTooltip ? sidebarContextValue : null; + // Optional: the plain tooltip renders outside the provider. + const sidebarContextValue = useOptionalSidebarContext(); + const sidebarContext = sidebarTooltip ? (sidebarContextValue ?? null) : null; const isControlled = controlledOpen !== undefined; const open = (isControlled ? !!controlledOpen : internalOpen) && !disabled; @@ -443,20 +465,7 @@ export const Tooltip: React.FC = ({ } /> )} - {header && ( -
-
- {header.logo || ( - Stirling PDF - )} -
- {header.title} -
- )} + {header && } - {/* Left: optional "Back to My Files" + view switcher */} + {/* Left: optional "Back to File library" + view switcher */}
{returnRoute && hasFiles && ( <> @@ -501,7 +503,7 @@ export default function WorkbenchBar({ : "filesPage.backToMyFiles", returnRoute.label ? `Back to ${returnRoute.label}` - : "Back to My Files", + : "Back to File library", { folder: returnRoute.label ?? "" }, )} leftSection={} @@ -511,7 +513,7 @@ export default function WorkbenchBar({ ? t("filesPage.backToFolder", "Back to {{folder}}", { folder: returnRoute.label, }) - : t("filesPage.backToMyFiles", "Back to My Files")} + : t("filesPage.backToMyFiles", "Back to File library")}
@@ -603,9 +605,13 @@ export default function WorkbenchBar({ enforcingProgress={enforcingProgress} /> )} - {/* Last in the globals, so it is the rightmost control. */} -
- + {isPhone && ( + <> + {/* Last in the globals, so it is the rightmost control. */} +
+ + + )}
); diff --git a/frontend/editor/src/core/components/shared/navFooter/NavFooter.tsx b/frontend/editor/src/core/components/shared/navFooter/NavFooter.tsx index 373c91bccf..848c0cb4a5 100644 --- a/frontend/editor/src/core/components/shared/navFooter/NavFooter.tsx +++ b/frontend/editor/src/core/components/shared/navFooter/NavFooter.tsx @@ -33,6 +33,8 @@ export interface NavFooterProps { otherApp?: NavFooterAppLink | null; /** Extra rows above the account row (the self-hosted link-account CTA). */ accountExtras?: ReactNode; + /** False where the rail owns the account control, so only one avatar is drawn. */ + showAccount?: boolean; /** Icon-rail state: labels collapse to tooltips. */ collapsed?: boolean; className?: string; @@ -69,6 +71,7 @@ export function NavFooter({ onOpenPlan, otherApp, accountExtras, + showAccount = true, collapsed = false, className, }: NavFooterProps) { @@ -144,49 +147,51 @@ export function NavFooter({ }); } - rows.push({ - key: "account", - node: ( - - - - ), - }); + {!collapsed && ( + + {displayName} + + )} + {onOpenSettings && !collapsed && ( + + + + )} + + + ), + }); + } + + if (rows.length === 0) return null; return ( void; +} + +export function QuickNavBrand({ onReturnHome }: QuickNavBrandProps) { + const { t } = useTranslation(); + const label = t("quickNav.home", "Stirling"); + + return ( +
+ + + +
+ ); +} diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavHostBridge.tsx b/frontend/editor/src/core/components/shared/quickNav/QuickNavHostBridge.tsx new file mode 100644 index 0000000000..9a9101a826 --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavHostBridge.tsx @@ -0,0 +1,85 @@ +import { useCallback, useMemo, useState } from "react"; +import { useAccountIdentity } from "@app/hooks/useAccountIdentity"; +import { + NotificationPanel, + NOTIFICATIONS_PANEL_ID, +} from "@app/components/notifications/NotificationPanel"; +import { useNotificationActions } from "@app/components/notifications/notificationActions"; +import { useQuickNavToolReasons } from "@app/components/shared/quickNav/useQuickNavToolReasons"; +import { useNotificationsAvailable } from "@app/components/notifications/useNotificationsAvailable"; +import { useSigningBadgeCount } from "@app/hooks/signing/useSigningBadgeCount"; +import { + useRegisterQuickNavHost, + type QuickNavToolReasons, +} from "@app/contexts/QuickNavHostContext"; +import type { ToolId } from "@app/types/toolId"; + +export interface QuickNavHostBridgeProps { + portalAccess?: boolean; + readerMode?: boolean; + onSetReaderMode?: (on: boolean) => void; + onOpenSettings: () => void; + requestNavigation?: (go: () => void) => void; + onGoToDefaultState?: () => void; + onSelectTool?: (toolId: ToolId) => void; + /** Merged over the reasons worked out here, for what only the app can see. */ + toolReasons?: QuickNavToolReasons; +} + +/** Registers with the rail what only the app can see, and owns the notifications panel. */ +export function QuickNavHostBridge({ + portalAccess = false, + readerMode = false, + onSetReaderMode, + onOpenSettings, + requestNavigation, + onSelectTool, + onGoToDefaultState, + toolReasons, +}: QuickNavHostBridgeProps) { + const { displayName, profilePictureUrl } = useAccountIdentity(); + const signingBadge = useSigningBadgeCount(); + const notificationsAvailable = useNotificationsAvailable(); + // Built even when closed: it carries a one-shot document pickup that would sit unclaimed. + const notificationActions = useNotificationActions(); + const endpointReasons = useQuickNavToolReasons(); + const mergedToolReasons = useMemo(() => { + // An empty map from the app is silence, not an answer. + const extra = + toolReasons && Object.keys(toolReasons).length > 0 ? toolReasons : null; + if (!endpointReasons && !extra) return undefined; + return { ...endpointReasons, ...extra }; + }, [endpointReasons, toolReasons]); + const [notificationsOpen, setNotificationsOpen] = useState(false); + const closeNotifications = useCallback(() => setNotificationsOpen(false), []); + + useRegisterQuickNavHost( + { + identity: { displayName, profilePictureUrl }, + signingBadge, + portalAccess, + readerMode, + notificationsOpen, + toolReasons: mergedToolReasons, + }, + { + openSettings: onOpenSettings, + requestNavigation, + selectTool: onSelectTool, + setReaderMode: onSetReaderMode, + goToDefaultState: onGoToDefaultState, + toggleNotifications: () => setNotificationsOpen((open) => !open), + }, + ); + + // Mounted only while open, so a closed panel never subscribes to the poll. + if (!notificationsAvailable || !notificationsOpen) return null; + return ( + + ); +} diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRail.css b/frontend/editor/src/core/components/shared/quickNav/QuickNavRail.css new file mode 100644 index 0000000000..4d6845f592 --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRail.css @@ -0,0 +1,173 @@ +/* ========== QUICK NAV RAIL ========== */ + +.quick-nav-rail { + display: flex; + flex-direction: column; + align-items: center; + height: 100%; + width: 100%; +} + +.quick-nav-rail-group { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--quicknav-item-gap); + flex-shrink: 0; + width: 100%; +} + +/* The glyph stays at the sidebar's scale; the button fills the rail for hit area. */ +.quick-nav-rail-item { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 100%; + height: 2.25rem; + padding: 0; + border: none; + border-radius: var(--radius-md); + background: transparent; + color: var(--c-text-subtle); + cursor: pointer; + transition: + background-color var(--motion-fast), + color var(--motion-fast); +} + +.quick-nav-rail-item svg, +.quick-nav-rail-item img { + width: 1.125rem; + height: 1.125rem; + color: inherit; + fill: currentColor; +} + +/* Taller than wide (71x79), so height drives the width. */ +.quick-nav-rail-item .sui-brandmark { + width: auto; + height: 1.125rem; +} + +.quick-nav-rail-item[aria-disabled="true"] .sui-brandmark, +.quick-nav-rail-item[aria-disabled="true"] svg[viewBox="0 0 256 256"] { + filter: grayscale(1); +} + +.quick-nav-rail-item:hover { + background: var(--c-hover); + color: var(--c-text); +} + +/* Opacity rather than a colour: there is no disabled-text token. */ +.quick-nav-rail-item[aria-disabled="true"] { + opacity: 0.5; + cursor: not-allowed; +} +.quick-nav-rail-item[aria-disabled="true"]:hover { + background: transparent; + color: var(--c-primary); +} + +.quick-nav-rail-item:focus-visible { + outline: 0.125rem solid var(--c-primary); + outline-offset: -0.125rem; +} + +.quick-nav-rail-badge { + position: absolute; + top: 0.125rem; + inset-inline-end: 0.125rem; + min-width: 0.875rem; + height: 0.875rem; + padding: 0 0.1875rem; + border-radius: var(--radius-pill); + /* The solid step: 9px numerals need the darker end of the ramp. */ + background: var(--c-danger-solid); + color: var(--c-text-on-primary); + font-size: 0.5625rem; + font-weight: var(--font-weight-semibold); + line-height: 0.875rem; + text-align: center; + font-variant-numeric: tabular-nums; + pointer-events: none; +} + +.quick-nav-rail-badge[data-tone="warning"] { + background: var(--c-warning-solid); +} + +/* Top margin only: the group below supplies the other half, centring the rule. */ +.quick-nav-rail-divider { + width: 100%; + height: 0; + margin: var(--quicknav-item-gap) 0 0; + border: 0; + border-top: 1px solid var(--c-border); +} + +.quick-nav-rail-footer { + margin-top: auto; + display: flex; + flex-direction: column; + align-items: center; + gap: var(--quicknav-item-gap); + width: 100%; + flex-shrink: 0; +} + +/* ---- Brand: one header row tall, so it lines up with the sidebar's wordmark ---- */ +.quick-nav-brand { + width: 100%; + height: var(--nav-header-h); + flex-shrink: 0; + /* The bar's inset supplies part of the gap; only the remainder is added here. */ + margin-bottom: calc(var(--quicknav-item-gap) - var(--quicknav-surface-pad)); +} + +.quick-nav-brand-button { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + border: none; + background: transparent; + padding: 0; + cursor: pointer; +} + +.quick-nav-brand-button:focus-visible { + outline: 0.125rem solid var(--c-primary); + outline-offset: -0.125rem; + border-radius: var(--radius-md); +} + +/* The "on" state: a solid block with the glyph knocked out, hover owning the tints. */ +.quick-nav-rail-item[aria-current="true"], +.quick-nav-rail-item[aria-current="true"]:hover, +.quick-nav-rail-item[aria-pressed="true"], +.quick-nav-rail-item[aria-pressed="true"]:hover { + background: var(--c-text); + color: var(--c-surface); +} + +/* On a dark ground full ink is white, so mix the block back toward the surface. */ +[data-theme="dark"] .quick-nav-rail-item[aria-current="true"], +[data-theme="dark"] .quick-nav-rail-item[aria-current="true"]:hover, +[data-theme="dark"] .quick-nav-rail-item[aria-pressed="true"], +[data-theme="dark"] .quick-nav-rail-item[aria-pressed="true"]:hover, +html[data-app-theme="midnight"] .quick-nav-rail-item[aria-current="true"], +html[data-app-theme="midnight"] .quick-nav-rail-item[aria-current="true"]:hover, +html[data-app-theme="midnight"] .quick-nav-rail-item[aria-pressed="true"], +html[data-app-theme="midnight"] .quick-nav-rail-item[aria-pressed="true"]:hover, +[data-mantine-color-scheme="dark"] .quick-nav-rail-item[aria-current="true"], +[data-mantine-color-scheme="dark"] + .quick-nav-rail-item[aria-current="true"]:hover, +[data-mantine-color-scheme="dark"] .quick-nav-rail-item[aria-pressed="true"], +[data-mantine-color-scheme="dark"] + .quick-nav-rail-item[aria-pressed="true"]:hover { + background: color-mix(in srgb, var(--c-text) 80%, var(--c-surface)); + color: var(--c-surface); +} diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailAccount.css b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailAccount.css new file mode 100644 index 0000000000..c12a6a1f71 --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailAccount.css @@ -0,0 +1,29 @@ +/* The account control, pinned to the bottom of the bar. */ + +.quick-nav-rail-account { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-1); + flex-shrink: 0; + /* Further than the shortcut gap: a filled disc reads heavier than a line glyph. */ + margin-top: var(--space-2); + /* Matches the slack centring the brand mark leaves at the top. */ + padding-bottom: 0.3125rem; +} + +.quick-nav-rail-avatar-target { + display: inline-flex; +} + +/* Appearance comes from the shared Avatar; only the button reset is ours. */ +.quick-nav-rail-avatar { + border: none; + padding: 0; + user-select: none; +} + +.quick-nav-rail-avatar:focus-visible { + outline: 0.125rem solid var(--c-primary); + outline-offset: 0.125rem; +} diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailAccount.tsx b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailAccount.tsx new file mode 100644 index 0000000000..1404925de6 --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailAccount.tsx @@ -0,0 +1,45 @@ +import { useTranslation } from "react-i18next"; +import { Tooltip } from "@app/components/shared/Tooltip"; +import { Avatar } from "@app/ui/Avatar"; +import type { QuickNavIdentity } from "@app/contexts/QuickNavHostContext"; +import "@app/components/shared/quickNav/QuickNavRailAccount.css"; + +export interface QuickNavRailAccountProps { + onOpenSettings: () => void; + /** Null between apps; the disc still renders, so the bar keeps its shape. */ + identity: QuickNavIdentity | null; +} + +/** The avatar opens settings, so there is no separate gear beside it. */ +export function QuickNavRailAccount({ + onOpenSettings, + identity, +}: QuickNavRailAccountProps) { + const { t } = useTranslation(); + const displayName = + identity?.displayName ?? t("auth.displayName.user", "User"); + const profilePictureUrl = identity?.profilePictureUrl ?? null; + const label = `${displayName} — ${t("fileSidebar.openSettings", "Open settings")}`; + + return ( +
+ + {/* A span, not the Avatar: Tooltip binds by cloning its child. */} + + + + +
+ ); +} diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailBase.test.tsx b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailBase.test.tsx new file mode 100644 index 0000000000..328f2d8f4e --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailBase.test.tsx @@ -0,0 +1,123 @@ +import { describe, it, expect } from "vitest"; +import { render } from "@testing-library/react"; +import { + QuickNavRailBase, + type QuickNavEntry, +} from "@app/components/shared/quickNav/QuickNavRailBase"; + +/** The rail needs no providers. */ +function withProviders(ui: React.ReactNode) { + return <>{ui}; +} + +function entry( + id: string, + overrides: Partial = {}, +): QuickNavEntry { + return { + id, + label: id, + icon: null, + onClick: () => {}, + ...overrides, + }; +} + +const PROCESSOR = entry("processor"); +const WITHIN = [entry("files"), entry("reader")]; + +function renderRail(groups: QuickNavEntry[][]) { + const { container } = render( + withProviders(), + ); + return { + labels: [...container.querySelectorAll(".quick-nav-rail-item")].map((b) => + b.getAttribute("aria-label"), + ), + dividers: container.querySelectorAll(".quick-nav-rail-divider").length, + }; +} + +describe("QuickNavRailBase — groups", () => { + it("divides one group from the next", () => { + const { labels, dividers } = renderRail([[PROCESSOR], WITHIN]); + + expect(labels).toEqual(["processor", "files", "reader"]); + expect(dividers).toBe(1); + }); + + it("drops an empty group, and the divider with it", () => { + const { labels, dividers } = renderRail([[], WITHIN]); + + expect(labels).toEqual(["files", "reader"]); + expect(dividers).toBe(0); + }); +}); + +describe("QuickNavRailBase — entry state", () => { + it("reports on/off for a toggle and nothing for the rest", () => { + // Nothing here is a view you occupy, so only a real toggle has state. + const { container } = render( + withProviders( + , + ), + ); + + const state = [...container.querySelectorAll(".quick-nav-rail-item")].map( + (b) => [b.getAttribute("aria-label"), b.getAttribute("aria-pressed")], + ); + expect(state).toEqual([ + ["processor", null], + ["reader", "true"], + ["files", null], + ]); + expect(container.querySelectorAll("[aria-current]")).toHaveLength(0); + }); + + it("keeps a disabled entry in the tab order so its reason stays reachable", () => { + // The tooltip carrying the reason is only reachable while it can be focused. + const { container } = render( + withProviders( + , + ), + ); + + const automate = container.querySelector('[aria-label="automate"]')!; + expect(automate.getAttribute("aria-disabled")).toBe("true"); + expect(automate.hasAttribute("disabled")).toBe(false); + }); + + it("keeps an unavailable entry rendered, disabled rather than dropped", () => { + // Slots must not appear and vanish as access resolves. + const { container } = render( + withProviders( + , + ), + ); + + const processor = container.querySelector('[aria-label="processor"]'); + expect(processor).not.toBeNull(); + expect(processor?.getAttribute("aria-disabled")).toBe("true"); + // aria-disabled, not the disabled attribute: it stays focusable for its tooltip. + expect(processor?.hasAttribute("disabled")).toBe(false); + }); +}); diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailBase.tsx b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailBase.tsx new file mode 100644 index 0000000000..5c0be45db0 --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailBase.tsx @@ -0,0 +1,101 @@ +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { Tooltip } from "@app/components/shared/Tooltip"; +import "@app/components/shared/quickNav/QuickNavRail.css"; + +export type QuickNavTarget = "reader" | "editor" | "files" | "processor"; + +export interface QuickNavEntry { + id: string; + label: string; + icon: ReactNode; + /** The app you are in, drawn with an edge bar. */ + current?: boolean; + /** Only for entries that toggle something; use `current` for the app you are in. */ + pressed?: boolean; + /** Inert, with `reason` as its tooltip. Entries are dimmed, never dropped. */ + disabled?: boolean; + reason?: string; + badge?: number; + /** Popup semantics for an entry whose panel is rendered in another tree. */ + expanded?: boolean; + controls?: string; + /** "danger" waits on the user; "warning" is awareness only. */ + badgeTone?: "danger" | "warning"; + onClick: () => void; +} + +export interface QuickNavRailBaseProps { + /** Divided by a rule; empty groups are dropped. */ + groups: QuickNavEntry[][]; + footer?: ReactNode; +} + +/** Exported so footer entries reuse it rather than a lookalike. */ +export function RailButton({ + label, + icon, + pressed, + disabled, + reason, + badge, + badgeTone = "danger", + current, + expanded, + controls, + onClick, +}: Omit) { + return ( + + + + ); +} + +export function QuickNavRailBase({ groups, footer }: QuickNavRailBaseProps) { + const { t } = useTranslation(); + const populated = groups.filter((entries) => entries.length > 0); + return ( + + ); +} diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailContainer.css b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailContainer.css new file mode 100644 index 0000000000..606c2c3831 --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailContainer.css @@ -0,0 +1,38 @@ +.quick-nav-rail-container { + /* On the column, so the parts outside the nav inherit them too. */ + --quicknav-item-gap: var(--space-3); + --quicknav-surface-pad: 0.375rem; + + width: var(--nav-rail-w); + height: 100%; + flex-shrink: 0; + box-sizing: border-box; + /* On the column, not the bar, so the fill covers the gutters too. */ + background-color: var(--c-surface); + border-inline-end: 1px solid var(--c-border-subtle); + display: flex; + flex-direction: column; + padding-block: var(--nav-gutter); + padding-inline: calc(var(--nav-gutter) / 2); +} + +/* Child selector to beat .sui-nav-surface, which would win on order. */ +.quick-nav-rail-container > .quick-nav-rail-surface { + background: transparent; + border: 0; + border-radius: 0; +} + +.quick-nav-rail-surface { + flex: 1; + min-height: 0; + /* No inline padding: the bar is one target wide and would squeeze the buttons. */ + padding: var(--quicknav-surface-pad) 0; +} + +/* Below this width the sidebar is an off-canvas drawer, and the rail is just noise. */ +@media (max-width: 48rem) { + .quick-nav-rail-container { + display: none; + } +} diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailContainer.tsx b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailContainer.tsx new file mode 100644 index 0000000000..154700518e --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailContainer.tsx @@ -0,0 +1,83 @@ +import { useTranslation } from "react-i18next"; +import { NavSurface } from "@app/ui/NavSurface"; +import LocalIcon from "@app/components/shared/LocalIcon"; +import { QuickNavBrand } from "@app/components/shared/quickNav/QuickNavBrand"; +import type { QuickNavIdentity } from "@app/contexts/QuickNavHostContext"; +import { + QuickNavRailBase, + RailButton, + type QuickNavRailBaseProps, +} from "@app/components/shared/quickNav/QuickNavRailBase"; +import { QuickNavRailAccount } from "@app/components/shared/quickNav/QuickNavRailAccount"; +import { QuickNavRailNotifications } from "@app/components/shared/quickNav/QuickNavRailNotifications"; +import "@app/components/shared/quickNav/QuickNavRailContainer.css"; + +export type { + QuickNavEntry, + QuickNavTarget, +} from "@app/components/shared/quickNav/QuickNavRailBase"; + +export interface QuickNavRailContainerProps extends Omit< + QuickNavRailBaseProps, + "footer" +> { + /** The rail owns the account control, so the sidebars drop their own row. */ + onOpenSettings?: () => void; + /** Omitted in builds with no processor to invite anyone into. */ + onInvite?: () => void; + onToggleNotifications?: () => void; + notificationsOpen?: boolean; + identity?: QuickNavIdentity | null; + onReturnHome: () => void; +} + +/** The fixed-width column the rail sits in. */ +export function QuickNavRailContainer({ + onOpenSettings, + onInvite, + onToggleNotifications, + notificationsOpen, + identity = null, + onReturnHome, + ...railProps +}: QuickNavRailContainerProps) { + const { t } = useTranslation(); + return ( +
+ + + + + {onInvite && ( + + } + onClick={onInvite} + /> + )} + {onOpenSettings && ( + + )} +
+ } + /> +
+
+ ); +} diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailHost.tsx b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailHost.tsx new file mode 100644 index 0000000000..5c748b36cc --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailHost.tsx @@ -0,0 +1,178 @@ +import { useTranslation } from "react-i18next"; +import { useLocation, useNavigate } from "react-router-dom"; +import LocalIcon from "@app/components/shared/LocalIcon"; +import { QuickNavRailContainer } from "@app/components/shared/quickNav/QuickNavRailContainer"; +import type { QuickNavEntry } from "@app/components/shared/quickNav/QuickNavRailBase"; +import type { ToolId } from "@app/types/toolId"; +import { useQuickNavHost } from "@app/contexts/QuickNavHostContext"; +import { requestReaderMode } from "@app/utils/pendingReaderMode"; +import { + saveEditorReturnPath, + takeEditorReturnPath, +} from "@app/services/workbenchSession"; +import { EDITOR_BASENAME } from "@app/routes/editorBasename"; +import { PORTAL_BASENAME } from "@app/routes/portalBasename"; +import { HAS_PORTAL } from "@app/routes/hasPortal"; + +const SIZE = "1.125rem"; + +/** Entries come from the URL, not either app's context, so the rail survives a switch. */ +export function QuickNavRailHost() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { pathname, search } = useLocation(); + const host = useQuickNavHost(); + + const appMounted = Boolean(host?.appMounted); + + const inPortal = pathname.startsWith(PORTAL_BASENAME); + + // Only the app knows its own default state. + const returnHome = () => { + const reset = host?.actions.current?.goToDefaultState; + if (reset) reset(); + else navigate(inPortal ? PORTAL_BASENAME : EDITOR_BASENAME); + }; + + // Guarded where the app supplies a guard, so leaving mid-edit still prompts. + const go = (to: string) => { + const guard = host?.actions.current?.requestNavigation; + if (guard) guard(() => navigate(to)); + else navigate(to); + }; + + // Through the app where possible: its route only selects a tool on a fresh mount. + const openTool = (toolId: ToolId, route: string) => { + const select = host?.actions.current?.selectTool; + if (select) select(toolId); + else go(route); + }; + + const unusable = (id: ToolId) => { + const reason = host?.toolReasons?.[id]; + return { disabled: Boolean(reason), reason }; + }; + + const apps: QuickNavEntry[] = [ + { + id: "processor", + label: t("quickNav.processor", "Processor"), + // Two literals, not a computed name: the offline icon bundle scans for `icon="..."`. + icon: inPortal ? ( + + ) : ( + + ), + current: inPortal, + disabled: HAS_PORTAL && !inPortal && !host?.portalAccess, + reason: + HAS_PORTAL && !inPortal && !host?.portalAccess + ? t("quickNav.noProcessorAccess", "Ask an admin for processor access") + : undefined, + onClick: () => { + if (inPortal) { + returnHome(); + return; + } + saveEditorReturnPath(pathname + search); + go(PORTAL_BASENAME); + }, + }, + { + id: "editor", + label: t("quickNav.editor", "Editor"), + icon: inPortal ? ( + + ) : ( + + ), + current: !inPortal, + onClick: () => { + if (!inPortal) { + returnHome(); + return; + } + // Back to where you left the editor, not its front door. + navigate(takeEditorReturnPath() ?? EDITOR_BASENAME); + }, + }, + ]; + + const within: QuickNavEntry[] = [ + { + id: "files", + label: t("fileSidebar.myFiles", "File library"), + icon: ( + + ), + onClick: () => go("/files"), + }, + { + id: "reader", + label: t("quickNav.reader", "Reader"), + icon: ( + + ), + pressed: Boolean(host?.readerMode), + // From the processor there is no editor to toggle - see pendingReaderMode. + onClick: () => { + const setMode = host?.actions.current?.setReaderMode; + if (setMode) { + setMode(!host?.readerMode); + return; + } + requestReaderMode(); + go(EDITOR_BASENAME); + }, + }, + { + id: "automate", + label: t("quickAccess.automate", "Automate"), + icon: ( + + ), + ...unusable("automate"), + onClick: () => openTool("automate", "/automate"), + }, + { + id: "sharedSign", + label: t("home.sharedSign.title", "Shared Signing"), + icon: ( + + ), + badge: host?.signingBadge, + badgeTone: "warning", + ...unusable("sharedSign"), + onClick: () => openTool("sharedSign", "/shared-sign"), + }, + ]; + + // Read at click time, so it's always the mounted app's. + const openSettings = () => host?.actions.current?.openSettings?.(); + + // A route that isn't the app hides the bar - see useSuppressQuickNavRail. + if (!appMounted || host?.chromeless) return null; + + return ( + go(`${PORTAL_BASENAME}/users`) + : undefined + } + onToggleNotifications={() => + host?.actions.current?.toggleNotifications?.() + } + notificationsOpen={host?.notificationsOpen} + /> + ); +} diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailNotifications.test.tsx b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailNotifications.test.tsx new file mode 100644 index 0000000000..800eb55b5b --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailNotifications.test.tsx @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import type { AppNotification } from "@app/services/notifications"; +import { QuickNavRailNotifications } from "@app/components/shared/quickNav/QuickNavRailNotifications"; + +const fetchNotifications = vi.fn(); + +vi.mock("@app/services/notifications", () => ({ + fetchNotifications: (...args: unknown[]) => fetchNotifications(...args), +})); + +vi.mock("@app/services/localFilePresence", () => ({ + hasLocalFile: () => Promise.resolve(false), +})); + +const h = vi.hoisted(() => ({ notificationsAvailable: true })); + +vi.mock("@app/components/notifications/useNotificationsAvailable", () => ({ + useNotificationsAvailable: () => h.notificationsAvailable, +})); + +function notification(id: string): AppNotification { + return { + id, + kind: "PIPELINE_FAILED", + title: id, + createdAt: "2026-01-01T00:00:00Z", + fileId: null, + sourceId: null, + count: 1, + actions: [], + } as unknown as AppNotification; +} + +describe("QuickNavRailNotifications", () => { + beforeEach(() => { + window.localStorage.clear(); + fetchNotifications.mockReset().mockResolvedValue([]); + h.notificationsAvailable = true; + }); + + it("keeps out of a build with no notifications API, and off its timer", async () => { + // No endpoint to poll and nothing it could show. + h.notificationsAvailable = false; + + const { container } = render( + {}} />, + ); + + await Promise.resolve(); + expect(container.querySelector(".quick-nav-rail-item")).toBeNull(); + expect(fetchNotifications).not.toHaveBeenCalled(); + }); + + it("carries the unread count on the icon", async () => { + fetchNotifications.mockResolvedValue([ + notification("a"), + notification("b"), + ]); + + render( {}} />); + + expect(await screen.findByText("2")).toBeTruthy(); + }); + + it("asks the mounted app to open the panel rather than opening one itself", async () => { + const onToggle = vi.fn(); + const { container } = render( + , + ); + + await waitFor(() => expect(fetchNotifications).toHaveBeenCalled()); + fireEvent.click(container.querySelector(".quick-nav-rail-item")!); + + expect(onToggle).toHaveBeenCalledTimes(1); + // No panel of its own: a row's actions would have no workbench to act on. + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("stays pressable before an app has registered, doing nothing", async () => { + // Between apps there is briefly no handler. + const { container } = render(); + + await waitFor(() => expect(fetchNotifications).toHaveBeenCalled()); + const button = container.querySelector(".quick-nav-rail-item")!; + expect(() => fireEvent.click(button)).not.toThrow(); + }); +}); diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailNotifications.tsx b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailNotifications.tsx new file mode 100644 index 0000000000..aee294de1c --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailNotifications.tsx @@ -0,0 +1,51 @@ +import { useTranslation } from "react-i18next"; +import LocalIcon from "@app/components/shared/LocalIcon"; +import { RailButton } from "@app/components/shared/quickNav/QuickNavRailBase"; +import { useNotifications } from "@app/hooks/useNotifications"; +import { useNotificationsAvailable } from "@app/components/notifications/useNotificationsAvailable"; +import { NOTIFICATIONS_PANEL_ID } from "@app/components/notifications/NotificationPanel"; + +export interface QuickNavRailNotificationsProps { + onToggle?: () => void; + /** Whether the app's panel is open, which this button reports but does not own. */ + open?: boolean; +} + +/** The count is read here; the app owns the panel - see NotificationPanel. */ +export function QuickNavRailNotifications({ + onToggle, + open = false, +}: QuickNavRailNotificationsProps) { + // Gated before the count is read: subscribing starts the poll. + const available = useNotificationsAvailable(); + if (!available) return null; + return ; +} + +function MountedRailNotifications({ + onToggle, + open, +}: QuickNavRailNotificationsProps) { + const { t } = useTranslation(); + const { unreadCount } = useNotifications(); + + return ( + // Read by the panel's outside-click handler; on a wrapper, RailButton's props being fixed. + + + } + badge={unreadCount} + expanded={Boolean(open)} + controls={NOTIFICATIONS_PANEL_ID} + onClick={() => onToggle?.()} + /> + + ); +} diff --git a/frontend/editor/src/core/components/shared/quickNav/useQuickNavToolReasons.test.tsx b/frontend/editor/src/core/components/shared/quickNav/useQuickNavToolReasons.test.tsx new file mode 100644 index 0000000000..6295ddde66 --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/useQuickNavToolReasons.test.tsx @@ -0,0 +1,139 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { useQuickNavToolReasons } from "@app/components/shared/quickNav/useQuickNavToolReasons"; + +const h = vi.hoisted(() => ({ + endpointStatus: {} as Record, + endpointDetails: {} as Record, + loading: false, + configLoading: false, + groupSigningEnabled: true, +})); + +vi.mock("@app/hooks/useEndpointConfig", () => ({ + useMultipleEndpointsEnabled: () => ({ + endpointStatus: h.endpointStatus, + endpointDetails: h.endpointDetails, + loading: h.loading, + error: null, + refetch: async () => {}, + }), +})); + +vi.mock("@app/contexts/AppConfigContext", () => ({ + useAppConfig: () => ({ + config: null, + loading: h.configLoading, + error: null, + refetch: async () => {}, + }), +})); + +vi.mock("@app/hooks/useGroupSigningEnabled", () => ({ + useGroupSigningEnabled: () => h.groupSigningEnabled, +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (_key: string, fallback?: string) => fallback ?? _key, + }), +})); + +describe("useQuickNavToolReasons", () => { + beforeEach(() => { + window.localStorage.clear(); + h.endpointStatus = {}; + h.endpointDetails = {}; + h.loading = false; + h.configLoading = false; + h.groupSigningEnabled = true; + }); + + it("admits it does not know rather than reporting nothing wrong", () => { + h.loading = true; + h.endpointStatus = { automate: false }; + + expect( + renderHook(() => useQuickNavToolReasons()).result.current, + ).toBeNull(); + }); + + it("reports what it last knew while the answer is being fetched again", () => { + // Each app has its own query cache, and a reload has none at all. + h.endpointStatus = { automate: false }; + h.endpointDetails = { automate: { reason: "CONFIG" } }; + renderHook(() => useQuickNavToolReasons()); + + h.loading = true; + h.endpointStatus = {}; + h.endpointDetails = {}; + + const { result } = renderHook(() => useQuickNavToolReasons()); + expect(result.current?.automate).toBe("Disabled by server administrator"); + }); + + it("forgets a reason once the server stops reporting it", () => { + h.endpointStatus = { automate: false }; + h.endpointDetails = { automate: { reason: "CONFIG" } }; + renderHook(() => useQuickNavToolReasons()); + + h.endpointStatus = { automate: true }; + h.endpointDetails = {}; + expect(renderHook(() => useQuickNavToolReasons()).result.current).toEqual( + {}, + ); + + // The cleared state, not the old reason, is what a reload reads back. + h.loading = true; + expect(renderHook(() => useQuickNavToolReasons()).result.current).toEqual( + {}, + ); + }); + + it("says nothing about an endpoint the server reports as available", () => { + h.endpointStatus = { automate: true }; + + expect(renderHook(() => useQuickNavToolReasons()).result.current).toEqual( + {}, + ); + }); + + it("blames the administrator when the endpoint was turned off by config", () => { + h.endpointStatus = { automate: false }; + h.endpointDetails = { automate: { reason: "CONFIG" } }; + + const { result } = renderHook(() => useQuickNavToolReasons()); + // The tool picker's label with its trailing colon stripped. + expect(result.current?.automate).toBe("Disabled by server administrator"); + }); + + it("blames the missing dependency when that is what the server said", () => { + h.endpointStatus = { automate: false }; + h.endpointDetails = { automate: { reason: "DEPENDENCY" } }; + + const { result } = renderHook(() => useQuickNavToolReasons()); + expect(result.current?.automate).toBe( + "Unavailable - required tool missing on server", + ); + }); + + it("greys out shared signing when the server has the feature switched off", () => { + // A whole feature rather than a removable endpoint, so it has its own signal. + h.groupSigningEnabled = false; + + const { result } = renderHook(() => useQuickNavToolReasons()); + expect(result.current?.sharedSign).toBe( + "Collaborative signing isn't enabled on this server", + ); + }); + + it("waits for the config before judging shared signing", () => { + // The config loads separately and reads as "off" before it arrives. + h.configLoading = true; + h.groupSigningEnabled = false; + + expect( + renderHook(() => useQuickNavToolReasons()).result.current, + ).toBeNull(); + }); +}); diff --git a/frontend/editor/src/core/components/shared/quickNav/useQuickNavToolReasons.ts b/frontend/editor/src/core/components/shared/quickNav/useQuickNavToolReasons.ts new file mode 100644 index 0000000000..235f4d4f08 --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/useQuickNavToolReasons.ts @@ -0,0 +1,131 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useMultipleEndpointsEnabled } from "@app/hooks/useEndpointConfig"; +import { useAppConfig } from "@app/contexts/AppConfigContext"; +import { useGroupSigningEnabled } from "@app/hooks/useGroupSigningEnabled"; +import { getDisabledLabel } from "@app/components/tools/fullscreen/shared"; +import type { QuickNavToolReasons } from "@app/contexts/QuickNavHostContext"; +import type { ToolId } from "@app/types/toolId"; + +const ENTRY_ENDPOINTS = { + automate: ["automate"], +} satisfies Partial>; + +// Object.keys widens to string, which a tool-id-keyed record can't be indexed by. +const ENDPOINT_ENTRIES = Object.keys( + ENTRY_ENDPOINTS, +) as (keyof typeof ENTRY_ENDPOINTS)[]; + +/** Shared signing is a feature toggle rather than an endpoint, so it has its own cause. */ +type EndpointCause = "missingDependency" | "disabledByAdmin"; +type Cause = EndpointCause | "groupSigningOff"; +type Causes = Partial>; +const CAUSES: Cause[] = [ + "missingDependency", + "disabledByAdmin", + "groupSigningOff", +]; + +/** Causes, not sentences, so a language change can't resurrect stale text. */ +const STORAGE_KEY = "stirling.quickNav.toolCauses"; + +function readRemembered(): Causes | null { + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return null; + } + const known = Object.entries(parsed as Record).filter( + ([, cause]) => CAUSES.includes(cause as Cause), + ) as [ToolId, Cause][]; + return Object.fromEntries(known); + } catch { + return null; + } +} + +function remember(causes: Causes): void { + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(causes)); + } catch { + // Won't survive the next reload. + } +} + +function causesFor( + endpointStatus: Record, + endpointDetails: Record, +): Causes { + const causes: Causes = {}; + for (const entry of ENDPOINT_ENTRIES) { + const off = ENTRY_ENDPOINTS[entry].filter( + (name) => endpointStatus[name] === false, + ); + if (off.length === 0) continue; + causes[entry] = off.some( + (name) => endpointDetails[name]?.reason === "DEPENDENCY", + ) + ? "missingDependency" + : "disabledByAdmin"; + } + return causes; +} + +/** Why a rail entry can't be used. Null means no answer yet, an empty map nothing wrong. */ +export function useQuickNavToolReasons(): QuickNavToolReasons | null { + const { t } = useTranslation(); + const endpoints = useMemo(() => Object.values(ENTRY_ENDPOINTS).flat(), []); + const { endpointStatus, endpointDetails, loading } = + useMultipleEndpointsEnabled(endpoints); + + // Read once: later reads would fight the live answer. + const [remembered] = useState(readRemembered); + + const { loading: configLoading } = useAppConfig(); + const groupSigningEnabled = useGroupSigningEnabled(); + + const live = useMemo(() => { + // A half answer would dim entries it can't see yet. + if (loading || configLoading) return null; + const causes = causesFor(endpointStatus, endpointDetails); + if (!groupSigningEnabled) causes.sharedSign = "groupSigningOff"; + return causes; + }, [ + loading, + configLoading, + endpointStatus, + endpointDetails, + groupSigningEnabled, + ]); + + // Keyed on contents: the object is rebuilt every render. + const liveKey = live ? JSON.stringify(live) : null; + useEffect(() => { + if (liveKey) remember(JSON.parse(liveKey) as Causes); + }, [liveKey]); + + const causes = live ?? remembered; + + return useMemo(() => { + if (!causes) return null; + const reasons: QuickNavToolReasons = {}; + for (const entry of Object.keys(causes) as ToolId[]) { + const cause = causes[entry]; + if (cause === "groupSigningOff") { + // The tool's own wording, minus the full stop. + reasons[entry] = t( + "sharedSign.disabledBody", + "Collaborative signing isn't enabled on this server.", + ).replace(/\.\s*$/, ""); + continue; + } + if (!cause) continue; + // These labels normally sit in front of a tool name, hence the trailing colon. + const { key, fallback } = getDisabledLabel(cause); + reasons[entry] = t(key, fallback).replace(/:\s*$/, ""); + } + return reasons; + }, [causes, t]); +} diff --git a/frontend/editor/src/core/components/tools/RightSidebar.tsx b/frontend/editor/src/core/components/tools/RightSidebar.tsx index d66d30c442..031ecd8e20 100644 --- a/frontend/editor/src/core/components/tools/RightSidebar.tsx +++ b/frontend/editor/src/core/components/tools/RightSidebar.tsx @@ -115,7 +115,7 @@ export default function RightSidebar() { const computedWidth = () => { if (isMobile) return "100%"; - if (!isPanelVisible) return "3.5rem"; + if (!isPanelVisible) return "var(--nav-rail-w)"; return expandedWidth; }; @@ -181,7 +181,8 @@ export default function RightSidebar() { content={tool.name} position="left" arrow - delay={300} + // No delay: collapsed to icons, the tooltip is the only label. + delay={0} > ( - - - -
+ +
+ {/* Inside the Popover: Tooltip binds by cloning, and Popover passes no ref on. */} + -
-
- -
- -
-
- - + +
+
+ +
+ +
+
+
), }, { diff --git a/frontend/editor/src/core/contexts/QuickNavHostContext.test.tsx b/frontend/editor/src/core/contexts/QuickNavHostContext.test.tsx new file mode 100644 index 0000000000..fc648cbfe5 --- /dev/null +++ b/frontend/editor/src/core/contexts/QuickNavHostContext.test.tsx @@ -0,0 +1,128 @@ +import { describe, it, expect } from "vitest"; +import { render, act } from "@testing-library/react"; +import { + QuickNavHostProvider, + useQuickNavHost, + useRegisterQuickNavHost, + useSuppressQuickNavRail, +} from "@app/contexts/QuickNavHostContext"; + +function Probe({ onRead }: { onRead: (value: unknown) => void }) { + const host = useQuickNavHost(); + onRead({ + appMounted: host?.appMounted, + chromeless: host?.chromeless, + identity: host?.identity, + openSettings: Boolean(host?.actions.current?.openSettings), + }); + return null; +} + +function App() { + useRegisterQuickNavHost( + { identity: { displayName: "Ada", profilePictureUrl: null } }, + { openSettings: () => {} }, + ); + return null; +} + +function LoginRoute() { + useSuppressQuickNavRail(); + return null; +} + +function setup() { + let latest: Record = {}; + const view = render( + + (latest = value as Record)} /> + + , + ); + return { view, read: () => latest }; +} + +describe("QuickNavHostContext", () => { + it("keeps what the app published after it unmounts, but drops its handlers", () => { + // Data survives the gap between one app unmounting and the next registering. + const { view, read } = setup(); + + expect(read().appMounted).toBe(true); + expect(read().identity).toEqual({ + displayName: "Ada", + profilePictureUrl: null, + }); + expect(read().openSettings).toBe(true); + + view.rerender( + + {}} /> + , + ); + + // Re-read through a fresh probe in the same provider. + let after: Record = {}; + view.rerender( + + (after = value as Record)} /> + , + ); + expect(after.appMounted).toBe(true); + expect(after.openSettings).toBe(false); + }); + + it("hides the bar while a route with no app chrome is on screen", () => { + // appMounted is sticky, so it can't answer "is an app on screen now". + const { view, read } = setup(); + expect(read().chromeless).toBe(false); + + act(() => { + view.rerender( + + {}} /> + + + , + ); + }); + + let during: Record = {}; + view.rerender( + + (during = value as Record)} + /> + + + , + ); + expect(during.chromeless).toBe(true); + }); + + it("brings the bar back when that route leaves", () => { + const { view } = setup(); + + act(() => { + view.rerender( + + {}} /> + + + , + ); + }); + + let after: Record = {}; + act(() => { + view.rerender( + + (after = value as Record)} + /> + + , + ); + }); + expect(after.chromeless).toBe(false); + }); +}); diff --git a/frontend/editor/src/core/contexts/QuickNavHostContext.tsx b/frontend/editor/src/core/contexts/QuickNavHostContext.tsx new file mode 100644 index 0000000000..540ec8cd6c --- /dev/null +++ b/frontend/editor/src/core/contexts/QuickNavHostContext.tsx @@ -0,0 +1,201 @@ +import type { ToolId } from "@app/types/toolId"; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; + +export type QuickNavToolReasons = Partial>; + +export interface QuickNavIdentity { + displayName: string; + profilePictureUrl: string | null; +} + +export interface QuickNavHostData { + /** Sticky: one app unmounts before the next one registers. */ + appMounted: boolean; + identity: QuickNavIdentity | null; + signingBadge: number; + portalAccess: boolean; + readerMode: boolean; + /** The app owns the panel; the rail's bell only reports its state. */ + notificationsOpen: boolean; + /** Translated; absent means usable. */ + toolReasons: QuickNavToolReasons; + /** Mirrors `openSettings`, which lives in a ref and so cannot trigger a render. */ + hasSettings: boolean; +} + +export interface QuickNavHostActions { + openSettings?: () => void; + /** The editor reads its tool from the URL only on mount. */ + selectTool?: (toolId: ToolId) => void; + setReaderMode?: (on: boolean) => void; + toggleNotifications?: () => void; + goToDefaultState?: () => void; + requestNavigation?: (go: () => void) => void; +} + +interface QuickNavHostValue extends QuickNavHostData { + /** Reset on unmount, unlike the data above. */ + chromeless: boolean; + setChromeless: (chromeless: boolean) => void; + /** A ref, so a click reaches the app currently mounted. */ + actions: React.RefObject; + setData: (data: Partial) => void; + setActions: (actions: QuickNavHostActions) => void; +} + +const EMPTY_REASONS: QuickNavToolReasons = {}; + +const EMPTY_DATA: QuickNavHostData = { + appMounted: false, + toolReasons: EMPTY_REASONS, + identity: null, + signingBadge: 0, + portalAccess: false, + readerMode: false, + notificationsOpen: false, + hasSettings: false, +}; + +function sameReasons( + next: QuickNavToolReasons, + prev: QuickNavToolReasons, +): boolean { + const nextKeys = Object.keys(next); + if (nextKeys.length !== Object.keys(prev).length) return false; + return nextKeys.every((key) => next[key as ToolId] === prev[key as ToolId]); +} + +const QuickNavHostContext = createContext(null); + +/** Outside both apps' providers, so each app registers what only it knows. */ +export function QuickNavHostProvider({ children }: { children: ReactNode }) { + const [data, setDataState] = useState(EMPTY_DATA); + const [chromeless, setChromelessState] = useState(false); + const actions = useRef({}); + + const setData = useCallback((next: Partial) => { + setDataState((prev) => { + const merged = { ...prev, ...next }; + const unchanged = + merged.appMounted === prev.appMounted && + merged.signingBadge === prev.signingBadge && + merged.portalAccess === prev.portalAccess && + merged.readerMode === prev.readerMode && + merged.notificationsOpen === prev.notificationsOpen && + merged.hasSettings === prev.hasSettings && + merged.identity?.displayName === prev.identity?.displayName && + merged.identity?.profilePictureUrl === + prev.identity?.profilePictureUrl && + // Compared by value: the object is rebuilt every render. + sameReasons(merged.toolReasons, prev.toolReasons); + return unchanged ? prev : merged; + }); + }, []); + + const setActions = useCallback((next: QuickNavHostActions) => { + actions.current = next; + }, []); + + const setChromeless = useCallback((next: boolean) => { + setChromelessState(next); + }, []); + + const value = useMemo( + () => ({ + ...data, + chromeless, + actions, + setData, + setActions, + setChromeless, + }), + [data, chromeless, setData, setActions, setChromeless], + ); + + return ( + + {children} + + ); +} + +export function useQuickNavHost(): QuickNavHostValue | null { + return useContext(QuickNavHostContext); +} + +/** No-ops outside the provider. */ +export function useRegisterQuickNavHost( + data: Partial, + actions: QuickNavHostActions, +): void { + const host = useQuickNavHost(); + const { + identity, + signingBadge, + portalAccess, + readerMode, + notificationsOpen, + toolReasons, + } = data; + const hasSettings = Boolean(actions.openSettings); + + useEffect(() => { + host?.setData({ + appMounted: true, + identity: identity ?? null, + signingBadge: signingBadge ?? 0, + portalAccess: portalAccess ?? false, + readerMode: readerMode ?? false, + notificationsOpen: notificationsOpen ?? false, + // Omitted when unknown, so the last answer survives a re-fetch. + ...(toolReasons ? { toolReasons } : {}), + hasSettings, + }); + // By field: identity is rebuilt every render. + }, [ + host, + identity?.displayName, + identity?.profilePictureUrl, + signingBadge, + portalAccess, + readerMode, + notificationsOpen, + toolReasons, + hasSettings, + ]); + + const setActions = host?.setActions; + + // No deps: a click has to reach the current closure. + useEffect(() => { + setActions?.(actions); + }); + + // Handlers only: clearing the data too would blink the controls mid-switch. + useEffect( + () => () => { + setActions?.({}); + }, + [setActions], + ); +} + +/** `appMounted` is sticky, so a screen that isn't the app has to say so itself. */ +export function useSuppressQuickNavRail(active = true): void { + const host = useQuickNavHost(); + const setChromeless = host?.setChromeless; + useEffect(() => { + if (!active) return; + setChromeless?.(true); + return () => setChromeless?.(false); + }, [active, setChromeless]); +} diff --git a/frontend/editor/src/core/contexts/SidebarContext.tsx b/frontend/editor/src/core/contexts/SidebarContext.tsx index ac9ddbb0df..ce0e5184bb 100644 --- a/frontend/editor/src/core/contexts/SidebarContext.tsx +++ b/frontend/editor/src/core/contexts/SidebarContext.tsx @@ -62,6 +62,11 @@ export function SidebarProvider({ children }: SidebarProviderProps) { ); } +/** For components that render outside a SidebarProvider, such as the rail's tooltips. */ +export function useOptionalSidebarContext(): SidebarContextValue | undefined { + return useContext(SidebarContext); +} + export function useSidebarContext(): SidebarContextValue { const context = useContext(SidebarContext); if (context === undefined) { diff --git a/frontend/editor/src/core/contexts/ToolWorkflowContext.tsx b/frontend/editor/src/core/contexts/ToolWorkflowContext.tsx index 6bc61fa294..070442a966 100644 --- a/frontend/editor/src/core/contexts/ToolWorkflowContext.tsx +++ b/frontend/editor/src/core/contexts/ToolWorkflowContext.tsx @@ -218,8 +218,8 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) { const setReaderMode = useCallback( (mode: boolean) => { if (mode) { + // Reading is a mode the open document is put into, not a tool run on it. actions.setWorkbench("viewer"); - actions.setSelectedTool("read"); } dispatch({ type: "SET_READER_MODE", payload: mode }); }, diff --git a/frontend/editor/src/core/pages/HomePage.tsx b/frontend/editor/src/core/pages/HomePage.tsx index 0d7a571f98..efb6af40d2 100644 --- a/frontend/editor/src/core/pages/HomePage.tsx +++ b/frontend/editor/src/core/pages/HomePage.tsx @@ -1,4 +1,11 @@ -import { forwardRef, useCallback, useEffect, useRef, useState } from "react"; +import { + forwardRef, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { useTranslation } from "react-i18next"; import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; import { Group } from "@mantine/core"; @@ -14,6 +21,7 @@ import { useFileContext } from "@app/contexts/file/fileHooks"; import { useNavigationState, useNavigationActions, + useNavigationGuard, } from "@app/contexts/NavigationContext"; import { isApplyingRestoredView } from "@app/services/workbenchSession"; import { useViewer } from "@app/contexts/ViewerContext"; @@ -28,10 +36,21 @@ import FileSidebar from "@app/components/shared/FileSidebar"; import FileManager from "@app/components/FileManager"; import LocalIcon from "@app/components/shared/LocalIcon"; import AppConfigModal from "@app/components/shared/AppConfigModalLazy"; -import { getStartupNavigationAction } from "@app/utils/homePageNavigation"; +import { + getStartupNavigationAction, + getDefaultWorkbenchForFileCount, +} from "@app/utils/homePageNavigation"; import { EDITOR_BASENAME } from "@app/routes/editorBasename"; import { stripBasePath } from "@app/constants/app"; import { HomePageExtensions } from "@app/components/home/HomePageExtensions"; +import { QuickNavHostBridge } from "@app/components/shared/quickNav/QuickNavHostBridge"; +import type { QuickNavToolReasons } from "@app/contexts/QuickNavHostContext"; +import { + getToolDisabledReason, + getDisabledLabel, +} from "@app/components/tools/fullscreen/shared"; +import { useOtherAppSwitch } from "@app/hooks/useOtherAppSwitch"; +import { consumeReaderModeRequest } from "@app/utils/pendingReaderMode"; import { FilesPageProvider, useFilesPage, @@ -42,6 +61,7 @@ import { FolderTreePanel } from "@app/components/filesPage/FolderTreePanel"; import type { FileSidebarProps } from "@app/components/shared/FileSidebar"; import { Button } from "@app/ui/Button"; +import "@app/components/layout/WorkspaceFrame.css"; import "@app/pages/HomePage.css"; const SIDEBAR_COLLAPSED_STORAGE_KEY = "stirling.fileSidebarCollapsed"; @@ -90,9 +110,11 @@ export default function HomePage() { handleToolSelect, handleBackToTools, readerMode, + setReaderMode, setLeftPanelView, toolAvailability, customWorkbenchViews, + toolRegistry, } = useToolWorkflow(); const navigate = useNavigate(); @@ -103,6 +125,7 @@ export default function HomePage() { const [activeMobileView, setActiveMobileView] = useState("tools"); const isProgrammaticScroll = useRef(false); const [configModalOpen, setConfigModalOpen] = useState(false); + const otherApp = useOtherAppSwitch(); const location = useLocation(); // Persisted user preference for the FileSidebar collapsed state. Auto- // collapse on /files is layered on top in the transition effect below and @@ -152,8 +175,64 @@ export default function HomePage() { const { activeFiles } = useFileContext(); const navigationState = useNavigationState(); + const { requestNavigation } = useNavigationGuard(); + + // From the processor's Reader entry. Ref-guarded: one-shot, and StrictMode double-invokes. + const consumedReaderRequest = useRef(false); + useEffect(() => { + if (consumedReaderRequest.current) return; + consumedReaderRequest.current = true; + if (consumeReaderModeRequest()) setReaderMode(true); + }, [setReaderMode]); const { actions } = useNavigationActions(); + const { searchInterfaceActions } = useViewer(); + + // Reading hides both search controls, so leave it first. e.code, for non-QWERTY layouts. + const focusSearchAfterRestore = useRef(false); + useEffect(() => { + if (!readerMode) return; + const onKeyDown = (e: KeyboardEvent) => { + const combo = (e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey; + if (!combo) return; + if (e.code !== "KeyK" && e.code !== "KeyF") return; + // Same carve-out the search itself makes: a dialog owns the keyboard. + if ((e.target as HTMLElement | null)?.closest?.('[role="dialog"]')) + return; + e.preventDefault(); + setReaderMode(false); + if (e.code === "KeyK") { + focusSearchAfterRestore.current = true; + return; + } + // Visibility is state, so it can open before the bar it renders in exists. + searchInterfaceActions.open(); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [readerMode, setReaderMode, searchInterfaceActions]); + + useEffect(() => { + if (readerMode || !focusSearchAfterRestore.current) return; + focusSearchAfterRestore.current = false; + requestAnimationFrame(() => + window.dispatchEvent(new Event("superSearch:focus")), + ); + }, [readerMode]); + + // Clean slate: no tool, out of the file library and reading. + const goToDefaultState = useCallback(() => { + handleBackToTools(); + if (location.pathname.startsWith("/files")) navigate(EDITOR_BASENAME); + actions.setWorkbench(getDefaultWorkbenchForFileCount(activeFiles.length)); + }, [ + handleBackToTools, + location.pathname, + navigate, + actions, + activeFiles.length, + ]); + // Sync the /files* URL into the workbench state so the file manager view // takes over the workbench area when the user lands on it. This is the // only state-of-truth for the active workbench, so keep the URL pinned. @@ -194,6 +273,17 @@ export default function HomePage() { prevWorkbenchRef.current = curr; // fileSidebarCollapsed read as snapshot on transition only. }, [navigationState.workbench]); + // Imperative, so the toggle still works while reading. Never persisted: not a preference. + const prevReaderModeRef = useRef(readerMode); + useEffect(() => { + if (readerMode !== prevReaderModeRef.current) { + setFileSidebarCollapsed( + readerMode ? true : readPersistedSidebarCollapsed(), + ); + prevReaderModeRef.current = readerMode; + } + }, [readerMode]); + const { setActiveFileIndex } = useViewer(); const prevFileCountRef = useRef(activeFiles.length); @@ -242,6 +332,38 @@ export default function HomePage() { const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo"); + // The tool picker's own helpers, so the wording can't drift. + const quickNavToolReasons = useMemo(() => { + const reasons: QuickNavToolReasons = {}; + for (const id of ["automate", "sharedSign"] as const) { + const tool = toolRegistry[id]; + if (!tool) continue; + const disabledReason = getToolDisabledReason( + id, + tool, + toolAvailability, + config?.premiumEnabled, + ); + if (!disabledReason) continue; + const { key, fallback } = getDisabledLabel(disabledReason); + reasons[id] = t(key, fallback).replace(/:\s*$/, ""); + } + return reasons; + }, [toolRegistry, toolAvailability, config?.premiumEnabled, t]); + + // Shared with the sidebar's own toggle. On /files it leaves rather than collapses. + const handleSidebarToggle = useCallback(() => { + if (navigationState.workbench === "myFiles") { + navigate(EDITOR_BASENAME); + return; + } + setFileSidebarCollapsed((c) => { + const next = !c; + writePersistedSidebarCollapsed(next); + return next; + }); + }, [navigationState.workbench, navigate]); + const [showSwipeHint, setShowSwipeHint] = useState( () => !readSwipeHintSeen(), ); @@ -395,6 +517,16 @@ export default function HomePage() { return (
+ setConfigModalOpen(true)} + requestNavigation={requestNavigation} + readerMode={readerMode} + onSetReaderMode={setReaderMode} + onGoToDefaultState={goToDefaultState} + onSelectTool={handleToolSelect} + toolReasons={quickNavToolReasons} + /> {isMobile ? (
- - ) : undefined - } - onToggleCollapse={() => { - if (navigationState.workbench === "myFiles") { - navigate(EDITOR_BASENAME); - return; +
+ { - const next = !c; - writePersistedSidebarCollapsed(next); - return next; - }); - }} - onOpenSettings={() => setConfigModalOpen(true)} - /> + toggleIcon={ + navigationState.workbench === "myFiles" ? ( + + ) : undefined + } + active={navigationState.workbench === "myFiles"} + // Forced: a deep link to /files has no transition to collapse on. + collapsed={ + navigationState.workbench === "myFiles" || + fileSidebarCollapsed + } + onToggleCollapse={handleSidebarToggle} + onOpenSettings={() => setConfigModalOpen(true)} + /> +
{!hideToolPanel && } diff --git a/frontend/editor/src/core/routes/hasPortal.ts b/frontend/editor/src/core/routes/hasPortal.ts new file mode 100644 index 0000000000..3d7f107ac3 --- /dev/null +++ b/frontend/editor/src/core/routes/hasPortal.ts @@ -0,0 +1,2 @@ +/** Whether this build ships the processor. Shadowed per build. */ +export const HAS_PORTAL = false; diff --git a/frontend/editor/src/core/tests/live/viewer-sidebar-add-buttons.spec.ts b/frontend/editor/src/core/tests/live/viewer-sidebar-add-buttons.spec.ts index df3ce94b23..a2e2c9c7e3 100644 --- a/frontend/editor/src/core/tests/live/viewer-sidebar-add-buttons.spec.ts +++ b/frontend/editor/src/core/tests/live/viewer-sidebar-add-buttons.spec.ts @@ -60,7 +60,8 @@ function fixture(filename: string): string { } async function openSamplePdfInViewer(page: import("@playwright/test").Page) { - await page.goto("/read"); + // Not /read: reading collapses the workbench bar these sidebars are toggled from. + await page.goto("/"); await page.waitForLoadState("domcontentloaded"); await page .locator('[data-testid="file-input"]') diff --git a/frontend/editor/src/core/tests/stubbed/viewer-sidebar-add-buttons.spec.ts b/frontend/editor/src/core/tests/stubbed/viewer-sidebar-add-buttons.spec.ts index 67df3865e1..f0da928122 100644 --- a/frontend/editor/src/core/tests/stubbed/viewer-sidebar-add-buttons.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/viewer-sidebar-add-buttons.spec.ts @@ -23,7 +23,8 @@ const SAMPLE_PDF = path.join( ); async function openViewerWithSample(page: import("@playwright/test").Page) { - await page.goto("/read"); + // Not /read: reading collapses the workbench bar these sidebars are toggled from. + await page.goto("/"); await page.waitForLoadState("domcontentloaded"); await page .locator('[data-testid="file-input"]') diff --git a/frontend/editor/src/core/tests/stubbed/workbench-session-restore.spec.ts b/frontend/editor/src/core/tests/stubbed/workbench-session-restore.spec.ts index 48765392fe..c1a71ac48c 100644 --- a/frontend/editor/src/core/tests/stubbed/workbench-session-restore.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/workbench-session-restore.spec.ts @@ -27,6 +27,7 @@ async function restoreEnabled( } const NO_RESTORE = "this build ships the workbench restore off"; +const NO_PORTAL = "this build ships no processor to switch to"; // Switching editor -> processor unmounts every editor provider; the session record // in sessionStorage is what brings the workbench back on return. @@ -73,8 +74,13 @@ test.describe("Workbench survives the editor/processor switch", () => { page.getByRole("radio", { name: /Active Files/i }), ).toBeChecked(); - // Out through the sidebar footer switch - the real user path. - await page.getByRole("button", { name: "Open PDF Processor" }).click(); + // Out through the rail's processor mark, the only chrome that offers the switch. + const processorMark = page.getByRole("button", { name: /^Processor$/i }); + test.skip( + !(await processorMark.isVisible({ timeout: 5_000 }).catch(() => false)), + NO_PORTAL, + ); + await processorMark.click(); await expect(page).toHaveURL(/\/processor/, { timeout: 15000 }); // Split the two halves of the feature: if this fails the writer is at fault, diff --git a/frontend/editor/src/core/theme/colors.css b/frontend/editor/src/core/theme/colors.css index 31f9666271..1a74158022 100644 --- a/frontend/editor/src/core/theme/colors.css +++ b/frontend/editor/src/core/theme/colors.css @@ -46,7 +46,7 @@ html[data-app-theme="light"] { non-text floor applies. Scheme-independent: a filled badge reads white on either ground. */ --c-success-solid: var(--p-green-700); - --c-danger-solid: var(--p-red-600); + --c-danger-solid: var(--p-red-700); --c-warning-solid: var(--p-amber-700); --c-neutral-solid: var(--p-gray-600); --c-accent-solid: var(--p-blue-600); diff --git a/frontend/editor/src/core/theme/dimensions.css b/frontend/editor/src/core/theme/dimensions.css index 554baddcca..50531641cb 100644 --- a/frontend/editor/src/core/theme/dimensions.css +++ b/frontend/editor/src/core/theme/dimensions.css @@ -30,7 +30,12 @@ --radius-nav: 0.625rem; --nav-gutter: 0.5rem; - --nav-rail-w: 3.5rem; + /* Every minimised rail is this wide, so they line up as one column of icons. */ + --nav-rail-w: 3rem; + /* Header row, so the rail's brand and a sidebar's wordmark line up. */ + --nav-header-h: 3rem; + --sidebar-w: 16.25rem; + --sidebar-collapsed-w: var(--nav-rail-w); /* ── Layout sizing ── */ --footer-height: 2rem; diff --git a/frontend/editor/src/core/ui/NavSurface.tsx b/frontend/editor/src/core/ui/NavSurface.tsx index 38947fb91f..37caaa063e 100644 --- a/frontend/editor/src/core/ui/NavSurface.tsx +++ b/frontend/editor/src/core/ui/NavSurface.tsx @@ -2,8 +2,8 @@ import { forwardRef, type HTMLAttributes } from "react"; import "@app/ui/NavSurface.css"; export interface NavSurfaceProps extends HTMLAttributes { - /** Element to render; `section`/`aside` when the box is a landmark. */ - as?: "div" | "section" | "aside"; + /** Element to render; `section`/`aside`/`nav` when the box is a landmark. */ + as?: "div" | "section" | "aside" | "nav"; } /** diff --git a/frontend/editor/src/core/utils/homePageNavigation.ts b/frontend/editor/src/core/utils/homePageNavigation.ts index 001e026710..7a91bfec65 100644 --- a/frontend/editor/src/core/utils/homePageNavigation.ts +++ b/frontend/editor/src/core/utils/homePageNavigation.ts @@ -1,4 +1,4 @@ -import type { WorkbenchType } from "@app/types/workbench"; +import { getDefaultWorkbench, type WorkbenchType } from "@app/types/workbench"; export type StartupWorkbench = "viewer" | "fileEditor"; @@ -7,6 +7,13 @@ export interface StartupNavigationAction { activeFileIndex?: number; } +/** Several files means the file editor; one or none the viewer. */ +export function getDefaultWorkbenchForFileCount( + fileCount: number, +): WorkbenchType { + return fileCount > 1 ? "fileEditor" : getDefaultWorkbench(); +} + export function getStartupNavigationAction( previousFileCount: number, currentFileCount: number, diff --git a/frontend/editor/src/core/utils/pendingReaderMode.ts b/frontend/editor/src/core/utils/pendingReaderMode.ts new file mode 100644 index 0000000000..faacfba6b8 --- /dev/null +++ b/frontend/editor/src/core/utils/pendingReaderMode.ts @@ -0,0 +1,13 @@ +let pending = false; + +/** Carries "open in reading mode" across an app switch, and deliberately not a reload. */ +export function requestReaderMode(): void { + pending = true; +} + +/** True once per request. */ +export function consumeReaderModeRequest(): boolean { + if (!pending) return false; + pending = false; + return true; +} diff --git a/frontend/editor/src/core/utils/viewTransition.test.ts b/frontend/editor/src/core/utils/viewTransition.test.ts new file mode 100644 index 0000000000..5fb11c8aa2 --- /dev/null +++ b/frontend/editor/src/core/utils/viewTransition.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { withViewTransition } from "@app/utils/viewTransition"; + +// The stub carries only the field the helper reads, hence the cast through unknown. +type MutableDoc = { startViewTransition?: unknown }; +const doc = document as unknown as MutableDoc; + +function stubApi(): ReturnType { + const start = vi.fn((cb: () => void) => { + cb(); + return { finished: Promise.resolve() }; + }); + doc.startViewTransition = start; + return start; +} + +function stubReducedMotion(reduced: boolean): void { + vi.stubGlobal("matchMedia", (query: string) => ({ + matches: reduced && query.includes("prefers-reduced-motion"), + media: query, + addEventListener: () => {}, + removeEventListener: () => {}, + })); +} + +afterEach(() => { + delete doc.startViewTransition; + vi.unstubAllGlobals(); +}); + +describe("withViewTransition", () => { + it("runs the update inside a transition when one is possible", async () => { + const start = stubApi(); + stubReducedMotion(false); + const update = vi.fn(); + + await withViewTransition(update); + + expect(start).toHaveBeenCalledTimes(1); + expect(update).toHaveBeenCalledTimes(1); + }); + + it("skips the transition when the user asked for less motion", async () => { + // The state change must still happen - only the animation is dropped. + const start = stubApi(); + stubReducedMotion(true); + const update = vi.fn(); + + await withViewTransition(update); + + expect(start).not.toHaveBeenCalled(); + expect(update).toHaveBeenCalledTimes(1); + }); + + it("still applies the update where the API is unavailable", async () => { + stubReducedMotion(false); + const update = vi.fn(); + + await withViewTransition(update); + + expect(update).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/editor/src/core/utils/viewTransition.ts b/frontend/editor/src/core/utils/viewTransition.ts index 049c3e7673..85a05ca78d 100644 --- a/frontend/editor/src/core/utils/viewTransition.ts +++ b/frontend/editor/src/core/utils/viewTransition.ts @@ -4,21 +4,20 @@ type ViewTransitionDoc = Document & { startViewTransition?: (cb: () => void) => { finished: Promise }; }; -/** - * Run a state update inside a View Transition so the browser cross-fades - * (and morphs any elements sharing a {@code view-transition-name}) between - * the before/after DOMs. - * - * Falls back to a plain synchronous update when the API is unavailable - * (Firefox <130, JSDOM, motion-reduced preference). - */ +/** Runs a state update in a View Transition, plainly where that is unavailable. */ export function withViewTransition(update: () => void): Promise { if (typeof document === "undefined") { update(); return Promise.resolve(); } + // Callers don't each check: reduced motion still gets the state change. + const reduced = + typeof window !== "undefined" && + typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + const doc = document as ViewTransitionDoc; - if (doc.startViewTransition) { + if (doc.startViewTransition && !reduced) { return doc.startViewTransition(() => flushSync(update)).finished; } update(); diff --git a/frontend/editor/src/desktop/components/shared/AppSwitcher.tsx b/frontend/editor/src/desktop/components/shared/AppSwitcher.tsx deleted file mode 100644 index 21896d9a1d..0000000000 --- a/frontend/editor/src/desktop/components/shared/AppSwitcher.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { Logo } from "@app/ui/Logo"; -import { type AppSwitcherProps } from "@core/components/shared/AppSwitcher"; - -/** - * Desktop inherits proprietary's layers but does not ship the portal (see - * desktop/routes/adminRouteExtensions), so there's nothing to switch to — - * shadow the brand header back to a plain logo. (Also avoids the desktop - * bundle referencing @portal via the proprietary switcher's imports.) - */ -export function AppSwitcher({ collapsed }: AppSwitcherProps) { - return ( - - ); -} diff --git a/frontend/editor/src/desktop/routes/hasPortal.ts b/frontend/editor/src/desktop/routes/hasPortal.ts new file mode 100644 index 0000000000..0eec40f4c9 --- /dev/null +++ b/frontend/editor/src/desktop/routes/hasPortal.ts @@ -0,0 +1,2 @@ +/** Desktop inherits proprietary's app but never ships the portal. */ +export const HAS_PORTAL = false; diff --git a/frontend/editor/src/portal/components/AppShell.tsx b/frontend/editor/src/portal/components/AppShell.tsx index fe8f775fd0..ec6c720297 100644 --- a/frontend/editor/src/portal/components/AppShell.tsx +++ b/frontend/editor/src/portal/components/AppShell.tsx @@ -7,8 +7,11 @@ import { PortalSearchBar } from "@portal/components/PortalSearchBar"; import { useUI } from "@portal/contexts/UIContext"; import { MenuIcon, SearchIcon } from "@portal/components/icons"; import { Logo } from "@app/ui/Logo"; +import "@app/components/layout/WorkspaceFrame.css"; +import { QuickNavHostBridge } from "@app/components/shared/quickNav/QuickNavHostBridge"; import "@portal/components/AppShell.css"; import { NotificationBell } from "@app/components/notifications/NotificationBell"; +import { useIsPhone } from "@app/hooks/useIsMobile"; /** * Compact header shown only under the mobile breakpoint (CSS-hidden on @@ -58,8 +61,10 @@ function MobileTopbar() { * prop-free. */ export function AppShell({ children }: { children: ReactNode }) { - const { mobileNavOpen, closeMobileNav } = useUI(); + const { mobileNavOpen, closeMobileNav, openSettings } = useUI(); const { pathname } = useLocation(); + // Below this width the rail, and the bell it carries, is gone. + const isPhone = useIsPhone(); // Navigating (tap on a nav row, back button, deep link) always dismisses the // drawer. Depends on pathname only: the close fn's identity changes with any @@ -79,7 +84,11 @@ export function AppShell({ children }: { children: ReactNode }) { return (
- + {/* portalAccess: being here is proof the processor is available. */} + openSettings()} /> +
+ +
{mobileNavOpen && (
-
- -
+ {/* Phone only: above that the rail carries it, and this would be a second. */} + {isPhone && ( +
+ +
+ )}
{children}
diff --git a/frontend/editor/src/portal/components/EditorStatusCard.tsx b/frontend/editor/src/portal/components/EditorStatusCard.tsx index e4918ed859..774dc4d1be 100644 --- a/frontend/editor/src/portal/components/EditorStatusCard.tsx +++ b/frontend/editor/src/portal/components/EditorStatusCard.tsx @@ -11,31 +11,9 @@ import { import { type EditorInstance } from "@portal/api/editorDeploy"; import { DownloadEditorModal } from "@portal/components/DownloadEditorModal"; import "@portal/theme/surface.css"; +import { BrandTile } from "@app/components/shared/BrandTile"; import "@portal/components/EditorStatusCard.css"; -/** The Stirling brand mark, drawn at the hero size. Decorative. */ -function StirlingMark() { - return ( - - - - - - ); -} - /** The instance to headline: the busiest healthy one, else the first. */ function primaryInstance(instances: EditorInstance[]): EditorInstance | null { if (instances.length === 0) return null; @@ -126,7 +104,7 @@ export function EditorStatusCard({ footer }: EditorStatusCardProps) { >
- +
diff --git a/frontend/editor/src/portal/components/PortalSearchBar.css b/frontend/editor/src/portal/components/PortalSearchBar.css index 8e253b6136..847991af20 100644 --- a/frontend/editor/src/portal/components/PortalSearchBar.css +++ b/frontend/editor/src/portal/components/PortalSearchBar.css @@ -1,5 +1,4 @@ -/* Unpainted strip at the top of the main column. Height matches the sidebar's - logo row (.portal-sidebar__logo, 51px) so the search lines up with the brand. */ +/* Unpainted strip at the top of the main column, matching the sidebar header's height. */ .portal-searchbar { display: flex; align-items: center; diff --git a/frontend/editor/src/portal/components/Sidebar.css b/frontend/editor/src/portal/components/Sidebar.css index 48b54b177d..43f8c44695 100644 --- a/frontend/editor/src/portal/components/Sidebar.css +++ b/frontend/editor/src/portal/components/Sidebar.css @@ -1,8 +1,10 @@ .portal-sidebar { - width: 15rem; + width: var(--sidebar-w); height: 100vh; height: 100dvh; /* track mobile browser chrome */ - background: var(--c-bg); + /* Matches the editor's sidebar: one solid panel with a rule on the content side. */ + background: var(--c-surface); + border-inline-end: 1px solid var(--c-border-subtle); display: flex; flex-direction: column; flex-shrink: 0; @@ -21,10 +23,6 @@ /* Nav labels stay on one line and are clipped by the narrowing rail so they reveal/hide cleanly as the width animates rather than wrapping. */ -.portal-sidebar__nav, -.portal-sidebar__footer { - overflow-x: hidden; -} .portal-sidebar .sui-navitem__label, .portal-sidebar__section-label { white-space: nowrap; @@ -34,6 +32,8 @@ .portal-sidebar__close { display: none; flex-shrink: 0; + /* Trailing edge: on mobile this is the row's only control. */ + margin-left: auto; } .portal-sidebar__collapse { @@ -78,23 +78,16 @@ /* ---- Collapsed icon rail (desktop only) ---- */ .portal-sidebar[data-collapsed] { - width: var(--nav-rail-w); -} -.portal-sidebar[data-collapsed] .portal-sidebar__logo { - flex-direction: column; - height: auto; - padding: 0.5rem 0; - gap: 0.375rem; -} -.portal-sidebar[data-collapsed] .portal-sidebar__collapse { - margin-left: 0; + width: var(--sidebar-collapsed-w); } +/* Flush, like the nav: the selected row runs the full width of the rail. */ .portal-sidebar[data-collapsed] .portal-sidebar__nav { - padding-inline: 0.375rem; + padding-inline: 0; } +/* Stretch, not centre: a centred group shrinks to its content, so rows can't fill the rail. */ .portal-sidebar[data-collapsed] .portal-sidebar__section { padding-inline: 0; - align-items: center; + align-items: stretch; } .portal-sidebar[data-collapsed] .portal-sidebar__section-label { display: none; @@ -111,23 +104,16 @@ margin-inline: 0; padding-inline: 0; width: 100%; -} -/* Neutralise the active-item edge-bar geometry (negative margins + overhang) - that assumes the full-width rail. */ -.portal-sidebar[data-collapsed] .sui-navitem.is-active { - width: 100%; - margin-inline: 0; - border-left: none; - border-radius: 0.5rem; - padding-left: 0; + /* A square target, so it takes the rail's radius rather than NavItem's pill. */ + border-radius: var(--radius-md); } .portal-sidebar[data-collapsed] .portal-sidebar__footer { margin-inline: 0.375rem; } -.portal-sidebar__logo { - height: 3.1875rem; /* 51px */ - padding: 0 0.875rem; +.portal-sidebar__header { + height: var(--nav-header-h); + padding: 0 var(--nav-gutter); display: flex; align-items: center; gap: 0.5rem; @@ -136,35 +122,58 @@ .portal-sidebar__nav { flex: 0 1 auto; overflow-y: auto; - padding: 0.75rem 0.625rem; + overflow-x: clip; + /* No inline inset above the rows, so a row is full width and needs no bleed past the clip. */ + padding: var(--nav-gutter) 0; display: flex; flex-direction: column; - gap: 0.5rem; + gap: var(--nav-gutter); } .portal-sidebar .sui-navitem { - margin-inline: 0.25rem; - padding-inline: 0.625rem; + margin-inline: 0; + padding-inline: 1.75rem; } -.portal-sidebar .sui-navitem.is-active { - width: calc(100% + 0.75rem); - margin-inline: -0.375rem; +/* The selected view, marked as the rail marks the current app: a knocked-out solid block. */ +.portal-sidebar .sui-navitem.is-active, +.portal-sidebar .sui-navitem.is-active:hover { + background: var(--c-text); + color: var(--c-surface); border-radius: 0; - border-left: 3px solid var(--c-primary); - padding-left: calc(1.25rem - 3px); +} + +/* On a dark ground full ink is white, so mix the block back toward the surface. */ +[data-theme="dark"] .portal-sidebar .sui-navitem.is-active, +[data-theme="dark"] .portal-sidebar .sui-navitem.is-active:hover, +html[data-app-theme="midnight"] .portal-sidebar .sui-navitem.is-active, +html[data-app-theme="midnight"] .portal-sidebar .sui-navitem.is-active:hover, +[data-mantine-color-scheme="dark"] .portal-sidebar .sui-navitem.is-active, +[data-mantine-color-scheme="dark"] + .portal-sidebar + .sui-navitem.is-active:hover { + background: color-mix(in srgb, var(--c-text) 80%, var(--c-surface)); + color: var(--c-surface); } .portal-sidebar__section { - padding: 0.5rem 0.375rem 0.375rem; + padding: 0.5rem 0 0.375rem; display: flex; flex-direction: column; gap: 0.375rem; } +/* Flattened here; two classes deep to beat .sui-nav-surface regardless of load order. */ +.portal-sidebar .sui-nav-surface { + background: transparent; + border: 0; + border-radius: 0; +} + .portal-sidebar__section-label { margin: 0; - padding: 0 0.5rem; + /* Its own 0.5rem, plus the inset the nav and section no longer add. */ + padding: 0 1.375rem; font-size: 0.8125rem; font-weight: 600; letter-spacing: 0.02em; @@ -181,4 +190,18 @@ only positions it. */ .portal-sidebar__footer { margin: 0 0.625rem 0.75rem; + overflow-x: hidden; +} + +/* Fills the frame, not the viewport: a 100vh sticky column would overhang it. */ +.workspace-frame .portal-sidebar { + height: 100%; + position: static; +} + +@media (max-width: 48rem) { + .workspace-frame .portal-sidebar { + position: fixed; + height: auto; + } } diff --git a/frontend/editor/src/portal/components/Sidebar.tsx b/frontend/editor/src/portal/components/Sidebar.tsx index 685fa32722..2895ad4ec4 100644 --- a/frontend/editor/src/portal/components/Sidebar.tsx +++ b/frontend/editor/src/portal/components/Sidebar.tsx @@ -1,20 +1,16 @@ import { useMediaQuery } from "@mantine/hooks"; import { Tooltip } from "@mantine/core"; import { ActionIcon, NavItem, NavSurface } from "@app/ui"; -import { BrandSwitcher } from "@app/components/shared/BrandSwitcher"; +import { SidebarToggleButton } from "@app/components/shared/SidebarToggleButton"; +import { Logo } from "@app/ui/Logo"; import { NavFooter } from "@app/components/shared/navFooter/NavFooter"; import { useAccountIdentity } from "@app/hooks/useAccountIdentity"; import { useFreeCreditsSummary } from "@portal/hooks/useFreeCreditsSummary"; import { useOpenPlan } from "@portal/hooks/useOpenPlan"; -import { SidebarToggleIcon } from "@app/components/shared/SidebarToggleIcon"; import { useTranslation } from "react-i18next"; -import { useNavigate } from "react-router-dom"; import { useView, type ViewId } from "@portal/contexts/ViewContext"; import { useUI } from "@portal/contexts/UIContext"; import { LinkAccountFooterItem } from "@portal/components/LinkAccountFooterItem"; -import { EDITOR_URL, EDITOR_IS_SAME_APP } from "@portal/auth/editorUrl"; -import { EDITOR_BASENAME } from "@app/routes/editorBasename"; -import { takeEditorReturnPath } from "@app/services/workbenchSession"; import { CloseIcon } from "@portal/components/icons"; import { GROUP_PROCESSOR, @@ -30,19 +26,17 @@ const NAV_SECTIONS: NavGroup[] = [ ]; /** Must match the shell breakpoint in AppShell.css / Sidebar.css. */ -const MOBILE_QUERY = "(max-width: 48rem)"; +export const MOBILE_QUERY = "(max-width: 48rem)"; export function Sidebar() { const { activeView, setActiveView } = useView(); const { - openSettings, mobileNavOpen, closeMobileNav, sidebarCollapsed, toggleSidebarCollapsed, } = useUI(); const { t } = useTranslation(); - const navigate = useNavigate(); const isMobile = useMediaQuery(MOBILE_QUERY, false, { getInitialValueInEffect: false, }); @@ -54,14 +48,6 @@ export function Sidebar() { // off-canvas drawer, so the icon-rail state never applies there. const collapsed = sidebarCollapsed && !isMobile; - // Editor and portal are one SPA when the editor serves this origin's root, so - // the switch stays client-side; an absolute EDITOR_URL (dev cross-app setup) - // needs a full page load. - const goToEditor = () => { - if (EDITOR_IS_SAME_APP) navigate(takeEditorReturnPath() ?? EDITOR_BASENAME); - else window.location.href = EDITOR_URL; - }; - // Procurement is no longer a nav tab — it lives on Home as the deal-status hero and expands into // a takeover modal (matching the marketing prototype). @@ -107,25 +93,13 @@ export function Sidebar() { // Off-canvas on mobile: remove from the tab order and accessibility tree. inert={isMobile && !mobileNavOpen} > -
- +
+ {!collapsed && } - - - + } collapsed={collapsed} /> diff --git a/frontend/editor/src/proprietary/App.tsx b/frontend/editor/src/proprietary/App.tsx index c64935bb16..ca040d4bbe 100644 --- a/frontend/editor/src/proprietary/App.tsx +++ b/frontend/editor/src/proprietary/App.tsx @@ -18,6 +18,8 @@ const MobileScannerPage = lazy(() => import("@app/pages/MobileScannerPage")); const MobileSignPage = lazy(() => import("@app/pages/MobileSignPage")); import { WATCHED_FOLDERS_ENABLED } from "@app/constants/featureFlags"; import { getAdminRouteExtensions } from "@app/routes/adminRouteExtensions"; +import { AppFrame } from "@app/components/layout/AppFrame"; +import { NoAppChrome } from "@app/components/layout/NoAppChrome"; import { RootGate } from "@app/routes/RootGate"; // Import global styles @@ -80,40 +82,52 @@ export default function App() { } /> - {/* Admin-only route-set (the portal): its own top-level shell, mounted - before the catch-all. Absent from core/desktop builds (empty stub). */} - {getAdminRouteExtensions()} + {/* Both apps, under a shared frame so the rail renders once outside them. */} + }> + {/* The portal: its own shell, before the catch-all. An empty stub in core. */} + {getAdminRouteExtensions()} - {/* All other routes need AppProviders for backend integration. - RootGate makes "/" route by role BEFORE any of it mounts, so a user - bound for the processor never boots the editor on the way. */} - - - - - } /> - {/* Self-hosted has no signup - accounts are created by an - admin. Old links land on login instead. */} - } - /> - } /> - } /> - } /> - {/* The editor and its tool routes - Landing handles auth logic */} - } /> - - - {WATCHED_FOLDERS_ENABLED && } - - - - } - /> + {/* All other routes need AppProviders for backend integration. RootGate + routes "/" by role before any of it mounts. */} + + + + + {/* Not the app: no rail over any of these, ever. */} + }> + } /> + {/* Self-hosted has no signup: old links land on login. */} + } + /> + } + /> + } + /> + } + /> + + {/* The editor and its tool routes - Landing handles auth logic */} + } /> + + + {WATCHED_FOLDERS_ENABLED && } + + + + } + /> + ); diff --git a/frontend/editor/src/proprietary/components/shared/AppSwitcher.tsx b/frontend/editor/src/proprietary/components/shared/AppSwitcher.tsx deleted file mode 100644 index 9ba0b6438d..0000000000 --- a/frontend/editor/src/proprietary/components/shared/AppSwitcher.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { Logo } from "@app/ui/Logo"; -import { BrandSwitcher } from "@app/components/shared/BrandSwitcher"; -import { type AppSwitcherProps } from "@core/components/shared/AppSwitcher"; -import { useOtherAppSwitch } from "@app/hooks/useOtherAppSwitch"; - -/** - * Sidebar brand header for builds that ship the processor. When this user can - * open it, the Stirling logo doubles as the editor⇄processor switcher: the mark - * morphs into a chevron and opens the switch menu (the same BrandSwitcher the - * processor sidebar uses). Users without access get a plain logo. - * - * The access gate lives in {@link useOtherAppSwitch} so this header and the - * sidebar footer's "Open PDF Processor" row are driven by one answer. - */ -export function AppSwitcher({ collapsed }: AppSwitcherProps) { - const otherApp = useOtherAppSwitch(); - - if (!otherApp) { - return ( - - ); - } - - return ( - - ); -} diff --git a/frontend/editor/src/proprietary/data/processorEntitySearch.ts b/frontend/editor/src/proprietary/data/processorEntitySearch.ts index 544babf59f..58b32b64d3 100644 --- a/frontend/editor/src/proprietary/data/processorEntitySearch.ts +++ b/frontend/editor/src/proprietary/data/processorEntitySearch.ts @@ -6,15 +6,10 @@ import type { PortalEntityItems, PortalEntityScopeId, } from "@portal/search/entitySearch"; +import { HAS_PORTAL } from "@app/routes/hasPortal"; type EntitySearchModule = typeof import("@portal/search/entitySearch"); -// Mirrors the admin-route seam's gate: the portal route-set is only mounted in -// dev and in builds made with VITE_INCLUDE_PORTAL=true, so the search must not -// fetch or offer entities that have nowhere to open. -const includePortal = - import.meta.env.VITE_INCLUDE_PORTAL === "true" || import.meta.env.DEV; - const NO_GROUPS: SuperSearchGroup[] = []; const NO_SCOPES: readonly PortalEntityScopeId[] = []; @@ -41,7 +36,8 @@ export function useProcessorEntityGroups( ): SuperSearchGroup[] { const [mod, setMod] = useState(null); const modRef = useRef(null); - const active = enabled && includePortal; + // Without the portal these entities have nowhere to open, so don't fetch them. + const active = enabled && HAS_PORTAL; const hasQuery = trimmed.length > 0; useEffect(() => { diff --git a/frontend/editor/src/proprietary/data/processorSearchIndex.ts b/frontend/editor/src/proprietary/data/processorSearchIndex.ts index ae48893e25..8d99b2f77a 100644 --- a/frontend/editor/src/proprietary/data/processorSearchIndex.ts +++ b/frontend/editor/src/proprietary/data/processorSearchIndex.ts @@ -3,15 +3,10 @@ import { PORTAL_BASENAME } from "@app/routes/portalBasename"; // the lazy portal chunk into the main bundle the way @portal/* values would. import { usersCapabilities } from "@app/portal/usersCapabilities"; import type { ProcessorSearchEntry } from "@core/data/processorSearchIndex"; +import { HAS_PORTAL } from "@app/routes/hasPortal"; export type { ProcessorSearchEntry }; -// Mirrors the admin-route seam's gate: the portal route-set is only mounted in -// dev and in builds made with VITE_INCLUDE_PORTAL=true, so the search must not -// offer destinations that would 404 elsewhere. -const includePortal = - import.meta.env.VITE_INCLUDE_PORTAL === "true" || import.meta.env.DEV; - /** * The portal's in-app views. Deliberately a static mirror of the portal's nav * (labels via the same portal.nav.* keys its sidebar uses) rather than an @@ -91,7 +86,8 @@ const VIEWS: ProcessorSearchEntry[] = [ }, ]; -export const PROCESSOR_SEARCH_INDEX: ProcessorSearchEntry[] = includePortal +// Empty without the portal: these destinations would 404. +export const PROCESSOR_SEARCH_INDEX: ProcessorSearchEntry[] = HAS_PORTAL ? VIEWS : []; diff --git a/frontend/editor/src/proprietary/routes/Landing.tsx b/frontend/editor/src/proprietary/routes/Landing.tsx index 62c9083aad..7406078d30 100644 --- a/frontend/editor/src/proprietary/routes/Landing.tsx +++ b/frontend/editor/src/proprietary/routes/Landing.tsx @@ -2,6 +2,7 @@ import { useEffect } from "react"; import { Navigate, useLocation, useNavigate } from "react-router-dom"; import { useAuth } from "@app/auth/UseSession"; import { useAppConfig } from "@app/contexts/AppConfigContext"; +import { useSuppressQuickNavRail } from "@app/contexts/QuickNavHostContext"; import HomePage from "@app/pages/HomePage"; import { useBackendProbe } from "@app/hooks/useBackendProbe"; import { EDITOR_BASENAME } from "@app/routes/editorBasename"; @@ -27,6 +28,9 @@ export default function Landing() { const loading = authLoading || configLoading || backendProbe.loading; + // The backend-down screen is not the app. Loading is: it resolves in a moment. + useSuppressQuickNavRail(!session && backendProbe.status !== "up"); + // Debug: Track Landing component lifecycle useEffect(() => { const mountId = Math.random().toString(36).substring(7); diff --git a/frontend/editor/src/proprietary/routes/adminRouteExtensions.tsx b/frontend/editor/src/proprietary/routes/adminRouteExtensions.tsx index 22ce46f03c..a422376086 100644 --- a/frontend/editor/src/proprietary/routes/adminRouteExtensions.tsx +++ b/frontend/editor/src/proprietary/routes/adminRouteExtensions.tsx @@ -2,11 +2,9 @@ import { lazy } from "react"; import type { ReactElement } from "react"; import { Route } from "react-router-dom"; import { PORTAL_BASENAME } from "@app/routes/portalBasename"; +import { HAS_PORTAL } from "@app/routes/hasPortal"; -const includePortal = - import.meta.env.VITE_INCLUDE_PORTAL === "true" || import.meta.env.DEV; - -const PortalApp = includePortal +const PortalApp = HAS_PORTAL ? lazy(async () => { const m = await import("@portal/PortalApp"); return { default: m.PortalApp }; @@ -16,7 +14,7 @@ const PortalApp = includePortal /** * Return leg of the account-link handshake, which Stirling redirects to with the admin's session in the URL fragment. */ -const ConnectCallback = includePortal +const ConnectCallback = HAS_PORTAL ? lazy(async () => { const m = await import("@portal/views/ConnectCallback"); return { default: m.default }; diff --git a/frontend/editor/src/proprietary/routes/hasPortal.ts b/frontend/editor/src/proprietary/routes/hasPortal.ts new file mode 100644 index 0000000000..e3c54bc50e --- /dev/null +++ b/frontend/editor/src/proprietary/routes/hasPortal.ts @@ -0,0 +1,3 @@ +/** Dev always ships it, so the switch is there to work on. */ +export const HAS_PORTAL = + import.meta.env.VITE_INCLUDE_PORTAL === "true" || import.meta.env.DEV; diff --git a/frontend/editor/src/saas/App.tsx b/frontend/editor/src/saas/App.tsx index 2e091ebf05..07a563ea5f 100644 --- a/frontend/editor/src/saas/App.tsx +++ b/frontend/editor/src/saas/App.tsx @@ -19,6 +19,8 @@ import OAuthConsent from "@app/routes/OAuthConsent"; import ConnectApprove from "@app/routes/ConnectApprove"; import ShareLinkPage from "@app/routes/ShareLinkPage"; import { getAdminRouteExtensions } from "@app/routes/adminRouteExtensions"; +import { AppFrame } from "@app/components/layout/AppFrame"; +import { NoAppChrome } from "@app/components/layout/NoAppChrome"; import OnboardingBootstrap from "@app/components/OnboardingBootstrap"; import SignupRequiredBootstrap from "@app/components/SignupRequiredBootstrap"; import UsageLimitModalHost from "@app/components/UsageLimitModalHost"; @@ -96,46 +98,63 @@ export default function App() { } /> - {/* Admin-only route-set (the portal): its own top-level shell, mounted - before the catch-all. */} - {getAdminRouteExtensions()} + {/* Both apps, under a shared frame so the rail renders once outside them. */} + }> + {/* The portal: its own top-level shell, before the catch-all. */} + {getAdminRouteExtensions()} - {/* Everything else needs the auth/backend providers. RootGate makes "/" - route by role BEFORE any of it mounts, so a user bound for the - processor never boots the editor on the way. */} - - - - - - - } /> - } /> - } /> - } /> - } /> - {/* Human half of the self-hosted account-link handshake. It - lives on this origin because a customer hostname can - never be in the provider's redirect allow-list. */} - } /> - {/* Shared-file links. Team invites are NOT routed here: on - SaaS they are accepted in-app via the Supabase team - invitation banner, not the Spring password-based - /invite/:token page used by the self-hosted build. */} - } /> - } /> - - - - - - } - /> + {/* Everything else needs the auth/backend providers. RootGate routes "/" + by role before any of it mounts. */} + + + + + + + {/* Not the app: no rail over any of these, ever. */} + }> + } /> + } /> + } + /> + } /> + } + /> + {/* Human half of the self-hosted account-link handshake. + It lives on this origin because a customer hostname can + never be in the provider's redirect allow-list. Grouped + with the pages above: it is an approval step, not the + app. */} + } /> + {/* Shared-file links. Team invites are NOT routed here: + on SaaS they are accepted in-app via the Supabase team + invitation banner, not the Spring password-based + /invite/:token page used by the self-hosted build. */} + } + /> + + } /> + + + + + + } + /> + ); From a48356a2d22babeae7ad7c66673bee9a405fa083 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Thu, 27 Aug 2026 23:17:21 +0100 Subject: [PATCH 09/27] Deploy a dev SaaS server alongside the PR previews and main demo (#7697) --- .github/workflows/Saas-Dev-Deploy.yml | 246 ++++++++++++++++++++++++++ 1 file changed, 246 insertions(+) create mode 100644 .github/workflows/Saas-Dev-Deploy.yml diff --git a/.github/workflows/Saas-Dev-Deploy.yml b/.github/workflows/Saas-Dev-Deploy.yml new file mode 100644 index 0000000000..733b92bca2 --- /dev/null +++ b/.github/workflows/Saas-Dev-Deploy.yml @@ -0,0 +1,246 @@ +name: Auto SaaS Dev Deployment + +on: + push: + branches: + - saas-prod + workflow_dispatch: + +permissions: + contents: read + +env: + FRONTEND_PORT: "901" + BACKEND_PORT: "902" + DEPLOY_DIR: /stirling/SAAS-DEV + +jobs: + deploy-saas-dev: + runs-on: ubuntu-latest + environment: saas-dev + concurrency: + group: saas-dev-deploy + cancel-in-progress: true + permissions: + contents: read + packages: write + + steps: + - name: Harden Runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: Check SaaS configuration + id: config + env: + PROJECT_REF: ${{ secrets.SAAS_DB_PROJECT_REF }} + run: | + echo "supabase_url=https://${PROJECT_REF}.supabase.co" >> "$GITHUB_OUTPUT" + echo "meter_endpoint=https://${PROJECT_REF}.supabase.co/functions/v1/meter-payg-units" >> "$GITHUB_OUTPUT" + + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + + - name: Login to GitHub Container Registry + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + + - name: Convert repository owner to lowercase + id: repoowner + run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT + + - name: Get commit hash + id: commit-hash + run: echo "app_short=$(git rev-parse --short=8 HEAD)" >> $GITHUB_OUTPUT + + - name: Build and push backend image + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + file: ./docker/backend/Dockerfile + push: true + cache-from: type=gha,scope=stirling-saas-backend + cache-to: type=gha,mode=max,scope=stirling-saas-backend + tags: | + ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:saas-backend-${{ steps.commit-hash.outputs.app_short }} + ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:saas-backend-latest + build-args: | + VERSION_TAG=v2-alpha + STIRLING_FLAVOR=saas + platforms: linux/amd64 + + - name: Build and push frontend image + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + file: ./docker/frontend/Dockerfile + push: true + cache-from: type=gha,scope=stirling-saas-frontend + cache-to: type=gha,mode=max,scope=stirling-saas-frontend + tags: | + ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:saas-frontend-${{ steps.commit-hash.outputs.app_short }} + ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:saas-frontend-latest + build-args: | + VERSION_TAG=v2-alpha + STIRLING_FLAVOR=saas + VITE_BUILD_MODE=development + VITE_SUPABASE_URL=${{ steps.config.outputs.supabase_url }} + VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY=${{ secrets.SAAS_SUPABASE_PUBLISHABLE_KEY }} + platforms: linux/amd64 + + - name: Build and push AI engine image + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + file: ./engine/Dockerfile + push: true + cache-from: type=gha,scope=stirling-saas-engine + cache-to: type=gha,mode=max,scope=stirling-saas-engine + tags: | + ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:saas-engine-${{ steps.commit-hash.outputs.app_short }} + ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:saas-engine-latest + platforms: linux/amd64 + + - name: Set up SSH + env: + SSH_KEY: ${{ secrets.NEW_VPS_SSH_KEY }} + run: | + mkdir -p ~/.ssh/ + echo "$SSH_KEY" > ../private.key + sudo chmod 600 ../private.key + + - name: Deploy to VPS + env: + IMAGE_BASE: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test + IMAGE_TAG: ${{ steps.commit-hash.outputs.app_short }} + GHCR_USER: ${{ github.actor }} + GHCR_TOKEN: ${{ github.token }} + VPS_USERNAME: ${{ secrets.NEW_VPS_USERNAME }} + VPS_HOST: ${{ secrets.NEW_VPS_HOST }} + SAAS_DB_URL: ${{ secrets.SAAS_DB_URL }} + SAAS_DB_USERNAME: ${{ secrets.SAAS_DB_USERNAME || 'postgres' }} + SAAS_DB_PASSWORD: ${{ secrets.SAAS_DB_PASSWORD }} + SAAS_DB_PROJECT_REF: ${{ secrets.SAAS_DB_PROJECT_REF }} + SUPABASE_EDGE_FUNCTION_SECRET: ${{ secrets.SUPABASE_EDGE_FUNCTION_SECRET }} + PAYG_METER_ENDPOINT: ${{ steps.config.outputs.meter_endpoint }} + STIRLING_KEYGEN_ENABLED: ${{ secrets.KEYGEN_ACCOUNT_ID != '' && secrets.KEYGEN_API_TOKEN != '' && secrets.KEYGEN_POLICY_ID != '' }} + KEYGEN_ACCOUNT_ID: ${{ secrets.KEYGEN_ACCOUNT_ID }} + KEYGEN_API_TOKEN: ${{ secrets.KEYGEN_API_TOKEN }} + KEYGEN_POLICY_ID: ${{ secrets.KEYGEN_POLICY_ID }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + VOYAGE_API_KEY: ${{ secrets.VOYAGE_API_KEY }} + run: | + set -euo pipefail + + BASE_URL="http://${VPS_HOST}:${FRONTEND_PORT}" + + yaml() { + printf "'%s'" "$(printf '%s' "$1" | sed -e "s/'/''/g" -e 's/\$/$$/g')" + } + + ENGINE_SECRET="$(openssl rand -hex 32)" + AI_BACKEND_VARS=" + SYSTEM_AIENGINE_ENABLED: \"true\" + SYSTEM_AIENGINE_URL: \"http://saas-engine:5001\" + APP_AI_SERVICEBASEURL: \"http://saas-engine:5001\" + STIRLING_ENGINE_SHARED_SECRET: $(yaml "$ENGINE_SECRET")" + AI_SERVICE=" + + saas-engine: + container_name: stirling-saas-dev-engine + image: ${IMAGE_BASE}:saas-engine-${IMAGE_TAG} + environment: + ANTHROPIC_API_KEY: $(yaml "$ANTHROPIC_API_KEY") + VOYAGE_API_KEY: $(yaml "$VOYAGE_API_KEY") + STIRLING_ENGINE_SHARED_SECRET: $(yaml "$ENGINE_SECRET") + restart: on-failure:5" + + cat > docker-compose.yml << EOF + version: '3.3' + services: + saas-backend: + container_name: stirling-saas-dev-backend + image: ${IMAGE_BASE}:saas-backend-${IMAGE_TAG} + ports: + - "${BACKEND_PORT}:8080" + volumes: + - ${DEPLOY_DIR}/config:/configs:rw + - ${DEPLOY_DIR}/logs:/logs:rw + - ${DEPLOY_DIR}/storage:/storage:rw + environment: + SPRING_PROFILES_ACTIVE: "saas" + DISABLE_ADDITIONAL_FEATURES: "false" + SAAS_DB_URL: $(yaml "$SAAS_DB_URL") + SAAS_DB_USERNAME: $(yaml "$SAAS_DB_USERNAME") + SAAS_DB_PASSWORD: $(yaml "$SAAS_DB_PASSWORD") + SAAS_DB_PROJECT_REF: $(yaml "$SAAS_DB_PROJECT_REF") + SUPABASE_EDGE_FUNCTION_SECRET: $(yaml "$SUPABASE_EDGE_FUNCTION_SECRET") + PAYG_METER_ENDPOINT: $(yaml "$PAYG_METER_ENDPOINT") + STIRLING_KEYGEN_ENABLED: $(yaml "$STIRLING_KEYGEN_ENABLED") + KEYGEN_ACCOUNT_ID: $(yaml "$KEYGEN_ACCOUNT_ID") + KEYGEN_API_TOKEN: $(yaml "$KEYGEN_API_TOKEN") + KEYGEN_POLICY_ID: $(yaml "$KEYGEN_POLICY_ID") + SYSTEM_DEFAULTLOCALE: en-US + SYSTEM_MAXFILESIZE: "100" + METRICS_ENABLED: "true" + SYSTEM_GOOGLEVISIBILITY: "false" + SWAGGER_SERVER_URL: "${BASE_URL}" + baseUrl: "${BASE_URL}"${AI_BACKEND_VARS} + restart: on-failure:5 + + saas-frontend: + container_name: stirling-saas-dev-frontend + image: ${IMAGE_BASE}:saas-frontend-${IMAGE_TAG} + ports: + - "${FRONTEND_PORT}:80" + environment: + VITE_API_BASE_URL: "http://saas-backend:8080" + depends_on: + - saas-backend + restart: on-failure:5${AI_SERVICE} + EOF + + SSH_OPTS=(-i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null) + + scp "${SSH_OPTS[@]}" docker-compose.yml "${VPS_USERNAME}@${VPS_HOST}:/tmp/saas-dev-docker-compose.yml" + + ssh "${SSH_OPTS[@]}" -T "${VPS_USERNAME}@${VPS_HOST}" << ENDSSH + set -e + mkdir -p ${DEPLOY_DIR}/{config,logs,storage} + mv /tmp/saas-dev-docker-compose.yml ${DEPLOY_DIR}/docker-compose.yml + chmod 600 ${DEPLOY_DIR}/docker-compose.yml + cd ${DEPLOY_DIR} + printf '%s' "${GHCR_TOKEN}" | docker login ghcr.io -u "${GHCR_USER}" --password-stdin + docker-compose down --remove-orphans 2>/dev/null || true + docker-compose pull + docker-compose up -d + docker logout ghcr.io >/dev/null 2>&1 || true + docker image prune -af --filter "until=336h" --filter "label!=keep=true" || true + ENDSSH + + - name: Wait for the backend to answer + env: + VPS_HOST: ${{ secrets.NEW_VPS_HOST }} + run: | + URL="http://${VPS_HOST}:${BACKEND_PORT}/api/v1/info/status" + for i in $(seq 1 60); do + code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "$URL" || true) + if [ "$code" = "200" ]; then echo "Healthy after $((i * 10))s"; exit 0; fi + sleep 10 + done + echo "::error::SaaS dev backend did not become healthy within 10 minutes" + exit 1 + + - name: Cleanup temporary files + if: always() + run: rm -f ../private.key docker-compose.yml + continue-on-error: true From 849d616451feb79b706ad6f9a9250924327b0020 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Thu, 27 Aug 2026 23:45:10 +0100 Subject: [PATCH 10/27] Fix refreshing causing you to go to the Processor (#7694) --- .../common/util/RequestUriUtilsTest.java | 10 ++++++++ .../configuration/SecurityConfiguration.java | 6 ++--- .../src/proprietary/routes/Login.test.tsx | 23 ++++++++++++++++++- .../editor/src/proprietary/routes/Login.tsx | 8 +++++-- .../proprietary/services/apiClientSetup.ts | 4 ++++ 5 files changed, 45 insertions(+), 6 deletions(-) diff --git a/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java b/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java index 72e5eae9a2..c144f9726e 100644 --- a/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java +++ b/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java @@ -113,6 +113,16 @@ class RequestUriUtilsTest { assertTrue(RequestUriUtils.isFrontendRoute("", "/split-pdf")); } + @Test + void testIsFrontendRoute_editorRouteOwnedByFrontend() { + // /editor (and its tool routes) is an SPA route: a direct-nav/refresh must + // serve index.html, not the auth filter's 302-to-/login. Regression test for + // the editor moving from / to /editor, whose refresh bounced processor users + // to the processor because the redirect dropped the return path. + assertTrue(RequestUriUtils.isFrontendRoute("", "/editor")); + assertTrue(RequestUriUtils.isFrontendRoute("/app", "/app/editor")); + } + @Test void testIsFrontendRoute_filesRouteOwnedByFrontend() { // /files and /files/ are FileManagerView routes - they diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java index 9fc4428f73..0f0c7315d9 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java @@ -357,12 +357,12 @@ public class SecurityConfiguration { req -> { String uri = req.getRequestURI(); String contextPath = req.getContextPath(); - // Check if it's a public auth endpoint or static - // resource return RequestUriUtils.isStaticResource( contextPath, uri) || RequestUriUtils.isPublicAuthEndpoint( - uri, contextPath); + uri, contextPath) + || RequestUriUtils.isFrontendRoute( + contextPath, uri); }) .permitAll() .anyRequest() diff --git a/frontend/editor/src/proprietary/routes/Login.test.tsx b/frontend/editor/src/proprietary/routes/Login.test.tsx index 29ae9d9d79..5553f202fb 100644 --- a/frontend/editor/src/proprietary/routes/Login.test.tsx +++ b/frontend/editor/src/proprietary/routes/Login.test.tsx @@ -234,7 +234,10 @@ describe("Login", () => { ); }; - afterEach(() => window.history.replaceState({}, "", "/")); + afterEach(() => { + window.history.replaceState({}, "", "/"); + sessionStorage.clear(); + }); it("returns to where the user came from", async () => { signedIn(); @@ -247,6 +250,24 @@ describe("Login", () => { }); }); + // A full-page 401 redirect drops router state and Spring can strip ?from=, + // leaving the return path only in the sessionStorage stash. Without reading + // it here a processor user refreshing /editor falls through to the role + // router and lands on the processor. + it("returns to the stashed path when there is no ?from=", async () => { + signedIn(); + sessionStorage.setItem("stirling_post_login_path", "/compress"); + renderAtLogin(""); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith("/compress", { + replace: true, + }); + }); + // Consumed, so a later sign-in can't reuse a stale path. + expect(sessionStorage.getItem("stirling_post_login_path")).toBeNull(); + }); + // Delegated to the shared isSafePostLoginRedirect, so the backslash form // (browsers normalise "\" to "/") and auth routes are covered too. it.each([ diff --git a/frontend/editor/src/proprietary/routes/Login.tsx b/frontend/editor/src/proprietary/routes/Login.tsx index b134d9ee89..7fd3e86670 100644 --- a/frontend/editor/src/proprietary/routes/Login.tsx +++ b/frontend/editor/src/proprietary/routes/Login.tsx @@ -7,7 +7,10 @@ import { } from "react-router-dom"; import { Button } from "@app/ui/Button"; import { isSafePostLoginRedirect } from "@app/auth"; -import { setPostLoginRedirectPath } from "@app/auth/spring/springAuthClient"; +import { + setPostLoginRedirectPath, + consumePostLoginRedirectPath, +} from "@app/auth/spring/springAuthClient"; import { useAuth } from "@app/auth/UseSession"; import { useAppConfig } from "@app/contexts/AppConfigContext"; import { useTranslation } from "react-i18next"; @@ -207,7 +210,8 @@ export default function Login() { useEffect(() => { if (loading) return; if (!session) return; - const returnPath = resolveReturnPath(); + const stashed = consumePostLoginRedirectPath(); + const returnPath = resolveReturnPath() ?? stashed; if (returnPath) { navigate(returnPath, { replace: true }); return; diff --git a/frontend/editor/src/proprietary/services/apiClientSetup.ts b/frontend/editor/src/proprietary/services/apiClientSetup.ts index 8615156e9a..3f50e1bb4c 100644 --- a/frontend/editor/src/proprietary/services/apiClientSetup.ts +++ b/frontend/editor/src/proprietary/services/apiClientSetup.ts @@ -1,6 +1,7 @@ import { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from "axios"; import { withBasePath } from "@app/constants/app"; import { getBrowserId } from "@app/utils/browserIdentifier"; +import { setPostLoginRedirectPath } from "@app/auth/spring/springAuthClient"; let isRefreshing = false; let failedQueue: Array<{ @@ -93,6 +94,9 @@ async function refreshAuthToken(client: AxiosInstance): Promise { // Redirect to login const loginPath = withBasePath("/login"); if (window.location.pathname !== loginPath) { + setPostLoginRedirectPath( + window.location.pathname + window.location.search, + ); console.log("[API Client] Redirecting to login page..."); window.location.href = loginPath; } From 0a3f0c181474e1b0ebd03744330481aee6f495be Mon Sep 17 00:00:00 2001 From: James Brunton Date: Fri, 28 Aug 2026 10:03:52 +0000 Subject: [PATCH 11/27] Fix redirect bugs in SaaS (#7721) # Description of Changes Fixes various bugs that affected SaaS (and some self-hosted): - Refreshing on Editor caused the user to be redirected to Processor - User was unable to access Processor in SaaS - Deep link hijacking fixes - Fix double prefix `/app/app` issue - Fix going from tool -> editor -> processor -> editor putting you back into tool --------- Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> --- .../shared/quickNav/QuickNavRailHost.tsx | 4 +- .../src/core/contexts/ToolWorkflowContext.tsx | 16 +++ .../editor/src/core/hooks/useUrlSync.test.tsx | 80 +++++++++++ frontend/editor/src/core/hooks/useUrlSync.ts | 30 +++- .../httpErrorHandler.basePath.test.ts | 76 +++++++++++ .../src/core/services/httpErrorHandler.ts | 28 ++-- .../core/services/postLoginRedirect.test.ts | 39 ++++++ .../src/core/services/postLoginRedirect.ts | 15 ++ .../core/services/workbenchSession.test.ts | 8 +- .../src/core/services/workbenchSession.ts | 5 +- .../auth/spring/springAuthClient.ts | 22 +-- .../proprietary/auth/supabase/UseSession.tsx | 14 +- .../auth/supabase/portalAccessFetch.test.tsx | 128 ++++++++++++++++++ .../hooks/useOtherAppSwitch.test.tsx | 5 +- .../proprietary/hooks/useOtherAppSwitch.ts | 5 +- .../proprietary/services/apiClientSetup.ts | 6 +- .../services/postLoginRedirect.test.ts | 25 ++++ .../proprietary/services/postLoginRedirect.ts | 7 + .../src/saas/hooks/useOtherAppSwitch.ts | 5 +- .../editor/src/saas/routes/AuthCallback.tsx | 21 ++- frontend/editor/src/saas/routes/Login.tsx | 13 +- .../src/saas/services/apiClient.test.ts | 51 ++++++- .../editor/src/saas/services/apiClient.ts | 25 +++- 23 files changed, 545 insertions(+), 83 deletions(-) create mode 100644 frontend/editor/src/core/hooks/useUrlSync.test.tsx create mode 100644 frontend/editor/src/core/services/httpErrorHandler.basePath.test.ts create mode 100644 frontend/editor/src/core/services/postLoginRedirect.test.ts create mode 100644 frontend/editor/src/core/services/postLoginRedirect.ts create mode 100644 frontend/editor/src/proprietary/auth/supabase/portalAccessFetch.test.tsx create mode 100644 frontend/editor/src/proprietary/services/postLoginRedirect.test.ts create mode 100644 frontend/editor/src/proprietary/services/postLoginRedirect.ts diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailHost.tsx b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailHost.tsx index 5c748b36cc..3fba4d7e1f 100644 --- a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailHost.tsx +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailHost.tsx @@ -20,7 +20,7 @@ const SIZE = "1.125rem"; export function QuickNavRailHost() { const { t } = useTranslation(); const navigate = useNavigate(); - const { pathname, search } = useLocation(); + const { pathname } = useLocation(); const host = useQuickNavHost(); const appMounted = Boolean(host?.appMounted); @@ -74,7 +74,7 @@ export function QuickNavRailHost() { returnHome(); return; } - saveEditorReturnPath(pathname + search); + saveEditorReturnPath(); go(PORTAL_BASENAME); }, }, diff --git a/frontend/editor/src/core/contexts/ToolWorkflowContext.tsx b/frontend/editor/src/core/contexts/ToolWorkflowContext.tsx index 070442a966..b4ef874184 100644 --- a/frontend/editor/src/core/contexts/ToolWorkflowContext.tsx +++ b/frontend/editor/src/core/contexts/ToolWorkflowContext.tsx @@ -29,6 +29,8 @@ import { isBaseWorkbench, } from "@app/types/workbench"; import { useNavigationUrlSync } from "@app/hooks/useUrlSync"; +import { stripBasePath } from "@app/constants/app"; +import { EDITOR_BASENAME } from "@app/routes/editorBasename"; import { filterToolRegistryByQuery } from "@app/utils/toolSearch"; import { useToolHistory } from "@app/hooks/tools/useUserToolActivity"; import { @@ -373,15 +375,28 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) { // This runs once to navigate to the user's preferred tab (read/automate) // instead of always starting on the tools tab. const hasAppliedStartupView = React.useRef(false); + // Set when the startup view picks the tool, so the URL sync knows this + // selection came from a preference and must not be written to the address. + const startupSelectedToolRef = React.useRef(null); useEffect(() => { if (hasAppliedStartupView.current) return; + // The URL wins: the startup view decides what you see when you arrive at the + // editor's home, never what a deep link to a tool shows. Without this, a + // "Reader" preference rewrote every / link to /read. + const path = stripBasePath(window.location.pathname); + if (path !== "/" && path !== EDITOR_BASENAME) { + hasAppliedStartupView.current = true; + return; + } const startupView = preferences.defaultStartupView; if (startupView === "read") { hasAppliedStartupView.current = true; + startupSelectedToolRef.current = "read"; setReaderMode(true); actions.setSelectedTool("read"); } else if (startupView === "automate") { hasAppliedStartupView.current = true; + startupSelectedToolRef.current = "automate"; actions.setSelectedTool("automate"); setLeftPanelView("toolContent"); } @@ -573,6 +588,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) { handleBackToTools, allTools, true, + startupSelectedToolRef, ); // Ref-backed wrappers so callback identities stay stable across renders. diff --git a/frontend/editor/src/core/hooks/useUrlSync.test.tsx b/frontend/editor/src/core/hooks/useUrlSync.test.tsx new file mode 100644 index 0000000000..e35c7dbd44 --- /dev/null +++ b/frontend/editor/src/core/hooks/useUrlSync.test.tsx @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { useRef } from "react"; +import type { ToolId } from "@app/types/toolId"; + +const h = vi.hoisted(() => ({ + updateToolRoute: vi.fn(), + clearToolRoute: vi.fn(), +})); + +vi.mock("@app/utils/urlRouting", () => ({ + parseToolRoute: () => ({ workbench: "fileEditor", toolId: null }), + updateToolRoute: h.updateToolRoute, + clearToolRoute: h.clearToolRoute, +})); +vi.mock("@app/utils/scarfTracking", () => ({ firePixel: vi.fn() })); +vi.mock("@app/contexts/AppConfigContext", () => ({ + useAppConfig: () => ({ config: { premiumEnabled: true } }), +})); + +import { useNavigationUrlSync } from "@app/hooks/useUrlSync"; + +const registry = { + read: { name: "Read", workbench: "viewer" }, + compress: { name: "Compress", workbench: "fileEditor" }, +} as never; + +/** Drives the hook the way ToolWorkflowContext does, with a startup marker. */ +function useHarness(selectedTool: ToolId | null, startupTool: ToolId | null) { + const ref = useRef(startupTool); + useNavigationUrlSync(selectedTool, vi.fn(), vi.fn(), registry, true, ref); + return ref; +} + +describe("useNavigationUrlSync — startup-view selections", () => { + beforeEach(() => h.updateToolRoute.mockClear()); + + // The default-startup-view preference selects a tool to change the *view*. + // Writing it to the address turned every visit to /editor into /read. + it("never writes the URL for the startup-applied tool", () => { + const { rerender } = renderHook( + ({ tool }: { tool: ToolId | null }) => useHarness(tool, "read" as ToolId), + { initialProps: { tool: null as ToolId | null } }, + ); + rerender({ tool: "read" as ToolId }); + expect(h.updateToolRoute).not.toHaveBeenCalled(); + }); + + // The effect re-runs whenever the registry identity changes, so a marker that + // was consumed on first sight let the second run write /read anyway. + it("survives a re-run for the same tool", () => { + const { rerender } = renderHook( + ({ tool }: { tool: ToolId | null }) => useHarness(tool, "read" as ToolId), + { initialProps: { tool: null as ToolId | null } }, + ); + rerender({ tool: "read" as ToolId }); + rerender({ tool: "read" as ToolId }); + rerender({ tool: "read" as ToolId }); + expect(h.updateToolRoute).not.toHaveBeenCalled(); + }); + + it("still writes the URL when the user picks a different tool", () => { + const { rerender } = renderHook( + ({ tool }: { tool: ToolId | null }) => useHarness(tool, "read" as ToolId), + { initialProps: { tool: null as ToolId | null } }, + ); + rerender({ tool: "read" as ToolId }); + rerender({ tool: "compress" as ToolId }); + expect(h.updateToolRoute).toHaveBeenCalledWith("compress", registry, false); + }); + + it("writes the URL for a tool chosen without a startup marker", () => { + const { rerender } = renderHook( + ({ tool }: { tool: ToolId | null }) => useHarness(tool, null), + { initialProps: { tool: null as ToolId | null } }, + ); + rerender({ tool: "read" as ToolId }); + expect(h.updateToolRoute).toHaveBeenCalledWith("read", registry, false); + }); +}); diff --git a/frontend/editor/src/core/hooks/useUrlSync.ts b/frontend/editor/src/core/hooks/useUrlSync.ts index 5fad71ba29..76fd67178a 100644 --- a/frontend/editor/src/core/hooks/useUrlSync.ts +++ b/frontend/editor/src/core/hooks/useUrlSync.ts @@ -2,7 +2,7 @@ * URL synchronization hooks for tool routing with registry support */ -import { useEffect, useCallback, useRef } from "react"; +import { useEffect, useCallback, useRef, type MutableRefObject } from "react"; import { ToolId } from "@app/types/toolId"; import { parseToolRoute, @@ -24,6 +24,11 @@ export function useNavigationUrlSync( clearToolSelection: () => void, registry: ToolRegistry, enableSync: boolean = true, + /** + * Tool the default-startup-view preference selected, if any. That selection + * sets the view, not the address, so it must not be written to the URL. + */ + startupSelectedToolRef?: MutableRefObject, ) { const { config } = useAppConfig(); const premiumEnabled = config?.premiumEnabled; @@ -77,8 +82,16 @@ export function useNavigationUrlSync( useEffect(() => { if (!enableSync) return; + const startupTool = startupSelectedToolRef?.current ?? null; + if (selectedTool) { - updateToolRoute(selectedTool, registry, false); // Use pushState for user navigation + // A startup-view selection is a view preference, not a navigation: writing + // it here rewrote /editor to /read on every load. The effect re-runs + // whenever the registry identity changes, so the marker has to survive + // until the selection actually moves off it (cleared below). + if (startupTool !== selectedTool) { + updateToolRoute(selectedTool, registry, false); // Use pushState for user navigation + } } else if (prevSelectedTool.current !== null) { // Only clear URL if we had a tool before (user navigated away) // Don't clear on initial load when both current and previous are null @@ -88,8 +101,19 @@ export function useNavigationUrlSync( } } + // Spent once the user leaves the startup-applied tool, so re-picking it + // later is a real navigation and does update the URL. + if ( + startupSelectedToolRef && + startupTool !== null && + prevSelectedTool.current === startupTool && + selectedTool !== startupTool + ) { + startupSelectedToolRef.current = null; + } + prevSelectedTool.current = selectedTool; - }, [selectedTool, registry, enableSync]); + }, [selectedTool, registry, enableSync, startupSelectedToolRef]); // Handle browser back/forward navigation useEffect(() => { diff --git a/frontend/editor/src/core/services/httpErrorHandler.basePath.test.ts b/frontend/editor/src/core/services/httpErrorHandler.basePath.test.ts new file mode 100644 index 0000000000..27a4231a2c --- /dev/null +++ b/frontend/editor/src/core/services/httpErrorHandler.basePath.test.ts @@ -0,0 +1,76 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@app/components/toast", () => ({ alert: vi.fn() })); +vi.mock("@app/services/specialErrorToasts", () => ({ + showSpecialErrorToast: vi.fn(() => false), +})); +vi.mock("@app/services/saasErrorInterceptor", () => ({ + handleSaaSError: vi.fn(() => false), +})); +vi.mock("@app/services/errorUtils", () => ({ + broadcastErroredFiles: vi.fn(), + extractErrorFileIds: vi.fn(() => []), + normalizeAxiosErrorData: vi.fn(async (d: unknown) => d), +})); + +const hrefs: string[] = []; + +/** Serve the app from `base`, sitting on `pathname`, then load the handler fresh. */ +async function loadAt(base: string, pathname: string) { + document.head.innerHTML = ``; + Object.defineProperty(window, "location", { + configurable: true, + value: { + pathname, + search: "", + origin: "http://localhost:3000", + get href() { + // Absolute: jsdom resolves against this. + return "http://localhost:3000" + pathname; + }, + set href(v: string) { + hrefs.push(v); + }, + }, + }); + vi.resetModules(); + return (await import("@app/services/httpErrorHandler")).handleHttpError; +} + +const unauthorized = { + isAxiosError: true, + message: "unauthorized", + config: {}, + response: { status: 401, data: {} }, +}; + +beforeEach(() => { + hrefs.length = 0; + sessionStorage.clear(); + localStorage.clear(); +}); +afterEach(() => vi.resetModules()); + +describe("401 return path is router-relative", () => { + // Login replays this through navigate(), which re-applies the router + // basename. Carrying /app here produced /app/app/compress. + it("strips the base path on a subpath deploy", async () => { + const handle = await loadAt("/app/", "/app/compress"); + await handle(unauthorized); + + expect(sessionStorage.getItem("stirling_post_login_path")).toBe( + "/compress", + ); + expect(hrefs[0]).toBe("/app/login?from=%2Fcompress"); + }); + + it("is unchanged at the origin root", async () => { + const handle = await loadAt("/", "/compress"); + await handle(unauthorized); + + expect(sessionStorage.getItem("stirling_post_login_path")).toBe( + "/compress", + ); + expect(hrefs[0]).toBe("/login?from=%2Fcompress"); + }); +}); diff --git a/frontend/editor/src/core/services/httpErrorHandler.ts b/frontend/editor/src/core/services/httpErrorHandler.ts index dd0c692d97..30272c381e 100644 --- a/frontend/editor/src/core/services/httpErrorHandler.ts +++ b/frontend/editor/src/core/services/httpErrorHandler.ts @@ -12,7 +12,8 @@ import { clampText, extractAxiosErrorMessage, } from "@app/services/httpErrorUtils"; -import { withBasePath } from "@app/constants/app"; +import { stripBasePath, withBasePath } from "@app/constants/app"; +import { isSafePostLoginRedirect } from "@app/services/postLoginRedirect"; // Module-scoped state to reduce global variable usage const recentSpecialByEndpoint: Record = {}; @@ -21,26 +22,9 @@ const SPECIAL_SUPPRESS_MS = 1500; // brief window to suppress generic duplicate // Mirrors the key in proprietary/auth/springAuthClient.ts; AuthCallback consumes it. const POST_LOGIN_REDIRECT_STORAGE_KEY = "stirling_post_login_path"; -function isSafePostLoginPath(path: string): boolean { - if ( - !path.startsWith("/") || - path.startsWith("//") || - path.startsWith("/\\") - ) { - return false; - } - const lowered = path.toLowerCase(); - return ( - !lowered.startsWith("/login") && - !lowered.startsWith("/auth/") && - !lowered.startsWith("/oauth2") && - !lowered.startsWith("/saml2") - ); -} - function stashPostLoginRedirect(path: string): void { try { - if (typeof window === "undefined" || !isSafePostLoginPath(path)) return; + if (typeof window === "undefined" || !isSafePostLoginRedirect(path)) return; window.sessionStorage.setItem(POST_LOGIN_REDIRECT_STORAGE_KEY, path); } catch { // sessionStorage unavailable (private mode) — fail open @@ -128,7 +112,11 @@ export async function handleHttpError(error: unknown): Promise { 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). - const currentLocation = window.location.pathname + window.location.search; + // Router-relative, not browser-relative: every consumer replays this + // through navigate(), which re-applies the basename. Keeping the base + // path here yields /app/app/ on a subpath deploy. + const currentLocation = + stripBasePath(window.location.pathname) + window.location.search; stashPostLoginRedirect(currentLocation); let hadStoredJwt = false; try { diff --git a/frontend/editor/src/core/services/postLoginRedirect.test.ts b/frontend/editor/src/core/services/postLoginRedirect.test.ts new file mode 100644 index 0000000000..9f1687f392 --- /dev/null +++ b/frontend/editor/src/core/services/postLoginRedirect.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { isSafePostLoginRedirect } from "@app/services/postLoginRedirect"; + +// Core default. Rejects off-origin forms and the auth routes every build has +// (/login, /auth/…); Spring SSO routes are the proprietary override's concern. +describe("isSafePostLoginRedirect (core base)", () => { + it("accepts same-origin router paths", () => { + expect(isSafePostLoginRedirect("/editor")).toBe(true); + expect(isSafePostLoginRedirect("/compress")).toBe(true); + expect(isSafePostLoginRedirect("/editor?foo=bar")).toBe(true); + expect(isSafePostLoginRedirect("/oauth/consent?x=1")).toBe(true); + expect(isSafePostLoginRedirect("/")).toBe(true); + }); + + it("rejects empty and non-string values", () => { + expect(isSafePostLoginRedirect(null)).toBe(false); + expect(isSafePostLoginRedirect(undefined)).toBe(false); + expect(isSafePostLoginRedirect("")).toBe(false); + expect(isSafePostLoginRedirect(42 as unknown)).toBe(false); + }); + + it("rejects off-origin and protocol-relative forms", () => { + expect(isSafePostLoginRedirect("//evil.example.com")).toBe(false); + expect(isSafePostLoginRedirect("/\\evil.example.com")).toBe(false); + expect(isSafePostLoginRedirect("https://evil.example.com")).toBe(false); + expect(isSafePostLoginRedirect("editor")).toBe(false); + }); + + it("rejects the universal auth routes so returning back can never loop", () => { + expect(isSafePostLoginRedirect("/login")).toBe(false); + expect(isSafePostLoginRedirect("/login?next=%2Feditor")).toBe(false); + expect(isSafePostLoginRedirect("/auth/callback")).toBe(false); + }); + + it("leaves the Spring SSO routes to the proprietary override", () => { + expect(isSafePostLoginRedirect("/oauth2/authorize")).toBe(true); + expect(isSafePostLoginRedirect("/saml2/login")).toBe(true); + }); +}); diff --git a/frontend/editor/src/core/services/postLoginRedirect.ts b/frontend/editor/src/core/services/postLoginRedirect.ts new file mode 100644 index 0000000000..d9ffe82940 --- /dev/null +++ b/frontend/editor/src/core/services/postLoginRedirect.ts @@ -0,0 +1,15 @@ +/** + * Is `path` safe to send a user back to after they log in? + */ +export function isSafePostLoginRedirect(path: unknown): path is string { + if (typeof path !== "string" || path.length === 0) return false; + if ( + !path.startsWith("/") || + path.startsWith("//") || + path.startsWith("/\\") + ) { + return false; + } + const lowered = path.toLowerCase(); + return !lowered.startsWith("/login") && !lowered.startsWith("/auth/"); +} diff --git a/frontend/editor/src/core/services/workbenchSession.test.ts b/frontend/editor/src/core/services/workbenchSession.test.ts index d9feec8d1c..0258b83623 100644 --- a/frontend/editor/src/core/services/workbenchSession.test.ts +++ b/frontend/editor/src/core/services/workbenchSession.test.ts @@ -55,10 +55,14 @@ describe("workbench session record", () => { }); describe("editor return path", () => { - it("is consumed by the first take", () => { - saveEditorReturnPath("/compress?x=1"); + it("captures the live address bar and is consumed by the first take", () => { + // The editor writes its tool route via raw history.pushState, so the save + // must read window.location, not a lagging router location. + window.history.pushState({}, "", "/compress?x=1"); + saveEditorReturnPath(); expect(takeEditorReturnPath()).toBe("/compress?x=1"); expect(takeEditorReturnPath()).toBeNull(); + window.history.pushState({}, "", "/"); }); }); diff --git a/frontend/editor/src/core/services/workbenchSession.ts b/frontend/editor/src/core/services/workbenchSession.ts index 67f6d1abfc..2f23db8655 100644 --- a/frontend/editor/src/core/services/workbenchSession.ts +++ b/frontend/editor/src/core/services/workbenchSession.ts @@ -2,6 +2,7 @@ // does not cost the user their workbench. sessionStorage on purpose: per-tab, tabs never clobber. import type { StirlingFileStub } from "@app/types/fileContext"; +import { stripBasePath } from "@app/constants/app"; const SESSION_KEY = "stirling.workbench.session"; /** Bumped when the record's shape or meaning changes, so an old one is discarded rather than @@ -153,8 +154,10 @@ export function isSeedableView( return view !== undefined && SEEDABLE_VIEWS.includes(view); } -export function saveEditorReturnPath(path: string): void { +export function saveEditorReturnPath(): void { try { + const path = + stripBasePath(window.location.pathname) + window.location.search; sessionStorage.setItem(RETURN_PATH_KEY, path); } catch { // Best-effort: the switch back just lands on the editor root. diff --git a/frontend/editor/src/proprietary/auth/spring/springAuthClient.ts b/frontend/editor/src/proprietary/auth/spring/springAuthClient.ts index b9583e3dee..6d887ce8a9 100644 --- a/frontend/editor/src/proprietary/auth/spring/springAuthClient.ts +++ b/frontend/editor/src/proprietary/auth/spring/springAuthClient.ts @@ -16,6 +16,7 @@ import { AxiosError, type AxiosRequestConfig } from "axios"; import { getSpringAuthConfig } from "@app/auth/config"; import { type OAuthProvider } from "@app/auth/spring/oauthTypes"; import { resetOAuthState } from "@app/auth/spring/oauthStorage"; +import { isSafePostLoginRedirect } from "@app/services/postLoginRedirect"; import type { AuthUser as User, AuthSession as Session, @@ -100,23 +101,10 @@ function persistRedirectPath(path: string): void { } } -// Same-origin relative path, not pointing at auth plumbing. Rejects protocol-relative -// URLs to guard against open-redirect abuse if the stored value is tampered with. -export function isSafePostLoginRedirect(path: unknown): path is string { - if (typeof path !== "string" || path.length === 0) return false; - if (!path.startsWith("/") || path.startsWith("//")) return false; - if (path.startsWith("/\\")) return false; - const lowered = path.toLowerCase(); - if ( - lowered.startsWith("/login") || - lowered.startsWith("/auth/") || - lowered.startsWith("/oauth2") || - lowered.startsWith("/saml2") - ) { - return false; - } - return true; -} +// The safe-return-path rule lives in the shared @app/services/postLoginRedirect +// extension point (proprietary override adds the Spring SSO routes). Re-exported +// here so existing importers via @app/auth keep resolving it. +export { isSafePostLoginRedirect }; export function setPostLoginRedirectPath( path: string | null | undefined, diff --git a/frontend/editor/src/proprietary/auth/supabase/UseSession.tsx b/frontend/editor/src/proprietary/auth/supabase/UseSession.tsx index 385f3f4e35..07670bfff5 100644 --- a/frontend/editor/src/proprietary/auth/supabase/UseSession.tsx +++ b/frontend/editor/src/proprietary/auth/supabase/UseSession.tsx @@ -16,6 +16,7 @@ import type { import { getSupabaseClient } from "@app/auth/supabase/supabaseClient"; import { AuthContext } from "@app/auth/context"; import { isAdminRole } from "@app/auth/roles"; +import { getApiBaseUrl } from "@app/services/apiClientConfig"; import { defaultTranslate, type AuthContextValue, @@ -154,14 +155,23 @@ export function SupabaseAuthProvider({ return; } let cancelled = false; + // Same API base the rest of the app uses: SaaS serves the frontend and the + // API from different hosts, so a root-relative path never reaches /me. + const apiBase = (getApiBaseUrl() || "").replace(/\/+$/, ""); + const meUrl = `${apiBase}/api/v1/auth/me`; const loadAccess = () => { - void fetch("/api/v1/auth/me", { + void fetch(meUrl, { headers: { Authorization: `Bearer ${token}`, Accept: "application/json", }, }) - .then((res) => (res.ok ? res.json() : null)) + .then((res) => { + // Must throw, not resolve null: swallowing a non-ok leaves + // portalAccess undefined and hangs the portal gate on a spinner. + if (!res.ok) throw new Error(`auth/me responded ${res.status}`); + return res.json(); + }) .then( ( data: { diff --git a/frontend/editor/src/proprietary/auth/supabase/portalAccessFetch.test.tsx b/frontend/editor/src/proprietary/auth/supabase/portalAccessFetch.test.tsx new file mode 100644 index 0000000000..3b3d9b37c8 --- /dev/null +++ b/frontend/editor/src/proprietary/auth/supabase/portalAccessFetch.test.tsx @@ -0,0 +1,128 @@ +import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; +import { render, waitFor } from "@testing-library/react"; +import { useContext } from "react"; + +const h = vi.hoisted(() => ({ apiBase: "/" })); + +vi.mock("@app/services/apiClientConfig", () => ({ + getApiBaseUrl: () => h.apiBase, +})); + +const sbSession = { + access_token: "supabase-token", + user: { + id: "u1", + email: "user@example.com", + is_anonymous: false, + app_metadata: {}, + user_metadata: {}, + }, +}; + +vi.mock("@app/auth/supabase/supabaseClient", () => ({ + getSupabaseClient: () => ({ + auth: { + getSession: () => Promise.resolve({ data: { session: sbSession } }), + onAuthStateChange: () => ({ + data: { subscription: { unsubscribe: () => {} } }, + }), + refreshSession: () => Promise.resolve({ data: {}, error: null }), + signOut: () => Promise.resolve({ error: null }), + }, + }), +})); + +import { SupabaseAuthProvider } from "@app/auth/supabase/UseSession"; +import { AuthContext } from "@app/auth/context"; + +function Probe() { + const v = useContext(AuthContext); + return ( + <> + {String(v?.portalAccess)} + {/* Raw, un-defaulted value: this is what SaasPortalGate reads to decide + "access not known yet" vs "denied". undefined = spinner forever. */} + {String(v?.user?.portalAccess)} + + ); +} + +const mount = () => + render( + + + , + ); + +let fetchMock: ReturnType; + +beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); +}); +afterEach(() => vi.unstubAllGlobals()); + +describe("supabase provider portalAccess lookup", () => { + // SaaS serves the frontend and the API from different hosts; a root-relative + // path silently missed /me, so a granted non-admin was denied the Processor. + it("calls /me on the configured API base, not the page origin", async () => { + h.apiBase = "https://api.example.com"; + fetchMock.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ user: { portalAccess: true } }), + }); + mount(); + await waitFor(() => + expect(fetchMock).toHaveBeenCalledWith( + "https://api.example.com/api/v1/auth/me", + expect.anything(), + ), + ); + }); + + it("keeps a same-origin base as a single leading slash", async () => { + h.apiBase = "/"; + fetchMock.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ user: { portalAccess: true } }), + }); + mount(); + await waitFor(() => + expect(fetchMock).toHaveBeenCalledWith( + "/api/v1/auth/me", + expect.anything(), + ), + ); + }); + + it("grants access when /me says so", async () => { + h.apiBase = "https://api.example.com"; + fetchMock.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ user: { portalAccess: true } }), + }); + const { getByTestId } = mount(); + await waitFor(() => expect(getByTestId("access").textContent).toBe("true")); + }); + + // A non-ok used to resolve to null and return early, leaving the raw + // portalAccess undefined forever - SaasPortalGate reads that as "still + // loading" and hangs on a spinner instead of falling back. + it("resolves the raw portalAccess when /me returns non-ok", async () => { + h.apiBase = "https://api.example.com"; + fetchMock.mockResolvedValue({ + ok: false, + status: 401, + json: () => Promise.resolve({}), + }); + const { getByTestId } = mount(); + await waitFor(() => expect(getByTestId("raw").textContent).toBe("false")); + }); + + it("leaves the raw portalAccess defined when the request rejects", async () => { + h.apiBase = "https://api.example.com"; + fetchMock.mockRejectedValue(new Error("network down")); + const { getByTestId } = mount(); + await waitFor(() => expect(getByTestId("raw").textContent).toBe("false")); + }); +}); diff --git a/frontend/editor/src/proprietary/hooks/useOtherAppSwitch.test.tsx b/frontend/editor/src/proprietary/hooks/useOtherAppSwitch.test.tsx index c2a81c53d9..4acdc7a17a 100644 --- a/frontend/editor/src/proprietary/hooks/useOtherAppSwitch.test.tsx +++ b/frontend/editor/src/proprietary/hooks/useOtherAppSwitch.test.tsx @@ -7,9 +7,10 @@ const mocks = vi.hoisted(() => ({ portalAccess: true, })); +// The hook reads window.location for the return path (not useLocation), because +// the editor's raw history.pushState leaves react-router's location stale. vi.mock("react-router-dom", () => ({ useNavigate: () => mocks.navigate, - useLocation: () => ({ pathname: "/compress", search: "?mode=fast" }), })); vi.mock("@app/auth/context", () => ({ useAuth: () => ({ portalAccess: mocks.portalAccess }), @@ -27,6 +28,7 @@ beforeEach(() => { sessionStorage.clear(); vi.clearAllMocks(); mocks.portalAccess = true; + window.history.pushState({}, "", "/"); }); describe("useOtherAppSwitch", () => { @@ -45,6 +47,7 @@ describe("useOtherAppSwitch", () => { }); it("records where to return to, then navigates to the processor", () => { + window.history.pushState({}, "", "/compress?mode=fast"); const { result } = renderHook(() => useOtherAppSwitch()); result.current?.onOpen(); mocks.requestNavigation.mock.calls[0][0](); diff --git a/frontend/editor/src/proprietary/hooks/useOtherAppSwitch.ts b/frontend/editor/src/proprietary/hooks/useOtherAppSwitch.ts index dff36a06f4..69b55aff35 100644 --- a/frontend/editor/src/proprietary/hooks/useOtherAppSwitch.ts +++ b/frontend/editor/src/proprietary/hooks/useOtherAppSwitch.ts @@ -1,4 +1,4 @@ -import { useLocation, useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import { useAuth } from "@app/auth/context"; import { useNavigationActions } from "@app/contexts/NavigationContext"; import { PORTAL_BASENAME } from "@app/routes/portalBasename"; @@ -12,7 +12,6 @@ import { type NavFooterAppLink } from "@app/components/shared/navFooter/NavFoote export function useOtherAppSwitch(): NavFooterAppLink | null { const { portalAccess } = useAuth(); const navigate = useNavigate(); - const location = useLocation(); const { actions } = useNavigationActions(); if (!portalAccess) return null; return { @@ -20,7 +19,7 @@ export function useOtherAppSwitch(): NavFooterAppLink | null { onOpen: () => // Through the guard, so unsaved edits get the same warning as any other navigation. actions.requestNavigation(() => { - saveEditorReturnPath(location.pathname + location.search); + saveEditorReturnPath(); navigate(PORTAL_BASENAME); }), }; diff --git a/frontend/editor/src/proprietary/services/apiClientSetup.ts b/frontend/editor/src/proprietary/services/apiClientSetup.ts index 3f50e1bb4c..459f527080 100644 --- a/frontend/editor/src/proprietary/services/apiClientSetup.ts +++ b/frontend/editor/src/proprietary/services/apiClientSetup.ts @@ -1,5 +1,5 @@ import { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from "axios"; -import { withBasePath } from "@app/constants/app"; +import { stripBasePath, withBasePath } from "@app/constants/app"; import { getBrowserId } from "@app/utils/browserIdentifier"; import { setPostLoginRedirectPath } from "@app/auth/spring/springAuthClient"; @@ -94,8 +94,10 @@ async function refreshAuthToken(client: AxiosInstance): Promise { // Redirect to login const loginPath = withBasePath("/login"); if (window.location.pathname !== loginPath) { + // Router-relative: Login replays this through navigate(), which applies + // the basename itself. See the same note in httpErrorHandler. setPostLoginRedirectPath( - window.location.pathname + window.location.search, + stripBasePath(window.location.pathname) + window.location.search, ); console.log("[API Client] Redirecting to login page..."); window.location.href = loginPath; diff --git a/frontend/editor/src/proprietary/services/postLoginRedirect.test.ts b/frontend/editor/src/proprietary/services/postLoginRedirect.test.ts new file mode 100644 index 0000000000..6f2451f336 --- /dev/null +++ b/frontend/editor/src/proprietary/services/postLoginRedirect.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { isSafePostLoginRedirect } from "@app/services/postLoginRedirect"; + +// Proprietary override: the core base plus the Spring SSO routes (/oauth2, /saml2). +describe("isSafePostLoginRedirect (proprietary override)", () => { + it("still accepts ordinary router paths", () => { + expect(isSafePostLoginRedirect("/editor")).toBe(true); + expect(isSafePostLoginRedirect("/share/abc123?x=1")).toBe(true); + expect(isSafePostLoginRedirect("/")).toBe(true); + }); + + it("inherits the base rejections", () => { + expect(isSafePostLoginRedirect("")).toBe(false); + expect(isSafePostLoginRedirect(null)).toBe(false); + expect(isSafePostLoginRedirect("//evil.example")).toBe(false); + expect(isSafePostLoginRedirect("/\\evil")).toBe(false); + expect(isSafePostLoginRedirect("/login")).toBe(false); + expect(isSafePostLoginRedirect("/auth/callback")).toBe(false); + }); + + it("also rejects the Spring SSO routes", () => { + expect(isSafePostLoginRedirect("/oauth2/authorization/google")).toBe(false); + expect(isSafePostLoginRedirect("/saml2/authenticate/x")).toBe(false); + }); +}); diff --git a/frontend/editor/src/proprietary/services/postLoginRedirect.ts b/frontend/editor/src/proprietary/services/postLoginRedirect.ts new file mode 100644 index 0000000000..5cd6546fa7 --- /dev/null +++ b/frontend/editor/src/proprietary/services/postLoginRedirect.ts @@ -0,0 +1,7 @@ +import { isSafePostLoginRedirect as isSafeBaseRedirect } from "@core/services/postLoginRedirect"; + +export function isSafePostLoginRedirect(path: unknown): path is string { + if (!isSafeBaseRedirect(path)) return false; + const lowered = path.toLowerCase(); + return !lowered.startsWith("/oauth2") && !lowered.startsWith("/saml2"); +} diff --git a/frontend/editor/src/saas/hooks/useOtherAppSwitch.ts b/frontend/editor/src/saas/hooks/useOtherAppSwitch.ts index 67d0aa7d49..e97d4d0ed3 100644 --- a/frontend/editor/src/saas/hooks/useOtherAppSwitch.ts +++ b/frontend/editor/src/saas/hooks/useOtherAppSwitch.ts @@ -1,4 +1,4 @@ -import { useLocation, useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import { usePortalAccess } from "@app/hooks/usePortalAccess"; import { useNavigationActions } from "@app/contexts/NavigationContext"; import { PORTAL_BASENAME } from "@app/routes/portalBasename"; @@ -13,7 +13,6 @@ import { type NavFooterAppLink } from "@app/components/shared/navFooter/NavFoote export function useOtherAppSwitch(): NavFooterAppLink | null { const portalAccess = usePortalAccess(); const navigate = useNavigate(); - const location = useLocation(); const { actions } = useNavigationActions(); if (!portalAccess) return null; return { @@ -21,7 +20,7 @@ export function useOtherAppSwitch(): NavFooterAppLink | null { onOpen: () => // Through the guard, so unsaved edits get the same warning as any other navigation. actions.requestNavigation(() => { - saveEditorReturnPath(location.pathname + location.search); + saveEditorReturnPath(); navigate(PORTAL_BASENAME); }), }; diff --git a/frontend/editor/src/saas/routes/AuthCallback.tsx b/frontend/editor/src/saas/routes/AuthCallback.tsx index b69619b85d..3fe081ed82 100644 --- a/frontend/editor/src/saas/routes/AuthCallback.tsx +++ b/frontend/editor/src/saas/routes/AuthCallback.tsx @@ -5,6 +5,7 @@ import { supabase } from "@app/auth/supabase"; import { Button } from "@app/ui/Button"; import { withBasePath } from "@app/constants/app"; import { readPendingConnect } from "@app/routes/pendingConnect"; +import { isSafePostLoginRedirect } from "@app/services/postLoginRedirect"; import { AuthShell } from "@app/auth/ui/AuthShell"; import ErrorMessage from "@app/auth/ui/ErrorMessage"; import { Spinner } from "@app/ui/Spinner"; @@ -136,18 +137,16 @@ export default function AuthCallback() { // else on the editor. // Explicit `next` first, so a sign-in started for another reason is not // hijacked by a remembered connect request. - const explicitNext = url.searchParams.get("next"); + const explicitNext = + url.searchParams.get("next") ?? url.searchParams.get("from"); const pendingConnect = readPendingConnect(); - const destination = - explicitNext && - explicitNext.startsWith("/") && - !explicitNext.startsWith("//") - ? explicitNext - : pendingConnect - ? `/link?request=${encodeURIComponent(pendingConnect)}` - : next.startsWith("/") && !next.startsWith("//") - ? next - : await resolveLandingPath(); + const destination = isSafePostLoginRedirect(explicitNext) + ? explicitNext + : pendingConnect + ? `/link?request=${encodeURIComponent(pendingConnect)}` + : isSafePostLoginRedirect(next) + ? next + : await resolveLandingPath(); console.log("[Auth Callback Debug] Redirecting to:", destination); setTimeout(() => navigate(destination, { replace: true }), 1500); diff --git a/frontend/editor/src/saas/routes/Login.tsx b/frontend/editor/src/saas/routes/Login.tsx index 418616104e..ba251561fd 100644 --- a/frontend/editor/src/saas/routes/Login.tsx +++ b/frontend/editor/src/saas/routes/Login.tsx @@ -14,6 +14,7 @@ import { getBaseUrl, withBasePath, } from "@app/constants/app"; +import { isSafePostLoginRedirect } from "@app/services/postLoginRedirect"; import LinkRoundedIcon from "@mui/icons-material/LinkRounded"; // Import login components @@ -48,14 +49,14 @@ export default function Login() { } }, []); - // Same-origin relative path to return to after login (e.g. the OAuth - // consent page). Same sanitization rules as AuthCallback's `next`. + // Same-origin router path to return to after login (e.g. the OAuth consent + // page, or the editor a 401 bounced the user off). `?next=` is what this app + // writes; `?from=` is what the shared core 401 handler writes. const nextPath = useMemo(() => { try { - const next = new URL(window.location.href).searchParams.get("next"); - return next && next.startsWith("/") && !next.startsWith("//") - ? next - : null; + const params = new URL(window.location.href).searchParams; + const candidate = params.get("next") ?? params.get("from"); + return isSafePostLoginRedirect(candidate) ? candidate : null; } catch (_) { return null; } diff --git a/frontend/editor/src/saas/services/apiClient.test.ts b/frontend/editor/src/saas/services/apiClient.test.ts index 45646bf8d8..af4a1d90f4 100644 --- a/frontend/editor/src/saas/services/apiClient.test.ts +++ b/frontend/editor/src/saas/services/apiClient.test.ts @@ -219,10 +219,11 @@ describe("apiClient", () => { // Import apiClient after mocking const { default: apiClient } = await import("@app/services/apiClient"); - // Mock window.location for redirect test + // On /editor when the session dies: the return path must ride along so the + // login screen sends the user back here, not to the role-based landing. Object.defineProperty(window, "location", { writable: true, - value: { href: "" }, + value: { href: "", pathname: "/editor", search: "" }, }); const mockAdapter = vi.fn((config) => { @@ -245,8 +246,50 @@ describe("apiClient", () => { } catch (_) { // Verify refresh was attempted expect(supabase.auth.refreshSession).toHaveBeenCalled(); - // Verify redirect to login - expect(window.location.href).toBe("/login"); + // Verify redirect to login carries the return path + expect(window.location.href).toBe("/login?next=%2Feditor"); + } + }); + + it("does not redirect (or loop) when already on the login page", async () => { + expectConsole.error(/\[API Client\] Token refresh failed/); + const oldSession = { access_token: "old", user: { id: "user-123" } }; + vi.mocked(supabase.auth.getSession).mockResolvedValue({ + data: { session: oldSession as unknown as Session }, + error: null, + }); + vi.mocked(supabase.auth.refreshSession).mockResolvedValue({ + data: { user: null, session: null }, + error: { + name: "AuthError", + message: "Refresh failed", + status: 400, + code: "auth_error", + } as unknown as AuthError, + }); + + const { default: apiClient } = await import("@app/services/apiClient"); + + Object.defineProperty(window, "location", { + writable: true, + value: { href: "", pathname: "/login", search: "?next=%2Feditor" }, + }); + + apiClient.defaults.adapter = vi.fn((config) => + Promise.reject( + Object.assign(new Error("Unauthorized"), { + response: { status: 401, data: { error: "Unauthorized" } }, + config, + }), + ), + ); + + try { + await apiClient.get("/api/v1/test"); + expect(true).toBe(false); + } catch (_) { + // Left untouched: no second redirect off the login page. + expect(window.location.href).toBe(""); } }); }); diff --git a/frontend/editor/src/saas/services/apiClient.ts b/frontend/editor/src/saas/services/apiClient.ts index 64cd05fd01..73ebe97e47 100644 --- a/frontend/editor/src/saas/services/apiClient.ts +++ b/frontend/editor/src/saas/services/apiClient.ts @@ -5,8 +5,9 @@ import { classifyPaygError, handlePaygError, } from "@app/services/paygErrorInterceptor"; -import { withBasePath } from "@app/constants/app"; +import { stripBasePath, withBasePath } from "@app/constants/app"; import { getBrowserId } from "@app/utils/browserIdentifier"; +import { isSafePostLoginRedirect } from "@app/services/postLoginRedirect"; // Helper: decode base64url JWT payload safely function decodeJwtPayload(token: string): Record | null { @@ -113,6 +114,21 @@ function refreshSessionOnce(): ReturnType { return inFlightRefresh; } +// Hard-redirect to /login, carrying where the user was so the login screen can +// return them there instead of falling through to the role-based landing (which +// sends processor users to the processor - the "refresh /editor bounces me to +// the processor" bug). Router-relative, matching what Login reads via `?next=`. +function redirectToLogin(): void { + const loginPath = withBasePath("/login"); + // Already on the login page: another redirect would just loop. + if (window.location.pathname === loginPath) return; + const returnPath = + stripBasePath(window.location.pathname) + window.location.search; + window.location.href = isSafePostLoginRedirect(returnPath) + ? `${loginPath}?next=${encodeURIComponent(returnPath)}` + : loginPath; +} + // Response interceptor for handling token refresh apiClient.interceptors.response.use( (response) => response, @@ -173,7 +189,7 @@ apiClient.interceptors.response.use( // The session genuinely can't be recovered. Send protected requests // to login; public ones just fail quietly (no redirect). if (!isPublicEndpoint) { - window.location.href = withBasePath("/login"); + redirectToLogin(); } return Promise.reject(error); @@ -194,10 +210,7 @@ apiClient.interceptors.response.use( console.debug( "[API Client] No session to refresh, 401 on protected endpoint", ); - const loginPath = withBasePath("/login"); - if (window.location.pathname !== loginPath) { - window.location.href = loginPath; - } + redirectToLogin(); return Promise.reject(error); } } catch (refreshError) { From 658aa54c20446f9f1188f59cf35fb82fa86bc81e Mon Sep 17 00:00:00 2001 From: James Brunton Date: Fri, 28 Aug 2026 10:35:41 +0000 Subject: [PATCH 12/27] Update tool models to fix main (#7725) # Description of Changes When #6697 merged, the CI didn't run for some reason so it was never caught that the tool models were out of date. This PR updates them to the correct state. --- engine/src/stirling/models/tool_models.py | 3 +++ frontend/editor/src/core/types/toolApiTypes.ts | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/engine/src/stirling/models/tool_models.py b/engine/src/stirling/models/tool_models.py index 6650942b6f..dff977a7f7 100644 --- a/engine/src/stirling/models/tool_models.py +++ b/engine/src/stirling/models/tool_models.py @@ -725,6 +725,9 @@ class OcrPdfParams(ApiModel): ) ocr_type: OcrType = Field(..., description="Specify the OCR type, e.g., 'skip-text', 'force-ocr', or 'Normal'") remove_images_after: bool | None = Field(None, description="Remove images from the output PDF if set to true") + rotate_pages: bool | None = Field( + None, description="Auto-correct page orientation (90/180/270) using Tesseract OSD if set to true" + ) sidecar: bool | None = Field(None, description="Include OCR text in a sidecar text file if set to true") diff --git a/frontend/editor/src/core/types/toolApiTypes.ts b/frontend/editor/src/core/types/toolApiTypes.ts index 55938d231e..d980029624 100644 --- a/frontend/editor/src/core/types/toolApiTypes.ts +++ b/frontend/editor/src/core/types/toolApiTypes.ts @@ -1022,6 +1022,10 @@ export interface ProcessPdfWithOcrRequest { * Remove images from the output PDF if set to true */ removeImagesAfter?: boolean; + /** + * Auto-correct page orientation (90/180/270) using Tesseract OSD if set to true + */ + rotatePages?: boolean; /** * Include OCR text in a sidecar text file if set to true */ From 4ab2505a6c8fe2ed469576b6d4e93d7565b2567a Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:56:50 +0000 Subject: [PATCH 13/27] Comment-quality standard, and the gate that enforces it (#7663) ## The problem AI PRs write comments that restate the line below them, mark sections with box drawing, and narrate the diff. Nothing in the repo said not to, and nothing checked. `AGENTS.md` had one line about comments and it was buried in the Python section. Banners and `Step N:` narration have zero occurrences in the 15 months before Aug 2025, so this is new. ## The fix A written standard, plus a linter that enforces the mechanical part of it on added lines only. - [devGuide/CODE_COMMENTS.md](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/devGuide/CODE_COMMENTS.md) holds the reasoning and worked examples; a section in [AGENTS.md](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/AGENTS.md) holds the operative rules, kept short so they stay in an agent's context. The two are split by kind rather than duplicated, because the same prose in two places drifts. - Rules in [comment-rules.mjs](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs), shared by both engines. - Two engines. `.ts` / `.tsx` / `.mjs` go to an [oxlint JS plugin](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-lint-oxlint-plugin.mjs) so comments come from the parser rather than a line scan; `.java` / `.py` go to a [line scanner](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-lint.mjs). Neither reads the other's files, so they cannot disagree about one file. - Between them they read every comment form the repo writes: `//` and `/* */`, Javadoc and JSDoc, JSX comments, `#`, and Python docstrings. - Runs in `task pre-commit`, so the git hook and the `pre_commit.yml` CI job both get it, and as a Claude Code [`Stop` hook](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-lint-hook.mjs) so an agent fixes the comment inside the turn that wrote it. ## The rules The part worth arguing about. **Every rule blocks.** A rule that only warns is a rule nobody acts on, so a finding you believe is wrong is a bug in the rule: narrow it, or mark the line and say why. | | Fires on | | --- | --- | | [CMT001](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L71) | Every word in the comment already appears in the code below it. Max 6 words, skipped for prose punctuation and for a bare Arrange/Act/Assert marker | | [CMT002](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L92) | 4+ rule or box-drawing characters, or a bare section label from [a fixed list](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L84) (`Types`, `Helpers`, `State`, `Handlers`, ...) | | [CMT003](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L110) | `Step N:` with a separator, or `Then,` / `Next,` / `Finally,`. Suppressed in test files | | [CMT004](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L129) | A comment about the code's own past: `this used to`, `renamed from`, `was previously called`. Suppressed in test files | | [CMT005](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L154) | 3+ consecutive comment lines where 2/3 [parse as code](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L143) | | [CMT006](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L31) | A run of implementation comment over 12 lines, outside the first 5 lines of a file. Doc blocks are exempt, because the standard asks for thorough contracts | | [CMT007](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L180) | A parameter or return description that adds no word its name lacks. Reads Javadoc/JSDoc `@param`, Sphinx `:param name:` and Google `name: description` | | [CMT008](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L239) | An allow directive naming a rule that does not exist, or one that silenced nothing | | [CMT009](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L219) | A `TODO` / `FIXME` / `HACK` naming no issue or link. An owner is not accepted: a username goes stale, an issue outlives it | Each rule carries the readings it deliberately excludes, next to the rule. Those exclusions came from running the rules over this repo, not from taste: `CMT004` does not match a bare "no longer needed" because that is as often about runtime lifecycle as about history, and `CMT003` needs a separator after the number so a wrapped line beginning "step 2 unmounts + remounts the panel" reads as the prose it is. A comment sharing a line with code is judged by the rules that do not depend on the code below it, so a trailing `// TODO fix this` or `/* this used to run before the flush */` still reports, while `50L * 1024 * 1024 // 50 MB` does not. `CMT001` would have been wrong about six in seven trailing comments here, so it stays out of them. If a finding is wrong, `// comment-lint-allow: CMT002` on the line above. Rule-specific, [no blanket disable](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L229). A directive naming a rule that does not exist, or silencing nothing, is itself a `CMT008` failure, so a typo cannot quietly disable a rule and a stale one gets deleted rather than accumulating. No native linter covers `CMT007`. `eslint-plugin-jsdoc`'s `require-param-description`, Checkstyle's `NonEmptyAtclauseDescription` and ruff's D-rules all check that a description exists, not whether it says anything. ## Scoping Added comment **text** only, not lines git calls new. Reindenting a file or moving a block makes git mark untouched comments as added; findings are matched against the comment text at the base, so only genuinely new content reports. The whole file is read and every comment in it evaluated. Only the *reporting* is filtered, so a rule still sees the code a comment introduces, the full run it belongs to, and the base version of the file. Existing tree is untouched. `task pre-commit:comment-lint:all` reports it and always exits 0: | | java | ts/js | py | | --- | --- | --- | --- | | findings | 1,218 | 741 | 204 | 2,163 across 542 files, mostly `CMT002` banners (1,482) and `CMT001` restatements (456). Clearing it is separate work, by directory. Not in this PR: an advisory LLM review layer for the things no pattern can judge. ## Verification Run against [#7494](https://github.com/Stirling-Tools/Stirling-PDF/pull/7494) as CI would, in a throwaway worktree: **two findings on a 78 file, +4,512 line change, both genuine banners, in 952ms**. A whole-file scan of those same files gives 11; the other 9 were withheld because that PR's author did not write them, and they are the `@param teamId the team ID` shape this standard exists to stop. Both scanners blank string and character literals before looking for comment markers, because a partial lex desynchronises everything after it: one apostrophe in a Java comment, or one Python template whose closing quotes start a line, is enough to read dozens of lines of code as a single comment. Two fixtures carry canaries that stop being reported if either engine ever desynchronises again. The [fixture corpus](https://github.com/Stirling-Tools/Stirling-PDF/tree/claude/ai-pr-comment-quality-dd970e/scripts/lint/fixtures) pins all 9 rules against both engines, and `--selftest` fails if the two disagree about the same file. ## Two things reviewers should know **The oxlint JS plugin API is alpha.** oxlint itself is stable and already this repo's frontend linter; the plugin API is the new dependency. Its documented failure mode ([oxc#25203](https://github.com/oxc-project/oxc/issues/25203)) is being skipped silently while oxlint still reports success. That affects the standalone release binary rather than the npm package this invokes, but the class of failure reads exactly like clean code, so the run asserts `number_of_rules >= 1` from oxlint's own report and a broken engine exits 2 rather than passing. If the API ever breaks, the fallback is folding these rules into the line scanner, which already implements all nine for Java and Python. **`.claude/settings.json` is now committed**, carrying the hook and nothing else: 19 lines, no `permissions`, nothing machine-specific. That partly reverts `c35546a212` ("Ignore claude dir"), which existed because this file had twice been committed by accident with a personal `permissions` allowlist, once with absolute machine paths. Personal config still belongs in `.claude/settings.local.json`, which the new pattern keeps ignored, and hook entries merge across the two so nobody's own hooks are lost. If you already hand-wrote a `.claude/settings.json`, copy it somewhere first: that path used to be git-ignored, and git overwrites an ignored file without warning when a commit starts tracking it. Across 19 local checkouts here, 13 have `settings.local.json` and none has a hand-written `settings.json`. To turn the hook off, `{ "env": { "COMMENT_LINT_HOOK": "0" } }` in local settings. Claude Code can only disable all hooks at once, hence the switch. The commit-time gate still applies. ## How to test ```bash task pre-commit:comment-lint:ci ``` The fixture corpus, then the diff. The corpus checks the rules themselves rather than the code under review, so it runs on CI and before a rule change, not on every local commit. ```bash task comment-lint:branch ``` `clean (34 files in scope)`. `task comment-lint` is the same thing scoped to uncommitted work, which is what the git hook and CI run. To watch it bite, add `// Is banner` above `export function isBanner` in `scripts/lint/comment-rules.mjs` and run `task comment-lint`: one `CMT001`, exit 1. The gate covers its own source, which is why these scripts have no section dividers. ```bash task pre-commit:comment-lint:all ``` The standing backlog, report-only. Verified on the pinned oxlint 1.77.0, not only the 1.79 the plugin was prototyped against. --- .claude/settings.json | 19 + .github/pull_request_template.md | 1 + .github/workflows/pre_commit.yml | 5 + .gitignore | 9 +- .taskfiles/pre-commit.yml | 82 +++ AGENTS.md | 39 +- CONTRIBUTING.md | 1 + Taskfile.yml | 14 + devGuide/CODE_COMMENTS.md | 232 ++++++ devGuide/README.md | 1 + frontend/oxlint.comments.config.ts | 31 + scripts/lint/comment-lint-hook.mjs | 119 ++++ scripts/lint/comment-lint-oxlint-plugin.mjs | 126 ++++ scripts/lint/comment-lint.mjs | 743 ++++++++++++++++++++ scripts/lint/comment-rules.mjs | 500 +++++++++++++ scripts/lint/fixtures/AaaTest.java | 19 + scripts/lint/fixtures/README.md | 17 + scripts/lint/fixtures/allow.java | 32 + scripts/lint/fixtures/apostrophes.java | 23 + scripts/lint/fixtures/clean.java | 26 + scripts/lint/fixtures/clean.ts | 17 + scripts/lint/fixtures/deadcode.java | 14 + scripts/lint/fixtures/docs.java | 12 + scripts/lint/fixtures/docstrings.py | 36 + scripts/lint/fixtures/expected.json | 63 ++ scripts/lint/fixtures/narration.java | 22 + scripts/lint/fixtures/narration.tsx | 17 + scripts/lint/fixtures/restates.java | 24 + scripts/lint/fixtures/restates.py | 9 + scripts/lint/fixtures/restates.ts | 20 + scripts/lint/fixtures/strings.java | 11 + scripts/lint/fixtures/templates.py | 24 + scripts/lint/fixtures/todos.java | 25 + scripts/lint/fixtures/trailing.java | 22 + 34 files changed, 2352 insertions(+), 3 deletions(-) create mode 100644 .claude/settings.json create mode 100644 devGuide/CODE_COMMENTS.md create mode 100644 frontend/oxlint.comments.config.ts create mode 100644 scripts/lint/comment-lint-hook.mjs create mode 100644 scripts/lint/comment-lint-oxlint-plugin.mjs create mode 100644 scripts/lint/comment-lint.mjs create mode 100644 scripts/lint/comment-rules.mjs create mode 100644 scripts/lint/fixtures/AaaTest.java create mode 100644 scripts/lint/fixtures/README.md create mode 100644 scripts/lint/fixtures/allow.java create mode 100644 scripts/lint/fixtures/apostrophes.java create mode 100644 scripts/lint/fixtures/clean.java create mode 100644 scripts/lint/fixtures/clean.ts create mode 100644 scripts/lint/fixtures/deadcode.java create mode 100644 scripts/lint/fixtures/docs.java create mode 100644 scripts/lint/fixtures/docstrings.py create mode 100644 scripts/lint/fixtures/expected.json create mode 100644 scripts/lint/fixtures/narration.java create mode 100644 scripts/lint/fixtures/narration.tsx create mode 100644 scripts/lint/fixtures/restates.java create mode 100644 scripts/lint/fixtures/restates.py create mode 100644 scripts/lint/fixtures/restates.ts create mode 100644 scripts/lint/fixtures/strings.java create mode 100644 scripts/lint/fixtures/templates.py create mode 100644 scripts/lint/fixtures/todos.java create mode 100644 scripts/lint/fixtures/trailing.java diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000000..40d0b73379 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-settings.json", + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "node", + "args": [ + "${CLAUDE_PROJECT_DIR}/scripts/lint/comment-lint-hook.mjs" + ], + "timeout": 60 + } + ] + } + ] + } +} diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index d9eb6dbe10..41666ff608 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -20,6 +20,7 @@ Closes #(issue_number) - [ ] 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 +- [ ] Every comment I added says something the code does not ([guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/CODE_COMMENTS.md)) - [ ] My changes generate no new warnings ### Documentation diff --git a/.github/workflows/pre_commit.yml b/.github/workflows/pre_commit.yml index 674822363b..3566f5864f 100644 --- a/.github/workflows/pre_commit.yml +++ b/.github/workflows/pre_commit.yml @@ -38,3 +38,8 @@ jobs: - name: Run pre-commit checks run: task pre-commit + + # The fixture corpus checks the comment rules themselves, so it runs here + # rather than on every local commit. + - name: Check the comment-lint fixture corpus + run: task pre-commit:comment-lint:selftest diff --git a/.gitignore b/.gitignore index 1290056e05..ca683dc5d4 100644 --- a/.gitignore +++ b/.gitignore @@ -298,8 +298,13 @@ docs/type3/signatures/ **/application-dev-local.properties -# Claude -.claude/ +# Claude. Contents are ignored so personal config stays local, with the two +# shared pieces re-included: settings.json (the comment-lint hook) and skills/. +# The directory itself cannot be ignored or git will not look inside it. +.claude/* +!.claude/settings.json +!.claude/skills/ +.claude/settings.local.json # Playwright MCP screenshots / traces .playwright-mcp/ diff --git a/.taskfiles/pre-commit.yml b/.taskfiles/pre-commit.yml index 444115010f..f65a296160 100644 --- a/.taskfiles/pre-commit.yml +++ b/.taskfiles/pre-commit.yml @@ -11,6 +11,7 @@ vars: '.github/scripts/*.py' 'app/core/src/main/resources/static/python/*.py' ':(exclude)*split_photos.py' + ':(exclude)scripts/lint/fixtures/*' SPELL_FILES: >- '*.html' '*.css' @@ -59,6 +60,7 @@ tasks: - task: gitleaks - task: whitespace - task: toml-sort + - task: comment-lint fix: desc: "Auto-fix formatting, spelling, and secrets issues across the repo" @@ -75,6 +77,7 @@ tasks: vars: { FIX: '1' } - task: codespell - task: gitleaks + - task: comment-lint install: desc: "Install the pinned pre-commit Python tools" @@ -130,6 +133,85 @@ tasks: cmds: - "{{.GITLEAKS_BIN}} git --pre-commit --redact --staged --verbose" + comment-lint: + desc: "Check comment quality on the lines this branch adds" + summary: | + Blocks a comment that restates the code below it, a section banner, or a + block of commented-out code. Everything else it reports is advisory. + + Scoped to added lines, so touching an old file never surfaces the standing + backlog. The standard is devGuide/CODE_COMMENTS.md. + + With no arguments it diffs the working tree against HEAD, which is what a + pre-commit run wants: the lines you are about to commit. On a CI pull request + it diffs against the target branch instead, via GITHUB_BASE_REF. + + To ask what a whole branch adds instead, use the branch variant, which + needs no argument passing: + task comment-lint:branch + + Full tree (report only): task pre-commit:comment-lint:all + Fixture corpus: task pre-commit:comment-lint:selftest + # Depends on the frontend install because the .ts/.tsx half of the rule set + # runs as an oxlint plugin. Without it the TS engine warns and skips, which + # would leave the frontend silently unchecked on CI. + deps: [":frontend:install"] + cmds: + - node scripts/lint/comment-lint.mjs {{.CLI_ARGS}} + + comment-lint:branch: + desc: "Check comment quality on everything this branch adds over its base" + summary: | + Like `task comment-lint`, but scoped to the whole branch rather than to + uncommitted work, so it still reports after you commit. + + Exists as its own task because passing `-- --since origin/main` through Task + is not portable: with the npm build of Task the launcher is a PowerShell + script, and PowerShell strips the `--` before Task sees it, leaving Task to + print its own usage. + + Override the base with BASE=. + vars: + BASE: '{{.BASE | default "origin/main"}}' + deps: [":frontend:install"] + cmds: + - node scripts/lint/comment-lint.mjs --since {{.BASE}} + + comment-lint:ci: + desc: "Comment gate as CI runs it: fixture corpus, then the diff" + summary: | + The corpus checks the rules themselves rather than the code under review, so + it belongs on CI and not on every local commit. Run this before changing a + rule, and let CI run it on every pull request. + deps: [":frontend:install"] + cmds: + - node scripts/lint/comment-lint.mjs --selftest + - node scripts/lint/comment-lint.mjs {{.CLI_ARGS}} + + comment-lint:hook: + desc: "Comment gate for the editor hook: everything this turn changed" + summary: | + Same scope as `task comment-lint`, kept as its own name so the hook has a + stable entry point and the taskfile shows every way the linter is invoked. + + Not in the frontend-install dependency chain on purpose: this runs at the end + of every turn, so it stays as short as it can be. If oxlint is missing the TS + half warns and skips. + cmds: + - node scripts/lint/comment-lint.mjs + + comment-lint:all: + desc: "Report every comment finding in the tree (never fails)" + deps: [":frontend:install"] + cmds: + - node scripts/lint/comment-lint.mjs --all + + comment-lint:selftest: + desc: "Check both comment-lint engines against the fixture corpus" + deps: [":frontend:install"] + cmds: + - node scripts/lint/comment-lint.mjs --selftest + gitleaks-bin: internal: true desc: "Ensure the pinned, checksum-verified gitleaks binary is cached in .task/bin" diff --git a/AGENTS.md b/AGENTS.md index 7383ab3c3d..88543ca3c8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,6 +21,43 @@ Task `desc:` fields should describe **what** the task does, not **how** it does - `task docker:build` — build standard Docker image - `task docker:up` — start Docker compose stack +## Comments + +A comment must carry information the code cannot. If a reader could derive it from the code in front of them, delete it. + +Comment the current state. Not what the code used to do, not what changed, not why it changed: git holds that. Where history explains the shape, state the reason instead, so "this used to reimplement the modal internals" becomes "thin wrapper over the shared Modal: duplicating its portal and focus trap is how dialogs drift apart". Future state goes in a TODO with an issue. + +Write a comment when it does one of these four jobs: + +- **Contract.** What a caller must know that the signature cannot say: preconditions, invariants, units, ownership and lifetime, thread-safety, error semantics, side effects. Document the contract of everything a caller outside the file can reach, and nothing else. Goes on the type/method/module as Javadoc, JSDoc, or a docstring. +- **Why.** The constraint the code satisfies, the bug it avoids, the alternative rejected and the reason. +- **Hazard.** "Must stay in sync with X", "order matters because Y", "do not remove, it prevents Z". +- **Map.** A short orientation at the top of a genuinely complex file: what it owns, and what it deliberately does not. + +Never write: + +- A comment that restates the next line. `// Handle drag start` above `handleDragStart` is noise. +- Section banners or position markers: `// --- Types ---`, `// Helpers`, `// =====`. +- Step narration in a function body (`// Step 1:`, `// Then we`). If the steps need labels they need names: extract functions. Numbering a genuinely numbered thing, like a wizard step, is fine. +- Commented-out code. Delete it. +- Doc tags that restate the signature. `@param blob - The blob` says nothing; omit the tag rather than pad it. +- Docs on self-explanatory members with no constraint to state. + +Two tests before keeping a comment: + +- **Delete it.** Is any information lost? If not, it stays deleted. +- **Could a name carry it instead?** A better identifier, an extracted function, or a named constant beats a comment. Prefer the code change. + +A comment at the end of a line usually decodes that line, and that is worth keeping: `{0x25, 0x50} // "%PDF"`, `50L * 1024 * 1024 // 50 MB`. The rules that compare a comment against the code below it do not apply there, but a trailing TODO or a trailing bit of history is judged like any other. + +A reference is supplementary, never load-bearing: the comment must survive deleting it. `// See #1234` is a dead end; `// saving first loses every annotation (#6865)` is not. Prefer a spec (`RFC 3161`) or CVE where one applies. + +A TODO needs an issue, not an owner: `// TODO(#1234): re-enable the gate once account syncing lands`. If it is not worth an issue, it is not worth a TODO. A question is not a TODO. + +A comment block over ~12 lines outside a file or type header usually means the code needs restructuring, or that the prose is product documentation and belongs in the docs repo. + +`task comment-lint` checks the mechanical part of this on the lines you add, and runs inside `task pre-commit`. Reasoning, worked examples and the linter's own rules: @devGuide/CODE_COMMENTS.md + ## Common Development Commands ### Build and Test @@ -70,7 +107,7 @@ The project structure is defined in `engine/pyproject.toml`. Any new dependencie - Avoid nested functions and nested classes unless the language construct requires them. - Prefer composition to inheritance when combining concepts. - Avoid speculative abstractions. Add a layer only when it removes real duplication or clarifies lifecycle. -- Add comments sparingly and only when they explain non-obvious intent. +- Comments follow the repo-wide rules in the "Comments" section above. #### Python Typing and Models - Deserialize into Pydantic models as early as possible. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 65fc4bc262..371630193b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,6 +42,7 @@ Please make sure your Pull Request adheres to the following guidelines: - Keep commits atomic. One commit should contain one change. If you want to make multiple changes, submit multiple Pull Requests. - Commits should be clear, concise, and easy to understand. - References to the Issue number in the Pull Request and/or Commit message. +- Every comment in the diff should say something the code does not. See [Code comments](devGuide/CODE_COMMENTS.md); `task comment-lint` checks the mechanical part. ## Translations diff --git a/Taskfile.yml b/Taskfile.yml index e364242f1f..eaec7dec92 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -266,6 +266,20 @@ tasks: cmds: - task: frontend:lint - task: engine:lint + - task: comment-lint + + comment-lint: + desc: "Check comment quality on the lines this branch adds" + aliases: [comments] + cmds: + - task: pre-commit:comment-lint + vars: { CLI_ARGS: '{{.CLI_ARGS}}' } + + comment-lint:branch: + desc: "Check comment quality on everything this branch adds over its base" + cmds: + - task: pre-commit:comment-lint:branch + vars: { BASE: '{{.BASE}}' } fix: desc: "Auto-fix all components" diff --git a/devGuide/CODE_COMMENTS.md b/devGuide/CODE_COMMENTS.md new file mode 100644 index 0000000000..739744142f --- /dev/null +++ b/devGuide/CODE_COMMENTS.md @@ -0,0 +1,232 @@ +# Code comments + +A comment must carry information the code cannot. If a reader could derive it from +the code in front of them, delete it: a redundant comment still has to be +maintained, will eventually contradict the code, and dilutes the comments that +matter. + +The operative rules are in `AGENTS.md`, kept short so they stay in an agent's +context. This document is the reasoning and the worked examples behind them, plus +how to run the linter. + +## Comment the current state + +Describe the code as it is. Not what it used to be, not what changed, not why it +changed. A comment that narrates history is stale the moment the next change +lands, and git already holds that record. + +When you know the history and it explains the shape of the code, the useful half is +the reason, not the sequence. State the reason: + +```java +// Don't: +// This used to reimplement the modal internals, which is how the procurement +// dialogs drifted from the billing ones. + +// Do: +// Thin wrapper over the shared Modal: duplicating its portal and focus trap is +// how dialogs drift apart. +``` + +Future state is the exception, and it belongs in a TODO with an issue. + +## The four jobs + +**Contract.** What a caller must know that the signature cannot say: +preconditions, invariants, units, ownership and lifetime, thread-safety, error +semantics, side effects. + +The bound is the surface, not the volume: document the contract of everything a +caller outside the file can reach, and nothing else. Inside that surface say +whatever a caller needs; outside it a comment earns its place on the same terms as +any other. + +```java +/** + * Authority on which filesystem locations a policy may read or write. Fail-closed + * in order: denied entirely under the saas profile; Stirling's own config dir is + * always rejected; the path must resolve within policies.allowedFolderRoots. + * + *

Compared after normalisation so {@code ..} cannot escape a root. Symlink + * escape is not defended: an operator who roots an allowlist on a symlink to a + * sensitive location is trusted. + */ +``` + +**Why.** The constraint the code satisfies, the bug it avoids, the alternative +rejected and the reason. + +```java +// whenComplete runs on the worker thread after the run finishes, so the +// terminal event never races the step events. +handle.completion() +``` + +A reference is supplementary, never load-bearing: the comment must survive +deleting it. `// See #1234` is a dead end. + +```java +// flatten() reads the annotation list that save() clears, so saving first loses +// every annotation (#6865). +document.flatten(annotations); +``` + +Prefer a spec (`RFC 3161`, `ISO 4217`) or a CVE where one applies. Both are +immutable; a ticket can be closed, moved or made private. + +**Hazard.** "Must stay in sync with X." "Order matters because Y." "Do not remove, +it prevents Z." + +**Map.** A short orientation at the head of a genuinely complex file: what it owns, +and what it deliberately does not. + +## The test that decides it + +A comment earns its place when it sits at a different level of detail than the line +below it: lower, stating a precise fact the code implies but does not say, or +higher, giving intent a reader would otherwise assemble from ten lines. +Same-altitude is the definition of redundant. + +- **Delete it.** Is any information lost? If not, it stays deleted. +- **Could a name carry it instead?** A better identifier, an extracted function or + a named constant beats a comment. Prefer the code change. + +## What not to write + +| Don't | Instead | +| --- | --- | +| `// Handle drag start` above `handleDragStart` | Nothing. The name already says it. | +| `// ─── Types ───`, `// Helpers`, `// ====` | If a file needs internal signposting, split the file. | +| `// Step 1:` narrating a function body | Extract functions. If the steps need labels they need names. | +| `// No longer needed`, `// Previously this used X` | State why the code is as it is now, or nothing. | +| Commented-out code | Delete it. Git remembers. | +| `@param blob - The blob to download` | Omit the tag rather than pad it. | +| Docs on a self-explanatory member | Nothing, unless there is a real constraint to state. | + +Step numbering is fine where it labels a genuinely numbered thing, such as a wizard +step or a step in a written test procedure. It is narration when it numbers the +lines of one function. + +## Comments at the end of a line + +A trailing comment usually does a different job from one above the code: it decodes +the line it sits on. Those are worth keeping, and the linter leaves them alone. + +```java +byte[] pdfBytes = {0x25, 0x50, 0x44, 0x46}; // "%PDF" +long maxAttachmentSize = 50L * 1024 * 1024; // 50 MB +double buffer = 0.10; // 10% headroom +default -> toBytes(value, 2); // MB +``` + +Each overlaps in words with the code and each adds the interpretation the code +leaves implicit, which is the lower-altitude case the test above asks for. So +`CMT001` does not judge trailing comments; on this codebase it would have been +wrong about roughly six in seven of them. + +What still applies is anything that does not depend on the code below: a trailing +`// TODO fix this` is as unowned as one on its own line, and a trailing +`// this used to run before the flush` narrates history wherever it sits. + +A comment block over about 12 lines, outside a file or type header, is usually a +sign the code needs restructuring. If it is genuinely product documentation, it +belongs in the docs repo. + +## TODOs + +A TODO needs an issue, because an issue is the only part that will close it: + +```java +// TODO(#1234): re-enable the checkout gate once account syncing lands +``` + +An owner is not a substitute: a username goes stale when someone changes team and +means nothing to an outside contributor. If the work is not worth an issue it is +not worth a TODO, and the options are to do it now or leave the code alone. A +question is not a TODO. + +## Per language + +**Java.** Google Java Style, which this repo already formats to. Its §7.3.1 +exception applies: omit Javadoc on a self-explanatory member where there is +genuinely nothing to add, but do not cite it to skip something a reader needs. +Summary fragments are noun or verb phrases, not sentences starting "This method +returns". + +**TypeScript.** JSDoc on the `@app/*` seams, exported hooks, and anything crossing +a layer boundary. No `@param`/`@returns` that restates a typed signature. JSX +comments follow the same rules as any other. + +**Python.** Docstrings on modules, public functions and Pydantic models where the +contract is not obvious from the type. + +## The linter + +```bash +task comment-lint # what the working tree adds over HEAD +task comment-lint:branch # what the branch adds over origin/main (BASE= to change) +task pre-commit:comment-lint:ci # the fixture corpus, then the diff +``` + +`comment-lint` is the pre-commit question, so it reports nothing once you have +committed; on a CI pull request it compares against the target branch via +`GITHUB_BASE_REF`. `comment-lint:branch` is the review question. The corpus checks +the rules themselves rather than the code under review, so it runs on CI and before +a rule change, not on every local commit. + +`task comment-lint` also runs inside `task pre-commit`, and as a Claude Code `Stop` +hook, so an agent is told before it finishes a turn and fixes the comment inside +that turn. Stop rather than per file write: a run costs the same for one file as for +twenty-five, and half of all writes in a turn go to a file already written in it. + +Findings are scoped to comment text that is new, not to lines git calls new, so +reindenting or moving code does not resurface comments you did not write. + +The rules are the `RULES` object in +[`scripts/lint/comment-rules.mjs`](../scripts/lint/comment-rules.mjs); the exact +condition for each is the predicate of the same name in that file, with the +readings it deliberately excludes beside it. + +**Every rule blocks.** A rule that only warns is a rule nobody acts on. So a +finding you believe is wrong is a bug in the rule, not something to live with: +narrow the rule, or mark the line and say why. + +Every comment form the repo writes is covered: `//` and `/* */`, Javadoc and JSDoc, +JSX comments, `#`, and Python docstrings. `CMT007` reads all three parameter +conventions in use here, Javadoc/JSDoc `@param`, Sphinx `:param name:` and Google +`name: description` under `Args:`. + +Two engines, one rule set. `.ts`/`.tsx`/`.mjs` go to an oxlint JS plugin, so +comments come from the parser: a `//` inside a string is not a comment, and JSX +`{/* … */}` is. `.java`/`.py` go to a line scanner. Neither reads the other's +files, so they cannot disagree about one file. `scripts/lint/fixtures/` is the +corpus that keeps them meaning the same thing. + +### When a finding is wrong + +Name the rule on the line above: + +```ts +// comment-lint-allow: CMT002 +// ─── kept deliberately, because ─── +``` + +There is no form that disables every rule, and the directive has to earn its +place. `CMT008` reports one that names something which is not a rule, and one that +silences nothing, so a typo does not read as a suppression and a stale +suppression does not sit there blinding the line. The whole comment must be the +directive; prose that mentions the syntax is just prose. + +If you reach for this more than occasionally the rule is wrong: fix it in +`comment-rules.mjs` and update the fixture corpus in the same commit, so the diff +shows what moved. + +### The existing backlog + +`task pre-commit:comment-lint:all` reports the whole tree and never fails. There is +a standing backlog being cleared by directory; diff scoping is what keeps it off +whoever touches a file first. + +To turn the editor hook off, put `{ "env": { "COMMENT_LINT_HOOK": "0" } }` in +`.claude/settings.local.json`. The commit-time gate still applies, so you lose the +early warning rather than the check. diff --git a/devGuide/README.md b/devGuide/README.md index 5e8486f013..2ddaff63c3 100644 --- a/devGuide/README.md +++ b/devGuide/README.md @@ -8,6 +8,7 @@ This directory contains all development-related documentation for Stirling PDF. - **[DeveloperGuide.md](../DeveloperGuide.md)** - Main developer setup and architecture guide (in repo root) - **[Taskfile.yml](../Taskfile.yml)** - Unified task runner for all build/dev/test/lint commands - **[EXCEPTION_HANDLING_GUIDE.md](./EXCEPTION_HANDLING_GUIDE.md)** - Exception handling patterns and i18n best practices +- **[CODE_COMMENTS.md](./CODE_COMMENTS.md)** - What a comment is for, what not to write, and the `task comment-lint` rules - **[HowToAddNewLanguage.md](./HowToAddNewLanguage.md)** - Internationalization and translation guide - **[STORAGE_ENCRYPTION_AT_REST.md](./STORAGE_ENCRYPTION_AT_REST.md)** - Encryption at rest for stored files: key setup, migration, revocation, rotation diff --git a/frontend/oxlint.comments.config.ts b/frontend/oxlint.comments.config.ts new file mode 100644 index 0000000000..a3a238893c --- /dev/null +++ b/frontend/oxlint.comments.config.ts @@ -0,0 +1,31 @@ +import { defineConfig } from "oxlint"; + +// Comment-quality rules only, kept out of oxlint.config.ts on purpose. +// +// The main config is run with --max-warnings=0 over the whole frontend, and the +// existing tree still has several hundred findings from these rules. Enabling +// them there would fail every build until the cleanup lands. So they live here +// and are driven by `task comment-lint`, which scopes findings to the lines a +// branch actually added. +// +// Fold this into oxlint.config.ts once the tree is clean; that is the step that +// also buys IDE squiggles, and the point at which this file goes away. + +export default defineConfig({ + jsPlugins: ["../scripts/lint/comment-lint-oxlint-plugin.mjs"], + categories: { correctness: "off" }, + ignorePatterns: [ + "dist", + "dist-portal", + "node_modules", + "playwright-report", + "storybook-static", + "test-results", + "editor/dist", + "editor/public", + "editor/src-tauri", + ], + rules: { + "comments/quality": "error", + }, +}); diff --git a/scripts/lint/comment-lint-hook.mjs b/scripts/lint/comment-lint-hook.mjs new file mode 100644 index 0000000000..af573c6a91 --- /dev/null +++ b/scripts/lint/comment-lint-hook.mjs @@ -0,0 +1,119 @@ +#!/usr/bin/env node +// Claude Code Stop hook: check the comments this turn wrote, before it ends. +// +// Wired up by .claude/settings.json. Exit 2 stops Claude from finishing and shows +// stderr to it, so the comment is fixed inside the same turn and never reaches a +// diff, a CI run, or a reviewer. +// +// Stop rather than PostToolUse, measured over 605 real turns: a run costs the same +// whether it looks at one file or twenty-five, because node startup and one git +// diff dominate and the TS engine is spawned once for the batch. Per write it was +// 40 minutes of hook latency across those turns, and up to 35 seconds inside a +// single heavy one; per turn it is under a second, flat. Half of all writes were +// to a file already written that turn, so most of that work was repeated. +// +// To turn it off, set COMMENT_LINT_HOOK=0. Claude Code has no way to disable one +// hook (only disableAllHooks, which turns off everyone's), so the opt-out lives +// here instead. Per developer, in .claude/settings.local.json: +// +// { "env": { "COMMENT_LINT_HOOK": "0" } } +// +// The commit-time gate still applies either way, so opting out costs you the +// early warning, not the check. + +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO = resolve(HERE, "..", ".."); + +// Invoked through Task so the taskfile stays the one place that defines how the +// linter is called. +const TASK_NAME = "pre-commit:comment-lint:hook"; +const OFF = new Set(["0", "off", "false", "no"]); + +// The linter's own exit codes: 1 when it found something, 2 when its engine could +// not run. The hook's codes mean different things, so they are mapped explicitly. +const FOUND = 1; +const ENGINE_BROKEN = 2; + +if (OFF.has((process.env.COMMENT_LINT_HOOK ?? "").toLowerCase())) process.exit(0); + +const payload = readStdin(); + +// Blocking the stop puts Claude back to work, which ends in another stop and +// another chance to block. Claude Code sets this flag once a Stop hook has +// already blocked in this turn, so a rule the agent cannot satisfy costs one +// extra attempt rather than looping. The commit gate still catches whatever +// survives. +if (payload?.stop_hook_active) process.exit(0); + +const result = run(); +if (result.status === 0) process.exit(0); + +if (result.status === ENGINE_BROKEN) { + process.stderr.write("comment-lint could not run, so comments in this turn were not checked.\n"); + process.exit(1); +} + +// Anything else is Task itself failing, which means the check did not happen. +if (result.status !== FOUND) { + process.stderr.write(`comment-lint did not run (task exit ${result.status}), so comments in this turn were not checked.\n`); + process.exit(1); +} + +// The linter's own report already names the file, line, rule and the standard, so +// it is passed through rather than rewritten. +process.stderr.write(`${result.output.trim()}\n\nFix these before finishing.\n`); +process.exit(2); + +function readStdin() { + try { + return JSON.parse(readFileSync(0, "utf8")); + } catch { + return null; + } +} + +// `task` on PATH is a shell wrapper that boots Node to launch the Go binary the +// npm package already ships, which costs about 400ms. Prefer the binary; fall +// back to the wrapper when the layout is not one of the ones probed, or when Task +// came from somewhere else entirely. +function taskCommand() { + const nodeDir = dirname(process.execPath); + const exe = process.platform === "win32" ? "task.exe" : "task"; + const candidates = [ + resolve(nodeDir, "node_modules/@go-task/cli/bin", exe), + resolve(nodeDir, "../lib/node_modules/@go-task/cli/bin", exe), + resolve(REPO, "node_modules/@go-task/cli/bin", exe), + ]; + for (const candidate of candidates) { + if (existsSync(candidate)) return { command: candidate, shell: false }; + } + return { command: process.platform === "win32" ? "task.cmd" : "task", shell: process.platform === "win32" }; +} + +function run() { + const { command, shell } = taskCommand(); + try { + // --output=interleaved because the root Taskfile sets `output: prefixed`, + // which would put the task name in front of every reported finding. + // --exit-code because Task otherwise reports its own 201 for any failed task, + // which hides whether the linter found something or could not run. + // Task's stderr is captured rather than inherited: it announces its own + // "Failed to run task" for any non-zero command, which would reach Claude + // alongside the findings and read as a tooling error. + const output = execFileSync(command, [TASK_NAME, "--silent", "--output=interleaved", "--exit-code"], { + cwd: REPO, + encoding: "utf8", + maxBuffer: 1 << 26, + shell, + stdio: ["ignore", "pipe", "pipe"], + }); + return { status: 0, output }; + } catch (error) { + return { status: error.status ?? -1, output: error.stdout ?? "" }; + } +} diff --git a/scripts/lint/comment-lint-oxlint-plugin.mjs b/scripts/lint/comment-lint-oxlint-plugin.mjs new file mode 100644 index 0000000000..045b23518b --- /dev/null +++ b/scripts/lint/comment-lint-oxlint-plugin.mjs @@ -0,0 +1,126 @@ +// oxlint JS plugin: the comment-quality rules for .ts and .tsx. +// +// This engine owns the frontend outright; comment-lint.mjs never scans TS. The +// reason to run oxlint here rather than scan lines is that comment tokens come +// from the parser, so a `//` inside a string or regex is not a comment, JSX +// `{/* … */}` is, and positions are exact. Rule decisions themselves live in +// comment-rules.mjs, shared with the Java/Python engine. +// +// Reported as one rule with the CMT id in the message, because oxlint config +// severity is per rule name and every finding here shares one on/off switch. +// +// Enabled by frontend/oxlint.comments.config.ts. `context.report` needs +// `node.range`; passing start/end throws. + +import { analyse, isTestPath, isGenerated, isExcludedPath, ruleLabel } from "./comment-rules.mjs"; + +const comments = { + create(context) { + return { + "Program:exit"() { + const sourceCode = context.sourceCode; + const filename = context.filename ?? context.getFilename?.() ?? ""; + if (isExcludedPath(filename)) return; + + const text = sourceCode.text; + if (isGenerated(text)) return; + + const lines = sourceCode.getLines(); + const tokens = sourceCode.getAllComments(); + if (tokens.length === 0) return; + + const runs = groupIntoRuns(tokens, lines); + const findings = analyse({ lines, runs, isTestFile: isTestPath(filename) }); + + // Ranges come from the run entries rather than the enclosing token, so a + // finding on the eighth line of a doc block points at that line instead + // of at the opening `/**`. + const ranges = new Map(); + for (const run of runs) { + for (const entry of run.lines) ranges.set(entry.line, entry.range); + } + + for (const finding of findings) { + context.report({ + message: `${ruleLabel(finding.rule)}: ${finding.detail}`, + node: { type: "Line", range: ranges.get(finding.line) ?? [0, 1] }, + }); + } + }, + }; + }, +}; + +// Adjacent comment lines with no code between them form one run, which is the +// unit the block-length and dead-code rules judge. A comment sharing its line +// with code is a trailing note, not part of any run. +function groupIntoRuns(tokens, lines) { + const runs = []; + let current = null; + + for (const token of tokens) { + const entries = expand(token, lines); + if (entries.length === 0) continue; + + const kind = token.type === "Line" ? "line" : token.value.startsWith("*") ? "doc" : "block"; + const startLine = entries[0].line; + const trailing = entries[0].trailing === true; + const contiguous = !trailing && current && startLine === current.endLine + 1 && current.kind === kind && !current.trailing; + + if (contiguous) { + current.lines.push(...entries); + current.endLine = entries[entries.length - 1].line; + continue; + } + current = { startLine, endLine: entries[entries.length - 1].line, kind, trailing, lines: entries }; + runs.push(current); + + // Code sits in front of a trailing comment, so nothing can continue it. + if (trailing) current = null; + } + + return runs; +} + +// One entry per physical line, with the leading `*` of a doc block stripped so +// the rules see the prose rather than the box drawing around it. Each entry +// carries its own source range so findings can be reported where they are. +function expand(token, lines) { + const start = token.loc.start.line; + const column = token.loc.start.column + 1; + const before = (lines[start - 1] ?? "").slice(0, token.loc.start.column).trim(); + + // Code in front of the comment makes it a trailing note. Marked rather than + // dropped, so CMT004 and CMT009 still see it: a TODO is a TODO wherever it + // sits. The rules that compare a comment against the code below it stay out, + // because a trailing comment usually decodes the line it sits on. + // + // A block comment counts as trailing only when it also closes on that line. + // One that runs on has its bulk on lines of its own, so it is judged as the + // block it is. + const sameLine = token.loc.start.line === token.loc.end.line; + const trailing = before.length > 0 && !before.startsWith("{") && (token.type === "Line" || sameLine); + + if (token.type === "Line") { + return [{ line: start, column, body: token.value, range: token.range, trailing }]; + } + + // token.value is the text between the delimiters, so it begins two chars in. + let offset = token.range[0] + 2; + return token.value.split("\n").map((raw, index) => { + const range = [offset, offset + Math.max(raw.length, 1)]; + offset += raw.length + 1; + return { + line: start + index, + column: index === 0 ? column : 1, + body: raw.replace(/^\s*\*+/, "").trim(), + range, + trailing, + }; + }); +} + +export default { + meta: { name: "comments" }, + rules: { quality: comments }, +}; diff --git a/scripts/lint/comment-lint.mjs b/scripts/lint/comment-lint.mjs new file mode 100644 index 0000000000..55b0cb4039 --- /dev/null +++ b/scripts/lint/comment-lint.mjs @@ -0,0 +1,743 @@ +#!/usr/bin/env node + +// comment-lint - the comment-quality gate. Standard: devGuide/CODE_COMMENTS.md +// +// Owns .java and engine .py directly, and delegates .ts/.tsx to oxlint (see +// comment-lint-oxlint-plugin.mjs) so the frontend is judged against real comment +// tokens rather than lines. Both paths share the rules in comment-rules.mjs, so +// a finding means the same thing whichever engine produced it. +// +// node scripts/lint/comment-lint.mjs default: everything this +// working tree adds over HEAD, +// or over the target branch on CI +// node scripts/lint/comment-lint.mjs --since main findings on lines this branch added +// node scripts/lint/comment-lint.mjs --all whole tree, report only, never fails +// node scripts/lint/comment-lint.mjs those files, every line +// node scripts/lint/comment-lint.mjs --selftest run the fixture corpus +// --quiet ...saying nothing unless it fails +// node scripts/lint/comment-lint.mjs --json machine-readable findings +// +// Exits non-zero for any finding on a line in scope: every rule blocks, because a +// warning is a finding nobody acts on. --all never fails, because the tree still +// has a backlog; it is the mode for working through it. + +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { dirname, isAbsolute, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + analyse, + commentBodiesOf, + normaliseComment, + isExcludedPath, + isGenerated, + isTestPath, + ruleLabel, + RULES, +} from "./comment-rules.mjs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO = resolve(HERE, "..", ".."); +const FRONTEND = join(REPO, "frontend"); + +// The Python this repo owns and formats: the engine service, plus the helper +// scripts that pre-commit already runs ruff over. Vendored and sample .py +// elsewhere in the tree is not ours to restyle. +const JAVA = /\.java$/; +const PYTHON = /^(engine|scripts|\.github\/scripts)\/.*\.py$/; + +// The oxlint engine parses plain JS as happily as TS, so the lint scripts and +// build tooling are held to the same rules as the app. +const TYPESCRIPT = /\.(tsx?|mts|cts|mjs|cjs|jsx?)$/; + +const FIXTURES_REL = "scripts/lint/fixtures/"; + +// oxlint rejects any path containing "..", so it is always run from the repo +// root and given repo-relative paths. Its config still lives under frontend/, +// which is what makes `oxlint` and the plugin resolvable from there. +const OXLINT_BIN = "frontend/node_modules/oxlint/bin/oxlint"; +const OXLINT_CONFIG = "frontend/oxlint.comments.config.ts"; + +// Windows caps a command line near 32k characters, which a whole-tree file list +// exceeds by a wide margin. Unbatched it dies with ENAMETOOLONG, and silently: +// oxlint exits non-zero normally, so the error reads as "no findings". +const ARGV_BUDGET = 24_000; + +// Every module constant lives in this block. The top-level run below starts +// before any function body is reached, so a `const` declared further down is +// still in its temporal dead zone when the first call touches it. +const baseComments = new Map(); + +// A Java char literal, which is the only thing a single quote can legitimately +// open: one character, or one escape. An apostrophe in prose never matches, so +// `/** The approver's team ... */` keeps its closing delimiter. Without this the +// apostrophe opened a literal that never closed, the `*/` was blanked away, and +// the scanner read the next 47 lines of code as one comment. +const CHAR_LITERAL = /^'(\\[btnfr'"\\0]|\\u[0-9a-fA-F]{4}|[^'\\])'/; + +// A docstring opens the line, optionally behind a string prefix. Anything with +// code in front of the quotes is a value, not documentation. +const DOCSTRING_OPEN = /^[rbuf]{0,2}("""|''')/; + +const argv = process.argv.slice(2); +const flags = new Set(argv.filter((a) => a.startsWith("--"))); +const positional = argv.filter((a) => !a.startsWith("--") && !isFlagValue(a)); + +if (flags.has("--selftest")) process.exit(runSelfTest()); +if (flags.has("--help")) { + process.stdout.write( + readFileSync(fileURLToPath(import.meta.url), "utf8") + .split("\n") + .slice(1, 22) + .join("\n") + .replace(/^\/\/ ?/gm, "") + "\n", + ); + process.exit(0); +} + +const scope = resolveScope(); +const findings = collect(scope); +process.exit(publish(findings, scope)); + +// A "scope" is the set of files to look at plus, when the run is diff-based, the +// set of lines that are new. Reporting a legacy finding in a file someone merely +// touched is how a gate like this gets switched off, so diff runs filter by line. + +function resolveScope() { + if (flags.has("--all")) return { mode: "all", files: trackedFiles(), added: null }; + + const paths = positional.map(toRepoPath); + + // Paths plus --since is how the editor hook asks about one file: lint it, but + // only the lines this session actually wrote. + if (paths.length > 0 && flags.has("--since")) { + const ref = mergeBase(flagValue("--since")); + return narrow(diffScope(["diff", "--unified=0", "--no-color", ref, "--", ...paths], ref, paths), paths); + } + if (paths.length > 0) return { mode: "paths", files: paths, added: null }; + + if (flags.has("--since")) { + const ref = mergeBase(flagValue("--since")); + return diffScope(["diff", "--unified=0", "--no-color", ref], ref); + } + + // Always a working-tree comparison, never `--cached`. Findings are read from + // the file on disk, so diffing the index instead would pair index line numbers + // with working-tree content and silently mismatch once the two differ. + // CI knows the target branch; a developer running this before a commit does not. + const base = process.env.GITHUB_BASE_REF; + const ref = mergeBase(base ? `origin/${base}` : "HEAD"); + return diffScope(["diff", "--unified=0", "--no-color", ref], ref); +} + +function mergeBase(ref) { + try { + return git(["merge-base", "HEAD", ref]).trim(); + } catch { + // A shallow clone or a missing remote ref: compare against the ref itself. + return ref; + } +} + +function diffScope(args, base, paths = null) { + let diff; + try { + diff = git(args); + } catch (error) { + // A shallow clone, a detached CI checkout, or a base branch that was never + // fetched. Degrading to report-only beats failing a build over plumbing. + warn(`could not resolve a diff (${firstLine(error.stderr ?? error.message)}), so nothing was checked.`); + return { mode: "all", files: [], added: null }; + } + + const added = new Map(); + let file = null; + for (const line of diff.split("\n")) { + if (line.startsWith("+++ ")) { + const path = line.slice(4).replace(/^b\//, "").trim(); + file = path === "/dev/null" ? null : path; + if (file) added.set(file, new Set()); + continue; + } + if (!file || !line.startsWith("@@")) continue; + const hunk = /\+(\d+)(?:,(\d+))?/.exec(line); + if (!hunk) continue; + const start = Number(hunk[1]); + const count = hunk[2] === undefined ? 1 : Number(hunk[2]); + for (let i = 0; i < count; i++) added.get(file).add(start + i); + } + + // git diff never mentions an untracked file, so a brand new one would be waved + // through entirely. Every line of one is new. Listing every untracked file in + // the repo costs about as much as the diff, so when the caller already named the + // paths, only those are asked about. + for (const file of untrackedFiles(paths)) { + if (added.has(file)) continue; + added.set(file, allLinesOf(file)); + } + + return { mode: "diff", files: [...added.keys()], added, base }; +} + +function untrackedFiles(paths = null) { + const args = ["ls-files", "--others", "--exclude-standard"]; + if (paths) args.push("--", ...paths); + return git(args).split("\n").filter(Boolean); +} + +function allLinesOf(file) { + const path = insideRepo(file); + if (!path || !existsSync(path)) return new Set(); + const total = readFileSync(path, "utf8").split(/\r?\n/).length; + return new Set(Array.from({ length: total }, (_, i) => i + 1)); +} + +function narrow(scope, paths) { + // A diff that could not be resolved already returned a degraded scope with no + // added map. Pass it straight through: an empty map here would read as a clean + // pass rather than as a run that checked nothing. + if (!scope.added) return scope; + + const wanted = new Set(paths); + const added = new Map([...scope.added].filter(([file]) => wanted.has(file))); + return { mode: "diff", files: [...added.keys()], added, base: scope.base }; +} + +function trackedFiles() { + return git(["ls-files"]).split("\n").filter(Boolean); +} + +function toRepoPath(path) { + return relative(REPO, resolve(process.cwd(), path)).replace(/\\/g, "/"); +} + +function collect(scope) { + const selected = scope.files.map((f) => f.replace(/\\/g, "/")).filter(isLintable); + + // A path named on the command line and then dropped has to be said out loud. + // Reporting "clean" for a file this never opened is the failure mode the rest + // of this script works to avoid. + if (scope.mode === "paths") { + for (const file of scope.files) { + if (!selected.includes(file.replace(/\\/g, "/"))) warn(`skipped ${file}: not a lintable file inside the repo.`); + } + } + const results = []; + + for (const file of selected.filter((f) => JAVA.test(f) || PYTHON.test(f))) { + results.push(...lintLineBased(file)); + } + + const ts = selected.filter((f) => TYPESCRIPT.test(f)); + if (ts.length > 0) results.push(...lintTypeScript(ts)); + + if (scope.added) { + const onAddedLine = results.filter((r) => scope.added.get(r.file)?.has(r.line)); + return onAddedLine.filter((r) => !existedAtBase(r, scope.base)); + } + return results; +} + +// git marks a reindented or moved line as added, so line membership alone reports +// comments nobody wrote. A finding only counts if its comment text is not already +// in the file at the base. +// +// Cost is one `git show` per file, memoised. It gets one case wrong: adding a +// further copy of an already-duplicated comment reads as pre-existing. That is the +// right way round for a blocking rule. + +function existedAtBase(finding, base) { + if (!base) return false; + // Findings from the oxlint plugin arrive without their comment text, because + // they cross a process boundary as a message string. Recover it from the file + // on disk at the reported line, which is the same text the rule judged. + const body = finding.body ?? currentLineBody(finding); + if (!body) return false; + const key = `${base}:${finding.file}`; + if (!baseComments.has(key)) { + let source = ""; + try { + source = git(["show", key]); + } catch { + // Not in the base at all, so the whole file is new. + } + baseComments.set(key, commentBodiesOf(source)); + } + return baseComments.get(key).has(body); +} + +function currentLineBody(finding) { + try { + const line = readFileSync(insideRepo(finding.file), "utf8").split(/\r?\n/)[finding.line - 1]; + return line === undefined ? "" : normaliseComment(line); + } catch { + return ""; + } +} + +// Everything this tool reads is named by git or by a developer on the command +// line, so a path outside the repo is a mistake rather than an attack. Resolving +// through here keeps the contract true: git show and git diff cannot answer for a +// path outside the work tree, so escaping it only produces confusing output. +function insideRepo(file) { + const target = resolve(REPO, file); + const rel = relative(REPO, target); + if (rel.length === 0 || rel.startsWith("..") || isAbsolute(rel)) return ""; + return target; +} + +function isLintable(file) { + if (!insideRepo(file)) return false; + if (isExcludedPath(file)) return false; + + // The corpus is deliberately full of findings. Only the selftest reads it, + // and it does so by path rather than through this filter. + if (file.startsWith(FIXTURES_REL)) return false; + if (!JAVA.test(file) && !PYTHON.test(file) && !TYPESCRIPT.test(file)) return false; + return existsSync(insideRepo(file)); +} + +function lintLineBased(file) { + const source = readFileSync(insideRepo(file), "utf8"); + if (isGenerated(source)) return []; + const lines = source.split(/\r?\n/); + const runs = readRuns(lines, PYTHON.test(file) ? "py" : "java"); + return analyse({ lines, runs, isTestFile: isTestPath(file) }).map((f) => ({ ...f, file })); +} + +// Groups comment lines into runs, the same shape the oxlint plugin builds from +// parser tokens. String literals are blanked first so a `//` inside one is not +// mistaken for a comment; without that, every URL in a string became a finding. +function readRuns(lines, language) { + const runs = []; + let current = null; + let inBlock = false; + let docstring = null; + + const push = (index, column, body, kind, trailing = false) => { + const line = index + 1; + if (!trailing && current && current.endLine === line - 1 && current.kind === kind && !current.trailing) { + current.lines.push({ line, column, body }); + current.endLine = line; + return; + } + current = { startLine: line, endLine: line, kind, trailing, lines: [{ line, column, body }] }; + runs.push(current); + if (trailing) current = null; + }; + + for (let i = 0; i < lines.length; i++) { + const raw = lines[i]; + const text = blankStrings(raw, language); + const trimmed = text.trim(); + const column = raw.length - raw.trimStart().length + 1; + + if (inBlock) { + push(i, column, stripDocPrefix(raw), "doc"); + if (trimmed.includes("*/")) inBlock = false; + continue; + } + if (trimmed.length === 0) { + current = null; + continue; + } + if (language === "py") { + // Every triple-quoted string is tracked, not just the documenting ones. + // A template assigned to a constant opens mid-line and so is not + // documentation, but its closing delimiter sits alone on a line and reads + // exactly like an opener. Ignoring those strings desynchronised the + // scanner for the rest of the file, and 35 lines of ordinary code were + // reported as commented-out. + if (docstring) { + if (docstring.isDoc) push(i, column, stripDocstringDelimiters(raw), "doc"); + else current = null; + if (raw.includes(docstring.delimiter)) docstring = null; + continue; + } + if (trimmed.startsWith("#")) { + push(i, column, raw.trim().replace(/^#+/, ""), "line"); + continue; + } + const hashAt = raw.indexOf("#"); + if (hashAt > 0 && !/["']/.test(raw.slice(0, hashAt))) { + const body = raw.slice(hashAt + 1).trim(); + if (body.length > 0) { + push(i, hashAt + 1, body, "line", true); + continue; + } + } + const quoted = tripleQuoted(trimmed); + if (quoted) { + if (quoted.isDoc) push(i, column, stripDocstringDelimiters(raw), "doc"); + else current = null; + if (!quoted.closes) docstring = quoted; + continue; + } + current = null; + continue; + } + if (trimmed.startsWith("/*")) { + push(i, column, stripDocPrefix(raw), trimmed.startsWith("/**") ? "doc" : "block"); + if (!trimmed.includes("*/")) inBlock = true; + continue; + } + if (trimmed.startsWith("//")) { + push(i, column, raw.trim().replace(/^\/\/+/, ""), "line"); + continue; + } + + // Code first, then a comment. blankStrings has already neutralised any `//` + // inside a string literal, so this index is a real comment marker. A block + // comment counts here only when it also closes on this line, matching the + // oxlint engine: one that runs on has its bulk on lines of its own. + const trailingLine = text.indexOf("//"); + const trailingBlock = text.indexOf("/*"); + const at = + trailingLine > 0 ? trailingLine : trailingBlock > 0 && text.includes("*/", trailingBlock) ? trailingBlock : -1; + if (at > 0) { + const body = raw + .slice(at + 2) + .replace(/\*\/.*$/, "") + .trim(); + if (body.length > 0) { + push(i, at + 1, body, "line", true); + continue; + } + } + current = null; + } + + return runs; +} + +// The prose inside a docstring line, with the triple quotes and any string +// prefix taken off so the rules see what a reader sees. +// Where a triple-quoted string starts on this line, and whether it counts as +// documentation. It documents when the quotes open the line, allowing a string +// prefix; a template assigned to a constant opens mid-line and is data, and +// reading JSON as prose would judge its keys as comments. An odd number of +// delimiters means the string continues onto the next line. +function tripleQuoted(trimmed) { + const found = /("""|''')/.exec(trimmed); + if (!found) return null; + const delimiter = found[1]; + const occurrences = trimmed.split(delimiter).length - 1; + return { + delimiter, + isDoc: DOCSTRING_OPEN.test(trimmed), + closes: occurrences % 2 === 0, + }; +} + +function stripDocstringDelimiters(raw) { + return raw + .trim() + .replace(/^[rbuf]{0,2}("""|''')/, "") + .replace(/("""|''')\s*$/, "") + .trim(); +} + +function stripDocPrefix(raw) { + return raw + .trim() + .replace(/^\/\*+/, "") + .replace(/\*+\/$/, "") + .replace(/^\*+/, "") + .trim(); +} + +// Replaces the contents of string and char literals with spaces, preserving +// length so columns stay correct. Escapes are honoured so "\"" does not end it. +function blankStrings(line, language) { + if (language === "py") return line; + let out = ""; + let quote = null; + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (quote) { + if (ch === "\\") { + out += " "; + i++; + continue; + } + out += ch === quote ? ch : " "; + if (ch === quote) quote = null; + continue; + } + if (ch === '"') { + quote = ch; + out += ch; + continue; + } + if (ch === "'") { + const literal = CHAR_LITERAL.exec(line.slice(i)); + if (!literal) { + // Prose, not a literal. Leave it alone. + out += ch; + continue; + } + out += `'${" ".repeat(literal[0].length - 2)}'`; + i += literal[0].length - 1; + continue; + } + if (ch === "/" && line[i + 1] === "/") return out + line.slice(i); + out += ch; + } + return out; +} + +function lintTypeScript(files) { + if (!existsSync(join(REPO, OXLINT_BIN))) { + warn("frontend/node_modules/oxlint is missing, so TS/TSX was skipped. Run `task frontend:install`."); + return []; + } + return batch(files, ARGV_BUDGET).flatMap(runOxlint); +} + +function batch(files, budget) { + const batches = []; + let current = []; + let size = 0; + for (const file of files) { + if (current.length > 0 && size + file.length + 1 > budget) { + batches.push(current); + current = []; + size = 0; + } + current.push(file); + size += file.length + 1; + } + if (current.length > 0) batches.push(current); + return batches; +} + +function runOxlint(files) { + let stdout; + try { + // Invoked as `node ` rather than through npx: spawning a .cmd shim on + // Windows fails with EINVAL unless a shell is used, and a shell would mean + // quoting every path. It must also be the npm package rather than the + // standalone release binary, which accepts a jsPlugins config, skips loading + // it, and still reports success (oxc-project/oxc#25203). + stdout = execFileSync(process.execPath, [OXLINT_BIN, "--config", OXLINT_CONFIG, "--format=json", ...files], { + cwd: REPO, + encoding: "utf8", + maxBuffer: 1 << 28, + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (error) { + // oxlint exits non-zero whenever it reports something, which is the normal case. + stdout = error.stdout ?? ""; + if (!stdout.trim()) { + die(`oxlint failed on ${files.length} file(s): ${firstLine(error.stderr ?? error.message)}`); + } + } + + return parseOxlint(stdout); +} + +function firstLine(value) { + return value.toString().trim().split(/\r?\n/)[0]; +} + +function parseOxlint(stdout) { + const start = stdout.indexOf("{"); + if (start < 0) die("oxlint produced no JSON report."); + let report; + try { + report = JSON.parse(stdout.slice(start)); + } catch { + die("could not parse oxlint JSON output."); + } + + // JS plugins are alpha, and their documented failure mode is being skipped + // silently while oxlint still reports success (oxc-project/oxc#25203). + // number_of_rules is the report saying whether the plugin's rule was actually + // registered. Without this check a dead plugin reads exactly like clean code. + if ((report.number_of_rules ?? 0) < 1) { + die("oxlint loaded no rules, so the comment plugin did not run. Refusing to report a pass."); + } + + return (report.diagnostics ?? []).flatMap((d) => { + const parsed = /^(CMT\d{3})\s+(\S+):\s*(.*)$/.exec(d.message); + if (!parsed) return []; + const [, rule, , detail] = parsed; + const span = d.labels?.[0]?.span; + return [ + { + file: d.filename.replace(/\\/g, "/"), + line: span?.line ?? 1, + column: span?.column ?? 1, + rule, + detail, + severity: RULES[rule].severity, + }, + ]; + }); +} + +function publish(findings, scope) { + if (flags.has("--json")) { + process.stdout.write(`${JSON.stringify({ mode: scope.mode, findings }, null, 2)}\n`); + return 0; + } + + if (findings.length === 0) { + process.stdout.write(`comment-lint: clean (${scope.files.length} file${scope.files.length === 1 ? "" : "s"} in scope)\n`); + return 0; + } + + const byFile = new Map(); + for (const f of findings) { + if (!byFile.has(f.file)) byFile.set(f.file, []); + byFile.get(f.file).push(f); + } + + for (const [file, group] of [...byFile.entries()].sort()) { + process.stdout.write(`\n${file}\n`); + for (const f of group.sort((a, b) => a.line - b.line)) { + process.stdout.write(` ${String(f.line).padStart(5)} ${ruleLabel(f.rule)} ${f.detail}\n`); + } + } + + process.stdout.write( + `\ncomment-lint: ${findings.length} finding${findings.length === 1 ? "" : "s"} across ${byFile.size} file${byFile.size === 1 ? "" : "s"}\n`, + ); + + if (scope.mode === "all") { + process.stdout.write("Report-only mode: --all never fails, so the standing backlog can be worked through in chunks.\n"); + return 0; + } + + process.stdout.write( + "\nThe standard is devGuide/CODE_COMMENTS.md. A comment must carry information the\n" + + "code cannot; if a reader could derive it from the code in front of them, delete it.\n" + + "If a finding is genuinely wrong, put `comment-lint-allow: CMT00X` on the line above.\n", + ); + return 1; +} + +function warn(message) { + process.stderr.write(`comment-lint: ${message}\n`); +} + +// A gate that cannot run must not report a pass. Reserved for the engine being +// broken, as opposed to absent: a missing oxlint install is handled by skipping +// with a warning, so the hook stays usable before `task frontend:install`. +function die(message) { + process.stderr.write(`comment-lint: ${message}\n`); + process.exit(2); +} + +// The fixture corpus is the contract between the two engines: the same rule set +// applied to .java/.py by the line scanner and to .ts/.tsx by oxlint, with +// fixtures/expected.json asserting what each file should produce. +// +// Expectations live outside the fixtures on purpose. An in-file marker would sit +// inside the very comment under test, changing its word count and its run +// length, so the fixture would stop being an example of the real thing. +// +// --selftest compare against expected.json +// --selftest --update rewrite expected.json from current behaviour +// +// A rule change is meant to show up as a reviewable diff in expected.json. + +function runSelfTest() { + const dir = join(HERE, "fixtures"); + const expectedPath = join(dir, "expected.json"); + const files = readdirSync(dir) + .filter((f) => /\.(java|py|ts|tsx)$/.test(f)) + .sort(); + if (files.length === 0) { + warn("no fixtures found"); + return 1; + } + + const asRepoPath = (name) => relative(REPO, join(dir, name)).replace(/\\/g, "/"); + const tsFixtures = files.filter((f) => TYPESCRIPT.test(f)); + const tsFindings = tsFixtures.length > 0 ? lintTypeScript(tsFixtures.map(asRepoPath)) : []; + + // A skipped engine looks exactly like a clean engine in the snapshot, so + // refuse to record or compare rather than baking in a false pass. + if (tsFixtures.length > 0 && tsFindings.length === 0) { + warn("the TS engine produced nothing, so it did not run. Install frontend deps first."); + return 1; + } + + const actual = {}; + for (const name of files) { + const found = TYPESCRIPT.test(name) + ? tsFindings.filter((f) => f.file.endsWith(`/${name}`)) + : lintLineBased(asRepoPath(name)); + actual[name] = found + .map((f) => `${f.line}:${f.rule}:${f.severity}`) + .sort((a, b) => Number(a.split(":")[0]) - Number(b.split(":")[0])); + } + + if (flags.has("--update")) { + writeFileSync(expectedPath, `${JSON.stringify(actual, null, 2)}\n`); + process.stdout.write( + `comment-lint selftest: recorded ${Object.keys(actual).length} fixtures to ${relative(REPO, expectedPath)}\n`, + ); + return 0; + } + + if (!existsSync(expectedPath)) { + warn("fixtures/expected.json is missing. Run --selftest --update to record it."); + return 1; + } + + // --quiet says nothing unless something is wrong. It is how the lint tasks run + // the corpus first without burying their own output under eleven ok lines. + const quiet = flags.has("--quiet"); + const expected = JSON.parse(readFileSync(expectedPath, "utf8")); + let failures = 0; + for (const name of files) { + const want = (expected[name] ?? []).join(" | "); + const got = actual[name].join(" | "); + if (want === got) { + if (!quiet) process.stdout.write(`ok ${name} (${actual[name].length})\n`); + continue; + } + failures++; + process.stdout.write(`FAIL ${name}\n expected: ${want || "(nothing)"}\n actual: ${got || "(nothing)"}\n`); + } + + const stale = Object.keys(expected).filter((n) => !files.includes(n)); + for (const name of stale) { + failures++; + process.stdout.write(`FAIL ${name} is in expected.json but the fixture is gone\n`); + } + + if (failures > 0) { + process.stdout.write( + `\ncomment-lint selftest: ${failures} fixture(s) differ. If intended, rerun with --update and review the diff.\n`, + ); + return 1; + } + if (!quiet) process.stdout.write("\ncomment-lint selftest: both engines match the corpus\n"); + return 0; +} + +function isFlagValue(arg) { + const index = argv.indexOf(arg); + return index > 0 && argv[index - 1] === "--since"; +} + +function flagValue(flag) { + const index = argv.indexOf(flag); + return argv[index + 1] ?? "origin/main"; +} + +function git(args) { + // stderr is captured rather than inherited so git's line-ending advice ("CRLF + // will be replaced by LF") does not print once per file on Windows. Real + // failures still surface: execFileSync throws, and the caller reads .stderr. + return execFileSync("git", args, { + cwd: REPO, + encoding: "utf8", + maxBuffer: 1 << 28, + stdio: ["ignore", "pipe", "pipe"], + }); +} diff --git a/scripts/lint/comment-rules.mjs b/scripts/lint/comment-rules.mjs new file mode 100644 index 0000000000..4211be9689 --- /dev/null +++ b/scripts/lint/comment-rules.mjs @@ -0,0 +1,500 @@ +// The comment-quality rule set, shared by both engines so a rule means the same +// thing everywhere: the oxlint JS plugin (which owns .ts/.tsx, and has real +// comment tokens and an AST) and comment-lint.mjs (which owns .java and .py, and +// has only lines). Neither engine ever scans the other's files, so the two can +// differ in precision without producing contradictory findings on one file. +// +// The standard these rules enforce is devGuide/CODE_COMMENTS.md. Changing a rule +// here without changing that document leaves the repo with two answers. +// +// Between them the engines read every comment form the repo writes: // and /* */, +// Javadoc and JSDoc, JSX comments, # and Python docstrings. + +export const SEVERITY = { ERROR: "error", WARN: "warn" }; + +// Every rule blocks. A rule that only warns is a rule nobody acts on, so a +// finding that turns out to be wrong is a bug in the rule: narrow it, or mark the +// line with comment-lint-allow and say why. Each rule below carries the readings +// it deliberately excludes, which is where to start when one misfires. +export const RULES = { + CMT001: { name: "restates-code", severity: SEVERITY.ERROR }, + CMT002: { name: "banner", severity: SEVERITY.ERROR }, + CMT003: { name: "step-narration", severity: SEVERITY.ERROR }, + CMT004: { name: "diff-narration", severity: SEVERITY.ERROR }, + CMT005: { name: "dead-code", severity: SEVERITY.ERROR }, + CMT006: { name: "block-too-long", severity: SEVERITY.ERROR }, + CMT007: { name: "doc-restates-signature", severity: SEVERITY.ERROR }, + CMT008: { name: "bad-allow", severity: SEVERITY.ERROR }, + CMT009: { name: "unowned-todo", severity: SEVERITY.ERROR }, +}; + +export const MAX_BLOCK_LINES = 12; + +// CMT001 compares a comment against the code it introduces. Both sides are +// reduced to the same shape first: lowercased, camel/snake/kebab split into +// words, stop words and short words dropped. What survives is the information +// each side actually carries, so "Handle drag start" and `handleDragStart` land +// on the same set and the comment is shown to add nothing. + +const STOP_WORDS = new Set( + ( + "a an the and or but if then else for to of in on at by with from into is are be was were this that these those it its as we our you your do" + + " does done use uses used using will would should can could may might not no yes new only also just so such via per each all any some more" + + " most other another same when while where which what who how why here there now next finally first second third let const var function" + + " return set get" + ).split(" "), +); + +const WORD_RE = /[a-z][a-z0-9]*/g; + +export function contentWords(text) { + return (text.toLowerCase().match(WORD_RE) ?? []).filter((w) => w.length > 2 && !STOP_WORDS.has(w)); +} + +export function identWords(text) { + const split = text.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[_\-.]/g, " "); + return contentWords(split); +} + +// Sentence punctuation marks prose, which is usually saying something the code +// does not. A single trailing full stop does not count. +const PROSE_PUNCT = /[.;:?!]/; +const MAX_RESTATE_WORDS = 6; + +// Arrange/Act/Assert and Given/When/Then label the shape of a test rather than +// describe the line beneath. Exempt only as a bare marker, so +// `// Assert the cap is clamped to the tier maximum` is prose and judged on its +// merits. +const TEST_STRUCTURE = /^(arrange|act|assert|given|when|then)\b/i; +const MAX_MARKER_WORDS = 4; + +export function restatesCode(body, codeText) { + if (TEST_STRUCTURE.test(body.trim()) && body.trim().split(/\s+/).length <= MAX_MARKER_WORDS) return false; + if (PROSE_PUNCT.test(body.replace(/\.$/, ""))) return false; + const comment = contentWords(body); + if (comment.length === 0 || comment.length > MAX_RESTATE_WORDS) return false; + const code = identWords(codeText); + if (code.length === 0) return false; + + // Prefix matching either way, so "config" covers "configuration" and vice versa. + return comment.every((w) => code.some((k) => k.startsWith(w) || w.startsWith(k))); +} + +const RULE_CHARS = /^[=~_*#+\-]{4,}|[─-╿]{4,}|[=~_*+]{4,}$/; +const SECTION_LABEL = new RegExp( + "^(imports?|exports?|types?|interfaces?|constants?|config|helpers?|utils?|utilities|state|handlers?|callbacks?|effects?" + + "|render|rendering|styles?|props?|hooks?|setup|teardown|cleanup|main|public|private|internal|api|queries|mutations" + + "|selectors?|actions?|reducers?|components?|fields?|getters?|setters?|lifecycle|boilerplate)" + + "\\s*(section|area|block)?\\s*$", + "i", +); + +export function isBanner(body) { + if (RULE_CHARS.test(body.trim())) return true; + + // A label wrapped in decoration is still a label: strip the decoration first. + const bare = body + .replace(/[=~_*#+\-─-╿]/g, " ") + .replace(/\s+/g, " ") + .trim(); + return bare.length > 0 && SECTION_LABEL.test(bare); +} + +// A bare "1." is not narration: numbered lists are how a doc block enumerates +// conditions or alternatives, and matching them buries the rule in false +// positives. Only the explicit step form and sequencing adverbs qualify, and the +// number needs a separator after it, so a wrapped line beginning "step 2 unmounts +// + remounts the panel" reads as the prose it is. +const STEP = /^(step\s*\d+(\.\d+)?\s*[:.)\-]|(then|next|finally|afterwards|lastly)\s*[,:]\s+\S)/i; + +export function isStepNarration(body) { + return STEP.test(body.trim()); +} + +// Only phrases that can be talking about the code's own past. Excluded because +// each has an innocent reading that fires constantly: +// "used to" alone - "Used to clamp the live line" means "is used to" +// "previously" alone - "re-show even if previously dismissed" is runtime state +// "was called" - collides with "verify getSession was called" +// "left over from" - "no cards left over from the unfiltered grid" +const DIFF_NARRATION = new RegExp( + "\\b((this|it|we|they|that) used to|used to (be|live|sit)" + + "|(this|it|that|which|the (class|method|field|code|file|module|palette|banner)) is no longer (needed|used)" + + "|renamed from|was (previously|formerly) (called|named|known)" + + "|instead of the old|has been (removed|replaced) )", + "i", +); +const REMOVAL_SUFFIX = /(^|\s)[-(—]\s*(removed|deleted|dropped|no longer needed)\s*\)?\s*$/i; + +export function isDiffNarration(body) { + const t = body.trim(); + return DIFF_NARRATION.test(t) || REMOVAL_SUFFIX.test(t); +} + +const CODE_KEYWORD = new RegExp( + "^(import|package|public|private|protected|static|final|abstract|class|interface|enum|record|extends|implements" + + "|def|async|await|const|let|var|function|export|return|if|else|elif|for|while|do|try|catch|finally|switch|case" + + "|throw|new|super|this|@[A-Za-z])\\b", +); +const STATEMENT_TAIL = /[;{}]\s*$/; +const CALL_ONLY = /^[\w.$]+\s*\([^)]*\)\s*;?\s*$/; +const ASSIGNMENT = /\S\s*=\s*\S/; + +export function looksLikeCode(line) { + const t = line.trim(); + if (t.length === 0) return false; + if (CODE_KEYWORD.test(t)) return true; + if (CALL_ONLY.test(t)) return true; + return STATEMENT_TAIL.test(t) && ASSIGNMENT.test(t); +} + +export const MIN_DEAD_CODE_RUN = 3; +const DEAD_CODE_SHARE = 2 / 3; + +export function isDeadCodeRun(bodies) { + if (bodies.length < MIN_DEAD_CODE_RUN) return false; + const codeish = bodies.filter(looksLikeCode).length; + return codeish / bodies.length >= DEAD_CODE_SHARE; +} + +// Every documented-parameter form this repo writes, so the rule is not quietly +// Javadoc-only: +// Javadoc / JSDoc @param blob The blob to download +// Sphinx :param blob: The blob to download +// Google docstring blob: The blob to download (under an Args: heading) +// NumPy style is deliberately absent: it splits the name and the description +// across two lines, and there is one instance of it in the tree. +const PARAM_TAG = /^@param\s+(?:\{[^}]*\}\s+)?([\w$.]+)\s*-?\s*(.+)$/; +const SPHINX_PARAM = /^:(?:param|arg|key)\s+(?:\S+\s+)?([\w.]+)\s*:\s*(.+)$/; +const GOOGLE_PARAM = /^([a-z_][\w]*)\s*(?:\([^)]*\))?\s*:\s*(.+)$/; + +const RETURN_TAG = /^@returns?\s+(.+)$/; +const SPHINX_RETURN = /^:returns?\s*:\s*(.+)$/; + +// Description adds nothing when every word in it already appears in the thing +// being described. `@param blob - The blob to download` is the canonical case. +// +// No native linter covers this. eslint-plugin-jsdoc's require-param-description, +// Checkstyle's NonEmptyAtclauseDescription and ruff's D-rules all check that a +// description exists, not whether it says anything. +export function docRestatesSignature(body, ownerName = "") { + const t = body.trim(); + + for (const pattern of [PARAM_TAG, SPHINX_PARAM, GOOGLE_PARAM]) { + const match = pattern.exec(t); + if (!match) continue; + const [, name, description] = match; + // Google form is just `name: description`, which also matches ordinary prose + // containing a colon. Require the description to be short and unpunctuated so + // "Note: the cap is clamped" is not read as a parameter called "note". + if (pattern === GOOGLE_PARAM && /[.;,]/.test(description)) return false; + return addsNothing(description, name, 5); + } + + const returns = RETURN_TAG.exec(t) ?? SPHINX_RETURN.exec(t); + if (returns && ownerName) return addsNothing(returns[1], ownerName, 4); + return false; +} + +function addsNothing(description, subject, limit) { + const words = contentWords(description); + if (words.length === 0 || words.length > limit) return false; + const known = identWords(subject); + return known.length > 0 && words.every((w) => known.some((k) => k.startsWith(w) || w.startsWith(k))); +} + +// A TODO with no reference has nothing that will ever close it. An owner is not +// accepted in its place: a username goes stale when someone leaves and means +// nothing to an outside contributor, while an issue outlives both. +// +// Anchored at the start, so this catches a comment that *is* a TODO rather than +// prose that mentions the word. +const TODO_MARKER = /^(TODO|FIXME|HACK|XXX)\b/; + +// What counts as something that will close it: an issue, a link, or a security +// advisory. Checked across the whole comment run, so the reference can sit on a +// continuation line. +const HAS_REFERENCE = /(#\d+|https?:\/\/|CVE-\d|GHSA-|[A-Z]{2,}-\d+)/; + +export function isUnownedTodo(body, runText = body) { + return TODO_MARKER.test(body) && !HAS_REFERENCE.test(runText); +} + +// A rule id is silenced by `comment-lint-allow: CMT002`, on the comment itself or +// on the line above it. There is deliberately no form that disables every rule. +// +// The whole comment must be the directive. Matching it anywhere in the text meant +// prose that merely mentions the syntax silenced a rule, which this file's own +// paragraph above did. +const DIRECTIVE = /^comment-lint-allow:\s*(.+?)\s*$/i; + +export function isDirective(body) { + return DIRECTIVE.test(body.trim()); +} + +// A directive that names nothing real, or that suppresses nothing, is dead +// configuration: it reads as a silenced rule while silencing nothing, and it +// blinds the line for whoever inherits it. Reported for the same reason ESLint +// has --report-unused-disable-directives and ruff has RUF100. +class Allowance { + constructor(directives) { + this.entries = []; + for (const directive of directives) { + for (const token of directiveTokens(directive.body)) { + this.entries.push({ token, directive, known: token in RULES, used: false }); + } + } + } + + // Called only once a rule has decided it would report, so a directive counts + // as used when it actually silenced something. Asking before the rule decided + // marked every consulted directive as used, which hid the unused ones. + suppresses(rule) { + let allowed = false; + for (const entry of this.entries) { + if (entry.token !== rule) continue; + entry.used = true; + allowed = true; + } + return allowed; + } + + reportUnused(report) { + for (const entry of this.entries) { + if (entry.used) continue; + const detail = entry.known ? `${entry.token} is allowed here but nothing reported it` : `${entry.token} is not a rule`; + report("CMT008", entry.directive.line, entry.directive.column, detail, entry.directive.body); + } + } +} + +// Every token a directive names, valid or not, so an unknown one is reported +// rather than quietly ignored. Matching only real ids would let `CMT999` through +// as a silent no-op: it looks like a rule and silences nothing. +export function directiveTokens(body) { + const match = DIRECTIVE.exec(body.trim()); + if (!match) return []; + return match[1] + .split(",") + .map((token) => token.trim().toUpperCase()) + .filter(Boolean); +} + +// Generated files carry whatever the generator emits, and editing them to +// satisfy a lint rule would be undone on the next regeneration. +const GENERATED_MARKER = /AUTO-?GENERATED|@generated|DO NOT EDIT|Code generated by/i; +const GENERATED_HEADER_LINES = 10; + +export function isGenerated(source) { + return GENERATED_MARKER.test(source.split("\n", GENERATED_HEADER_LINES).join("\n")); +} + +export const EXCLUDED_PATHS = [ + /(^|\/)node_modules\//, + /(^|\/)dist(-\w+)?\//, + /(^|\/)build\//, + /(^|\/)target\//, + /(^|\/)vendor\//, + /pdfjs/i, + /thirdParty/i, + /\.min\./, + /src-tauri\/gen\//, + /public\/locales\//, + /\.d\.ts$/, + /(^|\/)storybook-static\//, + /(^|\/)playwright-report\//, + /(^|\/)org\/apache\//, +]; + +export function isExcludedPath(file) { + const normalised = file.replace(/\\/g, "/"); + return EXCLUDED_PATHS.some((re) => re.test(normalised)); +} + +// Comment text reduced to what a reader would call "the same comment": trimmed, +// whitespace collapsed, comment markers and decoration stripped. Both sides of +// the pre-existing check normalise through here so indentation and marker style +// cannot make an unchanged comment look new. +export function normaliseComment(text) { + return String(text) + .replace(/^[\s{]*(\/\/+|\/\*+|#+|\*+)/gm, " ") + .replace(/\*+\/[\s}]*$/gm, " ") + .replace(/\s+/g, " ") + .trim() + .toLowerCase(); +} + +// Every comment in a source file, normalised. Deliberately permissive and +// language-agnostic: it only ever decides whether a finding is pre-existing, so +// over-matching suppresses a duplicate comment and under-matching just reports +// something the author can look at. +export function commentBodiesOf(source) { + const bodies = new Set(); + for (const raw of source.split(/\r?\n/)) { + const marker = /(\/\/+|\/\*+|^\s*\*+|#+)/.exec(raw); + if (!marker) continue; + const body = normaliseComment(raw.slice(marker.index)); + if (body.length > 0) bodies.add(body); + } + return bodies; +} + +export function ruleLabel(id) { + return `${id} ${RULES[id].name}`; +} + +// Both engines funnel into this. They differ only in how they build `runs`: the +// oxlint plugin reads real comment tokens, comment-lint.mjs scans lines. Keeping +// the rule application here is what stops the two drifting apart. +// +// A "run" is a group of comment lines with no code between them, which is the +// unit CMT005 and CMT006 judge. Shape: +// { startLine, kind: "line" | "block" | "doc", lines: [{ line, column, body }] } +// `line` is 1-based to match every editor and every diff. + +export function analyse({ lines, runs, isTestFile = false }) { + const findings = []; + // `body` is the comment's own text, kept alongside the formatted detail so the + // caller can ask whether this exact comment already existed before the change. + // That is what stops a reindent or a code move reporting comments nobody wrote. + const report = (rule, line, column, detail, body) => { + if (isTestFile && SUPPRESSED_IN_TESTS.has(rule)) return; + findings.push({ rule, line, column, detail, body: normaliseComment(body ?? detail), severity: RULES[rule].severity }); + }; + + for (const run of runs) { + // A directive is scaffolding, not content. Leaving it in the run made it two + // lines long, and CMT001 only judges a one-line run, so any directive + // silenced CMT001 whatever rule it named. + const directives = run.lines.filter((l) => isDirective(l.body)); + const content = run.lines.filter((l) => !isDirective(l.body)); + const allowed = new Allowance(directives); + + if (content.length === 0) { + allowed.reportUnused(report); + continue; + } + + const bodies = content.map((l) => l.body); + const runText = bodies.join("\n"); + const first = content[0]; + + // Only CMT004 and CMT009 judge a trailing comment. The others depend on the + // comment introducing the code below it, and a trailing comment sits beside + // it: `0x25 // "%PDF"` overlaps in words while adding the decoding, which is + // the kind of lower-altitude fact the standard asks for. + if (run.trailing) { + for (const entry of content) { + const body = entry.body.trim(); + if (body.length === 0) continue; + if (isDiffNarration(body) && !allowed.suppresses("CMT004")) { + report("CMT004", entry.line, entry.column, truncate(body), body); + continue; + } + if (isUnownedTodo(body, runText) && !allowed.suppresses("CMT009")) { + report("CMT009", entry.line, entry.column, truncate(body), body); + } + } + allowed.reportUnused(report); + continue; + } + + if (isDeadCodeRun(bodies) && !allowed.suppresses("CMT005")) { + report("CMT005", run.startLine, first.column, `${bodies.length} commented-out lines`, runText); + allowed.reportUnused(report); + continue; // Every other rule would pile onto the same block of dead code. + } + + // A doc block is exempt: the standard asks for thorough contracts, so capping + // their length would argue with itself. This judges runs of implementation + // comment, where an essay means the code needs restructuring. + const essay = run.kind !== "doc" && content.length > MAX_BLOCK_LINES && run.startLine > FILE_HEADER_LINES; + if (essay && !allowed.suppresses("CMT006")) { + report("CMT006", run.startLine, first.column, `${content.length} lines, limit ${MAX_BLOCK_LINES}`, runText); + } + + const owner = run.kind === "line" ? "" : nextCodeLine(lines, run); + + for (const entry of content) { + const body = entry.body.trim(); + if (body.length === 0) continue; + + if (isBanner(body) && !allowed.suppresses("CMT002")) { + report("CMT002", entry.line, entry.column, truncate(body), body); + continue; + } + if (isStepNarration(body) && !allowed.suppresses("CMT003")) { + report("CMT003", entry.line, entry.column, truncate(body), body); + continue; + } + if (isDiffNarration(body) && !allowed.suppresses("CMT004")) { + report("CMT004", entry.line, entry.column, truncate(body), body); + continue; + } + if (docRestatesSignature(body, owner) && !allowed.suppresses("CMT007")) { + report("CMT007", entry.line, entry.column, truncate(body), body); + continue; + } + if (isUnownedTodo(body, runText) && !allowed.suppresses("CMT009")) { + report("CMT009", entry.line, entry.column, truncate(body), body); + continue; + } + } + + // CMT001 judges a whole single-line run against the code it introduces, so + // a two-line comment that happens to echo one identifier is left alone. + // A one-line `/* … */` counts, which is how JSX `{/* Cap editor */}` above + // `` is caught. A doc block does not: it is a contract, and + // CMT007 is the rule that judges those. + if (run.kind !== "doc" && content.length === 1) { + const entry = first; + const body = entry.body.trim(); + const code = nextCodeLine(lines, run); + if (code && !isBanner(body) && restatesCode(body, code) && !allowed.suppresses("CMT001")) { + report("CMT001", entry.line, entry.column, `${truncate(body)} -> ${truncate(code)}`, body); + } + } + + allowed.reportUnused(report); + } + + return findings.sort((a, b) => a.line - b.line || a.column - b.column); +} + +// A file header is allowed to be as long as it needs to be. +const FILE_HEADER_LINES = 5; +const DETAIL_WIDTH = 58; + +// Both of these say something real in a test and nothing anywhere else. A +// regression test explains itself by describing the old behaviour, and the e2e +// specs number their comments to match a written manual test procedure. +const SUPPRESSED_IN_TESTS = new Set(["CMT003", "CMT004"]); + +function precedingLine(lines, startLine) { + return lines[startLine - 2] ?? ""; +} + +function nextCodeLine(lines, run) { + const commentLines = new Set(run.lines.map((l) => l.line)); + for (let i = run.startLine; i < lines.length; i++) { + const lineNumber = i + 1; + if (commentLines.has(lineNumber)) continue; + const text = lines[i]?.trim() ?? ""; + if (text.length === 0) continue; + if (text.startsWith("//") || text.startsWith("#") || text.startsWith("*") || text.startsWith("/*")) continue; + if (text === "}" || text === "};" || text === ")" || text === ");") return ""; + return text; + } + return ""; +} + +function truncate(text) { + const flat = text.replace(/\s+/g, " ").trim(); + return flat.length > DETAIL_WIDTH ? `${flat.slice(0, DETAIL_WIDTH - 1)}…` : flat; +} + +export const TEST_FILE = /\.(test|spec)\.[jt]sx?$|(^|\/)src\/test\/|Test\.java$|Tests\.java$|(^|\/)test_[^/]+\.py$|_test\.py$/; + +export function isTestPath(file) { + return TEST_FILE.test(file.replace(/\\/g, "/")); +} diff --git a/scripts/lint/fixtures/AaaTest.java b/scripts/lint/fixtures/AaaTest.java new file mode 100644 index 0000000000..1aac398327 --- /dev/null +++ b/scripts/lint/fixtures/AaaTest.java @@ -0,0 +1,19 @@ +package fixtures; + +class AaaTest { + + void clampsToTierMaximum() { + // Arrange + var wallet = walletAt(500); + + // Act + var result = clamp(wallet); + + // Assert + assertEquals(100, result.cap()); + + // Assert the cap is clamped rather than rejected, because the tier + // downgrade path relies on it. + assertTrue(result.clamped()); + } +} diff --git a/scripts/lint/fixtures/README.md b/scripts/lint/fixtures/README.md new file mode 100644 index 0000000000..ceb8f1044b --- /dev/null +++ b/scripts/lint/fixtures/README.md @@ -0,0 +1,17 @@ +# comment-lint fixtures + +The contract between the two engines. Each file is a small, realistic example of +what a rule fires on, or of something it must leave alone. `expected.json` records +what every fixture should produce, down to the line and the severity. + +Expectations live outside the fixtures deliberately: an in-file `EXPECT:` marker +would sit inside the comment under test, changing its word count and its run +length, so the fixture would stop being an example of the real thing. + +```bash +node scripts/lint/comment-lint.mjs --selftest # compare +node scripts/lint/comment-lint.mjs --selftest --update # re-record, then review the diff +``` + +Adding a rule means adding a fixture that fires it and a line in a `clean.*` +fixture that must not. A rule with no fixture is a rule nobody can safely change. diff --git a/scripts/lint/fixtures/allow.java b/scripts/lint/fixtures/allow.java new file mode 100644 index 0000000000..50fe34d21e --- /dev/null +++ b/scripts/lint/fixtures/allow.java @@ -0,0 +1,32 @@ +package fixtures; + +class Allow { + + void keptOnPurpose() { + // comment-lint-allow: CMT002 + // ---------- kept on purpose, this fixture proves the escape hatch ---------- + run(); + } + + void unknownToken(Session session) { + // comment-lint-allow: FAKE_RULE + session.close(); + } + + void looksLikeARuleButIsNot(Session session) { + // comment-lint-allow: CMT999 + session.close(); + } + + void allowedButNothingFires(Session session) { + // comment-lint-allow: CMT001 + // Closed once the signing round trip has settled, not before. + session.close(); + } + + void directiveMustNotHideOtherRules(Registry registry) { + // comment-lint-allow: CMT002 + // Clear the registry + registry.clear(); + } +} diff --git a/scripts/lint/fixtures/apostrophes.java b/scripts/lint/fixtures/apostrophes.java new file mode 100644 index 0000000000..25d0745fd8 --- /dev/null +++ b/scripts/lint/fixtures/apostrophes.java @@ -0,0 +1,23 @@ +package fixtures; + +class Apostrophes { + + /** The approver's team, which must match the one this server already belongs to. */ + Long teamId; + + // The caller's own retry budget applies here; this method does not retry. + void submit() {} + + void literals() { + char quote = '\''; + char newline = '\n'; + char slash = '/'; + String path = "a//b"; + } + + /** Kept last: if the scanner desynchronises above, this stops being seen. */ + void canary() { + // Build document + document = build(); + } +} diff --git a/scripts/lint/fixtures/clean.java b/scripts/lint/fixtures/clean.java new file mode 100644 index 0000000000..34f21c313c --- /dev/null +++ b/scripts/lint/fixtures/clean.java @@ -0,0 +1,26 @@ +package fixtures; + +/** + * Authority on which filesystem locations a policy may read or write. Fail-closed: + * denied entirely under the saas profile, then Stirling's own config directory is + * always rejected, then the path must resolve inside an allowed root. + * + *

Compared after normalisation so {@code ..} cannot escape a root. Symlink + * escape is not defended: an operator who roots an allowlist on a symlink to a + * sensitive location is trusted. + */ +class Clean { + + /** Returns the normalised absolute path; throws if not permitted. */ + Path check(Path candidate) { + // whenComplete runs on the worker thread after the run finishes, so the + // terminal event never races the step events. + return candidate.toAbsolutePath().normalize(); + } + + void sizes() { + // Bytes, not KiB: the API contract predates the unit change and callers + // still send bytes. + long limit = 5_242_880L; + } +} diff --git a/scripts/lint/fixtures/clean.ts b/scripts/lint/fixtures/clean.ts new file mode 100644 index 0000000000..cac6895417 --- /dev/null +++ b/scripts/lint/fixtures/clean.ts @@ -0,0 +1,17 @@ +/** + * Auth/session seam. saas keeps a Supabase web session; desktop keeps a JWT in + * the Tauri secure store, and cloud code reads the token through here instead. + * Default no-op; saas/ and desktop/ shadow it. + */ +export interface SessionSeam { + /** Bearer access token for authenticated API calls, or null when signed out. */ + getAccessToken(): Promise; +} + +export function createSeam(): SessionSeam { + return { + // Resolves null rather than throwing: a signed-out caller is an ordinary + // state here, and every consumer already branches on null. + getAccessToken: async () => null, + }; +} diff --git a/scripts/lint/fixtures/deadcode.java b/scripts/lint/fixtures/deadcode.java new file mode 100644 index 0000000000..e2ad5b887e --- /dev/null +++ b/scripts/lint/fixtures/deadcode.java @@ -0,0 +1,14 @@ +package fixtures; + +class DeadCode { + + // private void oldPath(PDDocument document) { + // PDPage page = document.getPage(0); + // page.setRotation(90); + // document.save(target); + // } + + void currentPath(PDDocument document) { + document.save(target); + } +} diff --git a/scripts/lint/fixtures/docs.java b/scripts/lint/fixtures/docs.java new file mode 100644 index 0000000000..f572dc545f --- /dev/null +++ b/scripts/lint/fixtures/docs.java @@ -0,0 +1,12 @@ +package fixtures; + +class Docs { + + /** + * @param blob the blob + * @param timeoutMs how long to wait before abandoning the read; the caller + * owns retrying, because only it knows whether the operation is + * idempotent + */ + void download(Blob blob, long timeoutMs) {} +} diff --git a/scripts/lint/fixtures/docstrings.py b/scripts/lint/fixtures/docstrings.py new file mode 100644 index 0000000000..b9c67023c7 --- /dev/null +++ b/scripts/lint/fixtures/docstrings.py @@ -0,0 +1,36 @@ +"""Module docstring, which the scanner must see as documentation.""" + + +def download(blob, timeout_ms): + """Fetch the blob. + + Args: + blob: The blob + timeout_ms: How long to wait before abandoning the read; the caller owns + retrying, because only it knows whether the operation is idempotent. + + Returns: + The fetched bytes. + """ + return read(blob) + + +def sphinx(blob): + """Fetch the blob. + + :param blob: The blob + :returns: the sphinx result + """ + return read(blob) + + +def payload(): + # Not documentation: a triple-quoted value, so its contents are data. + body = """{"key": "value", "note": "Types"}""" + return body + + +def canary(): + # Build document + document = build() + return document diff --git a/scripts/lint/fixtures/expected.json b/scripts/lint/fixtures/expected.json new file mode 100644 index 0000000000..34c89482a5 --- /dev/null +++ b/scripts/lint/fixtures/expected.json @@ -0,0 +1,63 @@ +{ + "AaaTest.java": [], + "allow.java": [ + "12:CMT008:error", + "17:CMT008:error", + "22:CMT008:error", + "28:CMT008:error", + "29:CMT001:error" + ], + "apostrophes.java": [ + "20:CMT001:error" + ], + "clean.java": [], + "clean.ts": [], + "deadcode.java": [ + "5:CMT005:error" + ], + "docs.java": [ + "6:CMT007:error" + ], + "docstrings.py": [ + "8:CMT007:error", + "21:CMT007:error", + "34:CMT001:error" + ], + "narration.java": [ + "6:CMT003:error", + "9:CMT003:error" + ], + "narration.tsx": [ + "2:CMT003:error", + "5:CMT004:error", + "13:CMT001:error" + ], + "restates.java": [ + "6:CMT001:error", + "9:CMT001:error", + "17:CMT002:error", + "19:CMT002:error" + ], + "restates.py": [ + "2:CMT001:error", + "5:CMT002:error" + ], + "restates.ts": [ + "2:CMT001:error", + "14:CMT002:error", + "18:CMT002:error" + ], + "strings.java": [], + "templates.py": [ + "22:CMT001:error" + ], + "todos.java": [ + "6:CMT009:error" + ], + "trailing.java": [ + "13:CMT009:error", + "14:CMT004:error", + "19:CMT009:error", + "20:CMT004:error" + ] +} diff --git a/scripts/lint/fixtures/narration.java b/scripts/lint/fixtures/narration.java new file mode 100644 index 0000000000..6dcb69b981 --- /dev/null +++ b/scripts/lint/fixtures/narration.java @@ -0,0 +1,22 @@ +package fixtures; + +class Narration { + + void export(Document document) { + // Step 1: collect the annotations + var annotations = document.annotations(); + + // Then, flatten them onto the page + document.flatten(annotations); + + // IMPORTANT: do not reorder these + document.save(); + + // No longer needed after the storage migration + legacyCleanup(); + + // Ordering matters: flatten() reads the annotation list that save() + // clears, so a save first loses every annotation. See #6865. + document.close(); + } +} diff --git a/scripts/lint/fixtures/narration.tsx b/scripts/lint/fixtures/narration.tsx new file mode 100644 index 0000000000..fa931ce925 --- /dev/null +++ b/scripts/lint/fixtures/narration.tsx @@ -0,0 +1,17 @@ +export function Panel() { + // Step 1: read the cap + const cap = useCap(); + + // This used to be initialised by the footer, which mounted after the banner. + useConsentBanner(); + + // CRITICAL: keep this above the early return + useLayoutEffect(() => sync(cap), [cap]); + + return ( +

+ {/* Cap editor */} + +
+ ); +} diff --git a/scripts/lint/fixtures/restates.java b/scripts/lint/fixtures/restates.java new file mode 100644 index 0000000000..47b2e004b8 --- /dev/null +++ b/scripts/lint/fixtures/restates.java @@ -0,0 +1,24 @@ +package fixtures; + +class Restates { + + void run(Registry registry, Job job) { + // Clear the registry + registry.clear(); + + // Sanitize filename + String safeFilename = sanitizeFilename(job.originalFilename()); + + // Wait for the worker to drain before clearing, or an in-flight job + // re-registers its temp file after the sweep. + registry.awaitQuiescence(); + } + + // ---------- Internal helpers ---------- + + // Types + private enum Mode { + FAST, + SAFE + } +} diff --git a/scripts/lint/fixtures/restates.py b/scripts/lint/fixtures/restates.py new file mode 100644 index 0000000000..b952a934c0 --- /dev/null +++ b/scripts/lint/fixtures/restates.py @@ -0,0 +1,9 @@ +def build(request): + # Get the current status + status = request.current_status() + + # ---- helpers ---- + + # Truncated to 200 chars because the audit column is varchar(200) and a + # longer value fails the insert rather than being trimmed. + return status[:200] diff --git a/scripts/lint/fixtures/restates.ts b/scripts/lint/fixtures/restates.ts new file mode 100644 index 0000000000..3b19eb1f10 --- /dev/null +++ b/scripts/lint/fixtures/restates.ts @@ -0,0 +1,20 @@ +export function useGrid() { + // Handle drag start + const handleDragStart = (event: DragStartEvent) => event.active.id; + + // Selection state + const selection = new Set(); + + // Debounced so a fast drag does not queue a layout pass per pointer move. + const updateLayout = debounce(() => measure(), 16); + + return { handleDragStart, selection, updateLayout }; +} + +// ─── Types ──────────────────────────────────────────────────────────────── + +export type Gate = "OFFSITE_PROCESSING" | "AUTOMATION"; + +// Helpers + +export function noop() {} diff --git a/scripts/lint/fixtures/strings.java b/scripts/lint/fixtures/strings.java new file mode 100644 index 0000000000..d3600ceb3d --- /dev/null +++ b/scripts/lint/fixtures/strings.java @@ -0,0 +1,11 @@ +package fixtures; + +class Strings { + + // A `//` inside a literal is not a comment, and neither is an escaped quote. + void urls() { + String docs = "https://example.com/guide"; + String quoted = "a \" then // not a comment"; + char slash = '/'; + } +} diff --git a/scripts/lint/fixtures/templates.py b/scripts/lint/fixtures/templates.py new file mode 100644 index 0000000000..86e11edea8 --- /dev/null +++ b/scripts/lint/fixtures/templates.py @@ -0,0 +1,24 @@ +"""Templates that open mid-line, whose closing delimiter starts a line.""" + +_TOOL_IO = ''' +TOOL_IO: dict[ToolEndpoint, ToolIOSpec] = {{ +{declarations} +}} +''' + +_HEADER = """ +# Header emitted into the output file. +# Types +""" + + +def resolve(schema): + if "$ref" in schema: + return lookup(schema["$ref"]) + return schema + + +def canary(): + # Build document + document = build() + return document diff --git a/scripts/lint/fixtures/todos.java b/scripts/lint/fixtures/todos.java new file mode 100644 index 0000000000..2287ca143e --- /dev/null +++ b/scripts/lint/fixtures/todos.java @@ -0,0 +1,25 @@ +package fixtures; + +class Todos { + + void bare() { + // TODO: re-enable once account syncing lands + skip(); + } + + void referenced() { + // TODO(#1234): re-enable once account syncing lands + skip(); + } + + void linked() { + // FIXME: the upstream fix is tracked at https://example.com/issues/9 + workaround(); + } + + void mentioned() { + // Image placeholders are not scored: their body text is a TODO marker + // rather than prose, so scoring it would reward the placeholder. + score(); + } +} diff --git a/scripts/lint/fixtures/trailing.java b/scripts/lint/fixtures/trailing.java new file mode 100644 index 0000000000..58077e4561 --- /dev/null +++ b/scripts/lint/fixtures/trailing.java @@ -0,0 +1,22 @@ +package fixtures; + +class Trailing { + + void decodings() { + byte[] header = {0x25, 0x50, 0x44, 0x46}; // "%PDF" + long maxSize = 50L * 1024 * 1024; // 50 MB + double buffer = 0.10; // 10% headroom + int mode = 2; // MB + } + + void stillJudged() { + boolean supportsSign = false; // TODO make Sign work + cleanup(); // this used to run before the flush + } + + void blockFormToo() { + int mode = 2; /* MB */ + boolean ready = false; /* TODO wire this up */ + reset(); /* this used to run before the flush */ + } +} From 1c055f3d1857e19e4e70c76436e95585abe83563 Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:27:05 +0000 Subject: [PATCH 14/27] Centre modals in the viewport instead of pinning them near the top (#7715) ## What Every dialog in the processor is the shared `.sui-modal` shell, and its backdrop was top-aligning the panel: ```css align-items: flex-start; padding: 5rem 1.5rem 1.5rem; /* 80px above, 24px below */ ``` On a 900px-tall viewport that started every dialog at `y=80` with ~350px of dead space beneath it. Phones already had an `align-items: center` override; desktop never got one. ## Change `frontend/editor/src/core/ui/Modal.css` only: - Symmetric block inset, `align-items: center`. - The inset is published as `--modal-inset-block`, and `.sui-modal`'s `max-height` derives from it. That coupling is the point: if the two drift apart, a tall modal overflows a centre-aligned backdrop and loses its header off the top of the screen, unreachable. - The phone breakpoint now only moves the variable. Measured at 375x812 it resolves to exactly the previous values (`16px 12px`, `max-height: 780px`), so mobile behaviour is unchanged. One shared file, so this covers flow modals, source / user / pipeline / API-key modals, billing and procurement. ## Before / After image ## Testing - `task frontend:check` passes (lint + typecheck + 2356 tests). - Phone breakpoint measured directly in the browser, values match the previous behaviour. --- frontend/editor/src/core/ui/Modal.css | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/frontend/editor/src/core/ui/Modal.css b/frontend/editor/src/core/ui/Modal.css index 2988ec920e..e41ffb0eda 100644 --- a/frontend/editor/src/core/ui/Modal.css +++ b/frontend/editor/src/core/ui/Modal.css @@ -1,11 +1,16 @@ +/* The inset is symmetric so the panel sits in the optical centre of the viewport rather than + riding the top edge. It is published as a var because .sui-modal's max-height has to be the + viewport minus both halves of it — if the two drift apart a tall modal overflows the backdrop + and, because the panel is centre-aligned, loses its header off the top of the screen. */ .sui-modal__backdrop { + --modal-inset-block: 2.5rem; position: fixed; inset: 0; background: rgba(0, 0, 0, 0.55); display: flex; - align-items: flex-start; + align-items: center; justify-content: center; - padding: 5rem 1.5rem 1.5rem; + padding: var(--modal-inset-block) 1.5rem; z-index: 100; animation: fadeIn 0.18s ease both; overscroll-behavior: contain; @@ -24,20 +29,19 @@ display: flex; flex-direction: column; width: 100%; - max-height: calc(100vh - 6.5rem); - max-height: calc(100dvh - 6.5rem); /* mobile browser chrome shrinks 100vh */ + max-height: calc(100vh - var(--modal-inset-block) * 2); + /* mobile browser chrome shrinks 100vh */ + max-height: calc(100dvh - var(--modal-inset-block) * 2); overflow: hidden; animation: scaleIn 0.2s cubic-bezier(0.4, 0, 0.2, 1) both; } -/* Phones: drop the tall top inset so the modal gets the vertical space */ +/* Phones: tighten the inset so the modal gets the vertical space. Only the variable moves — + the max-height above follows it, so the pair cannot fall out of step. */ @media (max-width: 30rem) { .sui-modal__backdrop { - padding: 1rem 0.75rem; - align-items: center; - } - .sui-modal { - max-height: calc(100dvh - 2rem); + --modal-inset-block: 1rem; + padding-inline: 0.75rem; } } From d3708c1e63f5161d1143b780f497c6f4b671f58a Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Fri, 28 Aug 2026 13:02:47 +0000 Subject: [PATCH 15/27] Highlight the rail entry whose tool is open (#7723) --- .../shared/quickNav/QuickNavHostBridge.tsx | 3 ++ .../shared/quickNav/QuickNavRailHost.tsx | 4 +++ .../contexts/QuickNavHostContext.test.tsx | 31 +++++++++++++++++++ .../src/core/contexts/QuickNavHostContext.tsx | 7 +++++ frontend/editor/src/core/pages/HomePage.tsx | 1 + 5 files changed, 46 insertions(+) diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavHostBridge.tsx b/frontend/editor/src/core/components/shared/quickNav/QuickNavHostBridge.tsx index 9a9101a826..d97674464e 100644 --- a/frontend/editor/src/core/components/shared/quickNav/QuickNavHostBridge.tsx +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavHostBridge.tsx @@ -22,6 +22,7 @@ export interface QuickNavHostBridgeProps { requestNavigation?: (go: () => void) => void; onGoToDefaultState?: () => void; onSelectTool?: (toolId: ToolId) => void; + activeTool?: ToolId | null; /** Merged over the reasons worked out here, for what only the app can see. */ toolReasons?: QuickNavToolReasons; } @@ -34,6 +35,7 @@ export function QuickNavHostBridge({ onOpenSettings, requestNavigation, onSelectTool, + activeTool = null, onGoToDefaultState, toolReasons, }: QuickNavHostBridgeProps) { @@ -59,6 +61,7 @@ export function QuickNavHostBridge({ signingBadge, portalAccess, readerMode, + activeTool, notificationsOpen, toolReasons: mergedToolReasons, }, diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailHost.tsx b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailHost.tsx index 3fba4d7e1f..e7a1b6e8c6 100644 --- a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailHost.tsx +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailHost.tsx @@ -48,6 +48,8 @@ export function QuickNavRailHost() { else go(route); }; + const openingTool = (id: ToolId) => ({ current: host?.activeTool === id }); + const unusable = (id: ToolId) => { const reason = host?.toolReasons?.[id]; return { disabled: Boolean(reason), reason }; @@ -135,6 +137,7 @@ export function QuickNavRailHost() { icon: ( ), + ...openingTool("automate"), ...unusable("automate"), onClick: () => openTool("automate", "/automate"), }, @@ -146,6 +149,7 @@ export function QuickNavRailHost() { ), badge: host?.signingBadge, badgeTone: "warning", + ...openingTool("sharedSign"), ...unusable("sharedSign"), onClick: () => openTool("sharedSign", "/shared-sign"), }, diff --git a/frontend/editor/src/core/contexts/QuickNavHostContext.test.tsx b/frontend/editor/src/core/contexts/QuickNavHostContext.test.tsx index fc648cbfe5..0a348262fa 100644 --- a/frontend/editor/src/core/contexts/QuickNavHostContext.test.tsx +++ b/frontend/editor/src/core/contexts/QuickNavHostContext.test.tsx @@ -13,6 +13,7 @@ function Probe({ onRead }: { onRead: (value: unknown) => void }) { appMounted: host?.appMounted, chromeless: host?.chromeless, identity: host?.identity, + activeTool: host?.activeTool, openSettings: Boolean(host?.actions.current?.openSettings), }); return null; @@ -26,6 +27,11 @@ function App() { return null; } +function AppWithTool({ tool }: { tool: "automate" | null }) { + useRegisterQuickNavHost({ activeTool: tool }, {}); + return null; +} + function LoginRoute() { useSuppressQuickNavRail(); return null; @@ -71,6 +77,31 @@ describe("QuickNavHostContext", () => { expect(after.openSettings).toBe(false); }); + it("clears the open tool when the next app registers without one", () => { + let latest: Record = {}; + const view = render( + + (latest = value as Record)} + /> + + , + ); + expect(latest.activeTool).toBe("automate"); + + act(() => { + view.rerender( + + (latest = value as Record)} + /> + + , + ); + }); + expect(latest.activeTool).toBe(null); + }); + it("hides the bar while a route with no app chrome is on screen", () => { // appMounted is sticky, so it can't answer "is an app on screen now". const { view, read } = setup(); diff --git a/frontend/editor/src/core/contexts/QuickNavHostContext.tsx b/frontend/editor/src/core/contexts/QuickNavHostContext.tsx index 540ec8cd6c..1a19cffc6f 100644 --- a/frontend/editor/src/core/contexts/QuickNavHostContext.tsx +++ b/frontend/editor/src/core/contexts/QuickNavHostContext.tsx @@ -24,6 +24,7 @@ export interface QuickNavHostData { signingBadge: number; portalAccess: boolean; readerMode: boolean; + activeTool: ToolId | null; /** The app owns the panel; the rail's bell only reports its state. */ notificationsOpen: boolean; /** Translated; absent means usable. */ @@ -61,6 +62,7 @@ const EMPTY_DATA: QuickNavHostData = { signingBadge: 0, portalAccess: false, readerMode: false, + activeTool: null, notificationsOpen: false, hasSettings: false, }; @@ -90,6 +92,7 @@ export function QuickNavHostProvider({ children }: { children: ReactNode }) { merged.signingBadge === prev.signingBadge && merged.portalAccess === prev.portalAccess && merged.readerMode === prev.readerMode && + merged.activeTool === prev.activeTool && merged.notificationsOpen === prev.notificationsOpen && merged.hasSettings === prev.hasSettings && merged.identity?.displayName === prev.identity?.displayName && @@ -143,6 +146,7 @@ export function useRegisterQuickNavHost( signingBadge, portalAccess, readerMode, + activeTool, notificationsOpen, toolReasons, } = data; @@ -155,6 +159,8 @@ export function useRegisterQuickNavHost( signingBadge: signingBadge ?? 0, portalAccess: portalAccess ?? false, readerMode: readerMode ?? false, + // Cleared, not omitted as toolReasons is: a stale tool marks an entry. + activeTool: activeTool ?? null, notificationsOpen: notificationsOpen ?? false, // Omitted when unknown, so the last answer survives a re-fetch. ...(toolReasons ? { toolReasons } : {}), @@ -168,6 +174,7 @@ export function useRegisterQuickNavHost( signingBadge, portalAccess, readerMode, + activeTool, notificationsOpen, toolReasons, hasSettings, diff --git a/frontend/editor/src/core/pages/HomePage.tsx b/frontend/editor/src/core/pages/HomePage.tsx index efb6af40d2..6434acbc9e 100644 --- a/frontend/editor/src/core/pages/HomePage.tsx +++ b/frontend/editor/src/core/pages/HomePage.tsx @@ -525,6 +525,7 @@ export default function HomePage() { onSetReaderMode={setReaderMode} onGoToDefaultState={goToDefaultState} onSelectTool={handleToolSelect} + activeTool={selectedToolKey} toolReasons={quickNavToolReasons} /> From c22d9ecf5801a75c84da927788a5bce2092c56d9 Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Sat, 29 Aug 2026 00:22:05 +0000 Subject: [PATCH 16/27] feat(editor): move the admin directory onto TanStack Query (#7726) # Description of Changes Step 5 of the TanStack Query rollout, covering the admin People, Teams and Team details screens. Follows #7264, #7283, #7285. ## The problem Two separate ones, in the same three files. **Reads.** Each section fetched and held its own copy of the same resources: People read the roster and the team list, Teams read the team list plus the roster again when its add-member modal opened, Team details read all three. Cost scaled with how many screens you visited rather than with how much data exists. **Writes.** Thirteen handlers each did the same five things by hand: set a processing flag, call the service, toast the outcome, dig a message out of an axios error, and reload their own slice. Refreshing was a convention, not a mechanism, and one handler had already forgotten it. ## The fix Three shared query keys (`adminUsers`, `teams`, `teamDetails`), and one `useAdminMutation` helper that every write is declared against: ```ts const createTeam = useAdminMutation({ write: (name: string) => teamService.createTeam(name), invalidates: ["teams"], success: t("workspace.teams.createTeam.success"), errorFallback: t("workspace.teams.createTeam.error"), onDone: () => { setNewTeamName(""); setCreateModalOpened(false); }, }); ``` Each write names the slices it disturbs, which is the part that only works when reads and writes are designed together: `createTeam` invalidates the team list, while a membership move invalidates the list, both teams' detail rows and the roster, because it genuinely changes all three. Invalidation refetches only mounted queries, so this costs nothing extra. The blanket "invalidate everything" helper survives in exactly one role: child components (invite, password change, seat update) that write through their own services, where the affected scopes are not visible from the call site. ## Why it is better, measured Request counts come from one harness driving `teams -> team details -> back -> people`, run against the branch point and against this branch. The assertion is committed, so it cannot silently regress. | | Before | After | |---|---|---| | Requests | 7 | **3** | | `getTeams` | 4 | **1** | | `getUsers` | 2 | **1** | | `getTeamDetails` | 1 | 1 | | Committed renders | 17 | **15** | Three is one per distinct resource, the floor for that sequence. The four `getTeams` were the Teams table, Team details fetching the same list for its "move to team" dropdown, the explicit refresh on the back button, and People. Renders barely move, which is expected: this changes where data lives, not how often React draws. It is reported because a caching change can quietly cost renders, and this one does not. On the code itself, across the three sections: | | | |---|---| | Net lines | **-216** | | `useState`/`useEffect` removed | **11**, none added | | Duplicated `isAxiosError` blocks | 13 to **1** | | `setProcessing` calls | 19 to **0** | `isAxiosError` is no longer imported by any of the three files. ## Bug fixed `disableMfaByAdmin` showed a success toast and never refreshed. The menu item renders only when `user.mfaEnabled` is true, so an admin disabled MFA, was told it worked, and watched the option stay on screen until a manual reload. It is covered by a test that fails if the invalidation is removed. ## Behaviour worth checking in review - A write no longer blocks its handler before closing the modal. The dialog closes when the write succeeds and the table updates when the refetch lands, rather than the button spinning through both. - Modal submit buttons now track their own mutation rather than one shared flag. Team details still derives a single busy flag, now from its five mutations rather than a `useState`, so its row actions disable together as before. - The per-handler `console.error` is kept, once, in the shared error path. ## Testing Five tests, each verified by breaking the implementation and confirming that one test, and only that one, fails: | Mutation | Caught by | |---|---| | Drop the shared stale window (`staleTime: 0`) | request-count test | | Make invalidation a no-op | write-visibility test | | Ignore the login-enabled gate | login-disabled test | | Stop invalidating after the MFA write | MFA-refresh test | | Fall back to the generic error message | server-message test | The write tests drive the real flows through their modals and menus rather than calling hooks directly. `task frontend:check` passes typecheck, lint and oxfmt, and 2383 of 2385 editor tests. The two failures, `workbenchSession.test.ts` and `notificationActions.test.tsx`, are untouched here and fail identically with this branch's changes reverted. ## Scope The three services keep their current shape; nothing outside these three sections and the new hook module changes. Child modals that write through their own services still refresh via the blanket helper, and converting those is separate work. --- frontend/editor/src/core/query/keys.ts | 4 + .../config/configSections/PeopleSection.tsx | 477 +++++++----------- .../configSections/TeamDetailsSection.tsx | 393 ++++++--------- .../config/configSections/TeamsSection.tsx | 216 +++----- .../config/configSections/adminReads.test.tsx | 295 +++++++++++ .../proprietary/hooks/useAdminDirectory.ts | 151 ++++++ 6 files changed, 885 insertions(+), 651 deletions(-) create mode 100644 frontend/editor/src/proprietary/components/shared/config/configSections/adminReads.test.tsx create mode 100644 frontend/editor/src/proprietary/hooks/useAdminDirectory.ts diff --git a/frontend/editor/src/core/query/keys.ts b/frontend/editor/src/core/query/keys.ts index 5354b56b63..d95f674011 100644 --- a/frontend/editor/src/core/query/keys.ts +++ b/frontend/editor/src/core/query/keys.ts @@ -1,5 +1,7 @@ /** Editor query keys: ["editor", , ...params]. */ export const qk = { + /** The admin directory payload: a different endpoint and shape to qk.users(). */ + adminUsers: () => ["editor", "adminUsers"] as const, appConfig: () => ["editor", "appConfig"] as const, endpointsAvailability: () => ["editor", "endpointsAvailability"] as const, endpointEnabled: (endpoint: string) => @@ -9,5 +11,7 @@ export const qk = { /** Keyed on the asking identity: two users must never share one answer. */ portalAccess: (userId: string | null) => ["editor", "portalAccess", userId] as const, + teamDetails: (teamId: number) => ["editor", "teamDetails", teamId] as const, + teams: () => ["editor", "teams"] as const, users: () => ["editor", "users"] as const, } as const; diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/PeopleSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/PeopleSection.tsx index d955cb341b..6b14d2b421 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/PeopleSection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/PeopleSection.tsx @@ -1,5 +1,4 @@ -import { useState, useEffect } from "react"; -import { isAxiosError } from "axios"; +import { useMemo, useState } from "react"; import { Trans, useTranslation } from "react-i18next"; import { Stack, @@ -20,14 +19,12 @@ import { import { Button } from "@app/ui/Button"; import { ActionIcon } from "@app/ui/ActionIcon"; import LocalIcon from "@app/components/shared/LocalIcon"; -import { alert } from "@app/components/toast"; import { userManagementService, User, } from "@app/services/userManagementService"; -import { teamService, Team } from "@app/services/teamService"; +import { type Team } from "@app/services/teamService"; import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex"; -import { useAppConfig } from "@app/contexts/AppConfigContext"; import InviteMembersModal from "@app/components/shared/InviteMembersModal"; import { useLoginRequired } from "@app/hooks/useLoginRequired"; import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner"; @@ -36,17 +33,109 @@ import UpdateSeatsButton from "@app/components/shared/UpdateSeatsButton"; import { useLicense } from "@app/contexts/LicenseContext"; import ChangeUserPasswordModal from "@app/components/shared/ChangeUserPasswordModal"; import { useAuth } from "@app/auth/UseSession"; +import { + useAdminUsers, + useTeams, + useAdminMutation, + useInvalidateAdminDirectory, +} from "@app/hooks/useAdminDirectory"; + +const EXAMPLE_USERS: User[] = [ + { + id: 1, + username: "admin", + email: "admin@example.com", + enabled: true, + roleName: "ROLE_ADMIN", + rolesAsString: "ROLE_ADMIN", + authenticationType: "password", + isActive: true, + lastRequest: Date.now(), + team: { id: 1, name: "Engineering" }, + }, + { + id: 2, + username: "john.doe", + email: "john.doe@example.com", + enabled: true, + roleName: "ROLE_USER", + rolesAsString: "ROLE_USER", + authenticationType: "password", + isActive: false, + lastRequest: Date.now() - 86400000, + team: { id: 1, name: "Engineering" }, + }, + { + id: 3, + username: "jane.smith", + email: "jane.smith@example.com", + enabled: true, + roleName: "ROLE_USER", + rolesAsString: "ROLE_USER", + authenticationType: "oauth", + isActive: true, + lastRequest: Date.now(), + team: { id: 2, name: "Marketing" }, + }, + { + id: 4, + username: "bob.wilson", + email: "bob.wilson@example.com", + enabled: false, + roleName: "ROLE_USER", + rolesAsString: "ROLE_USER", + authenticationType: "password", + isActive: false, + lastRequest: Date.now() - 604800000, + team: undefined, + }, +]; + +const EXAMPLE_TEAMS: Team[] = [ + { id: 1, name: "Engineering", userCount: 3 }, + { id: 2, name: "Marketing", userCount: 2 }, +]; + +const EXAMPLE_LICENSE = { + maxAllowedUsers: 10, + availableSlots: 6, + grandfatheredUserCount: 0, + licenseMaxUsers: 5, + premiumEnabled: true, + totalUsers: 4, +}; export default function PeopleSection() { const { t } = useTranslation(); - const { config } = useAppConfig(); const { loginEnabled } = useLoginRequired(); const { user: currentUser } = useAuth(); const navigate = useNavigate(); const { licenseInfo: globalLicenseInfo } = useLicense(); - const [users, setUsers] = useState([]); - const [teams, setTeams] = useState([]); - const [loading, setLoading] = useState(true); + const admin = useAdminUsers(loginEnabled); + const { data: fetchedTeams } = useTeams(loginEnabled); + const refreshDirectory = useInvalidateAdminDirectory(); + + // Session and MFA state arrive alongside the roster, keyed by username. + const fetchedUsers = useMemo(() => { + if (!admin.data) return []; + return admin.data.users.map((user) => ({ + ...user, + isActive: admin.data.userSessions[user.username] || false, + lastRequest: admin.data.userLastRequest[user.username] || undefined, + mfaEnabled: + ( + admin.data.userSettings?.[user.username] as + | Record + | undefined + )?.mfaEnabled === "true", + })); + }, [admin.data]); + + // Login off means the endpoints are not callable, so the table shows a + // worked example instead of an empty state. + const users = loginEnabled ? fetchedUsers : EXAMPLE_USERS; + const teams = loginEnabled ? (fetchedTeams ?? []) : EXAMPLE_TEAMS; + const loading = loginEnabled && admin.isPending; const [searchQuery, setSearchQuery] = useState(""); const [inviteModalOpened, setInviteModalOpened] = useState(false); const [editUserModalOpened, setEditUserModalOpened] = useState(false); @@ -54,19 +143,20 @@ export default function PeopleSection() { useState(false); const [passwordUser, setPasswordUser] = useState(null); const [selectedUser, setSelectedUser] = useState(null); - const [processing, setProcessing] = useState(false); - const [mailEnabled, setMailEnabled] = useState(false); - const [lockedUsers, setLockedUsers] = useState([]); - - // License information - const [licenseInfo, setLicenseInfo] = useState<{ - maxAllowedUsers: number; - availableSlots: number; - grandfatheredUserCount: number; - licenseMaxUsers: number; - premiumEnabled: boolean; - totalUsers: number; - } | null>(null); + const mailEnabled = loginEnabled ? (admin.data?.mailEnabled ?? false) : false; + const lockedUsers = loginEnabled ? (admin.data?.lockedUsers ?? []) : []; + const licenseInfo = loginEnabled + ? admin.data + ? { + maxAllowedUsers: admin.data.maxAllowedUsers, + availableSlots: admin.data.availableSlots, + grandfatheredUserCount: admin.data.grandfatheredUserCount, + licenseMaxUsers: admin.data.licenseMaxUsers, + premiumEnabled: admin.data.premiumEnabled, + totalUsers: admin.data.totalUsers, + } + : null + : EXAMPLE_LICENSE; const hasNoSlots = licenseInfo ? licenseInfo.availableSlots === 0 : false; const handleAddMembersClick = () => { if (!loginEnabled) { @@ -115,253 +205,103 @@ export default function PeopleSection() { teamId: undefined as number | undefined, }); - useEffect(() => { - fetchData(); - }, []); + const updateUserRole = useAdminMutation({ + write: (payload: { username: string; role: string; teamId?: number }) => + userManagementService.updateUserRole(payload), + // A role edit can also move the user, which changes both teams' counts. + invalidates: ["users", "teams"], + success: t("workspace.people.editMember.success"), + errorFallback: t("workspace.people.editMember.error"), + onDone: () => closeEditModal(), + }); - useEffect(() => { - if (config) { - console.log( - "[PeopleSection] Email invites enabled:", - config.enableEmailInvites, - ); - } - }, [config]); + const toggleEnabled = useAdminMutation({ + write: (user: User) => + userManagementService.toggleUserEnabled(user.username, !user.enabled), + invalidates: ["users"], + success: t("workspace.people.toggleEnabled.success"), + errorFallback: t("workspace.people.toggleEnabled.error"), + }); - const fetchData = async () => { - try { - setLoading(true); + const deleteUser = useAdminMutation({ + write: (username: string) => userManagementService.deleteUser(username), + invalidates: ["users", "teams"], + success: t( + "workspace.people.deleteUserSuccess", + "User deleted successfully", + ), + errorFallback: t( + "workspace.people.deleteUserError", + "Failed to delete user", + ), + }); - if (loginEnabled) { - const [adminData, teamsData] = await Promise.all([ - userManagementService.getUsers(), - teamService.getTeams(), - ]); + const unlockUser = useAdminMutation({ + write: (username: string) => userManagementService.unlockUser(username), + invalidates: ["users"], + success: t( + "workspace.people.unlockUserSuccess", + "User account unlocked successfully", + ), + errorFallback: t( + "workspace.people.unlockUserError", + "Failed to unlock user account", + ), + }); - // Enrich users with session data - const enrichedUsers = adminData.users.map((user) => ({ - ...user, - isActive: adminData.userSessions[user.username] || false, - lastRequest: adminData.userLastRequest[user.username] || undefined, - mfaEnabled: - ( - adminData.userSettings?.[user.username] as - | Record - | undefined - )?.mfaEnabled === "true", - })); + const disableMfa = useAdminMutation({ + write: (username: string) => + userManagementService.disableMfaByAdmin(username), + invalidates: ["users"], + success: t( + "workspace.people.mfa.adminDisableSuccess", + "MFA disabled successfully for user", + ), + errorFallback: t( + "workspace.people.mfa.adminDisableError", + "Failed to disable MFA for user", + ), + }); - setUsers(enrichedUsers); - setTeams(teamsData); - - // Store license information - setLicenseInfo({ - maxAllowedUsers: adminData.maxAllowedUsers, - availableSlots: adminData.availableSlots, - grandfatheredUserCount: adminData.grandfatheredUserCount, - licenseMaxUsers: adminData.licenseMaxUsers, - premiumEnabled: adminData.premiumEnabled, - totalUsers: adminData.totalUsers, - }); - setMailEnabled(adminData.mailEnabled); - setLockedUsers(adminData.lockedUsers || []); - } else { - // Provide example data when login is disabled - const exampleUsers: User[] = [ - { - id: 1, - username: "admin", - email: "admin@example.com", - enabled: true, - roleName: "ROLE_ADMIN", - rolesAsString: "ROLE_ADMIN", - authenticationType: "password", - isActive: true, - lastRequest: Date.now(), - team: { id: 1, name: "Engineering" }, - }, - { - id: 2, - username: "john.doe", - email: "john.doe@example.com", - enabled: true, - roleName: "ROLE_USER", - rolesAsString: "ROLE_USER", - authenticationType: "password", - isActive: false, - lastRequest: Date.now() - 86400000, - team: { id: 1, name: "Engineering" }, - }, - { - id: 3, - username: "jane.smith", - email: "jane.smith@example.com", - enabled: true, - roleName: "ROLE_USER", - rolesAsString: "ROLE_USER", - authenticationType: "oauth", - isActive: true, - lastRequest: Date.now(), - team: { id: 2, name: "Marketing" }, - }, - { - id: 4, - username: "bob.wilson", - email: "bob.wilson@example.com", - enabled: false, - roleName: "ROLE_USER", - rolesAsString: "ROLE_USER", - authenticationType: "password", - isActive: false, - lastRequest: Date.now() - 604800000, - team: undefined, - }, - ]; - - const exampleTeams: Team[] = [ - { id: 1, name: "Engineering", userCount: 3 }, - { id: 2, name: "Marketing", userCount: 2 }, - ]; - - setUsers(exampleUsers); - setTeams(exampleTeams); - setMailEnabled(false); - setLockedUsers([]); - - // Example license information - setLicenseInfo({ - maxAllowedUsers: 10, - availableSlots: 6, - grandfatheredUserCount: 0, - licenseMaxUsers: 5, - premiumEnabled: true, - totalUsers: 4, - }); - } - } catch (error) { - console.error("[PeopleSection] Failed to fetch people data:", error); - alert({ alertType: "error", title: "Failed to load people data" }); - } finally { - setLoading(false); - } - }; - - const handleUpdateUserRole = async () => { + const handleUpdateUserRole = () => { if (!selectedUser) return; - - try { - setProcessing(true); - await userManagementService.updateUserRole({ - username: selectedUser.username, - role: editForm.role, - teamId: editForm.teamId, - }); - alert({ - alertType: "success", - title: t("workspace.people.editMember.success"), - }); - closeEditModal(); - fetchData(); - } catch (error: unknown) { - console.error("[PeopleSection] Failed to update user:", error); - const errorMessage = isAxiosError(error) - ? error.response?.data?.message || - error.response?.data?.error || - error.message - : (error instanceof Error ? error.message : undefined) || - t("workspace.people.editMember.error"); - alert({ alertType: "error", title: errorMessage }); - } finally { - setProcessing(false); - } + updateUserRole.mutate({ + username: selectedUser.username, + role: editForm.role, + teamId: editForm.teamId, + }); }; - const handleToggleEnabled = async (user: User) => { - try { - await userManagementService.toggleUserEnabled( - user.username, - !user.enabled, - ); - alert({ - alertType: "success", - title: t("workspace.people.toggleEnabled.success"), - }); - fetchData(); - } catch (error: unknown) { - console.error("[PeopleSection] Failed to toggle user status:", error); - const errorMessage = isAxiosError(error) - ? error.response?.data?.message || - error.response?.data?.error || - error.message - : (error instanceof Error ? error.message : undefined) || - t("workspace.people.toggleEnabled.error"); - alert({ alertType: "error", title: errorMessage }); - } + const handleToggleEnabled = (user: User) => { + toggleEnabled.mutate(user); }; - const handleDeleteUser = async (user: User) => { + const handleDeleteUser = (user: User) => { const confirmMessage = t( "workspace.people.confirmDelete", "Are you sure you want to delete this user? This action cannot be undone.", ); - if (!window.confirm(`${confirmMessage}\n\nUser: ${user.username}`)) { - return; - } + if ( + !window.confirm(`${confirmMessage} - try { - await userManagementService.deleteUser(user.username); - alert({ - alertType: "success", - title: t( - "workspace.people.deleteUserSuccess", - "User deleted successfully", - ), - }); - fetchData(); - } catch (error: unknown) { - console.error("[PeopleSection] Failed to delete user:", error); - const errorMessage = isAxiosError(error) - ? error.response?.data?.message || - error.response?.data?.error || - error.message - : (error instanceof Error ? error.message : undefined) || - t("workspace.people.deleteUserError", "Failed to delete user"); - alert({ alertType: "error", title: errorMessage }); - } +User: ${user.username}`) + ) + return; + deleteUser.mutate(user.username); }; - const handleUnlockUser = async (user: User) => { + const handleUnlockUser = (user: User) => { const confirmMessage = t( "workspace.people.confirmUnlock", "Are you sure you want to unlock this user account?", ); - if (!window.confirm(`${confirmMessage}\n\nUser: ${user.username}`)) { - return; - } + if ( + !window.confirm(`${confirmMessage} - try { - await userManagementService.unlockUser(user.username); - alert({ - alertType: "success", - title: t( - "workspace.people.unlockUserSuccess", - "User account unlocked successfully", - ), - }); - fetchData(); - } catch (error: unknown) { - console.error("[PeopleSection] Failed to unlock user:", error); - const errorMessage = isAxiosError(error) - ? error.response?.data?.message || - error.response?.data?.error || - error.message - : (error instanceof Error ? error.message : undefined) || - t( - "workspace.people.unlockUserError", - "Failed to unlock user account", - ); - alert({ alertType: "error", title: errorMessage }); - } +User: ${user.username}`) + ) + return; + unlockUser.mutate(user.username); }; const openEditModal = (user: User) => { @@ -549,7 +489,7 @@ export default function PeopleSection() { - + )} @@ -891,40 +831,7 @@ export default function PeopleSection() { height="1rem" /> } - onClick={async () => { - try { - await userManagementService.disableMfaByAdmin( - user.username, - ); - alert({ - alertType: "success", - title: t( - "workspace.people.mfa.adminDisableSuccess", - "MFA disabled successfully for user", - ), - }); - } catch (error: unknown) { - console.error( - "[PeopleSection] Failed to disable MFA for user:", - error, - ); - const errorMessage = isAxiosError(error) - ? error.response?.data?.message || - error.response?.data?.error || - error.message - : (error instanceof Error - ? error.message - : undefined) || - t( - "workspace.people.mfa.adminDisableError", - "Failed to disable MFA for user", - ); - alert({ - alertType: "error", - title: errorMessage, - }); - } - }} + onClick={() => disableMfa.mutate(user.username)} disabled={!loginEnabled} > {t( @@ -968,14 +875,14 @@ export default function PeopleSection() { setInviteModalOpened(false)} - onSuccess={fetchData} + onSuccess={refreshDirectory} /> @@ -1075,7 +982,7 @@ export default function PeopleSection() { /> @@ -186,7 +188,7 @@ export function FormSaveBar({ loading={saving} disabled={applying || policyEnforcing} onClick={handleDownload} - style={{ flex: 1 }} + style={{ flex: "1 1 10rem", minWidth: 0 }} > {t("viewer.formBar.download", "Download PDF")} From 8c00fffe185d027d022befac6f8d10216fab7472 Mon Sep 17 00:00:00 2001 From: brios <127139797+balazs-szucs@users.noreply.github.com> Date: Sun, 30 Aug 2026 00:04:06 +0200 Subject: [PATCH 24/27] refactor(api): replace com.fasterxml.jackson with tools.jackson (Jackson 2 to Jackson 3 namespace.) (#7444) Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> --- .../cluster/valkey/ValkeyJobStore.java | 14 +++--- .../storage/converter/JsonMapConverter.java | 16 +++---- .../controller/SigningSessionController.java | 4 +- .../workflow/util/WorkflowMapper.java | 4 +- .../ai/controller/AiCreateController.java | 12 ++--- .../AiCreateInternalController.java | 12 ++--- .../service/StripeUsageReportingService.java | 2 +- .../saas/legal/LegalDocumentRegistry.java | 44 +++++++++---------- .../payg/entitlement/EntitlementGuard.java | 2 +- .../api/ProcurementController.java | 2 +- .../procurement/legal/AgreementAssembler.java | 2 +- .../KeygenEnterpriseLicenseService.java | 4 +- .../service/ProcurementService.java | 6 +-- .../entitlement/EntitlementGuardTest.java | 4 +- 14 files changed, 63 insertions(+), 65 deletions(-) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyJobStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyJobStore.java index 8e93871859..750abea4fc 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyJobStore.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyJobStore.java @@ -17,16 +17,16 @@ import org.springframework.data.redis.core.ScanOptions; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Component; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; - import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.common.cluster.JobStore; import stirling.software.common.cluster.JobStoreEntry; +import tools.jackson.core.JacksonException; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.ObjectMapper; + /** * Valkey-backed {@link JobStore}. Each job is one hash; a reverse index maps fileId to jobId. * @@ -265,7 +265,7 @@ public class ValkeyJobStore implements JobStore { } try { return MAPPER.readValue(v.toString(), MAP_STRING); - } catch (JsonProcessingException e) { + } catch (JacksonException e) { log.warn( "JobStore {} field 'resultMeta' is not valid JSON '{}' - treating as empty", key, @@ -277,7 +277,7 @@ public class ValkeyJobStore implements JobStore { private static String writeJson(Object value) { try { return MAPPER.writeValueAsString(value); - } catch (JsonProcessingException e) { + } catch (JacksonException e) { throw new IllegalStateException("Failed to JSON-serialize JobStore field", e); } } @@ -286,7 +286,7 @@ public class ValkeyJobStore implements JobStore { try { List parsed = MAPPER.readValue(json, LIST_STRING); return parsed == null ? new ArrayList<>() : parsed; - } catch (JsonProcessingException e) { + } catch (JacksonException e) { log.warn( "JobStore {} field 'fileIds' is not valid JSON '{}' - treating as empty", key, diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/converter/JsonMapConverter.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/converter/JsonMapConverter.java index 1c9f7ab765..5576ebb181 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/converter/JsonMapConverter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/converter/JsonMapConverter.java @@ -3,16 +3,16 @@ package stirling.software.proprietary.storage.converter; import java.util.HashMap; import java.util.Map; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; - import jakarta.persistence.AttributeConverter; import jakarta.persistence.Converter; import lombok.extern.slf4j.Slf4j; +import tools.jackson.core.JacksonException; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; + /** * JPA AttributeConverter for storing Map as JSON in database columns. * @@ -33,7 +33,7 @@ public class JsonMapConverter implements AttributeConverter, try { return objectMapper.writeValueAsString(attribute); - } catch (JsonProcessingException e) { + } catch (JacksonException e) { log.error("Failed to convert map to JSON", e); throw new RuntimeException("Failed to convert map to JSON", e); } @@ -48,7 +48,7 @@ public class JsonMapConverter implements AttributeConverter, try { // Try normal parsing first return objectMapper.readValue(dbData, new TypeReference>() {}); - } catch (JsonProcessingException e) { + } catch (JacksonException e) { // Fallback: try double-parsing for legacy double-encoded data // This handles data that was stored as JSON strings instead of JSON objects log.debug("Attempting double-decode fallback for legacy metadata format"); @@ -69,7 +69,7 @@ public class JsonMapConverter implements AttributeConverter, return objectMapper.readValue( node.asText(), new TypeReference>() {}); } - } catch (JsonProcessingException e2) { + } catch (JacksonException e2) { log.error("Failed to parse metadata even with double-decode fallback", e2); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/SigningSessionController.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/SigningSessionController.java index 4e95707217..73867776e9 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/SigningSessionController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/SigningSessionController.java @@ -20,8 +20,6 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.server.ResponseStatusException; -import com.fasterxml.jackson.databind.ObjectMapper; - import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -44,6 +42,8 @@ import stirling.software.proprietary.workflow.service.CertificateSubmissionValid import stirling.software.proprietary.workflow.service.SigningFinalizationService; import stirling.software.proprietary.workflow.service.WorkflowSessionService; +import tools.jackson.databind.ObjectMapper; + @Slf4j @RestController @RequestMapping("/api/v1/security") diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/util/WorkflowMapper.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/util/WorkflowMapper.java index b2f53c2824..8d61ae032c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/util/WorkflowMapper.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/util/WorkflowMapper.java @@ -4,14 +4,14 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; -import com.fasterxml.jackson.databind.ObjectMapper; - import stirling.software.proprietary.workflow.dto.ParticipantResponse; import stirling.software.proprietary.workflow.dto.WetSignatureMetadata; import stirling.software.proprietary.workflow.dto.WorkflowSessionResponse; import stirling.software.proprietary.workflow.model.WorkflowParticipant; import stirling.software.proprietary.workflow.model.WorkflowSession; +import tools.jackson.databind.ObjectMapper; + /** * Utility class for mapping workflow entities to DTOs. Centralizes conversion logic for consistent * API responses. diff --git a/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateController.java b/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateController.java index 28bbfbdea0..77a87a27bb 100644 --- a/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateController.java +++ b/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateController.java @@ -26,8 +26,8 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.tags.Tag; @@ -167,7 +167,7 @@ public class AiCreateController { if (request.constraints() != null) { try { constraintsPayload = objectMapper.writeValueAsString(request.constraints()); - } catch (JsonProcessingException exc) { + } catch (JacksonException exc) { throw new ResponseStatusException( HttpStatus.BAD_REQUEST, "Invalid constraints payload", exc); } @@ -202,7 +202,7 @@ public class AiCreateController { String payload; try { payload = objectMapper.writeValueAsString(request.draftSections()); - } catch (JsonProcessingException exc) { + } catch (JacksonException exc) { throw new ResponseStatusException( HttpStatus.BAD_REQUEST, "Invalid draft sections payload", exc); } @@ -392,7 +392,7 @@ public class AiCreateController { objectMapper .getTypeFactory() .constructCollectionType(List.class, DraftSection.class)); - } catch (JsonProcessingException exc) { + } catch (JacksonException exc) { log.warn("Failed to parse draft sections payload", exc); return null; } @@ -408,7 +408,7 @@ public class AiCreateController { objectMapper .getTypeFactory() .constructMapType(Map.class, String.class, Object.class)); - } catch (JsonProcessingException exc) { + } catch (JacksonException exc) { log.warn("Failed to parse outline constraints payload", exc); return null; } diff --git a/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateInternalController.java b/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateInternalController.java index 60c8dc4615..04b8302e7e 100644 --- a/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateInternalController.java +++ b/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateInternalController.java @@ -14,8 +14,8 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.tags.Tag; @@ -61,7 +61,7 @@ public class AiCreateInternalController { try { outlineConstraintsPayload = objectMapper.writeValueAsString(request.outlineConstraints()); - } catch (JsonProcessingException exc) { + } catch (JacksonException exc) { throw new ResponseStatusException( HttpStatus.BAD_REQUEST, "Invalid outline constraints payload", exc); } @@ -70,7 +70,7 @@ public class AiCreateInternalController { if (request.draftSections() != null) { try { draftSectionsPayload = objectMapper.writeValueAsString(request.draftSections()); - } catch (JsonProcessingException exc) { + } catch (JacksonException exc) { throw new ResponseStatusException( HttpStatus.BAD_REQUEST, "Invalid draft sections payload", exc); } @@ -136,7 +136,7 @@ public class AiCreateInternalController { .getTypeFactory() .constructCollectionType( List.class, AiCreateController.DraftSection.class)); - } catch (JsonProcessingException exc) { + } catch (JacksonException exc) { log.warn("Failed to parse draft sections payload", exc); return null; } @@ -152,7 +152,7 @@ public class AiCreateInternalController { objectMapper .getTypeFactory() .constructMapType(Map.class, String.class, Object.class)); - } catch (JsonProcessingException exc) { + } catch (JacksonException exc) { log.warn("Failed to parse outline constraints payload", exc); return null; } diff --git a/app/saas/src/main/java/stirling/software/saas/billing/service/StripeUsageReportingService.java b/app/saas/src/main/java/stirling/software/saas/billing/service/StripeUsageReportingService.java index d9445483a3..ae5d3d943a 100644 --- a/app/saas/src/main/java/stirling/software/saas/billing/service/StripeUsageReportingService.java +++ b/app/saas/src/main/java/stirling/software/saas/billing/service/StripeUsageReportingService.java @@ -12,7 +12,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; diff --git a/app/saas/src/main/java/stirling/software/saas/legal/LegalDocumentRegistry.java b/app/saas/src/main/java/stirling/software/saas/legal/LegalDocumentRegistry.java index ffbe030863..48dd6a1c3b 100644 --- a/app/saas/src/main/java/stirling/software/saas/legal/LegalDocumentRegistry.java +++ b/app/saas/src/main/java/stirling/software/saas/legal/LegalDocumentRegistry.java @@ -13,8 +13,8 @@ import java.util.regex.Pattern; import org.springframework.core.io.ClassPathResource; import org.springframework.stereotype.Service; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; import jakarta.annotation.PostConstruct; @@ -56,28 +56,26 @@ public class LegalDocumentRegistry { subprocessorUrl = root.path("subprocessorUrl").asText(""); eulaUrl = root.path("eulaUrl").asText(""); JsonNode docs = root.path("documents"); - docs.fieldNames() - .forEachRemaining( - id -> { - JsonNode d = docs.get(id); - List parts = - objectMapper.convertValue( - d.path("parts"), - objectMapper - .getTypeFactory() - .constructCollectionType( - List.class, String.class)); - documents.put( + docs.forEachEntry( + (id, d) -> { + List parts = + objectMapper.convertValue( + d.path("parts"), + objectMapper + .getTypeFactory() + .constructCollectionType( + List.class, String.class)); + documents.put( + id, + new LegalDocumentMeta( id, - new LegalDocumentMeta( - id, - d.path("label").asText(id), - d.path("displayName").asText(id), - d.path("version").asText("0"), - d.path("effectiveDate").asText(""), - d.path("status").asText("draft"), - parts == null ? List.of() : parts)); - }); + d.path("label").asText(id), + d.path("displayName").asText(id), + d.path("version").asText("0"), + d.path("effectiveDate").asText(""), + d.path("status").asText("draft"), + parts == null ? List.of() : parts)); + }); log.info("[legal] loaded {} document(s) from {}", documents.size(), MANIFEST); } diff --git a/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementGuard.java b/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementGuard.java index ae8474fa42..441ebf1220 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementGuard.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementGuard.java @@ -20,7 +20,7 @@ import org.springframework.stereotype.Component; import org.springframework.web.method.HandlerMethod; import org.springframework.web.servlet.HandlerInterceptor; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.ObjectMapper; import io.micrometer.core.instrument.Counter; import io.micrometer.core.instrument.MeterRegistry; diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java b/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java index 1c495b3afd..c08778bdbc 100644 --- a/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java +++ b/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java @@ -18,7 +18,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.ObjectMapper; import io.swagger.v3.oas.annotations.Hidden; diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/legal/AgreementAssembler.java b/app/saas/src/main/java/stirling/software/saas/procurement/legal/AgreementAssembler.java index c641f2e596..e75f3cbb32 100644 --- a/app/saas/src/main/java/stirling/software/saas/procurement/legal/AgreementAssembler.java +++ b/app/saas/src/main/java/stirling/software/saas/procurement/legal/AgreementAssembler.java @@ -10,7 +10,7 @@ import java.util.Map; import org.springframework.stereotype.Service; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/license/KeygenEnterpriseLicenseService.java b/app/saas/src/main/java/stirling/software/saas/procurement/license/KeygenEnterpriseLicenseService.java index a11ad05755..a1c09a9cb9 100644 --- a/app/saas/src/main/java/stirling/software/saas/procurement/license/KeygenEnterpriseLicenseService.java +++ b/app/saas/src/main/java/stirling/software/saas/procurement/license/KeygenEnterpriseLicenseService.java @@ -16,8 +16,8 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java b/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java index a739e538b2..26bfb733dd 100644 --- a/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java +++ b/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java @@ -10,8 +10,8 @@ import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; @@ -707,7 +707,7 @@ public class ProcurementService { private String writeLineItems(QuoteBreakdown breakdown) { try { return OBJECT_MAPPER.writeValueAsString(breakdown.lineItems()); - } catch (JsonProcessingException e) { + } catch (JacksonException e) { log.warn("[procurement] failed to serialise line items", e); return "[]"; } diff --git a/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementGuardTest.java b/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementGuardTest.java index 0a77de3e7e..a9ca7642a6 100644 --- a/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementGuardTest.java +++ b/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementGuardTest.java @@ -28,8 +28,8 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.web.method.HandlerMethod; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.simple.SimpleMeterRegistry; From 74be5bf0ad0bbf592b8944cb2a8677f0c4055ff0 Mon Sep 17 00:00:00 2001 From: brios <127139797+balazs-szucs@users.noreply.github.com> Date: Sun, 30 Aug 2026 00:04:35 +0200 Subject: [PATCH 25/27] fix(forms): Fix checkbox export values and wide dropdown options (#7288) Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> --- frontend/editor/src/core/tools/formFill/FieldInput.tsx | 7 +++++-- .../editor/src/core/tools/formFill/FormFieldOverlay.tsx | 9 ++++++--- .../core/tools/formFill/providers/PdfiumFormProvider.ts | 2 +- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/frontend/editor/src/core/tools/formFill/FieldInput.tsx b/frontend/editor/src/core/tools/formFill/FieldInput.tsx index 9662298dc4..9a8943ea47 100644 --- a/frontend/editor/src/core/tools/formFill/FieldInput.tsx +++ b/frontend/editor/src/core/tools/formFill/FieldInput.tsx @@ -68,8 +68,11 @@ function FieldInputInner({ ); case "checkbox": { - const isChecked = !!value && value !== "Off"; - const onValue = (field.widgets && field.widgets[0]?.exportValue) || "Yes"; + const exportVal = field.widgets && field.widgets[0]?.exportValue; + const isChecked = exportVal + ? value === exportVal || value === "Yes" + : !!value && value !== "Off"; + const onValue = exportVal || "Yes"; return ( Date: Sun, 30 Aug 2026 00:11:41 +0200 Subject: [PATCH 26/27] chore(crop): Remove invalid crop area message and related validation logic (#7160) Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> --- .../public/locales/ar-AR/translation.toml | 1 - .../public/locales/az-AZ/translation.toml | 1 - .../public/locales/bg-BG/translation.toml | 1 - .../public/locales/bo-CN/translation.toml | 1 - .../public/locales/ca-CA/translation.toml | 1 - .../public/locales/cs-CZ/translation.toml | 1 - .../public/locales/da-DK/translation.toml | 1 - .../public/locales/de-DE/translation.toml | 1 - .../public/locales/el-GR/translation.toml | 1 - .../public/locales/en-GB/translation.toml | 1 - .../public/locales/en-US/translation.toml | 1 - .../public/locales/es-ES/translation.toml | 1 - .../public/locales/eu-ES/translation.toml | 1 - .../public/locales/fa-IR/translation.toml | 1 - .../public/locales/fr-FR/translation.toml | 1 - .../public/locales/ga-IE/translation.toml | 1 - .../public/locales/hi-IN/translation.toml | 1 - .../public/locales/hr-HR/translation.toml | 1 - .../public/locales/hu-HU/translation.toml | 1 - .../public/locales/id-ID/translation.toml | 1 - .../public/locales/it-IT/translation.toml | 1 - .../public/locales/ja-JP/translation.toml | 1 - .../public/locales/ko-KR/translation.toml | 1 - .../public/locales/ml-ML/translation.toml | 1 - .../public/locales/nl-NL/translation.toml | 1 - .../public/locales/no-NB/translation.toml | 1 - .../public/locales/pl-PL/translation.toml | 1 - .../public/locales/pt-BR/translation.toml | 1 - .../public/locales/pt-PT/translation.toml | 1 - .../public/locales/ro-RO/translation.toml | 1 - .../public/locales/ru-RU/translation.toml | 1 - .../public/locales/sk-SK/translation.toml | 1 - .../public/locales/sl-SI/translation.toml | 1 - .../locales/sr-LATN-RS/translation.toml | 1 - .../public/locales/sv-SE/translation.toml | 1 - .../public/locales/th-TH/translation.toml | 1 - .../public/locales/tr-TR/translation.toml | 1 - .../public/locales/uk-UA/translation.toml | 1 - .../public/locales/vi-VN/translation.toml | 1 - .../public/locales/zh-BO/translation.toml | 1 - .../public/locales/zh-CN/translation.toml | 1 - .../public/locales/zh-TW/translation.toml | 1 - .../components/tools/crop/CropSettings.tsx | 23 +---------- .../hooks/tools/crop/useCropParameters.ts | 39 ++++++++----------- .../editor/src/core/utils/cropCoordinates.ts | 16 +++++++- 45 files changed, 32 insertions(+), 88 deletions(-) diff --git a/frontend/editor/public/locales/ar-AR/translation.toml b/frontend/editor/public/locales/ar-AR/translation.toml index 4c4d45e2b4..4180d77811 100644 --- a/frontend/editor/public/locales/ar-AR/translation.toml +++ b/frontend/editor/public/locales/ar-AR/translation.toml @@ -3526,7 +3526,6 @@ label = "إحداثي Y" [crop.error] failed = "فشل قصّ PDF" -invalidArea = "منطقة القص تتجاوز حدود PDF" [crop.preview] title = "معاينة منطقة القص" diff --git a/frontend/editor/public/locales/az-AZ/translation.toml b/frontend/editor/public/locales/az-AZ/translation.toml index f73930133e..0033be05d5 100644 --- a/frontend/editor/public/locales/az-AZ/translation.toml +++ b/frontend/editor/public/locales/az-AZ/translation.toml @@ -3526,7 +3526,6 @@ label = "Y mövqeyi" [crop.error] failed = "PDF-i kəsmək alınmadı" -invalidArea = "Kəsmə sahəsi PDF sərhədlərini aşır" [crop.preview] title = "Kəsmə sahəsinin seçimi" diff --git a/frontend/editor/public/locales/bg-BG/translation.toml b/frontend/editor/public/locales/bg-BG/translation.toml index dc6ef92e39..07cf5a73d3 100644 --- a/frontend/editor/public/locales/bg-BG/translation.toml +++ b/frontend/editor/public/locales/bg-BG/translation.toml @@ -3526,7 +3526,6 @@ label = "Y позиция" [crop.error] failed = "Неуспешно изрязване на PDF" -invalidArea = "Областта за изрязване излиза извън границите на PDF" [crop.preview] title = "Избор на област за изрязване" diff --git a/frontend/editor/public/locales/bo-CN/translation.toml b/frontend/editor/public/locales/bo-CN/translation.toml index 86d0b4f10d..21193f5f2d 100644 --- a/frontend/editor/public/locales/bo-CN/translation.toml +++ b/frontend/editor/public/locales/bo-CN/translation.toml @@ -3526,7 +3526,6 @@ label = "Yཡི་གནས་བབ།" [crop.error] failed = "སོན་བཟང་མ་འདང་བ། PDF" -invalidArea = "སོན་འདེབས་རྒྱ་ཁྱོན་དེ་PDFམཚམས་ཐིག་ལས་བརྒལ་ཡོད།" [crop.preview] title = "སོན་བཟང་ཁུལ་འདེམས་པ།" diff --git a/frontend/editor/public/locales/ca-CA/translation.toml b/frontend/editor/public/locales/ca-CA/translation.toml index 498da94436..c22244ac98 100644 --- a/frontend/editor/public/locales/ca-CA/translation.toml +++ b/frontend/editor/public/locales/ca-CA/translation.toml @@ -3526,7 +3526,6 @@ label = "Posició Y" [crop.error] failed = "No s'ha pogut retallar el PDF" -invalidArea = "L'àrea de retall s'estén més enllà dels límits del PDF" [crop.preview] title = "Selecció de l'àrea de retall" diff --git a/frontend/editor/public/locales/cs-CZ/translation.toml b/frontend/editor/public/locales/cs-CZ/translation.toml index 7b234329c7..f2161674f4 100644 --- a/frontend/editor/public/locales/cs-CZ/translation.toml +++ b/frontend/editor/public/locales/cs-CZ/translation.toml @@ -3526,7 +3526,6 @@ label = "Pozice Y" [crop.error] failed = "Oříznutí PDF se nezdařilo" -invalidArea = "Oblast ořezu přesahuje hranice PDF" [crop.preview] title = "Výběr oblasti ořezu" diff --git a/frontend/editor/public/locales/da-DK/translation.toml b/frontend/editor/public/locales/da-DK/translation.toml index f4c068da92..a0ddd7f4d9 100644 --- a/frontend/editor/public/locales/da-DK/translation.toml +++ b/frontend/editor/public/locales/da-DK/translation.toml @@ -3526,7 +3526,6 @@ label = "Y-position" [crop.error] failed = "Kunne ikke beskære PDF" -invalidArea = "Beskæringsområdet strækker sig ud over PDF'ens grænser" [crop.preview] title = "Valg af beskæringsområde" diff --git a/frontend/editor/public/locales/de-DE/translation.toml b/frontend/editor/public/locales/de-DE/translation.toml index 7c1bc79a8c..81c88d7599 100644 --- a/frontend/editor/public/locales/de-DE/translation.toml +++ b/frontend/editor/public/locales/de-DE/translation.toml @@ -3526,7 +3526,6 @@ label = "Y-Position" [crop.error] failed = "PDF zuschneiden fehlgeschlagen" -invalidArea = "Zuschneidebereich überschreitet die PDF-Grenzen" [crop.preview] title = "Zuschneidebereich-Auswahl" diff --git a/frontend/editor/public/locales/el-GR/translation.toml b/frontend/editor/public/locales/el-GR/translation.toml index 5269791aec..57ebd2eb6b 100644 --- a/frontend/editor/public/locales/el-GR/translation.toml +++ b/frontend/editor/public/locales/el-GR/translation.toml @@ -3526,7 +3526,6 @@ label = "Θέση Y" [crop.error] failed = "Αποτυχία περικοπής του PDF" -invalidArea = "Η περιοχή περικοπής εκτείνεται πέρα από τα όρια του PDF" [crop.preview] title = "Επιλογή περιοχής περικοπής" diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml index 8fdd452928..d2b2aa38e3 100644 --- a/frontend/editor/public/locales/en-GB/translation.toml +++ b/frontend/editor/public/locales/en-GB/translation.toml @@ -3526,7 +3526,6 @@ label = "Y Position" [crop.error] failed = "Failed to crop PDF" -invalidArea = "Crop area extends beyond PDF boundaries" [crop.preview] title = "Crop Area Selection" diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 378157818c..baee16cd02 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -3846,7 +3846,6 @@ label = "Y Position" [crop.error] failed = "Failed to crop PDF" -invalidArea = "Crop area extends beyond PDF boundaries" [crop.preview] title = "Crop Area Selection" diff --git a/frontend/editor/public/locales/es-ES/translation.toml b/frontend/editor/public/locales/es-ES/translation.toml index 73aeb53429..5fffd6eae8 100644 --- a/frontend/editor/public/locales/es-ES/translation.toml +++ b/frontend/editor/public/locales/es-ES/translation.toml @@ -3526,7 +3526,6 @@ label = "Posición Y" [crop.error] failed = "Error al recortar PDF" -invalidArea = "El área de recorte se extiende más allá de los límites del PDF" [crop.preview] title = "Selección de Área de Recorte" diff --git a/frontend/editor/public/locales/eu-ES/translation.toml b/frontend/editor/public/locales/eu-ES/translation.toml index b5a2388e67..018bfbabc5 100644 --- a/frontend/editor/public/locales/eu-ES/translation.toml +++ b/frontend/editor/public/locales/eu-ES/translation.toml @@ -3526,7 +3526,6 @@ label = "Y posizioa" [crop.error] failed = "Huts egin du PDFa mozteak" -invalidArea = "Mozketa-area PDFaren mugak baino harago doa" [crop.preview] title = "Mozketa-arearen hautapena" diff --git a/frontend/editor/public/locales/fa-IR/translation.toml b/frontend/editor/public/locales/fa-IR/translation.toml index 14a40c4694..000d039dfa 100644 --- a/frontend/editor/public/locales/fa-IR/translation.toml +++ b/frontend/editor/public/locales/fa-IR/translation.toml @@ -3526,7 +3526,6 @@ label = "موقعیت Y" [crop.error] failed = "برش PDF ناموفق بود" -invalidArea = "ناحیه برش از مرزهای PDF فراتر رفته است" [crop.preview] title = "انتخاب ناحیه برش" diff --git a/frontend/editor/public/locales/fr-FR/translation.toml b/frontend/editor/public/locales/fr-FR/translation.toml index 5550076052..e7d8a2a9ab 100644 --- a/frontend/editor/public/locales/fr-FR/translation.toml +++ b/frontend/editor/public/locales/fr-FR/translation.toml @@ -3526,7 +3526,6 @@ label = "Position Y" [crop.error] failed = "Échec du recadrage du PDF" -invalidArea = "La zone de recadrage dépasse les limites du PDF" [crop.preview] title = "Sélection de la zone de recadrage" diff --git a/frontend/editor/public/locales/ga-IE/translation.toml b/frontend/editor/public/locales/ga-IE/translation.toml index 4d3ad56168..1507ae92df 100644 --- a/frontend/editor/public/locales/ga-IE/translation.toml +++ b/frontend/editor/public/locales/ga-IE/translation.toml @@ -3526,7 +3526,6 @@ label = "Suíomh Y" [crop.error] failed = "Theip ar an PDF a bhearradh" -invalidArea = "Téann an limistéar bearrtha thar theorainneacha an PDF" [crop.preview] title = "Roghnú Limistéir Bhearrtha" diff --git a/frontend/editor/public/locales/hi-IN/translation.toml b/frontend/editor/public/locales/hi-IN/translation.toml index ae5993b387..bd769216a8 100644 --- a/frontend/editor/public/locales/hi-IN/translation.toml +++ b/frontend/editor/public/locales/hi-IN/translation.toml @@ -3526,7 +3526,6 @@ label = "Y स्थान" [crop.error] failed = "PDF क्रॉप करने में विफल" -invalidArea = "क्रॉप क्षेत्र PDF सीमाओं से बाहर जा रहा है" [crop.preview] title = "क्रॉप क्षेत्र चयन" diff --git a/frontend/editor/public/locales/hr-HR/translation.toml b/frontend/editor/public/locales/hr-HR/translation.toml index 587933825b..371c1b782c 100644 --- a/frontend/editor/public/locales/hr-HR/translation.toml +++ b/frontend/editor/public/locales/hr-HR/translation.toml @@ -3526,7 +3526,6 @@ label = "Y položaj" [crop.error] failed = "Izrezivanje PDF-a nije uspjelo" -invalidArea = "Područje izrezivanja prelazi granice PDF-a" [crop.preview] title = "Odabir područja izrezivanja" diff --git a/frontend/editor/public/locales/hu-HU/translation.toml b/frontend/editor/public/locales/hu-HU/translation.toml index 98d393c047..c8fb681564 100644 --- a/frontend/editor/public/locales/hu-HU/translation.toml +++ b/frontend/editor/public/locales/hu-HU/translation.toml @@ -3526,7 +3526,6 @@ label = "Y pozíció" [crop.error] failed = "A PDF vágása sikertelen" -invalidArea = "A vágási terület túlnyúlik a PDF határain" [crop.preview] title = "Vágási terület kiválasztása" diff --git a/frontend/editor/public/locales/id-ID/translation.toml b/frontend/editor/public/locales/id-ID/translation.toml index 15fe756aa6..83751f1dce 100644 --- a/frontend/editor/public/locales/id-ID/translation.toml +++ b/frontend/editor/public/locales/id-ID/translation.toml @@ -3526,7 +3526,6 @@ label = "Posisi Y" [crop.error] failed = "Gagal memangkas PDF" -invalidArea = "Area pangkas melampaui batas PDF" [crop.preview] title = "Pilihan Area Pangkas" diff --git a/frontend/editor/public/locales/it-IT/translation.toml b/frontend/editor/public/locales/it-IT/translation.toml index a99f00a343..592f644216 100644 --- a/frontend/editor/public/locales/it-IT/translation.toml +++ b/frontend/editor/public/locales/it-IT/translation.toml @@ -3526,7 +3526,6 @@ label = "Posizione Y" [crop.error] failed = "Impossibile ritagliare il PDF" -invalidArea = "L’area di ritaglio supera i limiti del PDF" [crop.preview] title = "Selezione area di ritaglio" diff --git a/frontend/editor/public/locales/ja-JP/translation.toml b/frontend/editor/public/locales/ja-JP/translation.toml index dcc5735dbd..a8d6730ba4 100644 --- a/frontend/editor/public/locales/ja-JP/translation.toml +++ b/frontend/editor/public/locales/ja-JP/translation.toml @@ -3526,7 +3526,6 @@ label = "Y 位置" [crop.error] failed = "PDF の切り抜きに失敗しました" -invalidArea = "切り抜き範囲が PDF の境界を超えています" [crop.preview] title = "切り抜き範囲の選択" diff --git a/frontend/editor/public/locales/ko-KR/translation.toml b/frontend/editor/public/locales/ko-KR/translation.toml index 833f710c7f..33ca27a071 100644 --- a/frontend/editor/public/locales/ko-KR/translation.toml +++ b/frontend/editor/public/locales/ko-KR/translation.toml @@ -3526,7 +3526,6 @@ label = "Y 위치" [crop.error] failed = "PDF 자르기에 실패했습니다" -invalidArea = "자르기 영역이 PDF 경계를 벗어났습니다" [crop.preview] title = "자르기 영역 선택" diff --git a/frontend/editor/public/locales/ml-ML/translation.toml b/frontend/editor/public/locales/ml-ML/translation.toml index 53e8ef302f..10235abe24 100644 --- a/frontend/editor/public/locales/ml-ML/translation.toml +++ b/frontend/editor/public/locales/ml-ML/translation.toml @@ -3526,7 +3526,6 @@ label = "Y സ്ഥാനം" [crop.error] failed = "PDF ക്രോപ്പ് ചെയ്യാൻ കഴിഞ്ഞില്ല" -invalidArea = "ക്രോപ്പ് ഏരിയ PDF അതിരുകൾക്ക് പുറത്തേക്ക് നീളുന്നു" [crop.preview] title = "ക്രോപ്പ് ഏരിയ തിരഞ്ഞെടുപ്പ്" diff --git a/frontend/editor/public/locales/nl-NL/translation.toml b/frontend/editor/public/locales/nl-NL/translation.toml index d4ada20e32..e5841842f2 100644 --- a/frontend/editor/public/locales/nl-NL/translation.toml +++ b/frontend/editor/public/locales/nl-NL/translation.toml @@ -3526,7 +3526,6 @@ label = "Y-positie" [crop.error] failed = "PDF bijsnijden mislukt" -invalidArea = "Bijsnijgebied valt buiten PDF-randen" [crop.preview] title = "Selectie bijsnijgebied" diff --git a/frontend/editor/public/locales/no-NB/translation.toml b/frontend/editor/public/locales/no-NB/translation.toml index 9402f04f22..1dd1167219 100644 --- a/frontend/editor/public/locales/no-NB/translation.toml +++ b/frontend/editor/public/locales/no-NB/translation.toml @@ -3526,7 +3526,6 @@ label = "Y-posisjon" [crop.error] failed = "Kunne ikke beskjære PDF" -invalidArea = "Beskjæringsområdet går utenfor PDF-grensene" [crop.preview] title = "Valg av beskjæringsområde" diff --git a/frontend/editor/public/locales/pl-PL/translation.toml b/frontend/editor/public/locales/pl-PL/translation.toml index 566a681f9a..c327cf2483 100644 --- a/frontend/editor/public/locales/pl-PL/translation.toml +++ b/frontend/editor/public/locales/pl-PL/translation.toml @@ -3526,7 +3526,6 @@ label = "Pozycja Y" [crop.error] failed = "Nie udało się przyciąć PDF" -invalidArea = "Obszar przycięcia wykracza poza granice PDF" [crop.preview] title = "Wybór obszaru przycięcia" diff --git a/frontend/editor/public/locales/pt-BR/translation.toml b/frontend/editor/public/locales/pt-BR/translation.toml index d89968fdfa..8455176984 100644 --- a/frontend/editor/public/locales/pt-BR/translation.toml +++ b/frontend/editor/public/locales/pt-BR/translation.toml @@ -3526,7 +3526,6 @@ label = "Posição Y" [crop.error] failed = "Falha ao recortar o PDF" -invalidArea = "A área de corte se estende além dos limites do PDF" [crop.preview] title = "Seleção da área de corte" diff --git a/frontend/editor/public/locales/pt-PT/translation.toml b/frontend/editor/public/locales/pt-PT/translation.toml index 0f9aebeff7..0b24388d75 100644 --- a/frontend/editor/public/locales/pt-PT/translation.toml +++ b/frontend/editor/public/locales/pt-PT/translation.toml @@ -3526,7 +3526,6 @@ label = "Posição Y" [crop.error] failed = "Falha ao recortar o PDF" -invalidArea = "A área de recorte excede os limites do PDF" [crop.preview] title = "Seleção da área de recorte" diff --git a/frontend/editor/public/locales/ro-RO/translation.toml b/frontend/editor/public/locales/ro-RO/translation.toml index 0f7c1555b8..2a3f4eba7d 100644 --- a/frontend/editor/public/locales/ro-RO/translation.toml +++ b/frontend/editor/public/locales/ro-RO/translation.toml @@ -3526,7 +3526,6 @@ label = "Poziția Y" [crop.error] failed = "Nu s-a putut decupa PDF-ul" -invalidArea = "Zona de decupare depășește limitele PDF-ului" [crop.preview] title = "Selecție zonă de decupare" diff --git a/frontend/editor/public/locales/ru-RU/translation.toml b/frontend/editor/public/locales/ru-RU/translation.toml index eae673f896..7d32f58a6f 100644 --- a/frontend/editor/public/locales/ru-RU/translation.toml +++ b/frontend/editor/public/locales/ru-RU/translation.toml @@ -3526,7 +3526,6 @@ label = "Положение Y" [crop.error] failed = "Не удалось обрезать PDF" -invalidArea = "Область обрезки выходит за границы PDF" [crop.preview] title = "Выбор области обрезки" diff --git a/frontend/editor/public/locales/sk-SK/translation.toml b/frontend/editor/public/locales/sk-SK/translation.toml index 1906d101d0..9383f6a866 100644 --- a/frontend/editor/public/locales/sk-SK/translation.toml +++ b/frontend/editor/public/locales/sk-SK/translation.toml @@ -3526,7 +3526,6 @@ label = "Pozícia Y" [crop.error] failed = "Nepodarilo sa orezať PDF" -invalidArea = "Oblasť orezania presahuje hranice PDF" [crop.preview] title = "Výber oblasti orezania" diff --git a/frontend/editor/public/locales/sl-SI/translation.toml b/frontend/editor/public/locales/sl-SI/translation.toml index 120606f790..c6c9cdbd54 100644 --- a/frontend/editor/public/locales/sl-SI/translation.toml +++ b/frontend/editor/public/locales/sl-SI/translation.toml @@ -3526,7 +3526,6 @@ label = "Položaj Y" [crop.error] failed = "Obrezovanje PDF-ja ni uspelo" -invalidArea = "Območje obrezovanja presega meje PDF-ja" [crop.preview] title = "Izbira območja obrezovanja" diff --git a/frontend/editor/public/locales/sr-LATN-RS/translation.toml b/frontend/editor/public/locales/sr-LATN-RS/translation.toml index 2e4cb53c7b..c639e19155 100644 --- a/frontend/editor/public/locales/sr-LATN-RS/translation.toml +++ b/frontend/editor/public/locales/sr-LATN-RS/translation.toml @@ -3526,7 +3526,6 @@ label = "Y pozicija" [crop.error] failed = "Nije uspelo isecanje PDF-a" -invalidArea = "Oblast isečka prelazi granice PDF-a" [crop.preview] title = "Izbor oblasti za isecanje" diff --git a/frontend/editor/public/locales/sv-SE/translation.toml b/frontend/editor/public/locales/sv-SE/translation.toml index bd61dbedff..ba2b56e5e0 100644 --- a/frontend/editor/public/locales/sv-SE/translation.toml +++ b/frontend/editor/public/locales/sv-SE/translation.toml @@ -3526,7 +3526,6 @@ label = "Y-position" [crop.error] failed = "Det gick inte att beskära PDF" -invalidArea = "Beskärningsområdet sträcker sig utanför PDF:ens gränser" [crop.preview] title = "Val av beskärningsområde" diff --git a/frontend/editor/public/locales/th-TH/translation.toml b/frontend/editor/public/locales/th-TH/translation.toml index 6b1c927d9f..6a294505f1 100644 --- a/frontend/editor/public/locales/th-TH/translation.toml +++ b/frontend/editor/public/locales/th-TH/translation.toml @@ -3526,7 +3526,6 @@ label = "ตำแหน่ง Y" [crop.error] failed = "ครอบตัด PDF ไม่สำเร็จ" -invalidArea = "พื้นที่ครอบตัดเกินขอบเขตของ PDF" [crop.preview] title = "การเลือกพื้นที่ครอบตัด" diff --git a/frontend/editor/public/locales/tr-TR/translation.toml b/frontend/editor/public/locales/tr-TR/translation.toml index 6ac3a86d07..ae580843e5 100644 --- a/frontend/editor/public/locales/tr-TR/translation.toml +++ b/frontend/editor/public/locales/tr-TR/translation.toml @@ -3526,7 +3526,6 @@ label = "Y Konumu" [crop.error] failed = "PDF kırpılamadı" -invalidArea = "Kırpma alanı PDF sınırlarının dışına taşıyor" [crop.preview] title = "Kırpma Alanı Seçimi" diff --git a/frontend/editor/public/locales/uk-UA/translation.toml b/frontend/editor/public/locales/uk-UA/translation.toml index d51189230b..2f9e2daeef 100644 --- a/frontend/editor/public/locales/uk-UA/translation.toml +++ b/frontend/editor/public/locales/uk-UA/translation.toml @@ -3526,7 +3526,6 @@ label = "Позиція Y" [crop.error] failed = "Не вдалося обрізати PDF" -invalidArea = "Область обрізки виходить за межі PDF" [crop.preview] title = "Вибір області обрізки" diff --git a/frontend/editor/public/locales/vi-VN/translation.toml b/frontend/editor/public/locales/vi-VN/translation.toml index a574d29833..566568dade 100644 --- a/frontend/editor/public/locales/vi-VN/translation.toml +++ b/frontend/editor/public/locales/vi-VN/translation.toml @@ -3526,7 +3526,6 @@ label = "Vị trí Y" [crop.error] failed = "Không cắt được PDF" -invalidArea = "Vùng cắt vượt quá ranh giới PDF" [crop.preview] title = "Chọn vùng cắt" diff --git a/frontend/editor/public/locales/zh-BO/translation.toml b/frontend/editor/public/locales/zh-BO/translation.toml index 895fbc5c82..7a64d7453e 100644 --- a/frontend/editor/public/locales/zh-BO/translation.toml +++ b/frontend/editor/public/locales/zh-BO/translation.toml @@ -3526,7 +3526,6 @@ label = "Y 位置" [crop.error] failed = "裁剪 PDF 失败" -invalidArea = "裁剪区域超出 PDF 边界" [crop.preview] title = "裁剪区域选择" diff --git a/frontend/editor/public/locales/zh-CN/translation.toml b/frontend/editor/public/locales/zh-CN/translation.toml index ccf73691ff..b29e3442d4 100644 --- a/frontend/editor/public/locales/zh-CN/translation.toml +++ b/frontend/editor/public/locales/zh-CN/translation.toml @@ -3526,7 +3526,6 @@ label = "Y 位置" [crop.error] failed = "裁剪 PDF 失败" -invalidArea = "裁剪区域超出 PDF 边界" [crop.preview] title = "裁剪区域选择" diff --git a/frontend/editor/public/locales/zh-TW/translation.toml b/frontend/editor/public/locales/zh-TW/translation.toml index 269d9c9d59..efb8adb8b9 100644 --- a/frontend/editor/public/locales/zh-TW/translation.toml +++ b/frontend/editor/public/locales/zh-TW/translation.toml @@ -3526,7 +3526,6 @@ label = "Y 位置" [crop.error] failed = "裁切 PDF 失敗" -invalidArea = "裁切區域超出 PDF 邊界" [crop.preview] title = "裁切區域選擇" diff --git a/frontend/editor/src/core/components/tools/crop/CropSettings.tsx b/frontend/editor/src/core/components/tools/crop/CropSettings.tsx index 82516059b0..0229613959 100644 --- a/frontend/editor/src/core/components/tools/crop/CropSettings.tsx +++ b/frontend/editor/src/core/components/tools/crop/CropSettings.tsx @@ -1,13 +1,5 @@ import { useState, useEffect } from "react"; -import { - Stack, - Text, - Box, - Group, - Center, - Alert, - Checkbox, -} from "@mantine/core"; +import { Stack, Text, Box, Group, Center, Checkbox } from "@mantine/core"; import { ActionIcon } from "@app/ui/ActionIcon"; import { useTranslation } from "react-i18next"; import RestartAltIcon from "@mui/icons-material/RestartAlt"; @@ -161,7 +153,6 @@ const CropSettings = ({ parameters, disabled = false }: CropSettingsProps) => { ); } - const isCropValid = parameters.isCropAreaValid(pdfBounds); const isFullCrop = parameters.isFullPDFCrop(pdfBounds); return ( @@ -239,18 +230,6 @@ const CropSettings = ({ parameters, disabled = false }: CropSettingsProps) => { showAutomationInfo={false} /> )} - - {/* Validation Alert - Only show when autoCrop is false */} - {!parameters.parameters.autoCrop && !isCropValid && ( - - - {t( - "crop.error.invalidArea", - "Crop area extends beyond PDF boundaries", - )} - - - )} ); }; diff --git a/frontend/editor/src/core/hooks/tools/crop/useCropParameters.ts b/frontend/editor/src/core/hooks/tools/crop/useCropParameters.ts index cc37421cdf..62e7ede14b 100644 --- a/frontend/editor/src/core/hooks/tools/crop/useCropParameters.ts +++ b/frontend/editor/src/core/hooks/tools/crop/useCropParameters.ts @@ -8,6 +8,7 @@ import { Rectangle, PDFBounds, constrainCropAreaToPDF, + createDefaultCropArea, createFullPDFCropArea, roundCropArea, isRectangle, @@ -29,6 +30,8 @@ export type CropParametersHook = BaseParametersHook & { setCropArea: (cropArea: Rectangle, pdfBounds?: PDFBounds) => void; /** Get current crop area as CropArea object */ getCropArea: () => Rectangle; + /** Reset to default inset crop area inside PDF bounds */ + resetToDefaultCropArea: (pdfBounds: PDFBounds) => void; /** Reset to full PDF dimensions */ resetToFullPDF: (pdfBounds: PDFBounds) => void; /** Check if current crop area is valid for the PDF */ @@ -76,6 +79,15 @@ export const useCropParameters = (): CropParametersHook => { [baseHook], ); + // Reset to default crop area inside PDF bounds (10% inset) + const resetToDefaultCropArea = useCallback( + (pdfBounds: PDFBounds) => { + const defaultCropArea = createDefaultCropArea(pdfBounds); + setCropArea(defaultCropArea); + }, + [setCropArea], + ); + // Reset to cover entire PDF const resetToFullPDF = useCallback( (pdfBounds: PDFBounds) => { @@ -85,31 +97,11 @@ export const useCropParameters = (): CropParametersHook => { [setCropArea], ); - // Check if current crop area is valid for the given PDF bounds + // Check if current crop area is valid (dimensions must be non-zero; out-of-bounds coordinates clamp automatically) const isCropAreaValid = useCallback( - (pdfBounds?: PDFBounds): boolean => { + (_pdfBounds?: PDFBounds): boolean => { const cropArea = getCropArea(); - - // Basic validation - if ( - cropArea.x < 0 || - cropArea.y < 0 || - cropArea.width <= 0 || - cropArea.height <= 0 - ) { - return false; - } - - // PDF bounds validation if provided - if (pdfBounds) { - const tolerance = 0.01; // Small tolerance for floating point precision - return ( - cropArea.x + cropArea.width <= pdfBounds.actualWidth + tolerance && - cropArea.y + cropArea.height <= pdfBounds.actualHeight + tolerance - ); - } - - return true; + return cropArea.width > 0 && cropArea.height > 0; }, [getCropArea], ); @@ -174,6 +166,7 @@ export const useCropParameters = (): CropParametersHook => { validateParameters: () => validateParameters(), setCropArea, getCropArea, + resetToDefaultCropArea, resetToFullPDF, isCropAreaValid, isFullPDFCrop, diff --git a/frontend/editor/src/core/utils/cropCoordinates.ts b/frontend/editor/src/core/utils/cropCoordinates.ts index 5a275c85ea..4b3bf1c600 100644 --- a/frontend/editor/src/core/utils/cropCoordinates.ts +++ b/frontend/editor/src/core/utils/cropCoordinates.ts @@ -204,7 +204,21 @@ export const isPointInThumbnail = ( }; /** - * Create a default crop area that covers the entire PDF + * Create a default crop area inside PDF bounds (10% inset from each edge, centered) + */ +export const createDefaultCropArea = (pdfBounds: PDFBounds): Rectangle => { + const insetX = pdfBounds.actualWidth * 0.1; + const insetY = pdfBounds.actualHeight * 0.1; + return { + x: Math.round(insetX * 10) / 10, + y: Math.round(insetY * 10) / 10, + width: Math.round((pdfBounds.actualWidth - insetX * 2) * 10) / 10, + height: Math.round((pdfBounds.actualHeight - insetY * 2) * 10) / 10, + }; +}; + +/** + * Create a crop area that covers the entire PDF */ export const createFullPDFCropArea = (pdfBounds: PDFBounds): Rectangle => { return { From 34694c6f5ec11b286e2c557e7eddf1813a1764f3 Mon Sep 17 00:00:00 2001 From: brios <127139797+balazs-szucs@users.noreply.github.com> Date: Sun, 30 Aug 2026 00:19:26 +0200 Subject: [PATCH 27/27] refactor(api): standardize syntax and simplify type declarations across security, workflow, and controller modules (#7127) Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> --- .../SPDF/pdf/parser/TabulaTableParser.java | 2 +- .../StringToMapPropertyEditor.java | 3 +- .../controller/api/EditTextController.java | 2 +- .../SPDF/controller/api/UIDataController.java | 3 +- .../api/form/FormPayloadParser.java | 9 +- .../api/misc/AddCommentsController.java | 4 +- .../proprietary/audit/AuditLevel.java | 2 +- .../cluster/valkey/ValkeyJobStore.java | 6 +- .../config/AuditConfigurationProperties.java | 2 +- .../controller/api/UsageRestController.java | 2 +- .../model/UserLicenseSettings.java | 3 +- .../security/CustomLogoutSuccessHandler.java | 32 +++--- .../configuration/SecurityConfiguration.java | 68 ++++++------- .../controller/api/AuthController.java | 23 ++--- .../controller/api/UserController.java | 16 +-- .../proprietary/security/model/Authority.java | 3 +- .../security/model/InviteToken.java | 3 +- ...tomOAuth2AuthenticationFailureHandler.java | 99 ++++++++++--------- ...mSaml2ResponseAuthenticationConverter.java | 7 +- .../service/CustomOAuth2UserService.java | 7 +- .../service/KeyPersistenceService.java | 4 +- .../security/service/UserService.java | 16 +-- .../session/SessionPersistentRegistry.java | 30 +++--- .../proprietary/storage/model/FileShare.java | 3 +- .../storage/model/FileShareAccess.java | 3 +- .../storage/model/StorageCleanupEntry.java | 3 +- .../proprietary/storage/model/StoredFile.java | 3 +- .../storage/model/StoredFileBlob.java | 3 +- .../proprietary/web/AuditWebFilter.java | 3 +- .../controller/SigningSessionController.java | 5 +- .../WorkflowParticipantController.java | 3 +- .../workflow/model/WorkflowParticipant.java | 3 +- .../workflow/model/WorkflowSession.java | 3 +- .../service/SigningFinalizationService.java | 14 +-- .../service/WorkflowSessionService.java | 31 +++--- 35 files changed, 229 insertions(+), 194 deletions(-) diff --git a/app/common/src/main/java/stirling/software/SPDF/pdf/parser/TabulaTableParser.java b/app/common/src/main/java/stirling/software/SPDF/pdf/parser/TabulaTableParser.java index b85ddbb08e..d3c516d1fe 100644 --- a/app/common/src/main/java/stirling/software/SPDF/pdf/parser/TabulaTableParser.java +++ b/app/common/src/main/java/stirling/software/SPDF/pdf/parser/TabulaTableParser.java @@ -237,7 +237,7 @@ public class TabulaTableParser implements TableParser { score -= 0.3f; } - return Math.max(0f, Math.min(1f, score)); + return Math.clamp(score, 0f, 1f); } private Bounds tableBounds(Table table) { diff --git a/app/common/src/main/java/stirling/software/common/util/propertyeditor/StringToMapPropertyEditor.java b/app/common/src/main/java/stirling/software/common/util/propertyeditor/StringToMapPropertyEditor.java index 63476d5568..7ab1013a8a 100644 --- a/app/common/src/main/java/stirling/software/common/util/propertyeditor/StringToMapPropertyEditor.java +++ b/app/common/src/main/java/stirling/software/common/util/propertyeditor/StringToMapPropertyEditor.java @@ -15,7 +15,8 @@ public class StringToMapPropertyEditor extends PropertyEditorSupport { @Override public void setAsText(String text) throws IllegalArgumentException { try { - TypeReference> typeRef = new TypeReference<>() {}; + TypeReference> typeRef = + new TypeReference>() {}; Map map = objectMapper.readValue(text, typeRef); setValue(map); } catch (Exception e) { diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/EditTextController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/EditTextController.java index 17d1d7d8a7..b76f52144a 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/EditTextController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/EditTextController.java @@ -237,7 +237,7 @@ public class EditTextController { Matcher matcher = edit.pattern().matcher(joined); List spans = new ArrayList<>(); - StringBuffer interpolation = new StringBuffer(); + StringBuilder interpolation = new StringBuilder(); int previousAppendPosition = 0; while (matcher.find()) { if (matcher.start() == matcher.end()) { diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java index 7f93fb3d64..b6cef0b2d6 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java @@ -95,7 +95,8 @@ public class UIDataController { try (InputStream is = resource.getInputStream()) { Map> licenseData = - objectMapper.readValue(is, new TypeReference<>() {}); + objectMapper.readValue( + is, new TypeReference>>() {}); data.setDependencies(licenseData.get("dependencies")); } catch (IOException e) { log.error("Failed to load licenses data", e); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormPayloadParser.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormPayloadParser.java index f48f419a6d..5236706f74 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormPayloadParser.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormPayloadParser.java @@ -25,12 +25,15 @@ final class FormPayloadParser { private static final String KEY_VALUE = "value"; private static final String KEY_DEFAULT_VALUE = "defaultValue"; - private static final TypeReference> MAP_TYPE = new TypeReference<>() {}; + private static final TypeReference> MAP_TYPE = + new TypeReference>() {}; private static final TypeReference> - MODIFY_FIELD_LIST_TYPE = new TypeReference<>() {}; + MODIFY_FIELD_LIST_TYPE = + new TypeReference>() {}; private static final TypeReference> NEW_FIELD_LIST_TYPE = new TypeReference<>() {}; - private static final TypeReference> STRING_LIST_TYPE = new TypeReference<>() {}; + private static final TypeReference> STRING_LIST_TYPE = + new TypeReference>() {}; private FormPayloadParser() {} diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AddCommentsController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AddCommentsController.java index dc2dd22863..09b1d282e9 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AddCommentsController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AddCommentsController.java @@ -96,7 +96,9 @@ public class AddCommentsController { List dtos; try { - dtos = objectMapper.readValue(commentsJson, new TypeReference<>() {}); + dtos = + objectMapper.readValue( + commentsJson, new TypeReference>() {}); } catch (JacksonException e) { throw new ResponseStatusException( HttpStatus.BAD_REQUEST, "comments must be a JSON array of CommentSpec objects"); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditLevel.java b/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditLevel.java index 59adc2af80..c2b0e53eb7 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditLevel.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditLevel.java @@ -59,7 +59,7 @@ public enum AuditLevel { */ public static AuditLevel fromInt(int level) { // Ensure level is within valid bounds - int boundedLevel = Math.min(Math.max(level, 0), 3); + int boundedLevel = Math.clamp(level, 0, 3); for (AuditLevel auditLevel : values()) { if (auditLevel.level == boundedLevel) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyJobStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyJobStore.java index 750abea4fc..f03992ed4d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyJobStore.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyJobStore.java @@ -44,8 +44,10 @@ public class ValkeyJobStore implements JobStore { private static final String FILE_INDEX_PREFIX = "stirling:file2job:"; private static final ObjectMapper MAPPER = new ObjectMapper(); - private static final TypeReference> LIST_STRING = new TypeReference<>() {}; - private static final TypeReference> MAP_STRING = new TypeReference<>() {}; + private static final TypeReference> LIST_STRING = + new TypeReference>() {}; + private static final TypeReference> MAP_STRING = + new TypeReference>() {}; private final StringRedisTemplate template; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/config/AuditConfigurationProperties.java b/app/proprietary/src/main/java/stirling/software/proprietary/config/AuditConfigurationProperties.java index 366d91b11c..ac6c25ac5e 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/config/AuditConfigurationProperties.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/config/AuditConfigurationProperties.java @@ -35,7 +35,7 @@ public class AuditConfigurationProperties { // Ensure level is within valid bounds (0-3) int configLevel = auditConfig.getLevel(); - this.level = Math.min(Math.max(configLevel, 0), 3); + this.level = Math.clamp(configLevel, 0, 3); // Retention days (0 means infinite) this.retentionDays = auditConfig.getRetentionDays(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/UsageRestController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/UsageRestController.java index 1230d928cc..65bf8240a4 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/UsageRestController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/UsageRestController.java @@ -48,7 +48,7 @@ public class UsageRestController { @RequestParam(value = "dataType", defaultValue = "all") String dataType, @RequestParam(value = "days", defaultValue = "30") Integer days) { - int lookbackDays = Math.max(1, Math.min(days, 365)); + int lookbackDays = Math.clamp(days, 1, 365); // Get audit events filtered by type List events = getEventsByDataType(dataType, lookbackDays); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/UserLicenseSettings.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/UserLicenseSettings.java index bb7f52142a..1683ad9134 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/model/UserLicenseSettings.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/UserLicenseSettings.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.model; +import java.io.Serial; import java.io.Serializable; import jakarta.persistence.*; @@ -19,7 +20,7 @@ import lombok.*; @ToString public class UserLicenseSettings implements Serializable { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; public static final Long SINGLETON_ID = 1L; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java index 4bfef06c9b..d83b684166 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java @@ -70,21 +70,23 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler { if (!response.isCommitted()) { if (authentication != null) { - if (authentication instanceof Saml2Authentication samlAuthentication) { - // Handle SAML2 logout redirection - getRedirect_saml2(request, response, samlAuthentication); - } else if (authentication instanceof OAuth2AuthenticationToken oAuthToken) { - // Handle OAuth2 logout redirection - getRedirect_oauth2(request, response, oAuthToken); - } else if (authentication instanceof UsernamePasswordAuthenticationToken) { - // Handle Username/Password logout - getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH); - } else { - // Handle unknown authentication types - log.error( - "Authentication class unknown: {}", - authentication.getClass().getSimpleName()); - getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH); + switch (authentication) { + case Saml2Authentication samlAuthentication -> + // Handle SAML2 logout redirection + getRedirect_saml2(request, response, samlAuthentication); + case OAuth2AuthenticationToken oAuthToken -> + // Handle OAuth2 logout redirection + getRedirect_oauth2(request, response, oAuthToken); + case UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken -> + // Handle Username/Password logout + getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH); + default -> { + // Handle unknown authentication types + log.error( + "Authentication class unknown: {}", + authentication.getClass().getSimpleName()); + getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH); + } } } else { if (jwtService != null) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java index 0f0c7315d9..7c5b412d33 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java @@ -392,40 +392,40 @@ public class SecurityConfiguration { // Handle OAUTH2 Logins if (securityProperties.isOauth2Active()) { http.oauth2Login( - oauth2 -> { - oauth2.loginPage("/login") - .authorizationEndpoint( - authorizationEndpoint -> { - if (clientRegistrationRepository != null) { - authorizationEndpoint - .authorizationRequestResolver( - new TauriAuthorizationRequestResolver( - clientRegistrationRepository)); - } - }) - .successHandler( - new CustomOAuth2AuthenticationSuccessHandler( - loginAttemptService, - securityProperties.getOauth2(), - userService, - jwtService, - licenseSettingsService, - applicationProperties)) - .failureHandler(new CustomOAuth2AuthenticationFailureHandler()) - // Add existing Authorities from the database - .userInfoEndpoint( - userInfoEndpoint -> - userInfoEndpoint - .oidcUserService( - new CustomOAuth2UserService( - securityProperties - .getOauth2(), - userService, - loginAttemptService)) - .userAuthoritiesMapper( - oAuth2userAuthoritiesMapper)) - .permitAll(); - }); + oauth2 -> + oauth2.loginPage("/login") + .authorizationEndpoint( + authorizationEndpoint -> { + if (clientRegistrationRepository != null) { + authorizationEndpoint + .authorizationRequestResolver( + new TauriAuthorizationRequestResolver( + clientRegistrationRepository)); + } + }) + .successHandler( + new CustomOAuth2AuthenticationSuccessHandler( + loginAttemptService, + securityProperties.getOauth2(), + userService, + jwtService, + licenseSettingsService, + applicationProperties)) + .failureHandler( + new CustomOAuth2AuthenticationFailureHandler()) + // Add existing Authorities from the database + .userInfoEndpoint( + userInfoEndpoint -> + userInfoEndpoint + .oidcUserService( + new CustomOAuth2UserService( + securityProperties + .getOauth2(), + userService, + loginAttemptService)) + .userAuthoritiesMapper( + oAuth2userAuthoritiesMapper)) + .permitAll()); } // Handle SAML if (securityProperties.isSaml2Active() && runningProOrHigher) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java index 86a1c5fe0c..6661c86395 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java @@ -703,17 +703,18 @@ public class AuthController { } private long extractEpochMillis(Object claimValue) { - if (claimValue == null) { - return -1L; - } - - if (claimValue instanceof java.util.Date date) { - return date.getTime(); - } - - if (claimValue instanceof Number number) { - long epochSeconds = number.longValue(); - return epochSeconds * 1000L; + switch (claimValue) { + case null -> { + return -1L; + } + case java.util.Date date -> { + return date.getTime(); + } + case Number number -> { + long epochSeconds = number.longValue(); + return epochSeconds * 1000L; + } + default -> {} } return -1L; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java index fdacda72b2..2385eb011f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java @@ -760,14 +760,14 @@ public class UserController { for (Object principal : principals) { List sessionsInformation = sessionRegistry.getAllSessions(principal, false); - if (principal instanceof UserDetails detailsUser) { - userNameP = detailsUser.getUsername(); - } else if (principal instanceof OAuth2User oAuth2User) { - userNameP = oAuth2User.getName(); - } else if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2User) { - userNameP = saml2User.name(); - } else if (principal instanceof String stringUser) { - userNameP = stringUser; + switch (principal) { + case null -> {} + case UserDetails detailsUser -> userNameP = detailsUser.getUsername(); + case OAuth2User oAuth2User -> userNameP = oAuth2User.getName(); + case CustomSaml2AuthenticatedPrincipal saml2User -> + userNameP = saml2User.name(); + case String stringUser -> userNameP = stringUser; + default -> {} } if (userNameP.equalsIgnoreCase(username)) { for (SessionInformation sessionInfo : sessionsInformation) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/Authority.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/Authority.java index 659f7691bd..4ffea54740 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/Authority.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/Authority.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.security.model; +import java.io.Serial; import java.io.Serializable; import org.springframework.security.core.GrantedAuthority; @@ -28,7 +29,7 @@ import lombok.Setter; @Setter public class Authority implements GrantedAuthority, Serializable { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/InviteToken.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/InviteToken.java index 975220bf48..062cce058f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/InviteToken.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/InviteToken.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.security.model; +import java.io.Serial; import java.io.Serializable; import java.time.LocalDateTime; @@ -18,7 +19,7 @@ import lombok.Setter; @Setter public class InviteToken implements Serializable { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationFailureHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationFailureHandler.java index 784a9f0a2f..670b08c53f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationFailureHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationFailureHandler.java @@ -36,57 +36,62 @@ public class CustomOAuth2AuthenticationFailureHandler AuthenticationException exception) throws IOException, ServletException { - if (exception instanceof BadCredentialsException) { - log.error("BadCredentialsException", exception); - getRedirectStrategy().sendRedirect(request, response, "/login?error=badCredentials"); - return; - } - if (exception instanceof DisabledException) { - log.error("User is deactivated: ", exception); - getRedirectStrategy().sendRedirect(request, response, "/logout?userIsDisabled=true"); - return; - } - if (exception instanceof LockedException) { - log.error("Account locked: ", exception); - getRedirectStrategy().sendRedirect(request, response, "/logout?error=locked"); - return; - } - if (exception instanceof OAuth2AuthenticationException oAuth2Exception) { - OAuth2Error error = oAuth2Exception.getError(); - - String errorCode = error.getErrorCode(); - - if ("Password must not be null".equals(error.getErrorCode())) { - errorCode = "userAlreadyExistsWeb"; + switch (exception) { + case BadCredentialsException badCredentialsException -> { + log.error("BadCredentialsException", exception); + getRedirectStrategy() + .sendRedirect(request, response, "/login?error=badCredentials"); + return; } + case DisabledException disabledException -> { + log.error("User is deactivated: ", exception); + getRedirectStrategy() + .sendRedirect(request, response, "/logout?userIsDisabled=true"); + return; + } + case LockedException lockedException -> { + log.error("Account locked: ", exception); + getRedirectStrategy().sendRedirect(request, response, "/logout?error=locked"); + return; + } + case OAuth2AuthenticationException oAuth2Exception -> { + OAuth2Error error = oAuth2Exception.getError(); - log.error( - "OAuth2 Authentication error: {}", - errorCode != null ? errorCode : exception.getMessage(), - exception); - String errorValue = errorCode != null ? errorCode : "oauth2AuthenticationError"; - clearRedirectCookie(response); - boolean tauriState = TauriOAuthUtils.isTauriState(request); - String redirectUrl; - if (tauriState) { - String basePath = - TauriOAuthUtils.defaultTauriCallbackPath(request.getContextPath()); - redirectUrl = basePath; - String stateParam = request.getParameter("state"); - if (stateParam != null && !stateParam.isBlank()) { - redirectUrl = appendQueryParam(redirectUrl, "state", stateParam); - // Extract and pass nonce for CSRF validation - String nonce = TauriOAuthUtils.extractNonceFromState(stateParam); - if (nonce != null) { - redirectUrl = appendQueryParam(redirectUrl, "nonce", nonce); - } + String errorCode = error.getErrorCode(); + + if ("Password must not be null".equals(error.getErrorCode())) { + errorCode = "userAlreadyExistsWeb"; } - redirectUrl = appendQueryParam(redirectUrl, "errorOAuth", errorValue); - } else { - redirectUrl = buildFailureRedirectUrl(request, errorValue); + + log.error( + "OAuth2 Authentication error: {}", + errorCode != null ? errorCode : exception.getMessage(), + exception); + String errorValue = errorCode != null ? errorCode : "oauth2AuthenticationError"; + clearRedirectCookie(response); + boolean tauriState = TauriOAuthUtils.isTauriState(request); + String redirectUrl; + if (tauriState) { + String basePath = + TauriOAuthUtils.defaultTauriCallbackPath(request.getContextPath()); + redirectUrl = basePath; + String stateParam = request.getParameter("state"); + if (stateParam != null && !stateParam.isBlank()) { + redirectUrl = appendQueryParam(redirectUrl, "state", stateParam); + // Extract and pass nonce for CSRF validation + String nonce = TauriOAuthUtils.extractNonceFromState(stateParam); + if (nonce != null) { + redirectUrl = appendQueryParam(redirectUrl, "nonce", nonce); + } + } + redirectUrl = appendQueryParam(redirectUrl, "errorOAuth", errorValue); + } else { + redirectUrl = buildFailureRedirectUrl(request, errorValue); + } + getRedirectStrategy().sendRedirect(request, response, redirectUrl); + return; } - getRedirectStrategy().sendRedirect(request, response, redirectUrl); - return; + default -> {} } log.error("Unhandled authentication exception", exception); super.onAuthenticationFailure(request, response, exception); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2ResponseAuthenticationConverter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2ResponseAuthenticationConverter.java index b2ce4adb68..96dcdecd03 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2ResponseAuthenticationConverter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2ResponseAuthenticationConverter.java @@ -61,7 +61,12 @@ public class CustomSaml2ResponseAuthenticationConverter @Override public Saml2Authentication convert(ResponseToken responseToken) { - Assertion assertion = responseToken.getResponse().getAssertions().getFirst(); + List assertions = responseToken.getResponse().getAssertions(); + if (assertions == null || assertions.isEmpty()) { + log.error("SAML response contains no assertions"); + return null; + } + Assertion assertion = assertions.getFirst(); Map> attributes = extractAttributes(assertion); // Debug log with actual values diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/CustomOAuth2UserService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/CustomOAuth2UserService.java index c1057c7e36..b8054c89d9 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/CustomOAuth2UserService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/CustomOAuth2UserService.java @@ -213,8 +213,11 @@ public class CustomOAuth2UserService implements OAuth2UserService {} + case UserDetails detailsUser -> usernameP = detailsUser.getUsername(); + case OAuth2User oAuth2User -> usernameP = oAuth2User.getName(); + case CustomSaml2AuthenticatedPrincipal saml2User -> + usernameP = saml2User.name(); + case String stringUser -> usernameP = stringUser; + default -> {} } if (usernameP.equalsIgnoreCase(username)) { sessionRegistry.expireSession(sessionsInformation.getSessionId()); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/session/SessionPersistentRegistry.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/session/SessionPersistentRegistry.java index e615416e59..1f3a4e84ff 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/session/SessionPersistentRegistry.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/session/SessionPersistentRegistry.java @@ -47,14 +47,13 @@ public class SessionPersistentRegistry implements SessionRegistry { List sessionInformations = new ArrayList<>(); String principalName = null; - if (principal instanceof UserDetails detailsUser) { - principalName = detailsUser.getUsername(); - } else if (principal instanceof OAuth2User oAuth2User) { - principalName = oAuth2User.getName(); - } else if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2User) { - principalName = saml2User.name(); - } else if (principal instanceof String stringUser) { - principalName = stringUser; + switch (principal) { + case null -> {} + case UserDetails detailsUser -> principalName = detailsUser.getUsername(); + case OAuth2User oAuth2User -> principalName = oAuth2User.getName(); + case CustomSaml2AuthenticatedPrincipal saml2User -> principalName = saml2User.name(); + case String stringUser -> principalName = stringUser; + default -> {} } if (principalName != null) { @@ -78,14 +77,13 @@ public class SessionPersistentRegistry implements SessionRegistry { public void registerNewSession(String sessionId, Object principal) { String principalName = null; - if (principal instanceof UserDetails detailsUser) { - principalName = detailsUser.getUsername(); - } else if (principal instanceof OAuth2User oAuth2User) { - principalName = oAuth2User.getName(); - } else if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2User) { - principalName = saml2User.name(); - } else if (principal instanceof String stringUser) { - principalName = stringUser; + switch (principal) { + case null -> {} + case UserDetails detailsUser -> principalName = detailsUser.getUsername(); + case OAuth2User oAuth2User -> principalName = oAuth2User.getName(); + case CustomSaml2AuthenticatedPrincipal saml2User -> principalName = saml2User.name(); + case String stringUser -> principalName = stringUser; + default -> {} } if (principalName != null) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/FileShare.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/FileShare.java index 1b0fd86f78..6ddd0c8a86 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/FileShare.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/FileShare.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.storage.model; +import java.io.Serial; import java.io.Serializable; import java.time.LocalDateTime; @@ -46,7 +47,7 @@ import stirling.software.proprietary.security.model.User; @Setter public class FileShare implements Serializable { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/FileShareAccess.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/FileShareAccess.java index 49f75a4a4c..cb2f5d5209 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/FileShareAccess.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/FileShareAccess.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.storage.model; +import java.io.Serial; import java.io.Serializable; import java.time.LocalDateTime; @@ -39,7 +40,7 @@ import stirling.software.proprietary.security.model.User; @Setter public class FileShareAccess implements Serializable { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StorageCleanupEntry.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StorageCleanupEntry.java index 3158f4c041..68afe20173 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StorageCleanupEntry.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StorageCleanupEntry.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.storage.model; +import java.io.Serial; import java.io.Serializable; import java.time.LocalDateTime; @@ -24,7 +25,7 @@ import lombok.Setter; @Setter public class StorageCleanupEntry implements Serializable { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFile.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFile.java index db80bd1e91..1b098672b6 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFile.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFile.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.storage.model; +import java.io.Serial; import java.io.Serializable; import java.time.LocalDateTime; import java.util.HashSet; @@ -45,7 +46,7 @@ import stirling.software.proprietary.workflow.model.WorkflowSession; @Setter public class StoredFile implements Serializable { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFileBlob.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFileBlob.java index 52ef1107fc..4abcffd3e6 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFileBlob.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFileBlob.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.storage.model; +import java.io.Serial; import java.io.Serializable; import jakarta.persistence.Column; @@ -19,7 +20,7 @@ import lombok.Setter; @Setter public class StoredFileBlob implements Serializable { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; @Id @Column(name = "storage_key", nullable = false, length = 128) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/web/AuditWebFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/web/AuditWebFilter.java index b6f5b47f3b..70847a0702 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/web/AuditWebFilter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/web/AuditWebFilter.java @@ -7,6 +7,7 @@ import org.slf4j.MDC; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; @@ -64,7 +65,7 @@ public class AuditWebFilter extends OncePerRequestFilter { if (auth != null && auth.getAuthorities() != null) { String roles = auth.getAuthorities().stream() - .map(a -> a.getAuthority()) + .map(GrantedAuthority::getAuthority) .reduce((a, b) -> a + "," + b) .orElse(""); MDC.put("userRoles", roles); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/SigningSessionController.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/SigningSessionController.java index 73867776e9..b224a09841 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/SigningSessionController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/SigningSessionController.java @@ -37,6 +37,7 @@ import stirling.software.proprietary.workflow.dto.CertificateInfo; import stirling.software.proprietary.workflow.dto.CertificateValidationResponse; import stirling.software.proprietary.workflow.dto.ParticipantRequest; import stirling.software.proprietary.workflow.dto.WorkflowCreationRequest; +import stirling.software.proprietary.workflow.model.WorkflowParticipant; import stirling.software.proprietary.workflow.model.WorkflowSession; import stirling.software.proprietary.workflow.service.CertificateSubmissionValidator; import stirling.software.proprietary.workflow.service.SigningFinalizationService; @@ -259,7 +260,9 @@ public class SigningSessionController { + "database until manual cleanup.", sessionId, session.getParticipants() != null - ? session.getParticipants().stream().map(p -> p.getEmail()).toList() + ? session.getParticipants().stream() + .map(WorkflowParticipant::getEmail) + .toList() : "unknown", e); throw new ResponseStatusException( diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/WorkflowParticipantController.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/WorkflowParticipantController.java index 5f903e4b56..4df0c93e1d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/WorkflowParticipantController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/WorkflowParticipantController.java @@ -5,6 +5,7 @@ import java.nio.charset.StandardCharsets; import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; import java.util.HashMap; +import java.util.List; import java.util.Map; import org.springframework.http.ContentDisposition; @@ -429,7 +430,7 @@ public class WorkflowParticipantController { java.util.List> wetSigs = objectMapper.readValue( request.getWetSignaturesData(), - new TypeReference>>() {}); + new TypeReference>>() {}); if (wetSigs.size() > WetSignatureMetadata.MAX_SIGNATURES_PER_PARTICIPANT) { throw new ResponseStatusException( HttpStatus.BAD_REQUEST, "Too many wet signatures submitted"); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/WorkflowParticipant.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/WorkflowParticipant.java index 2e6091b963..b119565c13 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/WorkflowParticipant.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/WorkflowParticipant.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.workflow.model; +import java.io.Serial; import java.io.Serializable; import java.time.LocalDateTime; import java.util.ArrayList; @@ -51,7 +52,7 @@ import stirling.software.proprietary.storage.model.ShareAccessRole; @Setter public class WorkflowParticipant implements Serializable { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/WorkflowSession.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/WorkflowSession.java index 3fc6b53b44..7df5af710f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/WorkflowSession.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/WorkflowSession.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.workflow.model; +import java.io.Serial; import java.io.Serializable; import java.time.LocalDateTime; import java.util.ArrayList; @@ -53,7 +54,7 @@ import stirling.software.proprietary.storage.model.StoredFile; @Setter public class WorkflowSession implements Serializable { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/SigningFinalizationService.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/SigningFinalizationService.java index e5e122df45..3fce8c69dd 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/SigningFinalizationService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/SigningFinalizationService.java @@ -217,16 +217,13 @@ public class SigningFinalizationService { wetSignatures.size(), session.getSessionId()); - PDDocument document = pdfDocumentFactory.load(new ByteArrayInputStream(pdfBytes)); - try { + try (PDDocument document = pdfDocumentFactory.load(new ByteArrayInputStream(pdfBytes))) { for (WetSignatureMetadata wetSig : wetSignatures) { applyWetSignatureToPage(document, wetSig); } ByteArrayOutputStream baos = new ByteArrayOutputStream(); document.save(baos); return baos.toByteArray(); - } finally { - document.close(); } } @@ -242,11 +239,10 @@ public class SigningFinalizationService { } PDPage page = document.getPage(pageIndex); - PDPageContentStream contentStream = - new PDPageContentStream( - document, page, PDPageContentStream.AppendMode.APPEND, true, true); - try { + try (PDPageContentStream contentStream = + new PDPageContentStream( + document, page, PDPageContentStream.AppendMode.APPEND, true, true)) { // Use WetSignatureMetadata.extractBase64Data() to strip data URL prefix String base64Data = wetSig.extractBase64Data(); if (base64Data == null || base64Data.isBlank()) { @@ -279,8 +275,6 @@ public class SigningFinalizationService { pdfY, width, height); - } finally { - contentStream.close(); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/WorkflowSessionService.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/WorkflowSessionService.java index 4c60c60df2..a2db5deb5a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/WorkflowSessionService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/WorkflowSessionService.java @@ -954,21 +954,22 @@ public class WorkflowSessionService { Object pemObject = pemParser.readObject(); JcaPEMKeyConverter converter = new JcaPEMKeyConverter().setProvider("BC"); PrivateKeyInfo keyInfo; - if (pemObject instanceof PKCS8EncryptedPrivateKeyInfo encrypted) { - InputDecryptorProvider decryptor = - new JceOpenSSLPKCS8DecryptorProviderBuilder().build(password); - keyInfo = encrypted.decryptPrivateKeyInfo(decryptor); - } else if (pemObject instanceof PEMEncryptedKeyPair encryptedKeyPair) { - PEMDecryptorProvider decryptor = - new JcePEMDecryptorProviderBuilder().build(password); - keyInfo = encryptedKeyPair.decryptKeyPair(decryptor).getPrivateKeyInfo(); - } else if (pemObject instanceof PEMKeyPair keyPair) { - keyInfo = keyPair.getPrivateKeyInfo(); - } else if (pemObject instanceof PrivateKeyInfo info) { - keyInfo = info; - } else { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "Unsupported PEM private key format"); + switch (pemObject) { + case PKCS8EncryptedPrivateKeyInfo encrypted -> { + InputDecryptorProvider decryptor = + new JceOpenSSLPKCS8DecryptorProviderBuilder().build(password); + keyInfo = encrypted.decryptPrivateKeyInfo(decryptor); + } + case PEMEncryptedKeyPair encryptedKeyPair -> { + PEMDecryptorProvider decryptor = + new JcePEMDecryptorProviderBuilder().build(password); + keyInfo = encryptedKeyPair.decryptKeyPair(decryptor).getPrivateKeyInfo(); + } + case PEMKeyPair keyPair -> keyInfo = keyPair.getPrivateKeyInfo(); + case PrivateKeyInfo info -> keyInfo = info; + case null, default -> + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "Unsupported PEM private key format"); } return converter.getPrivateKey(keyInfo); }