diff --git a/backend/pom.xml b/backend/pom.xml
index 65d6d02..d770b4c 100644
--- a/backend/pom.xml
+++ b/backend/pom.xml
@@ -52,6 +52,12 @@
spring-boot-starter-validation
+
+
+ org.springframework.boot
+ spring-boot-starter-mail
+
+
io.jsonwebtoken
diff --git a/backend/src/main/java/backend/fullstack/auth/AcceptInviteRequest.java b/backend/src/main/java/backend/fullstack/auth/AcceptInviteRequest.java
new file mode 100644
index 0000000..839b4ee
--- /dev/null
+++ b/backend/src/main/java/backend/fullstack/auth/AcceptInviteRequest.java
@@ -0,0 +1,30 @@
+package backend.fullstack.auth;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Pattern;
+import jakarta.validation.constraints.Size;
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * Request payload used when accepting an invite and setting initial password.
+ */
+@Getter
+@Setter
+@Schema(description = "Accept invite request payload")
+public class AcceptInviteRequest {
+
+ @NotBlank(message = "Invite token is required")
+ @Schema(description = "One-time invite token from email link")
+ private String token;
+
+ @NotBlank(message = "Password is required")
+ @Size(min = 8, max = 128, message = "Password must be between 8 and 128 characters")
+ @Pattern(
+ regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).+$",
+ message = "Password must contain at least one uppercase letter, one lowercase letter, and one number"
+ )
+ @Schema(description = "New account password", example = "P@ssw0rd1")
+ private String password;
+}
diff --git a/backend/src/main/java/backend/fullstack/auth/AuthController.java b/backend/src/main/java/backend/fullstack/auth/AuthController.java
index f71f9eb..7346643 100644
--- a/backend/src/main/java/backend/fullstack/auth/AuthController.java
+++ b/backend/src/main/java/backend/fullstack/auth/AuthController.java
@@ -15,6 +15,7 @@
import backend.fullstack.config.ApiResponse;
import backend.fullstack.config.JwtUtil;
+import backend.fullstack.auth.invite.UserInviteService;
import backend.fullstack.user.User;
import jakarta.validation.Valid;
@@ -29,15 +30,18 @@
public class AuthController {
private final AuthService authService;
+ private final UserInviteService userInviteService;
private final JwtUtil jwtUtil;
private final AuthenticationManager authenticationManager;
public AuthController(
AuthService authService,
+ UserInviteService userInviteService,
JwtUtil jwtUtil,
AuthenticationManager authenticationManager
) {
this.authService = authService;
+ this.userInviteService = userInviteService;
this.jwtUtil = jwtUtil;
this.authenticationManager = authenticationManager;
}
@@ -115,4 +119,19 @@ public ResponseEntity> logout() {
.header("Set-Cookie", cleanCookie.toString())
.body(ApiResponse.success("Logged out", null));
}
+
+ /**
+ * Accepts a one-time invite token and sets the initial account password.
+ */
+ @PostMapping("/invite/accept")
+ @Operation(summary = "Accept invite", description = "Sets initial password using one-time invite token")
+ @ApiResponses(value = {
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Invite accepted"),
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid request data"),
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Invalid or expired invite token")
+ })
+ public ResponseEntity> acceptInvite(@Valid @RequestBody AcceptInviteRequest request) {
+ userInviteService.acceptInvite(request.getToken(), request.getPassword());
+ return ResponseEntity.ok(ApiResponse.success("Password set successfully", null));
+ }
}
diff --git a/backend/src/main/java/backend/fullstack/auth/invite/InviteProperties.java b/backend/src/main/java/backend/fullstack/auth/invite/InviteProperties.java
new file mode 100644
index 0000000..eef370a
--- /dev/null
+++ b/backend/src/main/java/backend/fullstack/auth/invite/InviteProperties.java
@@ -0,0 +1,31 @@
+package backend.fullstack.auth.invite;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * Externalized settings used for invite email generation.
+ */
+@Configuration
+@ConfigurationProperties(prefix = "app.invite")
+public class InviteProperties {
+
+ private String frontendBaseUrl = "http://localhost:5173";
+ private String fromAddress = "no-reply@iksystem.local";
+
+ public String getFrontendBaseUrl() {
+ return frontendBaseUrl;
+ }
+
+ public void setFrontendBaseUrl(String frontendBaseUrl) {
+ this.frontendBaseUrl = frontendBaseUrl;
+ }
+
+ public String getFromAddress() {
+ return fromAddress;
+ }
+
+ public void setFromAddress(String fromAddress) {
+ this.fromAddress = fromAddress;
+ }
+}
diff --git a/backend/src/main/java/backend/fullstack/auth/invite/UserInviteService.java b/backend/src/main/java/backend/fullstack/auth/invite/UserInviteService.java
new file mode 100644
index 0000000..27a3179
--- /dev/null
+++ b/backend/src/main/java/backend/fullstack/auth/invite/UserInviteService.java
@@ -0,0 +1,142 @@
+package backend.fullstack.auth.invite;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.time.LocalDateTime;
+import java.util.Base64;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.mail.SimpleMailMessage;
+import org.springframework.mail.javamail.JavaMailSender;
+import org.springframework.security.access.AccessDeniedException;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import backend.fullstack.exceptions.ResourceNotFoundException;
+import backend.fullstack.user.User;
+import backend.fullstack.user.UserRepository;
+
+/**
+ * Handles one-time user invites and invite acceptance.
+ */
+@Service
+public class UserInviteService {
+
+ private static final Logger logger = LoggerFactory.getLogger(UserInviteService.class);
+ private static final int TOKEN_BYTES = 48;
+
+ private final UserInviteTokenRepository userInviteTokenRepository;
+ private final UserRepository userRepository;
+ private final PasswordEncoder passwordEncoder;
+ private final JavaMailSender mailSender;
+ private final InviteProperties inviteProperties;
+ private final SecureRandom secureRandom = new SecureRandom();
+
+ public UserInviteService(
+ UserInviteTokenRepository userInviteTokenRepository,
+ UserRepository userRepository,
+ PasswordEncoder passwordEncoder,
+ JavaMailSender mailSender,
+ InviteProperties inviteProperties
+ ) {
+ this.userInviteTokenRepository = userInviteTokenRepository;
+ this.userRepository = userRepository;
+ this.passwordEncoder = passwordEncoder;
+ this.mailSender = mailSender;
+ this.inviteProperties = inviteProperties;
+ }
+
+ @Transactional
+ public void createAndSendInvite(User user) {
+ userInviteTokenRepository.deleteByUserIdAndConsumedAtIsNull(user.getId());
+
+ String rawToken = generateToken();
+ String tokenHash = hashToken(rawToken);
+
+ UserInviteToken inviteToken = UserInviteToken.builder()
+ .userId(user.getId())
+ .tokenHash(tokenHash)
+ .expiresAt(LocalDateTime.now().plusHours(24))
+ .build();
+
+ userInviteTokenRepository.save(inviteToken);
+ sendInviteEmail(user, rawToken);
+ }
+
+ @Transactional
+ public void acceptInvite(String token, String password) {
+ String tokenHash = hashToken(token);
+ UserInviteToken inviteToken = userInviteTokenRepository.findByTokenHash(tokenHash)
+ .orElseThrow(() -> new AccessDeniedException("Invalid invite token"));
+
+ if (inviteToken.getConsumedAt() != null) {
+ throw new AccessDeniedException("Invite token has already been used");
+ }
+
+ if (inviteToken.getExpiresAt().isBefore(LocalDateTime.now())) {
+ throw new AccessDeniedException("Invite token has expired");
+ }
+
+ User user = userRepository.findById(inviteToken.getUserId())
+ .orElseThrow(() -> new ResourceNotFoundException("Invited user not found"));
+
+ user.setPasswordHash(passwordEncoder.encode(password));
+ user.setActive(true);
+ userRepository.save(user);
+
+ inviteToken.setConsumedAt(LocalDateTime.now());
+ userInviteTokenRepository.save(inviteToken);
+ }
+
+ private void sendInviteEmail(User user, String token) {
+ String base = trimTrailingSlash(inviteProperties.getFrontendBaseUrl());
+ String inviteLink = base + "/set-password?token=" + token;
+
+ SimpleMailMessage message = new SimpleMailMessage();
+ message.setTo(user.getEmail());
+ message.setFrom(inviteProperties.getFromAddress());
+ message.setSubject("Set up your account");
+ message.setText("Hi " + user.getFirstName() + ",\n\n"
+ + "Your account has been created. Use this link to set your password:\n"
+ + inviteLink + "\n\n"
+ + "This link expires in 24 hours and can only be used once.\n");
+
+ mailSender.send(message);
+ logger.info("Invite email sent to userId={} email={}", user.getId(), user.getEmail());
+ }
+
+ private String generateToken() {
+ byte[] bytes = new byte[TOKEN_BYTES];
+ secureRandom.nextBytes(bytes);
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
+ }
+
+ private String hashToken(String token) {
+ try {
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ byte[] hash = digest.digest(token.getBytes(StandardCharsets.UTF_8));
+ return toHex(hash);
+ } catch (NoSuchAlgorithmException ex) {
+ throw new IllegalStateException("SHA-256 not available", ex);
+ }
+ }
+
+ private String toHex(byte[] bytes) {
+ StringBuilder sb = new StringBuilder(bytes.length * 2);
+ for (byte b : bytes) {
+ sb.append(String.format("%02x", b));
+ }
+ return sb.toString();
+ }
+
+ private String trimTrailingSlash(String value) {
+ if (value == null || value.isBlank()) {
+ return "http://localhost:5173";
+ }
+ return value.endsWith("/") ? value.substring(0, value.length() - 1) : value;
+ }
+}
diff --git a/backend/src/main/java/backend/fullstack/auth/invite/UserInviteToken.java b/backend/src/main/java/backend/fullstack/auth/invite/UserInviteToken.java
new file mode 100644
index 0000000..9f5f897
--- /dev/null
+++ b/backend/src/main/java/backend/fullstack/auth/invite/UserInviteToken.java
@@ -0,0 +1,54 @@
+package backend.fullstack.auth.invite;
+
+import java.time.LocalDateTime;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+/**
+ * One-time invite token used to activate a user account and set initial password.
+ */
+@Entity
+@Table(name = "user_invite_tokens")
+@Getter
+@Setter
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class UserInviteToken {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(name = "user_id", nullable = false)
+ private Long userId;
+
+ @Column(name = "token_hash", nullable = false, unique = true, length = 64)
+ private String tokenHash;
+
+ @Column(name = "expires_at", nullable = false)
+ private LocalDateTime expiresAt;
+
+ @Column(name = "consumed_at")
+ private LocalDateTime consumedAt;
+
+ @Column(name = "created_at", nullable = false, updatable = false)
+ private LocalDateTime createdAt;
+
+ @jakarta.persistence.PrePersist
+ protected void onCreate() {
+ if (createdAt == null) {
+ createdAt = LocalDateTime.now();
+ }
+ }
+}
diff --git a/backend/src/main/java/backend/fullstack/auth/invite/UserInviteTokenRepository.java b/backend/src/main/java/backend/fullstack/auth/invite/UserInviteTokenRepository.java
new file mode 100644
index 0000000..89aeb96
--- /dev/null
+++ b/backend/src/main/java/backend/fullstack/auth/invite/UserInviteTokenRepository.java
@@ -0,0 +1,10 @@
+package backend.fullstack.auth.invite;
+
+import java.util.Optional;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface UserInviteTokenRepository extends JpaRepository {
+ Optional findByTokenHash(String tokenHash);
+ void deleteByUserIdAndConsumedAtIsNull(Long userId);
+}
diff --git a/backend/src/main/java/backend/fullstack/config/SecurityConfig.java b/backend/src/main/java/backend/fullstack/config/SecurityConfig.java
index 1c316ce..75d3a0a 100644
--- a/backend/src/main/java/backend/fullstack/config/SecurityConfig.java
+++ b/backend/src/main/java/backend/fullstack/config/SecurityConfig.java
@@ -57,7 +57,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
.authorizeHttpRequests(auth -> auth
.requestMatchers("/error").permitAll()
.requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll()
- .requestMatchers(HttpMethod.POST, "/api/auth/login", "/api/auth/logout").permitAll()
+ .requestMatchers(HttpMethod.POST, "/api/auth/login", "/api/auth/logout", "/api/auth/invite/accept").permitAll()
.requestMatchers(HttpMethod.POST, "/api/auth/register", "/api/organization")
.access(this::canAccessBootstrapSetup)
.anyRequest().authenticated()
diff --git a/backend/src/main/java/backend/fullstack/permission/PermissionBootstrapSeeder.java b/backend/src/main/java/backend/fullstack/permission/PermissionBootstrapSeeder.java
index 6cbac2c..99ba3dd 100644
--- a/backend/src/main/java/backend/fullstack/permission/PermissionBootstrapSeeder.java
+++ b/backend/src/main/java/backend/fullstack/permission/PermissionBootstrapSeeder.java
@@ -1,6 +1,5 @@
package backend.fullstack.permission;
-import java.util.List;
import java.util.Map;
import java.util.Set;
@@ -11,6 +10,12 @@
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
+import backend.fullstack.permission.catalog.DefaultRolePermissionMatrix;
+import backend.fullstack.permission.catalog.RolePermissionBinding;
+import backend.fullstack.permission.catalog.RolePermissionBindingRepository;
+import backend.fullstack.permission.definition.PermissionDefinition;
+import backend.fullstack.permission.definition.PermissionDefinitionRepository;
+import backend.fullstack.permission.model.Permission;
import backend.fullstack.user.role.Role;
/**
diff --git a/backend/src/main/java/backend/fullstack/permission/definition/PermissionBootstrapSeeder.java b/backend/src/main/java/backend/fullstack/permission/definition/PermissionBootstrapSeeder.java
deleted file mode 100644
index f33a8c4..0000000
--- a/backend/src/main/java/backend/fullstack/permission/definition/PermissionBootstrapSeeder.java
+++ /dev/null
@@ -1,80 +0,0 @@
-package backend.fullstack.permission.definition;
-
-import java.util.Map;
-import java.util.Set;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-import org.springframework.boot.ApplicationArguments;
-import org.springframework.boot.ApplicationRunner;
-import org.springframework.stereotype.Component;
-
-import backend.fullstack.permission.catalog.DefaultRolePermissionMatrix;
-import backend.fullstack.permission.catalog.RolePermissionBinding;
-import backend.fullstack.permission.catalog.RolePermissionBindingRepository;
-import backend.fullstack.permission.model.Permission;
-import backend.fullstack.user.role.Role;
-
-@Component
-public class PermissionBootstrapSeeder implements ApplicationRunner {
-
- private static final Logger logger = LoggerFactory.getLogger(PermissionBootstrapSeeder.class);
-
- private final PermissionDefinitionRepository permissionDefinitionRepository;
- private final RolePermissionBindingRepository rolePermissionBindingRepository;
-
- public PermissionBootstrapSeeder(
- PermissionDefinitionRepository permissionDefinitionRepository,
- RolePermissionBindingRepository rolePermissionBindingRepository
- ) {
- this.permissionDefinitionRepository = permissionDefinitionRepository;
- this.rolePermissionBindingRepository = rolePermissionBindingRepository;
- }
-
- @Override
- public void run(ApplicationArguments args) {
- try {
- seedPermissionDefinitions();
- seedRolePermissions();
- } catch (RuntimeException ex) {
- // Startup should not fail solely because permission seed cannot run.
- logger.warn("Permission bootstrap seed skipped due to repository/database issue", ex);
- }
- }
-
- private void seedPermissionDefinitions() {
- for (Permission permission : Permission.values()) {
- permissionDefinitionRepository.findByPermissionKey(permission.key())
- .orElseGet(() -> permissionDefinitionRepository.save(
- PermissionDefinition.builder()
- .permissionKey(permission.key())
- .description(permission.key())
- .build()
- ));
- }
- }
-
- private void seedRolePermissions() {
- Map> defaults = DefaultRolePermissionMatrix.create();
-
- for (Map.Entry> entry : defaults.entrySet()) {
- Role role = entry.getKey();
- if (rolePermissionBindingRepository.existsByRole(role)) {
- continue;
- }
-
- for (Permission permission : entry.getValue()) {
- PermissionDefinition definition = permissionDefinitionRepository
- .findByPermissionKey(permission.key())
- .orElseThrow(() -> new IllegalStateException("Permission definition missing: " + permission.key()));
-
- rolePermissionBindingRepository.save(
- RolePermissionBinding.builder()
- .role(role)
- .permission(definition)
- .build()
- );
- }
- }
- }
-}
diff --git a/backend/src/main/java/backend/fullstack/user/UserController.java b/backend/src/main/java/backend/fullstack/user/UserController.java
index 272c804..a752c7b 100644
--- a/backend/src/main/java/backend/fullstack/user/UserController.java
+++ b/backend/src/main/java/backend/fullstack/user/UserController.java
@@ -39,6 +39,7 @@
* - POST /api/users/me/change-password: Change current user password.
* - POST /api/users/{id}/deactivate: Deactivate user.
* - POST /api/users/{id}/reactivate: Reactivate user.
+ * - POST /api/users/{id}/resend-invite: Resend invite link email.
*
* @version 1.0
* @since 03.04.26
@@ -121,4 +122,11 @@ public ApiResponse reactivate(@PathVariable Long id) {
userService.reactivate(id);
return ApiResponse.success("User reactivated", null);
}
+
+ @PostMapping("/{id}/resend-invite")
+ @Operation(summary = "Resend invite")
+ public ApiResponse resendInvite(@PathVariable Long id) {
+ userService.resendInvite(id);
+ return ApiResponse.success("Invite resent", null);
+ }
}
diff --git a/backend/src/main/java/backend/fullstack/user/UserService.java b/backend/src/main/java/backend/fullstack/user/UserService.java
index 28f2023..3864bdc 100644
--- a/backend/src/main/java/backend/fullstack/user/UserService.java
+++ b/backend/src/main/java/backend/fullstack/user/UserService.java
@@ -14,6 +14,7 @@
import backend.fullstack.permission.profile.PermissionProfileRepository;
import backend.fullstack.permission.profile.UserProfileAssignment;
import backend.fullstack.permission.profile.UserProfileAssignmentRepository;
+import backend.fullstack.auth.invite.UserInviteService;
import backend.fullstack.user.dto.ChangePasswordRequest;
import backend.fullstack.user.dto.CreateUserRequest;
import backend.fullstack.user.dto.UpdateUserProfileRequest;
@@ -28,6 +29,7 @@
import java.time.LocalDateTime;
import java.util.List;
+import java.util.UUID;
/**
* Service for managing users within an organization.
@@ -52,6 +54,7 @@ public class UserService {
private final UserPermissionOverrideRepository userPermissionOverrideRepository;
private final UserLocationScopeAssignmentRepository userLocationScopeAssignmentRepository;
private final PasswordEncoder passwordEncoder;
+ private final UserInviteService userInviteService;
/**
* Returns all users belonging to the caller's organization.
@@ -101,7 +104,6 @@ public UserResponse getMyProfile() {
/**
* Creates a new user within the caller's organization.
- * The password is hashed with BCrypt before persisting.
* MANAGER and STAFF roles require a locationId in the request.
*
* @param request the user creation request
@@ -122,13 +124,16 @@ public UserResponse create(CreateUserRequest request) {
// 2. Build the entity from the DTO
User user = userMapper.toEntity(request);
user.setOrganization(accessContext.getCurrentUser().getOrganization());
- user.setPasswordHash(passwordEncoder.encode(request.getPassword()));
- user.setActive(true);
+ // Temporary random password hash; user sets real password via invite link.
+ user.setPasswordHash(passwordEncoder.encode(UUID.randomUUID().toString()));
+ user.setActive(false);
// 3. Resolve location based on role
assignLocationForRole(user, request.getRole(), request.getLocationId(), orgId);
- return toResponseWithLocations(userRepository.save(user));
+ User saved = userRepository.save(user);
+ userInviteService.createAndSendInvite(saved);
+ return toResponseWithLocations(saved);
}
@@ -290,6 +295,21 @@ public void reactivate(Long id) {
userRepository.save(user);
}
+ /**
+ * Resends invite email for a user in the current organization.
+ * Only ADMIN can perform this operation.
+ *
+ * @param id the target user id
+ */
+ @Transactional
+ public void resendInvite(Long id) {
+ accessContext.assertHasRole(Role.ADMIN);
+
+ User user = findInCurrentOrg(id);
+ authorizationService.assertCanManageUser(user);
+ userInviteService.createAndSendInvite(user);
+ }
+
@Transactional
public void assignProfiles(
Long id,
diff --git a/backend/src/main/java/backend/fullstack/user/dto/CreateUserRequest.java b/backend/src/main/java/backend/fullstack/user/dto/CreateUserRequest.java
index 0aaaf75..a3cf54f 100644
--- a/backend/src/main/java/backend/fullstack/user/dto/CreateUserRequest.java
+++ b/backend/src/main/java/backend/fullstack/user/dto/CreateUserRequest.java
@@ -5,7 +5,6 @@
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
-import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;
@@ -39,13 +38,4 @@ public class CreateUserRequest {
@Schema(description = "ID of the user's home location for STAFF/MANAGER", example = "1")
private Long locationId;
-
- @NotBlank(message = "Password is required")
- @Size(min = 8, message = "Password must be at least 8 characters")
- @Pattern(
- regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).+$",
- message = "Password must contain at least one uppercase letter, one lowercase letter, and one number"
- )
- @Schema(description = "User's password", example = "P@ssw0rd")
- private String password;
}
diff --git a/backend/src/main/resources/application-dev.properties b/backend/src/main/resources/application-dev.properties
index d041a2b..abd9463 100644
--- a/backend/src/main/resources/application-dev.properties
+++ b/backend/src/main/resources/application-dev.properties
@@ -1,2 +1,13 @@
# Flyway in dev: run normal schema migrations + dev-only seed migrations
spring.flyway.locations=classpath:db/migration,classpath:db/migration-dev
+
+# Invite sender for local/dev demo
+app.invite.from-address=noreply.iksys@gmail.com
+
+# Gmail SMTP for local/dev demo
+spring.mail.host=smtp.gmail.com
+spring.mail.port=587
+spring.mail.username=noreply.iksys@gmail.com
+spring.mail.password=sxxiiqtdxgkulkqy
+spring.mail.properties.mail.smtp.auth=true
+spring.mail.properties.mail.smtp.starttls.enable=true
diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties
index fa0afe9..81bd815 100644
--- a/backend/src/main/resources/application.properties
+++ b/backend/src/main/resources/application.properties
@@ -23,6 +23,18 @@ app.jwt.expiration-ms=28800000
app.jwt.cookie-name=jwt
app.jwt.cookie-secure=true
+# Invite onboarding settings
+app.invite.frontend-base-url=http://localhost:5173
+app.invite.from-address=no-reply@iksystem.local
+
+# Mail configuration (set in environment or override per profile)
+# spring.mail.host=smtp.example.com
+# spring.mail.port=587
+# spring.mail.username=your-username
+# spring.mail.password=your-password
+# spring.mail.properties.mail.smtp.auth=true
+# spring.mail.properties.mail.smtp.starttls.enable=true
+
# Flyway migrations
spring.flyway.enabled=true
spring.flyway.locations=classpath:db/migration
diff --git a/backend/src/main/resources/db/migration/V9__create_user_invite_tokens.sql b/backend/src/main/resources/db/migration/V9__create_user_invite_tokens.sql
new file mode 100644
index 0000000..00d7b11
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V9__create_user_invite_tokens.sql
@@ -0,0 +1,11 @@
+CREATE TABLE IF NOT EXISTS user_invite_tokens (
+ id BIGINT PRIMARY KEY AUTO_INCREMENT,
+ user_id BIGINT NOT NULL,
+ token_hash VARCHAR(64) NOT NULL,
+ expires_at TIMESTAMP NOT NULL,
+ consumed_at TIMESTAMP NULL,
+ created_at TIMESTAMP NOT NULL
+);
+
+CREATE UNIQUE INDEX uk_user_invite_tokens_token_hash ON user_invite_tokens(token_hash);
+CREATE INDEX idx_user_invite_tokens_user_id ON user_invite_tokens(user_id);
diff --git a/backend/src/test/java/backend/fullstack/auth/AuthControllerTest.java b/backend/src/test/java/backend/fullstack/auth/AuthControllerTest.java
index 5f19620..a7305fc 100644
--- a/backend/src/test/java/backend/fullstack/auth/AuthControllerTest.java
+++ b/backend/src/test/java/backend/fullstack/auth/AuthControllerTest.java
@@ -17,8 +17,10 @@
import com.fasterxml.jackson.databind.ObjectMapper;
+import backend.fullstack.config.GlobalExceptionHandler;
import backend.fullstack.config.JwtProperties;
import backend.fullstack.config.JwtUtil;
+import backend.fullstack.auth.invite.UserInviteService;
import backend.fullstack.organization.Organization;
import backend.fullstack.user.User;
import backend.fullstack.user.role.Role;
@@ -32,13 +34,16 @@ class AuthControllerTest {
void setUp() {
TestAuthService authService = new TestAuthService();
TestJwtUtil jwtUtil = new TestJwtUtil();
+ UserInviteService userInviteService = new TestUserInviteService();
AuthenticationManager authenticationManager = authentication -> {
User principal = buildUser(42L, "admin@everest.no", 10L);
return new UsernamePasswordAuthenticationToken(principal, null, principal.getAuthorities());
};
- AuthController controller = new AuthController(authService, jwtUtil, authenticationManager);
- mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
+ AuthController controller = new AuthController(authService, userInviteService, jwtUtil, authenticationManager);
+ mockMvc = MockMvcBuilders.standaloneSetup(controller)
+ .setControllerAdvice(new GlobalExceptionHandler())
+ .build();
objectMapper = new ObjectMapper();
}
@@ -88,6 +93,19 @@ void logoutClearsJwtCookie() throws Exception {
.andExpect(jsonPath("$.message").value("Logged out"));
}
+ @Test
+ void acceptInviteReturnsForbiddenForInvalidToken() throws Exception {
+ AcceptInviteRequest request = new AcceptInviteRequest();
+ request.setToken("invalid-token");
+ request.setPassword("Password1");
+
+ mockMvc.perform(post("/api/auth/invite/accept")
+ .contentType(APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isForbidden())
+ .andExpect(jsonPath("$.errorCode").value("ACCESS_DENIED"));
+ }
+
private static User buildUser(Long userId, String email, Long orgId) {
Organization organization = Organization.builder()
.id(orgId)
@@ -160,4 +178,18 @@ public ResponseCookie getCleanJwtCookie() {
.build();
}
}
+
+ private static final class TestUserInviteService extends UserInviteService {
+
+ private TestUserInviteService() {
+ super(null, null, null, null, null);
+ }
+
+ @Override
+ public void acceptInvite(String token, String password) {
+ if ("invalid-token".equals(token)) {
+ throw new org.springframework.security.access.AccessDeniedException("Invalid invite token");
+ }
+ }
+ }
}
diff --git a/backend/src/test/java/backend/fullstack/auth/invite/UserInviteServiceTest.java b/backend/src/test/java/backend/fullstack/auth/invite/UserInviteServiceTest.java
new file mode 100644
index 0000000..3c1face
--- /dev/null
+++ b/backend/src/test/java/backend/fullstack/auth/invite/UserInviteServiceTest.java
@@ -0,0 +1,140 @@
+package backend.fullstack.auth.invite;
+
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.time.LocalDateTime;
+import java.util.Optional;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.mail.javamail.JavaMailSender;
+import org.springframework.security.access.AccessDeniedException;
+import org.springframework.security.crypto.password.PasswordEncoder;
+
+import backend.fullstack.organization.Organization;
+import backend.fullstack.user.User;
+import backend.fullstack.user.UserRepository;
+import backend.fullstack.user.role.Role;
+
+@ExtendWith(MockitoExtension.class)
+class UserInviteServiceTest {
+
+ @Mock
+ private UserInviteTokenRepository userInviteTokenRepository;
+ @Mock
+ private UserRepository userRepository;
+ @Mock
+ private PasswordEncoder passwordEncoder;
+ @Mock
+ private JavaMailSender mailSender;
+
+ private UserInviteService userInviteService;
+
+ @BeforeEach
+ void setUp() {
+ InviteProperties inviteProperties = new InviteProperties();
+ inviteProperties.setFrontendBaseUrl("http://localhost:5173");
+ inviteProperties.setFromAddress("no-reply@test.local");
+
+ userInviteService = new UserInviteService(
+ userInviteTokenRepository,
+ userRepository,
+ passwordEncoder,
+ mailSender,
+ inviteProperties
+ );
+ }
+
+ @Test
+ void acceptInviteRejectsInvalidToken() {
+ when(userInviteTokenRepository.findByTokenHash(hash("invalid"))).thenReturn(Optional.empty());
+
+ assertThrows(AccessDeniedException.class, () -> userInviteService.acceptInvite("invalid", "Password1"));
+ verify(userRepository, never()).save(org.mockito.ArgumentMatchers.any());
+ }
+
+ @Test
+ void acceptInviteRejectsExpiredToken() {
+ UserInviteToken token = UserInviteToken.builder()
+ .id(1L)
+ .userId(7L)
+ .tokenHash(hash("expired-token"))
+ .expiresAt(LocalDateTime.now().minusMinutes(1))
+ .consumedAt(null)
+ .build();
+
+ when(userInviteTokenRepository.findByTokenHash(hash("expired-token"))).thenReturn(Optional.of(token));
+
+ assertThrows(AccessDeniedException.class, () -> userInviteService.acceptInvite("expired-token", "Password1"));
+ verify(userRepository, never()).save(org.mockito.ArgumentMatchers.any());
+ }
+
+ @Test
+ void acceptInviteRejectsReusedToken() {
+ UserInviteToken token = UserInviteToken.builder()
+ .id(1L)
+ .userId(7L)
+ .tokenHash(hash("used-token"))
+ .expiresAt(LocalDateTime.now().plusHours(1))
+ .consumedAt(LocalDateTime.now().minusMinutes(5))
+ .build();
+
+ when(userInviteTokenRepository.findByTokenHash(hash("used-token"))).thenReturn(Optional.of(token));
+
+ assertThrows(AccessDeniedException.class, () -> userInviteService.acceptInvite("used-token", "Password1"));
+ verify(userRepository, never()).save(org.mockito.ArgumentMatchers.any());
+ }
+
+ @Test
+ void acceptInviteConsumesTokenAndActivatesUser() {
+ UserInviteToken token = UserInviteToken.builder()
+ .id(1L)
+ .userId(7L)
+ .tokenHash(hash("valid-token"))
+ .expiresAt(LocalDateTime.now().plusHours(1))
+ .consumedAt(null)
+ .build();
+
+ User user = User.builder()
+ .id(7L)
+ .email("user@everest.no")
+ .firstName("User")
+ .lastName("Test")
+ .passwordHash("old")
+ .role(Role.STAFF)
+ .organization(Organization.builder().id(1L).name("Everest").organizationNumber("123456789").build())
+ .isActive(false)
+ .build();
+
+ when(userInviteTokenRepository.findByTokenHash(hash("valid-token"))).thenReturn(Optional.of(token));
+ when(userRepository.findById(7L)).thenReturn(Optional.of(user));
+ when(passwordEncoder.encode("Password1")).thenReturn("encoded");
+
+ userInviteService.acceptInvite("valid-token", "Password1");
+
+ verify(userRepository).save(user);
+ verify(userInviteTokenRepository).save(token);
+ }
+
+ private static String hash(String value) {
+ try {
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ byte[] hash = digest.digest(value.getBytes(StandardCharsets.UTF_8));
+ StringBuilder sb = new StringBuilder(hash.length * 2);
+ for (byte b : hash) {
+ sb.append(String.format("%02x", b));
+ }
+ return sb.toString();
+ } catch (Exception ex) {
+ throw new IllegalStateException(ex);
+ }
+ }
+}
diff --git a/backend/src/test/java/backend/fullstack/organization/OrganizationControllerTest.java b/backend/src/test/java/backend/fullstack/organization/OrganizationControllerTest.java
index ff07476..7a62d0d 100644
--- a/backend/src/test/java/backend/fullstack/organization/OrganizationControllerTest.java
+++ b/backend/src/test/java/backend/fullstack/organization/OrganizationControllerTest.java
@@ -1,15 +1,14 @@
package backend.fullstack.organization;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
import static org.springframework.http.MediaType.APPLICATION_JSON;
+import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
-
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -125,7 +124,7 @@ private static final class TestOrganizationService extends OrganizationService {
private Long lastId;
private TestOrganizationService() {
- super(null, null, null, null, null);
+ super(null, null, null, null);
}
@Override
diff --git a/backend/src/test/java/backend/fullstack/organization/OrganizationServiceTest.java b/backend/src/test/java/backend/fullstack/organization/OrganizationServiceTest.java
index 20aa98b..a7f36aa 100644
--- a/backend/src/test/java/backend/fullstack/organization/OrganizationServiceTest.java
+++ b/backend/src/test/java/backend/fullstack/organization/OrganizationServiceTest.java
@@ -1,18 +1,16 @@
package backend.fullstack.organization;
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertThrows;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.Mockito.never;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.when;
-
import java.util.Optional;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
+import static org.mockito.ArgumentMatchers.any;
import org.mockito.Mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.access.AccessDeniedException;
@@ -23,7 +21,6 @@
import backend.fullstack.permission.core.AuthorizationService;
import backend.fullstack.permission.model.Permission;
import backend.fullstack.user.AccessContextService;
-import backend.fullstack.user.UserRepository;
@ExtendWith(MockitoExtension.class)
class OrganizationServiceTest {
@@ -31,8 +28,6 @@ class OrganizationServiceTest {
@Mock
private OrganizationRepository organizationRepository;
@Mock
- private UserRepository userRepository;
- @Mock
private AccessContextService accessContext;
@Mock
private AuthorizationService authorizationService;
@@ -45,7 +40,6 @@ class OrganizationServiceTest {
void setUp() {
organizationService = new OrganizationService(
organizationRepository,
- userRepository,
accessContext,
authorizationService,
organizationMapper
@@ -58,24 +52,11 @@ void createRejectsDuplicateOrganizationNumber() {
request.setName("Everest");
request.setOrganizationNumber("937219997");
- when(userRepository.count()).thenReturn(0L);
when(organizationRepository.existsByOrganizationNumber("937219997")).thenReturn(true);
assertThrows(OrganizationConflictException.class, () -> organizationService.create(request));
}
- @Test
- void createRejectsOrganizationCreationAfterBootstrap() {
- OrganizationRequest request = new OrganizationRequest();
- request.setName("Everest");
- request.setOrganizationNumber("937219997");
-
- when(userRepository.count()).thenReturn(1L);
-
- assertThrows(AccessDeniedException.class, () -> organizationService.create(request));
- verify(organizationRepository, never()).save(any(Organization.class));
- }
-
@Test
void createSavesOrganizationAndMapsResponse() {
OrganizationRequest request = new OrganizationRequest();
@@ -98,7 +79,6 @@ void createSavesOrganizationAndMapsResponse() {
.locationCount(0)
.build();
- when(userRepository.count()).thenReturn(0L);
when(organizationRepository.existsByOrganizationNumber("937219997")).thenReturn(false);
when(organizationMapper.toEntity(request)).thenReturn(mapped);
when(organizationRepository.save(any(Organization.class))).thenReturn(saved);
@@ -135,8 +115,7 @@ void updateCurrentOrganizationSavesChangesBeforeMappingResponse() {
when(accessContext.getCurrentOrganizationId()).thenReturn(100L);
when(organizationRepository.findById(100L)).thenReturn(Optional.of(existing));
when(organizationRepository.existsByOrganizationNumberAndIdNot("987654321", 100L)).thenReturn(false);
- when(organizationRepository.save(existing)).thenReturn(saved);
- when(organizationMapper.toResponse(saved)).thenReturn(response);
+ when(organizationMapper.toResponse(existing)).thenReturn(response);
OrganizationResponse result = organizationService.updateCurrentOrganization(request);
@@ -144,7 +123,6 @@ void updateCurrentOrganizationSavesChangesBeforeMappingResponse() {
assertEquals("Updated Everest", existing.getName());
assertEquals("987654321", existing.getOrganizationNumber());
verify(authorizationService).assertPermission(Permission.ORGANIZATION_SETTINGS_UPDATE);
- verify(organizationRepository).save(existing);
}
@Test
diff --git a/backend/src/test/java/backend/fullstack/user/UserControllerTest.java b/backend/src/test/java/backend/fullstack/user/UserControllerTest.java
index f965897..96f38bd 100644
--- a/backend/src/test/java/backend/fullstack/user/UserControllerTest.java
+++ b/backend/src/test/java/backend/fullstack/user/UserControllerTest.java
@@ -171,6 +171,15 @@ void reactivateDelegatesToService() throws Exception {
assert userService.reactivatedId.equals(3L);
}
+ @Test
+ void resendInviteDelegatesToService() throws Exception {
+ mockMvc.perform(post("/api/users/3/resend-invite"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.message").value("Invite resent"));
+
+ assert userService.resendInviteId.equals(3L);
+ }
+
@Test
void createReturnsValidationErrorForMissingFields() throws Exception {
mockMvc.perform(post("/api/users")
@@ -181,8 +190,7 @@ void createReturnsValidationErrorForMissingFields() throws Exception {
.andExpect(jsonPath("$.fieldErrors.firstName").value("First name is required"))
.andExpect(jsonPath("$.fieldErrors.lastName").value("Last name is required"))
.andExpect(jsonPath("$.fieldErrors.email").value("Email is required"))
- .andExpect(jsonPath("$.fieldErrors.role").value("Role is required"))
- .andExpect(jsonPath("$.fieldErrors.password").value("Password is required"));
+ .andExpect(jsonPath("$.fieldErrors.role").value("Role is required"));
}
private static CreateUserRequest createRequest() {
@@ -192,7 +200,6 @@ private static CreateUserRequest createRequest() {
request.setEmail("staff@everest.no");
request.setRole(Role.STAFF);
request.setLocationId(1L);
- request.setPassword("Password1");
return request;
}
@@ -235,9 +242,10 @@ private static final class TestUserService extends UserService {
private Long lastId;
private Long deactivatedId;
private Long reactivatedId;
+ private Long resendInviteId;
private TestUserService() {
- super(null, null, null, null, null, null, null, null, null, null);
+ super(null, null, null, null, null, null, null, null, null, null, null);
}
@Override
@@ -295,5 +303,10 @@ public void deactivate(Long id) {
public void reactivate(Long id) {
reactivatedId = id;
}
+
+ @Override
+ public void resendInvite(Long id) {
+ resendInviteId = id;
+ }
}
}
diff --git a/backend/src/test/java/backend/fullstack/user/UserServicePermissionFlowTest.java b/backend/src/test/java/backend/fullstack/user/UserServicePermissionFlowTest.java
index b6d34f5..53511a1 100644
--- a/backend/src/test/java/backend/fullstack/user/UserServicePermissionFlowTest.java
+++ b/backend/src/test/java/backend/fullstack/user/UserServicePermissionFlowTest.java
@@ -40,6 +40,7 @@
import backend.fullstack.permission.profile.PermissionProfileRepository;
import backend.fullstack.permission.profile.UserProfileAssignment;
import backend.fullstack.permission.profile.UserProfileAssignmentRepository;
+import backend.fullstack.auth.invite.UserInviteService;
import backend.fullstack.user.dto.UserMapper;
import backend.fullstack.user.role.Role;
@@ -62,6 +63,8 @@ class UserServicePermissionFlowTest {
private UserLocationScopeAssignmentRepository userLocationScopeAssignmentRepository;
@Mock
private PasswordEncoder passwordEncoder;
+ @Mock
+ private UserInviteService userInviteService;
private AccessContextService accessContext;
private AuthorizationService authorizationService;
@@ -81,7 +84,8 @@ void setUp() {
userProfileAssignmentRepository,
userPermissionOverrideRepository,
userLocationScopeAssignmentRepository,
- passwordEncoder
+ passwordEncoder,
+ userInviteService
);
setAdminPrincipal();
@@ -218,6 +222,24 @@ void assignProfilesRequiresAdminRole() {
() -> userService.assignProfiles(50L, List.of(1L), null, null, null, true));
}
+ @Test
+ void resendInviteRequiresAdminRole() {
+ setPrincipal(Role.MANAGER);
+
+ assertThrows(AccessDeniedException.class, () -> userService.resendInvite(50L));
+ verify(userInviteService, never()).createAndSendInvite(any());
+ }
+
+ @Test
+ void resendInviteAsAdminDelegatesToInviteService() {
+ User target = targetUser();
+ when(userRepository.findById(50L)).thenReturn(Optional.of(target));
+
+ userService.resendInvite(50L);
+
+ verify(userInviteService).createAndSendInvite(target);
+ }
+
private static User targetUser() {
Organization organization = Organization.builder()
.id(100L)
diff --git a/mail.md b/mail.md
new file mode 100644
index 0000000..7d36afb
--- /dev/null
+++ b/mail.md
@@ -0,0 +1,45 @@
+# Mail Production Checklist
+
+Use this before going live with invite and notification email flows.
+
+## Security
+- Never hardcode SMTP passwords or API keys in source code.
+- Store secrets in environment variables or a secret manager.
+- Rotate credentials regularly (and immediately after demos if shared).
+- Use a dedicated sender account for system mail (not a personal mailbox).
+
+## Sender Domain and Deliverability
+- Send from a domain you control (for example: noreply@yourdomain.com).
+- Configure and verify SPF, DKIM, and DMARC for the sender domain.
+- Keep a stable From address and friendly sender name.
+- Warm up new sending domains/accounts gradually.
+
+## Provider and Setup
+- Prefer a transactional provider for production (SendGrid, Postmark, SES, Mailgun, etc.).
+- Keep separate credentials for dev, staging, and production.
+- Keep sandbox/test mode for non-production environments.
+- Set sane SMTP/API timeouts.
+
+## App Behavior
+- Treat email send as a recoverable operation (retry strategy/backoff).
+- Consider async sending (queue/outbox) to avoid blocking API requests.
+- Log message IDs and delivery attempts (without logging secrets).
+- Decide failure behavior clearly: rollback vs continue with warning.
+
+## Invite Flow Specific
+- One-time token only, short expiry (24h is fine).
+- Invalidate old pending invites on resend.
+- Add resend cooldown/rate limit to prevent abuse.
+- Add audit events: invite created, resent, accepted, expired.
+
+## Observability and Ops
+- Add alerts for high mail failure rate.
+- Track bounce, block, and complaint metrics.
+- Create a simple runbook for SMTP/provider outages.
+- Test full invite flow regularly in staging.
+
+## Compliance and Content
+- Keep email templates clear and minimal.
+- Avoid sensitive data in email body.
+- Include support contact information.
+- Ensure legal/compliance requirements are met for your region.