From 31fda096ec51ca81182774df1899c27dcc90add0 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Mon, 27 Oct 2025 11:02:00 +0000 Subject: [PATCH] send email and invite link --- .../common/model/ApplicationProperties.java | 5 + .../controller/api/misc/ConfigController.java | 6 +- .../configuration/SecurityConfiguration.java | 5 + .../controller/api/InviteLinkController.java | 484 ++++++++++++++++++ .../controller/api/UserController.java | 9 +- .../filter/JwtAuthenticationFilter.java | 5 +- .../filter/UserAuthenticationFilter.java | 3 + .../security/model/InviteToken.java | 62 +++ .../repository/InviteTokenRepository.java | 32 ++ .../security/service/EmailService.java | 53 ++ .../public/locales/en-GB/translation.json | 56 +- frontend/src/App.tsx | 18 +- .../src/components/shared/FirstLoginModal.tsx | 22 +- .../config/configSections/PeopleSection.tsx | 269 ++++++++-- .../shared/config/useRestartServer.ts | 17 +- frontend/src/routes/InviteAccept.tsx | 281 ++++++++++ frontend/src/routes/Login.tsx | 42 +- .../src/routes/signup/SignupFormValidation.ts | 2 - frontend/src/services/accountService.ts | 12 +- .../src/services/userManagementService.ts | 85 +++ frontend/vite.config.ts | 1 + 21 files changed, 1393 insertions(+), 76 deletions(-) create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/InviteLinkController.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/model/InviteToken.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/repository/InviteTokenRepository.java create mode 100644 frontend/src/routes/InviteAccept.tsx diff --git a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java index 30c41df28..75c5dd8ad 100644 --- a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java +++ b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java @@ -364,6 +364,10 @@ public class ApplicationProperties { private String fileUploadLimit; private TempFileManagement tempFileManagement = new TempFileManagement(); private List corsAllowedOrigins = new ArrayList<>(); + private String + frontendUrl; // Base URL for frontend (used for invite links, etc.). If not set, + + // falls back to backend URL. public boolean isAnalyticsEnabled() { return this.getEnableAnalytics() != null && this.getEnableAnalytics(); @@ -535,6 +539,7 @@ public class ApplicationProperties { public static class Mail { private boolean enabled; private boolean enableInvites = false; + private int inviteLinkExpiryHours = 72; // Default: 72 hours (3 days) private String host; private int port; private String username; diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java index 27c72eaf0..4a2fef040 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java @@ -62,8 +62,10 @@ public class ConfigController { // Security settings configData.put("enableLogin", applicationProperties.getSecurity().getEnableLogin()); - // Mail settings - configData.put("enableEmailInvites", applicationProperties.getMail().isEnableInvites()); + // Mail settings - check both SMTP enabled AND invites enabled + boolean smtpEnabled = applicationProperties.getMail().isEnabled(); + boolean invitesEnabled = applicationProperties.getMail().isEnableInvites(); + configData.put("enableEmailInvites", smtpEnabled && invitesEnabled); // Check if user is admin using UserServiceInterface boolean isAdmin = false; 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 92def884f..f28b35f84 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,7 @@ public class SecurityConfiguration { || trimmedUri.endsWith(".svg") || trimmedUri.startsWith("/register") || trimmedUri.startsWith("/signup") + || trimmedUri.startsWith("/invite") || trimmedUri.startsWith("/auth/callback") || trimmedUri.startsWith("/error") || trimmedUri.startsWith("/images/") @@ -263,6 +264,10 @@ public class SecurityConfiguration { || trimmedUri.startsWith( "/api/v1/auth/refresh") || trimmedUri.startsWith("/api/v1/auth/me") + || trimmedUri.startsWith( + "/api/v1/invite/validate") + || trimmedUri.startsWith( + "/api/v1/invite/accept") || trimmedUri.startsWith("/v1/api-docs") || uri.contains("/v1/api-docs"); }) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/InviteLinkController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/InviteLinkController.java new file mode 100644 index 000000000..048ed178b --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/InviteLinkController.java @@ -0,0 +1,484 @@ +package stirling.software.proprietary.security.controller.api; + +import java.security.Principal; +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import jakarta.servlet.http.HttpServletRequest; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.annotations.api.UserApi; +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.model.enumeration.Role; +import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.security.model.InviteToken; +import stirling.software.proprietary.security.repository.InviteTokenRepository; +import stirling.software.proprietary.security.repository.TeamRepository; +import stirling.software.proprietary.security.service.EmailService; +import stirling.software.proprietary.security.service.TeamService; +import stirling.software.proprietary.security.service.UserService; + +@UserApi +@Slf4j +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/invite") +public class InviteLinkController { + + private final InviteTokenRepository inviteTokenRepository; + private final TeamRepository teamRepository; + private final UserService userService; + private final ApplicationProperties applicationProperties; + private final Optional emailService; + + /** + * Generate a new invite link (admin only) + * + * @param email The email address to invite + * @param role The role to assign (default: ROLE_USER) + * @param teamId The team to assign (optional, uses default team if not provided) + * @param expiryHours Custom expiry hours (optional, uses default from config) + * @param sendEmail Whether to send the invite link via email (default: false) + * @param principal The authenticated admin user + * @param request The HTTP request + * @return ResponseEntity with the invite link or error + */ + @PreAuthorize("hasRole('ROLE_ADMIN')") + @PostMapping("/generate") + public ResponseEntity generateInviteLink( + @RequestParam(name = "email", required = false) String email, + @RequestParam(name = "role", defaultValue = "ROLE_USER") String role, + @RequestParam(name = "teamId", required = false) Long teamId, + @RequestParam(name = "expiryHours", required = false) Integer expiryHours, + @RequestParam(name = "sendEmail", defaultValue = "false") boolean sendEmail, + Principal principal, + HttpServletRequest request) { + + try { + // Check if email invites are enabled + if (!applicationProperties.getMail().isEnableInvites()) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Email invites are not enabled")); + } + + // If email is provided, validate and check for conflicts + if (email != null && !email.trim().isEmpty()) { + // Validate email format + if (!email.contains("@")) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Invalid email address")); + } + + email = email.trim().toLowerCase(); + + // Check if user already exists + if (userService.usernameExistsIgnoreCase(email)) { + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(Map.of("error", "User already exists")); + } + + // Check if there's already an active invite for this email + Optional existingInvite = inviteTokenRepository.findByEmail(email); + if (existingInvite.isPresent() && existingInvite.get().isValid()) { + return ResponseEntity.status(HttpStatus.CONFLICT) + .body( + Map.of( + "error", + "An active invite already exists for this email address")); + } + + // If sendEmail is requested but no email provided, reject + if (sendEmail) { + // Email will be sent + } + } else { + // No email provided - this is a general invite link + email = null; // Ensure it's null, not empty string + + // Cannot send email if no email address provided + if (sendEmail) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Cannot send email without an email address")); + } + } + + // Check license limits + if (applicationProperties.getPremium().isEnabled()) { + long currentUserCount = userService.getTotalUsersCount(); + long activeInvites = inviteTokenRepository.countActiveInvites(LocalDateTime.now()); + int maxUsers = applicationProperties.getPremium().getMaxUsers(); + + if (currentUserCount + activeInvites >= maxUsers) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body( + Map.of( + "error", + "Maximum number of users reached for your license")); + } + } + + // Validate role + try { + Role roleEnum = Role.fromString(role); + if (roleEnum == Role.INTERNAL_API_USER) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Cannot assign INTERNAL_API_USER role")); + } + } catch (IllegalArgumentException e) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Invalid role specified")); + } + + // Determine team + Long effectiveTeamId = teamId; + if (effectiveTeamId == null) { + Team defaultTeam = + teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME).orElse(null); + if (defaultTeam != null) { + effectiveTeamId = defaultTeam.getId(); + } + } else { + Team selectedTeam = teamRepository.findById(effectiveTeamId).orElse(null); + if (selectedTeam != null + && TeamService.INTERNAL_TEAM_NAME.equals(selectedTeam.getName())) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Cannot assign users to Internal team")); + } + } + + // Generate token + String token = UUID.randomUUID().toString(); + + // Determine expiry time + int effectiveExpiryHours = + (expiryHours != null && expiryHours > 0) + ? expiryHours + : applicationProperties.getMail().getInviteLinkExpiryHours(); + LocalDateTime expiresAt = LocalDateTime.now().plusHours(effectiveExpiryHours); + + // Create invite token + InviteToken inviteToken = new InviteToken(); + inviteToken.setToken(token); + inviteToken.setEmail(email); + inviteToken.setRole(role); + inviteToken.setTeamId(effectiveTeamId); + inviteToken.setExpiresAt(expiresAt); + inviteToken.setCreatedBy(principal.getName()); + + inviteTokenRepository.save(inviteToken); + + // Build invite URL + // Use configured frontend URL if available, otherwise fall back to backend URL + String baseUrl; + String configuredFrontendUrl = applicationProperties.getSystem().getFrontendUrl(); + if (configuredFrontendUrl != null && !configuredFrontendUrl.trim().isEmpty()) { + // Use configured frontend URL (remove trailing slash if present) + baseUrl = + configuredFrontendUrl.endsWith("/") + ? configuredFrontendUrl.substring( + 0, configuredFrontendUrl.length() - 1) + : configuredFrontendUrl; + } else { + // Fall back to backend URL from request + baseUrl = + request.getScheme() + + "://" + + request.getServerName() + + (request.getServerPort() != 80 && request.getServerPort() != 443 + ? ":" + request.getServerPort() + : ""); + } + String inviteUrl = baseUrl + "/invite?token=" + token; + + log.info("Generated invite link for {} by {}", email, principal.getName()); + + // Optionally send email + boolean emailSent = false; + String emailError = null; + if (sendEmail) { + if (!emailService.isPresent()) { + emailError = "Email service is not configured"; + log.warn("Cannot send invite email: Email service not configured"); + } else { + try { + emailService + .get() + .sendInviteLinkEmail(email, inviteUrl, expiresAt.toString()); + emailSent = true; + log.info("Sent invite link email to: {}", email); + } catch (Exception emailEx) { + emailError = emailEx.getMessage(); + log.error( + "Failed to send invite email to {}: {}", + email, + emailEx.getMessage()); + } + } + } + + Map response = new HashMap<>(); + response.put("token", token); + response.put("inviteUrl", inviteUrl); + response.put("email", email); + response.put("expiresAt", expiresAt.toString()); + response.put("expiryHours", effectiveExpiryHours); + if (sendEmail) { + response.put("emailSent", emailSent); + if (emailError != null) { + response.put("emailError", emailError); + } + } + + return ResponseEntity.ok(response); + + } catch (Exception e) { + log.error("Failed to generate invite link: {}", e.getMessage(), e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("error", "Failed to generate invite link: " + e.getMessage())); + } + } + + /** + * List all active invite links (admin only) + * + * @return List of active invite tokens + */ + @PreAuthorize("hasRole('ROLE_ADMIN')") + @GetMapping("/list") + public ResponseEntity listInviteLinks() { + try { + List activeInvites = + inviteTokenRepository.findByUsedFalseAndExpiresAtAfter(LocalDateTime.now()); + + List> inviteList = + activeInvites.stream() + .map( + invite -> { + Map inviteMap = new HashMap<>(); + inviteMap.put("id", invite.getId()); + inviteMap.put("email", invite.getEmail()); + inviteMap.put("role", invite.getRole()); + inviteMap.put("teamId", invite.getTeamId()); + inviteMap.put("createdBy", invite.getCreatedBy()); + inviteMap.put( + "createdAt", invite.getCreatedAt().toString()); + inviteMap.put( + "expiresAt", invite.getExpiresAt().toString()); + return inviteMap; + }) + .collect(Collectors.toList()); + + return ResponseEntity.ok(Map.of("invites", inviteList)); + + } catch (Exception e) { + log.error("Failed to list invite links: {}", e.getMessage(), e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("error", "Failed to list invite links")); + } + } + + /** + * Revoke an invite link (admin only) + * + * @param inviteId The invite token ID to revoke + * @return Success or error response + */ + @PreAuthorize("hasRole('ROLE_ADMIN')") + @DeleteMapping("/revoke/{inviteId}") + public ResponseEntity revokeInviteLink(@PathVariable Long inviteId) { + try { + Optional inviteOpt = inviteTokenRepository.findById(inviteId); + if (inviteOpt.isEmpty()) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(Map.of("error", "Invite not found")); + } + + inviteTokenRepository.deleteById(inviteId); + log.info("Revoked invite link ID: {}", inviteId); + + return ResponseEntity.ok(Map.of("message", "Invite link revoked successfully")); + + } catch (Exception e) { + log.error("Failed to revoke invite link: {}", e.getMessage(), e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("error", "Failed to revoke invite link")); + } + } + + /** + * Clean up expired invite tokens (admin only) + * + * @return Number of deleted tokens + */ + @PreAuthorize("hasRole('ROLE_ADMIN')") + @PostMapping("/cleanup") + public ResponseEntity cleanupExpiredInvites() { + try { + List expiredInvites = + inviteTokenRepository.findAll().stream() + .filter(invite -> !invite.isValid()) + .collect(Collectors.toList()); + + int count = expiredInvites.size(); + inviteTokenRepository.deleteAll(expiredInvites); + + log.info("Cleaned up {} expired invite tokens", count); + + return ResponseEntity.ok(Map.of("deletedCount", count)); + + } catch (Exception e) { + log.error("Failed to cleanup expired invites: {}", e.getMessage(), e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("error", "Failed to cleanup expired invites")); + } + } + + /** + * Validate an invite token (public endpoint) + * + * @param token The invite token to validate + * @return Invite details if valid, error otherwise + */ + @GetMapping("/validate/{token}") + public ResponseEntity validateInviteToken(@PathVariable String token) { + try { + Optional inviteOpt = inviteTokenRepository.findByToken(token); + + if (inviteOpt.isEmpty()) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(Map.of("error", "Invalid invite link")); + } + + InviteToken invite = inviteOpt.get(); + + if (invite.isUsed()) { + return ResponseEntity.status(HttpStatus.GONE) + .body(Map.of("error", "This invite link has already been used")); + } + + if (invite.isExpired()) { + return ResponseEntity.status(HttpStatus.GONE) + .body(Map.of("error", "This invite link has expired")); + } + + // Check if user already exists (only if email is pre-set) + if (invite.getEmail() != null + && userService.usernameExistsIgnoreCase(invite.getEmail())) { + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(Map.of("error", "User already exists")); + } + + Map response = new HashMap<>(); + response.put("email", invite.getEmail()); + response.put("role", invite.getRole()); + response.put("expiresAt", invite.getExpiresAt().toString()); + response.put("emailRequired", invite.getEmail() == null); + + return ResponseEntity.ok(response); + + } catch (Exception e) { + log.error("Failed to validate invite token: {}", e.getMessage(), e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("error", "Failed to validate invite link")); + } + } + + /** + * Accept an invite and create user account (public endpoint) + * + * @param token The invite token + * @param email The email address (required if not pre-set in invite) + * @param password The password to set for the new account + * @return Success or error response + */ + @PostMapping("/accept/{token}") + public ResponseEntity acceptInvite( + @PathVariable String token, + @RequestParam(name = "email", required = false) String email, + @RequestParam(name = "password") String password) { + try { + // Validate password + if (password == null || password.isEmpty()) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Password is required")); + } + + Optional inviteOpt = inviteTokenRepository.findByToken(token); + + if (inviteOpt.isEmpty()) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(Map.of("error", "Invalid invite link")); + } + + InviteToken invite = inviteOpt.get(); + + if (invite.isUsed()) { + return ResponseEntity.status(HttpStatus.GONE) + .body(Map.of("error", "This invite link has already been used")); + } + + if (invite.isExpired()) { + return ResponseEntity.status(HttpStatus.GONE) + .body(Map.of("error", "This invite link has expired")); + } + + // Determine the email to use + String effectiveEmail = invite.getEmail(); + if (effectiveEmail == null) { + // Email not pre-set, must be provided by user + if (email == null || email.trim().isEmpty()) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Email address is required")); + } + + // Validate email format + if (!email.contains("@")) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Invalid email address")); + } + + effectiveEmail = email.trim().toLowerCase(); + } + + // Check if user already exists + if (userService.usernameExistsIgnoreCase(effectiveEmail)) { + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(Map.of("error", "User already exists")); + } + + // Create the user account + userService.saveUser( + effectiveEmail, + password, + invite.getTeamId(), + invite.getRole(), + false); // Don't force password change + + // Mark invite as used + invite.setUsed(true); + invite.setUsedAt(LocalDateTime.now()); + inviteTokenRepository.save(invite); + + log.info( + "User account created via invite link: {} with role: {}", + effectiveEmail, + invite.getRole()); + + return ResponseEntity.ok( + Map.of("message", "Account created successfully", "username", effectiveEmail)); + + } catch (Exception e) { + log.error("Failed to accept invite: {}", e.getMessage(), e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("error", "Failed to create account: " + e.getMessage())); + } + } +} 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 42cb155c1..322fd2709 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 @@ -77,10 +77,9 @@ public class UserController { .body(Map.of("error", "Invalid username format")); } - if (usernameAndPass.getPassword() == null - || usernameAndPass.getPassword().length() < 6) { + if (usernameAndPass.getPassword() == null || usernameAndPass.getPassword().isEmpty()) { return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", "Password must be at least 6 characters")); + .body(Map.of("error", "Password is required")); } Team team = teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME).orElse(null); @@ -367,10 +366,6 @@ public class UserController { return ResponseEntity.status(HttpStatus.BAD_REQUEST) .body(Map.of("error", "Password is required.")); } - if (password.length() < 6) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", "Password must be at least 6 characters.")); - } userService.saveUser(username, password, effectiveTeamId, role, forceChange); } return ResponseEntity.ok(Map.of("message", "User created successfully")); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java index d6a34264f..786cb8b14 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java @@ -84,11 +84,14 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { boolean isPublicAuthEndpoint = requestURI.startsWith(contextPath + "/login") || requestURI.startsWith(contextPath + "/signup") + || requestURI.startsWith(contextPath + "/invite") || requestURI.startsWith(contextPath + "/auth/") || requestURI.startsWith(contextPath + "/oauth2") || requestURI.startsWith(contextPath + "/api/v1/auth/login") || requestURI.startsWith(contextPath + "/api/v1/auth/register") - || requestURI.startsWith(contextPath + "/api/v1/auth/refresh"); + || requestURI.startsWith(contextPath + "/api/v1/auth/refresh") + || requestURI.startsWith(contextPath + "/api/v1/invite/validate") + || requestURI.startsWith(contextPath + "/api/v1/invite/accept"); if (!isPublicAuthEndpoint) { // For API requests, return 401 JSON diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java index 8bf8bdd4a..e61ecb057 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java @@ -227,6 +227,7 @@ public class UserAuthenticationFilter extends OncePerRequestFilter { contextPath + "/login", contextPath + "/signup", contextPath + "/register", + contextPath + "/invite", contextPath + "/error", contextPath + "/images/", contextPath + "/public/", @@ -240,6 +241,8 @@ public class UserAuthenticationFilter extends OncePerRequestFilter { contextPath + "/api/v1/auth/register", contextPath + "/api/v1/auth/refresh", contextPath + "/api/v1/auth/me", + contextPath + "/api/v1/invite/validate", + contextPath + "/api/v1/invite/accept", contextPath + "/site.webmanifest" }; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/InviteToken.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/InviteToken.java new file mode 100644 index 000000000..975220bf4 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/InviteToken.java @@ -0,0 +1,62 @@ +package stirling.software.proprietary.security.model; + +import java.io.Serializable; +import java.time.LocalDateTime; + +import org.hibernate.annotations.CreationTimestamp; + +import jakarta.persistence.*; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Table(name = "invite_tokens") +@NoArgsConstructor +@Getter +@Setter +public class InviteToken implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "token", unique = true, nullable = false, length = 100) + private String token; + + @Column(name = "email", nullable = true, length = 255) + private String email; // Optional - if not set, user can provide their own email + + @Column(name = "role", nullable = false, length = 50) + private String role; + + @Column(name = "team_id") + private Long teamId; + + @Column(name = "expires_at", nullable = false) + private LocalDateTime expiresAt; + + @Column(name = "used", nullable = false) + private boolean used = false; + + @Column(name = "created_by", nullable = false, length = 255) + private String createdBy; + + @CreationTimestamp + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + + @Column(name = "used_at") + private LocalDateTime usedAt; + + public boolean isExpired() { + return LocalDateTime.now().isAfter(expiresAt); + } + + public boolean isValid() { + return !used && !isExpired(); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/InviteTokenRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/InviteTokenRepository.java new file mode 100644 index 000000000..be3cd9c9e --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/InviteTokenRepository.java @@ -0,0 +1,32 @@ +package stirling.software.proprietary.security.repository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import stirling.software.proprietary.security.model.InviteToken; + +@Repository +public interface InviteTokenRepository extends JpaRepository { + + Optional findByToken(String token); + + Optional findByEmail(String email); + + List findByUsedFalseAndExpiresAtAfter(LocalDateTime now); + + List findByCreatedBy(String createdBy); + + @Modifying + @Query("DELETE FROM InviteToken it WHERE it.expiresAt < :now") + void deleteExpiredTokens(@Param("now") LocalDateTime now); + + @Query("SELECT COUNT(it) FROM InviteToken it WHERE it.used = false AND it.expiresAt > :now") + long countActiveInvites(@Param("now") LocalDateTime now); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/EmailService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/EmailService.java index d625d556f..4668e9a38 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/EmailService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/EmailService.java @@ -158,4 +158,57 @@ public class EmailService { sendPlainEmail(to, subject, body, true); } + + /** + * Sends an invitation link email to a new user. + * + * @param to The recipient email address + * @param inviteUrl The full URL for accepting the invite + * @param expiresAt The expiration timestamp + * @throws MessagingException If there is an issue with creating or sending the email. + */ + @Async + public void sendInviteLinkEmail(String to, String inviteUrl, String expiresAt) + throws MessagingException { + String subject = "You've been invited to Stirling PDF"; + + String body = + String.format( + "" + + "
" + + "
" + + " " + + "
" + + " \"Stirling" + + "
" + + " " + + "
" + + "

Welcome to Stirling PDF!

" + + "

Hi there,

" + + "

You have been invited to join the Stirling PDF workspace. Click the button below to set up your account:

" + + " " + + "
" + + " Accept Invitation" + + "
" + + "

Or copy and paste this link in your browser:

" + + "
" + + " %s" + + "
" + + "
" + + "

⚠️ Important: This invitation link will expire on %s. Please complete your registration before then.

" + + "
" + + "

If you didn't expect this invitation, you can safely ignore this email.

" + + "

— The Stirling PDF Team

" + + "
" + + " " + + "
" + + " © 2025 Stirling PDF. All rights reserved." + + "
" + + "
" + + "
" + + "", + inviteUrl, inviteUrl, expiresAt); + + sendPlainEmail(to, subject, body, true); + } } diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 480d9f0df..dc85a460b 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -3060,7 +3060,10 @@ "magicLinkSent": "Magic link sent to {{email}}! Check your email and click the link to sign in.", "passwordResetSent": "Password reset link sent to {{email}}! Check your email and follow the instructions.", "failedToSignIn": "Failed to sign in with {{provider}}: {{message}}", - "unexpectedError": "Unexpected error: {{message}}" + "unexpectedError": "Unexpected error: {{message}}", + "accountCreatedSuccess": "Account created successfully! You can now sign in.", + "passwordChangedSuccess": "Password changed successfully! Please sign in with your new password.", + "credentialsUpdated": "Your credentials have been updated. Please sign in again." }, "signup": { "title": "Create an account", @@ -4474,10 +4477,31 @@ "directInvite": { "tab": "Direct Create" }, + "inviteLinkTab": { + "tab": "Invite Link" + }, + "inviteLink": { + "description": "Generate a secure link that allows the user to set their own password", + "email": "Email Address", + "emailRequired": "Email address is required", + "emailOptional": "Optional - leave blank for a general invite link", + "emailRequiredForSend": "Email address is required to send email notification", + "expiryHours": "Expiry Hours", + "expiryDescription": "How many hours until the link expires", + "sendEmail": "Send invite link via email", + "smtpRequired": "SMTP not configured", + "generate": "Generate Link", + "generated": "Invite Link Generated", + "copied": "Link copied to clipboard", + "success": "Invite link generated successfully", + "successWithEmail": "Invite link generated and sent via email", + "emailFailed": "Email failed to send", + "error": "Failed to generate invite link" + }, "inviteMode": { "username": "Username", "email": "Email", - "emailDisabled": "Email invites require SMTP configuration and mail.enableInvites=true in settings" + "emailDisabled": "SMTP not configured" } }, "teams": { @@ -4580,5 +4604,33 @@ "passwordMustBeDifferent": "New password must be different from current password", "passwordChangedSuccess": "Password changed successfully! Please log in again.", "passwordChangeFailed": "Failed to change password. Please check your current password." + }, + "invite": { + "welcome": "Welcome to Stirling PDF", + "invalidToken": "Invalid invitation link", + "validationError": "Failed to validate invitation link", + "passwordRequired": "Password is required", + "passwordTooShort": "Password must be at least 6 characters", + "passwordMismatch": "Passwords do not match", + "acceptError": "Failed to create account", + "validating": "Validating invitation...", + "invalidInvitation": "Invalid Invitation", + "goToLogin": "Go to Login", + "welcomeTitle": "You've been invited!", + "welcomeSubtitle": "Complete your account setup to get started", + "accountFor": "Creating account for", + "linkExpires": "Link expires", + "email": "Email address", + "emailPlaceholder": "Enter your email address", + "emailRequired": "Email address is required", + "invalidEmail": "Invalid email address", + "choosePassword": "Choose a password", + "passwordPlaceholder": "Enter your password", + "confirmPassword": "Confirm password", + "confirmPasswordPlaceholder": "Re-enter your password", + "createAccount": "Create Account", + "creating": "Creating Account...", + "alreadyHaveAccount": "Already have an account?", + "signIn": "Sign in" } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fd4d466ad..b42d14c9a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -20,11 +20,26 @@ import Landing from "./routes/Landing"; import Login from "./routes/Login"; import Signup from "./routes/Signup"; import AuthCallback from "./routes/AuthCallback"; +import InviteAccept from "./routes/InviteAccept"; // Import global styles import "./styles/tailwind.css"; -import "./styles/cookieconsent.css"; import "./index.css"; + +// Load cookieconsent.css optionally - won't block UI if ad blocker blocks it +const loadOptionalCSS = () => { + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = '/src/styles/cookieconsent.css'; + link.onerror = () => { + console.debug('Cookie consent styles blocked by ad blocker - continuing without them'); + }; + document.head.appendChild(link); +}; +// Load it once when app initializes +if (typeof document !== 'undefined') { + loadOptionalCSS(); +} import { RightRailProvider } from "./contexts/RightRailContext"; import { ViewerProvider } from "./contexts/ViewerContext"; import { SignatureProvider } from "./contexts/SignatureContext"; @@ -59,6 +74,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/components/shared/FirstLoginModal.tsx b/frontend/src/components/shared/FirstLoginModal.tsx index 92c293765..61314579c 100644 --- a/frontend/src/components/shared/FirstLoginModal.tsx +++ b/frontend/src/components/shared/FirstLoginModal.tsx @@ -1,10 +1,12 @@ import { useState } from 'react'; import { Modal, Stack, Text, PasswordInput, Button, Alert } from '@mantine/core'; import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; import LocalIcon from './LocalIcon'; import { accountService } from '../../services/accountService'; import { alert } from '../toast'; import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '../../styles/zIndex'; +import { useAuth } from '../../auth/UseSession'; interface FirstLoginModalProps { opened: boolean; @@ -20,6 +22,8 @@ interface FirstLoginModalProps { */ export default function FirstLoginModal({ opened, onPasswordChanged, username }: FirstLoginModalProps) { const { t } = useTranslation(); + const navigate = useNavigate(); + const { signOut } = useAuth(); const [currentPassword, setCurrentPassword] = useState(''); const [newPassword, setNewPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); @@ -38,11 +42,6 @@ export default function FirstLoginModal({ opened, onPasswordChanged, username }: return; } - if (newPassword.length < 8) { - setError(t('firstLogin.passwordTooShort', 'Password must be at least 8 characters')); - return; - } - if (newPassword === currentPassword) { setError(t('firstLogin.passwordMustBeDifferent', 'New password must be different from current password')); return; @@ -52,7 +51,8 @@ export default function FirstLoginModal({ opened, onPasswordChanged, username }: setLoading(true); setError(''); - await accountService.changePassword(currentPassword, newPassword); + // Use changePasswordOnLogin to clear the first-use flag + await accountService.changePasswordOnLogin(currentPassword, newPassword); alert({ alertType: 'success', @@ -64,11 +64,9 @@ export default function FirstLoginModal({ opened, onPasswordChanged, username }: setNewPassword(''); setConfirmPassword(''); - // Wait a moment for the user to see the success message - // Then the backend will have logged them out, and onPasswordChanged will handle redirect - setTimeout(() => { - onPasswordChanged(); - }, 1500); + // Backend has logged us out, so clear frontend auth state and redirect to login + await signOut(); + navigate('/login?messageType=passwordChanged'); } catch (err: any) { console.error('Failed to change password:', err); setError( @@ -130,7 +128,7 @@ export default function FirstLoginModal({ opened, onPasswordChanged, username }: setNewPassword(e.currentTarget.value)} required diff --git a/frontend/src/components/shared/config/configSections/PeopleSection.tsx b/frontend/src/components/shared/config/configSections/PeopleSection.tsx index baae4e260..022bfea45 100644 --- a/frontend/src/components/shared/config/configSections/PeopleSection.tsx +++ b/frontend/src/components/shared/config/configSections/PeopleSection.tsx @@ -14,7 +14,7 @@ import { Modal, Select, Paper, - Checkbox, + Switch, Textarea, SegmentedControl, Tooltip, @@ -38,7 +38,7 @@ export default function PeopleSection() { const [editUserModalOpened, setEditUserModalOpened] = useState(false); const [selectedUser, setSelectedUser] = useState(null); const [processing, setProcessing] = useState(false); - const [inviteMode, setInviteMode] = useState<'email' | 'direct'>('direct'); + const [inviteMode, setInviteMode] = useState<'email' | 'direct' | 'link'>('direct'); // Form state for direct invite const [inviteForm, setInviteForm] = useState({ @@ -56,6 +56,17 @@ export default function PeopleSection() { teamId: undefined as number | undefined, }); + // Form state for invite link + const [inviteLinkForm, setInviteLinkForm] = useState({ + email: '', + role: 'ROLE_USER', + teamId: undefined as number | undefined, + expiryHours: 72, + sendEmail: false, + }); + + const [generatedInviteLink, setGeneratedInviteLink] = useState(null); + // Form state for edit user modal const [editForm, setEditForm] = useState({ role: 'ROLE_USER', @@ -189,6 +200,45 @@ export default function PeopleSection() { } }; + const handleGenerateInviteLink = async () => { + // Email is optional - if provided, it will be pre-filled for the user + // If not provided, user will be asked to enter their email during signup + + try { + setProcessing(true); + const response = await userManagementService.generateInviteLink({ + email: inviteLinkForm.email.trim() || undefined, + role: inviteLinkForm.role, + teamId: inviteLinkForm.teamId, + expiryHours: inviteLinkForm.expiryHours, + sendEmail: inviteLinkForm.sendEmail, + }); + + // Construct invite URL using current frontend origin (not backend URL) + const frontendUrl = `${window.location.origin}/invite?token=${response.token}`; + setGeneratedInviteLink(frontendUrl); + + if (response.emailSent) { + alert({ alertType: 'success', title: t('workspace.people.inviteLink.successWithEmail', 'Invite link generated and sent via email') }); + } else { + alert({ alertType: 'success', title: t('workspace.people.inviteLink.success', 'Invite link generated successfully') }); + } + + if (response.emailError) { + alert({ alertType: 'warning', title: t('workspace.people.inviteLink.emailFailed', 'Email failed to send'), body: response.emailError }); + } + } catch (error: any) { + console.error('Failed to generate invite link:', error); + const errorMessage = error.response?.data?.message || + error.response?.data?.error || + error.message || + t('workspace.people.inviteLink.error', 'Failed to generate invite link'); + alert({ alertType: 'error', title: errorMessage }); + } finally { + setProcessing(false); + } + }; + const handleUpdateUserRole = async () => { if (!selectedUser) return; @@ -463,7 +513,17 @@ export default function PeopleSection() { {/* Add Member Modal */} setInviteModalOpened(false)} + onClose={() => { + setInviteModalOpened(false); + setGeneratedInviteLink(null); + setInviteLinkForm({ + email: '', + role: 'ROLE_USER', + teamId: undefined, + expiryHours: 72, + sendEmail: false, + }); + }} size="md" zIndex={Z_INDEX_OVER_CONFIG_MODAL} centered @@ -472,7 +532,17 @@ export default function PeopleSection() { >
setInviteModalOpened(false)} + onClick={() => { + setInviteModalOpened(false); + setGeneratedInviteLink(null); + setInviteLinkForm({ + email: '', + role: 'ROLE_USER', + teamId: undefined, + expiryHours: 72, + sendEmail: false, + }); + }} size="lg" style={{ position: 'absolute', @@ -496,32 +566,36 @@ export default function PeopleSection() { {/* Mode Toggle */} - -
- setInviteMode(value as 'email' | 'direct')} - data={[ - { - label: t('workspace.people.inviteMode.username', 'Username'), - value: 'direct', - }, - { - label: t('workspace.people.inviteMode.email', 'Email'), - value: 'email', - disabled: !config?.enableEmailInvites, - }, - ]} - fullWidth - /> -
-
+ setInviteMode(value as 'email' | 'direct' | 'link')} + data={[ + { + label: t('workspace.people.directInvite.tab', 'Direct Create'), + value: 'direct', + }, + { + label: ( + + {t('workspace.people.emailInvite.tab', 'Email Invite')} + + ), + value: 'email', + disabled: !config?.enableEmailInvites, + }, + { + label: t('workspace.people.inviteLinkTab.tab', 'Invite Link'), + value: 'link', + }, + ]} + fullWidth + /> {/* Email Mode */} {inviteMode === 'email' && config?.enableEmailInvites && ( @@ -589,7 +663,7 @@ export default function PeopleSection() { clearable comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }} /> - setInviteForm({ ...inviteForm, forceChange: e.currentTarget.checked })} @@ -597,16 +671,147 @@ export default function PeopleSection() { )} + {/* Invite Link Mode */} + {inviteMode === 'link' && ( + <> + + {t('workspace.people.inviteLink.description', 'Generate a secure link that allows the user to set their own password')} + + + { + const newEmail = e.currentTarget.value; + const hasEmail = newEmail.trim().length > 0; + const smtpEnabled = config?.enableEmailInvites || false; + + setInviteLinkForm({ + ...inviteLinkForm, + email: newEmail, + // Auto-enable sendEmail when email is provided and SMTP is configured + // Disable when email is cleared + sendEmail: hasEmail && smtpEnabled ? true : false, + }); + }} + /> + + setInviteLinkForm({ ...inviteLinkForm, teamId: value ? parseInt(value) : undefined })} + clearable + comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }} + /> + + setInviteLinkForm({ ...inviteLinkForm, expiryHours: parseInt(e.currentTarget.value) || 72 })} + min={1} + max={720} + /> + + {inviteLinkForm.email.trim() && ( + +
+ setInviteLinkForm({ ...inviteLinkForm, sendEmail: e.currentTarget.checked })} + /> +
+
+ )} + + {generatedInviteLink && ( + + + + {t('workspace.people.inviteLink.generated', 'Invite Link Generated')} + + + + { + try { + // Try modern clipboard API first (requires HTTPS or localhost) + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(generatedInviteLink); + alert({ alertType: 'success', title: t('workspace.people.inviteLink.copied', 'Link copied to clipboard') }); + } else { + // Fallback for HTTP (non-secure contexts) + const textArea = document.createElement('textarea'); + textArea.value = generatedInviteLink; + textArea.style.position = 'fixed'; + textArea.style.left = '-9999px'; + document.body.appendChild(textArea); + textArea.select(); + const successful = document.execCommand('copy'); + document.body.removeChild(textArea); + + if (successful) { + alert({ alertType: 'success', title: t('workspace.people.inviteLink.copied', 'Link copied to clipboard') }); + } else { + throw new Error('Copy command failed'); + } + } + } catch (err) { + console.error('Failed to copy:', err); + alert({ alertType: 'error', title: 'Failed to copy to clipboard' }); + } + }} + > + + + + + + )} + + )} + {/* Action Button */} diff --git a/frontend/src/components/shared/config/useRestartServer.ts b/frontend/src/components/shared/config/useRestartServer.ts index b204f47ec..cf4245770 100644 --- a/frontend/src/components/shared/config/useRestartServer.ts +++ b/frontend/src/components/shared/config/useRestartServer.ts @@ -1,6 +1,7 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { alert } from '../../toast'; +import apiClient from '../../../services/apiClient'; export function useRestartServer() { const { t } = useTranslation(); @@ -27,18 +28,12 @@ export function useRestartServer() { ), }); - const response = await fetch('/api/v1/admin/settings/restart', { - method: 'POST', - }); + await apiClient.post('/api/v1/admin/settings/restart'); - if (response.ok) { - // Wait a moment then reload the page - setTimeout(() => { - window.location.reload(); - }, 3000); - } else { - throw new Error('Failed to restart'); - } + // Wait a moment then reload the page + setTimeout(() => { + window.location.reload(); + }, 3000); } catch (_error) { alert({ alertType: 'error', diff --git a/frontend/src/routes/InviteAccept.tsx b/frontend/src/routes/InviteAccept.tsx new file mode 100644 index 000000000..641ad4785 --- /dev/null +++ b/frontend/src/routes/InviteAccept.tsx @@ -0,0 +1,281 @@ +import { useState, useEffect } from 'react'; +import { useSearchParams, useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { useDocumentMeta } from '../hooks/useDocumentMeta'; +import AuthLayout from './authShared/AuthLayout'; +import LoginHeader from './login/LoginHeader'; +import ErrorMessage from './login/ErrorMessage'; +import { BASE_PATH } from '../constants/app'; +import apiClient from '../services/apiClient'; + +interface InviteData { + email: string | null; + role: string; + expiresAt: string; + emailRequired: boolean; +} + +export default function InviteAccept() { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const { t } = useTranslation(); + const token = searchParams.get('token'); + + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [inviteData, setInviteData] = useState(null); + const [error, setError] = useState(null); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + + const baseUrl = window.location.origin + BASE_PATH; + + // Set document meta + useDocumentMeta({ + title: `${t('invite.welcome', 'Welcome to Stirling PDF')} - Stirling PDF`, + description: t('app.description', 'The Free Adobe Acrobat alternative (10M+ Downloads)'), + ogTitle: `${t('invite.welcome', 'Welcome to Stirling PDF')} - 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}` + }); + + useEffect(() => { + if (!token) { + setError(t('invite.invalidToken', 'Invalid invitation link')); + setLoading(false); + return; + } + + validateToken(); + }, [token]); + + const validateToken = async () => { + try { + setLoading(true); + const response = await apiClient.get(`/api/v1/invite/validate/${token}`, { + suppressErrorToast: true, + } as any); + setInviteData(response.data); + setError(null); + } catch (err: any) { + const errorMessage = + err.response?.data?.error || + err.message || + t('invite.validationError', 'Failed to validate invitation link'); + setError(errorMessage); + } finally { + setLoading(false); + } + }; + + const handleAccept = async (e: React.FormEvent) => { + e.preventDefault(); + + // Validate email if required + if (inviteData?.emailRequired) { + if (!email || email.trim().length === 0) { + setError(t('invite.emailRequired', 'Email address is required')); + return; + } + if (!email.includes('@')) { + setError(t('invite.invalidEmail', 'Invalid email address')); + return; + } + } + + // Validate passwords + if (!password) { + setError(t('invite.passwordRequired', 'Password is required')); + return; + } + + if (password !== confirmPassword) { + setError(t('invite.passwordMismatch', 'Passwords do not match')); + return; + } + + try { + setSubmitting(true); + setError(null); + + const formData = new FormData(); + if (inviteData?.emailRequired) { + formData.append('email', email.trim().toLowerCase()); + } + formData.append('password', password); + + await apiClient.post(`/api/v1/invite/accept/${token}`, formData, { + suppressErrorToast: true, + } as any); + + // Success - redirect to login + navigate('/login?messageType=accountCreated'); + } catch (err: any) { + const errorMessage = + err.response?.data?.error || + err.message || + t('invite.acceptError', 'Failed to create account'); + setError(errorMessage); + } finally { + setSubmitting(false); + } + }; + + if (loading) { + return ( + + +
+
+
+
+ ); + } + + if (error && !inviteData) { + return ( + + + +
+ +
+
+ ); + } + + return ( + + + + {inviteData && !inviteData.emailRequired && ( +
+
+

+ {t('invite.accountFor', 'Creating account for')} +

+

+ {inviteData.email} +

+

+ {t('invite.linkExpires', 'Link expires')}: {new Date(inviteData.expiresAt).toLocaleDateString()} at {new Date(inviteData.expiresAt).toLocaleTimeString()} +

+
+
+ )} + + + +
+ {inviteData?.emailRequired && ( +
+ + setEmail(e.target.value)} + placeholder={t('invite.emailPlaceholder', 'Enter your email address')} + disabled={submitting} + required + className="auth-input" + autoComplete="email" + /> +
+ )} + +
+ + setPassword(e.target.value)} + placeholder={t('invite.passwordPlaceholder', 'Enter your password')} + disabled={submitting} + required + className="auth-input" + autoComplete="new-password" + /> +
+ +
+ + setConfirmPassword(e.target.value)} + placeholder={t('invite.confirmPasswordPlaceholder', 'Re-enter your password')} + disabled={submitting} + required + className="auth-input" + autoComplete="new-password" + /> +
+ +
+ +
+
+ +
+

+ {t('invite.alreadyHaveAccount', 'Already have an account?')}{' '} + +

+
+
+ ); +} diff --git a/frontend/src/routes/Login.tsx b/frontend/src/routes/Login.tsx index 61efb0c74..c8e0a6847 100644 --- a/frontend/src/routes/Login.tsx +++ b/frontend/src/routes/Login.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import { useNavigate } from 'react-router-dom' +import { useNavigate, useSearchParams } from 'react-router-dom' import { springAuth } from '../auth/springAuthClient' import { useAuth } from '../auth/UseSession' import { useTranslation } from 'react-i18next' @@ -17,26 +17,42 @@ import { BASE_PATH } from '../constants/app' export default function Login() { const navigate = useNavigate() + const [searchParams] = useSearchParams() const { session, loading } = useAuth() const { t } = useTranslation() const [isSigningIn, setIsSigningIn] = useState(false) const [error, setError] = useState(null) + const [successMessage, setSuccessMessage] = useState(null) const [showEmailForm, setShowEmailForm] = useState(false) const [email, setEmail] = useState('') const [password, setPassword] = useState('') - // Prefill email from query param (e.g. after password reset) + // Handle query params (email prefill and success messages) useEffect(() => { try { - const url = new URL(window.location.href) - const emailFromQuery = url.searchParams.get('email') + const emailFromQuery = searchParams.get('email') if (emailFromQuery) { setEmail(emailFromQuery) } + + const messageType = searchParams.get('messageType') + if (messageType) { + switch (messageType) { + case 'accountCreated': + setSuccessMessage(t('login.accountCreatedSuccess', 'Account created successfully! You can now sign in.')) + break + case 'passwordChanged': + setSuccessMessage(t('login.passwordChangedSuccess', 'Password changed successfully! Please sign in with your new password.')) + break + case 'credsUpdated': + setSuccessMessage(t('login.credentialsUpdated', 'Your credentials have been updated. Please sign in again.')) + break + } + } } catch (_) { // ignore } - }, []) + }, [searchParams, t]) const baseUrl = window.location.origin + BASE_PATH; @@ -121,6 +137,22 @@ export default function Login() { + {/* Success message */} + {successMessage && ( +
+

+ {successMessage} +

+
+ )} + {/* OAuth first */} diff --git a/frontend/src/routes/signup/SignupFormValidation.ts b/frontend/src/routes/signup/SignupFormValidation.ts index 7abe498a0..4b6dc6d22 100644 --- a/frontend/src/routes/signup/SignupFormValidation.ts +++ b/frontend/src/routes/signup/SignupFormValidation.ts @@ -40,8 +40,6 @@ export const useSignupFormValidation = () => { // 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 diff --git a/frontend/src/services/accountService.ts b/frontend/src/services/accountService.ts index e9a9f0a13..475c5a25b 100644 --- a/frontend/src/services/accountService.ts +++ b/frontend/src/services/accountService.ts @@ -23,7 +23,7 @@ export const accountService = { }, /** - * Change user password + * Change user password (for regular password changes) */ async changePassword(currentPassword: string, newPassword: string): Promise { const formData = new FormData(); @@ -31,4 +31,14 @@ export const accountService = { formData.append('newPassword', newPassword); await apiClient.post('/api/v1/user/change-password', formData); }, + + /** + * Change password on first login (clears the first-use flag) + */ + async changePasswordOnLogin(currentPassword: string, newPassword: string): Promise { + const formData = new FormData(); + formData.append('currentPassword', currentPassword); + formData.append('newPassword', newPassword); + await apiClient.post('/api/v1/user/change-password-on-login', formData); + }, }; diff --git a/frontend/src/services/userManagementService.ts b/frontend/src/services/userManagementService.ts index 4027acf2c..06818df05 100644 --- a/frontend/src/services/userManagementService.ts +++ b/frontend/src/services/userManagementService.ts @@ -62,6 +62,35 @@ export interface InviteUsersResponse { error?: string; } +export interface InviteLinkRequest { + email: string; + role: string; + teamId?: number; + expiryHours?: number; + sendEmail?: boolean; +} + +export interface InviteLinkResponse { + token: string; + inviteUrl: string; + email: string; + expiresAt: string; + expiryHours: number; + emailSent?: boolean; + emailError?: string; + error?: string; +} + +export interface InviteToken { + id: number; + email: string; + role: string; + teamId?: number; + createdBy: string; + createdAt: string; + expiresAt: string; +} + /** * User Management Service * Provides functions to interact with user management backend APIs @@ -163,4 +192,60 @@ export const userManagementService = { return response.data; }, + + /** + * Generate an invite link (admin only) + */ + async generateInviteLink(data: InviteLinkRequest): Promise { + const formData = new FormData(); + // Only append email if it's provided and not empty + if (data.email && data.email.trim()) { + formData.append('email', data.email); + } + formData.append('role', data.role); + if (data.teamId) { + formData.append('teamId', data.teamId.toString()); + } + if (data.expiryHours) { + formData.append('expiryHours', data.expiryHours.toString()); + } + if (data.sendEmail !== undefined) { + formData.append('sendEmail', data.sendEmail.toString()); + } + + const response = await apiClient.post( + '/api/v1/invite/generate', + formData, + { + suppressErrorToast: true, + } as any + ); + + return response.data; + }, + + /** + * Get list of active invite links (admin only) + */ + async getInviteLinks(): Promise { + const response = await apiClient.get<{ invites: InviteToken[] }>('/api/v1/invite/list'); + return response.data.invites; + }, + + /** + * Revoke an invite link (admin only) + */ + async revokeInviteLink(inviteId: number): Promise { + await apiClient.delete(`/api/v1/invite/revoke/${inviteId}`, { + suppressErrorToast: true, + } as any); + }, + + /** + * Clean up expired invite links (admin only) + */ + async cleanupExpiredInvites(): Promise<{ deletedCount: number }> { + const response = await apiClient.post<{ deletedCount: number }>('/api/v1/invite/cleanup'); + return response.data; + }, }; diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 05d5da57b..4730d92cc 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -4,6 +4,7 @@ import react from '@vitejs/plugin-react-swc'; export default defineConfig({ plugins: [react()], server: { + host: true, // Listen on all addresses (0.0.0.0) - allows access from any domain/IP proxy: { '/api': { target: 'http://localhost:8080',