mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-12-18 20:04:17 +01:00
send email and invite link
This commit is contained in:
parent
0d6966de92
commit
31fda096ec
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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");
|
||||
})
|
||||
|
||||
@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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"));
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
};
|
||||
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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;\">"
|
||||
+ " © 2025 Stirling PDF. All rights reserved."
|
||||
+ " </div>"
|
||||
+ " </div>"
|
||||
+ "</div>"
|
||||
+ "</body></html>",
|
||||
inviteUrl, inviteUrl, expiresAt);
|
||||
|
||||
sendPlainEmail(to, subject, body, true);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 */}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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',
|
||||
|
||||
281
frontend/src/routes/InviteAccept.tsx
Normal file
281
frontend/src/routes/InviteAccept.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 */}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@ -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',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user