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/package-lock.json b/frontend/package-lock.json index 272c4d974..7dd9e15cd 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -436,6 +436,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -482,6 +483,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -505,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" @@ -585,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" }, @@ -601,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" }, @@ -617,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" }, @@ -651,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" }, @@ -683,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" }, @@ -717,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" }, @@ -788,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" }, @@ -941,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", @@ -984,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", @@ -2017,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", @@ -2067,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" } @@ -2134,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", @@ -2326,6 +2341,203 @@ } } }, + "node_modules/@napi-rs/canvas": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz", + "integrity": "sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==", + "license": "MIT", + "optional": true, + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.80", + "@napi-rs/canvas-darwin-arm64": "0.1.80", + "@napi-rs/canvas-darwin-x64": "0.1.80", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.80", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.80", + "@napi-rs/canvas-linux-arm64-musl": "0.1.80", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.80", + "@napi-rs/canvas-linux-x64-gnu": "0.1.80", + "@napi-rs/canvas-linux-x64-musl": "0.1.80", + "@napi-rs/canvas-win32-x64-msvc": "0.1.80" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.80.tgz", + "integrity": "sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.80.tgz", + "integrity": "sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.80.tgz", + "integrity": "sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.80.tgz", + "integrity": "sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.80.tgz", + "integrity": "sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.80.tgz", + "integrity": "sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.80.tgz", + "integrity": "sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.80.tgz", + "integrity": "sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.80.tgz", + "integrity": "sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.80.tgz", + "integrity": "sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "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", @@ -3379,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", @@ -3702,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" } @@ -3712,6 +3926,7 @@ "integrity": "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.0.0" } @@ -3772,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", @@ -3971,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", @@ -4216,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" } @@ -4226,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" @@ -4237,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", @@ -4250,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" @@ -4278,6 +4759,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4952,6 +5434,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001741", @@ -5955,7 +6438,8 @@ "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" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/dezalgo": { "version": "1.0.4", @@ -6350,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", @@ -6521,6 +7006,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -7794,6 +8280,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.27.6" }, @@ -8590,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", @@ -10378,6 +10866,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -10669,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" @@ -11041,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" } @@ -11050,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" }, @@ -12687,6 +13179,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -12969,6 +13462,7 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13051,6 +13545,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -13270,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", @@ -13401,6 +13897,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -13414,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", 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 5cb16aacd..904817c75 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -3038,6 +3038,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", @@ -3076,6 +3081,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}}" @@ -3974,6 +3983,34 @@ "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!", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b145ee8a5..fd4d466ad 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,5 @@ 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"; @@ -11,9 +12,15 @@ 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"; import "./styles/cookieconsent.css"; @@ -44,35 +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/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/LoginRightCarousel.tsx b/frontend/src/components/shared/LoginRightCarousel.tsx new file mode 100644 index 000000000..c2ebb29bf --- /dev/null +++ b/frontend/src/components/shared/LoginRightCarousel.tsx @@ -0,0 +1,159 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { BASE_PATH } from '../../constants/app'; + +type ImageSlide = { src: string; alt?: string; cornerModelUrl?: string; title?: string; subtitle?: string; followMouseTilt?: boolean; tiltMaxDeg?: number } + +export default function LoginRightCarousel({ + imageSlides = [], + showBackground = true, + initialSeconds = 5, + slideSeconds = 8, +}: { + imageSlides?: ImageSlide[] + showBackground?: boolean + initialSeconds?: number + slideSeconds?: number +}) { + const totalSlides = imageSlides.length + const [index, setIndex] = useState(0) + const mouse = useRef({ x: 0, y: 0 }) + + const durationsMs = useMemo(() => { + 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/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/contexts/OnboardingContext.tsx b/frontend/src/contexts/OnboardingContext.tsx index 78a3461c1..d2898e46d 100644 --- a/frontend/src/contexts/OnboardingContext.tsx +++ b/frontend/src/contexts/OnboardingContext.tsx @@ -1,6 +1,7 @@ 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; @@ -18,6 +19,7 @@ const OnboardingContext = createContext(unde 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); @@ -26,11 +28,16 @@ export const OnboardingProvider: React.FC<{ children: React.ReactNode }> = ({ ch // 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 (!preferences.hasCompletedOnboarding && preferences.toolPanelModePromptSeen && !isMobile) { - setShowWelcomeModal(true); + 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]); + }, [preferences.hasCompletedOnboarding, preferences.toolPanelModePromptSeen, isMobile, session, loading]); const startTour = useCallback(() => { setCurrentStep(0); 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/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/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/styles/theme.css b/frontend/src/styles/theme.css index a3b9b0b89..642372870 100644 --- a/frontend/src/styles/theme.css +++ b/frontend/src/styles/theme.css @@ -262,6 +262,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; @@ -480,7 +501,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/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}` : './',