Fixing user refresh isuue

This commit is contained in:
Dario Ghunney Ware
2025-10-15 20:11:49 +01:00
parent 01d18279e0
commit a969f13715
22 changed files with 390 additions and 103 deletions

View File

@@ -1,6 +1,7 @@
package stirling.software.SPDF.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@@ -17,6 +18,17 @@ public class WebMvcConfig implements WebMvcConfigurer {
registry.addInterceptor(endpointInterceptor);
}
@Override
public void addCorsMappings(CorsRegistry registry) {
// Allow frontend dev server (Vite on localhost:5173) to access backend
registry.addMapping("/**")
.allowedOrigins("http://localhost:5173", "http://127.0.0.1:5173")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
// @Override
// public void addResourceHandlers(ResourceHandlerRegistry registry) {
// // Handler for external static resources - DISABLED in backend-only mode

View File

@@ -156,6 +156,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)
@@ -254,9 +261,13 @@ public class SecurityConfiguration {
|| trimmedUri.startsWith("/favicon")
|| trimmedUri.startsWith(
"/api/v1/info/status")
|| trimmedUri.startsWith("/api/v1/auth/register")
|| trimmedUri.startsWith("/api/v1/auth/login")
|| trimmedUri.startsWith("/api/v1/auth/refresh")
|| trimmedUri.startsWith(
"/api/v1/auth/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");
})

View File

@@ -26,11 +26,11 @@ import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.UserService;
/**
* REST API Controller for authentication operations.
* Replaces Supabase authentication with Spring Security + JWT.
* REST API Controller for authentication operations. Replaces Supabase authentication with Spring
* Security + JWT.
*
* This controller provides endpoints matching the Supabase API surface
* to enable seamless frontend integration.
* <p>This controller provides endpoints matching the Supabase API surface to enable seamless
* frontend integration.
*/
@RestController
@RequestMapping("/api/v1/auth")
@@ -52,27 +52,26 @@ public class AuthController {
*/
@PostMapping("/login")
public ResponseEntity<?> login(
@RequestBody LoginRequest request,
HttpServletResponse response) {
@RequestBody LoginRequest request, HttpServletResponse response) {
try {
log.debug("Login attempt for user: {}", request.getEmail());
log.debug("Login attempt for user: {}", request.email());
// Load user
UserDetails userDetails = userDetailsService.loadUserByUsername(request.getEmail());
UserDetails userDetails = userDetailsService.loadUserByUsername(request.email());
User user = (User) userDetails;
// Validate password
if (!userService.isPasswordCorrect(user, request.getPassword())) {
log.warn("Invalid password for user: {}", request.getEmail());
if (!userService.isPasswordCorrect(user, request.password())) {
log.warn("Invalid password for user: {}", request.email());
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Invalid credentials"));
.body(Map.of("error", "Invalid credentials"));
}
// Check if user is enabled
if (!user.isEnabled()) {
log.warn("Disabled user attempted login: {}", request.getEmail());
log.warn("Disabled user attempted login: {}", request.email());
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "User account is disabled"));
.body(Map.of("error", "User account is disabled"));
}
// Generate JWT with claims
@@ -85,25 +84,22 @@ public class AuthController {
// Set JWT cookie (HttpOnly for security)
jwtService.addToken(response, token);
log.info("Login successful for user: {}", request.getEmail());
log.info("Login successful for user: {}", request.email());
// Return user info (matches Supabase response structure)
return ResponseEntity.ok(Map.of(
"user", buildUserResponse(user),
"session", Map.of(
"access_token", token,
"expires_in", 3600
)
));
return ResponseEntity.ok(
Map.of(
"user", buildUserResponse(user),
"session", Map.of("access_token", token, "expires_in", 3600)));
} catch (AuthenticationException e) {
log.error("Authentication failed for user: {}", request.getEmail(), e);
log.error("Authentication failed for user: {}", request.email(), e);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Invalid credentials"));
.body(Map.of("error", "Invalid credentials"));
} catch (Exception e) {
log.error("Login error for user: {}", request.getEmail(), e);
log.error("Login error for user: {}", request.email(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Internal server error"));
.body(Map.of("error", "Internal server error"));
}
}
@@ -116,53 +112,57 @@ public class AuthController {
@PostMapping("/register")
public ResponseEntity<?> register(@RequestBody RegisterRequest request) {
try {
log.debug("Registration attempt for user: {}", request.getEmail());
log.debug("Registration attempt for user: {}", request.email());
// Check if username exists
if (userService.usernameExistsIgnoreCase(request.getEmail())) {
log.warn("Registration failed: username already exists: {}", request.getEmail());
if (userService.usernameExistsIgnoreCase(request.email())) {
log.warn("Registration failed: username already exists: {}", request.email());
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "User already exists"));
.body(Map.of("error", "User already exists"));
}
// Validate username format
if (!userService.isUsernameValid(request.getEmail())) {
log.warn("Registration failed: invalid username format: {}", request.getEmail());
if (!userService.isUsernameValid(request.email())) {
log.warn("Registration failed: invalid username format: {}", request.email());
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Invalid username format"));
.body(Map.of("error", "Invalid username format"));
}
// Validate password
if (request.getPassword() == null || request.getPassword().length() < 6) {
if (request.password() == null || request.password().length() < 6) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Password must be at least 6 characters"));
.body(Map.of("error", "Password must be at least 6 characters"));
}
// Create user (using default team and USER role)
User user = userService.saveUser(
request.getEmail(),
request.getPassword(),
(Long) null, // team (use default)
Role.USER.getRoleId(),
false // first login not required
);
User user =
userService.saveUser(
request.email(),
request.password(),
(Long) null, // team (use default)
Role.USER.getRoleId(),
false // first login not required
);
log.info("User registered successfully: {}", request.getEmail());
log.info("User registered successfully: {}", request.email());
// Return user info (Note: No session, user must login)
return ResponseEntity.status(HttpStatus.CREATED).body(Map.of(
"user", buildUserResponse(user),
"message", "Account created successfully. Please log in."
));
return ResponseEntity.status(HttpStatus.CREATED)
.body(
Map.of(
"user",
buildUserResponse(user),
"message",
"Account created successfully. Please log in."));
} catch (IllegalArgumentException e) {
log.error("Registration validation error: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", e.getMessage()));
.body(Map.of("error", e.getMessage()));
} catch (Exception e) {
log.error("Registration error for user: {}", request.getEmail(), e);
log.error("Registration error for user: {}", request.email(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Registration failed: " + e.getMessage()));
.body(Map.of("error", "Registration failed: " + e.getMessage()));
}
}
@@ -176,22 +176,22 @@ public class AuthController {
try {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated() || auth.getPrincipal().equals("anonymousUser")) {
if (auth == null
|| !auth.isAuthenticated()
|| auth.getPrincipal().equals("anonymousUser")) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Not authenticated"));
.body(Map.of("error", "Not authenticated"));
}
UserDetails userDetails = (UserDetails) auth.getPrincipal();
User user = (User) userDetails;
return ResponseEntity.ok(Map.of(
"user", buildUserResponse(user)
));
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"));
.body(Map.of("error", "Internal server error"));
}
}
@@ -217,7 +217,7 @@ public class AuthController {
} catch (Exception e) {
log.error("Logout error", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Internal server error"));
.body(Map.of("error", "Internal server error"));
}
}
@@ -229,15 +229,13 @@ public class AuthController {
* @return New token information
*/
@PostMapping("/refresh")
public ResponseEntity<?> refresh(
HttpServletRequest request,
HttpServletResponse response) {
public ResponseEntity<?> refresh(HttpServletRequest request, HttpServletResponse response) {
try {
String token = jwtService.extractToken(request);
if (token == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "No token found"));
.body(Map.of("error", "No token found"));
}
// Validate and extract username
@@ -257,15 +255,12 @@ public class AuthController {
log.debug("Token refreshed for user: {}", username);
return ResponseEntity.ok(Map.of(
"access_token", newToken,
"expires_in", 3600
));
return ResponseEntity.ok(Map.of("access_token", newToken, "expires_in", 3600));
} catch (Exception e) {
log.error("Token refresh error", e);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Token refresh failed"));
.body(Map.of("error", "Token refresh failed"));
}
}
@@ -295,13 +290,9 @@ public class AuthController {
// Request/Response DTOs
// ===========================
/**
* Login request DTO
*/
/** Login request DTO */
public record LoginRequest(String email, String password) {}
/**
* Registration request DTO
*/
/** Registration request DTO */
public record RegisterRequest(String email, String password, String name) {}
}
}

View File

@@ -75,14 +75,23 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
String jwtToken = jwtService.extractToken(request);
if (jwtToken == null) {
// Any unauthenticated requests should redirect to /login
// Allow auth endpoints to pass through without JWT
String requestURI = request.getRequestURI();
String contextPath = request.getContextPath();
if (!requestURI.startsWith(contextPath + "/login")) {
// Skip redirect for auth endpoints (they'll handle their own auth checks)
if (!requestURI.startsWith(contextPath + "/login")
&& !requestURI.startsWith(contextPath + "/api/v1/auth")) {
response.sendRedirect("/login");
return;
}
// For auth endpoints without JWT, continue to the endpoint
// (it will return 401 if needed)
if (requestURI.startsWith(contextPath + "/api/v1/auth")) {
filterChain.doFilter(request, response);
return;
}
}
try {

View File

@@ -236,6 +236,9 @@ 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 + "/site.webmanifest"
};

View File

@@ -279,9 +279,12 @@ public class JwtService implements JwtServiceInterface {
ResponseCookie.from(JWT_COOKIE_NAME, Newlines.stripAll(token))
.httpOnly(true)
.secure(secureCookie)
.sameSite("Strict")
.sameSite("Lax") // Changed from Strict to Lax for cross-port dev
// compatibility
.maxAge(EXPIRATION / 1000)
.path("/")
.domain("localhost") // Set domain to localhost for cross-port dev
// compatibility
.build();
response.addHeader("Set-Cookie", cookie.toString());
@@ -296,6 +299,8 @@ public class JwtService implements JwtServiceInterface {
.sameSite("None")
.maxAge(0)
.path("/")
.domain("localhost") // Set domain to localhost for cross-port dev
// compatibility
.build();
response.addHeader("Set-Cookie", cookie.toString());

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

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

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

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

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

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

View File

@@ -1,5 +1,5 @@
import { Suspense } from "react";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { Routes, Route } from "react-router-dom";
import { RainbowThemeProvider } from "./components/shared/RainbowThemeProvider";
import { FileContextProvider } from "./contexts/FileContext";
import { NavigationProvider } from "./contexts/NavigationContext";
@@ -52,8 +52,7 @@ const LoadingFallback = () => (
export default function App() {
return (
<Suspense fallback={<LoadingFallback />}>
<BrowserRouter>
<PreferencesProvider>
<PreferencesProvider>
<RainbowThemeProvider>
<ErrorBoundary>
<AuthProvider>
@@ -83,16 +82,16 @@ export default function App() {
<OnboardingTour />
</TourOrchestrationProvider>
</RightRailProvider>
</SignatureProvider>
</ViewerProvider>
</SidebarProvider>
</HotkeyProvider>
</ToolWorkflowProvider>
</FilesModalProvider>
</NavigationProvider>
</ToolRegistryProvider>
</FileContextProvider>
</OnboardingProvider>
</SignatureProvider>
</ViewerProvider>
</SidebarProvider>
</HotkeyProvider>
</ToolWorkflowProvider>
</FilesModalProvider>
</NavigationProvider>
</ToolRegistryProvider>
</FileContextProvider>
</OnboardingProvider>
}
/>
</Routes>
@@ -100,7 +99,6 @@ export default function App() {
</ErrorBoundary>
</RainbowThemeProvider>
</PreferencesProvider>
</BrowserRouter>
</Suspense>
);
}

View File

@@ -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>
)
}

View File

@@ -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

View File

@@ -102,8 +102,8 @@ export default function Login() {
setError(error.message)
} else if (user && session) {
console.log('[Login] Email sign in successful')
// Navigate to home page
navigate('/')
// 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)

View File

@@ -1,4 +1,6 @@
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 {
@@ -58,11 +60,7 @@ export default function AuthLayout({ children }: AuthLayoutProps) {
</div>
</div>
{!hideRightPanel && (
<div style={{
backgroundImage: `url(${window.location.origin}/images/auth-background.jpg)`,
backgroundSize: 'cover',
backgroundPosition: 'center'
}} />
<LoginRightCarousel imageSlides={loginSlides} initialSeconds={5} slideSeconds={8} />
)}
</div>
</div>

View File

@@ -29,8 +29,13 @@ export default function EmailPasswordForm({
}: 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.email')}</label>
@@ -70,12 +75,12 @@ export default function EmailPasswordForm({
</div>
<button
onClick={onSubmit}
type="submit"
disabled={isSubmitting || !email || (showPasswordField && !password)}
className="auth-button"
>
{submitButtonText}
</button>
</>
</form>
)
}

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;