diff --git a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java index d341a9d0c..e9230185b 100644 --- a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java +++ b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java @@ -307,7 +307,6 @@ public class ApplicationProperties { private boolean enableKeyRotation = false; private boolean enableKeyCleanup = true; private int keyRetentionDays = 7; - private boolean secureCookie; } @Data @@ -364,6 +363,7 @@ public class ApplicationProperties { private CustomPaths customPaths = new CustomPaths(); private String fileUploadLimit; private TempFileManagement tempFileManagement = new TempFileManagement(); + private List corsAllowedOrigins = new ArrayList<>(); public boolean isAnalyticsEnabled() { return this.getEnableAnalytics() != null && this.getEnableAnalytics(); diff --git a/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java b/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java index dab11c697..4dba70300 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java @@ -1,22 +1,49 @@ package stirling.software.SPDF.config; import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import lombok.RequiredArgsConstructor; +import stirling.software.common.model.ApplicationProperties; + @Configuration @RequiredArgsConstructor public class WebMvcConfig implements WebMvcConfigurer { private final EndpointInterceptor endpointInterceptor; + private final ApplicationProperties applicationProperties; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(endpointInterceptor); } + @Override + public void addCorsMappings(CorsRegistry registry) { + // Only configure CORS if allowed origins are specified + if (applicationProperties.getSystem() != null + && applicationProperties.getSystem().getCorsAllowedOrigins() != null + && !applicationProperties.getSystem().getCorsAllowedOrigins().isEmpty()) { + + String[] allowedOrigins = + applicationProperties + .getSystem() + .getCorsAllowedOrigins() + .toArray(new String[0]); + + registry.addMapping("/**") + .allowedOrigins(allowedOrigins) + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH") + .allowedHeaders("*") + .allowCredentials(true) + .maxAge(3600); + } + // If no origins are configured, CORS is not enabled (secure by default) + } + // @Override // public void addResourceHandlers(ResourceHandlerRegistry registry) { // // Handler for external static resources - DISABLED in backend-only mode diff --git a/app/core/src/main/resources/application.properties b/app/core/src/main/resources/application.properties index 1f4e831df..18e1f4f8a 100644 --- a/app/core/src/main/resources/application.properties +++ b/app/core/src/main/resources/application.properties @@ -2,7 +2,7 @@ multipart.enabled=true logging.level.org.springframework=WARN logging.level.org.hibernate=WARN logging.level.org.eclipse.jetty=WARN -#logging.level.org.springframework.security.saml2=TRACE +#logging.level.org.springframework.security.oauth2=DEBUG #logging.level.org.springframework.security=DEBUG #logging.level.org.opensaml=DEBUG #logging.level.stirling.software.proprietary.security=DEBUG @@ -35,12 +35,12 @@ spring.datasource.username=sa spring.datasource.password= spring.h2.console.enabled=false spring.jpa.hibernate.ddl-auto=update -# Defer datasource initialization to ensure that the database is fully set up -# before Hibernate attempts to access it. This is particularly useful when +# Defer datasource initialization to ensure that the database is fully set up +# before Hibernate attempts to access it. This is particularly useful when # using database initialization scripts or tools. spring.jpa.defer-datasource-initialization=true -# Disable SQL logging to avoid cluttering the logs in production. Enable this +# Disable SQL logging to avoid cluttering the logs in production. Enable this # property during development if you need to debug SQL queries. spring.jpa.show-sql=false server.servlet.session.timeout:30m @@ -60,4 +60,4 @@ spring.main.allow-bean-definition-overriding=true java.io.tmpdir=${stirling.tempfiles.directory:${java.io.tmpdir}/stirling-pdf} # V2 features -v2=false +v2=true diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index 6bf882685..412b1abfc 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -64,7 +64,6 @@ security: enableKeyRotation: true # Set to 'true' to enable key pair rotation enableKeyCleanup: true # Set to 'true' to enable key pair cleanup keyRetentionDays: 7 # Number of days to retain old keys. The default is 7 days. - secureCookie: false # Set to 'true' to use secure cookies for JWTs validation: # PDF signature validation settings trust: serverAsAnchor: true # Trust server certificate as anchor for PDF signatures (if configured and self-signed or CA) @@ -125,6 +124,7 @@ system: enableUrlToPDF: false # Set to 'true' to enable URL to PDF, INTERNAL ONLY, known security issues, should not be used externally disableSanitize: false # set to true to disable Sanitize HTML; (can lead to injections in HTML) maxDPI: 500 # Maximum allowed DPI for PDF to image conversion + corsAllowedOrigins: [] # List of allowed origins for CORS (e.g. ['http://localhost:5173', 'https://app.example.com']). Leave empty to disable CORS. serverCertificate: enabled: true # Enable server-side certificate for "Sign with Stirling-PDF" option organizationName: Stirling-PDF # Organization name for generated certificates diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomAuthenticationSuccessHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomAuthenticationSuccessHandler.java index 51908ef03..028cee685 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomAuthenticationSuccessHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomAuthenticationSuccessHandler.java @@ -57,7 +57,6 @@ public class CustomAuthenticationSuccessHandler String jwt = jwtService.generateToken( authentication, Map.of("authType", AuthenticationType.WEB)); - jwtService.addToken(response, jwt); log.debug("JWT generated for user: {}", userName); getRedirectStrategy().sendRedirect(request, response, "/"); 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 136120528..c94c2b607 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 @@ -72,7 +72,6 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler { getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH); } } else if (!jwtService.extractToken(request).isBlank()) { - jwtService.clearToken(response); getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH); } else { // Redirect to login page after logout @@ -115,8 +114,12 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler { // Set service provider keys for the SamlClient samlClient.setSPKeys(certificate, privateKey); - // Redirect to identity provider for logout. todo: add relay state - samlClient.redirectToIdentityProvider(response, null, nameIdValue); + // Build relay state to return user to login page after IdP logout + String relayState = + UrlUtils.getOrigin(request) + request.getContextPath() + LOGOUT_PATH; + + // Redirect to identity provider for logout with relay state + samlClient.redirectToIdentityProvider(response, relayState, nameIdValue); } catch (Exception e) { log.error( "Error retrieving logout URL from Provider {} for user {}", diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/RateLimitResetScheduler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/RateLimitResetScheduler.java index 25b3c5096..a1e8112d1 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/RateLimitResetScheduler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/RateLimitResetScheduler.java @@ -13,7 +13,7 @@ public class RateLimitResetScheduler { private final IPRateLimitingFilter rateLimitingFilter; - @Scheduled(cron = "0 0 0 * * MON") // At 00:00 every Monday TODO: configurable + @Scheduled(cron = "${security.rate-limit.reset-schedule:0 0 0 * * MON}") public void resetRateLimit() { rateLimitingFilter.resetRequestCounts(); } 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 aceb3b712..b7e51fe2c 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 @@ -132,19 +132,15 @@ public class SecurityConfiguration { if (loginEnabledValue) { boolean v2Enabled = appConfig.v2Enabled(); - if (v2Enabled) { - http.addFilterBefore( - jwtAuthenticationFilter(), - UsernamePasswordAuthenticationFilter.class) - .exceptionHandling( - exceptionHandling -> - exceptionHandling.authenticationEntryPoint( - jwtAuthenticationEntryPoint)); - } http.addFilterBefore( userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) - .addFilterAfter(rateLimitingFilter(), UserAuthenticationFilter.class) - .addFilterAfter(firstLoginFilter, UsernamePasswordAuthenticationFilter.class); + .addFilterBefore( + rateLimitingFilter(), UsernamePasswordAuthenticationFilter.class) + .addFilterAfter(firstLoginFilter, IPRateLimitingFilter.class); + + if (v2Enabled) { + http.addFilterBefore(jwtAuthenticationFilter(), UserAuthenticationFilter.class); + } if (!securityProperties.getCsrfDisabled()) { CookieCsrfTokenRepository cookieRepo = @@ -156,6 +152,13 @@ public class SecurityConfiguration { csrf -> csrf.ignoringRequestMatchers( request -> { + String uri = request.getRequestURI(); + + // Ignore CSRF for auth endpoints + if (uri.startsWith("/api/v1/auth/")) { + return true; + } + String apiKey = request.getHeader("X-API-KEY"); // If there's no API key, don't ignore CSRF // (return false) @@ -238,9 +241,12 @@ public class SecurityConfiguration { : uri; return trimmedUri.startsWith("/login") || trimmedUri.startsWith("/oauth") + || trimmedUri.startsWith("/oauth2") || trimmedUri.startsWith("/saml2") || trimmedUri.endsWith(".svg") || trimmedUri.startsWith("/register") + || trimmedUri.startsWith("/signup") + || trimmedUri.startsWith("/auth/callback") || trimmedUri.startsWith("/error") || trimmedUri.startsWith("/images/") || trimmedUri.startsWith("/public/") @@ -252,6 +258,16 @@ public class SecurityConfiguration { || trimmedUri.startsWith("/favicon") || trimmedUri.startsWith( "/api/v1/info/status") + || trimmedUri.startsWith("/api/v1/config") + || trimmedUri.startsWith( + "/api/v1/auth/register") + || trimmedUri.startsWith( + "/api/v1/user/register") + || trimmedUri.startsWith( + "/api/v1/auth/login") + || trimmedUri.startsWith( + "/api/v1/auth/refresh") + || trimmedUri.startsWith("/api/v1/auth/me") || trimmedUri.startsWith("/v1/api-docs") || uri.contains("/v1/api-docs"); }) @@ -277,33 +293,40 @@ public class SecurityConfiguration { // Handle OAUTH2 Logins if (securityProperties.isOauth2Active()) { http.oauth2Login( - oauth2 -> - oauth2.loginPage("/oauth2") - /* - This Custom handler is used to check if the OAUTH2 user trying to log in, already exists in the database. - If user exists, login proceeds as usual. If user does not exist, then it is auto-created but only if 'OAUTH2AutoCreateUser' - is set as true, else login fails with an error message advising the same. - */ - .successHandler( - new CustomOAuth2AuthenticationSuccessHandler( - loginAttemptService, - securityProperties.getOauth2(), - userService, - jwtService)) - .failureHandler( - new CustomOAuth2AuthenticationFailureHandler()) - // Add existing Authorities from the database - .userInfoEndpoint( - userInfoEndpoint -> - userInfoEndpoint - .oidcUserService( - new CustomOAuth2UserService( - securityProperties, - userService, - loginAttemptService)) - .userAuthoritiesMapper( - oAuth2userAuthoritiesMapper)) - .permitAll()); + oauth2 -> { + // v1: Use /oauth2 as login page for Thymeleaf templates + if (!v2Enabled) { + oauth2.loginPage("/oauth2"); + } + + // v2: Don't set loginPage, let default OAuth2 flow handle it + oauth2 + /* + This Custom handler is used to check if the OAUTH2 user trying to log in, already exists in the database. + If user exists, login proceeds as usual. If user does not exist, then it is auto-created but only if 'OAUTH2AutoCreateUser' + is set as true, else login fails with an error message advising the same. + */ + .successHandler( + new CustomOAuth2AuthenticationSuccessHandler( + loginAttemptService, + securityProperties.getOauth2(), + userService, + jwtService)) + .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 new file mode 100644 index 000000000..0dd8ee4bf --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java @@ -0,0 +1,238 @@ +package stirling.software.proprietary.security.controller.api; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.web.bind.annotation.*; + +import io.swagger.v3.oas.annotations.tags.Tag; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.security.model.AuthenticationType; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.model.api.user.UsernameAndPass; +import stirling.software.proprietary.security.service.CustomUserDetailsService; +import stirling.software.proprietary.security.service.JwtServiceInterface; +import stirling.software.proprietary.security.service.UserService; + +/** REST API Controller for authentication operations. */ +@RestController +@RequestMapping("/api/v1/auth") +@RequiredArgsConstructor +@Slf4j +@Tag(name = "Authentication", description = "Endpoints for user authentication and registration") +public class AuthController { + + private final UserService userService; + private final JwtServiceInterface jwtService; + private final CustomUserDetailsService userDetailsService; + + /** + * Login endpoint - replaces Supabase signInWithPassword + * + * @param request Login credentials (email/username and password) + * @param response HTTP response to set JWT cookie + * @return User and session information + */ + @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") + @PostMapping("/login") + public ResponseEntity login( + @RequestBody UsernameAndPass request, HttpServletResponse response) { + try { + // Validate input parameters + if (request.getUsername() == null || request.getUsername().trim().isEmpty()) { + log.warn("Login attempt with null or empty username"); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Username is required")); + } + + if (request.getPassword() == null || request.getPassword().isEmpty()) { + log.warn( + "Login attempt with null or empty password for user: {}", + request.getUsername()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Password is required")); + } + + log.debug("Login attempt for user: {}", request.getUsername()); + + UserDetails userDetails = + userDetailsService.loadUserByUsername(request.getUsername().trim()); + User user = (User) userDetails; + + if (!userService.isPasswordCorrect(user, request.getPassword())) { + log.warn("Invalid password for user: {}", request.getUsername()); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "Invalid credentials")); + } + + if (!user.isEnabled()) { + log.warn("Disabled user attempted login: {}", request.getUsername()); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "User account is disabled")); + } + + Map claims = new HashMap<>(); + claims.put("authType", AuthenticationType.WEB.toString()); + claims.put("role", user.getRolesAsString()); + + String token = jwtService.generateToken(user.getUsername(), claims); + + log.info("Login successful for user: {}", request.getUsername()); + + return ResponseEntity.ok( + Map.of( + "user", buildUserResponse(user), + "session", Map.of("access_token", token, "expires_in", 3600))); + + } catch (UsernameNotFoundException e) { + log.warn("User not found: {}", request.getUsername()); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "Invalid username or password")); + } catch (AuthenticationException e) { + log.error("Authentication failed for user: {}", request.getUsername(), e); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "Invalid credentials")); + } catch (Exception e) { + log.error("Login error for user: {}", request.getUsername(), e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("error", "Internal server error")); + } + } + + /** + * Get current user + * + * @return Current authenticated user information + */ + @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") + @GetMapping("/me") + public ResponseEntity getCurrentUser() { + try { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + + if (auth == null + || !auth.isAuthenticated() + || auth.getPrincipal().equals("anonymousUser")) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "Not authenticated")); + } + + UserDetails userDetails = (UserDetails) auth.getPrincipal(); + User user = (User) userDetails; + + return ResponseEntity.ok(Map.of("user", buildUserResponse(user))); + + } catch (Exception e) { + log.error("Get current user error", e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("error", "Internal server error")); + } + } + + /** + * Logout endpoint + * + * @param response HTTP response + * @return Success message + */ + @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") + @PostMapping("/logout") + public ResponseEntity logout(HttpServletResponse response) { + try { + SecurityContextHolder.clearContext(); + + log.debug("User logged out successfully"); + + return ResponseEntity.ok(Map.of("message", "Logged out successfully")); + + } catch (Exception e) { + log.error("Logout error", e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("error", "Internal server error")); + } + } + + /** + * Refresh token + * + * @param request HTTP request containing current JWT cookie + * @param response HTTP response to set new JWT cookie + * @return New token information + */ + @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") + @PostMapping("/refresh") + public ResponseEntity refresh(HttpServletRequest request, HttpServletResponse response) { + try { + String token = jwtService.extractToken(request); + + if (token == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "No token found")); + } + + jwtService.validateToken(token); + String username = jwtService.extractUsername(token); + + UserDetails userDetails = userDetailsService.loadUserByUsername(username); + User user = (User) userDetails; + + Map claims = new HashMap<>(); + claims.put("authType", user.getAuthenticationType()); + claims.put("role", user.getRolesAsString()); + + String newToken = jwtService.generateToken(username, claims); + + log.debug("Token refreshed for user: {}", username); + + return ResponseEntity.ok(Map.of("access_token", newToken, "expires_in", 3600)); + + } catch (Exception e) { + log.error("Token refresh error", e); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "Token refresh failed")); + } + } + + /** + * Helper method to build user response object + * + * @param user User entity + * @return Map containing user information + */ + private Map buildUserResponse(User user) { + Map userMap = new HashMap<>(); + userMap.put("id", user.getId()); + userMap.put("email", user.getUsername()); // Use username as email + userMap.put("username", user.getUsername()); + userMap.put("role", user.getRolesAsString()); + userMap.put("enabled", user.isEnabled()); + + // Add metadata for OAuth compatibility + Map appMetadata = new HashMap<>(); + appMetadata.put("provider", user.getAuthenticationType()); // Default to email provider + userMap.put("app_metadata", appMetadata); + + return userMap; + } + + // =========================== + // Request/Response DTOs + // =========================== + + /** Login request DTO */ + public record LoginRequest(String email, String password) {} +} 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 8e3aa818d..6d4b803c2 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 @@ -3,6 +3,7 @@ package stirling.software.proprietary.security.controller.api; import java.io.IOException; import java.security.Principal; import java.sql.SQLException; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -15,7 +16,6 @@ import org.springframework.security.core.session.SessionInformation; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler; -import org.springframework.ui.Model; import org.springframework.web.bind.annotation.*; import org.springframework.web.servlet.mvc.support.RedirectAttributes; import org.springframework.web.servlet.view.RedirectView; @@ -56,24 +56,83 @@ public class UserController { @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") @PostMapping("/register") - public String register(@ModelAttribute UsernameAndPass requestModel, Model model) + public ResponseEntity register(@RequestBody UsernameAndPass usernameAndPass) throws SQLException, UnsupportedProviderException { - if (userService.usernameExistsIgnoreCase(requestModel.getUsername())) { - model.addAttribute("error", "Username already exists"); - return "register"; - } try { + log.debug("Registration attempt for user: {}", usernameAndPass.getUsername()); + + if (userService.usernameExistsIgnoreCase(usernameAndPass.getUsername())) { + log.warn( + "Registration failed: username already exists: {}", + usernameAndPass.getUsername()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "User already exists")); + } + + if (!userService.isUsernameValid(usernameAndPass.getUsername())) { + log.warn( + "Registration failed: invalid username format: {}", + usernameAndPass.getUsername()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Invalid username format")); + } + + if (usernameAndPass.getPassword() == null + || usernameAndPass.getPassword().length() < 6) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Password must be at least 6 characters")); + } + Team team = teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME).orElse(null); - userService.saveUser( - requestModel.getUsername(), - requestModel.getPassword(), - team, - Role.USER.getRoleId(), - false); + User user = + userService.saveUser( + usernameAndPass.getUsername(), + usernameAndPass.getPassword(), + team, + Role.USER.getRoleId(), + false); + + log.info("User registered successfully: {}", usernameAndPass.getUsername()); + + return ResponseEntity.status(HttpStatus.CREATED) + .body( + Map.of( + "user", + buildUserResponse(user), + "message", + "Account created successfully. Please log in.")); + } catch (IllegalArgumentException e) { - return "redirect:/login?messageType=invalidUsername"; + log.error("Registration validation error: {}", e.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", e.getMessage())); + } catch (Exception e) { + log.error("Registration error for user: {}", usernameAndPass.getUsername(), e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("error", "Registration failed: " + e.getMessage())); } - return "redirect:/login?registered=true"; + } + + /** + * Helper method to build user response object + * + * @param user User entity + * @return Map containing user information + */ + private Map buildUserResponse(User user) { + Map userMap = new HashMap<>(); + userMap.put("id", user.getId()); + userMap.put("email", user.getUsername()); // Use username as email + userMap.put("username", user.getUsername()); + userMap.put("role", user.getRolesAsString()); + userMap.put("enabled", user.isEnabled()); + + // Add metadata for OAuth compatibility + Map appMetadata = new HashMap<>(); + appMetadata.put("provider", user.getAuthenticationType()); // Default to email provider + userMap.put("app_metadata", appMetadata); + + return userMap; } @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/UserRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/UserRepository.java index 4d74dbfd8..36fa23a6a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/UserRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/UserRepository.java @@ -22,6 +22,8 @@ public interface UserRepository extends JpaRepository { Optional findByApiKey(String apiKey); + Optional findBySsoProviderAndSsoProviderId(String ssoProvider, String ssoProviderId); + List findByAuthenticationTypeIgnoreCase(String authenticationType); @Query("SELECT u FROM User u WHERE u.team IS NULL") diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java index faf50832f..d6a34264f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java @@ -1,8 +1,9 @@ package stirling.software.proprietary.security.filter; import static stirling.software.common.util.RequestUriUtils.isStaticResource; -import static stirling.software.proprietary.security.model.AuthenticationType.*; +import static stirling.software.proprietary.security.model.AuthenticationType.OAUTH2; import static stirling.software.proprietary.security.model.AuthenticationType.SAML2; +import static stirling.software.proprietary.security.model.AuthenticationType.WEB; import java.io.IOException; import java.sql.SQLException; @@ -75,29 +76,60 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { String jwtToken = jwtService.extractToken(request); if (jwtToken == null) { - // Any unauthenticated requests should redirect to /login + // Allow specific auth endpoints to pass through without JWT String requestURI = request.getRequestURI(); String contextPath = request.getContextPath(); - if (!requestURI.startsWith(contextPath + "/login")) { - response.sendRedirect("/login"); + // Public auth endpoints that don't require JWT + boolean isPublicAuthEndpoint = + requestURI.startsWith(contextPath + "/login") + || requestURI.startsWith(contextPath + "/signup") + || requestURI.startsWith(contextPath + "/auth/") + || requestURI.startsWith(contextPath + "/oauth2") + || requestURI.startsWith(contextPath + "/api/v1/auth/login") + || requestURI.startsWith(contextPath + "/api/v1/auth/register") + || requestURI.startsWith(contextPath + "/api/v1/auth/refresh"); + + if (!isPublicAuthEndpoint) { + // For API requests, return 401 JSON + String acceptHeader = request.getHeader("Accept"); + if (requestURI.startsWith(contextPath + "/api/") + || (acceptHeader != null + && acceptHeader.contains("application/json"))) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json"); + response.getWriter().write("{\"error\":\"Authentication required\"}"); + return; + } + + // For HTML requests (SPA routes), let React Router handle it (serve + // index.html) + filterChain.doFilter(request, response); return; } + + // For public auth endpoints without JWT, continue to the endpoint + filterChain.doFilter(request, response); + return; } try { + log.debug("Validating JWT token"); jwtService.validateToken(jwtToken); + log.debug("JWT token validated successfully"); } catch (AuthenticationFailureException e) { - jwtService.clearToken(response); + log.warn("JWT validation failed: {}", e.getMessage()); handleAuthenticationFailure(request, response, e); return; } Map claims = jwtService.extractClaims(jwtToken); String tokenUsername = claims.get("sub").toString(); + log.debug("JWT token username: {}", tokenUsername); try { authenticate(request, claims); + log.debug("Authentication successful for user: {}", tokenUsername); } catch (SQLException | UnsupportedProviderException e) { log.error("Error processing user authentication for user: {}", tokenUsername, e); handleAuthenticationFailure( @@ -175,21 +207,26 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private void processUserAuthenticationType(Map claims, String username) throws SQLException, UnsupportedProviderException { AuthenticationType authenticationType = - AuthenticationType.valueOf(claims.getOrDefault("authType", WEB).toString()); + AuthenticationType.valueOf( + claims.getOrDefault("authType", WEB).toString().toUpperCase()); log.debug("Processing {} login for {} user", authenticationType, username); switch (authenticationType) { case OAUTH2 -> { ApplicationProperties.Security.OAUTH2 oauth2Properties = securityProperties.getOauth2(); + // Provider IDs should already be set during initial authentication + // Pass null here since this is validating an existing JWT token userService.processSSOPostLogin( - username, oauth2Properties.getAutoCreateUser(), OAUTH2); + username, null, null, oauth2Properties.getAutoCreateUser(), OAUTH2); } case SAML2 -> { ApplicationProperties.Security.SAML2 saml2Properties = securityProperties.getSaml2(); + // Provider IDs should already be set during initial authentication + // Pass null here since this is validating an existing JWT token userService.processSSOPostLogin( - username, saml2Properties.getAutoCreateUser(), SAML2); + username, null, null, saml2Properties.getAutoCreateUser(), SAML2); } } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java index f51a9d543..8bf8bdd4a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java @@ -236,6 +236,10 @@ public class UserAuthenticationFilter extends OncePerRequestFilter { contextPath + "/pdfjs/", contextPath + "/pdfjs-legacy/", contextPath + "/api/v1/info/status", + contextPath + "/api/v1/auth/login", + contextPath + "/api/v1/auth/register", + contextPath + "/api/v1/auth/refresh", + contextPath + "/api/v1/auth/me", contextPath + "/site.webmanifest" }; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java index 616067bed..1c342bf5b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java @@ -1,12 +1,15 @@ package stirling.software.proprietary.security.model; import java.io.Serializable; +import java.time.LocalDateTime; import java.util.HashMap; import java.util.HashSet; import java.util.Map; import java.util.Set; import java.util.stream.Collectors; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; import org.springframework.security.core.userdetails.UserDetails; import com.fasterxml.jackson.annotation.JsonIgnore; @@ -59,6 +62,12 @@ public class User implements UserDetails, Serializable { @Column(name = "authenticationtype") private String authenticationType; + @Column(name = "sso_provider_id") + private String ssoProviderId; + + @Column(name = "sso_provider") + private String ssoProvider; + @OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL, mappedBy = "user") private Set authorities = new HashSet<>(); @@ -74,6 +83,14 @@ public class User implements UserDetails, Serializable { @CollectionTable(name = "user_settings", joinColumns = @JoinColumn(name = "user_id")) private Map settings = new HashMap<>(); // Key-value pairs of settings. + @CreationTimestamp + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + + @UpdateTimestamp + @Column(name = "updated_at") + private LocalDateTime updatedAt; + public String getRoleName() { return Role.getRoleNameByRoleId(getRolesAsString()); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java index 4e7ed9d9e..fe5fd5bcc 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java @@ -10,6 +10,7 @@ import java.util.Map; import org.springframework.security.authentication.LockedException; import org.springframework.security.core.Authentication; import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler; import org.springframework.security.web.savedrequest.SavedRequest; @@ -72,12 +73,6 @@ public class CustomOAuth2AuthenticationSuccessHandler throw new LockedException( "Your account has been locked due to too many failed login attempts."); } - if (jwtService.isJwtEnabled()) { - String jwt = - jwtService.generateToken( - authentication, Map.of("authType", AuthenticationType.OAUTH2)); - jwtService.addToken(response, jwt); - } if (userService.isUserDisabled(username)) { getRedirectStrategy() .sendRedirect(request, response, "/logout?userIsDisabled=true"); @@ -98,14 +93,95 @@ public class CustomOAuth2AuthenticationSuccessHandler response.sendRedirect(contextPath + "/logout?oAuth2AdminBlockedUser=true"); return; } - if (principal instanceof OAuth2User) { + if (principal instanceof OAuth2User oAuth2User) { + // Extract SSO provider information from OAuth2User + String ssoProviderId = oAuth2User.getAttribute("sub"); // OIDC ID + // Extract provider from authentication - need to get it from the token/request + // For now, we'll extract it in a more generic way + String ssoProvider = extractProviderFromAuthentication(authentication); + userService.processSSOPostLogin( - username, oauth2Properties.getAutoCreateUser(), OAUTH2); + username, + ssoProviderId, + ssoProvider, + oauth2Properties.getAutoCreateUser(), + OAUTH2); + } + + // Generate JWT if v2 is enabled + if (jwtService.isJwtEnabled()) { + String jwt = + jwtService.generateToken( + authentication, Map.of("authType", AuthenticationType.OAUTH2)); + + // Build context-aware redirect URL based on the original request + String redirectUrl = buildContextAwareRedirectUrl(request, contextPath, jwt); + + response.sendRedirect(redirectUrl); + } else { + // v1: redirect directly to home + response.sendRedirect(contextPath + "/"); } - response.sendRedirect(contextPath + "/"); } catch (IllegalArgumentException | SQLException | UnsupportedProviderException e) { response.sendRedirect(contextPath + "/logout?invalidUsername=true"); } } } + + /** + * Extracts the OAuth2 provider registration ID from the authentication object. + * + * @param authentication The authentication object + * @return The provider registration ID (e.g., "google", "github"), or null if not available + */ + private String extractProviderFromAuthentication(Authentication authentication) { + if (authentication instanceof OAuth2AuthenticationToken oauth2Token) { + return oauth2Token.getAuthorizedClientRegistrationId(); + } + return null; + } + + /** + * Builds a context-aware redirect URL based on the request's origin + * + * @param request The HTTP request + * @param contextPath The application context path + * @param jwt The JWT token to include + * @return The appropriate redirect URL + */ + private String buildContextAwareRedirectUrl( + HttpServletRequest request, String contextPath, String jwt) { + // Try to get the origin from the Referer header first + String referer = request.getHeader("Referer"); + if (referer != null && !referer.isEmpty()) { + try { + java.net.URL refererUrl = new java.net.URL(referer); + String origin = refererUrl.getProtocol() + "://" + refererUrl.getHost(); + if (refererUrl.getPort() != -1 + && refererUrl.getPort() != 80 + && refererUrl.getPort() != 443) { + origin += ":" + refererUrl.getPort(); + } + return origin + "/auth/callback#access_token=" + jwt; + } catch (java.net.MalformedURLException e) { + // Fall back to other methods if referer is malformed + } + } + + // Fall back to building from request host/port + String scheme = request.getScheme(); + String serverName = request.getServerName(); + int serverPort = request.getServerPort(); + + StringBuilder origin = new StringBuilder(); + origin.append(scheme).append("://").append(serverName); + + // Only add port if it's not the default port for the scheme + if ((!"http".equals(scheme) || serverPort != 80) + && (!"https".equals(scheme) || serverPort != 443)) { + origin.append(":").append(serverPort); + } + + return origin.toString() + "/auth/callback#access_token=" + jwt; + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/OAuth2Configuration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/OAuth2Configuration.java index 913dc458a..cd04d6da0 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/OAuth2Configuration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/OAuth2Configuration.java @@ -10,7 +10,6 @@ import java.util.List; import java.util.Optional; import java.util.Set; -import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -41,7 +40,7 @@ import stirling.software.proprietary.security.service.UserService; @Slf4j @Configuration -@ConditionalOnBooleanProperty("security.oauth2.enabled") +@ConditionalOnProperty(prefix = "security", name = "oauth2.enabled", havingValue = "true") public class OAuth2Configuration { public static final String REDIRECT_URI_PATH = "{baseUrl}/login/oauth2/code/"; @@ -53,6 +52,9 @@ public class OAuth2Configuration { ApplicationProperties applicationProperties, @Lazy UserService userService) { this.userService = userService; this.applicationProperties = applicationProperties; + log.info( + "OAuth2Configuration initialized - OAuth2 enabled: {}", + applicationProperties.getSecurity().getOauth2().getEnabled()); } @Bean @@ -75,7 +77,7 @@ public class OAuth2Configuration { private Optional keycloakClientRegistration() { OAUTH2 oauth2 = applicationProperties.getSecurity().getOauth2(); - if (isOAuth2Enabled(oauth2) || isClientInitialised(oauth2)) { + if (isOAuth2Disabled(oauth2) || isClientInitialised(oauth2)) { return Optional.empty(); } @@ -105,7 +107,7 @@ public class OAuth2Configuration { private Optional googleClientRegistration() { OAUTH2 oAuth2 = applicationProperties.getSecurity().getOauth2(); - if (isOAuth2Enabled(oAuth2) || isClientInitialised(oAuth2)) { + if (isOAuth2Disabled(oAuth2) || isClientInitialised(oAuth2)) { return Optional.empty(); } @@ -138,12 +140,23 @@ public class OAuth2Configuration { private Optional githubClientRegistration() { OAUTH2 oAuth2 = applicationProperties.getSecurity().getOauth2(); - if (isOAuth2Enabled(oAuth2)) { + if (isOAuth2Disabled(oAuth2)) { + log.debug("OAuth2 is disabled, skipping GitHub client registration"); return Optional.empty(); } Client client = oAuth2.getClient(); + if (client == null) { + log.debug("OAuth2 client configuration is null, skipping GitHub"); + return Optional.empty(); + } + GitHubProvider githubClient = client.getGithub(); + if (githubClient == null) { + log.debug("GitHub client configuration is null"); + return Optional.empty(); + } + Provider github = new GitHubProvider( githubClient.getClientId(), @@ -151,7 +164,15 @@ public class OAuth2Configuration { githubClient.getScopes(), githubClient.getUseAsUsername()); - return validateProvider(github) + boolean isValid = validateProvider(github); + log.info( + "GitHub OAuth2 provider validation: {} (clientId: {}, clientSecret: {}, scopes: {})", + isValid, + githubClient.getClientId(), + githubClient.getClientSecret() != null ? "***" : "null", + githubClient.getScopes()); + + return isValid ? Optional.of( ClientRegistration.withRegistrationId(github.getName()) .clientId(github.getClientId()) @@ -171,7 +192,7 @@ public class OAuth2Configuration { private Optional oidcClientRegistration() { OAUTH2 oauth = applicationProperties.getSecurity().getOauth2(); - if (isOAuth2Enabled(oauth) || isClientInitialised(oauth)) { + if (isOAuth2Disabled(oauth) || isClientInitialised(oauth)) { return Optional.empty(); } @@ -207,7 +228,7 @@ public class OAuth2Configuration { : Optional.empty(); } - private boolean isOAuth2Enabled(OAUTH2 oAuth2) { + private boolean isOAuth2Disabled(OAUTH2 oAuth2) { return oAuth2 == null || !oAuth2.getEnabled(); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java index 3255cbc15..14bbd83d4 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java @@ -116,13 +116,41 @@ public class CustomSaml2AuthenticationSuccessHandler contextPath + "/login?errorOAuth=oAuth2AdminBlockedUser"); return; } - log.debug("Processing SSO post-login for user: {}", username); + + // Extract SSO provider information from SAML2 assertion + String ssoProviderId = saml2Principal.nameId(); + String ssoProvider = "saml2"; // fixme + + log.debug( + "Processing SSO post-login for user: {} (Provider: {}, ProviderId: {})", + username, + ssoProvider, + ssoProviderId); + userService.processSSOPostLogin( - username, saml2Properties.getAutoCreateUser(), SAML2); + username, + ssoProviderId, + ssoProvider, + saml2Properties.getAutoCreateUser(), + SAML2); log.debug("Successfully processed authentication for user: {}", username); - generateJwt(response, authentication); - response.sendRedirect(contextPath + "/"); + // Generate JWT if v2 is enabled + if (jwtService.isJwtEnabled()) { + String jwt = + jwtService.generateToken( + authentication, + Map.of("authType", AuthenticationType.SAML2)); + + // Build context-aware redirect URL based on the original request + String redirectUrl = + buildContextAwareRedirectUrl(request, contextPath, jwt); + + response.sendRedirect(redirectUrl); + } else { + // v1: redirect directly to home + response.sendRedirect(contextPath + "/"); + } } catch (IllegalArgumentException | SQLException | UnsupportedProviderException e) { log.debug( "Invalid username detected for user: {}, redirecting to logout", @@ -136,12 +164,48 @@ public class CustomSaml2AuthenticationSuccessHandler } } - private void generateJwt(HttpServletResponse response, Authentication authentication) { - if (jwtService.isJwtEnabled()) { - String jwt = - jwtService.generateToken( - authentication, Map.of("authType", AuthenticationType.SAML2)); - jwtService.addToken(response, jwt); + /** + * Builds a context-aware redirect URL based on the request's origin + * + * @param request The HTTP request + * @param contextPath The application context path + * @param jwt The JWT token to include + * @return The appropriate redirect URL + */ + private String buildContextAwareRedirectUrl( + HttpServletRequest request, String contextPath, String jwt) { + // Try to get the origin from the Referer header first + String referer = request.getHeader("Referer"); + if (referer != null && !referer.isEmpty()) { + try { + java.net.URL refererUrl = new java.net.URL(referer); + String origin = refererUrl.getProtocol() + "://" + refererUrl.getHost(); + if (refererUrl.getPort() != -1 + && refererUrl.getPort() != 80 + && refererUrl.getPort() != 443) { + origin += ":" + refererUrl.getPort(); + } + return origin + "/auth/callback#access_token=" + jwt; + } catch (java.net.MalformedURLException e) { + log.debug( + "Malformed referer URL: {}, falling back to request-based origin", referer); + } } + + // Fall back to building from request host/port + String scheme = request.getScheme(); + String serverName = request.getServerName(); + int serverPort = request.getServerPort(); + + StringBuilder origin = new StringBuilder(); + origin.append(scheme).append("://").append(serverName); + + // Only add port if it's not the default port for the scheme + if ((!"http".equals(scheme) || serverPort != 80) + && (!"https".equals(scheme) || serverPort != 443)) { + origin.append(":").append(serverPort); + } + + return origin + "/auth/callback#access_token=" + jwt; } } 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 8f9afbe3d..6592bc95c 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 @@ -14,7 +14,6 @@ import org.springframework.security.oauth2.core.oidc.user.OidcUser; import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.ApplicationProperties; -import stirling.software.common.model.ApplicationProperties.Security.OAUTH2; import stirling.software.common.model.enumeration.UsernameAttribute; import stirling.software.proprietary.security.model.User; @@ -27,13 +26,13 @@ public class CustomOAuth2UserService implements OAuth2UserService internalUser = - userService.findByUsernameIgnoreCase(user.getAttribute(usernameAttributeKey)); + // Extract SSO provider information + String ssoProviderId = user.getSubject(); // Standard OIDC 'sub' claim + String ssoProvider = userRequest.getClientRegistration().getRegistrationId(); + String username = user.getAttribute(usernameAttributeKey); + + log.debug( + "OAuth2 login - Provider: {}, ProviderId: {}, Username: {}", + ssoProvider, + ssoProviderId, + username); + + Optional internalUser = userService.findByUsernameIgnoreCase(username); if (internalUser.isPresent()) { String internalUsername = internalUser.get().getUsername(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java index 8724da9a8..a65c79665 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java @@ -14,14 +14,11 @@ import java.util.function.Function; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.ResponseCookie; import org.springframework.security.core.Authentication; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.stereotype.Service; -import io.github.pixee.security.Newlines; import io.jsonwebtoken.Claims; import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.Jwts; @@ -29,9 +26,7 @@ import io.jsonwebtoken.MalformedJwtException; import io.jsonwebtoken.UnsupportedJwtException; import io.jsonwebtoken.security.SignatureException; -import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; @@ -43,13 +38,9 @@ import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrin @Service public class JwtService implements JwtServiceInterface { - private static final String JWT_COOKIE_NAME = "stirling_jwt"; - private static final String ISSUER = "Stirling PDF"; + private static final String ISSUER = "https://stirling.com"; private static final long EXPIRATION = 3600000; - @Value("${stirling.security.jwt.secureCookie:true}") - private boolean secureCookie; - private final KeyPersistenceServiceInterface keyPersistenceService; private final boolean v2Enabled; @@ -59,6 +50,7 @@ public class JwtService implements JwtServiceInterface { KeyPersistenceServiceInterface keyPersistenceService) { this.v2Enabled = v2Enabled; this.keyPersistenceService = keyPersistenceService; + log.info("JwtService initialized"); } @Override @@ -260,47 +252,18 @@ public class JwtService implements JwtServiceInterface { @Override public String extractToken(HttpServletRequest request) { - Cookie[] cookies = request.getCookies(); - - if (cookies != null) { - for (Cookie cookie : cookies) { - if (JWT_COOKIE_NAME.equals(cookie.getName())) { - return cookie.getValue(); - } - } + // Extract from Authorization header Bearer token + String authHeader = request.getHeader("Authorization"); + if (authHeader != null && authHeader.startsWith("Bearer ")) { + String token = authHeader.substring(7); // Remove "Bearer " prefix + log.debug("JWT token extracted from Authorization header"); + return token; } + log.debug("No JWT token found in Authorization header"); return null; } - @Override - public void addToken(HttpServletResponse response, String token) { - ResponseCookie cookie = - ResponseCookie.from(JWT_COOKIE_NAME, Newlines.stripAll(token)) - .httpOnly(true) - .secure(secureCookie) - .sameSite("Strict") - .maxAge(EXPIRATION / 1000) - .path("/") - .build(); - - response.addHeader("Set-Cookie", cookie.toString()); - } - - @Override - public void clearToken(HttpServletResponse response) { - ResponseCookie cookie = - ResponseCookie.from(JWT_COOKIE_NAME, "") - .httpOnly(true) - .secure(secureCookie) - .sameSite("None") - .maxAge(0) - .path("/") - .build(); - - response.addHeader("Set-Cookie", cookie.toString()); - } - @Override public boolean isJwtEnabled() { return v2Enabled; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtServiceInterface.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtServiceInterface.java index 7cdca8209..2107f2ffd 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtServiceInterface.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtServiceInterface.java @@ -5,7 +5,6 @@ import java.util.Map; import org.springframework.security.core.Authentication; import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; public interface JwtServiceInterface { @@ -66,21 +65,6 @@ public interface JwtServiceInterface { */ String extractToken(HttpServletRequest request); - /** - * Add JWT token to HTTP response (header and cookie) - * - * @param response HTTP servlet response - * @param token JWT token to add - */ - void addToken(HttpServletResponse response, String token); - - /** - * Clear JWT token from HTTP response (remove cookie) - * - * @param response HTTP servlet response - */ - void clearToken(HttpServletResponse response); - /** * Check if JWT authentication is enabled * diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java index 6f213b25e..80e48dbf6 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java @@ -60,19 +60,46 @@ public class UserService implements UserServiceInterface { private final ApplicationProperties.Security.OAUTH2 oAuth2; - // Handle OAUTH2 login and user auto creation. public void processSSOPostLogin( - String username, boolean autoCreateUser, AuthenticationType type) + String username, + String ssoProviderId, + String ssoProvider, + boolean autoCreateUser, + AuthenticationType type) throws IllegalArgumentException, SQLException, UnsupportedProviderException { if (!isUsernameValid(username)) { return; } - Optional existingUser = findByUsernameIgnoreCase(username); + + // Find user by SSO provider ID first + Optional existingUser; + if (ssoProviderId != null && ssoProvider != null) { + existingUser = + userRepository.findBySsoProviderAndSsoProviderId(ssoProvider, ssoProviderId); + + if (existingUser.isPresent()) { + log.debug("User found by SSO provider ID: {}", ssoProviderId); + return; + } + } + + existingUser = findByUsernameIgnoreCase(username); if (existingUser.isPresent()) { + User user = existingUser.get(); + + // Migrate existing user to use provider ID if not already set + if (user.getSsoProviderId() == null && ssoProviderId != null && ssoProvider != null) { + log.info("Migrating user {} to use SSO provider ID: {}", username, ssoProviderId); + user.setSsoProviderId(ssoProviderId); + user.setSsoProvider(ssoProvider); + userRepository.save(user); + databaseService.exportDatabase(); + } return; } + if (autoCreateUser) { - saveUser(username, type); + saveUser(username, ssoProviderId, ssoProvider, type); } } @@ -154,6 +181,21 @@ public class UserService implements UserServiceInterface { saveUser(username, authenticationType, (Long) null, Role.USER.getRoleId()); } + public void saveUser( + String username, + String ssoProviderId, + String ssoProvider, + AuthenticationType authenticationType) + throws IllegalArgumentException, SQLException, UnsupportedProviderException { + saveUser( + username, + ssoProviderId, + ssoProvider, + authenticationType, + (Long) null, + Role.USER.getRoleId()); + } + private User saveUser(Optional user, String apiKey) { if (user.isPresent()) { user.get().setApiKey(apiKey); @@ -168,6 +210,30 @@ public class UserService implements UserServiceInterface { return saveUserCore( username, // username null, // password + null, // ssoProviderId + null, // ssoProvider + authenticationType, // authenticationType + teamId, // teamId + null, // team + role, // role + false, // firstLogin + true // enabled + ); + } + + public User saveUser( + String username, + String ssoProviderId, + String ssoProvider, + AuthenticationType authenticationType, + Long teamId, + String role) + throws IllegalArgumentException, SQLException, UnsupportedProviderException { + return saveUserCore( + username, // username + null, // password + ssoProviderId, // ssoProviderId + ssoProvider, // ssoProvider authenticationType, // authenticationType teamId, // teamId null, // team @@ -183,6 +249,8 @@ public class UserService implements UserServiceInterface { return saveUserCore( username, // username null, // password + null, // ssoProviderId + null, // ssoProvider authenticationType, // authenticationType null, // teamId team, // team @@ -197,6 +265,8 @@ public class UserService implements UserServiceInterface { return saveUserCore( username, // username password, // password + null, // ssoProviderId + null, // ssoProvider AuthenticationType.WEB, // authenticationType teamId, // teamId null, // team @@ -212,6 +282,8 @@ public class UserService implements UserServiceInterface { return saveUserCore( username, // username password, // password + null, // ssoProviderId + null, // ssoProvider AuthenticationType.WEB, // authenticationType null, // teamId team, // team @@ -227,6 +299,8 @@ public class UserService implements UserServiceInterface { return saveUserCore( username, // username password, // password + null, // ssoProviderId + null, // ssoProvider AuthenticationType.WEB, // authenticationType teamId, // teamId null, // team @@ -247,6 +321,8 @@ public class UserService implements UserServiceInterface { saveUserCore( username, // username password, // password + null, // ssoProviderId + null, // ssoProvider AuthenticationType.WEB, // authenticationType teamId, // teamId null, // team @@ -411,6 +487,8 @@ public class UserService implements UserServiceInterface { * * @param username Username for the new user * @param password Password for the user (may be null for SSO/OAuth users) + * @param ssoProviderId Unique identifier from SSO provider (may be null for non-SSO users) + * @param ssoProvider Name of the SSO provider (may be null for non-SSO users) * @param authenticationType Type of authentication (WEB, SSO, etc.) * @param teamId ID of the team to assign (may be null to use default) * @param team Team object to assign (takes precedence over teamId if both provided) @@ -425,6 +503,8 @@ public class UserService implements UserServiceInterface { private User saveUserCore( String username, String password, + String ssoProviderId, + String ssoProvider, AuthenticationType authenticationType, Long teamId, Team team, @@ -445,6 +525,12 @@ public class UserService implements UserServiceInterface { user.setPassword(passwordEncoder.encode(password)); } + // Set SSO provider details if provided + if (ssoProviderId != null && ssoProvider != null) { + user.setSsoProviderId(ssoProviderId); + user.setSsoProvider(ssoProvider); + } + // Set authentication type user.setAuthenticationType(authenticationType); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/CustomLogoutSuccessHandlerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/CustomLogoutSuccessHandlerTest.java index 7a4076260..c6b6d17e7 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/CustomLogoutSuccessHandlerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/CustomLogoutSuccessHandlerTest.java @@ -1,6 +1,8 @@ package stirling.software.proprietary.security; -import static org.mockito.Mockito.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import java.io.IOException; @@ -38,7 +40,6 @@ class CustomLogoutSuccessHandlerTest { when(response.isCommitted()).thenReturn(false); when(jwtService.extractToken(request)).thenReturn(token); - doNothing().when(jwtService).clearToken(response); when(request.getContextPath()).thenReturn(""); when(response.encodeRedirectURL(logoutPath)).thenReturn(logoutPath); @@ -56,14 +57,12 @@ class CustomLogoutSuccessHandlerTest { when(response.isCommitted()).thenReturn(false); when(jwtService.extractToken(request)).thenReturn(token); - doNothing().when(jwtService).clearToken(response); when(request.getContextPath()).thenReturn(""); when(response.encodeRedirectURL(logoutPath)).thenReturn(logoutPath); customLogoutSuccessHandler.onLogoutSuccess(request, response, null); verify(response).sendRedirect(logoutPath); - verify(jwtService).clearToken(response); } @Test diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilterTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilterTest.java index d3f484486..244ce387f 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilterTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilterTest.java @@ -127,7 +127,6 @@ class JwtAuthenticationFilterTest { .setAuthentication(any(UsernamePasswordAuthenticationToken.class)); verify(jwtService) .generateToken(any(UsernamePasswordAuthenticationToken.class), eq(claims)); - verify(jwtService).addToken(response, newToken); verify(filterChain).doFilter(request, response); } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtServiceTest.java index 6f9af4c54..e8a6d6045 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtServiceTest.java @@ -8,8 +8,6 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.atLeast; -import static org.mockito.Mockito.contains; -import static org.mockito.Mockito.eq; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; @@ -17,7 +15,6 @@ import static org.mockito.Mockito.when; import java.security.KeyPair; import java.security.KeyPairGenerator; -import java.security.NoSuchAlgorithmException; import java.util.Base64; import java.util.Collections; import java.util.HashMap; @@ -27,13 +24,10 @@ import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.security.core.Authentication; -import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -59,7 +53,7 @@ class JwtServiceTest { private JwtVerificationKey testVerificationKey; @BeforeEach - void setUp() throws NoSuchAlgorithmException { + void setUp() throws Exception { // Generate a test keypair KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); keyPairGenerator.initialize(2048); @@ -224,7 +218,8 @@ class JwtServiceTest { assertEquals("admin", extractedClaims.get("role")); assertEquals("IT", extractedClaims.get("department")); assertEquals(username, extractedClaims.get("sub")); - assertEquals("Stirling PDF", extractedClaims.get("iss")); + // Verify the constant issuer is set correctly + assertEquals("https://stirling.com", extractedClaims.get("iss")); } @Test @@ -239,62 +234,27 @@ class JwtServiceTest { } @Test - void testExtractTokenWithCookie() { + void testExtractTokenWithAuthorizationHeader() { String token = "test-token"; - Cookie[] cookies = {new Cookie("stirling_jwt", token)}; - when(request.getCookies()).thenReturn(cookies); + when(request.getHeader("Authorization")).thenReturn("Bearer " + token); assertEquals(token, jwtService.extractToken(request)); } @Test - void testExtractTokenWithNoCookies() { - when(request.getCookies()).thenReturn(null); + void testExtractTokenWithNoAuthorizationHeader() { + when(request.getHeader("Authorization")).thenReturn(null); assertNull(jwtService.extractToken(request)); } @Test - void testExtractTokenWithWrongCookie() { - Cookie[] cookies = {new Cookie("OTHER_COOKIE", "value")}; - when(request.getCookies()).thenReturn(cookies); + void testExtractTokenWithInvalidAuthorizationHeaderFormat() { + when(request.getHeader("Authorization")).thenReturn("InvalidFormat token"); assertNull(jwtService.extractToken(request)); } - @Test - void testExtractTokenWithInvalidAuthorizationHeader() { - when(request.getCookies()).thenReturn(null); - - assertNull(jwtService.extractToken(request)); - } - - @ParameterizedTest - @ValueSource(booleans = {true, false}) - void testAddToken(boolean secureCookie) throws Exception { - String token = "test-token"; - - // Create new JwtService instance with the secureCookie parameter - JwtService testJwtService = createJwtServiceWithSecureCookie(secureCookie); - - testJwtService.addToken(response, token); - - verify(response).addHeader(eq("Set-Cookie"), contains("stirling_jwt=" + token)); - verify(response).addHeader(eq("Set-Cookie"), contains("HttpOnly")); - - if (secureCookie) { - verify(response).addHeader(eq("Set-Cookie"), contains("Secure")); - } - } - - @Test - void testClearToken() { - jwtService.clearToken(response); - - verify(response).addHeader(eq("Set-Cookie"), contains("stirling_jwt=")); - verify(response).addHeader(eq("Set-Cookie"), contains("Max-Age=0")); - } - @Test void testGenerateTokenWithKeyId() throws Exception { String username = "testuser"; @@ -373,17 +333,4 @@ class JwtServiceTest { // Verify fallback logic was used verify(keystoreService, atLeast(1)).getActiveKey(); } - - private JwtService createJwtServiceWithSecureCookie(boolean secureCookie) throws Exception { - // Use reflection to create JwtService with custom secureCookie value - JwtService testService = new JwtService(true, keystoreService); - - // Set the secureCookie field using reflection - java.lang.reflect.Field secureCookieField = - JwtService.class.getDeclaredField("secureCookie"); - secureCookieField.setAccessible(true); - secureCookieField.set(testService, secureCookie); - - return testService; - } } diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index 0b92de05b..c4c826183 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -1,9 +1,10 @@ // @ts-check import eslint from '@eslint/js'; -import globals from "globals"; +import globals from 'globals'; import { defineConfig } from 'eslint/config'; import tseslint from 'typescript-eslint'; +import importPlugin from 'eslint-plugin-import'; const srcGlobs = [ 'src/**/*.{js,mjs,jsx,ts,tsx}', @@ -14,35 +15,37 @@ const nodeGlobs = [ ]; export default defineConfig( + { + // Everything that contains 3rd party code that we don't want to lint + ignores: [ + 'dist', + 'node_modules', + 'public', + ], + }, eslint.configs.recommended, tseslint.configs.recommended, - { - ignores: [ - "dist", // Contains 3rd party code - "public", // Contains 3rd party code - ], - }, { rules: { - "@typescript-eslint/no-empty-object-type": [ - "error", + '@typescript-eslint/no-empty-object-type': [ + 'error', { // Allow empty extending interfaces because there's no real reason not to, and it makes it obvious where to put extra attributes in the future allowInterfaces: 'with-single-extends', }, ], - "@typescript-eslint/no-explicit-any": "off", // Temporarily disabled until codebase conformant - "@typescript-eslint/no-require-imports": "off", // Temporarily disabled until codebase conformant - "@typescript-eslint/no-unused-vars": [ - "error", + '@typescript-eslint/no-explicit-any': 'off', // Temporarily disabled until codebase conformant + '@typescript-eslint/no-require-imports': 'off', // Temporarily disabled until codebase conformant + '@typescript-eslint/no-unused-vars': [ + 'error', { - "args": "all", // All function args must be used (or explicitly ignored) - "argsIgnorePattern": "^_", // Allow unused variables beginning with an underscore - "caughtErrors": "all", // Caught errors must be used (or explicitly ignored) - "caughtErrorsIgnorePattern": "^_", // Allow unused variables beginning with an underscore - "destructuredArrayIgnorePattern": "^_", // Allow unused variables beginning with an underscore - "varsIgnorePattern": "^_", // Allow unused variables beginning with an underscore - "ignoreRestSiblings": true, // Allow unused variables when removing attributes from objects (otherwise this requires explicit renaming like `({ x: _x, ...y }) => y`, which is clunky) + 'args': 'all', // All function args must be used (or explicitly ignored) + 'argsIgnorePattern': '^_', // Allow unused variables beginning with an underscore + 'caughtErrors': 'all', // Caught errors must be used (or explicitly ignored) + 'caughtErrorsIgnorePattern': '^_', // Allow unused variables beginning with an underscore + 'destructuredArrayIgnorePattern': '^_', // Allow unused variables beginning with an underscore + 'varsIgnorePattern': '^_', // Allow unused variables beginning with an underscore + 'ignoreRestSiblings': true, // Allow unused variables when removing attributes from objects (otherwise this requires explicit renaming like `({ x: _x, ...y }) => y`, which is clunky) }, ], }, @@ -65,4 +68,21 @@ export default defineConfig( } } }, + // Config for import plugin + { + ...importPlugin.flatConfigs.recommended, + ...importPlugin.flatConfigs.typescript, + rules: { + // ...importPlugin.flatConfigs.recommended.rules, // Temporarily disabled until codebase conformant + ...importPlugin.flatConfigs.typescript.rules, + 'import/no-cycle': 'error', + }, + settings: { + 'import/resolver': { + typescript: { + project: './tsconfig.json', + }, + }, + }, + }, ); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e74347837..7dd9e15cd 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -37,6 +37,7 @@ "@mantine/hooks": "^8.3.1", "@mui/icons-material": "^7.3.2", "@mui/material": "^7.3.2", + "@reactour/tour": "^3.8.0", "@tailwindcss/postcss": "^4.1.13", "@tanstack/react-virtual": "^3.13.12", "autoprefixer": "^10.4.21", @@ -79,6 +80,8 @@ "@vitejs/plugin-react-swc": "^4.1.0", "@vitest/coverage-v8": "^3.2.4", "eslint": "^9.36.0", + "eslint-import-resolver-typescript": "^4.4.4", + "eslint-plugin-import": "^2.32.0", "eslint-plugin-react-hooks": "^5.2.0", "jsdom": "^27.0.0", "license-checker": "^25.0.1", @@ -87,6 +90,7 @@ "postcss-cli": "^11.0.1", "postcss-preset-mantine": "^1.18.0", "postcss-simple-vars": "^7.0.1", + "puppeteer": "^24.25.0", "typescript": "^5.9.2", "typescript-eslint": "^8.44.1", "vite": "^7.1.7", @@ -432,6 +436,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -478,6 +483,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -501,6 +507,7 @@ "resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.3.14.tgz", "integrity": "sha512-lE/vfhA53CxamaCfGWEibrEPr+JeZT42QCF+cOELUwv4+Zt6b+IE6+4wsznx/8wjjJYwllXJ3GJ/un1UzTqARw==", "license": "MIT", + "peer": true, "dependencies": { "@embedpdf/engines": "1.3.14", "@embedpdf/models": "1.3.14" @@ -581,6 +588,7 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-1.3.14.tgz", "integrity": "sha512-77hnNLp0W0FHw8lT7SeqzCgp8bOClfeOAPZdcInu/jPDhVASUGYbtE/0fkLhiaqPH7kyMirNCLif4sF6n4b5vg==", "license": "MIT", + "peer": true, "dependencies": { "@embedpdf/models": "1.3.14" }, @@ -597,6 +605,7 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.3.14.tgz", "integrity": "sha512-nR0ZxNoTQtGqOHhweFh6QJ+nUJ4S4Ag1wWur6vAUAi8U95HUOfZhOEa0polZo0zR9WmmblGqRWjFM+mVSOoi1w==", "license": "MIT", + "peer": true, "dependencies": { "@embedpdf/models": "1.3.14" }, @@ -613,6 +622,7 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-loader/-/plugin-loader-1.3.14.tgz", "integrity": "sha512-KoJX1MacEWE2DrO1OeZeG/Ehz76//u+ida/xb4r9BfwqAp5TfYlksq09cOvcF8LMW5FY4pbAL+AHKI1Hjz+HNA==", "license": "MIT", + "peer": true, "dependencies": { "@embedpdf/models": "1.3.14" }, @@ -647,6 +657,7 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.3.14.tgz", "integrity": "sha512-IPj7GCQXJBsY++JaU+z7y+FwX5NaDBj4YYV6hsHNtSGf42Y1AdlwJzDYetivG2bA84xmk7KgD1X2Y3eIFBhjwA==", "license": "MIT", + "peer": true, "dependencies": { "@embedpdf/models": "1.3.14" }, @@ -679,6 +690,7 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-1.3.14.tgz", "integrity": "sha512-fQbt7OlRMLQJMuZj/Bzh0qpRxMw1ld5Qe/OTw8N54b/plljnFA52joE7cITl3H03huWWyHS3NKOScbw7f34dog==", "license": "MIT", + "peer": true, "dependencies": { "@embedpdf/models": "1.3.14" }, @@ -713,6 +725,7 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.3.14.tgz", "integrity": "sha512-EXENuaAsse3rT6cjA1nYzyrNvoy62ojJl28wblCng6zcs3HSlGPemIQZAvaYKPUxoY608M+6nKlcMQ5neRnk/A==", "license": "MIT", + "peer": true, "dependencies": { "@embedpdf/models": "1.3.14" }, @@ -784,6 +797,7 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-1.3.14.tgz", "integrity": "sha512-mfJ7EbbU68eKk6oFvQ4ozGJNpxUxWbjQ5Gm3uuB+Gj5/tWgBocBOX36k/9LgivEEeX7g2S0tOgyErljApmH8Vg==", "license": "MIT", + "peer": true, "dependencies": { "@embedpdf/models": "1.3.14" }, @@ -827,6 +841,58 @@ "vue": ">=3.2.0" } }, + "node_modules/@emnapi/core": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.5.0.tgz", + "integrity": "sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/core/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/@emnapi/runtime": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.5.0.tgz", + "integrity": "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, "node_modules/@emotion/babel-plugin": { "version": "11.13.5", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", @@ -885,6 +951,7 @@ "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -928,6 +995,7 @@ "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -1961,6 +2029,7 @@ "resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.1.tgz", "integrity": "sha512-OYfxn9cTv+K6RZ8+Ozn/HDQXkB8Fmn+KJJt5lxyFDP9F09EHnC59Ldadv1LyUZVBGtNqz4sn6b3vBShbxwAmYw==", "license": "MIT", + "peer": true, "dependencies": { "@floating-ui/react": "^0.27.16", "clsx": "^2.1.1", @@ -2011,6 +2080,7 @@ "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.1.tgz", "integrity": "sha512-lQutBS+Q0iz/cNFvdrsYassPWo3RtWcmDGJeOtKfHigLzFOhxUuLOkQgepDbMf3WcVMB/tist6Px1PQOv57JTw==", "license": "MIT", + "peer": true, "peerDependencies": { "react": "^18.x || ^19.x" } @@ -2078,6 +2148,7 @@ "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.2.tgz", "integrity": "sha512-qXvbnawQhqUVfH1LMgMaiytP+ZpGoYhnGl7yYq2x57GYzcFL/iPzSZ3L30tlbwEjSVKNYcbiKO8tANR1tadjUg==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.28.3", "@mui/core-downloads-tracker": "^7.3.2", @@ -2455,6 +2526,18 @@ "node": ">= 10" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2554,6 +2637,79 @@ "integrity": "sha512-igElrcnRPJh2nWYACschjH4OwGwzSa6xVFzRDVzpnjirUivdJ8nv4hE+H31nvwE56MFhvvglfHuotnWLMcRW7w==", "license": "MIT" }, + "node_modules/@puppeteer/browsers": { + "version": "2.10.12", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.10.12.tgz", + "integrity": "sha512-mP9iLFZwH+FapKJLeA7/fLqOlSUwYpMwjR1P5J23qd4e7qGJwecJccJqHYrjw33jmIZYV4dtiTHPD/J+1e7cEw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.4.3", + "extract-zip": "^2.0.1", + "progress": "^2.0.3", + "proxy-agent": "^6.5.0", + "semver": "^7.7.3", + "tar-fs": "^3.1.1", + "yargs": "^17.7.2" + }, + "bin": { + "browsers": "lib/cjs/main-cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@reactour/mask": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@reactour/mask/-/mask-1.2.0.tgz", + "integrity": "sha512-XLgBLWfKJybtZjNTSO5lt/SIvRlCZBadB6JfE/hO1ErqURRjYhnv+edC0Ki1haUCqMGFppWk3lwcPCjmK0xNog==", + "license": "MIT", + "dependencies": { + "@reactour/utils": "*" + }, + "peerDependencies": { + "react": "16.x || 17.x || 18.x || 19.x" + } + }, + "node_modules/@reactour/popover": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@reactour/popover/-/popover-1.3.0.tgz", + "integrity": "sha512-YdyjSmHPvEeQEcJM4gcGFa5pI/Yf4nZGqwG4JnT+rK1SyUJBIPnm4Gkl/h7/+1g0KCFMkwNwagS3ZiXvZB7ThA==", + "license": "MIT", + "dependencies": { + "@reactour/utils": "*" + }, + "peerDependencies": { + "react": "16.x || 17.x || 18.x || 19.x" + } + }, + "node_modules/@reactour/tour": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@reactour/tour/-/tour-3.8.0.tgz", + "integrity": "sha512-KZTFi1pAvoTVKKRdBN5+XCYxXBp4k4Ql/acZcXyPvec8VU24fkMSEeV+v8krfYQpoVcewxIu3gM6xWZZLjxi7w==", + "license": "MIT", + "dependencies": { + "@reactour/mask": "*", + "@reactour/popover": "*", + "@reactour/utils": "*" + }, + "peerDependencies": { + "react": "16.x || 17.x || 18.x || 19.x" + } + }, + "node_modules/@reactour/utils": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@reactour/utils/-/utils-0.6.0.tgz", + "integrity": "sha512-GqaLjQi7MJsgtAKjdiw2Eak1toFkADoLRnm1+HZpaD+yl+DkaHpC1N7JAl+kVOO5I17bWInPA+OFbXjO9Co8Qg==", + "license": "MIT", + "dependencies": { + "@rooks/use-mutation-observer": "^4.11.2", + "resize-observer-polyfill": "^1.5.1" + }, + "peerDependencies": { + "react": "16.x || 17.x || 18.x || 19.x" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.35", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.35.tgz", @@ -2869,6 +3025,22 @@ "win32" ] }, + "node_modules/@rooks/use-mutation-observer": { + "version": "4.11.2", + "resolved": "https://registry.npmjs.org/@rooks/use-mutation-observer/-/use-mutation-observer-4.11.2.tgz", + "integrity": "sha512-vpsdrZdr6TkB1zZJcHx+fR1YC/pHs2BaqcuYiEGjBVbwY5xcC49+h0hAUtQKHth3oJqXfIX/Ng8S7s5HFHdM/A==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, "node_modules/@sec-ant/readable-stream": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", @@ -3419,6 +3591,7 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -3502,6 +3675,13 @@ "@testing-library/dom": ">=7.21.4" } }, + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", + "dev": true, + "license": "MIT" + }, "node_modules/@ts-graphviz/adapter": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@ts-graphviz/adapter/-/adapter-2.0.6.tgz", @@ -3592,6 +3772,23 @@ "node": ">=18" } }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.0.tgz", + "integrity": "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tybys/wasm-util/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -3684,6 +3881,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.5.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.5.2.tgz", @@ -3711,6 +3915,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.13.tgz", "integrity": "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -3721,6 +3926,7 @@ "integrity": "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.0.0" } @@ -3734,6 +3940,17 @@ "@types/react": "*" } }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.44.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.44.1.tgz", @@ -3770,6 +3987,7 @@ "integrity": "sha512-EHrrEsyhOhxYt8MTg4zTF+DJMuNBzWwgvvOYNj/zm1vnaD/IC5zCXFehZv94Piqa2cRFfXrTFxIvO95L7Qc/cw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.44.1", "@typescript-eslint/types": "8.44.1", @@ -3969,6 +4187,275 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@vitejs/plugin-react-swc": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-4.1.0.tgz", @@ -4214,7 +4701,6 @@ "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.21.tgz", "integrity": "sha512-3ah7sa+Cwr9iiYEERt9JfZKPw4A2UlbY8RbbnH2mGCE8NwHkhmlZt2VsH0oDA3P08X3jJd29ohBDtX+TbD9AsA==", "license": "MIT", - "peer": true, "dependencies": { "@vue/shared": "3.5.21" } @@ -4224,7 +4710,6 @@ "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.21.tgz", "integrity": "sha512-+DplQlRS4MXfIf9gfD1BOJpk5RSyGgGXD/R+cumhe8jdjUcq/qlxDawQlSI8hCKupBlvM+3eS1se5xW+SuNAwA==", "license": "MIT", - "peer": true, "dependencies": { "@vue/reactivity": "3.5.21", "@vue/shared": "3.5.21" @@ -4235,7 +4720,6 @@ "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.21.tgz", "integrity": "sha512-3M2DZsOFwM5qI15wrMmNF5RJe1+ARijt2HM3TbzBbPSuBHOQpoidE+Pa+XEaVN+czbHf81ETRoG1ltztP2em8w==", "license": "MIT", - "peer": true, "dependencies": { "@vue/reactivity": "3.5.21", "@vue/runtime-core": "3.5.21", @@ -4248,7 +4732,6 @@ "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.21.tgz", "integrity": "sha512-qr8AqgD3DJPJcGvLcJKQo2tAc8OnXRcfxhOJCPF+fcfn5bBGz7VCcO7t+qETOPxpWK1mgysXvVT/j+xWaHeMWA==", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-ssr": "3.5.21", "@vue/shared": "3.5.21" @@ -4276,6 +4759,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4391,6 +4875,23 @@ "dequal": "^2.0.3" } }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/array-find-index": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-find-index/-/array-find-index-1.0.2.tgz", @@ -4401,6 +4902,111 @@ "node": ">=0.10.0" } }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", @@ -4428,14 +5034,34 @@ "node": ">=18" } }, - "node_modules/ast-v8-to-istanbul": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.5.tgz", - "integrity": "sha512-9SdXjNheSiE8bALAQCQQuT6fgQaoxJh7IRYrRGZ8/9nv8WhJeC1aXAwN8TbaOssGOukUvyvnkgD9+Yuykvl1aA==", + "node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/trace-mapping": "^0.3.30", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/ast-types/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.7.tgz", + "integrity": "sha512-kr1Hy6YRZBkGQSb6puP+D6FQ59Cx4m0siYhAxygMCAgadiWQ6oxAxQXHOMvJx67SJ63jRoVIIg5eXzUbbct1ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^9.0.1" } @@ -4447,6 +5073,16 @@ "dev": true, "license": "MIT" }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -4499,6 +5135,22 @@ "postcss": "^8.1.0" } }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/axios": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", @@ -4510,6 +5162,21 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/b4a": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", + "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, "node_modules/babel-plugin-macros": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", @@ -4532,6 +5199,103 @@ "dev": true, "license": "MIT" }, + "node_modules/bare-events": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.0.tgz", + "integrity": "sha512-AOhh6Bg5QmFIXdViHbMc2tLDsBIRxdkIaIddPslJF9Z5De3APBScuqGP2uThXnIpqFrgoxMNC6km7uXNIMLHXA==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.4.11", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.4.11.tgz", + "integrity": "sha512-Bejmm9zRMvMTRoHS+2adgmXw1ANZnCNx+B5dgZpGwlP1E3x6Yuxea8RToddHUbWtVV0iUMWqsgZr8+jcgUI2SA==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.2.tgz", + "integrity": "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.7.0.tgz", + "integrity": "sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "streamx": "^2.21.0" + }, + "peerDependencies": { + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.0.tgz", + "integrity": "sha512-c+RCqMSZbkz97Mw1LWR0gcOqwK82oyYKfLoHJ8k13ybi1+I80ffdDzUy0TdAburdrR/kI0/VuN8YgEnJqX+Nyw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-path": "^3.0.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -4562,6 +5326,16 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/basic-ftp": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.0.5.tgz", + "integrity": "sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/bidi-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", @@ -4660,6 +5434,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001741", @@ -4699,6 +5474,16 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -4736,6 +5521,25 @@ "node": ">=18" } }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -4749,6 +5553,23 @@ "node": ">= 0.4" } }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -4879,6 +5700,20 @@ "node": ">=18" } }, + "node_modules/chromium-bidi": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-9.1.0.tgz", + "integrity": "sha512-rlUzQ4WzIAWdIbY/viPShhZU2n21CxDUgazXVbw4Hu1MwaeUSEksSeM6DqPgpRjCLXRk702AVRxJxoOz0dw4OA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "mitt": "^3.0.1", + "zod": "^3.24.1" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, "node_modules/cli-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", @@ -5138,6 +5973,16 @@ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "license": "MIT" }, + "node_modules/data-uri-to-buffer": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", + "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/data-urls": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz", @@ -5152,6 +5997,60 @@ "node": ">=20" } }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/dayjs": { "version": "1.11.18", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.18.tgz", @@ -5269,6 +6168,57 @@ "node": ">=10" } }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -5483,6 +6433,14 @@ "typescript": "^5.4.4" } }, + "node_modules/devtools-protocol": { + "version": "0.0.1508733", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1508733.tgz", + "integrity": "sha512-QJ1R5gtck6nDcdM+nlsaJXcelPEI7ZxSMw1ujHpO1c4+9l+Nue5qlebi9xO1Z2MGr92bFOQTW7/rrheh5hHxDg==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true + }, "node_modules/dezalgo": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", @@ -5494,6 +6452,19 @@ "wrappy": "1" } }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/dom-accessibility-api": { "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", @@ -5545,6 +6516,16 @@ "dev": true, "license": "MIT" }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.18.3", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", @@ -5571,6 +6552,16 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/eol": { "version": "0.10.0", "resolved": "https://registry.npmjs.org/eol/-/eol-0.10.0.tgz", @@ -5589,6 +6580,75 @@ "is-arrayish": "^0.2.1" } }, + "node_modules/es-abstract": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", + "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -5641,6 +6701,37 @@ "node": ">= 0.4" } }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/esbuild": { "version": "0.25.10", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz", @@ -5743,6 +6834,7 @@ "integrity": "sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5798,6 +6890,221 @@ } } }, + "node_modules/eslint-import-context": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/eslint-import-context/-/eslint-import-context-0.1.9.tgz", + "integrity": "sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-tsconfig": "^4.10.1", + "stable-hash-x": "^0.2.0" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-context" + }, + "peerDependencies": { + "unrs-resolver": "^1.0.0" + }, + "peerDependenciesMeta": { + "unrs-resolver": { + "optional": true + } + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-import-resolver-typescript": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-4.4.4.tgz", + "integrity": "sha512-1iM2zeBvrYmUNTj2vSC/90JTHDth+dfOfiNKkxApWRsTJYNrc8rOdxxIf5vazX+BiAXTeOT0UvWpGI/7qIWQOw==", + "dev": true, + "license": "ISC", + "dependencies": { + "debug": "^4.4.1", + "eslint-import-context": "^0.1.8", + "get-tsconfig": "^4.10.1", + "is-bun-module": "^2.0.0", + "stable-hash-x": "^0.2.0", + "tinyglobby": "^0.2.14", + "unrs-resolver": "^1.7.11" + }, + "engines": { + "node": "^16.17.0 || >=18.6.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", + "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/eslint-plugin-import/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint-plugin-import/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-plugin-import/node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, "node_modules/eslint-plugin-react-hooks": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", @@ -5989,6 +7296,16 @@ "node": ">=0.10.0" } }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, "node_modules/expect-type": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", @@ -6006,6 +7323,43 @@ "dev": true, "license": "MIT" }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/extract-zip/node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -6013,6 +7367,13 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -6067,6 +7428,16 @@ "reusify": "^1.0.4" } }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, "node_modules/fflate": { "version": "0.4.8", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.8.tgz", @@ -6229,6 +7600,22 @@ } } }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -6343,6 +7730,37 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-amd-module-type": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-amd-module-type/-/get-amd-module-type-6.0.1.tgz", @@ -6445,6 +7863,52 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz", + "integrity": "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/get-uri": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", + "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.2", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -6516,6 +7980,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gonzales-pe": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/gonzales-pe/-/gonzales-pe-4.3.0.tgz", @@ -6591,6 +8072,19 @@ "node": ">=0.8.0" } }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -6601,6 +8095,35 @@ "node": ">=8" } }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -6757,6 +8280,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.27.6" }, @@ -6900,12 +8424,91 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ip-address": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", "license": "MIT" }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -6919,6 +8522,46 @@ "node": ">=8" } }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bun-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.7.1" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-core-module": { "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", @@ -6934,6 +8577,41 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -6944,6 +8622,22 @@ "node": ">=0.10.0" } }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -6954,6 +8648,25 @@ "node": ">=8" } }, + "node_modules/is-generator-function": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", + "integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-proto": "^1.0.0", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -6977,6 +8690,32 @@ "node": ">=8" } }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -6987,6 +8726,23 @@ "node": ">=0.12.0" } }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-obj": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", @@ -7004,6 +8760,25 @@ "dev": true, "license": "MIT" }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-regexp": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz", @@ -7014,6 +8789,35 @@ "node": ">=0.10.0" } }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-stream": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", @@ -7026,6 +8830,57 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-unicode-supported": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", @@ -7059,6 +8914,52 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", @@ -7176,6 +9077,7 @@ "integrity": "sha512-lIHeR1qlIRrIN5VMccd8tI2Sgw6ieYXSVktcSHaNe3Z5nE/tcPQYQWOq00wxMvYOsz+73eAkNenVvmPC6bba9A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@asamuzakjp/dom-selector": "^6.5.4", "cssstyle": "^5.3.0", @@ -8056,6 +9958,13 @@ "node": ">= 18" } }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "dev": true, + "license": "MIT" + }, "node_modules/mkdirp": { "version": "0.5.6", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", @@ -8171,6 +10080,22 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-postinstall": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.3.tgz", + "integrity": "sha512-uTp172LLXSxuSYHv/kou+f6KW3SMppU9ivthaVTXian9sOt3XM/zHYHpRZiLgQoxeWfYUnslNWQHF1+G71xcow==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -8178,6 +10103,16 @@ "dev": true, "license": "MIT" }, + "node_modules/netmask": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", + "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/no-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", @@ -8339,6 +10274,103 @@ "node": ">=0.10.0" } }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -8439,6 +10471,24 @@ "os-tmpdir": "^1.0.0" } }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/p-cancelable": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-4.0.1.tgz", @@ -8480,6 +10530,40 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pac-proxy-agent": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", + "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.6", + "pac-resolver": "^7.0.1", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-resolver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", + "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "dev": true, + "license": "MIT", + "dependencies": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -8663,6 +10747,13 @@ "@napi-rs/canvas": "^0.1.77" } }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -8746,6 +10837,16 @@ "node": ">=4" } }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -8765,6 +10866,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -9056,6 +11158,7 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.27.2.tgz", "integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -9178,6 +11281,16 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "license": "MIT" }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -9195,12 +11308,53 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/proxy-agent": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", + "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.1", + "https-proxy-agent": "^7.0.6", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.1.0", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-agent/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "license": "MIT" }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -9211,6 +11365,74 @@ "node": ">=6" } }, + "node_modules/puppeteer": { + "version": "24.25.0", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.25.0.tgz", + "integrity": "sha512-P3rUaom2w/Ubrnz3v3kSbxGkN7SpbtQeGRPb7iO86Bv/dAz2WUmGQBHr37W/Rp1fbAocMvu0rHFbCIJvjiNhGw==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "2.10.12", + "chromium-bidi": "9.1.0", + "cosmiconfig": "^9.0.0", + "devtools-protocol": "0.0.1508733", + "puppeteer-core": "24.25.0", + "typed-query-selector": "^2.12.0" + }, + "bin": { + "puppeteer": "lib/cjs/puppeteer/node/cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/puppeteer-core": { + "version": "24.25.0", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.25.0.tgz", + "integrity": "sha512-8Xs6q3Ut+C8y7sAaqjIhzv1QykGWG4gc2mEZ2mYE7siZFuRp4xQVehOf8uQKSQAkeL7jXUs3mknEeiqnRqUKvQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "2.10.12", + "chromium-bidi": "9.1.0", + "debug": "^4.4.3", + "devtools-protocol": "0.0.1508733", + "typed-query-selector": "^2.12.0", + "webdriver-bidi-protocol": "0.3.7", + "ws": "^8.18.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/puppeteer/node_modules/cosmiconfig": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", + "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/quansync": { "version": "0.2.11", "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", @@ -9309,6 +11531,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -9318,6 +11541,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -9651,6 +11875,50 @@ "node": ">=8" } }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -9699,6 +11967,12 @@ "node": ">=10.13.0" } }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", @@ -9744,6 +12018,16 @@ "node": ">=4" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/responselike": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/responselike/-/responselike-3.0.0.tgz", @@ -9857,12 +12141,81 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-array-concat/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, "node_modules/safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -9917,9 +12270,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -9951,6 +12304,55 @@ "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", "license": "MIT" }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/setimmediate": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", @@ -9980,6 +12382,82 @@ "node": ">=8" } }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -10023,6 +12501,47 @@ "node": "*" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", @@ -10121,6 +12640,16 @@ "wordwrap": "cli.js" } }, + "node_modules/stable-hash-x": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/stable-hash-x/-/stable-hash-x-0.2.0.tgz", + "integrity": "sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -10135,6 +12664,20 @@ "dev": true, "license": "MIT" }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/stream-to-array": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/stream-to-array/-/stream-to-array-2.3.0.tgz", @@ -10145,6 +12688,18 @@ "any-promise": "^1.1.0" } }, + "node_modules/streamx": { + "version": "2.23.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", + "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, "node_modules/string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", @@ -10185,6 +12740,65 @@ "node": ">=8" } }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/stringify-object": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz", @@ -10424,6 +13038,33 @@ "node": ">=18" } }, + "node_modules/tar-fs": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", + "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, "node_modules/test-exclude": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", @@ -10460,6 +13101,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/text-decoder": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", + "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -10528,6 +13179,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -10719,12 +13371,98 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-query-selector": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.0.tgz", + "integrity": "sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==", + "dev": true, + "license": "MIT" + }, "node_modules/typescript": { "version": "5.9.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10764,6 +13502,25 @@ "dev": true, "license": "MIT" }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/undici-types": { "version": "7.12.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.12.0.tgz", @@ -10781,6 +13538,42 @@ "node": ">= 10.0.0" } }, + "node_modules/unrs-resolver": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", + "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "dependencies": { + "napi-postinstall": "^0.3.0" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.11.1", + "@unrs/resolver-binding-android-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-x64": "1.11.1", + "@unrs/resolver-binding-freebsd-x64": "1.11.1", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", + "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-musl": "1.11.1", + "@unrs/resolver-binding-wasm32-wasi": "1.11.1", + "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", + "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", + "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" + } + }, "node_modules/update-browserslist-db": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", @@ -10972,6 +13765,7 @@ "integrity": "sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -11103,6 +13897,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -11116,6 +13911,7 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -11273,6 +14069,13 @@ "integrity": "sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==", "license": "Apache-2.0" }, + "node_modules/webdriver-bidi-protocol": { + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.3.7.tgz", + "integrity": "sha512-wIx5Gu/LLTeexxilpk8WxU2cpGAKlfbWRO5h+my6EMD1k5PYqM1qQO1MHUFf4f3KRnhBvpbZU7VkizAgeSEf7g==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/webidl-conversions": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.0.tgz", @@ -11336,6 +14139,102 @@ "node": ">= 8" } }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", + "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -11507,6 +14406,17 @@ "node": ">=12" } }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -11519,6 +14429,16 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/frontend/package.json b/frontend/package.json index f11184471..c49b2297b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -33,6 +33,7 @@ "@mantine/hooks": "^8.3.1", "@mui/icons-material": "^7.3.2", "@mui/material": "^7.3.2", + "@reactour/tour": "^3.8.0", "@tailwindcss/postcss": "^4.1.13", "@tanstack/react-virtual": "^3.13.12", "autoprefixer": "^10.4.21", @@ -66,6 +67,7 @@ "generate-licenses": "node scripts/generate-licenses.js", "generate-icons": "node scripts/generate-icons.js", "generate-icons:verbose": "node scripts/generate-icons.js --verbose", + "generate-sample-pdf": "node scripts/sample-pdf/generate.mjs", "test": "vitest", "test:run": "vitest run", "test:watch": "vitest --watch", @@ -119,6 +121,8 @@ "@vitest/coverage-v8": "^3.2.4", "eslint": "^9.36.0", "eslint-plugin-react-hooks": "^5.2.0", + "eslint-import-resolver-typescript": "^4.4.4", + "eslint-plugin-import": "^2.32.0", "jsdom": "^27.0.0", "license-checker": "^25.0.1", "madge": "^8.0.0", @@ -126,6 +130,7 @@ "postcss-cli": "^11.0.1", "postcss-preset-mantine": "^1.18.0", "postcss-simple-vars": "^7.0.1", + "puppeteer": "^24.25.0", "typescript": "^5.9.2", "typescript-eslint": "^8.44.1", "vite": "^7.1.7", diff --git a/frontend/public/Login/AddToPDF.png b/frontend/public/Login/AddToPDF.png new file mode 100644 index 000000000..94e9a0ded Binary files /dev/null and b/frontend/public/Login/AddToPDF.png differ diff --git a/frontend/public/Login/Firstpage.png b/frontend/public/Login/Firstpage.png new file mode 100644 index 000000000..f12133f4f Binary files /dev/null and b/frontend/public/Login/Firstpage.png differ diff --git a/frontend/public/Login/LoginBackgroundPanel.png b/frontend/public/Login/LoginBackgroundPanel.png new file mode 100644 index 000000000..4ea0e0ccf Binary files /dev/null and b/frontend/public/Login/LoginBackgroundPanel.png differ diff --git a/frontend/public/Login/SecurePDF.png b/frontend/public/Login/SecurePDF.png new file mode 100644 index 000000000..6184440e9 Binary files /dev/null and b/frontend/public/Login/SecurePDF.png differ diff --git a/frontend/public/Login/apple.svg b/frontend/public/Login/apple.svg new file mode 100644 index 000000000..b947f4b6b --- /dev/null +++ b/frontend/public/Login/apple.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/Login/azure.svg b/frontend/public/Login/azure.svg new file mode 100644 index 000000000..fc1130cbb --- /dev/null +++ b/frontend/public/Login/azure.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/public/Login/github.svg b/frontend/public/Login/github.svg new file mode 100644 index 000000000..651eaac2b --- /dev/null +++ b/frontend/public/Login/github.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/Login/google.svg b/frontend/public/Login/google.svg new file mode 100644 index 000000000..27e4a4ac9 --- /dev/null +++ b/frontend/public/Login/google.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/frontend/public/Login/microsoft.svg b/frontend/public/Login/microsoft.svg new file mode 100644 index 000000000..fc1130cbb --- /dev/null +++ b/frontend/public/Login/microsoft.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 4fdc5b79b..386eb03aa 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -13,7 +13,19 @@ "dismiss": "Maybe later" }, "fullscreen": { - "showDetails": "Show Details" + "showDetails": "Show Details", + "comingSoon": "Coming soon:", + "favorite": "Add to favourites", + "favorites": "Favourites", + "heading": "All tools (fullscreen view)", + "noResults": "Try adjusting your search or toggle descriptions to find what you need.", + "recommended": "Recommended", + "unfavorite": "Remove from favourites" + }, + "placeholder": "Choose a tool to get started", + "toggle": { + "fullscreen": "Switch to fullscreen mode", + "sidebar": "Switch to sidebar mode" } }, "unsavedChanges": "You have unsaved changes to your PDF.", @@ -56,7 +68,7 @@ "preview": "Position Selection", "previewDisclaimer": "Preview is approximate. Final output may vary due to PDF font metrics." }, - "pageSelectionPrompt": "Specify which pages to add numbers to. Examples: \"1,3,5\" for specific pages, \"1-5\" for ranges, \"2n\" for even pages, or leave blank for all pages.", + "pageSelectionPrompt": "Custom Page Selection (Enter a comma-separated list of page numbers 1,5,6 or Functions like 2n+1)", "startingNumberTooltip": "The first number to display. Subsequent pages will increment from this number.", "marginTooltip": "Distance between the page number and the edge of the page.", "fontSizeTooltip": "Size of the page number text in points. Larger numbers create bigger text.", @@ -72,7 +84,6 @@ "uploadLimitExceededPlural": "are too large. Maximum allowed size is", "processTimeWarning": "Warning: This process can take up to a minute depending on file-size", "pageOrderPrompt": "Custom Page Order (Enter a comma-separated list of page numbers or Functions like 2n+1) :", - "pageSelectionPrompt": "Custom Page Selection (Enter a comma-separated list of page numbers 1,5,6 or Functions like 2n+1) :", "goToPage": "Go", "true": "True", "false": "False", @@ -83,8 +94,8 @@ "save": "Save", "saveToBrowser": "Save to Browser", "download": "Download", - "pin": "Pin", - "unpin": "Unpin", + "pin": "Pin File (keep active after tool run)", + "unpin": "Unpin File (replace after tool run)", "undoOperationTooltip": "Click to undo the last operation and restore the original files", "undo": "Undo", "moreOptions": "More Options", @@ -104,7 +115,9 @@ "uploadFiles": "Upload Files", "addFiles": "Add files", "selectFromWorkbench": "Select files from the workbench or ", - "selectMultipleFromWorkbench": "Select at least {{count}} files from the workbench or " + "selectMultipleFromWorkbench": "Select at least {{count}} files from the workbench or ", + "created": "Created", + "size": "File Size" }, "noFavourites": "No favourites added", "downloadComplete": "Download Complete", @@ -289,7 +302,13 @@ "autoUnzipTooltip": "Automatically extract ZIP files returned from API operations. Disable to keep ZIP files intact. This does not affect automation workflows.", "autoUnzipFileLimit": "Auto-unzip file limit", "autoUnzipFileLimitDescription": "Maximum number of files to extract from ZIP", - "autoUnzipFileLimitTooltip": "Only unzip if the ZIP contains this many files or fewer. Set higher to extract larger ZIPs." + "autoUnzipFileLimitTooltip": "Only unzip if the ZIP contains this many files or fewer. Set higher to extract larger ZIPs.", + "defaultToolPickerMode": "Default tool picker mode", + "defaultToolPickerModeDescription": "Choose whether the tool picker opens in fullscreen or sidebar by default", + "mode": { + "fullscreen": "Fullscreen", + "sidebar": "Sidebar" + } }, "hotkeys": { "title": "Keyboard Shortcuts", @@ -306,7 +325,8 @@ "change": "Change shortcut", "reset": "Reset", "shortcut": "Shortcut", - "noShortcut": "No shortcut set" + "noShortcut": "No shortcut set", + "searchPlaceholder": "Search tools..." } }, "changeCreds": { @@ -435,6 +455,9 @@ "alphabetical": "Alphabetical", "globalPopularity": "Global Popularity", "sortBy": "Sort by:", + "mobile": { + "brandAlt": "Stirling PDF logo" + }, "multiTool": { "tags": "multiple,tools", "title": "PDF Multi Tool", @@ -665,9 +688,9 @@ "title": "Manage Certificates", "desc": "Import, export, or delete digital certificate files used for signing PDFs." }, - "read": { - "tags": "view,open,display", - "title": "Read", + "read": { + "tags": "view,open,display", + "title": "Read", "desc": "View and annotate PDFs. Highlight text, draw, or insert comments for review and collaboration." }, "reorganizePages": { @@ -724,6 +747,20 @@ "tags": "workflow,sequence,automation", "title": "Automate", "desc": "Build multi-step workflows by chaining together PDF actions. Ideal for recurring tasks." + }, + "mobile": { + "brandAlt": "Stirling PDF logo", + "openFiles": "Open files", + "swipeHint": "Swipe left or right to switch views", + "tools": "Tools", + "toolsSlide": "Tool selection panel", + "viewSwitcher": "Switch workspace view", + "workbenchSlide": "Workspace panel", + "workspace": "Workspace" + }, + "overlay-pdfs": { + "desc": "Overlay one PDF on top of another", + "title": "Overlay PDFs" } }, "landing": { @@ -909,13 +946,50 @@ "bullet1": "Bookmark Level: Which level to split on (1=top level)", "bullet2": "Include Metadata: Preserve document properties", "bullet3": "Allow Duplicates: Handle repeated bookmark names" + }, + "byDocCount": { + "bullet1": "Enter the number of output files you want", + "bullet2": "Pages are distributed as evenly as possible", + "bullet3": "Useful when you need a specific number of files", + "text": "Create a specific number of output files by evenly distributing pages across them.", + "title": "Split by Document Count" + }, + "byPageCount": { + "bullet1": "Enter the number of pages per output file", + "bullet2": "Last file may have fewer pages if not evenly divisible", + "bullet3": "Useful for batch processing workflows", + "text": "Create multiple PDFs with a specific number of pages each. Perfect for creating uniform document chunks.", + "title": "Split by Page Count" + }, + "byPageDivider": { + "bullet1": "Print divider sheets from the download link", + "bullet2": "Insert divider sheets between your documents", + "bullet3": "Scan all documents together as one PDF", + "bullet4": "Upload - divider pages are automatically detected and removed", + "bullet5": "Enable Duplex Mode if scanning both sides of divider sheets", + "text": "Automatically split scanned documents using physical divider sheets with QR codes. Perfect for processing multiple documents scanned together.", + "title": "Split by Page Divider" } - } + }, + "methodSelection": { + "tooltip": { + "bullet1": "Click on a method card to select it", + "bullet2": "Hover over each card to see a quick description", + "bullet3": "The settings step will appear after you select a method", + "bullet4": "You can change methods at any time before processing", + "header": { + "text": "Choose how you want to split your PDF document. Each method is optimized for different use cases and document types.", + "title": "Split Method Selection" + }, + "title": "Choose Your Split Method" + } + }, + "selectMethod": "Select a split method" }, "rotate": { "title": "Rotate PDF", "submit": "Apply Rotation", - "selectRotation": "Select Rotation Angle (Clockwise)", + "selectRotation": "Select Rotation Angle (Clockwise)", "error": { "failed": "An error occurred while rotating the PDF." }, @@ -1003,7 +1077,8 @@ "imagesExt": "Images (JPG, PNG, etc.)", "markdown": "Markdown", "textRtf": "Text/RTF", - "grayscale": "Greyscale" + "grayscale": "Greyscale", + "errorConversion": "An error occurred while converting the file." }, "imageToPdf": { "tags": "conversion,img,jpg,picture,photo" @@ -1030,7 +1105,7 @@ "header": "PDF Page Organiser", "submit": "Rearrange Pages", "mode": { - "_value": "Organization mode", + "_value": "Organisation mode", "1": "Custom Page Order", "2": "Reverse Order", "3": "Duplex Sort", @@ -1041,7 +1116,20 @@ "8": "Remove Last", "9": "Remove First and Last", "10": "Odd-Even Merge", - "11": "Duplicate all pages" + "11": "Duplicate all pages", + "desc": { + "BOOKLET_SORT": "Arrange pages for booklet printing (last, first, second, second last, …).", + "CUSTOM": "Use a custom sequence of page numbers or expressions to define a new order.", + "DUPLEX_SORT": "Interleave fronts then backs as if a duplex scanner scanned all fronts, then all backs (1, n, 2, n-1, …).", + "DUPLICATE": "Duplicate each page according to the custom order count (e.g., 4 duplicates each page 4×).", + "ODD_EVEN_MERGE": "Merge two PDFs by alternating pages: odd from the first, even from the second.", + "ODD_EVEN_SPLIT": "Split the document into two outputs: all odd pages and all even pages.", + "REMOVE_FIRST": "Remove the first page from the document.", + "REMOVE_FIRST_AND_LAST": "Remove both the first and last pages from the document.", + "REMOVE_LAST": "Remove the last page from the document.", + "REVERSE_ORDER": "Flip the document so the last page becomes first and so on.", + "SIDE_STITCH_BOOKLET_SORT": "Arrange pages for side‑stitch booklet printing (optimized for binding on the side)." + } }, "desc": { "CUSTOM": "Use a custom sequence of page numbers or expressions to define a new order.", @@ -1107,7 +1195,9 @@ "opacity": "Opacity (%)", "spacing": { "horizontal": "Horizontal Spacing", - "vertical": "Vertical Spacing" + "vertical": "Vertical Spacing", + "height": "Height Spacing", + "width": "Width Spacing" }, "convertToImage": "Flatten PDF pages to images" }, @@ -1250,6 +1340,10 @@ "bullet4": "Best for sensitive or copyrighted content" } } + }, + "type": { + "1": "Text", + "2": "Image" } }, "permissions": { @@ -1363,6 +1457,38 @@ }, "examples": { "title": "Examples" + }, + "complex": { + "bullet1": "1,3-5,8,2n → pages 1, 3–5, 8, plus evens", + "bullet2": "10-,2n-1 → from page 10 to end + odd pages", + "description": "Mix different types.", + "title": "Complex Combinations" + }, + "description": "Choose which pages to use for the operation. Supports single pages, ranges, formulas, and the all keyword.", + "individual": { + "bullet1": "1,3,5 → selects pages 1, 3, 5", + "bullet2": "2,7,12 → selects pages 2, 7, 12", + "description": "Enter numbers separated by commas.", + "title": "Individual Pages" + }, + "mathematical": { + "bullet1": "2n → all even pages (2, 4, 6…)", + "bullet2": "2n-1 → all odd pages (1, 3, 5…)", + "bullet3": "3n → every 3rd page (3, 6, 9…)", + "bullet4": "4n-1 → pages 3, 7, 11, 15…", + "description": "Use n in formulas for patterns.", + "title": "Mathematical Functions" + }, + "ranges": { + "bullet1": "3-6 → selects pages 3–6", + "bullet2": "10-15 → selects pages 10–15", + "bullet3": "5- → selects pages 5 to end", + "description": "Use - for consecutive pages.", + "title": "Page Ranges" + }, + "special": { + "bullet1": "all → selects all pages", + "title": "Special Keywords" } } }, @@ -1672,6 +1798,9 @@ "text": "Post-processes the final PDF by removing OCR artefacts and optimising the text layer for better readability and smaller file size." } } + }, + "error": { + "failed": "OCR operation failed" } }, "extractImages": { @@ -1792,8 +1921,14 @@ "title": "Sign", "header": "Sign PDFs", "upload": "Upload Image", - "draw": "Draw Signature", - "text": "Text Input", + "draw": { + "title": "Draw your signature", + "clear": "Clear" + }, + "text": { + "name": "Signer Name", + "placeholder": "Enter your full name" + }, "clear": "Clear", "add": "Add", "saved": "Saved Signatures", @@ -1822,19 +1957,11 @@ "image": "Image", "text": "Text" }, - "draw": { - "title": "Draw your signature", - "clear": "Clear" - }, "image": { "label": "Upload signature image", "placeholder": "Select image file", "hint": "Upload a PNG or JPG image of your signature" }, - "text": { - "name": "Signer Name", - "placeholder": "Enter your full name" - }, "instructions": { "title": "How to add signature", "canvas": "After drawing your signature in the canvas, close the modal then click anywhere on the PDF to place it.", @@ -1961,7 +2088,13 @@ "bullet3": "Can be disabled to reduce output file size" } }, - "submit": "Remove blank pages" + "submit": "Remove blank pages", + "error": { + "failed": "Failed to remove blank pages" + }, + "results": { + "title": "Removed Blank Pages" + } }, "removeAnnotations": { "tags": "comments,highlight,notes,markup,remove", @@ -2064,7 +2197,12 @@ "bullet3": "Choose which page to place the signature", "bullet4": "Optional logo can be included" } - } + }, + "invisible": "Invisible", + "options": { + "title": "Signature Details" + }, + "visible": "Visible" }, "sign": { "submit": "Sign PDF", @@ -2125,7 +2263,22 @@ "text": "Convert your file to a Java keystore (.jks) with keytool, then pick JKS." } } - } + }, + "chooseCertificate": "Choose Certificate File", + "chooseJksFile": "Choose JKS File", + "chooseP12File": "Choose PKCS12 File", + "choosePfxFile": "Choose PFX File", + "choosePrivateKey": "Choose Private Key File", + "location": "Location", + "logoTitle": "Logo", + "name": "Name", + "noLogo": "No Logo", + "pageNumber": "Page Number", + "password": "Certificate Password", + "passwordOptional": "Leave empty if no password", + "reason": "Reason", + "serverCertMessage": "Using server certificate - no files or password required", + "showLogo": "Show Logo" }, "removeCertSign": { "tags": "authenticate,PEM,P12,official,decrypt", @@ -2151,7 +2304,17 @@ "header": "Multi Page Layout", "pagesPerSheet": "Pages per sheet:", "addBorder": "Add Borders", - "submit": "Submit" + "submit": "Submit", + "desc": { + "2": "Place 2 pages side-by-side on a single sheet.", + "3": "Place 3 pages on a single sheet in a single row.", + "4": "Place 4 pages on a single sheet (2 × 2 grid).", + "9": "Place 9 pages on a single sheet (3 × 3 grid).", + "16": "Place 16 pages on a single sheet (4 × 4 grid)." + }, + "error": { + "failed": "An error occurred while creating the multi-page layout." + } }, "bookletImposition": { "tags": "booklet,imposition,printing,binding,folding,signature", @@ -2337,10 +2500,22 @@ "reset": "Reset to full PDF", "coordinates": { "title": "Position and Size", - "x": "X Position", - "y": "Y Position", - "width": "Width", - "height": "Height" + "x": { + "label": "X Position", + "desc": "Left edge (points)" + }, + "y": { + "label": "Y Position", + "desc": "Bottom edge (points)" + }, + "width": { + "label": "Width", + "desc": "Crop width (points)" + }, + "height": { + "label": "Height", + "desc": "Crop height (points)" + } }, "error": { "invalidArea": "Crop area extends beyond PDF boundaries", @@ -2358,6 +2533,10 @@ }, "results": { "title": "Crop Results" + }, + "automation": { + "info": "Enter crop coordinates in PDF points. Origin (0,0) is at bottom-left. These values will be applied to all PDFs processed in this automation.", + "reference": "Reference: A4 page is 595.28 × 841.89 points (210mm × 297mm). 1 inch = 72 points." } }, "autoSplitPDF": { @@ -2587,7 +2766,8 @@ "counts": { "label": "Overlay Counts (for Fixed Repeat Mode)", "placeholder": "Enter comma-separated counts (e.g., 2,3,1)", - "item": "Count for file" + "item": "Count for file", + "noFiles": "Add overlay files to configure counts" }, "position": { "label": "Select Overlay Position", @@ -2628,6 +2808,9 @@ "title": "Counts (Fixed Repeat only)", "text": "Provide a positive number for each overlay file showing how many pages to take before moving to the next. Required when mode is Fixed Repeat." } + }, + "error": { + "failed": "An error occurred while overlaying PDFs." } }, "split-by-sections": { @@ -2663,7 +2846,18 @@ "customMargin": "Custom Margin", "customColor": "Custom Text Colour", "submit": "Submit", - "noStampSelected": "No stamp selected. Return to Step 1." + "noStampSelected": "No stamp selected. Return to Step 1.", + "customPosition": "Drag the stamp to the desired location in the preview window.", + "error": { + "failed": "An error occurred while adding stamp to the PDF." + }, + "imageSize": "Image Size", + "margin": "Margin", + "positionAndFormatting": "Position & Formatting", + "quickPosition": "Select a position on the page to place the stamp.", + "results": { + "title": "Stamp Results" + } }, "removeImagePdf": { "tags": "Remove Image,Page operations,Back end,server side" @@ -2681,7 +2875,8 @@ "status": { "_value": "Status", "valid": "Valid", - "invalid": "Invalid" + "invalid": "Invalid", + "complete": "Validation complete" }, "signer": "Signer", "date": "Date", @@ -2708,16 +2903,71 @@ "version": "Version", "keyUsage": "Key Usage", "selfSigned": "Self-Signed", - "bits": "bits" + "bits": "bits", + "details": "Certificate Details" }, "signature": { "info": "Signature Information", "_value": "Signature", "mathValid": "Signature is mathematically valid BUT:" }, - "selectCustomCert": "Custom Certificate File X.509 (Optional)" + "selectCustomCert": "Custom Certificate File X.509 (Optional)", + "downloadCsv": "Download CSV", + "downloadJson": "Download JSON", + "downloadPdf": "Download PDF Report", + "downloadType": { + "csv": "CSV", + "json": "JSON", + "pdf": "PDF" + }, + "error": { + "allFailed": "Unable to validate the selected files.", + "partial": "Some files could not be validated.", + "reportGeneration": "Could not generate the PDF report. JSON and CSV are available.", + "unexpected": "Unexpected error during validation." + }, + "finalizing": "Preparing downloads...", + "issue": { + "certExpired": "Certificate expired", + "certRevocationUnknown": "Certificate revocation status unknown", + "certRevoked": "Certificate revoked", + "chainInvalid": "Certificate chain invalid", + "signatureInvalid": "Signature cryptographic check failed", + "trustInvalid": "Certificate not trusted" + }, + "noResults": "Run the validation to generate a report.", + "noSignaturesShort": "No signatures", + "processing": "Validating signatures...", + "report": { + "continued": "Continued", + "downloads": "Downloads", + "entryLabel": "Signature Summary", + "fields": { + "created": "Created", + "fileSize": "File Size", + "signatureCount": "Total Signatures", + "signatureDate": "Signature Date" + }, + "filesEvaluated": "{{count}} files evaluated", + "footer": "Validated via Stirling PDF", + "generatedAt": "Generated", + "noPdf": "PDF report will be available after a successful validation.", + "page": "Page", + "shortTitle": "Signature Summary", + "signatureCountLabel": "{{count}} signatures", + "signaturesFound": "{{count}} signatures detected", + "signaturesValid": "{{count}} fully valid", + "title": "Signature Validation Report" + }, + "settings": { + "certHint": "Upload a trusted X.509 certificate to validate against a custom trust source.", + "title": "Validation Settings" + }, + "signatureDate": "Signature Date", + "totalSignatures": "Total Signatures" }, "replaceColor": { + "tags": "Replace Colour,Page operations,Back end,server side", "labels": { "settings": "Settings", "colourOperation": "Colour operation" @@ -2758,9 +3008,6 @@ "failed": "An error occurred while processing the colour replacement." } }, - "replaceColor": { - "tags": "Replace Colour,Page operations,Back end,server side" - }, "login": { "title": "Sign in", "header": "Sign in", @@ -2792,6 +3039,11 @@ "enterEmail": "Enter your email", "enterPassword": "Enter your password", "loggingIn": "Logging In...", + "username": "Username", + "enterUsername": "Enter username", + "useEmailInstead": "Login with email", + "forgotPassword": "Forgot your password?", + "logIn": "Log In", "signingIn": "Signing in...", "login": "Login", "or": "Or", @@ -2830,6 +3082,10 @@ "passwordsDoNotMatch": "Passwords do not match", "passwordTooShort": "Password must be at least 6 characters long", "invalidEmail": "Please enter a valid email address", + "nameRequired": "Name is required", + "emailRequired": "Email is required", + "passwordRequired": "Password is required", + "confirmPasswordRequired": "Confirm password is required", "checkEmailConfirmation": "Check your email for a confirmation link to complete your registration.", "accountCreatedSuccessfully": "Account created successfully! You can now sign in.", "unexpectedError": "Unexpected error: {{message}}" @@ -2876,7 +3132,19 @@ "contrast": "Contrast:", "brightness": "Brightness:", "saturation": "Saturation:", - "download": "Download" + "download": "Download", + "adjustColors": "Adjust Colors", + "blue": "Blue", + "confirm": "Confirm", + "error": { + "failed": "Failed to adjust colors/contrast" + }, + "green": "Green", + "noPreview": "Select a PDF to preview", + "red": "Red", + "results": { + "title": "Adjusted PDF" + } }, "compress": { "title": "Compress", @@ -3023,7 +3291,13 @@ "title": "Remove image", "header": "Remove image", "removeImage": "Remove image", - "submit": "Remove image" + "submit": "Remove image", + "error": { + "failed": "Failed to remove images from the PDF." + }, + "results": { + "title": "Remove Images Results" + } }, "splitByChapters": { "title": "Split PDF by Chapters", @@ -3168,7 +3442,9 @@ }, "search": { "title": "Search PDF", - "placeholder": "Enter search term..." + "placeholder": "Enter search term...", + "noResults": "No results found", + "searching": "Searching..." }, "guestBanner": { "title": "You're using Stirling PDF as a guest!", @@ -3206,6 +3482,7 @@ "automate": "Automate", "files": "Files", "activity": "Activity", + "help": "Help", "account": "Account", "config": "Config", "allTools": "All Tools" @@ -3290,7 +3567,17 @@ "selectedCount": "{{count}} selected", "download": "Download", "delete": "Delete", - "unsupported": "Unsupported" + "unsupported": "Unsupported", + "addToUpload": "Add to Upload", + "deleteAll": "Delete All", + "loadingFiles": "Loading files...", + "noFiles": "No files available", + "noFilesFound": "No files found matching your search", + "openInPageEditor": "Open in Page Editor", + "showAll": "Show All", + "sortByDate": "Sort by Date", + "sortByName": "Sort by Name", + "sortBySize": "Sort by Size" }, "storage": { "temporaryNotice": "Files are stored temporarily in your browser and may be cleared automatically", @@ -3545,16 +3832,6 @@ "processImagesDesc": "Converts multiple image files into a single PDF document, then applies OCR technology to extract searchable text from the images." } }, - "viewer": { - "firstPage": "First Page", - "lastPage": "Last Page", - "previousPage": "Previous Page", - "nextPage": "Next Page", - "zoomIn": "Zoom In", - "zoomOut": "Zoom Out", - "singlePageView": "Single Page View", - "dualPageView": "Dual Page View" - }, "common": { "copy": "Copy", "copied": "Copied!", @@ -3563,7 +3840,8 @@ "remaining": "remaining", "used": "used", "available": "available", - "cancel": "Cancel" + "cancel": "Cancel", + "preview": "Preview" }, "config": { "account": { @@ -3621,8 +3899,150 @@ "submit": "Add Attachments", "results": { "title": "Attachment Results" + }, + "error": { + "failed": "Add attachments operation failed" } }, "termsAndConditions": "Terms & Conditions", - "logOut": "Log out" + "logOut": "Log out", + "addAttachments": { + "error": { + "failed": "An error occurred while adding attachments to the PDF." + } + }, + "autoRename": { + "description": "This tool will automatically rename PDF files based on their content. It analyzes the document to find the most suitable title from the text." + }, + "customPosition": "Custom Position", + "details": "Details", + "downloadUnavailable": "Download unavailable for this item", + "invalidUndoData": "Cannot undo: invalid operation data", + "margin": { + "large": "Large", + "medium": "Medium", + "small": "Small", + "xLarge": "Extra Large" + }, + "noFilesToUndo": "Cannot undo: no files were processed in the last operation", + "noOperationToUndo": "No operation to undo", + "noValidFiles": "No valid files to process", + "operationCancelled": "Operation cancelled", + "pageEdit": { + "deselectAll": "Select None", + "selectAll": "Select All" + }, + "quickPosition": "Quick Position", + "reorganizePages": { + "error": { + "failed": "Failed to reorganize pages" + }, + "results": { + "title": "Pages Reorganized" + }, + "settings": { + "title": "Settings" + }, + "submit": "Reorganize Pages" + }, + "replace-color": { + "options": { + "fill": "Fill colour", + "gradient": "Gradient" + }, + "previewOverlayOpacity": "Preview overlay opacity", + "previewOverlayTransparency": "Preview overlay transparency", + "previewOverlayVisibility": "Show preview overlay", + "selectText": { + "1": "Replace or invert colour options", + "2": "Default (preset high contrast colours)", + "3": "Custom (choose your own colours)", + "4": "Full invert (invert all colours)", + "5": "High contrast color options", + "6": "White text on black background", + "7": "Black text on white background", + "8": "Yellow text on black background", + "9": "Green text on black background", + "10": "Choose text Color", + "11": "Choose background Color", + "12": "Choose start colour", + "13": "Choose end colour" + }, + "submit": "Replace", + "title": "Replace-Invert-Color" + }, + "size": "Size", + "submit": "Submit", + "success": "Success", + "tools": { + "noSearchResults": "No tools found", + "noTools": "No tools available" + }, + "undoDataMismatch": "Cannot undo: operation data is corrupted", + "undoFailed": "Failed to undo operation", + "undoQuotaError": "Cannot undo: insufficient storage space", + "undoStorageError": "Undo completed but some files could not be saved to storage", + "undoSuccess": "Operation undone successfully", + "unsupported": "Unsupported", + "signup": { + "title": "Create an account", + "subtitle": "Join Stirling PDF to get started", + "name": "Name", + "email": "Email", + "password": "Password", + "confirmPassword": "Confirm password", + "enterName": "Enter your name", + "enterEmail": "Enter your email", + "enterPassword": "Enter your password", + "confirmPasswordPlaceholder": "Confirm password", + "or": "or", + "creatingAccount": "Creating Account...", + "signUp": "Sign Up", + "alreadyHaveAccount": "Already have an account? Sign in", + "pleaseFillAllFields": "Please fill in all fields", + "passwordsDoNotMatch": "Passwords do not match", + "passwordTooShort": "Password must be at least 6 characters long", + "invalidEmail": "Please enter a valid email address", + "checkEmailConfirmation": "Check your email for a confirmation link to complete your registration.", + "accountCreatedSuccessfully": "Account created successfully! You can now sign in.", + "unexpectedError": "Unexpected error: {{message}}", + "useEmailInstead": "Use Email Instead", + "nameRequired": "Name is required", + "emailRequired": "Email is required", + "passwordRequired": "Password is required", + "confirmPasswordRequired": "Please confirm your password" + }, + "onboarding": { + "welcomeModal": { + "title": "Welcome to Stirling PDF!", + "description": "Would you like to take a quick 1-minute tour to learn the key features and how to get started?", + "helpHint": "You can always access this tour later from the Help button in the bottom left.", + "startTour": "Start Tour", + "maybeLater": "Maybe Later", + "dontShowAgain": "Don't Show Again" + }, + "allTools": "This is the All Tools panel, where you can browse and select from all available PDF tools.", + "selectCropTool": "Let's select the Crop tool to demonstrate how to use one of the tools.", + "toolInterface": "This is the Crop tool interface. As you can see, there's not much there because we haven't added any PDF files to work with yet.", + "filesButton": "The Files button on the Quick Access bar allows you to upload PDFs to use the tools on.", + "fileSources": "You can upload new files or access recent files from here. For the tour, we'll just use a sample file.", + "workbench": "This is the Workbench - the main area where you view and edit your PDFs.", + "viewSwitcher": "Use these controls to select how you want to view your PDFs.", + "viewer": "The Viewer lets you read and annotate your PDFs.", + "pageEditor": "The Page Editor allows you to do various operations on the pages within your PDFs, such as reordering, rotating and deleting.", + "activeFiles": "The Active Files view shows all of the PDFs you have loaded into the tool, and allows you to select which ones to process.", + "fileCheckbox": "Clicking one of the files selects it for processing. You can select multiple files for batch operations.", + "selectControls": "The Right Rail contains buttons to quickly select/deselect all of your active PDFs, along with buttons to change the app's theme or language.", + "cropSettings": "Now that we've selected the file we want crop, we can configure the Crop tool to choose the area that we want to crop the PDF to.", + "runButton": "Once the tool has been configured, this button allows you to run the tool on all the selected PDFs.", + "results": "After the tool has finished running, the Review step will show a preview of the results in this panel, and allow you to undo the operation or download the file. ", + "fileReplacement": "The modified file will replace the original file in the Workbench automatically, allowing you to easily run it through more tools.", + "pinButton": "You can use the Pin button if you'd rather your files stay active after running tools on them.", + "wrapUp": "You're all set! You've learnt about the main areas of the app and how to use them. Click the Help button whenever you like to see this tour again.", + "previous": "Previous", + "next": "Next", + "finish": "Finish", + "startTour": "Start Tour", + "startTourDescription": "Take a guided tour of Stirling PDF's key features" + } } diff --git a/frontend/public/samples/Sample.pdf b/frontend/public/samples/Sample.pdf new file mode 100644 index 000000000..d78d9e1ef Binary files /dev/null and b/frontend/public/samples/Sample.pdf differ diff --git a/frontend/scripts/sample-pdf/generate.mjs b/frontend/scripts/sample-pdf/generate.mjs new file mode 100755 index 000000000..93e5cf7ee --- /dev/null +++ b/frontend/scripts/sample-pdf/generate.mjs @@ -0,0 +1,105 @@ +#!/usr/bin/env node + +/** + * Stirling PDF Sample Document Generator + * + * This script uses Puppeteer to generate a sample PDF from a HTML template. + * The output is used in the onboarding tour and as a demo document + * for users to experiment with Stirling PDF's features. + */ + +import puppeteer from 'puppeteer'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; +import { existsSync, mkdirSync, statSync } from 'fs'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const TEMPLATE_PATH = join(__dirname, 'template.html'); +const OUTPUT_DIR = join(__dirname, '../../public/samples'); +const OUTPUT_PATH = join(OUTPUT_DIR, 'Sample.pdf'); + +async function generatePDF() { + console.log('🚀 Starting Stirling PDF sample document generation...\n'); + + // Ensure output directory exists + if (!existsSync(OUTPUT_DIR)) { + mkdirSync(OUTPUT_DIR, { recursive: true }); + console.log(`✅ Created output directory: ${OUTPUT_DIR}`); + } + + // Check if template exists + if (!existsSync(TEMPLATE_PATH)) { + console.error(`❌ Template file not found: ${TEMPLATE_PATH}`); + process.exit(1); + } + + console.log(`📄 Reading template: ${TEMPLATE_PATH}`); + + let browser; + try { + // Launch Puppeteer + console.log('🌐 Launching browser...'); + browser = await puppeteer.launch({ + headless: 'new', + args: ['--no-sandbox', '--disable-setuid-sandbox'] + }); + + const page = await browser.newPage(); + + // Set viewport to match A4 proportions + await page.setViewport({ + width: 794, // A4 width in pixels at 96 DPI + height: 1123, // A4 height in pixels at 96 DPI + deviceScaleFactor: 2 // Higher quality rendering + }); + + // Navigate to the template file + const fileUrl = `file://${TEMPLATE_PATH}`; + console.log('📖 Loading HTML template...'); + await page.goto(fileUrl, { + waitUntil: 'networkidle0' // Wait for all resources to load + }); + + // Generate PDF with A4 dimensions + console.log('📝 Generating PDF...'); + await page.pdf({ + path: OUTPUT_PATH, + format: 'A4', + printBackground: true, + margin: { + top: 0, + right: 0, + bottom: 0, + left: 0 + }, + preferCSSPageSize: true + }); + + console.log('\n✅ PDF generated successfully!'); + console.log(`📦 Output: ${OUTPUT_PATH}`); + + // Get file size + const stats = statSync(OUTPUT_PATH); + const fileSizeInKB = (stats.size / 1024).toFixed(2); + console.log(`📊 File size: ${fileSizeInKB} KB`); + + } catch (error) { + console.error('\n❌ Error generating PDF:', error.message); + process.exit(1); + } finally { + if (browser) { + await browser.close(); + console.log('🔒 Browser closed.'); + } + } + + console.log('\n🎉 Done! Sample PDF is ready for use in Stirling PDF.\n'); +} + +// Run the generator +generatePDF().catch(error => { + console.error('Fatal error:', error); + process.exit(1); +}); diff --git a/frontend/scripts/sample-pdf/styles.css b/frontend/scripts/sample-pdf/styles.css new file mode 100644 index 000000000..067452833 --- /dev/null +++ b/frontend/scripts/sample-pdf/styles.css @@ -0,0 +1,432 @@ +/* Stirling PDF Sample Document Styles */ + +:root { + /* Brand Colors */ + --brand-red: #8e3231; + --brand-blue: #3b82f6; + + /* Category Colors */ + --color-general: #3b82f6; + --color-security: #f59e0b; + --color-formatting: #8b5cf6; + --color-automation: #ec4899; + + /* Neutral Colors */ + --color-black: #111827; + --color-gray-dark: #4b5563; + --color-gray-medium: #6b7280; + --color-gray-light: #e5e7eb; + --color-gray-lighter: #f3f4f6; + --color-white: #ffffff; + + /* Font Stack */ + --font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: var(--font-family); + color: var(--color-black); + line-height: 1.6; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* Page Structure - A4 Dimensions */ +.page { + width: 210mm; + height: 297mm; + background: white; + page-break-after: always; + position: relative; + overflow: hidden; +} + +.page:last-child { + page-break-after: auto; +} + +/* Page 1: Hero / Cover */ +.page-1 { + background: var(--brand-red); + display: flex; + align-items: center; + justify-content: center; + position: relative; + overflow: hidden; +} + +/* Decorative shapes container */ +.decorative-shapes { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + overflow: hidden; + z-index: 0; +} + +.shape { + position: absolute; +} + +/* Logo SVG shape - top-right */ +.shape-1 { + top: -120px; + right: -100px; + width: 450px; + height: auto; + opacity: 0.12; +} + +/* Logo SVG shape - top-left */ +.shape-2 { + top: -80px; + left: -80px; + width: 350px; + height: auto; + opacity: 0.08; +} + +/* Logo SVG shape - bottom-left */ +.shape-3 { + bottom: -180px; + left: -150px; + width: 550px; + height: auto; + opacity: 0.15; +} + +/* Logo SVG shape - bottom-right */ +.shape-4 { + bottom: -100px; + right: -120px; + width: 400px; + height: auto; + opacity: 0.1; +} + +/* Small accent shape center-right */ +.shape-5 { + top: 50%; + right: -30px; + width: 200px; + height: auto; + opacity: 0.08; + transform: translateY(-50%); +} + +.hero-content { + text-align: center; + padding: 60px; + position: relative; + z-index: 1; +} + +.logo-container { + margin-bottom: 48px; + position: relative; +} + +.hero-logo { + width: 280px; + height: auto; +} + +.hero-tagline { + font-size: 32px; + font-weight: 600; + color: var(--color-white); + margin-bottom: 32px; + line-height: 1.3; +} + +.hero-stats { + margin-bottom: 40px; +} + +.stat-badge { + display: inline-flex; + flex-direction: column; + align-items: center; + padding: 24px 48px; + background: rgba(255, 255, 255, 0.95); + border-radius: 16px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2); +} + +.stat-number { + font-size: 48px; + font-weight: 700; + color: var(--brand-red); + line-height: 1; +} + +.stat-label { + font-size: 18px; + color: var(--color-gray-dark); + margin-top: 8px; + font-weight: 500; +} + +.hero-features { + display: flex; + justify-content: center; + gap: 16px; + flex-wrap: wrap; +} + +.feature-pill { + padding: 12px 24px; + background: rgba(255, 255, 255, 0.2); + color: white; + border-radius: 24px; + font-size: 16px; + font-weight: 500; + border: 2px solid rgba(255, 255, 255, 0.3); + backdrop-filter: blur(10px); +} + +/* Page 2: What is Stirling PDF */ +.page-2 { + padding: 60px; +} + +.content-wrapper { + max-width: 700px; + margin: 0 auto; +} + +.page-title { + font-size: 36px; + font-weight: 700; + color: var(--brand-red); + margin-bottom: 24px; + border-bottom: 4px solid var(--brand-red); + padding-bottom: 16px; +} + +.intro-text { + font-size: 16px; + color: var(--color-gray-dark); + margin-bottom: 48px; + line-height: 1.8; +} + +.value-props { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 32px; +} + +.value-prop { + display: flex; + flex-direction: column; + gap: 12px; +} + +.value-icon { + width: 48px; + height: 48px; + color: var(--brand-red); + margin-bottom: 8px; +} + +.value-icon svg { + width: 100%; + height: 100%; +} + +.value-prop h3 { + font-size: 20px; + font-weight: 600; + color: var(--color-black); +} + +.value-prop p { + font-size: 14px; + color: var(--color-gray-dark); + line-height: 1.6; +} + +/* Page 3: Key Features */ +.page-3 { + padding: 60px; +} + +.features-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; + margin-bottom: 32px; +} + +.feature-card { + background: white; + border: 2px solid var(--color-gray-light); + border-radius: 12px; + padding: 24px; + transition: all 0.2s ease; +} + +.feature-card[data-category="general"] { + border-color: var(--color-general); +} + +.feature-card[data-category="security"] { + border-color: var(--color-security); +} + +.feature-card[data-category="formatting"] { + border-color: var(--color-formatting); +} + +.feature-card[data-category="automation"] { + border-color: var(--color-automation); +} + +.feature-header { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 16px; +} + +.feature-icon-large { + width: 40px; + height: 40px; + flex-shrink: 0; +} + +.feature-card[data-category="general"] .feature-icon-large { + color: var(--color-general); +} + +.feature-card[data-category="security"] .feature-icon-large { + color: var(--color-security); +} + +.feature-card[data-category="formatting"] .feature-icon-large { + color: var(--color-formatting); +} + +.feature-card[data-category="automation"] .feature-icon-large { + color: var(--color-automation); +} + +.feature-icon-large svg { + width: 100%; + height: 100%; +} + +.feature-card h3 { + font-size: 18px; + font-weight: 600; + color: var(--color-black); +} + +.feature-list { + list-style: none; + padding: 0; +} + +.feature-list li { + font-size: 14px; + color: var(--color-gray-dark); + padding: 6px 0; + padding-left: 20px; + position: relative; +} + +.feature-list li::before { + content: "•"; + position: absolute; + left: 0; + color: var(--brand-red); + font-weight: bold; +} + +.additional-features { + background: white; + border: 2px solid var(--brand-red); + padding: 24px; + border-radius: 12px; + margin-top: 24px; +} + +.additional-features-header { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 16px; +} + +.additional-features-icon { + width: 40px; + height: 40px; + color: var(--brand-red); + flex-shrink: 0; +} + +.additional-features-icon svg { + width: 100%; + height: 100%; +} + +.additional-features h3 { + font-size: 18px; + font-weight: 600; + color: var(--color-black); + margin: 0; +} + +.additional-features-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 32px; +} + +.additional-features-grid ul { + list-style: none; + padding: 0; + margin: 0; +} + +.additional-features-grid li { + font-size: 15px; + color: var(--color-gray-dark); + padding: 4px 0; + padding-left: 24px; + position: relative; + line-height: 1.5; +} + +.additional-features-grid li::before { + content: "•"; + position: absolute; + left: 0; + color: var(--brand-red); + font-weight: bold; + font-size: 18px; +} + +/* Print Styles */ +@media print { + body { + margin: 0; + padding: 0; + } + + .page { + margin: 0; + border: none; + box-shadow: none; + } +} diff --git a/frontend/scripts/sample-pdf/template.html b/frontend/scripts/sample-pdf/template.html new file mode 100644 index 000000000..e4ae57e50 --- /dev/null +++ b/frontend/scripts/sample-pdf/template.html @@ -0,0 +1,234 @@ + + + + + + Stirling PDF - Sample Document + + + + +
+
+ + + + + +
+
+
+ +
+

The Free Adobe Acrobat Alternative

+
+
+ 10M+ + Downloads +
+
+
+
Open Source
+
Privacy First
+
Self-Hosted
+
+
+
+ + +
+
+

What is Stirling PDF?

+

+ Stirling PDF is a robust, web-based PDF manipulation tool. + It enables you to carry out various operations on PDF files, including splitting, + merging, converting, rearranging, adding images, rotating, compressing, and more. +

+ +
+
+
+ + + +
+

50+ PDF Operations

+

Comprehensive toolkit covering all your PDF needs. From basic operations to advanced processing.

+
+ +
+
+ + + +
+

Workflow Automation

+

Chain multiple operations together and save them as reusable workflows. Perfect for recurring tasks.

+
+ +
+
+ + + + + +
+

Multi-Language Support

+

Available in over 30 languages with community-contributed translations. Accessible to users worldwide.

+
+ +
+
+ + + + + +
+

Privacy First

+

Self-hosted solution means your data stays on your infrastructure. You have full control over your documents.

+
+ +
+
+ + + + +
+

Open Source

+

Transparent, community-driven development. Inspect the code, contribute features, and adapt as needed.

+
+ +
+
+ + + + +
+

API Access

+

RESTful API for integration with external tools and scripts. Automate PDF operations programmatically.

+
+
+
+
+ + +
+
+

Key Features

+ +
+
+
+
+ + + + + + + +
+

Page Operations

+
+
    +
  • Merge & split PDFs
  • +
  • Rearrange pages
  • +
  • Rotate & crop
  • +
  • Extract pages
  • +
  • Multi-page layout
  • +
+
+ +
+
+
+ + + + +
+

Security & Signing

+
+
    +
  • Password protection
  • +
  • Digital signatures
  • +
  • Watermarks
  • +
  • Permission controls
  • +
  • Redaction tools
  • +
+
+ +
+
+
+ + + +
+

File Conversions

+
+
    +
  • PDF to/from images
  • +
  • Office documents
  • +
  • HTML to PDF
  • +
  • Markdown to PDF
  • +
  • PDF to Word/Excel
  • +
+
+ +
+
+
+ + + +
+

Automation

+
+
    +
  • Multi-step workflows
  • +
  • Chain PDF operations
  • +
  • Save recurring tasks
  • +
  • Batch file processing
  • +
  • API integration
  • +
+
+
+ +
+
+
+ + + +
+

Plus Many More

+
+
+
    +
  • OCR text recognition
  • +
  • Compress PDFs
  • +
  • Add images & stamps
  • +
  • Detect blank pages
  • +
  • Extract images
  • +
  • Edit metadata
  • +
+
    +
  • Flatten forms
  • +
  • PDF/A conversion
  • +
  • Add page numbers
  • +
  • Remove pages
  • +
  • Repair PDFs
  • +
  • And 40+ more tools
  • +
+
+
+
+
+ + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5db513d4a..fd4d466ad 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,14 +1,25 @@ import { Suspense } from "react"; +import { Routes, Route } from "react-router-dom"; import { RainbowThemeProvider } from "./components/shared/RainbowThemeProvider"; import { FileContextProvider } from "./contexts/FileContext"; import { NavigationProvider } from "./contexts/NavigationContext"; +import { ToolRegistryProvider } from "./contexts/ToolRegistryProvider"; import { FilesModalProvider } from "./contexts/FilesModalContext"; import { ToolWorkflowProvider } from "./contexts/ToolWorkflowContext"; import { HotkeyProvider } from "./contexts/HotkeyContext"; import { SidebarProvider } from "./contexts/SidebarContext"; import { PreferencesProvider } from "./contexts/PreferencesContext"; +import { OnboardingProvider } from "./contexts/OnboardingContext"; +import { TourOrchestrationProvider } from "./contexts/TourOrchestrationContext"; import ErrorBoundary from "./components/shared/ErrorBoundary"; -import HomePage from "./pages/HomePage"; +import OnboardingTour from "./components/onboarding/OnboardingTour"; + +// Import auth components +import { AuthProvider } from "./auth/UseSession"; +import Landing from "./routes/Landing"; +import Login from "./routes/Login"; +import Signup from "./routes/Signup"; +import AuthCallback from "./routes/AuthCallback"; // Import global styles import "./styles/tailwind.css"; @@ -40,28 +51,50 @@ const LoadingFallback = () => ( export default function App() { return ( }> - + - - - - - - - - - - - - - - - - - - - + + + {/* Auth routes - no FileContext or other providers needed */} + } /> + } /> + } /> + + {/* Main app routes - wrapped with all providers */} + + + + + + + + + + + + + + + + + + + + + + + + + + + } + /> + + diff --git a/frontend/src/auth/UseSession.tsx b/frontend/src/auth/UseSession.tsx new file mode 100644 index 000000000..7728627f1 --- /dev/null +++ b/frontend/src/auth/UseSession.tsx @@ -0,0 +1,233 @@ +import { createContext, useContext, useEffect, useState, ReactNode, useCallback } from 'react'; +import { springAuth } from './springAuthClient'; +import type { Session, User, AuthError } from './springAuthClient'; + +/** + * Auth Context Type + * Simplified version without SaaS-specific features (credits, subscriptions) + */ +interface AuthContextType { + session: Session | null; + user: User | null; + loading: boolean; + error: AuthError | null; + signOut: () => Promise; + refreshSession: () => Promise; +} + +const AuthContext = createContext({ + session: null, + user: null, + loading: true, + error: null, + signOut: async () => {}, + refreshSession: async () => {}, +}); + +/** + * Auth Provider Component + * + * Manages authentication state and provides it to the entire app. + * Integrates with Spring Security + JWT backend. + */ +export function AuthProvider({ children }: { children: ReactNode }) { + const [session, setSession] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + /** + * Refresh current session + */ + const refreshSession = useCallback(async () => { + try { + setLoading(true); + setError(null); + console.debug('[Auth] Refreshing session...'); + + const { data, error } = await springAuth.refreshSession(); + + if (error) { + console.error('[Auth] Session refresh error:', error); + setError(error); + setSession(null); + } else { + console.debug('[Auth] Session refreshed successfully'); + setSession(data.session); + } + } catch (err) { + console.error('[Auth] Unexpected error during session refresh:', err); + setError(err as AuthError); + } finally { + setLoading(false); + } + }, []); + + /** + * Sign out user + */ + const signOut = useCallback(async () => { + try { + setError(null); + console.debug('[Auth] Signing out...'); + + const { error } = await springAuth.signOut(); + + if (error) { + console.error('[Auth] Sign out error:', error); + setError(error); + } else { + console.debug('[Auth] Signed out successfully'); + setSession(null); + } + } catch (err) { + console.error('[Auth] Unexpected error during sign out:', err); + setError(err as AuthError); + } + }, []); + + /** + * Initialize auth on mount + */ + useEffect(() => { + let mounted = true; + + const initializeAuth = async () => { + try { + console.debug('[Auth] Initializing auth...'); + + // First check if login is enabled + const configResponse = await fetch('/api/v1/config/app-config'); + if (configResponse.ok) { + const config = await configResponse.json(); + + // If login is disabled, skip authentication entirely + if (config.enableLogin === false) { + console.debug('[Auth] Login disabled - skipping authentication'); + if (mounted) { + setSession(null); + setLoading(false); + } + return; + } + } + + // Login is enabled, proceed with normal auth check + const { data, error } = await springAuth.getSession(); + + if (!mounted) return; + + if (error) { + console.error('[Auth] Initial session error:', error); + setError(error); + } else { + console.debug('[Auth] Initial session loaded:', { + hasSession: !!data.session, + userId: data.session?.user?.id, + email: data.session?.user?.email, + }); + setSession(data.session); + } + } catch (err) { + console.error('[Auth] Unexpected error during auth initialization:', err); + if (mounted) { + setError(err as AuthError); + } + } finally { + if (mounted) { + setLoading(false); + } + } + }; + + initializeAuth(); + + // Subscribe to auth state changes + const { data: { subscription } } = springAuth.onAuthStateChange( + async (event, newSession) => { + if (!mounted) return; + + console.debug('[Auth] Auth state change:', { + event, + hasSession: !!newSession, + userId: newSession?.user?.id, + email: newSession?.user?.email, + timestamp: new Date().toISOString(), + }); + + // Schedule state update + setTimeout(() => { + if (mounted) { + setSession(newSession); + setError(null); + + // Handle specific events + if (event === 'SIGNED_OUT') { + console.debug('[Auth] User signed out, clearing session'); + } else if (event === 'SIGNED_IN') { + console.debug('[Auth] User signed in successfully'); + } else if (event === 'TOKEN_REFRESHED') { + console.debug('[Auth] Token refreshed'); + } else if (event === 'USER_UPDATED') { + console.debug('[Auth] User updated'); + } + } + }, 0); + } + ); + + return () => { + mounted = false; + subscription.unsubscribe(); + }; + }, []); + + const value: AuthContextType = { + session, + user: session?.user ?? null, + loading, + error, + signOut, + refreshSession, + }; + + return ( + + {children} + + ); +} + +/** + * Hook to access auth context + * Must be used within AuthProvider + */ +export function useAuth() { + const context = useContext(AuthContext); + + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider'); + } + + return context; +} + +/** + * Debug hook to expose auth state for debugging + * Can be used in development to monitor auth state + */ +export function useAuthDebug() { + const auth = useAuth(); + + useEffect(() => { + console.debug('[Auth Debug] Current auth state:', { + hasSession: !!auth.session, + hasUser: !!auth.user, + loading: auth.loading, + hasError: !!auth.error, + userId: auth.user?.id, + email: auth.user?.email, + }); + }, [auth.session, auth.user, auth.loading, auth.error]); + + return auth; +} diff --git a/frontend/src/auth/springAuthClient.ts b/frontend/src/auth/springAuthClient.ts new file mode 100644 index 000000000..03ea08f0d --- /dev/null +++ b/frontend/src/auth/springAuthClient.ts @@ -0,0 +1,447 @@ +/** + * Spring Auth Client + * + * This client integrates with the Spring Security + JWT backend. + * - Uses localStorage for JWT storage (sent via Authorization header) + * - JWT validation handled server-side + * - No email confirmation flow (auto-confirmed on registration) + */ + +// Auth types +export interface User { + id: string; + email: string; + username: string; + role: string; + enabled?: boolean; + is_anonymous?: boolean; + app_metadata?: Record; +} + +export interface Session { + user: User; + access_token: string; + expires_in: number; + expires_at?: number; +} + +export interface AuthError { + message: string; + status?: number; +} + +export interface AuthResponse { + user: User | null; + session: Session | null; + error: AuthError | null; +} + +export type AuthChangeEvent = + | 'SIGNED_IN' + | 'SIGNED_OUT' + | 'TOKEN_REFRESHED' + | 'USER_UPDATED'; + +type AuthChangeCallback = (event: AuthChangeEvent, session: Session | null) => void; + +class SpringAuthClient { + private listeners: AuthChangeCallback[] = []; + private sessionCheckInterval: NodeJS.Timeout | null = null; + private readonly SESSION_CHECK_INTERVAL = 60000; // 1 minute + private readonly TOKEN_REFRESH_THRESHOLD = 300000; // 5 minutes before expiry + + constructor() { + // Start periodic session validation + this.startSessionMonitoring(); + } + + /** + * Helper to get CSRF token from cookie + */ + private getCsrfToken(): string | null { + const cookies = document.cookie.split(';'); + for (const cookie of cookies) { + const [name, value] = cookie.trim().split('='); + if (name === 'XSRF-TOKEN') { + return value; + } + } + return null; + } + + /** + * Get current session + * JWT is stored in localStorage and sent via Authorization header + */ + async getSession(): Promise<{ data: { session: Session | null }; error: AuthError | null }> { + try { + // Get JWT from localStorage + const token = localStorage.getItem('stirling_jwt'); + + if (!token) { + console.debug('[SpringAuth] getSession: No JWT in localStorage'); + return { data: { session: null }, error: null }; + } + + // Verify with backend + const response = await fetch('/api/v1/auth/me', { + headers: { + 'Authorization': `Bearer ${token}`, + }, + }); + + if (!response.ok) { + // Token invalid or expired - clear it + localStorage.removeItem('stirling_jwt'); + console.debug('[SpringAuth] getSession: Not authenticated (status:', response.status, ')'); + return { data: { session: null }, error: null }; + } + + const data = await response.json(); + + // Create session object + const session: Session = { + user: data.user, + access_token: token, + expires_in: 3600, + expires_at: Date.now() + 3600 * 1000, + }; + + console.debug('[SpringAuth] getSession: Session retrieved successfully'); + return { data: { session }, error: null }; + } catch (error) { + console.error('[SpringAuth] getSession error:', error); + // Clear potentially invalid token + localStorage.removeItem('stirling_jwt'); + return { + data: { session: null }, + error: { message: error instanceof Error ? error.message : 'Unknown error' }, + }; + } + } + + /** + * Sign in with email and password + */ + async signInWithPassword(credentials: { + email: string; + password: string; + }): Promise { + try { + const response = await fetch('/api/v1/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', // Include cookies for CSRF + body: JSON.stringify({ + username: credentials.email, + password: credentials.password + }), + }); + + if (!response.ok) { + const error = await response.json(); + return { user: null, session: null, error: { message: error.error || 'Login failed' } }; + } + + const data = await response.json(); + const token = data.session.access_token; + + // Store JWT in localStorage + localStorage.setItem('stirling_jwt', token); + console.log('[SpringAuth] JWT stored in localStorage'); + + const session: Session = { + user: data.user, + access_token: token, + expires_in: data.session.expires_in, + expires_at: Date.now() + data.session.expires_in * 1000, + }; + + // Notify listeners + this.notifyListeners('SIGNED_IN', session); + + return { user: data.user, session, error: null }; + } catch (error) { + console.error('[SpringAuth] signInWithPassword error:', error); + return { + user: null, + session: null, + error: { message: error instanceof Error ? error.message : 'Login failed' }, + }; + } + } + + /** + * Sign up new user + */ + async signUp(credentials: { + email: string; + password: string; + options?: { data?: { full_name?: string }; emailRedirectTo?: string }; + }): Promise { + try { + const response = await fetch('/api/v1/user/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ + username: credentials.email, + password: credentials.password, + }), + }); + + if (!response.ok) { + const error = await response.json(); + return { user: null, session: null, error: { message: error.error || 'Registration failed' } }; + } + + const data = await response.json(); + + // Note: Spring backend auto-confirms users (no email verification) + // Return user but no session (user needs to login) + return { user: data.user, session: null, error: null }; + } catch (error) { + console.error('[SpringAuth] signUp error:', error); + return { + user: null, + session: null, + error: { message: error instanceof Error ? error.message : 'Registration failed' }, + }; + } + } + + /** + * Sign in with OAuth provider (GitHub, Google, etc.) + * This redirects to the Spring OAuth2 authorization endpoint + */ + async signInWithOAuth(params: { + provider: 'github' | 'google' | 'apple' | 'azure'; + options?: { redirectTo?: string; queryParams?: Record }; + }): Promise<{ error: AuthError | null }> { + try { + // Redirect to Spring OAuth2 endpoint (Vite will proxy to backend) + const redirectUrl = `/oauth2/authorization/${params.provider}`; + console.log('[SpringAuth] Redirecting to OAuth:', redirectUrl); + // Use window.location.assign for full page navigation + window.location.assign(redirectUrl); + return { error: null }; + } catch (error) { + return { + error: { message: error instanceof Error ? error.message : 'OAuth redirect failed' }, + }; + } + } + + /** + * Sign out + */ + async signOut(): Promise<{ error: AuthError | null }> { + try { + // Clear JWT from localStorage immediately + localStorage.removeItem('stirling_jwt'); + console.log('[SpringAuth] JWT removed from localStorage'); + + const csrfToken = this.getCsrfToken(); + const headers: HeadersInit = {}; + + if (csrfToken) { + headers['X-XSRF-TOKEN'] = csrfToken; + } + + // Notify backend (optional - mainly for session cleanup) + await fetch('/api/v1/auth/logout', { + method: 'POST', + credentials: 'include', + headers, + }); + + // Notify listeners + this.notifyListeners('SIGNED_OUT', null); + + return { error: null }; + } catch (error) { + console.error('[SpringAuth] signOut error:', error); + // Still remove token even if backend call fails + localStorage.removeItem('stirling_jwt'); + return { + error: { message: error instanceof Error ? error.message : 'Sign out failed' }, + }; + } + } + + /** + * Refresh session token + */ + async refreshSession(): Promise<{ data: { session: Session | null }; error: AuthError | null }> { + try { + const currentToken = localStorage.getItem('stirling_jwt'); + + if (!currentToken) { + return { data: { session: null }, error: { message: 'No token to refresh' } }; + } + + const response = await fetch('/api/v1/auth/refresh', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${currentToken}`, + }, + }); + + if (!response.ok) { + localStorage.removeItem('stirling_jwt'); + return { data: { session: null }, error: { message: 'Token refresh failed' } }; + } + + const refreshData = await response.json(); + const newToken = refreshData.access_token; + + // Store new token + localStorage.setItem('stirling_jwt', newToken); + + // Get updated user info + const userResponse = await fetch('/api/v1/auth/me', { + headers: { + 'Authorization': `Bearer ${newToken}`, + }, + }); + + if (!userResponse.ok) { + localStorage.removeItem('stirling_jwt'); + return { data: { session: null }, error: { message: 'Failed to get user info' } }; + } + + const userData = await userResponse.json(); + const session: Session = { + user: userData.user, + access_token: newToken, + expires_in: 3600, + expires_at: Date.now() + 3600 * 1000, + }; + + // Notify listeners + this.notifyListeners('TOKEN_REFRESHED', session); + + return { data: { session }, error: null }; + } catch (error) { + console.error('[SpringAuth] refreshSession error:', error); + localStorage.removeItem('stirling_jwt'); + return { + data: { session: null }, + error: { message: error instanceof Error ? error.message : 'Refresh failed' }, + }; + } + } + + /** + * Listen to auth state changes + */ + onAuthStateChange(callback: AuthChangeCallback): { data: { subscription: { unsubscribe: () => void } } } { + this.listeners.push(callback); + + return { + data: { + subscription: { + unsubscribe: () => { + this.listeners = this.listeners.filter((cb) => cb !== callback); + }, + }, + }, + }; + } + + // Private helper methods + + private notifyListeners(event: AuthChangeEvent, session: Session | null) { + // Use setTimeout to avoid calling callbacks synchronously + setTimeout(() => { + this.listeners.forEach((callback) => { + try { + callback(event, session); + } catch (error) { + console.error('[SpringAuth] Error in auth state change listener:', error); + } + }); + }, 0); + } + + private startSessionMonitoring() { + // Periodically check session validity + // Since we use HttpOnly cookies, we just need to check with the server + this.sessionCheckInterval = setInterval(async () => { + try { + // Try to get current session + const { data } = await this.getSession(); + + // If we have a session, proactively refresh if needed + // (The server will handle token expiry, but we can be proactive) + if (data.session) { + const timeUntilExpiry = (data.session.expires_at || 0) - Date.now(); + + // Refresh if token expires soon + if (timeUntilExpiry > 0 && timeUntilExpiry < this.TOKEN_REFRESH_THRESHOLD) { + console.log('[SpringAuth] Proactively refreshing token'); + await this.refreshSession(); + } + } + } catch (error) { + console.error('[SpringAuth] Session monitoring error:', error); + } + }, this.SESSION_CHECK_INTERVAL); + } + + public destroy() { + if (this.sessionCheckInterval) { + clearInterval(this.sessionCheckInterval); + } + } +} + +export const springAuth = new SpringAuthClient(); + +/** + * Get current user + */ +export const getCurrentUser = async () => { + const { data } = await springAuth.getSession(); + return data.session?.user || null; +}; + +/** + * Check if user is anonymous + */ +export const isUserAnonymous = (user: User | null) => { + return user?.is_anonymous === true; +}; + +/** + * Create an anonymous user object for use when login is disabled + * This provides a consistent User interface throughout the app + */ +export const createAnonymousUser = (): User => { + return { + id: 'anonymous', + email: 'anonymous@local', + username: 'Anonymous User', + role: 'USER', + enabled: true, + is_anonymous: true, + app_metadata: { + provider: 'anonymous', + }, + }; +}; + +/** + * Create an anonymous session for use when login is disabled + */ +export const createAnonymousSession = (): Session => { + return { + user: createAnonymousUser(), + access_token: '', + expires_in: Number.MAX_SAFE_INTEGER, + expires_at: Number.MAX_SAFE_INTEGER, + }; +}; + +// Export auth client as default for convenience +export default springAuth; diff --git a/frontend/src/components/fileEditor/FileEditorThumbnail.tsx b/frontend/src/components/fileEditor/FileEditorThumbnail.tsx index 5f14779a6..7f6bf0950 100644 --- a/frontend/src/components/fileEditor/FileEditorThumbnail.tsx +++ b/frontend/src/components/fileEditor/FileEditorThumbnail.tsx @@ -247,6 +247,7 @@ const FileEditorThumbnail = ({ ref={fileElementRef} data-file-id={file.id} data-testid="file-thumbnail" + data-tour="file-card-checkbox" data-selected={isSelected} data-supported={isSupported} className={`${styles.card} w-[18rem] h-[22rem] select-none flex flex-col shadow-sm transition-all relative`} @@ -293,11 +294,12 @@ const FileEditorThumbnail = ({ {/* Action buttons group */}
{/* Pin/Unpin icon */} - + { e.stopPropagation(); if (actualFile) { diff --git a/frontend/src/components/fileManager/DesktopLayout.tsx b/frontend/src/components/fileManager/DesktopLayout.tsx index 8d1e32ffc..78f90a97a 100644 --- a/frontend/src/components/fileManager/DesktopLayout.tsx +++ b/frontend/src/components/fileManager/DesktopLayout.tsx @@ -23,7 +23,7 @@ const DesktopLayout: React.FC = () => { width: '13.625rem', flexShrink: 0, height: '100%', - }}> + }} data-tour="file-sources"> diff --git a/frontend/src/components/layout/Workbench.tsx b/frontend/src/components/layout/Workbench.tsx index f6eabfb2b..f5e3ad637 100644 --- a/frontend/src/components/layout/Workbench.tsx +++ b/frontend/src/components/layout/Workbench.tsx @@ -158,6 +158,7 @@ export default function Workbench() { return ( { + const wasClosedNowOpen = !previousIsOpenRef.current && isOpen; + previousIsOpenRef.current = isOpen; + + if (wasClosedNowOpen) { + // Tour is being opened (Help button pressed), reset to first step + setCurrentStep(0); + } + setIsOpen(isOpen); + }, [isOpen, setIsOpen, setCurrentStep]); + + return null; +} + +export default function OnboardingTour() { + const { t } = useTranslation(); + const { completeTour, showWelcomeModal, setShowWelcomeModal, startTour } = useOnboarding(); + const { openFilesModal, closeFilesModal } = useFilesModalContext(); + const { + saveWorkbenchState, + restoreWorkbenchState, + backToAllTools, + selectCropTool, + loadSampleFile, + switchToViewer, + switchToPageEditor, + switchToActiveFiles, + selectFirstFile, + pinFile, + modifyCropSettings, + executeTool, + } = useTourOrchestration(); + + // Define steps as object keyed by enum - TypeScript ensures all keys are present + const stepsConfig: Record = { + [TourStep.ALL_TOOLS]: { + selector: '[data-tour="tool-panel"]', + content: t('onboarding.allTools', 'This is the All Tools panel, where you can browse and select from all available PDF tools.'), + position: 'center', + padding: 0, + action: () => { + saveWorkbenchState(); + closeFilesModal(); + backToAllTools(); + }, + }, + [TourStep.SELECT_CROP_TOOL]: { + selector: '[data-tour="tool-button-crop"]', + content: t('onboarding.selectCropTool', "Let's select the Crop tool to demonstrate how to use one of the tools."), + position: 'right', + padding: 0, + actionAfter: () => selectCropTool(), + }, + [TourStep.TOOL_INTERFACE]: { + selector: '[data-tour="tool-panel"]', + content: t('onboarding.toolInterface', "This is the Crop tool interface. As you can see, there's not much there because we haven't added any PDF files to work with yet."), + position: 'center', + padding: 0, + }, + [TourStep.FILES_BUTTON]: { + selector: '[data-tour="files-button"]', + content: t('onboarding.filesButton', "The Files button on the Quick Access bar allows you to upload PDFs to use the tools on."), + position: 'right', + padding: 10, + action: () => openFilesModal(), + }, + [TourStep.FILE_SOURCES]: { + selector: '[data-tour="file-sources"]', + content: t('onboarding.fileSources', "You can upload new files or access recent files from here. For the tour, we'll just use a sample file."), + position: 'right', + padding: 0, + actionAfter: () => { + loadSampleFile(); + closeFilesModal(); + } + }, + [TourStep.WORKBENCH]: { + selector: '[data-tour="workbench"]', + content: t('onboarding.workbench', 'This is the Workbench - the main area where you view and edit your PDFs.'), + position: 'center', + padding: 0, + }, + [TourStep.VIEW_SWITCHER]: { + selector: '[data-tour="view-switcher"]', + content: t('onboarding.viewSwitcher', 'Use these controls to select how you want to view your PDFs.'), + position: 'bottom', + padding: 0, + }, + [TourStep.VIEWER]: { + selector: '[data-tour="workbench"]', + content: t('onboarding.viewer', "The Viewer lets you read and annotate your PDFs."), + position: 'center', + padding: 0, + action: () => switchToViewer(), + }, + [TourStep.PAGE_EDITOR]: { + selector: '[data-tour="workbench"]', + content: t('onboarding.pageEditor', "The Page Editor allows you to do various operations on the pages within your PDFs, such as reordering, rotating and deleting."), + position: 'center', + padding: 0, + action: () => switchToPageEditor(), + }, + [TourStep.ACTIVE_FILES]: { + selector: '[data-tour="workbench"]', + content: t('onboarding.activeFiles', "The Active Files view shows all of the PDFs you have loaded into the tool, and allows you to select which ones to process."), + position: 'center', + padding: 0, + action: () => switchToActiveFiles(), + }, + [TourStep.FILE_CHECKBOX]: { + selector: '[data-tour="file-card-checkbox"]', + content: t('onboarding.fileCheckbox', "Clicking one of the files selects it for processing. You can select multiple files for batch operations."), + position: 'top', + padding: 10, + }, + [TourStep.SELECT_CONTROLS]: { + selector: '[data-tour="right-rail-controls"]', + highlightedSelectors: ['[data-tour="right-rail-controls"]', '[data-tour="right-rail-settings"]'], + content: t('onboarding.selectControls', "The Right Rail contains buttons to quickly select/deselect all of your active PDFs, along with buttons to change the app's theme or language."), + position: 'left', + padding: 5, + action: () => selectFirstFile(), + }, + [TourStep.CROP_SETTINGS]: { + selector: '[data-tour="crop-settings"]', + content: t('onboarding.cropSettings', "Now that we've selected the file we want crop, we can configure the Crop tool to choose the area that we want to crop the PDF to."), + position: 'left', + padding: 10, + action: () => modifyCropSettings(), + }, + [TourStep.RUN_BUTTON]: { + selector: '[data-tour="run-button"]', + content: t('onboarding.runButton', "Once the tool has been configured, this button allows you to run the tool on all the selected PDFs."), + position: 'top', + padding: 10, + actionAfter: () => executeTool(), + }, + [TourStep.RESULTS]: { + selector: '[data-tour="tool-panel"]', + content: t('onboarding.results', "After the tool has finished running, the Review step will show a preview of the results in this panel, and allow you to undo the operation or download the file. "), + position: 'center', + padding: 0, + }, + [TourStep.FILE_REPLACEMENT]: { + selector: '[data-tour="file-card-checkbox"]', + content: t('onboarding.fileReplacement', "The modified file will replace the original file in the Workbench automatically, allowing you to easily run it through more tools."), + position: 'left', + padding: 10, + }, + [TourStep.PIN_BUTTON]: { + selector: '[data-tour="file-card-pin"]', + content: t('onboarding.pinButton', "You can use the Pin button if you'd rather your files stay active after running tools on them."), + position: 'left', + padding: 10, + action: () => pinFile(), + }, + [TourStep.WRAP_UP]: { + selector: '[data-tour="help-button"]', + content: t('onboarding.wrapUp', "You're all set! You've learnt about the main areas of the app and how to use them. Click the Help button whenever you like to see this tour again."), + position: 'right', + padding: 10, + }, + }; + + // Convert to array using enum's numeric ordering + const steps = Object.values(stepsConfig); + + const advanceTour = ({ setCurrentStep, currentStep, steps, setIsOpen }: { + setCurrentStep: (value: number | ((prev: number) => number)) => void; + currentStep: number; + steps?: StepType[]; + setIsOpen: (value: boolean) => void; + }) => { + if (steps && currentStep === steps.length - 1) { + setIsOpen(false); + restoreWorkbenchState(); + completeTour(); + } else if (steps) { + setCurrentStep((s) => (s === steps.length - 1 ? 0 : s + 1)); + } + }; + + const handleCloseTour = ({ setIsOpen }: { setIsOpen: (value: boolean) => void }) => { + setIsOpen(false); + restoreWorkbenchState(); + completeTour(); + }; + + return ( + <> + { + setShowWelcomeModal(false); + startTour(); + }} + onMaybeLater={() => { + setShowWelcomeModal(false); + }} + onDontShowAgain={() => { + setShowWelcomeModal(false); + completeTour(); + }} + /> + { + e.stopPropagation(); + advanceTour(clickProps); + }} + keyboardHandler={(e, clickProps, status) => { + // Handle right arrow key to advance tour + if (e.key === 'ArrowRight' && !status?.isRightDisabled && clickProps) { + e.preventDefault(); + advanceTour(clickProps); + } + // Handle escape key to close tour + else if (e.key === 'Escape' && !status?.isEscDisabled && clickProps) { + e.preventDefault(); + handleCloseTour(clickProps); + } + }} + styles={{ + popover: (base) => ({ + ...base, + backgroundColor: 'var(--mantine-color-body)', + color: 'var(--mantine-color-text)', + borderRadius: '8px', + padding: '20px', + boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)', + maxWidth: '400px', + }), + maskArea: (base) => ({ + ...base, + rx: 8, + }), + badge: (base) => ({ + ...base, + backgroundColor: 'var(--mantine-primary-color-filled)', + }), + controls: (base) => ({ + ...base, + justifyContent: 'center', + }), + }} + highlightedMaskClassName="tour-highlight-glow" + showNavigation={true} + showBadge={false} + showCloseButton={true} + disableInteraction={true} + disableDotsNavigation={true} + prevButton={() => null} + nextButton={({ currentStep, stepsLength, setCurrentStep, setIsOpen }) => { + const isLast = currentStep === stepsLength - 1; + + return ( + { + advanceTour({ setCurrentStep, currentStep, steps, setIsOpen }); + }} + variant="subtle" + size="lg" + aria-label={isLast ? t('onboarding.finish', 'Finish') : t('onboarding.next', 'Next')} + > + {isLast ? : } + + ); + }} + components={{ + Close: ({ onClick }) => ( + + ), + Content: ({ content } : {content: string}) => ( +
+ ), + }} + > + + + + ); +} diff --git a/frontend/src/components/onboarding/TourWelcomeModal.tsx b/frontend/src/components/onboarding/TourWelcomeModal.tsx new file mode 100644 index 000000000..82aeaf26a --- /dev/null +++ b/frontend/src/components/onboarding/TourWelcomeModal.tsx @@ -0,0 +1,82 @@ +import { Modal, Title, Text, Button, Stack, Group } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '../../styles/zIndex'; + +interface TourWelcomeModalProps { + opened: boolean; + onStartTour: () => void; + onMaybeLater: () => void; + onDontShowAgain: () => void; +} + +export default function TourWelcomeModal({ + opened, + onStartTour, + onMaybeLater, + onDontShowAgain, +}: TourWelcomeModalProps) { + const { t } = useTranslation(); + + return ( + + + + + {t('onboarding.welcomeModal.title', 'Welcome to Stirling PDF!')} + + + {t('onboarding.welcomeModal.description', + "Would you like to take a quick 1-minute tour to learn the key features and how to get started?" + )} + + Help button in the bottom left.' + ) + }} + /> + + + + + + + + + + + + + + ); +} diff --git a/frontend/src/components/shared/AppConfigModal.tsx b/frontend/src/components/shared/AppConfigModal.tsx index 160547180..eb7125b72 100644 --- a/frontend/src/components/shared/AppConfigModal.tsx +++ b/frontend/src/components/shared/AppConfigModal.tsx @@ -155,4 +155,4 @@ const AppConfigModal: React.FC = ({ opened, onClose }) => { ); }; -export default AppConfigModal; \ No newline at end of file +export default AppConfigModal; diff --git a/frontend/src/components/shared/DividerWithText.tsx b/frontend/src/components/shared/DividerWithText.tsx new file mode 100644 index 000000000..9b82240a1 --- /dev/null +++ b/frontend/src/components/shared/DividerWithText.tsx @@ -0,0 +1,36 @@ +import './dividerWithText/DividerWithText.css' + +interface TextDividerProps { + text?: string + className?: string + style?: React.CSSProperties + variant?: 'default' | 'subcategory' + respondsToDarkMode?: boolean + opacity?: number +} + +export default function DividerWithText({ text, className = '', style, variant = 'default', respondsToDarkMode = true, opacity }: TextDividerProps) { + const variantClass = variant === 'subcategory' ? 'subcategory' : '' + const themeClass = respondsToDarkMode ? '' : 'force-light' + const styleWithOpacity = opacity !== undefined ? { ...(style || {}), ['--text-divider-opacity' as any]: opacity } : style + + if (text) { + return ( +
+
+ {text} +
+
+ ) + } + + return ( +
+ ) +} diff --git a/frontend/src/components/shared/FileCard.tsx b/frontend/src/components/shared/FileCard.tsx index 173cfa404..50d43dc06 100644 --- a/frontend/src/components/shared/FileCard.tsx +++ b/frontend/src/components/shared/FileCard.tsx @@ -51,6 +51,7 @@ const FileCard = ({ file, fileStub, onRemove, onDoubleClick, onView, onEdit, isS onMouseLeave={() => setIsHovered(false)} onClick={onSelect} data-testid="file-card" + data-tour="file-card-checkbox" > { + if (imageSlides.length === 0) return [] + return imageSlides.map((_, i) => (i === 0 ? (initialSeconds ?? slideSeconds) : slideSeconds) * 1000) + }, [imageSlides, initialSeconds, slideSeconds]) + + useEffect(() => { + if (totalSlides <= 1) return + const timeout = setTimeout(() => { + setIndex((i) => (i + 1) % totalSlides) + }, durationsMs[index] ?? slideSeconds * 1000) + return () => clearTimeout(timeout) + }, [index, totalSlides, durationsMs, slideSeconds]) + + useEffect(() => { + const onMove = (e: MouseEvent) => { + mouse.current.x = (e.clientX / window.innerWidth) * 2 - 1 + mouse.current.y = (e.clientY / window.innerHeight) * 2 - 1 + } + window.addEventListener('mousemove', onMove) + return () => window.removeEventListener('mousemove', onMove) + }, []) + + function TiltImage({ src, alt, enabled, maxDeg = 6 }: { src: string; alt?: string; enabled: boolean; maxDeg?: number }) { + const imgRef = useRef(null) + + useEffect(() => { + const el = imgRef.current + if (!el) return + + let raf = 0 + const tick = () => { + if (enabled) { + const rotY = (mouse.current.x || 0) * maxDeg + const rotX = -(mouse.current.y || 0) * maxDeg + el.style.transform = `translateY(-2rem) rotateX(${rotX.toFixed(2)}deg) rotateY(${rotY.toFixed(2)}deg)` + } else { + el.style.transform = 'translateY(-2rem)' + } + raf = requestAnimationFrame(tick) + } + raf = requestAnimationFrame(tick) + return () => cancelAnimationFrame(raf) + }, [enabled, maxDeg]) + + return ( + {alt + ) + } + + return ( +
+ {showBackground && ( + Background panel + )} + + {/* Image slides */} + {imageSlides.map((s, idx) => ( +
+ {(s.title || s.subtitle) && ( +
+ {s.title && ( +
{s.title}
+ )} + {s.subtitle && ( +
{s.subtitle}
+ )} +
+ )} + + +
+ ))} + + {/* Dot navigation */} +
+ {Array.from({ length: totalSlides }).map((_, i) => ( +
+
+ ) +} diff --git a/frontend/src/components/shared/QuickAccessBar.tsx b/frontend/src/components/shared/QuickAccessBar.tsx index 7a8dd7fdd..cbed210a2 100644 --- a/frontend/src/components/shared/QuickAccessBar.tsx +++ b/frontend/src/components/shared/QuickAccessBar.tsx @@ -14,6 +14,7 @@ import AllToolsNavButton from './AllToolsNavButton'; import ActiveToolButton from "./quickAccessBar/ActiveToolButton"; import AppConfigModal from './AppConfigModal'; import { useAppConfig } from '../../hooks/useAppConfig'; +import { useOnboarding } from '../../contexts/OnboardingContext'; import { isNavButtonActive, getNavButtonStyle, @@ -27,6 +28,7 @@ const QuickAccessBar = forwardRef((_, ref) => { const { handleReaderToggle, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode, resetTool } = useToolWorkflow(); const { getToolNavigation } = useSidebarNavigation(); const { config } = useAppConfig(); + const { startTour } = useOnboarding(); const [configModalOpen, setConfigModalOpen] = useState(false); const [activeButton, setActiveButton] = useState('tools'); const scrollableRef = useRef(null); @@ -60,7 +62,12 @@ const QuickAccessBar = forwardRef((_, ref) => { // Render navigation button with conditional URL support return ( -
+
((_, ref) => { ); }; - - const buttonConfigs: ButtonConfig[] = [ + const mainButtons: ButtonConfig[] = [ { id: 'read', name: t("quickAccess.read", "Read"), @@ -131,6 +137,9 @@ const QuickAccessBar = forwardRef((_, ref) => { } } }, + ]; + + const middleButtons: ButtonConfig[] = [ { id: 'files', name: t("quickAccess.files", "Files"), @@ -150,6 +159,20 @@ const QuickAccessBar = forwardRef((_, ref) => { // type: 'navigation', // onClick: () => setActiveButton('activity') //}, + ]; + + const bottomButtons: ButtonConfig[] = [ + { + id: 'help', + name: t("quickAccess.help", "Help"), + icon: , + isRound: true, + size: 'lg', + type: 'action', + onClick: () => { + startTour(); + }, + }, { id: 'config', name: config?.enableLogin ? t("quickAccess.account", "Account") : t("quickAccess.config", "Config"), @@ -162,8 +185,6 @@ const QuickAccessBar = forwardRef((_, ref) => { } ]; - - return (
((_, ref) => { }} >
- {/* Top section with main buttons */} + {/* Main navigation section */} - {buttonConfigs.slice(0, -1).map((config, index) => ( + {mainButtons.map((config, index) => ( {renderNavButton(config, index)} - - {/* Add divider after Automate button (index 1) and Files button (index 2) */} - {index === 1 && ( - - )} ))} - {/* Spacer to push Config button to bottom */} + {/* Divider after main buttons */} + + + {/* Middle section */} + + {middleButtons.map((config, index) => ( + + {renderNavButton(config, index)} + + ))} + + + {/* Spacer to push bottom buttons to bottom */}
- {/* Config button at the bottom */} - {buttonConfigs - .filter(config => config.id === 'config') - .map(config => ( -
- - - {config.icon} - - - - {config.name} - -
+ {/* Bottom section */} + + {bottomButtons.map((config, index) => ( + + {renderNavButton(config, index)} + ))} +
diff --git a/frontend/src/components/shared/RightRail.tsx b/frontend/src/components/shared/RightRail.tsx index c8168f373..149aa9eaa 100644 --- a/frontend/src/components/shared/RightRail.tsx +++ b/frontend/src/components/shared/RightRail.tsx @@ -168,7 +168,7 @@ export default function RightRail() {
{sectionsWithButtons.map(({ section, buttons: sectionButtons }) => ( -
+
{sectionButtons.map((btn, index) => { const content = renderButton(btn); if (!content) return null; @@ -186,7 +186,7 @@ export default function RightRail() { ))} -
+
{renderWithTooltip( view.data != null) .map((view) => ({ label: ( @@ -169,6 +169,7 @@ const TopControls = ({
void }>, - onLogoutClick: () => void + onLogoutClick: () => void, ): ConfigNavSection[] => { const sections: ConfigNavSection[] = [ { @@ -61,4 +61,4 @@ export const createConfigNavSections = ( ]; return sections; -}; \ No newline at end of file +}; diff --git a/frontend/src/components/shared/config/configSections/Overview.tsx b/frontend/src/components/shared/config/configSections/Overview.tsx index d3f250f49..b3a192cd0 100644 --- a/frontend/src/components/shared/config/configSections/Overview.tsx +++ b/frontend/src/components/shared/config/configSections/Overview.tsx @@ -1,9 +1,13 @@ import React from 'react'; -import { Stack, Text, Code, Group, Badge, Alert, Loader } from '@mantine/core'; +import { Stack, Text, Code, Group, Badge, Alert, Loader, Button } from '@mantine/core'; import { useAppConfig } from '../../../../hooks/useAppConfig'; +import { useAuth } from '../../../../auth/UseSession'; +import { useNavigate } from 'react-router-dom'; const Overview: React.FC = () => { const { config, loading, error } = useAppConfig(); + const { signOut, user } = useAuth(); + const navigate = useNavigate(); const renderConfigSection = (title: string, data: any) => { if (!data || typeof data !== 'object') return null; @@ -54,6 +58,15 @@ const Overview: React.FC = () => { SSOAutoLogin: config.SSOAutoLogin, } : null; + const handleLogout = async () => { + try { + await signOut(); + navigate('/login'); + } catch (error) { + console.error('Logout error:', error); + } + }; + if (loading) { return ( @@ -74,10 +87,24 @@ const Overview: React.FC = () => { return (
- Application Configuration - - Current application settings and configuration details. - +
+
+ Application Configuration + + Current application settings and configuration details. + + {user?.email && ( + + Signed in as: {user.email} + + )} +
+ {user && ( + + )} +
{config && ( diff --git a/frontend/src/components/shared/dividerWithText/DividerWithText.css b/frontend/src/components/shared/dividerWithText/DividerWithText.css new file mode 100644 index 000000000..ce26b4546 --- /dev/null +++ b/frontend/src/components/shared/dividerWithText/DividerWithText.css @@ -0,0 +1,52 @@ + +.text-divider { + display: flex; + align-items: center; + gap: 0.75rem; /* 12px */ + margin-top: 0.375rem; /* 6px */ + margin-bottom: 0.5rem; /* 8px */ +} + +.text-divider .text-divider__rule { + height: 0.0625rem; /* 1px */ + flex: 1 1 0%; + background-color: rgb(var(--text-divider-rule-rgb, var(--gray-200)) / var(--text-divider-opacity, 1)); +} + +.text-divider .text-divider__label { + color: rgb(var(--text-divider-label-rgb, var(--gray-400)) / var(--text-divider-opacity, 1)); + font-size: 0.75rem; /* 12px */ + white-space: nowrap; +} + +.text-divider.subcategory { + margin-top: 0; + margin-bottom: 0; +} + +.text-divider.subcategory .text-divider__rule { + background-color: var(--tool-subcategory-rule-color); +} + +.text-divider.subcategory .text-divider__label { + color: var(--tool-subcategory-text-color); + text-transform: uppercase; + font-weight: 600; +} + +/* Force light theme colors regardless of dark mode */ +.text-divider.force-light .text-divider__rule { + background-color: rgb(var(--text-divider-rule-rgb-light, var(--gray-200)) / var(--text-divider-opacity, 1)); +} + +.text-divider.force-light .text-divider__label { + color: rgb(var(--text-divider-label-rgb-light, var(--gray-400)) / var(--text-divider-opacity, 1)); +} + +.text-divider.subcategory.force-light .text-divider__rule { + background-color: var(--tool-subcategory-rule-color-light); +} + +.text-divider.subcategory.force-light .text-divider__label { + color: var(--tool-subcategory-text-color-light); +} diff --git a/frontend/src/components/shared/loginSlides.ts b/frontend/src/components/shared/loginSlides.ts new file mode 100644 index 000000000..aa3d7f443 --- /dev/null +++ b/frontend/src/components/shared/loginSlides.ts @@ -0,0 +1,43 @@ +import { BASE_PATH } from '../../constants/app'; + +export type LoginCarouselSlide = { + src: string + alt?: string + title?: string + subtitle?: string + cornerModelUrl?: string + followMouseTilt?: boolean + tiltMaxDeg?: number +} + +export const loginSlides: LoginCarouselSlide[] = [ + { + src: `${BASE_PATH}/Login/Firstpage.png`, + alt: 'Stirling PDF overview', + title: 'Your one-stop-shop for all your PDF needs.', + subtitle: + 'A privacy-first cloud suite for PDFs that lets you convert, sign, redact, and manage documents, along with 50+ other powerful tools.', + followMouseTilt: true, + tiltMaxDeg: 5, + }, + { + src: `${BASE_PATH}/Login/AddToPDF.png`, + alt: 'Edit PDFs', + title: 'Edit PDFs to display/secure the information you want', + subtitle: + 'With over a dozen tools to help you redact, sign, read and manipulate PDFs, you will be sure to find what you are looking for.', + followMouseTilt: true, + tiltMaxDeg: 5, + }, + { + src: `${BASE_PATH}/Login/SecurePDF.png`, + alt: 'Secure PDFs', + title: 'Protect sensitive information in your PDFs', + subtitle: + 'Add passwords, redact content, and manage certificates with ease.', + followMouseTilt: true, + tiltMaxDeg: 5, + }, +] + +export default loginSlides diff --git a/frontend/src/components/shared/quickAccessBar/QuickAccessBar.css b/frontend/src/components/shared/quickAccessBar/QuickAccessBar.css index 05f226417..e2c261b52 100644 --- a/frontend/src/components/shared/quickAccessBar/QuickAccessBar.css +++ b/frontend/src/components/shared/quickAccessBar/QuickAccessBar.css @@ -145,6 +145,7 @@ .content-divider { width: 3.75rem; border-color: var(--color-gray-300); + margin: 1rem 0; } /* Spacer */ diff --git a/frontend/src/components/tools/FullscreenToolSurface.tsx b/frontend/src/components/tools/FullscreenToolSurface.tsx index 69b3c205d..05cf47571 100644 --- a/frontend/src/components/tools/FullscreenToolSurface.tsx +++ b/frontend/src/components/tools/FullscreenToolSurface.tsx @@ -94,6 +94,7 @@ const FullscreenToolSurface = ({ style={style} role="region" aria-label={t('toolPanel.fullscreen.heading', 'All tools (fullscreen view)')} + data-tour="tool-panel" >
{!isMobile && leftPanelView === 'toolPicker' && ( - {t('addPageNumbers.pagesAndStarting', 'Pages & Starting Number')} - + void; onComplete: (automation: AutomationConfig) => void; - toolRegistry: ToolRegistry; + toolRegistry: Partial; } export default function AutomationCreation({ mode, existingAutomation, onBack, onComplete, toolRegistry }: AutomationCreationProps) { diff --git a/frontend/src/components/tools/automate/AutomationEntry.tsx b/frontend/src/components/tools/automate/AutomationEntry.tsx index 57f480f1f..f29a63a18 100644 --- a/frontend/src/components/tools/automate/AutomationEntry.tsx +++ b/frontend/src/components/tools/automate/AutomationEntry.tsx @@ -7,7 +7,7 @@ import DeleteIcon from '@mui/icons-material/Delete'; import ContentCopyIcon from '@mui/icons-material/ContentCopy'; import { Tooltip } from '../../shared/Tooltip'; import { ToolIcon } from '../../shared/ToolIcon'; -import { ToolRegistryEntry } from '../../../data/toolsTaxonomy'; +import { ToolRegistry } from '../../../data/toolsTaxonomy'; import { ToolId } from 'src/types/toolId'; interface AutomationEntryProps { @@ -32,7 +32,7 @@ interface AutomationEntryProps { /** Copy handler (for suggested automations) */ onCopy?: () => void; /** Tool registry to resolve operation names */ - toolRegistry?: Record; + toolRegistry?: Partial; } export default function AutomationEntry({ @@ -56,8 +56,9 @@ export default function AutomationEntry({ // Helper function to resolve tool display names const getToolDisplayName = (operation: string): string => { - if (toolRegistry?.[operation as ToolId]?.name) { - return toolRegistry[operation as ToolId].name; + const entry = toolRegistry?.[operation as ToolId]; + if (entry?.name) { + return entry.name; } // Fallback to translation or operation key return t(`${operation}.title`, operation); diff --git a/frontend/src/components/tools/automate/AutomationRun.tsx b/frontend/src/components/tools/automate/AutomationRun.tsx index d25008a3a..105a8981a 100644 --- a/frontend/src/components/tools/automate/AutomationRun.tsx +++ b/frontend/src/components/tools/automate/AutomationRun.tsx @@ -4,7 +4,7 @@ import { Button, Text, Stack, Group, Card, Progress } from "@mantine/core"; import PlayArrowIcon from "@mui/icons-material/PlayArrow"; import CheckIcon from "@mui/icons-material/Check"; import { useFileSelection } from "../../../contexts/FileContext"; -import { useFlatToolRegistry } from "../../../data/useTranslatedToolRegistry"; +import { useToolRegistry } from "../../../contexts/ToolRegistryContext"; import { AutomationConfig, ExecutionStep } from "../../../types/automation"; import { AUTOMATION_CONSTANTS, EXECUTION_STATUS } from "../../../constants/automation"; import { useResourceCleanup } from "../../../utils/resourceManager"; @@ -18,7 +18,8 @@ interface AutomationRunProps { export default function AutomationRun({ automation, onComplete, automateOperation }: AutomationRunProps) { const { t } = useTranslation(); const { selectedFiles } = useFileSelection(); - const toolRegistry = useFlatToolRegistry(); + const { regularTools } = useToolRegistry(); + const toolRegistry = regularTools; const cleanup = useResourceCleanup(); // Progress tracking state diff --git a/frontend/src/components/tools/automate/AutomationSelection.tsx b/frontend/src/components/tools/automate/AutomationSelection.tsx index edbec569a..9c3a637dc 100644 --- a/frontend/src/components/tools/automate/AutomationSelection.tsx +++ b/frontend/src/components/tools/automate/AutomationSelection.tsx @@ -6,8 +6,7 @@ import AutomationEntry from "./AutomationEntry"; import { useSuggestedAutomations } from "../../../hooks/tools/automate/useSuggestedAutomations"; import { AutomationConfig, SuggestedAutomation } from "../../../types/automation"; import { iconMap } from './iconMap'; -import { ToolRegistryEntry } from '../../../data/toolsTaxonomy'; -import { ToolId } from '../../../types/toolId'; +import { ToolRegistry } from '../../../data/toolsTaxonomy'; interface AutomationSelectionProps { savedAutomations: AutomationConfig[]; @@ -16,7 +15,7 @@ interface AutomationSelectionProps { onEdit: (automation: AutomationConfig) => void; onDelete: (automation: AutomationConfig) => void; onCopyFromSuggested: (automation: SuggestedAutomation) => void; - toolRegistry: Record; + toolRegistry: Partial; } export default function AutomationSelection({ diff --git a/frontend/src/components/tools/automate/ToolConfigurationModal.tsx b/frontend/src/components/tools/automate/ToolConfigurationModal.tsx index 271fd91a1..2185d0be7 100644 --- a/frontend/src/components/tools/automate/ToolConfigurationModal.tsx +++ b/frontend/src/components/tools/automate/ToolConfigurationModal.tsx @@ -15,6 +15,7 @@ import CheckIcon from '@mui/icons-material/Check'; import CloseIcon from '@mui/icons-material/Close'; import WarningIcon from '@mui/icons-material/Warning'; import { ToolRegistry } from '../../../data/toolsTaxonomy'; +import { ToolId } from '../../../types/toolId'; import { getAvailableToExtensions } from '../../../utils/convertUtils'; interface ToolConfigurationModalProps { opened: boolean; @@ -26,7 +27,7 @@ interface ToolConfigurationModalProps { }; onSave: (parameters: any) => void; onCancel: () => void; - toolRegistry: ToolRegistry; + toolRegistry: Partial; } export default function ToolConfigurationModal({ opened, tool, onSave, onCancel, toolRegistry }: ToolConfigurationModalProps) { @@ -35,7 +36,7 @@ export default function ToolConfigurationModal({ opened, tool, onSave, onCancel, const [parameters, setParameters] = useState({}); // Get tool info from registry - const toolInfo = toolRegistry[tool.operation as keyof ToolRegistry]; + const toolInfo = toolRegistry[tool.operation as ToolId]; const SettingsComponent = toolInfo?.automationSettings; // Initialize parameters from tool (which should contain defaults from registry) diff --git a/frontend/src/components/tools/automate/ToolList.tsx b/frontend/src/components/tools/automate/ToolList.tsx index bde0625f8..a0215c1f2 100644 --- a/frontend/src/components/tools/automate/ToolList.tsx +++ b/frontend/src/components/tools/automate/ToolList.tsx @@ -5,14 +5,14 @@ import SettingsIcon from "@mui/icons-material/Settings"; import CloseIcon from "@mui/icons-material/Close"; import AddCircleOutline from "@mui/icons-material/AddCircleOutline"; import { AutomationTool } from "../../../types/automation"; -import { ToolRegistryEntry } from "../../../data/toolsTaxonomy"; +import { ToolRegistry } from "../../../data/toolsTaxonomy"; import { ToolId } from "../../../types/toolId"; import ToolSelector from "./ToolSelector"; import AutomationEntry from "./AutomationEntry"; interface ToolListProps { tools: AutomationTool[]; - toolRegistry: Record; + toolRegistry: Partial; onToolUpdate: (index: number, updates: Partial) => void; onToolRemove: (index: number) => void; onToolConfigure: (index: number) => void; diff --git a/frontend/src/components/tools/automate/ToolSelector.tsx b/frontend/src/components/tools/automate/ToolSelector.tsx index bbf8e95f8..6958ca7d4 100644 --- a/frontend/src/components/tools/automate/ToolSelector.tsx +++ b/frontend/src/components/tools/automate/ToolSelector.tsx @@ -1,7 +1,7 @@ import { useState, useMemo, useCallback, useRef, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { Stack, Text, ScrollArea } from '@mantine/core'; -import { ToolRegistryEntry, getToolSupportsAutomate } from '../../../data/toolsTaxonomy'; +import { ToolRegistryEntry, ToolRegistry, getToolSupportsAutomate } from '../../../data/toolsTaxonomy'; import { useToolSections } from '../../../hooks/useToolSections'; import { renderToolButtons } from '../shared/renderToolButtons'; import ToolSearch from '../toolPicker/ToolSearch'; @@ -11,7 +11,7 @@ import { ToolId } from '../../../types/toolId'; interface ToolSelectorProps { onSelect: (toolKey: string) => void; excludeTools?: string[]; - toolRegistry: Record; // Pass registry as prop to break circular dependency + toolRegistry: Partial; // Pass registry as prop to break circular dependency selectedValue?: string; // For showing current selection when editing existing tool placeholder?: string; // Custom placeholder text } @@ -54,7 +54,7 @@ export default function ToolSelector({ // Create filtered tool registry for ToolSearch const filteredToolRegistry = useMemo(() => { - const registry: Record = {} as Record; + const registry: Partial = {}; baseFilteredTools.forEach(([key, tool]) => { registry[key as ToolId] = tool; }); @@ -142,10 +142,10 @@ export default function ToolSelector({ }; // Get display value for selected tool + const selectedTool = selectedValue ? toolRegistry[selectedValue as ToolId] : undefined; + const getDisplayValue = () => { - if (selectedValue && toolRegistry[selectedValue as ToolId]) { - return toolRegistry[selectedValue as ToolId].name; - } + if (selectedTool) return selectedTool.name; return placeholder || t('automate.creation.tools.add', 'Add a tool...'); }; @@ -153,12 +153,18 @@ export default function ToolSelector({
{/* Always show the target - either selected tool or search input */} - {selectedValue && toolRegistry[selectedValue as ToolId] && !opened ? ( + {selectedTool && !opened ? ( // Show selected tool in AutomationEntry style when tool is selected and dropdown closed
- {}} rounded={true} disableNavigation={true}> + {}} + rounded={true} + disableNavigation={true} + />
) : ( // Show search input when no tool selected OR when dropdown is opened diff --git a/frontend/src/components/tools/crop/CropAreaSelector.tsx b/frontend/src/components/tools/crop/CropAreaSelector.tsx index 75d326210..03d316c93 100644 --- a/frontend/src/components/tools/crop/CropAreaSelector.tsx +++ b/frontend/src/components/tools/crop/CropAreaSelector.tsx @@ -172,7 +172,8 @@ const CropAreaSelector: React.FC = ({ border: `2px solid ${theme.other.crop.overlayBorder}`, backgroundColor: theme.other.crop.overlayBackground, cursor: 'move', - pointerEvents: 'auto' + pointerEvents: 'auto', + transition: (isDragging || isResizing) ? undefined : 'all 1s ease-in-out' }} onMouseDown={handleOverlayMouseDown} > diff --git a/frontend/src/components/tools/crop/CropCoordinateInputs.tsx b/frontend/src/components/tools/crop/CropCoordinateInputs.tsx index 72c1e3d79..8607e3b94 100644 --- a/frontend/src/components/tools/crop/CropCoordinateInputs.tsx +++ b/frontend/src/components/tools/crop/CropCoordinateInputs.tsx @@ -35,7 +35,7 @@ const CropCoordinateInputs = ({ onCoordinateChange('x', value)} @@ -47,7 +47,7 @@ const CropCoordinateInputs = ({ size={showAutomationInfo ? "sm" : "xs"} /> onCoordinateChange('y', value)} @@ -62,7 +62,7 @@ const CropCoordinateInputs = ({ onCoordinateChange('width', value)} @@ -74,7 +74,7 @@ const CropCoordinateInputs = ({ size={showAutomationInfo ? "sm" : "xs"} /> onCoordinateChange('height', value)} diff --git a/frontend/src/components/tools/crop/CropSettings.tsx b/frontend/src/components/tools/crop/CropSettings.tsx index 624ab3b88..9643b39ae 100644 --- a/frontend/src/components/tools/crop/CropSettings.tsx +++ b/frontend/src/components/tools/crop/CropSettings.tsx @@ -93,6 +93,19 @@ const CropSettings = ({ parameters, disabled = false }: CropSettingsProps) => { loadPDFDimensions(); }, [selectedStub, selectedFile, parameters]); + // Listen for tour events to set crop area + useEffect(() => { + const handleSetCropArea = (event: Event) => { + const customEvent = event as CustomEvent; + if (customEvent.detail && pdfBounds) { + parameters.setCropArea(customEvent.detail, pdfBounds); + } + }; + + window.addEventListener('tour:setCropArea', handleSetCropArea); + return () => window.removeEventListener('tour:setCropArea', handleSetCropArea); + }, [parameters, pdfBounds]); + // Current crop area const cropArea = parameters.getCropArea(); @@ -137,7 +150,7 @@ const CropSettings = ({ parameters, disabled = false }: CropSettingsProps) => { const isFullCrop = parameters.isFullPDFCrop(pdfBounds); return ( - + {/* PDF Preview with Crop Selector */} diff --git a/frontend/src/components/tools/fullscreen/CompactToolItem.tsx b/frontend/src/components/tools/fullscreen/CompactToolItem.tsx index 896d70439..31323ee81 100644 --- a/frontend/src/components/tools/fullscreen/CompactToolItem.tsx +++ b/frontend/src/components/tools/fullscreen/CompactToolItem.tsx @@ -42,6 +42,7 @@ const CompactToolItem: React.FC = ({ id, tool, isSelected, onClick={onClick} aria-disabled={disabled} disabled={disabled} + data-tour={`tool-button-${id}`} > {tool.icon ? ( = ({ id, tool, isSelecte onClick={onClick} aria-disabled={disabled} disabled={disabled} + data-tour={`tool-button-${id}`} > {tool.icon ? ( { const { t } = useTranslation(); @@ -43,6 +45,7 @@ const OperationButton = ({ variant={variant} color={color} data-testid={dataTestId} + data-tour={dataTour} style={{ minHeight: '2.5rem' }} > {isLoading diff --git a/frontend/src/components/tools/shared/createToolFlow.tsx b/frontend/src/components/tools/shared/createToolFlow.tsx index f32a433da..de13514ee 100644 --- a/frontend/src/components/tools/shared/createToolFlow.tsx +++ b/frontend/src/components/tools/shared/createToolFlow.tsx @@ -108,6 +108,7 @@ export function createToolFlow(config: ToolFlowConfig) { loadingText={config.executeButton.loadingText} submitText={config.executeButton.text} data-testid={config.executeButton.testId} + data-tour="run-button" /> )} diff --git a/frontend/src/components/tools/toolPicker/ToolButton.tsx b/frontend/src/components/tools/toolPicker/ToolButton.tsx index 72affa2d0..fc3963128 100644 --- a/frontend/src/components/tools/toolPicker/ToolButton.tsx +++ b/frontend/src/components/tools/toolPicker/ToolButton.tsx @@ -112,10 +112,11 @@ const ToolButton: React.FC = ({ id, tool, isSelected, onSelect, fullWidth justify="flex-start" className="tool-button" - styles={{ - root: { - borderRadius: 0, - color: "var(--tools-text-and-icon-color)", + data-tour={`tool-button-${id}`} + styles={{ + root: { + borderRadius: 0, + color: "var(--tools-text-and-icon-color)", overflow: 'visible' }, label: { overflow: 'visible' } @@ -137,10 +138,11 @@ const ToolButton: React.FC = ({ id, tool, isSelected, onSelect, fullWidth justify="flex-start" className="tool-button" - styles={{ - root: { - borderRadius: 0, - color: "var(--tools-text-and-icon-color)", + data-tour={`tool-button-${id}`} + styles={{ + root: { + borderRadius: 0, + color: "var(--tools-text-and-icon-color)", overflow: 'visible' }, label: { overflow: 'visible' } @@ -159,14 +161,15 @@ const ToolButton: React.FC = ({ id, tool, isSelected, onSelect, justify="flex-start" className="tool-button" aria-disabled={isUnavailable} + data-tour={`tool-button-${id}`} styles={{ - root: { - borderRadius: 0, - color: "var(--tools-text-and-icon-color)", - cursor: isUnavailable ? 'not-allowed' : undefined, + root: { + borderRadius: 0, + color: "var(--tools-text-and-icon-color)", + cursor: isUnavailable ? 'not-allowed' : undefined, overflow: 'visible' - }, - label: { overflow: 'visible' } + }, + label: { overflow: 'visible' } }} > {buttonContent} diff --git a/frontend/src/components/viewer/HistoryAPIBridge.tsx b/frontend/src/components/viewer/HistoryAPIBridge.tsx index 4227ac47d..352fdee50 100644 --- a/frontend/src/components/viewer/HistoryAPIBridge.tsx +++ b/frontend/src/components/viewer/HistoryAPIBridge.tsx @@ -3,13 +3,7 @@ import { useHistoryCapability } from '@embedpdf/plugin-history/react'; import { useAnnotationCapability } from '@embedpdf/plugin-annotation/react'; import { useSignature } from '../../contexts/SignatureContext'; import { uuidV4 } from '@embedpdf/models'; - -export interface HistoryAPI { - undo: () => void; - redo: () => void; - canUndo: () => boolean; - canRedo: () => boolean; -} +import type { HistoryAPI } from './viewerTypes'; export const HistoryAPIBridge = forwardRef(function HistoryAPIBridge(_, ref) { const { provides: historyApi } = useHistoryCapability(); @@ -42,7 +36,7 @@ export const HistoryAPIBridge = forwardRef(function HistoryAPIBridge const currentStoredData = getImageData(annotation.id); // Check if the annotation lacks image data but we have it stored if (currentStoredData && (!annotation.imageSrc || annotation.imageSrc !== currentStoredData)) { - + // Generate new ID to avoid React key conflicts const newId = uuidV4(); @@ -113,4 +107,4 @@ export const HistoryAPIBridge = forwardRef(function HistoryAPIBridge return null; // This is a bridge component with no UI }); -HistoryAPIBridge.displayName = 'HistoryAPIBridge'; \ No newline at end of file +HistoryAPIBridge.displayName = 'HistoryAPIBridge'; diff --git a/frontend/src/components/viewer/LocalEmbedPDF.tsx b/frontend/src/components/viewer/LocalEmbedPDF.tsx index cc3550dd8..d9eae66c4 100644 --- a/frontend/src/components/viewer/LocalEmbedPDF.tsx +++ b/frontend/src/components/viewer/LocalEmbedPDF.tsx @@ -34,8 +34,9 @@ import { SpreadAPIBridge } from './SpreadAPIBridge'; import { SearchAPIBridge } from './SearchAPIBridge'; import { ThumbnailAPIBridge } from './ThumbnailAPIBridge'; import { RotateAPIBridge } from './RotateAPIBridge'; -import { SignatureAPIBridge, SignatureAPI } from './SignatureAPIBridge'; -import { HistoryAPIBridge, HistoryAPI } from './HistoryAPIBridge'; +import { SignatureAPIBridge } from './SignatureAPIBridge'; +import { HistoryAPIBridge } from './HistoryAPIBridge'; +import type { SignatureAPI, HistoryAPI } from './viewerTypes'; import { ExportAPIBridge } from './ExportAPIBridge'; interface LocalEmbedPDFProps { diff --git a/frontend/src/components/viewer/SignatureAPIBridge.tsx b/frontend/src/components/viewer/SignatureAPIBridge.tsx index 3e01f30b7..2042c0487 100644 --- a/frontend/src/components/viewer/SignatureAPIBridge.tsx +++ b/frontend/src/components/viewer/SignatureAPIBridge.tsx @@ -2,17 +2,7 @@ import { useImperativeHandle, forwardRef, useEffect } from 'react'; import { useAnnotationCapability } from '@embedpdf/plugin-annotation/react'; import { PdfAnnotationSubtype, uuidV4 } from '@embedpdf/models'; import { useSignature } from '../../contexts/SignatureContext'; - -export interface SignatureAPI { - addImageSignature: (signatureData: string, x: number, y: number, width: number, height: number, pageIndex: number) => void; - activateDrawMode: () => void; - activateSignaturePlacementMode: () => void; - activateDeleteMode: () => void; - deleteAnnotation: (annotationId: string, pageIndex: number) => void; - updateDrawSettings: (color: string, size: number) => void; - deactivateTools: () => void; - getPageAnnotations: (pageIndex: number) => Promise; -} +import type { SignatureAPI } from './viewerTypes'; export const SignatureAPIBridge = forwardRef(function SignatureAPIBridge(_, ref) { const { provides: annotationApi } = useAnnotationCapability(); @@ -246,4 +236,4 @@ export const SignatureAPIBridge = forwardRef(function SignatureAPI return null; // This is a bridge component with no UI }); -SignatureAPIBridge.displayName = 'SignatureAPIBridge'; \ No newline at end of file +SignatureAPIBridge.displayName = 'SignatureAPIBridge'; diff --git a/frontend/src/components/viewer/viewerTypes.ts b/frontend/src/components/viewer/viewerTypes.ts new file mode 100644 index 000000000..f8149dce7 --- /dev/null +++ b/frontend/src/components/viewer/viewerTypes.ts @@ -0,0 +1,24 @@ +export interface SignatureAPI { + addImageSignature: ( + signatureData: string, + x: number, + y: number, + width: number, + height: number, + pageIndex: number + ) => void; + activateDrawMode: () => void; + activateSignaturePlacementMode: () => void; + activateDeleteMode: () => void; + deleteAnnotation: (annotationId: string, pageIndex: number) => void; + updateDrawSettings: (color: string, size: number) => void; + deactivateTools: () => void; + getPageAnnotations: (pageIndex: number) => Promise; +} + +export interface HistoryAPI { + undo: () => void; + redo: () => void; + canUndo: () => boolean; + canRedo: () => boolean; +} diff --git a/frontend/src/constants/app.ts b/frontend/src/constants/app.ts index c83cffcfd..c61302c84 100644 --- a/frontend/src/constants/app.ts +++ b/frontend/src/constants/app.ts @@ -1,10 +1,3 @@ -import { useAppConfig } from '../hooks/useAppConfig'; - -// Get base URL from app config with fallback -export const getBaseUrl = (): string => { - const { config } = useAppConfig(); - return config?.baseUrl || 'https://stirling.com'; -}; // Base path from Vite config - build-time constant, normalized (no trailing slash) // When no subpath, use empty string instead of '.' to avoid relative path issues diff --git a/frontend/src/contexts/NavigationContext.tsx b/frontend/src/contexts/NavigationContext.tsx index 3d9b4849c..b29a26d25 100644 --- a/frontend/src/contexts/NavigationContext.tsx +++ b/frontend/src/contexts/NavigationContext.tsx @@ -1,7 +1,7 @@ import React, { createContext, useContext, useReducer, useCallback } from 'react'; import { WorkbenchType, getDefaultWorkbench } from '../types/workbench'; import { ToolId, isValidToolId } from '../types/toolId'; -import { useFlatToolRegistry } from '../data/useTranslatedToolRegistry'; +import { useToolRegistry } from './ToolRegistryContext'; /** * NavigationContext - Complete navigation management system @@ -107,7 +107,7 @@ export const NavigationProvider: React.FC<{ enableUrlSync?: boolean; }> = ({ children }) => { const [state, dispatch] = useReducer(navigationReducer, initialState); - const toolRegistry = useFlatToolRegistry(); + const { allTools: toolRegistry } = useToolRegistry(); const unsavedChangesCheckerRef = React.useRef<(() => boolean) | null>(null); const actions: NavigationContextActions = { diff --git a/frontend/src/contexts/OnboardingContext.tsx b/frontend/src/contexts/OnboardingContext.tsx new file mode 100644 index 000000000..d2898e46d --- /dev/null +++ b/frontend/src/contexts/OnboardingContext.tsx @@ -0,0 +1,87 @@ +import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'; +import { usePreferences } from './PreferencesContext'; +import { useMediaQuery } from '@mantine/hooks'; +import { useAuth } from '../auth/UseSession'; + +interface OnboardingContextValue { + isOpen: boolean; + currentStep: number; + setCurrentStep: (step: number) => void; + startTour: () => void; + closeTour: () => void; + completeTour: () => void; + resetTour: () => void; + showWelcomeModal: boolean; + setShowWelcomeModal: (show: boolean) => void; +} + +const OnboardingContext = createContext(undefined); + +export const OnboardingProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const { preferences, updatePreference } = usePreferences(); + const { session, loading } = useAuth(); + const [isOpen, setIsOpen] = useState(false); + const [currentStep, setCurrentStep] = useState(0); + const [showWelcomeModal, setShowWelcomeModal] = useState(false); + const isMobile = useMediaQuery("(max-width: 1024px)"); + + // Auto-show welcome modal for first-time users after preferences load + // Only show after user has seen the tool panel mode prompt + // Also, don't show tour on mobile devices because it feels clunky + // IMPORTANT: Only show welcome modal if user is authenticated or login is disabled + useEffect(() => { + if (!loading && !preferences.hasCompletedOnboarding && preferences.toolPanelModePromptSeen && !isMobile) { + // Only show welcome modal if user is authenticated (session exists) + // This prevents the modal from showing on login screens when security is enabled + if (session) { + setShowWelcomeModal(true); + } + } + }, [preferences.hasCompletedOnboarding, preferences.toolPanelModePromptSeen, isMobile, session, loading]); + + const startTour = useCallback(() => { + setCurrentStep(0); + setIsOpen(true); + }, []); + + const closeTour = useCallback(() => { + setIsOpen(false); + }, []); + + const completeTour = useCallback(() => { + setIsOpen(false); + updatePreference('hasCompletedOnboarding', true); + }, [updatePreference]); + + const resetTour = useCallback(() => { + updatePreference('hasCompletedOnboarding', false); + setCurrentStep(0); + setIsOpen(true); + }, [updatePreference]); + + return ( + + {children} + + ); +}; + +export const useOnboarding = (): OnboardingContextValue => { + const context = useContext(OnboardingContext); + if (!context) { + throw new Error('useOnboarding must be used within an OnboardingProvider'); + } + return context; +}; diff --git a/frontend/src/contexts/SignatureContext.tsx b/frontend/src/contexts/SignatureContext.tsx index 839e8a9df..bbb165564 100644 --- a/frontend/src/contexts/SignatureContext.tsx +++ b/frontend/src/contexts/SignatureContext.tsx @@ -1,7 +1,6 @@ import React, { createContext, useContext, useState, ReactNode, useCallback, useRef } from 'react'; import { SignParameters } from '../hooks/tools/sign/useSignParameters'; -import { SignatureAPI } from '../components/viewer/SignatureAPIBridge'; -import { HistoryAPI } from '../components/viewer/HistoryAPIBridge'; +import type { SignatureAPI, HistoryAPI } from '../components/viewer/viewerTypes'; // Signature state interface interface SignatureState { @@ -175,4 +174,4 @@ export const useSignatureMode = () => { isSignatureModeActive: context?.isPlacementMode || false, hasSignatureConfig: context?.signatureConfig !== null, }; -}; \ No newline at end of file +}; diff --git a/frontend/src/contexts/ToolRegistryContext.tsx b/frontend/src/contexts/ToolRegistryContext.tsx new file mode 100644 index 000000000..77dcf5567 --- /dev/null +++ b/frontend/src/contexts/ToolRegistryContext.tsx @@ -0,0 +1,30 @@ +import { createContext, useContext } from 'react'; + +import type { + ToolRegistryEntry, + ToolRegistry, + RegularToolRegistry, + SuperToolRegistry, + LinkToolRegistry, +} from '../data/toolsTaxonomy'; +import type { ToolId } from '../types/toolId'; + +export interface ToolRegistryCatalog { + regularTools: RegularToolRegistry; + superTools: SuperToolRegistry; + linkTools: LinkToolRegistry; + allTools: ToolRegistry; + getToolById: (toolId: ToolId | null) => ToolRegistryEntry | null; +} + +const ToolRegistryContext = createContext(null); + +export const useToolRegistry = (): ToolRegistryCatalog => { + const context = useContext(ToolRegistryContext); + if (context === null) { + throw new Error('useToolRegistry must be used within a ToolRegistryProvider'); + } + return context; +}; + +export default ToolRegistryContext; diff --git a/frontend/src/contexts/ToolRegistryProvider.tsx b/frontend/src/contexts/ToolRegistryProvider.tsx new file mode 100644 index 000000000..30a020501 --- /dev/null +++ b/frontend/src/contexts/ToolRegistryProvider.tsx @@ -0,0 +1,44 @@ +import React, { useMemo } from 'react'; + +import type { ToolId } from '../types/toolId'; +import type { ToolRegistry } from '../data/toolsTaxonomy'; +import ToolRegistryContext, { ToolRegistryCatalog } from './ToolRegistryContext'; +import { useTranslatedToolCatalog } from '../data/useTranslatedToolRegistry'; + +interface ToolRegistryProviderProps { + children: React.ReactNode; +} + +export const ToolRegistryProvider: React.FC = ({ children }) => { + const catalog = useTranslatedToolCatalog(); + + const contextValue = useMemo(() => { + const { regularTools, superTools, linkTools } = catalog; + const allTools: ToolRegistry = { + ...regularTools, + ...superTools, + ...linkTools, + }; + + const getToolById = (toolId: ToolId | null) => { + if (!toolId) { + return null; + } + return allTools[toolId] ?? null; + }; + + return { + regularTools, + superTools, + linkTools, + allTools, + getToolById, + }; + }, [catalog]); + + return ( + + {children} + + ); +}; diff --git a/frontend/src/contexts/ToolWorkflowContext.tsx b/frontend/src/contexts/ToolWorkflowContext.tsx index c6d6983be..7ace72336 100644 --- a/frontend/src/contexts/ToolWorkflowContext.tsx +++ b/frontend/src/contexts/ToolWorkflowContext.tsx @@ -20,6 +20,7 @@ import { } from './toolWorkflow/toolWorkflowState'; import type { ToolPanelMode } from '../constants/toolPanel'; import { usePreferences } from './PreferencesContext'; +import { useToolRegistry } from './ToolRegistryContext'; // State interface // Types and reducer/state moved to './toolWorkflow/state' @@ -116,10 +117,8 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) { const navigationState = useNavigationState(); // Tool management hook - const { - toolRegistry, - getSelectedTool, - } = useToolManagement(); + const { toolRegistry, getSelectedTool } = useToolManagement(); + const { allTools } = useToolRegistry(); // Tool history hook const { @@ -320,7 +319,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) { // Filter tools based on search query with fuzzy matching (name, description, id, synonyms) const filteredTools = useMemo(() => { if (!toolRegistry) return []; - return filterToolRegistryByQuery(toolRegistry as ToolRegistry, state.searchQuery); + return filterToolRegistryByQuery(toolRegistry, state.searchQuery); }, [toolRegistry, state.searchQuery]); const isPanelVisible = useMemo(() => @@ -332,7 +331,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) { navigationState.selectedTool, handleToolSelect, handleBackToTools, - toolRegistry as ToolRegistry, + allTools, true ); diff --git a/frontend/src/contexts/TourOrchestrationContext.tsx b/frontend/src/contexts/TourOrchestrationContext.tsx new file mode 100644 index 000000000..8c6c0c05b --- /dev/null +++ b/frontend/src/contexts/TourOrchestrationContext.tsx @@ -0,0 +1,207 @@ +import React, { createContext, useContext, useCallback, useRef } from 'react'; +import { useFileHandler } from '../hooks/useFileHandler'; +import { useFilesModalContext } from './FilesModalContext'; +import { useNavigationActions } from './NavigationContext'; +import { useToolWorkflow } from './ToolWorkflowContext'; +import { useAllFiles, useFileManagement } from './FileContext'; +import { StirlingFile } from '../types/fileContext'; +import { fileStorage } from '../services/fileStorage'; + +interface TourOrchestrationContextType { + // State management + saveWorkbenchState: () => void; + restoreWorkbenchState: () => Promise; + + // Tool deselection + backToAllTools: () => void; + + // Tool selection + selectCropTool: () => void; + + // File operations + loadSampleFile: () => Promise; + + // View switching + switchToViewer: () => void; + switchToPageEditor: () => void; + switchToActiveFiles: () => void; + + // File operations + selectFirstFile: () => void; + pinFile: () => void; + + // Crop settings (placeholder for now) + modifyCropSettings: () => void; + + // Tool execution + executeTool: () => void; +} + +const TourOrchestrationContext = createContext(undefined); + +export const TourOrchestrationProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const { addFiles } = useFileHandler(); + const { closeFilesModal } = useFilesModalContext(); + const { actions: navActions } = useNavigationActions(); + const { handleToolSelect, handleBackToTools } = useToolWorkflow(); + const { files } = useAllFiles(); + const { clearAllFiles } = useFileManagement(); + + // Store the user's files before tour starts + const savedFilesRef = useRef([]); + + // Keep a ref to always have the latest files + const filesRef = useRef(files); + React.useEffect(() => { + filesRef.current = files; + }, [files]); + + const saveWorkbenchState = useCallback(() => { + // Get fresh files from ref + const currentFiles = filesRef.current; + console.log('Saving workbench state, files count:', currentFiles.length); + savedFilesRef.current = [...currentFiles]; + // Clear all files for clean demo + clearAllFiles(); + }, [clearAllFiles]); + + const restoreWorkbenchState = useCallback(async () => { + console.log('Restoring workbench state, saved files count:', savedFilesRef.current.length); + + // Go back to All Tools + handleBackToTools(); + + // Clear all files (including tour sample) + clearAllFiles(); + + // Delete all active files from storage (they're just the ones from the tour) + const currentFiles = filesRef.current; + if (currentFiles.length > 0) { + try { + await Promise.all(currentFiles.map(file => fileStorage.deleteStirlingFile(file.fileId))); + console.log(`Deleted ${currentFiles.length} file(s) from storage`); + } catch (error) { + console.error('Failed to delete files from storage:', error); + } + } + + // Restore saved files + if (savedFilesRef.current.length > 0) { + // Create fresh File objects from StirlingFile to avoid ID conflicts + const filesToRestore = await Promise.all( + savedFilesRef.current.map(async (sf) => { + const buffer = await sf.arrayBuffer(); + return new File([buffer], sf.name, { type: sf.type, lastModified: sf.lastModified }); + }) + ); + console.log('Restoring files:', filesToRestore.map(f => f.name)); + await addFiles(filesToRestore); + savedFilesRef.current = []; + } + }, [clearAllFiles, addFiles, handleBackToTools]); + + const backToAllTools = useCallback(() => { + handleBackToTools(); + }, [handleBackToTools]); + + const selectCropTool = useCallback(() => { + handleToolSelect('crop'); + }, [handleToolSelect]); + + const loadSampleFile = useCallback(async () => { + try { + const response = await fetch('/samples/Sample.pdf'); + const blob = await response.blob(); + const file = new File([blob], 'Sample.pdf', { type: 'application/pdf' }); + + await addFiles([file]); + closeFilesModal(); + } catch (error) { + console.error('Failed to load sample file:', error); + } + }, [addFiles, closeFilesModal]); + + const switchToViewer = useCallback(() => { + navActions.setWorkbench('viewer'); + }, [navActions]); + + const switchToPageEditor = useCallback(() => { + navActions.setWorkbench('pageEditor'); + }, [navActions]); + + const switchToActiveFiles = useCallback(() => { + navActions.setWorkbench('fileEditor'); + }, [navActions]); + + const selectFirstFile = useCallback(() => { + // File selection is handled by FileCard onClick + // This function could trigger a click event on the first file card + const firstFileCard = document.querySelector('[data-tour="file-card-checkbox"]') as HTMLElement; + if (firstFileCard) { + // Check if already selected (data-selected attribute) + const isSelected = firstFileCard.getAttribute('data-selected') === 'true'; + // Only click if not already selected (to avoid toggling off) + if (!isSelected) { + firstFileCard.click(); + } + } + }, []); + + const pinFile = useCallback(() => { + // Click the pin button directly + const pinButton = document.querySelector('[data-tour="file-card-pin"]') as HTMLElement; + if (pinButton) { + pinButton.click(); + } + }, []); + + const modifyCropSettings = useCallback(() => { + // Dispatch a custom event to modify crop settings + const event = new CustomEvent('tour:setCropArea', { + detail: { + x: 80, + y: 435, + width: 440, + height: 170, + } + }); + window.dispatchEvent(event); + }, []); + + const executeTool = useCallback(() => { + // Trigger the run button click + const runButton = document.querySelector('[data-tour="run-button"]') as HTMLElement; + if (runButton) { + runButton.click(); + } + }, []); + + const value: TourOrchestrationContextType = { + saveWorkbenchState, + restoreWorkbenchState, + backToAllTools, + selectCropTool, + loadSampleFile, + switchToViewer, + switchToPageEditor, + switchToActiveFiles, + selectFirstFile, + pinFile, + modifyCropSettings, + executeTool, + }; + + return ( + + {children} + + ); +}; + +export const useTourOrchestration = (): TourOrchestrationContextType => { + const context = useContext(TourOrchestrationContext); + if (!context) { + throw new Error('useTourOrchestration must be used within TourOrchestrationProvider'); + } + return context; +}; diff --git a/frontend/src/data/toolsTaxonomy.ts b/frontend/src/data/toolsTaxonomy.ts index 789e3b23f..a0204f1ca 100644 --- a/frontend/src/data/toolsTaxonomy.ts +++ b/frontend/src/data/toolsTaxonomy.ts @@ -3,7 +3,7 @@ import React from 'react'; import { ToolOperationConfig } from '../hooks/tools/shared/useToolOperation'; import { BaseToolProps } from '../types/tool'; import { WorkbenchType } from '../types/workbench'; -import { ToolId } from '../types/toolId'; +import { LinkToolId, RegularToolId, SuperToolId, ToolId, ToolKind } from '../types/toolId'; import DrawRoundedIcon from '@mui/icons-material/DrawRounded'; import SecurityRoundedIcon from '@mui/icons-material/SecurityRounded'; import VerifiedUserRoundedIcon from '@mui/icons-material/VerifiedUserRounded'; @@ -47,7 +47,7 @@ export type ToolRegistryEntry = { supportedFormats?: string[]; endpoints?: string[]; link?: string; - type?: string; + kind?: ToolKind; // Workbench type for navigation workbench?: WorkbenchType; // Operation configuration for automation @@ -60,6 +60,9 @@ export type ToolRegistryEntry = { synonyms?: string[]; } +export type RegularToolRegistry = Record; +export type SuperToolRegistry = Record; +export type LinkToolRegistry = Record; export type ToolRegistry = Record; export const SUBCATEGORY_ORDER: SubcategoryId[] = [ diff --git a/frontend/src/data/useTranslatedToolRegistry.tsx b/frontend/src/data/useTranslatedToolRegistry.tsx index 85f6dfd15..0b416e6ca 100644 --- a/frontend/src/data/useTranslatedToolRegistry.tsx +++ b/frontend/src/data/useTranslatedToolRegistry.tsx @@ -13,7 +13,15 @@ import RemovePages from "../tools/RemovePages"; import ReorganizePages from "../tools/ReorganizePages"; import { reorganizePagesOperationConfig } from "../hooks/tools/reorganizePages/useReorganizePagesOperation"; import RemovePassword from "../tools/RemovePassword"; -import { SubcategoryId, ToolCategoryId, ToolRegistry } from "./toolsTaxonomy"; +import { + SubcategoryId, + ToolCategoryId, + ToolRegistry, + RegularToolRegistry, + SuperToolRegistry, + LinkToolRegistry, +} from "./toolsTaxonomy"; +import { isSuperToolId, isLinkToolId } from '../types/toolId'; import AdjustContrast from "../tools/AdjustContrast"; import AdjustContrastSingleStepSettings from "../components/tools/adjustContrast/AdjustContrastSingleStepSettings"; import { adjustContrastOperationConfig } from "../hooks/tools/adjustContrast/useAdjustContrastOperation"; @@ -111,13 +119,19 @@ import OverlayPdfsSettings from "../components/tools/overlayPdfs/OverlayPdfsSett import ValidateSignature from "../tools/ValidateSignature"; import Compare from "../tools/Compare"; -const showPlaceholderTools = true; // Show all tools; grey out unavailable ones in UI - // Convert tool supported file formats +import Automate from "../tools/Automate"; import { CONVERT_SUPPORTED_FORMATS } from "../constants/convertSupportedFornats"; +export interface TranslatedToolCatalog { + allTools: ToolRegistry; + regularTools: RegularToolRegistry; + superTools: SuperToolRegistry; + linkTools: LinkToolRegistry; +} + // Hook to get the translated tool registry -export function useFlatToolRegistry(): ToolRegistry { +export function useTranslatedToolCatalog(): TranslatedToolCatalog { const { t } = useTranslation(); return useMemo(() => { @@ -565,7 +579,7 @@ export function useFlatToolRegistry(): ToolRegistry { automate: { icon: , name: t("home.automate.title", "Automate"), - component: React.lazy(() => import("../tools/Automate")), + component: Automate, description: t( "home.automate.desc", "Build multi-step workflows by chaining together PDF actions. Ideal for recurring tasks." @@ -832,15 +846,26 @@ export function useFlatToolRegistry(): ToolRegistry { }, }; - if (showPlaceholderTools) { - return allTools; - } - const filteredTools = Object.keys(allTools) - .filter((key) => allTools[key as ToolId].component !== null || allTools[key as ToolId].link) - .reduce((obj, key) => { - obj[key as ToolId] = allTools[key as ToolId]; - return obj; - }, {} as ToolRegistry); - return filteredTools; + const regularTools = {} as RegularToolRegistry; + const superTools = {} as SuperToolRegistry; + const linkTools = {} as LinkToolRegistry; + + Object.entries(allTools).forEach(([key, entry]) => { + const toolId = key as ToolId; + if (isSuperToolId(toolId)) { + superTools[toolId] = entry; + } else if (isLinkToolId(toolId)) { + linkTools[toolId] = entry; + } else { + regularTools[toolId] = entry; + } + }); + + return { + allTools, + regularTools, + superTools, + linkTools, + }; }, [t]); // Only re-compute when translations change } diff --git a/frontend/src/hooks/tools/automate/useAutomateOperation.ts b/frontend/src/hooks/tools/automate/useAutomateOperation.ts index e051d5f1b..e7a2ea19c 100644 --- a/frontend/src/hooks/tools/automate/useAutomateOperation.ts +++ b/frontend/src/hooks/tools/automate/useAutomateOperation.ts @@ -1,11 +1,12 @@ import { ToolType, useToolOperation } from '../shared/useToolOperation'; import { useCallback } from 'react'; import { executeAutomationSequence } from '../../../utils/automationExecutor'; -import { useFlatToolRegistry } from '../../../data/useTranslatedToolRegistry'; +import { useToolRegistry } from '../../../contexts/ToolRegistryContext'; import { AutomateParameters } from '../../../types/automation'; export function useAutomateOperation() { - const toolRegistry = useFlatToolRegistry(); + const { allTools } = useToolRegistry(); + const toolRegistry = allTools; const customProcessor = useCallback(async (params: AutomateParameters, files: File[]) => { console.log('🚀 Starting automation execution via customProcessor', { params, files }); diff --git a/frontend/src/hooks/tools/automate/useAutomationForm.ts b/frontend/src/hooks/tools/automate/useAutomationForm.ts index 4b5dc7b5d..91df79ac9 100644 --- a/frontend/src/hooks/tools/automate/useAutomationForm.ts +++ b/frontend/src/hooks/tools/automate/useAutomationForm.ts @@ -9,7 +9,7 @@ import { ToolId } from 'src/types/toolId'; interface UseAutomationFormProps { mode: AutomationMode; existingAutomation?: AutomationConfig; - toolRegistry: ToolRegistry; + toolRegistry: Partial; } export function useAutomationForm({ mode, existingAutomation, toolRegistry }: UseAutomationFormProps) { @@ -21,12 +21,12 @@ export function useAutomationForm({ mode, existingAutomation, toolRegistry }: Us const [selectedTools, setSelectedTools] = useState([]); const getToolName = useCallback((operation: string) => { - const tool = toolRegistry?.[operation as keyof ToolRegistry] as any; + const tool = toolRegistry?.[operation as ToolId] as any; return tool?.name || t(`tools.${operation}.name`, operation); }, [toolRegistry, t]); const getToolDefaultParameters = useCallback((operation: string): Record => { - const config = toolRegistry[operation as keyof ToolRegistry]?.operationConfig; + const config = toolRegistry[operation as ToolId]?.operationConfig; if (config?.defaultParameters) { return { ...config.defaultParameters }; } diff --git a/frontend/src/hooks/tools/replaceColor/useReplaceColorOperation.ts b/frontend/src/hooks/tools/replaceColor/useReplaceColorOperation.ts index e2a101a1c..45eb8e8dd 100644 --- a/frontend/src/hooks/tools/replaceColor/useReplaceColorOperation.ts +++ b/frontend/src/hooks/tools/replaceColor/useReplaceColorOperation.ts @@ -33,6 +33,6 @@ export const useReplaceColorOperation = () => { return useToolOperation({ ...replaceColorOperationConfig, - getErrorMessage: createStandardErrorHandler(t('replaceColor.error.failed', 'An error occurred while processing the color replacement.')) + getErrorMessage: createStandardErrorHandler(t('replaceColor.error.failed', 'An error occurred while processing the colour replacement.')) }); -}; \ No newline at end of file +}; diff --git a/frontend/src/hooks/useAppConfig.ts b/frontend/src/hooks/useAppConfig.ts index cfca99b57..d8879ec9d 100644 --- a/frontend/src/hooks/useAppConfig.ts +++ b/frontend/src/hooks/useAppConfig.ts @@ -1,5 +1,11 @@ import { useState, useEffect } from 'react'; +// Helper to get JWT from localStorage for Authorization header +function getAuthHeaders(): HeadersInit { + const token = localStorage.getItem('stirling_jwt'); + return token ? { 'Authorization': `Bearer ${token}` } : {}; +} + export interface AppConfig { baseUrl?: string; contextPath?: string; @@ -46,7 +52,9 @@ export function useAppConfig(): UseAppConfigReturn { setLoading(true); setError(null); - const response = await fetch('/api/v1/config/app-config'); + const response = await fetch('/api/v1/config/app-config', { + headers: getAuthHeaders(), + }); if (!response.ok) { throw new Error(`Failed to fetch config: ${response.status} ${response.statusText}`); diff --git a/frontend/src/hooks/useBaseUrl.ts b/frontend/src/hooks/useBaseUrl.ts new file mode 100644 index 000000000..5db03705e --- /dev/null +++ b/frontend/src/hooks/useBaseUrl.ts @@ -0,0 +1,6 @@ +import { useAppConfig } from './useAppConfig'; + +export const useBaseUrl = (): string => { + const { config } = useAppConfig(); + return config?.baseUrl || 'https://demo.stirlingpdf.com'; +}; diff --git a/frontend/src/hooks/useEndpointConfig.ts b/frontend/src/hooks/useEndpointConfig.ts index 7516826ed..f87c0187a 100644 --- a/frontend/src/hooks/useEndpointConfig.ts +++ b/frontend/src/hooks/useEndpointConfig.ts @@ -1,5 +1,11 @@ import { useState, useEffect } from 'react'; +// Helper to get JWT from localStorage for Authorization header +function getAuthHeaders(): HeadersInit { + const token = localStorage.getItem('stirling_jwt'); + return token ? { 'Authorization': `Bearer ${token}` } : {}; +} + /** * Hook to check if a specific endpoint is enabled */ @@ -24,7 +30,9 @@ export function useEndpointEnabled(endpoint: string): { setLoading(true); setError(null); - const response = await fetch(`/api/v1/config/endpoint-enabled?endpoint=${encodeURIComponent(endpoint)}`); + const response = await fetch(`/api/v1/config/endpoint-enabled?endpoint=${encodeURIComponent(endpoint)}`, { + headers: getAuthHeaders(), + }); if (!response.ok) { throw new Error(`Failed to check endpoint: ${response.status} ${response.statusText}`); @@ -80,7 +88,9 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): { // Use batch API for efficiency const endpointsParam = endpoints.join(','); - const response = await fetch(`/api/v1/config/endpoints-enabled?endpoints=${encodeURIComponent(endpointsParam)}`); + const response = await fetch(`/api/v1/config/endpoints-enabled?endpoints=${encodeURIComponent(endpointsParam)}`, { + headers: getAuthHeaders(), + }); if (!response.ok) { throw new Error(`Failed to check endpoints: ${response.status} ${response.statusText}`); diff --git a/frontend/src/hooks/useToolManagement.tsx b/frontend/src/hooks/useToolManagement.tsx index fd5606e2e..75d4c4c7f 100644 --- a/frontend/src/hooks/useToolManagement.tsx +++ b/frontend/src/hooks/useToolManagement.tsx @@ -1,6 +1,5 @@ import { useState, useCallback, useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; -import { useFlatToolRegistry } from "../data/useTranslatedToolRegistry"; +import { useToolRegistry } from "../contexts/ToolRegistryContext"; import { getAllEndpoints, type ToolRegistryEntry, type ToolRegistry } from "../data/toolsTaxonomy"; import { useMultipleEndpointsEnabled } from "./useEndpointConfig"; import { FileId } from '../types/file'; @@ -15,19 +14,19 @@ interface ToolManagementResult { } export const useToolManagement = (): ToolManagementResult => { - const { t } = useTranslation(); - const [toolSelectedFileIds, setToolSelectedFileIds] = useState([]); // Build endpoints list from registry entries with fallback to legacy mapping - const baseRegistry = useFlatToolRegistry(); + const { allTools } = useToolRegistry(); + const baseRegistry = allTools; const allEndpoints = useMemo(() => getAllEndpoints(baseRegistry), [baseRegistry]); const { endpointStatus, loading: endpointsLoading } = useMultipleEndpointsEnabled(allEndpoints); const isToolAvailable = useCallback((toolKey: string): boolean => { if (endpointsLoading) return true; - const endpoints = baseRegistry[toolKey as keyof typeof baseRegistry]?.endpoints || []; + const tool = baseRegistry[toolKey as ToolId]; + const endpoints = tool?.endpoints || []; return endpoints.length === 0 || endpoints.some((endpoint: string) => endpointStatus[endpoint] === true); }, [endpointsLoading, endpointStatus, baseRegistry]); @@ -35,16 +34,18 @@ export const useToolManagement = (): ToolManagementResult => { const availableToolRegistry: Partial = {}; (Object.keys(baseRegistry) as ToolId[]).forEach(toolKey => { if (isToolAvailable(toolKey)) { - const baseTool = baseRegistry[toolKey as keyof typeof baseRegistry]; - availableToolRegistry[toolKey as ToolId] = { - ...baseTool, - name: baseTool.name, - description: baseTool.description, - }; + const baseTool = baseRegistry[toolKey]; + if (baseTool) { + availableToolRegistry[toolKey] = { + ...baseTool, + name: baseTool.name, + description: baseTool.description, + }; + } } }); return availableToolRegistry; - }, [isToolAvailable, t, baseRegistry]); + }, [isToolAvailable, baseRegistry]); const getSelectedTool = useCallback((toolKey: ToolId | null): ToolRegistryEntry | null => { return toolKey ? toolRegistry[toolKey] || null : null; diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index 168a07fbb..a80325be4 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -4,7 +4,8 @@ import { useToolWorkflow } from "../contexts/ToolWorkflowContext"; import { Group, useMantineColorScheme } from "@mantine/core"; import { useSidebarContext } from "../contexts/SidebarContext"; import { useDocumentMeta } from "../hooks/useDocumentMeta"; -import { BASE_PATH, getBaseUrl } from "../constants/app"; +import { BASE_PATH } from "../constants/app"; +import { useBaseUrl } from "../hooks/useBaseUrl"; import { useMediaQuery } from "@mantine/hooks"; import AppsIcon from '@mui/icons-material/AppsRounded'; @@ -135,7 +136,7 @@ export default function HomePage() { } }, [isMobile, activeMobileView, selectedTool, setLeftPanelView]); - const baseUrl = getBaseUrl(); + const baseUrl = useBaseUrl(); // Update document meta when tool changes useDocumentMeta({ diff --git a/frontend/src/routes/AuthCallback.tsx b/frontend/src/routes/AuthCallback.tsx new file mode 100644 index 000000000..285d58251 --- /dev/null +++ b/frontend/src/routes/AuthCallback.tsx @@ -0,0 +1,73 @@ +import { useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import { useAuth } from '../auth/UseSession' + +/** + * OAuth Callback Handler + * + * This component is rendered after OAuth providers (GitHub, Google, etc.) redirect back. + * The JWT is passed in the URL fragment (#access_token=...) by the Spring backend. + * We extract it, store in localStorage, and redirect to the home page. + */ +export default function AuthCallback() { + const navigate = useNavigate() + const { refreshSession } = useAuth() + + useEffect(() => { + const handleCallback = async () => { + try { + console.log('[AuthCallback] Handling OAuth callback...') + + // Extract JWT from URL fragment (#access_token=...) + const hash = window.location.hash.substring(1) // Remove '#' + const params = new URLSearchParams(hash) + const token = params.get('access_token') + + if (!token) { + console.error('[AuthCallback] No access_token in URL fragment') + navigate('/login', { + replace: true, + state: { error: 'OAuth login failed - no token received.' } + }) + return + } + + // Store JWT in localStorage + localStorage.setItem('stirling_jwt', token) + console.log('[AuthCallback] JWT stored in localStorage') + + // Refresh session to load user info into state + await refreshSession() + + console.log('[AuthCallback] Session refreshed, redirecting to home') + + // Clear the hash from URL and redirect to home page + navigate('/', { replace: true }) + } catch (error) { + console.error('[AuthCallback] Error:', error) + navigate('/login', { + replace: true, + state: { error: 'OAuth login failed. Please try again.' } + }) + } + } + + handleCallback() + }, [navigate, refreshSession]) + + return ( +
+
+
+
+ Completing authentication... +
+
+
+ ) +} diff --git a/frontend/src/routes/Landing.tsx b/frontend/src/routes/Landing.tsx new file mode 100644 index 000000000..0eb2ba091 --- /dev/null +++ b/frontend/src/routes/Landing.tsx @@ -0,0 +1,62 @@ +import { Navigate, useLocation } from 'react-router-dom' +import { useAuth } from '../auth/UseSession' +import { useAppConfig } from '../hooks/useAppConfig' +import HomePage from '../pages/HomePage' +import Login from './Login' + +/** + * Landing component - Smart router based on authentication status + * + * If login is disabled: Show HomePage directly (anonymous mode) + * If user is authenticated: Show HomePage + * If user is not authenticated: Show Login or redirect to /login + */ +export default function Landing() { + const { session, loading: authLoading } = useAuth() + const { config, loading: configLoading } = useAppConfig() + const location = useLocation() + + const loading = authLoading || configLoading + + console.log('[Landing] State:', { + pathname: location.pathname, + loading, + hasSession: !!session, + loginEnabled: config?.enableLogin, + }) + + // Show loading while checking auth and config + if (loading) { + return ( +
+
+
+
+ Loading... +
+
+
+ ) + } + + // If login is disabled, show app directly (anonymous mode) + if (config?.enableLogin === false) { + console.debug('[Landing] Login disabled - showing app in anonymous mode') + return + } + + // If we have a session, show the main app + if (session) { + return + } + + // If we're at home route ("/"), show login directly (marketing/landing page) + // Otherwise navigate to login (fixes URL mismatch for tool routes) + const isHome = location.pathname === '/' || location.pathname === '' + if (isHome) { + return + } + + // For non-home routes without auth, navigate to login (preserves from location) + return +} diff --git a/frontend/src/routes/Login.tsx b/frontend/src/routes/Login.tsx new file mode 100644 index 000000000..61efb0c74 --- /dev/null +++ b/frontend/src/routes/Login.tsx @@ -0,0 +1,189 @@ +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { springAuth } from '../auth/springAuthClient' +import { useAuth } from '../auth/UseSession' +import { useTranslation } from 'react-i18next' +import { useDocumentMeta } from '../hooks/useDocumentMeta' +import AuthLayout from './authShared/AuthLayout' + +// Import login components +import LoginHeader from './login/LoginHeader' +import ErrorMessage from './login/ErrorMessage' +import EmailPasswordForm from './login/EmailPasswordForm' +import OAuthButtons from './login/OAuthButtons' +import DividerWithText from '../components/shared/DividerWithText' +import LoggedInState from './login/LoggedInState' +import { BASE_PATH } from '../constants/app' + +export default function Login() { + const navigate = useNavigate() + const { session, loading } = useAuth() + const { t } = useTranslation() + const [isSigningIn, setIsSigningIn] = useState(false) + const [error, setError] = useState(null) + const [showEmailForm, setShowEmailForm] = useState(false) + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + + // Prefill email from query param (e.g. after password reset) + useEffect(() => { + try { + const url = new URL(window.location.href) + const emailFromQuery = url.searchParams.get('email') + if (emailFromQuery) { + setEmail(emailFromQuery) + } + } catch (_) { + // ignore + } + }, []) + + const baseUrl = window.location.origin + BASE_PATH; + + // Set document meta + useDocumentMeta({ + title: `${t('login.title', 'Sign in')} - Stirling PDF`, + description: t('app.description', 'The Free Adobe Acrobat alternative (10M+ Downloads)'), + ogTitle: `${t('login.title', 'Sign in')} - Stirling PDF`, + ogDescription: t('app.description', 'The Free Adobe Acrobat alternative (10M+ Downloads)'), + ogImage: `${baseUrl}/og_images/home.png`, + ogUrl: `${window.location.origin}${window.location.pathname}` + }) + + // Show logged in state if authenticated + if (session && !loading) { + return + } + + const signInWithProvider = async (provider: 'github' | 'google' | 'apple' | 'azure') => { + try { + setIsSigningIn(true) + setError(null) + + console.log(`[Login] Signing in with ${provider}`) + + // Redirect to Spring OAuth2 endpoint + const { error } = await springAuth.signInWithOAuth({ + provider, + options: { redirectTo: `${BASE_PATH}/auth/callback` } + }) + + if (error) { + console.error(`[Login] ${provider} error:`, error) + setError(t('login.failedToSignIn', { provider, message: error.message }) || `Failed to sign in with ${provider}`) + } + } catch (err) { + console.error(`[Login] Unexpected error:`, err) + setError(t('login.unexpectedError', { message: err instanceof Error ? err.message : 'Unknown error' }) || 'An unexpected error occurred') + } finally { + setIsSigningIn(false) + } + } + + const signInWithEmail = async () => { + if (!email || !password) { + setError(t('login.pleaseEnterBoth') || 'Please enter both email and password') + return + } + + try { + setIsSigningIn(true) + setError(null) + + console.log('[Login] Signing in with email:', email) + + const { user, session, error } = await springAuth.signInWithPassword({ + email: email.trim(), + password: password + }) + + if (error) { + console.error('[Login] Email sign in error:', error) + setError(error.message) + } else if (user && session) { + console.log('[Login] Email sign in successful') + // Auth state will update automatically and Landing will redirect to home + // No need to navigate manually here + } + } catch (err) { + console.error('[Login] Unexpected error:', err) + setError(t('login.unexpectedError', { message: err instanceof Error ? err.message : 'Unknown error' }) || 'An unexpected error occurred') + } finally { + setIsSigningIn(false) + } + } + + const handleForgotPassword = () => { + navigate('/auth/reset') + } + + return ( + + + + + + {/* OAuth first */} + + + {/* Divider between OAuth and Email */} + + + {/* Sign in with email button (primary color to match signup CTA) */} +
+ +
+ + {showEmailForm && ( +
+ +
+ )} + + {showEmailForm && ( +
+ +
+ )} + + {/* Divider then signup link */} + + +
+ +
+ +
+ ) +} diff --git a/frontend/src/routes/Signup.tsx b/frontend/src/routes/Signup.tsx new file mode 100644 index 000000000..0a5ee32ac --- /dev/null +++ b/frontend/src/routes/Signup.tsx @@ -0,0 +1,105 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { useTranslation } from 'react-i18next' +import { useDocumentMeta } from '../hooks/useDocumentMeta' +import AuthLayout from './authShared/AuthLayout' +import './authShared/auth.css' +import { BASE_PATH } from '../constants/app' + +// Import signup components +import LoginHeader from './login/LoginHeader' +import ErrorMessage from './login/ErrorMessage' +import DividerWithText from '../components/shared/DividerWithText' +import SignupForm from './signup/SignupForm' +import { useSignupFormValidation, SignupFieldErrors } from './signup/SignupFormValidation' +import { useAuthService } from './signup/AuthService' + +export default function Signup() { + const navigate = useNavigate() + const { t } = useTranslation() + const [isSigningUp, setIsSigningUp] = useState(false) + const [error, setError] = useState(null) + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [fieldErrors, setFieldErrors] = useState({}) + + const baseUrl = window.location.origin + BASE_PATH; + + // Set document meta + useDocumentMeta({ + title: `${t('signup.title', 'Create an account')} - Stirling PDF`, + description: t('app.description', 'The Free Adobe Acrobat alternative (10M+ Downloads)'), + ogTitle: `${t('signup.title', 'Create an account')} - Stirling PDF`, + ogDescription: t('app.description', 'The Free Adobe Acrobat alternative (10M+ Downloads)'), + ogImage: `${baseUrl}/og_images/home.png`, + ogUrl: `${window.location.origin}${window.location.pathname}` + }) + + const { validateSignupForm } = useSignupFormValidation() + const { signUp } = useAuthService() + + const handleSignUp = async () => { + const validation = validateSignupForm(email, password, confirmPassword) + if (!validation.isValid) { + setError(validation.error) + setFieldErrors(validation.fieldErrors || {}) + return + } + + try { + setIsSigningUp(true) + setError(null) + setFieldErrors({}) + + const result = await signUp(email, password, '') + + if (result.user) { + // Show success message and redirect to login + setError(null) + setTimeout(() => navigate('/login'), 2000) + } + } catch (err) { + console.error('[Signup] Unexpected error:', err) + setError(err instanceof Error ? err.message : t('signup.unexpectedError', { message: 'Unknown error' })) + } finally { + setIsSigningUp(false) + } + } + + return ( + + + + + + {/* Signup form - shown immediately */} + + + + + {/* Bottom row - centered */} +
+ +
+
+ ) +} diff --git a/frontend/src/routes/authShared/AuthLayout.module.css b/frontend/src/routes/authShared/AuthLayout.module.css new file mode 100644 index 000000000..0e597bee0 --- /dev/null +++ b/frontend/src/routes/authShared/AuthLayout.module.css @@ -0,0 +1,48 @@ +.authContainer { + position: relative; + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background-color: var(--auth-bg-color-light-only); + padding: 1.5rem 1.5rem 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + overflow: auto; +} + +.authCard { + width: min(45rem, 96vw); + height: min(50.875rem, 96vh); + display: grid; + grid-template-columns: 1fr; + background-color: var(--auth-card-bg); + border-radius: 1.25rem; + box-shadow: 0 1.25rem 3.75rem rgba(0, 0, 0, 0.12); + overflow: hidden; + min-height: 0; +} + +.authCardTwoColumns { + width: min(73.75rem, 96vw); + grid-template-columns: 1fr 1fr; +} + +.authLeftPanel { + display: flex; + align-items: center; + justify-content: center; + padding: 2rem; + overflow: hidden; + min-height: 0; + height: 100%; +} + +.authLeftPanel::-webkit-scrollbar { + display: none; /* WebKit browsers (Chrome, Safari, Edge) */ +} + +.authContent { + max-width: 26.25rem; /* 420px */ + width: 100%; +} diff --git a/frontend/src/routes/authShared/AuthLayout.tsx b/frontend/src/routes/authShared/AuthLayout.tsx new file mode 100644 index 000000000..e32f629e0 --- /dev/null +++ b/frontend/src/routes/authShared/AuthLayout.tsx @@ -0,0 +1,68 @@ +import React, { useEffect, useRef, useState } from 'react' +import LoginRightCarousel from '../../components/shared/LoginRightCarousel' +import loginSlides from '../../components/shared/loginSlides' +import styles from './AuthLayout.module.css' + +interface AuthLayoutProps { + children: React.ReactNode +} + +export default function AuthLayout({ children }: AuthLayoutProps) { + const cardRef = useRef(null) + const [hideRightPanel, setHideRightPanel] = useState(false) + + // Force light mode on auth pages + useEffect(() => { + const htmlElement = document.documentElement + const previousColorScheme = htmlElement.getAttribute('data-mantine-color-scheme') + + // Set light mode + htmlElement.setAttribute('data-mantine-color-scheme', 'light') + + // Cleanup: restore previous theme when leaving auth pages + return () => { + if (previousColorScheme) { + htmlElement.setAttribute('data-mantine-color-scheme', previousColorScheme) + } + } + }, []) + + useEffect(() => { + const update = () => { + // Use viewport to avoid hysteresis when the card is already in single-column mode + const viewportWidth = window.innerWidth + const viewportHeight = window.innerHeight + const cardWidthIfTwoCols = Math.min(1180, viewportWidth * 0.96) // matches min(73.75rem, 96vw) + const columnWidth = cardWidthIfTwoCols / 2 + const tooNarrow = columnWidth < 470 + const tooShort = viewportHeight < 740 + setHideRightPanel(tooNarrow || tooShort) + } + update() + window.addEventListener('resize', update) + window.addEventListener('orientationchange', update) + return () => { + window.removeEventListener('resize', update) + window.removeEventListener('orientationchange', update) + } + }, []) + + return ( +
+
+
+
+ {children} +
+
+ {!hideRightPanel && ( + + )} +
+
+ ) +} diff --git a/frontend/src/routes/authShared/auth.css b/frontend/src/routes/authShared/auth.css new file mode 100644 index 000000000..7f75e9dda --- /dev/null +++ b/frontend/src/routes/authShared/auth.css @@ -0,0 +1,378 @@ +.auth-fields { + display: flex; + flex-direction: column; + gap: 0.5rem; /* 8px */ + margin-bottom: 0.75rem; /* 12px */ +} + +.auth-field { + display: flex; + flex-direction: column; + gap: 0.25rem; /* 4px */ +} + +.auth-label { + font-size: 0.875rem; /* 14px */ + color: var(--auth-label-text-light-only); + font-weight: 500; +} + +.auth-input { + width: 100%; + padding: 0.625rem 0.75rem; /* 10px 12px */ + border: 1px solid var(--auth-input-border-light-only); + border-radius: 0.625rem; /* 10px */ + font-size: 0.875rem; /* 14px */ + background-color: var(--auth-input-bg-light-only); + color: var(--auth-input-text-light-only); + outline: none; +} + +.auth-input:focus { + border-color: var(--auth-border-focus-light-only); + box-shadow: 0 0 0 3px var(--auth-focus-ring-light-only); +} + +.auth-button { + width: 100%; + padding: 0.625rem 0.75rem; /* 10px 12px */ + border: none; + border-radius: 0.625rem; /* 10px */ + background-color: var(--auth-button-bg-light-only); + color: var(--auth-button-text-light-only); + font-size: 0.875rem; /* 14px */ + font-weight: 600; + margin-bottom: 0.75rem; /* 12px */ + cursor: pointer; +} + +.auth-button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.auth-toggle-wrapper { + text-align: center; + margin-bottom: 0.625rem; /* 10px */ +} + +.auth-toggle-link { + background: transparent; + border: 0; + color: var(--auth-label-text-light-only); + font-size: 0.875rem; /* 14px */ + text-decoration: underline; + cursor: pointer; +} + +.auth-toggle-link:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.auth-magic-row { + display: flex; + gap: 0.5rem; /* 8px */ + margin-bottom: 0.75rem; /* 12px */ +} + +.auth-magic-row .auth-input { + flex: 1 1 auto; +} + +.auth-magic-button { + padding: 0.875rem 1rem; /* 14px 16px */ + border: none; + border-radius: 0.625rem; /* 10px */ + background-color: var(--auth-magic-button-bg-light-only); + color: var(--auth-magic-button-text-light-only); + font-size: 0.875rem; /* 14px */ + font-weight: 600; + white-space: nowrap; + cursor: pointer; +} + +.auth-magic-button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.auth-terms { + display: flex; + align-items: center; + gap: 0.5rem; /* 8px */ + margin-bottom: 0.5rem; /* 8px */ +} + +.auth-checkbox { + width: 1rem; /* 16px */ + height: 1rem; /* 16px */ + accent-color: #AF3434; +} + +.auth-terms-label { + font-size: 0.75rem; /* 12px */ + color: var(--auth-label-text-light-only); +} + +.auth-terms-label a { + color: inherit; + text-decoration: underline; +} + +.auth-confirm { + overflow: hidden; + transition: max-height 240ms ease, opacity 200ms ease; +} + +/* OAuth Button Styles */ +.oauth-container-icons { + display: flex; + margin-bottom: 0.625rem; /* 10px */ + justify-content: space-between; +} + +.oauth-container-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 0.75rem; /* 12px */ + margin-bottom: 0.625rem; /* 10px */ +} + +.oauth-container-vertical { + display: flex; + flex-direction: column; + gap: 0.75rem; /* 12px */ +} + +.oauth-button-icon { + width: 3.75rem; /* 60px */ + height: 3.75rem; /* 60px */ + border-radius: 0.875rem; /* 14px */ + border: 1px solid var(--auth-input-border-light-only); + background: var(--auth-card-bg-light-only); + cursor: pointer; + box-shadow: 0 0.125rem 0.375rem rgba(0, 0, 0, 0.04); /* 0 2px 6px */ + display: flex; + align-items: center; + justify-content: center; +} + +.oauth-button-icon:disabled { + cursor: not-allowed; + opacity: 0.6; +} + +.oauth-button-grid { + width: 100%; + padding: 1rem; /* 16px */ + border-radius: 0.875rem; /* 14px */ + border: 1px solid var(--auth-input-border-light-only); + background: var(--auth-card-bg-light-only); + cursor: pointer; + box-shadow: 0 0.125rem 0.375rem rgba(0, 0, 0, 0.04); /* 0 2px 6px */ + display: flex; + align-items: center; + justify-content: center; +} + +.oauth-button-grid:disabled { + cursor: not-allowed; + opacity: 0.6; +} + +.oauth-button-vertical { + width: 100%; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem 1rem; /* 16px 16px */ + border: 1px solid #d1d5db; + border-radius: 0.75rem; /* 12px */ + background-color: var(--auth-card-bg-light-only); + font-size: 1rem; /* 16px */ + font-weight: 500; + color: var(--auth-text-primary-light-only); + cursor: pointer; + gap: 0.75rem; /* 12px */ +} + +.oauth-button-vertical:disabled { + cursor: not-allowed; + opacity: 0.6; +} + +.oauth-icon-small { + width: 1.75rem; /* 28px */ + height: 1.75rem; /* 28px */ + display: block; +} + +.oauth-icon-medium { + width: 1.75rem; /* 28px */ + height: 1.75rem; /* 28px */ + display: block; +} + +.oauth-icon-tiny { + width: 1.25rem; /* 20px */ + height: 1.25rem; /* 20px */ +} + +/* Login Header Styles */ +.login-header { + margin-bottom: 1rem; /* 16px */ + margin-top: 0.5rem; /* 8px */ +} + +.login-header-logos { + display: flex; + align-items: center; + gap: 0.75rem; /* 12px */ + margin-bottom: 1.25rem; /* 20px */ +} + +.login-logo-icon { + width: 2.5rem; /* 40px */ + height: 2.5rem; /* 40px */ + border-radius: 0.5rem; /* 8px */ +} + +.login-logo-text { + height: 1.5rem; /* 24px */ +} + +.login-title { + font-size: 2rem; /* 32px */ + font-weight: 800; + color: var(--auth-text-primary-light-only); + margin: 0 0 0.375rem; /* 0 0 6px */ +} + +.login-subtitle { + color: var(--auth-text-secondary-light-only); + font-size: 0.875rem; /* 14px */ + margin: 0; +} + +/* Navigation Link Styles */ +.navigation-link-container { + text-align: center; +} + +.navigation-link-button { + background: none; + border: none; + color: var(--auth-label-text-light-only); + font-size: 0.875rem; /* 14px */ + cursor: pointer; + text-decoration: underline; +} + +.navigation-link-button:disabled { + cursor: not-allowed; + opacity: 0.6; +} + +/* Message Styles */ +.error-message { + padding: 1rem; /* 16px */ + background-color: #fef2f2; + border: 1px solid #fecaca; + border-radius: 0.5rem; /* 8px */ + margin-bottom: 1.5rem; /* 24px */ +} + +.error-message-text { + color: #dc2626; + font-size: 0.875rem; /* 14px */ + margin: 0; +} + +.success-message { + padding: 1rem; /* 16px */ + background-color: #f0fdf4; + border: 1px solid #bbf7d0; + border-radius: 0.5rem; /* 8px */ + margin-bottom: 1.5rem; /* 24px */ +} + +.success-message-text { + color: #059669; + font-size: 0.875rem; /* 14px */ + margin: 0; +} + +/* Field-level error styles */ +.auth-field-error { + color: #dc2626; + font-size: 0.6875rem; /* 11px */ + margin-top: 0.125rem; /* 2px */ + line-height: 1.1; +} + +.auth-input-error { + border-color: #dc2626 !important; +} + +.auth-input-error:focus { + border-color: #dc2626 !important; + box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.1) !important; +} + +/* Shared auth styles extracted from inline */ +.auth-section { + margin: 0.75rem 0; +} + +.auth-section-sm { + margin: 0.5rem 0; +} + +.auth-bottom-row { + display: flex; + align-items: center; + justify-content: space-between; + margin: 0.5rem 0 0.25rem; +} + +.auth-bottom-right { + display: flex; + align-items: center; + justify-content: flex-end; + margin-top: 0.5rem; +} + +.auth-link-black { + background: transparent; + border: 0; + padding: 0; + margin: 0; + text-decoration: underline; + cursor: pointer; + font-size: 0.875rem; /* 14px */ + color: #000; +} + +.auth-dot-black { + opacity: 0.5; + padding: 0 0.5rem; + color: #000; +} + +/* Email login button - red CTA style matching SaaS version */ +.auth-cta-button { + background-color: #AF3434 !important; + color: white !important; + border: none !important; + font-weight: 600 !important; +} + +.auth-cta-button:hover:not(:disabled) { + background-color: #9a2e2e !important; +} + +.auth-cta-button:disabled { + background-color: #AF3434 !important; + opacity: 0.6 !important; +} diff --git a/frontend/src/routes/login/EmailPasswordForm.tsx b/frontend/src/routes/login/EmailPasswordForm.tsx new file mode 100644 index 000000000..7036e24cf --- /dev/null +++ b/frontend/src/routes/login/EmailPasswordForm.tsx @@ -0,0 +1,86 @@ +import { useTranslation } from 'react-i18next' +import '../authShared/auth.css' + +interface EmailPasswordFormProps { + email: string + password: string + setEmail: (email: string) => void + setPassword: (password: string) => void + onSubmit: () => void + isSubmitting: boolean + submitButtonText: string + showPasswordField?: boolean + fieldErrors?: { + email?: string + password?: string + } +} + +export default function EmailPasswordForm({ + email, + password, + setEmail, + setPassword, + onSubmit, + isSubmitting, + submitButtonText, + showPasswordField = true, + fieldErrors = {} +}: EmailPasswordFormProps) { + const { t } = useTranslation() + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + onSubmit() + } + + return ( +
+
+
+ + setEmail(e.target.value)} + className={`auth-input ${fieldErrors.email ? 'auth-input-error' : ''}`} + /> + {fieldErrors.email && ( +
{fieldErrors.email}
+ )} +
+ + {showPasswordField && ( +
+ + setPassword(e.target.value)} + className={`auth-input ${fieldErrors.password ? 'auth-input-error' : ''}`} + /> + {fieldErrors.password && ( +
{fieldErrors.password}
+ )} +
+ )} +
+ + +
+ ) +} diff --git a/frontend/src/routes/login/ErrorMessage.tsx b/frontend/src/routes/login/ErrorMessage.tsx new file mode 100644 index 000000000..4f237b77c --- /dev/null +++ b/frontend/src/routes/login/ErrorMessage.tsx @@ -0,0 +1,13 @@ +interface ErrorMessageProps { + error: string | null +} + +export default function ErrorMessage({ error }: ErrorMessageProps) { + if (!error) return null + + return ( +
+

{error}

+
+ ) +} diff --git a/frontend/src/routes/login/LoggedInState.tsx b/frontend/src/routes/login/LoggedInState.tsx new file mode 100644 index 000000000..19483b8bc --- /dev/null +++ b/frontend/src/routes/login/LoggedInState.tsx @@ -0,0 +1,54 @@ +import { useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import { useAuth } from '../../auth/UseSession' +import { useTranslation } from 'react-i18next' + +export default function LoggedInState() { + const navigate = useNavigate() + const { user } = useAuth() + const { t } = useTranslation() + + useEffect(() => { + const timer = setTimeout(() => { + navigate('/') + }, 2000) + + return () => clearTimeout(timer) + }, [navigate]) + + return ( +
+
+
+
+

+ {t('login.youAreLoggedIn')} +

+

+ {t('login.email')}: {user?.email} +

+
+ +
+

+ Redirecting to home... +

+
+
+
+ ) +} diff --git a/frontend/src/routes/login/LoginHeader.tsx b/frontend/src/routes/login/LoginHeader.tsx new file mode 100644 index 000000000..093100574 --- /dev/null +++ b/frontend/src/routes/login/LoginHeader.tsx @@ -0,0 +1,22 @@ + +import { BASE_PATH } from '../../constants/app'; + +interface LoginHeaderProps { + title: string + subtitle?: string +} + +export default function LoginHeader({ title, subtitle }: LoginHeaderProps) { + return ( +
+
+ Logo + Stirling PDF +
+

{title}

+ {subtitle && ( +

{subtitle}

+ )} +
+ ) +} diff --git a/frontend/src/routes/login/NavigationLink.tsx b/frontend/src/routes/login/NavigationLink.tsx new file mode 100644 index 000000000..965a659b9 --- /dev/null +++ b/frontend/src/routes/login/NavigationLink.tsx @@ -0,0 +1,19 @@ +interface NavigationLinkProps { + onClick: () => void + text: string + isDisabled?: boolean +} + +export default function NavigationLink({ onClick, text, isDisabled = false }: NavigationLinkProps) { + return ( +
+ +
+ ) +} diff --git a/frontend/src/routes/login/OAuthButtons.tsx b/frontend/src/routes/login/OAuthButtons.tsx new file mode 100644 index 000000000..0de8f3179 --- /dev/null +++ b/frontend/src/routes/login/OAuthButtons.tsx @@ -0,0 +1,78 @@ +import { useTranslation } from 'react-i18next' +import { BASE_PATH } from '../../constants/app' + +// OAuth provider configuration +const oauthProviders = [ + { id: 'google', label: 'Google', file: 'google.svg', isDisabled: false }, + { id: 'github', label: 'GitHub', file: 'github.svg', isDisabled: false }, + { id: 'apple', label: 'Apple', file: 'apple.svg', isDisabled: true }, + { id: 'azure', label: 'Microsoft', file: 'microsoft.svg', isDisabled: true } +] + +interface OAuthButtonsProps { + onProviderClick: (provider: 'github' | 'google' | 'apple' | 'azure') => void + isSubmitting: boolean + layout?: 'vertical' | 'grid' | 'icons' +} + +export default function OAuthButtons({ onProviderClick, isSubmitting, layout = 'vertical' }: OAuthButtonsProps) { + const { t } = useTranslation() + + // Filter out disabled providers - don't show them at all + const enabledProviders = oauthProviders.filter(p => !p.isDisabled) + + if (layout === 'icons') { + return ( +
+ {enabledProviders.map((p) => ( +
+ +
+ ))} +
+ ) + } + + if (layout === 'grid') { + return ( +
+ {enabledProviders.map((p) => ( +
+ +
+ ))} +
+ ) + } + + return ( +
+ {enabledProviders.map((p) => ( + + ))} +
+ ) +} diff --git a/frontend/src/routes/signup/AuthService.ts b/frontend/src/routes/signup/AuthService.ts new file mode 100644 index 000000000..ce2426fa2 --- /dev/null +++ b/frontend/src/routes/signup/AuthService.ts @@ -0,0 +1,54 @@ +import { springAuth } from '../../auth/springAuthClient' +import { BASE_PATH } from '../../constants/app' + +export const useAuthService = () => { + + const signUp = async ( + email: string, + password: string, + name: string + ) => { + console.log('[Signup] Creating account for:', email) + + const { user, session, error } = await springAuth.signUp({ + email: email.trim(), + password: password, + options: { + data: { full_name: name }, + emailRedirectTo: `${BASE_PATH}/auth/callback` + } + }) + + if (error) { + console.error('[Signup] Sign up error:', error) + throw new Error(error.message) + } + + if (user) { + console.log('[Signup] Sign up successful:', user) + return { + user: user, + session: session, + requiresEmailConfirmation: user && !session + } + } + + throw new Error('Unknown error occurred during signup') + } + + const signInWithProvider = async (provider: 'github' | 'google' | 'apple' | 'azure') => { + const { error } = await springAuth.signInWithOAuth({ + provider, + options: { redirectTo: `${BASE_PATH}/auth/callback` } + }) + + if (error) { + throw new Error(error.message) + } + } + + return { + signUp, + signInWithProvider + } +} \ No newline at end of file diff --git a/frontend/src/routes/signup/SignupForm.tsx b/frontend/src/routes/signup/SignupForm.tsx new file mode 100644 index 000000000..2ae809467 --- /dev/null +++ b/frontend/src/routes/signup/SignupForm.tsx @@ -0,0 +1,162 @@ +import { useEffect } from 'react' +import '../authShared/auth.css' +import { useTranslation } from 'react-i18next' +import { SignupFieldErrors } from './SignupFormValidation' + +interface SignupFormProps { + name?: string + email: string + password: string + confirmPassword: string + agree?: boolean + setName?: (name: string) => void + setEmail: (email: string) => void + setPassword: (password: string) => void + setConfirmPassword: (password: string) => void + setAgree?: (agree: boolean) => void + onSubmit: () => void + isSubmitting: boolean + fieldErrors?: SignupFieldErrors + showName?: boolean + showTerms?: boolean +} + +export default function SignupForm({ + name = '', + email, + password, + confirmPassword, + agree = true, + setName, + setEmail, + setPassword, + setConfirmPassword, + setAgree, + onSubmit, + isSubmitting, + fieldErrors = {}, + showName = false, + showTerms = false +}: SignupFormProps) { + const { t } = useTranslation() + const showConfirm = password.length >= 4 + + useEffect(() => { + if (!showConfirm && confirmPassword) { + setConfirmPassword('') + } + }, [showConfirm, confirmPassword, setConfirmPassword]) + + return ( + <> +
+ {showName && ( +
+ + setName?.(e.target.value)} + className={`auth-input ${fieldErrors.name ? 'auth-input-error' : ''}`} + /> + {fieldErrors.name && ( +
{fieldErrors.name}
+ )} +
+ )} + +
+ + setEmail(e.target.value)} + onKeyPress={(e) => e.key === 'Enter' && !isSubmitting && onSubmit()} + className={`auth-input ${fieldErrors.email ? 'auth-input-error' : ''}`} + /> + {fieldErrors.email && ( +
{fieldErrors.email}
+ )} +
+ +
+ + setPassword(e.target.value)} + onKeyPress={(e) => e.key === 'Enter' && !isSubmitting && onSubmit()} + className={`auth-input ${fieldErrors.password ? 'auth-input-error' : ''}`} + /> + {fieldErrors.password && ( +
{fieldErrors.password}
+ )} +
+ +
+
+ + setConfirmPassword(e.target.value)} + onKeyPress={(e) => e.key === 'Enter' && !isSubmitting && onSubmit()} + className={`auth-input ${fieldErrors.confirmPassword ? 'auth-input-error' : ''}`} + /> + {fieldErrors.confirmPassword && ( +
{fieldErrors.confirmPassword}
+ )} +
+
+
+ + {/* Terms - only show if showTerms is true */} + {showTerms && ( +
+ setAgree?.(e.target.checked)} + className="auth-checkbox" + /> + +
+ )} + + {/* Sign Up Button */} + + + ) +} \ No newline at end of file diff --git a/frontend/src/routes/signup/SignupFormValidation.ts b/frontend/src/routes/signup/SignupFormValidation.ts new file mode 100644 index 000000000..7abe498a0 --- /dev/null +++ b/frontend/src/routes/signup/SignupFormValidation.ts @@ -0,0 +1,66 @@ +import { useTranslation } from 'react-i18next' + +export interface SignupFieldErrors { + name?: string + email?: string + password?: string + confirmPassword?: string +} + +export interface SignupValidationResult { + isValid: boolean + error: string | null + fieldErrors?: SignupFieldErrors +} + +export const useSignupFormValidation = () => { + const { t } = useTranslation() + + const validateSignupForm = ( + email: string, + password: string, + confirmPassword: string, + name?: string + ): SignupValidationResult => { + const fieldErrors: SignupFieldErrors = {} + + // Validate name + if (name !== undefined && name !== null && !name.trim()) { + fieldErrors.name = t('signup.nameRequired', 'Name is required') + } + + // Validate email + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + if (!email) { + fieldErrors.email = t('signup.emailRequired', 'Email is required') + } else if (!emailRegex.test(email)) { + fieldErrors.email = t('signup.invalidEmail') + } + + // Validate password + if (!password) { + fieldErrors.password = t('signup.passwordRequired', 'Password is required') + } else if (password.length < 6) { + fieldErrors.password = t('signup.passwordTooShort') + } + + // Validate confirm password + if (!confirmPassword) { + fieldErrors.confirmPassword = t('signup.confirmPasswordRequired', 'Please confirm your password') + } else if (password !== confirmPassword) { + fieldErrors.confirmPassword = t('signup.passwordsDoNotMatch') + } + + const hasErrors = Object.keys(fieldErrors).length > 0 + + return { + isValid: !hasErrors, + error: null, // Don't show generic error, field errors are more specific + fieldErrors: hasErrors ? fieldErrors : undefined + } + } + + return { + validateSignupForm + } +} \ No newline at end of file diff --git a/frontend/src/services/apiClient.ts b/frontend/src/services/apiClient.ts index 536c47448..906c3cefb 100644 --- a/frontend/src/services/apiClient.ts +++ b/frontend/src/services/apiClient.ts @@ -8,6 +8,35 @@ const apiClient = axios.create({ responseType: 'json', }); +// Helper function to get JWT token from localStorage +function getJwtTokenFromStorage(): string | null { + try { + return localStorage.getItem('stirling_jwt'); + } catch (error) { + console.error('[API Client] Failed to read JWT from localStorage:', error); + return null; + } +} + +// ---------- Install request interceptor to add JWT token ---------- +apiClient.interceptors.request.use( + (config) => { + // Get JWT token from localStorage + const jwtToken = getJwtTokenFromStorage(); + + // If token exists and Authorization header is not already set, add it + if (jwtToken && !config.headers.Authorization) { + config.headers.Authorization = `Bearer ${jwtToken}`; + console.debug('[API Client] Added JWT token from localStorage to Authorization header'); + } + + return config; + }, + (error) => { + return Promise.reject(error); + } +); + // ---------- Install error interceptor ---------- apiClient.interceptors.response.use( (response) => response, diff --git a/frontend/src/services/preferencesService.ts b/frontend/src/services/preferencesService.ts index 5a8ff6286..da5e4350b 100644 --- a/frontend/src/services/preferencesService.ts +++ b/frontend/src/services/preferencesService.ts @@ -8,6 +8,7 @@ export interface UserPreferences { theme: ThemeMode; toolPanelModePromptSeen: boolean; showLegacyToolDescriptions: boolean; + hasCompletedOnboarding: boolean; } export const DEFAULT_PREFERENCES: UserPreferences = { @@ -17,6 +18,7 @@ export const DEFAULT_PREFERENCES: UserPreferences = { theme: getSystemTheme(), toolPanelModePromptSeen: false, showLegacyToolDescriptions: false, + hasCompletedOnboarding: false, }; const STORAGE_KEY = 'stirlingpdf_preferences'; diff --git a/frontend/src/setupTests.ts b/frontend/src/setupTests.ts index 38456157a..3ebb22fda 100644 --- a/frontend/src/setupTests.ts +++ b/frontend/src/setupTests.ts @@ -119,5 +119,49 @@ Object.defineProperty(window, 'matchMedia', { })), }); +// Provide a minimal DOMMatrix implementation for pdf.js in the test environment +if (typeof globalThis.DOMMatrix === 'undefined') { + class DOMMatrixStub { + a = 1; + b = 0; + c = 0; + d = 1; + e = 0; + f = 0; + + constructor(init?: string | number[]) { + if (Array.isArray(init) && init.length === 6) { + [this.a, this.b, this.c, this.d, this.e, this.f] = init as [number, number, number, number, number, number]; + } + } + + multiplySelf(): this { + return this; + } + + translateSelf(): this { + return this; + } + + scaleSelf(): this { + return this; + } + + rotateSelf(): this { + return this; + } + + inverse(): this { + return this; + } + } + + Object.defineProperty(globalThis, 'DOMMatrix', { + value: DOMMatrixStub, + writable: false, + configurable: true, + }); +} + // Set global test timeout to prevent hangs vi.setConfig({ testTimeout: 5000, hookTimeout: 5000 }); diff --git a/frontend/src/styles/theme.css b/frontend/src/styles/theme.css index a1f7667c7..090606c4f 100644 --- a/frontend/src/styles/theme.css +++ b/frontend/src/styles/theme.css @@ -263,6 +263,27 @@ --modal-content-bg: #ffffff; --modal-header-border: rgba(0, 0, 0, 0.06); + /* Auth page colors (light mode only - auth pages force light mode) */ + --auth-bg-color-light-only: #f3f4f6; + --auth-card-bg: #ffffff; + --auth-card-bg-light-only: #ffffff; + --auth-label-text-light-only: #374151; + --auth-input-border-light-only: #d1d5db; + --auth-input-bg-light-only: #ffffff; + --auth-input-text-light-only: #111827; + --auth-border-focus-light-only: #3b82f6; + --auth-focus-ring-light-only: rgba(59, 130, 246, 0.1); + --auth-button-bg-light-only: #AF3434; + --auth-button-text-light-only: #ffffff; + --auth-magic-button-bg-light-only: #e5e7eb; + --auth-magic-button-text-light-only: #374151; + --auth-text-primary-light-only: #111827; + --auth-text-secondary-light-only: #6b7280; + --text-divider-rule-rgb-light: 229, 231, 235; + --text-divider-label-rgb-light: 156, 163, 175; + --tool-subcategory-rule-color-light: #e5e7eb; + --tool-subcategory-text-color-light: #9ca3af; + /* PDF Report Colors (always light) */ --pdf-light-header-bg: 239 246 255; --pdf-light-accent: 59 130 246; @@ -482,7 +503,7 @@ /* Tool panel search bar background colors (dark mode) */ --tool-panel-search-bg: #1F2329; --tool-panel-search-border-bottom: #4B525A; - + --information-text-bg: #292e34; --information-text-color: #ececec; diff --git a/frontend/src/tests/missingTranslations.test.ts b/frontend/src/tests/missingTranslations.test.ts new file mode 100644 index 000000000..908ab1504 --- /dev/null +++ b/frontend/src/tests/missingTranslations.test.ts @@ -0,0 +1,178 @@ +import fs from 'fs'; +import path from 'path'; +import ts from 'typescript'; +import { describe, expect, test } from 'vitest'; + +const REPO_ROOT = path.join(__dirname, '../../../') +const SRC_ROOT = path.join(__dirname, '..'); +const EN_GB_FILE = path.join(__dirname, '../../public/locales/en-GB/translation.json'); + +const IGNORED_DIRS = new Set([ + 'tests', + '__mocks__', +]); +const IGNORED_FILE_PATTERNS = [ + /\.d\.ts$/, + /\.test\./, + /\.spec\./, + /\.stories\./, +]; +const IGNORED_KEYS = new Set([ + // If the script has found a false-positive that shouldn't be in the translations, include it here +]); + +type FoundKey = { + key: string; + file: string; + line: number; + column: number; +}; + +const flattenKeys = (node: unknown, prefix = '', acc = new Set()): Set => { + if (!node || typeof node !== 'object' || Array.isArray(node)) { + if (prefix) { + acc.add(prefix); + } + return acc; + } + + for (const [childKey, value] of Object.entries(node as Record)) { + const next = prefix ? `${prefix}.${childKey}` : childKey; + flattenKeys(value, next, acc); + } + + return acc; +}; + +const listSourceFiles = (): string[] => { + const files = ts.sys.readDirectory(SRC_ROOT, ['.ts', '.tsx', '.js', '.jsx'], undefined, [ + '**/*', + ]); + + return files + .filter((file) => !file.split(path.sep).some((segment) => IGNORED_DIRS.has(segment))) + .filter((file) => !IGNORED_FILE_PATTERNS.some((re) => re.test(file))); +}; + +const getScriptKind = (file: string): ts.ScriptKind => { + if (file.endsWith('.tsx')) { + return ts.ScriptKind.TSX; + } + + if (file.endsWith('.ts')) { + return ts.ScriptKind.TS; + } + + if (file.endsWith('.jsx')) { + return ts.ScriptKind.JSX; + } + + return ts.ScriptKind.JS; +}; + +/** + * Find all of the static first keys for translation functions that we can. + * Ignores dynamic strings because we can't know what the actual translation key will be. + */ +const extractKeys = (file: string): FoundKey[] => { + const code = fs.readFileSync(file, 'utf8'); + const sourceFile = ts.createSourceFile( + file, + code, + ts.ScriptTarget.Latest, + true, + getScriptKind(file), + ); + + const found: FoundKey[] = []; + + const record = (node: ts.Node, key: string) => { + const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart()); + found.push({ key, file, line: line + 1, column: character + 1 }); + }; + + const visit = (node: ts.Node) => { + if (ts.isCallExpression(node)) { + const callee = node.expression; + const arg = node.arguments.at(0); + + const isT = + (ts.isIdentifier(callee) && callee.text === 't') || + (ts.isPropertyAccessExpression(callee) && callee.name.text === 't'); + + if (isT && arg && (ts.isStringLiteral(arg) || ts.isNoSubstitutionTemplateLiteral(arg))) { + record(arg, arg.text); + } + } + + if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) { + for (const attr of node.attributes.properties) { + if ( + !ts.isJsxAttribute(attr) || + attr.name.getText(sourceFile) !== 'i18nKey' || + !attr.initializer + ) { + continue; + } + + const init = attr.initializer; + + if (ts.isStringLiteral(init)) { + record(init, init.text); + continue; + } + + if ( + ts.isJsxExpression(init) && + init.expression && + ts.isStringLiteral(init.expression) + ) { + record(init.expression, init.expression.text); + } + } + } + + ts.forEachChild(node, visit); + }; + + ts.forEachChild(sourceFile, visit); + return found; +}; + +describe('Missing translation coverage', () => { + test('fails if any en-GB translation key used in source is missing', () => { + expect(fs.existsSync(EN_GB_FILE)).toBe(true); + + const localeContent = fs.readFileSync(EN_GB_FILE, 'utf8'); + const enGb = JSON.parse(localeContent); + const availableKeys = flattenKeys(enGb); + + const usedKeys = listSourceFiles() + .flatMap(extractKeys) + .filter(({ key }) => !IGNORED_KEYS.has(key)); + expect(usedKeys.length).toBeGreaterThan(100); // Sanity check + + const missingKeys = usedKeys.filter(({ key }) => !availableKeys.has(key)); + + const annotations = missingKeys.map(({ key, file, line, column }) => { + const workspaceRelativeRaw = path.relative(REPO_ROOT, file); + const workspaceRelativeFile = workspaceRelativeRaw.replace(/\\/g, '/'); + + return { + key, + file: workspaceRelativeFile, + line, + column, + }; + }); + + // Output errors in GitHub Annotations format so they appear tagged in the code in CI + for (const { key, file, line, column } of annotations) { + process.stderr.write( + `::error file=${file},line=${line},col=${column}::Missing en-GB translation for ${key}\n`, + ); + } + + expect(missingKeys).toEqual([]); + }); +}); diff --git a/frontend/src/tools/Automate.tsx b/frontend/src/tools/Automate.tsx index 4ec6dc646..c91ad8825 100644 --- a/frontend/src/tools/Automate.tsx +++ b/frontend/src/tools/Automate.tsx @@ -12,7 +12,7 @@ import AutomationRun from "../components/tools/automate/AutomationRun"; import { useAutomateOperation } from "../hooks/tools/automate/useAutomateOperation"; import { BaseToolProps } from "../types/tool"; -import { useFlatToolRegistry } from "../data/useTranslatedToolRegistry"; +import { useToolRegistry } from "../contexts/ToolRegistryContext"; import { useSavedAutomations } from "../hooks/tools/automate/useSavedAutomations"; import { AutomationConfig, AutomationStepData, AutomationMode, AutomationStep } from "../types/automation"; import { AUTOMATION_STEPS } from "../constants/automation"; @@ -27,7 +27,7 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const [stepData, setStepData] = useState({ step: AUTOMATION_STEPS.SELECTION }); const automateOperation = useAutomateOperation(); - const toolRegistry = useFlatToolRegistry(); + const { regularTools: toolRegistry } = useToolRegistry(); const hasResults = automateOperation.files.length > 0 || automateOperation.downloadUrl !== null; const { savedAutomations, deleteAutomation, refreshAutomations, copyFromSuggested } = useSavedAutomations(); diff --git a/frontend/src/tools/Crop.tsx b/frontend/src/tools/Crop.tsx index d185e3877..4cf6e6b6e 100644 --- a/frontend/src/tools/Crop.tsx +++ b/frontend/src/tools/Crop.tsx @@ -28,7 +28,7 @@ const Crop = (props: BaseToolProps) => { steps: [ { title: t("crop.steps.selectArea", "Select Crop Area"), - isCollapsed: !base.hasFiles, // Collapsed until files selected + isCollapsed: base.settingsCollapsed, onCollapsedClick: base.hasResults ? base.handleSettingsReset : undefined, tooltip: tooltips, content: ( diff --git a/frontend/src/types/toolId.ts b/frontend/src/types/toolId.ts index a839997b0..d485cd220 100644 --- a/frontend/src/types/toolId.ts +++ b/frontend/src/types/toolId.ts @@ -1,5 +1,6 @@ -// Define all possible tool IDs as source of truth -export const TOOL_IDS = [ +export type ToolKind = 'regular' | 'super' | 'link'; + +export const REGULAR_TOOL_IDS = [ 'certSign', 'sign', 'addPassword', @@ -26,7 +27,6 @@ export const TOOL_IDS = [ 'adjustContrast', 'crop', 'pdfToSinglePage', - 'multiTool', 'repair', 'compare', 'addPageNumbers', @@ -44,21 +44,52 @@ export const TOOL_IDS = [ 'overlayPdfs', 'getPdfInfo', 'validateSignature', - 'read', - 'automate', 'replaceColor', 'showJS', + 'bookletImposition', +] as const; + +export const SUPER_TOOL_IDS = [ + 'multiTool', + 'read', + 'automate', +] as const; + +const LINK_TOOL_IDS = [ 'devApi', 'devFolderScanning', 'devSsoGuide', 'devAirgapped', - 'bookletImposition', ] as const; +const TOOL_IDS = [ + ...REGULAR_TOOL_IDS, + ...SUPER_TOOL_IDS, + ...LINK_TOOL_IDS, +]; + // Tool identity - what PDF operation we're performing (type-safe) export type ToolId = typeof TOOL_IDS[number]; +export const isValidToolId = (value: string): value is ToolId => + TOOL_IDS.includes(value as ToolId); + +export type RegularToolId = typeof REGULAR_TOOL_IDS[number]; +export const isRegularToolId = (toolId: ToolId): toolId is RegularToolId => + REGULAR_TOOL_IDS.includes(toolId as RegularToolId); + +export type SuperToolId = typeof SUPER_TOOL_IDS[number]; +export const isSuperToolId = (toolId: ToolId): toolId is SuperToolId => + SUPER_TOOL_IDS.includes(toolId as SuperToolId); + +export type LinkToolId = typeof LINK_TOOL_IDS[number]; +export const isLinkToolId = (toolId: ToolId): toolId is LinkToolId => + LINK_TOOL_IDS.includes(toolId as LinkToolId); + + +type Assert = A; +type Disjoint = [A & B] extends [never] ? true : false; + +type _Check1 = Assert>; +type _Check2 = Assert>; +type _Check3 = Assert>; -// Type guard using the same source of truth -export const isValidToolId = (value: string): value is ToolId => { - return TOOL_IDS.includes(value as ToolId); -}; diff --git a/frontend/src/utils/automationExecutor.ts b/frontend/src/utils/automationExecutor.ts index cb34d3407..8c59be9b1 100644 --- a/frontend/src/utils/automationExecutor.ts +++ b/frontend/src/utils/automationExecutor.ts @@ -1,5 +1,6 @@ import axios from 'axios'; import { ToolRegistry } from '../data/toolsTaxonomy'; +import { ToolId } from '../types/toolId'; import { AUTOMATION_CONSTANTS } from '../constants/automation'; import { AutomationFileProcessor } from './automationFileProcessor'; import { ToolType } from '../hooks/tools/shared/useToolOperation'; @@ -149,7 +150,7 @@ export const executeToolOperationWithPrefix = async ( toolRegistry: ToolRegistry, filePrefix: string = AUTOMATION_CONSTANTS.FILE_PREFIX ): Promise => { - const config = toolRegistry[operationName as keyof ToolRegistry]?.operationConfig; + const config = toolRegistry[operationName as ToolId]?.operationConfig; if (!config) { throw new Error(`Tool operation not supported: ${operationName}`); } diff --git a/frontend/src/utils/signatureFlattening.ts b/frontend/src/utils/signatureFlattening.ts index ccb739253..062313df5 100644 --- a/frontend/src/utils/signatureFlattening.ts +++ b/frontend/src/utils/signatureFlattening.ts @@ -2,7 +2,7 @@ import { PDFDocument, rgb } from 'pdf-lib'; import { generateThumbnailWithMetadata } from './thumbnailUtils'; import { createProcessedFile, createChildStub } from '../contexts/file/fileActions'; import { createStirlingFile, StirlingFile, FileId, StirlingFileStub } from '../types/fileContext'; -import type { SignatureAPI } from '../components/viewer/SignatureAPIBridge'; +import type { SignatureAPI } from '../components/viewer/viewerTypes'; interface MinimalFileContextSelectors { getAllFileIds: () => FileId[]; @@ -319,4 +319,4 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr console.error('Error flattening signatures:', error); return null; } -} \ No newline at end of file +} diff --git a/frontend/src/utils/toolSearch.ts b/frontend/src/utils/toolSearch.ts index b2ec9b590..595bf7c19 100644 --- a/frontend/src/utils/toolSearch.ts +++ b/frontend/src/utils/toolSearch.ts @@ -1,5 +1,5 @@ import { ToolId } from "src/types/toolId"; -import { ToolRegistryEntry } from "../data/toolsTaxonomy"; +import { ToolRegistryEntry, ToolRegistry } from "../data/toolsTaxonomy"; import { scoreMatch, minScoreForQuery, normalizeForSearch } from "./fuzzySearch"; export interface RankedToolItem { @@ -8,7 +8,7 @@ export interface RankedToolItem { } export function filterToolRegistryByQuery( - toolRegistry: Record, + toolRegistry: Partial, query: string ): RankedToolItem[] { const entries = Object.entries(toolRegistry) as [ToolId, ToolRegistryEntry][]; @@ -96,5 +96,3 @@ export function filterToolRegistryByQuery( return entries.map(([id, tool]) => ({ item: [id, tool] as [ToolId, ToolRegistryEntry] })); } - - diff --git a/frontend/src/utils/urlRouting.ts b/frontend/src/utils/urlRouting.ts index 14bc3752f..12abf2ae0 100644 --- a/frontend/src/utils/urlRouting.ts +++ b/frontend/src/utils/urlRouting.ts @@ -115,4 +115,3 @@ export function getToolDisplayName(toolId: ToolId, registry: ToolRegistry): stri const tool = registry[toolId]; return tool ? tool.name : toolId; } - diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 59ebfd663..05d5da57b 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -10,6 +10,16 @@ export default defineConfig({ changeOrigin: true, secure: false, }, + '/oauth2': { + target: 'http://localhost:8080', + changeOrigin: true, + secure: false, + }, + '/login/oauth2': { + target: 'http://localhost:8080', + changeOrigin: true, + secure: false, + }, }, }, base: process.env.RUN_SUBPATH ? `/${process.env.RUN_SUBPATH}` : './',