send email and invite link

This commit is contained in:
Anthony Stirling 2025-10-27 11:02:00 +00:00
parent 0d6966de92
commit 31fda096ec
21 changed files with 1393 additions and 76 deletions

View File

@ -364,6 +364,10 @@ public class ApplicationProperties {
private String fileUploadLimit;
private TempFileManagement tempFileManagement = new TempFileManagement();
private List<String> 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;

View File

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

View File

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

View File

@ -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> 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<InviteToken> 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<String, Object> 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<InviteToken> activeInvites =
inviteTokenRepository.findByUsedFalseAndExpiresAtAfter(LocalDateTime.now());
List<Map<String, Object>> inviteList =
activeInvites.stream()
.map(
invite -> {
Map<String, Object> 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<InviteToken> 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<InviteToken> 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<InviteToken> 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<String, Object> 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<InviteToken> 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()));
}
}
}

View File

@ -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"));

View File

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

View File

@ -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"
};

View File

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

View File

@ -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<InviteToken, Long> {
Optional<InviteToken> findByToken(String token);
Optional<InviteToken> findByEmail(String email);
List<InviteToken> findByUsedFalseAndExpiresAtAfter(LocalDateTime now);
List<InviteToken> 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);
}

View File

@ -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(
"<html><body style=\"margin: 0; padding: 0;\">"
+ "<div style=\"font-family: Arial, sans-serif; background-color: #f8f9fa; padding: 20px;\">"
+ " <div style=\"max-width: 600px; margin: auto; background-color: #ffffff; border-radius: 8px; overflow: hidden; border: 1px solid #e0e0e0;\">"
+ " <!-- Logo -->"
+ " <div style=\"text-align: center; padding: 20px; background-color: #222;\">"
+ " <img src=\"https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/main/docs/stirling-transparent.svg\" alt=\"Stirling PDF\" style=\"max-height: 60px;\">"
+ " </div>"
+ " <!-- Content -->"
+ " <div style=\"padding: 30px; color: #333;\">"
+ " <h2 style=\"color: #222; margin-top: 0;\">Welcome to Stirling PDF!</h2>"
+ " <p>Hi there,</p>"
+ " <p>You have been invited to join the Stirling PDF workspace. Click the button below to set up your account:</p>"
+ " <!-- CTA Button -->"
+ " <div style=\"text-align: center; margin: 30px 0;\">"
+ " <a href=\"%s\" style=\"display: inline-block; background-color: #007bff; color: #ffffff; padding: 14px 28px; text-decoration: none; border-radius: 5px; font-weight: bold;\">Accept Invitation</a>"
+ " </div>"
+ " <p style=\"font-size: 14px; color: #666;\">Or copy and paste this link in your browser:</p>"
+ " <div style=\"background-color: #f8f9fa; padding: 12px; margin: 15px 0; border-radius: 4px; word-break: break-all; font-size: 13px; color: #555;\">"
+ " %s"
+ " </div>"
+ " <div style=\"background-color: #fff3cd; border-left: 4px solid #ffc107; padding: 15px; margin: 20px 0; border-radius: 4px;\">"
+ " <p style=\"margin: 0; color: #856404; font-size: 14px;\"><strong>⚠️ Important:</strong> This invitation link will expire on %s. Please complete your registration before then.</p>"
+ " </div>"
+ " <p>If you didn't expect this invitation, you can safely ignore this email.</p>"
+ " <p style=\"margin-bottom: 0;\">— The Stirling PDF Team</p>"
+ " </div>"
+ " <!-- Footer -->"
+ " <div style=\"text-align: center; padding: 15px; font-size: 12px; color: #777; background-color: #f0f0f0;\">"
+ " &copy; 2025 Stirling PDF. All rights reserved."
+ " </div>"
+ " </div>"
+ "</div>"
+ "</body></html>",
inviteUrl, inviteUrl, expiresAt);
sendPlainEmail(to, subject, body, true);
}
}

View File

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

View File

@ -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 */}
<Route path="/login" element={<Login />} />
<Route path="/signup" element={<Signup />} />
<Route path="/invite" element={<InviteAccept />} />
<Route path="/auth/callback" element={<AuthCallback />} />
{/* Main app routes - wrapped with all providers */}

View File

@ -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 }:
<PasswordInput
label={t('firstLogin.newPassword', 'New Password')}
placeholder={t('firstLogin.enterNewPassword', 'Enter new password (min 8 characters)')}
placeholder={t('firstLogin.enterNewPassword', 'Enter new password')}
value={newPassword}
onChange={(e) => setNewPassword(e.currentTarget.value)}
required

View File

@ -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<User | null>(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<string | null>(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 */}
<Modal
opened={inviteModalOpened}
onClose={() => 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() {
>
<div style={{ position: 'relative' }}>
<CloseButton
onClick={() => 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() {
</Stack>
{/* Mode Toggle */}
<Tooltip
label={t('workspace.people.inviteMode.emailDisabled', 'Email invites require SMTP configuration and mail.enableInvites=true in settings')}
disabled={!!config?.enableEmailInvites}
position="bottom"
withArrow
zIndex={Z_INDEX_OVER_CONFIG_MODAL + 1}
>
<div>
<SegmentedControl
value={inviteMode}
onChange={(value) => 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
/>
</div>
</Tooltip>
<SegmentedControl
value={inviteMode}
onChange={(value) => setInviteMode(value as 'email' | 'direct' | 'link')}
data={[
{
label: t('workspace.people.directInvite.tab', 'Direct Create'),
value: 'direct',
},
{
label: (
<Tooltip
label={t('workspace.people.inviteMode.emailDisabled', 'Email invites require SMTP configuration and mail.enableInvites=true')}
disabled={!!config?.enableEmailInvites}
position="bottom"
withArrow
zIndex={Z_INDEX_OVER_CONFIG_MODAL + 1}
>
<span>{t('workspace.people.emailInvite.tab', 'Email Invite')}</span>
</Tooltip>
),
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 }}
/>
<Checkbox
<Switch
label={t('workspace.people.addMember.forcePasswordChange', 'Force password change on first login')}
checked={inviteForm.forceChange}
onChange={(e) => setInviteForm({ ...inviteForm, forceChange: e.currentTarget.checked })}
@ -597,16 +671,147 @@ export default function PeopleSection() {
</>
)}
{/* Invite Link Mode */}
{inviteMode === 'link' && (
<>
<Text size="sm" c="dimmed">
{t('workspace.people.inviteLink.description', 'Generate a secure link that allows the user to set their own password')}
</Text>
<TextInput
label={t('workspace.people.inviteLink.email', 'Email Address')}
placeholder="user@example.com"
description={t('workspace.people.inviteLink.emailOptional', 'Optional - leave blank for a general invite link')}
value={inviteLinkForm.email}
onChange={(e) => {
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,
});
}}
/>
<Select
label={t('workspace.people.addMember.role')}
data={roleOptions}
value={inviteLinkForm.role}
onChange={(value) => setInviteLinkForm({ ...inviteLinkForm, role: value || 'ROLE_USER' })}
renderOption={renderRoleOption}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<Select
label={t('workspace.people.addMember.team')}
placeholder={t('workspace.people.addMember.teamPlaceholder')}
data={teamOptions}
value={inviteLinkForm.teamId?.toString()}
onChange={(value) => setInviteLinkForm({ ...inviteLinkForm, teamId: value ? parseInt(value) : undefined })}
clearable
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<TextInput
type="number"
label={t('workspace.people.inviteLink.expiryHours', 'Expiry Hours')}
description={t('workspace.people.inviteLink.expiryDescription', 'How many hours until the link expires')}
value={inviteLinkForm.expiryHours}
onChange={(e) => setInviteLinkForm({ ...inviteLinkForm, expiryHours: parseInt(e.currentTarget.value) || 72 })}
min={1}
max={720}
/>
{inviteLinkForm.email.trim() && (
<Tooltip
label={t('workspace.people.inviteLink.smtpRequired', 'SMTP must be configured to send emails')}
disabled={!!config?.enableEmailInvites}
position="top-start"
withArrow
zIndex={Z_INDEX_OVER_CONFIG_MODAL + 1}
>
<div>
<Switch
label={t('workspace.people.inviteLink.sendEmail', 'Send invite link via email')}
description={!config?.enableEmailInvites ? t('workspace.people.inviteLink.smtpRequired', 'SMTP must be configured to send emails') : undefined}
checked={inviteLinkForm.sendEmail}
disabled={!config?.enableEmailInvites}
onChange={(e) => setInviteLinkForm({ ...inviteLinkForm, sendEmail: e.currentTarget.checked })}
/>
</div>
</Tooltip>
)}
{generatedInviteLink && (
<Paper p="md" withBorder style={{ backgroundColor: 'var(--mantine-color-gray-0)' }}>
<Stack gap="xs">
<Text size="sm" fw={600}>
{t('workspace.people.inviteLink.generated', 'Invite Link Generated')}
</Text>
<Group gap="xs">
<TextInput
value={generatedInviteLink}
readOnly
style={{ flex: 1 }}
/>
<ActionIcon
variant="subtle"
onClick={async () => {
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' });
}
}}
>
<LocalIcon icon="content-copy" />
</ActionIcon>
</Group>
</Stack>
</Paper>
)}
</>
)}
{/* Action Button */}
<Button
onClick={inviteMode === 'email' ? handleEmailInvite : handleInviteUser}
onClick={inviteMode === 'email' ? handleEmailInvite : inviteMode === 'link' ? handleGenerateInviteLink : handleInviteUser}
loading={processing}
fullWidth
size="md"
mt="md"
leftSection={inviteMode === 'link' ? <LocalIcon icon="link" /> : undefined}
>
{inviteMode === 'email'
? t('workspace.people.emailInvite.submit', 'Send Invites')
: inviteMode === 'link'
? t('workspace.people.inviteLink.generate', 'Generate Link')
: t('workspace.people.addMember.submit')}
</Button>
</Stack>

View File

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

View File

@ -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<InviteData | null>(null);
const [error, setError] = useState<string | null>(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<InviteData>(`/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 (
<AuthLayout>
<LoginHeader title={t('invite.validating', 'Validating invitation...')} />
<div style={{ textAlign: 'center', padding: '3rem 0' }}>
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
</div>
</AuthLayout>
);
}
if (error && !inviteData) {
return (
<AuthLayout>
<LoginHeader title={t('invite.invalidInvitation', 'Invalid Invitation')} />
<ErrorMessage error={error} />
<div className="auth-section">
<button
type="button"
onClick={() => navigate('/login')}
className="w-full px-4 py-[0.75rem] rounded-[0.625rem] text-base font-semibold cursor-pointer border-0 auth-cta-button"
>
{t('invite.goToLogin', 'Go to Login')}
</button>
</div>
</AuthLayout>
);
}
return (
<AuthLayout>
<LoginHeader
title={t('invite.welcomeTitle', "You've been invited!")}
subtitle={t('invite.welcomeSubtitle', 'Complete your account setup to get started')}
/>
{inviteData && !inviteData.emailRequired && (
<div style={{ marginBottom: '1.5rem' }}>
<div style={{
textAlign: 'center',
padding: '1.25rem',
backgroundColor: 'rgba(59, 130, 246, 0.08)',
borderRadius: '0.75rem',
border: '1px solid rgba(59, 130, 246, 0.2)'
}}>
<p style={{
fontSize: '0.8125rem',
textTransform: 'uppercase',
letterSpacing: '0.05em',
color: '#6b7280',
margin: '0 0 0.5rem 0',
fontWeight: 500
}}>
{t('invite.accountFor', 'Creating account for')}
</p>
<p style={{
fontSize: '1.125rem',
fontWeight: 600,
margin: '0 0 0.75rem 0',
color: '#1f2937'
}}>
{inviteData.email}
</p>
<p style={{
fontSize: '0.8125rem',
color: '#6b7280',
margin: 0
}}>
{t('invite.linkExpires', 'Link expires')}: {new Date(inviteData.expiresAt).toLocaleDateString()} at {new Date(inviteData.expiresAt).toLocaleTimeString()}
</p>
</div>
</div>
)}
<ErrorMessage error={error} />
<form onSubmit={handleAccept}>
{inviteData?.emailRequired && (
<div style={{ marginBottom: '1rem' }}>
<label htmlFor="email" className="auth-label">
{t('invite.email', 'Email address')}
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t('invite.emailPlaceholder', 'Enter your email address')}
disabled={submitting}
required
className="auth-input"
autoComplete="email"
/>
</div>
)}
<div style={{ marginBottom: '1rem' }}>
<label htmlFor="password" className="auth-label">
{t('invite.choosePassword', 'Choose a password')}
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t('invite.passwordPlaceholder', 'Enter your password')}
disabled={submitting}
required
className="auth-input"
autoComplete="new-password"
/>
</div>
<div style={{ marginBottom: '1.5rem' }}>
<label htmlFor="confirmPassword" className="auth-label">
{t('invite.confirmPassword', 'Confirm password')}
</label>
<input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder={t('invite.confirmPasswordPlaceholder', 'Re-enter your password')}
disabled={submitting}
required
className="auth-input"
autoComplete="new-password"
/>
</div>
<div className="auth-section">
<button
type="submit"
disabled={submitting}
className="w-full px-4 py-[0.75rem] rounded-[0.625rem] text-base font-semibold cursor-pointer border-0 disabled:opacity-50 disabled:cursor-not-allowed auth-cta-button"
>
{submitting ? t('invite.creating', 'Creating Account...') : t('invite.createAccount', 'Create Account')}
</button>
</div>
</form>
<div style={{ textAlign: 'center', margin: '1rem 0 0' }}>
<p style={{ color: '#6b7280', fontSize: '0.875rem', margin: 0 }}>
{t('invite.alreadyHaveAccount', 'Already have an account?')}{' '}
<button
type="button"
onClick={() => navigate('/login')}
className="auth-link-black"
>
{t('invite.signIn', 'Sign in')}
</button>
</p>
</div>
</AuthLayout>
);
}

View File

@ -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<string | null>(null)
const [successMessage, setSuccessMessage] = useState<string | null>(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() {
<AuthLayout>
<LoginHeader title={t('login.login') || 'Sign in'} />
{/* Success message */}
{successMessage && (
<div style={{
padding: '1rem',
marginBottom: '1rem',
backgroundColor: 'rgba(34, 197, 94, 0.1)',
border: '1px solid rgba(34, 197, 94, 0.3)',
borderRadius: '0.5rem',
color: '#16a34a'
}}>
<p style={{ margin: 0, fontSize: '0.875rem', textAlign: 'center' }}>
{successMessage}
</p>
</div>
)}
<ErrorMessage error={error} />
{/* OAuth first */}

View File

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

View File

@ -23,7 +23,7 @@ export const accountService = {
},
/**
* Change user password
* Change user password (for regular password changes)
*/
async changePassword(currentPassword: string, newPassword: string): Promise<void> {
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<void> {
const formData = new FormData();
formData.append('currentPassword', currentPassword);
formData.append('newPassword', newPassword);
await apiClient.post('/api/v1/user/change-password-on-login', formData);
},
};

View File

@ -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<InviteLinkResponse> {
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<InviteLinkResponse>(
'/api/v1/invite/generate',
formData,
{
suppressErrorToast: true,
} as any
);
return response.data;
},
/**
* Get list of active invite links (admin only)
*/
async getInviteLinks(): Promise<InviteToken[]> {
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<void> {
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;
},
};

View File

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