diff --git a/.github/workflows/ai-engine.yml b/.github/workflows/ai-engine.yml index caa5af1acb..357dfa1e49 100644 --- a/.github/workflows/ai-engine.yml +++ b/.github/workflows/ai-engine.yml @@ -34,7 +34,6 @@ jobs: cache-dependency-glob: | engine/pyproject.toml engine/uv.lock - cache-suffix: ai-engine - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 diff --git a/.github/workflows/check-generated-models.yml b/.github/workflows/check-generated-models.yml index 476f9d21a7..f917a4c602 100644 --- a/.github/workflows/check-generated-models.yml +++ b/.github/workflows/check-generated-models.yml @@ -42,7 +42,6 @@ jobs: cache-dependency-glob: | engine/pyproject.toml engine/uv.lock - cache-suffix: generated-models - name: Restore cache Gradle User Home if: inputs.use_shared_cache diff --git a/.github/workflows/pre_commit.yml b/.github/workflows/pre_commit.yml index 3566f5864f..fbd9efc474 100644 --- a/.github/workflows/pre_commit.yml +++ b/.github/workflows/pre_commit.yml @@ -31,7 +31,6 @@ jobs: cache-dependency-glob: | engine/pyproject.toml engine/uv.lock - cache-suffix: pre-commit - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 diff --git a/.github/workflows/sync_files_v2.yml b/.github/workflows/sync_files_v2.yml index a199fd6cbd..f688476159 100644 --- a/.github/workflows/sync_files_v2.yml +++ b/.github/workflows/sync_files_v2.yml @@ -59,7 +59,6 @@ jobs: cache-dependency-glob: | engine/pyproject.toml engine/uv.lock - cache-suffix: sync-files - name: Install Python dependencies run: | diff --git a/app/common/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java b/app/common/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java index 3974213ad1..81e70a6319 100644 --- a/app/common/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java +++ b/app/common/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java @@ -48,7 +48,7 @@ public class EndpointConfiguration { private final ApplicationProperties applicationProperties; @Getter private Map endpointStatuses = new ConcurrentHashMap<>(); private Map> endpointGroups = new ConcurrentHashMap<>(); - private Set disabledGroups = new HashSet<>(); + private Set disabledGroups = ConcurrentHashMap.newKeySet(); private Map endpointDisableReasons = new ConcurrentHashMap<>(); private Map groupDisableReasons = new ConcurrentHashMap<>(); private Map> endpointAlternatives = new ConcurrentHashMap<>(); diff --git a/app/common/src/main/java/stirling/software/SPDF/pdf/parser/TabulaTableParser.java b/app/common/src/main/java/stirling/software/SPDF/pdf/parser/TabulaTableParser.java index b85ddbb08e..d3c516d1fe 100644 --- a/app/common/src/main/java/stirling/software/SPDF/pdf/parser/TabulaTableParser.java +++ b/app/common/src/main/java/stirling/software/SPDF/pdf/parser/TabulaTableParser.java @@ -237,7 +237,7 @@ public class TabulaTableParser implements TableParser { score -= 0.3f; } - return Math.max(0f, Math.min(1f, score)); + return Math.clamp(score, 0f, 1f); } private Bounds tableBounds(Table table) { diff --git a/app/common/src/main/java/stirling/software/common/util/propertyeditor/StringToMapPropertyEditor.java b/app/common/src/main/java/stirling/software/common/util/propertyeditor/StringToMapPropertyEditor.java index 63476d5568..7ab1013a8a 100644 --- a/app/common/src/main/java/stirling/software/common/util/propertyeditor/StringToMapPropertyEditor.java +++ b/app/common/src/main/java/stirling/software/common/util/propertyeditor/StringToMapPropertyEditor.java @@ -15,7 +15,8 @@ public class StringToMapPropertyEditor extends PropertyEditorSupport { @Override public void setAsText(String text) throws IllegalArgumentException { try { - TypeReference> typeRef = new TypeReference<>() {}; + TypeReference> typeRef = + new TypeReference>() {}; Map map = objectMapper.readValue(text, typeRef); setValue(map); } catch (Exception e) { diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/EditTextController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/EditTextController.java index 17d1d7d8a7..b76f52144a 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/EditTextController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/EditTextController.java @@ -237,7 +237,7 @@ public class EditTextController { Matcher matcher = edit.pattern().matcher(joined); List spans = new ArrayList<>(); - StringBuffer interpolation = new StringBuffer(); + StringBuilder interpolation = new StringBuilder(); int previousAppendPosition = 0; while (matcher.find()) { if (matcher.start() == matcher.end()) { diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java index 7f93fb3d64..b6cef0b2d6 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java @@ -95,7 +95,8 @@ public class UIDataController { try (InputStream is = resource.getInputStream()) { Map> licenseData = - objectMapper.readValue(is, new TypeReference<>() {}); + objectMapper.readValue( + is, new TypeReference>>() {}); data.setDependencies(licenseData.get("dependencies")); } catch (IOException e) { log.error("Failed to load licenses data", e); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormPayloadParser.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormPayloadParser.java index f48f419a6d..5236706f74 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormPayloadParser.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormPayloadParser.java @@ -25,12 +25,15 @@ final class FormPayloadParser { private static final String KEY_VALUE = "value"; private static final String KEY_DEFAULT_VALUE = "defaultValue"; - private static final TypeReference> MAP_TYPE = new TypeReference<>() {}; + private static final TypeReference> MAP_TYPE = + new TypeReference>() {}; private static final TypeReference> - MODIFY_FIELD_LIST_TYPE = new TypeReference<>() {}; + MODIFY_FIELD_LIST_TYPE = + new TypeReference>() {}; private static final TypeReference> NEW_FIELD_LIST_TYPE = new TypeReference<>() {}; - private static final TypeReference> STRING_LIST_TYPE = new TypeReference<>() {}; + private static final TypeReference> STRING_LIST_TYPE = + new TypeReference>() {}; private FormPayloadParser() {} diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AddCommentsController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AddCommentsController.java index dc2dd22863..09b1d282e9 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AddCommentsController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AddCommentsController.java @@ -96,7 +96,9 @@ public class AddCommentsController { List dtos; try { - dtos = objectMapper.readValue(commentsJson, new TypeReference<>() {}); + dtos = + objectMapper.readValue( + commentsJson, new TypeReference>() {}); } catch (JacksonException e) { throw new ResponseStatusException( HttpStatus.BAD_REQUEST, "comments must be a JSON array of CommentSpec objects"); diff --git a/app/core/src/main/java/stirling/software/SPDF/service/WeeklyActiveUsersService.java b/app/core/src/main/java/stirling/software/SPDF/service/WeeklyActiveUsersService.java index 3b1ae1d048..23d8247218 100644 --- a/app/core/src/main/java/stirling/software/SPDF/service/WeeklyActiveUsersService.java +++ b/app/core/src/main/java/stirling/software/SPDF/service/WeeklyActiveUsersService.java @@ -4,7 +4,9 @@ import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import lombok.extern.slf4j.Slf4j; @@ -21,7 +23,7 @@ public class WeeklyActiveUsersService { private final Map activeBrowsers = new ConcurrentHashMap<>(); // Track total unique browsers seen (overall) - private long totalUniqueBrowsers = 0; + private final AtomicLong totalUniqueBrowsers = new AtomicLong(0); // Application start time private final Instant startTime = Instant.now(); @@ -36,12 +38,12 @@ public class WeeklyActiveUsersService { return; } - boolean isNewBrowser = !activeBrowsers.containsKey(browserId); - activeBrowsers.put(browserId, Instant.now()); + Instant now = Instant.now(); + Instant previous = activeBrowsers.put(browserId, now); - if (isNewBrowser) { - totalUniqueBrowsers++; - log.debug("New browser recorded: {} (Total: {})", browserId, totalUniqueBrowsers); + if (previous == null) { + long total = totalUniqueBrowsers.incrementAndGet(); + log.debug("New browser recorded: {} (Total: {})", browserId, total); } } @@ -61,7 +63,7 @@ public class WeeklyActiveUsersService { * @return Total unique browsers count */ public long getTotalUniqueBrowsers() { - return totalUniqueBrowsers; + return totalUniqueBrowsers.get(); } /** @@ -88,7 +90,8 @@ public class WeeklyActiveUsersService { activeBrowsers.entrySet().removeIf(entry -> entry.getValue().isBefore(sevenDaysAgo)); } - /** Manual cleanup trigger (can be called by scheduled task if needed) */ + /** Scheduled cleanup trigger running every hour */ + @Scheduled(fixedRate = 3600000) public void performCleanup() { int sizeBefore = activeBrowsers.size(); cleanupOldEntries(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditLevel.java b/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditLevel.java index 59adc2af80..c2b0e53eb7 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditLevel.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditLevel.java @@ -59,7 +59,7 @@ public enum AuditLevel { */ public static AuditLevel fromInt(int level) { // Ensure level is within valid bounds - int boundedLevel = Math.min(Math.max(level, 0), 3); + int boundedLevel = Math.clamp(level, 0, 3); for (AuditLevel auditLevel : values()) { if (auditLevel.level == boundedLevel) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyJobStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyJobStore.java index 8e93871859..f03992ed4d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyJobStore.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyJobStore.java @@ -17,16 +17,16 @@ import org.springframework.data.redis.core.ScanOptions; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Component; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; - import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.common.cluster.JobStore; import stirling.software.common.cluster.JobStoreEntry; +import tools.jackson.core.JacksonException; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.ObjectMapper; + /** * Valkey-backed {@link JobStore}. Each job is one hash; a reverse index maps fileId to jobId. * @@ -44,8 +44,10 @@ public class ValkeyJobStore implements JobStore { private static final String FILE_INDEX_PREFIX = "stirling:file2job:"; private static final ObjectMapper MAPPER = new ObjectMapper(); - private static final TypeReference> LIST_STRING = new TypeReference<>() {}; - private static final TypeReference> MAP_STRING = new TypeReference<>() {}; + private static final TypeReference> LIST_STRING = + new TypeReference>() {}; + private static final TypeReference> MAP_STRING = + new TypeReference>() {}; private final StringRedisTemplate template; @@ -265,7 +267,7 @@ public class ValkeyJobStore implements JobStore { } try { return MAPPER.readValue(v.toString(), MAP_STRING); - } catch (JsonProcessingException e) { + } catch (JacksonException e) { log.warn( "JobStore {} field 'resultMeta' is not valid JSON '{}' - treating as empty", key, @@ -277,7 +279,7 @@ public class ValkeyJobStore implements JobStore { private static String writeJson(Object value) { try { return MAPPER.writeValueAsString(value); - } catch (JsonProcessingException e) { + } catch (JacksonException e) { throw new IllegalStateException("Failed to JSON-serialize JobStore field", e); } } @@ -286,7 +288,7 @@ public class ValkeyJobStore implements JobStore { try { List parsed = MAPPER.readValue(json, LIST_STRING); return parsed == null ? new ArrayList<>() : parsed; - } catch (JsonProcessingException e) { + } catch (JacksonException e) { log.warn( "JobStore {} field 'fileIds' is not valid JSON '{}' - treating as empty", key, diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/config/AuditConfigurationProperties.java b/app/proprietary/src/main/java/stirling/software/proprietary/config/AuditConfigurationProperties.java index 366d91b11c..ac6c25ac5e 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/config/AuditConfigurationProperties.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/config/AuditConfigurationProperties.java @@ -35,7 +35,7 @@ public class AuditConfigurationProperties { // Ensure level is within valid bounds (0-3) int configLevel = auditConfig.getLevel(); - this.level = Math.min(Math.max(configLevel, 0), 3); + this.level = Math.clamp(configLevel, 0, 3); // Retention days (0 means infinite) this.retentionDays = auditConfig.getRetentionDays(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/UsageRestController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/UsageRestController.java index 1230d928cc..65bf8240a4 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/UsageRestController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/UsageRestController.java @@ -48,7 +48,7 @@ public class UsageRestController { @RequestParam(value = "dataType", defaultValue = "all") String dataType, @RequestParam(value = "days", defaultValue = "30") Integer days) { - int lookbackDays = Math.max(1, Math.min(days, 365)); + int lookbackDays = Math.clamp(days, 1, 365); // Get audit events filtered by type List events = getEventsByDataType(dataType, lookbackDays); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/UserLicenseSettings.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/UserLicenseSettings.java index bb7f52142a..1683ad9134 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/model/UserLicenseSettings.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/UserLicenseSettings.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.model; +import java.io.Serial; import java.io.Serializable; import jakarta.persistence.*; @@ -19,7 +20,7 @@ import lombok.*; @ToString public class UserLicenseSettings implements Serializable { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; public static final Long SINGLETON_ID = 1L; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java index 4bfef06c9b..d83b684166 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java @@ -70,21 +70,23 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler { if (!response.isCommitted()) { if (authentication != null) { - if (authentication instanceof Saml2Authentication samlAuthentication) { - // Handle SAML2 logout redirection - getRedirect_saml2(request, response, samlAuthentication); - } else if (authentication instanceof OAuth2AuthenticationToken oAuthToken) { - // Handle OAuth2 logout redirection - getRedirect_oauth2(request, response, oAuthToken); - } else if (authentication instanceof UsernamePasswordAuthenticationToken) { - // Handle Username/Password logout - getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH); - } else { - // Handle unknown authentication types - log.error( - "Authentication class unknown: {}", - authentication.getClass().getSimpleName()); - getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH); + switch (authentication) { + case Saml2Authentication samlAuthentication -> + // Handle SAML2 logout redirection + getRedirect_saml2(request, response, samlAuthentication); + case OAuth2AuthenticationToken oAuthToken -> + // Handle OAuth2 logout redirection + getRedirect_oauth2(request, response, oAuthToken); + case UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken -> + // Handle Username/Password logout + getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH); + default -> { + // Handle unknown authentication types + log.error( + "Authentication class unknown: {}", + authentication.getClass().getSimpleName()); + getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH); + } } } else { if (jwtService != null) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java index 5bf4e95f4c..9923e3aa81 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java @@ -393,40 +393,40 @@ public class SecurityConfiguration { // Handle OAUTH2 Logins if (securityProperties.isOauth2Active()) { http.oauth2Login( - oauth2 -> { - oauth2.loginPage("/login") - .authorizationEndpoint( - authorizationEndpoint -> { - if (clientRegistrationRepository != null) { - authorizationEndpoint - .authorizationRequestResolver( - new TauriAuthorizationRequestResolver( - clientRegistrationRepository)); - } - }) - .successHandler( - new CustomOAuth2AuthenticationSuccessHandler( - loginAttemptService, - securityProperties.getOauth2(), - userService, - jwtService, - licenseSettingsService, - applicationProperties)) - .failureHandler(new CustomOAuth2AuthenticationFailureHandler()) - // Add existing Authorities from the database - .userInfoEndpoint( - userInfoEndpoint -> - userInfoEndpoint - .oidcUserService( - new CustomOAuth2UserService( - securityProperties - .getOauth2(), - userService, - loginAttemptService)) - .userAuthoritiesMapper( - oAuth2userAuthoritiesMapper)) - .permitAll(); - }); + oauth2 -> + oauth2.loginPage("/login") + .authorizationEndpoint( + authorizationEndpoint -> { + if (clientRegistrationRepository != null) { + authorizationEndpoint + .authorizationRequestResolver( + new TauriAuthorizationRequestResolver( + clientRegistrationRepository)); + } + }) + .successHandler( + new CustomOAuth2AuthenticationSuccessHandler( + loginAttemptService, + securityProperties.getOauth2(), + userService, + jwtService, + licenseSettingsService, + applicationProperties)) + .failureHandler( + new CustomOAuth2AuthenticationFailureHandler()) + // Add existing Authorities from the database + .userInfoEndpoint( + userInfoEndpoint -> + userInfoEndpoint + .oidcUserService( + new CustomOAuth2UserService( + securityProperties + .getOauth2(), + userService, + loginAttemptService)) + .userAuthoritiesMapper( + oAuth2userAuthoritiesMapper)) + .permitAll()); } // Handle SAML if (securityProperties.isSaml2Active() && runningProOrHigher) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java index 86a1c5fe0c..6661c86395 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java @@ -703,17 +703,18 @@ public class AuthController { } private long extractEpochMillis(Object claimValue) { - if (claimValue == null) { - return -1L; - } - - if (claimValue instanceof java.util.Date date) { - return date.getTime(); - } - - if (claimValue instanceof Number number) { - long epochSeconds = number.longValue(); - return epochSeconds * 1000L; + switch (claimValue) { + case null -> { + return -1L; + } + case java.util.Date date -> { + return date.getTime(); + } + case Number number -> { + long epochSeconds = number.longValue(); + return epochSeconds * 1000L; + } + default -> {} } return -1L; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java index fdacda72b2..2385eb011f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java @@ -760,14 +760,14 @@ public class UserController { for (Object principal : principals) { List sessionsInformation = sessionRegistry.getAllSessions(principal, false); - if (principal instanceof UserDetails detailsUser) { - userNameP = detailsUser.getUsername(); - } else if (principal instanceof OAuth2User oAuth2User) { - userNameP = oAuth2User.getName(); - } else if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2User) { - userNameP = saml2User.name(); - } else if (principal instanceof String stringUser) { - userNameP = stringUser; + switch (principal) { + case null -> {} + case UserDetails detailsUser -> userNameP = detailsUser.getUsername(); + case OAuth2User oAuth2User -> userNameP = oAuth2User.getName(); + case CustomSaml2AuthenticatedPrincipal saml2User -> + userNameP = saml2User.name(); + case String stringUser -> userNameP = stringUser; + default -> {} } if (userNameP.equalsIgnoreCase(username)) { for (SessionInformation sessionInfo : sessionsInformation) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/Authority.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/Authority.java index 659f7691bd..4ffea54740 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/Authority.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/Authority.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.security.model; +import java.io.Serial; import java.io.Serializable; import org.springframework.security.core.GrantedAuthority; @@ -28,7 +29,7 @@ import lombok.Setter; @Setter public class Authority implements GrantedAuthority, Serializable { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/InviteToken.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/InviteToken.java index 975220bf48..062cce058f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/InviteToken.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/InviteToken.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.security.model; +import java.io.Serial; import java.io.Serializable; import java.time.LocalDateTime; @@ -18,7 +19,7 @@ import lombok.Setter; @Setter public class InviteToken implements Serializable { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationFailureHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationFailureHandler.java index 784a9f0a2f..670b08c53f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationFailureHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationFailureHandler.java @@ -36,57 +36,62 @@ public class CustomOAuth2AuthenticationFailureHandler AuthenticationException exception) throws IOException, ServletException { - if (exception instanceof BadCredentialsException) { - log.error("BadCredentialsException", exception); - getRedirectStrategy().sendRedirect(request, response, "/login?error=badCredentials"); - return; - } - if (exception instanceof DisabledException) { - log.error("User is deactivated: ", exception); - getRedirectStrategy().sendRedirect(request, response, "/logout?userIsDisabled=true"); - return; - } - if (exception instanceof LockedException) { - log.error("Account locked: ", exception); - getRedirectStrategy().sendRedirect(request, response, "/logout?error=locked"); - return; - } - if (exception instanceof OAuth2AuthenticationException oAuth2Exception) { - OAuth2Error error = oAuth2Exception.getError(); - - String errorCode = error.getErrorCode(); - - if ("Password must not be null".equals(error.getErrorCode())) { - errorCode = "userAlreadyExistsWeb"; + switch (exception) { + case BadCredentialsException badCredentialsException -> { + log.error("BadCredentialsException", exception); + getRedirectStrategy() + .sendRedirect(request, response, "/login?error=badCredentials"); + return; } + case DisabledException disabledException -> { + log.error("User is deactivated: ", exception); + getRedirectStrategy() + .sendRedirect(request, response, "/logout?userIsDisabled=true"); + return; + } + case LockedException lockedException -> { + log.error("Account locked: ", exception); + getRedirectStrategy().sendRedirect(request, response, "/logout?error=locked"); + return; + } + case OAuth2AuthenticationException oAuth2Exception -> { + OAuth2Error error = oAuth2Exception.getError(); - log.error( - "OAuth2 Authentication error: {}", - errorCode != null ? errorCode : exception.getMessage(), - exception); - String errorValue = errorCode != null ? errorCode : "oauth2AuthenticationError"; - clearRedirectCookie(response); - boolean tauriState = TauriOAuthUtils.isTauriState(request); - String redirectUrl; - if (tauriState) { - String basePath = - TauriOAuthUtils.defaultTauriCallbackPath(request.getContextPath()); - redirectUrl = basePath; - String stateParam = request.getParameter("state"); - if (stateParam != null && !stateParam.isBlank()) { - redirectUrl = appendQueryParam(redirectUrl, "state", stateParam); - // Extract and pass nonce for CSRF validation - String nonce = TauriOAuthUtils.extractNonceFromState(stateParam); - if (nonce != null) { - redirectUrl = appendQueryParam(redirectUrl, "nonce", nonce); - } + String errorCode = error.getErrorCode(); + + if ("Password must not be null".equals(error.getErrorCode())) { + errorCode = "userAlreadyExistsWeb"; } - redirectUrl = appendQueryParam(redirectUrl, "errorOAuth", errorValue); - } else { - redirectUrl = buildFailureRedirectUrl(request, errorValue); + + log.error( + "OAuth2 Authentication error: {}", + errorCode != null ? errorCode : exception.getMessage(), + exception); + String errorValue = errorCode != null ? errorCode : "oauth2AuthenticationError"; + clearRedirectCookie(response); + boolean tauriState = TauriOAuthUtils.isTauriState(request); + String redirectUrl; + if (tauriState) { + String basePath = + TauriOAuthUtils.defaultTauriCallbackPath(request.getContextPath()); + redirectUrl = basePath; + String stateParam = request.getParameter("state"); + if (stateParam != null && !stateParam.isBlank()) { + redirectUrl = appendQueryParam(redirectUrl, "state", stateParam); + // Extract and pass nonce for CSRF validation + String nonce = TauriOAuthUtils.extractNonceFromState(stateParam); + if (nonce != null) { + redirectUrl = appendQueryParam(redirectUrl, "nonce", nonce); + } + } + redirectUrl = appendQueryParam(redirectUrl, "errorOAuth", errorValue); + } else { + redirectUrl = buildFailureRedirectUrl(request, errorValue); + } + getRedirectStrategy().sendRedirect(request, response, redirectUrl); + return; } - getRedirectStrategy().sendRedirect(request, response, redirectUrl); - return; + default -> {} } log.error("Unhandled authentication exception", exception); super.onAuthenticationFailure(request, response, exception); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2ResponseAuthenticationConverter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2ResponseAuthenticationConverter.java index b2ce4adb68..96dcdecd03 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2ResponseAuthenticationConverter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2ResponseAuthenticationConverter.java @@ -61,7 +61,12 @@ public class CustomSaml2ResponseAuthenticationConverter @Override public Saml2Authentication convert(ResponseToken responseToken) { - Assertion assertion = responseToken.getResponse().getAssertions().getFirst(); + List assertions = responseToken.getResponse().getAssertions(); + if (assertions == null || assertions.isEmpty()) { + log.error("SAML response contains no assertions"); + return null; + } + Assertion assertion = assertions.getFirst(); Map> attributes = extractAttributes(assertion); // Debug log with actual values diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/CustomOAuth2UserService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/CustomOAuth2UserService.java index c1057c7e36..b8054c89d9 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/CustomOAuth2UserService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/CustomOAuth2UserService.java @@ -213,8 +213,11 @@ public class CustomOAuth2UserService implements OAuth2UserService {} + case UserDetails detailsUser -> usernameP = detailsUser.getUsername(); + case OAuth2User oAuth2User -> usernameP = oAuth2User.getName(); + case CustomSaml2AuthenticatedPrincipal saml2User -> + usernameP = saml2User.name(); + case String stringUser -> usernameP = stringUser; + default -> {} } if (usernameP.equalsIgnoreCase(username)) { sessionRegistry.expireSession(sessionsInformation.getSessionId()); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/session/SessionPersistentRegistry.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/session/SessionPersistentRegistry.java index e615416e59..1f3a4e84ff 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/session/SessionPersistentRegistry.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/session/SessionPersistentRegistry.java @@ -47,14 +47,13 @@ public class SessionPersistentRegistry implements SessionRegistry { List sessionInformations = new ArrayList<>(); String principalName = null; - if (principal instanceof UserDetails detailsUser) { - principalName = detailsUser.getUsername(); - } else if (principal instanceof OAuth2User oAuth2User) { - principalName = oAuth2User.getName(); - } else if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2User) { - principalName = saml2User.name(); - } else if (principal instanceof String stringUser) { - principalName = stringUser; + switch (principal) { + case null -> {} + case UserDetails detailsUser -> principalName = detailsUser.getUsername(); + case OAuth2User oAuth2User -> principalName = oAuth2User.getName(); + case CustomSaml2AuthenticatedPrincipal saml2User -> principalName = saml2User.name(); + case String stringUser -> principalName = stringUser; + default -> {} } if (principalName != null) { @@ -78,14 +77,13 @@ public class SessionPersistentRegistry implements SessionRegistry { public void registerNewSession(String sessionId, Object principal) { String principalName = null; - if (principal instanceof UserDetails detailsUser) { - principalName = detailsUser.getUsername(); - } else if (principal instanceof OAuth2User oAuth2User) { - principalName = oAuth2User.getName(); - } else if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2User) { - principalName = saml2User.name(); - } else if (principal instanceof String stringUser) { - principalName = stringUser; + switch (principal) { + case null -> {} + case UserDetails detailsUser -> principalName = detailsUser.getUsername(); + case OAuth2User oAuth2User -> principalName = oAuth2User.getName(); + case CustomSaml2AuthenticatedPrincipal saml2User -> principalName = saml2User.name(); + case String stringUser -> principalName = stringUser; + default -> {} } if (principalName != null) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/converter/JsonMapConverter.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/converter/JsonMapConverter.java index 1c9f7ab765..5576ebb181 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/converter/JsonMapConverter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/converter/JsonMapConverter.java @@ -3,16 +3,16 @@ package stirling.software.proprietary.storage.converter; import java.util.HashMap; import java.util.Map; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; - import jakarta.persistence.AttributeConverter; import jakarta.persistence.Converter; import lombok.extern.slf4j.Slf4j; +import tools.jackson.core.JacksonException; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; + /** * JPA AttributeConverter for storing Map as JSON in database columns. * @@ -33,7 +33,7 @@ public class JsonMapConverter implements AttributeConverter, try { return objectMapper.writeValueAsString(attribute); - } catch (JsonProcessingException e) { + } catch (JacksonException e) { log.error("Failed to convert map to JSON", e); throw new RuntimeException("Failed to convert map to JSON", e); } @@ -48,7 +48,7 @@ public class JsonMapConverter implements AttributeConverter, try { // Try normal parsing first return objectMapper.readValue(dbData, new TypeReference>() {}); - } catch (JsonProcessingException e) { + } catch (JacksonException e) { // Fallback: try double-parsing for legacy double-encoded data // This handles data that was stored as JSON strings instead of JSON objects log.debug("Attempting double-decode fallback for legacy metadata format"); @@ -69,7 +69,7 @@ public class JsonMapConverter implements AttributeConverter, return objectMapper.readValue( node.asText(), new TypeReference>() {}); } - } catch (JsonProcessingException e2) { + } catch (JacksonException e2) { log.error("Failed to parse metadata even with double-decode fallback", e2); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/FileShare.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/FileShare.java index 1b0fd86f78..6ddd0c8a86 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/FileShare.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/FileShare.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.storage.model; +import java.io.Serial; import java.io.Serializable; import java.time.LocalDateTime; @@ -46,7 +47,7 @@ import stirling.software.proprietary.security.model.User; @Setter public class FileShare implements Serializable { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/FileShareAccess.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/FileShareAccess.java index 49f75a4a4c..cb2f5d5209 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/FileShareAccess.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/FileShareAccess.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.storage.model; +import java.io.Serial; import java.io.Serializable; import java.time.LocalDateTime; @@ -39,7 +40,7 @@ import stirling.software.proprietary.security.model.User; @Setter public class FileShareAccess implements Serializable { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StorageCleanupEntry.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StorageCleanupEntry.java index 3158f4c041..68afe20173 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StorageCleanupEntry.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StorageCleanupEntry.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.storage.model; +import java.io.Serial; import java.io.Serializable; import java.time.LocalDateTime; @@ -24,7 +25,7 @@ import lombok.Setter; @Setter public class StorageCleanupEntry implements Serializable { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFile.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFile.java index db80bd1e91..1b098672b6 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFile.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFile.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.storage.model; +import java.io.Serial; import java.io.Serializable; import java.time.LocalDateTime; import java.util.HashSet; @@ -45,7 +46,7 @@ import stirling.software.proprietary.workflow.model.WorkflowSession; @Setter public class StoredFile implements Serializable { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFileBlob.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFileBlob.java index 52ef1107fc..4abcffd3e6 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFileBlob.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFileBlob.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.storage.model; +import java.io.Serial; import java.io.Serializable; import jakarta.persistence.Column; @@ -19,7 +20,7 @@ import lombok.Setter; @Setter public class StoredFileBlob implements Serializable { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; @Id @Column(name = "storage_key", nullable = false, length = 128) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/web/AuditWebFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/web/AuditWebFilter.java index b6f5b47f3b..70847a0702 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/web/AuditWebFilter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/web/AuditWebFilter.java @@ -7,6 +7,7 @@ import org.slf4j.MDC; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; @@ -64,7 +65,7 @@ public class AuditWebFilter extends OncePerRequestFilter { if (auth != null && auth.getAuthorities() != null) { String roles = auth.getAuthorities().stream() - .map(a -> a.getAuthority()) + .map(GrantedAuthority::getAuthority) .reduce((a, b) -> a + "," + b) .orElse(""); MDC.put("userRoles", roles); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/SigningSessionController.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/SigningSessionController.java index 4e95707217..b224a09841 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/SigningSessionController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/SigningSessionController.java @@ -20,8 +20,6 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.server.ResponseStatusException; -import com.fasterxml.jackson.databind.ObjectMapper; - import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -39,11 +37,14 @@ import stirling.software.proprietary.workflow.dto.CertificateInfo; import stirling.software.proprietary.workflow.dto.CertificateValidationResponse; import stirling.software.proprietary.workflow.dto.ParticipantRequest; import stirling.software.proprietary.workflow.dto.WorkflowCreationRequest; +import stirling.software.proprietary.workflow.model.WorkflowParticipant; import stirling.software.proprietary.workflow.model.WorkflowSession; import stirling.software.proprietary.workflow.service.CertificateSubmissionValidator; import stirling.software.proprietary.workflow.service.SigningFinalizationService; import stirling.software.proprietary.workflow.service.WorkflowSessionService; +import tools.jackson.databind.ObjectMapper; + @Slf4j @RestController @RequestMapping("/api/v1/security") @@ -259,7 +260,9 @@ public class SigningSessionController { + "database until manual cleanup.", sessionId, session.getParticipants() != null - ? session.getParticipants().stream().map(p -> p.getEmail()).toList() + ? session.getParticipants().stream() + .map(WorkflowParticipant::getEmail) + .toList() : "unknown", e); throw new ResponseStatusException( diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/WorkflowParticipantController.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/WorkflowParticipantController.java index 5f903e4b56..4df0c93e1d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/WorkflowParticipantController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/WorkflowParticipantController.java @@ -5,6 +5,7 @@ import java.nio.charset.StandardCharsets; import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; import java.util.HashMap; +import java.util.List; import java.util.Map; import org.springframework.http.ContentDisposition; @@ -429,7 +430,7 @@ public class WorkflowParticipantController { java.util.List> wetSigs = objectMapper.readValue( request.getWetSignaturesData(), - new TypeReference>>() {}); + new TypeReference>>() {}); if (wetSigs.size() > WetSignatureMetadata.MAX_SIGNATURES_PER_PARTICIPANT) { throw new ResponseStatusException( HttpStatus.BAD_REQUEST, "Too many wet signatures submitted"); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/WorkflowParticipant.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/WorkflowParticipant.java index 2e6091b963..b119565c13 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/WorkflowParticipant.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/WorkflowParticipant.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.workflow.model; +import java.io.Serial; import java.io.Serializable; import java.time.LocalDateTime; import java.util.ArrayList; @@ -51,7 +52,7 @@ import stirling.software.proprietary.storage.model.ShareAccessRole; @Setter public class WorkflowParticipant implements Serializable { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/WorkflowSession.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/WorkflowSession.java index 3fc6b53b44..7df5af710f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/WorkflowSession.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/WorkflowSession.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.workflow.model; +import java.io.Serial; import java.io.Serializable; import java.time.LocalDateTime; import java.util.ArrayList; @@ -53,7 +54,7 @@ import stirling.software.proprietary.storage.model.StoredFile; @Setter public class WorkflowSession implements Serializable { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/SigningFinalizationService.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/SigningFinalizationService.java index e5e122df45..3fce8c69dd 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/SigningFinalizationService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/SigningFinalizationService.java @@ -217,16 +217,13 @@ public class SigningFinalizationService { wetSignatures.size(), session.getSessionId()); - PDDocument document = pdfDocumentFactory.load(new ByteArrayInputStream(pdfBytes)); - try { + try (PDDocument document = pdfDocumentFactory.load(new ByteArrayInputStream(pdfBytes))) { for (WetSignatureMetadata wetSig : wetSignatures) { applyWetSignatureToPage(document, wetSig); } ByteArrayOutputStream baos = new ByteArrayOutputStream(); document.save(baos); return baos.toByteArray(); - } finally { - document.close(); } } @@ -242,11 +239,10 @@ public class SigningFinalizationService { } PDPage page = document.getPage(pageIndex); - PDPageContentStream contentStream = - new PDPageContentStream( - document, page, PDPageContentStream.AppendMode.APPEND, true, true); - try { + try (PDPageContentStream contentStream = + new PDPageContentStream( + document, page, PDPageContentStream.AppendMode.APPEND, true, true)) { // Use WetSignatureMetadata.extractBase64Data() to strip data URL prefix String base64Data = wetSig.extractBase64Data(); if (base64Data == null || base64Data.isBlank()) { @@ -279,8 +275,6 @@ public class SigningFinalizationService { pdfY, width, height); - } finally { - contentStream.close(); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/WorkflowSessionService.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/WorkflowSessionService.java index 4c60c60df2..a2db5deb5a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/WorkflowSessionService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/WorkflowSessionService.java @@ -954,21 +954,22 @@ public class WorkflowSessionService { Object pemObject = pemParser.readObject(); JcaPEMKeyConverter converter = new JcaPEMKeyConverter().setProvider("BC"); PrivateKeyInfo keyInfo; - if (pemObject instanceof PKCS8EncryptedPrivateKeyInfo encrypted) { - InputDecryptorProvider decryptor = - new JceOpenSSLPKCS8DecryptorProviderBuilder().build(password); - keyInfo = encrypted.decryptPrivateKeyInfo(decryptor); - } else if (pemObject instanceof PEMEncryptedKeyPair encryptedKeyPair) { - PEMDecryptorProvider decryptor = - new JcePEMDecryptorProviderBuilder().build(password); - keyInfo = encryptedKeyPair.decryptKeyPair(decryptor).getPrivateKeyInfo(); - } else if (pemObject instanceof PEMKeyPair keyPair) { - keyInfo = keyPair.getPrivateKeyInfo(); - } else if (pemObject instanceof PrivateKeyInfo info) { - keyInfo = info; - } else { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "Unsupported PEM private key format"); + switch (pemObject) { + case PKCS8EncryptedPrivateKeyInfo encrypted -> { + InputDecryptorProvider decryptor = + new JceOpenSSLPKCS8DecryptorProviderBuilder().build(password); + keyInfo = encrypted.decryptPrivateKeyInfo(decryptor); + } + case PEMEncryptedKeyPair encryptedKeyPair -> { + PEMDecryptorProvider decryptor = + new JcePEMDecryptorProviderBuilder().build(password); + keyInfo = encryptedKeyPair.decryptKeyPair(decryptor).getPrivateKeyInfo(); + } + case PEMKeyPair keyPair -> keyInfo = keyPair.getPrivateKeyInfo(); + case PrivateKeyInfo info -> keyInfo = info; + case null, default -> + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "Unsupported PEM private key format"); } return converter.getPrivateKey(keyInfo); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/util/WorkflowMapper.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/util/WorkflowMapper.java index b2f53c2824..8d61ae032c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/util/WorkflowMapper.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/util/WorkflowMapper.java @@ -4,14 +4,14 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; -import com.fasterxml.jackson.databind.ObjectMapper; - import stirling.software.proprietary.workflow.dto.ParticipantResponse; import stirling.software.proprietary.workflow.dto.WetSignatureMetadata; import stirling.software.proprietary.workflow.dto.WorkflowSessionResponse; import stirling.software.proprietary.workflow.model.WorkflowParticipant; import stirling.software.proprietary.workflow.model.WorkflowSession; +import tools.jackson.databind.ObjectMapper; + /** * Utility class for mapping workflow entities to DTOs. Centralizes conversion logic for consistent * API responses. diff --git a/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateController.java b/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateController.java index 28bbfbdea0..77a87a27bb 100644 --- a/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateController.java +++ b/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateController.java @@ -26,8 +26,8 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.tags.Tag; @@ -167,7 +167,7 @@ public class AiCreateController { if (request.constraints() != null) { try { constraintsPayload = objectMapper.writeValueAsString(request.constraints()); - } catch (JsonProcessingException exc) { + } catch (JacksonException exc) { throw new ResponseStatusException( HttpStatus.BAD_REQUEST, "Invalid constraints payload", exc); } @@ -202,7 +202,7 @@ public class AiCreateController { String payload; try { payload = objectMapper.writeValueAsString(request.draftSections()); - } catch (JsonProcessingException exc) { + } catch (JacksonException exc) { throw new ResponseStatusException( HttpStatus.BAD_REQUEST, "Invalid draft sections payload", exc); } @@ -392,7 +392,7 @@ public class AiCreateController { objectMapper .getTypeFactory() .constructCollectionType(List.class, DraftSection.class)); - } catch (JsonProcessingException exc) { + } catch (JacksonException exc) { log.warn("Failed to parse draft sections payload", exc); return null; } @@ -408,7 +408,7 @@ public class AiCreateController { objectMapper .getTypeFactory() .constructMapType(Map.class, String.class, Object.class)); - } catch (JsonProcessingException exc) { + } catch (JacksonException exc) { log.warn("Failed to parse outline constraints payload", exc); return null; } diff --git a/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateInternalController.java b/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateInternalController.java index 60c8dc4615..04b8302e7e 100644 --- a/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateInternalController.java +++ b/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateInternalController.java @@ -14,8 +14,8 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.tags.Tag; @@ -61,7 +61,7 @@ public class AiCreateInternalController { try { outlineConstraintsPayload = objectMapper.writeValueAsString(request.outlineConstraints()); - } catch (JsonProcessingException exc) { + } catch (JacksonException exc) { throw new ResponseStatusException( HttpStatus.BAD_REQUEST, "Invalid outline constraints payload", exc); } @@ -70,7 +70,7 @@ public class AiCreateInternalController { if (request.draftSections() != null) { try { draftSectionsPayload = objectMapper.writeValueAsString(request.draftSections()); - } catch (JsonProcessingException exc) { + } catch (JacksonException exc) { throw new ResponseStatusException( HttpStatus.BAD_REQUEST, "Invalid draft sections payload", exc); } @@ -136,7 +136,7 @@ public class AiCreateInternalController { .getTypeFactory() .constructCollectionType( List.class, AiCreateController.DraftSection.class)); - } catch (JsonProcessingException exc) { + } catch (JacksonException exc) { log.warn("Failed to parse draft sections payload", exc); return null; } @@ -152,7 +152,7 @@ public class AiCreateInternalController { objectMapper .getTypeFactory() .constructMapType(Map.class, String.class, Object.class)); - } catch (JsonProcessingException exc) { + } catch (JacksonException exc) { log.warn("Failed to parse outline constraints payload", exc); return null; } diff --git a/app/saas/src/main/java/stirling/software/saas/billing/service/StripeUsageReportingService.java b/app/saas/src/main/java/stirling/software/saas/billing/service/StripeUsageReportingService.java index d9445483a3..ae5d3d943a 100644 --- a/app/saas/src/main/java/stirling/software/saas/billing/service/StripeUsageReportingService.java +++ b/app/saas/src/main/java/stirling/software/saas/billing/service/StripeUsageReportingService.java @@ -12,7 +12,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; diff --git a/app/saas/src/main/java/stirling/software/saas/legal/LegalDocumentRegistry.java b/app/saas/src/main/java/stirling/software/saas/legal/LegalDocumentRegistry.java index ffbe030863..48dd6a1c3b 100644 --- a/app/saas/src/main/java/stirling/software/saas/legal/LegalDocumentRegistry.java +++ b/app/saas/src/main/java/stirling/software/saas/legal/LegalDocumentRegistry.java @@ -13,8 +13,8 @@ import java.util.regex.Pattern; import org.springframework.core.io.ClassPathResource; import org.springframework.stereotype.Service; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; import jakarta.annotation.PostConstruct; @@ -56,28 +56,26 @@ public class LegalDocumentRegistry { subprocessorUrl = root.path("subprocessorUrl").asText(""); eulaUrl = root.path("eulaUrl").asText(""); JsonNode docs = root.path("documents"); - docs.fieldNames() - .forEachRemaining( - id -> { - JsonNode d = docs.get(id); - List parts = - objectMapper.convertValue( - d.path("parts"), - objectMapper - .getTypeFactory() - .constructCollectionType( - List.class, String.class)); - documents.put( + docs.forEachEntry( + (id, d) -> { + List parts = + objectMapper.convertValue( + d.path("parts"), + objectMapper + .getTypeFactory() + .constructCollectionType( + List.class, String.class)); + documents.put( + id, + new LegalDocumentMeta( id, - new LegalDocumentMeta( - id, - d.path("label").asText(id), - d.path("displayName").asText(id), - d.path("version").asText("0"), - d.path("effectiveDate").asText(""), - d.path("status").asText("draft"), - parts == null ? List.of() : parts)); - }); + d.path("label").asText(id), + d.path("displayName").asText(id), + d.path("version").asText("0"), + d.path("effectiveDate").asText(""), + d.path("status").asText("draft"), + parts == null ? List.of() : parts)); + }); log.info("[legal] loaded {} document(s) from {}", documents.size(), MANIFEST); } diff --git a/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementGuard.java b/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementGuard.java index ae8474fa42..441ebf1220 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementGuard.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementGuard.java @@ -20,7 +20,7 @@ import org.springframework.stereotype.Component; import org.springframework.web.method.HandlerMethod; import org.springframework.web.servlet.HandlerInterceptor; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.ObjectMapper; import io.micrometer.core.instrument.Counter; import io.micrometer.core.instrument.MeterRegistry; diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java b/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java index 1c495b3afd..c08778bdbc 100644 --- a/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java +++ b/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java @@ -18,7 +18,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.ObjectMapper; import io.swagger.v3.oas.annotations.Hidden; diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/legal/AgreementAssembler.java b/app/saas/src/main/java/stirling/software/saas/procurement/legal/AgreementAssembler.java index c641f2e596..e75f3cbb32 100644 --- a/app/saas/src/main/java/stirling/software/saas/procurement/legal/AgreementAssembler.java +++ b/app/saas/src/main/java/stirling/software/saas/procurement/legal/AgreementAssembler.java @@ -10,7 +10,7 @@ import java.util.Map; import org.springframework.stereotype.Service; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/license/KeygenEnterpriseLicenseService.java b/app/saas/src/main/java/stirling/software/saas/procurement/license/KeygenEnterpriseLicenseService.java index a11ad05755..a1c09a9cb9 100644 --- a/app/saas/src/main/java/stirling/software/saas/procurement/license/KeygenEnterpriseLicenseService.java +++ b/app/saas/src/main/java/stirling/software/saas/procurement/license/KeygenEnterpriseLicenseService.java @@ -16,8 +16,8 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java b/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java index a739e538b2..26bfb733dd 100644 --- a/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java +++ b/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java @@ -10,8 +10,8 @@ import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; @@ -707,7 +707,7 @@ public class ProcurementService { private String writeLineItems(QuoteBreakdown breakdown) { try { return OBJECT_MAPPER.writeValueAsString(breakdown.lineItems()); - } catch (JsonProcessingException e) { + } catch (JacksonException e) { log.warn("[procurement] failed to serialise line items", e); return "[]"; } diff --git a/app/saas/src/main/java/stirling/software/saas/service/RateLimitService.java b/app/saas/src/main/java/stirling/software/saas/service/RateLimitService.java index 775c5862ae..8a006a6934 100644 --- a/app/saas/src/main/java/stirling/software/saas/service/RateLimitService.java +++ b/app/saas/src/main/java/stirling/software/saas/service/RateLimitService.java @@ -113,19 +113,13 @@ public class RateLimitService { public void cleanupExpiredBuckets() { long now = System.currentTimeMillis(); - int hourlyRemoved = - (int) - hourlyLimits.entrySet().stream() - .filter(e -> e.getValue().getResetTime() < now) - .peek(e -> hourlyLimits.remove(e.getKey())) - .count(); + int hourlyBefore = hourlyLimits.size(); + hourlyLimits.entrySet().removeIf(e -> e.getValue().getResetTime() < now); + int hourlyRemoved = hourlyBefore - hourlyLimits.size(); - int dailyRemoved = - (int) - dailyLimits.entrySet().stream() - .filter(e -> e.getValue().getResetTime() < now) - .peek(e -> dailyLimits.remove(e.getKey())) - .count(); + int dailyBefore = dailyLimits.size(); + dailyLimits.entrySet().removeIf(e -> e.getValue().getResetTime() < now); + int dailyRemoved = dailyBefore - dailyLimits.size(); if (hourlyRemoved + dailyRemoved > 0) { log.debug( diff --git a/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementGuardTest.java b/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementGuardTest.java index 0a77de3e7e..a9ca7642a6 100644 --- a/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementGuardTest.java +++ b/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementGuardTest.java @@ -28,8 +28,8 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.web.method.HandlerMethod; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.simple.SimpleMeterRegistry; diff --git a/frontend/editor/public/locales/ar-AR/translation.toml b/frontend/editor/public/locales/ar-AR/translation.toml index 4c4d45e2b4..4180d77811 100644 --- a/frontend/editor/public/locales/ar-AR/translation.toml +++ b/frontend/editor/public/locales/ar-AR/translation.toml @@ -3526,7 +3526,6 @@ label = "إحداثي Y" [crop.error] failed = "فشل قصّ PDF" -invalidArea = "منطقة القص تتجاوز حدود PDF" [crop.preview] title = "معاينة منطقة القص" diff --git a/frontend/editor/public/locales/az-AZ/translation.toml b/frontend/editor/public/locales/az-AZ/translation.toml index f73930133e..0033be05d5 100644 --- a/frontend/editor/public/locales/az-AZ/translation.toml +++ b/frontend/editor/public/locales/az-AZ/translation.toml @@ -3526,7 +3526,6 @@ label = "Y mövqeyi" [crop.error] failed = "PDF-i kəsmək alınmadı" -invalidArea = "Kəsmə sahəsi PDF sərhədlərini aşır" [crop.preview] title = "Kəsmə sahəsinin seçimi" diff --git a/frontend/editor/public/locales/bg-BG/translation.toml b/frontend/editor/public/locales/bg-BG/translation.toml index dc6ef92e39..07cf5a73d3 100644 --- a/frontend/editor/public/locales/bg-BG/translation.toml +++ b/frontend/editor/public/locales/bg-BG/translation.toml @@ -3526,7 +3526,6 @@ label = "Y позиция" [crop.error] failed = "Неуспешно изрязване на PDF" -invalidArea = "Областта за изрязване излиза извън границите на PDF" [crop.preview] title = "Избор на област за изрязване" diff --git a/frontend/editor/public/locales/bo-CN/translation.toml b/frontend/editor/public/locales/bo-CN/translation.toml index 86d0b4f10d..21193f5f2d 100644 --- a/frontend/editor/public/locales/bo-CN/translation.toml +++ b/frontend/editor/public/locales/bo-CN/translation.toml @@ -3526,7 +3526,6 @@ label = "Yཡི་གནས་བབ།" [crop.error] failed = "སོན་བཟང་མ་འདང་བ། PDF" -invalidArea = "སོན་འདེབས་རྒྱ་ཁྱོན་དེ་PDFམཚམས་ཐིག་ལས་བརྒལ་ཡོད།" [crop.preview] title = "སོན་བཟང་ཁུལ་འདེམས་པ།" diff --git a/frontend/editor/public/locales/ca-CA/translation.toml b/frontend/editor/public/locales/ca-CA/translation.toml index 498da94436..c22244ac98 100644 --- a/frontend/editor/public/locales/ca-CA/translation.toml +++ b/frontend/editor/public/locales/ca-CA/translation.toml @@ -3526,7 +3526,6 @@ label = "Posició Y" [crop.error] failed = "No s'ha pogut retallar el PDF" -invalidArea = "L'àrea de retall s'estén més enllà dels límits del PDF" [crop.preview] title = "Selecció de l'àrea de retall" diff --git a/frontend/editor/public/locales/cs-CZ/translation.toml b/frontend/editor/public/locales/cs-CZ/translation.toml index 7b234329c7..f2161674f4 100644 --- a/frontend/editor/public/locales/cs-CZ/translation.toml +++ b/frontend/editor/public/locales/cs-CZ/translation.toml @@ -3526,7 +3526,6 @@ label = "Pozice Y" [crop.error] failed = "Oříznutí PDF se nezdařilo" -invalidArea = "Oblast ořezu přesahuje hranice PDF" [crop.preview] title = "Výběr oblasti ořezu" diff --git a/frontend/editor/public/locales/da-DK/translation.toml b/frontend/editor/public/locales/da-DK/translation.toml index f4c068da92..a0ddd7f4d9 100644 --- a/frontend/editor/public/locales/da-DK/translation.toml +++ b/frontend/editor/public/locales/da-DK/translation.toml @@ -3526,7 +3526,6 @@ label = "Y-position" [crop.error] failed = "Kunne ikke beskære PDF" -invalidArea = "Beskæringsområdet strækker sig ud over PDF'ens grænser" [crop.preview] title = "Valg af beskæringsområde" diff --git a/frontend/editor/public/locales/de-DE/translation.toml b/frontend/editor/public/locales/de-DE/translation.toml index 7c1bc79a8c..81c88d7599 100644 --- a/frontend/editor/public/locales/de-DE/translation.toml +++ b/frontend/editor/public/locales/de-DE/translation.toml @@ -3526,7 +3526,6 @@ label = "Y-Position" [crop.error] failed = "PDF zuschneiden fehlgeschlagen" -invalidArea = "Zuschneidebereich überschreitet die PDF-Grenzen" [crop.preview] title = "Zuschneidebereich-Auswahl" diff --git a/frontend/editor/public/locales/el-GR/translation.toml b/frontend/editor/public/locales/el-GR/translation.toml index 5269791aec..57ebd2eb6b 100644 --- a/frontend/editor/public/locales/el-GR/translation.toml +++ b/frontend/editor/public/locales/el-GR/translation.toml @@ -3526,7 +3526,6 @@ label = "Θέση Y" [crop.error] failed = "Αποτυχία περικοπής του PDF" -invalidArea = "Η περιοχή περικοπής εκτείνεται πέρα από τα όρια του PDF" [crop.preview] title = "Επιλογή περιοχής περικοπής" diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml index 8fdd452928..d2b2aa38e3 100644 --- a/frontend/editor/public/locales/en-GB/translation.toml +++ b/frontend/editor/public/locales/en-GB/translation.toml @@ -3526,7 +3526,6 @@ label = "Y Position" [crop.error] failed = "Failed to crop PDF" -invalidArea = "Crop area extends beyond PDF boundaries" [crop.preview] title = "Crop Area Selection" diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index b9eef1aa4e..b22bdb259f 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -3931,7 +3931,6 @@ label = "Y Position" [crop.error] failed = "Failed to crop PDF" -invalidArea = "Crop area extends beyond PDF boundaries" [crop.preview] title = "Crop Area Selection" diff --git a/frontend/editor/public/locales/es-ES/translation.toml b/frontend/editor/public/locales/es-ES/translation.toml index 73aeb53429..5fffd6eae8 100644 --- a/frontend/editor/public/locales/es-ES/translation.toml +++ b/frontend/editor/public/locales/es-ES/translation.toml @@ -3526,7 +3526,6 @@ label = "Posición Y" [crop.error] failed = "Error al recortar PDF" -invalidArea = "El área de recorte se extiende más allá de los límites del PDF" [crop.preview] title = "Selección de Área de Recorte" diff --git a/frontend/editor/public/locales/eu-ES/translation.toml b/frontend/editor/public/locales/eu-ES/translation.toml index b5a2388e67..018bfbabc5 100644 --- a/frontend/editor/public/locales/eu-ES/translation.toml +++ b/frontend/editor/public/locales/eu-ES/translation.toml @@ -3526,7 +3526,6 @@ label = "Y posizioa" [crop.error] failed = "Huts egin du PDFa mozteak" -invalidArea = "Mozketa-area PDFaren mugak baino harago doa" [crop.preview] title = "Mozketa-arearen hautapena" diff --git a/frontend/editor/public/locales/fa-IR/translation.toml b/frontend/editor/public/locales/fa-IR/translation.toml index 14a40c4694..000d039dfa 100644 --- a/frontend/editor/public/locales/fa-IR/translation.toml +++ b/frontend/editor/public/locales/fa-IR/translation.toml @@ -3526,7 +3526,6 @@ label = "موقعیت Y" [crop.error] failed = "برش PDF ناموفق بود" -invalidArea = "ناحیه برش از مرزهای PDF فراتر رفته است" [crop.preview] title = "انتخاب ناحیه برش" diff --git a/frontend/editor/public/locales/fr-FR/translation.toml b/frontend/editor/public/locales/fr-FR/translation.toml index 5550076052..e7d8a2a9ab 100644 --- a/frontend/editor/public/locales/fr-FR/translation.toml +++ b/frontend/editor/public/locales/fr-FR/translation.toml @@ -3526,7 +3526,6 @@ label = "Position Y" [crop.error] failed = "Échec du recadrage du PDF" -invalidArea = "La zone de recadrage dépasse les limites du PDF" [crop.preview] title = "Sélection de la zone de recadrage" diff --git a/frontend/editor/public/locales/ga-IE/translation.toml b/frontend/editor/public/locales/ga-IE/translation.toml index 4d3ad56168..1507ae92df 100644 --- a/frontend/editor/public/locales/ga-IE/translation.toml +++ b/frontend/editor/public/locales/ga-IE/translation.toml @@ -3526,7 +3526,6 @@ label = "Suíomh Y" [crop.error] failed = "Theip ar an PDF a bhearradh" -invalidArea = "Téann an limistéar bearrtha thar theorainneacha an PDF" [crop.preview] title = "Roghnú Limistéir Bhearrtha" diff --git a/frontend/editor/public/locales/hi-IN/translation.toml b/frontend/editor/public/locales/hi-IN/translation.toml index ae5993b387..bd769216a8 100644 --- a/frontend/editor/public/locales/hi-IN/translation.toml +++ b/frontend/editor/public/locales/hi-IN/translation.toml @@ -3526,7 +3526,6 @@ label = "Y स्थान" [crop.error] failed = "PDF क्रॉप करने में विफल" -invalidArea = "क्रॉप क्षेत्र PDF सीमाओं से बाहर जा रहा है" [crop.preview] title = "क्रॉप क्षेत्र चयन" diff --git a/frontend/editor/public/locales/hr-HR/translation.toml b/frontend/editor/public/locales/hr-HR/translation.toml index 587933825b..371c1b782c 100644 --- a/frontend/editor/public/locales/hr-HR/translation.toml +++ b/frontend/editor/public/locales/hr-HR/translation.toml @@ -3526,7 +3526,6 @@ label = "Y položaj" [crop.error] failed = "Izrezivanje PDF-a nije uspjelo" -invalidArea = "Područje izrezivanja prelazi granice PDF-a" [crop.preview] title = "Odabir područja izrezivanja" diff --git a/frontend/editor/public/locales/hu-HU/translation.toml b/frontend/editor/public/locales/hu-HU/translation.toml index 98d393c047..c8fb681564 100644 --- a/frontend/editor/public/locales/hu-HU/translation.toml +++ b/frontend/editor/public/locales/hu-HU/translation.toml @@ -3526,7 +3526,6 @@ label = "Y pozíció" [crop.error] failed = "A PDF vágása sikertelen" -invalidArea = "A vágási terület túlnyúlik a PDF határain" [crop.preview] title = "Vágási terület kiválasztása" diff --git a/frontend/editor/public/locales/id-ID/translation.toml b/frontend/editor/public/locales/id-ID/translation.toml index 15fe756aa6..83751f1dce 100644 --- a/frontend/editor/public/locales/id-ID/translation.toml +++ b/frontend/editor/public/locales/id-ID/translation.toml @@ -3526,7 +3526,6 @@ label = "Posisi Y" [crop.error] failed = "Gagal memangkas PDF" -invalidArea = "Area pangkas melampaui batas PDF" [crop.preview] title = "Pilihan Area Pangkas" diff --git a/frontend/editor/public/locales/it-IT/translation.toml b/frontend/editor/public/locales/it-IT/translation.toml index a99f00a343..592f644216 100644 --- a/frontend/editor/public/locales/it-IT/translation.toml +++ b/frontend/editor/public/locales/it-IT/translation.toml @@ -3526,7 +3526,6 @@ label = "Posizione Y" [crop.error] failed = "Impossibile ritagliare il PDF" -invalidArea = "L’area di ritaglio supera i limiti del PDF" [crop.preview] title = "Selezione area di ritaglio" diff --git a/frontend/editor/public/locales/ja-JP/translation.toml b/frontend/editor/public/locales/ja-JP/translation.toml index dcc5735dbd..a8d6730ba4 100644 --- a/frontend/editor/public/locales/ja-JP/translation.toml +++ b/frontend/editor/public/locales/ja-JP/translation.toml @@ -3526,7 +3526,6 @@ label = "Y 位置" [crop.error] failed = "PDF の切り抜きに失敗しました" -invalidArea = "切り抜き範囲が PDF の境界を超えています" [crop.preview] title = "切り抜き範囲の選択" diff --git a/frontend/editor/public/locales/ko-KR/translation.toml b/frontend/editor/public/locales/ko-KR/translation.toml index 833f710c7f..33ca27a071 100644 --- a/frontend/editor/public/locales/ko-KR/translation.toml +++ b/frontend/editor/public/locales/ko-KR/translation.toml @@ -3526,7 +3526,6 @@ label = "Y 위치" [crop.error] failed = "PDF 자르기에 실패했습니다" -invalidArea = "자르기 영역이 PDF 경계를 벗어났습니다" [crop.preview] title = "자르기 영역 선택" diff --git a/frontend/editor/public/locales/ml-ML/translation.toml b/frontend/editor/public/locales/ml-ML/translation.toml index 53e8ef302f..10235abe24 100644 --- a/frontend/editor/public/locales/ml-ML/translation.toml +++ b/frontend/editor/public/locales/ml-ML/translation.toml @@ -3526,7 +3526,6 @@ label = "Y സ്ഥാനം" [crop.error] failed = "PDF ക്രോപ്പ് ചെയ്യാൻ കഴിഞ്ഞില്ല" -invalidArea = "ക്രോപ്പ് ഏരിയ PDF അതിരുകൾക്ക് പുറത്തേക്ക് നീളുന്നു" [crop.preview] title = "ക്രോപ്പ് ഏരിയ തിരഞ്ഞെടുപ്പ്" diff --git a/frontend/editor/public/locales/nl-NL/translation.toml b/frontend/editor/public/locales/nl-NL/translation.toml index d4ada20e32..e5841842f2 100644 --- a/frontend/editor/public/locales/nl-NL/translation.toml +++ b/frontend/editor/public/locales/nl-NL/translation.toml @@ -3526,7 +3526,6 @@ label = "Y-positie" [crop.error] failed = "PDF bijsnijden mislukt" -invalidArea = "Bijsnijgebied valt buiten PDF-randen" [crop.preview] title = "Selectie bijsnijgebied" diff --git a/frontend/editor/public/locales/no-NB/translation.toml b/frontend/editor/public/locales/no-NB/translation.toml index 9402f04f22..1dd1167219 100644 --- a/frontend/editor/public/locales/no-NB/translation.toml +++ b/frontend/editor/public/locales/no-NB/translation.toml @@ -3526,7 +3526,6 @@ label = "Y-posisjon" [crop.error] failed = "Kunne ikke beskjære PDF" -invalidArea = "Beskjæringsområdet går utenfor PDF-grensene" [crop.preview] title = "Valg av beskjæringsområde" diff --git a/frontend/editor/public/locales/pl-PL/translation.toml b/frontend/editor/public/locales/pl-PL/translation.toml index 566a681f9a..c327cf2483 100644 --- a/frontend/editor/public/locales/pl-PL/translation.toml +++ b/frontend/editor/public/locales/pl-PL/translation.toml @@ -3526,7 +3526,6 @@ label = "Pozycja Y" [crop.error] failed = "Nie udało się przyciąć PDF" -invalidArea = "Obszar przycięcia wykracza poza granice PDF" [crop.preview] title = "Wybór obszaru przycięcia" diff --git a/frontend/editor/public/locales/pt-BR/translation.toml b/frontend/editor/public/locales/pt-BR/translation.toml index d89968fdfa..8455176984 100644 --- a/frontend/editor/public/locales/pt-BR/translation.toml +++ b/frontend/editor/public/locales/pt-BR/translation.toml @@ -3526,7 +3526,6 @@ label = "Posição Y" [crop.error] failed = "Falha ao recortar o PDF" -invalidArea = "A área de corte se estende além dos limites do PDF" [crop.preview] title = "Seleção da área de corte" diff --git a/frontend/editor/public/locales/pt-PT/translation.toml b/frontend/editor/public/locales/pt-PT/translation.toml index 0f9aebeff7..0b24388d75 100644 --- a/frontend/editor/public/locales/pt-PT/translation.toml +++ b/frontend/editor/public/locales/pt-PT/translation.toml @@ -3526,7 +3526,6 @@ label = "Posição Y" [crop.error] failed = "Falha ao recortar o PDF" -invalidArea = "A área de recorte excede os limites do PDF" [crop.preview] title = "Seleção da área de recorte" diff --git a/frontend/editor/public/locales/ro-RO/translation.toml b/frontend/editor/public/locales/ro-RO/translation.toml index 0f7c1555b8..2a3f4eba7d 100644 --- a/frontend/editor/public/locales/ro-RO/translation.toml +++ b/frontend/editor/public/locales/ro-RO/translation.toml @@ -3526,7 +3526,6 @@ label = "Poziția Y" [crop.error] failed = "Nu s-a putut decupa PDF-ul" -invalidArea = "Zona de decupare depășește limitele PDF-ului" [crop.preview] title = "Selecție zonă de decupare" diff --git a/frontend/editor/public/locales/ru-RU/translation.toml b/frontend/editor/public/locales/ru-RU/translation.toml index eae673f896..7d32f58a6f 100644 --- a/frontend/editor/public/locales/ru-RU/translation.toml +++ b/frontend/editor/public/locales/ru-RU/translation.toml @@ -3526,7 +3526,6 @@ label = "Положение Y" [crop.error] failed = "Не удалось обрезать PDF" -invalidArea = "Область обрезки выходит за границы PDF" [crop.preview] title = "Выбор области обрезки" diff --git a/frontend/editor/public/locales/sk-SK/translation.toml b/frontend/editor/public/locales/sk-SK/translation.toml index 1906d101d0..9383f6a866 100644 --- a/frontend/editor/public/locales/sk-SK/translation.toml +++ b/frontend/editor/public/locales/sk-SK/translation.toml @@ -3526,7 +3526,6 @@ label = "Pozícia Y" [crop.error] failed = "Nepodarilo sa orezať PDF" -invalidArea = "Oblasť orezania presahuje hranice PDF" [crop.preview] title = "Výber oblasti orezania" diff --git a/frontend/editor/public/locales/sl-SI/translation.toml b/frontend/editor/public/locales/sl-SI/translation.toml index 120606f790..c6c9cdbd54 100644 --- a/frontend/editor/public/locales/sl-SI/translation.toml +++ b/frontend/editor/public/locales/sl-SI/translation.toml @@ -3526,7 +3526,6 @@ label = "Položaj Y" [crop.error] failed = "Obrezovanje PDF-ja ni uspelo" -invalidArea = "Območje obrezovanja presega meje PDF-ja" [crop.preview] title = "Izbira območja obrezovanja" diff --git a/frontend/editor/public/locales/sr-LATN-RS/translation.toml b/frontend/editor/public/locales/sr-LATN-RS/translation.toml index 2e4cb53c7b..c639e19155 100644 --- a/frontend/editor/public/locales/sr-LATN-RS/translation.toml +++ b/frontend/editor/public/locales/sr-LATN-RS/translation.toml @@ -3526,7 +3526,6 @@ label = "Y pozicija" [crop.error] failed = "Nije uspelo isecanje PDF-a" -invalidArea = "Oblast isečka prelazi granice PDF-a" [crop.preview] title = "Izbor oblasti za isecanje" diff --git a/frontend/editor/public/locales/sv-SE/translation.toml b/frontend/editor/public/locales/sv-SE/translation.toml index bd61dbedff..ba2b56e5e0 100644 --- a/frontend/editor/public/locales/sv-SE/translation.toml +++ b/frontend/editor/public/locales/sv-SE/translation.toml @@ -3526,7 +3526,6 @@ label = "Y-position" [crop.error] failed = "Det gick inte att beskära PDF" -invalidArea = "Beskärningsområdet sträcker sig utanför PDF:ens gränser" [crop.preview] title = "Val av beskärningsområde" diff --git a/frontend/editor/public/locales/th-TH/translation.toml b/frontend/editor/public/locales/th-TH/translation.toml index 6b1c927d9f..6a294505f1 100644 --- a/frontend/editor/public/locales/th-TH/translation.toml +++ b/frontend/editor/public/locales/th-TH/translation.toml @@ -3526,7 +3526,6 @@ label = "ตำแหน่ง Y" [crop.error] failed = "ครอบตัด PDF ไม่สำเร็จ" -invalidArea = "พื้นที่ครอบตัดเกินขอบเขตของ PDF" [crop.preview] title = "การเลือกพื้นที่ครอบตัด" diff --git a/frontend/editor/public/locales/tr-TR/translation.toml b/frontend/editor/public/locales/tr-TR/translation.toml index 6ac3a86d07..ae580843e5 100644 --- a/frontend/editor/public/locales/tr-TR/translation.toml +++ b/frontend/editor/public/locales/tr-TR/translation.toml @@ -3526,7 +3526,6 @@ label = "Y Konumu" [crop.error] failed = "PDF kırpılamadı" -invalidArea = "Kırpma alanı PDF sınırlarının dışına taşıyor" [crop.preview] title = "Kırpma Alanı Seçimi" diff --git a/frontend/editor/public/locales/uk-UA/translation.toml b/frontend/editor/public/locales/uk-UA/translation.toml index d51189230b..2f9e2daeef 100644 --- a/frontend/editor/public/locales/uk-UA/translation.toml +++ b/frontend/editor/public/locales/uk-UA/translation.toml @@ -3526,7 +3526,6 @@ label = "Позиція Y" [crop.error] failed = "Не вдалося обрізати PDF" -invalidArea = "Область обрізки виходить за межі PDF" [crop.preview] title = "Вибір області обрізки" diff --git a/frontend/editor/public/locales/vi-VN/translation.toml b/frontend/editor/public/locales/vi-VN/translation.toml index a574d29833..566568dade 100644 --- a/frontend/editor/public/locales/vi-VN/translation.toml +++ b/frontend/editor/public/locales/vi-VN/translation.toml @@ -3526,7 +3526,6 @@ label = "Vị trí Y" [crop.error] failed = "Không cắt được PDF" -invalidArea = "Vùng cắt vượt quá ranh giới PDF" [crop.preview] title = "Chọn vùng cắt" diff --git a/frontend/editor/public/locales/zh-BO/translation.toml b/frontend/editor/public/locales/zh-BO/translation.toml index 895fbc5c82..7a64d7453e 100644 --- a/frontend/editor/public/locales/zh-BO/translation.toml +++ b/frontend/editor/public/locales/zh-BO/translation.toml @@ -3526,7 +3526,6 @@ label = "Y 位置" [crop.error] failed = "裁剪 PDF 失败" -invalidArea = "裁剪区域超出 PDF 边界" [crop.preview] title = "裁剪区域选择" diff --git a/frontend/editor/public/locales/zh-CN/translation.toml b/frontend/editor/public/locales/zh-CN/translation.toml index ccf73691ff..b29e3442d4 100644 --- a/frontend/editor/public/locales/zh-CN/translation.toml +++ b/frontend/editor/public/locales/zh-CN/translation.toml @@ -3526,7 +3526,6 @@ label = "Y 位置" [crop.error] failed = "裁剪 PDF 失败" -invalidArea = "裁剪区域超出 PDF 边界" [crop.preview] title = "裁剪区域选择" diff --git a/frontend/editor/public/locales/zh-TW/translation.toml b/frontend/editor/public/locales/zh-TW/translation.toml index 269d9c9d59..efb8adb8b9 100644 --- a/frontend/editor/public/locales/zh-TW/translation.toml +++ b/frontend/editor/public/locales/zh-TW/translation.toml @@ -3526,7 +3526,6 @@ label = "Y 位置" [crop.error] failed = "裁切 PDF 失敗" -invalidArea = "裁切區域超出 PDF 邊界" [crop.preview] title = "裁切區域選擇" diff --git a/frontend/editor/src/core/api/signing.ts b/frontend/editor/src/core/api/signing.ts new file mode 100644 index 0000000000..c58fad8aac --- /dev/null +++ b/frontend/editor/src/core/api/signing.ts @@ -0,0 +1,21 @@ +import apiClient from "@app/services/apiClient"; +import type { + SignRequestSummary, + SessionSummary, +} from "@app/types/signingSession"; + +export interface SigningSessions { + signRequests: SignRequestSummary[]; + mySessions: SessionSummary[]; +} + +/** The two lists the signing UI always needs together. */ +export async function fetchSigningSessions(): Promise { + const [requests, sessions] = await Promise.all([ + apiClient.get( + "/api/v1/security/cert-sign/sign-requests", + ), + apiClient.get("/api/v1/security/cert-sign/sessions"), + ]); + return { signRequests: requests.data, mySessions: sessions.data }; +} diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavHostBridge.tsx b/frontend/editor/src/core/components/shared/quickNav/QuickNavHostBridge.tsx index 9a9101a826..d97674464e 100644 --- a/frontend/editor/src/core/components/shared/quickNav/QuickNavHostBridge.tsx +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavHostBridge.tsx @@ -22,6 +22,7 @@ export interface QuickNavHostBridgeProps { requestNavigation?: (go: () => void) => void; onGoToDefaultState?: () => void; onSelectTool?: (toolId: ToolId) => void; + activeTool?: ToolId | null; /** Merged over the reasons worked out here, for what only the app can see. */ toolReasons?: QuickNavToolReasons; } @@ -34,6 +35,7 @@ export function QuickNavHostBridge({ onOpenSettings, requestNavigation, onSelectTool, + activeTool = null, onGoToDefaultState, toolReasons, }: QuickNavHostBridgeProps) { @@ -59,6 +61,7 @@ export function QuickNavHostBridge({ signingBadge, portalAccess, readerMode, + activeTool, notificationsOpen, toolReasons: mergedToolReasons, }, diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailHost.tsx b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailHost.tsx index 3fba4d7e1f..e7a1b6e8c6 100644 --- a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailHost.tsx +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailHost.tsx @@ -48,6 +48,8 @@ export function QuickNavRailHost() { else go(route); }; + const openingTool = (id: ToolId) => ({ current: host?.activeTool === id }); + const unusable = (id: ToolId) => { const reason = host?.toolReasons?.[id]; return { disabled: Boolean(reason), reason }; @@ -135,6 +137,7 @@ export function QuickNavRailHost() { icon: ( ), + ...openingTool("automate"), ...unusable("automate"), onClick: () => openTool("automate", "/automate"), }, @@ -146,6 +149,7 @@ export function QuickNavRailHost() { ), badge: host?.signingBadge, badgeTone: "warning", + ...openingTool("sharedSign"), ...unusable("sharedSign"), onClick: () => openTool("sharedSign", "/shared-sign"), }, diff --git a/frontend/editor/src/core/components/tools/crop/CropSettings.tsx b/frontend/editor/src/core/components/tools/crop/CropSettings.tsx index 82516059b0..0229613959 100644 --- a/frontend/editor/src/core/components/tools/crop/CropSettings.tsx +++ b/frontend/editor/src/core/components/tools/crop/CropSettings.tsx @@ -1,13 +1,5 @@ import { useState, useEffect } from "react"; -import { - Stack, - Text, - Box, - Group, - Center, - Alert, - Checkbox, -} from "@mantine/core"; +import { Stack, Text, Box, Group, Center, Checkbox } from "@mantine/core"; import { ActionIcon } from "@app/ui/ActionIcon"; import { useTranslation } from "react-i18next"; import RestartAltIcon from "@mui/icons-material/RestartAlt"; @@ -161,7 +153,6 @@ const CropSettings = ({ parameters, disabled = false }: CropSettingsProps) => { ); } - const isCropValid = parameters.isCropAreaValid(pdfBounds); const isFullCrop = parameters.isFullPDFCrop(pdfBounds); return ( @@ -239,18 +230,6 @@ const CropSettings = ({ parameters, disabled = false }: CropSettingsProps) => { showAutomationInfo={false} /> )} - - {/* Validation Alert - Only show when autoCrop is false */} - {!parameters.parameters.autoCrop && !isCropValid && ( - - - {t( - "crop.error.invalidArea", - "Crop area extends beyond PDF boundaries", - )} - - - )} ); }; diff --git a/frontend/editor/src/core/contexts/QuickNavHostContext.test.tsx b/frontend/editor/src/core/contexts/QuickNavHostContext.test.tsx index fc648cbfe5..0a348262fa 100644 --- a/frontend/editor/src/core/contexts/QuickNavHostContext.test.tsx +++ b/frontend/editor/src/core/contexts/QuickNavHostContext.test.tsx @@ -13,6 +13,7 @@ function Probe({ onRead }: { onRead: (value: unknown) => void }) { appMounted: host?.appMounted, chromeless: host?.chromeless, identity: host?.identity, + activeTool: host?.activeTool, openSettings: Boolean(host?.actions.current?.openSettings), }); return null; @@ -26,6 +27,11 @@ function App() { return null; } +function AppWithTool({ tool }: { tool: "automate" | null }) { + useRegisterQuickNavHost({ activeTool: tool }, {}); + return null; +} + function LoginRoute() { useSuppressQuickNavRail(); return null; @@ -71,6 +77,31 @@ describe("QuickNavHostContext", () => { expect(after.openSettings).toBe(false); }); + it("clears the open tool when the next app registers without one", () => { + let latest: Record = {}; + const view = render( + + (latest = value as Record)} + /> + + , + ); + expect(latest.activeTool).toBe("automate"); + + act(() => { + view.rerender( + + (latest = value as Record)} + /> + + , + ); + }); + expect(latest.activeTool).toBe(null); + }); + it("hides the bar while a route with no app chrome is on screen", () => { // appMounted is sticky, so it can't answer "is an app on screen now". const { view, read } = setup(); diff --git a/frontend/editor/src/core/contexts/QuickNavHostContext.tsx b/frontend/editor/src/core/contexts/QuickNavHostContext.tsx index 540ec8cd6c..1a19cffc6f 100644 --- a/frontend/editor/src/core/contexts/QuickNavHostContext.tsx +++ b/frontend/editor/src/core/contexts/QuickNavHostContext.tsx @@ -24,6 +24,7 @@ export interface QuickNavHostData { signingBadge: number; portalAccess: boolean; readerMode: boolean; + activeTool: ToolId | null; /** The app owns the panel; the rail's bell only reports its state. */ notificationsOpen: boolean; /** Translated; absent means usable. */ @@ -61,6 +62,7 @@ const EMPTY_DATA: QuickNavHostData = { signingBadge: 0, portalAccess: false, readerMode: false, + activeTool: null, notificationsOpen: false, hasSettings: false, }; @@ -90,6 +92,7 @@ export function QuickNavHostProvider({ children }: { children: ReactNode }) { merged.signingBadge === prev.signingBadge && merged.portalAccess === prev.portalAccess && merged.readerMode === prev.readerMode && + merged.activeTool === prev.activeTool && merged.notificationsOpen === prev.notificationsOpen && merged.hasSettings === prev.hasSettings && merged.identity?.displayName === prev.identity?.displayName && @@ -143,6 +146,7 @@ export function useRegisterQuickNavHost( signingBadge, portalAccess, readerMode, + activeTool, notificationsOpen, toolReasons, } = data; @@ -155,6 +159,8 @@ export function useRegisterQuickNavHost( signingBadge: signingBadge ?? 0, portalAccess: portalAccess ?? false, readerMode: readerMode ?? false, + // Cleared, not omitted as toolReasons is: a stale tool marks an entry. + activeTool: activeTool ?? null, notificationsOpen: notificationsOpen ?? false, // Omitted when unknown, so the last answer survives a re-fetch. ...(toolReasons ? { toolReasons } : {}), @@ -168,6 +174,7 @@ export function useRegisterQuickNavHost( signingBadge, portalAccess, readerMode, + activeTool, notificationsOpen, toolReasons, hasSettings, diff --git a/frontend/editor/src/core/hooks/signing/useSigningSessions.test.tsx b/frontend/editor/src/core/hooks/signing/useSigningSessions.test.tsx new file mode 100644 index 0000000000..14e473e33c --- /dev/null +++ b/frontend/editor/src/core/hooks/signing/useSigningSessions.test.tsx @@ -0,0 +1,319 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, waitFor, act } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { baseQueryOptions } from "@app/query/queryClient"; +import { TestQueryProvider } from "@app/tests/utils/TestQueryProvider"; +import { useSigningSessions } from "@app/hooks/signing/useSigningSessions"; +import { fetchSigningSessions } from "@app/api/signing"; +import { alert } from "@app/components/toast"; +import { expectConsole } from "@app/tests/failOnConsole"; + +vi.mock("@app/api/signing", () => ({ fetchSigningSessions: vi.fn() })); +vi.mock("@app/components/toast", () => ({ alert: vi.fn() })); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (_k: string, fallback?: string) => fallback ?? _k, + }), +})); + +const mockFetch = vi.mocked(fetchSigningSessions); +const mockAlert = vi.mocked(alert); + +const EMPTY = { signRequests: [], mySessions: [] }; + +function setVisibility(state: "visible" | "hidden") { + Object.defineProperty(document, "visibilityState", { + configurable: true, + get: () => state, + }); + // Bubbles, as the real event does: query-core listens for it on window. + document.dispatchEvent(new Event("visibilitychange", { bubbles: true })); +} + +describe("useSigningSessions", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFetch.mockResolvedValue(EMPTY); + }); + + afterEach(() => { + vi.useRealTimers(); + setVisibility("visible"); + }); + + it("dedupes concurrent observers of the same key", async () => { + const { result } = renderHook( + () => ({ + badge: useSigningSessions({ + enabled: true, + autoRefreshInterval: 60000, + }), + launcher: useSigningSessions({ enabled: true }), + controller: useSigningSessions({ + enabled: true, + autoRefreshInterval: 15000, + }), + }), + { wrapper: TestQueryProvider }, + ); + + await waitFor(() => expect(result.current.badge.loading).toBe(false)); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("does not fetch while disabled", async () => { + vi.useFakeTimers(); + const { result } = renderHook( + () => useSigningSessions({ enabled: false, autoRefreshInterval: 15000 }), + { wrapper: TestQueryProvider }, + ); + + expect(mockFetch).not.toHaveBeenCalled(); + await act(async () => { + vi.advanceTimersByTime(60000); + }); + expect(mockFetch).not.toHaveBeenCalled(); + expect(result.current.signRequests).toEqual([]); + }); + + it("starts fetching when enabled flips on", async () => { + const { result, rerender } = renderHook( + ({ on }: { on: boolean }) => useSigningSessions({ enabled: on }), + { wrapper: TestQueryProvider, initialProps: { on: false } }, + ); + + expect(mockFetch).not.toHaveBeenCalled(); + rerender({ on: true }); + await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(result.current.loading).toBe(false)); + }); + + it("polls on the interval", async () => { + vi.useFakeTimers(); + const { result } = renderHook( + () => useSigningSessions({ enabled: true, autoRefreshInterval: 15000 }), + { wrapper: TestQueryProvider }, + ); + + expect(result.current.loading).toBe(true); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(mockFetch).toHaveBeenCalledTimes(1); + + await act(async () => { + await vi.advanceTimersByTimeAsync(15000); + }); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it("does not raise the spinner while a background poll is in flight", async () => { + // Real timers, a held-open poll, and every render recorded. Asserting on + // result.current alone is not enough: waitFor returns as soon as the fetch + // count moves, before React has re-rendered, so a spinner that did flip on + // would be missed. + const seen: boolean[] = []; + const { result } = renderHook( + () => { + const state = useSigningSessions({ + enabled: true, + autoRefreshInterval: 50, + }); + seen.push(state.loading); + return state; + }, + { wrapper: TestQueryProvider }, + ); + await waitFor(() => expect(result.current.loading).toBe(false)); + + // Marked before the poll: waitFor flushes renders, so recording after it + // would skip straight past the in-flight one. + const fromPollStart = seen.length; + + let release: (v: unknown) => void = () => {}; + mockFetch.mockReturnValueOnce( + new Promise((resolve) => { + release = resolve; + }) as never, + ); + + await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2)); + + // Give React room to render the in-flight state, if it produces one. + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 30)); + }); + + // Mid-poll: this is what the old `silent` flag bought. + expect(seen.slice(fromPollStart)).not.toContain(true); + expect(result.current.loading).toBe(false); + + await act(async () => { + release(EMPTY); + }); + }); + + it("shows the spinner for a user-initiated refresh, not a background poll", async () => { + // Real timers: the in-flight window has to be observable, which is exactly + // what a fake-timer act() hides. + const { result } = renderHook(() => useSigningSessions({ enabled: true }), { + wrapper: TestQueryProvider, + }); + await waitFor(() => expect(result.current.loading).toBe(false)); + + let release: (v: unknown) => void = () => {}; + mockFetch.mockReturnValueOnce( + new Promise((resolve) => { + release = resolve; + }) as never, + ); + + let done: Promise; + act(() => { + done = result.current.refetch(); + }); + await waitFor(() => expect(result.current.loading).toBe(true)); + + await act(async () => { + release(EMPTY); + await done; + }); + expect(result.current.loading).toBe(false); + }); + + it("toasts a first-load failure", async () => { + expectConsole.error(/Failed to fetch signing data/); + mockFetch.mockRejectedValue(new Error("down")); + + const { result } = renderHook(() => useSigningSessions({ enabled: true }), { + wrapper: TestQueryProvider, + }); + + await waitFor(() => expect(result.current.error).toBeTruthy()); + expect(mockAlert).toHaveBeenCalledTimes(1); + }); + + it("stays silent when a background poll fails after a success", async () => { + vi.useFakeTimers(); + mockFetch.mockResolvedValueOnce(EMPTY); + + const { result } = renderHook( + () => useSigningSessions({ enabled: true, autoRefreshInterval: 15000 }), + { wrapper: TestQueryProvider }, + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(result.current.loading).toBe(false); + expect(mockAlert).not.toHaveBeenCalled(); + + mockFetch.mockRejectedValue(new Error("flaky")); + await act(async () => { + await vi.advanceTimersByTimeAsync(15000); + }); + + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockAlert).not.toHaveBeenCalled(); + }); + + it("toasts an explicit refetch failure even with data on screen", async () => { + expectConsole.error(/Failed to fetch signing data/); + const { result } = renderHook(() => useSigningSessions({ enabled: true }), { + wrapper: TestQueryProvider, + }); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(mockAlert).not.toHaveBeenCalled(); + + mockFetch.mockRejectedValue(new Error("nope")); + await act(async () => { + await result.current.refetch(); + }); + + expect(mockAlert).toHaveBeenCalledTimes(1); + }); + + it("stops polling while the tab is hidden", async () => { + vi.useFakeTimers(); + const { result } = renderHook( + () => useSigningSessions({ enabled: true, autoRefreshInterval: 15000 }), + { wrapper: TestQueryProvider }, + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(result.current.loading).toBe(false); + expect(mockFetch).toHaveBeenCalledTimes(1); + + setVisibility("hidden"); + await act(async () => { + await vi.advanceTimersByTimeAsync(60000); + }); + // Four intervals elapsed with the tab in the background. + expect(mockFetch).toHaveBeenCalledTimes(1); + + setVisibility("visible"); + await act(async () => { + await vi.advanceTimersByTimeAsync(15000); + }); + expect(mockFetch.mock.calls.length).toBeGreaterThan(1); + }); + + it("refetches on becoming visible rather than waiting out the interval", async () => { + vi.useFakeTimers(); + // The app client turns focus refetching off globally; TestQueryProvider + // does not, and would pass this on the library default alone. + const client = new QueryClient({ + defaultOptions: { + queries: { ...baseQueryOptions, retry: false, gcTime: Infinity }, + }, + }); + const { result } = renderHook( + () => useSigningSessions({ enabled: true, autoRefreshInterval: 15000 }), + { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }, + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(result.current.loading).toBe(false); + expect(mockFetch).toHaveBeenCalledTimes(1); + + setVisibility("hidden"); + await act(async () => { + await vi.advanceTimersByTimeAsync(60000); + }); + expect(mockFetch).toHaveBeenCalledTimes(1); + + setVisibility("visible"); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it("stops polling once unmounted", async () => { + vi.useFakeTimers(); + const { unmount } = renderHook( + () => useSigningSessions({ enabled: true, autoRefreshInterval: 15000 }), + { wrapper: TestQueryProvider }, + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(mockFetch).toHaveBeenCalledTimes(1); + + unmount(); + await act(async () => { + await vi.advanceTimersByTimeAsync(60000); + }); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/editor/src/core/hooks/signing/useSigningSessions.ts b/frontend/editor/src/core/hooks/signing/useSigningSessions.ts index 785e792414..e001af5441 100644 --- a/frontend/editor/src/core/hooks/signing/useSigningSessions.ts +++ b/frontend/editor/src/core/hooks/signing/useSigningSessions.ts @@ -1,9 +1,14 @@ -import { useState, useCallback, useEffect } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; -import apiClient from "@app/services/apiClient"; +import { fetchSigningSessions } from "@app/api/signing"; +import { qk } from "@app/query/keys"; import { alert } from "@app/components/toast"; import { SignRequestSummary, SessionSummary } from "@app/types/signingSession"; +const EMPTY_REQUESTS: SignRequestSummary[] = []; +const EMPTY_SESSIONS: SessionSummary[] = []; + export interface UseSigningSessionsOptions { enabled?: boolean; autoRefreshInterval?: number; // milliseconds, 0 to disable @@ -18,8 +23,8 @@ export interface UseSigningSessionsResult { } /** - * Hook to fetch signing sessions data (sign requests and user's sessions). - * Supports auto-refresh for real-time updates. + * Signing sessions. Background polls never raise the spinner or a toast; only a + * first load or an explicit refetch does. */ export const useSigningSessions = ( options: UseSigningSessionsOptions = {}, @@ -27,83 +32,64 @@ export const useSigningSessions = ( const { enabled = true, autoRefreshInterval = 0 } = options; const { t } = useTranslation(); - const [signRequests, setSignRequests] = useState([]); - const [mySessions, setMySessions] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); + const { data, isLoading, isLoadingError, error, refetch } = useQuery({ + queryKey: qk.signingSessions(), + queryFn: fetchSigningSessions, + enabled, + staleTime: 0, + refetchInterval: autoRefreshInterval > 0 ? autoRefreshInterval : false, + refetchIntervalInBackground: false, + // The interval pauses while unfocused, so returning has to catch up: the + // client-wide default of false would hold stale data until the next tick. + refetchOnWindowFocus: autoRefreshInterval > 0, + }); - const fetchData = useCallback( - async (opts?: { silent?: boolean }) => { - if (!enabled) return; + const notifyFailure = useCallback(() => { + console.error("Failed to fetch signing data"); + alert({ + alertType: "warning", + title: t("common.error"), + body: t("certSign.fetchFailed", "Failed to load signing data"), + expandable: false, + durationMs: 2500, + }); + }, [t]); - // Background auto-refreshes pass { silent: true } to skip the loading spinner - // and failure toasts; only the initial load and explicit refetch surface errors. - const silent = opts?.silent ?? false; - - if (!silent) setLoading(true); - setError(null); - - try { - const [requestsResponse, sessionsResponse] = await Promise.all([ - apiClient.get( - "/api/v1/security/cert-sign/sign-requests", - ), - apiClient.get( - "/api/v1/security/cert-sign/sessions", - ), - ]); - - setSignRequests(requestsResponse.data); - setMySessions(sessionsResponse.data); - } catch (err) { - const errorObj = - err instanceof Error - ? err - : new Error("Failed to fetch signing data"); - setError(errorObj); - console.error("Failed to fetch signing data:", err); - - if (!silent) { - alert({ - alertType: "warning", - title: t("common.error"), - body: t("certSign.fetchFailed", "Failed to load signing data"), - expandable: false, - durationMs: 2500, - }); - } - } finally { - if (!silent) setLoading(false); - } - }, - [enabled, t], - ); - - // Initial fetch + // isLoadingError is "failed with nothing cached", i.e. a first load. A poll + // that fails after a success keeps the old data and stays silent. + const reportedRef = useRef(false); useEffect(() => { - if (enabled) { - fetchData(); - } - }, [enabled, fetchData]); - - // Auto-refresh - useEffect(() => { - if (!enabled || !autoRefreshInterval || autoRefreshInterval <= 0) { + if (!isLoadingError) { + reportedRef.current = false; return; } + if (reportedRef.current) return; + reportedRef.current = true; + notifyFailure(); + }, [isLoadingError, notifyFailure]); - const interval = setInterval(() => { - fetchData({ silent: true }); - }, autoRefreshInterval); + // Neither isLoading nor isFetching alone matches the old `silent` flag: a + // user-initiated refresh showed the spinner even with data on screen, a + // background poll never did. isFetching cannot tell them apart, so track it. + const [refreshing, setRefreshing] = useState(false); - return () => clearInterval(interval); - }, [enabled, autoRefreshInterval, fetchData]); + const explicitRefetch = useCallback(async () => { + setRefreshing(true); + try { + const result = await refetch(); + // Reported here rather than by the effect: a user-initiated refresh + // should say so even when stale data is already on screen. + if (result.error && !reportedRef.current) notifyFailure(); + } finally { + setRefreshing(false); + } + }, [refetch, notifyFailure]); return { - signRequests, - mySessions, - loading, - error, - refetch: fetchData, + signRequests: data?.signRequests ?? EMPTY_REQUESTS, + mySessions: data?.mySessions ?? EMPTY_SESSIONS, + loading: isLoading || refreshing, + error: (error as Error | null) ?? null, + refetch: explicitRefetch, }; }; diff --git a/frontend/editor/src/core/hooks/tools/crop/useCropParameters.ts b/frontend/editor/src/core/hooks/tools/crop/useCropParameters.ts index cc37421cdf..62e7ede14b 100644 --- a/frontend/editor/src/core/hooks/tools/crop/useCropParameters.ts +++ b/frontend/editor/src/core/hooks/tools/crop/useCropParameters.ts @@ -8,6 +8,7 @@ import { Rectangle, PDFBounds, constrainCropAreaToPDF, + createDefaultCropArea, createFullPDFCropArea, roundCropArea, isRectangle, @@ -29,6 +30,8 @@ export type CropParametersHook = BaseParametersHook & { setCropArea: (cropArea: Rectangle, pdfBounds?: PDFBounds) => void; /** Get current crop area as CropArea object */ getCropArea: () => Rectangle; + /** Reset to default inset crop area inside PDF bounds */ + resetToDefaultCropArea: (pdfBounds: PDFBounds) => void; /** Reset to full PDF dimensions */ resetToFullPDF: (pdfBounds: PDFBounds) => void; /** Check if current crop area is valid for the PDF */ @@ -76,6 +79,15 @@ export const useCropParameters = (): CropParametersHook => { [baseHook], ); + // Reset to default crop area inside PDF bounds (10% inset) + const resetToDefaultCropArea = useCallback( + (pdfBounds: PDFBounds) => { + const defaultCropArea = createDefaultCropArea(pdfBounds); + setCropArea(defaultCropArea); + }, + [setCropArea], + ); + // Reset to cover entire PDF const resetToFullPDF = useCallback( (pdfBounds: PDFBounds) => { @@ -85,31 +97,11 @@ export const useCropParameters = (): CropParametersHook => { [setCropArea], ); - // Check if current crop area is valid for the given PDF bounds + // Check if current crop area is valid (dimensions must be non-zero; out-of-bounds coordinates clamp automatically) const isCropAreaValid = useCallback( - (pdfBounds?: PDFBounds): boolean => { + (_pdfBounds?: PDFBounds): boolean => { const cropArea = getCropArea(); - - // Basic validation - if ( - cropArea.x < 0 || - cropArea.y < 0 || - cropArea.width <= 0 || - cropArea.height <= 0 - ) { - return false; - } - - // PDF bounds validation if provided - if (pdfBounds) { - const tolerance = 0.01; // Small tolerance for floating point precision - return ( - cropArea.x + cropArea.width <= pdfBounds.actualWidth + tolerance && - cropArea.y + cropArea.height <= pdfBounds.actualHeight + tolerance - ); - } - - return true; + return cropArea.width > 0 && cropArea.height > 0; }, [getCropArea], ); @@ -174,6 +166,7 @@ export const useCropParameters = (): CropParametersHook => { validateParameters: () => validateParameters(), setCropArea, getCropArea, + resetToDefaultCropArea, resetToFullPDF, isCropAreaValid, isFullPDFCrop, diff --git a/frontend/editor/src/core/pages/HomePage.tsx b/frontend/editor/src/core/pages/HomePage.tsx index efb6af40d2..6434acbc9e 100644 --- a/frontend/editor/src/core/pages/HomePage.tsx +++ b/frontend/editor/src/core/pages/HomePage.tsx @@ -525,6 +525,7 @@ export default function HomePage() { onSetReaderMode={setReaderMode} onGoToDefaultState={goToDefaultState} onSelectTool={handleToolSelect} + activeTool={selectedToolKey} toolReasons={quickNavToolReasons} /> diff --git a/frontend/editor/src/core/query/keys.ts b/frontend/editor/src/core/query/keys.ts index 5354b56b63..3e44395de0 100644 --- a/frontend/editor/src/core/query/keys.ts +++ b/frontend/editor/src/core/query/keys.ts @@ -1,13 +1,18 @@ /** Editor query keys: ["editor", , ...params]. */ export const qk = { + /** The admin directory payload: a different endpoint and shape to qk.users(). */ + adminUsers: () => ["editor", "adminUsers"] as const, appConfig: () => ["editor", "appConfig"] as const, endpointsAvailability: () => ["editor", "endpointsAvailability"] as const, endpointEnabled: (endpoint: string) => ["editor", "endpointEnabled", endpoint] as const, footerInfo: () => ["editor", "footerInfo"] as const, groupEnabled: (group: string) => ["editor", "groupEnabled", group] as const, + signingSessions: () => ["editor", "signingSessions"] as const, /** Keyed on the asking identity: two users must never share one answer. */ portalAccess: (userId: string | null) => ["editor", "portalAccess", userId] as const, + teamDetails: (teamId: number) => ["editor", "teamDetails", teamId] as const, + teams: () => ["editor", "teams"] as const, users: () => ["editor", "users"] as const, } as const; diff --git a/frontend/editor/src/core/tools/formFill/FieldInput.tsx b/frontend/editor/src/core/tools/formFill/FieldInput.tsx index 9662298dc4..9a8943ea47 100644 --- a/frontend/editor/src/core/tools/formFill/FieldInput.tsx +++ b/frontend/editor/src/core/tools/formFill/FieldInput.tsx @@ -68,8 +68,11 @@ function FieldInputInner({ ); case "checkbox": { - const isChecked = !!value && value !== "Off"; - const onValue = (field.widgets && field.widgets[0]?.exportValue) || "Yes"; + const exportVal = field.widgets && field.widgets[0]?.exportValue; + const isChecked = exportVal + ? value === exportVal || value === "Yes" + : !!value && value !== "Off"; + const onValue = exportVal || "Yes"; return ( @@ -168,7 +170,7 @@ export function FormSaveBar({ {isDirty && ( - + @@ -186,7 +188,7 @@ export function FormSaveBar({ loading={saving} disabled={applying || policyEnforcing} onClick={handleDownload} - style={{ flex: 1 }} + style={{ flex: "1 1 10rem", minWidth: 0 }} > {t("viewer.formBar.download", "Download PDF")} diff --git a/frontend/editor/src/core/tools/formFill/providers/PdfiumFormProvider.ts b/frontend/editor/src/core/tools/formFill/providers/PdfiumFormProvider.ts index 87d18c3f2f..471bba5e43 100644 --- a/frontend/editor/src/core/tools/formFill/providers/PdfiumFormProvider.ts +++ b/frontend/editor/src/core/tools/formFill/providers/PdfiumFormProvider.ts @@ -92,7 +92,7 @@ function toFormField( // Derive value string let value = f.value; if (type === "checkbox") { - value = f.isChecked ? "Yes" : "Off"; + value = f.isChecked ? f.widgets[0]?.exportValue || "Yes" : "Off"; } else if (type === "radio") { // Use widget index as the canonical radio value. // This avoids issues with duplicate exportValues across widgets diff --git a/frontend/editor/src/core/ui/Modal.css b/frontend/editor/src/core/ui/Modal.css index 2988ec920e..e41ffb0eda 100644 --- a/frontend/editor/src/core/ui/Modal.css +++ b/frontend/editor/src/core/ui/Modal.css @@ -1,11 +1,16 @@ +/* The inset is symmetric so the panel sits in the optical centre of the viewport rather than + riding the top edge. It is published as a var because .sui-modal's max-height has to be the + viewport minus both halves of it — if the two drift apart a tall modal overflows the backdrop + and, because the panel is centre-aligned, loses its header off the top of the screen. */ .sui-modal__backdrop { + --modal-inset-block: 2.5rem; position: fixed; inset: 0; background: rgba(0, 0, 0, 0.55); display: flex; - align-items: flex-start; + align-items: center; justify-content: center; - padding: 5rem 1.5rem 1.5rem; + padding: var(--modal-inset-block) 1.5rem; z-index: 100; animation: fadeIn 0.18s ease both; overscroll-behavior: contain; @@ -24,20 +29,19 @@ display: flex; flex-direction: column; width: 100%; - max-height: calc(100vh - 6.5rem); - max-height: calc(100dvh - 6.5rem); /* mobile browser chrome shrinks 100vh */ + max-height: calc(100vh - var(--modal-inset-block) * 2); + /* mobile browser chrome shrinks 100vh */ + max-height: calc(100dvh - var(--modal-inset-block) * 2); overflow: hidden; animation: scaleIn 0.2s cubic-bezier(0.4, 0, 0.2, 1) both; } -/* Phones: drop the tall top inset so the modal gets the vertical space */ +/* Phones: tighten the inset so the modal gets the vertical space. Only the variable moves — + the max-height above follows it, so the pair cannot fall out of step. */ @media (max-width: 30rem) { .sui-modal__backdrop { - padding: 1rem 0.75rem; - align-items: center; - } - .sui-modal { - max-height: calc(100dvh - 2rem); + --modal-inset-block: 1rem; + padding-inline: 0.75rem; } } diff --git a/frontend/editor/src/core/utils/cropCoordinates.ts b/frontend/editor/src/core/utils/cropCoordinates.ts index 5a275c85ea..4b3bf1c600 100644 --- a/frontend/editor/src/core/utils/cropCoordinates.ts +++ b/frontend/editor/src/core/utils/cropCoordinates.ts @@ -204,7 +204,21 @@ export const isPointInThumbnail = ( }; /** - * Create a default crop area that covers the entire PDF + * Create a default crop area inside PDF bounds (10% inset from each edge, centered) + */ +export const createDefaultCropArea = (pdfBounds: PDFBounds): Rectangle => { + const insetX = pdfBounds.actualWidth * 0.1; + const insetY = pdfBounds.actualHeight * 0.1; + return { + x: Math.round(insetX * 10) / 10, + y: Math.round(insetY * 10) / 10, + width: Math.round((pdfBounds.actualWidth - insetX * 2) * 10) / 10, + height: Math.round((pdfBounds.actualHeight - insetY * 2) * 10) / 10, + }; +}; + +/** + * Create a crop area that covers the entire PDF */ export const createFullPDFCropArea = (pdfBounds: PDFBounds): Rectangle => { return { diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/PeopleSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/PeopleSection.tsx index d955cb341b..6b14d2b421 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/PeopleSection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/PeopleSection.tsx @@ -1,5 +1,4 @@ -import { useState, useEffect } from "react"; -import { isAxiosError } from "axios"; +import { useMemo, useState } from "react"; import { Trans, useTranslation } from "react-i18next"; import { Stack, @@ -20,14 +19,12 @@ import { import { Button } from "@app/ui/Button"; import { ActionIcon } from "@app/ui/ActionIcon"; import LocalIcon from "@app/components/shared/LocalIcon"; -import { alert } from "@app/components/toast"; import { userManagementService, User, } from "@app/services/userManagementService"; -import { teamService, Team } from "@app/services/teamService"; +import { type Team } from "@app/services/teamService"; import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex"; -import { useAppConfig } from "@app/contexts/AppConfigContext"; import InviteMembersModal from "@app/components/shared/InviteMembersModal"; import { useLoginRequired } from "@app/hooks/useLoginRequired"; import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner"; @@ -36,17 +33,109 @@ import UpdateSeatsButton from "@app/components/shared/UpdateSeatsButton"; import { useLicense } from "@app/contexts/LicenseContext"; import ChangeUserPasswordModal from "@app/components/shared/ChangeUserPasswordModal"; import { useAuth } from "@app/auth/UseSession"; +import { + useAdminUsers, + useTeams, + useAdminMutation, + useInvalidateAdminDirectory, +} from "@app/hooks/useAdminDirectory"; + +const EXAMPLE_USERS: User[] = [ + { + id: 1, + username: "admin", + email: "admin@example.com", + enabled: true, + roleName: "ROLE_ADMIN", + rolesAsString: "ROLE_ADMIN", + authenticationType: "password", + isActive: true, + lastRequest: Date.now(), + team: { id: 1, name: "Engineering" }, + }, + { + id: 2, + username: "john.doe", + email: "john.doe@example.com", + enabled: true, + roleName: "ROLE_USER", + rolesAsString: "ROLE_USER", + authenticationType: "password", + isActive: false, + lastRequest: Date.now() - 86400000, + team: { id: 1, name: "Engineering" }, + }, + { + id: 3, + username: "jane.smith", + email: "jane.smith@example.com", + enabled: true, + roleName: "ROLE_USER", + rolesAsString: "ROLE_USER", + authenticationType: "oauth", + isActive: true, + lastRequest: Date.now(), + team: { id: 2, name: "Marketing" }, + }, + { + id: 4, + username: "bob.wilson", + email: "bob.wilson@example.com", + enabled: false, + roleName: "ROLE_USER", + rolesAsString: "ROLE_USER", + authenticationType: "password", + isActive: false, + lastRequest: Date.now() - 604800000, + team: undefined, + }, +]; + +const EXAMPLE_TEAMS: Team[] = [ + { id: 1, name: "Engineering", userCount: 3 }, + { id: 2, name: "Marketing", userCount: 2 }, +]; + +const EXAMPLE_LICENSE = { + maxAllowedUsers: 10, + availableSlots: 6, + grandfatheredUserCount: 0, + licenseMaxUsers: 5, + premiumEnabled: true, + totalUsers: 4, +}; export default function PeopleSection() { const { t } = useTranslation(); - const { config } = useAppConfig(); const { loginEnabled } = useLoginRequired(); const { user: currentUser } = useAuth(); const navigate = useNavigate(); const { licenseInfo: globalLicenseInfo } = useLicense(); - const [users, setUsers] = useState([]); - const [teams, setTeams] = useState([]); - const [loading, setLoading] = useState(true); + const admin = useAdminUsers(loginEnabled); + const { data: fetchedTeams } = useTeams(loginEnabled); + const refreshDirectory = useInvalidateAdminDirectory(); + + // Session and MFA state arrive alongside the roster, keyed by username. + const fetchedUsers = useMemo(() => { + if (!admin.data) return []; + return admin.data.users.map((user) => ({ + ...user, + isActive: admin.data.userSessions[user.username] || false, + lastRequest: admin.data.userLastRequest[user.username] || undefined, + mfaEnabled: + ( + admin.data.userSettings?.[user.username] as + | Record + | undefined + )?.mfaEnabled === "true", + })); + }, [admin.data]); + + // Login off means the endpoints are not callable, so the table shows a + // worked example instead of an empty state. + const users = loginEnabled ? fetchedUsers : EXAMPLE_USERS; + const teams = loginEnabled ? (fetchedTeams ?? []) : EXAMPLE_TEAMS; + const loading = loginEnabled && admin.isPending; const [searchQuery, setSearchQuery] = useState(""); const [inviteModalOpened, setInviteModalOpened] = useState(false); const [editUserModalOpened, setEditUserModalOpened] = useState(false); @@ -54,19 +143,20 @@ export default function PeopleSection() { useState(false); const [passwordUser, setPasswordUser] = useState(null); const [selectedUser, setSelectedUser] = useState(null); - const [processing, setProcessing] = useState(false); - const [mailEnabled, setMailEnabled] = useState(false); - const [lockedUsers, setLockedUsers] = useState([]); - - // License information - const [licenseInfo, setLicenseInfo] = useState<{ - maxAllowedUsers: number; - availableSlots: number; - grandfatheredUserCount: number; - licenseMaxUsers: number; - premiumEnabled: boolean; - totalUsers: number; - } | null>(null); + const mailEnabled = loginEnabled ? (admin.data?.mailEnabled ?? false) : false; + const lockedUsers = loginEnabled ? (admin.data?.lockedUsers ?? []) : []; + const licenseInfo = loginEnabled + ? admin.data + ? { + maxAllowedUsers: admin.data.maxAllowedUsers, + availableSlots: admin.data.availableSlots, + grandfatheredUserCount: admin.data.grandfatheredUserCount, + licenseMaxUsers: admin.data.licenseMaxUsers, + premiumEnabled: admin.data.premiumEnabled, + totalUsers: admin.data.totalUsers, + } + : null + : EXAMPLE_LICENSE; const hasNoSlots = licenseInfo ? licenseInfo.availableSlots === 0 : false; const handleAddMembersClick = () => { if (!loginEnabled) { @@ -115,253 +205,103 @@ export default function PeopleSection() { teamId: undefined as number | undefined, }); - useEffect(() => { - fetchData(); - }, []); + const updateUserRole = useAdminMutation({ + write: (payload: { username: string; role: string; teamId?: number }) => + userManagementService.updateUserRole(payload), + // A role edit can also move the user, which changes both teams' counts. + invalidates: ["users", "teams"], + success: t("workspace.people.editMember.success"), + errorFallback: t("workspace.people.editMember.error"), + onDone: () => closeEditModal(), + }); - useEffect(() => { - if (config) { - console.log( - "[PeopleSection] Email invites enabled:", - config.enableEmailInvites, - ); - } - }, [config]); + const toggleEnabled = useAdminMutation({ + write: (user: User) => + userManagementService.toggleUserEnabled(user.username, !user.enabled), + invalidates: ["users"], + success: t("workspace.people.toggleEnabled.success"), + errorFallback: t("workspace.people.toggleEnabled.error"), + }); - const fetchData = async () => { - try { - setLoading(true); + const deleteUser = useAdminMutation({ + write: (username: string) => userManagementService.deleteUser(username), + invalidates: ["users", "teams"], + success: t( + "workspace.people.deleteUserSuccess", + "User deleted successfully", + ), + errorFallback: t( + "workspace.people.deleteUserError", + "Failed to delete user", + ), + }); - if (loginEnabled) { - const [adminData, teamsData] = await Promise.all([ - userManagementService.getUsers(), - teamService.getTeams(), - ]); + const unlockUser = useAdminMutation({ + write: (username: string) => userManagementService.unlockUser(username), + invalidates: ["users"], + success: t( + "workspace.people.unlockUserSuccess", + "User account unlocked successfully", + ), + errorFallback: t( + "workspace.people.unlockUserError", + "Failed to unlock user account", + ), + }); - // Enrich users with session data - const enrichedUsers = adminData.users.map((user) => ({ - ...user, - isActive: adminData.userSessions[user.username] || false, - lastRequest: adminData.userLastRequest[user.username] || undefined, - mfaEnabled: - ( - adminData.userSettings?.[user.username] as - | Record - | undefined - )?.mfaEnabled === "true", - })); + const disableMfa = useAdminMutation({ + write: (username: string) => + userManagementService.disableMfaByAdmin(username), + invalidates: ["users"], + success: t( + "workspace.people.mfa.adminDisableSuccess", + "MFA disabled successfully for user", + ), + errorFallback: t( + "workspace.people.mfa.adminDisableError", + "Failed to disable MFA for user", + ), + }); - setUsers(enrichedUsers); - setTeams(teamsData); - - // Store license information - setLicenseInfo({ - maxAllowedUsers: adminData.maxAllowedUsers, - availableSlots: adminData.availableSlots, - grandfatheredUserCount: adminData.grandfatheredUserCount, - licenseMaxUsers: adminData.licenseMaxUsers, - premiumEnabled: adminData.premiumEnabled, - totalUsers: adminData.totalUsers, - }); - setMailEnabled(adminData.mailEnabled); - setLockedUsers(adminData.lockedUsers || []); - } else { - // Provide example data when login is disabled - const exampleUsers: User[] = [ - { - id: 1, - username: "admin", - email: "admin@example.com", - enabled: true, - roleName: "ROLE_ADMIN", - rolesAsString: "ROLE_ADMIN", - authenticationType: "password", - isActive: true, - lastRequest: Date.now(), - team: { id: 1, name: "Engineering" }, - }, - { - id: 2, - username: "john.doe", - email: "john.doe@example.com", - enabled: true, - roleName: "ROLE_USER", - rolesAsString: "ROLE_USER", - authenticationType: "password", - isActive: false, - lastRequest: Date.now() - 86400000, - team: { id: 1, name: "Engineering" }, - }, - { - id: 3, - username: "jane.smith", - email: "jane.smith@example.com", - enabled: true, - roleName: "ROLE_USER", - rolesAsString: "ROLE_USER", - authenticationType: "oauth", - isActive: true, - lastRequest: Date.now(), - team: { id: 2, name: "Marketing" }, - }, - { - id: 4, - username: "bob.wilson", - email: "bob.wilson@example.com", - enabled: false, - roleName: "ROLE_USER", - rolesAsString: "ROLE_USER", - authenticationType: "password", - isActive: false, - lastRequest: Date.now() - 604800000, - team: undefined, - }, - ]; - - const exampleTeams: Team[] = [ - { id: 1, name: "Engineering", userCount: 3 }, - { id: 2, name: "Marketing", userCount: 2 }, - ]; - - setUsers(exampleUsers); - setTeams(exampleTeams); - setMailEnabled(false); - setLockedUsers([]); - - // Example license information - setLicenseInfo({ - maxAllowedUsers: 10, - availableSlots: 6, - grandfatheredUserCount: 0, - licenseMaxUsers: 5, - premiumEnabled: true, - totalUsers: 4, - }); - } - } catch (error) { - console.error("[PeopleSection] Failed to fetch people data:", error); - alert({ alertType: "error", title: "Failed to load people data" }); - } finally { - setLoading(false); - } - }; - - const handleUpdateUserRole = async () => { + const handleUpdateUserRole = () => { if (!selectedUser) return; - - try { - setProcessing(true); - await userManagementService.updateUserRole({ - username: selectedUser.username, - role: editForm.role, - teamId: editForm.teamId, - }); - alert({ - alertType: "success", - title: t("workspace.people.editMember.success"), - }); - closeEditModal(); - fetchData(); - } catch (error: unknown) { - console.error("[PeopleSection] Failed to update user:", error); - const errorMessage = isAxiosError(error) - ? error.response?.data?.message || - error.response?.data?.error || - error.message - : (error instanceof Error ? error.message : undefined) || - t("workspace.people.editMember.error"); - alert({ alertType: "error", title: errorMessage }); - } finally { - setProcessing(false); - } + updateUserRole.mutate({ + username: selectedUser.username, + role: editForm.role, + teamId: editForm.teamId, + }); }; - const handleToggleEnabled = async (user: User) => { - try { - await userManagementService.toggleUserEnabled( - user.username, - !user.enabled, - ); - alert({ - alertType: "success", - title: t("workspace.people.toggleEnabled.success"), - }); - fetchData(); - } catch (error: unknown) { - console.error("[PeopleSection] Failed to toggle user status:", error); - const errorMessage = isAxiosError(error) - ? error.response?.data?.message || - error.response?.data?.error || - error.message - : (error instanceof Error ? error.message : undefined) || - t("workspace.people.toggleEnabled.error"); - alert({ alertType: "error", title: errorMessage }); - } + const handleToggleEnabled = (user: User) => { + toggleEnabled.mutate(user); }; - const handleDeleteUser = async (user: User) => { + const handleDeleteUser = (user: User) => { const confirmMessage = t( "workspace.people.confirmDelete", "Are you sure you want to delete this user? This action cannot be undone.", ); - if (!window.confirm(`${confirmMessage}\n\nUser: ${user.username}`)) { - return; - } + if ( + !window.confirm(`${confirmMessage} - try { - await userManagementService.deleteUser(user.username); - alert({ - alertType: "success", - title: t( - "workspace.people.deleteUserSuccess", - "User deleted successfully", - ), - }); - fetchData(); - } catch (error: unknown) { - console.error("[PeopleSection] Failed to delete user:", error); - const errorMessage = isAxiosError(error) - ? error.response?.data?.message || - error.response?.data?.error || - error.message - : (error instanceof Error ? error.message : undefined) || - t("workspace.people.deleteUserError", "Failed to delete user"); - alert({ alertType: "error", title: errorMessage }); - } +User: ${user.username}`) + ) + return; + deleteUser.mutate(user.username); }; - const handleUnlockUser = async (user: User) => { + const handleUnlockUser = (user: User) => { const confirmMessage = t( "workspace.people.confirmUnlock", "Are you sure you want to unlock this user account?", ); - if (!window.confirm(`${confirmMessage}\n\nUser: ${user.username}`)) { - return; - } + if ( + !window.confirm(`${confirmMessage} - try { - await userManagementService.unlockUser(user.username); - alert({ - alertType: "success", - title: t( - "workspace.people.unlockUserSuccess", - "User account unlocked successfully", - ), - }); - fetchData(); - } catch (error: unknown) { - console.error("[PeopleSection] Failed to unlock user:", error); - const errorMessage = isAxiosError(error) - ? error.response?.data?.message || - error.response?.data?.error || - error.message - : (error instanceof Error ? error.message : undefined) || - t( - "workspace.people.unlockUserError", - "Failed to unlock user account", - ); - alert({ alertType: "error", title: errorMessage }); - } +User: ${user.username}`) + ) + return; + unlockUser.mutate(user.username); }; const openEditModal = (user: User) => { @@ -549,7 +489,7 @@ export default function PeopleSection() { - + )} @@ -891,40 +831,7 @@ export default function PeopleSection() { height="1rem" /> } - onClick={async () => { - try { - await userManagementService.disableMfaByAdmin( - user.username, - ); - alert({ - alertType: "success", - title: t( - "workspace.people.mfa.adminDisableSuccess", - "MFA disabled successfully for user", - ), - }); - } catch (error: unknown) { - console.error( - "[PeopleSection] Failed to disable MFA for user:", - error, - ); - const errorMessage = isAxiosError(error) - ? error.response?.data?.message || - error.response?.data?.error || - error.message - : (error instanceof Error - ? error.message - : undefined) || - t( - "workspace.people.mfa.adminDisableError", - "Failed to disable MFA for user", - ); - alert({ - alertType: "error", - title: errorMessage, - }); - } - }} + onClick={() => disableMfa.mutate(user.username)} disabled={!loginEnabled} > {t( @@ -968,14 +875,14 @@ export default function PeopleSection() { setInviteModalOpened(false)} - onSuccess={fetchData} + onSuccess={refreshDirectory} /> @@ -1075,7 +982,7 @@ export default function PeopleSection() { />