mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-03-04 02:20:19 +01:00
Added rest api for signup/login. added frontend auth service
This commit is contained in:
@@ -241,6 +241,8 @@ public class SecurityConfiguration {
|
||||
|| 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 +254,9 @@ 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("/v1/api-docs")
|
||||
|| uri.contains("/v1/api-docs");
|
||||
})
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
package stirling.software.proprietary.security.controller.api;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.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.web.bind.annotation.*;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.annotations.api.UserApi;
|
||||
import stirling.software.common.model.enumeration.Role;
|
||||
import stirling.software.proprietary.security.model.AuthenticationType;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.service.CustomUserDetailsService;
|
||||
import stirling.software.proprietary.security.service.JwtServiceInterface;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
/**
|
||||
* REST API Controller for authentication operations.
|
||||
* Replaces Supabase authentication with Spring Security + JWT.
|
||||
*
|
||||
* This controller provides endpoints matching the Supabase API surface
|
||||
* to enable seamless frontend integration.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/auth")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
@UserApi
|
||||
public class AuthController {
|
||||
|
||||
private final UserService userService;
|
||||
private final JwtServiceInterface jwtService;
|
||||
private final CustomUserDetailsService userDetailsService;
|
||||
|
||||
/**
|
||||
* Login endpoint - replaces Supabase signInWithPassword
|
||||
*
|
||||
* @param request Login credentials (email/username and password)
|
||||
* @param response HTTP response to set JWT cookie
|
||||
* @return User and session information
|
||||
*/
|
||||
@PostMapping("/login")
|
||||
public ResponseEntity<?> login(
|
||||
@RequestBody LoginRequest request,
|
||||
HttpServletResponse response) {
|
||||
try {
|
||||
log.debug("Login attempt for user: {}", request.getEmail());
|
||||
|
||||
// Load user
|
||||
UserDetails userDetails = userDetailsService.loadUserByUsername(request.getEmail());
|
||||
User user = (User) userDetails;
|
||||
|
||||
// Validate password
|
||||
if (!userService.isPasswordCorrect(user, request.getPassword())) {
|
||||
log.warn("Invalid password for user: {}", request.getEmail());
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "Invalid credentials"));
|
||||
}
|
||||
|
||||
// Check if user is enabled
|
||||
if (!user.isEnabled()) {
|
||||
log.warn("Disabled user attempted login: {}", request.getEmail());
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "User account is disabled"));
|
||||
}
|
||||
|
||||
// Generate JWT with claims
|
||||
Map<String, Object> claims = new HashMap<>();
|
||||
claims.put("authType", AuthenticationType.WEB.toString());
|
||||
claims.put("role", user.getRolesAsString());
|
||||
|
||||
String token = jwtService.generateToken(user.getUsername(), claims);
|
||||
|
||||
// Set JWT cookie (HttpOnly for security)
|
||||
jwtService.addToken(response, token);
|
||||
|
||||
log.info("Login successful for user: {}", request.getEmail());
|
||||
|
||||
// Return user info (matches Supabase response structure)
|
||||
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);
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "Invalid credentials"));
|
||||
} catch (Exception e) {
|
||||
log.error("Login error for user: {}", request.getEmail(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Internal server error"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registration endpoint - replaces Supabase signUp
|
||||
*
|
||||
* @param request Registration details (email, password, name)
|
||||
* @return User information or error
|
||||
*/
|
||||
@PostMapping("/register")
|
||||
public ResponseEntity<?> register(@RequestBody RegisterRequest request) {
|
||||
try {
|
||||
log.debug("Registration attempt for user: {}", request.getEmail());
|
||||
|
||||
// Check if username exists
|
||||
if (userService.usernameExistsIgnoreCase(request.getEmail())) {
|
||||
log.warn("Registration failed: username already exists: {}", request.getEmail());
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "User already exists"));
|
||||
}
|
||||
|
||||
// Validate username format
|
||||
if (!userService.isUsernameValid(request.getEmail())) {
|
||||
log.warn("Registration failed: invalid username format: {}", request.getEmail());
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Invalid username format"));
|
||||
}
|
||||
|
||||
// Validate password
|
||||
if (request.getPassword() == null || request.getPassword().length() < 6) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.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
|
||||
);
|
||||
|
||||
log.info("User registered successfully: {}", request.getEmail());
|
||||
|
||||
// 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."
|
||||
));
|
||||
|
||||
} catch (IllegalArgumentException e) {
|
||||
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: {}", request.getEmail(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Registration failed: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user - replaces Supabase getSession
|
||||
*
|
||||
* @return Current authenticated user information
|
||||
*/
|
||||
@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 - replaces Supabase signOut
|
||||
*
|
||||
* @param response HTTP response to clear JWT cookie
|
||||
* @return Success message
|
||||
*/
|
||||
@PostMapping("/logout")
|
||||
public ResponseEntity<?> logout(HttpServletResponse response) {
|
||||
try {
|
||||
// Clear JWT cookie
|
||||
jwtService.clearToken(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 - replaces Supabase refreshSession
|
||||
*
|
||||
* @param request HTTP request containing current JWT cookie
|
||||
* @param response HTTP response to set new JWT cookie
|
||||
* @return New token information
|
||||
*/
|
||||
@PostMapping("/refresh")
|
||||
public ResponseEntity<?> refresh(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response) {
|
||||
try {
|
||||
String token = jwtService.extractToken(request);
|
||||
|
||||
if (token == null) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "No token found"));
|
||||
}
|
||||
|
||||
// Validate and extract username
|
||||
jwtService.validateToken(token);
|
||||
String username = jwtService.extractUsername(token);
|
||||
|
||||
// Generate new token
|
||||
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
|
||||
User user = (User) userDetails;
|
||||
|
||||
Map<String, Object> claims = new HashMap<>();
|
||||
claims.put("authType", AuthenticationType.WEB.toString());
|
||||
claims.put("role", user.getRolesAsString());
|
||||
|
||||
String newToken = jwtService.generateToken(username, claims);
|
||||
jwtService.addToken(response, newToken);
|
||||
|
||||
log.debug("Token refreshed for user: {}", username);
|
||||
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"access_token", newToken,
|
||||
"expires_in", 3600
|
||||
));
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Token refresh error", e);
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "Token refresh failed"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to build user response object
|
||||
*
|
||||
* @param user User entity
|
||||
* @return Map containing user information
|
||||
*/
|
||||
private Map<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", "email"); // Default to email provider
|
||||
userMap.put("app_metadata", appMetadata);
|
||||
|
||||
return userMap;
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Request/Response DTOs
|
||||
// ===========================
|
||||
|
||||
/**
|
||||
* Login request DTO
|
||||
*/
|
||||
public record LoginRequest(String email, String password) {}
|
||||
|
||||
/**
|
||||
* Registration request DTO
|
||||
*/
|
||||
public record RegisterRequest(String email, String password, String name) {}
|
||||
}
|
||||
38
frontend/package-lock.json
generated
38
frontend/package-lock.json
generated
@@ -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",
|
||||
@@ -3379,6 +3394,7 @@
|
||||
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.10.4",
|
||||
"@babel/runtime": "^7.12.5",
|
||||
@@ -3681,6 +3697,7 @@
|
||||
"integrity": "sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.12.0"
|
||||
}
|
||||
@@ -3702,6 +3719,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 +3730,7 @@
|
||||
"integrity": "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.0.0"
|
||||
}
|
||||
@@ -3772,6 +3791,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",
|
||||
@@ -4216,7 +4236,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 +4245,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 +4255,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 +4267,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 +4294,7 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -4952,6 +4969,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.8.3",
|
||||
"caniuse-lite": "^1.0.30001741",
|
||||
@@ -6350,6 +6368,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",
|
||||
@@ -7794,6 +7813,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.27.6"
|
||||
},
|
||||
@@ -8590,6 +8610,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 +10399,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -10669,6 +10691,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 +11064,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 +11074,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 +12712,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -12969,6 +12995,7 @@
|
||||
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -13270,6 +13297,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 +13429,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -13414,6 +13443,7 @@
|
||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/expect": "3.2.4",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Suspense } from "react";
|
||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||
import { RainbowThemeProvider } from "./components/shared/RainbowThemeProvider";
|
||||
import { FileContextProvider } from "./contexts/FileContext";
|
||||
import { NavigationProvider } from "./contexts/NavigationContext";
|
||||
@@ -14,6 +15,13 @@ 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,38 +52,55 @@ const LoadingFallback = () => (
|
||||
export default function App() {
|
||||
return (
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<BrowserRouter>
|
||||
<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>
|
||||
</BrowserRouter>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
215
frontend/src/auth/UseSession.tsx
Normal file
215
frontend/src/auth/UseSession.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
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.
|
||||
* Replaces Supabase's AuthProvider with Spring Security + JWT integration.
|
||||
*/
|
||||
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...');
|
||||
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;
|
||||
}
|
||||
367
frontend/src/auth/springAuthClient.ts
Normal file
367
frontend/src/auth/springAuthClient.ts
Normal file
@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* Spring Auth Client - Replaces Supabase client
|
||||
*
|
||||
* This client provides the same API surface as Supabase for authentication,
|
||||
* but integrates with the Spring Security + JWT backend instead.
|
||||
*
|
||||
* Main differences from Supabase:
|
||||
* - Uses HttpOnly cookies for JWT storage (more secure than localStorage)
|
||||
* - JWT validation handled server-side
|
||||
* - No email confirmation flow (auto-confirmed on registration)
|
||||
*/
|
||||
|
||||
// Types matching Supabase structure for compatibility
|
||||
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;
|
||||
|
||||
/**
|
||||
* Spring Auth Client - Replaces Supabase client
|
||||
* Maintains same API surface as Supabase for easy migration
|
||||
*/
|
||||
class SpringAuthClient {
|
||||
private listeners: AuthChangeCallback[] = [];
|
||||
private sessionCheckInterval: NodeJS.Timeout | null = null;
|
||||
private readonly SESSION_CHECK_INTERVAL = 60000; // 1 minute
|
||||
private readonly TOKEN_REFRESH_THRESHOLD = 300000; // 5 minutes before expiry
|
||||
|
||||
constructor() {
|
||||
// Start periodic session validation
|
||||
this.startSessionMonitoring();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current session
|
||||
* Note: JWT is stored in HttpOnly cookie, so we can't read it directly
|
||||
* We check auth status by calling the /me endpoint
|
||||
*/
|
||||
async getSession(): Promise<{ data: { session: Session | null }; error: AuthError | null }> {
|
||||
try {
|
||||
// Verify with backend
|
||||
const response = await fetch('/api/v1/auth/me', {
|
||||
credentials: 'include', // Include cookies
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Not authenticated
|
||||
return { data: { session: null }, error: null };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Create session object (we don't have access to the actual token due to HttpOnly)
|
||||
const session: Session = {
|
||||
user: data.user,
|
||||
access_token: '', // HttpOnly cookie, not accessible
|
||||
expires_in: 3600,
|
||||
expires_at: Date.now() + 3600 * 1000,
|
||||
};
|
||||
|
||||
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', // Important: include cookies
|
||||
body: JSON.stringify(credentials),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
return { user: null, session: null, error: { message: error.error || 'Login failed' } };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const session: Session = {
|
||||
user: data.user,
|
||||
access_token: data.session.access_token,
|
||||
expires_in: data.session.expires_in,
|
||||
expires_at: Date.now() + data.session.expires_in * 1000,
|
||||
};
|
||||
|
||||
// Notify listeners
|
||||
this.notifyListeners('SIGNED_IN', session);
|
||||
|
||||
return { user: data.user, session, error: null };
|
||||
} catch (error) {
|
||||
console.error('[SpringAuth] signInWithPassword error:', error);
|
||||
return {
|
||||
user: null,
|
||||
session: null,
|
||||
error: { message: error instanceof Error ? error.message : 'Login failed' },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign up new user
|
||||
*/
|
||||
async signUp(credentials: {
|
||||
email: string;
|
||||
password: string;
|
||||
options?: { data?: { full_name?: string }; emailRedirectTo?: string };
|
||||
}): Promise<AuthResponse> {
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
email: credentials.email,
|
||||
password: credentials.password,
|
||||
name: credentials.options?.data?.full_name || '',
|
||||
}),
|
||||
});
|
||||
|
||||
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
|
||||
const redirectUrl = `/oauth2/authorization/${params.provider}`;
|
||||
window.location.href = redirectUrl;
|
||||
return { error: null };
|
||||
} catch (error) {
|
||||
return {
|
||||
error: { message: error instanceof Error ? error.message : 'OAuth redirect failed' },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign out
|
||||
*/
|
||||
async signOut(): Promise<{ error: AuthError | null }> {
|
||||
try {
|
||||
await fetch('/api/v1/auth/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
// 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
|
||||
*/
|
||||
async refreshSession(): Promise<{ data: { session: Session | null }; error: AuthError | null }> {
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/refresh', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return { data: { session: null }, error: { message: 'Token refresh failed' } };
|
||||
}
|
||||
|
||||
// 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: '', // 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 (mimics Supabase onAuthStateChange)
|
||||
*/
|
||||
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 singleton instance (mimics Supabase pattern)
|
||||
export const springAuth = new SpringAuthClient();
|
||||
|
||||
// Export helper functions (matching Supabase exports)
|
||||
|
||||
/**
|
||||
* Anonymous sign-in
|
||||
* Note: Not implemented yet - returns error
|
||||
*/
|
||||
export const signInAnonymously = async () => {
|
||||
// For now, return error - implement anonymous auth if needed
|
||||
return {
|
||||
data: { user: null, session: null },
|
||||
error: { message: 'Anonymous authentication not implemented' },
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
|
||||
// Export auth client as default for convenience
|
||||
export default springAuth;
|
||||
36
frontend/src/components/shared/DividerWithText.tsx
Normal file
36
frontend/src/components/shared/DividerWithText.tsx
Normal file
@@ -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,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);
|
||||
}
|
||||
56
frontend/src/routes/AuthCallback.tsx
Normal file
56
frontend/src/routes/AuthCallback.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
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 has already been set as an HttpOnly cookie by the Spring backend.
|
||||
* We just need to refresh the session state 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 already set as cookie by backend
|
||||
// Refresh session to load user info into state
|
||||
await refreshSession()
|
||||
|
||||
console.log('[AuthCallback] Session refreshed, 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>
|
||||
)
|
||||
}
|
||||
51
frontend/src/routes/Landing.tsx
Normal file
51
frontend/src/routes/Landing.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Navigate, useLocation } from 'react-router-dom'
|
||||
import { useAuth } from '../auth/UseSession'
|
||||
import HomePage from '../pages/HomePage'
|
||||
import Login from './Login'
|
||||
|
||||
/**
|
||||
* Landing component - Smart router based on authentication status
|
||||
*
|
||||
* If user is authenticated: Show HomePage
|
||||
* If user is not authenticated: Show Login or redirect to /login
|
||||
*/
|
||||
export default function Landing() {
|
||||
const { session, loading } = useAuth()
|
||||
const location = useLocation()
|
||||
|
||||
console.log('[Landing] State:', {
|
||||
pathname: location.pathname,
|
||||
loading,
|
||||
hasSession: !!session,
|
||||
})
|
||||
|
||||
// Show loading while checking auth
|
||||
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 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 }} />
|
||||
}
|
||||
147
frontend/src/routes/Login.tsx
Normal file
147
frontend/src/routes/Login.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
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 NavigationLink from './login/NavigationLink'
|
||||
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 [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')
|
||||
// Navigate to home page
|
||||
navigate('/')
|
||||
}
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<LoginHeader title={t('login.login') || 'Sign in'} />
|
||||
|
||||
<ErrorMessage error={error} />
|
||||
|
||||
<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')}
|
||||
/>
|
||||
|
||||
<DividerWithText text={t('login.or', 'or')} respondsToDarkMode={false} opacity={0.4} />
|
||||
|
||||
<OAuthButtons
|
||||
onProviderClick={signInWithProvider}
|
||||
isSubmitting={isSigningIn}
|
||||
layout="icons"
|
||||
/>
|
||||
|
||||
<NavigationLink
|
||||
onClick={() => navigate('/signup')}
|
||||
text={t('login.dontHaveAccount') || "Don't have an account? Sign up"}
|
||||
isDisabled={isSigningIn}
|
||||
/>
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
152
frontend/src/routes/Signup.tsx
Normal file
152
frontend/src/routes/Signup.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import { 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 { BASE_PATH } from '../constants/app'
|
||||
import AuthLayout from './authShared/AuthLayout'
|
||||
|
||||
// Import signup components
|
||||
import LoginHeader from './login/LoginHeader'
|
||||
import ErrorMessage from './login/ErrorMessage'
|
||||
import OAuthButtons from './login/OAuthButtons'
|
||||
import DividerWithText from '../components/shared/DividerWithText'
|
||||
import NavigationLink from './login/NavigationLink'
|
||||
import SignupForm from './signup/SignupForm'
|
||||
import { useSignupFormValidation, SignupFieldErrors } from './signup/SignupFormValidation'
|
||||
|
||||
export default function Signup() {
|
||||
const navigate = useNavigate()
|
||||
const { session, loading } = useAuth()
|
||||
const { t } = useTranslation()
|
||||
const [isSigningUp, setIsSigningUp] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [name, setName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [agree, setAgree] = useState(true)
|
||||
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 handleSignUp = async () => {
|
||||
const validation = validateSignupForm(email, password, confirmPassword, name)
|
||||
if (!validation.isValid) {
|
||||
setError(validation.error)
|
||||
setFieldErrors(validation.fieldErrors || {})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSigningUp(true)
|
||||
setError(null)
|
||||
setFieldErrors({})
|
||||
|
||||
console.log('[Signup] Creating account for:', email)
|
||||
|
||||
const { user, error } = await springAuth.signUp({
|
||||
email: email.trim(),
|
||||
password: password,
|
||||
options: {
|
||||
data: { full_name: name }
|
||||
}
|
||||
})
|
||||
|
||||
if (error) {
|
||||
console.error('[Signup] Sign up error:', error)
|
||||
setError(error.message)
|
||||
} else if (user) {
|
||||
console.log('[Signup] Account created successfully')
|
||||
// Show success message
|
||||
alert(t('signup.accountCreatedSuccessfully') || 'Account created successfully! Please log in.')
|
||||
// Redirect to login
|
||||
setTimeout(() => navigate('/login'), 1000)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Signup] Unexpected error:', err)
|
||||
setError(err instanceof Error ? err.message : (t('signup.unexpectedError', { message: 'Unknown error' }) || 'An unexpected error occurred'))
|
||||
} finally {
|
||||
setIsSigningUp(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleProviderSignIn = async (provider: 'github' | 'google' | 'apple' | 'azure') => {
|
||||
try {
|
||||
setIsSigningUp(true)
|
||||
setError(null)
|
||||
|
||||
console.log(`[Signup] Signing up with ${provider}`)
|
||||
|
||||
const { error } = await springAuth.signInWithOAuth({
|
||||
provider,
|
||||
options: { redirectTo: `${BASE_PATH}/auth/callback` }
|
||||
})
|
||||
|
||||
if (error) {
|
||||
setError(error.message)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : (t('signup.unexpectedError', { message: 'Unknown error' }) || 'An unexpected error occurred'))
|
||||
} finally {
|
||||
setIsSigningUp(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<LoginHeader title={t('signup.title') || 'Create an account'} subtitle={t('signup.subtitle')} />
|
||||
|
||||
<ErrorMessage error={error} />
|
||||
|
||||
<SignupForm
|
||||
name={name}
|
||||
email={email}
|
||||
password={password}
|
||||
confirmPassword={confirmPassword}
|
||||
agree={agree}
|
||||
setName={setName}
|
||||
setEmail={setEmail}
|
||||
setPassword={setPassword}
|
||||
setConfirmPassword={setConfirmPassword}
|
||||
setAgree={setAgree}
|
||||
onSubmit={handleSignUp}
|
||||
isSubmitting={isSigningUp}
|
||||
fieldErrors={fieldErrors}
|
||||
/>
|
||||
|
||||
<div style={{ margin: '0.5rem 0' }}>
|
||||
<DividerWithText text={t('signup.or', "or")} respondsToDarkMode={false} opacity={0.4} />
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '0.5rem' }}>
|
||||
<OAuthButtons
|
||||
onProviderClick={handleProviderSignIn}
|
||||
isSubmitting={isSigningUp}
|
||||
layout="icons"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '0.5rem', textAlign: 'center' }}>
|
||||
<NavigationLink
|
||||
onClick={() => navigate('/login')}
|
||||
text={t('signup.alreadyHaveAccount') || 'Already have an account? Sign in'}
|
||||
isDisabled={isSigningUp}
|
||||
/>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
48
frontend/src/routes/authShared/AuthLayout.module.css
Normal file
48
frontend/src/routes/authShared/AuthLayout.module.css
Normal file
@@ -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%;
|
||||
}
|
||||
70
frontend/src/routes/authShared/AuthLayout.tsx
Normal file
70
frontend/src/routes/authShared/AuthLayout.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
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 && (
|
||||
<div style={{
|
||||
backgroundImage: `url(${window.location.origin}/images/auth-background.jpg)`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center'
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
321
frontend/src/routes/authShared/auth.css
Normal file
321
frontend/src/routes/authShared/auth.css
Normal file
@@ -0,0 +1,321 @@
|
||||
.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: 0.875rem 1rem; /* 14px 16px */
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.75rem; /* 12px */
|
||||
background-color: var(--auth-card-bg-light-only);
|
||||
font-size: 0.875rem; /* 14px */
|
||||
font-weight: 500;
|
||||
color: var(--auth-text-primary-light-only);
|
||||
cursor: pointer;
|
||||
gap: 0.5rem; /* 8px */
|
||||
}
|
||||
|
||||
.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: 1rem; /* 16px */
|
||||
height: 1rem; /* 16px */
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
81
frontend/src/routes/login/EmailPasswordForm.tsx
Normal file
81
frontend/src/routes/login/EmailPasswordForm.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
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()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="auth-fields">
|
||||
<div className="auth-field">
|
||||
<label htmlFor="email" className="auth-label">{t('login.email')}</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
autoComplete="username email"
|
||||
placeholder={t('login.enterEmail')}
|
||||
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
|
||||
onClick={onSubmit}
|
||||
disabled={isSubmitting || !email || (showPasswordField && !password)}
|
||||
className="auth-button"
|
||||
>
|
||||
{submitButtonText}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
13
frontend/src/routes/login/ErrorMessage.tsx
Normal file
13
frontend/src/routes/login/ErrorMessage.tsx
Normal file
@@ -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>
|
||||
)
|
||||
}
|
||||
54
frontend/src/routes/login/LoggedInState.tsx
Normal file
54
frontend/src/routes/login/LoggedInState.tsx
Normal file
@@ -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
frontend/src/routes/login/LoginHeader.tsx
Normal file
22
frontend/src/routes/login/LoginHeader.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
19
frontend/src/routes/login/NavigationLink.tsx
Normal file
19
frontend/src/routes/login/NavigationLink.tsx
Normal file
@@ -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>
|
||||
)
|
||||
}
|
||||
75
frontend/src/routes/login/OAuthButtons.tsx
Normal file
75
frontend/src/routes/login/OAuthButtons.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { BASE_PATH } from '../../constants/app'
|
||||
|
||||
// OAuth provider configuration
|
||||
const oauthProviders = [
|
||||
{ id: 'github', label: 'GitHub', file: 'github.svg', isDisabled: false },
|
||||
{ id: 'google', label: 'Google', file: 'google.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()
|
||||
|
||||
if (layout === 'icons') {
|
||||
return (
|
||||
<div className="oauth-container-icons">
|
||||
{oauthProviders.map((p) => (
|
||||
<div key={p.id} title={`${t('login.signInWith', 'Sign in with')} ${p.label}`}>
|
||||
<button
|
||||
onClick={() => onProviderClick(p.id as any)}
|
||||
disabled={isSubmitting || p.isDisabled}
|
||||
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 ${p.isDisabled ? 'opacity-20' : ''}`}/>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (layout === 'grid') {
|
||||
return (
|
||||
<div className="oauth-container-grid">
|
||||
{oauthProviders.map((p) => (
|
||||
<div key={p.id} title={`${t('login.signInWith', 'Sign in with')} ${p.label}`}>
|
||||
<button
|
||||
onClick={() => onProviderClick(p.id as any)}
|
||||
disabled={isSubmitting || p.isDisabled}
|
||||
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 ${p.isDisabled ? 'opacity-20' : ''}`}/>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="oauth-container-vertical">
|
||||
{oauthProviders.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => onProviderClick(p.id as any)}
|
||||
disabled={isSubmitting || p.isDisabled}
|
||||
className="oauth-button-vertical"
|
||||
title={p.label}
|
||||
>
|
||||
<img src={`${BASE_PATH}/Login/${p.file}`} alt={p.label} className={`oauth-icon-tiny ${p.isDisabled ? 'opacity-20' : ''}`} />
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
154
frontend/src/routes/signup/SignupForm.tsx
Normal file
154
frontend/src/routes/signup/SignupForm.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
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
|
||||
}
|
||||
|
||||
export default function SignupForm({
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
confirmPassword,
|
||||
agree,
|
||||
setName,
|
||||
setEmail,
|
||||
setPassword,
|
||||
setConfirmPassword,
|
||||
setAgree,
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
fieldErrors = {}
|
||||
}: SignupFormProps) {
|
||||
const { t } = useTranslation()
|
||||
const showConfirm = password.length >= 4
|
||||
|
||||
useEffect(() => {
|
||||
if (!showConfirm && confirmPassword) {
|
||||
setConfirmPassword('')
|
||||
}
|
||||
}, [showConfirm, confirmPassword, setConfirmPassword])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="auth-fields">
|
||||
<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 */}
|
||||
<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 || !agree}
|
||||
className="auth-button"
|
||||
>
|
||||
{isSubmitting ? t('signup.creatingAccount') : t('signup.signUp')}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
66
frontend/src/routes/signup/SignupFormValidation.ts
Normal file
66
frontend/src/routes/signup/SignupFormValidation.ts
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user