Compare commits

...
Author SHA1 Message Date
DarioGii cb14b592e1 wip 2025-10-24 17:39:44 +01:00
DarioGii 6337fbd30d 1. CORS Configuration Validation - Prevents allowCredentials(true) with ["*"] origins
2. OAuth2/SAML2 Redirect Security - Validates Referer against CORS whitelist, prevents JWT leakage
3. JWT in HttpOnly Cookies - Moved JWT from URL fragments to secure HttpOnly cookies
4. Refresh Token Infrastructure - Complete implementation with rotation and revocation
5. V2 Flag Removal - Removed from application.properties, AppConfig, and JwtService
2025-10-24 14:15:43 +01:00
DarioGii b901a66466 more cleanup, updated translations 2025-10-23 17:47:52 +01:00
DarioGii 4fbb22a721 reverted unrelated changes 2025-10-23 17:13:59 +01:00
DarioGii 6f7267f3d7 addressing comments 2025-10-23 17:05:04 +01:00
DarioGii 0aaa5d3bb5 addressing comments 2025-10-23 16:58:26 +01:00
DarioGii cd03c7487a cleaning up 2025-10-23 12:02:13 +01:00
DarioGii 7e6f9524ea added missing translations 2025-10-23 11:45:17 +01:00
Dario Ghunney Ware aa73530804 cleaned imports, fixed enum 2025-10-21 16:20:41 +01:00
Dario Ghunney Ware 0f079d966b More cleanup 2025-10-21 15:58:44 +01:00
Dario Ghunney Ware e0512e0931 Enable access to tools with security disabled 2025-10-21 15:58:33 +01:00
Dario Ghunney Ware 9fde241d1e Little cleanup 2025-10-21 15:58:33 +01:00
Dario Ghunney Ware 0e2e6d06fd Fixed tests 2025-10-21 15:57:44 +01:00
Dario Ghunney Ware 378bddaa7e Fixed SSO user identity management 2025-10-21 15:57:44 +01:00
Dario Ghunney Ware 833791b603 Fixed oauth logins, moved jwt storage from cookie to auth header 2025-10-21 15:57:44 +01:00
Dario Ghunney Ware 38a97dc3e3 Fixed login refresh, added logout 2025-10-21 15:57:44 +01:00
Dario Ghunney Ware a969f13715 Fixing user refresh isuue 2025-10-21 15:57:07 +01:00
Dario Ghunney Ware 01d18279e0 Added rest api for signup/login. added frontend auth service 2025-10-21 15:56:59 +01:00
stirlingbot[bot] 2826920cc4 🤖 format everything with pre-commit by stirlingbot (#4175)
Auto-generated by [create-pull-request][1] with **stirlingbot**

[1]: https://github.com/peter-evans/create-pull-request

Signed-off-by: stirlingbot[bot] <stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
2025-10-21 15:39:04 +01:00
Dario Ghunney Ware 0496aba6d9 JWT Authentication (#3921)
This PR introduces JWT (JSON Web Token) authentication for Stirling-PDF,
allowing for stateless authentication capabilities alongside the
existing session-based authentication system.

### Key Features & Changes

  JWT Authentication System
- Core Service: JwtService.java - Token generation, validation, and
cookie management
- Authentication Filter: JwtAuthenticationFilter.java - Request
interceptor for JWT validation
- Key Management: KeyPersistenceService.java +
KeyPairCleanupService.java - RSA key rotation and persistence
  - Frontend: jwt-init.js - Client-side JWT handling and URL cleanup

  Security Integration
- SAML2: JwtSaml2AuthenticationRequestRepository.java - JWT-backed SAML
request storage
- OAuth2: Updated CustomAuthenticationSuccessHandler. java,
CustomOAuth2AuthenticationSuccessHandler.java &
CustomSaml2AuthenticationSuccessHandler.java for JWT integration
- Configuration: Enhanced SecurityConfiguration.java with JWT filter
chain

  Infrastructure
  - Caching: CacheConfig.java - Caffeine cache for JWT keys
  - Database: New JwtVerificationKey.java entity for key storage
- Error Handling: JwtAuthenticationEntryPoint.java for unauthorized
access

### Challenges Encountered

- Configured SecurityConfiguration to use either
`UsernamePasswordAuthenticationFilter` or `JWTAuthenticationFilter`
based on whether JWTs are enabled to prevent the former intercepting
requests while in stateless mode.
- Removed the `.defaultSuccessUrl("/")` from login configuration as its
inclusion was preventing overriding the use of the
`CustomAuthenticationSuccessHandler` and preventing proper
authentication flows.
---

## Checklist

### General

- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [x] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [x] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings

### Documentation

- [x] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [x] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [x] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)
<img width="599" height="515" alt="Screenshot 2025-07-10 at 13 35 56"
src="https://github.com/user-attachments/assets/4126b752-ad0d-4ffa-b295-6714c43381e1"
/>

<img width="392" height="376" alt="Screenshot 2025-07-10 at 13 36 10"
src="https://github.com/user-attachments/assets/c681bc43-68ff-4934-8245-d544e2ad7b9c"
/>

<img width="1870" height="986" alt="eb750e8c3954fc47b2dd2e6e76ddb7d5"
src="https://github.com/user-attachments/assets/fca9b23d-b0b6-4884-8a26-98a441b641ef"
/>

<img width="1299" height="702" alt="Screenshot 2025-07-10 at 13 30 57"
src="https://github.com/user-attachments/assets/9415d8bf-fac4-4d38-8c3a-985d043d1076"
/>

### Testing (if applicable)

- [x] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Ludy <Ludy87@users.noreply.github.com>
Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
Co-authored-by: Ethan <ethan@MacBook-Pro.local>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
# Conflicts:
#	.claude/settings.local.json
#	app/common/src/main/java/stirling/software/common/configuration/AppConfig.java
#	app/core/src/main/resources/static/js/fetch-utils.js
#	app/core/src/main/resources/static/js/jwt-init.js
#	app/proprietary/src/main/java/stirling/software/proprietary/security/model/Authority.java
#	app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java
2025-10-21 15:39:04 +01:00
70 changed files with 4820 additions and 321 deletions
@@ -49,14 +49,6 @@ public class AppConfig {
@Value("${server.port:8080}")
private String serverPort;
@Value("${v2}")
public boolean v2Enabled;
@Bean
public boolean v2Enabled() {
return v2Enabled;
}
/* Commented out Thymeleaf template engine bean - to be removed when frontend migration is complete
@Bean
@ConditionalOnProperty(name = "system.customHTMLFiles", havingValue = "true")
@@ -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<String> corsAllowedOrigins = new ArrayList<>();
public boolean isAnalyticsEnabled() {
return this.getEnableAnalytics() != null && this.getEnableAnalytics();
@@ -1,22 +1,83 @@
package stirling.software.SPDF.config;
import jakarta.annotation.PostConstruct;
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 lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
@Slf4j
@Configuration
@RequiredArgsConstructor
public class WebMvcConfig implements WebMvcConfigurer {
private final EndpointInterceptor endpointInterceptor;
private final ApplicationProperties applicationProperties;
/**
* Validates CORS configuration on application startup to prevent runtime errors
* Spring will reject allowCredentials(true) + allowedOrigins("*") at runtime
* This validation provides a clear error message during startup instead
*/
@PostConstruct
public void validateCorsConfiguration() {
if (applicationProperties.getSystem() != null
&& applicationProperties.getSystem().getCorsAllowedOrigins() != null
&& !applicationProperties.getSystem().getCorsAllowedOrigins().isEmpty()) {
var allowedOrigins = applicationProperties.getSystem().getCorsAllowedOrigins();
// Check if wildcard "*" is used with credentials
if (allowedOrigins.contains("*")) {
String errorMessage =
"INVALID CORS CONFIGURATION: Cannot use allowedOrigins=[\"*\"] with allowCredentials=true.\n"
+ "This configuration is rejected by Spring Security at runtime.\n"
+ "Please specify exact origins in system.corsAllowedOrigins (e.g., [\"http://localhost:3000\", \"https://example.com\"])\n"
+ "or remove credentials support by modifying WebMvcConfig.";
log.error(errorMessage);
throw new IllegalStateException(errorMessage);
}
log.info(
"CORS configuration validated successfully. Allowed origins: {}",
allowedOrigins);
}
}
@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
@@ -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
@@ -58,6 +58,3 @@ spring.main.allow-bean-definition-overriding=true
# Set up a consistent temporary directory location
java.io.tmpdir=${stirling.tempfiles.directory:${java.io.tmpdir}/stirling-pdf}
# V2 features
v2=false
@@ -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
+1
View File
@@ -43,6 +43,7 @@ dependencies {
api "org.springframework.security:spring-security-core:$springSecuritySamlVersion"
api "org.springframework.security:spring-security-web:$springSecuritySamlVersion"
api "org.springframework.security:spring-security-config:$springSecuritySamlVersion"
api("org.springframework.boot:spring-boot-starter-oauth2-resource-server:3.5.7")
api "org.springframework.security:spring-security-saml2-service-provider:$springSecuritySamlVersion"
api 'org.springframework.boot:spring-boot-starter-jetty'
api 'org.springframework.boot:spring-boot-starter-security'
@@ -57,9 +57,11 @@ public class CustomAuthenticationSuccessHandler
String jwt =
jwtService.generateToken(
authentication, Map.of("authType", AuthenticationType.WEB));
jwtService.addToken(response, jwt);
log.debug("JWT generated for user: {}", userName);
// Set JWT as HttpOnly cookie for security
jwtService.setJwtCookie(response, jwt, request.getContextPath());
getRedirectStrategy().sendRedirect(request, response, "/");
} else {
// Get the saved request
@@ -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 {}",
@@ -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();
}
@@ -68,7 +68,7 @@ public class SecurityConfiguration {
private final boolean loginEnabledValue;
private final boolean runningProOrHigher;
private final ApplicationProperties.Security securityProperties;
private final ApplicationProperties applicationProperties;
private final AppConfig appConfig;
private final UserAuthenticationFilter userAuthenticationFilter;
private final JwtServiceInterface jwtService;
@@ -88,7 +88,7 @@ public class SecurityConfiguration {
@Qualifier("loginEnabled") boolean loginEnabledValue,
@Qualifier("runningProOrHigher") boolean runningProOrHigher,
AppConfig appConfig,
ApplicationProperties.Security securityProperties,
ApplicationProperties applicationProperties,
UserAuthenticationFilter userAuthenticationFilter,
JwtServiceInterface jwtService,
JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint,
@@ -105,7 +105,7 @@ public class SecurityConfiguration {
this.loginEnabledValue = loginEnabledValue;
this.runningProOrHigher = runningProOrHigher;
this.appConfig = appConfig;
this.securityProperties = securityProperties;
this.applicationProperties = applicationProperties;
this.userAuthenticationFilter = userAuthenticationFilter;
this.jwtService = jwtService;
this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint;
@@ -125,28 +125,19 @@ public class SecurityConfiguration {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
if (securityProperties.getCsrfDisabled() || !loginEnabledValue) {
if (applicationProperties.getSecurity().getCsrfDisabled() || !loginEnabledValue) {
http.csrf(CsrfConfigurer::disable);
}
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)
.addFilterBefore(jwtAuthenticationFilter(), UserAuthenticationFilter.class);
if (!securityProperties.getCsrfDisabled()) {
if (!applicationProperties.getSecurity().getCsrfDisabled()) {
CookieCsrfTokenRepository cookieRepo =
CookieCsrfTokenRepository.withHttpOnlyFalse();
CsrfTokenRequestAttributeHandler requestHandler =
@@ -156,6 +147,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)
@@ -182,21 +180,12 @@ public class SecurityConfiguration {
}
http.sessionManagement(
sessionManagement -> {
if (v2Enabled) {
sessionManagement.sessionCreationPolicy(
SessionCreationPolicy.STATELESS);
} else {
sessionManagement
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.maximumSessions(10)
.maxSessionsPreventsLogin(false)
.sessionRegistry(sessionRegistry)
.expiredUrl("/login?logout=true");
}
});
http.authenticationProvider(daoAuthenticationProvider());
http.requestCache(requestCache -> requestCache.requestCache(new NullRequestCache()));
sessionManagement ->
sessionManagement.sessionCreationPolicy(
SessionCreationPolicy.STATELESS))
.authenticationProvider(daoAuthenticationProvider())
.requestCache(
requestCache -> requestCache.requestCache(new NullRequestCache()));
http.logout(
logout ->
logout.logoutRequestMatcher(
@@ -204,7 +193,9 @@ public class SecurityConfiguration {
.matcher("/logout"))
.logoutSuccessHandler(
new CustomLogoutSuccessHandler(
securityProperties, appConfig, jwtService))
applicationProperties.getSecurity(),
appConfig,
jwtService))
.clearAuthentication(true)
.invalidateHttpSession(true)
.deleteCookies("JSESSIONID", "remember-me", "stirling_jwt"));
@@ -238,9 +229,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 +246,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");
})
@@ -259,7 +263,7 @@ public class SecurityConfiguration {
.anyRequest()
.authenticated());
// Handle User/Password Logins
if (securityProperties.isUserPass()) {
if (applicationProperties.getSecurity().isUserPass()) {
http.formLogin(
formLogin ->
formLogin
@@ -275,19 +279,19 @@ public class SecurityConfiguration {
.permitAll());
}
// Handle OAUTH2 Logins
if (securityProperties.isOauth2Active()) {
if (applicationProperties.getSecurity().isOauth2Active()) {
http.oauth2Login(
oauth2 ->
oauth2.loginPage("/oauth2")
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.
*/
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(
applicationProperties,
loginAttemptService,
securityProperties.getOauth2(),
userService,
jwtService))
.failureHandler(
@@ -298,7 +302,9 @@ public class SecurityConfiguration {
userInfoEndpoint
.oidcUserService(
new CustomOAuth2UserService(
securityProperties,
applicationProperties
.getSecurity()
.getOauth2(),
userService,
loginAttemptService))
.userAuthoritiesMapper(
@@ -306,7 +312,7 @@ public class SecurityConfiguration {
.permitAll());
}
// Handle SAML
if (securityProperties.isSaml2Active() && runningProOrHigher) {
if (applicationProperties.getSecurity().isSaml2Active() && runningProOrHigher) {
OpenSaml4AuthenticationProvider authenticationProvider =
new OpenSaml4AuthenticationProvider();
authenticationProvider.setResponseAuthenticationConverter(
@@ -322,8 +328,8 @@ public class SecurityConfiguration {
new ProviderManager(authenticationProvider))
.successHandler(
new CustomSaml2AuthenticationSuccessHandler(
applicationProperties,
loginAttemptService,
securityProperties.getSaml2(),
userService,
jwtService))
.failureHandler(
@@ -368,6 +374,6 @@ public class SecurityConfiguration {
userService,
userDetailsService,
jwtAuthenticationEntryPoint,
securityProperties);
applicationProperties.getSecurity());
}
}
@@ -0,0 +1,367 @@
package stirling.software.proprietary.security.controller.api;
import java.util.HashMap;
import java.util.Map;
import jakarta.servlet.http.Cookie;
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.RefreshTokenService;
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 RefreshTokenService refreshTokenService;
private final CustomUserDetailsService userDetailsService;
/**
* Login endpoint - replaces Supabase signInWithPassword
*
* @param request Login credentials (email/username and password)
* @param servletRequest HTTP request for extracting IP and user agent
* @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,
HttpServletRequest servletRequest,
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<String, Object> claims = new HashMap<>();
claims.put("authType", AuthenticationType.WEB.toString());
claims.put("role", user.getRolesAsString());
String token = jwtService.generateToken(user.getUsername(), claims);
// Generate refresh token for token rotation
String refreshToken =
refreshTokenService.generateRefreshToken(user.getId(), servletRequest);
// Set JWT as HttpOnly cookie for security
setJwtCookie(response, token);
// Set refresh token as HttpOnly cookie
setRefreshTokenCookie(response, refreshToken);
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 - revokes all refresh tokens and clears cookies
*
* @param response HTTP response to clear cookies
* @return Success message
*/
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@PostMapping("/logout")
public ResponseEntity<?> logout(HttpServletResponse response) {
try {
// Get current user from security context
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && authentication.getPrincipal() instanceof User user) {
// Revoke all refresh tokens for this user
int revokedCount = refreshTokenService.revokeAllTokensForUser(user.getId());
log.info(
"Revoked {} refresh token(s) for user: {}",
revokedCount,
user.getUsername());
}
// Clear cookies
clearAuthCookies(response);
// Clear security context
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 endpoint - validates refresh token and issues new access token.
* Implements token rotation for security: revokes old refresh token and issues new one
*
* @param request HTTP request
* @param response HTTP response
* @return the refreshed token
*/
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@PostMapping("/refresh")
public ResponseEntity<?> refresh(HttpServletRequest request, HttpServletResponse response) {
try {
// Extract refresh token from cookie
String refreshToken = extractRefreshTokenFromCookie(request);
if (refreshToken == null || refreshToken.isEmpty()) {
log.debug("Token refresh failed: no refresh token in cookie");
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "No refresh token found"));
}
var refreshTokenOpt = refreshTokenService.validateRefreshToken(refreshToken);
if (refreshTokenOpt.isEmpty()) {
log.debug("Token refresh failed: invalid or expired refresh token");
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Invalid or expired refresh token"));
}
var refreshTokenEntity = refreshTokenOpt.get();
Long userId = refreshTokenEntity.getUserId();
User user = userService.findById(userId).orElse(null);
if (user == null) {
log.warn("Token refresh failed: user not found for ID: {}", userId);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "User not found"));
}
if (!user.isEnabled()) {
log.warn("Token refresh failed: user disabled: {}", user.getUsername());
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "User account is disabled"));
}
// Generate new access token
Map<String, Object> claims = new HashMap<>();
claims.put("authType", user.getAuthenticationType());
claims.put("role", user.getRolesAsString());
String newAccessToken = jwtService.generateToken(user.getUsername(), claims);
// Rotate refresh token for security (revoke old, issue new)
String newRefreshToken =
refreshTokenService.rotateRefreshToken(refreshToken, userId, request);
// Set new cookies
setJwtCookie(response, newAccessToken);
setRefreshTokenCookie(response, newRefreshToken);
log.info("Token refreshed successfully for user: {}", user.getUsername());
return ResponseEntity.ok(Map.of("access_token", newAccessToken, "expires_in", 3600));
} catch (Exception e) {
log.error("Token refresh error", e);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Token refresh failed"));
}
}
/**
* Extracts refresh token from HTTP cookie
*
* @param request HTTP request
* @return Refresh token or null if not found
*/
private String extractRefreshTokenFromCookie(HttpServletRequest request) {
if (request.getCookies() == null) {
return null;
}
for (Cookie cookie : request.getCookies()) {
if ("stirling_refresh_token".equals(cookie.getName())) {
return cookie.getValue();
}
}
return null;
}
/**
* Sets JWT as an HttpOnly cookie for security Prevents XSS attacks by making token inaccessible
* to JavaScript
*
* @param response HTTP response to set cookie
* @param jwt JWT token to store
*/
private void setJwtCookie(HttpServletResponse response, String jwt) {
jwtService.setJwtCookie(response, jwt, "");
}
/**
* Sets refresh token as an HttpOnly cookie for security
*
* @param response HTTP response to set cookie
* @param refreshToken Refresh token to store
*/
private void setRefreshTokenCookie(HttpServletResponse response, String refreshToken) {
jwtService.setRefreshTokenCookie(response, refreshToken, "", 7 * 24 * 3600);
}
/**
* Clears authentication cookies (used on logout)
*
* @param response HTTP response
*/
private void clearAuthCookies(HttpServletResponse response) {
jwtService.removeJwtCookie(response, "");
jwtService.removeRefreshTokenCookie(response, "");
}
/**
* Helper method to build user response object
*
* @param user User entity
* @return Map containing user information
*/
private Map<String, Object> buildUserResponse(User user) {
Map<String, Object> 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<String, Object> appMetadata = new HashMap<>();
appMetadata.put("provider", user.getAuthenticationType()); // Default to email provider
userMap.put("app_metadata", appMetadata);
return userMap;
}
/**
* Get security configuration (including secureCookie flag)
*
* @return Security configuration
*/
@GetMapping("/config")
public ResponseEntity<Map<String, Object>> getAuthConfig() {
Map<String, Object> config = new HashMap<>();
config.put("secureCookie", jwtService.isSecureCookie());
config.put("jwtEnabled", jwtService.isJwtEnabled());
log.debug("Auth config requested: secureCookie={}", jwtService.isSecureCookie());
return ResponseEntity.ok(config);
}
// ===========================
// Request/Response DTOs
// ===========================
/** Login request DTO */
public record LoginRequest(String email, String password) {}
}
@@ -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<String, Object> buildUserResponse(User user) {
Map<String, Object> 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<String, Object> appMetadata = new HashMap<>();
appMetadata.put("provider", user.getAuthenticationType()); // Default to email provider
userMap.put("app_metadata", appMetadata);
return userMap;
}
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@@ -22,6 +22,8 @@ public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByApiKey(String apiKey);
Optional<User> findBySsoProviderAndSsoProviderId(String ssoProvider, String ssoProviderId);
List<User> findByAuthenticationTypeIgnoreCase(String authenticationType);
@Query("SELECT u FROM User u WHERE u.team IS NULL")
@@ -1,20 +1,26 @@
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;
import java.util.Map;
import java.util.Optional;
import java.time.Instant;
import java.util.HashMap;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
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.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.web.filter.OncePerRequestFilter;
@@ -75,29 +81,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<String, Object> claims = jwtService.extractClaims(jwtToken);
String tokenUsername = claims.get("sub").toString();
log.debug("JWT token username: {}", tokenUsername);
try {
authenticate(request, claims);
authenticate(request, jwtToken, claims);
log.debug("Authentication successful for user: {}", tokenUsername);
} catch (SQLException | UnsupportedProviderException e) {
log.error("Error processing user authentication for user: {}", tokenUsername, e);
handleAuthenticationFailure(
@@ -151,7 +188,8 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
return true;
}
private void authenticate(HttpServletRequest request, Map<String, Object> claims)
private void authenticate(
HttpServletRequest request, String jwtToken, Map<String, Object> claims)
throws SQLException, UnsupportedProviderException {
String username = claims.get("sub").toString();
@@ -160,11 +198,14 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (userDetails != null) {
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
// Create a Jwt object from the token string and claims
Jwt jwt = createJwtFromClaims(jwtToken, claims);
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
JwtAuthenticationToken authToken =
new JwtAuthenticationToken(jwt, userDetails.getAuthorities());
authToken.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
} else {
throw new UsernameNotFoundException("User not found: " + username);
@@ -172,24 +213,60 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
}
}
/**
* Creates a Spring Security Jwt object from the token string and claims This is needed for
* JwtAuthenticationToken
*
* @param tokenValue The JWT token string
* @param claims The extracted claims from the token
* @return A Jwt object
*/
private Jwt createJwtFromClaims(String tokenValue, Map<String, Object> claims) {
// Extract standard claims
Instant issuedAt =
claims.containsKey("iat")
? Instant.ofEpochSecond(((Number) claims.get("iat")).longValue())
: Instant.now();
Instant expiresAt =
claims.containsKey("exp")
? Instant.ofEpochSecond(((Number) claims.get("exp")).longValue())
: Instant.now().plusSeconds(3600);
// Extract headers (kid if present)
Map<String, Object> headers = new HashMap<>();
if (claims.containsKey("kid")) {
headers.put("kid", claims.get("kid"));
}
headers.put("alg", "RS256");
// Create and return the Jwt object
return new Jwt(tokenValue, issuedAt, expiresAt, headers, claims);
}
private void processUserAuthenticationType(Map<String, Object> 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);
}
}
}
@@ -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"
};
@@ -0,0 +1,75 @@
package stirling.software.proprietary.security.model;
import java.time.LocalDateTime;
import org.hibernate.annotations.CreationTimestamp;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* Refresh Token entity for implementing secure token rotation Refresh tokens are long-lived tokens
* that can be used to obtain new access tokens This prevents stolen access tokens from being kept
* alive indefinitely
*/
@Entity
@Table(
name = "refresh_tokens",
indexes = {
@Index(name = "idx_user_id", columnList = "user_id"),
@Index(name = "idx_token_hash", columnList = "token_hash"),
@Index(name = "idx_expires_at", columnList = "expires_at")
})
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class RefreshToken {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "user_id", nullable = false)
private Long userId;
@Column(name = "token_hash", nullable = false, unique = true, length = 64)
private String tokenHash;
@Column(name = "expires_at", nullable = false)
private LocalDateTime expiresAt;
@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "revoked", nullable = false)
@Builder.Default
private boolean revoked = false;
@Column(name = "issued_ip", length = 45)
private String issuedIp;
@Column(name = "user_agent", length = 255)
private String userAgent;
public boolean isExpired() {
return LocalDateTime.now().isAfter(expiresAt);
}
public boolean isValid() {
return !revoked && !isExpired();
}
}
@@ -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<Authority> authorities = new HashSet<>();
@@ -74,6 +83,14 @@ public class User implements UserDetails, Serializable {
@CollectionTable(name = "user_settings", joinColumns = @JoinColumn(name = "user_id"))
private Map<String, String> 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());
}
@@ -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;
@@ -20,6 +21,7 @@ import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.exception.UnsupportedProviderException;
@@ -29,12 +31,13 @@ import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.LoginAttemptService;
import stirling.software.proprietary.security.service.UserService;
@Slf4j
@RequiredArgsConstructor
public class CustomOAuth2AuthenticationSuccessHandler
extends SavedRequestAwareAuthenticationSuccessHandler {
private final ApplicationProperties applicationProperties;
private final LoginAttemptService loginAttemptService;
private final ApplicationProperties.Security.OAUTH2 oauth2Properties;
private final UserService userService;
private final JwtServiceInterface jwtService;
@@ -65,6 +68,9 @@ public class CustomOAuth2AuthenticationSuccessHandler
// Redirect to the original destination
super.onAuthenticationSuccess(request, response, authentication);
} else {
ApplicationProperties.Security.OAUTH2 oauth2Properties =
applicationProperties.getSecurity().getOauth2();
if (loginAttemptService.isBlocked(username)) {
if (session != null) {
session.removeAttribute("SPRING_SECURITY_SAVED_REQUEST");
@@ -72,12 +78,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 +98,126 @@ 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));
// Set JWT as HttpOnly cookie for security
jwtService.setJwtCookie(response, jwt, contextPath);
// Build context-aware redirect URL (without JWT in URL)
String redirectUrl = buildContextAwareRedirectUrl(request, contextPath);
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;
}
/**
* Validates if the origin is in the CORS whitelist
*
* @param origin Origin to validate
* @return true if origin is whitelisted or no whitelist configured
*/
private boolean isOriginWhitelisted(String origin) {
ApplicationProperties applicationProperties = new ApplicationProperties();
if (applicationProperties.getSystem() == null
|| applicationProperties.getSystem().getCorsAllowedOrigins() == null
|| applicationProperties.getSystem().getCorsAllowedOrigins().isEmpty()) {
// No whitelist configured - only trust request origin
return false;
}
return applicationProperties.getSystem().getCorsAllowedOrigins().contains(origin);
}
/**
* Builds a context-aware redirect URL based on the request's origin Validates Referer against
* CORS whitelist to prevent token leakage to third parties
*
* @param request The HTTP request
* @param contextPath The application context path
* @return The appropriate redirect URL
*/
private String buildContextAwareRedirectUrl(HttpServletRequest request, String contextPath) {
// Try to get the origin from the Referer header
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();
}
// SECURITY: Only trust Referer if it's in the CORS whitelist
// This prevents redirecting with JWT to untrusted domains (e.g., IdP domain)
if (isOriginWhitelisted(origin)) {
log.debug("Using whitelisted Referer origin for redirect: {}", origin);
return origin + "/auth/callback";
} else {
log.warn(
"Referer origin {} not in CORS whitelist, falling back to request origin",
origin);
}
} catch (java.net.MalformedURLException e) {
log.warn("Malformed Referer URL, falling back to request origin: {}", referer);
}
}
// Fall back to building from request host/port (always safe)
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);
}
log.debug("Using request origin for redirect: {}", origin);
return origin + "/auth/callback";
}
}
@@ -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<ClientRegistration> 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<ClientRegistration> 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<ClientRegistration> 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<ClientRegistration> 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();
}
@@ -0,0 +1,90 @@
package stirling.software.proprietary.security.repository;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import stirling.software.proprietary.security.model.RefreshToken;
@Repository
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
/**
* Find a refresh token by its hash
*
* @param tokenHash SHA-256 hash of the token
* @return Optional containing the refresh token if found
*/
Optional<RefreshToken> findByTokenHash(String tokenHash);
/**
* Find all refresh tokens for a specific user
*
* @param userId User ID
* @return List of refresh tokens
*/
List<RefreshToken> findByUserId(Long userId);
/**
* Find all valid (non-revoked, non-expired) refresh tokens for a user
*
* @param userId User ID
* @param now Current timestamp
* @return List of valid refresh tokens
*/
@Query(
"SELECT r FROM RefreshToken r WHERE r.userId = :userId AND r.revoked = false AND r.expiresAt > :now")
List<RefreshToken> findValidTokensByUserId(
@Param("userId") Long userId, @Param("now") LocalDateTime now);
/**
* Revoke all refresh tokens for a specific user (used on logout or security events)
*
* @param userId User ID
* @return Number of tokens revoked
*/
@Modifying
@Transactional
@Query("UPDATE RefreshToken r SET r.revoked = true WHERE r.userId = :userId")
int revokeAllByUserId(@Param("userId") Long userId);
/**
* Revoke a specific refresh token by its hash
*
* @param tokenHash SHA-256 hash of the token
* @return Number of tokens revoked (0 or 1)
*/
@Modifying
@Transactional
@Query("UPDATE RefreshToken r SET r.revoked = true WHERE r.tokenHash = :tokenHash")
int revokeByTokenHash(@Param("tokenHash") String tokenHash);
/**
* Delete all expired refresh tokens (cleanup job)
*
* @param now Current timestamp
* @return Number of tokens deleted
*/
@Modifying
@Transactional
@Query("DELETE FROM RefreshToken r WHERE r.expiresAt < :now")
int deleteExpiredTokens(@Param("now") LocalDateTime now);
/**
* Count valid tokens for a user
*
* @param userId User ID
* @param now Current timestamp
* @return Count of valid tokens
*/
@Query(
"SELECT COUNT(r) FROM RefreshToken r WHERE r.userId = :userId AND r.revoked = false AND r.expiresAt > :now")
long countValidTokensByUserId(@Param("userId") Long userId, @Param("now") LocalDateTime now);
}
@@ -33,8 +33,8 @@ import stirling.software.proprietary.security.service.UserService;
public class CustomSaml2AuthenticationSuccessHandler
extends SavedRequestAwareAuthenticationSuccessHandler {
private final ApplicationProperties applicationProperties;
private LoginAttemptService loginAttemptService;
private ApplicationProperties.Security.SAML2 saml2Properties;
private UserService userService;
private final JwtServiceInterface jwtService;
@@ -72,7 +72,7 @@ public class CustomSaml2AuthenticationSuccessHandler
} else {
log.debug(
"Processing SAML2 authentication with autoCreateUser: {}",
saml2Properties.getAutoCreateUser());
applicationProperties.getSecurity().getSaml2().getAutoCreateUser());
if (loginAttemptService.isBlocked(username)) {
log.debug("User {} is blocked due to too many login attempts", username);
@@ -100,7 +100,7 @@ public class CustomSaml2AuthenticationSuccessHandler
if (userExists
&& hasPassword
&& (!isSSOUser || !isSAML2User)
&& saml2Properties.getAutoCreateUser()) {
&& applicationProperties.getSecurity().getSaml2().getAutoCreateUser()) {
log.debug(
"User {} exists with password but is not SSO user, redirecting to logout",
username);
@@ -110,19 +110,53 @@ public class CustomSaml2AuthenticationSuccessHandler
}
try {
if (!userExists || saml2Properties.getBlockRegistration()) {
if (!userExists
|| applicationProperties
.getSecurity()
.getSaml2()
.getBlockRegistration()) {
log.debug("Registration blocked for new user: {}", username);
response.sendRedirect(
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,
applicationProperties.getSecurity().getSaml2().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));
// Set JWT as HttpOnly cookie for security
jwtService.setJwtCookie(response, jwt, contextPath);
// Build context-aware redirect URL (without JWT in URL)
String redirectUrl = buildContextAwareRedirectUrl(request, contextPath);
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 +170,74 @@ 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);
/**
* Validates if the origin is in the CORS whitelist
*
* @param origin Origin to validate
* @return true if origin is whitelisted or no whitelist configured
*/
private boolean isOriginWhitelisted(String origin) {
if (applicationProperties.getSystem() == null
|| applicationProperties.getSystem().getCorsAllowedOrigins() == null
|| applicationProperties.getSystem().getCorsAllowedOrigins().isEmpty()) {
// No whitelist configured - only trust request origin
return false;
}
return applicationProperties.getSystem().getCorsAllowedOrigins().contains(origin);
}
/**
* Builds a context-aware redirect URL based on the request's origin Validates Referer against
* CORS whitelist to prevent token leakage to third parties
*
* @param request The HTTP request
* @param contextPath The application context path
* @return The appropriate redirect URL
*/
private String buildContextAwareRedirectUrl(HttpServletRequest request, String contextPath) {
// Try to get the origin from the Referer header
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();
}
// SECURITY: Only trust Referer if it's in the CORS whitelist
// This prevents redirecting with JWT to untrusted domains (e.g., IdP domain)
if (isOriginWhitelisted(origin)) {
log.debug("Using whitelisted Referer origin for redirect: {}", origin);
return origin + "/auth/callback";
} else {
log.warn(
"Referer origin {} not in CORS whitelist, falling back to request origin",
origin);
}
} catch (java.net.MalformedURLException e) {
log.warn("Malformed Referer URL, falling back to request origin: {}", referer);
}
}
// Fall back to building from request host/port (always safe)
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);
}
log.debug("Using request origin for redirect: {}", origin);
return origin + "/auth/callback";
}
}
@@ -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<OidcUserReques
private final LoginAttemptService loginAttemptService;
private final ApplicationProperties.Security securityProperties;
private final ApplicationProperties.Security.OAUTH2 oauth2Properties;
public CustomOAuth2UserService(
ApplicationProperties.Security securityProperties,
ApplicationProperties.Security.OAUTH2 oauth2Properties,
UserService userService,
LoginAttemptService loginAttemptService) {
this.securityProperties = securityProperties;
this.oauth2Properties = oauth2Properties;
this.userService = userService;
this.loginAttemptService = loginAttemptService;
}
@@ -42,14 +41,22 @@ public class CustomOAuth2UserService implements OAuth2UserService<OidcUserReques
public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException {
try {
OidcUser user = delegate.loadUser(userRequest);
OAUTH2 oauth2 = securityProperties.getOauth2();
UsernameAttribute usernameAttribute =
UsernameAttribute.valueOf(oauth2.getUseAsUsername().toUpperCase());
String usernameAttributeKey = usernameAttribute.getName();
String usernameAttributeKey =
UsernameAttribute.valueOf(oauth2Properties.getUseAsUsername().toUpperCase())
.getName();
// todo: save user by OIDC ID instead of username
Optional<User> 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<User> internalUser = userService.findByUsernameIgnoreCase(username);
if (internalUser.isPresent()) {
String internalUsername = internalUser.get().getUsername();
@@ -13,15 +13,12 @@ import java.util.Optional;
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;
@@ -43,22 +40,20 @@ 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 static final String JWT_COOKIE_NAME = "stirling_jwt";
private static final String REFRESH_TOKEN_COOKIE_NAME = "stirling_refresh";
private final KeyPersistenceServiceInterface keyPersistenceService;
private final boolean v2Enabled;
@Value("${security.jwt.secureCookie:true}")
private boolean secureCookie;
@Autowired
public JwtService(
@Qualifier("v2Enabled") boolean v2Enabled,
KeyPersistenceServiceInterface keyPersistenceService) {
this.v2Enabled = v2Enabled;
public JwtService(KeyPersistenceServiceInterface keyPersistenceService) {
this.keyPersistenceService = keyPersistenceService;
log.info("JwtService initialized");
}
@Override
@@ -260,50 +255,21 @@ 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;
return true; // JWT is always enabled now (V2 is the default)
}
private String extractKeyId(String token) {
@@ -327,4 +293,97 @@ public class JwtService implements JwtServiceInterface {
return null;
}
}
/**
* Sets JWT as an HttpOnly cookie for security. Prevents XSS attacks by making token
* inaccessible to JavaScript.
*
* @param response HTTP response to set cookie
* @param jwt JWT token to store
* @param contextPath Application context path for cookie path
*/
public void setJwtCookie(HttpServletResponse response, String jwt, String contextPath) {
Cookie cookie = new Cookie(JWT_COOKIE_NAME, jwt);
cookie.setHttpOnly(true);
cookie.setSecure(secureCookie);
cookie.setPath(contextPath.isEmpty() ? "/" : contextPath);
cookie.setMaxAge((int) (EXPIRATION / 1000));
cookie.setAttribute("SameSite", "Lax");
response.addCookie(cookie);
log.debug(
"JWT cookie set with secure={}, maxAge={}, path={}",
secureCookie,
(EXPIRATION / 1000),
contextPath.isEmpty() ? "/" : contextPath);
}
/**
* Sets refresh token as an HttpOnly cookie for security.
*
* @param response HTTP response to set cookie
* @param refreshToken Refresh token to store
* @param contextPath Application context path for cookie path
* @param maxAge Maximum age in seconds
*/
public void setRefreshTokenCookie(
HttpServletResponse response, String refreshToken, String contextPath, int maxAge) {
Cookie cookie = new Cookie(REFRESH_TOKEN_COOKIE_NAME, refreshToken);
cookie.setHttpOnly(true); // Prevent JavaScript access (XSS protection)
cookie.setSecure(secureCookie); // Only send over HTTPS (configurable for local dev)
cookie.setPath(
contextPath.isEmpty() ? "/" : contextPath); // Cookie available for entire app
cookie.setMaxAge(maxAge);
cookie.setAttribute("SameSite", "Lax"); // CSRF protection
response.addCookie(cookie);
log.debug(
"Refresh token cookie set with secure={}, maxAge={}, path={}",
secureCookie,
maxAge,
contextPath.isEmpty() ? "/" : contextPath);
}
/**
* Removes JWT cookie from the response (used for logout).
*
* @param response HTTP response to remove cookie
* @param contextPath Application context path for cookie path
*/
public void removeJwtCookie(HttpServletResponse response, String contextPath) {
Cookie cookie = new Cookie(JWT_COOKIE_NAME, "");
cookie.setHttpOnly(true);
cookie.setSecure(secureCookie);
cookie.setPath(contextPath.isEmpty() ? "/" : contextPath);
cookie.setMaxAge(0); // Expire immediately
cookie.setAttribute("SameSite", "Lax");
response.addCookie(cookie);
log.debug("JWT cookie removed");
}
/**
* Removes refresh token cookie from the response (used for logout).
*
* @param response HTTP response to remove cookie
* @param contextPath Application context path for cookie path
*/
public void removeRefreshTokenCookie(HttpServletResponse response, String contextPath) {
Cookie cookie = new Cookie(REFRESH_TOKEN_COOKIE_NAME, "");
cookie.setHttpOnly(true);
cookie.setSecure(secureCookie);
cookie.setPath(contextPath.isEmpty() ? "/" : contextPath);
cookie.setMaxAge(0); // Expire immediately
cookie.setAttribute("SameSite", "Lax");
response.addCookie(cookie);
log.debug("Refresh token cookie removed");
}
/**
* Gets the configured secureCookie flag.
*
* @return true if cookies should be set with Secure flag, false otherwise
*/
public boolean isSecureCookie() {
return secureCookie;
}
}
@@ -66,25 +66,54 @@ 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
*
* @return true if JWT is enabled, false otherwise
*/
boolean isJwtEnabled();
/**
* Sets JWT as an HttpOnly cookie for security. Prevents XSS attacks by making token
* inaccessible to JavaScript.
*
* @param response HTTP response to set cookie
* @param jwt JWT token to store
* @param contextPath Application context path for cookie path
*/
void setJwtCookie(HttpServletResponse response, String jwt, String contextPath);
/**
* Sets refresh token as an HttpOnly cookie for security.
*
* @param response HTTP response to set cookie
* @param refreshToken Refresh token to store
* @param contextPath Application context path for cookie path
* @param maxAge Maximum age in seconds
*/
void setRefreshTokenCookie(
HttpServletResponse response, String refreshToken, String contextPath, int maxAge);
/**
* Removes JWT cookie from the response (used for logout).
*
* @param response HTTP response to remove cookie
* @param contextPath Application context path for cookie path
*/
void removeJwtCookie(HttpServletResponse response, String contextPath);
/**
* Removes refresh token cookie from the response (used for logout).
*
* @param response HTTP response to remove cookie
* @param contextPath Application context path for cookie path
*/
void removeRefreshTokenCookie(HttpServletResponse response, String contextPath);
/**
* Gets the configured secureCookie flag.
*
* @return true if cookies should be set with Secure flag, false otherwise
*/
boolean isSecureCookie();
}
@@ -0,0 +1,248 @@
package stirling.software.proprietary.security.service;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.LocalDateTime;
import java.util.Base64;
import java.util.Optional;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.security.model.RefreshToken;
import stirling.software.proprietary.security.repository.RefreshTokenRepository;
/**
* Service for managing refresh tokens. Implements secure token generation, validation, and
* revocation
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class RefreshTokenService {
private final RefreshTokenRepository refreshTokenRepository;
private final SecureRandom secureRandom = new SecureRandom();
@Value("${security.jwt.refreshTokenDays:7}")
private long refreshTokenValidityDays;
private static final int TOKEN_LENGTH = 32;
/**
* Generates a new refresh token for a user
*
* @param userId User ID
* @param request HTTP request for audit trail (IP, user agent)
* @return The generated refresh token (plaintext - only shown once)
*/
@Transactional
public String generateRefreshToken(Long userId, HttpServletRequest request) {
byte[] tokenBytes = new byte[TOKEN_LENGTH];
secureRandom.nextBytes(tokenBytes);
String token = Base64.getUrlEncoder().withoutPadding().encodeToString(tokenBytes);
// Hash the token for storage
String tokenHash = hashToken(token);
// Build refresh token entity
RefreshToken refreshToken =
RefreshToken.builder()
.userId(userId)
.tokenHash(tokenHash)
.expiresAt(LocalDateTime.now().plusDays(refreshTokenValidityDays))
.issuedIp(extractIpAddress(request))
.userAgent(extractUserAgent(request))
.revoked(false)
.build();
refreshTokenRepository.save(refreshToken);
log.debug("Generated new refresh token for user ID: {}", userId);
return token;
}
/**
* Validates a refresh token
*
* @param token The plaintext refresh token
* @return Optional containing the RefreshToken entity if valid
*/
public Optional<RefreshToken> validateRefreshToken(String token) {
if (token == null || token.isEmpty()) {
log.debug("Refresh token validation failed: token is null or empty");
return Optional.empty();
}
try {
String tokenHash = hashToken(token);
Optional<RefreshToken> refreshTokenOpt =
refreshTokenRepository.findByTokenHash(tokenHash);
if (refreshTokenOpt.isEmpty()) {
log.debug("Refresh token validation failed: token not found");
return Optional.empty();
}
RefreshToken refreshToken = refreshTokenOpt.get();
if (!refreshToken.isValid()) {
log.debug(
"Refresh token validation failed: token revoked or expired (userId: {})",
refreshToken.getUserId());
return Optional.empty();
}
log.debug(
"Refresh token validated successfully for user ID: {}",
refreshToken.getUserId());
return Optional.of(refreshToken);
} catch (Exception e) {
log.error("Error validating refresh token", e);
return Optional.empty();
}
}
/**
* Revokes a specific refresh token
*
* @param token The plaintext refresh token
* @return true if revoked successfully
*/
@Transactional
public boolean revokeRefreshToken(String token) {
try {
String tokenHash = hashToken(token);
int revoked = refreshTokenRepository.revokeByTokenHash(tokenHash);
log.debug("Revoked {} refresh token(s)", revoked);
return revoked > 0;
} catch (Exception e) {
log.error("Error revoking refresh token", e);
return false;
}
}
/**
* Revokes all refresh tokens for a user (used on logout or security events)
*
* @param userId User ID
* @return Number of tokens revoked
*/
@Transactional
public int revokeAllTokensForUser(Long userId) {
int revoked = refreshTokenRepository.revokeAllByUserId(userId);
log.info("Revoked {} refresh token(s) for user ID: {}", revoked, userId);
return revoked;
}
/**
* Rotates a refresh token (revokes old, generates new) Best practice for security: rotate
* tokens on each refresh
*
* @param oldToken The old refresh token to revoke
* @param userId User ID
* @param request HTTP request for audit trail
* @return New refresh token
*/
@Transactional
public String rotateRefreshToken(String oldToken, Long userId, HttpServletRequest request) {
// Revoke the old token
revokeRefreshToken(oldToken);
// Generate and return a new token
return generateRefreshToken(userId, request);
}
/**
* Cleans up expired refresh tokens (should be called periodically)
*
* @return Number of tokens deleted
*/
@Transactional
public int cleanupExpiredTokens() {
int deleted = refreshTokenRepository.deleteExpiredTokens(LocalDateTime.now());
if (deleted > 0) {
log.info("Cleaned up {} expired refresh tokens", deleted);
}
return deleted;
}
/**
* Hashes a token using SHA-256
*
* @param token Plaintext token
* @return Hex-encoded hash
*/
private String hashToken(String token) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(token.getBytes(StandardCharsets.UTF_8));
return bytesToHex(hash);
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("SHA-256 algorithm not available", e);
}
}
/** Converts byte array to hex string */
private String bytesToHex(byte[] bytes) {
StringBuilder result = new StringBuilder();
for (byte b : bytes) {
result.append(String.format("%02x", b));
}
return result.toString();
}
/** Extracts IP address from request */
private String extractIpAddress(HttpServletRequest request) {
if (request == null) {
return null;
}
// Check for forwarded IP (behind proxy)
String ip = request.getHeader("X-Forwarded-For");
if (ip == null || ip.isEmpty()) {
ip = request.getHeader("X-Real-IP");
}
if (ip == null || ip.isEmpty()) {
ip = request.getRemoteAddr();
}
// Handle multiple IPs (take first one)
if (ip != null && ip.contains(",")) {
ip = ip.split(",")[0].trim();
}
// Truncate to fit column size (45 chars for IPv6)
if (ip != null && ip.length() > 45) {
ip = ip.substring(0, 45);
}
return ip;
}
/** Extracts user agent from request */
private String extractUserAgent(HttpServletRequest request) {
if (request == null) {
return null;
}
String userAgent = request.getHeader("User-Agent");
// Truncate to fit column size (255 chars)
if (userAgent != null && userAgent.length() > 255) {
userAgent = userAgent.substring(0, 255);
}
return userAgent;
}
}
@@ -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<User> existingUser = findByUsernameIgnoreCase(username);
// Find user by SSO provider ID first
Optional<User> 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> 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
@@ -302,6 +378,10 @@ public class UserService implements UserServiceInterface {
}
}
public Optional<User> findById(Long id) {
return userRepository.findById(id);
}
public Optional<User> findByUsername(String username) {
return userRepository.findByUsername(username);
}
@@ -411,6 +491,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 +507,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 +529,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);
@@ -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
@@ -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);
}
}
@@ -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);
@@ -70,7 +64,7 @@ class JwtServiceTest {
Base64.getEncoder().encodeToString(testKeyPair.getPublic().getEncoded());
testVerificationKey = new JwtVerificationKey("test-key-id", encodedPublicKey);
jwtService = new JwtService(true, keystoreService);
jwtService = new JwtService(keystoreService);
}
@Test
@@ -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;
}
}
+503 -5
View File
@@ -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",
Binary file not shown.

After

Width:  |  Height:  |  Size: 717 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M17.05 20.28c-.98.95-2.05.8-3.08.35-1.09-.46-2.09-.48-3.24 0-1.44.62-2.2.44-3.06-.35C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09l.01-.01zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z"/>
</svg>

After

Width:  |  Height:  |  Size: 426 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 23 23" fill="none">
<path d="M0 0h10.5v10.5H0V0z" fill="#F25022"/>
<path d="M12.5 0H23v10.5H12.5V0z" fill="#7FBA00"/>
<path d="M0 12.5h10.5V23H0V12.5z" fill="#00A4EF"/>
<path d="M12.5 12.5H23V23H12.5V12.5z" fill="#FFB900"/>
</svg>

After

Width:  |  Height:  |  Size: 292 B

+3
View File
@@ -0,0 +1,3 @@
<svg width="37" height="36" viewBox="0 0 37 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18.5 3.3125C16.5712 3.3125 14.6613 3.6924 12.8793 4.43052C11.0974 5.16864 9.47823 6.25051 8.11437 7.61437C5.35993 10.3688 3.8125 14.1046 3.8125 18C3.8125 24.4919 8.02781 29.9997 13.8588 31.9531C14.5931 32.0706 14.8281 31.6153 14.8281 31.2187V28.7366C10.7597 29.6178 9.89312 26.7684 9.89312 26.7684C9.2175 25.0647 8.26281 24.6094 8.26281 24.6094C6.92625 23.6987 8.36562 23.7281 8.36562 23.7281C9.83437 23.8309 10.6128 25.2409 10.6128 25.2409C11.8906 27.4734 14.0497 26.8125 14.8869 26.46C15.0191 25.5053 15.4009 24.8591 15.8122 24.4919C12.5516 24.1247 9.12937 22.8616 9.12937 17.2656C9.12937 15.6353 9.6875 14.3281 10.6422 13.2853C10.4953 12.9181 9.98125 11.3906 10.7891 9.40781C10.7891 9.40781 12.0228 9.01125 14.8281 10.9059C15.9884 10.5828 17.2516 10.4212 18.5 10.4212C19.7484 10.4212 21.0116 10.5828 22.1719 10.9059C24.9772 9.01125 26.2109 9.40781 26.2109 9.40781C27.0188 11.3906 26.5047 12.9181 26.3578 13.2853C27.3125 14.3281 27.8706 15.6353 27.8706 17.2656C27.8706 22.8762 24.4338 24.11 21.1584 24.4772C21.6872 24.9325 22.1719 25.8284 22.1719 27.1944V31.2187C22.1719 31.6153 22.4069 32.0853 23.1559 31.9531C28.9869 29.985 33.1875 24.4919 33.1875 18C33.1875 16.0712 32.8076 14.1613 32.0695 12.3793C31.3314 10.5974 30.2495 8.97823 28.8856 7.61437C27.5218 6.25051 25.9026 5.16864 24.1207 4.43052C22.3387 3.6924 20.4288 3.3125 18.5 3.3125Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+14
View File
@@ -0,0 +1,14 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2781_85129)">
<path d="M8.36055 0.789432C5.96258 1.62131 3.89457 3.20024 2.46029 5.29431C1.026 7.38838 0.301037 9.8872 0.391883 12.4237C0.482728 14.9603 1.38459 17.4008 2.96501 19.3869C4.54543 21.373 6.72109 22.8 9.17243 23.4582C11.1598 23.971 13.2419 23.9935 15.2399 23.5238C17.0499 23.1172 18.7233 22.2476 20.0962 21.0001C21.5251 19.662 22.5622 17.9597 23.0962 16.0763C23.6765 14.0282 23.7798 11.8743 23.3981 9.78006H12.2381V14.4094H18.7012C18.572 15.1478 18.2952 15.8525 17.8873 16.4814C17.4795 17.1102 16.9489 17.6504 16.3274 18.0694C15.5382 18.5915 14.6485 18.9428 13.7156 19.1007C12.7798 19.2747 11.82 19.2747 10.8843 19.1007C9.93591 18.9046 9.03874 18.5132 8.24993 17.9513C6.98271 17.0543 6.0312 15.7799 5.53118 14.3101C5.02271 12.8127 5.02271 11.1893 5.53118 9.69193C5.8871 8.64234 6.47549 7.68669 7.25243 6.89631C8.14154 5.97521 9.26718 5.3168 10.5058 4.99332C11.7445 4.66985 13.0484 4.6938 14.2743 5.06256C15.232 5.35654 16.1078 5.87019 16.8318 6.56256C17.5606 5.83756 18.2881 5.11068 19.0143 4.38193C19.3893 3.99006 19.7981 3.61693 20.1674 3.21568C19.0622 2.1872 17.765 1.38691 16.3499 0.860682C13.7731 -0.0749615 10.9536 -0.100106 8.36055 0.789432Z" fill="white"/>
<path d="M8.3607 0.789367C10.9536 -0.100776 13.7731 -0.0762934 16.3501 0.858742C17.7654 1.38855 19.062 2.19269 20.1657 3.22499C19.7907 3.62624 19.3951 4.00124 19.0126 4.39124C18.2851 5.11749 17.5582 5.84124 16.832 6.56249C16.1079 5.87012 15.2321 5.35648 14.2745 5.06249C13.0489 4.69244 11.7451 4.66711 10.5061 4.98926C9.26712 5.31141 8.14079 5.96861 7.2507 6.88874C6.47377 7.67912 5.88538 8.63477 5.52945 9.68437L1.64258 6.67499C3.03384 3.91604 5.44273 1.80566 8.3607 0.789367Z" fill="#E33629"/>
<path d="M0.611401 9.65654C0.820316 8.62116 1.16716 7.61847 1.64265 6.67529L5.52953 9.69217C5.02105 11.1896 5.02105 12.8129 5.52953 14.3103C4.23453 15.3103 2.9389 16.3153 1.64265 17.3253C0.452308 14.9559 0.0892746 12.2562 0.611401 9.65654Z" fill="#F8BD00"/>
<path d="M12.2381 9.77783H23.3981C23.7799 11.8721 23.6766 14.026 23.0963 16.0741C22.5623 17.9575 21.5252 19.6597 20.0963 20.9978C18.8419 20.0191 17.5819 19.0478 16.3275 18.0691C16.9494 17.6496 17.4802 17.1089 17.8881 16.4793C18.296 15.8498 18.5726 15.1444 18.7013 14.4053H12.2381C12.2363 12.8641 12.2381 11.321 12.2381 9.77783Z" fill="#587DBD"/>
<path d="M1.64062 17.3251C2.93687 16.3251 4.2325 15.3201 5.5275 14.3101C6.02851 15.7804 6.98138 17.0549 8.25 17.9513C9.04126 18.5106 9.94037 18.8988 10.89 19.0913C11.8257 19.2653 12.7855 19.2653 13.7212 19.0913C14.6542 18.9334 15.5439 18.5821 16.3331 18.0601C17.5875 19.0388 18.8475 20.0101 20.1019 20.9888C18.7292 22.237 17.0558 23.1073 15.2456 23.5144C13.2476 23.9841 11.1655 23.9616 9.17812 23.4488C7.60632 23.0291 6.13814 22.2893 4.86562 21.2757C3.51874 20.2063 2.41867 18.8588 1.64062 17.3251Z" fill="#319F43"/>
</g>
<defs>
<clipPath id="clip0_2781_85129">
<rect width="24" height="24" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 23 23" fill="none">
<path d="M0 0h10.5v10.5H0V0z" fill="#F25022"/>
<path d="M12.5 0H23v10.5H12.5V0z" fill="#7FBA00"/>
<path d="M0 12.5h10.5V23H0V12.5z" fill="#00A4EF"/>
<path d="M12.5 12.5H23V23H12.5V12.5z" fill="#FFB900"/>
</svg>

After

Width:  |  Height:  |  Size: 292 B

@@ -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!",
+50 -28
View File
@@ -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 (
<Suspense fallback={<LoadingFallback />}>
<PreferencesProvider>
<PreferencesProvider>
<RainbowThemeProvider>
<ErrorBoundary>
<OnboardingProvider>
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
<ToolRegistryProvider>
<NavigationProvider>
<FilesModalProvider>
<ToolWorkflowProvider>
<HotkeyProvider>
<SidebarProvider>
<ViewerProvider>
<SignatureProvider>
<RightRailProvider>
<TourOrchestrationProvider>
<HomePage />
<OnboardingTour />
</TourOrchestrationProvider>
</RightRailProvider>
</SignatureProvider>
</ViewerProvider>
</SidebarProvider>
</HotkeyProvider>
</ToolWorkflowProvider>
</FilesModalProvider>
</NavigationProvider>
</ToolRegistryProvider>
</FileContextProvider>
</OnboardingProvider>
<AuthProvider>
<Routes>
{/* Auth routes - no FileContext or other providers needed */}
<Route path="/login" element={<Login />} />
<Route path="/signup" element={<Signup />} />
<Route path="/auth/callback" element={<AuthCallback />} />
{/* Main app routes - wrapped with all providers */}
<Route
path="/*"
element={
<OnboardingProvider>
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
<ToolRegistryProvider>
<NavigationProvider>
<FilesModalProvider>
<ToolWorkflowProvider>
<HotkeyProvider>
<SidebarProvider>
<ViewerProvider>
<SignatureProvider>
<RightRailProvider>
<TourOrchestrationProvider>
<Landing />
<OnboardingTour />
</TourOrchestrationProvider>
</RightRailProvider>
</SignatureProvider>
</ViewerProvider>
</SidebarProvider>
</HotkeyProvider>
</ToolWorkflowProvider>
</FilesModalProvider>
</NavigationProvider>
</ToolRegistryProvider>
</FileContextProvider>
</OnboardingProvider>
}
/>
</Routes>
</AuthProvider>
</ErrorBoundary>
</RainbowThemeProvider>
</PreferencesProvider>
+233
View File
@@ -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<void>;
refreshSession: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType>({
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<Session | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<AuthError | null>(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 (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
}
/**
* 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;
}
+447
View File
@@ -0,0 +1,447 @@
/**
* Spring Auth Client
*
* This client integrates with the Spring Security + JWT backend.
* - Uses HttpOnly cookies for JWT storage (automatic secure storage)
* - JWT validation handled server-side
* - No email confirmation flow (auto-confirmed on registration)
* - Refresh tokens stored in separate HttpOnly cookie
*/
// Auth types
export interface User {
id: string;
email: string;
username: string;
role: string;
enabled?: boolean;
is_anonymous?: boolean;
app_metadata?: Record<string, any>;
}
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;
export interface AuthConfig {
secureCookie: boolean;
jwtEnabled: boolean;
}
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
private authConfig: AuthConfig | null = null;
constructor() {
// Load auth config
this.loadAuthConfig();
// Start periodic session validation
this.startSessionMonitoring();
}
/**
* Load authentication configuration from backend
*/
private async loadAuthConfig() {
try {
const response = await fetch('/api/v1/auth/config', {
credentials: 'include',
});
if (response.ok) {
this.authConfig = await response.json();
console.debug('[SpringAuth] Auth config loaded:', this.authConfig);
} else {
console.warn('[SpringAuth] Failed to load auth config');
}
} catch (error) {
console.error('[SpringAuth] Error loading auth config:', error);
}
}
/**
* Get authentication configuration
*/
getAuthConfig(): AuthConfig | null {
return this.authConfig;
}
/**
* 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 HttpOnly cookie (automatic with credentials: 'include')
*/
async getSession(): Promise<{ data: { session: Session | null }; error: AuthError | null }> {
try {
// Verify with backend (JWT automatically sent via HttpOnly cookie)
const response = await fetch('/api/v1/auth/me', {
credentials: 'include', // Include cookies
});
if (!response.ok) {
console.debug('[SpringAuth] getSession: Not authenticated (status:', response.status, ')');
return { data: { session: null }, error: null };
}
const data = await response.json();
// Create session object (no access_token in JS - it's in HttpOnly cookie)
const session: Session = {
user: data.user,
access_token: '', // Not accessible to JavaScript (stored in HttpOnly cookie)
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);
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<AuthResponse> {
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();
// JWT is now stored in HttpOnly cookie by backend (no localStorage needed)
console.log('[SpringAuth] JWT stored in HttpOnly cookie by backend');
const session: Session = {
user: data.user,
access_token: '', // Not accessible to JavaScript (stored in HttpOnly cookie)
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<AuthResponse> {
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<string, any> };
}): 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 - revokes refresh tokens and clears HttpOnly cookies
*/
async signOut(): Promise<{ error: AuthError | null }> {
try {
console.log('[SpringAuth] Signing out, clearing HttpOnly cookies');
const csrfToken = this.getCsrfToken();
const headers: HeadersInit = {};
if (csrfToken) {
headers['X-XSRF-TOKEN'] = csrfToken;
}
// Notify backend to revoke refresh tokens and clear cookies
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);
return {
error: { message: error instanceof Error ? error.message : 'Sign out failed' },
};
}
}
/**
* Refresh session token using refresh token (automatically sent via HttpOnly cookie)
*/
async refreshSession(): Promise<{ data: { session: Session | null }; error: AuthError | null }> {
try {
// Refresh token is automatically sent via HttpOnly cookie
const response = await fetch('/api/v1/auth/refresh', {
method: 'POST',
credentials: 'include',
});
if (!response.ok) {
return { data: { session: null }, error: { message: 'Token refresh failed' } };
}
// Backend sets new access token and refresh token in HttpOnly cookies
// Get updated user info
const userResponse = await fetch('/api/v1/auth/me', {
credentials: 'include',
});
if (!userResponse.ok) {
return { data: { session: null }, error: { message: 'Failed to get user info' } };
}
const userData = await userResponse.json();
const session: Session = {
user: userData.user,
access_token: '', // Not accessible to JavaScript (stored in HttpOnly cookie)
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);
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;
@@ -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 (
<div
className={`text-divider ${variantClass} ${themeClass} ${className}`}
style={styleWithOpacity}
>
<div className="text-divider__rule" />
<span className="text-divider__label">{text}</span>
<div className="text-divider__rule" />
</div>
)
}
return (
<div
className={`h-px my-2.5 ${themeClass} ${className}`}
style={styleWithOpacity}
/>
)
}
@@ -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<HTMLImageElement | null>(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 (
<img
ref={imgRef}
src={src}
alt={alt ?? 'Carousel slide'}
style={{
maxWidth: '86%',
maxHeight: '78%',
objectFit: 'contain',
borderRadius: '18px',
background: 'transparent',
transform: 'translateY(-2rem)',
transition: 'transform 80ms ease-out',
willChange: 'transform',
transformOrigin: '50% 50%',
}}
/>
)
}
return (
<div style={{ position: 'relative', overflow: 'hidden', width: '100%', height: '100%' }}>
{showBackground && (
<img
src={`${BASE_PATH}/Login/LoginBackgroundPanel.png`}
alt="Background panel"
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover' }}
/>
)}
{/* Image slides */}
{imageSlides.map((s, idx) => (
<div
key={s.src}
style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'opacity 600ms ease',
opacity: index === idx ? 1 : 0,
perspective: '900px',
}}
>
{(s.title || s.subtitle) && (
<div style={{ position: 'absolute', bottom: 24 + 32, left: 0, right: 0, textAlign: 'center', padding: '0 2rem', width: '100%' }}>
{s.title && (
<div style={{ fontSize: 20, fontWeight: 800, color: '#ffffff', textShadow: '0 2px 6px rgba(0,0,0,0.25)', marginBottom: 6 }}>{s.title}</div>
)}
{s.subtitle && (
<div style={{ fontSize: 13, color: 'rgba(255,255,255,0.92)', textShadow: '0 1px 4px rgba(0,0,0,0.25)' }}>{s.subtitle}</div>
)}
</div>
)}
<TiltImage src={s.src} alt={s.alt} enabled={index === idx && !!s.followMouseTilt} maxDeg={s.tiltMaxDeg ?? 6} />
</div>
))}
{/* Dot navigation */}
<div
style={{
position: 'absolute',
bottom: 16,
left: 0,
right: 0,
display: 'flex',
justifyContent: 'center',
gap: 10,
zIndex: 2,
}}
>
{Array.from({ length: totalSlides }).map((_, i) => (
<button
key={i}
aria-label={`Go to slide ${i + 1}`}
onClick={() => setIndex(i)}
style={{
width: '10px',
height: '12px',
borderRadius: '50%',
border: 'none',
cursor: 'pointer',
backgroundColor: i === index ? '#ffffff' : 'rgba(255,255,255,0.5)',
boxShadow: '0 2px 6px rgba(0,0,0,0.25)',
display: 'block',
flexShrink: 0,
}}
/>
))}
</div>
</div>
)
}
@@ -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 (
<Stack align="center" py="md">
@@ -74,10 +87,24 @@ const Overview: React.FC = () => {
return (
<Stack gap="lg">
<div>
<Text fw={600} size="lg">Application Configuration</Text>
<Text size="sm" c="dimmed">
Current application settings and configuration details.
</Text>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
<div>
<Text fw={600} size="lg">Application Configuration</Text>
<Text size="sm" c="dimmed">
Current application settings and configuration details.
</Text>
{user?.email && (
<Text size="xs" c="dimmed" mt="0.25rem">
Signed in as: {user.email}
</Text>
)}
</div>
{user && (
<Button color="red" variant="filled" onClick={handleLogout}>
Log out
</Button>
)}
</div>
</div>
{config && (
@@ -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);
}
@@ -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
+10 -3
View File
@@ -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<OnboardingContextValue | undefined>(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);
+9 -1
View File
@@ -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}`);
+12 -2
View File
@@ -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}`);
+64
View File
@@ -0,0 +1,64 @@
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 now stored in an HttpOnly cookie by the Spring backend (secure, no localStorage).
* We just need to verify the authentication 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...')
// JWT is now stored in HttpOnly cookie by backend - just refresh session to verify
const result = await refreshSession()
if (result.error || !result.data.session) {
console.error('[AuthCallback] Authentication verification failed:', result.error)
navigate('/login', {
replace: true,
state: { error: 'OAuth login failed - authentication could not be verified.' }
})
return
}
console.log('[AuthCallback] Authentication verified, redirecting to home')
// 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 (
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100vh'
}}>
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-3"></div>
<div className="text-gray-600">
Completing authentication...
</div>
</div>
</div>
)
}
+62
View File
@@ -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 (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-3"></div>
<div className="text-gray-600">
Loading...
</div>
</div>
</div>
)
}
// If login is disabled, show app directly (anonymous mode)
if (config?.enableLogin === false) {
console.debug('[Landing] Login disabled - showing app in anonymous mode')
return <HomePage />
}
// If we have a session, show the main app
if (session) {
return <HomePage />
}
// 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 <Login />
}
// For non-home routes without auth, navigate to login (preserves from location)
return <Navigate to="/login" replace state={{ from: location }} />
}
+189
View File
@@ -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<string | null>(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 <LoggedInState />
}
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 (
<AuthLayout>
<LoginHeader title={t('login.login') || 'Sign in'} />
<ErrorMessage error={error} />
{/* OAuth first */}
<OAuthButtons
onProviderClick={signInWithProvider}
isSubmitting={isSigningIn}
layout="vertical"
/>
{/* Divider between OAuth and Email */}
<DividerWithText text={t('signup.or', 'or')} respondsToDarkMode={false} opacity={0.4} />
{/* Sign in with email button (primary color to match signup CTA) */}
<div className="auth-section">
<button
type="button"
onClick={() => setShowEmailForm(true)}
disabled={isSigningIn}
className="w-full px-4 py-[0.75rem] rounded-[0.625rem] text-base font-semibold mb-2 cursor-pointer border-0 disabled:opacity-50 disabled:cursor-not-allowed auth-cta-button"
>
{t('login.useEmailInstead', 'Login with email')}
</button>
</div>
{showEmailForm && (
<div style={{ marginTop: '1rem' }}>
<EmailPasswordForm
email={email}
password={password}
setEmail={setEmail}
setPassword={setPassword}
onSubmit={signInWithEmail}
isSubmitting={isSigningIn}
submitButtonText={isSigningIn ? (t('login.loggingIn') || 'Signing in...') : (t('login.login') || 'Sign in')}
/>
</div>
)}
{showEmailForm && (
<div className="auth-section-sm">
<button
type="button"
onClick={handleForgotPassword}
className="auth-link-black"
>
{t('login.forgotPassword', 'Forgot your password?')}
</button>
</div>
)}
{/* Divider then signup link */}
<DividerWithText text={t('signup.or', 'or')} respondsToDarkMode={false} opacity={0.4} />
<div style={{ textAlign: 'center', margin: '0.5rem 0 0.25rem' }}>
<button
type="button"
onClick={() => navigate('/signup')}
className="auth-link-black"
>
{t('signup.signUp', 'Sign up')}
</button>
</div>
</AuthLayout>
)
}
+105
View File
@@ -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<string | null>(null)
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [fieldErrors, setFieldErrors] = useState<SignupFieldErrors>({})
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 (
<AuthLayout>
<LoginHeader title={t('signup.title', 'Create an account')} subtitle={t('signup.subtitle', 'Join Stirling PDF')} />
<ErrorMessage error={error} />
{/* Signup form - shown immediately */}
<SignupForm
email={email}
password={password}
confirmPassword={confirmPassword}
setEmail={setEmail}
setPassword={setPassword}
setConfirmPassword={setConfirmPassword}
onSubmit={handleSignUp}
isSubmitting={isSigningUp}
fieldErrors={fieldErrors}
showName={false}
showTerms={false}
/>
<DividerWithText text={t('signup.or', 'or')} respondsToDarkMode={false} opacity={0.4} />
{/* Bottom row - centered */}
<div style={{ textAlign: 'center', margin: '0.5rem 0 0.25rem' }}>
<button
type="button"
onClick={() => navigate('/login')}
className="auth-link-black"
>
{t('login.logIn', 'Log In')}
</button>
</div>
</AuthLayout>
)
}
@@ -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%;
}
@@ -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<HTMLDivElement | null>(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 (
<div className={styles.authContainer}>
<div
ref={cardRef}
className={`${styles.authCard} ${!hideRightPanel ? styles.authCardTwoColumns : ''}`}
style={{ marginBottom: 'auto' }}
>
<div className={styles.authLeftPanel}>
<div className={styles.authContent}>
{children}
</div>
</div>
{!hideRightPanel && (
<LoginRightCarousel imageSlides={loginSlides} initialSeconds={5} slideSeconds={8} />
)}
</div>
</div>
)
}
+378
View File
@@ -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;
}
@@ -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 (
<form onSubmit={handleSubmit}>
<div className="auth-fields">
<div className="auth-field">
<label htmlFor="email" className="auth-label">{t('login.username', 'Username')}</label>
<input
id="email"
type="text"
name="username"
autoComplete="username"
placeholder={t('login.enterUsername', 'Enter username')}
value={email}
onChange={(e) => setEmail(e.target.value)}
className={`auth-input ${fieldErrors.email ? 'auth-input-error' : ''}`}
/>
{fieldErrors.email && (
<div className="auth-field-error">{fieldErrors.email}</div>
)}
</div>
{showPasswordField && (
<div className="auth-field">
<label htmlFor="password" className="auth-label">{t('login.password')}</label>
<input
id="password"
type="password"
name="current-password"
autoComplete="current-password"
placeholder={t('login.enterPassword')}
value={password}
onChange={(e) => setPassword(e.target.value)}
className={`auth-input ${fieldErrors.password ? 'auth-input-error' : ''}`}
/>
{fieldErrors.password && (
<div className="auth-field-error">{fieldErrors.password}</div>
)}
</div>
)}
</div>
<button
type="submit"
disabled={isSubmitting || !email || (showPasswordField && !password)}
className="auth-button"
>
{submitButtonText}
</button>
</form>
)
}
@@ -0,0 +1,13 @@
interface ErrorMessageProps {
error: string | null
}
export default function ErrorMessage({ error }: ErrorMessageProps) {
if (!error) return null
return (
<div className="error-message">
<p className="error-message-text">{error}</p>
</div>
)
}
@@ -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 (
<div style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#f3f4f6',
padding: '16px'
}}>
<div style={{
maxWidth: '400px',
width: '100%',
backgroundColor: '#ffffff',
borderRadius: '16px',
boxShadow: '0 10px 25px rgba(0, 0, 0, 0.1)',
padding: '32px'
}}>
<div style={{ textAlign: 'center', marginBottom: '24px' }}>
<div style={{ fontSize: '48px', marginBottom: '16px' }}></div>
<h1 style={{ fontSize: '24px', fontWeight: 'bold', color: '#059669', marginBottom: '8px' }}>
{t('login.youAreLoggedIn')}
</h1>
<p style={{ color: '#6b7280', fontSize: '14px' }}>
{t('login.email')}: {user?.email}
</p>
</div>
<div style={{ textAlign: 'center', marginTop: '16px' }}>
<p style={{ color: '#6b7280', fontSize: '14px' }}>
Redirecting to home...
</p>
</div>
</div>
</div>
)
}
+22
View File
@@ -0,0 +1,22 @@
import { BASE_PATH } from '../../constants/app';
interface LoginHeaderProps {
title: string
subtitle?: string
}
export default function LoginHeader({ title, subtitle }: LoginHeaderProps) {
return (
<div className="login-header">
<div className="login-header-logos">
<img src={`${BASE_PATH}/logo192.png`} alt="Logo" className="login-logo-icon" />
<img src={`${BASE_PATH}/branding/StirlingPDFLogoBlackText.svg`} alt="Stirling PDF" className="login-logo-text" />
</div>
<h1 className="login-title">{title}</h1>
{subtitle && (
<p className="login-subtitle">{subtitle}</p>
)}
</div>
)
}
@@ -0,0 +1,19 @@
interface NavigationLinkProps {
onClick: () => void
text: string
isDisabled?: boolean
}
export default function NavigationLink({ onClick, text, isDisabled = false }: NavigationLinkProps) {
return (
<div className="navigation-link-container">
<button
onClick={onClick}
disabled={isDisabled}
className="navigation-link-button"
>
{text}
</button>
</div>
)
}
@@ -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 (
<div className="oauth-container-icons">
{enabledProviders.map((p) => (
<div key={p.id} title={`${t('login.signInWith', 'Sign in with')} ${p.label}`}>
<button
onClick={() => onProviderClick(p.id as any)}
disabled={isSubmitting}
className="oauth-button-icon"
aria-label={`${t('login.signInWith', 'Sign in with')} ${p.label}`}
>
<img src={`${BASE_PATH}/Login/${p.file}`} alt={p.label} className="oauth-icon-small"/>
</button>
</div>
))}
</div>
)
}
if (layout === 'grid') {
return (
<div className="oauth-container-grid">
{enabledProviders.map((p) => (
<div key={p.id} title={`${t('login.signInWith', 'Sign in with')} ${p.label}`}>
<button
onClick={() => onProviderClick(p.id as any)}
disabled={isSubmitting}
className="oauth-button-grid"
aria-label={`${t('login.signInWith', 'Sign in with')} ${p.label}`}
>
<img src={`${BASE_PATH}/Login/${p.file}`} alt={p.label} className="oauth-icon-medium"/>
</button>
</div>
))}
</div>
)
}
return (
<div className="oauth-container-vertical">
{enabledProviders.map((p) => (
<button
key={p.id}
onClick={() => onProviderClick(p.id as any)}
disabled={isSubmitting}
className="oauth-button-vertical"
title={p.label}
>
<img src={`${BASE_PATH}/Login/${p.file}`} alt={p.label} className="oauth-icon-tiny" />
{p.label}
</button>
))}
</div>
)
}
+54
View File
@@ -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
}
}
+162
View File
@@ -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 (
<>
<div className="auth-fields">
{showName && (
<div className="auth-field">
<label htmlFor="name" className="auth-label">{t('signup.name')}</label>
<input
id="name"
type="text"
name="name"
autoComplete="name"
placeholder={t('signup.enterName')}
value={name}
onChange={(e) => setName?.(e.target.value)}
className={`auth-input ${fieldErrors.name ? 'auth-input-error' : ''}`}
/>
{fieldErrors.name && (
<div className="auth-field-error">{fieldErrors.name}</div>
)}
</div>
)}
<div className="auth-field">
<label htmlFor="email" className="auth-label">{t('signup.email')}</label>
<input
id="email"
type="email"
name="email"
autoComplete="email"
placeholder={t('signup.enterEmail')}
value={email}
onChange={(e) => setEmail(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && !isSubmitting && onSubmit()}
className={`auth-input ${fieldErrors.email ? 'auth-input-error' : ''}`}
/>
{fieldErrors.email && (
<div className="auth-field-error">{fieldErrors.email}</div>
)}
</div>
<div className="auth-field">
<label htmlFor="password" className="auth-label">{t('signup.password')}</label>
<input
id="password"
type="password"
name="new-password"
autoComplete="new-password"
placeholder={t('signup.enterPassword')}
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && !isSubmitting && onSubmit()}
className={`auth-input ${fieldErrors.password ? 'auth-input-error' : ''}`}
/>
{fieldErrors.password && (
<div className="auth-field-error">{fieldErrors.password}</div>
)}
</div>
<div
aria-hidden={!showConfirm}
className="auth-confirm"
style={{ maxHeight: showConfirm ? 96 : 0, opacity: showConfirm ? 1 : 0 }}
>
<div className="auth-field">
<label htmlFor="confirmPassword" className="auth-label">{t('signup.confirmPassword')}</label>
<input
id="confirmPassword"
type="password"
name="new-password"
autoComplete="new-password"
placeholder={t('signup.confirmPasswordPlaceholder')}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && !isSubmitting && onSubmit()}
className={`auth-input ${fieldErrors.confirmPassword ? 'auth-input-error' : ''}`}
/>
{fieldErrors.confirmPassword && (
<div className="auth-field-error">{fieldErrors.confirmPassword}</div>
)}
</div>
</div>
</div>
{/* Terms - only show if showTerms is true */}
{showTerms && (
<div className="auth-terms">
<input
id="agree"
type="checkbox"
checked={agree}
onChange={(e) => setAgree?.(e.target.checked)}
className="auth-checkbox"
/>
<label htmlFor="agree" className="auth-terms-label">
{t("legal.iAgreeToThe", 'I agree to all of the')} {" "}
<a href="https://www.stirlingpdf.com/terms" target="_blank" rel="noopener noreferrer">
{t('legal.terms', 'Terms and Conditions')}
</a>
</label>
</div>
)}
{/* Sign Up Button */}
<button
onClick={onSubmit}
disabled={isSubmitting || !email || !password || !confirmPassword || (showTerms && !agree)}
className="auth-button"
>
{isSubmitting ? t('signup.creatingAccount') : t('signup.signUp')}
</button>
</>
)
}
@@ -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
}
}
+29
View File
@@ -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,
+22 -1
View File
@@ -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;
+10
View File
@@ -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}` : './',