From 01d18279e0c3c164308e6fba940941d7580ada50 Mon Sep 17 00:00:00 2001 From: Dario Ghunney Ware Date: Wed, 15 Oct 2025 16:28:36 +0100 Subject: [PATCH] Added rest api for signup/login. added frontend auth service --- .../configuration/SecurityConfiguration.java | 5 + .../controller/api/AuthController.java | 307 +++++++++++++++ frontend/package-lock.json | 38 +- frontend/src/App.tsx | 77 ++-- frontend/src/auth/UseSession.tsx | 215 ++++++++++ frontend/src/auth/springAuthClient.ts | 367 ++++++++++++++++++ .../src/components/shared/DividerWithText.tsx | 36 ++ .../dividerWithText/DividerWithText.css | 52 +++ frontend/src/routes/AuthCallback.tsx | 56 +++ frontend/src/routes/Landing.tsx | 51 +++ frontend/src/routes/Login.tsx | 147 +++++++ frontend/src/routes/Signup.tsx | 152 ++++++++ .../routes/authShared/AuthLayout.module.css | 48 +++ frontend/src/routes/authShared/AuthLayout.tsx | 70 ++++ frontend/src/routes/authShared/auth.css | 321 +++++++++++++++ .../src/routes/login/EmailPasswordForm.tsx | 81 ++++ frontend/src/routes/login/ErrorMessage.tsx | 13 + frontend/src/routes/login/LoggedInState.tsx | 54 +++ frontend/src/routes/login/LoginHeader.tsx | 22 ++ frontend/src/routes/login/NavigationLink.tsx | 19 + frontend/src/routes/login/OAuthButtons.tsx | 75 ++++ frontend/src/routes/signup/SignupForm.tsx | 154 ++++++++ .../src/routes/signup/SignupFormValidation.ts | 66 ++++ 23 files changed, 2396 insertions(+), 30 deletions(-) create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java create mode 100644 frontend/src/auth/UseSession.tsx create mode 100644 frontend/src/auth/springAuthClient.ts create mode 100644 frontend/src/components/shared/DividerWithText.tsx create mode 100644 frontend/src/components/shared/dividerWithText/DividerWithText.css create mode 100644 frontend/src/routes/AuthCallback.tsx create mode 100644 frontend/src/routes/Landing.tsx create mode 100644 frontend/src/routes/Login.tsx create mode 100644 frontend/src/routes/Signup.tsx create mode 100644 frontend/src/routes/authShared/AuthLayout.module.css create mode 100644 frontend/src/routes/authShared/AuthLayout.tsx create mode 100644 frontend/src/routes/authShared/auth.css create mode 100644 frontend/src/routes/login/EmailPasswordForm.tsx create mode 100644 frontend/src/routes/login/ErrorMessage.tsx create mode 100644 frontend/src/routes/login/LoggedInState.tsx create mode 100644 frontend/src/routes/login/LoginHeader.tsx create mode 100644 frontend/src/routes/login/NavigationLink.tsx create mode 100644 frontend/src/routes/login/OAuthButtons.tsx create mode 100644 frontend/src/routes/signup/SignupForm.tsx create mode 100644 frontend/src/routes/signup/SignupFormValidation.ts diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java index aceb3b712..432d52d29 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java @@ -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"); }) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java new file mode 100644 index 000000000..a89352cc3 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java @@ -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 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 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 buildUserResponse(User user) { + Map userMap = new HashMap<>(); + userMap.put("id", user.getId()); + userMap.put("email", user.getUsername()); // Use username as email + userMap.put("username", user.getUsername()); + userMap.put("role", user.getRolesAsString()); + userMap.put("enabled", user.isEnabled()); + + // Add metadata for OAuth compatibility + Map appMetadata = new HashMap<>(); + appMetadata.put("provider", "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) {} +} \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 272c4d974..f5ac9b8cd 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -436,6 +436,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -482,6 +483,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -505,6 +507,7 @@ "resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.3.14.tgz", "integrity": "sha512-lE/vfhA53CxamaCfGWEibrEPr+JeZT42QCF+cOELUwv4+Zt6b+IE6+4wsznx/8wjjJYwllXJ3GJ/un1UzTqARw==", "license": "MIT", + "peer": true, "dependencies": { "@embedpdf/engines": "1.3.14", "@embedpdf/models": "1.3.14" @@ -585,6 +588,7 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-1.3.14.tgz", "integrity": "sha512-77hnNLp0W0FHw8lT7SeqzCgp8bOClfeOAPZdcInu/jPDhVASUGYbtE/0fkLhiaqPH7kyMirNCLif4sF6n4b5vg==", "license": "MIT", + "peer": true, "dependencies": { "@embedpdf/models": "1.3.14" }, @@ -601,6 +605,7 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.3.14.tgz", "integrity": "sha512-nR0ZxNoTQtGqOHhweFh6QJ+nUJ4S4Ag1wWur6vAUAi8U95HUOfZhOEa0polZo0zR9WmmblGqRWjFM+mVSOoi1w==", "license": "MIT", + "peer": true, "dependencies": { "@embedpdf/models": "1.3.14" }, @@ -617,6 +622,7 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-loader/-/plugin-loader-1.3.14.tgz", "integrity": "sha512-KoJX1MacEWE2DrO1OeZeG/Ehz76//u+ida/xb4r9BfwqAp5TfYlksq09cOvcF8LMW5FY4pbAL+AHKI1Hjz+HNA==", "license": "MIT", + "peer": true, "dependencies": { "@embedpdf/models": "1.3.14" }, @@ -651,6 +657,7 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.3.14.tgz", "integrity": "sha512-IPj7GCQXJBsY++JaU+z7y+FwX5NaDBj4YYV6hsHNtSGf42Y1AdlwJzDYetivG2bA84xmk7KgD1X2Y3eIFBhjwA==", "license": "MIT", + "peer": true, "dependencies": { "@embedpdf/models": "1.3.14" }, @@ -683,6 +690,7 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-1.3.14.tgz", "integrity": "sha512-fQbt7OlRMLQJMuZj/Bzh0qpRxMw1ld5Qe/OTw8N54b/plljnFA52joE7cITl3H03huWWyHS3NKOScbw7f34dog==", "license": "MIT", + "peer": true, "dependencies": { "@embedpdf/models": "1.3.14" }, @@ -717,6 +725,7 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.3.14.tgz", "integrity": "sha512-EXENuaAsse3rT6cjA1nYzyrNvoy62ojJl28wblCng6zcs3HSlGPemIQZAvaYKPUxoY608M+6nKlcMQ5neRnk/A==", "license": "MIT", + "peer": true, "dependencies": { "@embedpdf/models": "1.3.14" }, @@ -788,6 +797,7 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-1.3.14.tgz", "integrity": "sha512-mfJ7EbbU68eKk6oFvQ4ozGJNpxUxWbjQ5Gm3uuB+Gj5/tWgBocBOX36k/9LgivEEeX7g2S0tOgyErljApmH8Vg==", "license": "MIT", + "peer": true, "dependencies": { "@embedpdf/models": "1.3.14" }, @@ -941,6 +951,7 @@ "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -984,6 +995,7 @@ "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -2017,6 +2029,7 @@ "resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.1.tgz", "integrity": "sha512-OYfxn9cTv+K6RZ8+Ozn/HDQXkB8Fmn+KJJt5lxyFDP9F09EHnC59Ldadv1LyUZVBGtNqz4sn6b3vBShbxwAmYw==", "license": "MIT", + "peer": true, "dependencies": { "@floating-ui/react": "^0.27.16", "clsx": "^2.1.1", @@ -2067,6 +2080,7 @@ "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.1.tgz", "integrity": "sha512-lQutBS+Q0iz/cNFvdrsYassPWo3RtWcmDGJeOtKfHigLzFOhxUuLOkQgepDbMf3WcVMB/tist6Px1PQOv57JTw==", "license": "MIT", + "peer": true, "peerDependencies": { "react": "^18.x || ^19.x" } @@ -2134,6 +2148,7 @@ "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.2.tgz", "integrity": "sha512-qXvbnawQhqUVfH1LMgMaiytP+ZpGoYhnGl7yYq2x57GYzcFL/iPzSZ3L30tlbwEjSVKNYcbiKO8tANR1tadjUg==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.28.3", "@mui/core-downloads-tracker": "^7.3.2", @@ -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", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b145ee8a5..c9736c04d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 ( }> + - - - - - - - - - - - - - - - - - - - - - - - - - - + + + {/* Auth routes - no FileContext or other providers needed */} + } /> + } /> + } /> + + {/* Main app routes - wrapped with all providers */} + + + + + + + + + + + + + + + + + + + + + + + + + + + } + /> + + + ); } diff --git a/frontend/src/auth/UseSession.tsx b/frontend/src/auth/UseSession.tsx new file mode 100644 index 000000000..743d728f0 --- /dev/null +++ b/frontend/src/auth/UseSession.tsx @@ -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; + refreshSession: () => Promise; +} + +const AuthContext = createContext({ + session: null, + user: null, + loading: true, + error: null, + signOut: async () => {}, + refreshSession: async () => {}, +}); + +/** + * Auth Provider Component + * + * Manages authentication state and provides it to the entire app. + * Replaces Supabase's AuthProvider with Spring Security + JWT integration. + */ +export function AuthProvider({ children }: { children: ReactNode }) { + const [session, setSession] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + /** + * Refresh current session + */ + const refreshSession = useCallback(async () => { + try { + setLoading(true); + setError(null); + console.debug('[Auth] Refreshing session...'); + + const { data, error } = await springAuth.refreshSession(); + + if (error) { + console.error('[Auth] Session refresh error:', error); + setError(error); + setSession(null); + } else { + console.debug('[Auth] Session refreshed successfully'); + setSession(data.session); + } + } catch (err) { + console.error('[Auth] Unexpected error during session refresh:', err); + setError(err as AuthError); + } finally { + setLoading(false); + } + }, []); + + /** + * Sign out user + */ + const signOut = useCallback(async () => { + try { + setError(null); + console.debug('[Auth] Signing out...'); + + const { error } = await springAuth.signOut(); + + if (error) { + console.error('[Auth] Sign out error:', error); + setError(error); + } else { + console.debug('[Auth] Signed out successfully'); + setSession(null); + } + } catch (err) { + console.error('[Auth] Unexpected error during sign out:', err); + setError(err as AuthError); + } + }, []); + + /** + * Initialize auth on mount + */ + useEffect(() => { + let mounted = true; + + const initializeAuth = async () => { + try { + console.debug('[Auth] Initializing auth...'); + const { data, error } = await springAuth.getSession(); + + if (!mounted) return; + + if (error) { + console.error('[Auth] Initial session error:', error); + setError(error); + } else { + console.debug('[Auth] Initial session loaded:', { + hasSession: !!data.session, + userId: data.session?.user?.id, + email: data.session?.user?.email, + }); + setSession(data.session); + } + } catch (err) { + console.error('[Auth] Unexpected error during auth initialization:', err); + if (mounted) { + setError(err as AuthError); + } + } finally { + if (mounted) { + setLoading(false); + } + } + }; + + initializeAuth(); + + // Subscribe to auth state changes + const { data: { subscription } } = springAuth.onAuthStateChange( + async (event, newSession) => { + if (!mounted) return; + + console.debug('[Auth] Auth state change:', { + event, + hasSession: !!newSession, + userId: newSession?.user?.id, + email: newSession?.user?.email, + timestamp: new Date().toISOString(), + }); + + // Schedule state update + setTimeout(() => { + if (mounted) { + setSession(newSession); + setError(null); + + // Handle specific events + if (event === 'SIGNED_OUT') { + console.debug('[Auth] User signed out, clearing session'); + } else if (event === 'SIGNED_IN') { + console.debug('[Auth] User signed in successfully'); + } else if (event === 'TOKEN_REFRESHED') { + console.debug('[Auth] Token refreshed'); + } else if (event === 'USER_UPDATED') { + console.debug('[Auth] User updated'); + } + } + }, 0); + } + ); + + return () => { + mounted = false; + subscription.unsubscribe(); + }; + }, []); + + const value: AuthContextType = { + session, + user: session?.user ?? null, + loading, + error, + signOut, + refreshSession, + }; + + return ( + + {children} + + ); +} + +/** + * Hook to access auth context + * Must be used within AuthProvider + */ +export function useAuth() { + const context = useContext(AuthContext); + + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider'); + } + + return context; +} + +/** + * Debug hook to expose auth state for debugging + * Can be used in development to monitor auth state + */ +export function useAuthDebug() { + const auth = useAuth(); + + useEffect(() => { + console.debug('[Auth Debug] Current auth state:', { + hasSession: !!auth.session, + hasUser: !!auth.user, + loading: auth.loading, + hasError: !!auth.error, + userId: auth.user?.id, + email: auth.user?.email, + }); + }, [auth.session, auth.user, auth.loading, auth.error]); + + return auth; +} diff --git a/frontend/src/auth/springAuthClient.ts b/frontend/src/auth/springAuthClient.ts new file mode 100644 index 000000000..6b9d4623e --- /dev/null +++ b/frontend/src/auth/springAuthClient.ts @@ -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; +} + +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 { + 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 { + 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 }; + }): 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; \ No newline at end of file diff --git a/frontend/src/components/shared/DividerWithText.tsx b/frontend/src/components/shared/DividerWithText.tsx new file mode 100644 index 000000000..9b82240a1 --- /dev/null +++ b/frontend/src/components/shared/DividerWithText.tsx @@ -0,0 +1,36 @@ +import './dividerWithText/DividerWithText.css' + +interface TextDividerProps { + text?: string + className?: string + style?: React.CSSProperties + variant?: 'default' | 'subcategory' + respondsToDarkMode?: boolean + opacity?: number +} + +export default function DividerWithText({ text, className = '', style, variant = 'default', respondsToDarkMode = true, opacity }: TextDividerProps) { + const variantClass = variant === 'subcategory' ? 'subcategory' : '' + const themeClass = respondsToDarkMode ? '' : 'force-light' + const styleWithOpacity = opacity !== undefined ? { ...(style || {}), ['--text-divider-opacity' as any]: opacity } : style + + if (text) { + return ( +
+
+ {text} +
+
+ ) + } + + return ( +
+ ) +} diff --git a/frontend/src/components/shared/dividerWithText/DividerWithText.css b/frontend/src/components/shared/dividerWithText/DividerWithText.css new file mode 100644 index 000000000..ce26b4546 --- /dev/null +++ b/frontend/src/components/shared/dividerWithText/DividerWithText.css @@ -0,0 +1,52 @@ + +.text-divider { + display: flex; + align-items: center; + gap: 0.75rem; /* 12px */ + margin-top: 0.375rem; /* 6px */ + margin-bottom: 0.5rem; /* 8px */ +} + +.text-divider .text-divider__rule { + height: 0.0625rem; /* 1px */ + flex: 1 1 0%; + background-color: rgb(var(--text-divider-rule-rgb, var(--gray-200)) / var(--text-divider-opacity, 1)); +} + +.text-divider .text-divider__label { + color: rgb(var(--text-divider-label-rgb, var(--gray-400)) / var(--text-divider-opacity, 1)); + font-size: 0.75rem; /* 12px */ + white-space: nowrap; +} + +.text-divider.subcategory { + margin-top: 0; + margin-bottom: 0; +} + +.text-divider.subcategory .text-divider__rule { + background-color: var(--tool-subcategory-rule-color); +} + +.text-divider.subcategory .text-divider__label { + color: var(--tool-subcategory-text-color); + text-transform: uppercase; + font-weight: 600; +} + +/* Force light theme colors regardless of dark mode */ +.text-divider.force-light .text-divider__rule { + background-color: rgb(var(--text-divider-rule-rgb-light, var(--gray-200)) / var(--text-divider-opacity, 1)); +} + +.text-divider.force-light .text-divider__label { + color: rgb(var(--text-divider-label-rgb-light, var(--gray-400)) / var(--text-divider-opacity, 1)); +} + +.text-divider.subcategory.force-light .text-divider__rule { + background-color: var(--tool-subcategory-rule-color-light); +} + +.text-divider.subcategory.force-light .text-divider__label { + color: var(--tool-subcategory-text-color-light); +} diff --git a/frontend/src/routes/AuthCallback.tsx b/frontend/src/routes/AuthCallback.tsx new file mode 100644 index 000000000..c99c70af5 --- /dev/null +++ b/frontend/src/routes/AuthCallback.tsx @@ -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 ( +
+
+
+
+ Completing authentication... +
+
+
+ ) +} diff --git a/frontend/src/routes/Landing.tsx b/frontend/src/routes/Landing.tsx new file mode 100644 index 000000000..5246da504 --- /dev/null +++ b/frontend/src/routes/Landing.tsx @@ -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 ( +
+
+
+
+ Loading... +
+
+
+ ) + } + + // If we have a session, show the main app + if (session) { + return + } + + // If we're at home route ("/"), show login directly (marketing/landing page) + // Otherwise navigate to login (fixes URL mismatch for tool routes) + const isHome = location.pathname === '/' || location.pathname === '' + if (isHome) { + return + } + + // For non-home routes without auth, navigate to login (preserves from location) + return +} diff --git a/frontend/src/routes/Login.tsx b/frontend/src/routes/Login.tsx new file mode 100644 index 000000000..f497b8c1d --- /dev/null +++ b/frontend/src/routes/Login.tsx @@ -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(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 + } + + 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 ( + + + + + + + + + + + + navigate('/signup')} + text={t('login.dontHaveAccount') || "Don't have an account? Sign up"} + isDisabled={isSigningIn} + /> + + ) +} diff --git a/frontend/src/routes/Signup.tsx b/frontend/src/routes/Signup.tsx new file mode 100644 index 000000000..e54b81a27 --- /dev/null +++ b/frontend/src/routes/Signup.tsx @@ -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(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({}) + + 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 ( + + + + + + + +
+ +
+ +
+ +
+ +
+ navigate('/login')} + text={t('signup.alreadyHaveAccount') || 'Already have an account? Sign in'} + isDisabled={isSigningUp} + /> +
+
+ ) +} diff --git a/frontend/src/routes/authShared/AuthLayout.module.css b/frontend/src/routes/authShared/AuthLayout.module.css new file mode 100644 index 000000000..0e597bee0 --- /dev/null +++ b/frontend/src/routes/authShared/AuthLayout.module.css @@ -0,0 +1,48 @@ +.authContainer { + position: relative; + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background-color: var(--auth-bg-color-light-only); + padding: 1.5rem 1.5rem 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + overflow: auto; +} + +.authCard { + width: min(45rem, 96vw); + height: min(50.875rem, 96vh); + display: grid; + grid-template-columns: 1fr; + background-color: var(--auth-card-bg); + border-radius: 1.25rem; + box-shadow: 0 1.25rem 3.75rem rgba(0, 0, 0, 0.12); + overflow: hidden; + min-height: 0; +} + +.authCardTwoColumns { + width: min(73.75rem, 96vw); + grid-template-columns: 1fr 1fr; +} + +.authLeftPanel { + display: flex; + align-items: center; + justify-content: center; + padding: 2rem; + overflow: hidden; + min-height: 0; + height: 100%; +} + +.authLeftPanel::-webkit-scrollbar { + display: none; /* WebKit browsers (Chrome, Safari, Edge) */ +} + +.authContent { + max-width: 26.25rem; /* 420px */ + width: 100%; +} diff --git a/frontend/src/routes/authShared/AuthLayout.tsx b/frontend/src/routes/authShared/AuthLayout.tsx new file mode 100644 index 000000000..28056604d --- /dev/null +++ b/frontend/src/routes/authShared/AuthLayout.tsx @@ -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(null) + const [hideRightPanel, setHideRightPanel] = useState(false) + + // Force light mode on auth pages + useEffect(() => { + const htmlElement = document.documentElement + const previousColorScheme = htmlElement.getAttribute('data-mantine-color-scheme') + + // Set light mode + htmlElement.setAttribute('data-mantine-color-scheme', 'light') + + // Cleanup: restore previous theme when leaving auth pages + return () => { + if (previousColorScheme) { + htmlElement.setAttribute('data-mantine-color-scheme', previousColorScheme) + } + } + }, []) + + useEffect(() => { + const update = () => { + // Use viewport to avoid hysteresis when the card is already in single-column mode + const viewportWidth = window.innerWidth + const viewportHeight = window.innerHeight + const cardWidthIfTwoCols = Math.min(1180, viewportWidth * 0.96) // matches min(73.75rem, 96vw) + const columnWidth = cardWidthIfTwoCols / 2 + const tooNarrow = columnWidth < 470 + const tooShort = viewportHeight < 740 + setHideRightPanel(tooNarrow || tooShort) + } + update() + window.addEventListener('resize', update) + window.addEventListener('orientationchange', update) + return () => { + window.removeEventListener('resize', update) + window.removeEventListener('orientationchange', update) + } + }, []) + + return ( +
+
+
+
+ {children} +
+
+ {!hideRightPanel && ( +
+ )} +
+
+ ) +} diff --git a/frontend/src/routes/authShared/auth.css b/frontend/src/routes/authShared/auth.css new file mode 100644 index 000000000..1b6d8be88 --- /dev/null +++ b/frontend/src/routes/authShared/auth.css @@ -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; +} diff --git a/frontend/src/routes/login/EmailPasswordForm.tsx b/frontend/src/routes/login/EmailPasswordForm.tsx new file mode 100644 index 000000000..781cde72e --- /dev/null +++ b/frontend/src/routes/login/EmailPasswordForm.tsx @@ -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 ( + <> +
+
+ + setEmail(e.target.value)} + className={`auth-input ${fieldErrors.email ? 'auth-input-error' : ''}`} + /> + {fieldErrors.email && ( +
{fieldErrors.email}
+ )} +
+ + {showPasswordField && ( +
+ + setPassword(e.target.value)} + className={`auth-input ${fieldErrors.password ? 'auth-input-error' : ''}`} + /> + {fieldErrors.password && ( +
{fieldErrors.password}
+ )} +
+ )} +
+ + + + ) +} diff --git a/frontend/src/routes/login/ErrorMessage.tsx b/frontend/src/routes/login/ErrorMessage.tsx new file mode 100644 index 000000000..4f237b77c --- /dev/null +++ b/frontend/src/routes/login/ErrorMessage.tsx @@ -0,0 +1,13 @@ +interface ErrorMessageProps { + error: string | null +} + +export default function ErrorMessage({ error }: ErrorMessageProps) { + if (!error) return null + + return ( +
+

{error}

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

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

+

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

+
+ +
+

+ Redirecting to home... +

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

{title}

+ {subtitle && ( +

{subtitle}

+ )} +
+ ) +} diff --git a/frontend/src/routes/login/NavigationLink.tsx b/frontend/src/routes/login/NavigationLink.tsx new file mode 100644 index 000000000..965a659b9 --- /dev/null +++ b/frontend/src/routes/login/NavigationLink.tsx @@ -0,0 +1,19 @@ +interface NavigationLinkProps { + onClick: () => void + text: string + isDisabled?: boolean +} + +export default function NavigationLink({ onClick, text, isDisabled = false }: NavigationLinkProps) { + return ( +
+ +
+ ) +} diff --git a/frontend/src/routes/login/OAuthButtons.tsx b/frontend/src/routes/login/OAuthButtons.tsx new file mode 100644 index 000000000..9239d4868 --- /dev/null +++ b/frontend/src/routes/login/OAuthButtons.tsx @@ -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 ( +
+ {oauthProviders.map((p) => ( +
+ +
+ ))} +
+ ) + } + + if (layout === 'grid') { + return ( +
+ {oauthProviders.map((p) => ( +
+ +
+ ))} +
+ ) + } + + return ( +
+ {oauthProviders.map((p) => ( + + ))} +
+ ) +} diff --git a/frontend/src/routes/signup/SignupForm.tsx b/frontend/src/routes/signup/SignupForm.tsx new file mode 100644 index 000000000..0e2e8ea4b --- /dev/null +++ b/frontend/src/routes/signup/SignupForm.tsx @@ -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 ( + <> +
+
+ + setName(e.target.value)} + className={`auth-input ${fieldErrors.name ? 'auth-input-error' : ''}`} + /> + {fieldErrors.name && ( +
{fieldErrors.name}
+ )} +
+ +
+ + setEmail(e.target.value)} + onKeyPress={(e) => e.key === 'Enter' && !isSubmitting && onSubmit()} + className={`auth-input ${fieldErrors.email ? 'auth-input-error' : ''}`} + /> + {fieldErrors.email && ( +
{fieldErrors.email}
+ )} +
+ +
+ + setPassword(e.target.value)} + onKeyPress={(e) => e.key === 'Enter' && !isSubmitting && onSubmit()} + className={`auth-input ${fieldErrors.password ? 'auth-input-error' : ''}`} + /> + {fieldErrors.password && ( +
{fieldErrors.password}
+ )} +
+ +
+
+ + setConfirmPassword(e.target.value)} + onKeyPress={(e) => e.key === 'Enter' && !isSubmitting && onSubmit()} + className={`auth-input ${fieldErrors.confirmPassword ? 'auth-input-error' : ''}`} + /> + {fieldErrors.confirmPassword && ( +
{fieldErrors.confirmPassword}
+ )} +
+
+
+ + {/* Terms */} +
+ setAgree(e.target.checked)} + className="auth-checkbox" + /> + +
+ + {/* Sign Up Button */} + + + ) +} diff --git a/frontend/src/routes/signup/SignupFormValidation.ts b/frontend/src/routes/signup/SignupFormValidation.ts new file mode 100644 index 000000000..688a5fe30 --- /dev/null +++ b/frontend/src/routes/signup/SignupFormValidation.ts @@ -0,0 +1,66 @@ +import { useTranslation } from 'react-i18next' + +export interface SignupFieldErrors { + name?: string + email?: string + password?: string + confirmPassword?: string +} + +export interface SignupValidationResult { + isValid: boolean + error: string | null + fieldErrors?: SignupFieldErrors +} + +export const useSignupFormValidation = () => { + const { t } = useTranslation() + + const validateSignupForm = ( + email: string, + password: string, + confirmPassword: string, + name?: string + ): SignupValidationResult => { + const fieldErrors: SignupFieldErrors = {} + + // Validate name + if (name !== undefined && name !== null && !name.trim()) { + fieldErrors.name = t('signup.nameRequired', 'Name is required') + } + + // Validate email + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + if (!email) { + fieldErrors.email = t('signup.emailRequired', 'Email is required') + } else if (!emailRegex.test(email)) { + fieldErrors.email = t('signup.invalidEmail') + } + + // Validate password + if (!password) { + fieldErrors.password = t('signup.passwordRequired', 'Password is required') + } else if (password.length < 6) { + fieldErrors.password = t('signup.passwordTooShort') + } + + // Validate confirm password + if (!confirmPassword) { + fieldErrors.confirmPassword = t('signup.confirmPasswordRequired', 'Please confirm your password') + } else if (password !== confirmPassword) { + fieldErrors.confirmPassword = t('signup.passwordsDoNotMatch') + } + + const hasErrors = Object.keys(fieldErrors).length > 0 + + return { + isValid: !hasErrors, + error: null, // Don't show generic error, field errors are more specific + fieldErrors: hasErrors ? fieldErrors : undefined + } + } + + return { + validateSignupForm + } +}