From 3bb3b721de206f28346dc410df1512f6a90814f2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 16 Aug 2026 14:15:34 +0100 Subject: [PATCH 01/23] build(deps): bump logback from 1.5.32 to 1.6.1 (#7339) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps `logback` from 1.5.32 to 1.6.1. Updates `ch.qos.logback:logback-core` from 1.5.32 to 1.6.1
Release notes

Sourced from ch.qos.logback:logback-core's releases.

Logback 1.6.1

2026-07-28 Release of logback version 1.6.1

• In TimeBasedRollingPolicy, when the file option is set, the intermediate file renamed before asynchronous compression now receives the target archive name without the compression suffix (e.g. .gz, .zip, .xz). Previously it used a nanotime-based .tmp suffix. This makes the file easier to identify if compression fails during rollover. (See also the following paragraph.)

• On GZ, ZIP, or XZ compression failure, the original (uncompressed) log file is no longer deleted. Compression strategies now delete the source file only after successful compression and emit a warning that the original was left intact.

• ConsoleAppender with now probes JLine's org.jline.jansi.AnsiConsole first and falls back to the legacy FuseSource org.fusesource.jansi.AnsiConsole class. This keeps ANSI coloring working after Jansi moved under the JLine project. The optional org.jline:jansi-core artifact is declared as a dependency alongside the existing FuseSource jansi dependency. A preferredJansiClassName property was added for tests. This issue was reported in issues/1043 by seonwoo_jung who also provided the relevant PR.

• LayoutWrappingEncoder now reports an error at start() when no layout is set and guards encode() against a null layout. Previously, a missing layout (for example after an ignored // branch) allowed the encoder to start and then fail with a NullPointerException on every event, resulting in silent log loss. This issue was reported in issues/1046 by seonwoo_jung who also provided the relevant PR.

• FileCollisionAnalyser now detects file collisions involving nested appenders of SiftingAppender. When the nested file or fileNamePattern does not textually reference the discriminator key (e.g. ${userId}), a warning is issued at configuration time naming the appender, the key, and the shared target. This closes a gap where statically declared file appenders were checked but sifted nested appenders were not. This enhancement was contributed in [PR #1041](qos-ch/logback#1041) by seonwoo_jung.

• More defensive handling in SyslogOutputStream and SyslogAppenderBase: the close() method now ensures that resources are closed, writes and flushes check that the underlying resources are in a valid state and fallback to no-op otherwise.

• A bit-wise identical binary of this version can be reproduced by building from source code at commit 57759f433000a133088ef0441038963134437fbd associated with the tag v_1.6.1. The release was built using Java "21" 2023-10-17 LTS build 21.0.1.+12-LTS-29 under Linux Debian 11.6.

• See https://logback.qos.ch/news.html#1.6.1 for the original text.

Logback 1.6.0

2026-07-23 Release of logback version 1.6.0

• Removed certain deprecated variables, methods, and classes. For the list of removed members see release_1.6.0.txt.

• In AsyncAppenderBase, the put(ILoggingEvent) method now has the protected modifier to allow access from derived classes. This change was requested by Thomas Skjølberg in pr#1053.

• Bump SLF4J dependency to version 2.0.18.

See also the overview of the 1.6.x series.

• A bit-wise identical binary of this version can be reproduced by building from source code at commit b07adf36019b51a10f824fdd94009985c587b1d3 associated with the tag v_1.6.0. The release was built using Java "21" 2023-10-17 LTS build 21.0.1.+12-LTS-29 under Linux Debian 11.6.

Logback 1.5.38

2026-07-09 Release of logback version 1.5.38

• In HardenedObjectInputStream, fixed a typo preventing Throwable objects from being white-filtered. This issue was reported in [PR #1045](qos-ch/logback#1045) by t0rchwo0d.

• A bitwise identical binary of this version can be reproduced by building from source code at commit d04984a41fce42977466f45a2f076f0ee5cc4207 associated with the tag v_1.5.38. Release built using Java "21" 2023-10-17 LTS build 21.0.1.+12-LTS-29 under Linux Debian 11.6.

Logback 1.5.37

2026-06-26 Release of logback version 1.5.37

  1. • Given the numerous vulnerabilities related to conditional configuration processing based on the evaluation of Java expressions using the Janino library, support for such expressions has been removed. Users are offered the an online migration service or the <condition> element introduced in version 1.5.20. See the relevant documentation for more details.

• A bitwise identical binary of this version can be reproduced by building from source code at commit c1df7f522e648eec7b4ef6a12c8758fec0f00048 associated with the tag v_1.5.37. Release built using Java "21" 2023-10-17 LTS build 21.0.1.+12-LTS-29 under Linux Debian 11.6.

Logback 1.5.36

2026-06-25 Release of logback version 1.5.36

• The 'condition' attribute in <if> elements now reject certain references that are associated with ACE attacks. This issue was reported by "yulate" (yulate531@gmail.com.com) and registered as CVE-2026-13006. Please note that version 1.5.37 provides the full fix to this vulnerability.

... (truncated)

Commits

Updates `ch.qos.logback:logback-classic` from 1.5.32 to 1.6.1
Release notes

Sourced from ch.qos.logback:logback-classic's releases.

Logback 1.6.1

2026-07-28 Release of logback version 1.6.1

• In TimeBasedRollingPolicy, when the file option is set, the intermediate file renamed before asynchronous compression now receives the target archive name without the compression suffix (e.g. .gz, .zip, .xz). Previously it used a nanotime-based .tmp suffix. This makes the file easier to identify if compression fails during rollover. (See also the following paragraph.)

• On GZ, ZIP, or XZ compression failure, the original (uncompressed) log file is no longer deleted. Compression strategies now delete the source file only after successful compression and emit a warning that the original was left intact.

• ConsoleAppender with now probes JLine's org.jline.jansi.AnsiConsole first and falls back to the legacy FuseSource org.fusesource.jansi.AnsiConsole class. This keeps ANSI coloring working after Jansi moved under the JLine project. The optional org.jline:jansi-core artifact is declared as a dependency alongside the existing FuseSource jansi dependency. A preferredJansiClassName property was added for tests. This issue was reported in issues/1043 by seonwoo_jung who also provided the relevant PR.

• LayoutWrappingEncoder now reports an error at start() when no layout is set and guards encode() against a null layout. Previously, a missing layout (for example after an ignored // branch) allowed the encoder to start and then fail with a NullPointerException on every event, resulting in silent log loss. This issue was reported in issues/1046 by seonwoo_jung who also provided the relevant PR.

• FileCollisionAnalyser now detects file collisions involving nested appenders of SiftingAppender. When the nested file or fileNamePattern does not textually reference the discriminator key (e.g. ${userId}), a warning is issued at configuration time naming the appender, the key, and the shared target. This closes a gap where statically declared file appenders were checked but sifted nested appenders were not. This enhancement was contributed in [PR #1041](qos-ch/logback#1041) by seonwoo_jung.

• More defensive handling in SyslogOutputStream and SyslogAppenderBase: the close() method now ensures that resources are closed, writes and flushes check that the underlying resources are in a valid state and fallback to no-op otherwise.

• A bit-wise identical binary of this version can be reproduced by building from source code at commit 57759f433000a133088ef0441038963134437fbd associated with the tag v_1.6.1. The release was built using Java "21" 2023-10-17 LTS build 21.0.1.+12-LTS-29 under Linux Debian 11.6.

• See https://logback.qos.ch/news.html#1.6.1 for the original text.

Logback 1.6.0

2026-07-23 Release of logback version 1.6.0

• Removed certain deprecated variables, methods, and classes. For the list of removed members see release_1.6.0.txt.

• In AsyncAppenderBase, the put(ILoggingEvent) method now has the protected modifier to allow access from derived classes. This change was requested by Thomas Skjølberg in pr#1053.

• Bump SLF4J dependency to version 2.0.18.

See also the overview of the 1.6.x series.

• A bit-wise identical binary of this version can be reproduced by building from source code at commit b07adf36019b51a10f824fdd94009985c587b1d3 associated with the tag v_1.6.0. The release was built using Java "21" 2023-10-17 LTS build 21.0.1.+12-LTS-29 under Linux Debian 11.6.

Logback 1.5.38

2026-07-09 Release of logback version 1.5.38

• In HardenedObjectInputStream, fixed a typo preventing Throwable objects from being white-filtered. This issue was reported in [PR #1045](qos-ch/logback#1045) by t0rchwo0d.

• A bitwise identical binary of this version can be reproduced by building from source code at commit d04984a41fce42977466f45a2f076f0ee5cc4207 associated with the tag v_1.5.38. Release built using Java "21" 2023-10-17 LTS build 21.0.1.+12-LTS-29 under Linux Debian 11.6.

Logback 1.5.37

2026-06-26 Release of logback version 1.5.37

  1. • Given the numerous vulnerabilities related to conditional configuration processing based on the evaluation of Java expressions using the Janino library, support for such expressions has been removed. Users are offered the an online migration service or the <condition> element introduced in version 1.5.20. See the relevant documentation for more details.

• A bitwise identical binary of this version can be reproduced by building from source code at commit c1df7f522e648eec7b4ef6a12c8758fec0f00048 associated with the tag v_1.5.37. Release built using Java "21" 2023-10-17 LTS build 21.0.1.+12-LTS-29 under Linux Debian 11.6.

Logback 1.5.36

2026-06-25 Release of logback version 1.5.36

• The 'condition' attribute in <if> elements now reject certain references that are associated with ACE attacks. This issue was reported by "yulate" (yulate531@gmail.com.com) and registered as CVE-2026-13006. Please note that version 1.5.37 provides the full fix to this vulnerability.

... (truncated)

Commits

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 2b2d0c625f..c7d8eb6f1d 100644 --- a/build.gradle +++ b/build.gradle @@ -30,7 +30,7 @@ ext { openSamlVersion = "5.2.1" commonmarkVersion = "0.28.0" googleJavaFormatVersion = "1.35.0" - logback = "1.5.32" + logback = "1.6.1" commonsIoVersion = "2.22.0" commonsLang3 = "3.20.0" rhinoVersion = "1.9.1" From 88afdd07afa9f16359cf0297957b7bffd2f27db6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 16 Aug 2026 14:15:58 +0100 Subject: [PATCH 02/23] build(deps): bump awsSdkVersion from 2.44.12 to 2.51.2 (#7371) Bumps `awsSdkVersion` from 2.44.12 to 2.51.2. Updates `software.amazon.awssdk:s3` from 2.44.12 to 2.51.2 Updates `software.amazon.awssdk:url-connection-client` from 2.44.12 to 2.51.2 Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index c7d8eb6f1d..5c8f4b47bb 100644 --- a/build.gradle +++ b/build.gradle @@ -44,7 +44,7 @@ ext { batikVersion = "1.19" jpdfiumVersion = "1.0.4" jwtVersion = "0.13.0" - awsSdkVersion = "2.44.12" + awsSdkVersion = "2.51.2" jschVersion = "2.28.6" commonsNetVersion = "3.11.1" smbjVersion = "0.14.0" From fb362f70ab77fcd4914983fa842459f46280915a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 16 Aug 2026 14:16:15 +0100 Subject: [PATCH 03/23] build(deps): bump github/codeql-action/upload-sarif from 4.37.4 to 4.37.6 (#7467) Bumps [github/codeql-action/upload-sarif](https://github.com/github/codeql-action) from 4.37.4 to 4.37.6.
Release notes

Sourced from github/codeql-action/upload-sarif's releases.

v4.37.6

  • Changed the default filepath for the new remote file address format that was introduced in CodeQL Action 4.37.0 / 3.37.0 to .github/codeql-config.yml to align it with the suggested path that is used elsewhere. #4070

v4.37.5

  • Fixed a bug where a network error while streaming the download of the CodeQL bundle could terminate the init Action instead of falling back to downloading the bundle before extracting it. #4061
Changelog

Sourced from github/codeql-action/upload-sarif's changelog.

CodeQL Action Changelog

See the releases page for the relevant changes to the CodeQL CLI and language packs.

[UNRELEASED]

  • Update default CodeQL bundle version to 2.26.3. #4085

4.37.6 - 04 Aug 2026

  • Changed the default filepath for the new remote file address format that was introduced in CodeQL Action 4.37.0 / 3.37.0 to .github/codeql-config.yml to align it with the suggested path that is used elsewhere. #4070

4.37.5 - 03 Aug 2026

  • Fixed a bug where a network error while streaming the download of the CodeQL bundle could terminate the init Action instead of falling back to downloading the bundle before extracting it. #4061

4.37.4 - 29 Jul 2026

  • This version of the CodeQL Action adds support for the tools input for the codeql-action/init step to be specified using a github-codeql-tools repository property. This feature will gradually be rolled out following the release of this version. Once rolled out, this allows for the CodeQL CLI version that is used in GitHub-managed workflows, such as Default Setup, to be set to a custom value. For example, customers who run into issues with rate limits when a new CodeQL CLI version is released can set the value to toolcache to always use the CodeQL CLI version that is available in the runner toolcache. For Advanced Setup workflows, the value provided for tools in the workflow definition always takes precedence unless the value of the repository property starts with !. #4037
  • Update default CodeQL bundle version to 2.26.2. #4051

4.37.3 - 22 Jul 2026

No user facing changes.

4.37.2 - 21 Jul 2026

  • The new address format for the config-file input that was introduced in CodeQL Action 4.37.0 is now enabled by default. In addition to the format described there, the remote= prefix can now be used to explicitly indicate that the input refers to a remote file. All previous input formats continue to be accepted as well. #4023
  • The CodeQL Action can now make use of configured private registries in Default Setup to retrieve CodeQL configuration files from remote repositories that require authentication. This will allow customers to store their CodeQL configuration in a single repository that can then be referenced by Default Setup workflows in other repositories. We expect to roll this and other, related changes out to everyone in July. #4007

4.37.1 - 16 Jul 2026

  • Upcoming breaking change: Add a deprecation warning for customers using CodeQL version 2.20.6 and earlier. These versions of CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise Server 3.16, and will be unsupported by the next minor release of the CodeQL Action. #3956
  • Update default CodeQL bundle version to 2.26.1. #4019

4.37.0 - 08 Jul 2026

  • Update default CodeQL bundle version to 2.26.0. #3995
  • In addition to the existing input format, the config-file input for the codeql-action/init step will soon support a new [owner/]repo[@ref][:path] format. All components except the repository name are optional. If omitted, owner defaults to the same owner as the repository the analysis is running for, ref to main, and path to .github/codeql-action.yaml. Support for this format ships in this version of the CodeQL Action, but will only be enabled over the coming weeks. #3973

4.36.3 - 01 Jul 2026

No user facing changes.

4.36.2 - 04 Jun 2026

  • Cache CodeQL CLI version information across Actions steps. #3943
  • Reduce requests while waiting for analysis processing by using exponential backoff when polling SARIF processing status. #3937
  • Update default CodeQL bundle version to 2.25.6. #3948

... (truncated)

Commits
  • 5595cca Merge pull request #4071 from github/update-v4.37.6-6a9359a1b
  • ec9c757 Add change note for PR 4070
  • 45c8742 Update changelog for v4.37.6
  • 6a9359a Merge pull request #4070 from github/mbg/remote-address/change-file-default
  • 065cdc0 Change DEFAULT_CONFIG_FILE_NAME
  • f99dd5a Merge pull request #4066 from github/dependabot/npm_and_yarn/js-yaml-5.2.2
  • 1804b21 Merge pull request #4068 from github/mergeback/v4.37.5-to-main-d1ba80a1
  • 3020a2f Rebuild
  • 93c3a5a Update changelog and version after v4.37.5
  • d1ba80a Merge pull request #4067 from github/update-v4.37.5-1cd4d01d5
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=github/codeql-action/upload-sarif&package-manager=github_actions&previous-version=4.37.4&new-version=4.37.6)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/scorecards.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml index 2eab52b1d4..0e9b27c829 100644 --- a/.github/workflows/scorecards.yml +++ b/.github/workflows/scorecards.yml @@ -75,6 +75,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4 + uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 with: sarif_file: results.sarif From eece9fbdf8f80a09ca66dfd70dabbb65bde141a7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 16 Aug 2026 14:16:32 +0100 Subject: [PATCH 04/23] build(deps): bump commons-net:commons-net from 3.11.1 to 3.13.0 (#7517) Bumps [commons-net:commons-net](https://github.com/apache/commons-net) from 3.11.1 to 3.13.0.
Changelog

Sourced from commons-net:commons-net's changelog.

Apache Commons Net 3.13.0 Release Notes

The Apache Commons Net team is pleased to announce the release of Apache Commons Net 3.13.0.

Apache Commons Net library contains a collection of network utilities and protocol implementations. Supported protocols include Echo, Finger, FTP, NNTP, NTP, POP3(S), SMTP(S), Telnet, and Whois.

This is a feature and maintenance release. Java 8 or later is required.

For complete information on Apache Commons Net, including instructions on how to submit bug reports, patches, or suggestions for improvement, see the Apache Commons Net website:

https://commons.apache.org/proper/commons-net/

Download page: https://commons.apache.org/proper/commons-net/download_net.cgi

New features

o Add DatagramSocketClient.getDefaultTimeoutDuration() and deprecate getDefaultTimeout(). Thanks to Gary Gregory. o NET-741: Add subnet IPv6 handling with SubnetUtils6 #391. Thanks to Maros Orsak, Gary Gregory.

Fixed Bugs

o DaytimeTCPClientTest now should now pass inside most VPNs. Thanks to Henri Biestro, Gary Gregory. o Migrate tests to JUnit5 #358, #359. Thanks to Jakub Kupczyk, Gary Gregory. o Fix malformed Javadoc comments. Thanks to Gary Gregory. o IMAPExportMbox now restores the current thread's interrupt flag when catching InterruptedException. Thanks to Gary Gregory. o IOUtil.readWrite() now restores the current thread's interrupt flag when catching InterruptedException. Thanks to Gary Gregory. o TelnetInputStream now restores the current thread's interrupt flag when catching InterruptedException. Thanks to Gary Gregory. o NET-740: FTP fails to parse listings for Linux vsftpd in Chinese or Japanese #393. Thanks to Jianwei Guo, Gary Gregory. o TelnetInputStream.read() doesn't preserve the original InterruptedException as the cause of its InterruptedIOException. Thanks to Gary Gregory. o FTPClient._storeFile(String, String, InputStream) doesn't always close it's internal socket when an exception is thrown early in processing. Thanks to Gary Gregory. o ListenerList.removeListener(T) now ignores null input to avoid a NullPointerException. Thanks to Gary Gregory. o ListenerList.addListener(T) now ignores null input. Thanks to Gary Gregory. o Fix typo in FTPConnectionClosedException message from FTP.getReply(boolean). Thanks to Gary Gregory. o Reimplement Util.copyReader() with IOUtils.copyLarge(). Thanks to Gary Gregory. o Reimplement Util.copyStream() with IOUtils.copyLarge(). Thanks to Gary Gregory. o Reimplement Util.copyStream() with IOUtils.copyLarge(). Thanks to Gary Gregory. o Deprecate Util.copyReader(Reader, Writer) in favor of IOUtils.copyLarge(Reader, Writer). Thanks to Gary Gregory.

Changes

o Bump org.apache.commons:commons-parent from 85 to 97 #371, #388, #389. Thanks to Gary Gregory, Dependabot. o Bump org.apache.commons:commons-lang3 from 3.18.0 to 3.19.0. Thanks to Gary Gregory, Dependabot.

... (truncated)

Commits
  • 35fa0df Prepare for the release candidate 3.13.0 RC1
  • 1cc5b6e Prepare for the next release candidate
  • a4b246e Reuse IOUtils.copyLarge()
  • e5ca262 Javadoc
  • 3cb18ec Deprecate Util.copyReader(Reader, Writer) in favor of
  • 3373d77 Reimplement Util.copyReader() with IOUtils.copyLarge()
  • 3be2077 Reimplement Util.copyStream() with IOUtils.copyLarge()
  • 7b41144 Javadoc
  • 1b584b4 Fix typo in FTPConnectionClosedException message from
  • 3792023 Javadoc
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=commons-net:commons-net&package-manager=gradle&previous-version=3.11.1&new-version=3.13.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 5c8f4b47bb..b7fcb794cc 100644 --- a/build.gradle +++ b/build.gradle @@ -46,7 +46,7 @@ ext { jwtVersion = "0.13.0" awsSdkVersion = "2.51.2" jschVersion = "2.28.6" - commonsNetVersion = "3.11.1" + commonsNetVersion = "3.13.0" smbjVersion = "0.14.0" tinkVersion = "1.23.0" testcontainersMinioVersion = "1.21.4" From e2700bf3914bac9d855d8f7ed9b77e1ff5398187 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 16 Aug 2026 14:16:50 +0100 Subject: [PATCH 05/23] build(deps): bump go-task/setup-task from 2.0.0 to 2.1.0 (#7468) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [go-task/setup-task](https://github.com/go-task/setup-task) from 2.0.0 to 2.1.0.
Release notes

Sourced from go-task/setup-task's releases.

v2.1.0

What's Changed

  • Replaced typed-rest-client with @actions/http-client for GitHub API calls to eliminate the Node 24 DEP0169 deprecation warning about url.parse() (#5 by @​vmaerten).
  • Modernized the TypeScript tooling stack (vitest, oxlint, @actions/core@2, @actions/io@2, updated @types/node, @vercel/ncc, prettier, etc.) (#5 by @​vmaerten).
  • Migrated the project to ESM (sources + bundle). Aligns with the new @actions/* ESM-only majors and produces a ~47% smaller dist/index.js (#5 by @​vmaerten).
  • Upgraded @actions/core 2 → 3, @actions/http-client 2 → 4, @actions/io 2 → 3, @actions/tool-cache 2 → 4, typescript 5 → 6, and markdownlint-cli 0.47 → 0.48 (#5 by @​vmaerten).
Changelog

Sourced from go-task/setup-task's changelog.

Changelog

Unreleased

v2.2.0 - 2026-08-09

  • Added a checksum input to verify the SHA256 digest of the downloaded Task archive (#16 by @​illera88).
  • Updated development dependencies: typescript 6 → 7, @vercel/ncc 0.38 → 0.44, markdownlint-cli 0.48 → 0.49, and oxfmt 0.50 → 0.62 (#15).

v2.1.0 - 2026-05-17

  • Replaced typed-rest-client with @actions/http-client for GitHub API calls to eliminate the Node 24 DEP0169 deprecation warning about url.parse().
  • Modernized the TypeScript tooling stack (vitest, oxlint, @actions/core@2, @actions/io@2, updated @types/node, @vercel/ncc, prettier, etc.).
  • Migrated the project to ESM (sources + bundle). Aligns with the new @actions/* ESM-only majors and produces a ~47% smaller dist/index.js.
  • Upgraded @actions/core 2 → 3, @actions/http-client 2 → 4, @actions/io 2 → 3, @actions/tool-cache 2 → 4, typescript 5 → 6, and markdownlint-cli 0.47 → 0.48.

v2.0.0 - 2026-03-18

  • BREAKING: Upgraded to Node 24. Requires a GitHub Actions runner with Node.js 24 support (#10 by @​vmaerten).

v1.1.0 - 2026-03-17

  • Added configurable HTTP retry for API requests (#7 by @​vmaerten).

v1.0.0 - 2025-09-12

Commits

Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/pre_commit.yml | 2 +- .github/workflows/sync_files_v2.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pre_commit.yml b/.github/workflows/pre_commit.yml index 18c4782927..878ce60673 100644 --- a/.github/workflows/pre_commit.yml +++ b/.github/workflows/pre_commit.yml @@ -34,7 +34,7 @@ jobs: cache-suffix: pre-commit - name: Install Task - uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0 + uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 - name: Run pre-commit checks run: task pre-commit diff --git a/.github/workflows/sync_files_v2.yml b/.github/workflows/sync_files_v2.yml index 7e6f999618..a21eefffdd 100644 --- a/.github/workflows/sync_files_v2.yml +++ b/.github/workflows/sync_files_v2.yml @@ -66,7 +66,7 @@ jobs: uv sync --project engine --locked --group tools - name: Install Task - uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0 + uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 - name: Sync translation TOML files run: | From 42c84a16937666398848332b26e4714066345fb2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 16 Aug 2026 14:17:01 +0100 Subject: [PATCH 06/23] build(deps): bump base64 from 0.23.0 to 0.23.1 in /frontend/editor/src-tauri (#7460) Bumps [base64](https://github.com/marshallpierce/rust-base64) from 0.23.0 to 0.23.1.
Changelog

Sourced from base64's changelog.

0.23.1

  • Make the tests build again on non-SIMD architectures
Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=base64&package-manager=cargo&previous-version=0.23.0&new-version=0.23.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- frontend/editor/src-tauri/Cargo.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/editor/src-tauri/Cargo.lock b/frontend/editor/src-tauri/Cargo.lock index c7d25757c9..fe8352f5a7 100644 --- a/frontend/editor/src-tauri/Cargo.lock +++ b/frontend/editor/src-tauri/Cargo.lock @@ -276,9 +276,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64" -version = "0.23.0" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b25655df2c3cdd83c5e5b293b88acd880332b2ddadd7c30ac43144fdc0033da9" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" [[package]] name = "bit-set" @@ -4551,7 +4551,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" name = "stirling-pdf" version = "0.1.0" dependencies = [ - "base64 0.23.0", + "base64 0.23.1", "core-foundation 0.10.1", "core-services", "keyring", From 0deb6fc3d552c25246c1cc4ded5e06ab97413de8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 16 Aug 2026 14:17:18 +0100 Subject: [PATCH 07/23] build(deps): bump dorny/paths-filter from 4.0.2 to 4.0.3 (#7465) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [dorny/paths-filter](https://github.com/dorny/paths-filter) from 4.0.2 to 4.0.3.
Release notes

Sourced from dorny/paths-filter's releases.

v4.0.3

What's Changed

Security

New Contributors

Full Changelog: https://github.com/dorny/paths-filter/compare/v4...v4.0.3

Changelog

Sourced from dorny/paths-filter's changelog.

Changelog

v4.0.3

v4.0.2

v4.0.1

v4.0.0

v3.0.4

v3.0.3

v3.0.2

v3.0.1

v3.0.0

v2.11.1

v2.11.0

v2.10.2

... (truncated)

Commits
  • ceb8a2b Update CHANGELOG.md for v4.0.3 and v3.0.4 (#327)
  • ef09b88 Document safe handling of file list outputs in workflows (#326)
  • 44adc5b Merge commit from fork
  • 4711b7a feat: add 'some-with-excludes' predicate quantifier (#322)
  • 93c889f fix: escape multi-line filenames in list-files shell and csv output
  • b41dfa9 docs: add contents permission to PR example (#248)
  • 9af6e5a fix: scope base-ignored warning to API path (#319)
  • cae9006 docs: update outputs in readme to account for the 'every' predicate-quantifie...
  • See full diff in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=dorny/paths-filter&package-manager=github_actions&previous-version=4.0.2&new-version=4.0.3)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/PR-Auto-Deploy-V2.yml | 2 +- .github/workflows/build.yml | 2 +- .github/workflows/frontend-backend-licenses-update.yml | 2 +- .github/workflows/testdriver.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/PR-Auto-Deploy-V2.yml b/.github/workflows/PR-Auto-Deploy-V2.yml index 1ae657fa76..c867929905 100644 --- a/.github/workflows/PR-Auto-Deploy-V2.yml +++ b/.github/workflows/PR-Auto-Deploy-V2.yml @@ -334,7 +334,7 @@ jobs: # under the exact same access rules as the app preview. - name: Detect Storybook changes id: sb-changes - uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 + uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 with: list-files: json filters: | diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 939b701ce9..ee9ebf7bd4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -55,7 +55,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Check for file changes - uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 + uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 id: changes with: filters: .github/config/.files.yaml diff --git a/.github/workflows/frontend-backend-licenses-update.yml b/.github/workflows/frontend-backend-licenses-update.yml index f2cca9cee4..69c2a90c63 100644 --- a/.github/workflows/frontend-backend-licenses-update.yml +++ b/.github/workflows/frontend-backend-licenses-update.yml @@ -36,7 +36,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Check for file changes - uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 + uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 id: changes with: filters: .github/config/.files.yaml diff --git a/.github/workflows/testdriver.yml b/.github/workflows/testdriver.yml index f98d874dfa..fe27e0ba1f 100644 --- a/.github/workflows/testdriver.yml +++ b/.github/workflows/testdriver.yml @@ -159,7 +159,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Check for file changes - uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 + uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 id: changes with: filters: ".github/config/.files.yaml" From 88db26b001ebff746442a79a20f696fd15399ac7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 16 Aug 2026 14:17:29 +0100 Subject: [PATCH 08/23] build(deps): bump the eclipse-temurin group across 3 directories with 1 update (#7453) Bumps the eclipse-temurin group with 1 update in the /docker/backend directory: eclipse-temurin. Bumps the eclipse-temurin group with 1 update in the /docker/base directory: eclipse-temurin. Bumps the eclipse-temurin group with 1 update in the /docker/embedded directory: eclipse-temurin. Updates `eclipse-temurin` from `2f1da10` to `fbcf915` Updates `eclipse-temurin` from `2f1da10` to `fbcf915` Updates `eclipse-temurin` from `2f1da10` to `fbcf915` Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docker/backend/Dockerfile | 2 +- docker/base/Dockerfile | 2 +- docker/embedded/Dockerfile | 2 +- docker/embedded/Dockerfile.fat | 2 +- docker/embedded/Dockerfile.ultra-lite | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docker/backend/Dockerfile b/docker/backend/Dockerfile index 454fdedcfd..fd26ee2fb7 100644 --- a/docker/backend/Dockerfile +++ b/docker/backend/Dockerfile @@ -45,7 +45,7 @@ RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo li --no-daemon # Stage 2: Extract Spring Boot Layers -FROM eclipse-temurin:25-jre-noble@sha256:2f1da100788559b397bcf48c736169ea5b070bde84e55f203bbee8e83d87a175 AS jar-extract +FROM eclipse-temurin:25-jre-noble@sha256:fbcf915c585659b30eb766ada4d6d7cfc9ec1040bf521e95bf61b10a25af73db AS jar-extract WORKDIR /tmp COPY --from=app-build /app/app/core/build/libs/*.jar app.jar RUN java -Djarmode=tools -jar app.jar extract --layers --destination /layers diff --git a/docker/base/Dockerfile b/docker/base/Dockerfile index 6cffbf88f9..95e4e84795 100644 --- a/docker/base/Dockerfile +++ b/docker/base/Dockerfile @@ -364,7 +364,7 @@ RUN --mount=type=cache,target=/root/.cache/pip,sharing=locked \ # Final runtime image - the actual base image -FROM eclipse-temurin:25-jre-noble@sha256:2f1da100788559b397bcf48c736169ea5b070bde84e55f203bbee8e83d87a175 AS runtime +FROM eclipse-temurin:25-jre-noble@sha256:fbcf915c585659b30eb766ada4d6d7cfc9ec1040bf521e95bf61b10a25af73db AS runtime SHELL ["/bin/bash", "-o", "pipefail", "-c"] diff --git a/docker/embedded/Dockerfile b/docker/embedded/Dockerfile index de7da3789e..d4ad422476 100644 --- a/docker/embedded/Dockerfile +++ b/docker/embedded/Dockerfile @@ -61,7 +61,7 @@ RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo li --no-daemon # Stage 2: Extract Spring Boot Layers -FROM eclipse-temurin:25-jre-noble@sha256:2f1da100788559b397bcf48c736169ea5b070bde84e55f203bbee8e83d87a175 AS jar-extract +FROM eclipse-temurin:25-jre-noble@sha256:fbcf915c585659b30eb766ada4d6d7cfc9ec1040bf521e95bf61b10a25af73db AS jar-extract WORKDIR /tmp COPY --from=app-build /app/app/core/build/libs/*.jar app.jar RUN java -Djarmode=tools -jar app.jar extract --layers --destination /layers diff --git a/docker/embedded/Dockerfile.fat b/docker/embedded/Dockerfile.fat index 300729e560..58eafec851 100644 --- a/docker/embedded/Dockerfile.fat +++ b/docker/embedded/Dockerfile.fat @@ -56,7 +56,7 @@ RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo li --no-daemon # Stage 2: Extract Spring Boot Layers -FROM eclipse-temurin:25-jre-noble@sha256:2f1da100788559b397bcf48c736169ea5b070bde84e55f203bbee8e83d87a175 AS jar-extract +FROM eclipse-temurin:25-jre-noble@sha256:fbcf915c585659b30eb766ada4d6d7cfc9ec1040bf521e95bf61b10a25af73db AS jar-extract WORKDIR /tmp COPY --from=app-build /app/app/core/build/libs/*.jar app.jar RUN java -Djarmode=tools -jar app.jar extract --layers --destination /layers diff --git a/docker/embedded/Dockerfile.ultra-lite b/docker/embedded/Dockerfile.ultra-lite index 68a65dea6a..d4a21b7812 100644 --- a/docker/embedded/Dockerfile.ultra-lite +++ b/docker/embedded/Dockerfile.ultra-lite @@ -57,7 +57,7 @@ RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo li # Stage 2: Runtime image # glibc base (not Alpine/musl): JPDFium's PDFium natives are glibc-linked. -FROM eclipse-temurin:25-jre-noble@sha256:2f1da100788559b397bcf48c736169ea5b070bde84e55f203bbee8e83d87a175 +FROM eclipse-temurin:25-jre-noble@sha256:fbcf915c585659b30eb766ada4d6d7cfc9ec1040bf521e95bf61b10a25af73db ENV DEBIAN_FRONTEND=noninteractive \ LANG=C.UTF-8 \ From fb0281fc80ef6baee42f76232fd1bdf99d6f3d9a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 16 Aug 2026 14:18:23 +0100 Subject: [PATCH 09/23] build(deps-dev): bump codespell from 2.4.2 to 2.4.3 in /engine (#7456) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [codespell](https://github.com/codespell-project/codespell) from 2.4.2 to 2.4.3.
Release notes

Sourced from codespell's releases.

v2.4.3

What's Changed

... (truncated)

Commits
  • 57b2140 Read only [tool.codespell] from TOML config (#3975)
  • 23b8d94 [pre-commit.ci] pre-commit autoupdate
  • 926c4d6 [pre-commit.ci] pre-commit autoupdate
  • 820a301 Merge pull request #3967 from codespell-project/peternewman-patch-1
  • 9fe42c9 Add common misspellings for reseeve->reserve to dictionary
  • 725173c [pre-commit.ci] pre-commit autoupdate
  • 86c13ee [pre-commit.ci] pre-commit autoupdate
  • 7b9f36c Bump actions/checkout from 6 to 7
  • bcc1b80 Add spelling corrections for simpe and variants.
  • 89584cc [pre-commit.ci] pre-commit autoupdate
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=codespell&package-manager=uv&previous-version=2.4.2&new-version=2.4.3)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- engine/pyproject.toml | 2 +- engine/uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/engine/pyproject.toml b/engine/pyproject.toml index 3f2c277fbb..87e7cc99c0 100644 --- a/engine/pyproject.toml +++ b/engine/pyproject.toml @@ -64,7 +64,7 @@ updater-signatures = [ ] # Pinned repository-wide pre-commit tooling. pre-commit = [ - "codespell==2.4.2", + "codespell==2.4.3", "ruff==0.15.5", "tomli-w==1.2.0", ] diff --git a/engine/uv.lock b/engine/uv.lock index 0b25716add..fbfa665bc6 100644 --- a/engine/uv.lock +++ b/engine/uv.lock @@ -414,11 +414,11 @@ wheels = [ [[package]] name = "codespell" -version = "2.4.2" +version = "2.4.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2d/9d/1d0903dff693160f893ca6abcabad545088e7a2ee0a6deae7c24e958be69/codespell-2.4.2.tar.gz", hash = "sha256:3c33be9ae34543807f088aeb4832dfad8cb2dae38da61cac0a7045dd376cfdf3", size = 352058, upload-time = "2026-03-05T18:10:42.936Z" } +sdist = { url = "https://files.pythonhosted.org/packages/80/19/45e941380f69c042b43423513d201e6592346f992394347f5e7174c31407/codespell-2.4.3.tar.gz", hash = "sha256:cbe085e331227b37bb86ef8bddd08dc768c704ee9a07ca869852c093fa2793e2", size = 352773, upload-time = "2026-07-15T11:51:54.159Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/42/a1/52fa05533e95fe45bcc09bcf8a503874b1c08f221a4e35608017e0938f55/codespell-2.4.2-py3-none-any.whl", hash = "sha256:97e0c1060cf46bd1d5db89a936c98db8c2b804e1fdd4b5c645e82a1ec6b1f886", size = 353715, upload-time = "2026-03-05T18:10:41.398Z" }, + { url = "https://files.pythonhosted.org/packages/8b/bf/bdb951d34eb169140b546f44be9ec4525d1acefb9eb5572071f5492b19fc/codespell-2.4.3-py3-none-any.whl", hash = "sha256:af2505b335e8573dbd2d384d1c4ef498f4006f4ba2d6fceca01e55b91f52628a", size = 340736, upload-time = "2026-07-15T11:51:52.925Z" }, ] [[package]] @@ -718,7 +718,7 @@ engine-dev = [ { name = "ruff", specifier = "==0.15.5" }, ] pre-commit = [ - { name = "codespell", specifier = "==2.4.2" }, + { name = "codespell", specifier = "==2.4.3" }, { name = "ruff", specifier = "==0.15.5" }, { name = "tomli-w", specifier = "==1.2.0" }, ] From beea37cb92bcdcd18a53e0311cffb4c91e2f585e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 16 Aug 2026 14:18:51 +0100 Subject: [PATCH 10/23] build(deps): bump step-security/harden-runner from 2.20.0 to 2.20.1 (#7466) Bumps [step-security/harden-runner](https://github.com/step-security/harden-runner) from 2.20.0 to 2.20.1.
Release notes

Sourced from step-security/harden-runner's releases.

v2.20.1

What's Changed

  • AWS CodeBuild-hosted runner support
  • Implicitly allow single-labeled (internal) domains in block-mode

Full Changelog: https://github.com/step-security/harden-runner/compare/v2.20.0...v2.20.1

Commits
  • b09bb98 Merge pull request #680 from step-security/aws-code-build
  • 35cd77b docs: document the Global Block List in the features list
  • bb6dbef chore: rebuild dist with clean dependency install
  • 98f73c5 chore: update eBPF agent to v1.8.14
  • 54193c1 Reapply "feat(runners): detect AWS CodeBuild-hosted runners as third-party pr...
  • d22dd48 Revert "fix(self-hosted): flush agent events at job end when deploy-on-self-h...
  • 0ff0941 fix(self-hosted): flush agent events at job end when deploy-on-self-hosted-vm...
  • a3c333d Revert "feat(runners): detect AWS CodeBuild-hosted runners as third-party pro...
  • bf94c00 feat(runners): detect AWS CodeBuild-hosted runners as third-party provider
  • 514522c fix(self-hosted): resolve runner user when USER env var is unset
  • See full diff in compare view

Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/PR-Auto-Deploy-V2.yml | 6 +++--- .github/workflows/PR-Demo-Comment-with-react.yml | 6 +++--- .github/workflows/PR-Demo-cleanup.yml | 2 +- .github/workflows/_runner-pick.yml | 2 +- .github/workflows/ai-engine.yml | 2 +- .github/workflows/ai_pr_title_review.yml | 2 +- .github/workflows/aur-publish.yml | 4 ++-- .github/workflows/auto-labelerV2.yml | 2 +- .github/workflows/backend-build.yml | 2 +- .github/workflows/build-enterprise.yml | 4 ++-- .github/workflows/build.yml | 6 +++--- .github/workflows/check-generated-models.yml | 2 +- .github/workflows/check-licence.yml | 2 +- .github/workflows/check-openapi.yml | 2 +- .github/workflows/check_toml.yml | 2 +- .github/workflows/coverage-aggregate.yml | 2 +- .github/workflows/db-migration-test.yml | 2 +- .github/workflows/dependency-review.yml | 2 +- .github/workflows/deploy-on-v2-commit.yml | 2 +- .github/workflows/docker-compose-tests.yml | 2 +- .github/workflows/e2e-live.yml | 2 +- .github/workflows/e2e-stubbed.yml | 2 +- .github/workflows/frontend-a11y.yml | 2 +- .github/workflows/frontend-backend-licenses-update.yml | 6 +++--- .github/workflows/frontend-validation.yml | 2 +- .github/workflows/manage-label.yml | 2 +- .github/workflows/multiOSReleases.yml | 8 ++++---- .github/workflows/nightly.yml | 6 +++--- .github/workflows/package-managers.yml | 4 ++-- .github/workflows/pr-conflict-labeler.yml | 2 +- .github/workflows/pre_commit.yml | 2 +- .github/workflows/push-docker-base.yml | 2 +- .github/workflows/push-docker.yml | 2 +- .github/workflows/rollback-latest.yml | 2 +- .github/workflows/scorecards.yml | 2 +- .github/workflows/stale.yml | 2 +- .github/workflows/swagger.yml | 2 +- .github/workflows/sync-portal-docs.yml | 2 +- .github/workflows/sync_files_v2.yml | 2 +- .github/workflows/tauri-build.yml | 8 ++++---- .github/workflows/test-build-docker.yml | 4 ++-- .github/workflows/testdriver.yml | 8 ++++---- 42 files changed, 65 insertions(+), 65 deletions(-) diff --git a/.github/workflows/PR-Auto-Deploy-V2.yml b/.github/workflows/PR-Auto-Deploy-V2.yml index c867929905..0f07aabbe5 100644 --- a/.github/workflows/PR-Auto-Deploy-V2.yml +++ b/.github/workflows/PR-Auto-Deploy-V2.yml @@ -39,7 +39,7 @@ jobs: pr_ref: ${{ steps.resolve.outputs.ref }} steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit @@ -121,7 +121,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit @@ -472,7 +472,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit diff --git a/.github/workflows/PR-Demo-Comment-with-react.yml b/.github/workflows/PR-Demo-Comment-with-react.yml index 8e0e66032e..fe3f28a637 100644 --- a/.github/workflows/PR-Demo-Comment-with-react.yml +++ b/.github/workflows/PR-Demo-Comment-with-react.yml @@ -70,7 +70,7 @@ jobs: enable_prototypes: ${{ steps.check-prototypes-flag.outputs.enable_prototypes }} steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit @@ -177,7 +177,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit @@ -485,7 +485,7 @@ jobs: issues: write # add/remove labels, delete the command comment steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit diff --git a/.github/workflows/PR-Demo-cleanup.yml b/.github/workflows/PR-Demo-cleanup.yml index 7b4ee8b3a3..e0032955e3 100644 --- a/.github/workflows/PR-Demo-cleanup.yml +++ b/.github/workflows/PR-Demo-cleanup.yml @@ -23,7 +23,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit diff --git a/.github/workflows/_runner-pick.yml b/.github/workflows/_runner-pick.yml index 0b3f76a8cd..d65831c5df 100644 --- a/.github/workflows/_runner-pick.yml +++ b/.github/workflows/_runner-pick.yml @@ -38,7 +38,7 @@ jobs: is_fork: ${{ steps.decide.outputs.is_fork }} steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit diff --git a/.github/workflows/ai-engine.yml b/.github/workflows/ai-engine.yml index 056dc35ca3..015934030f 100644 --- a/.github/workflows/ai-engine.yml +++ b/.github/workflows/ai-engine.yml @@ -20,7 +20,7 @@ jobs: pull-requests: write steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit diff --git a/.github/workflows/ai_pr_title_review.yml b/.github/workflows/ai_pr_title_review.yml index 9922177b4b..b9b391af0e 100644 --- a/.github/workflows/ai_pr_title_review.yml +++ b/.github/workflows/ai_pr_title_review.yml @@ -21,7 +21,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit diff --git a/.github/workflows/aur-publish.yml b/.github/workflows/aur-publish.yml index f1ca2be8ca..af0dc85aa6 100644 --- a/.github/workflows/aur-publish.yml +++ b/.github/workflows/aur-publish.yml @@ -26,7 +26,7 @@ jobs: jar_sha256: ${{ steps.hashes.outputs.jar_sha256 }} steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit @@ -71,7 +71,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit diff --git a/.github/workflows/auto-labelerV2.yml b/.github/workflows/auto-labelerV2.yml index bcbd0fcba5..8e6d974f3c 100644 --- a/.github/workflows/auto-labelerV2.yml +++ b/.github/workflows/auto-labelerV2.yml @@ -18,7 +18,7 @@ jobs: issues: write # labels are applied through the issues API steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit diff --git a/.github/workflows/backend-build.yml b/.github/workflows/backend-build.yml index 3cba3c8d39..9561833f07 100644 --- a/.github/workflows/backend-build.yml +++ b/.github/workflows/backend-build.yml @@ -28,7 +28,7 @@ jobs: flavor: [core, proprietary, saas] steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit - name: Checkout repository diff --git a/.github/workflows/build-enterprise.yml b/.github/workflows/build-enterprise.yml index f1301b6fd2..08849c683b 100644 --- a/.github/workflows/build-enterprise.yml +++ b/.github/workflows/build-enterprise.yml @@ -50,7 +50,7 @@ jobs: SYSTEM_ENABLEANALYTICS: "false" steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit - name: Checkout repository @@ -324,7 +324,7 @@ jobs: MN_COMPOSE: docker-compose-multinode.yml steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit - name: Checkout repository diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ee9ebf7bd4..eee07d599a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -48,7 +48,7 @@ jobs: proprietary: ${{ steps.changes.outputs.proprietary }} steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit - name: Checkout repository @@ -67,7 +67,7 @@ jobs: timeout-minutes: 15 steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit - name: Checkout repository @@ -306,7 +306,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit diff --git a/.github/workflows/check-generated-models.yml b/.github/workflows/check-generated-models.yml index 833b9c8e47..39c6467889 100644 --- a/.github/workflows/check-generated-models.yml +++ b/.github/workflows/check-generated-models.yml @@ -23,7 +23,7 @@ jobs: pull-requests: write steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit diff --git a/.github/workflows/check-licence.yml b/.github/workflows/check-licence.yml index b1692c904d..d45a68e860 100644 --- a/.github/workflows/check-licence.yml +++ b/.github/workflows/check-licence.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit diff --git a/.github/workflows/check-openapi.yml b/.github/workflows/check-openapi.yml index c26bf60aba..751a34a33e 100644 --- a/.github/workflows/check-openapi.yml +++ b/.github/workflows/check-openapi.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit diff --git a/.github/workflows/check_toml.yml b/.github/workflows/check_toml.yml index eff416c379..ffb3e01226 100644 --- a/.github/workflows/check_toml.yml +++ b/.github/workflows/check_toml.yml @@ -28,7 +28,7 @@ jobs: pull-requests: write # Allow writing to pull requests steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit diff --git a/.github/workflows/coverage-aggregate.yml b/.github/workflows/coverage-aggregate.yml index 507525f254..a97b579f15 100644 --- a/.github/workflows/coverage-aggregate.yml +++ b/.github/workflows/coverage-aggregate.yml @@ -34,7 +34,7 @@ jobs: timeout-minutes: 15 steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit - name: Checkout repository diff --git a/.github/workflows/db-migration-test.yml b/.github/workflows/db-migration-test.yml index 377d2daf98..4181390285 100644 --- a/.github/workflows/db-migration-test.yml +++ b/.github/workflows/db-migration-test.yml @@ -17,7 +17,7 @@ jobs: timeout-minutes: 30 steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 07f1f8ce1a..b27596ddfb 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit diff --git a/.github/workflows/deploy-on-v2-commit.yml b/.github/workflows/deploy-on-v2-commit.yml index 21d12044f5..01114c64b2 100644 --- a/.github/workflows/deploy-on-v2-commit.yml +++ b/.github/workflows/deploy-on-v2-commit.yml @@ -22,7 +22,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit diff --git a/.github/workflows/docker-compose-tests.yml b/.github/workflows/docker-compose-tests.yml index 05b66ba34b..ddb35b4e1a 100644 --- a/.github/workflows/docker-compose-tests.yml +++ b/.github/workflows/docker-compose-tests.yml @@ -25,7 +25,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit diff --git a/.github/workflows/e2e-live.yml b/.github/workflows/e2e-live.yml index 3c5c12d6e7..4aa2a78c89 100644 --- a/.github/workflows/e2e-live.yml +++ b/.github/workflows/e2e-live.yml @@ -15,7 +15,7 @@ jobs: timeout-minutes: 30 steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit - name: Checkout repository diff --git a/.github/workflows/e2e-stubbed.yml b/.github/workflows/e2e-stubbed.yml index df3c5fa82a..d02005936c 100644 --- a/.github/workflows/e2e-stubbed.yml +++ b/.github/workflows/e2e-stubbed.yml @@ -27,7 +27,7 @@ jobs: project: stubbed-webkit steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit - name: Checkout repository diff --git a/.github/workflows/frontend-a11y.yml b/.github/workflows/frontend-a11y.yml index 95050a5db3..8f97120d96 100644 --- a/.github/workflows/frontend-a11y.yml +++ b/.github/workflows/frontend-a11y.yml @@ -21,7 +21,7 @@ jobs: timeout-minutes: 25 steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit - name: Checkout repository diff --git a/.github/workflows/frontend-backend-licenses-update.yml b/.github/workflows/frontend-backend-licenses-update.yml index 69c2a90c63..1155f01e39 100644 --- a/.github/workflows/frontend-backend-licenses-update.yml +++ b/.github/workflows/frontend-backend-licenses-update.yml @@ -28,7 +28,7 @@ jobs: licenses-backend: ${{ steps.changes.outputs.licenses-backend }} steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit @@ -52,7 +52,7 @@ jobs: repository-projects: write # Required for enabling automerge steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit @@ -326,7 +326,7 @@ jobs: repository-projects: write # Required for enabling automerge steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit diff --git a/.github/workflows/frontend-validation.yml b/.github/workflows/frontend-validation.yml index 7e2602c311..133d940b28 100644 --- a/.github/workflows/frontend-validation.yml +++ b/.github/workflows/frontend-validation.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit - name: Checkout repository diff --git a/.github/workflows/manage-label.yml b/.github/workflows/manage-label.yml index 571479b030..1a8bdd112d 100644 --- a/.github/workflows/manage-label.yml +++ b/.github/workflows/manage-label.yml @@ -15,7 +15,7 @@ jobs: issues: write steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit diff --git a/.github/workflows/multiOSReleases.yml b/.github/workflows/multiOSReleases.yml index c712467e7b..3bb014a82d 100644 --- a/.github/workflows/multiOSReleases.yml +++ b/.github/workflows/multiOSReleases.yml @@ -45,7 +45,7 @@ jobs: version: ${{ steps.versionNumber.outputs.versionNumber }} steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit @@ -137,7 +137,7 @@ jobs: file_suffix: "-server" steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit @@ -208,7 +208,7 @@ jobs: RELEASE_GPG_PRIVATE_KEY: ${{ secrets.RELEASE_GPG_PRIVATE_KEY }} steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit allowed-endpoints: > @@ -740,7 +740,7 @@ jobs: contents: write steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 48b3299827..70e99d6074 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -23,7 +23,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit @@ -74,7 +74,7 @@ jobs: timeout-minutes: 60 steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit @@ -137,7 +137,7 @@ jobs: contents: read steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit diff --git a/.github/workflows/package-managers.yml b/.github/workflows/package-managers.yml index 751b3c1877..777c8d6ed4 100644 --- a/.github/workflows/package-managers.yml +++ b/.github/workflows/package-managers.yml @@ -28,7 +28,7 @@ jobs: jar_sha256: ${{ steps.hashes.outputs.jar_sha256 }} steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit @@ -80,7 +80,7 @@ jobs: contents: write steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit diff --git a/.github/workflows/pr-conflict-labeler.yml b/.github/workflows/pr-conflict-labeler.yml index 362421d0f8..c642b6cf3d 100644 --- a/.github/workflows/pr-conflict-labeler.yml +++ b/.github/workflows/pr-conflict-labeler.yml @@ -32,7 +32,7 @@ jobs: pull-requests: write # pulls.get/list plus add/remove the label on PRs steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit diff --git a/.github/workflows/pre_commit.yml b/.github/workflows/pre_commit.yml index 878ce60673..db67ccefe6 100644 --- a/.github/workflows/pre_commit.yml +++ b/.github/workflows/pre_commit.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit diff --git a/.github/workflows/push-docker-base.yml b/.github/workflows/push-docker-base.yml index f7dd1bcab4..658583ea08 100644 --- a/.github/workflows/push-docker-base.yml +++ b/.github/workflows/push-docker-base.yml @@ -45,7 +45,7 @@ jobs: echo "version=${VERSION}" >> $GITHUB_OUTPUT - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit diff --git a/.github/workflows/push-docker.yml b/.github/workflows/push-docker.yml index c9caf1b2f2..b88d69c3c2 100644 --- a/.github/workflows/push-docker.yml +++ b/.github/workflows/push-docker.yml @@ -53,7 +53,7 @@ jobs: RUN_UNOSERVER: ${{ github.event_name != 'workflow_dispatch' || inputs.build_unoserver }} steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit diff --git a/.github/workflows/rollback-latest.yml b/.github/workflows/rollback-latest.yml index 442a6f499e..3e49f727c6 100644 --- a/.github/workflows/rollback-latest.yml +++ b/.github/workflows/rollback-latest.yml @@ -19,7 +19,7 @@ jobs: packages: write steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml index 0e9b27c829..f0129277de 100644 --- a/.github/workflows/scorecards.yml +++ b/.github/workflows/scorecards.yml @@ -35,7 +35,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index ab9078831a..af255de3cf 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -17,7 +17,7 @@ jobs: pull-requests: write steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit diff --git a/.github/workflows/swagger.yml b/.github/workflows/swagger.yml index 733ce9e7ed..38c1985a83 100644 --- a/.github/workflows/swagger.yml +++ b/.github/workflows/swagger.yml @@ -27,7 +27,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit diff --git a/.github/workflows/sync-portal-docs.yml b/.github/workflows/sync-portal-docs.yml index 5ff4f465c7..636cd5f2e1 100644 --- a/.github/workflows/sync-portal-docs.yml +++ b/.github/workflows/sync-portal-docs.yml @@ -33,7 +33,7 @@ jobs: pull-requests: write steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit diff --git a/.github/workflows/sync_files_v2.yml b/.github/workflows/sync_files_v2.yml index a21eefffdd..124c992c71 100644 --- a/.github/workflows/sync_files_v2.yml +++ b/.github/workflows/sync_files_v2.yml @@ -37,7 +37,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit diff --git a/.github/workflows/tauri-build.yml b/.github/workflows/tauri-build.yml index 5c04f6009f..c25430262e 100644 --- a/.github/workflows/tauri-build.yml +++ b/.github/workflows/tauri-build.yml @@ -63,7 +63,7 @@ jobs: matrix: ${{ steps.set-matrix.outputs.matrix }} steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit @@ -120,7 +120,7 @@ jobs: SIGN_BUNDLE: ${{ inputs.sign && (matrix.platform == 'macos-15' && secrets.APPLE_CERTIFICATE != '' || github.ref == 'refs/heads/main') }} steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit @@ -703,7 +703,7 @@ jobs: pull-requests: write steps: - name: Harden the runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit @@ -795,7 +795,7 @@ jobs: if: always() steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit diff --git a/.github/workflows/test-build-docker.yml b/.github/workflows/test-build-docker.yml index 4ce06e6247..660cd2458b 100644 --- a/.github/workflows/test-build-docker.yml +++ b/.github/workflows/test-build-docker.yml @@ -53,7 +53,7 @@ jobs: cache-scope: stirling-pdf-fat steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit @@ -191,7 +191,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit diff --git a/.github/workflows/testdriver.yml b/.github/workflows/testdriver.yml index fe27e0ba1f..751eaf44f7 100644 --- a/.github/workflows/testdriver.yml +++ b/.github/workflows/testdriver.yml @@ -29,7 +29,7 @@ jobs: packages: write steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit @@ -152,7 +152,7 @@ jobs: frontend: ${{ steps.changes.outputs.frontend }} steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit @@ -171,7 +171,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit @@ -208,7 +208,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit From 9c10204cb06780d34c92f394775c5d586d8d4093 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:15:37 +0100 Subject: [PATCH 11/23] build(deps): bump Swatinem/rust-cache from 2.9.1 to 2.9.2 (#7531) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [Swatinem/rust-cache](https://github.com/swatinem/rust-cache) from 2.9.1 to 2.9.2.
Release notes

Sourced from Swatinem/rust-cache's releases.

v2.9.2

What's Changed

New Contributors

Full Changelog: https://github.com/Swatinem/rust-cache/compare/v2.9.1...v2.9.2

Changelog

Sourced from Swatinem/rust-cache's changelog.

Changelog

2.9.2

  • Fix credentials.toml cleanup
  • Improvements to cleanup, preserving more valid targets
  • Improvements to cargo install handling
  • Correctly sort/dedupe Rust versions

2.9.1

  • Fix regression in hash calculation

2.9.0

  • Update to node24
  • Support running from within a nix shell
  • Consider all installed toolchains for cache key
  • Use case-insensitive comparison to determine exact cache hit

2.8.2

  • Don't overwrite env for cargo-metadata call

2.8.1

  • Set empty CARGO_ENCODED_RUSTFLAGS when retrieving metadata
  • Various dependency updates

2.8.0

  • Add support for warpbuild cache provider
  • Add new cache-workspace-crates feature

2.7.8

  • Include CPU arch in the cache key

2.7.7

  • Also cache cargo install metadata

2.7.6

  • Allow opting out of caching $CARGO_HOME/bin
  • Add runner OS in cache key
  • Adds an option to do lookup-only of the cache

2.7.5

... (truncated)

Commits
  • 6323deb 2.9.2
  • b16e8d7 bump rollup and rebuild
  • 3bf42ac invert target/profile check in cleanup
  • 6e5b278 correctly sort and dedupe Rust versions
  • 5adc05f Bump the actions group across 1 directory with 3 updates (#368)
  • 66b1e95 fix: support Cargo V2 build dir layout (#371)
  • 72d126e Merge pull request #367 from Swatinem/dependabot/npm_and_yarn/dev-patch-2b495...
  • 48968d2 Bump the dev-patch group with 2 updates
  • 9f151ac update dependencies, rebuild
  • 0e24e5d Bump the actions group across 1 directory with 6 updates (#364)
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=Swatinem/rust-cache&package-manager=github_actions&previous-version=2.9.1&new-version=2.9.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/tauri-build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tauri-build.yml b/.github/workflows/tauri-build.yml index c25430262e..ab6d8aca71 100644 --- a/.github/workflows/tauri-build.yml +++ b/.github/workflows/tauri-build.yml @@ -150,7 +150,7 @@ jobs: # only recompiles the app crate. Written on main; PRs and the merge queue # restore from it. - name: Cache Rust build - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: workspaces: frontend/editor/src-tauri # Stable key shared across workflows so the nightly warmer. From 9a90797041e7d49201f60b529cd7219db6029c98 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:15:47 +0100 Subject: [PATCH 12/23] build(deps): bump awsSdkVersion from 2.51.2 to 2.51.3 (#7529) Bumps `awsSdkVersion` from 2.51.2 to 2.51.3. Updates `software.amazon.awssdk:s3` from 2.51.2 to 2.51.3 Updates `software.amazon.awssdk:url-connection-client` from 2.51.2 to 2.51.3 Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index b7fcb794cc..cadf4bafa6 100644 --- a/build.gradle +++ b/build.gradle @@ -44,7 +44,7 @@ ext { batikVersion = "1.19" jpdfiumVersion = "1.0.4" jwtVersion = "0.13.0" - awsSdkVersion = "2.51.2" + awsSdkVersion = "2.51.3" jschVersion = "2.28.6" commonsNetVersion = "3.13.0" smbjVersion = "0.14.0" From 2c89f5fe5210df7c93c6feeb74ba19b23afdb635 Mon Sep 17 00:00:00 2001 From: Ludy Date: Sun, 16 Aug 2026 18:17:16 +0200 Subject: [PATCH 13/23] fix(ci): locale path and docs (#7525) # Description of Changes Update the TOML locale check to match the current frontend path and align comments/docs with the relocated editor and proprietary source layout. This keeps CI filtering and internal references accurate after the frontend reorganization. --- ## 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/config/.files.yaml | 36 +++++++++---------- .github/workflows/check_toml.yml | 2 +- .../src/core/services/httpErrorHandler.ts | 2 +- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/.github/config/.files.yaml b/.github/config/.files.yaml index e6e4f08230..70a964b020 100644 --- a/.github/config/.files.yaml +++ b/.github/config/.files.yaml @@ -1,7 +1,7 @@ -# CI routing infra. Editing the top-level router (build.yml) or this filter -# config re-runs every area's jobs, so every job-gating filter below includes -# *ci. That makes a change to how jobs are dispatched actually exercise those -# jobs (self-testing), instead of a router edit only matching the project filter. +# CI routing infrastructure. Changes to the top-level router (build.yml) or +# this filter configuration rerun every area's jobs. Every job-gating filter +# therefore includes *ci, so routing changes exercise the jobs they affect +# instead of matching only the project filter. ci: &ci - .github/workflows/build.yml - .github/config/.files.yaml @@ -24,9 +24,9 @@ openapi: &openapi docker-base: &docker-base - docker/base/Dockerfile -# Dockerfiles only (base + embedded + unoserver). Gates the slow multi-arch -# (arm64) leg of the PR docker test build: arm64 is only rebuilt when a -# Dockerfile itself changes, not on every code PR. +# Dockerfiles only (base, embedded, and unoserver). The slow multi-architecture +# (arm64) leg of the PR Docker test build runs only when a Dockerfile changes, +# rather than for every code PR. dockerfiles: &dockerfiles - docker/**/Dockerfile* @@ -88,8 +88,8 @@ frontend: &frontend - .github/workflows/e2e-stubbed.yml - .github/workflows/e2e-live.yml -# Files that affect the Tauri desktop bundle. Gate the multi-OS Tauri build -# job on changes to any of these. +# Files that affect the Tauri desktop bundle. Changes to any of these files +# trigger the multi-OS Tauri build job. tauri: &tauri - *ci - frontend/editor/src-tauri/** @@ -102,9 +102,9 @@ tauri: &tauri - Taskfile.yml - .taskfiles/desktop.yml -# Files that affect the AI engine (Python tool models, fixers, tests). Gate -# the engine validation job on changes to engine sources or to the Java -# tool surfaces it generates models from. +# Files that affect the AI engine, including its Python tool models, fixers, +# and tests. The engine validation job also runs when the Java tool surfaces +# used to generate those models change. engine: &engine - *ci - engine/** @@ -114,10 +114,10 @@ engine: &engine - .taskfiles/engine.yml # Files that can make the committed generated API models (frontend tool API -# types + engine tool models) go stale: the Java tool surfaces they derive from, -# the generators, the generated files themselves (to catch a hand-edit), and the -# tasks that drive generation. Deliberately excludes the broad frontend/docker/ -# testing globs, so a CSS-only PR does not boot the backend to rebuild the spec. +# types and engine tool models) stale: their Java sources, generators, +# generated outputs (to catch hand edits), and generation tasks. Broad +# frontend, Docker, and testing globs are intentionally excluded, so a CSS-only +# PR does not start the backend to rebuild the specification. generated-models: &generated-models - *ci - *openapi @@ -141,8 +141,8 @@ licenses-backend: &licenses-backend - ".github/workflows/frontend-backend-licenses-update.yml" - *build -# Files that can affect premium / enterprise behaviour. Gate the enterprise -# Playwright job on changes to any of these on PRs. +# Files that can affect premium or enterprise behaviour. Changes to any of +# these files trigger the enterprise Playwright job for pull requests. proprietary: &proprietary - *ci - app/proprietary/** diff --git a/.github/workflows/check_toml.yml b/.github/workflows/check_toml.yml index ffb3e01226..1681546e71 100644 --- a/.github/workflows/check_toml.yml +++ b/.github/workflows/check_toml.yml @@ -125,7 +125,7 @@ jobs: const changedFiles = files .filter(file => file.status !== "removed" && - /^frontend\/public\/locales\/[a-zA-Z-]+\/translation\.toml$/.test(file.filename) + /^frontend\/editor\/public\/locales\/[a-zA-Z-]+\/translation\.toml$/.test(file.filename) ) .map(file => file.filename); diff --git a/frontend/editor/src/core/services/httpErrorHandler.ts b/frontend/editor/src/core/services/httpErrorHandler.ts index ed7108b6a6..55b0a425f9 100644 --- a/frontend/editor/src/core/services/httpErrorHandler.ts +++ b/frontend/editor/src/core/services/httpErrorHandler.ts @@ -1,4 +1,4 @@ -// frontend/src/services/httpErrorHandler.ts +// frontend/editor/src/core/services/httpErrorHandler.ts import { alert } from "@app/components/toast"; import { broadcastErroredFiles, From 08b08aa8a1a82d7f7ff2dc14f30559b96b954b80 Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:27:20 +0000 Subject: [PATCH 14/23] Let everyone read the failures they caused (Review Flow PR 3) (#7477) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review Flow PR 3. Stacked on #7296. A recorded failure becomes readable by the person who caused it. ## What changes Before this, reading or triaging a failure required leader permissions: `FileRunEventController.requireFailureReviewAllowed()` returned 403 to anyone who could not edit policies. #7296 lets any user report a failure, so they could file into a queue they could never read. That gate is removed from the endpoints and the decision moves into `FileRunEventService`: | Caller | Reads and closes | |---|---| | Team leader or admin | the whole team's failures (unchanged) | | Anyone else | only failures where `actor` is them | | Team unresolvable | nothing | | Name unresolvable | nothing | `GET /kinds` is also opened. It returns static enum metadata, and a member needs it to render failures they can already see. ## Additions - An `actor` predicate on both list queries in `FileRunEventRepository`, threaded through `FileRunEventStore.list`. - `ReadScope` (permitted, teamId, actor) replacing `TeamScope`, with `wholeTeam` / `mine` / `denied` factories. - An actor filter on `dispatch`, so acting on another person's row answers **404, not 403** — the same response as an id that does not exist. ## Fixes - **`report()` filed rows under the wrong team.** It took the team from the read scope, which returns null for a caller who cannot be named, so such a report landed unteamed in the bucket every team shares. It now uses a dedicated `currentTeamId()`. - **`forgetFiles` narrows to the caller even for a leader.** File ids are minted by each client, so scoping on team alone would let one caller close a colleague's incidents by naming ids. - The controller no longer injects `PolicyManagementAuthority` or `ApplicationProperties`; with the gate gone it decides nothing. ## Team isolation Unchanged and covered by database-backed tests rather than mocks. `FileRunEventStoreDbTest` asserts that a caller with a team sees only their own team's rows and never the unteamed ones, and that the actor predicate narrows within a team without ever widening across one. Delete either clause from the JPQL and one of those tests fails. No endpoint accepts a team parameter; the team always comes from the authenticated principal. **Attribution is fixed here too, because this PR depends on it.** A failure's actor was read from the MDC audit principal, which carries the BILLING identity — for a stored policy, always its owner. Since reads are now narrowed to the rows you are the actor on, a wrong actor means the member who caused a failure and holds the document reads nothing, while the policy owner is handed incidents from runs they never triggered. The triggering user is now carried on the run, separate from the billing principal and the output owner, and is null for a trigger-fired sweep so an unattended failure stays ownerless. `PolicyFailureAttributionTest` runs the real engine, recorder, store and service together. The two sides used to assert independently — the engine's test matched the actor with `any()`, which is how this went unnoticed. ## How to test Needs a proprietary or SaaS build with login enabled and two accounts in the same team, one a leader and one not. `task dev:all` gives you the stack. 1. **As the member**, fail a tool: open a PDF and run **Remove Password** with a wrong password. 2. **Still as the member**, go to `/processor/documents` → **Failures**. Before this PR you got nothing here. Now you see your own row, and only yours. 3. **As the leader**, open the same view. You see the whole team's rows, including the member's. 4. **Member cannot reach a colleague's row.** As the leader, copy a row's id from **Show raw JSON**. As the member, `POST /api/v1/file-run-events/{thatId}/actions/DISMISS`. It answers **404**, and the row is untouched — it must not answer 403, which would confirm the row exists. 5. **Member can close their own.** Dismiss your own row as the member. It leaves the default view. 6. **Deleting a file only closes your own rows.** As the leader, delete a file in your editor. The member's incidents are untouched even if the leader's client happened to name the same ids. ## Migration None. `actor` is an existing column; this only adds predicates to existing queries. --- .../failure/FileRunEventController.java | 57 ++-- .../failure/FileRunEventRepository.java | 11 +- .../failure/FileRunEventService.java | 91 ++++-- .../failure/FileRunEventStore.java | 9 +- .../policy/engine/PolicyEngine.java | 30 +- .../proprietary/policy/model/PolicyRun.java | 20 +- .../failure/FileRunEventControllerTest.java | 112 +++++--- .../FileRunEventHttpIntegrationTest.java | 7 +- .../failure/FileRunEventServiceTest.java | 116 ++++++-- .../failure/FileRunEventStoreDbTest.java | 38 ++- .../failure/FileRunEventStoreTest.java | 12 +- .../InMemoryFileRunEventRepository.java | 23 +- .../failure/PolicyFailureAttributionTest.java | 264 ++++++++++++++++++ .../failure/PolicyFailureRecorderTest.java | 30 +- .../controller/PolicyControllerTest.java | 10 +- .../policy/engine/PolicyEngineTest.java | 152 +++++++++- .../policy/engine/PolicyRunRegistryTest.java | 1 + 17 files changed, 800 insertions(+), 183 deletions(-) create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/failure/PolicyFailureAttributionTest.java diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventController.java b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventController.java index a14f243678..aac1c3364b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventController.java @@ -22,15 +22,13 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import stirling.software.common.model.ApplicationProperties; -import stirling.software.proprietary.policy.config.PolicyManagementAuthority; - /** - * Read and triage recorded failures for the caller's team. Note the absence of a team parameter: - * the team comes from the authenticated principal, never the request. + * Read and triage recorded failures. Note the absence of a team parameter: the team comes from the + * authenticated principal, never the request. * - *

Reviewing failures is a leader-level capability, gated the same way policy editing is: see - * {@link #requireFailureReviewAllowed()}. + *

Every endpoint is open to any authenticated user and scoped in the service instead: a leader + * reads and closes the whole team's failures, everyone else their own. Nothing here decides who may + * do what, so the two cannot drift apart. */ @Slf4j @RestController @@ -46,21 +44,21 @@ public class FileRunEventController { private static final int DEFAULT_LIMIT = 50; private final FileRunEventService service; - private final PolicyManagementAuthority policyManagementAuthority; - private final ApplicationProperties applicationProperties; @GetMapping @Operation( summary = "List recorded failures", description = - "Failures recorded for the caller's team, newest first. Each row carries its" - + " available actions already resolved.") + "Failures the caller may see, newest first: their team's for a leader, their own" + + " for everyone else. Each row carries its available actions already" + + " resolved.") public FileRunEventsResponse list( // Spring's converter 400s on a value outside the enum, so no hand-rolled parse. @RequestParam(required = false) FileRunEventStatus status, @RequestParam(required = false) String kindId, @RequestParam(required = false) Integer limit) { - requireFailureReviewAllowed(); + // No role gate: the service scopes the read instead, so a member gets their own failures + // and a leader the team's. int cappedLimit = Math.min(limit == null ? DEFAULT_LIMIT : Math.max(1, limit), MAX_LIMIT); List events = @@ -82,7 +80,8 @@ public class FileRunEventController { @PathVariable String eventId, @PathVariable String actionId, @RequestBody(required = false) ActionRequest request) { - requireFailureReviewAllowed(); + // No role gate: the service decides, which lets someone close their own failure while + // still keeping a colleague's out of reach. Map inputs = request == null ? Map.of() : request.safeInputs(); try { FileRunEvent updated = service.dispatch(eventId, actionId, inputs); @@ -97,9 +96,9 @@ public class FileRunEventController { summary = "Report a failure hit in the editor", description = "For failures the server never sees, because the editor calls tools directly." - + " Open to any authenticated user, unlike the read and triage endpoints:" - + " whoever's work failed can say so, and a leader reviews it. Rejected" - + " with 400 if it names more files than one report may carry.") + + " Open to any authenticated user: whoever's work failed can say so, and" + + " reads it back scoped to themselves. Rejected with 400 if it names" + + " more files than one report may carry.") public ResponseEntity report(@RequestBody EditorFailureReport report) { if (report == null || !report.hasOperation()) { throw new ResponseStatusException( @@ -128,8 +127,8 @@ public class FileRunEventController { summary = "Close the incidents about files deleted from the editor", description = "Deleting the document leaves nothing to act on, so its incidents drop out of" - + " the queue while the rows stay for audit. Open to any authenticated" - + " user, and applies only to their own editor rows.") + + " the queue while the rows stay for audit. Applies only to the" + + " caller's own editor rows, however senior they are.") public ResponseEntity filesRemoved(@RequestBody(required = false) RemovedFiles request) { service.forgetFiles(request == null ? List.of() : request.safeFileIds()); // No body: the editor is telling the server, not asking it anything. @@ -143,29 +142,11 @@ public class FileRunEventController { "The failure registry. Lets a client describe kinds it was not built with, and" + " doubles as the probe for whether failure tracking exists at all.") public List kinds() { - requireFailureReviewAllowed(); + // The registry is copy and metadata, not anyone's data, and a member needs it to render the + // failures they can already see. return Arrays.stream(FailureKind.values()).map(FailureKindView::of).toList(); } - /** - * Triage is for a team leader (SaaS) or admin (self-hosted), mirroring {@code - * PolicyController.requirePolicyEditingAllowed()} rather than inventing a second notion of who - * manages a team's automation: a member can trigger runs, a leader reviews them. - * - *

Login disabled means a single-user deployment with no roles to tell apart, the same - * carve-out the policy endpoints make. Team scoping is separate, and lives in the service. - */ - private void requireFailureReviewAllowed() { - if (!applicationProperties.getSecurity().isEnableLogin()) { - return; - } - if (!policyManagementAuthority.canEditPolicies()) { - throw new ResponseStatusException( - HttpStatus.FORBIDDEN, - "Recorded failures may only be reviewed by a team leader"); - } - } - /** * A closed row is a conflict rather than a bad request: the request was well-formed and would * have been valid a moment earlier. diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventRepository.java index 1bee2398f7..1930b36e6a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventRepository.java @@ -20,26 +20,33 @@ public interface FileRunEventRepository extends JpaRepository{@code actor} narrows to one person's own failures. Null means the whole team, which only + * a leader ever asks for: see {@code FileRunEventService#readScope}. */ @Query( "select e from FileRunEventEntity e where ((:teamId is null and e.teamId is null) or" + " e.teamId = :teamId) and e.status in :statuses" - + " and (:kindId is null or e.kindId = :kindId) order by e.lastSeenAt desc") + + " and (:kindId is null or e.kindId = :kindId)" + + " and (:actor is null or e.actor = :actor) order by e.lastSeenAt desc") List findByTeamAndStatusIn( @Param("teamId") Long teamId, @Param("statuses") List statuses, @Param("kindId") String kindId, + @Param("actor") String actor, Pageable pageable); /** As {@link #findByTeamAndStatusIn} but for exactly one status, for the surface's filters. */ @Query( "select e from FileRunEventEntity e where ((:teamId is null and e.teamId is null) or" + " e.teamId = :teamId) and e.status = :status" - + " and (:kindId is null or e.kindId = :kindId) order by e.lastSeenAt desc") + + " and (:kindId is null or e.kindId = :kindId)" + + " and (:actor is null or e.actor = :actor) order by e.lastSeenAt desc") List findByTeamAndStatus( @Param("teamId") Long teamId, @Param("status") FileRunEventStatus status, @Param("kindId") String kindId, + @Param("actor") String actor, Pageable pageable); /** diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventService.java b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventService.java index 57eb4698e4..fa858b6e22 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventService.java @@ -13,12 +13,14 @@ import stirling.software.common.service.UserServiceInterface; import stirling.software.proprietary.policy.config.PolicyManagementAuthority; /** - * Reads and acts on incidents for the calling user's team. + * Reads and acts on the incidents the calling user is allowed to see, which is where that decision + * is made: a leader reads and closes the whole team's failures, everyone else their own. Keeping it + * here rather than on the endpoints means the read and the triage cannot drift apart. * *

Team scoping mirrors {@code PolicyAccessGuard}: everyone sees only their own team's rows, the * team always comes from the authenticated principal, and scoping applies only when login is * enabled so single-user deployments keep working. When the team cannot be resolved the caller - * reads nothing; see {@link #scope()}. + * reads nothing; see {@link #readScope()}. */ @Slf4j @Service @@ -41,7 +43,9 @@ public class FileRunEventService { */ public List report(EditorFailureReport report) { FailureKind kind = FailureKind.byErrorCode(report.errorCode()).orElse(FailureKind.UNKNOWN); - Long teamId = scope().teamId(); + // The caller's team, not their read scope: recording is open to everyone, and a reader who + // may see nothing still has their failure filed under the team it happened in. + Long teamId = currentTeamId(); String actor = currentActor(); String detail = detailFor(report); @@ -81,10 +85,14 @@ public class FileRunEventService { * cleared cache or another device never will. Rows left open that way are retention's problem, * not this method's. * + *

Narrowed to the caller's own rows however senior they are, which is why it passes {@link + * #currentActor()} rather than the read scope's actor: file ids are minted by each client, so a + * leader reading with a null actor would match every unattributed row in the team. + * * @return how many incidents were closed */ public int forgetFiles(List fileIds) { - TeamScope scope = scope(); + ReadScope scope = readScope(); if (!scope.permitted()) { return 0; } @@ -92,13 +100,16 @@ public class FileRunEventService { return store.markFilesRemoved(scope.teamId(), currentActor(), named); } - /** The calling user's events, newest first. Empty when their team cannot be resolved. */ + /** + * The events the caller may read, newest first: the team's for a leader, their own for everyone + * else. Empty when their team cannot be resolved. + */ public List list(FileRunEventStatus status, String kindId, int limit) { - TeamScope scope = scope(); + ReadScope scope = readScope(); if (!scope.permitted()) { return List.of(); } - return store.list(scope.teamId(), status, kindId, limit); + return store.list(scope.teamId(), status, kindId, scope.actor(), limit); } /** @@ -108,7 +119,13 @@ public class FileRunEventService { * event's kind does not declare the action, or the event is already closed */ public FileRunEvent dispatch(String eventId, String actionId, Map inputs) { - TeamScope scope = scope(); + // Whoever can see it can close it: a leader for the whole team, everyone else for the + // failures they caused. Someone who fixes their own problem should not have to ask a leader + // to clear the row. + // + // Closing the row is all this covers. Acting on the document behind it, such as supplying a + // password for a retry, would need its own permission, and no such action exists yet. + ReadScope scope = readScope(); if (!scope.permitted()) { // Reported as "no such event", the same as an id from another team, so the response // does @@ -118,6 +135,12 @@ public class FileRunEventService { } FileRunEvent event = store.find(eventId, scope.teamId()) + // Reported as "no such event" rather than a refusal, so a member cannot + // learn that a colleague's incident exists by trying to close it. + .filter( + found -> + scope.actor() == null + || scope.actor().equals(found.actor())) .orElseThrow( () -> new FailureActionException( @@ -179,31 +202,55 @@ public class FileRunEventService { } /** - * Which rows the caller may touch, since a null team id means two different things. Login - * disabled is the self-hosted setup with no users or teams, where unteamed rows are everyone's, - * as {@code PolicyAccessGuard} also treats them. Login enabled with no resolvable team reads - * nothing, because unteamed rows there are shared by every team's ad-hoc runs. + * Which rows the caller may read. A leader reviews the whole team's, as before. Everyone else + * reads the failures they caused themselves: a member can already report one, so letting them + * see their own back is what makes telling them about it worth anything, and it exposes nothing + * of a colleague's. + * + *

A null team id means two different things. Login disabled is the self-hosted setup with no + * users or teams, where unteamed rows are everyone's, as {@code PolicyAccessGuard} also treats + * them. Login enabled with no resolvable team reads nothing, because unteamed rows there are + * shared by every team's ad-hoc runs. */ - private TeamScope scope() { + private ReadScope readScope() { if (!enforced()) { - return TeamScope.of(null); + return ReadScope.wholeTeam(null); } - Long teamId = policyManagementAuthority.currentUserTeamId(); - return teamId == null ? TeamScope.denied() : TeamScope.of(teamId); + Long teamId = currentTeamId(); + if (teamId == null) { + return ReadScope.denied(); + } + if (policyManagementAuthority.canEditPolicies()) { + return ReadScope.wholeTeam(teamId); + } + // Narrowing to "mine" needs a name to narrow by. Without one the filter would be dropped + // and a member would read the whole team, so refuse rather than widen. + String actor = currentActor(); + return actor == null ? ReadScope.denied() : ReadScope.mine(teamId, actor); } /** - * The caller's readable team, or a refusal. {@code teamId} is only meaningful when permitted. + * What the caller may read. {@code actor} is the person to narrow to, or null for the whole + * team; both are only meaningful when permitted. */ - private record TeamScope(boolean permitted, Long teamId) { + private record ReadScope(boolean permitted, Long teamId, String actor) { - static TeamScope of(Long teamId) { - return new TeamScope(true, teamId); + static ReadScope wholeTeam(Long teamId) { + return new ReadScope(true, teamId, null); } - static TeamScope denied() { - return new TeamScope(false, null); + static ReadScope mine(Long teamId, String actor) { + return new ReadScope(true, teamId, actor); } + + static ReadScope denied() { + return new ReadScope(false, null, null); + } + } + + /** The team a row belongs to, which is nobody's when there are no teams to belong to. */ + private Long currentTeamId() { + return enforced() ? policyManagementAuthority.currentUserTeamId() : null; } private String currentActor() { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventStore.java index cf336ab76e..2ed86e61b2 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventStore.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/failure/FileRunEventStore.java @@ -121,16 +121,19 @@ public class FileRunEventStore { * *

Both filters live in the query, before the limit: filtering an already-limited page could * return nothing while matching rows exist. + * + *

{@code actor} narrows to one person's own failures, or reads the whole team when null. Who + * gets which is the service's decision, not this method's. */ @Transactional(readOnly = true) public List list( - Long teamId, FileRunEventStatus status, String kindId, int limit) { + Long teamId, FileRunEventStatus status, String kindId, String actor, int limit) { Pageable page = PageRequest.of(0, Math.max(1, limit)); List rows = status == null ? repository.findByTeamAndStatusIn( - teamId, FileRunEventStatus.open(), kindId, page) - : repository.findByTeamAndStatus(teamId, status, kindId, page); + teamId, FileRunEventStatus.open(), kindId, actor, page) + : repository.findByTeamAndStatus(teamId, status, kindId, actor, page); return rows.stream().map(FileRunEvent::of).toList(); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java index 1a424176ce..0d75866a6f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java @@ -130,7 +130,15 @@ public class PolicyEngine { // worker. String principal = currentActingPrincipal(); return submitForPrincipal( - principal, principal, policyId, definition, inputs, listener, null, null); + principal, + principal, + principal, + policyId, + definition, + inputs, + listener, + null, + null); } /** Run a stored policy on demand. {@code enabled} gates triggers, not explicit runs. */ @@ -158,6 +166,10 @@ public class PolicyEngine { // they can download their enforced file; otherwise an org-wide policy's output is owned by // the admin and the triggering user is denied it. Trigger-fired runs have no such user, so // the owner owns those outputs. + // + // The triggering user is also carried on the run, as the actor of any failure it records: + // null for a trigger-fired run, which is what makes an unattended incident ownerless rather + // than the owner's problem. Three identities, deliberately not interchangeable. String triggeringUser = currentActingPrincipal(); String fileOwner = triggeringUser != null ? triggeringUser : policy.owner(); // Stored supporting files (certificates, watermark images, ...) load here, before the @@ -172,6 +184,7 @@ public class PolicyEngine { return submitForPrincipal( policy.owner(), fileOwner, + triggeringUser, policy.id(), definition, // main's asset-resolved inputs, not the raw ones: stored certificates and watermark @@ -185,6 +198,7 @@ public class PolicyEngine { private PolicyRunHandle submitForPrincipal( String billingPrincipal, String fileOwner, + String triggeringUser, String policyId, PipelineDefinition definition, PolicyInputs inputs, @@ -199,7 +213,8 @@ public class PolicyEngine { if (policyId != null) { taskManager.putMetadata(runId, "policyId", policyId); } - PolicyRun run = new PolicyRun(runId, policyId, definition, sourceId, fileIdentity); + PolicyRun run = + new PolicyRun(runId, policyId, definition, sourceId, fileIdentity, triggeringUser); registry.register(run); CompletableFuture completion = new CompletableFuture<>(); PolicyProgressListener tracking = trackingListener(runId, run, listener); @@ -364,12 +379,14 @@ public class PolicyEngine { taskManager.setError(run.getRunId(), message); // No exception to classify here: nothing was thrown by a tool, the run simply was not // admitted. Record it explicitly so a run lost to load pressure is still accounted for. + // Attributed like any other failure: a user whose run was refused is still the person + // holding that document, and an unattended sweep's run carries no triggering user. failureRecorder.recordRunFailureAs( FailureKind.UNKNOWN, run.getRunId(), run.getPolicyId(), run.getSourceId(), - null, + run.getTriggeringUser(), message); completion.complete(run); } @@ -379,6 +396,11 @@ public class PolicyEngine { /** * Record why a run failed. Called after the run's own state transition and task-manager update, * so a recording problem cannot change the outcome the caller observes. + * + *

The actor is the run's triggering user, not the MDC audit principal: that carries the + * BILLING identity, which for a stored policy is always its owner. Reading it here filed every + * failure under the owner — hiding an attended failure from the member who caused it and holds + * the document, and leaving an unattended sweep's failure looking attended. */ private void recordFailure(PolicyRun run, String message, Throwable cause) { failureRecorder.recordRunFailure( @@ -386,7 +408,7 @@ public class PolicyEngine { run.getPolicyId(), run.getSourceId(), run.getFileIdentity(), - MDC.get(AUDIT_PRINCIPAL_MDC_KEY), + run.getTriggeringUser(), message, cause); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyRun.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyRun.java index 65cfa658ca..8eb0cfcc73 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyRun.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/PolicyRun.java @@ -35,6 +35,14 @@ public class PolicyRun { */ private final String fileIdentity; + /** + * The user who triggered this run, or null when nothing attended it (a trigger-fired sweep). + * Recorded as a failure's actor, so an attended failure is handed to the person holding the + * document. Deliberately not the billing principal: a shared policy is billed to its owner, who + * may never have touched the file. + */ + private final String triggeringUser; + private final Instant createdAt = Instant.now(); private volatile PolicyRunStatus status = PolicyRunStatus.PENDING; @@ -63,22 +71,24 @@ public class PolicyRun { private volatile Instant updatedAt = Instant.now(); /** - * Both references are required rather than defaulted: a run with neither is a real case (a - * user's upload, an ad-hoc pipeline), but it should be stated at the call site. Overloads that - * omitted them would make losing the attribution the frictionless option, which is how both - * fields went unpopulated in the first place. + * All three attribution references are required rather than defaulted: a run with none is a + * real case (an unattended sweep of a generator pipeline), but it should be stated at the call + * site. Overloads that omitted them would make losing the attribution the frictionless option, + * which is how {@code sourceId} and {@code fileIdentity} went unpopulated in the first place. */ public PolicyRun( String runId, String policyId, PipelineDefinition definition, String sourceId, - String fileIdentity) { + String fileIdentity, + String triggeringUser) { this.runId = runId; this.policyId = policyId; this.sourceId = sourceId; this.definition = definition; this.fileIdentity = fileIdentity; + this.triggeringUser = triggeringUser; } public int stepCount() { diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventControllerTest.java index 088cffe8ec..58b8d1b408 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventControllerTest.java @@ -51,9 +51,7 @@ class FileRunEventControllerTest { List.of(new AcknowledgeAction(store), new DismissAction(store))); controller = new FileRunEventController( - new FileRunEventService(store, registry, authority, userService, props), - authority, - props); + new FileRunEventService(store, registry, authority, userService, props)); lenient().when(authority.canEditPolicies()).thenReturn(true); lenient().when(authority.currentUserTeamId()).thenReturn(TEAM); @@ -61,14 +59,27 @@ class FileRunEventControllerTest { } private FileRunEvent given(FailureKind kind, Long teamId, String fileId) { + return recorded("author@example.com", kind, teamId, fileId, "run-1"); + } + + /** + * As {@link #given} but naming who hit it, in its own run. A RUN-scoped kind keys on the run, + * so two rows sharing one run id are one incident, however they differ otherwise. + */ + private FileRunEvent givenHitBy(String actor, FailureKind kind, Long teamId, String fileId) { + return recorded(actor, kind, teamId, fileId, "run-" + fileId); + } + + private FileRunEvent recorded( + String actor, FailureKind kind, Long teamId, String fileId, String runId) { return store.record( new RecordFailure( kind, FailureOrigin.POLICY, teamId, - "author@example.com", + actor, "policy-1", - "run-1", + runId, null, fileId, "the raw failure message")); @@ -281,48 +292,68 @@ class FileRunEventControllerTest { } @Nested - @DisplayName("only a team leader may review failures") + @DisplayName("a leader reviews the team's failures, everyone else their own") class Authorization { @Test - void aMemberCannotListThem() { + void aMemberSeesTheirOwnFailuresAndNobodyElses() { + // A member can report a failure, so they get to see it back. It must not widen to a + // colleague's. + givenHitBy("reviewer@example.com", FailureKind.UNKNOWN, TEAM, "mine"); + givenHitBy("colleague@example.com", FailureKind.UNKNOWN, TEAM, "theirs"); when(authority.canEditPolicies()).thenReturn(false); - assertThatThrownBy(() -> controller.list(null, null, null)) - .isInstanceOf(ResponseStatusException.class) - .satisfies( - e -> - assertThat(((ResponseStatusException) e).getStatusCode()) - .isEqualTo(HttpStatus.FORBIDDEN)); + assertThat(controller.list(null, null, null).events()) + .extracting(FileRunEventView::fileId) + .containsExactly("mine"); } @Test - void aMemberCannotDispatchAnAction() { - // The read being refused is not enough on its own: an id learned any other way must - // not let a member close another user's failure. - FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f-1"); - when(authority.canEditPolicies()).thenReturn(false); + void aLeaderSeesTheWholeTeams() { + givenHitBy("reviewer@example.com", FailureKind.UNKNOWN, TEAM, "mine"); + givenHitBy("colleague@example.com", FailureKind.UNKNOWN, TEAM, "theirs"); + when(authority.canEditPolicies()).thenReturn(true); - assertThatThrownBy(() -> controller.act(event.id(), "DISMISS", null)) - .isInstanceOf(ResponseStatusException.class) - .satisfies( - e -> - assertThat(((ResponseStatusException) e).getStatusCode()) - .isEqualTo(HttpStatus.FORBIDDEN)); + assertThat(controller.list(null, null, null).events()) + .extracting(FileRunEventView::fileId) + .containsExactlyInAnyOrder("mine", "theirs"); } @Test - void theRegistryIsAlsoLeaderOnly() { + void aMemberMayCloseTheirOwn() { + // Someone who fixes their own problem should not have to ask a leader to clear the row. + FileRunEvent mine = + givenHitBy("reviewer@example.com", FailureKind.UNKNOWN, TEAM, "mine"); when(authority.canEditPolicies()).thenReturn(false); - assertThatThrownBy(() -> controller.kinds()) - .isInstanceOf(ResponseStatusException.class); + assertThat(controller.act(mine.id(), "DISMISS", null).status()) + .isEqualTo(FileRunEventStatus.DISMISSED); + } + + @Test + void aMemberCannotCloseAColleaguesEvenKnowingTheId() { + // Refusing the read is not enough on its own: an id learned any other way must not work + // either. Answered as not-found rather than forbidden, so trying does not confirm the + // row exists. + FileRunEvent theirs = + givenHitBy("colleague@example.com", FailureKind.UNKNOWN, TEAM, "theirs"); + when(authority.canEditPolicies()).thenReturn(false); + + assertThat(statusOf(() -> controller.act(theirs.id(), "DISMISS", null))) + .isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + void theRegistryIsOpenBecauseItIsCopyNotData() { + // A member renders the failures they can see, so they need the labels for them. No role + // stub: the point is that kinds() never asks. + assertThat(controller.kinds()).isNotEmpty(); } @Test void loginDisabledTrustsTheLocalOperator() { - // A single-user deployment has no roles to distinguish, so the role gate must not lock - // the only user out of their own failures. + // A single-user deployment has no roles to distinguish, so the narrowing must not leave + // the only user reading nothing. ApplicationProperties unsecured = new ApplicationProperties(); unsecured.getSecurity().setEnableLogin(false); FileRunEventController noLogin = @@ -335,9 +366,7 @@ class FileRunEventControllerTest { new DismissAction(store))), authority, userService, - unsecured), - authority, - unsecured); + unsecured)); assertThatCode(() -> noLogin.list(null, null, null)).doesNotThrowAnyException(); // Not merely permitted: the role is never consulted at all, which is what makes the @@ -382,9 +411,7 @@ class FileRunEventControllerTest { new DismissAction(store))), authority, userService, - unsecured), - authority, - unsecured); + unsecured)); given(FailureKind.UNKNOWN, null, "unteamed"); given(FailureKind.UNKNOWN, TEAM, "teamed"); @@ -399,9 +426,9 @@ class FileRunEventControllerTest { class Reporting { @Test - void aMemberMayReportEvenThoughTheyMayNotRead() { - // The asymmetry is the point: anyone whose work failed can say so, but only a leader - // reviews the queue. + void aMemberMayReportAndThenSeeTheirOwnReport() { + // Reporting was always open to a member; reading their own back is the round trip that + // makes the report worth anything to them. when(authority.canEditPolicies()).thenReturn(false); assertThatCode( @@ -410,8 +437,9 @@ class FileRunEventControllerTest { new EditorFailureReport( "compress", "E004", List.of("f-1"), "boom"))) .doesNotThrowAnyException(); - assertThatThrownBy(() -> controller.list(null, null, null)) - .isInstanceOf(ResponseStatusException.class); + assertThat(controller.list(null, null, null).events()) + .extracting(FileRunEventView::fileId) + .containsExactly("f-1"); } @Test @@ -441,7 +469,7 @@ class FileRunEventControllerTest { new EditorFailureReport("compress", "E004", atLimit, "boom"); assertThat(controller.report(report).getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); - assertThat(store.list(TEAM, null, null, EditorFailureReport.MAX_FILE_IDS + 10)) + assertThat(store.list(TEAM, null, null, null, EditorFailureReport.MAX_FILE_IDS + 10)) .hasSize(EditorFailureReport.MAX_FILE_IDS); } @@ -463,7 +491,7 @@ class FileRunEventControllerTest { overLimit, "boom")))) .isEqualTo(HttpStatus.BAD_REQUEST); - assertThat(store.list(TEAM, null, null, EditorFailureReport.MAX_FILE_IDS + 10)) + assertThat(store.list(TEAM, null, null, null, EditorFailureReport.MAX_FILE_IDS + 10)) .isEmpty(); } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventHttpIntegrationTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventHttpIntegrationTest.java index b3ee4b7d15..375e46f4ad 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventHttpIntegrationTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventHttpIntegrationTest.java @@ -438,11 +438,8 @@ class FileRunEventHttpIntegrationTest { } @Bean - FileRunEventController fileRunEventController( - FileRunEventService service, - PolicyManagementAuthority authority, - ApplicationProperties props) { - return new FileRunEventController(service, authority, props); + FileRunEventController fileRunEventController(FileRunEventService service) { + return new FileRunEventController(service); } } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventServiceTest.java index fbbe8e6359..4e6508bed9 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventServiceTest.java @@ -54,6 +54,9 @@ class FileRunEventServiceTest { lenient().when(authority.currentUserTeamId()).thenReturn(TEAM); lenient().when(userService.getCurrentUsername()).thenReturn(ACTOR); + // A leader unless a test says otherwise: most of these are about team scoping, which is + // what a leader sees. The member narrowing has its own tests. + lenient().when(authority.canEditPolicies()).thenReturn(true); } private FileRunEvent given(FailureKind kind, Long teamId, String fileId) { @@ -291,7 +294,7 @@ class FileRunEventServiceTest { } @Nested - @DisplayName("team scoping") + @DisplayName("read scoping") class Scoping { @Test @@ -304,6 +307,68 @@ class FileRunEventServiceTest { .containsExactly("mine"); } + @Test + void aMemberReadsOnlyTheFailuresTheyCaused() { + // Reporting is open to a member, so reading their own back is what lets us tell them + // anything at all. A colleague's must not come with it. + store.record(RecordFailure.forEditor(FailureKind.UNKNOWN, TEAM, ACTOR, "mine", "boom")); + store.record( + RecordFailure.forEditor( + FailureKind.UNKNOWN, TEAM, "colleague@example.com", "theirs", "boom")); + when(authority.canEditPolicies()).thenReturn(false); + + assertThat(service.list(null, null, 50)) + .extracting(FileRunEvent::fileId) + .containsExactly("mine"); + } + + @Test + void aMemberWithNoResolvableNameReadsNothingRatherThanEverything() { + // Narrowing to "mine" needs a name to narrow by. Dropping the filter would hand the + // whole team to someone who may not have it. + given(FailureKind.UNKNOWN, TEAM, "mine"); + when(authority.canEditPolicies()).thenReturn(false); + when(userService.getCurrentUsername()).thenReturn(null); + + assertThat(service.list(null, null, 50)).isEmpty(); + } + + @Test + void aMemberCannotActOnAColleaguesRowEvenKnowingItsId() { + // Refusing the read is not enough on its own: an id learned any other way must not work + // either. Reported as not-found, so trying does not confirm the row exists. + FileRunEvent theirs = + store.record( + RecordFailure.forEditor( + FailureKind.UNKNOWN, + TEAM, + "colleague@example.com", + "theirs", + "boom")); + when(authority.canEditPolicies()).thenReturn(false); + + assertThatThrownBy(() -> service.dispatch(theirs.id(), "DISMISS", Map.of())) + .isInstanceOf(FailureActionException.class) + .extracting(e -> ((FailureActionException) e).getReason()) + .isEqualTo(FailureActionException.Reason.EVENT_NOT_FOUND); + + assertThat(store.find(theirs.id(), TEAM).orElseThrow().status()) + .isEqualTo(FileRunEventStatus.NEW); + } + + @Test + void aMemberMayCloseTheirOwn() { + // Someone who fixes their own problem should not have to ask a leader to clear the row. + FileRunEvent mine = + store.record( + RecordFailure.forEditor( + FailureKind.UNKNOWN, TEAM, ACTOR, "mine", "boom")); + when(authority.canEditPolicies()).thenReturn(false); + + assertThat(service.dispatch(mine.id(), "DISMISS", Map.of()).status()) + .isEqualTo(FileRunEventStatus.DISMISSED); + } + @Test void aCallerWhoseTeamCannotBeResolvedReadsNothing() { // A run with no stored policy is recorded unteamed, and those rows are shared by every @@ -399,7 +464,7 @@ class FileRunEventServiceTest { service.report( new EditorFailureReport("remove-password", "E004", List.of("f-1"), "boom")); - FileRunEvent event = store.list(TEAM, null, null, 10).getFirst(); + FileRunEvent event = store.list(TEAM, null, null, null, 10).getFirst(); assertThat(event.kind()).isEqualTo(FailureKind.INPUT_PASSWORD_PROTECTED); assertThat(event.origin()).isEqualTo(FailureOrigin.TOOL); assertThat(event.fileId()).isEqualTo("f-1"); @@ -412,16 +477,32 @@ class FileRunEventServiceTest { // session. service.report(new EditorFailureReport("compress", "E004", List.of("f-1"), "boom")); - FileRunEvent event = store.list(TEAM, null, null, 10).getFirst(); + FileRunEvent event = store.list(TEAM, null, null, null, 10).getFirst(); assertThat(event.teamId()).isEqualTo(TEAM); assertThat(event.actor()).isEqualTo(ACTOR); } + @Test + void stillFilesTheRowUnderTheTeamWhenTheReporterCannotBeNamed() { + // Recording is open to everyone and takes the caller's team, not their read scope: a + // reporter who cannot be named reads nothing back, but the row is still the team's + // rather than dropping into the unteamed bucket every team shares. No role stub either, + // since recording never asks. + when(userService.getCurrentUsername()).thenReturn(null); + + service.report(new EditorFailureReport("compress", "E004", List.of("f-1"), "boom")); + + assertThat(store.list(TEAM, null, null, null, 10)) + .singleElement() + .extracting(FileRunEvent::teamId) + .isEqualTo(TEAM); + } + @Test void recordsAnUnrecognisedCodeAsUnknownRatherThanDroppingIt() { service.report(new EditorFailureReport("ocr", "E999", List.of("f-1"), "no idea")); - assertThat(store.list(TEAM, null, null, 10).getFirst().kind()) + assertThat(store.list(TEAM, null, null, null, 10).getFirst().kind()) .isEqualTo(FailureKind.UNKNOWN); } @@ -429,7 +510,7 @@ class FileRunEventServiceTest { void recordsAnAbsentCodeAsUnknown() { service.report(new EditorFailureReport("ocr", null, List.of("f-1"), "network died")); - assertThat(store.list(TEAM, null, null, 10).getFirst().kind()) + assertThat(store.list(TEAM, null, null, null, 10).getFirst().kind()) .isEqualTo(FailureKind.UNKNOWN); } @@ -439,7 +520,7 @@ class FileRunEventServiceTest { new EditorFailureReport( "compress", "E004", List.of("f-1", "f-2", "f-3"), "boom")); - assertThat(store.list(TEAM, null, null, 10)) + assertThat(store.list(TEAM, null, null, null, 10)) .hasSize(3) .extracting(FileRunEvent::fileId) .containsExactlyInAnyOrder("f-1", "f-2", "f-3"); @@ -451,7 +532,7 @@ class FileRunEventServiceTest { service.report( new EditorFailureReport("compress", "E004", List.of("f-1"), "boom again")); - assertThat(store.list(TEAM, null, null, 10)) + assertThat(store.list(TEAM, null, null, null, 10)) .singleElement() .extracting(FileRunEvent::occurrences) .isEqualTo(2); @@ -461,7 +542,7 @@ class FileRunEventServiceTest { void recordsOneUnattributedIncidentWhenNoFileWasNamed() { service.report(new EditorFailureReport("compress", "E004", List.of(), "boom")); - assertThat(store.list(TEAM, null, null, 10)) + assertThat(store.list(TEAM, null, null, null, 10)) .singleElement() .extracting(FileRunEvent::fileId) .isNull(); @@ -476,7 +557,7 @@ class FileRunEventServiceTest { service.report(new EditorFailureReport("compress", "E004", many, "boom")); - assertThat(store.list(TEAM, null, null, 200)).hasSize(60); + assertThat(store.list(TEAM, null, null, null, 200)).hasSize(60); } @Test @@ -486,7 +567,7 @@ class FileRunEventServiceTest { service.report( new EditorFailureReport("remove-password", "E004", List.of("f-1"), "boom")); - FileRunEvent event = store.list(TEAM, null, null, 10).getFirst(); + FileRunEvent event = store.list(TEAM, null, null, null, 10).getFirst(); assertThat(event.detail()).contains("remove-password"); assertThat(event.fileId()).isEqualTo("f-1"); } @@ -499,7 +580,7 @@ class FileRunEventServiceTest { new EditorFailureReport( "compress", "E004", List.of("f-1"), "Failed on Q4 report.pdf")); - assertThat(store.list(TEAM, null, null, 10).getFirst().detail()) + assertThat(store.list(TEAM, null, null, null, 10).getFirst().detail()) .isEqualTo("compress: Failed on Q4 report.pdf"); } @@ -540,7 +621,7 @@ class FileRunEventServiceTest { reportedBy("alice@example.com", "a-1"); reportedBy("bob@example.com", "b-1"); - assertThat(store.list(TEAM, null, null, 10)) + assertThat(store.list(TEAM, null, null, null, 10)) .extracting(FileRunEvent::actor) .containsExactlyInAnyOrder("alice@example.com", "bob@example.com"); } @@ -550,7 +631,7 @@ class FileRunEventServiceTest { reportedBy("alice@example.com", "a-1"); reportedBy("alice@example.com", "a-2"); - assertThat(store.list(TEAM, null, null, 10)) + assertThat(store.list(TEAM, null, null, null, 10)) .extracting(FileRunEvent::fileId) .containsExactlyInAnyOrder("a-1", "a-2"); } @@ -560,7 +641,7 @@ class FileRunEventServiceTest { reportedBy("alice@example.com", "a-1"); reportedBy("alice@example.com", "a-1"); - assertThat(store.list(TEAM, null, null, 10)) + assertThat(store.list(TEAM, null, null, null, 10)) .singleElement() .extracting(FileRunEvent::occurrences) .isEqualTo(2); @@ -612,8 +693,11 @@ class FileRunEventServiceTest { } @Test - void aColleaguesIncidentIsUntouched() { - // File ids come from the client, so naming one must not close someone else's row. + void aColleaguesIncidentIsUntouchedEvenForALeader() { + // File ids come from the client, so naming one must not close someone else's row. The + // caller here is a leader, who reads the whole team: this path narrows to their own + // rows + // regardless, since a null actor would otherwise match every unattributed row. store.record( RecordFailure.forEditor( FailureKind.UNKNOWN, TEAM, "employee@example.com", "f-1", "theirs")); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventStoreDbTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventStoreDbTest.java index 949904dfb9..982d804354 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventStoreDbTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventStoreDbTest.java @@ -55,11 +55,16 @@ class FileRunEventStoreDbTest { } private RecordFailure failure(FailureKind kind, Long teamId, String fileId) { + return failure(kind, teamId, "author@example.com", fileId); + } + + /** As {@link #failure} but naming the actor, which is what the read scope narrows by. */ + private RecordFailure failure(FailureKind kind, Long teamId, String actor, String fileId) { return new RecordFailure( kind, FailureOrigin.POLICY, teamId, - "author@example.com", + actor, "policy-1", "run-1", null, @@ -74,16 +79,37 @@ class FileRunEventStoreDbTest { store.record(failure(FailureKind.UNKNOWN, OTHER_TEAM, "theirs")); store.record(failure(FailureKind.UNKNOWN, null, "unteamed")); - assertThat(store.list(TEAM, null, null, 10)) + assertThat(store.list(TEAM, null, null, null, 10)) .extracting(FileRunEvent::fileId) .containsExactly("ours"); // A plain `e.teamId = :teamId` would return nothing here: SQL equality against NULL is // never true, which is what the explicit null branch in the JPQL exists for. - assertThat(store.list(null, null, null, 10)) + assertThat(store.list(null, null, null, null, 10)) .extracting(FileRunEvent::fileId) .containsExactly("unteamed"); } + @Test + @DisplayName("actor narrowing is enforced by the query, within the team") + void actorNarrowingIsEnforcedBySql() { + // The clause that makes a member read only their own rows. Exercised here rather than only + // against the in-memory repository, which reimplements the filter in Java and would agree + // with a query that had lost it. + store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "mine@example.com", "f1")); + store.record( + failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "theirs@example.com", "f2")); + store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, null, "f3")); + + assertThat(store.list(TEAM, null, null, "mine@example.com", 10)) + .extracting(FileRunEvent::fileId) + .containsExactly("f1"); + // A null actor is "no filter", which is what a leader reads with: the whole team, including + // the rows nobody is named on. + assertThat(store.list(TEAM, null, null, null, 10)) + .extracting(FileRunEvent::fileId) + .containsExactlyInAnyOrder("f1", "f2", "f3"); + } + @Test @DisplayName("a fold lands on the row's current state, not the caller's snapshot") void foldTargetsTheCurrentRowNotACallersSnapshot() { @@ -170,7 +196,7 @@ class FileRunEventStoreDbTest { assertThat(replacement.id()).isNotEqualTo(first.id()); assertThat(replacement.occurrences()).isEqualTo(1); - assertThat(store.list(TEAM, null, null, 10)).hasSize(1); + assertThat(store.list(TEAM, null, null, null, 10)).hasSize(1); } @Test @@ -188,7 +214,7 @@ class FileRunEventStoreDbTest { FileRunEvent folded = store.record(secondSweep); assertThat(folded.occurrences()).isEqualTo(2); - assertThat(store.list(TEAM, null, null, 10)) + assertThat(store.list(TEAM, null, null, null, 10)) .as("one incident per document, however many runs it failed in") .extracting(FileRunEvent::fileId) .containsExactlyInAnyOrder("file-hash-a", "file-hash-b"); @@ -267,7 +293,7 @@ class FileRunEventStoreDbTest { store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "newer-" + i)); } - assertThat(store.list(TEAM, null, "UNKNOWN", 1)) + assertThat(store.list(TEAM, null, "UNKNOWN", null, 1)) .extracting(FileRunEvent::fileId) .containsExactly("old-unknown"); } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventStoreTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventStoreTest.java index 714d65c29c..97de1952e9 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventStoreTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/failure/FileRunEventStoreTest.java @@ -281,7 +281,7 @@ class FileRunEventStoreTest { store.record(failure(FailureKind.UNKNOWN, TEAM, "mine", "a")); store.record(failure(FailureKind.UNKNOWN, OTHER_TEAM, "theirs", "b")); - assertThat(store.list(TEAM, null, null, 50)) + assertThat(store.list(TEAM, null, null, null, 50)) .extracting(FileRunEvent::fileId) .containsExactly("mine"); } @@ -293,7 +293,7 @@ class FileRunEventStoreTest { store.record(failure(FailureKind.UNKNOWN, null, "unteamed", "a")); store.record(failure(FailureKind.UNKNOWN, TEAM, "teamed", "b")); - assertThat(store.list(null, null, null, 50)) + assertThat(store.list(null, null, null, null, 50)) .extracting(FileRunEvent::fileId) .containsExactly("unteamed"); } @@ -305,8 +305,8 @@ class FileRunEventStoreTest { FileRunEvent event = store.record(failure(FailureKind.UNKNOWN, TEAM, "f1", "boom")); store.applyStatus(event.id(), TEAM, FileRunEventStatus.DISMISSED, "reviewer"); - assertThat(store.list(TEAM, null, null, 10)).isEmpty(); - assertThat(store.list(TEAM, FileRunEventStatus.DISMISSED, null, 10)).hasSize(1); + assertThat(store.list(TEAM, null, null, null, 10)).isEmpty(); + assertThat(store.list(TEAM, FileRunEventStatus.DISMISSED, null, null, 10)).hasSize(1); } @Test @@ -314,7 +314,7 @@ class FileRunEventStoreTest { FileRunEvent event = store.record(failure(FailureKind.UNKNOWN, TEAM, "f1", "boom")); store.applyStatus(event.id(), TEAM, FileRunEventStatus.ACKNOWLEDGED, "reviewer"); - assertThat(store.list(TEAM, null, null, 10)).hasSize(1); + assertThat(store.list(TEAM, null, null, null, 10)).hasSize(1); } @Test @@ -323,7 +323,7 @@ class FileRunEventStoreTest { store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "closed", "b")); store.applyStatus(open.id(), TEAM, FileRunEventStatus.ACKNOWLEDGED, "me"); - assertThat(store.list(TEAM, FileRunEventStatus.ACKNOWLEDGED, null, 50)) + assertThat(store.list(TEAM, FileRunEventStatus.ACKNOWLEDGED, null, null, 50)) .extracting(FileRunEvent::fileId) .containsExactly("open"); } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/failure/InMemoryFileRunEventRepository.java b/app/proprietary/src/test/java/stirling/software/proprietary/failure/InMemoryFileRunEventRepository.java index 1873f6265b..6dbb16322b 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/failure/InMemoryFileRunEventRepository.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/failure/InMemoryFileRunEventRepository.java @@ -54,9 +54,18 @@ class InMemoryFileRunEventRepository implements FileRunEventRepository { return kindId == null || kindId.equals(entity.getKindId()); } + /** Null means the whole team, matching the JPQL's {@code :actor is null} branch. */ + private static boolean sameActor(FileRunEventEntity entity, String actor) { + return actor == null || actor.equals(entity.getActor()); + } + @Override public List findByTeamAndStatus( - Long teamId, FileRunEventStatus status, String kindId, Pageable pageable) { + Long teamId, + FileRunEventStatus status, + String kindId, + String actor, + Pageable pageable) { return page( newestFirst( rows.values().stream() @@ -64,7 +73,8 @@ class InMemoryFileRunEventRepository implements FileRunEventRepository { e -> sameTeam(e, teamId) && e.getStatus() == status - && sameKind(e, kindId)) + && sameKind(e, kindId) + && sameActor(e, actor)) .toList()), pageable); } @@ -142,7 +152,11 @@ class InMemoryFileRunEventRepository implements FileRunEventRepository { @Override public List findByTeamAndStatusIn( - Long teamId, List statuses, String kindId, Pageable pageable) { + Long teamId, + List statuses, + String kindId, + String actor, + Pageable pageable) { return page( newestFirst( rows.values().stream() @@ -150,7 +164,8 @@ class InMemoryFileRunEventRepository implements FileRunEventRepository { e -> sameTeam(e, teamId) && statuses.contains(e.getStatus()) - && sameKind(e, kindId)) + && sameKind(e, kindId) + && sameActor(e, actor)) .toList()), pageable); } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/failure/PolicyFailureAttributionTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/failure/PolicyFailureAttributionTest.java new file mode 100644 index 0000000000..2a71c236a7 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/failure/PolicyFailureAttributionTest.java @@ -0,0 +1,264 @@ +package stirling.software.proprietary.failure; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.slf4j.MDC; +import org.springframework.core.io.ByteArrayResource; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.service.FileStorage; +import stirling.software.common.service.InternalApiClient; +import stirling.software.common.service.JobOwnershipService; +import stirling.software.common.service.JobQueue; +import stirling.software.common.service.ResourceMonitor; +import stirling.software.common.service.TaskManager; +import stirling.software.common.service.ToolMetadataService; +import stirling.software.common.service.UserServiceInterface; +import stirling.software.common.util.TempFileManager; +import stirling.software.common.util.TempFileRegistry; +import stirling.software.proprietary.policy.asset.InProcessPolicyAssetStore; +import stirling.software.proprietary.policy.asset.PolicyAssetResolver; +import stirling.software.proprietary.policy.config.PolicyManagementAuthority; +import stirling.software.proprietary.policy.engine.PolicyEngine; +import stirling.software.proprietary.policy.engine.PolicyExecutor; +import stirling.software.proprietary.policy.engine.PolicyRunRegistry; +import stirling.software.proprietary.policy.model.OutputSpec; +import stirling.software.proprietary.policy.model.PipelineStep; +import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.model.PolicyInputs; +import stirling.software.proprietary.policy.output.InlineOutputSink; +import stirling.software.proprietary.policy.output.PolicyOutputResolver; +import stirling.software.proprietary.policy.progress.PolicyProgressListener; +import stirling.software.proprietary.policy.source.InProcessSourceStore; +import stirling.software.proprietary.policy.store.PolicyStore; + +import tools.jackson.databind.json.JsonMapper; + +/** + * Pins what the engine records as a failure's actor against what a reader gets back, because the + * two sides used to assert independently: the engine's test passed {@code any()} for the actor, and + * the service's fixtures assumed an actor the engine never actually produced. Every collaborator + * between the failing tool call and the read is real here, so a regression in either one fails. + * + *

This is what makes this PR's promise hold. Reads are narrowed to the rows the caller is the + * actor on, so if the engine names the wrong person, a member reads nothing at all. + * + *

The bug it exists for: the engine recorded the BILLING principal as the actor, which for a + * stored policy is always its owner. So an attended failure was filed under someone who never + * touched the document, and the member who did could not see it. + */ +@ExtendWith(MockitoExtension.class) +class PolicyFailureAttributionTest { + + private static final String ROTATE = "/api/v1/general/rotate-pdf"; + private static final Long TEAM = 3L; + + @Mock private InternalApiClient internalApiClient; + @Mock private ToolMetadataService toolMetadataService; + @Mock private TaskManager taskManager; + @Mock private FileStorage fileStorage; + @Mock private JobOwnershipService jobOwnershipService; + @Mock private ResourceMonitor resourceMonitor; + @Mock private JobQueue jobQueue; + @Mock private PolicyStore policyStore; + @Mock private PolicyManagementAuthority authority; + @Mock private UserServiceInterface userService; + + @TempDir Path tempDir; + + private PolicyEngine engine; + private FileRunEventService service; + + @BeforeEach + void setUp() { + ApplicationProperties props = new ApplicationProperties(); + props.getSecurity().setEnableLogin(true); + props.getSystem().getTempFileManagement().setBaseTmpDir(tempDir.toString()); + props.getSystem().getTempFileManagement().setPrefix("failure-attribution-test-"); + + FileRunEventStore store = new FileRunEventStore(new InMemoryFileRunEventRepository()); + service = + new FileRunEventService( + store, + new FailureActionRegistry( + List.of(new AcknowledgeAction(store), new DismissAction(store))), + authority, + userService, + props); + + PolicyFailureRecorder recorder = + new PolicyFailureRecorder( + new FailureClassifier(JsonMapper.builder().build()), store, policyStore); + PolicyExecutor executor = + new PolicyExecutor( + internalApiClient, + toolMetadataService, + new TempFileManager(new TempFileRegistry(), props), + JsonMapper.builder().build()); + engine = + new PolicyEngine( + executor, + taskManager, + new PolicyRunRegistry(new ApplicationProperties()), + recorder, + fileStorage, + jobOwnershipService, + List.of(new InlineOutputSink(fileStorage)), + new PolicyOutputResolver(new InProcessSourceStore()), + resourceMonitor, + jobQueue, + new PolicyAssetResolver(new InProcessPolicyAssetStore())); + + lenient() + .when(jobOwnershipService.createScopedJobKey(anyString())) + .thenAnswer(invocation -> invocation.getArgument(0)); + lenient().when(resourceMonitor.shouldQueueJob(anyInt())).thenReturn(false); + lenient().when(toolMetadataService.isMultiInput(anyString())).thenReturn(false); + // The team is resolved from the policy, so the recorded row lands in the reader's team. + lenient().when(policyStore.get(anyString())).thenReturn(Optional.of(sharedPolicy())); + lenient().when(authority.currentUserTeamId()).thenReturn(TEAM); + } + + /** Alice's policy, shared with her team. Bob is a member of it and does not own it. */ + private static Policy sharedPolicy() { + return new Policy( + "p1", + "rotate", + "alice", + true, + List.of(), + List.of(new PipelineStep(ROTATE, Map.of())), + OutputSpec.inline(), + TEAM); + } + + /** + * Run the shared policy so its single tool step fails, as {@code triggeredBy} (null = sweep). + */ + private void runAndFail(String triggeredBy, String sourceId, String fileIdentity) + throws Exception { + when(internalApiClient.post(eq(ROTATE), any())).thenThrow(new RuntimeException("boom")); + if (triggeredBy != null) { + MDC.put("auditPrincipal", triggeredBy); + } + try { + engine.runPolicy( + sharedPolicy(), + PolicyInputs.of(List.of(pdf())), + PolicyProgressListener.NOOP, + sourceId, + fileIdentity) + .completion() + .get(10, TimeUnit.SECONDS); + } finally { + MDC.remove("auditPrincipal"); + } + } + + private static ByteArrayResource pdf() { + return new ByteArrayResource("input".getBytes()) { + @Override + public String getFilename() { + return "input.pdf"; + } + }; + } + + /** Read as a plain member, who is narrowed to the rows they are the actor on. */ + private FileRunEvent asMember(String reader) { + lenient().when(userService.getCurrentUsername()).thenReturn(reader); + lenient().when(authority.canEditPolicies()).thenReturn(false); + List visible = service.list(null, null, 10); + return visible.isEmpty() ? null : visible.getFirst(); + } + + /** Read as a team leader, who reviews the whole team's incidents. */ + private FileRunEvent asReviewer(String reader) { + lenient().when(userService.getCurrentUsername()).thenReturn(reader); + lenient().when(authority.canEditPolicies()).thenReturn(true); + return service.list(null, null, 10).getFirst(); + } + + @Nested + @DisplayName("a non-owner runs a shared policy on their own upload") + class AttendedByANonOwner { + + @Test + void theTriggeringUserCanReadTheFailureTheyCaused() throws Exception { + runAndFail("bob", null, "bob-doc-1"); + + // The whole point: Bob's read scope narrows to his own rows, so the row only reaches + // him if the engine named him. Before the fix this list was empty. + FileRunEvent mine = asMember("bob"); + assertThat(mine).as("bob must be able to see the failure he caused").isNotNull(); + assertThat(mine.actor()).isEqualTo("bob"); + } + + @Test + void thePolicyOwnerIsNotNamedAsTheActorMerelyForBeingBilled() throws Exception { + runAndFail("bob", null, "bob-doc-1"); + + // Alice owns the policy and pays for the run, but she never touched the document. + assertThat(asReviewer("alice").actor()).isEqualTo("bob"); + } + + @Test + void aColleagueWhoDidNotTriggerItCannotSeeItAtAll() throws Exception { + runAndFail("bob", null, "bob-doc-1"); + + assertThat(asMember("carol")).isNull(); + } + } + + @Nested + @DisplayName("an unattended sweep pulls a file from a source") + class UnattendedSweep { + + @Test + void theRowIsRecordedWithNoActorWhileStillBillingTheOwner() throws Exception { + runAndFail(null, "src-watched-folder", "file-hash-1"); + + assertThat(asReviewer("alice").actor()) + .as("a trigger-fired run has no user to name") + .isNull(); + } + + @Test + void theSourceThatFedItIsStillRecorded() throws Exception { + runAndFail(null, "src-watched-folder", "file-hash-1"); + + FileRunEvent unattended = asReviewer("alice"); + assertThat(unattended.sourceId()).isEqualTo("src-watched-folder"); + assertThat(unattended.fileId()).isEqualTo("file-hash-1"); + } + + @Test + void aMemberDoesNotInheritAnUnattendedFailureAsTheirOwn() throws Exception { + // An unowned row must not fall to whoever happens to be reading: with no actor there is + // nothing for a member's narrowed read to match. + runAndFail(null, "src-watched-folder", "file-hash-1"); + + assertThat(asMember("bob")).isNull(); + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/failure/PolicyFailureRecorderTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/failure/PolicyFailureRecorderTest.java index e1a17843f0..73c5add6ad 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/failure/PolicyFailureRecorderTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/failure/PolicyFailureRecorderTest.java @@ -95,7 +95,7 @@ class PolicyFailureRecorderTest { "Policy run failed: locked", passwordFailure()); - FileRunEvent event = store.list(TEAM, null, null, 10).getFirst(); + FileRunEvent event = store.list(TEAM, null, null, null, 10).getFirst(); assertThat(event.kind()).isEqualTo(FailureKind.INPUT_PASSWORD_PROTECTED); assertThat(event.runId()).isEqualTo("run-1"); assertThat(event.policyId()).isEqualTo("policy-1"); @@ -121,7 +121,7 @@ class PolicyFailureRecorderTest { "Policy run failed: something we do not recognise", new RuntimeException("boom")); - FileRunEvent event = store.list(TEAM, null, null, 10).getFirst(); + FileRunEvent event = store.list(TEAM, null, null, null, 10).getFirst(); assertThat(event.kind()).isEqualTo(FailureKind.UNKNOWN); assertThat(event.detail()).contains("something we do not recognise"); } @@ -139,7 +139,7 @@ class PolicyFailureRecorderTest { "Policy run failed: java.lang.NullPointerException", new RuntimeException("npe")); - FileRunEvent event = store.list(TEAM, null, null, 10).getFirst(); + FileRunEvent event = store.list(TEAM, null, null, null, 10).getFirst(); assertThat(event.kind()).isEqualTo(FailureKind.UNKNOWN); assertThat(event.detail()).contains("NullPointerException"); } @@ -152,7 +152,7 @@ class PolicyFailureRecorderTest { recorder.recordRunFailureAs( FailureKind.UNKNOWN, "run-3", "policy-1", null, null, "could not be queued"); - assertThat(store.list(TEAM, null, null, 10)).hasSize(1); + assertThat(store.list(TEAM, null, null, null, 10)).hasSize(1); } @Test @@ -180,7 +180,7 @@ class PolicyFailureRecorderTest { "locked", passwordFailure()); - List events = store.list(TEAM, null, null, 10); + List events = store.list(TEAM, null, null, null, 10); assertThat(events).hasSize(2); assertThat(events).allMatch(event -> event.occurrences() == 1); assertThat(events) @@ -203,7 +203,7 @@ class PolicyFailureRecorderTest { "locked", passwordFailure()); - FileRunEvent event = store.list(TEAM, null, null, 10).getFirst(); + FileRunEvent event = store.list(TEAM, null, null, null, 10).getFirst(); assertThat(event.sourceId()).isEqualTo("src-s3-invoices"); assertThat(event.actor()).isNull(); } @@ -218,7 +218,7 @@ class PolicyFailureRecorderTest { recorder.recordRunFailureAs( FailureKind.UNKNOWN, "run-2", "policy-1", "src-b", null, "unreachable"); - assertThat(store.list(TEAM, null, null, 10)) + assertThat(store.list(TEAM, null, null, null, 10)) .hasSize(2) .extracting(FileRunEvent::sourceId) .containsExactlyInAnyOrder("src-a", "src-b"); @@ -245,7 +245,7 @@ class PolicyFailureRecorderTest { "locked", passwordFailure()); - assertThat(store.list(TEAM, null, null, 10)) + assertThat(store.list(TEAM, null, null, null, 10)) .singleElement() .extracting(FileRunEvent::occurrences) .isEqualTo(2); @@ -263,7 +263,7 @@ class PolicyFailureRecorderTest { recorder.recordRunFailure( "run-1", "policy-1", null, null, null, "boom", new RuntimeException()); - assertThat(store.list(TEAM, null, null, 10)).hasSize(1); + assertThat(store.list(TEAM, null, null, null, 10)).hasSize(1); } @Test @@ -273,8 +273,8 @@ class PolicyFailureRecorderTest { recorder.recordRunFailure( "run-1", null, null, null, null, "boom", new RuntimeException()); - assertThat(store.list(null, null, null, 10)).hasSize(1); - assertThat(store.list(TEAM, null, null, 10)).isEmpty(); + assertThat(store.list(null, null, null, null, 10)).hasSize(1); + assertThat(store.list(TEAM, null, null, null, 10)).isEmpty(); } @Test @@ -293,7 +293,7 @@ class PolicyFailureRecorderTest { new RuntimeException())) .doesNotThrowAnyException(); // Still recorded, just unteamed: a lookup problem must not lose the incident. - assertThat(store.list(null, null, null, 10)).hasSize(1); + assertThat(store.list(null, null, null, null, 10)).hasSize(1); } } @@ -344,7 +344,7 @@ class PolicyFailureRecorderTest { "no cause", null)) .doesNotThrowAnyException(); - assertThat(store.list(TEAM, null, null, 10).getFirst().kind()) + assertThat(store.list(TEAM, null, null, null, 10).getFirst().kind()) .isEqualTo(FailureKind.UNKNOWN); } } @@ -362,7 +362,7 @@ class PolicyFailureRecorderTest { recorder.recordRunFailure( "run-1", "policy-1", null, null, null, "boom", new IOException("x")); - List events = store.list(TEAM, null, null, 10); + List events = store.list(TEAM, null, null, null, 10); assertThat(events).hasSize(1); assertThat(events.getFirst().occurrences()).isEqualTo(2); } @@ -377,7 +377,7 @@ class PolicyFailureRecorderTest { recorder.recordRunFailure( "run-2", "policy-1", null, null, null, "boom", new IOException("x")); - assertThat(store.list(TEAM, null, null, 10)).hasSize(2); + assertThat(store.list(TEAM, null, null, null, 10)).hasSize(2); } } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java index c38f5c7d89..84e9998b90 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java @@ -216,7 +216,7 @@ class PolicyControllerTest { } private static PolicyRunHandle handle(String runId) { - PolicyRun run = new PolicyRun(runId, null, definitionWithStep(), null, null); + PolicyRun run = new PolicyRun(runId, null, definitionWithStep(), null, null, null); return new PolicyRunHandle(runId, CompletableFuture.completedFuture(run)); } @@ -317,7 +317,7 @@ class PolicyControllerTest { @Test @DisplayName("returns the run view when present") void found() { - PolicyRun run = new PolicyRun("run-3", null, definitionWithStep(), null, null); + PolicyRun run = new PolicyRun("run-3", null, definitionWithStep(), null, null, null); when(runRegistry.get("run-3")).thenReturn(run); ResponseEntity response = controller.status("run-3"); @@ -346,11 +346,11 @@ class PolicyControllerTest { @Test @DisplayName("excludes ad-hoc runs and runs owned by others") void filtersRuns() { - PolicyRun adHoc = new PolicyRun("adhoc", null, definitionWithStep(), null, null); + PolicyRun adHoc = new PolicyRun("adhoc", null, definitionWithStep(), null, null, null); PolicyRun ownedStored = - new PolicyRun("owned", "policy-A", definitionWithStep(), null, null); + new PolicyRun("owned", "policy-A", definitionWithStep(), null, null, null); PolicyRun otherStored = - new PolicyRun("other", "policy-B", definitionWithStep(), null, null); + new PolicyRun("other", "policy-B", definitionWithStep(), null, null, null); when(runRegistry.all()).thenReturn(List.of(adHoc, ownedStored, otherStored)); // ownedByCurrentUser: strip then re-apply scope reproduces the key only for the owned diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyEngineTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyEngineTest.java index b3b45dcd96..1efe094fe4 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyEngineTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyEngineTest.java @@ -9,6 +9,7 @@ import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.doThrow; @@ -231,20 +232,15 @@ class PolicyEngineTest { @Test void recordsWhichSourceFedAFailedRun() throws Exception { // The source is threaded onto the run so an unattended failure is attributable: there is no - // user to name for a file that arrived from a bucket. + // user to name for a file that arrived from a bucket. The actor is asserted null rather + // than + // any(): a loose matcher here is what let the owner be recorded as the actor unnoticed. when(toolMetadataService.isMultiInput(ROTATE)).thenReturn(false); when(internalApiClient.post(eq(ROTATE), any())).thenThrow(new RuntimeException("boom")); PolicyRunHandle handle = engine.runPolicy( - new Policy( - "p1", - "rotate", - "owner", - true, - List.of(), - List.of(new PipelineStep(ROTATE, Map.of())), - OutputSpec.inline()), + policyOwnedBy("owner"), PolicyInputs.of(List.of(pdf("input", "input.pdf"))), PolicyProgressListener.NOOP, "src-s3-invoices", @@ -257,11 +253,136 @@ class PolicyEngineTest { any(), eq("src-s3-invoices"), eq("file-hash-1"), - any(), + isNull(), anyString(), any(Throwable.class)); } + @Test + void anAttendedFailureIsRecordedAgainstWhoTriggeredItNotThePolicysOwner() throws Exception { + // Bob runs Alice's shared policy on his own upload and it fails. The row must name Bob: he + // is the one whose browser holds the document, and a member's read scope narrows to their + // own rows, so filing it under Alice hides it from the only person who can act on it. + when(toolMetadataService.isMultiInput(ROTATE)).thenReturn(false); + when(internalApiClient.post(eq(ROTATE), any())).thenThrow(new RuntimeException("boom")); + + MDC.put("auditPrincipal", "bob"); // the request thread's acting user + try { + engine.runPolicy( + policyOwnedBy("alice"), + PolicyInputs.of(List.of(pdf("input", "input.pdf"))), + PolicyProgressListener.NOOP, + null, + "bob-doc-1") + .completion() + .get(10, TimeUnit.SECONDS); + } finally { + MDC.remove("auditPrincipal"); + } + + verify(failureRecorder) + .recordRunFailure( + anyString(), + any(), + isNull(), + eq("bob-doc-1"), + eq("bob"), + anyString(), + any(Throwable.class)); + } + + @Test + void anUnattendedFailureIsRecordedWithNoActorWhileStillBillingTheOwner() throws Exception { + // The two identities are deliberately different, and this pins both at once: usage is + // charged to the owner (MDC audit principal on the worker), but the failure has no actor, + // which is what makes it UNOWNED and hands the owner actions to the team's reviewer. + when(toolMetadataService.isMultiInput(ROTATE)).thenReturn(false); + String[] principalAtDispatch = {""}; + when(internalApiClient.post(eq(ROTATE), any())) + .thenAnswer( + invocation -> { + principalAtDispatch[0] = MDC.get("auditPrincipal"); + throw new RuntimeException("boom"); + }); + + // No MDC and no security context: exactly a trigger-fired sweep. + engine.runPolicy( + policyOwnedBy("alice"), + PolicyInputs.of(List.of(pdf("input", "input.pdf"))), + PolicyProgressListener.NOOP, + "src-watched-folder", + "file-hash-1") + .completion() + .get(10, TimeUnit.SECONDS); + + assertEquals("alice", principalAtDispatch[0], "billing must still be the policy owner"); + verify(failureRecorder) + .recordRunFailure( + anyString(), + any(), + eq("src-watched-folder"), + eq("file-hash-1"), + isNull(), + anyString(), + any(Throwable.class)); + } + + @Test + void anAdHocFailureIsRecordedAgainstTheSubmittingUser() throws Exception { + // An ad-hoc run has no stored policy, so the submitter is both payer and actor. Asserted so + // the two entry points cannot drift apart. + when(toolMetadataService.isMultiInput(ROTATE)).thenReturn(false); + when(internalApiClient.post(eq(ROTATE), any())).thenThrow(new RuntimeException("boom")); + + MDC.put("auditPrincipal", "bob"); + try { + engine.submit( + definition(new PipelineStep(ROTATE, Map.of())), + PolicyInputs.of(List.of(pdf("input", "input.pdf"))), + PolicyProgressListener.NOOP) + .completion() + .get(10, TimeUnit.SECONDS); + } finally { + MDC.remove("auditPrincipal"); + } + + verify(failureRecorder) + .recordRunFailure( + anyString(), + any(), + any(), + any(), + eq("bob"), + anyString(), + any(Throwable.class)); + } + + @Test + void aRunRefusedAtAdmissionIsRecordedAgainstWhoeverTriggeredIt() throws Exception { + // The queue-full path records its own row, and it is attended: the user is still holding + // the + // document, so it must reach them rather than landing as an ownerless incident. + when(resourceMonitor.shouldQueueJob(anyInt())).thenReturn(true); + CompletableFuture rejected = new CompletableFuture<>(); + rejected.completeExceptionally(new RuntimeException("Job queue full")); + doReturn(rejected).when(jobQueue).queueJob(anyString(), anyInt(), any(), anyLong()); + + MDC.put("auditPrincipal", "bob"); + try { + engine.runPolicy( + policyOwnedBy("alice"), + PolicyInputs.of(List.of(pdf("input", "input.pdf"))), + PolicyProgressListener.NOOP, + null, + "bob-doc-1"); + } finally { + MDC.remove("auditPrincipal"); + } + + verify(failureRecorder) + .recordRunFailureAs(any(), anyString(), any(), isNull(), eq("bob"), anyString()); + } + @Test void recordingAFailureNeverChangesTheRunsOutcome() throws Exception { // Recording is best-effort: losing the incident row is bad, but turning a classified @@ -540,6 +661,17 @@ class PolicyEngineTest { return new PipelineDefinition("test", List.of(steps), OutputSpec.inline()); } + private static Policy policyOwnedBy(String owner) { + return new Policy( + "p1", + "rotate", + owner, + true, + List.of(), + List.of(new PipelineStep(ROTATE, Map.of())), + OutputSpec.inline()); + } + private void stubEndpoint(String endpoint, Resource body) { when(internalApiClient.post(eq(endpoint), any())).thenReturn(ResponseEntity.ok(body)); } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunRegistryTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunRegistryTest.java index 1eb0a1a1fe..692fe84c37 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunRegistryTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunRegistryTest.java @@ -101,6 +101,7 @@ class PolicyRunRegistryTest { null, new PipelineDefinition(runId, List.of(), List.of()), null, + null, null); registry.register(run); return run; From f15832b2bb1aec1aa8547ea774184e3c3296d855 Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Mon, 17 Aug 2026 10:08:09 +0000 Subject: [PATCH 15/23] chore(saas): make schema ownership explicit and enforce it (#7489) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## The problem The SaaS database has two writers and always has: the Supabase migrations in the SaaS repo, and Hibernate's `ddl-auto`. That was a convention rather than a rule, and it leaked twice. - An older `ddl-auto` run widened `team_memberships.role` to varchar(255), which needed [a dedicated migration](https://github.com/Stirling-Tools/Stirling-PDF-SaaS/blob/v3/supabase/migrations/20260804000000_fix_team_memberships_role_varchar50.sql) to repair, because RLS policies depended on the column. - `payg_instance_usage` shipped with an entity and **no migration**, and nobody noticed for months — staging already had the table from an earlier `ddl-auto` run. It surfaced only when a fresh preview branch, built from migrations alone, threw `relation does not exist`. Both are the same bug: nobody had to *say* who owned a table, so the answer got decided by accident. ## The fix `SaasSchemaOwnership` is the register — **29 migration-owned, 29 inherited** and left to Hibernate. `MigrationOwnedSchemaFilter` applies it via Hibernate's `hbm2ddl.schema_filter_provider`, wired on the **saas profile only**. Hibernate is never shown a migration-owned table, so it cannot create, alter, drop or truncate one whatever `ddl-auto` is set to. Inherited tables stay managed, so a fresh preview branch still heals itself on first boot. Self-hosted is untouched — there Hibernate rightly owns everything. **Why a filter rather than just `ddl-auto=none`:** off, and a fresh branch is missing the 29 inherited tables. On, and Hibernate can reach the other 29. The filter is what lets both be true at once. **Why per-table, not per-schema:** Hibernate's schema management runs over every mapped entity regardless of namespace. Moving SaaS tables to their own schema would *not* by itself keep Hibernate out of them — worth knowing, because that was the intuitive fix and it doesn't work. ## The part that makes it stick `SaasSchemaOwnershipTest` makes the register binding: every `@Entity` on the SaaS classpath must appear in exactly one set, so **a new entity fails the build until someone states who owns its table**. That's the forcing function that would have caught `payg_instance_usage`. I verified it bites rather than assuming it — removing a single entry fails with: ``` These entity tables are not declared in SaasSchemaOwnership, so nobody owns them. Offending tables -> entities: [policies (stirling.software.proprietary.policy.store.PolicyEntity)] ``` naming both the table and the class, which is what the next person actually needs. ## One debatable call The **validate** filter excludes them too. Letting validation through would flag drift, which is genuinely useful — but `ddl-auto=validate` fails startup, and it would fail on differences we've deliberately accepted (`ai_create_sessions` carries columns from a reverted Typst feature that nothing maps). A boot failure over a table we chose not to manage is noise. Argued in the javadoc; happy to flip it if you'd rather have the signal. ## Dependency Depends on [Stirling-PDF-SaaS#324](https://github.com/Stirling-Tools/Stirling-PDF-SaaS/pull/324), which adds migrations for the four SaaS-owned tables that had none. They're listed here as migration-owned on that basis, so #324 should land first. Companion to [#7483](https://github.com/Stirling-Tools/Stirling-PDF/pull/7483) (dev/staging profiles with per-profile `ddl-auto`). ## Verification `:saas:test` green including the 5 new tests, `spotlessCheck` green, and the mutation check above. --- .../config/MigrationOwnedSchemaFilter.java | 108 +++++++++++ .../saas/config/SaasSchemaOwnership.java | 117 ++++++++++++ .../resources/application-saas.properties | 7 + .../MigrationOwnedSchemaFilterTest.java | 129 ++++++++++++++ .../saas/config/SaasSchemaOwnershipTest.java | 167 ++++++++++++++++++ 5 files changed, 528 insertions(+) create mode 100644 app/saas/src/main/java/stirling/software/saas/config/MigrationOwnedSchemaFilter.java create mode 100644 app/saas/src/main/java/stirling/software/saas/config/SaasSchemaOwnership.java create mode 100644 app/saas/src/test/java/stirling/software/saas/config/MigrationOwnedSchemaFilterTest.java create mode 100644 app/saas/src/test/java/stirling/software/saas/config/SaasSchemaOwnershipTest.java diff --git a/app/saas/src/main/java/stirling/software/saas/config/MigrationOwnedSchemaFilter.java b/app/saas/src/main/java/stirling/software/saas/config/MigrationOwnedSchemaFilter.java new file mode 100644 index 0000000000..4b2496bf15 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/config/MigrationOwnedSchemaFilter.java @@ -0,0 +1,108 @@ +package stirling.software.saas.config; + +import org.hibernate.boot.model.relational.Namespace; +import org.hibernate.boot.model.relational.Sequence; +import org.hibernate.mapping.Table; +import org.hibernate.tool.schema.spi.SchemaFilter; +import org.hibernate.tool.schema.spi.SchemaFilterProvider; + +/** + * Hides the migration-owned tables from Hibernate's schema management. + * + *

Wired on the SaaS profile only, via {@code hibernate.hbm2ddl.schema_filter_provider}. + * Self-hosted is untouched: there Hibernate rightly owns everything. + * + *

Why a filter rather than simply turning {@code ddl-auto} off: the SaaS database has two + * writers. The Supabase migrations own the SaaS tables, and Hibernate owns roughly thirty tables + * inherited from the self-hosted app that no migration has ever created. Turn {@code ddl-auto} off + * and a fresh preview branch is missing that second half; leave it on and Hibernate is free to + * reconcile migration-owned tables, which is how {@code team_memberships.role} ended up widened to + * varchar(255) and needed a migration to put back. A filter keeps the first half working and makes + * the second impossible. + * + *

Note that this is a per-table filter, not a per-schema one. Hibernate's schema management runs + * over every mapped entity regardless of namespace, so moving SaaS tables to their own schema would + * not by itself keep Hibernate out of them. {@link SaasSchemaOwnership} is the register; this class + * only applies it. + * + *

Foreign keys still cross the line, on purpose. Several inherited tables reference + * migration-owned ones — {@code folders}, {@code stored_files} and {@code file_shares} all point at + * {@code users}/{@code teams}. Hibernate's {@code SchemaCreatorImpl.createForeignKeys} and {@code + * AbstractSchemaMigrator.applyForeignKeys} check {@code includeTable} against the *owning* table + * only and then emit every foreign key on it, without consulting the referenced table. So excluding + * {@code users} does not cost the branch its referential integrity, and a branch ends up matching + * staging. It does mean the referenced tables have to exist by the time Hibernate runs, which holds + * because a Supabase branch applies its migrations at build time and the app connects afterwards. + * + *

Known gap: this cannot detect drift. Filtering means Hibernate never inspects these + * tables, and {@link #getValidateFilter()} extends that to {@code validate}, so nothing here + * compares a migration-owned table against its entity. Combined with the register being a + * hand-maintained list of another repo's contents (see {@link SaasSchemaOwnership}), there is + * currently no automated signal when the register, the entities and the database disagree. That is + * a deliberate trade for a boot that does not fail on differences we accept, not a claim that drift + * cannot happen; a non-fatal drift report is the missing piece and belongs outside this class. + */ +public class MigrationOwnedSchemaFilter implements SchemaFilterProvider, SchemaFilter { + + /** + * The one decision this class makes. Everything Hibernate might do to a table it does not own — + * create, alter, drop, truncate — is refused. + */ + @Override + public boolean includeTable(Table table) { + return !SaasSchemaOwnership.isMigrationOwned(table.getName()); + } + + /** + * Namespaces are never filtered. The inherited tables and the migration-owned ones share {@code + * stirling_pdf}, so excluding the namespace would take both with it. + */ + @Override + public boolean includeNamespace(Namespace namespace) { + return true; + } + + /** + * Sequences are left alone. Every id here is an identity column rather than a mapped generator, + * so there is nothing for Hibernate to create; filtering them would be dead code pretending to + * be a safeguard. + */ + @Override + public boolean includeSequence(Sequence sequence) { + return true; + } + + @Override + public SchemaFilter getCreateFilter() { + return this; + } + + @Override + public SchemaFilter getMigrateFilter() { + return this; + } + + @Override + public SchemaFilter getDropFilter() { + return this; + } + + @Override + public SchemaFilter getTruncatorFilter() { + return this; + } + + /** + * Validation is filtered too, which is the one debatable call here. + * + *

Letting it through would give a useful signal when a migration-owned table drifts from its + * entity. But {@code ddl-auto=validate} fails startup, and it would fail on differences we have + * deliberately accepted — {@code ai_create_sessions} carries columns from a reverted feature + * that nothing maps, for instance. A boot failure over a table we have chosen not to manage is + * noise, so the rule stays uniform: Hibernate does not concern itself with these tables at all. + */ + @Override + public SchemaFilter getValidateFilter() { + return this; + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/config/SaasSchemaOwnership.java b/app/saas/src/main/java/stirling/software/saas/config/SaasSchemaOwnership.java new file mode 100644 index 0000000000..dffd1eccf1 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/config/SaasSchemaOwnership.java @@ -0,0 +1,117 @@ +package stirling.software.saas.config; + +import java.util.Set; + +/** + * Which side owns each table in the SaaS database. + * + *

The SaaS schema has two writers and always has: the Supabase migrations in the + * Stirling-PDF-SaaS repo, and Hibernate's {@code ddl-auto}. That was a convention rather than a + * rule, and it leaked twice. An older {@code ddl-auto} run widened {@code team_memberships.role} to + * varchar(255), which needed a dedicated migration to repair because RLS policies depended on the + * column. Separately {@code payg_instance_usage} went months with an entity and no migration, so it + * simply did not exist on a fresh preview branch. + * + *

This class makes the boundary explicit and {@code SaasSchemaOwnershipTest} makes it binding: + * every {@code @Entity} the SaaS app maps must appear in exactly one of these two sets. A new + * entity fails the build until someone states who owns its table, which is the decision that was + * previously made by accident. + * + *

{@link MigrationOwnedSchemaFilter} enforces it at runtime: Hibernate is never shown the + * migration-owned tables, so it cannot create, alter or drop them whatever {@code ddl-auto} says. + * Inherited tables stay under Hibernate, so a preview branch built from migrations alone still + * heals itself on first boot. + * + *

What this does not catch. The register is a hand-maintained copy of what lives in + * another repository, and only one direction is enforced. The test fails when a *new* entity + * appears with no owner. It cannot notice a table changing sides: write a migration for {@code + * folders} in Stirling-PDF-SaaS and nothing here changes, the test still passes, and Hibernate + * carries on managing a table the migrations now own — which is precisely how {@code + * team_memberships.role} got widened. Adding a migration for anything in {@link #HIBERNATE_MANAGED} + * therefore means moving it to {@link #MIGRATION_OWNED} in the same change; nothing will remind + * you. Making that structural rather than remembered is what moving the SaaS tables into their own + * schema would buy, and is the reason this class is a stepping stone rather than the answer. + */ +public final class SaasSchemaOwnership { + + /** + * Created and altered by the Supabase migrations. Hibernate must not touch these: the + * migrations carry constraints, defaults and RLS policies it knows nothing about and would + * reconcile away. + */ + public static final Set MIGRATION_OWNED = + Set.of( + "ai_create_sessions", + "audit_events", + "authorities", + "billing_subscriptions", + "job_artifact_hash", + "legal_consent", + "linked_instance", + "payg_instance_usage", + "payg_meter_event_log", + "payg_prepaid_bundle", + "payg_shadow_charge", + "payg_team_extensions", + "persistent_logins", + "pricing_policy", + "processing_job", + "processing_job_step", + "procurement_agreement_signature", + "procurement_deal", + "procurement_quote", + "saas_team_extensions", + "saas_user_extensions", + "sessions", + "team_invitations", + "team_memberships", + "teams", + "users", + "wallet_entitlement_snapshot", + "wallet_ledger", + "wallet_policy"); + + /** + * Inherited from the self-hosted app, where {@code ddl-auto} owns the schema and no Supabase + * migration exists. Deliberately left under Hibernate so a fresh branch gets them on first + * boot. + */ + public static final Set HIBERNATE_MANAGED = + Set.of( + "account_link_device_credential", + "account_link_metered_signature", + "account_link_sync_state", + "account_link_usage_counter", + "api_key_daily_usage", + "api_keys", + "file_encryption_keys", + "file_run_events", + "file_share_accesses", + "file_shares", + "folders", + "integration_configs", + "invite_tokens", + "jwt_signing_keys", + "policies", + "policy_assets", + "policy_completed_migrations", + "policy_processed_files", + "policy_source_doc_counts", + "policy_source_doc_totals", + "policy_sources", + "resource_grants", + "storage_cleanup_entries", + "stored_file_blobs", + "stored_files", + "user_license_settings", + "user_server_certificates", + "workflow_participants", + "workflow_sessions"); + + private SaasSchemaOwnership() {} + + /** Case-insensitive: Hibernate hands us whatever casing the mapping used. */ + public static boolean isMigrationOwned(String tableName) { + return tableName != null && MIGRATION_OWNED.contains(tableName.toLowerCase()); + } +} diff --git a/app/saas/src/main/resources/application-saas.properties b/app/saas/src/main/resources/application-saas.properties index f0630aaac1..e3c32c429e 100644 --- a/app/saas/src/main/resources/application-saas.properties +++ b/app/saas/src/main/resources/application-saas.properties @@ -27,6 +27,13 @@ spring.jpa.properties.hibernate.hbm2ddl.create_namespaces=true spring.jpa.hibernate.ddl-auto=update +# ...but only over the tables Hibernate actually owns. The SaaS database has two writers: the +# Supabase migrations own the SaaS tables, Hibernate owns ~30 inherited from the self-hosted app that +# no migration has ever created. This filter hides the former from schema management, so ddl-auto can +# still heal a fresh preview branch without being free to reconcile a migration-owned table — which +# is how team_memberships.role ended up widened to varchar(255). Register: SaasSchemaOwnership. +spring.jpa.properties.hibernate.hbm2ddl.schema_filter_provider=stirling.software.saas.config.MigrationOwnedSchemaFilter + # ---------- Supabase JWT auth ---------- # Required: set SAAS_DB_PROJECT_REF via env. app.supabase.project-ref=${SAAS_DB_PROJECT_REF:} diff --git a/app/saas/src/test/java/stirling/software/saas/config/MigrationOwnedSchemaFilterTest.java b/app/saas/src/test/java/stirling/software/saas/config/MigrationOwnedSchemaFilterTest.java new file mode 100644 index 0000000000..956654384e --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/config/MigrationOwnedSchemaFilterTest.java @@ -0,0 +1,129 @@ +package stirling.software.saas.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.InputStream; +import java.util.Properties; + +import org.hibernate.mapping.Table; +import org.hibernate.tool.schema.spi.SchemaFilter; +import org.hibernate.tool.schema.spi.SchemaFilterProvider; +import org.junit.jupiter.api.Test; + +/** + * Covers {@link MigrationOwnedSchemaFilter} and, just as importantly, its wiring. + * + *

{@link SaasSchemaOwnershipTest} proves the register is complete; nothing proved the filter + * applies it, or that Hibernate is even asking. A typo in the {@code + * hibernate.hbm2ddl.schema_filter_provider} key, a stale fully-qualified name after a package move, + * or a getter returning null would all leave every migration-owned table exposed to {@code + * ddl-auto} with a fully green build. Hence the property assertion below, which is the only thing + * here that would catch that. + */ +class MigrationOwnedSchemaFilterTest { + + private static final String FILTER_PROPERTY = + "spring.jpa.properties.hibernate.hbm2ddl.schema_filter_provider"; + + private final MigrationOwnedSchemaFilter filter = new MigrationOwnedSchemaFilter(); + + /** "orm" is Hibernate's own default contributor; the value is irrelevant to the filter. */ + private static Table table(String name) { + return new Table("orm", name); + } + + @Test + void migrationOwnedTablesAreHiddenFromHibernate() { + assertThat(filter.includeTable(table("teams"))).isFalse(); + assertThat(filter.includeTable(table("users"))).isFalse(); + assertThat(filter.includeTable(table("team_memberships"))).isFalse(); + assertThat(filter.includeTable(table("payg_instance_usage"))).isFalse(); + } + + @Test + void inheritedTablesStayUnderHibernate() { + assertThat(filter.includeTable(table("folders"))).isTrue(); + assertThat(filter.includeTable(table("stored_files"))).isTrue(); + assertThat(filter.includeTable(table("api_keys"))).isTrue(); + } + + @Test + void anUnknownTableIsLeftToHibernate() { + // Fail-open is the right default: an unrecognised table is either brand new or from a + // module + // we do not know about, and SaasSchemaOwnershipTest is what stops it staying unrecognised. + assertThat(filter.includeTable(table("no_such_table"))).isTrue(); + } + + @Test + void casingDoesNotDefeatTheFilter() { + assertThat(filter.includeTable(table("TEAMS"))).isFalse(); + assertThat(filter.includeTable(table("Team_Memberships"))).isFalse(); + } + + /** + * Foreign keys from an inherited table into a migration-owned one survive the filter. + * + *

Worth pinning, because it is not obvious and it decides whether a preview branch keeps + * referential integrity. {@code folders}, {@code stored_files} and {@code file_shares} all + * reference {@code users}/{@code teams}, which the filter excludes. Hibernate 7.2's {@code + * SchemaCreatorImpl.createForeignKeys} (and {@code AbstractSchemaMigrator.applyForeignKeys}) + * tests {@code includeTable} against the *owning* table only, then emits every foreign key on + * it; the referenced table is never consulted. So the constraints are still created and a + * branch matches staging. + * + *

The one thing this depends on is ordering: the referenced tables have to exist first. They + * do, because a Supabase branch runs its migrations at build time and the app connects after. + */ + @Test + void foreignKeysIntoMigrationOwnedTablesAreStillEmitted() { + assertThat(filter.includeTable(table("folders"))).isTrue(); + assertThat(filter.includeTable(table("file_shares"))).isTrue(); + assertThat(filter.includeTable(table("users"))).isFalse(); + assertThat(filter.includeTable(table("teams"))).isFalse(); + } + + @Test + void everySchemaActionGetsTheSameFilter() { + assertThat(filter.getCreateFilter()).isSameAs(filter); + assertThat(filter.getMigrateFilter()).isSameAs(filter); + assertThat(filter.getDropFilter()).isSameAs(filter); + assertThat(filter.getTruncatorFilter()).isSameAs(filter); + assertThat(filter.getValidateFilter()).isSameAs(filter); + } + + @Test + void namespacesAndSequencesAreNeverFiltered() { + // Both share the stirling_pdf namespace, so filtering it would take the inherited tables + // with it. Neither argument is read, so nulls are fine and keep the test free of Hibernate + // bootstrap machinery. + assertThat(filter.includeNamespace(null)).isTrue(); + assertThat(filter.includeSequence(null)).isTrue(); + } + + @Test + void theFilterIsActuallyWiredIntoHibernate() throws Exception { + Properties properties = new Properties(); + try (InputStream in = getClass().getResourceAsStream("/application-saas.properties")) { + assertThat(in) + .as("application-saas.properties must be on the test classpath to check wiring") + .isNotNull(); + properties.load(in); + } + + String configured = properties.getProperty(FILTER_PROPERTY); + assertThat(configured) + .as( + "%s is unset, so Hibernate installs its default filter and every" + + " migration-owned table is back under ddl-auto", + FILTER_PROPERTY) + .isNotBlank(); + + Class wired = Class.forName(configured.trim()); + assertThat(SchemaFilterProvider.class) + .as("Hibernate only accepts a SchemaFilterProvider here") + .isAssignableFrom(wired); + assertThat(SchemaFilter.class).isAssignableFrom(wired); + assertThat(wired).isEqualTo(MigrationOwnedSchemaFilter.class); + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/config/SaasSchemaOwnershipTest.java b/app/saas/src/test/java/stirling/software/saas/config/SaasSchemaOwnershipTest.java new file mode 100644 index 0000000000..e07b1042e7 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/config/SaasSchemaOwnershipTest.java @@ -0,0 +1,167 @@ +package stirling.software.saas.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.boot.persistence.autoconfigure.EntityScan; +import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider; +import org.springframework.core.type.filter.AnnotationTypeFilter; +import org.springframework.util.ClassUtils; + +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +import stirling.software.proprietary.security.configuration.DatabaseConfig; + +/** + * Makes {@link SaasSchemaOwnership} binding rather than decorative. + * + *

Every {@code @Entity} the SaaS app maps has to be declared as owned by either the Supabase + * migrations or Hibernate. Adding an entity without saying which fails here, at build time, instead + * of months later on a preview branch that has no such table. That is not hypothetical: {@code + * payg_instance_usage} shipped with an entity and no migration and went unnoticed until a branch + * tried to use it. + * + *

"Maps" is meant precisely: the scan covers the packages named by the {@code @EntityScan} + * declarations the app actually boots with, not everything under {@code stirling.software}. See + * {@link #mappedPackages()}. Note this only enforces one direction — {@link SaasSchemaOwnership} + * documents the drift it cannot see. + */ +class SaasSchemaOwnershipTest { + + /** + * The packages the running app actually maps, read off the two {@code @EntityScan} declarations + * that define them rather than hardcoded. + * + *

Scanning all of {@code stirling.software} would be easier and wrong in a quiet way: it is + * a superset, so it would force ownership declarations for entities Hibernate never sees and + * let the register claim tables that do not exist as far as the SaaS app is concerned. Deriving + * the list means this test measures the same set Hibernate does, and follows a package being + * added or moved without anyone updating it here. + */ + private static Set mappedPackages() { + Set packages = new TreeSet<>(); + for (Class config : List.of(SaasJpaConfig.class, DatabaseConfig.class)) { + EntityScan scan = config.getAnnotation(EntityScan.class); + assertThat(scan) + .as("%s must carry @EntityScan, or its entities are not mapped", config) + .isNotNull(); + packages.addAll(Arrays.asList(scan.value())); + } + return packages; + } + + private static TreeMap mappedTables() { + ClassPathScanningCandidateComponentProvider scanner = + new ClassPathScanningCandidateComponentProvider(false); + scanner.addIncludeFilter(new AnnotationTypeFilter(Entity.class)); + TreeMap byTable = new TreeMap<>(); + for (String basePackage : mappedPackages()) { + for (BeanDefinition bd : scanner.findCandidateComponents(basePackage)) { + String className = bd.getBeanClassName(); + Class type; + try { + type = + ClassUtils.forName( + className, SaasSchemaOwnershipTest.class.getClassLoader()); + } catch (ClassNotFoundException | LinkageError e) { + continue; // not on this module's runtime classpath; nothing to own + } + Table table = type.getAnnotation(Table.class); + String name = + table != null && !table.name().isBlank() + ? table.name() + : camelToSnake(type.getSimpleName()); + byTable.put(name.toLowerCase(), className); + } + } + return byTable; + } + + /** Mirrors Spring Boot's default CamelCaseToUnderscoresNamingStrategy for an unnamed @Table. */ + private static String camelToSnake(String name) { + return name.replaceAll("([a-z0-9])([A-Z])", "$1_$2").toLowerCase(); + } + + @Test + void everyEntityTableIsOwnedByExactlyOneSide() { + TreeMap mapped = mappedTables(); + assertThat(mapped) + .as("entity scan found nothing, so this test proves nothing") + .isNotEmpty(); + // The scan is derived from @EntityScan now, so a package quietly dropped from either + // declaration would shrink it and weaken this test rather than fail it. These four straddle + // the two declarations, so losing either side fails here instead of silently checking less. + assertThat(mapped.keySet()) + .as("both @EntityScan declarations must have contributed to the scan") + .contains("users", "teams", "payg_instance_usage", "folders"); + + Set undeclared = new TreeSet<>(); + Set both = new TreeSet<>(); + for (String table : mapped.keySet()) { + boolean migration = SaasSchemaOwnership.MIGRATION_OWNED.contains(table); + boolean hibernate = SaasSchemaOwnership.HIBERNATE_MANAGED.contains(table); + if (migration && hibernate) both.add(table); + if (!migration && !hibernate) undeclared.add(table); + } + + assertThat(undeclared) + .as( + """ + These entity tables are not declared in SaasSchemaOwnership, so nobody owns \ + them. Decide and add each to exactly one set: + - MIGRATION_OWNED: also add a migration in Stirling-PDF-SaaS, or the table \ + will not exist on a fresh preview branch. + - HIBERNATE_MANAGED: only correct for a table inherited from the \ + self-hosted app that no Supabase migration creates. + Offending tables -> entities: %s""" + .formatted( + undeclared.stream() + .map(t -> t + " (" + mapped.get(t) + ")") + .toList())) + .isEmpty(); + + assertThat(both) + .as("declared as owned by both sides, which is the one thing it cannot be") + .isEmpty(); + } + + @Test + void theTwoSetsDoNotOverlap() { + Set overlap = new TreeSet<>(SaasSchemaOwnership.MIGRATION_OWNED); + overlap.retainAll(SaasSchemaOwnership.HIBERNATE_MANAGED); + assertThat(overlap).isEmpty(); + } + + @Test + void tableNamesAreLowercaseSoLookupsCannotMiss() { + // isMigrationOwned() lowercases its input; a capital in either set would be unreachable. + assertThat(SaasSchemaOwnership.MIGRATION_OWNED) + .allSatisfy(t -> assertThat(t).isEqualTo(t.toLowerCase())); + assertThat(SaasSchemaOwnership.HIBERNATE_MANAGED) + .allSatisfy(t -> assertThat(t).isEqualTo(t.toLowerCase())); + } + + @Test + void migrationOwnedTablesIncludeTheOnesThatBitUs() { + // team_memberships is the table an old ddl-auto run widened; payg_instance_usage is the one + // that had an entity and no migration. Both must be on the migrations' side of the line. + assertThat(SaasSchemaOwnership.MIGRATION_OWNED) + .contains("team_memberships", "payg_instance_usage", "teams", "users"); + } + + @Test + void isMigrationOwnedIsCaseInsensitiveAndNullSafe() { + assertThat(SaasSchemaOwnership.isMigrationOwned("TEAM_MEMBERSHIPS")).isTrue(); + assertThat(SaasSchemaOwnership.isMigrationOwned("team_memberships")).isTrue(); + assertThat(SaasSchemaOwnership.isMigrationOwned(null)).isFalse(); + assertThat(SaasSchemaOwnership.isMigrationOwned("no_such_table")).isFalse(); + } +} From 99dda7a9f6160652050e26279f1ff72a0b1af123 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Mon, 17 Aug 2026 11:47:08 +0000 Subject: [PATCH 16/23] Convert Processor tables to be consistent with each other (#7312) # Description of Changes Currently, the tables in the processor are inconsistently styled and function differently to each other because they don't all share the same table component. This is a big problem since the Processor is a largely table-based UI and most of most pages in it are tables. This PR creates a new common `DataTable` component and converts all existing tables in the Processor (other than those in the docs pages) to use it, tweaking the individual tables content as necessary so they all work the same. The `DataTable` component is designed to take minimal JSX as arguments. Instead, it takes typed data from the caller and it converts that into JSX, which makes it a lot easier to guarantee that the tables will all render the same and if we want to update the table styling in the future, we just need to do it in one place. I also added sorting capabilities to many of the tables since it was easy with the library and it was appropriate for many of the existing table columns. ## Table Comparisons AuditTab-comparison DocumentExtractions-comparison InstanceHealthTable-comparison Integrations-comparison InvoicesList-comparison LinkedInstancesTable-comparison PendingInvitations-comparison PipelinesTable-comparison PolicyCatalogue-comparison ReviewQueueTable-comparison SourcesTable-comparison UsersDirectory-comparison --- .../public/locales/en-US/translation.toml | 32 +- frontend/editor/src/core/theme/colors.css | 5 + frontend/editor/src/core/theme/dimensions.css | 1 + frontend/editor/src/core/ui/DataTable.css | 376 +++++++++ .../editor/src/core/ui/DataTable.stories.tsx | 262 +++++++ frontend/editor/src/core/ui/DataTable.tsx | 516 ++++++++++++ frontend/editor/src/core/ui/Dropdown.css | 12 +- frontend/editor/src/core/ui/Dropdown.tsx | 91 ++- frontend/editor/src/core/ui/Table.css | 73 -- frontend/editor/src/core/ui/Table.stories.tsx | 109 --- frontend/editor/src/core/ui/Table.tsx | 141 ---- .../editor/src/core/ui/dataTableColumns.tsx | 569 ++++++++++++++ frontend/editor/src/core/ui/index.ts | 2 +- frontend/editor/src/portal/api/documents.ts | 6 - .../account-link/LinkedInstancesTable.tsx | 128 ++- .../components/billing/InvoicesList.tsx | 143 ++-- .../documents/DocumentExtractions.stories.tsx | 34 +- .../documents/DocumentExtractions.tsx | 42 +- .../components/documents/ReviewQueueTable.tsx | 204 ++--- .../editor-admin/InstanceHealthTable.tsx | 102 +-- .../components/infrastructure/AuditTab.tsx | 130 ++- .../infrastructure/TableSkeleton.stories.tsx | 23 - .../infrastructure/TableSkeleton.tsx | 16 - .../components/infrastructure/infraFormat.ts | 9 - .../components/pipelines/PipelinesTable.tsx | 103 +-- .../policies/PolicyCatalogueTable.tsx | 134 ++-- .../components/sources/SourcesTable.tsx | 119 +-- .../users/PendingInvitations.test.tsx | 2 +- .../components/users/PendingInvitations.tsx | 95 ++- .../users/UsersDirectory.stories.tsx | 3 +- .../components/users/UsersDirectory.tsx | 740 ++++++++---------- .../src/portal/views/Integrations.test.tsx | 25 +- .../editor/src/portal/views/Integrations.tsx | 401 +++++----- .../src/portal/views/Users.saas.test.tsx | 2 +- frontend/editor/src/portal/views/Users.tsx | 56 +- frontend/package-lock.json | 65 ++ frontend/package.json | 1 + 37 files changed, 2983 insertions(+), 1789 deletions(-) create mode 100644 frontend/editor/src/core/ui/DataTable.css create mode 100644 frontend/editor/src/core/ui/DataTable.stories.tsx create mode 100644 frontend/editor/src/core/ui/DataTable.tsx delete mode 100644 frontend/editor/src/core/ui/Table.css delete mode 100644 frontend/editor/src/core/ui/Table.stories.tsx delete mode 100644 frontend/editor/src/core/ui/Table.tsx create mode 100644 frontend/editor/src/core/ui/dataTableColumns.tsx delete mode 100644 frontend/editor/src/portal/components/infrastructure/TableSkeleton.stories.tsx delete mode 100644 frontend/editor/src/portal/components/infrastructure/TableSkeleton.tsx diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 4d5e271103..99d68c35de 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -3010,6 +3010,7 @@ saturation = "Saturation and brightness" title = "Choose color" [common] +actions = "Actions" back = "Back" cancel = "Cancel" close = "Close" @@ -3025,6 +3026,7 @@ error = "Error" expand = "Expand" loading = "Loading..." next = "Next" +open = "Open" preview = "Preview" previous = "Previous" refresh = "Refresh" @@ -6339,7 +6341,6 @@ revoked = "Revoked" unnamed = "Unnamed instance" [portal.accountLink.instances.columns] -actions = "Actions" instance = "Instance" lastSeen = "Last seen" linked = "Linked" @@ -7262,12 +7263,11 @@ editorAction = "Editor" empty = "No documents match this filter." rowActions = "Row actions" sensitiveLabel = "Sensitive" -sensitiveTitle = "Sensitive — access required" [portal.documents.table.columns] action = "Pipeline / Action" -actions = "Actions" document = "Document" +labels = "Labels" product = "Product" status = "Status" time = "Time" @@ -7283,6 +7283,7 @@ host = "Host" lastSeen = "Last seen" region = "Region" status = "Status" +target = "Target" version = "Version" [portal.editorAdmin.health.empty] @@ -7602,11 +7603,9 @@ security = "Security" storage = "Storage" [portal.integrations] -addAnother = "Add another" availableHeading = "Available" comingSoonHeading = "Coming soon" connect = "Connect" -connectedHeading = "Connected" connectionCount_one = "{{count}} connection" connectionCount_other = "{{count}} connections" customApi = "Custom API" @@ -7624,6 +7623,10 @@ security = "Security" signing = "Signing" storage = "Storage" +[portal.integrations.noResults] +description = "No integrations match your filters. Try a different category or search." +title = "No matches" + [portal.integrations.status] connected = "Connected" @@ -7826,10 +7829,10 @@ paused = "Paused" [portal.pipelines.table] name = "Pipeline" -open = "Open" sources = "Sources" status = "Status" steps = "Steps" +trigger = "Trigger" [portal.pipelines.trigger] folder-watch = "Folder watch" @@ -8722,9 +8725,9 @@ unused = "Unused" [portal.sources.table] documents = "Documents" -open = "Open" source = "Source" status = "Status" +type = "Type" usedBy = "Policies" [portal.sources.types.box] @@ -10948,6 +10951,13 @@ approver = "Approves policy" editor = "Editor" processor = "Processor" +[users.columns] +capabilities = "Capabilities" +email = "Email" +person = "Person" +role = "Role" +status = "Status" + [users.confirm] cancelInviteBody = "Cancel the invitation to {{email}}? They won't be able to join with the current link." cancelInviteTitle = "Cancel invitation" @@ -10967,10 +10977,8 @@ title = "No members yet" addToTeam = "Add to team" guestCount = "{{count}} guest" guests = "Guests" -guestsDesc = "External collaborators, scoped to what you shared. Editor only." ledBy = "led by {{owner}}" org = "Organization" -orgDesc = "Owners with org-wide authority and policy approval" owners = "{{count}} owner" team = "{{name}} team" teamMeta = "{{count}} people" @@ -11010,13 +11018,15 @@ usernamePlaceholder = "jsmith" [users.invites] by = "Invited by {{who}}" cancel = "Cancel" -count = "{{count}} pending" -desc = "Invited people who haven't joined yet. They hold a seat until they accept." expiresInDays_one = "Expires in {{count}} day" expiresInDays_other = "Expires in {{count}} days" expiresToday = "Expires today" title = "Pending invitations" +[users.invites.columns] +expires = "Expires" +invitee = "Invitee" + [users.loadError] description = "Something went wrong reaching the backend, or you don't have access. Try again." title = "Couldn't load members" diff --git a/frontend/editor/src/core/theme/colors.css b/frontend/editor/src/core/theme/colors.css index 3caf240da4..dffd6beaba 100644 --- a/frontend/editor/src/core/theme/colors.css +++ b/frontend/editor/src/core/theme/colors.css @@ -32,6 +32,9 @@ html[data-app-theme="light"] { --c-primary: var(--p-blue-500); --c-primary-hover: var(--p-blue-600); --c-primary-subtle: color-mix(in srgb, var(--p-blue-500) 10%, transparent); + /* Accent used as TEXT (links, toggles): a deeper hue that clears 4.5:1 on + light surfaces, where --c-primary itself does not. */ + --c-accent-text: var(--p-blue-700); --c-success: var(--p-green-600); --c-danger: var(--p-red-600); @@ -144,6 +147,8 @@ html[data-app-theme="midnight"] { --c-text-muted: var(--p-zinc-200); --c-text-subtle: var(--p-zinc-250); --c-text-on-primary: var(--p-white); + /* Accent used as TEXT on dark surfaces: a lighter step for 4.5:1. */ + --c-accent-text: var(--p-blue-400); --c-btn-solid: var(--c-text); --c-btn-inverse: var(--p-ink); --c-btn-secondary: var(--p-c-1a1a1d); diff --git a/frontend/editor/src/core/theme/dimensions.css b/frontend/editor/src/core/theme/dimensions.css index aa0a1b38cc..554baddcca 100644 --- a/frontend/editor/src/core/theme/dimensions.css +++ b/frontend/editor/src/core/theme/dimensions.css @@ -62,6 +62,7 @@ /* ── Z-index ladder ── */ --z-dropdown: 25; --z-drawer: 50; + --z-popover: 150; --z-toast: 200; /* Fullscreen tool-picker surfaces (editor) */ --z-fullscreen-icon-svg: 1; diff --git a/frontend/editor/src/core/ui/DataTable.css b/frontend/editor/src/core/ui/DataTable.css new file mode 100644 index 0000000000..c7572efa90 --- /dev/null +++ b/frontend/editor/src/core/ui/DataTable.css @@ -0,0 +1,376 @@ +/* + * DataTable: the one Stirling table. Appearance is owned entirely here: the + * canonical surface, the header/row grammar, the standardized loading / empty / + * error states, and every cell KIND (`sui-dtc__*`). Call-sites choose a kind and + * supply data; they never style a cell. This is what lets every table look and + * behave the same. + */ + +.sui-datatable { + width: 100%; +} + +/* The one canonical surface: every table wears it; call-sites don't wrap it. */ +.sui-datatable__frame { + border: 1px solid var(--c-border); + border-radius: var(--radius-md); + background: var(--c-surface); + overflow: hidden; +} + +.sui-datatable__toolbar { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; + padding: 0.75rem 0.875rem; + border-bottom: 1px solid var(--c-border); +} + +.sui-datatable__scroll { + width: 100%; + overflow-x: auto; +} + +.sui-datatable__table { + width: 100%; + border-collapse: collapse; + font-size: 0.8125rem; +} + +.sui-datatable__caption { + caption-side: top; + text-align: left; + padding: 0.625rem 0.875rem; + color: var(--c-text-subtle); + font-size: 0.75rem; +} + +/* Header */ +.sui-datatable__th { + text-align: left; + font-weight: 600; + color: var(--c-text-subtle); + font-size: 0.6875rem; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 0.625rem 0.875rem; + border-bottom: 1px solid var(--c-border); + white-space: nowrap; +} +.sui-datatable__th--right { + text-align: right; +} +.sui-datatable__th--fit { + width: 1%; +} +/* Accessible-only header text for blank affordance/action columns. */ +.sui-datatable__th-sr { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.sui-datatable__sort { + display: inline-flex; + align-items: center; + gap: 0.25rem; + background: none; + border: none; + padding: 0; + margin: 0; + cursor: pointer; + font: inherit; + letter-spacing: inherit; + text-transform: inherit; + color: inherit; +} +.sui-datatable__th--right .sui-datatable__sort { + flex-direction: row-reverse; +} +.sui-datatable__sort:hover { + color: var(--c-text); +} +.sui-datatable__sort:focus-visible { + outline: 0.125rem solid var(--c-primary); + outline-offset: 0.125rem; + border-radius: var(--radius-sm); +} +.sui-datatable__sort-icon { + display: inline-flex; + color: var(--c-text-subtle); + opacity: 0; + transition: opacity var(--motion-fast); +} +.sui-datatable__sort:hover .sui-datatable__sort-icon { + opacity: 0.5; +} +.sui-datatable__sort-icon--asc, +.sui-datatable__sort-icon--desc { + opacity: 1; + color: var(--c-primary); +} +.sui-datatable__sort-icon--desc svg { + transform: rotate(180deg); +} + +/* Body */ +.sui-datatable__td { + padding: 0.625rem 0.875rem; + color: var(--c-text-muted); + border-bottom: 1px solid var(--c-border-subtle); + vertical-align: middle; +} +.sui-datatable__table tbody tr:last-child .sui-datatable__td { + border-bottom: none; +} +.sui-datatable__td--right { + text-align: right; +} +.sui-datatable__td--nowrap { + white-space: nowrap; +} +.sui-datatable__td--fit { + width: 1%; + white-space: nowrap; +} + +/* Density (the compact variant) */ +.sui-datatable--compact .sui-datatable__th, +.sui-datatable--compact .sui-datatable__td { + padding: 0.375rem 0.625rem; +} + +/* Interactive rows */ +.sui-datatable__row--interactive { + cursor: pointer; + transition: background var(--motion-fast); +} +.sui-datatable__row--interactive:hover { + background: var(--c-hover); +} +.sui-datatable__row--interactive:focus-visible { + outline: 0.125rem solid var(--c-primary); + outline-offset: -0.125rem; +} + +/* Non-actionable rows (e.g. coming-soon): de-emphasized to read as disabled. + Muting is done with accessible colours + a faded (non-text) icon rather than + row opacity, which would blend text under the WCAG contrast floor. */ +.sui-datatable__row--muted .sui-dtc__entity-name { + color: var(--c-text-muted); + font-weight: 500; +} +.sui-datatable__row--muted .sui-dtc__entity-icon { + opacity: 0.4; +} + +.sui-datatable__chevron { + display: inline-flex; + color: var(--c-text-subtle); +} + +/* Full-width state cell (empty / error) */ +.sui-datatable__state { + padding: 2rem; + text-align: center; + color: var(--c-text-subtle); +} +.sui-datatable__state--error { + color: var(--c-danger); +} +.sui-datatable__state--node { + padding: 0; +} + +/* + * Cell kinds (sui-dtc__*): the locked cell vocabulary. These absorb the + * per-namespace portal-*__ cell styles (mono / muted / cell-stack / name-cell / + * caret …) that every table used to re-implement. + */ +.sui-dtc__text { + color: var(--c-text-muted); +} +/* Labeled value ("Category: action"): full-strength text, bold label. */ +.sui-dtc__text--labeled { + color: var(--c-text); +} +.sui-dtc__text-label { + font-weight: 600; +} +.sui-dtc__mono { + font-family: var(--font-mono); + font-size: 0.75rem; + color: var(--c-text-muted); +} +.sui-dtc__muted { + color: var(--c-text-subtle); +} +.sui-dtc__num { + font-variant-numeric: tabular-nums; +} +.sui-dtc__labels { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.375rem; +} + +/* entity: leading icon + bold name + optional muted note */ +.sui-dtc__entity { + display: flex; + align-items: center; + gap: 0.625rem; + min-width: 0; +} +.sui-dtc__entity-icon { + display: inline-flex; + align-items: center; + flex: none; + color: var(--c-text-muted); +} +/* The component sizes bare icons; self-styled marks (Avatar) render as-is. */ +.sui-dtc__entity-icon svg { + width: 1.2rem; + height: 1.2rem; +} +.sui-dtc__entity-body { + display: flex; + flex-direction: column; + gap: 0.1875rem; + min-width: 0; +} +.sui-dtc__entity-head { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 0.25rem; +} +.sui-dtc__entity-name { + font-weight: 600; + color: var(--c-text); +} +.sui-dtc__entity-suffix { + font-size: 0.75rem; + font-weight: 400; + color: var(--c-text-subtle); +} +.sui-dtc__note { + font-size: 0.75rem; + color: var(--c-text-subtle); +} + +.sui-dtc__actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.25rem; +} +.sui-dtc__menu-item--danger { + color: var(--c-danger); +} + +/* Grouped tables: section header row + per-group "show all" toggle. */ +.sui-datatable__group-cell { + padding: 0.5rem 0.875rem; + background: var(--c-surface-sunken, var(--c-hover)); + border-bottom: 1px solid var(--c-border); +} +.sui-datatable__table + tbody + tr.sui-datatable__group:not(:first-child) + .sui-datatable__group-cell { + border-top: 1px solid var(--c-border); +} +.sui-datatable__group-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} +.sui-datatable__group-title { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 0.5rem; +} +.sui-datatable__group-title strong { + font-size: 0.8125rem; + color: var(--c-text); +} +.sui-datatable__group-meta { + font-size: 0.75rem; + color: var(--c-text-subtle); +} +.sui-datatable__group-more { + padding: 0.5rem 0.875rem; +} +.sui-datatable__show-all { + background: none; + border: none; + padding: 0; + cursor: pointer; + font: inherit; + font-size: 0.75rem; + color: var(--c-accent-text); +} +.sui-datatable__show-all:hover { + text-decoration: underline; +} + +.sui-dtc__progress { + display: flex; + align-items: center; + gap: 0.5rem; +} +.sui-dtc__progress-bar { + display: block; + width: 5rem; +} +.sui-dtc__progress-pct { + font-variant-numeric: tabular-nums; + color: var(--c-text-muted); + min-width: 2.5rem; +} + +.sui-dtc__links { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.75rem; +} +/* Neutral chip-style link (matches the invoice-link look): the colour is not an + * accent - the trailing glyph + hover fill carry the affordance. */ +.sui-dtc__link { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.5rem; + border-radius: var(--radius-sm); + color: var(--c-text); + text-decoration: none; + font-weight: 500; + border: 1px solid transparent; + transition: + background var(--motion-fast), + border-color var(--motion-fast); +} +.sui-dtc__link:hover { + background: var(--c-surface-sunken); + border-color: var(--c-border); +} +.sui-dtc__link:focus-visible { + outline: none; + border-color: var(--c-primary); + background: var(--c-surface-sunken); +} + +.sui-dtc__select { + min-width: 13rem; +} diff --git a/frontend/editor/src/core/ui/DataTable.stories.tsx b/frontend/editor/src/core/ui/DataTable.stories.tsx new file mode 100644 index 0000000000..630537ace9 --- /dev/null +++ b/frontend/editor/src/core/ui/DataTable.stories.tsx @@ -0,0 +1,262 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Button } from "@app/ui/Button"; +import { column, DataTable, type DataTableColumn } from "@app/ui/DataTable"; + +interface Region { + id: string; + name: string; + code: string; + status: "healthy" | "degraded"; + docs: number; + latency: number; + auto: boolean; +} + +const REGIONS: Region[] = [ + { + id: "1", + name: "US East", + code: "us-east-1", + status: "healthy", + docs: 12481, + latency: 41, + auto: true, + }, + { + id: "2", + name: "US West", + code: "us-west-2", + status: "healthy", + docs: 8210, + latency: 63, + auto: false, + }, + { + id: "3", + name: "EU West", + code: "eu-west-1", + status: "degraded", + docs: 3044, + latency: 190, + auto: true, + }, + { + id: "4", + name: "AP South", + code: "ap-south-1", + status: "healthy", + docs: 5622, + latency: 88, + auto: false, + }, +]; + +const tone = (r: Region) => ({ + tone: r.status === "healthy" ? ("success" as const) : ("warning" as const), + label: r.status, +}); + +const COLUMNS: DataTableColumn[] = [ + column.entity({ + key: "name", + header: "Region", + primary: (r) => r.name, + }), + column.mono({ key: "code", header: "Code", get: (r) => r.code }), + column.badge({ key: "status", header: "Status", get: tone }), + column.number({ + key: "docs", + header: "Docs 24h", + get: (r) => r.docs, + format: (n) => n.toLocaleString(), + }), + column.number({ + key: "latency", + header: "P95", + get: (r) => r.latency, + format: (n) => `${n} ms`, + }), +]; + +const SORTABLE_COLUMNS: DataTableColumn[] = [ + column.entity({ + key: "name", + header: "Region", + primary: (r) => r.name, + sortable: true, + }), + column.mono({ + key: "code", + header: "Code", + get: (r) => r.code, + sortable: true, + }), + column.badge({ key: "status", header: "Status", get: tone, sortable: true }), + column.number({ + key: "docs", + header: "Docs 24h", + get: (r) => r.docs, + format: (n) => n.toLocaleString(), + sortable: true, + }), + column.number({ + key: "latency", + header: "P95", + get: (r) => r.latency, + format: (n) => `${n} ms`, + sortable: true, + }), +]; + +const meta: Meta = { + title: "Compound/DataTable", + component: DataTable, + tags: ["autodocs"], + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +/** Columns come from the `column` vocabulary; call-sites never style a cell. */ +export const Basic: Story = { + render: () => ( + columns={COLUMNS} rows={REGIONS} rowKey={(r) => r.id} /> + ), +}; + +/** Sorting is opt-in per column (`sortable: true`); click a header. */ +export const Sortable: Story = { + render: () => ( + + columns={SORTABLE_COLUMNS} + rows={REGIONS} + rowKey={(r) => r.id} + defaultSort={{ key: "docs", direction: "desc" }} + /> + ), +}; + +/** `onRowClick` + a chevron affordance. The line above shows the click land. */ +export const Interactive: Story = { + render: () => { + function Bound() { + const [clicked, setClicked] = useState(null); + return ( + <> +

+ {clicked + ? `Clicked: ${clicked.name} (${clicked.code})` + : "Click a row to fire onRowClick."} +

+ + columns={COLUMNS} + rows={REGIONS} + rowKey={(r) => r.id} + onRowClick={setClicked} + rowAffordance="chevron" + /> + + ); + } + return ; + }, +}; + +/** A trailing action column (icon-only kebab), locked to the design system. */ +export const WithActions: Story = { + render: () => ( + + columns={[ + ...COLUMNS, + column.actions({ + key: "actions", + get: () => [ + { + label: "Row actions", + glyph: "kebab", + iconOnly: true, + onClick: () => {}, + }, + ], + }), + ]} + rows={REGIONS} + rowKey={(r) => r.id} + /> + ), +}; + +/** First-load skeleton mirrors the real column layout. */ +export const Loading: Story = { + render: () => ( + + columns={COLUMNS} + rows={[]} + rowKey={(r) => r.id} + loading + /> + ), +}; + +/** Standardized empty slot. */ +export const Empty: Story = { + render: () => ( + + columns={COLUMNS} + rows={[]} + rowKey={(r) => r.id} + empty="No regions deployed yet." + /> + ), +}; + +/** Standardized error slot (announced as an alert). */ +export const ErrorState: Story = { + render: () => ( + + columns={COLUMNS} + rows={[]} + rowKey={(r) => r.id} + error="Couldn't load regions. Try again." + /> + ), +}; + +/** Optional toolbar slot above the table, inside the surface. */ +export const WithToolbar: Story = { + render: () => ( + + columns={COLUMNS} + rows={REGIONS} + rowKey={(r) => r.id} + toolbar={ + <> + Regions + + + + } + /> + ), +}; + +/** The one look choice: the `compact` variant. */ +export const Compact: Story = { + render: () => ( + + columns={COLUMNS} + rows={REGIONS} + rowKey={(r) => r.id} + variant="compact" + /> + ), +}; diff --git a/frontend/editor/src/core/ui/DataTable.tsx b/frontend/editor/src/core/ui/DataTable.tsx new file mode 100644 index 0000000000..ccb5578016 --- /dev/null +++ b/frontend/editor/src/core/ui/DataTable.tsx @@ -0,0 +1,516 @@ +import { type KeyboardEvent, type ReactNode, useMemo, useState } from "react"; +import { + type ColumnDef, + createColumnHelper, + createSortedRowModel, + flexRender, + type RowData, + rowSortingFeature, + sortFn_alphanumeric, + sortFn_basic, + type SortingState, + tableFeatures, + useTable, +} from "@tanstack/react-table"; +import { useTranslation } from "react-i18next"; +import { Skeleton } from "@app/ui/Skeleton"; +import { + type CellAction, + type DataTableColumn, + renderCellActions, +} from "@app/ui/dataTableColumns"; +import "@app/ui/DataTable.css"; + +export * from "@app/ui/dataTableColumns"; + +/** Per-column presentation carried through TanStack's typed `meta` slot. */ +interface ColumnMeta { + align: "left" | "right"; + nowrap: boolean; + fit: boolean; + /** Visually-hidden header text for blank affordance/action columns, so the + * column still has an accessible name (avoids axe `empty-table-header`). */ + srHeader?: string; +} + +/** + * Feature registry for every DataTable, built once. Sorting is always + * registered so any column can opt in; the core row model defaults in. + */ +const DATA_TABLE_FEATURES = tableFeatures({ + rowSortingFeature, + sortedRowModel: createSortedRowModel(), + columnMeta: {} as ColumnMeta, + // Register the comparators the column vocabulary uses. Without this v9 falls + // back to a case-sensitive `basic` sort and warns per column. + sortFns: { alphanumeric: sortFn_alphanumeric, basic: sortFn_basic }, +}); +type DataTableFeatures = typeof DATA_TABLE_FEATURES; + +/** Closed appearance dial — the only look choice a call-site may make. */ +export type DataTableVariant = "default" | "compact"; + +/** + * A collapsible section of rows under a locked header. Group headers are + * structured (title + muted meta + optional right-aligned actions), never raw + * markup, so grouped tables stay as opinionated as flat ones. Provide `groups` + * instead of `rows`. + */ +export interface DataTableGroup { + key: string; + title: string; + /** Muted sub-text on the header (e.g. "5 people · led by Dana"). */ + meta?: string; + /** Right-aligned header actions (e.g. "Add to team", a kebab menu). */ + actions?: CellAction[]; + rows: T[]; + /** Collapse rows past this count behind a "Show all N" toggle. */ + collapseAfter?: number; + /** Render the group's rows greyed/disabled (non-actionable, e.g. coming-soon). */ + muted?: boolean; +} + +export interface DataTableProps { + /** Columns built with the `column` vocabulary — never raw JSX. */ + columns: DataTableColumn[]; + /** Flat rows. Provide this OR `groups`, not both. */ + rows?: T[]; + /** Grouped rows with section headers. Takes precedence over `rows`. */ + groups?: DataTableGroup[]; + rowKey: (row: T) => string; + + /** Makes rows interactive (hover + click + keyboard). */ + onRowClick?: (row: T) => void; + /** Per-row interactivity gate, checked only when `onRowClick` is set. */ + isRowInteractive?: (row: T) => boolean; + /** Trailing affordance drawn on interactive rows. */ + rowAffordance?: "none" | "chevron"; + + /** Initial sort, applied to the matching sortable column. */ + defaultSort?: { key: string; direction?: "asc" | "desc" }; + + /** First-load state: renders column-shaped skeleton rows under the header. */ + loading?: boolean; + /** Skeleton row count while loading. Defaults to 6. */ + skeletonRows?: number; + /** Error slot — replaces the rows with an alert message row. */ + error?: ReactNode; + /** Shown when there are no rows (and not loading / no error). Text or a node. */ + empty?: ReactNode; + + /** Content above the table (filters, search, actions), inside the surface. */ + toolbar?: ReactNode; + /** The only appearance choice. */ + variant?: DataTableVariant; + /** Accessible caption for the table. */ + caption?: string; + /** Labels for a group's "show all / show less" toggle (pass translated). + * `showAll` receives the group's total row count. */ + collapseLabels?: { showAll: (total: number) => string; showLess: string }; +} + +function ChevronGlyph() { + return ( + + + + ); +} + +function SortGlyph() { + return ( + + + + ); +} + +const CHEVRON_COLUMN_KEY = "__affordance"; + +/** + * The shared Stirling table. Call-sites supply data + behaviour; the component + * owns 100% of the appearance. Columns come from the `column` vocabulary (typed + * cell kinds, no raw markup), the surface / density / states are standardized + * here, and the only look choice exposed is the closed `variant`. Behaviour - + * sorting today, more later - is opt-in per column or via props. + */ +export function DataTable({ + columns, + rows = [], + groups, + rowKey, + onRowClick, + isRowInteractive, + rowAffordance = "none", + defaultSort, + loading = false, + skeletonRows = 6, + error, + empty, + toolbar, + variant = "default", + caption, + collapseLabels = { + showAll: (n) => `Show all ${n}`, + showLess: "Show less", + }, +}: DataTableProps) { + const { t } = useTranslation(); + const [sorting, setSorting] = useState( + defaultSort + ? [{ id: defaultSort.key, desc: defaultSort.direction === "desc" }] + : [], + ); + const [openGroups, setOpenGroups] = useState>(new Set()); + const toggleGroup = (key: string) => + setOpenGroups((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + + // The data source is either grouped or flat; TanStack (headers, sorting for + // the flat path) is fed the flattened rows. + const flatRows = useMemo( + () => (groups ? groups.flatMap((g) => g.rows) : rows), + [groups, rows], + ); + + const interactive = Boolean(onRowClick); + const showChevron = interactive && rowAffordance === "chevron"; + // A row that holds its own controls (actions/links/select/caps) can't also be + // a `role="button"` (a button may not contain interactive descendants); it + // keeps the click as a mouse shortcut, and the inner control is the keyboard path. + const rowsContainControls = columns.some((c) => c.interactive); + + const effectiveColumns = useMemo[]>(() => { + if (!showChevron) return columns; + return [ + ...columns, + { + key: CHEVRON_COLUMN_KEY, + header: "", + align: "right", + nowrap: true, + fit: true, + sortable: false, + renderCell: (row) => + (isRowInteractive?.(row) ?? true) ? ( + + + + ) : null, + }, + ]; + }, [columns, showChevron, isRowInteractive]); + + const tanstackColumns = useMemo[]>(() => { + const helper = createColumnHelper(); + return effectiveColumns.map((c) => { + // A blank header (trailing affordance/action columns) still needs an + // accessible name for assistive tech. + const srHeader = c.header + ? undefined + : c.key === CHEVRON_COLUMN_KEY + ? t("common.open", "Open") + : t("common.actions", "Actions"); + const meta: ColumnMeta = { + align: c.align, + nowrap: c.nowrap, + fit: c.fit, + srHeader, + }; + if (c.sortable && c.sortValue) { + const sortValue = c.sortValue; + return helper.accessor((row: T): unknown => sortValue(row), { + id: c.key, + header: () => c.header, + cell: (ctx) => c.renderCell(ctx.row.original), + enableSorting: true, + sortUndefined: "last", + sortFn: c.sortFn ?? "basic", + meta, + }); + } + return helper.display({ + id: c.key, + header: () => c.header, + cell: (ctx) => c.renderCell(ctx.row.original), + meta, + }); + }); + }, [effectiveColumns, t]); + + const table = useTable({ + features: DATA_TABLE_FEATURES, + data: flatRows, + columns: tanstackColumns, + state: { sorting }, + onSortingChange: setSorting, + getRowId: (row) => rowKey(row), + }); + + const colCount = effectiveColumns.length; + + // Shared row wiring so grouped rows behave like flat ones (interactivity + + // the affordance column) instead of being a second-class path. + const rowProps = (original: T, muted?: boolean) => { + const rowInteractive = + interactive && (isRowInteractive?.(original) ?? true); + // A row that owns the whole interaction takes the button role + keyboard + // handling; a row with its own controls keeps just the mouse click. + const asButton = rowInteractive && !rowsContainControls; + return { + className: [ + "sui-datatable__row", + rowInteractive ? "sui-datatable__row--interactive" : "", + muted ? "sui-datatable__row--muted" : "", + ] + .filter(Boolean) + .join(" "), + onClick: rowInteractive ? () => onRowClick?.(original) : undefined, + tabIndex: asButton ? 0 : undefined, + role: asButton ? ("button" as const) : undefined, + onKeyDown: asButton + ? (e: KeyboardEvent) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onRowClick?.(original); + } + } + : undefined, + }; + }; + + // No rows at all - covers a grouped table whose groups are all empty (or an + // empty groups list), which would otherwise render a header-only table. + const noRows = groups + ? groups.every((g) => g.rows.length === 0) + : rows.length === 0; + + let body: ReactNode; + if (loading) { + body = Array.from({ length: skeletonRows }).map((_, r) => ( + + {effectiveColumns.map((c) => ( + + + + ))} + + )); + } else if (error != null) { + body = ( + + + {error} + + + ); + } else if (noRows) { + const isNode = typeof empty === "object" && empty !== null; + body = ( + + + {empty ?? "No data"} + + + ); + } else if (groups) { + body = groups.flatMap((g) => { + const limit = g.collapseAfter ?? Infinity; + const open = openGroups.has(g.key); + const overflow = g.rows.length > limit; + const shown = overflow && !open ? g.rows.slice(0, limit) : g.rows; + const header = ( + + +
+
+ {g.title} + {g.meta && ( + {g.meta} + )} +
+ {g.actions && + g.actions.length > 0 && + renderCellActions(g.actions)} +
+ + + ); + const rowEls = shown.map((row) => ( + + {effectiveColumns.map((c) => ( + + {c.renderCell(row)} + + ))} + + )); + const moreEl = overflow ? ( + + + + + + ) : null; + return moreEl ? [header, ...rowEls, moreEl] : [header, ...rowEls]; + }); + } else { + body = table.getRowModel().rows.map((row) => ( + + {row.getAllCells().map((cell) => { + const meta = cell.column.columnDef.meta; + return ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ); + })} + + )); + } + + return ( +
+
+ {toolbar &&
{toolbar}
} +
+ + {caption && ( + + )} + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const meta = header.column.columnDef.meta; + const align = meta?.align ?? "left"; + const canSort = header.column.getCanSort(); + const sorted = header.column.getIsSorted(); + const label = header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + ); + return ( + + ); + })} + + ))} + + {body} +
{caption}
+ {canSort ? ( + + ) : meta?.srHeader ? ( + + {meta.srHeader} + + ) : ( + label + )} +
+
+
+
+ ); +} + +function cellClass( + align: "left" | "right", + nowrap: boolean, + fit: boolean, +): string { + return [ + "sui-datatable__td", + `sui-datatable__td--${align}`, + nowrap ? "sui-datatable__td--nowrap" : "", + fit ? "sui-datatable__td--fit" : "", + ] + .filter(Boolean) + .join(" "); +} + +function headerClass(align: "left" | "right", fit: boolean): string { + return [ + "sui-datatable__th", + `sui-datatable__th--${align}`, + fit ? "sui-datatable__th--fit" : "", + ] + .filter(Boolean) + .join(" "); +} diff --git a/frontend/editor/src/core/ui/Dropdown.css b/frontend/editor/src/core/ui/Dropdown.css index 8b84af29f1..d71026d881 100644 --- a/frontend/editor/src/core/ui/Dropdown.css +++ b/frontend/editor/src/core/ui/Dropdown.css @@ -4,28 +4,20 @@ } .sui-dd__menu { - position: absolute; - top: calc(100% + var(--space-1)); + /* Positioned (fixed, portaled to ) entirely by the Menu component. */ min-width: 12rem; padding: var(--space-1); background: var(--c-surface); border: 1px solid var(--c-border); border-radius: var(--radius-md); box-shadow: var(--shadow-lg); - z-index: var(--z-dropdown); + z-index: var(--z-popover); animation: fadeInUp var(--motion-enter) both; display: flex; flex-direction: column; gap: 0.0625rem; } -.sui-dd__menu--start { - left: 0; -} -.sui-dd__menu--end { - right: 0; -} - .sui-dd__item { display: flex; align-items: center; diff --git a/frontend/editor/src/core/ui/Dropdown.tsx b/frontend/editor/src/core/ui/Dropdown.tsx index bf481c9ea5..25019d7c3c 100644 --- a/frontend/editor/src/core/ui/Dropdown.tsx +++ b/frontend/editor/src/core/ui/Dropdown.tsx @@ -6,12 +6,14 @@ import { useContext, useEffect, useId, + useLayoutEffect, useMemo, useRef, useState, type ReactElement, type ReactNode, } from "react"; +import { createPortal } from "react-dom"; import "@app/ui/Dropdown.css"; type Alignment = "start" | "end"; @@ -20,6 +22,8 @@ interface DropdownContextValue { open: boolean; setOpen: (open: boolean) => void; triggerRef: React.RefObject; + /** The portaled menu element, so click-outside can exclude it. */ + menuRef: React.RefObject; menuId: string; align: Alignment; } @@ -68,15 +72,20 @@ function Root({ const triggerRef = useRef(null); const containerRef = useRef(null); + const menuRef = useRef(null); const menuId = useId(); - // Click-outside + Escape close. + // Click-outside + Escape close. The menu is portaled to , so it is not + // inside containerRef - check it separately or a click on it would close the + // menu before the item's handler runs. useEffect(() => { if (!open) return; function onDocClick(e: MouseEvent) { + const target = e.target as Node; if ( containerRef.current && - !containerRef.current.contains(e.target as Node) + !containerRef.current.contains(target) && + !(menuRef.current && menuRef.current.contains(target)) ) { setOpen(false); } @@ -96,7 +105,7 @@ function Root({ }, [open, setOpen]); const value = useMemo( - () => ({ open, setOpen, triggerRef, menuId, align }), + () => ({ open, setOpen, triggerRef, menuRef, menuId, align }), [open, setOpen, menuId, align], ); @@ -150,23 +159,79 @@ export interface DropdownMenuProps { } function Menu({ children, className, width }: DropdownMenuProps) { - const { open, menuId, align } = useDropdownCtx(); - if (!open) return null; - const style = - width !== undefined + const { open, menuId, align, triggerRef, menuRef } = useDropdownCtx(); + // Fixed position tracked to the trigger. Portaling to keeps the menu + // out of any `overflow` ancestor (e.g. a table's horizontal scroll area), + // which would otherwise clip it and add a scrollbar. + const [pos, setPos] = useState<{ + top?: number; + bottom?: number; + left?: number; + right?: number; + maxHeight: number; + } | null>(null); + + useLayoutEffect(() => { + if (!open) return; + const place = () => { + const el = triggerRef.current; + if (!el) return; + const r = el.getBoundingClientRect(); + const gap = 4; + const margin = 8; + const spaceBelow = window.innerHeight - r.bottom - margin; + const spaceAbove = r.top - margin; + // Flip above when there's more room there, so a trigger near the viewport + // bottom doesn't open a fixed menu that runs off-screen and can't scroll. + const below = spaceBelow >= spaceAbove; + const horizontal = + align === "end" + ? { right: window.innerWidth - r.right } + : { left: r.left }; + setPos({ + ...horizontal, + ...(below + ? { top: r.bottom + gap } + : { bottom: window.innerHeight - r.top + gap }), + maxHeight: Math.max(0, (below ? spaceBelow : spaceAbove) - gap), + }); + }; + place(); + // Track the trigger while scrolling/resizing (capture catches inner scrollers). + window.addEventListener("scroll", place, true); + window.addEventListener("resize", place); + return () => { + window.removeEventListener("scroll", place, true); + window.removeEventListener("resize", place); + }; + }, [open, align, triggerRef]); + + if (!open || !pos) return null; + const style: React.CSSProperties = { + position: "fixed", + // Explicit auto (not undefined) so the CSS fallback `top`/`left` can't leak + // in on the axis this placement isn't pinning. + top: pos.top ?? "auto", + bottom: pos.bottom ?? "auto", + left: pos.left ?? "auto", + right: pos.right ?? "auto", + maxHeight: pos.maxHeight, + overflowY: "auto", + ...(width !== undefined ? { minWidth: typeof width === "number" ? `${width}px` : width } - : undefined; - return ( + : {}), + }; + return createPortal( + , + document.body, ); } diff --git a/frontend/editor/src/core/ui/Table.css b/frontend/editor/src/core/ui/Table.css deleted file mode 100644 index fa89fd04d6..0000000000 --- a/frontend/editor/src/core/ui/Table.css +++ /dev/null @@ -1,73 +0,0 @@ -.sui-table-wrap { - width: 100%; - overflow-x: auto; -} - -.sui-table { - width: 100%; - border-collapse: collapse; - font-size: 0.8125rem; -} - -/* Header text kept for assistive tech only, so a column of controls can be named - without putting a heading above it. Defined here rather than borrowing a global - utility, since the portal loads its own stylesheet. */ -.sui-table__th-sr { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip-path: inset(50%); - white-space: nowrap; - border: 0; -} - -.sui-table__th { - text-align: left; - font-weight: 600; - color: var(--c-text-subtle); - font-size: 0.6875rem; - text-transform: uppercase; - letter-spacing: 0.04em; - padding: 0.625rem 0.875rem; - border-bottom: 1px solid var(--c-border); - white-space: nowrap; -} -.sui-table__th--right, -.sui-table__td--right { - text-align: right; -} -.sui-table__th--center, -.sui-table__td--center { - text-align: center; -} - -.sui-table__td { - padding: 0.625rem 0.875rem; - color: var(--c-text-muted); - border-bottom: 1px solid var(--c-border-subtle); - vertical-align: middle; -} -.sui-table tbody tr:last-child .sui-table__td { - border-bottom: none; -} - -.sui-table__row--interactive { - cursor: pointer; - transition: background var(--motion-fast); -} -.sui-table__row--interactive:hover { - background: var(--c-hover); -} -.sui-table__row--interactive:focus-visible { - outline: 0.125rem solid var(--c-primary); - outline-offset: -0.125rem; -} - -.sui-table__empty { - padding: 2rem; - text-align: center; - color: var(--c-text-subtle); -} diff --git a/frontend/editor/src/core/ui/Table.stories.tsx b/frontend/editor/src/core/ui/Table.stories.tsx deleted file mode 100644 index 2e7e0f64f1..0000000000 --- a/frontend/editor/src/core/ui/Table.stories.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { Table, type TableColumn } from "@app/ui/Table"; -import { StatusBadge } from "@app/ui/StatusBadge"; - -interface Region { - id: string; - name: string; - code: string; - status: "healthy" | "degraded"; - docs: number; - latency: string; -} - -const REGIONS: Region[] = [ - { - id: "1", - name: "US East", - code: "us-east-1", - status: "healthy", - docs: 12481, - latency: "41 ms", - }, - { - id: "2", - name: "US West", - code: "us-west-2", - status: "healthy", - docs: 8210, - latency: "63 ms", - }, - { - id: "3", - name: "EU West", - code: "eu-west-1", - status: "degraded", - docs: 3044, - latency: "190 ms", - }, -]; - -const COLUMNS: TableColumn[] = [ - { key: "name", header: "Region", render: (r) => r.name }, - { - key: "code", - header: "Code", - render: (r) => ( - {r.code} - ), - }, - { - key: "status", - header: "Status", - render: (r) => ( - - {r.status} - - ), - }, - { - key: "docs", - header: "Docs 24h", - align: "right", - render: (r) => r.docs.toLocaleString(), - }, - { key: "latency", header: "P95", align: "right", render: (r) => r.latency }, -]; - -const meta: Meta = { - title: "Compound/Table", - component: Table, - tags: ["autodocs"], - parameters: { layout: "padded" }, -}; -export default meta; -type Story = StoryObj; - -/** Presentational table — columns own their cell renderers; pass pre-sorted rows. */ -export const Basic: Story = { - render: () => ( - columns={COLUMNS} rows={REGIONS} rowKey={(r) => r.id} /> - ), -}; - -/** With `onRowClick`, rows become focusable + hoverable (keyboard: Enter/Space). */ -export const Interactive: Story = { - render: () => ( - - columns={COLUMNS} - rows={REGIONS} - rowKey={(r) => r.id} - onRowClick={() => {}} - /> - ), -}; - -/** Empty body slot. */ -export const Empty: Story = { - render: () => ( - - columns={COLUMNS} - rows={[]} - rowKey={(r) => r.id} - empty="No regions deployed yet." - /> - ), -}; diff --git a/frontend/editor/src/core/ui/Table.tsx b/frontend/editor/src/core/ui/Table.tsx deleted file mode 100644 index 417df3d6c1..0000000000 --- a/frontend/editor/src/core/ui/Table.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import type { ReactNode } from "react"; -import "@app/ui/Surface.css"; -import "@app/ui/Table.css"; - -export interface TableColumn { - /** Stable column id. */ - key: string; - header: ReactNode; - /** - * Hides the header visually but keeps it for assistive tech. For a trailing column of controls - * or chevrons, where a visible heading would be noise but a blank one leaves the cells below it - * unlabelled. - */ - headerHidden?: boolean; - /** Cell renderer for a row. */ - render: (row: T) => ReactNode; - align?: "left" | "right" | "center"; - /** Optional fixed/min width (any CSS length). */ - width?: string; -} - -export interface TableProps { - columns: TableColumn[]; - rows: T[]; - /** Stable key per row. */ - rowKey: (row: T) => string; - /** Makes rows interactive (hover + click + keyboard). */ - onRowClick?: (row: T) => void; - /** - * Per-row gate for interactivity, checked only when {@link onRowClick} is set. A row for which - * this returns false is inert: no click/keyboard, and not announced as a button. Defaults to - * all rows interactive. - */ - isRowInteractive?: (row: T) => boolean; - /** - * Set when rows render controls of their own. The row keeps its click as a mouse shortcut but - * stops announcing itself as a button, because a button may not contain other controls and a - * {@code } is no longer a row to a screen reader. That row control is then the - * keyboard path to the same action, so nothing is lost by leaving the row itself inert. - */ - rowsContainControls?: boolean; - /** Rendered in place of the body when there are no rows. */ - empty?: ReactNode; - className?: string; -} - -/** - * Minimal data table primitive. Columns own their own cell renderers, so the - * table stays presentational — callers pre-sort/filter and pass the rows they - * want shown. Rows become focusable buttons-in-disguise when `onRowClick` is - * set. - */ -export function Table({ - columns, - rows, - rowKey, - onRowClick, - isRowInteractive, - rowsContainControls = false, - empty, - className, -}: TableProps) { - const interactive = Boolean(onRowClick); - return ( -
- - - - {columns.map((c) => ( - - ))} - - - - {rows.length === 0 ? ( - - - - ) : ( - rows.map((row) => { - const rowInteractive = - interactive && (isRowInteractive?.(row) ?? true); - // Only a row that owns the whole interaction takes the button role and the keyboard - // handling that goes with it; see rowsContainControls. - const rowIsControl = rowInteractive && !rowsContainControls; - return ( - onRowClick?.(row) : undefined} - tabIndex={rowIsControl ? 0 : undefined} - role={rowIsControl ? "button" : undefined} - onKeyDown={ - rowIsControl - ? (e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - onRowClick?.(row); - } - } - : undefined - } - > - {columns.map((c) => ( - - ))} - - ); - }) - )} - -
- {c.headerHidden ? ( - {c.header} - ) : ( - c.header - )} -
- {empty ?? "No data"} -
- {c.render(row)} -
-
- ); -} diff --git a/frontend/editor/src/core/ui/dataTableColumns.tsx b/frontend/editor/src/core/ui/dataTableColumns.tsx new file mode 100644 index 0000000000..df31dc384a --- /dev/null +++ b/frontend/editor/src/core/ui/dataTableColumns.tsx @@ -0,0 +1,569 @@ +import { Fragment, type ReactNode } from "react"; +import { StatusBadge, type StatusTone } from "@app/ui/StatusBadge"; +import { Chip, type ChipAccent } from "@app/ui/Chip"; +import { Button } from "@app/ui/Button"; +import { Dropdown } from "@app/ui/Dropdown"; +import { ProgressBar } from "@app/ui/ProgressBar"; +import { Select, type SelectOption } from "@app/ui/Select"; + +/** + * The column vocabulary for {@link DataTable}. Call-sites pick a cell KIND and + * supply the data + semantics; the component owns 100% of the appearance. There + * is no raw-JSX / className escape hatch by design; a cell can only look the way + * the design system draws its kind, so every table looks and behaves the same. + */ + +type Align = "left" | "right"; +type SortValue = string | number | boolean | null | undefined; + +/** + * Which built-in comparator sorts a column. Set by the builder from the cell's + * data type - `alphanumeric` (case-insensitive, natural: `v2` before `v10`) for + * text, `basic` (raw numeric) for numbers. Call-sites never choose this. + */ +export type DataTableSortFn = "alphanumeric" | "basic"; + +/** Opaque, fully-resolved column. Produced only by the {@link column} builders. */ +export interface DataTableColumn { + key: string; + header: ReactNode; + align: Align; + /** Prevent wrapping (mono/number values). */ + nowrap: boolean; + /** Shrink the column to its content (actions / affordances). */ + fit: boolean; + sortable: boolean; + sortValue?: (row: T) => SortValue; + /** Comparator kind, derived from the cell type. Only set when sortable. */ + sortFn?: DataTableSortFn; + /** Cell renders its own interactive control (button/link/select/chip). Rows + * containing one drop their `role="button"` so a button never nests inside a + * button - the control is the keyboard path instead. */ + interactive?: boolean; + /** Internal, design-system-owned renderer. Call-sites never supply this. */ + renderCell: (row: T) => ReactNode; +} + +/** The only design-system glyph a cell may use (icon-only actions). */ +export type CellGlyph = "kebab"; + +function KebabGlyph() { + return ( + + + + + + ); +} + +/** An item in a kebab action menu. */ +export interface CellMenuItem { + label: string; + tone?: "default" | "danger"; + disabled?: boolean; + onClick: () => void; + /** Draw a divider above this item. */ + dividerBefore?: boolean; +} + +/** A row/group action. A locked button, or a kebab menu when `menu` is set. */ +export interface CellAction { + label: string; + glyph?: CellGlyph; + /** Icon-only (uses `label` as the accessible name). */ + iconOnly?: boolean; + tone?: "default" | "danger"; + onClick?: () => void; + loading?: boolean; + disabled?: boolean; + /** When set, the button opens this menu instead of firing `onClick`. */ + menu?: CellMenuItem[]; +} + +/** Renders a row of locked action buttons / kebab menus. Shared by the + * `actions` cell kind and grouped-table headers. */ +export function renderCellActions(actions: CellAction[]): ReactNode { + return ( +
e.stopPropagation()}> + {actions.map((a) => + a.menu ? ( + + + + + + {a.menu.map((m) => ( + + {m.dividerBefore && } + + {m.label} + + + ))} + + + ) : ( + + ), + )} +
+ ); +} + +/** An external link inside a cell. */ +export interface CellLink { + label: string; + href: string; + ariaLabel?: string; +} + +interface Common { + key: string; + header: ReactNode; + sortable?: boolean; +} + +function base( + o: Common, + extra: Pick, "align" | "nowrap" | "fit" | "renderCell"> & { + sortValue?: (row: T) => SortValue; + sortFn?: DataTableSortFn; + interactive?: boolean; + }, +): DataTableColumn { + return { + key: o.key, + header: o.header, + align: extra.align, + nowrap: extra.nowrap, + fit: extra.fit, + sortable: !!o.sortable, + sortValue: o.sortable ? extra.sortValue : undefined, + sortFn: o.sortable ? extra.sortFn : undefined, + interactive: extra.interactive, + renderCell: extra.renderCell, + }; +} + +function text( + o: Common & { + get: (row: T) => string; + /** Optional bold label rendered before the value as "Label: value". */ + label?: (row: T) => string | null | undefined; + sortBy?: (row: T) => SortValue; + }, +): DataTableColumn { + return base(o, { + align: "left", + nowrap: false, + fit: false, + sortValue: o.sortBy ?? ((r) => o.get(r)), + sortFn: "alphanumeric", + renderCell: (r) => { + const label = o.label?.(r); + return label ? ( + + {label}: {o.get(r)} + + ) : ( + {o.get(r)} + ); + }, + }); +} + +function mono( + o: Common & { get: (row: T) => string; sortBy?: (row: T) => SortValue }, +): DataTableColumn { + return base(o, { + align: "left", + nowrap: true, + fit: false, + sortValue: o.sortBy ?? ((r) => o.get(r)), + sortFn: "alphanumeric", + renderCell: (r) => {o.get(r)}, + }); +} + +function muted( + o: Common & { + get: (row: T) => string | null | undefined; + placeholder?: string; + /** Override the sort key (e.g. an ISO date behind a "3 days ago" label). */ + sortBy?: (row: T) => SortValue; + }, +): DataTableColumn { + return base(o, { + align: "left", + nowrap: false, + fit: false, + sortValue: o.sortBy ?? ((r) => o.get(r) ?? undefined), + sortFn: "alphanumeric", + renderCell: (r) => ( + + {o.get(r) || (o.placeholder ?? "-")} + + ), + }); +} + +function number( + o: Common & { + get: (row: T) => number | null | undefined; + format?: (n: number, row: T) => string; + placeholder?: string; + /** Override the sort key (e.g. a raw count behind a formatted label). */ + sortBy?: (row: T) => SortValue; + }, +): DataTableColumn { + return base(o, { + align: "right", + nowrap: true, + fit: false, + sortValue: o.sortBy ?? ((r) => o.get(r) ?? undefined), + sortFn: "basic", + renderCell: (r) => { + const n = o.get(r); + if (n == null) { + return ( + + {o.placeholder ?? "-"} + + ); + } + return ( + + {o.format ? o.format(n, r) : String(n)} + + ); + }, + }); +} + +function badge( + o: Common & { + get: (row: T) => { tone: StatusTone; label: string }; + sortBy?: (row: T) => SortValue; + }, +): DataTableColumn { + return base(o, { + align: "left", + nowrap: true, + fit: false, + sortValue: o.sortBy ?? ((r) => o.get(r).label), + sortFn: "alphanumeric", + renderCell: (r) => { + const b = o.get(r); + return ( + + {b.label} + + ); + }, + }); +} + +/** + * A user-defined label, rendered as a dot-less pill. Use this ONLY for labels + * that come from data / the user (e.g. a document's classification). Values from + * a fixed set we define (types, environments, providers) are `text`, not pills. + */ +export interface CellLabel { + label: string; + accent?: ChipAccent; +} + +function labels( + o: Common & { get: (row: T) => CellLabel[] }, +): DataTableColumn { + return base(o, { + align: "left", + nowrap: false, + fit: false, + sortValue: (r) => o.get(r)[0]?.label ?? undefined, + sortFn: "alphanumeric", + renderCell: (r) => ( +
+ {o.get(r).map((l) => ( + + {l.label} + + ))} +
+ ), + }); +} + +/** + * An interactive capability chip: click to grant, remove to revoke, dashed to + * offer adding. A functional cell (it toggles state), distinct from static + * `labels`. + */ +export interface CellCap { + label: string; + accent?: ChipAccent; + onClick?: () => void; + onRemove?: () => void; + dashed?: boolean; +} + +function caps( + o: Common & { get: (row: T) => CellCap[] }, +): DataTableColumn { + return base(o, { + align: "left", + nowrap: false, + fit: false, + interactive: true, + renderCell: (r) => ( +
+ {o.get(r).map((c) => ( + + {c.label} + + ))} +
+ ), + }); +} + +function entity( + o: Common & { + /** Semantic leading icon (component owns its size + colour container). */ + icon?: (row: T) => ReactNode; + primary: (row: T) => string; + /** Muted inline suffix after the name, its own node (e.g. "(you)"). */ + suffix?: (row: T) => string | null | undefined; + /** Secondary muted line under the name. */ + note?: (row: T) => string | null | undefined; + sortBy?: (row: T) => SortValue; + }, +): DataTableColumn { + return base(o, { + align: "left", + nowrap: false, + fit: false, + sortValue: o.sortBy ?? ((r) => o.primary(r)), + sortFn: "alphanumeric", + renderCell: (r) => { + const icon = o.icon?.(r); + const suffix = o.suffix?.(r); + const note = o.note?.(r); + return ( +
+ {icon != null && ( + + {icon} + + )} +
+ + {o.primary(r)} + {suffix && ( + {suffix} + )} + + {note && {note}} +
+
+ ); + }, + }); +} + +function actions(o: { + key: string; + header?: ReactNode; + get: (row: T) => CellAction[]; +}): DataTableColumn { + return { + key: o.key, + header: o.header ?? "", + align: "right", + nowrap: true, + fit: true, + sortable: false, + interactive: true, + renderCell: (r) => renderCellActions(o.get(r)), + }; +} + +function progress( + o: Common & { + get: (row: T) => { value: number; label?: string }; + /** Accessible name for the bar (it has no visible text). Defaults to the + * shown percent; pass a description like "Load for us-east-1" when useful. */ + ariaLabel?: (row: T) => string; + }, +): DataTableColumn { + return base(o, { + align: "left", + nowrap: true, + fit: false, + sortValue: (r) => o.get(r).value, + sortFn: "basic", + renderCell: (r) => { + const p = o.get(r); + const shown = p.label ?? `${Math.round(p.value * 100)}%`; + return ( +
+ + + + {shown} +
+ ); + }, + }); +} + +function links(o: { + key: string; + header?: ReactNode; + get: (row: T) => CellLink[]; +}): DataTableColumn { + return { + key: o.key, + header: o.header ?? "", + align: "right", + nowrap: true, + fit: true, + sortable: false, + interactive: true, + renderCell: (r) => ( +
+ {o.get(r).map((l) => ( + + {l.label} + + ))} +
+ ), + }; +} + +function select(o: { + key: string; + header: ReactNode; + get: (row: T) => { + value?: string | null; + defaultValue?: string; + options: SelectOption[]; + ariaLabel?: string; + disabled?: boolean; + }; + /** Omit for an uncontrolled select (local UI state only). */ + onChange?: (row: T, value: string | null) => void; +}): DataTableColumn { + return { + key: o.key, + header: o.header, + align: "left", + nowrap: true, + fit: false, + sortable: false, + interactive: true, + renderCell: (r) => { + const s = o.get(r); + const change = o.onChange; + return ( +
+ onChangeRole(m, (value ?? m.role) as RoleId)} - /> -
- )} + const groups = useMemo[]>(() => { + function ownerNames(owners: string[]): string { + return owners.map((u) => nameByUsername.get(u) ?? u).join(", "); + } + // A team whose name/membership is system-managed - no rename/delete. + function isManagedTeam(team: TeamGroup): boolean { + return SYSTEM_TEAMS.has(team.name) || team.isPersonal === true; + } + function teamKebabHasItems(team: TeamGroup): boolean { + return ( + capabilities.manageGrants || + (!isManagedTeam(team) && + (capabilities.renameTeam || capabilities.deleteTeam)) + ); + } + function teamActions(team: TeamGroup): CellAction[] { + const acts: CellAction[] = [ + { + label: t("users.group.addToTeam", "Add to team"), + onClick: () => onAddToTeam(team), + }, + ]; + if (teamKebabHasItems(team)) { + const items: CellMenuItem[] = []; + if (capabilities.manageGrants) { + items.push( + processorTeamIds.has(team.id) + ? { + label: t( + "users.team.revokeProcessor", + "Revoke Processor from team", + ), + onClick: () => onRevokeTeamProcessor(team), + } + : { + label: t( + "users.team.grantProcessor", + "Grant Processor to team", + ), + onClick: () => onGrantTeamProcessor(team), + }, + ); + } + if (!isManagedTeam(team)) { + const divider = capabilities.manageGrants; + if (capabilities.renameTeam) { + items.push({ + label: t("users.action.rename", "Rename team"), + onClick: () => onRenameTeam(team), + dividerBefore: divider, + }); + } + if (capabilities.deleteTeam) { + items.push({ + label: t("users.action.deleteTeam", "Delete team"), + tone: "danger", + onClick: () => onDeleteTeam(team), + dividerBefore: divider && !capabilities.renameTeam, + }); + } + } + acts.push({ + label: t("users.teamActions", "Team actions"), + glyph: "kebab", + iconOnly: true, + menu: items, + }); + } + return acts; + } - {rowKebab(m)} - - ); - } - - /** Rows for a group, collapsing past COLLAPSED_LIMIT behind a toggle. */ - function renderMembers(list: Member[], key: string) { - const isOpen = expanded.has(key); - const overflow = list.length > COLLAPSED_LIMIT; - const shown = overflow && !isOpen ? list.slice(0, COLLAPSED_LIMIT) : list; - return ( - <> - {shown.map(renderRow)} - {overflow && ( - - )} - - ); - } + const gs: DataTableGroup[] = []; + if (capabilities.orgGroup && dir.organization.length > 0) { + gs.push({ + key: "org", + title: t("users.group.org", "Organization"), + meta: t("users.group.owners", "{{count}} owner", { + count: dir.organization.length, + }), + rows: dir.organization, + collapseAfter: COLLAPSED_LIMIT, + }); + } + for (const team of dir.teams) { + const led = + team.owners.length > 0 + ? ` · ${t("users.group.ledBy", "led by {{owner}}", { + owner: ownerNames(team.owners), + })}` + : ""; + gs.push({ + key: `team-${team.id}`, + title: t("users.group.team", "{{name}} team", { name: team.name }), + meta: + t("users.group.teamMeta", "{{count}} people", { + count: team.members.length, + }) + led, + actions: teamActions(team), + rows: team.members, + collapseAfter: COLLAPSED_LIMIT, + }); + } + if (showGuests && dir.guests.length > 0) { + gs.push({ + key: "guests", + title: t("users.group.guests", "Guests"), + meta: t("users.group.guestCount", "{{count}} guest", { + count: dir.guests.length, + }), + rows: dir.guests, + collapseAfter: COLLAPSED_LIMIT, + }); + } + return gs; + }, [ + t, + dir, + nameByUsername, + capabilities, + showGuests, + processorTeamIds, + onAddToTeam, + onGrantTeamProcessor, + onRevokeTeamProcessor, + onRenameTeam, + onDeleteTeam, + ]); return ( -
- {/* Organization (a single-org deployment only; SaaS has no org). */} - {capabilities.orgGroup && dir.organization.length > 0 && ( -
-
-
- {t("users.group.org", "Organization")} - - {t( - "users.group.orgDesc", - "Owners with org-wide authority and policy approval", - )} - -
- - {t("users.group.owners", "{{count}} owner", { - count: dir.organization.length, - })} - -
- {renderMembers(dir.organization, "org")} -
- )} - - {/* Teams */} - {dir.teams.map((team) => ( -
-
-
- - {t("users.group.team", "{{name}} team", { name: team.name })} - - - {t("users.group.teamMeta", "{{count}} people", { - count: team.members.length, - })} - {team.owners.length > 0 && - ` · ${t("users.group.ledBy", "led by {{owner}}", { - owner: ownerNames(team.owners), - })}`} - -
-
- - {teamKebabHasItems(team) && ( - - - - - - {capabilities.manageGrants && - (processorTeamIds.has(team.id) ? ( - onRevokeTeamProcessor(team)}> - {t( - "users.team.revokeProcessor", - "Revoke Processor from team", - )} - - ) : ( - onGrantTeamProcessor(team)}> - {t( - "users.team.grantProcessor", - "Grant Processor to team", - )} - - ))} - {!isManagedTeam(team) && - (capabilities.renameTeam || capabilities.deleteTeam) && ( - <> - {capabilities.manageGrants && } - {capabilities.renameTeam && ( - onRenameTeam(team)}> - {t("users.action.rename", "Rename team")} - - )} - {capabilities.deleteTeam && ( - onDeleteTeam(team)} - > - {t("users.action.deleteTeam", "Delete team")} - - )} - - )} - - - )} -
-
- {renderMembers(team.members, `team-${team.id}`)} -
- ))} - - {/* Guests (parked in the live app; shown when showGuests is set). */} - {showGuests && dir.guests.length > 0 && ( -
-
-
- {t("users.group.guests", "Guests")} - - {t( - "users.group.guestsDesc", - "External collaborators, scoped to what you shared. Editor only.", - )} - -
- - {t("users.group.guestCount", "{{count}} guest", { - count: dir.guests.length, - })} - -
- {renderMembers(dir.guests, "guests")} -
- )} -
+ + columns={columns} + groups={groups} + rowKey={(m) => String(m.id)} + collapseLabels={{ + showAll: (count) => t("users.showAll", "Show all {{count}}", { count }), + showLess: t("users.showLess", "Show less"), + }} + /> ); } diff --git a/frontend/editor/src/portal/views/Integrations.test.tsx b/frontend/editor/src/portal/views/Integrations.test.tsx index c5e96d8666..8c4e684bfa 100644 --- a/frontend/editor/src/portal/views/Integrations.test.tsx +++ b/frontend/editor/src/portal/views/Integrations.test.tsx @@ -68,39 +68,35 @@ describe("Integrations view", () => { ).toBeInTheDocument(); }); - it("groups connections of the same type and expands to the instances", async () => { + it("groups connections of the same type, instances shown as rows (no expand)", async () => { fetchIntegrations.mockResolvedValue([ bucket(1, "Claims"), bucket(2, "Archive"), ]); render(); - // One connected group row for S3 with the instance count, not two rows. - const group = await screen.findByText( - "portal.integrations.connectionCount", - ); - expect(group).toBeInTheDocument(); - - fireEvent.click(screen.getByText("portal.connections.types.s3.label")); + // Instances are rows directly under the S3 vendor group - no expand click. expect(await screen.findByText("Claims")).toBeInTheDocument(); expect(screen.getByText("Archive")).toBeInTheDocument(); + // Vendor group header shows the instance count and the "add another" action. expect( - screen.getByText("portal.integrations.addAnother"), + screen.getByText("portal.integrations.connectionCount"), ).toBeInTheDocument(); + // Each connected vendor group offers a Connect action (to add another). + expect( + screen.getAllByText("portal.integrations.connect").length, + ).toBeGreaterThan(0); // The available band remains for the other, unconnected vendors. expect( screen.getByText(/portal\.integrations\.availableHeading/), ).toBeInTheDocument(); }); - it("deletes an instance from the expanded group", async () => { + it("deletes an instance directly from its row", async () => { fetchIntegrations.mockResolvedValueOnce([bucket(5, "Claims")]); fetchIntegrations.mockResolvedValueOnce([]); render(); - fireEvent.click( - await screen.findByText("portal.connections.types.s3.label"), - ); fireEvent.click(await screen.findByText("portal.connections.delete")); await waitFor(() => expect(deleteIntegration).toHaveBeenCalledWith(5)); @@ -115,9 +111,6 @@ describe("Integrations view", () => { ); render(); - fireEvent.click( - await screen.findByText("portal.connections.types.s3.label"), - ); fireEvent.click(await screen.findByText("portal.connections.delete")); expect( diff --git a/frontend/editor/src/portal/views/Integrations.tsx b/frontend/editor/src/portal/views/Integrations.tsx index 993583a364..793c21f880 100644 --- a/frontend/editor/src/portal/views/Integrations.tsx +++ b/frontend/editor/src/portal/views/Integrations.tsx @@ -2,8 +2,15 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import AddRoundedIcon from "@mui/icons-material/AddRounded"; import SearchRoundedIcon from "@mui/icons-material/SearchRounded"; -import ExpandMoreRoundedIcon from "@mui/icons-material/ExpandMoreRounded"; -import { Banner, Button, Skeleton } from "@app/ui"; +import { + Banner, + Button, + column, + DataTable, + type DataTableColumn, + type DataTableGroup, + EmptyState, +} from "@app/ui"; import { errorMessage } from "@portal/api/http"; import { deleteIntegration, @@ -30,12 +37,11 @@ import "@portal/views/Integrations.css"; /** * The integrations catalogue: everything Stirling can talk to, in one place. * - * Three bands in one list. Connected first — stored connections grouped by - * vendor, expandable when a vendor has several (two S3 buckets is normal, not - * an error), each instance editable and one click from "add another". Then - * Available — the supported vendors, each saying what it works with (sources, - * policies, pipelines) so it's obvious whether a vendor feeds documents in or - * receives them. Coming-soon source connectors close the list greyed out, so + * Three bands, one grouped table. Connected first - stored connections grouped + * by vendor (two S3 buckets is normal, not an error), every instance a row you + * can edit or remove, with "add another" on the vendor's group header. Then + * Available - the supported vendors, each saying what it works with (sources, + * policies, pipelines). Coming-soon source connectors close the list so * "do you support X?" is answered honestly instead of hidden. * * Setup itself stays in the shared {@link ConnectionModal}; every entry point @@ -72,6 +78,20 @@ interface TypeGroup { connections: IntegrationConfig[]; } +/** One normalized row across the three bands, so a single grouped table renders + * connected instances, available vendors, and coming-soon vendors alike. */ +type IntegrationRow = { + key: string; + brandId: string; + title: string; + subtitle: string; + worksWith: WorksWith[]; +} & ( + | { kind: "instance"; connection: IntegrationConfig; canManage: boolean } + | { kind: "available"; typeId: string } + | { kind: "soon" } +); + export function Integrations() { const { t } = useTranslation(); const [connections, setConnections] = useState( @@ -82,13 +102,13 @@ export function Integrations() { >(undefined); const [filter, setFilter] = useState("all"); const [query, setQuery] = useState(""); - const [expanded, setExpanded] = useState>(new Set()); const [modal, setModal] = useState<{ open: boolean; editing: IntegrationConfig | null; fixedTypeId?: string; }>({ open: false, editing: null }); const [busy, setBusy] = useState(false); + const [deletingId, setDeletingId] = useState(null); const [error, setError] = useState(null); const refresh = useCallback(async () => { @@ -192,45 +212,152 @@ export function Integrations() { return counts; }, [catalogue]); - function toggleExpand(typeId: string) { - setExpanded((current) => { - const next = new Set(current); - if (next.has(typeId)) next.delete(typeId); - else next.add(typeId); - return next; - }); - } - - function openCreate(typeId: string) { + const openCreate = useCallback((typeId: string) => { setModal({ open: true, editing: null, fixedTypeId: typeId }); - } + }, []); - function openEdit(connection: IntegrationConfig) { + const openEdit = useCallback((connection: IntegrationConfig) => { setModal({ open: true, editing: connection }); - } + }, []); - async function remove(connection: IntegrationConfig) { - if (busy) return; - setBusy(true); - setError(null); - try { - await deleteIntegration(connection.id); - await refresh(); - } catch (e) { - setError(errorMessage(e)); - } finally { - setBusy(false); - } - } + const remove = useCallback( + async (connection: IntegrationConfig) => { + if (busy) return; + setBusy(true); + setDeletingId(connection.id); + setError(null); + try { + await deleteIntegration(connection.id); + await refresh(); + } catch (e) { + setError(errorMessage(e)); + } finally { + setBusy(false); + setDeletingId(null); + } + }, + [busy, refresh], + ); const isLoading = connections === null; - const chip = (kind: WorksWith) => ( - - {t(`portal.integrations.worksWith.${kind}`)} - + const worksWithText = useCallback( + (list: WorksWith[]) => + list.map((w) => t(`portal.integrations.worksWith.${w}`)).join(", "), + [t], ); + const columns = useMemo[]>( + () => [ + column.entity({ + key: "integration", + header: t("portal.integrations.table.integration"), + icon: (r) => , + primary: (r) => r.title, + note: (r) => r.subtitle || undefined, + }), + column.text({ + key: "worksWith", + header: t("portal.integrations.table.worksWith"), + get: (r) => worksWithText(r.worksWith), + }), + column.actions({ + key: "actions", + get: (r) => { + if (r.kind === "instance") { + return r.canManage + ? [ + { + label: t("portal.connections.edit"), + disabled: busy, + onClick: () => openEdit(r.connection), + }, + { + label: t("portal.connections.delete"), + tone: "danger", + loading: busy && deletingId === r.connection.id, + disabled: busy, + onClick: () => void remove(r.connection), + }, + ] + : []; + } + if (r.kind === "available") { + return [ + { + label: t("portal.integrations.connect"), + onClick: () => openCreate(r.typeId), + }, + ]; + } + return []; + }, + }), + ], + [t, busy, deletingId, remove, openEdit, openCreate, worksWithText], + ); + + const tableGroups = useMemo[]>(() => { + const gs: DataTableGroup[] = []; + for (const { type, connections: list } of connectedGroups) { + gs.push({ + key: `connected-${type.id}`, + title: t(type.labelKey), + meta: + list.length > 1 + ? t("portal.integrations.connectionCount", { count: list.length }) + : t("portal.integrations.status.connected"), + actions: [ + { + label: t("portal.integrations.connect"), + onClick: () => openCreate(type.id), + }, + ], + rows: list.map((c) => ({ + kind: "instance" as const, + key: `i-${c.id}`, + brandId: type.id, + title: c.name, + subtitle: connectionDetail(c), + worksWith: worksWith(type), + connection: c, + canManage: !!c.canManage, + })), + }); + } + if (availableTypes.length > 0) { + gs.push({ + key: "available", + title: t("portal.integrations.availableHeading"), + rows: availableTypes.map((type) => ({ + kind: "available" as const, + key: `a-${type.id}`, + brandId: type.id, + title: t(type.labelKey), + subtitle: t(type.descriptionKey), + worksWith: worksWith(type), + typeId: type.id, + })), + }); + } + if (comingSoon.length > 0) { + gs.push({ + key: "soon", + title: t("portal.integrations.comingSoonHeading"), + muted: true, + rows: comingSoon.map((entry) => ({ + kind: "soon" as const, + key: `s-${entry.type}`, + brandId: entry.type, + title: t(entry.labelKey), + subtitle: t(entry.descriptionKey), + worksWith: ["sources"], + })), + }); + } + return gs; + }, [connectedGroups, availableTypes, comingSoon, t, openCreate]); + return (
@@ -301,187 +428,23 @@ export function Integrations() { {error && } - {isLoading ? ( -
- {Array.from({ length: 4 }).map((_, i) => ( - - ))} -
+ {!isLoading && tableGroups.length === 0 ? ( + ) : ( -
-
- {t("portal.integrations.table.integration")} - {t("portal.integrations.table.worksWith")} - -
- - {connectedGroups.length > 0 && ( -
- {t("portal.integrations.connectedHeading")} ·{" "} - {connectedGroups.length} -
- )} - {connectedGroups.map(({ type, connections: list }) => { - const open = expanded.has(type.id); - return ( -
- - {open && ( -
- {list.map((connection) => ( -
- - {connection.name} - - - {connectionDetail(connection)} - - {connection.canManage && ( - - - - - )} -
- ))} -
- -
-
- )} -
- ); - })} - - {availableTypes.length > 0 && ( -
- {t("portal.integrations.availableHeading")} ·{" "} - {availableTypes.length} -
- )} - {availableTypes.map((type) => ( -
- - - - - {t(type.labelKey)} - - - {t(type.descriptionKey)} - - - - - {worksWith(type).map(chip)} - - - - -
- ))} - - {comingSoon.length > 0 && ( -
- {t("portal.integrations.comingSoonHeading")} · {comingSoon.length} -
- )} - {comingSoon.map((entry) => ( -
- - - - - {t(entry.labelKey)} - - - {t(entry.descriptionKey)} - - - - - {chip("sources")} - - - - {t("portal.sources.builder.comingSoon")} - - -
- ))} -
+ + columns={columns} + groups={tableGroups} + rowKey={(r) => r.key} + loading={isLoading} + skeletonRows={5} + /> )} DELETE /invitations/{id} -> refetch drops the invite. fireEvent.click( diff --git a/frontend/editor/src/portal/views/Users.tsx b/frontend/editor/src/portal/views/Users.tsx index 535bf3e627..79c6c11611 100644 --- a/frontend/editor/src/portal/views/Users.tsx +++ b/frontend/editor/src/portal/views/Users.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useSearchParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { Button, EmptyState, Skeleton } from "@app/ui"; @@ -71,12 +71,14 @@ export function Users() { }, [searchParams, setSearchParams]); // Scroll to and flash the row for ?member= (deep link from the super - // search), once the roster has rendered; then strip the param. + // search), once the roster has rendered; then strip the param. Scoped to the + // roster so a pending-invitation row sharing the id can't match first. + const rosterRef = useRef(null); useEffect(() => { const memberId = searchParams.get("member"); if (memberId === null || usersState.loading) return; - const row = document.querySelector( - `[data-member-id="${CSS.escape(memberId)}"]`, + const row = rosterRef.current?.querySelector( + `[data-row-key="${CSS.escape(memberId)}"]`, ); if (row) { row.scrollIntoView({ block: "center" }); @@ -343,28 +345,30 @@ export function Users() { )} {!loading && members.length > 0 && ( - openInvite(team.id)} - onResetPassword={setResetPwMember} - onMoveToTeam={setMoveMember} - onToggleEnabled={toggleEnabled} - onUnlock={unlock} - onDisableMfa={disableMfa} - onRemove={removeUser} - onRenameTeam={(team) => - setRenameTarget({ id: team.id, name: team.name }) - } - onDeleteTeam={deleteTeamAction} - /> +
+ openInvite(team.id)} + onResetPassword={setResetPwMember} + onMoveToTeam={setMoveMember} + onToggleEnabled={toggleEnabled} + onUnlock={unlock} + onDisableMfa={disableMfa} + onRemove={removeUser} + onRenameTeam={(team) => + setRenameTarget({ id: team.id, name: team.name }) + } + onDeleteTeam={deleteTeamAction} + /> +
)} =20" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=18" + } + }, "node_modules/@tanstack/react-virtual": { "version": "3.13.23", "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.23.tgz", @@ -4953,6 +4992,32 @@ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/@tanstack/store": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.11.1.tgz", + "integrity": "sha512-mzTOBhypOuDJAy/D8n2MfUZ1HFkXnmSETviRyhqEC8LUE7/IZQExOTxMANj3KjTofYTkFNpBY67qaVrT41YccA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/table-core": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-9.1.2.tgz", + "integrity": "sha512-ONpWQeass1sfg80CWF1NSwQ8r3GiqxA2lT/EdqIcrDEPZ0Z+0mM94eQoFYLPN0Kztzj8TQVb2+PrSZSItqA61g==", + "license": "MIT", + "dependencies": { + "@tanstack/store": "^0.11.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tanstack/virtual-core": { "version": "3.13.23", "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.23.tgz", diff --git a/frontend/package.json b/frontend/package.json index 3fd1f8e106..90a0e10b05 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -47,6 +47,7 @@ "@supabase/supabase-js": "^2.47.13", "@tailwindcss/postcss": "^4.1.13", "@tanstack/react-query": "^5.101.4", + "@tanstack/react-table": "^9.1.2", "@tanstack/react-virtual": "^3.13.12", "@tauri-apps/api": "^2.10.1", "@tauri-apps/plugin-dialog": "2.7.0", From 526bb85e17e5224f264af8d891399675291268f1 Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:00:35 +0000 Subject: [PATCH 17/23] Translate the failures debug panel strings (#7500) Follow-up to #7296, addressing a missing translation. --- .../public/locales/en-US/translation.toml | 8 ++++++++ .../components/failures/FileRunEventList.tsx | 18 ++++++++++++++---- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 99d68c35de..70d3d03bf4 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -7372,6 +7372,14 @@ confirm = "Are you sure?" dismiss = "Dismiss" dismissSkipFile = "Skip this file" +[portal.failures.debug] +copyJson = "Copy JSON" +dismissAll = "Dismiss all ({{total}})" +dismissing = "Dismissing..." +hideJson = "Hide raw JSON ({{total}})" +refresh = "Refresh failures" +showJson = "Show raw JSON ({{total}})" + [portal.failures.disabled] closed = "This failure is already closed." unavailable = "Not available for this failure." diff --git a/frontend/editor/src/portal/components/failures/FileRunEventList.tsx b/frontend/editor/src/portal/components/failures/FileRunEventList.tsx index 477af32e50..2330546655 100644 --- a/frontend/editor/src/portal/components/failures/FileRunEventList.tsx +++ b/frontend/editor/src/portal/components/failures/FileRunEventList.tsx @@ -69,7 +69,7 @@ export function FileRunEventList() { const debugPanel = !import.meta.env.DEV ? null : (
{showJson && (

From 89d8ffec5d1266251b7feeb05a3d998a6d5c747f Mon Sep 17 00:00:00 2001
From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
Date: Mon, 17 Aug 2026 17:49:13 +0000
Subject: [PATCH 18/23] Ci/environments cleanups, new envs and master to
 release naming (#7511)

# Description of Changes

Ci/environments cleanups, new envs and master to release naming

---

## 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/config/.files.yaml                    |   1 -
 .github/workflows/PR-Demo-cleanup.yml         |   4 -
 .github/workflows/ai_pr_title_review.yml      | 221 ----------------
 .github/workflows/backend-build.yml           |   1 +
 .github/workflows/build-enterprise.yml        |   2 +
 .github/workflows/build.yml                   |   1 +
 .github/workflows/check-licence.yml           |   1 +
 .github/workflows/check-openapi.yml           |   1 +
 .github/workflows/db-migration-test.yml       |   1 +
 .github/workflows/deploy-on-v2-commit.yml     | 209 ----------------
 .github/workflows/docker-compose-tests.yml    |   1 +
 .github/workflows/e2e-live.yml                |   1 +
 .../frontend-backend-licenses-update.yml      |   4 +
 .github/workflows/multiOSReleases.yml         |  63 ++---
 .github/workflows/nightly.yml                 |   1 +
 .github/workflows/push-docker-base.yml        |   3 +
 .github/workflows/push-docker.yml             |  29 ++-
 .github/workflows/swagger.yml                 |   5 +-
 .github/workflows/tauri-build.yml             |  42 +---
 .github/workflows/test-build-docker.yml       |   1 +
 .github/workflows/testdriver.yml              | 235 ------------------
 WINDOWS_SIGNING.md                            |  71 +++---
 .../editor/src/core/services/updateService.ts |   2 +-
 23 files changed, 95 insertions(+), 805 deletions(-)
 delete mode 100644 .github/workflows/ai_pr_title_review.yml
 delete mode 100644 .github/workflows/deploy-on-v2-commit.yml
 delete mode 100644 .github/workflows/testdriver.yml

diff --git a/.github/config/.files.yaml b/.github/config/.files.yaml
index 70a964b020..b5cc0527b0 100644
--- a/.github/config/.files.yaml
+++ b/.github/config/.files.yaml
@@ -68,7 +68,6 @@ project: &project
 frontend: &frontend
   - *ci
   - frontend/**
-  - .github/workflows/testdriver.yml
   - testing/**
   - docker/**
   - scripts/translations/*.py
diff --git a/.github/workflows/PR-Demo-cleanup.yml b/.github/workflows/PR-Demo-cleanup.yml
index e0032955e3..1407939994 100644
--- a/.github/workflows/PR-Demo-cleanup.yml
+++ b/.github/workflows/PR-Demo-cleanup.yml
@@ -7,10 +7,6 @@ on:
 permissions:
   contents: read
 
-env:
-  SERVER_IP: ${{ secrets.NEW_VPS_IP }} # Add this to your GitHub secrets
-  CLEANUP_PERFORMED: "false" # Add flag to track if cleanup occurred
-
 jobs:
   cleanup:
     environment: pr-preview
diff --git a/.github/workflows/ai_pr_title_review.yml b/.github/workflows/ai_pr_title_review.yml
deleted file mode 100644
index b9b391af0e..0000000000
--- a/.github/workflows/ai_pr_title_review.yml
+++ /dev/null
@@ -1,221 +0,0 @@
-name: AI - PR Title Review
-
-on:
-  pull_request:
-    types: [opened, edited]
-    branches: [main]
-
-permissions: # required for secure-repo hardening
-  contents: read
-
-jobs:
-  ai-title-review:
-    # GITHUB_TOKEN obeys this block, so it must cover every API call made below.
-    permissions:
-      contents: read # actions/checkout, git fetch/diff
-      issues: write # issues.listComments / createComment / updateComment on the PR
-      pull-requests: write # same endpoints when the target is a pull request
-      models: read # actions/ai-inference
-
-    runs-on: ubuntu-latest
-
-    steps:
-      - name: Harden Runner
-        uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
-        with:
-          egress-policy: audit
-
-      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
-        with:
-          fetch-depth: 0
-
-      - name: Configure Git to suppress detached HEAD warning
-        run: git config --global advice.detachedHead false
-
-      - name: Check if actor is repo developer
-        id: actor
-        run: |
-          if [[ "${{ github.actor }}" == *"[bot]" ]]; then
-            echo "PR opened by a bot – skipping AI title review."
-            echo "is_repo_dev=false" >> $GITHUB_OUTPUT
-            exit 0
-          fi
-          if [ ! -f .github/config/repo_devs.json ]; then
-            echo "Error: .github/config/repo_devs.json not found" >&2
-            exit 1
-          fi
-          # Validate JSON and extract repo_devs
-          REPO_DEVS=$(jq -r '.repo_devs[]' .github/config/repo_devs.json 2>/dev/null || { echo "Error: Invalid JSON in repo_devs.json" >&2; exit 1; })
-          # Convert developer list into Bash array
-          mapfile -t DEVS_ARRAY <<< "$REPO_DEVS"
-          if [[ " ${DEVS_ARRAY[*]} " == *" ${{ github.actor }} "* ]]; then
-            echo "is_repo_dev=true" >> $GITHUB_OUTPUT
-          else
-            echo "is_repo_dev=false" >> $GITHUB_OUTPUT
-          fi
-
-      - name: Get PR diff
-        if: steps.actor.outputs.is_repo_dev == 'true'
-        id: get_diff
-        run: |
-          git fetch origin ${{ github.base_ref }}
-          git diff origin/${{ github.base_ref }}...HEAD | head -n 10000 | grep -vP '[\x00-\x08\x0B\x0C\x0E-\x1F\x7F\x{202E}\x{200B}]' > pr.diff
-          echo "diff<> $GITHUB_OUTPUT
-          cat pr.diff >> $GITHUB_OUTPUT
-          echo "EOF" >> $GITHUB_OUTPUT
-
-      - name: Check and sanitize PR title
-        if: steps.actor.outputs.is_repo_dev == 'true'
-        id: sanitize_pr_title
-        env:
-          PR_TITLE_RAW: ${{ github.event.pull_request.title }}
-        run: |
-          # Sanitize PR title: max 72 characters, only printable characters
-          PR_TITLE=$(echo "$PR_TITLE_RAW" | tr -d '\n\r' | head -c 72 | sed 's/[^[:print:]]//g')
-          if [[ ${#PR_TITLE} -lt 5 ]]; then
-            echo "PR title is too short. Must be at least 5 characters." >&2
-          fi
-          echo "pr_title=$PR_TITLE" >> $GITHUB_OUTPUT
-
-      - name: AI PR Title Analysis
-        if: steps.actor.outputs.is_repo_dev == 'true'
-        id: ai-title-analysis
-        uses: actions/ai-inference@a7805884c80886efc241e94a5351df715968a0ad # v2.1.1
-        with:
-          model: openai/gpt-4o
-          system-prompt-file: ".github/config/system-prompt.txt"
-          prompt: |
-            Based on the following input data:
-
-            {
-              "diff": "${{ steps.get_diff.outputs.diff }}",
-              "pr_title": "${{ steps.sanitize_pr_title.outputs.pr_title }}"
-            }
-
-            Respond ONLY with valid JSON in the format:
-            {
-              "improved_rating": <0-10>,
-              "improved_ai_title_rating": <0-10>,
-              "improved_title": ""
-            }
-
-      - name: Validate and set SCRIPT_OUTPUT
-        if: steps.actor.outputs.is_repo_dev == 'true'
-        run: |
-          cat < ai_response.json
-          ${{ steps.ai-title-analysis.outputs.response }}
-          EOF
-
-          # Validate JSON structure
-          jq -e '
-            (keys | sort) == ["improved_ai_title_rating", "improved_rating", "improved_title"] and
-            (.improved_rating | type == "number" and . >= 0 and . <= 10) and
-            (.improved_ai_title_rating | type == "number" and . >= 0 and . <= 10) and
-            (.improved_title | type == "string")
-          ' ai_response.json
-          if [ $? -ne 0 ]; then
-            echo "Invalid AI response format" >&2
-            cat ai_response.json >&2
-            exit 1
-          fi
-          # Parse JSON fields
-          IMPROVED_RATING=$(jq -r '.improved_rating' ai_response.json)
-          IMPROVED_TITLE=$(jq -r '.improved_title' ai_response.json)
-          # Limit comment length to 1000 characters
-          COMMENT=$(cat < /tmp/ai-title-comment.md
-          # Log input and output to the GitHub Step Summary
-          echo "### 🤖 AI PR Title Analysis" >> $GITHUB_STEP_SUMMARY
-          echo "### Input PR Title" >> $GITHUB_STEP_SUMMARY
-          echo '```bash' >> $GITHUB_STEP_SUMMARY
-          echo "${{ steps.sanitize_pr_title.outputs.pr_title }}" >> $GITHUB_STEP_SUMMARY
-          echo '```' >> $GITHUB_STEP_SUMMARY
-          echo '### AI Response (raw JSON)' >> $GITHUB_STEP_SUMMARY
-          echo '```json' >> $GITHUB_STEP_SUMMARY
-          cat ai_response.json >> $GITHUB_STEP_SUMMARY
-          echo '```' >> $GITHUB_STEP_SUMMARY
-
-      - name: Post comment on PR if needed
-        if: steps.actor.outputs.is_repo_dev == 'true'
-        uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
-        continue-on-error: true
-        with:
-          github-token: ${{ github.token }}
-          script: |
-            const fs = require('fs');
-            const body = fs.readFileSync('/tmp/ai-title-comment.md', 'utf8');
-            const { GITHUB_REPOSITORY } = process.env;
-            const [owner, repo] = GITHUB_REPOSITORY.split('/');
-            const issue_number = context.issue.number;
-
-            const ratingMatch = body.match(/\*\*PR-Title Rating\*\*: (\d+)\/10/);
-            const rating = ratingMatch ? parseInt(ratingMatch[1], 10) : null;
-
-            const expectedActor = "github-actions[bot]";
-            const comments = await github.rest.issues.listComments({ owner, repo, issue_number });
-
-            const existing = comments.data.find(c =>
-              c.user?.login === expectedActor &&
-              c.body.includes("## 🤖 AI PR Title Suggestion")
-            );
-
-            if (rating === null) {
-              console.log("No rating found in AI response – skipping.");
-              return;
-            }
-
-            if (rating <= 5) {
-              if (existing) {
-                await github.rest.issues.updateComment({
-                  owner, repo,
-                  comment_id: existing.id,
-                  body
-                });
-                console.log("Updated existing suggestion comment.");
-              } else {
-                await github.rest.issues.createComment({
-                  owner, repo, issue_number,
-                  body
-                });
-                console.log("Created new suggestion comment.");
-              }
-            } else {
-              const praise = `## 🤖 AI PR Title Suggestion\n\nGreat job! The current PR title is clear and well-structured.\n\n✅ No suggestions needed.\n\n---\n*Generated by GitHub Models AI*`;
-
-              if (existing) {
-                await github.rest.issues.updateComment({
-                  owner, repo,
-                  comment_id: existing.id,
-                  body: praise
-                });
-                console.log("Replaced suggestion with praise.");
-              } else {
-                console.log("Rating > 5 and no existing comment – skipping comment.");
-              }
-            }
-
-      - name: is not repo dev
-        if: steps.actor.outputs.is_repo_dev != 'true'
-        run: |
-          exit 0 # Skip the AI title review for non-repo developers
-
-      - name: Clean up
-        if: always()
-        run: |
-          rm -f pr.diff ai_response.json /tmp/ai-title-comment.md
-          echo "Cleaned up temporary files."
-        continue-on-error: true # Ensure cleanup runs even if previous steps fail
diff --git a/.github/workflows/backend-build.yml b/.github/workflows/backend-build.yml
index 9561833f07..54bd4cb907 100644
--- a/.github/workflows/backend-build.yml
+++ b/.github/workflows/backend-build.yml
@@ -20,6 +20,7 @@ permissions:
 
 jobs:
   build:
+    environment: ci-unsigned
     runs-on: ubuntu-latest
     strategy:
       fail-fast: false
diff --git a/.github/workflows/build-enterprise.yml b/.github/workflows/build-enterprise.yml
index 08849c683b..0604f7176f 100644
--- a/.github/workflows/build-enterprise.yml
+++ b/.github/workflows/build-enterprise.yml
@@ -37,6 +37,7 @@ jobs:
     uses: ./.github/workflows/_runner-pick.yml
 
   playwright-e2e-enterprise:
+    environment: ci-unsigned
     needs: pick
     # Skip on fork PRs / untrusted authors: they have no PREMIUM_KEY_ENTERPRISE,
     # so the suite can't boot premium and would fail. See the header comment.
@@ -309,6 +310,7 @@ jobs:
   # Multi-node regression: builds + seeds the clustered stack (testing/compose/docker-compose-multinode.yml)
   # and runs behave features/multinode. Licence-gated, so it runs after the Playwright job (not in parallel).
   multinode-e2e:
+    environment: ci-unsigned
     needs: [pick, playwright-e2e-enterprise]
     # Nightly cron + manual dispatch only (heavy build), fork-gated for the licence secret.
     if: >-
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index eee07d599a..2f50249099 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -61,6 +61,7 @@ jobs:
           filters: .github/config/.files.yaml
 
   gradle-cache-prime:
+    environment: ci-unsigned
     name: Prime shared Gradle cache
     needs: [files-changed]
     runs-on: ubuntu-latest
diff --git a/.github/workflows/check-licence.yml b/.github/workflows/check-licence.yml
index d45a68e860..2eec970b8f 100644
--- a/.github/workflows/check-licence.yml
+++ b/.github/workflows/check-licence.yml
@@ -10,6 +10,7 @@ permissions:
 
 jobs:
   check-licence:
+    environment: ci-unsigned
     runs-on: ubuntu-latest
     steps:
       - name: Harden Runner
diff --git a/.github/workflows/check-openapi.yml b/.github/workflows/check-openapi.yml
index 751a34a33e..f224ce18cf 100644
--- a/.github/workflows/check-openapi.yml
+++ b/.github/workflows/check-openapi.yml
@@ -11,6 +11,7 @@ permissions:
 
 jobs:
   check-generate-openapi-docs:
+    environment: ci-unsigned
     runs-on: ubuntu-latest
     steps:
       - name: Harden Runner
diff --git a/.github/workflows/db-migration-test.yml b/.github/workflows/db-migration-test.yml
index 4181390285..d6a61b45c4 100644
--- a/.github/workflows/db-migration-test.yml
+++ b/.github/workflows/db-migration-test.yml
@@ -13,6 +13,7 @@ permissions:
 
 jobs:
   migration-test:
+    environment: ci-unsigned
     runs-on: ubuntu-latest
     timeout-minutes: 30
     steps:
diff --git a/.github/workflows/deploy-on-v2-commit.yml b/.github/workflows/deploy-on-v2-commit.yml
deleted file mode 100644
index 01114c64b2..0000000000
--- a/.github/workflows/deploy-on-v2-commit.yml
+++ /dev/null
@@ -1,209 +0,0 @@
-name: Auto V2 Deploy on Push
-
-on:
-  push:
-    branches:
-      - V2
-      - deploy-on-v2-commit
-
-permissions:
-  contents: read
-
-jobs:
-  deploy-v2-on-push:
-    environment: pr-preview
-    runs-on: ubuntu-latest
-    permissions:
-      contents: read
-      packages: write
-    concurrency:
-      group: deploy-v2-push-V2
-      cancel-in-progress: true
-
-    steps:
-      - name: Harden Runner
-        uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
-        with:
-          egress-policy: audit
-
-      - name: Checkout code
-        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
-
-      - name: Set up Docker Buildx
-        uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
-
-      - name: Get commit hashes for frontend and backend
-        id: commit-hashes
-        run: |
-          # Get last commit that touched the frontend folder, docker/frontend, or docker/compose
-          FRONTEND_HASH=$(git log -1 --format="%H" -- frontend/ docker/frontend/ docker/compose/ 2>/dev/null || echo "")
-          if [ -z "$FRONTEND_HASH" ]; then
-            FRONTEND_HASH="no-frontend-changes"
-          fi
-
-          # Get last commit that touched backend code, docker/backend, or docker/compose
-          BACKEND_HASH=$(git log -1 --format="%H" -- app/ docker/backend/ docker/compose/ 2>/dev/null || echo "")
-          if [ -z "$BACKEND_HASH" ]; then
-            BACKEND_HASH="no-backend-changes"
-          fi
-
-          echo "Frontend hash: $FRONTEND_HASH"
-          echo "Backend hash: $BACKEND_HASH"
-
-          echo "frontend_hash=$FRONTEND_HASH" >> $GITHUB_OUTPUT
-          echo "backend_hash=$BACKEND_HASH" >> $GITHUB_OUTPUT
-
-          # Short hashes for tags
-          if [ "$FRONTEND_HASH" = "no-frontend-changes" ]; then
-            echo "frontend_short=no-frontend" >> $GITHUB_OUTPUT
-          else
-            echo "frontend_short=${FRONTEND_HASH:0:8}" >> $GITHUB_OUTPUT
-          fi
-
-          if [ "$BACKEND_HASH" = "no-backend-changes" ]; then
-            echo "backend_short=no-backend" >> $GITHUB_OUTPUT
-          else
-            echo "backend_short=${BACKEND_HASH:0:8}" >> $GITHUB_OUTPUT
-          fi
-
-      - name: Convert repository owner to lowercase
-        id: repoowner
-        run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT
-
-      - 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: Check if frontend image exists
-        id: check-frontend
-        run: |
-          if docker manifest inspect ${IMAGE_BASE}:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }} >/dev/null 2>&1; then
-            echo "exists=true" >> $GITHUB_OUTPUT
-            echo "Frontend image already exists, skipping build"
-          else
-            echo "exists=false" >> $GITHUB_OUTPUT
-            echo "Frontend image needs to be built"
-          fi
-
-        env:
-          IMAGE_BASE: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test
-      - name: Check if backend image exists
-        id: check-backend
-        run: |
-          if docker manifest inspect ${IMAGE_BASE}:v2-backend-${{ steps.commit-hashes.outputs.backend_short }} >/dev/null 2>&1; then
-            echo "exists=true" >> $GITHUB_OUTPUT
-            echo "Backend image already exists, skipping build"
-          else
-            echo "exists=false" >> $GITHUB_OUTPUT
-            echo "Backend image needs to be built"
-          fi
-
-        env:
-          IMAGE_BASE: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test
-
-      - name: Build and push frontend image
-        if: steps.check-frontend.outputs.exists == 'false'
-        uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
-        with:
-          context: .
-          file: ./docker/frontend/Dockerfile
-          push: true
-          cache-from: type=gha,scope=stirling-v2-frontend
-          cache-to: type=gha,mode=max,scope=stirling-v2-frontend
-          tags: |
-            ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }}
-            ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:v2-frontend-latest
-          build-args: VERSION_TAG=v2-alpha
-          platforms: linux/amd64
-
-      - name: Build and push backend image
-        if: steps.check-backend.outputs.exists == 'false'
-        uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
-        with:
-          context: .
-          file: ./docker/backend/Dockerfile
-          push: true
-          cache-from: type=gha,scope=stirling-v2-backend
-          cache-to: type=gha,mode=max,scope=stirling-v2-backend
-          tags: |
-            ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }}
-            ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:v2-backend-latest
-          build-args: VERSION_TAG=v2-alpha
-          platforms: linux/amd64
-
-      - name: Set up SSH
-        run: |
-          mkdir -p ~/.ssh/
-          echo "${NEW_VPS_SSH_KEY}" > ../private.key
-          chmod 600 ../private.key
-
-        env:
-          NEW_VPS_SSH_KEY: ${{ secrets.NEW_VPS_SSH_KEY }}
-      - name: Deploy to VPS on port 3000
-        run: |
-          export UNIQUE_NAME=docker-compose-v2-$GITHUB_RUN_ID.yml
-
-          cat > $UNIQUE_NAME << EOF
-          version: '3.3'
-          services:
-            backend:
-              container_name: stirling-v2-backend
-              image: ${IMAGE_BASE}:v2-backend-${{ steps.commit-hashes.outputs.backend_short }}
-              ports:
-                - "13000:8080"
-              volumes:
-                - /stirling/V2/data:/usr/share/tessdata:rw
-                - /stirling/V2/config:/configs:rw
-                - /stirling/V2/logs:/logs:rw
-              environment:
-                DISABLE_ADDITIONAL_FEATURES: "true"
-                SECURITY_ENABLELOGIN: "false"
-                SYSTEM_DEFAULTLOCALE: en-US
-                UI_APPNAME: "Stirling-PDF V2"
-                UI_HOMEDESCRIPTION: "V2 Frontend/Backend Split"
-                UI_APPNAMENAVBAR: "V2 Deployment"
-                SYSTEM_MAXFILESIZE: "100"
-                METRICS_ENABLED: "true"
-                SYSTEM_GOOGLEVISIBILITY: "false"
-                SWAGGER_SERVER_URL: "https://demo.stirlingpdf.cloud"
-                baseUrl: "https://demo.stirlingpdf.cloud"
-              restart: on-failure:5
-
-            frontend:
-              container_name: stirling-v2-frontend
-              image: ${IMAGE_BASE}:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }}
-              ports:
-                - "3000:80"
-              environment:
-                VITE_API_BASE_URL: "http://${NEW_VPS_HOST}:13000"
-              depends_on:
-                - backend
-              restart: on-failure:5
-          EOF
-
-          # Copy to remote with unique name
-          scp -i ../private.key -o StrictHostKeyChecking=no $UNIQUE_NAME ${NEW_VPS_USERNAME}@${NEW_VPS_HOST}:/tmp/$UNIQUE_NAME
-
-          # SSH and rename/move atomically to avoid interference
-          ssh -i ../private.key -o StrictHostKeyChecking=no ${NEW_VPS_USERNAME}@${NEW_VPS_HOST} << ENDSSH
-            mkdir -p /stirling/V2/{data,config,logs}
-            mv /tmp/$UNIQUE_NAME /stirling/V2/docker-compose.yml
-            cd /stirling/V2
-            docker-compose down || true
-            docker-compose pull
-            docker-compose up -d
-            docker system prune -af --volumes || true
-            docker image prune -af --filter "until=336h" --filter "label!=keep=true" || true
-          ENDSSH
-
-        env:
-          IMAGE_BASE: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test
-          NEW_VPS_HOST: ${{ secrets.NEW_VPS_HOST }}
-          NEW_VPS_USERNAME: ${{ secrets.NEW_VPS_USERNAME }}
-      - name: Cleanup temporary files
-        if: always()
-        run: |
-          rm -f ../private.key
diff --git a/.github/workflows/docker-compose-tests.yml b/.github/workflows/docker-compose-tests.yml
index ddb35b4e1a..9d5911404f 100644
--- a/.github/workflows/docker-compose-tests.yml
+++ b/.github/workflows/docker-compose-tests.yml
@@ -17,6 +17,7 @@ permissions:
 
 jobs:
   docker-compose-tests:
+    environment: ci-unsigned
     runs-on: ubuntu-latest
     permissions:
       actions: write
diff --git a/.github/workflows/e2e-live.yml b/.github/workflows/e2e-live.yml
index 4aa2a78c89..7bc95df05e 100644
--- a/.github/workflows/e2e-live.yml
+++ b/.github/workflows/e2e-live.yml
@@ -11,6 +11,7 @@ permissions:
 
 jobs:
   playwright-e2e-live:
+    environment: ci-unsigned
     runs-on: ubuntu-latest
     timeout-minutes: 30
     steps:
diff --git a/.github/workflows/frontend-backend-licenses-update.yml b/.github/workflows/frontend-backend-licenses-update.yml
index 1155f01e39..458766660f 100644
--- a/.github/workflows/frontend-backend-licenses-update.yml
+++ b/.github/workflows/frontend-backend-licenses-update.yml
@@ -42,6 +42,8 @@ jobs:
           filters: .github/config/.files.yaml
 
   generate-frontend-license-report:
+    # ci-bot, not bot-identity: this job runs on PRs too, and bot-identity is main-only.
+    environment: ci-bot
     if: needs.files-changed.outputs.licenses-frontend == 'true'
     name: Generate Frontend License Report
     needs: files-changed
@@ -316,6 +318,8 @@ jobs:
           GH_TOKEN: ${{ steps.setup-bot.outputs.token }}
 
   generate-backend-license-report:
+    # ci-bot, not bot-identity: this job runs on PRs too, and bot-identity is main-only.
+    environment: ci-bot
     if: needs.files-changed.outputs.licenses-backend == 'true'
     needs: files-changed
     name: Generate Backend License Report
diff --git a/.github/workflows/multiOSReleases.yml b/.github/workflows/multiOSReleases.yml
index 3bb014a82d..d9477f722f 100644
--- a/.github/workflows/multiOSReleases.yml
+++ b/.github/workflows/multiOSReleases.yml
@@ -38,6 +38,7 @@ permissions:
 
 jobs:
   determine-matrix:
+    environment: ci-unsigned
     if: ${{ vars.CI_PROFILE != 'lite' }}
     runs-on: ubuntu-latest
     outputs:
@@ -118,6 +119,7 @@ jobs:
         env:
           INPUT_PLATFORM: ${{ github.event.inputs.platform }}
   build-jars:
+    environment: ci-unsigned
     needs: determine-matrix
     runs-on: ubuntu-latest
     strategy:
@@ -204,7 +206,6 @@ jobs:
     runs-on: ${{ matrix.platform }}
     env:
       SM_API_KEY: ${{ secrets.SM_API_KEY }}
-      WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
       RELEASE_GPG_PRIVATE_KEY: ${{ secrets.RELEASE_GPG_PRIVATE_KEY }}
     steps:
       - name: Harden Runner
@@ -295,7 +296,7 @@ jobs:
       # DigiCert KeyLocker Setup (Cloud HSM)
       - name: Setup DigiCert KeyLocker
         id: digicert-setup
-        if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
+        if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release') }}
         uses: digicert/ssm-code-signing@1d820463733701cf1484c7eb5d7d24a15ca2c454 # v1.2.1
         env:
           SM_API_KEY: ${{ secrets.SM_API_KEY }}
@@ -305,7 +306,7 @@ jobs:
           SM_HOST: ${{ secrets.SM_HOST }}
 
       - name: Setup DigiCert KeyLocker Certificate
-        if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
+        if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release') }}
         shell: pwsh
         run: |
           Write-Host "Setting up DigiCert KeyLocker environment..."
@@ -344,40 +345,8 @@ jobs:
           SM_API_KEY: ${{ secrets.SM_API_KEY }}
           SM_CLIENT_CERT_PASSWORD: ${{ secrets.SM_CLIENT_CERT_PASSWORD }}
           SM_KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
-      # Traditional PFX Certificate Import (fallback if KeyLocker not configured)
-      - name: Import Windows Code Signing Certificate
-        if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY == '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
-        env:
-          WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
-          WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
-        shell: powershell
-        run: |
-          if ($env:WINDOWS_CERTIFICATE) {
-            Write-Host "Importing Windows Code Signing Certificate..."
-
-            # Decode base64 certificate and save to file
-            $certBytes = [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE)
-            $certPath = Join-Path $env:RUNNER_TEMP "certificate.pfx"
-            [IO.File]::WriteAllBytes($certPath, $certBytes)
-
-            # Import certificate to CurrentUser\My store
-            $cert = Import-PfxCertificate -FilePath $certPath -CertStoreLocation Cert:\CurrentUser\My -Password (ConvertTo-SecureString -String $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force)
-
-            # Extract and set thumbprint as environment variable
-            $thumbprint = $cert.Thumbprint
-            Write-Host "Certificate imported with thumbprint: $thumbprint"
-            echo "WINDOWS_CERTIFICATE_THUMBPRINT=$thumbprint" >> $env:GITHUB_ENV
-
-            # Clean up certificate file
-            Remove-Item $certPath
-
-            Write-Host "Windows certificate import completed."
-          } else {
-            Write-Host "⚠️ WINDOWS_CERTIFICATE secret not set - building unsigned binary"
-          }
-
       - name: Import Apple Developer Certificate
-        if: matrix.platform == 'macos-15' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')
+        if: matrix.platform == 'macos-15' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release')
         env:
           APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
           APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
@@ -398,7 +367,7 @@ jobs:
           rm certificate.p12
 
       - name: Verify Certificate
-        if: matrix.platform == 'macos-15' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')
+        if: matrix.platform == 'macos-15' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release')
         run: |
           echo "Verifying Apple Developer Certificate..."
           KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
@@ -414,7 +383,7 @@ jobs:
       # Without this, signCommand failures are opaque (Tauri captures but drops
       # smctl's stderr) - running these loudly surfaces auth/env/keypair issues.
       - name: Preflight smctl
-        if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
+        if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release') }}
         shell: pwsh
         env:
           KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
@@ -445,7 +414,7 @@ jobs:
       # smctl reads SM_HOST, SM_API_KEY, SM_CLIENT_CERT_FILE, SM_CLIENT_CERT_PASSWORD
       # from env (set by prior DigiCert setup step). No --config-file needed.
       - name: Configure Windows code signing
-        if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
+        if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release') }}
         shell: bash
         env:
           KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
@@ -466,7 +435,7 @@ jobs:
           sed "s/${KEYPAIR_ALIAS}/***/g" ./frontend/editor/src-tauri/tauri.windows.conf.json
 
       - name: Import release GPG signing key (Linux)
-        if: matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')
+        if: matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release')
         run: |
           echo "$RELEASE_GPG_PRIVATE_KEY" | gpg --batch --import
           gpg --list-secret-keys --keyid-format=long
@@ -498,8 +467,8 @@ jobs:
           #   APPIMAGETOOL_SIGN_PASSPHRASE  appimagetool uses this to unlock the GPG key non-interactively
           #   SIGN_KEY                      appimagetool picks the key matching this fingerprint
           # Without SIGN=1, the other two are ignored and the AppImage is built unsigned even if a key is present.
-          # Mirror the Windows/macOS gate: only sign on a real release/dispatch+sign or V2-master, when secret is present.
-          SIGN: ${{ (env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')) && '1' || '0' }}
+          # Mirror the Windows/macOS gate: only sign on a real release/dispatch+sign or the release branch, when secret is present.
+          SIGN: ${{ (env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release')) && '1' || '0' }}
           APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.RELEASE_GPG_PASSPHRASE }}
           SIGN_KEY: ${{ vars.RELEASE_GPG_FINGERPRINT }}
           TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
@@ -525,7 +494,7 @@ jobs:
         env:
           TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
           TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
-          GPG_SIGN: ${{ (env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')) && '1' || '0' }}
+          GPG_SIGN: ${{ (env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release')) && '1' || '0' }}
           SIGN_KEY: ${{ vars.RELEASE_GPG_FINGERPRINT }}
           APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.RELEASE_GPG_PASSPHRASE }}
         run: |
@@ -564,7 +533,7 @@ jobs:
           echo "Stripped bundled libwayland from $(basename "$AI")"
 
       - name: Clear release GPG key from runner keyring (Linux)
-        if: always() && matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')
+        if: always() && matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release')
         env:
           RELEASE_GPG_FINGERPRINT: ${{ vars.RELEASE_GPG_FINGERPRINT }}
         run: |
@@ -579,7 +548,7 @@ jobs:
       # artifact. Tauri signs a COPY when bundling into the MSI and leaves the raw
       # cargo output unsigned, so checking it produces false negatives.
       - name: Verify Windows Code Signature
-        if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
+        if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release') }}
         timeout-minutes: 15
         shell: pwsh
         run: |
@@ -911,11 +880,11 @@ jobs:
       # workflow_dispatch path requires platform=='all' so a single-platform
       # dispatch can't overwrite an existing release's full latest.json with a
       # partial one (action-gh-release defaults overwrite_files:true).
-      # release / V2-master always build the full matrix so no extra guard needed.
+      # release event / release branch always build the full matrix so no extra guard needed.
       # fail_on_unmatched_files makes a missing latest.json or installer fail loudly
       # instead of silently shipping a broken auto-update.
       - name: Upload binaries to Release
-        if: (github.event_name == 'workflow_dispatch' && github.event.inputs.test_mode != 'true' && github.event.inputs.platform == 'all') || github.event_name == 'release' || github.ref == 'refs/heads/V2-master'
+        if: (github.event_name == 'workflow_dispatch' && github.event.inputs.test_mode != 'true' && github.event.inputs.platform == 'all') || github.event_name == 'release' || github.ref == 'refs/heads/release'
         uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
         with:
           tag_name: v${{ needs.determine-matrix.outputs.version }}
diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
index 70e99d6074..c92d17f027 100644
--- a/.github/workflows/nightly.yml
+++ b/.github/workflows/nightly.yml
@@ -127,6 +127,7 @@ jobs:
   # Runs the @nightly tag (conversion scenarios) plus a 10-shard concurrency run
   # of every other feature.
   cucumber-nightly:
+    environment: ci-unsigned
     name: Cucumber (nightly scenarios + full concurrency)
     runs-on: ubuntu-latest
     # Fork pull requests get no MAVEN_* secrets, so the image build cannot work.
diff --git a/.github/workflows/push-docker-base.yml b/.github/workflows/push-docker-base.yml
index 658583ea08..97c227f23c 100644
--- a/.github/workflows/push-docker-base.yml
+++ b/.github/workflows/push-docker-base.yml
@@ -17,6 +17,9 @@ permissions:
 
 jobs:
   push-base:
+    # Own environment: docker-publish is branch-locked to release/main,
+    # which excludes the baseDockerImage/accessIssueFix branches this runs on.
+    environment: docker-base-publish
     if: ${{ vars.CI_PROFILE != 'lite' && github.actor == 'Frooodle' }}
     runs-on: ubuntu-24.04-8core
     permissions:
diff --git a/.github/workflows/push-docker.yml b/.github/workflows/push-docker.yml
index b88d69c3c2..ec9d14822c 100644
--- a/.github/workflows/push-docker.yml
+++ b/.github/workflows/push-docker.yml
@@ -20,9 +20,8 @@ on:
         default: false
   push:
     branches:
-      - master
+      - release
       - main
-      - V2-master
 
 # cancel in-progress jobs if a new job is triggered
 # This is useful to avoid running multiple builds for the same branch if a new commit is pushed
@@ -91,13 +90,13 @@ jobs:
           MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
 
       - name: Install cosign
-        if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master'
+        if: github.ref == 'refs/heads/release'
         uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
         with:
           cosign-release: "v2.4.1"
 
       - name: Install cosign
-        if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master'
+        if: github.ref == 'refs/heads/release'
         uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
         with:
           cosign-release: "v2.4.1"
@@ -133,8 +132,8 @@ jobs:
             ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
             ${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
           tags: |
-            type=raw,value=${{ steps.versionNumber.outputs.versionNumber }},enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
-            type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
+            type=raw,value=${{ steps.versionNumber.outputs.versionNumber }},enable=${{ github.ref == 'refs/heads/release' }}
+            type=raw,value=latest,enable=${{ github.ref == 'refs/heads/release' }}
 
       - name: Build and push Unified Dockerfile (latest variant)
         id: build-push-latest
@@ -158,7 +157,7 @@ jobs:
           sbom: true
 
       - name: Sign regular images
-        if: env.RUN_MAIN_APP == 'true' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master') && steps.build-push-latest.outputs.digest != ''
+        if: env.RUN_MAIN_APP == 'true' && (github.ref == 'refs/heads/release') && steps.build-push-latest.outputs.digest != ''
         env:
           DIGEST: ${{ steps.build-push-latest.outputs.digest }}
           TAGS: ${{ steps.meta.outputs.tags }}
@@ -182,8 +181,8 @@ jobs:
             ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
             ${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
           tags: |
-            type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-fat,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
-            type=raw,value=latest-fat,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
+            type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-fat,enable=${{ github.ref == 'refs/heads/release' }}
+            type=raw,value=latest-fat,enable=${{ github.ref == 'refs/heads/release' }}
 
       - name: Build and push Unified Dockerfile (fat variant)
         id: build-push-fat
@@ -204,7 +203,7 @@ jobs:
           sbom: true
 
       - name: Sign fat images
-        if: env.RUN_MAIN_APP == 'true' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master') && steps.build-push-fat.outputs.digest != ''
+        if: env.RUN_MAIN_APP == 'true' && (github.ref == 'refs/heads/release') && steps.build-push-fat.outputs.digest != ''
         env:
           DIGEST: ${{ steps.build-push-fat.outputs.digest }}
           TAGS: ${{ steps.meta-fat.outputs.tags }}
@@ -226,8 +225,8 @@ jobs:
             ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
             ${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
           tags: |
-            type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-ultra-lite,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
-            type=raw,value=latest-ultra-lite,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
+            type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-ultra-lite,enable=${{ github.ref == 'refs/heads/release' }}
+            type=raw,value=latest-ultra-lite,enable=${{ github.ref == 'refs/heads/release' }}
 
       - name: Build and push Unified Dockerfile (ultra-lite variant)
         id: build-push-lite
@@ -248,7 +247,7 @@ jobs:
           sbom: true
 
       - name: Sign ultra-lite images
-        if: env.RUN_MAIN_APP == 'true' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master') && steps.build-push-lite.outputs.digest != ''
+        if: env.RUN_MAIN_APP == 'true' && (github.ref == 'refs/heads/release') && steps.build-push-lite.outputs.digest != ''
         env:
           DIGEST: ${{ steps.build-push-lite.outputs.digest }}
           TAGS: ${{ steps.meta-lite.outputs.tags }}
@@ -260,7 +259,7 @@ jobs:
           done
 
       # Standalone unoserver image — versioned independently via
-      # docker/unoserver/VERSION. master/V2-master: publish +latest
+      # docker/unoserver/VERSION. release: publish +latest
       # only when the version is new. main/testMain: republish :alpha only
       # when the source hash differs from the published image's annotation.
       - name: Read unoserver image version
@@ -319,7 +318,7 @@ jobs:
           fi
 
           case "$EFFECTIVE_REF" in
-            refs/heads/master|refs/heads/V2-master)
+            refs/heads/release)
               if [ "${FORCE_REBUILD}" = "true" ]; then
                 echo "force_unoserver_rebuild=true — building stable regardless"
                 mode="stable"
diff --git a/.github/workflows/swagger.yml b/.github/workflows/swagger.yml
index 38c1985a83..115de87d4e 100644
--- a/.github/workflows/swagger.yml
+++ b/.github/workflows/swagger.yml
@@ -4,7 +4,7 @@ on:
   workflow_dispatch:
   push:
     branches:
-      - master
+      - release
 
 # cancel in-progress jobs if a new job is triggered
 # This is useful to avoid running multiple builds for the same branch if a new commit is pushed
@@ -23,6 +23,9 @@ permissions:
 
 jobs:
   push:
+    # package-publish holds SWAGGERHUB_API_KEY. It requires reviewer approval and
+    # is limited to main / release / v* tags, so every push to release waits on one.
+    environment: package-publish
     if: ${{ vars.CI_PROFILE != 'lite' }}
     runs-on: ubuntu-latest
     steps:
diff --git a/.github/workflows/tauri-build.yml b/.github/workflows/tauri-build.yml
index ab6d8aca71..ddf1104bac 100644
--- a/.github/workflows/tauri-build.yml
+++ b/.github/workflows/tauri-build.yml
@@ -57,6 +57,9 @@ permissions:
 
 jobs:
   determine-matrix:
+    # Only probes APPLE_CERTIFICATE for presence, so it stays on the unrestricted
+    # signing environment - release-signing would block every PR run.
+    environment: ci-signing
     if: ${{ vars.CI_PROFILE != 'lite' }}
     runs-on: ubuntu-latest
     outputs:
@@ -103,6 +106,12 @@ jobs:
           echo "matrix={\"include\":[$JOINED]}" >> $GITHUB_OUTPUT
 
   build:
+    # Windows/GPG signing only runs on main (see the per-step gates below), so only
+    # that path needs the reviewer-gated release-signing environment. Everything else
+    # (PRs, merge queue, nightly) signs macOS only and uses ci-signing, which has no
+    # approval or branch restriction.
+    environment:
+      name: ${{ (inputs.sign && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))) && 'release-signing' || 'ci-signing' }}
     needs: determine-matrix
     strategy:
       fail-fast: false
@@ -110,7 +119,6 @@ jobs:
     runs-on: ${{ matrix.platform }}
     env:
       SM_API_KEY: ${{ secrets.SM_API_KEY }}
-      WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
       APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
       RELEASE_GPG_PRIVATE_KEY: ${{ secrets.RELEASE_GPG_PRIVATE_KEY }}
       # Per-platform sign gate. macOS signs on any run with the cert available,
@@ -264,38 +272,6 @@ jobs:
             }
           }
 
-      # Traditional PFX Certificate Import (fallback if KeyLocker not configured)
-      - name: Import Windows Code Signing Certificate
-        if: ${{ inputs.sign && startsWith(matrix.platform, 'windows') && env.SM_API_KEY == '' && github.ref == 'refs/heads/main' }}
-        env:
-          WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
-          WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
-        shell: powershell
-        run: |
-          if ($env:WINDOWS_CERTIFICATE) {
-            Write-Host "Importing Windows Code Signing Certificate..."
-
-            # Decode base64 certificate and save to file
-            $certBytes = [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE)
-            $certPath = Join-Path $env:RUNNER_TEMP "certificate.pfx"
-            [IO.File]::WriteAllBytes($certPath, $certBytes)
-
-            # Import certificate to CurrentUser\My store
-            $cert = Import-PfxCertificate -FilePath $certPath -CertStoreLocation Cert:\CurrentUser\My -Password (ConvertTo-SecureString -String $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force)
-
-            # Extract and set thumbprint as environment variable
-            $thumbprint = $cert.Thumbprint
-            Write-Host "Certificate imported with thumbprint: $thumbprint"
-            echo "WINDOWS_CERTIFICATE_THUMBPRINT=$thumbprint" >> $env:GITHUB_ENV
-
-            # Clean up certificate file
-            Remove-Item $certPath
-
-            Write-Host "Windows certificate import completed."
-          } else {
-            Write-Host "⚠️ WINDOWS_CERTIFICATE secret not set - building unsigned binary"
-          }
-
       - name: Import Apple Developer Certificate
         if: env.SIGN_BUNDLE == 'true' && matrix.platform == 'macos-15'
         env:
diff --git a/.github/workflows/test-build-docker.yml b/.github/workflows/test-build-docker.yml
index 660cd2458b..4a79cb3733 100644
--- a/.github/workflows/test-build-docker.yml
+++ b/.github/workflows/test-build-docker.yml
@@ -37,6 +37,7 @@ jobs:
   # spring-security=true matrix entry if `task backend:build` and
   # `task backend:build:ci` produce equivalent JARs (verify before wiring).
   test-build-docker-images:
+    environment: ci-unsigned
     runs-on: ubuntu-latest
     strategy:
       fail-fast: false
diff --git a/.github/workflows/testdriver.yml b/.github/workflows/testdriver.yml
deleted file mode 100644
index 751eaf44f7..0000000000
--- a/.github/workflows/testdriver.yml
+++ /dev/null
@@ -1,235 +0,0 @@
-name: UI test with TestDriverAI
-
-on:
-  push:
-    branches: ["master", "UITest", "testdriver"]
-
-# cancel in-progress jobs if a new job is triggered
-# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
-# or a pull request is updated.
-# It helps to save resources and time by ensuring that only the latest commit is built and tested
-# This is particularly useful for long-running jobs that may take a while to complete.
-# The `group` is set to a combination of the workflow name, event name, and branch name.
-# This ensures that jobs are grouped by the workflow and branch, allowing for cancellation of
-# in-progress jobs when a new commit is pushed to the same branch or a new pull request is opened.
-concurrency:
-  group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref_name || github.ref }}
-  cancel-in-progress: true
-
-permissions:
-  contents: read
-
-jobs:
-  deploy:
-    environment: pr-preview
-    if: ${{ vars.CI_PROFILE != 'lite' }}
-    runs-on: ubuntu-latest
-    permissions:
-      contents: read
-      packages: write
-    steps:
-      - name: Harden Runner
-        uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
-        with:
-          egress-policy: audit
-
-      - name: Checkout repository
-        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
-
-      - name: Set up JDK 25
-        uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
-        with:
-          java-version: "25"
-          distribution: "temurin"
-
-      - name: Cache Gradle User Home
-        uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
-        with:
-          path: |
-            ~/.gradle/caches
-            ~/.gradle/wrapper
-          key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
-          restore-keys: |
-            gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
-            gradle-${{ runner.os }}-${{ runner.arch }}-
-
-      - name: Build with Gradle
-        run: ./gradlew build
-        env:
-          MAVEN_USER: ${{ secrets.MAVEN_USER }}
-          MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
-          MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
-          DISABLE_ADDITIONAL_FEATURES: true
-
-      - name: Set up Docker Buildx
-        uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
-
-      - name: Get version number
-        id: versionNumber
-        run: |
-          VERSION=$(grep "^version =" build.gradle | awk -F'"' '{print $2}')
-          echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
-
-      - name: Convert repository owner to lowercase
-        id: repoowner
-        run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT
-
-      - 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: Build and push test image
-        uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
-        with:
-          context: .
-          file: ./docker/embedded/Dockerfile
-          push: true
-          cache-from: type=gha,scope=stirling-pdf-latest
-          cache-to: type=gha,mode=max,scope=stirling-pdf-latest
-          tags: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:test-${{ github.sha }}
-          build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
-          platforms: linux/amd64
-
-      - name: Set up SSH
-        run: |
-          mkdir -p ~/.ssh/
-          echo "${NEW_VPS_SSH_KEY}" > ../private.key
-          sudo chmod 600 ../private.key
-
-        env:
-          NEW_VPS_SSH_KEY: ${{ secrets.NEW_VPS_SSH_KEY }}
-      - name: Deploy to VPS
-        run: |
-          cat > docker-compose.yml << EOF
-          version: '3.3'
-          services:
-            stirling-pdf:
-              container_name: stirling-pdf-test-${{ github.sha }}
-              image: ${IMAGE_BASE}:test-${{ github.sha }}
-              ports:
-                - "1337:8080"
-              volumes:
-                - /stirling/test-${{ github.sha }}/data:/usr/share/tessdata:rw
-                - /stirling/test-${{ github.sha }}/config:/configs:rw
-                - /stirling/test-${{ github.sha }}/logs:/logs:rw
-              environment:
-                DISABLE_ADDITIONAL_FEATURES: "true"
-                SECURITY_ENABLELOGIN: "false"
-                SYSTEM_DEFAULTLOCALE: en-US
-                UI_APPNAME: "Stirling-PDF Test"
-                UI_HOMEDESCRIPTION: "Test Deployment"
-                UI_APPNAMENAVBAR: "Test"
-                SYSTEM_MAXFILESIZE: "100"
-                METRICS_ENABLED: "true"
-                SYSTEM_GOOGLEVISIBILITY: "false"
-                SYSTEM_ENABLEANALYTICS: "false"
-              restart: on-failure:5
-          EOF
-
-          scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${NEW_VPS_USERNAME}@${NEW_VPS_HOST}:/tmp/docker-compose.yml
-
-          ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${NEW_VPS_USERNAME}@${NEW_VPS_HOST} << EOF
-            mkdir -p /stirling/test-${{ github.sha }}/{data,config,logs}
-            mv /tmp/docker-compose.yml /stirling/test-${{ github.sha }}/docker-compose.yml
-            cd /stirling/test-${{ github.sha }}
-            docker-compose pull
-            docker-compose up -d
-          EOF
-
-        env:
-          IMAGE_BASE: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test
-          NEW_VPS_USERNAME: ${{ secrets.NEW_VPS_USERNAME }}
-          NEW_VPS_HOST: ${{ secrets.NEW_VPS_HOST }}
-  files-changed:
-    if: always()
-    name: detect what files changed
-    runs-on: ubuntu-latest
-    timeout-minutes: 3
-    outputs:
-      frontend: ${{ steps.changes.outputs.frontend }}
-    steps:
-      - name: Harden the runner (Audit all outbound calls)
-        uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
-        with:
-          egress-policy: audit
-
-      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
-
-      - name: Check for file changes
-        uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
-        id: changes
-        with:
-          filters: ".github/config/.files.yaml"
-
-  test:
-    environment: pr-preview
-    if: needs.files-changed.outputs.frontend == 'true'
-    needs: [deploy, files-changed]
-    runs-on: ubuntu-latest
-    steps:
-      - name: Harden Runner
-        uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
-        with:
-          egress-policy: audit
-
-      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
-
-      - name: Set up Node
-        uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
-        with:
-          cache: "npm"
-          cache-dependency-path: frontend/package-lock.json
-
-      - name: Run TestDriver.ai
-        uses: testdriverai/action@f0d0f45fdd684db628baa843fe9313f3ca3a8aa8 #1.1.3
-        with:
-          key: ${{secrets.TESTDRIVER_API_KEY}}
-          prerun: |
-            choco install go-task -y
-            task frontend:build
-            cd frontend
-            npm install dashcam-chrome --save
-            Start-Process "C:/Program Files/Google/Chrome/Application/chrome.exe" -ArgumentList "--start-maximized", "--load-extension=$(pwd)/node_modules/dashcam-chrome/build", "http://${{ secrets.NEW_VPS_HOST }}:1337"
-            Start-Sleep -Seconds 20
-          prompt: |
-            1. /run testing/testdriver/test.yml
-        env:
-          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-          FORCE_COLOR: "3"
-
-  cleanup:
-    environment: pr-preview
-    needs: [deploy, test]
-    runs-on: ubuntu-latest
-    if: always()
-
-    steps:
-      - name: Harden Runner
-        uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
-        with:
-          egress-policy: audit
-
-      - name: Set up SSH
-        run: |
-          mkdir -p ~/.ssh/
-          echo "${NEW_VPS_SSH_KEY}" > ../private.key
-          sudo chmod 600 ../private.key
-
-        env:
-          NEW_VPS_SSH_KEY: ${{ secrets.NEW_VPS_SSH_KEY }}
-      - name: Cleanup deployment
-        if: always()
-        run: |
-          ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${NEW_VPS_USERNAME}@${NEW_VPS_HOST} << EOF
-            cd /stirling/test-${{ github.sha }}
-            docker-compose down
-            cd /stirling
-            rm -rf test-${{ github.sha }}
-          EOF
-        env:
-          NEW_VPS_USERNAME: ${{ secrets.NEW_VPS_USERNAME }}
-          NEW_VPS_HOST: ${{ secrets.NEW_VPS_HOST }}
-        continue-on-error: true # Ensure cleanup runs even if previous steps fail
diff --git a/WINDOWS_SIGNING.md b/WINDOWS_SIGNING.md
index 58ffd6e657..95cbbd24e2 100644
--- a/WINDOWS_SIGNING.md
+++ b/WINDOWS_SIGNING.md
@@ -4,6 +4,11 @@ This guide explains how to set up Windows code signing for Stirling-PDF desktop
 
 ## Overview
 
+Releases are signed with **DigiCert KeyLocker**, a cloud HSM: the private key never
+leaves DigiCert, and the runner signs through a PKCS#11 provider. The older approach
+of uploading a base64 `.pfx` to a repository secret has been removed from the
+workflows - the sections below describe KeyLocker, which is what actually runs.
+
 Windows code signing is essential for:
 - Preventing Windows SmartScreen warnings
 - Building trust with users
@@ -49,29 +54,19 @@ openssl pkcs12 -export -out certificate.pfx -inkey private-key.key -in certifica
 
 ### Required Secrets
 
-Navigate to your GitHub repository → Settings → Secrets and variables → Actions
+Navigate to your GitHub repository → Settings → Environments → `release-signing`.
 
-Add the following secrets:
+These live in the `release-signing` environment, not at repository scope. That
+environment requires reviewer approval and is limited to `main`, `release`,
+`hotfix/*` and `v*` tags. All five come from the DigiCert ONE console.
 
-#### 1. `WINDOWS_CERTIFICATE`
-- **Description**: Base64-encoded .pfx certificate file
-- **How to create**:
-
-**On macOS/Linux:**
-```bash
-base64 -i certificate.pfx | pbcopy  # Copies to clipboard
-```
-
-**On Windows (PowerShell):**
-```powershell
-[Convert]::ToBase64String([IO.File]::ReadAllBytes("certificate.pfx")) | Set-Clipboard
-```
-
-Paste the entire base64 string into the GitHub secret.
-
-#### 2. `WINDOWS_CERTIFICATE_PASSWORD`
-- **Description**: Password for the .pfx certificate
-- **Value**: The password you set when creating/exporting the .pfx file
+| Secret | Description |
+| --- | --- |
+| `SM_API_KEY` | KeyLocker API key. Also acts as the on/off switch: signing steps are gated on it being non-empty. |
+| `SM_CLIENT_CERT_FILE_B64` | Base64-encoded PKCS#12 client authentication certificate. |
+| `SM_CLIENT_CERT_PASSWORD` | Password for that client certificate. |
+| `SM_KEYPAIR_ALIAS` | Alias of the signing keypair to use. |
+| `SM_HOST` | DigiCert ONE host, e.g. `https://clientauth.one.digicert.com`. |
 
 ### Optional Secrets for Tauri Updater
 
@@ -110,23 +105,23 @@ The Windows signing configuration is already set up:
 
 ### 2. GitHub Workflow (.github/workflows/tauri-build.yml)
 
-The workflow includes three Windows signing steps:
+The workflow includes four Windows signing steps, all gated on `SM_API_KEY` being
+set and the ref being the release branch:
 
-1. **Import Certificate**: Decodes and imports the .pfx certificate into Windows certificate store
-2. **Build Tauri App**: Builds and signs the application using the imported certificate
-3. **Verify Signature**: Validates that both .exe and .msi files are properly signed
+1. **Setup DigiCert KeyLocker**: Installs the DigiCert signing tools via `digicert/ssm-code-signing`
+2. **Setup DigiCert KeyLocker Certificate**: Writes the client cert and exports the PKCS#11 config
+3. **Configure Windows code signing / Build Tauri app**: Signs through the PKCS#11 provider
+4. **Verify Windows Code Signature**: Validates that the .exe and .msi are properly signed
 
 ## Testing the Setup
 
 ### 1. Local Testing (Windows Only)
 
-Before pushing to GitHub, test locally:
+KeyLocker is CI-only. To check signing locally, install your own certificate into
+the Windows store and point Tauri at it; the build no longer reads any certificate
+from an environment variable.
 
 ```powershell
-# Set environment variables
-$env:WINDOWS_CERTIFICATE = [Convert]::ToBase64String([IO.File]::ReadAllBytes("certificate.pfx"))
-$env:WINDOWS_CERTIFICATE_PASSWORD = "your-certificate-password"
-
 # Build the application
 cd frontend
 npm run tauri build
@@ -191,9 +186,10 @@ Look for:
 - Consider EV certificate for immediate reputation
 
 ### Certificate Not Found During Build
-- Verify `WINDOWS_CERTIFICATE` secret is set
-- Check base64 encoding is correct (no extra whitespace)
-- Ensure password is correct
+- Verify `SM_API_KEY` is present in the `release-signing` environment. If it is empty
+  the signing steps skip silently and the build succeeds unsigned.
+- Check `SM_CLIENT_CERT_FILE_B64` base64 encoding is correct (no extra whitespace)
+- Ensure `SM_CLIENT_CERT_PASSWORD` and `SM_KEYPAIR_ALIAS` match the DigiCert keypair
 
 ## Security Best Practices
 
@@ -220,11 +216,10 @@ Look for:
 ## Certificate Lifecycle
 
 ### Before Expiration
-1. Obtain new certificate from CA (typically annual renewal)
-2. Convert to .pfx format if needed
-3. Update `WINDOWS_CERTIFICATE` secret with new base64-encoded certificate
-4. Update `WINDOWS_CERTIFICATE_PASSWORD` if password changed
-5. Test build to verify new certificate works
+1. Renew the certificate in the DigiCert ONE console (typically annual)
+2. If the keypair alias changed, update `SM_KEYPAIR_ALIAS` in the `release-signing` environment
+3. If the client authentication certificate was reissued, update `SM_CLIENT_CERT_FILE_B64` and `SM_CLIENT_CERT_PASSWORD`
+4. Test build to verify the new certificate works
 
 ### Expired Certificates
 - Signed binaries remain valid (timestamp proves signing time)
diff --git a/frontend/editor/src/core/services/updateService.ts b/frontend/editor/src/core/services/updateService.ts
index 043c53bc36..8b23d26deb 100644
--- a/frontend/editor/src/core/services/updateService.ts
+++ b/frontend/editor/src/core/services/updateService.ts
@@ -185,7 +185,7 @@ export class UpdateService {
    */
   async getCurrentVersionFromGitHub(): Promise {
     const url =
-      "https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/V2-master/build.gradle";
+      "https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/release/build.gradle";
 
     try {
       const response = await fetch(url);

From a14eec94ec13677d71541bcfd26c1397a776ade4 Mon Sep 17 00:00:00 2001
From: James Brunton 
Date: Tue, 18 Aug 2026 09:02:17 +0000
Subject: [PATCH 19/23] Fix corner radius on Mantine checkboxes in Processor
 (#7537)

# Description of Changes

## Before
image

## After
image
---
 frontend/editor/src/portal/theme/mantineTheme.ts | 4 ++++
 1 file changed, 4 insertions(+)

diff --git a/frontend/editor/src/portal/theme/mantineTheme.ts b/frontend/editor/src/portal/theme/mantineTheme.ts
index 7108a90073..e371b4aeb8 100644
--- a/frontend/editor/src/portal/theme/mantineTheme.ts
+++ b/frontend/editor/src/portal/theme/mantineTheme.ts
@@ -163,6 +163,10 @@ export const mantineTheme = createTheme({
     CloseButton: { defaultProps: { "aria-label": "Close" } },
     Modal: { defaultProps: { closeButtonProps: { "aria-label": "Close" } } },
     Drawer: { defaultProps: { closeButtonProps: { "aria-label": "Close" } } },
+    // The portal's md default radius (8px) is right for cards and buttons but
+    // rounds a 20px checkbox into a circle. Pin it to the smaller radius the
+    // editor's checkboxes use so the box reads as a checkbox.
+    Checkbox: { styles: { input: { borderRadius: "var(--radius-sm)" } } },
   },
   fontFamily: "var(--font-sans)",
   fontFamilyMonospace: "var(--font-mono)",

From fb70fc13da03e25ee535b74116de00a83648764e Mon Sep 17 00:00:00 2001
From: James Brunton 
Date: Tue, 18 Aug 2026 13:08:23 +0000
Subject: [PATCH 20/23] Fix tools which crash in the Pipelines page (#7538)

# Description of Changes
Overlay PDFs and Change Metadata both crashed in the Processor because
they required `FilesModalContext` and `ViewerContext` respectively.
Neither of those contexts make sense to provide in the Processor because
there are no files in context and there is no Viewer, so redesign both
tool settings to only optionally require these contexts. Their behaviour
is unchanged in the Editor but they now work in the Processor (just
without the extra info about the active files, since there are none).

Also hooks up the Reorganise Pages settings so that it can be used from
Automate. The component already existed but just wasn't being used,
which just looks like an oversight.
---
 .../ChangeMetadataSingleStep.tsx              | 153 ++++++++++------
 .../tools/overlayPdfs/OverlayPdfsSettings.tsx |  61 +++++--
 .../src/core/contexts/FilesModalContext.tsx   |   4 +-
 ...tomatableToolsHaveOperationConfig.test.tsx |  11 ++
 .../core/data/useTranslatedToolRegistry.tsx   |   5 +-
 .../pipelines/PipelineStepSettings.test.tsx   | 168 +++++++++++++++++-
 6 files changed, 323 insertions(+), 79 deletions(-)

diff --git a/frontend/editor/src/core/components/tools/changeMetadata/ChangeMetadataSingleStep.tsx b/frontend/editor/src/core/components/tools/changeMetadata/ChangeMetadataSingleStep.tsx
index 2eff20b23b..07ba4e7e05 100644
--- a/frontend/editor/src/core/components/tools/changeMetadata/ChangeMetadataSingleStep.tsx
+++ b/frontend/editor/src/core/components/tools/changeMetadata/ChangeMetadataSingleStep.tsx
@@ -1,5 +1,7 @@
+import { useContext, useEffect, useState } from "react";
 import { Stack, Divider, Text } from "@mantine/core";
 import { useTranslation } from "react-i18next";
+import { ViewerContext } from "@app/contexts/ViewerContext";
 import {
   ChangeMetadataParameters,
   createCustomMetadataFunctions,
@@ -19,6 +21,31 @@ interface ChangeMetadataSingleStepProps {
   disabled?: boolean;
 }
 
+/**
+ * Pre-fills the form from the currently open document's existing metadata.
+ * Isolated in its own component so it only mounts where a ViewerProvider exists
+ * (the editor and the in-editor Automate modal). The pipeline builder has no
+ * viewer and no single "current document", so it is skipped there rather than
+ * crashing on useViewer.
+ */
+const MetadataPrefill = ({
+  onParameterChange,
+  onExtractingChange,
+}: {
+  onParameterChange: ChangeMetadataSingleStepProps["onParameterChange"];
+  onExtractingChange: (extracting: boolean) => void;
+}) => {
+  const { isExtractingMetadata } = useMetadataExtraction({
+    updateParameter: onParameterChange,
+  });
+
+  useEffect(() => {
+    onExtractingChange(isExtractingMetadata);
+  }, [isExtractingMetadata, onExtractingChange]);
+
+  return null;
+};
+
 const ChangeMetadataSingleStep = ({
   parameters,
   onParameterChange,
@@ -26,77 +53,85 @@ const ChangeMetadataSingleStep = ({
 }: ChangeMetadataSingleStepProps) => {
   const { t } = useTranslation();
 
+  // Auto-prefill reads the viewer/file contexts, which only exist in the editor.
+  // Gate on the viewer so the pipeline builder renders the fields without it.
+  const hasViewerContext = useContext(ViewerContext) !== null;
+  const [isExtractingMetadata, setIsExtractingMetadata] = useState(false);
+
   // Get custom metadata functions using the utility
   const { addCustomMetadata, removeCustomMetadata, updateCustomMetadata } =
     createCustomMetadataFunctions(parameters, onParameterChange);
 
-  // Extract metadata from uploaded files
-  const { isExtractingMetadata } = useMetadataExtraction({
-    updateParameter: onParameterChange,
-  });
-
   const isDeleteAllEnabled = parameters.deleteAll;
   const fieldsDisabled = disabled || isDeleteAllEnabled || isExtractingMetadata;
 
   return (
-    
-      {/* Delete All */}
-      
-        
-          {t("changeMetadata.deleteAll.label", "Delete All Metadata")}
-        
-        
+      {hasViewerContext && (
+        
-      
-
-      
-
-      {/* Standard Metadata Fields */}
+      )}
       
-        
-          {t("changeMetadata.standardFields.title", "Standard Metadata")}
-        
-        
+        {/* Delete All */}
+        
+          
+            {t("changeMetadata.deleteAll.label", "Delete All Metadata")}
+          
+          
+        
+
+        
+
+        {/* Standard Metadata Fields */}
+        
+          
+            {t("changeMetadata.standardFields.title", "Standard Metadata")}
+          
+          
+        
+
+        
+
+        {/* Document Dates */}
+        
+          
+            {t("changeMetadata.dates.title", "Document Dates")}
+          
+          
+        
+
+        
+
+        {/* Advanced Options */}
+        
+          
+            {t("changeMetadata.advanced.title", "Advanced Options")}
+          
+          
+        
       
-
-      
-
-      {/* Document Dates */}
-      
-        
-          {t("changeMetadata.dates.title", "Document Dates")}
-        
-        
-      
-
-      
-
-      {/* Advanced Options */}
-      
-        
-          {t("changeMetadata.advanced.title", "Advanced Options")}
-        
-        
-      
-    
+    
   );
 };
 
diff --git a/frontend/editor/src/core/components/tools/overlayPdfs/OverlayPdfsSettings.tsx b/frontend/editor/src/core/components/tools/overlayPdfs/OverlayPdfsSettings.tsx
index 2648990248..e7246bb4e3 100644
--- a/frontend/editor/src/core/components/tools/overlayPdfs/OverlayPdfsSettings.tsx
+++ b/frontend/editor/src/core/components/tools/overlayPdfs/OverlayPdfsSettings.tsx
@@ -1,3 +1,4 @@
+import { useContext, useRef } from "react";
 import {
   Stack,
   Text,
@@ -7,6 +8,7 @@ import {
   Divider,
 } from "@mantine/core";
 import { Button } from "@app/ui/Button";
+import { FilePicker } from "@app/ui/FilePicker";
 import { ActionIcon } from "@app/ui/ActionIcon";
 import { SegmentedControl } from "@app/ui/SegmentedControl";
 import { useTranslation } from "react-i18next";
@@ -15,7 +17,7 @@ import {
   type OverlayMode,
 } from "@app/hooks/tools/overlayPdfs/useOverlayPdfsParameters";
 import LocalIcon from "@app/components/shared/LocalIcon";
-import { useFilesModalContext } from "@app/contexts/FilesModalContext";
+import { FilesModalContext } from "@app/contexts/FilesModalContext";
 import styles from "@app/components/tools/overlayPdfs/OverlayPdfsSettings.module.css";
 import { Z_INDEX_AUTOMATE_DROPDOWN } from "@app/styles/zIndex";
 
@@ -34,7 +36,12 @@ export default function OverlayPdfsSettings({
   disabled = false,
 }: OverlayPdfsSettingsProps) {
   const { t } = useTranslation();
-  const { openFilesModal } = useFilesModalContext();
+  // Read optionally: the portal pipeline builder mounts no FilesModalProvider.
+  // Present (editor tool + Automate modal) -> keep the workspace file picker;
+  // absent (portal) -> fall back to the plain file input below.
+  const filesModal = useContext(FilesModalContext);
+  // Clears the FilePicker so the same file can be re-selected (Mantine resetRef).
+  const resetOverlayPicker = useRef<() => void>(null);
 
   const handleOverlayFilesChange = (files: File[]) => {
     onParameterChange("overlayFiles", files);
@@ -66,8 +73,8 @@ export default function OverlayPdfsSettings({
   };
 
   const handleOpenOverlayFilesModal = () => {
-    if (disabled) return;
-    openFilesModal({
+    if (disabled || !filesModal) return;
+    filesModal.openFilesModal({
       customHandler: (files: File[]) => {
         handleOverlayFilesChange([
           ...(parameters.overlayFiles || []),
@@ -77,6 +84,17 @@ export default function OverlayPdfsSettings({
     });
   };
 
+  const appendOverlayFiles = (files: File[]) => {
+    if (files.length === 0) return;
+    handleOverlayFilesChange([...(parameters.overlayFiles || []), ...files]);
+    resetOverlayPicker.current?.();
+  };
+
+  const overlayFilesButtonLabel =
+    parameters.overlayFiles?.length > 0
+      ? t("overlay-pdfs.overlayFiles.addMore", "Add more PDFs...")
+      : t("overlay-pdfs.overlayFiles.placeholder", "Choose PDF(s)...");
+
   return (
     
       
@@ -183,17 +201,30 @@ export default function OverlayPdfsSettings({
         
           {t("overlay-pdfs.overlayFiles.label", "Overlay Files")}
         
-        
+        {filesModal ? (
+          
+        ) : (
+          }
+            fullWidth
+          >
+            {overlayFilesButtonLabel}
+          
+        )}
 
         {parameters.overlayFiles?.length > 0 &&
           (() => {
diff --git a/frontend/editor/src/core/contexts/FilesModalContext.tsx b/frontend/editor/src/core/contexts/FilesModalContext.tsx
index 73d1b0477f..17585ae7e8 100644
--- a/frontend/editor/src/core/contexts/FilesModalContext.tsx
+++ b/frontend/editor/src/core/contexts/FilesModalContext.tsx
@@ -41,7 +41,9 @@ interface FilesModalContextType {
   setOnModalClose: (callback: () => void) => void;
 }
 
-const FilesModalContext = createContext(null);
+export const FilesModalContext = createContext(
+  null,
+);
 
 export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({
   children,
diff --git a/frontend/editor/src/core/data/automatableToolsHaveOperationConfig.test.tsx b/frontend/editor/src/core/data/automatableToolsHaveOperationConfig.test.tsx
index 461c07ebdf..c8942942df 100644
--- a/frontend/editor/src/core/data/automatableToolsHaveOperationConfig.test.tsx
+++ b/frontend/editor/src/core/data/automatableToolsHaveOperationConfig.test.tsx
@@ -30,4 +30,15 @@ describe("automatable tools", () => {
 
     expect(offeredWithoutConfig).toEqual([]);
   });
+
+  // Reorganize Pages has an automatable form (organization mode + page-order string) and a
+  // context-free settings component, but its registry entry once left automationSettings null,
+  // so both Automate and the pipeline builder showed "no configurable settings". Guard the wiring.
+  test("Reorganize Pages exposes automation settings so it is configurable, not no-settings", () => {
+    const { result } = renderHook(() => useTranslatedToolCatalog());
+
+    expect(
+      result.current.regularTools.reorganizePages?.automationSettings,
+    ).toBeTruthy();
+  });
 });
diff --git a/frontend/editor/src/core/data/useTranslatedToolRegistry.tsx b/frontend/editor/src/core/data/useTranslatedToolRegistry.tsx
index 0a39da2fb9..5ae8075d0f 100644
--- a/frontend/editor/src/core/data/useTranslatedToolRegistry.tsx
+++ b/frontend/editor/src/core/data/useTranslatedToolRegistry.tsx
@@ -700,7 +700,10 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
         endpoints: ["rearrange-pages"],
         operationConfig: asRegistryConfig(reorganizePagesOperationConfig),
         synonyms: getSynonyms(t, "reorganizePages"),
-        automationSettings: null,
+        automationSettings: lazySettings(
+          () =>
+            import("@app/components/tools/reorganizePages/ReorganizePagesSettings"),
+        ),
       },
       scalePages: {
         icon: (
diff --git a/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.test.tsx b/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.test.tsx
index 946e24b810..55a52b496e 100644
--- a/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.test.tsx
+++ b/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.test.tsx
@@ -1,10 +1,23 @@
 import { describe, expect, it, vi } from "vitest";
-import { useEffect, useState } from "react";
-import { render, screen } from "@testing-library/react";
+import {
+  Component,
+  Suspense,
+  useEffect,
+  useState,
+  type ComponentType,
+  type ReactNode,
+} from "react";
+import { render, renderHook, screen, waitFor } from "@testing-library/react";
 import { PortalTestProviders } from "@portal/test/TestQueryProvider";
+import { useTranslatedToolCatalog } from "@app/data/useTranslatedToolRegistry";
+import { PreferencesProvider } from "@app/contexts/PreferencesContext";
+import { SidebarProvider } from "@app/contexts/SidebarContext";
 import { Tooltip } from "@app/components/shared/Tooltip";
 import type { ToolRegistry } from "@app/data/toolsTaxonomy";
-import type { WorkingToolStep } from "@app/hooks/tools/shared/toolAutomation";
+import {
+  getExecutableTools,
+  type WorkingToolStep,
+} from "@app/hooks/tools/shared/toolAutomation";
 import {
   asRegistryConfig,
   type ErasedToolParams,
@@ -13,6 +26,10 @@ import {
 import ConvertSettings from "@app/components/tools/convert/ConvertSettings";
 import { convertOperationConfig } from "@app/hooks/tools/convert/useConvertOperation";
 import { defaultParameters as convertDefaults } from "@app/hooks/tools/convert/useConvertParameters";
+import ChangeMetadataSingleStep from "@app/components/tools/changeMetadata/ChangeMetadataSingleStep";
+import { defaultParameters as changeMetadataDefaults } from "@app/hooks/tools/changeMetadata/useChangeMetadataParameters";
+import OverlayPdfsSettings from "@app/components/tools/overlayPdfs/OverlayPdfsSettings";
+import { defaultParameters as overlayDefaults } from "@app/hooks/tools/overlayPdfs/useOverlayPdfsParameters";
 import { PipelineStepSettings } from "@portal/components/pipelines/PipelineStepSettings";
 
 // Override only useTranslation; keep the rest of react-i18next (initReactI18next et al.) real, so
@@ -21,6 +38,7 @@ vi.mock("react-i18next", async (importOriginal) => ({
   ...(await importOriginal()),
   useTranslation: () => ({
     t: (key: string, fallback?: string) => fallback ?? key,
+    i18n: { language: "en-US", changeLanguage: vi.fn() },
   }),
 }));
 
@@ -63,6 +81,32 @@ const convertRegistry = {
   },
 } as unknown as Partial;
 
+// The real Change Metadata automation settings. Its editor variant auto-prefills the
+// form from the open document via useViewer; that path is now gated on a ViewerProvider
+// so it renders here (the portal mounts none) instead of crashing on useViewer.
+const changeMetadataStep = {
+  support: "editable",
+  toolId: "changeMetadata",
+  params: changeMetadataDefaults,
+} as unknown as WorkingToolStep;
+
+const changeMetadataRegistry = {
+  changeMetadata: { automationSettings: ChangeMetadataSingleStep },
+} as unknown as Partial;
+
+// The real Overlay PDFs automation settings. Its overlay-file picker uses the
+// editor FilesModal when present; that read is now optional so the portal (which
+// mounts no FilesModalProvider) renders a plain file input instead of crashing.
+const overlayStep = {
+  support: "editable",
+  toolId: "overlayPdfs",
+  params: overlayDefaults,
+} as unknown as WorkingToolStep;
+
+const overlayRegistry = {
+  overlayPdfs: { automationSettings: OverlayPdfsSettings },
+} as unknown as Partial;
+
 describe("PipelineStepSettings", () => {
   it("renders reused editor tool settings (which use the shared Tooltip) without app-wide Preferences/Sidebar providers", () => {
     expect(() =>
@@ -94,6 +138,36 @@ describe("PipelineStepSettings", () => {
     expect(screen.getByText(/Convert from/)).toBeInTheDocument();
   });
 
+  it("renders the Change Metadata tool's fields in the portal, with no ViewerProvider mounted", () => {
+    expect(() =>
+      render(
+        
+           {}}
+          />
+        ,
+      ),
+    ).not.toThrow();
+    expect(screen.getByText("Standard Metadata")).toBeInTheDocument();
+  });
+
+  it("renders the Overlay PDFs tool's fields in the portal, with no FilesModalProvider mounted", () => {
+    expect(() =>
+      render(
+        
+           {}}
+          />
+        ,
+      ),
+    ).not.toThrow();
+    expect(screen.getByText("Overlay Mode")).toBeInTheDocument();
+  });
+
   // Reproduces the convert-in-pipeline bug: picking a source format fires several onParameterChange
   // calls in one tick (set fromExtension, auto-target, reset options). If each rebuilt from the
   // step snapshot captured at render they'd clobber each other and the earlier field would be lost.
@@ -148,3 +222,91 @@ describe("PipelineStepSettings", () => {
     });
   });
 });
+
+// Records a render crash and swallows it (renders nothing), so one broken tool is attributed by id
+// instead of aborting the whole sweep - mirroring the portal's own ErrorBoundary around the builder.
+class CaptureBoundary extends Component<
+  { onError: (error: Error) => void; children: ReactNode },
+  { failed: boolean }
+> {
+  state = { failed: false };
+  static getDerivedStateFromError() {
+    return { failed: true };
+  }
+  componentDidCatch(error: Error) {
+    this.props.onError(error);
+  }
+  render() {
+    return this.state.failed ? null : this.props.children;
+  }
+}
+
+// Automated version of the manual "add every tool" sweep: render each tool's real automation
+// settings in a portal-only context (the same Preferences + Sidebar + Suspense wrappers
+// PipelineStepSettings uses, and NO editor providers) and fail listing any that throw. This is the
+// guard that would have caught Change Metadata (useViewer) and Overlay PDFs (useFilesModalContext).
+describe("PipelineStepSettings: every tool's settings render in the portal", () => {
+  it("renders each tool's automation settings without throwing", async () => {
+    const { result } = renderHook(() => useTranslatedToolCatalog());
+    const catalog = result.current.allTools;
+    // getExecutableTools is exactly what PipelineBuilder feeds its "Add a tool" picker, so this
+    // sweeps precisely the tools a user can add. Narrow to "editable" (renders a settings
+    // component); "noSettings"/"unsupported" steps show a Banner instead and can't crash.
+    const editableTools = getExecutableTools(catalog)
+      .filter((tool) => tool.support === "editable")
+      .map((tool) => [tool.toolId, catalog[tool.toolId]] as const)
+      .filter(([, entry]) => Boolean(entry?.automationSettings));
+    // Guard against the filter silently matching nothing (e.g. a registry-shape change).
+    expect(editableTools.length).toBeGreaterThan(10);
+
+    const failures: { toolId: string; message: string }[] = [];
+
+    for (const [toolId, entry] of editableTools) {
+      const Settings = entry.automationSettings as ComponentType<
+        ToolAutomationSettingsProps
+      >;
+      const params = (entry.operationConfig?.defaultParameters ??
+        {}) as ErasedToolParams;
+
+      const caught: { error: Error | null } = { error: null };
+      // The sentinel sibling commits only once the lazy Settings actually renders, so we wait for a
+      // real render (or a caught throw) - not just the providers' wrapper DOM.
+      const { unmount } = render(
+        
+          
+            
+               {
+                  caught.error = error;
+                }}
+              >
+                
+                   {}}
+                    disabled={false}
+                  />
+                  
+                
+              
+            
+          
+        ,
+      );
+
+      await waitFor(() =>
+        expect(
+          caught.error !== null ||
+            screen.queryByTestId(`rendered-${toolId}`) !== null,
+        ).toBe(true),
+      );
+
+      if (caught.error) {
+        failures.push({ toolId, message: caught.error.message });
+      }
+      unmount();
+    }
+
+    expect(failures).toEqual([]);
+  }, 30000);
+});

From cf49742d9774802c603b4d068c2f8ac9d3ffbfd1 Mon Sep 17 00:00:00 2001
From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
Date: Tue, 18 Aug 2026 13:56:47 +0000
Subject: [PATCH 21/23] Fix the top bar styling (#7544)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Every top bar styled itself, so none of them matched the new UI. Also,
colors on the premium banner (and possibly others) clashed since the
theme changes.

## Before Example Issue

Screenshot 2026-08-17 at 11 47
20 PM


## After (all)

danger__dark
danger__light
default-app__dark
default-app__light
free-tier-limit__dark
free-tier-limit__light
server-attention__dark
server-attention__light
team-invitation__dark
team-invitation__light
upgrade-prompt__dark
upgrade-prompt__light


## What changed

- `InfoBanner` exposed 8 colour-override props (`background`,
`borderColor`, `textColor`, `iconColor`, `buttonColor`,
`buttonTextColor`, `closeIconColor`, `buttonVariant`), so every caller
invented its own look. Replaced with a closed tone set: `info` · `promo`
· `warning` · `danger`.
- Tone drives the whole bar — fill, border, icon and the button — so a
CTA can't drift from the bar it sits on. Text is neutral in every tone;
only the icon carries the tone colour.
- All colour comes from `--c-*` tokens mixed over `--c-surface`, so the
bars follow light and dark instead of ignoring them. The old bars were
hardcoded: in dark mode the two licence warnings stayed cream-on-white.
- `promo` keeps the gradient it was always meant to have, built from the
existing `--c-hue-indigo`/`--c-hue-purple` stops (documented in
`colors.css` as gradient hues, deliberately not accent-following), with
the existing `premium` button accent on it.
- Deleted the hardcoded colours from all four callers: the purple
gradient (`#667eea`→`#764ba2`), the orange soup (`#FFF4E6` / `#9A3412` /
`#EA580C`) duplicated across the urgent banner and the admin plan
section, and the fixed dark bar (`--mantine-color-dark-7`) on the team
invitation.
- `UpgradeBanner|AdminPlanSection` sat on the theme linter's exemption
list, which is how those colours survived the theme migration. Exemption
removed, so `code-colors` now guards them.
- The banner's class was colliding with `core/ui/Banner.css`'s
`.sui-banner` (16 live rules), which restyled it in the app but not in
Storybook — that's why the two disagreed on radius, border and tone.
Renamed to `.app-banner`; the two surfaces now render identically.
- Bar is square and full-bleed with a single hairline rule underneath;
button labels are optically centred.
- Added `--c-warning-subtle`, matching the existing `--c-danger-subtle`
/ `--c-success-subtle`.
- New `Shared → Top bars` story renders all six bars at once, so a
change to the shared component is visible against the whole set.
- Unrelated one-liner: `frontend/.prettierignore` now ignores the
gitignored `editor/screenshots/` capture artifacts, which were failing
`format:check` locally. Happy to drop it if you'd rather keep this PR to
the bars.

## Testing

- `task frontend:check` — typecheck, lint (oxlint + 4 theme-lint passes
+ stylelint), format, 244 files / 2119 tests.
- `frontend:storybook:a11y:changed` — clean in light and dark.
- The a11y gate caught a real defect mid-change: giving each banner
`role="region"` with the same label produced duplicate landmarks, which
the app hits for real whenever two banners show at once. Landmark
removed.
- All six bars captured in the running editor, light and dark, and
diffed against `origin/main`'s component rendered with each caller's
original props.
---
 .../public/locales/en-US/translation.toml     |   6 +-
 frontend/editor/scripts/lint/theme-lint.mjs   |   1 -
 .../shared/TeamInvitationBanner.tsx           |   8 +-
 .../src/core/components/AppLayout.stories.tsx |   4 +-
 .../src/core/components/shared/AppBanner.css  | 115 ++++++++
 .../components/shared/AppBanner.stories.tsx   | 194 +++++++++++++
 .../src/core/components/shared/AppBanner.tsx  | 124 +++++++++
 .../components/shared/InfoBanner.stories.tsx  |  38 ---
 .../src/core/components/shared/InfoBanner.tsx | 263 ------------------
 frontend/editor/src/core/theme/colors.css     |   5 +
 .../components/shared/DefaultAppBanner.tsx    |   4 +-
 .../components/shared/UpgradeBanner.tsx       |  22 +-
 .../configSections/AdminPlanSection.tsx       |  11 +-
 13 files changed, 453 insertions(+), 342 deletions(-)
 create mode 100644 frontend/editor/src/core/components/shared/AppBanner.css
 create mode 100644 frontend/editor/src/core/components/shared/AppBanner.stories.tsx
 create mode 100644 frontend/editor/src/core/components/shared/AppBanner.tsx
 delete mode 100644 frontend/editor/src/core/components/shared/InfoBanner.stories.tsx
 delete mode 100644 frontend/editor/src/core/components/shared/InfoBanner.tsx

diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml
index 70d3d03bf4..850f69ca02 100644
--- a/frontend/editor/public/locales/en-US/translation.toml
+++ b/frontend/editor/public/locales/en-US/translation.toml
@@ -1879,6 +1879,9 @@ width = "Width"
 [app]
 description = "The Free Adobe Acrobat alternative (10M+ Downloads)"
 
+[appBanner]
+dismiss = "Dismiss"
+
 [attachments]
 convertToPdfA3b = "Convert to PDF/A-3b"
 convertToPdfA3bDescription = "Creates an archival PDF with embedded attachments"
@@ -4827,9 +4830,6 @@ title = "Image to PDF"
 [imageToPdf]
 tags = "conversion,img,jpg,picture,photo"
 
-[infoBanner]
-dismiss = "Dismiss"
-
 [invite]
 acceptError = "Failed to create account"
 accountFor = "Creating account for"
diff --git a/frontend/editor/scripts/lint/theme-lint.mjs b/frontend/editor/scripts/lint/theme-lint.mjs
index 494fc03cca..97b32ef208 100644
--- a/frontend/editor/scripts/lint/theme-lint.mjs
+++ b/frontend/editor/scripts/lint/theme-lint.mjs
@@ -642,7 +642,6 @@ const CODE_EXEMPT_PATH = [
   /mantineTheme|\/theme\.ts$|toolsTaxonomy|LayoutPreview|PageNumberPreview|CloudStorageIcons|BrandMarks/,
   /\/onboarding\//,
   /addStamp|addWatermark|\/tooltips\//,
-  /UpgradeBanner|AdminPlanSection/,
   // Stories are checked like app code; colour-as-data lines opt out with
   // `theme-allow-color`.
   /\.test\.[jt]sx?$|\/types\//,
diff --git a/frontend/editor/src/cloud/components/shared/TeamInvitationBanner.tsx b/frontend/editor/src/cloud/components/shared/TeamInvitationBanner.tsx
index 3b373e9c3b..638c752f9e 100644
--- a/frontend/editor/src/cloud/components/shared/TeamInvitationBanner.tsx
+++ b/frontend/editor/src/cloud/components/shared/TeamInvitationBanner.tsx
@@ -3,7 +3,7 @@ import { Group, Text } from "@mantine/core";
 import { Button } from "@app/ui/Button";
 import { useTranslation } from "react-i18next";
 import LocalIcon from "@app/components/shared/LocalIcon";
-import { InfoBanner } from "@app/components/shared/InfoBanner";
+import { AppBanner } from "@app/components/shared/AppBanner";
 import { useSaaSTeam } from "@app/contexts/SaaSTeamContext";
 
 /**
@@ -105,7 +105,7 @@ export function TeamInvitationBanner() {
   );
 
   return (
-    
   );
 }
diff --git a/frontend/editor/src/core/components/AppLayout.stories.tsx b/frontend/editor/src/core/components/AppLayout.stories.tsx
index 4d7e6780cf..69aceef9d7 100644
--- a/frontend/editor/src/core/components/AppLayout.stories.tsx
+++ b/frontend/editor/src/core/components/AppLayout.stories.tsx
@@ -4,7 +4,7 @@ import { AppLayout } from "@app/components/AppLayout";
 import { BannerProvider, useBanner } from "@app/contexts/BannerContext";
 import { NavigationProvider } from "@app/contexts/NavigationContext";
 import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider";
-import { InfoBanner } from "@app/components/shared/InfoBanner";
+import { AppBanner } from "@app/components/shared/AppBanner";
 
 const meta = {
   title: "Components/AppLayout",
@@ -49,7 +49,7 @@ function BannerSetter() {
   const { setBanner } = useBanner();
   useEffect(() => {
     setBanner(
-      ;
+export default meta;
+type Story = StoryObj;
+
+export const Info: Story = {
+  args: {
+    icon: "info-rounded",
+    title: "Heads up",
+    message: "This document contains form fields that will be flattened.",
+  },
+};
+
+export const Promo: Story = {
+  args: {
+    tone: "promo",
+    icon: "stars-rounded",
+    title: "Upgrade to Server Plan",
+    message:
+      "Get the most out of Stirling PDF with unlimited users and advanced features.",
+    buttonText: "Upgrade Now",
+    buttonIcon: "upgrade-rounded",
+    onButtonClick: () => {},
+    compact: true,
+  },
+};
+
+export const Warning: Story = {
+  args: {
+    tone: "warning",
+    icon: "warning-rounded",
+    title: "Action required",
+    message: "Some pages could not be processed and were skipped.",
+    buttonText: "Review",
+    onButtonClick: () => {},
+  },
+};
+
+export const Danger: Story = {
+  args: {
+    tone: "danger",
+    icon: "warning-rounded",
+    title: "This server needs admin attention",
+    message: "Review the license requirements to keep this server compliant.",
+    buttonText: "See info",
+    buttonIcon: "info-rounded",
+    onButtonClick: () => {},
+    dismissible: false,
+  },
+};
+
+export const Compact: Story = {
+  args: {
+    compact: true,
+    icon: "info-rounded",
+    message: "Autosave is enabled for this file.",
+    dismissible: false,
+  },
+};
+
+/** Message-only, no title: the message takes the title's weight so the bar still reads. */
+export const MessageOnly: Story = {
+  args: {
+    icon: "picture-as-pdf-rounded",
+    message:
+      "Make Stirling PDF your default application for opening PDF files.",
+    buttonText: "Set Default",
+    onButtonClick: () => {},
+    secondaryButtonText: "Don't remind me again",
+    onSecondaryButtonClick: () => {},
+  },
+};
+
+function Row({ caption, children }: { caption: string; children: ReactNode }) {
+  return (
+    
+ + {caption} + + {children} +
+ ); +} + +/** + * Every top bar the app can show, in one place: each entry mirrors a real caller, + * so a change to the component is visible against the whole set at once. Renders a + * composition rather than the component, so it takes no args of its own. + */ +export const AllTopBars: StoryObj = { + render: () => ( +
+ + {}} + /> + + + + {}} + dismissible={false} + /> + + + + {}} + dismissible={false} + /> + + + + {}} + secondaryButtonText="Decline" + onSecondaryButtonClick={() => {}} + dismissible={false} + /> + + + + {}} + secondaryButtonText="Don't remind me again" + onSecondaryButtonClick={() => {}} + /> + + + + {}} + dismissible={false} + /> + +
+ ), +}; diff --git a/frontend/editor/src/core/components/shared/AppBanner.tsx b/frontend/editor/src/core/components/shared/AppBanner.tsx new file mode 100644 index 0000000000..ee0ba03741 --- /dev/null +++ b/frontend/editor/src/core/components/shared/AppBanner.tsx @@ -0,0 +1,124 @@ +import React, { ReactNode } from "react"; +import { Button } from "@app/ui/Button"; +import { ActionIcon } from "@app/ui/ActionIcon"; +import { useTranslation } from "react-i18next"; +import LocalIcon from "@app/components/shared/LocalIcon"; +import "@app/components/shared/AppBanner.css"; + +/** Picks the whole look. Callers choose meaning, never colours. */ +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" }, + warning: { variant: "primary", accent: "warning" }, + danger: { variant: "primary", accent: "danger" }, +} as const; + +interface AppBannerProps { + /** A LocalIcon name, or a pre-rendered node (e.g. a logo) dropped in as-is. */ + icon?: string | ReactNode; + title?: ReactNode; + message: ReactNode; + buttonText?: string; + buttonIcon?: string; + onButtonClick?: () => void; + /** Muted secondary action, e.g. "Don't remind me again". */ + secondaryButtonText?: string; + onSecondaryButtonClick?: () => void; + onDismiss?: () => void; + dismissible?: boolean; + loading?: boolean; + show?: boolean; + tone?: AppBannerTone; + compact?: boolean; +} + +/** The app's top bar: dismissible messaging above the workspace. */ +export const AppBanner: React.FC = ({ + icon, + title, + message, + buttonText, + buttonIcon = "check-circle-rounded", + onButtonClick, + secondaryButtonText, + onSecondaryButtonClick, + onDismiss, + dismissible = true, + loading = false, + show = true, + tone = "info", + compact = false, +}) => { + const { t } = useTranslation(); + if (!show) return null; + + const iconSize = compact ? "1rem" : "1.25rem"; + + return ( +
+ {icon != null && ( + + {typeof icon === "string" ? ( + + ) : ( + icon + )} + + )} + +
+ {title && {title}} + {message} +
+ +
+ {buttonText && onButtonClick && ( + + )} + {secondaryButtonText && onSecondaryButtonClick && ( + + )} + {dismissible && ( + onDismiss?.()} + aria-label={t("appBanner.dismiss", "Dismiss")} + > + + + )} +
+
+ ); +}; diff --git a/frontend/editor/src/core/components/shared/InfoBanner.stories.tsx b/frontend/editor/src/core/components/shared/InfoBanner.stories.tsx deleted file mode 100644 index 5fad071d05..0000000000 --- a/frontend/editor/src/core/components/shared/InfoBanner.stories.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { InfoBanner } from "@app/components/shared/InfoBanner"; - -const meta = { - title: "Shared/InfoBanner", - component: InfoBanner, - parameters: { layout: "padded" }, -} satisfies Meta; -export default meta; -type Story = StoryObj; - -export const Default: Story = { - args: { - icon: "info-rounded", - title: "Heads up", - message: "This document contains form fields that will be flattened.", - }, -}; - -export const Warning: Story = { - args: { - tone: "warning", - icon: "warning-rounded", - title: "Action required", - message: "Some pages could not be processed and were skipped.", - buttonText: "Review", - onButtonClick: () => {}, - }, -}; - -export const Compact: Story = { - args: { - compact: true, - icon: "info-rounded", - message: "Autosave is enabled for this file.", - dismissible: false, - }, -}; diff --git a/frontend/editor/src/core/components/shared/InfoBanner.tsx b/frontend/editor/src/core/components/shared/InfoBanner.tsx deleted file mode 100644 index 2056b6a92f..0000000000 --- a/frontend/editor/src/core/components/shared/InfoBanner.tsx +++ /dev/null @@ -1,263 +0,0 @@ -import React, { ReactNode } from "react"; -import { Paper, Group, Text, Stack } from "@mantine/core"; -import { Button, type ButtonVariant, type ButtonAccent } from "@app/ui/Button"; -import { ActionIcon } from "@app/ui/ActionIcon"; -import { useTranslation } from "react-i18next"; -import LocalIcon from "@app/components/shared/LocalIcon"; - -type InfoBannerTone = "info" | "warning"; - -const toneStyles: Record< - InfoBannerTone, - { - background: string; - border: string; - text: string; - icon: string; - buttonColor: string; - } -> = { - info: { - background: "var(--mantine-color-blue-0)", - border: "var(--mantine-color-blue-2)", - text: "var(--mantine-color-blue-9)", - icon: "var(--mantine-color-blue-6)", - buttonColor: "blue", - }, - warning: { - background: "var(--mantine-color-orange-0)", - border: "var(--mantine-color-orange-3)", - text: "var(--color-amber-dark)", - icon: "var(--mantine-color-orange-7)", - buttonColor: "orange", - }, -}; - -function toSharedButtonVariant( - variant: "light" | "filled" | "white" | "outline" | "subtle", -): ButtonVariant { - switch (variant) { - case "filled": - return "primary"; - case "outline": - return "secondary"; - case "subtle": - return "tertiary"; - case "light": - case "white": - default: - return "secondary"; - } -} - -function toSharedButtonAccent(color: string | undefined): ButtonAccent { - // Mantine colours may carry a shade suffix (e.g. "orange.7"); use the hue. - const hue = (color ?? "").split(".")[0]; - switch (hue) { - case "red": - return "danger"; - case "green": - return "success"; - case "yellow": - case "orange": - return "warning"; - case "blue": - default: - return "default"; - } -} - -interface InfoBannerProps { - /** - * Either a LocalIcon name (string) for the standard sized icon slot, or a - * pre-rendered ReactNode (e.g. a logo image) which is dropped in as-is. - */ - icon?: string | ReactNode; - title?: ReactNode; - message: ReactNode; - buttonText?: string; - buttonIcon?: string; - onButtonClick?: () => void; - /** Optional muted secondary action (e.g. "Don't remind me again"). */ - secondaryButtonText?: string; - onSecondaryButtonClick?: () => void; - onDismiss?: () => void; - dismissible?: boolean; - loading?: boolean; - show?: boolean; - tone?: InfoBannerTone; - background?: string; - borderColor?: string; - textColor?: string; - iconColor?: string; - buttonColor?: string; - buttonVariant?: "light" | "filled" | "white" | "outline" | "subtle"; - /** Override the button label colour (for dark/custom theme variants). */ - buttonTextColor?: string; - minHeight?: number | string; - closeIconColor?: string; - compact?: boolean; -} - -/** - * Generic info banner component for displaying dismissible messages at the top of the app - */ -export const InfoBanner: React.FC = ({ - icon, - title, - message, - buttonText, - buttonIcon = "check-circle-rounded", - onButtonClick, - secondaryButtonText, - onSecondaryButtonClick, - onDismiss, - dismissible = true, - loading = false, - show = true, - tone = "info", - background, - borderColor, - textColor, - iconColor, - buttonColor, - buttonVariant = "light", - buttonTextColor, - minHeight = 56, - closeIconColor, - compact = false, -}) => { - const { t } = useTranslation(); - if (!show) { - return null; - } - - const toneStyle = toneStyles[tone] ?? toneStyles.info; - const resolvedTextColor = textColor ?? toneStyle.text; - const handleDismiss = () => { - onDismiss?.(); - }; - - const iconSize = compact ? "1rem" : "1.2rem"; - const textSize = compact ? "xs" : "sm"; - - return ( - - - - {icon != null && - (typeof icon === "string" ? ( - - ) : ( -
- {icon} -
- ))} - - {title && ( - - {title} - - )} - - {message} - - -
- - {buttonText && onButtonClick && ( - - )} - {secondaryButtonText && onSecondaryButtonClick && ( - - )} - {dismissible && ( - - - - )} - -
-
- ); -}; diff --git a/frontend/editor/src/core/theme/colors.css b/frontend/editor/src/core/theme/colors.css index dffd6beaba..dd3c2aec7c 100644 --- a/frontend/editor/src/core/theme/colors.css +++ b/frontend/editor/src/core/theme/colors.css @@ -71,6 +71,11 @@ html[data-app-theme="light"] { var(--c-success) 10%, var(--c-surface) ); + --c-warning-subtle: color-mix( + in srgb, + var(--c-warning) 10%, + var(--c-surface) + ); /* ── Decorative / brand / categorical palette ────────────────────────── Fixed hues that intentionally do NOT follow the chosen accent: brand diff --git a/frontend/editor/src/desktop/components/shared/DefaultAppBanner.tsx b/frontend/editor/src/desktop/components/shared/DefaultAppBanner.tsx index 1b24cd4675..5ba92c2782 100644 --- a/frontend/editor/src/desktop/components/shared/DefaultAppBanner.tsx +++ b/frontend/editor/src/desktop/components/shared/DefaultAppBanner.tsx @@ -1,6 +1,6 @@ import React, { useState } from "react"; import { useTranslation } from "react-i18next"; -import { InfoBanner } from "@app/components/shared/InfoBanner"; +import { AppBanner } from "@app/components/shared/AppBanner"; import { useDefaultApp } from "@app/hooks/useDefaultApp"; export const DefaultAppBanner: React.FC = () => { @@ -15,7 +15,7 @@ export const DefaultAppBanner: React.FC = () => { const [sessionDismissed, setSessionDismissed] = useState(false); return ( - { ); return ( - { buttonIcon="info-rounded" onButtonClick={buttonText ? handleSeeInfo : undefined} dismissible={false} - minHeight={60} - background="#FFF4E6" - borderColor="var(--mantine-color-orange-7)" - textColor="#9A3412" - iconColor="#EA580C" - buttonVariant="filled" - buttonColor="orange.7" /> ); }; @@ -341,7 +334,7 @@ const UpgradeBanner: React.FC = () => { return ( <> {friendlyVisible && ( - { onButtonClick={handleUpgrade} onDismiss={handleFriendlyDismiss} show={friendlyVisible} - background="linear-gradient(135deg, #667eea 0%, #764ba2 100%)" - borderColor="transparent" - textColor="#fff" - iconColor="#fff" - closeIconColor="#fff" - buttonVariant="filled" - buttonColor="blue" - minHeight={48} + tone="promo" compact /> )} diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminPlanSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminPlanSection.tsx index f683861e8d..c69040f0e7 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminPlanSection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminPlanSection.tsx @@ -12,7 +12,7 @@ import AvailablePlansSection from "@app/components/shared/config/configSections/ import StaticPlanSection from "@app/components/shared/config/configSections/plan/StaticPlanSection"; import LicenseKeySection from "@app/components/shared/config/configSections/plan/LicenseKeySection"; import { alert } from "@app/components/toast"; -import { InfoBanner } from "@app/components/shared/InfoBanner"; +import { AppBanner } from "@app/components/shared/AppBanner"; import { useLicenseAlert } from "@app/hooks/useLicenseAlert"; import { getPreferredCurrency, @@ -200,7 +200,7 @@ const AdminPlanSection: React.FC = () => { {shouldShowLicenseWarning && ( - { buttonIcon="upgrade-rounded" onButtonClick={scrollToPlans} dismissible={false} - minHeight={68} - background="#FFF4E6" - borderColor="var(--mantine-color-orange-7)" - textColor="#9A3412" - iconColor="#EA580C" - buttonVariant="filled" - buttonColor="orange.7" /> )} From 913601ff0372d3fa1cadd11e48b1ebd2c921cdaa Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:00:43 +0000 Subject: [PATCH 22/23] Consolidate the editor + processor sidebar footers into one component (#7539) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Both sidebars ended in a different bottom section. The editor showed an account row (avatar, name, settings); the processor showed a "Link Stirling account" CTA plus a `Settings` nav item and no identity at all. They are now **one shared ``** rendering the same rows in both apps, in this order: 1. the link-account CTA (self-hosted, when unlinked) 2. free credits remaining 3. **Open \** 4. the account row — avatar, name, settings It's a **single surface** with hairline dividers between rows, not stacked cards. Rows are assembled as a list, so a row this build doesn't show (no wallet, no processor access, nothing to link) takes its divider with it rather than leaving a stray line. This also fixes the profile-picture/initials desync between the sidebar and the account settings page. ## Screenshots Captured with the stubbed Playwright harness at 1600x900, scoped to the sidebar and auto-cropped to the region that actually changed. Base is `origin/main`; every state is driven by dummy backend stubs so all the nav-bar permutations are covered. montage_cloud-dark montage_cloud-light montage_editor-dark montage_editor-light montage_processor-dark montage_processor-light The free-credits meter is a cloud-build surface, so the self-hosted capture can't reach it. Those states come from the new Storybook stories with dummy wallet data (`Shared/NavFooter`), which is also where the credit tone bands and the collapsed rail are easiest to review. ## How it's wired `NavFooter` is purely presentational. Each app resolves its own data through three `@app/*` seams, so core carries no build-specific gating and any box whose data is absent is dropped rather than rendered empty. | Seam | core | cloud / proprietary / saas | |---|---|---| | `useFreeCreditsSummary` | `null` — self-hosted editor installs aren't metered | cloud reads `freeRemaining` / `freeAllowance` off the same `useWallet()` the Plan page's free meter uses, so the sidebar and Plan can't disagree | | `useOtherAppSwitch` | `null` — core ships no processor | gated on `portalAccess` (`/api/v1/auth/me` in SaaS, the Spring session flag self-hosted) | | Link-account CTA | n/a | unchanged conditions — passed in as `accountExtras`, still only when `linkState === "unlinked"`, still a no-op in SaaS | - The processor reads the meter through its own `@portal/hooks/useFreeCreditsSummary` rather than the editor's `@app` one. Self-hosted resolves `@app/*` as proprietary → core, where the cloud wallet hook isn't in the cascade, and the implementation can't live in `proprietary/` because core/desktop builds ship no portal and must never resolve `@portal`. Keeping it in `portal/` gets the figure to the linked self-hosted processor without weakening that rule; it reads the same `GET /api/v1/payg/wallet` the Usage page's trial meter already renders, gated on link state and behind the portal's query cache. `portal-saas/` just re-exports the cloud hook, so both footers share one fetch. The processor-access gate previously lived in two near-identical `AppSwitcher` copies. It moves into `useOtherAppSwitch`, `AppSwitcher` now reads it too, and the duplicate `saas/components/shared/AppSwitcher.tsx` is deleted — the logo switcher and the footer row can no longer disagree about access. ## Profile picture sync One `useAccountIdentity` hook now backs the editor footer, the processor footer and the account settings page. Previously settings derived its initial from `email[0]` while the sidebar used `displayName[0]`, and the two drew different blue discs. Alongside that, the shared `Avatar`: - falls back to initials when a picture URL fails to load, instead of leaving an empty disc - renders one letter for single-word names (`admin` → "A", not "AD") - gains an `xl` size so the settings hero disc is the same component ## Notes - Labelled **"Free credits"** rather than "free monthly credits": `freeAllowance` is documented as a one-time lifetime grant, not a monthly reset, so "monthly" would misdescribe the data. Happy to change if the backend semantics differ from the type comments. ## Testing - `task frontend:check` and `task frontend:typecheck:all` pass (all 9 build variants). - 9 new `Shared/NavFooter` stories pass the Chromium + axe story scan; `frontend:storybook:a11y:changed` reports no regressions. - Stubbed E2E suite passes, including the `config-button` tour/settings specs that target the account row. Two failures (`console-clean › landing`, `viewer-text-selection › Ctrl+C`) also fail on `origin/main` locally — they need a backend on :8080 and clipboard permissions. --- .../public/locales/en-US/translation.toml | 23 +- .../config/configSections/usageMeters.tsx | 22 +- .../src/cloud/hooks/useFreeCreditsSummary.ts | 50 ++++ .../editor/src/cloud/hooks/useOpenPlan.ts | 13 ++ frontend/editor/src/cloud/hooks/useWallet.ts | 90 +++++++- .../src/core/components/shared/BrandMark.css | 46 ++++ .../core/components/shared/FileSidebar.css | 92 +------- .../core/components/shared/FileSidebar.tsx | 130 ++--------- .../components/shared/navFooter/NavFooter.css | 156 +++++++++++++ .../shared/navFooter/NavFooter.stories.tsx | 119 ++++++++++ .../shared/navFooter/NavFooter.test.tsx | 58 +++++ .../components/shared/navFooter/NavFooter.tsx | 213 ++++++++++++++++++ .../shared/navFooter/NavFooterCreditsRow.css | 83 +++++++ .../shared/navFooter/NavFooterCreditsRow.tsx | 158 +++++++++++++ .../src/core/hooks/useAccountIdentity.ts | 64 ++++++ .../src/core/hooks/useFreeCreditsSummary.ts | 12 + frontend/editor/src/core/hooks/useOpenPlan.ts | 10 + .../src/core/hooks/useOtherAppSwitch.ts | 12 + frontend/editor/src/core/query/keys.ts | 3 + .../src/core/services/navFooterCache.ts | 73 ++++++ frontend/editor/src/core/ui/Avatar.css | 6 + frontend/editor/src/core/ui/Avatar.tsx | 27 ++- .../hooks/useFreeCreditsSummary.ts | 7 + .../src/portal-saas/hooks/useOpenPlan.ts | 11 + .../editor/src/portal/components/Sidebar.css | 8 +- .../editor/src/portal/components/Sidebar.tsx | 29 ++- .../billing/PrepaidCapacityCard.tsx | 9 +- .../portal/components/billing/WalletMeter.tsx | 26 ++- .../hooks/useFreeCreditsSummary.test.tsx | 110 +++++++++ .../src/portal/hooks/useFreeCreditsSummary.ts | 65 ++++++ .../editor/src/portal/hooks/useOpenPlan.ts | 13 ++ frontend/editor/src/portal/queries/keys.ts | 2 + .../editor/src/proprietary/billing/format.ts | 23 ++ .../editor/src/proprietary/billing/index.ts | 1 + .../components/shared/AppSwitcher.tsx | 20 +- .../proprietary/hooks/useOtherAppSwitch.ts | 15 ++ .../saas/components/shared/AppSwitcher.tsx | 41 ---- .../shared/config/configSections/Overview.tsx | 24 +- .../src/saas/hooks/useOtherAppSwitch.ts | 16 ++ .../src/saas/hooks/usePortalAccess.test.tsx | 43 +++- .../editor/src/saas/hooks/usePortalAccess.ts | 76 ++++--- .../src/saas/hooks/useWallet.poll.test.tsx | 158 +++++++++++++ 42 files changed, 1804 insertions(+), 353 deletions(-) create mode 100644 frontend/editor/src/cloud/hooks/useFreeCreditsSummary.ts create mode 100644 frontend/editor/src/cloud/hooks/useOpenPlan.ts create mode 100644 frontend/editor/src/core/components/shared/navFooter/NavFooter.css create mode 100644 frontend/editor/src/core/components/shared/navFooter/NavFooter.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/navFooter/NavFooter.test.tsx create mode 100644 frontend/editor/src/core/components/shared/navFooter/NavFooter.tsx create mode 100644 frontend/editor/src/core/components/shared/navFooter/NavFooterCreditsRow.css create mode 100644 frontend/editor/src/core/components/shared/navFooter/NavFooterCreditsRow.tsx create mode 100644 frontend/editor/src/core/hooks/useAccountIdentity.ts create mode 100644 frontend/editor/src/core/hooks/useFreeCreditsSummary.ts create mode 100644 frontend/editor/src/core/hooks/useOpenPlan.ts create mode 100644 frontend/editor/src/core/hooks/useOtherAppSwitch.ts create mode 100644 frontend/editor/src/core/services/navFooterCache.ts create mode 100644 frontend/editor/src/portal-saas/hooks/useFreeCreditsSummary.ts create mode 100644 frontend/editor/src/portal-saas/hooks/useOpenPlan.ts create mode 100644 frontend/editor/src/portal/hooks/useFreeCreditsSummary.test.tsx create mode 100644 frontend/editor/src/portal/hooks/useFreeCreditsSummary.ts create mode 100644 frontend/editor/src/portal/hooks/useOpenPlan.ts create mode 100644 frontend/editor/src/proprietary/hooks/useOtherAppSwitch.ts delete mode 100644 frontend/editor/src/saas/components/shared/AppSwitcher.tsx create mode 100644 frontend/editor/src/saas/hooks/useOtherAppSwitch.ts create mode 100644 frontend/editor/src/saas/hooks/useWallet.poll.test.tsx diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 850f69ca02..12ad26ceec 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -5057,6 +5057,14 @@ title = "Upload from Mobile" tags = "Multi Tool,Multi operation,UI,click drag,front end,client side,interactive,intractable,move,delete,migrate,divide" title = "PDF Multi Tool" +[navFooter] +openEditor = "Open PDF Editor" +openProcessor = "Open PDF Processor" + +[navFooter.credits] +count = "{{remaining}} of {{total}}" +label = "Free credits" + [oauth.error] message = "Authentication was not successful. You can close this window and try again." title = "Authentication Failed" @@ -5621,8 +5629,8 @@ freeBody = "View, edit, merge, split, sign, watermark, compress, convert and man freeTitle = "Unlimited PDF editing" [payg.free.hero] -barAria = "Free PDFs used" -capSuffix = "/ {{limit}} free PDFs" +barAria = "Free PDFs remaining" +capSuffix = "of {{limit}} free PDFs left" metaCategories = "Automation · AI · API requests" [payg.free.member] @@ -6685,12 +6693,12 @@ reachedTitle = "Monthly spend limit reached" title = "Couldn't open Stripe portal" [portal.billing.walletMeter] -barAria = "Free PDFs used" -capSuffix_one = "of {{allowance}} free PDFs used" -capSuffix_other = "of {{allowance}} free PDFs used" +barAria = "Free PDFs remaining" +capSuffix_one = "of {{allowance}} free PDF left" +capSuffix_other = "of {{allowance}} free PDFs left" eyebrow = "Processor trial" -statusLabel_one = "{{remaining}} left" -statusLabel_other = "{{remaining}} left" +statusLabel_one = "{{used}} used" +statusLabel_other = "{{used}} used" sub = "Use the PDF Editor for free. Pay to process PDFs automatically." title_one = "Process {{allowance}} PDFs free" title_other = "Process {{allowance}} PDFs free" @@ -7665,7 +7673,6 @@ integrations = "Integrations" pipelines = "Pipelines" policies = "Policies" procurement = "Procurement" -settings = "Settings" sources = "Sources" usage = "Usage & Billing" users = "Users" diff --git a/frontend/editor/src/cloud/components/shared/config/configSections/usageMeters.tsx b/frontend/editor/src/cloud/components/shared/config/configSections/usageMeters.tsx index 8811e537b2..c4407cae7f 100644 --- a/frontend/editor/src/cloud/components/shared/config/configSections/usageMeters.tsx +++ b/frontend/editor/src/cloud/components/shared/config/configSections/usageMeters.tsx @@ -12,6 +12,7 @@ import { formatPeriodDate, MeterBar, meterState, + remainingMeter, } from "@app/billing"; import "@app/components/shared/config/configSections/Payg.css"; import "@app/components/shared/config/configSections/PaygFree.css"; @@ -48,7 +49,8 @@ export function useFreeSnapshot(): FreeSnapshot { export function FreeMeterPanel({ snap }: { snap: FreeSnapshot }) { const { t } = useTranslation(); - const { state, pct } = meterState(snap.billableUsed, snap.billableLimit); + const remaining = Math.max(0, snap.billableLimit - snap.billableUsed); + const { state, pct } = remainingMeter(remaining, snap.billableLimit); const stateLabel = state === "DEGRADED" ? t("payg.free.state.limitReached", "Limit reached") @@ -60,9 +62,9 @@ export function FreeMeterPanel({ snap }: { snap: FreeSnapshot }) { { + if (live !== undefined) writeCachedCredits(live); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [wallet]); + + return (live !== undefined ? live : seed) ?? null; +} diff --git a/frontend/editor/src/cloud/hooks/useOpenPlan.ts b/frontend/editor/src/cloud/hooks/useOpenPlan.ts new file mode 100644 index 0000000000..4d532319f6 --- /dev/null +++ b/frontend/editor/src/cloud/hooks/useOpenPlan.ts @@ -0,0 +1,13 @@ +import { useCallback } from "react"; +import { useNavigate } from "react-router-dom"; + +/** + * Cloud editor builds open the settings modal on its Plan section, which is + * where the free grant is explained and the Processor plan is switched on. + * Routed rather than called directly because the modal is URL-driven here + * (`/settings/*`), the same path the admin tour uses to open it. + */ +export function useOpenPlan(): (() => void) | null { + const navigate = useNavigate(); + return useCallback(() => navigate("/settings/plan"), [navigate]); +} diff --git a/frontend/editor/src/cloud/hooks/useWallet.ts b/frontend/editor/src/cloud/hooks/useWallet.ts index 0a3f78b3ce..ed3cb2ce6b 100644 --- a/frontend/editor/src/cloud/hooks/useWallet.ts +++ b/frontend/editor/src/cloud/hooks/useWallet.ts @@ -32,6 +32,14 @@ * promise see the UI flip exactly once the new state is visible — no * intermediate flash of the old value. * + *

Freshness

+ * + * The figures drain as metered work runs, so a mounted consumer re-reads the + * wallet every {@link WALLET_POLL_MS} and again whenever the tab regains + * visibility. Those refreshes are silent — they leave {@code loading} and + * {@code error} alone and only commit fresher data — so consumers that gate on + * those flags don't flicker on a background tick. + * *

Dev preview fallback

* * When the hook is rendered outside the saas app (e.g. on {@code @@ -178,6 +186,13 @@ function reuseIfEqual(prev: Wallet | null, next: Wallet): Wallet { return prev; } +/** + * How often a mounted consumer re-reads the wallet. Matches the app query + * client's staleTime, so the sidebar meter and anything cached elsewhere age + * out on the same clock. + */ +const WALLET_POLL_MS = 30_000; + export function useWallet(): UseWalletResult { // Resolved once: the dev-preview side-channel when rendered outside the real // app (saas /dev/payg-preview route), else null (every real build + desktop). @@ -201,13 +216,29 @@ export function useWallet(): UseWalletResult { // "the request fired." Cleared when no load is pending. const inFlight = useRef | null>(null); + // Set for refreshes the user didn't ask for (the poll below). Silence governs + // whether a load may RAISE `loading` / `error`, never whether it may clear + // them: consumers gate on both — the limit modals do + // `if (loading || !wallet) return null`, and Plan swaps in an error alert — + // so a background tick must not blink an open modal out or replace a working + // page over a transient failure. Clearing is always the latest request's job, + // silent or not; a silent load that skipped the clear would strand `loading` + // true after superseding a visible one, which suppresses those modals for the + // rest of the session. + const silentRefresh = useRef(false); + useEffect(() => { const reqId = ++latestReqId.current; let cancelled = false; + const silent = silentRefresh.current; + silentRefresh.current = false; + const promise = (async () => { - setLoading(true); - setError(null); + if (!silent) { + setLoading(true); + setError(null); + } if (devPreview) { const synth = devPreview.buildWallet(devPreview.role()); @@ -221,11 +252,22 @@ export function useWallet(): UseWalletResult { const res = await apiClient.get("/api/v1/payg/wallet"); if (cancelled || reqId !== latestReqId.current) return; setWallet((prev) => reuseIfEqual(prev, res.data)); + // Fresh data retires any earlier failure, including one a silent poll + // is recovering from — otherwise Plan keeps its alert over good data. + setError(null); } catch (e: unknown) { if (cancelled || reqId !== latestReqId.current) return; - console.warn("[useWallet] fetch failed", e); - setError(e instanceof Error ? e.message : "Failed to load wallet"); + if (!silent) { + console.warn("[useWallet] fetch failed", e); + setError(e instanceof Error ? e.message : "Failed to load wallet"); + } + // A failed background refresh is a non-event: the last good snapshot + // stands and the next tick self-heals, so it neither surfaces nor + // logs — otherwise an offline tab warns every WALLET_POLL_MS. } finally { + // Deliberately not gated on `silent`: whichever load is latest owns + // settling the flag, or a silent refresh that supersedes a visible one + // leaves it stuck true. if (!cancelled && reqId === latestReqId.current) { setLoading(false); } @@ -242,6 +284,46 @@ export function useWallet(): UseWalletResult { }; }, [devPreview, refetchTick]); + // The wallet drains as automation, AI and API work runs, so a figure fetched + // on mount goes stale while the user watches it. Refresh on a timer, and + // immediately on returning to the tab — coming back to a stale number is the + // case people actually notice. Hidden tabs don't poll, and the dev-preview + // wallet is synthesised locally so there is nothing to re-read. + useEffect(() => { + if (devPreview) return; + + let timer: ReturnType | undefined; + const refresh = () => { + silentRefresh.current = true; + setRefetchTick((t) => t + 1); + }; + const stop = () => { + if (timer !== undefined) { + clearInterval(timer); + timer = undefined; + } + }; + const start = () => { + stop(); + timer = setInterval(refresh, WALLET_POLL_MS); + }; + const onVisibilityChange = () => { + if (document.visibilityState === "visible") { + refresh(); + start(); + } else { + stop(); + } + }; + + if (document.visibilityState === "visible") start(); + document.addEventListener("visibilitychange", onVisibilityChange); + return () => { + stop(); + document.removeEventListener("visibilitychange", onVisibilityChange); + }; + }, [devPreview]); + const refetch = useCallback(async () => { setRefetchTick((t) => t + 1); // Snapshot the next-tick promise so the caller awaits this refetch diff --git a/frontend/editor/src/core/components/shared/BrandMark.css b/frontend/editor/src/core/components/shared/BrandMark.css index 7ddff9b4c7..df05ff1307 100644 --- a/frontend/editor/src/core/components/shared/BrandMark.css +++ b/frontend/editor/src/core/components/shared/BrandMark.css @@ -48,9 +48,55 @@ transform: matrix(0.483871, -0.017568, 0, 0.338028, 23.887097, 26.886428); } +/* One-shot "thinking" drift — the two parallelograms swap past each other and + settle back. Same motion the chat FAB loops while the agent works, but this + pair starts and ends at rest (translate 0, full opacity) so a single + iteration can end without snapping. Callers apply it for one beat; see + NavFooter.css for the hover use. */ +@keyframes sui-brandmark-drift-a { + 0%, + 100% { + transform: translate(0, 0); + opacity: 1; + } + 25% { + transform: translate(-1px, -5px); + opacity: 0.55; + } + 50% { + transform: translate(-6px, 0); + opacity: 0.9; + } + 75% { + transform: translate(-1px, 5px); + opacity: 0.6; + } +} + +@keyframes sui-brandmark-drift-b { + 0%, + 100% { + transform: translate(0, 0); + opacity: 1; + } + 25% { + transform: translate(1px, 5px); + opacity: 0.85; + } + 50% { + transform: translate(6px, 0); + opacity: 0.5; + } + 75% { + transform: translate(1px, -5px); + opacity: 0.85; + } +} + @media (prefers-reduced-motion: reduce) { .sui-brandmark__a, .sui-brandmark__b { transition: none; + animation: none; } } diff --git a/frontend/editor/src/core/components/shared/FileSidebar.css b/frontend/editor/src/core/components/shared/FileSidebar.css index 590f59fa7d..2347d9a2b2 100644 --- a/frontend/editor/src/core/components/shared/FileSidebar.css +++ b/frontend/editor/src/core/components/shared/FileSidebar.css @@ -75,16 +75,13 @@ padding: 0.25rem 0; overflow: hidden; } -.file-sidebar-footer-box { - padding: 0.25rem 0; - flex-shrink: 0; -} +/* The footer is the shared : it brings its own boxes and padding, + so this class only positions it in the column. */ /* Collapsed rail: the file tree isn't rendered, so hide its (empty) box and let the boxes stack at the top — controls, then the settings footer right after — instead of the files box stretching to fill. */ -.file-sidebar[data-collapsed="true"] .file-sidebar-controls, -.file-sidebar[data-collapsed="true"] .file-sidebar-footer-box { +.file-sidebar[data-collapsed="true"] .file-sidebar-controls { padding: 0.25rem; } .file-sidebar[data-collapsed="true"] .file-sidebar-files-box { @@ -538,86 +535,3 @@ pointer-events: none; animation: none; } - -/* ---- Bottom bar (user + settings) ---- */ -.file-sidebar-bottom-bar { - display: flex; - align-items: center; - gap: 8px; - padding: 4px 6px; - flex-shrink: 0; - min-height: 40px; -} - -/* Bottom bar settings icon tracks the right edge during collapse animation */ - -.file-sidebar-bottom-avatar { - width: 28px; - height: 28px; - border-radius: 50%; - background-color: var(--c-accent-text); - color: var(--c-text-on-primary); - font-size: 12px; - font-weight: 600; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - user-select: none; - overflow: hidden; -} - -/* No colored disc behind an actual photo; keep it for the initials fallback. */ -.file-sidebar-bottom-avatar--picture { - background-color: transparent; -} - -.file-sidebar-bottom-avatar-img { - width: 100%; - height: 100%; - border-radius: 50%; - object-fit: cover; -} - -.file-sidebar-bottom-name { - flex: 1; - font-size: 13px; - font-weight: 500; - color: var(--c-text); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - min-width: 0; -} - -.file-sidebar-bottom-bar[role="button"]:hover { - background-color: var(--c-hover); -} - -.file-sidebar-bottom-bar[role="button"]:focus-visible { - outline: 2px solid var(--c-primary); - outline-offset: -2px; -} - -.file-sidebar-bottom-settings { - display: flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - border-radius: 6px; - color: var(--c-text-subtle); - padding: 0; - flex-shrink: 0; - margin-left: auto; -} - -.file-sidebar[data-collapsed="true"] .file-sidebar-bottom-settings { - width: 32px; - height: 32px; -} - -.file-sidebar[data-collapsed="true"] .file-sidebar-bottom-bar { - justify-content: center; - padding: 8px 0; -} diff --git a/frontend/editor/src/core/components/shared/FileSidebar.tsx b/frontend/editor/src/core/components/shared/FileSidebar.tsx index 2514e7f556..1c06236027 100644 --- a/frontend/editor/src/core/components/shared/FileSidebar.tsx +++ b/frontend/editor/src/core/components/shared/FileSidebar.tsx @@ -22,13 +22,15 @@ import { } from "@app/contexts/NavigationContext"; import { useViewer } from "@app/contexts/ViewerContext"; import { useFileHandler } from "@app/hooks/useFileHandler"; -import { useAuth } from "@app/auth/UseSession"; -import { useProfilePictureUrl } from "@app/hooks/useProfilePictureUrl"; +import { 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 { useIndexedDB, useIndexedDBRevision, } from "@app/contexts/IndexedDBContext"; -import { accountService } from "@app/services/accountService"; import { GoogleDriveIcon } from "@app/components/shared/CloudStorageIcons"; import { AppSwitcher } from "@app/components/shared/AppSwitcher"; import { SidebarToggleIcon } from "@app/components/shared/SidebarToggleIcon"; @@ -37,8 +39,7 @@ import FolderOpenIcon from "@mui/icons-material/FolderOpen"; import FolderSpecialIcon from "@mui/icons-material/FolderSpecial"; import UploadFileIcon from "@mui/icons-material/UploadFile"; import AddIcon from "@mui/icons-material/Add"; -import OpenInNewIcon from "@mui/icons-material/OpenInNew"; -import SettingsIcon from "@mui/icons-material/Settings"; +import OpenInFullIcon from "@mui/icons-material/OpenInFull"; import type { FileId } from "@app/types/file"; import { FileItem } from "@app/components/shared/FileSidebarFileItem"; import { useLabelName } from "@app/data/labelDisplay"; @@ -241,43 +242,11 @@ const FileSidebar = forwardRef( const { addFiles } = useFileHandler(); const indexedDB = useIndexedDB(); - // Each auth layer derives its own displayName from its native user shape. - // Fall back to the proprietary REST endpoint only when the auth - // context yields nothing - then to "User" as a generic last resort. - const { displayName: authDisplayName, isAnonymous } = useAuth(); - const [accountUsername, setAccountUsername] = useState(null); - const displayName = - authDisplayName ?? accountUsername ?? t("auth.displayName.user", "User"); - - const profilePictureUrl = useProfilePictureUrl(); - const [pictureFailed, setPictureFailed] = useState(false); - useEffect(() => setPictureFailed(false), [profilePictureUrl]); - const showProfilePicture = !!profilePictureUrl && !pictureFailed; - - useEffect(() => { - if (!config?.enableLogin) { - setAccountUsername(null); - return; - } - if (authDisplayName) { - // The auth context has a name; don't bother hitting the REST - // endpoint, but clear any stale cached value from a prior call. - setAccountUsername(null); - return; - } - accountService - .getAccountData() - .then((data) => { - // Always reflect the latest result - including clearing it on - // sign-out, when the endpoint returns no username (or 401s into - // the catch branch below). Without this, signing out would leave - // the old username on screen. - setAccountUsername(data?.username ?? null); - }) - .catch(() => { - setAccountUsername(null); - }); - }, [config?.enableLogin, authDisplayName]); + const { displayName, profilePictureUrl, isAnonymous } = + useAccountIdentity(); + const credits = useFreeCreditsSummary(); + const otherApp = useOtherAppSwitch(); + const openPlan = useOpenPlan(); // Leaf files = user-visible files (excludes intermediate tool outputs) const [allFileStubs, setAllFileStubs] = useState([]); @@ -1115,7 +1084,7 @@ const FileSidebar = forwardRef( )} data-testid="open-files-page" > - + ( {/* Getting-started checklist, floating above the footer (SaaS only). */} - {/* Box 3 — account footer (avatar + name + settings). */} - - {/* Bottom bar: user name + settings */} - -
e.key === "Enter" && onOpenSettings() - : undefined - } - data-testid={onOpenSettings ? "config-button" : undefined} - data-tour={onOpenSettings ? "config-button" : undefined} - aria-label={ - onOpenSettings - ? t("fileSidebar.openSettings", "Open settings") - : displayName - } - style={onOpenSettings ? { cursor: "pointer" } : undefined} - > -
- {showProfilePicture ? ( - setPictureFailed(true)} - /> - ) : ( - displayName.charAt(0).toUpperCase() - )} -
- {!collapsed && ( - - {displayName} - - )} - {onOpenSettings && !collapsed && ( -
- -
- )} -
-
-
+ {/* Box 3 — the shared footer: credits, app switch, account row. */} +
); }, diff --git a/frontend/editor/src/core/components/shared/navFooter/NavFooter.css b/frontend/editor/src/core/components/shared/navFooter/NavFooter.css new file mode 100644 index 0000000000..aa2688cd35 --- /dev/null +++ b/frontend/editor/src/core/components/shared/navFooter/NavFooter.css @@ -0,0 +1,156 @@ +/* Shared sidebar footer: one surface holding the link-account CTA, the credits + meter, the other-app switch and the account row, hairline-separated. + Structural only — every colour comes from a --c-* semantic token. */ + +.nav-footer { + display: flex; + flex-direction: column; + flex-shrink: 0; + /* Vertical only: the slots carry the horizontal padding so their separator + runs the full width of the surface. */ + padding: 0.25rem 0; + overflow: hidden; +} + +.nav-footer__slot { + padding-inline: 0.375rem; +} + +/* Separators are drawn by the slots themselves, never as their own elements. + A slot whose contents render nothing (the link-account CTA returns null once + the org is linked, and an element is truthy even when it renders null) is + :empty, so it is skipped by both rules below — it can't leave a line behind, + and it can't push one to the top or bottom of the surface. A rule that only + ever matches a slot PRECEDED by another visible slot cannot draw a leading + separator, whatever the caller passes in. */ +.nav-footer__slot:empty { + display: none; +} + +.nav-footer__slot:not(:empty) ~ .nav-footer__slot:not(:empty) { + border-top: 1px solid var(--c-border-subtle); + margin-top: 0.25rem; + padding-top: 0.25rem; +} + +/* Fades the rows up on the first footer mount of a page session only. They are + seeded from cache, so they're already present at first paint; replaying this + on every later mount (switching apps, remounting a view) would animate + content that never changed and read as a twitch. */ +@keyframes nav-footer-row-in { + from { + opacity: 0; + transform: translateY(0.25rem); + } + to { + opacity: 1; + transform: none; + } +} + +.nav-footer[data-animate] .nav-footer__slot:not(:empty) { + animation: nav-footer-row-in var(--motion-enter) both; +} + +@media (prefers-reduced-motion: reduce) { + .nav-footer[data-animate] .nav-footer__slot:not(:empty) { + animation: none; + } +} + +/* ---- Rows (link-account, credits, switch, account) ---- */ + +.nav-footer__row { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + min-height: 2.25rem; + padding: 0.25rem 0.375rem; + border: 0; + border-radius: 0.5rem; + background: none; + color: var(--c-text); + font: inherit; + text-align: left; + cursor: pointer; +} + +.nav-footer__row:disabled { + cursor: default; +} + +.nav-footer__row:not(:disabled):hover { + background-color: var(--c-hover); +} + +.nav-footer__row:focus-visible { + outline: 2px solid var(--c-primary); + outline-offset: -2px; +} + +.nav-footer__row-icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 1.625rem; +} + +/* Hovering the switch row plays the mark's "thinking" drift once — the same + motion the chat FAB loops, for a single beat, as a hint that the row hands + off to the other app. One iteration only: it starts and ends at rest, so + nothing snaps when it finishes, and re-entering the row replays it. */ +.nav-footer__row:hover .sui-brandmark__a { + animation: sui-brandmark-drift-a 1.1s ease-in-out 1; +} +.nav-footer__row:hover .sui-brandmark__b { + animation: sui-brandmark-drift-b 1.1s ease-in-out 1; +} + +.nav-footer__row-label { + flex: 1; + min-width: 0; + font-size: 0.8125rem; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Trailing affordance on a row: the account row's gear, the switch row's + leaving-this-app arrow. */ +.nav-footer__trailing { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-left: auto; + color: var(--c-text-subtle); +} + +/* Rows contributed by a caller (the link-account NavItem) sit in the same + surface, so match this footer's row metrics rather than the nav rail's. */ +.nav-footer .sui-navitem { + min-height: 2.25rem; + padding: 0.25rem 0.375rem; + margin: 0; + border-radius: 0.5rem; + font-size: 0.8125rem; +} + +/* ---- Collapsed icon rail ---- */ + +.nav-footer[data-collapsed] .nav-footer__slot { + padding-inline: 0.25rem; +} + +.nav-footer[data-collapsed] .nav-footer__row { + justify-content: center; + padding-inline: 0; +} + +.nav-footer[data-collapsed] .sui-navitem { + justify-content: center; + padding-inline: 0; +} diff --git a/frontend/editor/src/core/components/shared/navFooter/NavFooter.stories.tsx b/frontend/editor/src/core/components/shared/navFooter/NavFooter.stories.tsx new file mode 100644 index 0000000000..31a04be596 --- /dev/null +++ b/frontend/editor/src/core/components/shared/navFooter/NavFooter.stories.tsx @@ -0,0 +1,119 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import LinkIcon from "@mui/icons-material/Link"; +import { NavItem } from "@app/ui/NavItem"; +import { NavFooter } from "@app/components/shared/navFooter/NavFooter"; + +/** Stands in for a CTA that has decided it has nothing to show. */ +function RendersNothing() { + return null; +} + +const meta: Meta = { + title: "Shared/NavFooter", + component: NavFooter, + parameters: { layout: "padded" }, + args: { + displayName: "admin", + onOpenSettings: () => {}, + credits: { remaining: 247, total: 500 }, + onOpenPlan: () => {}, + otherApp: { app: "processor", onOpen: () => {} }, + }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +/** The editor's footer: credits, "Open PDF Processor", the account row. */ +export const InEditor: Story = {}; + +/** The processor's footer. Same three boxes, opposite switch target. */ +export const InProcessor: Story = { + args: { otherApp: { app: "editor", onOpen: () => {} } }, +}; + +/** Self-hosted processor: no wallet, so no meter, and the link-account CTA + * rides along in the account box. */ +export const WithLinkAccountCta: Story = { + args: { + credits: null, + otherApp: { app: "editor", onOpen: () => {} }, + accountExtras: ( + } + /> + ), + }, +}; + +/** Regression guard: the processor always passes its link-account CTA, but that + * component renders null once the org is linked. An element is truthy even + * when it renders nothing, so this must not leave a separator above the first + * visible row. */ +export const ExtrasThatRenderNothing: Story = { + args: { accountExtras: }, +}; + +/** A real profile picture replaces the initials disc. */ +export const WithProfilePicture: Story = { + args: { + displayName: "Ada Lovelace", + profilePictureUrl: + "data:image/svg+xml;utf8," + + encodeURIComponent( + '', + ), + }, +}; + +/** Credits running low — the dot and bar shift to the warning tone at 20% left. */ +export const CreditsLow: Story = { + args: { credits: { remaining: 42, total: 500 } }, +}; + +/** Allowance exhausted. */ +export const CreditsExhausted: Story = { + args: { credits: { remaining: 0, total: 500 } }, +}; + +/** Core OSS: no wallet, no second app, settings only. */ +export const MinimalBuild: Story = { + args: { credits: null, otherApp: null }, +}; + +/** No settings handler — the account row is inert identity, not a button. */ +export const NoSettings: Story = { + args: { onOpenSettings: undefined }, +}; + +/** Collapsed icon rail: labels become tooltips. */ +export const Collapsed: Story = { + args: { collapsed: true }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; diff --git a/frontend/editor/src/core/components/shared/navFooter/NavFooter.test.tsx b/frontend/editor/src/core/components/shared/navFooter/NavFooter.test.tsx new file mode 100644 index 0000000000..a32063e56f --- /dev/null +++ b/frontend/editor/src/core/components/shared/navFooter/NavFooter.test.tsx @@ -0,0 +1,58 @@ +import { describe, it, expect } from "vitest"; +import { cleanup, render } from "@testing-library/react"; +import { MantineProvider } from "@mantine/core"; +import { NavFooter } from "@app/components/shared/navFooter/NavFooter"; + +/** The footer's tooltips need Mantine's theme context. */ +function withProviders(ui: React.ReactNode) { + return {ui}; +} + +function renderFooter() { + const { container } = render( + withProviders( + {}} + credits={{ remaining: 247, total: 500 }} + otherApp={{ app: "processor", onOpen: () => {} }} + />, + ), + ); + return container.querySelector(".nav-footer") as HTMLElement; +} + +describe("NavFooter — enter animation", () => { + it("plays once per page session, not on every remount", () => { + // The rows are seeded from cache, so they're present at first paint. Every + // later mount — switching apps, remounting a view — would otherwise replay + // the fade on content that never changed, which reads as a twitch. + expect(renderFooter().dataset.animate).toBe("true"); + cleanup(); + expect(renderFooter().dataset.animate).toBeUndefined(); + cleanup(); + expect(renderFooter().dataset.animate).toBeUndefined(); + }); +}); + +describe("NavFooter — separators", () => { + it("never renders a divider beside a row that renders nothing", () => { + // Dividers are CSS between adjacent non-empty slots, so an extras element + // that returns null (the linked org's link-account CTA) can't leave a line. + const { container } = render( + withProviders( + {}} + credits={null} + otherApp={null} + accountExtras={<>{null}} + />, + ), + ); + const slots = container.querySelectorAll(".nav-footer__slot"); + const filled = [...slots].filter((s) => s.childElementCount > 0); + expect(filled).toHaveLength(1); + expect(container.querySelectorAll(".nav-footer__divider")).toHaveLength(0); + }); +}); diff --git a/frontend/editor/src/core/components/shared/navFooter/NavFooter.tsx b/frontend/editor/src/core/components/shared/navFooter/NavFooter.tsx new file mode 100644 index 0000000000..373c91bccf --- /dev/null +++ b/frontend/editor/src/core/components/shared/navFooter/NavFooter.tsx @@ -0,0 +1,213 @@ +import { useState, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { Tooltip } from "@mantine/core"; +import ArrowForwardIcon from "@mui/icons-material/ArrowForward"; +import SettingsIcon from "@mui/icons-material/Settings"; +import { Avatar, NavSurface } from "@app/ui"; +import { BrandMark } from "@app/components/shared/BrandMark"; +import { type AppSwitchTarget } from "@app/components/shared/AppSwitch"; +import { + NavFooterCreditsRow, + type NavFooterCredits, +} from "@app/components/shared/navFooter/NavFooterCreditsRow"; +import "@app/components/shared/navFooter/NavFooter.css"; + +export interface NavFooterAppLink { + /** The app this footer is NOT in — the one the row opens. */ + app: AppSwitchTarget; + onOpen: () => void; +} + +export interface NavFooterProps { + /** Name shown next to the avatar, and the source of its initials fallback. */ + displayName: string; + /** Profile picture; initials are drawn when absent or the URL fails to load. */ + profilePictureUrl?: string | null; + /** Omit to render the account row as static text (no settings affordance). */ + onOpenSettings?: () => void; + /** Null/undefined hides the meter — builds with no wallet never show it. */ + credits?: NavFooterCredits | null; + /** Opens the plan surface from the credits row; omit to leave it inert. */ + onOpenPlan?: () => void; + /** Null/undefined hides the switch row — e.g. no access to the other app. */ + otherApp?: NavFooterAppLink | null; + /** Extra rows above the account row (the self-hosted link-account CTA). */ + accountExtras?: ReactNode; + /** Icon-rail state: labels collapse to tooltips. */ + collapsed?: boolean; + className?: string; +} + +/** + * Whether the enter animation has already played this page session. The rows + * are seeded from cache now, so they're present from first paint and every + * later mount — switching apps, remounting a view — would otherwise replay the + * animation on content that never changed, which reads as the UI twitching. + */ +let hasPlayedEnter = false; + +/** + * The bottom section every sidebar ends with, shared by the editor and the + * processor so both present the same rows. ONE surface, hairline-separated, in + * this order: + * + * 1. caller-contributed rows (the self-hosted link-account CTA) + * 2. free credits remaining + * 3. "Open " + * 4. the account row — avatar, name, settings + * + * Purely presentational: each app resolves its own identity, wallet and + * app-switch access and passes them in, so this file carries no build-specific + * gating. A row whose data is absent is dropped, and so is the separator that + * would have sat beside it. + */ +export function NavFooter({ + displayName, + profilePictureUrl, + onOpenSettings, + credits, + onOpenPlan, + otherApp, + accountExtras, + collapsed = false, + className, +}: NavFooterProps) { + const { t } = useTranslation(); + const [animate] = useState(() => { + if (hasPlayedEnter) return false; + hasPlayedEnter = true; + return true; + }); + + const settingsLabel = t("fileSidebar.openSettings", "Open settings"); + const accountLabel = onOpenSettings + ? `${displayName} - ${settingsLabel}` + : displayName; + + // One surface, hairline-separated rows. Each row gets a slot; the separators + // are drawn by CSS between adjacent NON-EMPTY slots (see NavFooter.css), so a + // row that renders nothing — the link-account CTA returns null once the org is + // linked, and an element is truthy even then — can't leave a line behind. + const rows: Array<{ key: string; node: ReactNode }> = []; + + if (accountExtras) rows.push({ key: "extras", node: accountExtras }); + + if (credits) { + rows.push({ + key: "credits", + node: ( + + ), + }); + } + + if (otherApp) { + rows.push({ + key: "switch", + node: ( + + + + ), + }); + } + + rows.push({ + key: "account", + node: ( + + + + ), + }); + + return ( + + {rows.map((row) => ( +
+ {row.node} +
+ ))} +
+ ); +} + +function openAppLabel( + app: AppSwitchTarget, + t: (key: string, fallback: string) => string, +): string { + return app === "editor" + ? t("navFooter.openEditor", "Open PDF Editor") + : t("navFooter.openProcessor", "Open PDF Processor"); +} diff --git a/frontend/editor/src/core/components/shared/navFooter/NavFooterCreditsRow.css b/frontend/editor/src/core/components/shared/navFooter/NavFooterCreditsRow.css new file mode 100644 index 0000000000..b37def6b2d --- /dev/null +++ b/frontend/editor/src/core/components/shared/navFooter/NavFooterCreditsRow.css @@ -0,0 +1,83 @@ +/* Free-credits meter inside the sidebar footer. The row base (padding, hover, + focus) comes from NavFooter.css; these rules are the meter itself. */ + +.nav-footer__credits { + flex-direction: column; + align-items: stretch; + gap: 0.375rem; + cursor: default; +} + +/* Inert by default, so it must not read as hoverable; the actionable variant + opts back into the shared row hover. */ +.nav-footer__credits:hover { + background: none; +} + +.nav-footer__credits--actionable { + cursor: pointer; +} +.nav-footer__credits--actionable:hover { + background-color: var(--c-hover); +} + +.nav-footer__credits-head { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.8125rem; +} + +.nav-footer__dot { + width: 0.4375rem; + height: 0.4375rem; + border-radius: 50%; + flex-shrink: 0; + background-color: var(--c-success); +} +.nav-footer__dot[data-tone="warning"] { + background-color: var(--c-warning); +} +.nav-footer__dot[data-tone="danger"] { + background-color: var(--c-danger); +} + +.nav-footer__credits-label { + flex: 1; + min-width: 0; + font-weight: 500; + color: var(--c-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.nav-footer__credits-count { + flex-shrink: 0; + color: var(--c-text-muted); + font-variant-numeric: tabular-nums; +} + +/* ---- Collapsed rail ---- */ + +/* Rotated so the fill starts at 12 o'clock and runs clockwise. */ +.nav-footer__credits-ring { + width: 1.25rem; + height: 1.25rem; + margin-inline: auto; + transform: rotate(-90deg); +} + +.nav-footer__credits-ring-track, +.nav-footer__credits-ring-fill { + fill: none; + stroke-width: 3; +} + +.nav-footer__credits-ring-track { + stroke: var(--c-surface-sunken); +} + +.nav-footer__credits-ring-fill { + stroke-linecap: round; +} diff --git a/frontend/editor/src/core/components/shared/navFooter/NavFooterCreditsRow.tsx b/frontend/editor/src/core/components/shared/navFooter/NavFooterCreditsRow.tsx new file mode 100644 index 0000000000..94e941d71c --- /dev/null +++ b/frontend/editor/src/core/components/shared/navFooter/NavFooterCreditsRow.tsx @@ -0,0 +1,158 @@ +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { Tooltip } from "@mantine/core"; +import { ProgressBar } from "@app/ui"; +import "@app/components/shared/navFooter/NavFooterCreditsRow.css"; + +export interface NavFooterCredits { + /** Free credits still available to spend. */ + remaining: number; + /** Size of the free allowance — the "of N" denominator. */ + total: number; +} + +/** Remaining-credit bands, mirroring the usage meters' 80% / 100% thresholds. */ +function creditsTone(remaining: number, total: number): string { + if (remaining <= 0) return "danger"; + return total > 0 && remaining / total <= 0.2 ? "warning" : "success"; +} + +interface NavFooterCreditsRowProps { + credits: NavFooterCredits; + /** Icon rail: the figures drop and the bar alone carries the state. */ + collapsed: boolean; + /** Row label, passed in so the meter owns no copy of its own. */ + label: string; + /** Opens the plan surface. Omit to render the meter as inert text. */ + onOpen?: () => void; +} + +/** + * The free-credits meter as it appears in the sidebar footer: a state dot, the + * label, "X of Y" remaining, and a fill bar underneath. Figures are clamped + * here so a wallet that reports more remaining than the allowance (or negative) + * can't overflow the bar. + * + * Rendered as a {@code nav-footer__row}, so it inherits that row's metrics + * from NavFooter.css and only brings its own meter styling. + */ +export function NavFooterCreditsRow({ + credits, + collapsed, + label, + onOpen, +}: NavFooterCreditsRowProps) { + const { t } = useTranslation(); + + const total = Math.max(0, credits.total); + const remaining = Math.min(Math.max(0, credits.remaining), total); + const tone = creditsTone(remaining, total); + const count = t("navFooter.credits.count", "{{remaining}} of {{total}}", { + remaining: remaining.toLocaleString(), + total: total.toLocaleString(), + }); + + return ( + + + {collapsed ? ( + // The rail is one icon wide, so a full-width bar would read as a + // stray line; a ring carries the same fraction at icon size. + 0 ? remaining / total : 0} + tone={tone} + label={`${label}: ${count}`} + /> + ) : ( + <> +
+ + {label} + {count} +
+ 0 ? remaining / total : 0} + height={6} + color={`var(--c-${tone})`} + label={`${label}: ${count}`} + /> + + )} +
+
+ ); +} + +/** Icon-sized donut carrying the same remaining fraction as the expanded bar. */ +function CreditsRing({ + fraction, + tone, + label, +}: { + fraction: number; + tone: string; + label: string; +}) { + const RADIUS = 8; + const circumference = 2 * Math.PI * RADIUS; + const filled = Math.min(1, Math.max(0, fraction)) * circumference; + + return ( + + + + + ); +} + +/** + * The meter is a button only where there is a plan surface to open — otherwise + * it stays a plain div, so a build with nowhere to go doesn't advertise a + * click that does nothing. + */ +function Row({ + onOpen, + label, + children, +}: { + onOpen?: () => void; + label: string; + children: ReactNode; +}) { + const className = `nav-footer__row nav-footer__credits${ + onOpen ? " nav-footer__credits--actionable" : "" + }`; + if (!onOpen) return
{children}
; + return ( + + ); +} diff --git a/frontend/editor/src/core/hooks/useAccountIdentity.ts b/frontend/editor/src/core/hooks/useAccountIdentity.ts new file mode 100644 index 0000000000..026ac08dab --- /dev/null +++ b/frontend/editor/src/core/hooks/useAccountIdentity.ts @@ -0,0 +1,64 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useAuth } from "@app/auth/UseSession"; +import { useProfilePictureUrl } from "@app/hooks/useProfilePictureUrl"; +import { useAppConfig } from "@app/contexts/AppConfigContext"; +import { accountService } from "@app/services/accountService"; + +export interface AccountIdentity { + /** Never empty — falls back to a generic "User" so a row is never blank. */ + displayName: string; + profilePictureUrl: string | null; + isAnonymous: boolean; +} + +/** + * The signed-in identity as the UI should draw it: one name and one picture, + * resolved the same way everywhere. Every surface that shows "who am I" (the + * editor and processor sidebar footers, the account settings page) reads this, + * so a user can't see one initial in the sidebar and a different one in + * settings. + * + * Resolution order for the name: the auth layer's own displayName (each layer + * derives it from its native user shape), then the proprietary REST endpoint, + * then a generic last resort. + */ +export function useAccountIdentity(): AccountIdentity { + const { t } = useTranslation(); + const { config } = useAppConfig(); + const { displayName: authDisplayName, isAnonymous } = useAuth(); + const profilePictureUrl = useProfilePictureUrl(); + const [accountUsername, setAccountUsername] = useState(null); + + useEffect(() => { + if (!config?.enableLogin) { + setAccountUsername(null); + return; + } + if (authDisplayName) { + // The auth context has a name; don't bother hitting the REST + // endpoint, but clear any stale cached value from a prior call. + setAccountUsername(null); + return; + } + accountService + .getAccountData() + .then((data) => { + // Always reflect the latest result - including clearing it on + // sign-out, when the endpoint returns no username (or 401s into + // the catch branch below). Without this, signing out would leave + // the old username on screen. + setAccountUsername(data?.username ?? null); + }) + .catch(() => { + setAccountUsername(null); + }); + }, [config?.enableLogin, authDisplayName]); + + return { + displayName: + authDisplayName ?? accountUsername ?? t("auth.displayName.user", "User"), + profilePictureUrl, + isAnonymous, + }; +} diff --git a/frontend/editor/src/core/hooks/useFreeCreditsSummary.ts b/frontend/editor/src/core/hooks/useFreeCreditsSummary.ts new file mode 100644 index 0000000000..52702a3f74 --- /dev/null +++ b/frontend/editor/src/core/hooks/useFreeCreditsSummary.ts @@ -0,0 +1,12 @@ +import { type NavFooterCredits } from "@app/components/shared/navFooter/NavFooterCreditsRow"; + +/** + * Free credits left on this team's allowance, for the sidebar footer meter. + * Null hides the meter entirely. + * + * Core has no wallet — self-hosted installs aren't metered — so there is + * nothing to show. Cloud builds override this with the live wallet figure. + */ +export function useFreeCreditsSummary(): NavFooterCredits | null { + return null; +} diff --git a/frontend/editor/src/core/hooks/useOpenPlan.ts b/frontend/editor/src/core/hooks/useOpenPlan.ts new file mode 100644 index 0000000000..da6fe2207e --- /dev/null +++ b/frontend/editor/src/core/hooks/useOpenPlan.ts @@ -0,0 +1,10 @@ +/** + * Opens the plan surface behind the sidebar footer's free-credits row, or null + * when this build has none (the row is then inert text rather than a button). + * + * Core ships no wallet and no plan section, so there is nothing to open. Builds + * that meter usage override this with their own surface. + */ +export function useOpenPlan(): (() => void) | null { + return null; +} diff --git a/frontend/editor/src/core/hooks/useOtherAppSwitch.ts b/frontend/editor/src/core/hooks/useOtherAppSwitch.ts new file mode 100644 index 0000000000..612589899b --- /dev/null +++ b/frontend/editor/src/core/hooks/useOtherAppSwitch.ts @@ -0,0 +1,12 @@ +import { type NavFooterAppLink } from "@app/components/shared/navFooter/NavFooter"; + +/** + * The sibling app this build can switch to (editor ⇄ processor), or null when + * there is none. The single gate behind both the brand switcher and the + * sidebar footer's "Open ..." row, so the two can never disagree about access. + * + * Core ships no processor, so there is nothing to switch to. + */ +export function useOtherAppSwitch(): NavFooterAppLink | null { + return null; +} diff --git a/frontend/editor/src/core/query/keys.ts b/frontend/editor/src/core/query/keys.ts index a7a68ea256..5354b56b63 100644 --- a/frontend/editor/src/core/query/keys.ts +++ b/frontend/editor/src/core/query/keys.ts @@ -6,5 +6,8 @@ export const qk = { ["editor", "endpointEnabled", endpoint] as const, footerInfo: () => ["editor", "footerInfo"] as const, groupEnabled: (group: string) => ["editor", "groupEnabled", group] as const, + /** Keyed on the asking identity: two users must never share one answer. */ + portalAccess: (userId: string | null) => + ["editor", "portalAccess", userId] as const, users: () => ["editor", "users"] as const, } as const; diff --git a/frontend/editor/src/core/services/navFooterCache.ts b/frontend/editor/src/core/services/navFooterCache.ts new file mode 100644 index 0000000000..cbde941c81 --- /dev/null +++ b/frontend/editor/src/core/services/navFooterCache.ts @@ -0,0 +1,73 @@ +/** + * Last-known sidebar-footer state, so the rows are correct at first paint + * instead of arriving a request later. + * + * The footer is mounted by both apps, and the editor and processor are separate + * React trees with separate query caches — so without this, every navigation + * between them (and every remount inside them) re-ran the fetches and the rows + * visibly popped in and shoved each other around. Persisting to storage rather + * than to an in-memory cache is what makes it survive that boundary, and a + * reload. + * + * Deliberately stale-then-revalidate: what's stored is only ever what the + * backend last said, every reader refetches immediately and overwrites, and + * nothing is gated on it — the processor enforces its own access server-side, + * and a stale credit figure is replaced within a second of the wallet landing. + */ +const CREDITS_KEY = "stirling.navFooter.credits"; +const OTHER_APP_KEY = "stirling.navFooter.otherApp"; + +/** Figures, or null for a team that sees no meter at all (a paying one). */ +export type CachedCredits = { remaining: number; total: number } | null; + +function read(key: string): string | null { + try { + return localStorage.getItem(key); + } catch { + // Private mode / storage disabled — behave as a first-ever load. + return null; + } +} + +function write(key: string, value: string): void { + try { + localStorage.setItem(key, value); + } catch { + // Nothing to do: the cache is an optimisation, never a correctness input. + } +} + +/** `undefined` when this browser has never seen an answer. */ +export function readCachedCredits(): CachedCredits | undefined { + const raw = read(CREDITS_KEY); + if (raw === null) return undefined; + if (raw === "none") return null; + try { + const parsed: unknown = JSON.parse(raw); + if ( + typeof parsed === "object" && + parsed !== null && + typeof (parsed as CachedCredits & object).remaining === "number" && + typeof (parsed as CachedCredits & object).total === "number" + ) { + return parsed as CachedCredits; + } + } catch { + // Corrupt entry — fall through and treat it as never-seen. + } + return undefined; +} + +export function writeCachedCredits(credits: CachedCredits): void { + write(CREDITS_KEY, credits === null ? "none" : JSON.stringify(credits)); +} + +/** `undefined` when this browser has never seen an answer. */ +export function readCachedOtherApp(): boolean | undefined { + const raw = read(OTHER_APP_KEY); + return raw === null ? undefined : raw === "true"; +} + +export function writeCachedOtherApp(canOpen: boolean): void { + write(OTHER_APP_KEY, String(canOpen)); +} diff --git a/frontend/editor/src/core/ui/Avatar.css b/frontend/editor/src/core/ui/Avatar.css index e2e8dc63bd..360c15c566 100644 --- a/frontend/editor/src/core/ui/Avatar.css +++ b/frontend/editor/src/core/ui/Avatar.css @@ -46,6 +46,12 @@ height: 2.5rem; font-size: 1rem; } +/* Account-settings hero disc. */ +.sui-avatar--xl { + width: 4.5rem; + height: 4.5rem; + font-size: 1.75rem; +} .sui-avatar__img { width: 100%; diff --git a/frontend/editor/src/core/ui/Avatar.tsx b/frontend/editor/src/core/ui/Avatar.tsx index c7cfac501b..42e7aca11f 100644 --- a/frontend/editor/src/core/ui/Avatar.tsx +++ b/frontend/editor/src/core/ui/Avatar.tsx @@ -1,6 +1,7 @@ +import { useEffect, useState } from "react"; import "@app/ui/Avatar.css"; -export type AvatarSize = "xs" | "sm" | "md" | "lg"; +export type AvatarSize = "xs" | "sm" | "md" | "lg" | "xl"; export type AvatarTone = | "blue" | "purple" @@ -23,10 +24,12 @@ export interface AvatarProps { className?: string; } -function initialsOf(name: string): string { +function avatarInitials(name: string): string { const parts = name.trim().split(/\s+/).filter(Boolean); if (parts.length === 0) return "?"; - if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); + // Single word (a username or an email) reads as one letter — two letters of + // "admin" ("AD") looks like a different person's initials, not a truncation. + if (parts.length === 1) return parts[0].slice(0, 1).toUpperCase(); return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); } @@ -43,6 +46,13 @@ export function Avatar({ ariaLabel, className, }: AvatarProps) { + // A picture URL that 404s (expired signed URL, deleted upload) must not leave + // an empty disc — fall back to the same initials the no-picture case shows, so + // every surface rendering this identity agrees on what it draws. + const [srcFailed, setSrcFailed] = useState(false); + useEffect(() => setSrcFailed(false), [src]); + const showImage = Boolean(src) && !srcFailed; + const classes = [ "sui-avatar", `sui-avatar--${size}`, @@ -53,11 +63,16 @@ export function Avatar({ .filter(Boolean) .join(" "); - const content = src ? ( - {ariaLabel + const content = showImage ? ( + {ariaLabel setSrcFailed(true)} + /> ) : ( - {initialsOf(name)} + {avatarInitials(name)} ); diff --git a/frontend/editor/src/portal-saas/hooks/useFreeCreditsSummary.ts b/frontend/editor/src/portal-saas/hooks/useFreeCreditsSummary.ts new file mode 100644 index 0000000000..8f7a16ca90 --- /dev/null +++ b/frontend/editor/src/portal-saas/hooks/useFreeCreditsSummary.ts @@ -0,0 +1,7 @@ +/** + * SaaS has no link concept — the signed-in account IS the SaaS account, and the + * editor's cloud wallet hook is already in this build's {@code @app/*} cascade. + * Delegating to it means the processor footer and the editor footer share one + * wallet fetch and can't disagree, so there is nothing portal-specific to do. + */ +export { useFreeCreditsSummary } from "@app/hooks/useFreeCreditsSummary"; diff --git a/frontend/editor/src/portal-saas/hooks/useOpenPlan.ts b/frontend/editor/src/portal-saas/hooks/useOpenPlan.ts new file mode 100644 index 0000000000..ce12e843b3 --- /dev/null +++ b/frontend/editor/src/portal-saas/hooks/useOpenPlan.ts @@ -0,0 +1,11 @@ +import { useCallback } from "react"; +import { useUI } from "@portal/contexts/UIContext"; + +/** + * SaaS processor: the settings modal it hosts carries the same Plan section the + * editor opens, so the footer's credits row lands both apps in one place. + */ +export function useOpenPlan(): (() => void) | null { + const { openSettings } = useUI(); + return useCallback(() => openSettings("plan"), [openSettings]); +} diff --git a/frontend/editor/src/portal/components/Sidebar.css b/frontend/editor/src/portal/components/Sidebar.css index dfb533f8ab..48b54b177d 100644 --- a/frontend/editor/src/portal/components/Sidebar.css +++ b/frontend/editor/src/portal/components/Sidebar.css @@ -123,8 +123,6 @@ } .portal-sidebar[data-collapsed] .portal-sidebar__footer { margin-inline: 0.375rem; - padding-inline: 0; - align-items: center; } .portal-sidebar__logo { @@ -179,10 +177,8 @@ gap: 0.125rem; } +/* The shared brings its own boxes, padding and gap; the sidebar + only positions it. */ .portal-sidebar__footer { margin: 0 0.625rem 0.75rem; - padding: 0.5rem 0.375rem; - display: flex; - flex-direction: column; - gap: 0.5rem; } diff --git a/frontend/editor/src/portal/components/Sidebar.tsx b/frontend/editor/src/portal/components/Sidebar.tsx index ebf91c636e..8ce7008d67 100644 --- a/frontend/editor/src/portal/components/Sidebar.tsx +++ b/frontend/editor/src/portal/components/Sidebar.tsx @@ -2,6 +2,10 @@ 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 { 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"; @@ -10,7 +14,7 @@ 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 { CloseIcon, SettingsIcon } from "@portal/components/icons"; +import { CloseIcon } from "@portal/components/icons"; import { GROUP_PROCESSOR, GROUP_PLATFORM, @@ -41,6 +45,9 @@ export function Sidebar() { const isMobile = useMediaQuery(MOBILE_QUERY, false, { getInitialValueInEffect: false, }); + const { displayName, profilePictureUrl } = useAccountIdentity(); + const credits = useFreeCreditsSummary(); + const openPlan = useOpenPlan(); // Collapse is a desktop-only affordance: on mobile the sidebar is an // off-canvas drawer, so the icon-rail state never applies there. @@ -146,15 +153,17 @@ export function Sidebar() { ))} - - - } - onClick={() => openSettings()} - /> - + } + collapsed={collapsed} + /> ); } diff --git a/frontend/editor/src/portal/components/billing/PrepaidCapacityCard.tsx b/frontend/editor/src/portal/components/billing/PrepaidCapacityCard.tsx index c7ab0e8704..6ea4d8d336 100644 --- a/frontend/editor/src/portal/components/billing/PrepaidCapacityCard.tsx +++ b/frontend/editor/src/portal/components/billing/PrepaidCapacityCard.tsx @@ -1,6 +1,6 @@ import { useTranslation } from "react-i18next"; import { Button, Card } from "@app/ui"; -import { formatPeriodDate, MeterBar, meterState } from "@app/billing"; +import { formatPeriodDate, MeterBar, remainingMeter } from "@app/billing"; import type { Wallet } from "@portal/api/billing"; /** @@ -10,8 +10,8 @@ import type { Wallet } from "@portal/api/billing"; * - No bundle → a slim "Get 12 months for the price of 10" offer nudge with a * "Review offer" CTA (the demo's commit-nudge card), shown only when a buyer * ({@code onBuy}, leader) is present. - * - Bundle held → the capacity meter (fills as the pool is drawn down, so it - * warns as capacity runs low) plus a "Top up" action for the leader. + * - Bundle held → the capacity meter (drains towards empty as the pool is drawn + * down, so it warns as capacity runs low) plus a "Top up" action for the leader. * * Prepaid is consumed before metered billing and sits outside the spend limit, so * it reads as its own dimension. Buying/topping up opens {@code BundleCheckoutModal} @@ -55,8 +55,7 @@ export function PrepaidCapacityCard({ const remaining = wallet.prepaidUnitsRemaining; const total = wallet.prepaidUnitsTotal; - const used = Math.max(0, total - remaining); - const { state, pct } = meterState(used, total); + const { state, pct } = remainingMeter(remaining, total); const stateLabel = state === "DEGRADED" ? t("portal.billing.prepaid.state.exhausted", "Used up") diff --git a/frontend/editor/src/portal/components/billing/WalletMeter.tsx b/frontend/editor/src/portal/components/billing/WalletMeter.tsx index c8be188390..9558960e89 100644 --- a/frontend/editor/src/portal/components/billing/WalletMeter.tsx +++ b/frontend/editor/src/portal/components/billing/WalletMeter.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { Card } from "@app/ui"; -import { formatMinor, MeterBar, meterState } from "@app/billing"; +import { formatMinor, MeterBar, remainingMeter } from "@app/billing"; import type { Wallet } from "@portal/api/billing"; import type { LocalUsage } from "@portal/api/link"; @@ -15,8 +15,10 @@ interface Props { } /** - * The free Processor-trial meter — "X / N free PDFs used" against the one-time - * grant. Uses the shared {@link MeterBar} (same `paygf-meter` structure as the + * The free Processor-trial meter — "X of N free PDFs left" against the one-time + * grant, with what has been used alongside as the status badge. The bar shows what + * is left, so it drains towards empty as the grant is spent. + * Uses the shared {@link MeterBar} (same `paygf-meter` structure as the * cloud plan page). The subscribed spend-vs-cap meter is a separate surface * ({@code SpendLimitCard}); this card is only the free face. * @@ -30,7 +32,7 @@ export function WalletMeter({ wallet, unsynced, action }: Props) { const pending = unsynced?.totalUnsyncedUnits ?? 0; const used = wallet.billableUsed + pending; const remaining = Math.max(0, wallet.freeRemaining - pending); - const { state, pct } = meterState(used, wallet.freeAllowance); + const { state, pct } = remainingMeter(remaining, wallet.freeAllowance); const rate = wallet.pricePerDocMinor != null && wallet.pricePerDocMinor > 0 ? wallet.pricePerDocMinor @@ -76,11 +78,14 @@ export function WalletMeter({ wallet, unsynced, action }: Props) {
diff --git a/frontend/editor/src/portal/hooks/useFreeCreditsSummary.test.tsx b/frontend/editor/src/portal/hooks/useFreeCreditsSummary.test.tsx new file mode 100644 index 0000000000..fb48250fed --- /dev/null +++ b/frontend/editor/src/portal/hooks/useFreeCreditsSummary.test.tsx @@ -0,0 +1,110 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { cleanup, render, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { LinkProvider, type LinkState } from "@portal/contexts/LinkContext"; +import { useFreeCreditsSummary } from "@portal/hooks/useFreeCreditsSummary"; + +const fetchWallet = vi.fn(); +vi.mock("@portal/api/billing", () => ({ + fetchWallet: () => fetchWallet(), +})); + +function Probe() { + const credits = useFreeCreditsSummary(); + return ( + + {credits ? `${credits.remaining}/${credits.total}` : "none"} + + ); +} + +function renderFor(initialState: LinkState) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + + + + + , + ).getByTestId("credits"); +} + +describe("useFreeCreditsSummary (self-hosted) — wallet behind the link gate", () => { + beforeEach(() => { + // The figures persist across mounts now, so isolate the suite from itself. + localStorage.clear(); + fetchWallet.mockReset(); + fetchWallet.mockResolvedValue({ + status: "free", + freeRemaining: 247, + freeAllowance: 500, + }); + }); + + it("unlinked reads no wallet at all", async () => { + const el = renderFor("unlinked"); + await waitFor(() => expect(el.textContent).toBe("none")); + expect(fetchWallet).not.toHaveBeenCalled(); + }); + + it("linked surfaces the free grant", async () => { + const el = renderFor("linked-free"); + await waitFor(() => expect(el.textContent).toBe("247/500")); + }); + + it("hides the meter once the team subscribes", async () => { + // The grant is a lifetime pool that survives subscribing, so a paying team + // would otherwise sit on a spent meter forever. + fetchWallet.mockResolvedValue({ + status: "subscribed", + freeRemaining: 0, + freeAllowance: 500, + }); + const el = renderFor("linked-subscribed"); + // The row holds its space while the wallet loads, then drops once the + // answer says this team is paying. + await waitFor(() => expect(el.textContent).toBe("none")); + }); + + it("hides the meter when the wallet read fails", async () => { + fetchWallet.mockRejectedValue(new Error("saas unreachable")); + const el = renderFor("linked-subscribed"); + await waitFor(() => expect(el.textContent).toBe("none")); + }); + + it("ignores cached figures once the instance is unlinked", async () => { + // The cache survives an unlink and nothing rewrites it afterwards, so the + // linkage gate has to cover the seed too, not just the fetch. + const linked = renderFor("linked-free"); + await waitFor(() => expect(linked.textContent).toBe("247/500")); + cleanup(); + + fetchWallet.mockClear(); + const unlinked = renderFor("unlinked"); + expect(unlinked.textContent).toBe("none"); + expect(fetchWallet).not.toHaveBeenCalled(); + }); + + it("shows the last known figures while the wallet reloads", async () => { + // What stops the row popping in — and resizing the footer — every time the + // processor mounts. + const el = renderFor("linked-free"); + await waitFor(() => expect(el.textContent).toBe("247/500")); + cleanup(); + + let release: (v: unknown) => void = () => {}; + fetchWallet.mockReturnValue( + new Promise((resolve) => { + release = resolve; + }), + ); + const second = renderFor("linked-free"); + // Seeded before the refetch lands... + expect(second.textContent).toBe("247/500"); + release({ status: "free", freeRemaining: 12, freeAllowance: 500 }); + // ...then updated in place, without the row ever being absent. + await waitFor(() => expect(second.textContent).toBe("12/500")); + }); +}); diff --git a/frontend/editor/src/portal/hooks/useFreeCreditsSummary.ts b/frontend/editor/src/portal/hooks/useFreeCreditsSummary.ts new file mode 100644 index 0000000000..3fd814ebb1 --- /dev/null +++ b/frontend/editor/src/portal/hooks/useFreeCreditsSummary.ts @@ -0,0 +1,65 @@ +import { useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useLink } from "@portal/contexts/LinkContext"; +import { fetchWallet } from "@portal/api/billing"; +import { qk } from "@portal/queries/keys"; +import { + readCachedCredits, + writeCachedCredits, + type CachedCredits, +} from "@app/services/navFooterCache"; +import { type NavFooterCredits } from "@app/components/shared/navFooter/NavFooterCreditsRow"; + +/** + * Free credits left on this team's allowance, for the processor's sidebar + * footer meter. Null hides the meter. + * + * This is the portal's own seam rather than the editor's {@code + * @app/hooks/useFreeCreditsSummary}, because self-hosted resolves {@code @app/*} + * as proprietary → core: the cloud wallet hook isn't in that cascade, and the + * implementation can't move down into proprietary either, since core/desktop + * builds ship no portal and must never resolve {@code @portal}. Keeping it here + * means only builds that actually have a processor pull in the wallet read. + * + * Self-hosted reads the same {@code GET /api/v1/payg/wallet} the Usage page's + * trial meter renders — {@code apiClient.saas} with the admin's Supabase JWT, + * since the wallet lives in the cloud even when the instance doesn't. Gated on + * linkage: an unlinked instance has no wallet to read. + * + * Free teams only, matching the editor and the Plan page. The grant is a + * lifetime pool that survives subscribing, so a paying team would otherwise sit + * on a permanent "0 of 500" in red; their usage lives on Usage & Billing. + */ +export function useFreeCreditsSummary(): NavFooterCredits | null { + const { isLinked } = useLink(); + // Shared query key, so the footer rides the same cached snapshot as any other + // wallet reader rather than adding a fetch per mount. + const { data: wallet } = useQuery({ + queryKey: qk.wallet(isLinked), + queryFn: fetchWallet, + enabled: isLinked, + }); + // Shared with the editor's seam, so crossing between the two apps shows the + // figures the other one last saw rather than re-fetching into an empty row. + const [seed] = useState(readCachedCredits); + + const live: CachedCredits | undefined = !wallet + ? undefined + : wallet.status === "subscribed" + ? null + : { remaining: wallet.freeRemaining, total: wallet.freeAllowance }; + + useEffect(() => { + // Only once linked: an unlinked instance never asks, so it has no answer of + // its own and must not overwrite what the editor recorded. + if (isLinked && live !== undefined) writeCachedCredits(live); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [wallet, isLinked]); + + // Linkage gates the seed as well as the fetch. The cache outlives an unlink + // — nothing refetches or rewrites it once the instance stops asking — so + // without this an unlinked instance would keep showing the figures from when + // it was linked, indefinitely. + if (!isLinked) return null; + return (live !== undefined ? live : seed) ?? null; +} diff --git a/frontend/editor/src/portal/hooks/useOpenPlan.ts b/frontend/editor/src/portal/hooks/useOpenPlan.ts new file mode 100644 index 0000000000..57b30c91d0 --- /dev/null +++ b/frontend/editor/src/portal/hooks/useOpenPlan.ts @@ -0,0 +1,13 @@ +import { useCallback } from "react"; +import { useView } from "@portal/contexts/ViewContext"; + +/** + * Self-hosted processor: settings carries no Plan section (it is a cloud + * surface, and this build's registry has none), so the footer's credits row + * opens the portal's own Usage & Billing view instead — the same figures, on + * the surface this flavor actually owns. + */ +export function useOpenPlan(): (() => void) | null { + const { setActiveView } = useView(); + return useCallback(() => setActiveView("usage"), [setActiveView]); +} diff --git a/frontend/editor/src/portal/queries/keys.ts b/frontend/editor/src/portal/queries/keys.ts index 6c29430cb4..e1c46a59c0 100644 --- a/frontend/editor/src/portal/queries/keys.ts +++ b/frontend/editor/src/portal/queries/keys.ts @@ -20,6 +20,8 @@ export const qk = { // Keyed on linkage: an unlinked account has no deal to read, so linking must not // serve the unlinked (null) snapshot back from cache. procurement: (linked: boolean) => ["portal", "procurement", linked] as const, + // Same reasoning: an unlinked instance has no wallet in the cloud. + wallet: (linked: boolean) => ["portal", "wallet", linked] as const, // Tier-dependent documents: (tier: Tier) => ["portal", "documents", tier] as const, diff --git a/frontend/editor/src/proprietary/billing/format.ts b/frontend/editor/src/proprietary/billing/format.ts index f91f98af44..ff44e69882 100644 --- a/frontend/editor/src/proprietary/billing/format.ts +++ b/frontend/editor/src/proprietary/billing/format.ts @@ -293,6 +293,29 @@ export function computeBundleQuote( export type MeterState = "FULL" | "WARNED" | "DEGRADED"; +/** + * Meter for a balance that is spent DOWN — a free grant, a prepaid pool. The + * bar shows what is LEFT, so full reads as "plenty" and empty as "none", which + * is how the sidebar footer's credits row reads and the only direction that + * matches a figure quoting the remainder. + * + * The state bands still key on consumption, so the tone is unchanged: amber + * once 80% is gone, red once it's exhausted. Meters for money SPENT against a + * cap keep using {@link meterState} directly — there a full bar correctly means + * "at your ceiling". + */ +export function remainingMeter( + remaining: number, + total: number, +): { state: MeterState; pct: number } { + const { state } = meterState(Math.max(0, total - remaining), total); + const pct = + total > 0 + ? Math.min(100, Math.max(0, (Math.max(0, remaining) / total) * 100)) + : 0; + return { state, pct }; +} + /** Warn (≥80%) / degrade (≥100%) band for a usage meter; mirrors the BE thresholds. */ export function meterState( used: number, diff --git a/frontend/editor/src/proprietary/billing/index.ts b/frontend/editor/src/proprietary/billing/index.ts index 687adec541..2fe9dffd93 100644 --- a/frontend/editor/src/proprietary/billing/index.ts +++ b/frontend/editor/src/proprietary/billing/index.ts @@ -14,6 +14,7 @@ export { docCapForMoney, formatPeriodDate, meterState, + remainingMeter, PREPAID_MONTHS_GRANTED, PREPAID_MONTHS_PAID, PDFS_PER_USER_MONTH, diff --git a/frontend/editor/src/proprietary/components/shared/AppSwitcher.tsx b/frontend/editor/src/proprietary/components/shared/AppSwitcher.tsx index 2e02db3068..9ba0b6438d 100644 --- a/frontend/editor/src/proprietary/components/shared/AppSwitcher.tsx +++ b/frontend/editor/src/proprietary/components/shared/AppSwitcher.tsx @@ -1,15 +1,21 @@ -import { useNavigate } from "react-router-dom"; -import { useAuth } from "@app/auth/context"; import { Logo } from "@app/ui/Logo"; import { BrandSwitcher } from "@app/components/shared/BrandSwitcher"; import { type AppSwitcherProps } from "@core/components/shared/AppSwitcher"; -import { PORTAL_BASENAME } from "@app/routes/portalBasename"; +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 { portalAccess } = useAuth(); - const navigate = useNavigate(); + const otherApp = useOtherAppSwitch(); - if (!portalAccess) { + if (!otherApp) { return ( navigate(PORTAL_BASENAME)} + onSwitch={otherApp.onOpen} collapsed={collapsed} /> ); diff --git a/frontend/editor/src/proprietary/hooks/useOtherAppSwitch.ts b/frontend/editor/src/proprietary/hooks/useOtherAppSwitch.ts new file mode 100644 index 0000000000..8bf07b5c2f --- /dev/null +++ b/frontend/editor/src/proprietary/hooks/useOtherAppSwitch.ts @@ -0,0 +1,15 @@ +import { useNavigate } from "react-router-dom"; +import { useAuth } from "@app/auth/context"; +import { PORTAL_BASENAME } from "@app/routes/portalBasename"; +import { type NavFooterAppLink } from "@app/components/shared/navFooter/NavFooter"; + +/** + * Self-hosted: the Spring session carries `portalAccess`, so the switch to the + * processor is offered exactly when that flag is set. + */ +export function useOtherAppSwitch(): NavFooterAppLink | null { + const { portalAccess } = useAuth(); + const navigate = useNavigate(); + if (!portalAccess) return null; + return { app: "processor", onOpen: () => navigate(PORTAL_BASENAME) }; +} diff --git a/frontend/editor/src/saas/components/shared/AppSwitcher.tsx b/frontend/editor/src/saas/components/shared/AppSwitcher.tsx deleted file mode 100644 index 364f094478..0000000000 --- a/frontend/editor/src/saas/components/shared/AppSwitcher.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { useNavigate } from "react-router-dom"; -import { Logo } from "@app/ui/Logo"; -import { BrandSwitcher } from "@app/components/shared/BrandSwitcher"; -import { type AppSwitcherProps } from "@core/components/shared/AppSwitcher"; -import { usePortalAccess } from "@app/hooks/usePortalAccess"; -import { PORTAL_BASENAME } from "@app/routes/portalBasename"; - -/** - * SaaS sidebar brand header. When the backend says this user can open the - * processor (`/api/v1/auth/me` → `portalAccess` — the exact signal the - * processor's own gate uses), the Stirling logo doubles as the - * editor⇄processor switcher: the mark morphs into a chevron and opens the - * switch menu (same BrandSwitcher the processor sidebar uses). Users without - * access get a plain logo. - * - * Deliberately NOT gated on the editor's Supabase auth context: that context - * never fetches /me, so it can't know about portal access (and its session - * state doesn't always mirror the backend login that actually grants it). - */ -export function AppSwitcher({ collapsed }: AppSwitcherProps) { - const portalAccess = usePortalAccess(); - const navigate = useNavigate(); - - if (!portalAccess) { - return ( - - ); - } - - return ( - navigate(PORTAL_BASENAME)} - collapsed={collapsed} - /> - ); -} diff --git a/frontend/editor/src/saas/components/shared/config/configSections/Overview.tsx b/frontend/editor/src/saas/components/shared/config/configSections/Overview.tsx index fac87148fe..3a28152425 100644 --- a/frontend/editor/src/saas/components/shared/config/configSections/Overview.tsx +++ b/frontend/editor/src/saas/components/shared/config/configSections/Overview.tsx @@ -1,7 +1,6 @@ import React, { useState } from "react"; import { Alert, - Avatar, Divider, Group, Image, @@ -11,10 +10,12 @@ import { TextInput, Modal, } from "@mantine/core"; +import { Avatar } from "@app/ui/Avatar"; import { Button as DSButton } from "@app/ui/Button"; import { FilePicker } from "@app/ui/FilePicker"; import { useTranslation } from "react-i18next"; import { useAuth } from "@app/auth/UseSession"; +import { useAccountIdentity } from "@app/hooks/useAccountIdentity"; import { isUserAnonymous, linkEmailIdentity, @@ -46,6 +47,8 @@ const Overview: React.FC = ({ onLogoutClick }) => { refreshProfilePicture, refreshProfilePictureMetadata, } = useAuth(); + // Same name + initials the sidebar footer draws, so the two discs agree. + const { displayName } = useAccountIdentity(); const PROFILE_BUCKET = "profile-pictures"; @@ -67,7 +70,6 @@ const Overview: React.FC = ({ onLogoutClick }) => { const provider = profilePictureMetadata?.provider; const profilePath = user ? `${user.id}/avatar` : null; - const profileInitial = user?.email?.trim()?.charAt(0)?.toUpperCase() || "U"; const handleProfileUpload = async (file: File | null) => { if (!file || !user || !profilePath) { @@ -410,12 +412,9 @@ const Overview: React.FC = ({ onLogoutClick }) => { - {profileInitial} - + name={displayName} + size="xl" + />
= ({ onLogoutClick }) => { - {profileInitial} - + name={displayName} + size="xl" + />
navigate(PORTAL_BASENAME) }; +} diff --git a/frontend/editor/src/saas/hooks/usePortalAccess.test.tsx b/frontend/editor/src/saas/hooks/usePortalAccess.test.tsx index a0e8ba618d..809138850a 100644 --- a/frontend/editor/src/saas/hooks/usePortalAccess.test.tsx +++ b/frontend/editor/src/saas/hooks/usePortalAccess.test.tsx @@ -1,5 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { renderHook, waitFor } from "@testing-library/react"; +import { renderHook as baseRenderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; const get = vi.fn(); let currentUserId: string | null = null; @@ -20,10 +22,28 @@ function meReturning(portalAccess: boolean) { return { data: { user: { portalAccess } } }; } +// A fresh client per render, so one test's cached answer can't satisfy the +// next — each case exercises a cold cache unless it deliberately shares one. +let client: QueryClient; + +function renderHook(cb: () => T) { + return baseRenderHook(cb, { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }); +} + describe("usePortalAccess", () => { beforeEach(() => { + // The hook now remembers the last answer across mounts, so without this a + // prior test's result seeds the next one. + localStorage.clear(); get.mockReset(); currentUserId = null; + client = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0, staleTime: 0 } }, + }); }); it("reports the backend's answer for the signed-in user", async () => { @@ -82,12 +102,31 @@ describe("usePortalAccess", () => { expect(first.result.current).toBe(false); first.unmount(); - // The failure isn't sticky. + // The failure isn't sticky — a cold cache asks again. + client.clear(); get.mockResolvedValue(meReturning(true)); const second = renderHook(() => usePortalAccess()); await waitFor(() => expect(second.result.current).toBe(true)); }); + it("shows the last known answer at first paint, then revalidates", async () => { + // What stops the switcher and the footer's "Open ..." row popping in a + // request late on every mount. + currentUserId = "admin-1"; + get.mockResolvedValue(meReturning(true)); + const first = renderHook(() => usePortalAccess()); + await waitFor(() => expect(first.result.current).toBe(true)); + first.unmount(); + + client.clear(); + get.mockResolvedValue(meReturning(false)); + const second = renderHook(() => usePortalAccess()); + // Seeded from the remembered answer before the request lands... + expect(second.result.current).toBe(true); + // ...and corrected once the backend disagrees. + await waitFor(() => expect(second.result.current).toBe(false)); + }); + it("ignores a response that lands after unmount", async () => { currentUserId = "admin-1"; let resolveMe: (v: unknown) => void = () => {}; diff --git a/frontend/editor/src/saas/hooks/usePortalAccess.ts b/frontend/editor/src/saas/hooks/usePortalAccess.ts index 442061cbe1..6e91f0864c 100644 --- a/frontend/editor/src/saas/hooks/usePortalAccess.ts +++ b/frontend/editor/src/saas/hooks/usePortalAccess.ts @@ -1,52 +1,64 @@ import { useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import apiClient from "@app/services/apiClient"; import { useAuth } from "@app/auth/UseSession"; +import { + readCachedOtherApp, + writeCachedOtherApp, +} from "@app/services/navFooterCache"; +import { qk } from "@app/query/keys"; + +async function fetchPortalAccess(): Promise { + const res = await apiClient.get<{ user?: { portalAccess?: boolean } }>( + "/api/v1/auth/me", + ); + return res.data.user?.portalAccess === true; +} /** * Whether the current user can open the processor (admin portal), straight * from the backend (`/api/v1/auth/me` → `portalAccess`) — the same signal the * processor's own SaasPortalGate uses. Components that must mirror processor - * access (e.g. the sidebar's editor⇄processor switcher) ask here. + * access (the sidebar's editor⇄processor switcher and its footer row) ask here. * * The editor's Supabase auth context can't *answer* this — it never fetches - * /me — so it is used only to identify who is asking. Keying the effect on - * that identity is what keeps the answer per-user: the SPA can swap users + * /me — so it is used only to identify who is asking. That identity is the + * cache key, which is what keeps the answer per-user: the SPA can swap users * without a reload (Supabase fires SIGNED_OUT/SIGNED_IN in place; only the - * settings Logout button hard-navigates), so any answer held beyond the - * current identity would leak to whoever signs in next. + * settings Logout button hard-navigates), and a keyed cache addresses each + * identity separately rather than holding one answer that would have to be + * invalidated on the swap — the bug class this hook once had. * - * Deliberately unmemoised beyond the mount: the one consumer (the sidebar - * switcher) mounts once, so a cross-mount cache would only add user-scoped - * state that has to be invalidated on identity change — the bug class this - * hook already had once. Guests skip the request entirely. + * Cached through the app query client, so leaving the editor for the processor + * and coming back resolves from cache: the switcher is there on first paint + * instead of appearing a request later. Guests skip the request entirely. */ export function usePortalAccess(): boolean { const { user } = useAuth(); const userId = user?.id ?? null; - const [access, setAccess] = useState(false); + // The query cache is per-tree and per-load, so it can't help a cold start or + // the hop into the processor, which mounts its own client. Seed from the last + // answer this browser saw so the switcher and the footer's "Open ..." row are + // there at first paint. Marked ancient so it still revalidates immediately. + const [seed] = useState(readCachedOtherApp); + + const { data, isSuccess } = useQuery({ + queryKey: qk.portalAccess(userId), + queryFn: fetchPortalAccess, + // Signed out: nothing to ask, and any previous answer is void. + enabled: userId !== null, + // Backend unreachable or guest (401) means no access now; a later refetch + // asks again rather than trusting the failure. + retry: false, + initialData: seed, + initialDataUpdatedAt: 0, + }); useEffect(() => { - // Signed out: nothing to ask, and any previous answer is void. - if (userId === null) { - setAccess(false); - return; - } + // Only a real answer is recorded — a failed probe is not one, so the next + // mount trusts the last backend response rather than a network blip. + if (isSuccess && data !== undefined) writeCachedOtherApp(data); + }, [isSuccess, data]); - let cancelled = false; - apiClient - .get<{ user?: { portalAccess?: boolean } }>("/api/v1/auth/me") - .then((res) => { - if (!cancelled) setAccess(res.data.user?.portalAccess === true); - }) - .catch(() => { - // Backend unreachable or guest (401): no access now; a remount or - // identity change asks again rather than trusting a failure. - if (!cancelled) setAccess(false); - }); - return () => { - cancelled = true; - }; - }, [userId]); - - return access; + return data === true; } diff --git a/frontend/editor/src/saas/hooks/useWallet.poll.test.tsx b/frontend/editor/src/saas/hooks/useWallet.poll.test.tsx new file mode 100644 index 0000000000..b72eac67f8 --- /dev/null +++ b/frontend/editor/src/saas/hooks/useWallet.poll.test.tsx @@ -0,0 +1,158 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, act, waitFor } from "@testing-library/react"; +import { expectConsole } from "@app/tests/failOnConsole"; + +const get = vi.fn(); +vi.mock("@app/services/apiClient", () => ({ + default: { get: (...args: unknown[]) => get(...args) }, +})); +vi.mock("@app/hooks/walletDevPreview", () => ({ + getWalletDevPreview: () => null, +})); +vi.mock("@app/services/billing", () => ({ createPortalSession: vi.fn() })); +vi.mock("@app/platform/openExternal", () => ({ openExternal: vi.fn() })); + +const { useWallet } = await import("@app/hooks/useWallet"); + +/** Full enough for the hook's deep-compare, which reads every field. */ +function walletWith(freeRemaining: number) { + return { + data: { + teamId: 1, + status: "free", + role: "leader", + billingPeriodStart: "2026-08-01", + billingPeriodEnd: "2026-08-31", + billableUsed: 500 - freeRemaining, + billableLimit: 500, + freeAllowance: 500, + freeRemaining, + pricePerDocMinor: 2, + bundleRatePerCreditMinor: null, + currency: "usd", + estimatedBillMinor: 0, + capUsd: null, + noCap: false, + stripeSubscriptionId: null, + spendUnitsThisPeriod: 0, + docsProcessedThisPeriod: 0, + uniquePdfsThisPeriod: 0, + sizeMultiplierPdfsThisPeriod: 0, + billingMode: "metered", + prepaidUnitsRemaining: 0, + prepaidUnitsTotal: 0, + prepaidExpiresAt: null, + recent: [], + members: [], + categoryBreakdown: { api: 0, ai: 0, automation: 0 }, + categoryDocs: { api: 0, ai: 0, automation: 0 }, + }, + }; +} + +describe("useWallet — keeping the figures fresh", () => { + beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + get.mockReset(); + get.mockResolvedValue(walletWith(500)); + }); + afterEach(() => vi.useRealTimers()); + + it("re-reads the wallet on the poll interval", async () => { + const { result } = renderHook(() => useWallet()); + await waitFor(() => expect(result.current.wallet).not.toBeNull()); + expect(get).toHaveBeenCalledTimes(1); + + get.mockResolvedValue(walletWith(480)); + await act(async () => { + vi.advanceTimersByTime(30_000); + }); + + await waitFor(() => expect(result.current.wallet?.freeRemaining).toBe(480)); + }); + + it("polls silently, so consumers gating on loading/error don't flicker", async () => { + const { result } = renderHook(() => useWallet()); + await waitFor(() => expect(result.current.wallet).not.toBeNull()); + + // A poll that fails must leave the last good snapshot, and must not raise + // `error` — Plan swaps a working page for an alert on that. + get.mockRejectedValue(new Error("network blip")); + await act(async () => { + vi.advanceTimersByTime(30_000); + }); + + expect(result.current.loading).toBe(false); + expect(result.current.error).toBeNull(); + expect(result.current.wallet?.freeRemaining).toBe(500); + }); + + it("settles loading when a silent poll supersedes an in-flight visible load", async () => { + // The mount load raises `loading`; a poll firing before it lands cancels it. + // If clearing the flag were the silent load's to skip, both would decline + // and `loading` would stay true forever — which permanently suppresses the + // limit modals, since they do `if (loading || !wallet) return null`. + const visibility = vi.spyOn(document, "visibilityState", "get"); + visibility.mockReturnValue("visible"); + + let landMount: (v: unknown) => void = () => {}; + get.mockReturnValueOnce( + new Promise((resolve) => { + landMount = resolve; + }), + ); + const { result } = renderHook(() => useWallet()); + expect(result.current.loading).toBe(true); + + get.mockResolvedValue(walletWith(470)); + await act(async () => { + document.dispatchEvent(new Event("visibilitychange")); + }); + await act(async () => { + landMount(walletWith(500)); + }); + + await waitFor(() => expect(result.current.wallet?.freeRemaining).toBe(470)); + expect(result.current.loading).toBe(false); + visibility.mockRestore(); + }); + + it("clears a stale error once a silent poll succeeds", async () => { + // The visible mount load failing is meant to be logged; only the silent + // retries stay quiet. + expectConsole.warn(/\[useWallet\] fetch failed/); + get.mockRejectedValueOnce(new Error("network blip")); + const { result } = renderHook(() => useWallet()); + await waitFor(() => expect(result.current.error).not.toBeNull()); + + get.mockResolvedValue(walletWith(500)); + await act(async () => { + vi.advanceTimersByTime(30_000); + }); + + await waitFor(() => expect(result.current.error).toBeNull()); + expect(result.current.wallet?.freeRemaining).toBe(500); + }); + + it("stops polling while the tab is hidden and re-reads on return", async () => { + const visibility = vi.spyOn(document, "visibilityState", "get"); + visibility.mockReturnValue("visible"); + const { result } = renderHook(() => useWallet()); + await waitFor(() => expect(result.current.wallet).not.toBeNull()); + const afterMount = get.mock.calls.length; + + visibility.mockReturnValue("hidden"); + await act(async () => { + document.dispatchEvent(new Event("visibilitychange")); + vi.advanceTimersByTime(120_000); + }); + expect(get).toHaveBeenCalledTimes(afterMount); + + visibility.mockReturnValue("visible"); + await act(async () => { + document.dispatchEvent(new Event("visibilitychange")); + }); + await waitFor(() => expect(get.mock.calls.length).toBe(afterMount + 1)); + visibility.mockRestore(); + }); +}); From ec3de16c0862c01190bf45896bae87e9f0e10ca7 Mon Sep 17 00:00:00 2001 From: Ludy Date: Tue, 18 Aug 2026 20:25:55 +0200 Subject: [PATCH 23/23] ci: centralize Gradle caching across GitHub Actions workflows (#7546) ## Summary This pull request restructures Gradle dependency caching across the GitHub Actions workflows. The central `gradle-cache-prime` job is responsible for preparing the shared backend Gradle cache. Reusable workflows restore that shared cache without writing to the same key, while independently triggered workflows use isolated cache namespaces. ## What changed ### Shared Gradle cache - Added a stable `gradle-v1-` cache namespace for the shared backend cache. - The cache key includes the runner OS, runner architecture, JDK version, and the relevant Gradle configuration files. - The cache key is calculated before Gradle runs and reused for the later save step. - The prime job performs a lookup first and resolves backend dependencies only when the exact cache is missing. - This prevents Gradle or Spotless changes during the prime step from producing a different save key from the key used by downstream jobs. ### Reusable workflows - Backend, OpenAPI, license, Docker, E2E, and migration workflows restore the shared cache instead of writing to the shared key. - The backend build matrix includes `matrix.jdk-version` in its cache key. - Enterprise, Tauri, and generated-model workflows support the `use_shared_cache` boolean input. - When `use_shared_cache` is enabled, those workflows restore the shared cache. - When it is disabled, they use workflow-specific cache namespaces. ### Independent workflows Independent workflows now use separate cache prefixes, including: - `gradle-license-report-v1-` - `gradle-swagger-v1-` - `gradle-push-docker-v1-` - `gradle-tauri-releases-v1-` - `gradle-deploy-pr-v1-` - `gradle-playwright-e2e-v1-` - `gradle-generated-models-v1-` This prevents them from creating or affecting the shared backend cache before the prime job. ### Build and E2E flow - Removed the `-PnoSpotless` option from the central Gradle dependency-resolution command. - Removed the separate Gradle dependency prime/retry logic from the live E2E workflow. - Connected the Tauri build and generated-models check to the central cache-prime job. ## Motivation Previously, multiple workflows could use and save the same Gradle cache key independently. The first workflow to save the cache could therefore determine its contents, even if it had resolved a different or incomplete set of dependencies. The cache key was also evaluated after some Gradle tasks had run. If Gradle or Spotless modified a file covered by `hashFiles(...)`, the save key could differ from the restore key used by downstream jobs. This change gives the shared cache a single owner, isolates workflow-specific caches, and makes cache usage deterministic across the CI pipeline. ## Expected result - `gradle-cache-prime` is the single writer for the shared backend Gradle cache. - Downstream jobs restore the same cache without competing cache writes. - Independently triggered workflows remain isolated through their own cache namespaces. - Changes to the monitored Gradle configuration files produce a new cache key. - The normal Gradle/Spotless path is included when the shared cache is populated. ## Validation - Compared the cache key expressions and `hashFiles(...)` inputs across the affected workflows. - Verified that the central restore and save steps use the same precomputed key. - CI should confirm that the prime job populates the shared cache and downstream workflows only restore it. ## Checklist - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have performed a self-review of my changes - [ ] I have run the relevant CI checks - [ ] I have tested the workflow changes --- .../workflows/PR-Demo-Comment-with-react.yml | 19 +++--- .github/workflows/backend-build.yml | 21 +++---- .github/workflows/build-enterprise.yml | 33 ++++++++--- .github/workflows/build.yml | 59 ++++++++++++++----- .github/workflows/check-generated-models.yml | 30 +++++++--- .github/workflows/check-licence.yml | 19 +++--- .github/workflows/check-openapi.yml | 19 +++--- .github/workflows/coverage-aggregate.yml | 19 +++--- .github/workflows/db-migration-test.yml | 19 +++--- .github/workflows/docker-compose-tests.yml | 19 +++--- .github/workflows/e2e-live.yml | 38 ++++-------- .../frontend-backend-licenses-update.yml | 19 +++--- .github/workflows/multiOSReleases.yml | 57 ++++++++---------- .github/workflows/push-docker.yml | 19 +++--- .github/workflows/swagger.yml | 19 +++--- .github/workflows/tauri-build.yml | 33 +++++++---- .github/workflows/test-build-docker.yml | 19 +++--- 17 files changed, 235 insertions(+), 226 deletions(-) diff --git a/.github/workflows/PR-Demo-Comment-with-react.yml b/.github/workflows/PR-Demo-Comment-with-react.yml index fe3f28a637..410aa82dc9 100644 --- a/.github/workflows/PR-Demo-Comment-with-react.yml +++ b/.github/workflows/PR-Demo-Comment-with-react.yml @@ -191,22 +191,19 @@ jobs: # untrusted tree gets built below - never leave credentials in .git/config persist-credentials: false - - name: Set up JDK 25 - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 - with: - java-version: "25" - distribution: "temurin" - - - name: Cache Gradle User Home + - name: Cache Gradle uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.gradle/caches ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} - restore-keys: | - gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- - gradle-${{ runner.os }}-${{ runner.arch }}- + key: gradle-deploy-pr-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + + - name: Set up JDK 25 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + with: + java-version: "25" + distribution: "temurin" - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 diff --git a/.github/workflows/backend-build.yml b/.github/workflows/backend-build.yml index 54bd4cb907..6623940bce 100644 --- a/.github/workflows/backend-build.yml +++ b/.github/workflows/backend-build.yml @@ -35,23 +35,20 @@ jobs: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Restore cache Gradle User Home + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-${{ matrix.jdk-version }}-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + - name: Set up JDK ${{ matrix.jdk-version }} uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: java-version: ${{ matrix.jdk-version }} distribution: "temurin" - - name: Cache Gradle User Home - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-${{ matrix.jdk-version }}-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} - restore-keys: | - gradle-${{ runner.os }}-${{ runner.arch }}-jdk-${{ matrix.jdk-version }}- - gradle-${{ runner.os }}-${{ runner.arch }}- - - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 - name: Check Java formatting (Spotless) @@ -156,7 +153,7 @@ jobs: STIRLING_FLAVOR: ${{ matrix.flavor }} # Configure the Gradle daemon explicitly; GRADLE_OPTS alone only # configures the Gradle client JVM. - GRADLE_OPTS: '-Dorg.gradle.jvmargs=-Xmx4g -XX:+UseG1GC' + GRADLE_OPTS: "-Dorg.gradle.jvmargs=-Xmx4g -XX:+UseG1GC" - name: Check Test Reports Exist if: always() diff --git a/.github/workflows/build-enterprise.yml b/.github/workflows/build-enterprise.yml index 0604f7176f..b4a8373ccc 100644 --- a/.github/workflows/build-enterprise.yml +++ b/.github/workflows/build-enterprise.yml @@ -15,6 +15,11 @@ name: Enterprise E2E (Playwright) on: workflow_call: + inputs: + use_shared_cache: + required: false + type: boolean + default: false push: branches: ["main"] schedule: @@ -56,21 +61,31 @@ jobs: egress-policy: audit - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Set up JDK 25 - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + + - name: Restore cache Gradle User Home + if: inputs.use_shared_cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - java-version: "25" - distribution: "temurin" - - name: Cache Gradle User Home + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + + - name: Restore cache Gradle + if: inputs.use_shared_cache == false uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.gradle/caches ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} - restore-keys: | - gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- - gradle-${{ runner.os }}-${{ runner.arch }}- + key: gradle-playwright-e2e-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + + - name: Set up JDK 25 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + with: + java-version: "25" + distribution: "temurin" + - name: Set up Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2f50249099..566262d24f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -73,29 +73,48 @@ jobs: egress-policy: audit - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Set up JDK 25 - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 - with: - java-version: "25" - distribution: "temurin" - - name: Cache Gradle User Home - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + + - name: Calculate Gradle cache key + id: gradle-cache-key + shell: bash + run: | + echo "key=gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}" >> "$GITHUB_OUTPUT" + + - name: Cache Gradle (lookup-only) + id: cache-gradle-restore + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.gradle/caches ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} - restore-keys: | - gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- - gradle-${{ runner.os }}-${{ runner.arch }}- + key: ${{ steps.gradle-cache-key.outputs.key }} + lookup-only: true + + - name: Set up JDK 25 + if: steps.cache-gradle-restore.outputs.cache-hit != 'true' + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + with: + java-version: "25" + distribution: "temurin" + - name: Resolve backend dependencies - run: ./gradlew :stirling-pdf:classes -PnoSpotless --no-daemon + if: steps.cache-gradle-restore.outputs.cache-hit != 'true' + run: ./gradlew :stirling-pdf:classes --no-daemon env: STIRLING_FLAVOR: saas MAVEN_USER: ${{ secrets.MAVEN_USER }} MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }} MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }} + - name: Save cache Gradle User Home + if: steps.cache-gradle-restore.outputs.cache-hit != 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ steps.gradle-cache-key.outputs.key }} + build: needs: [files-changed, gradle-cache-prime] permissions: @@ -170,6 +189,8 @@ jobs: contents: read uses: ./.github/workflows/build-enterprise.yml secrets: inherit + with: + use_shared_cache: true check-licence: if: needs.files-changed.outputs.build == 'true' @@ -193,7 +214,14 @@ jobs: test-build-docker-images: if: github.event_name == 'pull_request' && needs.files-changed.outputs.project == 'true' - needs: [files-changed, build, check-generateOpenApiDocs, check-licence, gradle-cache-prime] + needs: + [ + files-changed, + build, + check-generateOpenApiDocs, + check-licence, + gradle-cache-prime, + ] permissions: contents: read packages: read @@ -205,7 +233,7 @@ jobs: tauri-build: if: needs.files-changed.outputs.tauri == 'true' - needs: [files-changed] + needs: [files-changed, gradle-cache-prime] permissions: contents: read pull-requests: write @@ -219,6 +247,7 @@ jobs: with: platform: windows-macos sign: true + use_shared_cache: true ai-engine: if: needs.files-changed.outputs.engine == 'true' @@ -242,6 +271,8 @@ jobs: pull-requests: write uses: ./.github/workflows/check-generated-models.yml secrets: inherit + with: + use_shared_cache: true pre-commit: needs: [files-changed] diff --git a/.github/workflows/check-generated-models.yml b/.github/workflows/check-generated-models.yml index 39c6467889..fafffcc241 100644 --- a/.github/workflows/check-generated-models.yml +++ b/.github/workflows/check-generated-models.yml @@ -9,6 +9,11 @@ name: Check generated models # post-merge safety net. on: workflow_call: + inputs: + use_shared_cache: + required: false + type: boolean + default: false push: branches: [main] @@ -39,22 +44,29 @@ jobs: engine/uv.lock cache-suffix: generated-models - - name: Set up JDK 25 - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + - name: Restore cache Gradle User Home + if: inputs.use_shared_cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - java-version: "25" - distribution: "temurin" + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} - - name: Cache Gradle User Home + - name: Restore cache Gradle + if: inputs.use_shared_cache == false uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.gradle/caches ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} - restore-keys: | - gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- - gradle-${{ runner.os }}-${{ runner.arch }}- + key: gradle-generated-models-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + + - name: Set up JDK 25 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + with: + java-version: "25" + distribution: "temurin" - name: Set up Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 diff --git a/.github/workflows/check-licence.yml b/.github/workflows/check-licence.yml index 2eec970b8f..17c64d5c64 100644 --- a/.github/workflows/check-licence.yml +++ b/.github/workflows/check-licence.yml @@ -21,23 +21,20 @@ jobs: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Restore cache Gradle User Home + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + - name: Set up JDK 25 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: java-version: "25" distribution: "temurin" - - name: Cache Gradle User Home - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} - restore-keys: | - gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- - gradle-${{ runner.os }}-${{ runner.arch }}- - - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 - name: Check licenses for compatibility diff --git a/.github/workflows/check-openapi.yml b/.github/workflows/check-openapi.yml index f224ce18cf..ed83447335 100644 --- a/.github/workflows/check-openapi.yml +++ b/.github/workflows/check-openapi.yml @@ -22,23 +22,20 @@ jobs: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Restore cache Gradle User Home + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + - name: Set up JDK 25 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: java-version: "25" distribution: "temurin" - - name: Cache Gradle User Home - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} - restore-keys: | - gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- - gradle-${{ runner.os }}-${{ runner.arch }}- - - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 - name: Generate OpenAPI documentation diff --git a/.github/workflows/coverage-aggregate.yml b/.github/workflows/coverage-aggregate.yml index a97b579f15..61ef8793c4 100644 --- a/.github/workflows/coverage-aggregate.yml +++ b/.github/workflows/coverage-aggregate.yml @@ -40,23 +40,20 @@ jobs: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Restore cache Gradle User Home + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + - name: Set up JDK 25 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: java-version: "25" distribution: "temurin" - - name: Cache Gradle User Home - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} - restore-keys: | - gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- - gradle-${{ runner.os }}-${{ runner.arch }}- - - name: Install uv uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: diff --git a/.github/workflows/db-migration-test.yml b/.github/workflows/db-migration-test.yml index d6a61b45c4..ccb46d3988 100644 --- a/.github/workflows/db-migration-test.yml +++ b/.github/workflows/db-migration-test.yml @@ -25,23 +25,20 @@ jobs: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Restore cache Gradle User Home + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + - name: Set up JDK 25 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: java-version: 25 distribution: temurin - - name: Cache Gradle User Home - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} - restore-keys: | - gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- - gradle-${{ runner.os }}-${{ runner.arch }}- - # Keep the normal formatting path here so this smoke test exercises the # same Gradle configuration as the backend build. - name: Build Stirling-PDF JAR diff --git a/.github/workflows/docker-compose-tests.yml b/.github/workflows/docker-compose-tests.yml index 9d5911404f..039c73e5db 100644 --- a/.github/workflows/docker-compose-tests.yml +++ b/.github/workflows/docker-compose-tests.yml @@ -33,23 +33,20 @@ jobs: - name: Checkout Repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Restore cache Gradle User Home + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + - name: Set up JDK 25 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: java-version: "25" distribution: "temurin" - - name: Cache Gradle User Home - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} - restore-keys: | - gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- - gradle-${{ runner.os }}-${{ runner.arch }}- - # When the PR changes the base image, test.sh builds it locally # (stirling-pdf-base:local) into the daemon image store. A buildx # container builder can't see that store, so skip it here and let diff --git a/.github/workflows/e2e-live.yml b/.github/workflows/e2e-live.yml index 7bc95df05e..43d66dd1cf 100644 --- a/.github/workflows/e2e-live.yml +++ b/.github/workflows/e2e-live.yml @@ -21,39 +21,21 @@ jobs: egress-policy: audit - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Restore cache Gradle User Home + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + - name: Set up JDK 25 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: java-version: "25" distribution: "temurin" - - name: Cache Gradle User Home - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} - restore-keys: | - gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- - gradle-${{ runner.os }}-${{ runner.arch }}- - # Gradle does not retry 429s, and a cold cache resolving the buildscript - # classpath is exactly where Maven Central rate-limits us. Retry it here, - # where a failure is cheap, instead of inside the backgrounded bootRun. - - name: Prime Gradle dependencies - env: - MAVEN_USER: ${{ secrets.MAVEN_USER }} - MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }} - MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }} - run: | - for attempt in 1 2 3; do - if ./gradlew --quiet -PnoSpotless :stirling-pdf:classes; then - exit 0 - fi - echo "::warning::Gradle dependency resolution failed (attempt $attempt of 3)" - sleep $((attempt * 30)) - done - echo "::error::Gradle could not resolve dependencies after 3 attempts" - exit 1 + - name: Set up Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: diff --git a/.github/workflows/frontend-backend-licenses-update.yml b/.github/workflows/frontend-backend-licenses-update.yml index 458766660f..7fd0136718 100644 --- a/.github/workflows/frontend-backend-licenses-update.yml +++ b/.github/workflows/frontend-backend-licenses-update.yml @@ -348,22 +348,19 @@ jobs: app-id: ${{ secrets.GH_APP_ID }} private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - - name: Set up JDK 25 - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 - with: - java-version: "25" - distribution: "temurin" - - - name: Cache Gradle User Home + - name: Cache Gradle uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.gradle/caches ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} - restore-keys: | - gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- - gradle-${{ runner.os }}-${{ runner.arch }}- + key: gradle-license-report-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + + - name: Set up JDK 25 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + with: + java-version: "25" + distribution: "temurin" - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 diff --git a/.github/workflows/multiOSReleases.yml b/.github/workflows/multiOSReleases.yml index d9477f722f..0f0b2d3585 100644 --- a/.github/workflows/multiOSReleases.yml +++ b/.github/workflows/multiOSReleases.yml @@ -52,22 +52,19 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Set up JDK 25 - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 - with: - java-version: "25" - distribution: "temurin" - - - name: Cache Gradle User Home + - name: Cache Gradle uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.gradle/caches ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} - restore-keys: | - gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- - gradle-${{ runner.os }}-${{ runner.arch }}- + key: gradle-tauri-releases-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + + - name: Set up JDK 25 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + with: + java-version: "25" + distribution: "temurin" - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 @@ -145,22 +142,19 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Set up JDK 25 - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 - with: - java-version: "25" - distribution: "temurin" - - - name: Cache Gradle User Home + - name: Cache Gradle uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.gradle/caches ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} - restore-keys: | - gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- - gradle-${{ runner.os }}-${{ runner.arch }}- + key: gradle-tauri-releases-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + + - name: Set up JDK 25 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + with: + java-version: "25" + distribution: "temurin" - name: Setup Node.js if: matrix.variant.build_frontend == true @@ -238,6 +232,14 @@ jobs: toolchain: stable targets: ${{ matrix.platform == 'macos-15' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }} + - name: Cache Gradle + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-tauri-releases-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + # x86_64 JDK is set up first so the aarch64 step below can leave its # JAVA_HOME as the active one. The macOS universal JRE build needs # jmods from both arches; the x64 path is captured into the env @@ -261,17 +263,6 @@ jobs: java-version: "25" distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }} - - name: Cache Gradle User Home - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} - restore-keys: | - gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- - gradle-${{ runner.os }}-${{ runner.arch }}- - - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 diff --git a/.github/workflows/push-docker.yml b/.github/workflows/push-docker.yml index ec9d14822c..ea379cf7c6 100644 --- a/.github/workflows/push-docker.yml +++ b/.github/workflows/push-docker.yml @@ -58,22 +58,19 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Set up JDK 25 - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 - with: - java-version: "25" - distribution: "temurin" - - - name: Cache Gradle User Home + - name: Cache Gradle uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.gradle/caches ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} - restore-keys: | - gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- - gradle-${{ runner.os }}-${{ runner.arch }}- + key: gradle-push-docker-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + + - name: Set up JDK 25 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + with: + java-version: "25" + distribution: "temurin" - name: Set up Docker Buildx id: buildx diff --git a/.github/workflows/swagger.yml b/.github/workflows/swagger.yml index 115de87d4e..1bfc94be5b 100644 --- a/.github/workflows/swagger.yml +++ b/.github/workflows/swagger.yml @@ -36,22 +36,19 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Set up JDK 25 - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 - with: - java-version: "25" - distribution: "temurin" - - - name: Cache Gradle User Home + - name: Cache Gradle uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.gradle/caches ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} - restore-keys: | - gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- - gradle-${{ runner.os }}-${{ runner.arch }}- + key: gradle-swagger-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + + - name: Set up JDK 25 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + with: + java-version: "25" + distribution: "temurin" - name: Generate Swagger documentation run: ./gradlew :stirling-pdf:generateOpenApiDocs diff --git a/.github/workflows/tauri-build.yml b/.github/workflows/tauri-build.yml index ddf1104bac..e3f3122773 100644 --- a/.github/workflows/tauri-build.yml +++ b/.github/workflows/tauri-build.yml @@ -26,6 +26,10 @@ on: required: false type: boolean default: false + use_shared_cache: + required: false + type: boolean + default: false workflow_dispatch: inputs: platform: @@ -168,6 +172,24 @@ jobs: # Save the dependency cache even if a later step fails cache-on-failure: true + - name: Restore cache Gradle User Home + if: inputs.use_shared_cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + + - name: Restore cache Gradle + if: inputs.use_shared_cache == false + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-tauri-build-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + - name: Set up x86_64 JDK 25 (macOS universal JRE) if: matrix.platform == 'macos-15' uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 @@ -187,17 +209,6 @@ jobs: java-version: "25" distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }} - - name: Cache Gradle User Home - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} - restore-keys: | - gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- - gradle-${{ runner.os }}-${{ runner.arch }}- - - name: Setup Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 diff --git a/.github/workflows/test-build-docker.yml b/.github/workflows/test-build-docker.yml index 4a79cb3733..12d5a35a1f 100644 --- a/.github/workflows/test-build-docker.yml +++ b/.github/workflows/test-build-docker.yml @@ -79,23 +79,20 @@ jobs: docker system prune -af || true echo "Disk space after cleanup:" && df -h + - name: Restore cache Gradle User Home + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + - name: Set up JDK 25 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: java-version: "25" distribution: "temurin" - - name: Cache Gradle User Home - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} - restore-keys: | - gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- - gradle-${{ runner.os }}-${{ runner.arch }}- - - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 - name: Build application