From 0aaa5d3bb51f325cb3a27903586c51697d01d3e3 Mon Sep 17 00:00:00 2001 From: DarioGii Date: Thu, 23 Oct 2025 16:58:26 +0100 Subject: [PATCH] addressing comments --- .../src/main/resources/settings.yml.template | 1 - .../configuration/SecurityConfiguration.java | 2 + .../controller/api/AuthController.java | 112 ++--- .../controller/api/UserController.java | 87 +++- ...tomOAuth2AuthenticationSuccessHandler.java | 58 ++- ...stomSaml2AuthenticationSuccessHandler.java | 59 ++- .../security/service/JwtService.java | 7 +- .../security/service/JwtServiceTest.java | 9 +- frontend/package-lock.json | 472 +++++++++++++++++- .../public/locales/en-GB/translation.json | 28 ++ frontend/src/App.tsx | 2 + frontend/src/auth/springAuthClient.ts | 10 +- frontend/src/contexts/OnboardingContext.tsx | 13 +- frontend/src/routes/Login.tsx | 82 ++- frontend/src/routes/Signup.tsx | 107 ++++ frontend/src/routes/authShared/AuthLayout.tsx | 3 +- frontend/src/routes/authShared/auth.css | 67 ++- .../src/routes/login/EmailPasswordForm.tsx | 10 +- frontend/src/routes/login/OAuthButtons.tsx | 23 +- frontend/src/routes/signup/AuthService.ts | 54 ++ frontend/src/routes/signup/SignupForm.tsx | 90 ++-- .../src/routes/signup/SignupFormValidation.ts | 2 +- 22 files changed, 1083 insertions(+), 215 deletions(-) create mode 100644 frontend/src/routes/Signup.tsx create mode 100644 frontend/src/routes/signup/AuthService.ts diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index ebd0cbfc22..62e2aefd7c 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -60,7 +60,6 @@ security: privateKey: classpath:saml-private-key.key # Your private key. Generated from your keypair spCert: classpath:saml-public-cert.crt # Your signing certificate. Generated from your keypair jwt: # This feature is currently under development and not yet fully supported. Do not use in production. - issuer: https://stirling.com # The issuer field to include in the JWTs persistence: true # Set to 'true' to enable JWT key store enableKeyRotation: true # Set to 'true' to enable key pair rotation enableKeyCleanup: true # Set to 'true' to enable key pair cleanup 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 89b3007596..c49627a5b5 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 @@ -261,6 +261,8 @@ public class SecurityConfiguration { || trimmedUri.startsWith("/api/v1/config") || trimmedUri.startsWith( "/api/v1/auth/register") + || trimmedUri.startsWith( + "/api/v1/user/register") || trimmedUri.startsWith( "/api/v1/auth/login") || trimmedUri.startsWith( 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 index ca30cf8c45..0dd8ee4bfa 100644 --- 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 @@ -5,22 +5,25 @@ import java.util.Map; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.web.bind.annotation.*; +import io.swagger.v3.oas.annotations.tags.Tag; + import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import stirling.software.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.model.api.user.UsernameAndPass; import stirling.software.proprietary.security.service.CustomUserDetailsService; import stirling.software.proprietary.security.service.JwtServiceInterface; import stirling.software.proprietary.security.service.UserService; @@ -30,7 +33,7 @@ import stirling.software.proprietary.security.service.UserService; @RequestMapping("/api/v1/auth") @RequiredArgsConstructor @Slf4j -@UserApi +@Tag(name = "Authentication", description = "Endpoints for user authentication and registration") public class AuthController { private final UserService userService; @@ -44,23 +47,40 @@ public class AuthController { * @param response HTTP response to set JWT cookie * @return User and session information */ + @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") @PostMapping("/login") public ResponseEntity login( - @RequestBody LoginRequest request, HttpServletResponse response) { + @RequestBody UsernameAndPass request, HttpServletResponse response) { try { - log.debug("Login attempt for user: {}", request.email()); + // Validate input parameters + if (request.getUsername() == null || request.getUsername().trim().isEmpty()) { + log.warn("Login attempt with null or empty username"); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Username is required")); + } - UserDetails userDetails = userDetailsService.loadUserByUsername(request.email()); + if (request.getPassword() == null || request.getPassword().isEmpty()) { + log.warn( + "Login attempt with null or empty password for user: {}", + request.getUsername()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Password is required")); + } + + log.debug("Login attempt for user: {}", request.getUsername()); + + UserDetails userDetails = + userDetailsService.loadUserByUsername(request.getUsername().trim()); User user = (User) userDetails; - if (!userService.isPasswordCorrect(user, request.password())) { - log.warn("Invalid password for user: {}", request.email()); + if (!userService.isPasswordCorrect(user, request.getPassword())) { + log.warn("Invalid password for user: {}", request.getUsername()); return ResponseEntity.status(HttpStatus.UNAUTHORIZED) .body(Map.of("error", "Invalid credentials")); } if (!user.isEnabled()) { - log.warn("Disabled user attempted login: {}", request.email()); + log.warn("Disabled user attempted login: {}", request.getUsername()); return ResponseEntity.status(HttpStatus.UNAUTHORIZED) .body(Map.of("error", "User account is disabled")); } @@ -71,87 +91,34 @@ public class AuthController { String token = jwtService.generateToken(user.getUsername(), claims); - log.info("Login successful for user: {}", request.email()); + log.info("Login successful for user: {}", request.getUsername()); return ResponseEntity.ok( Map.of( "user", buildUserResponse(user), "session", Map.of("access_token", token, "expires_in", 3600))); + } catch (UsernameNotFoundException e) { + log.warn("User not found: {}", request.getUsername()); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "Invalid username or password")); } catch (AuthenticationException e) { - log.error("Authentication failed for user: {}", request.email(), e); + log.error("Authentication failed for user: {}", request.getUsername(), e); return ResponseEntity.status(HttpStatus.UNAUTHORIZED) .body(Map.of("error", "Invalid credentials")); } catch (Exception e) { - log.error("Login error for user: {}", request.email(), e); + log.error("Login error for user: {}", request.getUsername(), e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body(Map.of("error", "Internal server error")); } } - /** - * Registration endpoint - * - * @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.email()); - - if (userService.usernameExistsIgnoreCase(request.email())) { - log.warn("Registration failed: username already exists: {}", request.email()); - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", "User already exists")); - } - - if (!userService.isUsernameValid(request.email())) { - log.warn("Registration failed: invalid username format: {}", request.email()); - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", "Invalid username format")); - } - - if (request.password() == null || request.password().length() < 6) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", "Password must be at least 6 characters")); - } - - User user = - userService.saveUser( - request.email(), - request.password(), - (Long) null, // team - Role.USER.getRoleId(), - false // first login not required - ); - - log.info("User registered successfully: {}", request.email()); - - 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.email(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(Map.of("error", "Registration failed: " + e.getMessage())); - } - } - /** * Get current user * * @return Current authenticated user information */ + @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") @GetMapping("/me") public ResponseEntity getCurrentUser() { try { @@ -182,6 +149,7 @@ public class AuthController { * @param response HTTP response * @return Success message */ + @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") @PostMapping("/logout") public ResponseEntity logout(HttpServletResponse response) { try { @@ -205,6 +173,7 @@ public class AuthController { * @param response HTTP response to set new JWT cookie * @return New token information */ + @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") @PostMapping("/refresh") public ResponseEntity refresh(HttpServletRequest request, HttpServletResponse response) { try { @@ -266,7 +235,4 @@ public class AuthController { /** Login request DTO */ public record LoginRequest(String email, String password) {} - - /** Registration request DTO */ - public record RegisterRequest(String email, String password, String name) {} } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java index 8e3aa818d8..6d4b803c2a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java @@ -3,6 +3,7 @@ package stirling.software.proprietary.security.controller.api; import java.io.IOException; import java.security.Principal; import java.sql.SQLException; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -15,7 +16,6 @@ import org.springframework.security.core.session.SessionInformation; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler; -import org.springframework.ui.Model; import org.springframework.web.bind.annotation.*; import org.springframework.web.servlet.mvc.support.RedirectAttributes; import org.springframework.web.servlet.view.RedirectView; @@ -56,24 +56,83 @@ public class UserController { @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") @PostMapping("/register") - public String register(@ModelAttribute UsernameAndPass requestModel, Model model) + public ResponseEntity register(@RequestBody UsernameAndPass usernameAndPass) throws SQLException, UnsupportedProviderException { - if (userService.usernameExistsIgnoreCase(requestModel.getUsername())) { - model.addAttribute("error", "Username already exists"); - return "register"; - } try { + log.debug("Registration attempt for user: {}", usernameAndPass.getUsername()); + + if (userService.usernameExistsIgnoreCase(usernameAndPass.getUsername())) { + log.warn( + "Registration failed: username already exists: {}", + usernameAndPass.getUsername()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "User already exists")); + } + + if (!userService.isUsernameValid(usernameAndPass.getUsername())) { + log.warn( + "Registration failed: invalid username format: {}", + usernameAndPass.getUsername()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Invalid username format")); + } + + if (usernameAndPass.getPassword() == null + || usernameAndPass.getPassword().length() < 6) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Password must be at least 6 characters")); + } + Team team = teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME).orElse(null); - userService.saveUser( - requestModel.getUsername(), - requestModel.getPassword(), - team, - Role.USER.getRoleId(), - false); + User user = + userService.saveUser( + usernameAndPass.getUsername(), + usernameAndPass.getPassword(), + team, + Role.USER.getRoleId(), + false); + + log.info("User registered successfully: {}", usernameAndPass.getUsername()); + + return ResponseEntity.status(HttpStatus.CREATED) + .body( + Map.of( + "user", + buildUserResponse(user), + "message", + "Account created successfully. Please log in.")); + } catch (IllegalArgumentException e) { - return "redirect:/login?messageType=invalidUsername"; + log.error("Registration validation error: {}", e.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", e.getMessage())); + } catch (Exception e) { + log.error("Registration error for user: {}", usernameAndPass.getUsername(), e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("error", "Registration failed: " + e.getMessage())); } - return "redirect:/login?registered=true"; + } + + /** + * Helper method to build user response object + * + * @param user User entity + * @return Map containing user information + */ + private Map buildUserResponse(User user) { + Map userMap = new HashMap<>(); + userMap.put("id", user.getId()); + userMap.put("email", user.getUsername()); // Use username as email + userMap.put("username", user.getUsername()); + userMap.put("role", user.getRolesAsString()); + userMap.put("enabled", user.isEnabled()); + + // Add metadata for OAuth compatibility + Map appMetadata = new HashMap<>(); + appMetadata.put("provider", user.getAuthenticationType()); // Default to email provider + userMap.put("app_metadata", appMetadata); + + return userMap; } @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java index 248aea603f..fe5fd5bccf 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java @@ -114,18 +114,8 @@ public class CustomOAuth2AuthenticationSuccessHandler jwtService.generateToken( authentication, Map.of("authType", AuthenticationType.OAUTH2)); - // Check if backend is running on port 8080 (dev mode indicator) - String serverPort = String.valueOf(request.getServerPort()); - String redirectUrl; - - if ("8080".equals(serverPort)) { - // Dev mode: assume frontend is on 5173 or 5174 - // Try 5173 first (default Vite port) - redirectUrl = "http://localhost:5173/auth/callback#access_token=" + jwt; - } else { - // Prod mode: same origin - redirectUrl = contextPath + "/auth/callback#access_token=" + jwt; - } + // Build context-aware redirect URL based on the original request + String redirectUrl = buildContextAwareRedirectUrl(request, contextPath, jwt); response.sendRedirect(redirectUrl); } else { @@ -150,4 +140,48 @@ public class CustomOAuth2AuthenticationSuccessHandler } return null; } + + /** + * Builds a context-aware redirect URL based on the request's origin + * + * @param request The HTTP request + * @param contextPath The application context path + * @param jwt The JWT token to include + * @return The appropriate redirect URL + */ + private String buildContextAwareRedirectUrl( + HttpServletRequest request, String contextPath, String jwt) { + // Try to get the origin from the Referer header first + String referer = request.getHeader("Referer"); + if (referer != null && !referer.isEmpty()) { + try { + java.net.URL refererUrl = new java.net.URL(referer); + String origin = refererUrl.getProtocol() + "://" + refererUrl.getHost(); + if (refererUrl.getPort() != -1 + && refererUrl.getPort() != 80 + && refererUrl.getPort() != 443) { + origin += ":" + refererUrl.getPort(); + } + return origin + "/auth/callback#access_token=" + jwt; + } catch (java.net.MalformedURLException e) { + // Fall back to other methods if referer is malformed + } + } + + // Fall back to building from request host/port + String scheme = request.getScheme(); + String serverName = request.getServerName(); + int serverPort = request.getServerPort(); + + StringBuilder origin = new StringBuilder(); + origin.append(scheme).append("://").append(serverName); + + // Only add port if it's not the default port for the scheme + if ((!"http".equals(scheme) || serverPort != 80) + && (!"https".equals(scheme) || serverPort != 443)) { + origin.append(":").append(serverPort); + } + + return origin.toString() + "/auth/callback#access_token=" + jwt; + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java index 2f8fa27224..14bbd83d48 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java @@ -142,17 +142,9 @@ public class CustomSaml2AuthenticationSuccessHandler authentication, Map.of("authType", AuthenticationType.SAML2)); - // Check if backend is running on port 8080 (dev mode indicator) - String serverPort = String.valueOf(request.getServerPort()); - String redirectUrl; - - if ("8080".equals(serverPort)) { - // Dev mode: assume frontend is on 5173 - redirectUrl = "http://localhost:5173/auth/callback#access_token=" + jwt; - } else { - // Prod mode: same origin - redirectUrl = contextPath + "/auth/callback#access_token=" + jwt; - } + // Build context-aware redirect URL based on the original request + String redirectUrl = + buildContextAwareRedirectUrl(request, contextPath, jwt); response.sendRedirect(redirectUrl); } else { @@ -171,4 +163,49 @@ public class CustomSaml2AuthenticationSuccessHandler super.onAuthenticationSuccess(request, response, authentication); } } + + /** + * Builds a context-aware redirect URL based on the request's origin + * + * @param request The HTTP request + * @param contextPath The application context path + * @param jwt The JWT token to include + * @return The appropriate redirect URL + */ + private String buildContextAwareRedirectUrl( + HttpServletRequest request, String contextPath, String jwt) { + // Try to get the origin from the Referer header first + String referer = request.getHeader("Referer"); + if (referer != null && !referer.isEmpty()) { + try { + java.net.URL refererUrl = new java.net.URL(referer); + String origin = refererUrl.getProtocol() + "://" + refererUrl.getHost(); + if (refererUrl.getPort() != -1 + && refererUrl.getPort() != 80 + && refererUrl.getPort() != 443) { + origin += ":" + refererUrl.getPort(); + } + return origin + "/auth/callback#access_token=" + jwt; + } catch (java.net.MalformedURLException e) { + log.debug( + "Malformed referer URL: {}, falling back to request-based origin", referer); + } + } + + // Fall back to building from request host/port + String scheme = request.getScheme(); + String serverName = request.getServerName(); + int serverPort = request.getServerPort(); + + StringBuilder origin = new StringBuilder(); + origin.append(scheme).append("://").append(serverName); + + // Only add port if it's not the default port for the scheme + if ((!"http".equals(scheme) || serverPort != 80) + && (!"https".equals(scheme) || serverPort != 443)) { + origin.append(":").append(serverPort); + } + + return origin + "/auth/callback#access_token=" + jwt; + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java index 8e0da35c1e..a65c79665a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java @@ -14,7 +14,6 @@ import java.util.function.Function; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.Authentication; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.oauth2.core.user.OAuth2User; @@ -39,11 +38,9 @@ import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrin @Service public class JwtService implements JwtServiceInterface { + private static final String ISSUER = "https://stirling.com"; private static final long EXPIRATION = 3600000; - @Value("${security.jwt.issuer:${server.url:https://stirling.com}}") - private String issuer; - private final KeyPersistenceServiceInterface keyPersistenceService; private final boolean v2Enabled; @@ -88,7 +85,7 @@ public class JwtService implements JwtServiceInterface { Jwts.builder() .claims(claims) .subject(username) - .issuer(issuer) + .issuer(ISSUER) .issuedAt(new Date()) .expiration(new Date(System.currentTimeMillis() + EXPIRATION)) .signWith(keyPair.getPrivate(), Jwts.SIG.RS256); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtServiceTest.java index b80d69a6c4..e8a6d60453 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtServiceTest.java @@ -65,11 +65,6 @@ class JwtServiceTest { testVerificationKey = new JwtVerificationKey("test-key-id", encodedPublicKey); jwtService = new JwtService(true, keystoreService); - - // Set the issuer field using reflection since @Value annotation isn't populated in tests - java.lang.reflect.Field issuerField = JwtService.class.getDeclaredField("issuer"); - issuerField.setAccessible(true); - issuerField.set(jwtService, "https://stirling.com"); } @Test @@ -223,8 +218,8 @@ class JwtServiceTest { assertEquals("admin", extractedClaims.get("role")); assertEquals("IT", extractedClaims.get("department")); assertEquals(username, extractedClaims.get("sub")); - // Issuer is now configurable, so check it exists rather than exact value - assertNotNull(extractedClaims.get("iss")); + // Verify the constant issuer is set correctly + assertEquals("https://stirling.com", extractedClaims.get("iss")); } @Test diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f5ac9b8cdc..7dd9e15cd5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -2341,6 +2341,203 @@ } } }, + "node_modules/@napi-rs/canvas": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz", + "integrity": "sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==", + "license": "MIT", + "optional": true, + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.80", + "@napi-rs/canvas-darwin-arm64": "0.1.80", + "@napi-rs/canvas-darwin-x64": "0.1.80", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.80", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.80", + "@napi-rs/canvas-linux-arm64-musl": "0.1.80", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.80", + "@napi-rs/canvas-linux-x64-gnu": "0.1.80", + "@napi-rs/canvas-linux-x64-musl": "0.1.80", + "@napi-rs/canvas-win32-x64-msvc": "0.1.80" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.80.tgz", + "integrity": "sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.80.tgz", + "integrity": "sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.80.tgz", + "integrity": "sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.80.tgz", + "integrity": "sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.80.tgz", + "integrity": "sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.80.tgz", + "integrity": "sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.80.tgz", + "integrity": "sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.80.tgz", + "integrity": "sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.80.tgz", + "integrity": "sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.80.tgz", + "integrity": "sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -3697,7 +3894,6 @@ "integrity": "sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.12.0" } @@ -3991,6 +4187,275 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@vitejs/plugin-react-swc": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-4.1.0.tgz", @@ -5973,7 +6438,8 @@ "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1508733.tgz", "integrity": "sha512-QJ1R5gtck6nDcdM+nlsaJXcelPEI7ZxSMw1ujHpO1c4+9l+Nue5qlebi9xO1Z2MGr92bFOQTW7/rrheh5hHxDg==", "dev": true, - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/dezalgo": { "version": "1.0.4", @@ -6540,6 +7006,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -13078,6 +13545,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index b77485a9a9..1e86dcf074 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -3978,6 +3978,34 @@ "undoStorageError": "Undo completed but some files could not be saved to storage", "undoSuccess": "Operation undone successfully", "unsupported": "Unsupported", + "signup": { + "title": "Create an account", + "subtitle": "Join Stirling PDF to get started", + "name": "Name", + "email": "Email", + "password": "Password", + "confirmPassword": "Confirm password", + "enterName": "Enter your name", + "enterEmail": "Enter your email", + "enterPassword": "Enter your password", + "confirmPasswordPlaceholder": "Confirm password", + "or": "or", + "creatingAccount": "Creating Account...", + "signUp": "Sign Up", + "alreadyHaveAccount": "Already have an account? Sign in", + "pleaseFillAllFields": "Please fill in all fields", + "passwordsDoNotMatch": "Passwords do not match", + "passwordTooShort": "Password must be at least 6 characters long", + "invalidEmail": "Please enter a valid email address", + "checkEmailConfirmation": "Check your email for a confirmation link to complete your registration.", + "accountCreatedSuccessfully": "Account created successfully! You can now sign in.", + "unexpectedError": "Unexpected error: {{message}}", + "useEmailInstead": "Use Email Instead", + "nameRequired": "Name is required", + "emailRequired": "Email is required", + "passwordRequired": "Password is required", + "confirmPasswordRequired": "Please confirm your password" + }, "onboarding": { "welcomeModal": { "title": "Welcome to Stirling PDF!", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d077d1a69b..fd4d466ad4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -18,6 +18,7 @@ import OnboardingTour from "./components/onboarding/OnboardingTour"; 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 @@ -57,6 +58,7 @@ export default function App() { {/* Auth routes - no FileContext or other providers needed */} } /> + } /> } /> {/* Main app routes - wrapped with all providers */} diff --git a/frontend/src/auth/springAuthClient.ts b/frontend/src/auth/springAuthClient.ts index 806e4a3346..03ea08f0da 100644 --- a/frontend/src/auth/springAuthClient.ts +++ b/frontend/src/auth/springAuthClient.ts @@ -132,7 +132,10 @@ class SpringAuthClient { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', // Include cookies for CSRF - body: JSON.stringify(credentials), + body: JSON.stringify({ + username: credentials.email, + password: credentials.password + }), }); if (!response.ok) { @@ -177,14 +180,13 @@ class SpringAuthClient { options?: { data?: { full_name?: string }; emailRedirectTo?: string }; }): Promise { try { - const response = await fetch('/api/v1/auth/register', { + const response = await fetch('/api/v1/user/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ - email: credentials.email, + username: credentials.email, password: credentials.password, - name: credentials.options?.data?.full_name || '', }), }); diff --git a/frontend/src/contexts/OnboardingContext.tsx b/frontend/src/contexts/OnboardingContext.tsx index 78a3461c16..d2898e46d5 100644 --- a/frontend/src/contexts/OnboardingContext.tsx +++ b/frontend/src/contexts/OnboardingContext.tsx @@ -1,6 +1,7 @@ import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'; import { usePreferences } from './PreferencesContext'; import { useMediaQuery } from '@mantine/hooks'; +import { useAuth } from '../auth/UseSession'; interface OnboardingContextValue { isOpen: boolean; @@ -18,6 +19,7 @@ const OnboardingContext = createContext(unde export const OnboardingProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { const { preferences, updatePreference } = usePreferences(); + const { session, loading } = useAuth(); const [isOpen, setIsOpen] = useState(false); const [currentStep, setCurrentStep] = useState(0); const [showWelcomeModal, setShowWelcomeModal] = useState(false); @@ -26,11 +28,16 @@ export const OnboardingProvider: React.FC<{ children: React.ReactNode }> = ({ ch // Auto-show welcome modal for first-time users after preferences load // Only show after user has seen the tool panel mode prompt // Also, don't show tour on mobile devices because it feels clunky + // IMPORTANT: Only show welcome modal if user is authenticated or login is disabled useEffect(() => { - if (!preferences.hasCompletedOnboarding && preferences.toolPanelModePromptSeen && !isMobile) { - setShowWelcomeModal(true); + if (!loading && !preferences.hasCompletedOnboarding && preferences.toolPanelModePromptSeen && !isMobile) { + // Only show welcome modal if user is authenticated (session exists) + // This prevents the modal from showing on login screens when security is enabled + if (session) { + setShowWelcomeModal(true); + } } - }, [preferences.hasCompletedOnboarding, preferences.toolPanelModePromptSeen, isMobile]); + }, [preferences.hasCompletedOnboarding, preferences.toolPanelModePromptSeen, isMobile, session, loading]); const startTour = useCallback(() => { setCurrentStep(0); diff --git a/frontend/src/routes/Login.tsx b/frontend/src/routes/Login.tsx index df590135d3..9d739477d2 100644 --- a/frontend/src/routes/Login.tsx +++ b/frontend/src/routes/Login.tsx @@ -12,7 +12,6 @@ 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' @@ -22,6 +21,7 @@ export default function Login() { const { t } = useTranslation() const [isSigningIn, setIsSigningIn] = useState(false) const [error, setError] = useState(null) + const [showEmailForm, setShowEmailForm] = useState(false) const [email, setEmail] = useState('') const [password, setPassword] = useState('') @@ -113,35 +113,77 @@ export default function Login() { } } + const handleForgotPassword = () => { + navigate('/auth/reset') + } + return ( - + - - - - + {/* OAuth first */} - navigate('/signup')} - text={t('login.dontHaveAccount') || "Don't have an account? Sign up"} - isDisabled={isSigningIn} - /> + {/* Divider between OAuth and Email */} + + + {/* Sign in with email button (primary color to match signup CTA) */} +
+ +
+ + {showEmailForm && ( +
+ +
+ )} + + {showEmailForm && ( +
+ +
+ )} + + {/* Divider then signup link */} + + +
+ +
+
) } diff --git a/frontend/src/routes/Signup.tsx b/frontend/src/routes/Signup.tsx new file mode 100644 index 0000000000..8915713a8c --- /dev/null +++ b/frontend/src/routes/Signup.tsx @@ -0,0 +1,107 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { useAuth } from '../auth/UseSession' +import { useTranslation } from 'react-i18next' +import { useDocumentMeta } from '../hooks/useDocumentMeta' +import AuthLayout from './authShared/AuthLayout' +import './authShared/auth.css' +import { BASE_PATH } from '../constants/app' + +// Import signup components +import LoginHeader from './login/LoginHeader' +import ErrorMessage from './login/ErrorMessage' +import DividerWithText from '../components/shared/DividerWithText' +import SignupForm from './signup/SignupForm' +import { useSignupFormValidation, SignupFieldErrors } from './signup/SignupFormValidation' +import { useAuthService } from './signup/AuthService' + +export default function Signup() { + const navigate = useNavigate() + const { session, loading } = useAuth() + const { t } = useTranslation() + const [isSigningUp, setIsSigningUp] = useState(false) + const [error, setError] = useState(null) + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [fieldErrors, setFieldErrors] = useState({}) + + const baseUrl = window.location.origin + BASE_PATH; + + // Set document meta + useDocumentMeta({ + title: `${t('signup.title', 'Create an account')} - Stirling PDF`, + description: t('app.description', 'The Free Adobe Acrobat alternative (10M+ Downloads)'), + ogTitle: `${t('signup.title', 'Create an account')} - Stirling PDF`, + ogDescription: t('app.description', 'The Free Adobe Acrobat alternative (10M+ Downloads)'), + ogImage: `${baseUrl}/og_images/home.png`, + ogUrl: `${window.location.origin}${window.location.pathname}` + }) + + const { validateSignupForm } = useSignupFormValidation() + const { signUp } = useAuthService() + + const handleSignUp = async () => { + const validation = validateSignupForm(email, password, confirmPassword) + if (!validation.isValid) { + setError(validation.error) + setFieldErrors(validation.fieldErrors || {}) + return + } + + try { + setIsSigningUp(true) + setError(null) + setFieldErrors({}) + + const result = await signUp(email, password, '') + + if (result.user) { + // Show success message and redirect to login + setError(null) + setTimeout(() => navigate('/login'), 2000) + } + } catch (err) { + console.error('[Signup] Unexpected error:', err) + setError(err instanceof Error ? err.message : t('signup.unexpectedError', { message: 'Unknown error' })) + } finally { + setIsSigningUp(false) + } + } + + return ( + + + + + + {/* Signup form - shown immediately */} + + + + + {/* Bottom row - centered */} +
+ +
+
+ ) +} \ No newline at end of file diff --git a/frontend/src/routes/authShared/AuthLayout.tsx b/frontend/src/routes/authShared/AuthLayout.tsx index e32f629e07..cbf3b8d022 100644 --- a/frontend/src/routes/authShared/AuthLayout.tsx +++ b/frontend/src/routes/authShared/AuthLayout.tsx @@ -5,9 +5,10 @@ import styles from './AuthLayout.module.css' interface AuthLayoutProps { children: React.ReactNode + isEmailFormExpanded?: boolean } -export default function AuthLayout({ children }: AuthLayoutProps) { +export default function AuthLayout({ children, isEmailFormExpanded = false }: AuthLayoutProps) { const cardRef = useRef(null) const [hideRightPanel, setHideRightPanel] = useState(false) diff --git a/frontend/src/routes/authShared/auth.css b/frontend/src/routes/authShared/auth.css index 1b6d8be886..7f75e9dda5 100644 --- a/frontend/src/routes/authShared/auth.css +++ b/frontend/src/routes/authShared/auth.css @@ -186,15 +186,15 @@ display: flex; align-items: center; justify-content: center; - padding: 0.875rem 1rem; /* 14px 16px */ + padding: 1rem 1rem; /* 16px 16px */ border: 1px solid #d1d5db; border-radius: 0.75rem; /* 12px */ background-color: var(--auth-card-bg-light-only); - font-size: 0.875rem; /* 14px */ + font-size: 1rem; /* 16px */ font-weight: 500; color: var(--auth-text-primary-light-only); cursor: pointer; - gap: 0.5rem; /* 8px */ + gap: 0.75rem; /* 12px */ } .oauth-button-vertical:disabled { @@ -215,8 +215,8 @@ } .oauth-icon-tiny { - width: 1rem; /* 16px */ - height: 1rem; /* 16px */ + width: 1.25rem; /* 20px */ + height: 1.25rem; /* 20px */ } /* Login Header Styles */ @@ -319,3 +319,60 @@ border-color: #dc2626 !important; box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.1) !important; } + +/* Shared auth styles extracted from inline */ +.auth-section { + margin: 0.75rem 0; +} + +.auth-section-sm { + margin: 0.5rem 0; +} + +.auth-bottom-row { + display: flex; + align-items: center; + justify-content: space-between; + margin: 0.5rem 0 0.25rem; +} + +.auth-bottom-right { + display: flex; + align-items: center; + justify-content: flex-end; + margin-top: 0.5rem; +} + +.auth-link-black { + background: transparent; + border: 0; + padding: 0; + margin: 0; + text-decoration: underline; + cursor: pointer; + font-size: 0.875rem; /* 14px */ + color: #000; +} + +.auth-dot-black { + opacity: 0.5; + padding: 0 0.5rem; + color: #000; +} + +/* Email login button - red CTA style matching SaaS version */ +.auth-cta-button { + background-color: #AF3434 !important; + color: white !important; + border: none !important; + font-weight: 600 !important; +} + +.auth-cta-button:hover:not(:disabled) { + background-color: #9a2e2e !important; +} + +.auth-cta-button:disabled { + background-color: #AF3434 !important; + opacity: 0.6 !important; +} diff --git a/frontend/src/routes/login/EmailPasswordForm.tsx b/frontend/src/routes/login/EmailPasswordForm.tsx index c21652535c..7036e24cf5 100644 --- a/frontend/src/routes/login/EmailPasswordForm.tsx +++ b/frontend/src/routes/login/EmailPasswordForm.tsx @@ -38,13 +38,13 @@ export default function EmailPasswordForm({
- + setEmail(e.target.value)} className={`auth-input ${fieldErrors.email ? 'auth-input-error' : ''}`} diff --git a/frontend/src/routes/login/OAuthButtons.tsx b/frontend/src/routes/login/OAuthButtons.tsx index 9239d4868c..0de8f3179d 100644 --- a/frontend/src/routes/login/OAuthButtons.tsx +++ b/frontend/src/routes/login/OAuthButtons.tsx @@ -3,8 +3,8 @@ 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: 'github', label: 'GitHub', file: 'github.svg', isDisabled: false }, { id: 'apple', label: 'Apple', file: 'apple.svg', isDisabled: true }, { id: 'azure', label: 'Microsoft', file: 'microsoft.svg', isDisabled: true } ] @@ -18,18 +18,21 @@ interface OAuthButtonsProps { export default function OAuthButtons({ onProviderClick, isSubmitting, layout = 'vertical' }: OAuthButtonsProps) { const { t } = useTranslation() + // Filter out disabled providers - don't show them at all + const enabledProviders = oauthProviders.filter(p => !p.isDisabled) + if (layout === 'icons') { return (
- {oauthProviders.map((p) => ( + {enabledProviders.map((p) => (
))} @@ -40,15 +43,15 @@ export default function OAuthButtons({ onProviderClick, isSubmitting, layout = ' if (layout === 'grid') { return (
- {oauthProviders.map((p) => ( + {enabledProviders.map((p) => (
))} @@ -58,15 +61,15 @@ export default function OAuthButtons({ onProviderClick, isSubmitting, layout = ' return (
- {oauthProviders.map((p) => ( + {enabledProviders.map((p) => ( ))} diff --git a/frontend/src/routes/signup/AuthService.ts b/frontend/src/routes/signup/AuthService.ts new file mode 100644 index 0000000000..ce2426fa2a --- /dev/null +++ b/frontend/src/routes/signup/AuthService.ts @@ -0,0 +1,54 @@ +import { springAuth } from '../../auth/springAuthClient' +import { BASE_PATH } from '../../constants/app' + +export const useAuthService = () => { + + const signUp = async ( + email: string, + password: string, + name: string + ) => { + console.log('[Signup] Creating account for:', email) + + const { user, session, error } = await springAuth.signUp({ + email: email.trim(), + password: password, + options: { + data: { full_name: name }, + emailRedirectTo: `${BASE_PATH}/auth/callback` + } + }) + + if (error) { + console.error('[Signup] Sign up error:', error) + throw new Error(error.message) + } + + if (user) { + console.log('[Signup] Sign up successful:', user) + return { + user: user, + session: session, + requiresEmailConfirmation: user && !session + } + } + + throw new Error('Unknown error occurred during signup') + } + + const signInWithProvider = async (provider: 'github' | 'google' | 'apple' | 'azure') => { + const { error } = await springAuth.signInWithOAuth({ + provider, + options: { redirectTo: `${BASE_PATH}/auth/callback` } + }) + + if (error) { + throw new Error(error.message) + } + } + + return { + signUp, + signInWithProvider + } +} \ No newline at end of file diff --git a/frontend/src/routes/signup/SignupForm.tsx b/frontend/src/routes/signup/SignupForm.tsx index 0e2e8ea4b4..2ae809467c 100644 --- a/frontend/src/routes/signup/SignupForm.tsx +++ b/frontend/src/routes/signup/SignupForm.tsx @@ -4,27 +4,29 @@ import { useTranslation } from 'react-i18next' import { SignupFieldErrors } from './SignupFormValidation' interface SignupFormProps { - name: string + name?: string email: string password: string confirmPassword: string - agree: boolean - setName: (name: string) => void + agree?: boolean + setName?: (name: string) => void setEmail: (email: string) => void setPassword: (password: string) => void setConfirmPassword: (password: string) => void - setAgree: (agree: boolean) => void + setAgree?: (agree: boolean) => void onSubmit: () => void isSubmitting: boolean fieldErrors?: SignupFieldErrors + showName?: boolean + showTerms?: boolean } export default function SignupForm({ - name, + name = '', email, password, confirmPassword, - agree, + agree = true, setName, setEmail, setPassword, @@ -32,7 +34,9 @@ export default function SignupForm({ setAgree, onSubmit, isSubmitting, - fieldErrors = {} + fieldErrors = {}, + showName = false, + showTerms = false }: SignupFormProps) { const { t } = useTranslation() const showConfirm = password.length >= 4 @@ -46,22 +50,24 @@ export default function SignupForm({ return ( <>
-
- - setName(e.target.value)} - className={`auth-input ${fieldErrors.name ? 'auth-input-error' : ''}`} - /> - {fieldErrors.name && ( -
{fieldErrors.name}
- )} -
+ {showName && ( +
+ + setName?.(e.target.value)} + className={`auth-input ${fieldErrors.name ? 'auth-input-error' : ''}`} + /> + {fieldErrors.name && ( +
{fieldErrors.name}
+ )} +
+ )}
@@ -124,31 +130,33 @@ export default function SignupForm({
- {/* Terms */} -
- setAgree(e.target.checked)} - className="auth-checkbox" - /> - -
+ {/* Terms - only show if showTerms is true */} + {showTerms && ( +
+ setAgree?.(e.target.checked)} + className="auth-checkbox" + /> + +
+ )} {/* Sign Up Button */} ) -} +} \ No newline at end of file diff --git a/frontend/src/routes/signup/SignupFormValidation.ts b/frontend/src/routes/signup/SignupFormValidation.ts index 688a5fe30f..7abe498a09 100644 --- a/frontend/src/routes/signup/SignupFormValidation.ts +++ b/frontend/src/routes/signup/SignupFormValidation.ts @@ -63,4 +63,4 @@ export const useSignupFormValidation = () => { return { validateSignupForm } -} +} \ No newline at end of file