Merge pull request 'feat: invite-based self-service registration' (#273) from feat/issue-269-invite-registration into main
feat: invite-based self-service registration (#273) Closes #269
This commit was merged in pull request #273.
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -11,4 +11,5 @@ gitea/
|
||||
scripts/large-data.sql
|
||||
|
||||
.vitest-attachments
|
||||
**/test-results/
|
||||
**/test-results/
|
||||
.worktrees/
|
||||
|
||||
@@ -146,6 +146,12 @@
|
||||
<artifactId>flyway-database-postgresql</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Caffeine cache for in-memory rate limiting -->
|
||||
<dependency>
|
||||
<groupId>com.github.ben-manes.caffeine</groupId>
|
||||
<artifactId>caffeine</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- OpenAPI / Swagger UI — enabled only in the dev Spring profile -->
|
||||
<dependency>
|
||||
<groupId>org.springdoc</groupId>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package org.raddatz.familienarchiv.config;
|
||||
|
||||
import com.github.benmanes.caffeine.cache.Cache;
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
public class RateLimitInterceptor implements HandlerInterceptor {
|
||||
|
||||
private static final int MAX_REQUESTS_PER_MINUTE = 10;
|
||||
|
||||
// Caffeine cache: per-IP counter that expires 1 minute after first access.
|
||||
// Bounded to 10_000 entries to prevent OOM from IP exhaustion.
|
||||
private final Cache<String, AtomicInteger> requestCounts = Caffeine.newBuilder()
|
||||
.expireAfterAccess(1, TimeUnit.MINUTES)
|
||||
.maximumSize(10_000)
|
||||
.build();
|
||||
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
|
||||
throws Exception {
|
||||
String ip = resolveClientIp(request);
|
||||
AtomicInteger count = requestCounts.get(ip, k -> new AtomicInteger(0));
|
||||
if (count.incrementAndGet() > MAX_REQUESTS_PER_MINUTE) {
|
||||
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
|
||||
response.getWriter().write("{\"code\":\"RATE_LIMIT_EXCEEDED\",\"message\":\"Too many requests\"}");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private String resolveClientIp(HttpServletRequest request) {
|
||||
// Only trust X-Forwarded-For when the direct connection comes from a known
|
||||
// reverse proxy (loopback or Docker private network). Trusting it unconditionally
|
||||
// allows any client to spoof a different IP and bypass per-IP rate limiting.
|
||||
String remoteAddr = request.getRemoteAddr();
|
||||
if (isTrustedProxy(remoteAddr)) {
|
||||
String forwarded = request.getHeader("X-Forwarded-For");
|
||||
if (forwarded != null && !forwarded.isBlank()) {
|
||||
return forwarded.split(",")[0].trim();
|
||||
}
|
||||
}
|
||||
return remoteAddr;
|
||||
}
|
||||
|
||||
private boolean isTrustedProxy(String ip) {
|
||||
if (ip.equals("127.0.0.1") || ip.equals("::1") || ip.startsWith("10.") || ip.startsWith("192.168.")) {
|
||||
return true;
|
||||
}
|
||||
// Only RFC 1918 172.16.0.0/12 (172.16–172.31), not all of 172.x
|
||||
if (ip.startsWith("172.")) {
|
||||
String[] parts = ip.split("\\.");
|
||||
if (parts.length >= 2) {
|
||||
try {
|
||||
int second = Integer.parseInt(parts[1]);
|
||||
return second >= 16 && second <= 31;
|
||||
} catch (NumberFormatException ignored) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,8 @@ public class SecurityConfig {
|
||||
auth.requestMatchers("/actuator/health").permitAll();
|
||||
// Password reset endpoints are unauthenticated by nature
|
||||
auth.requestMatchers("/api/auth/forgot-password", "/api/auth/reset-password").permitAll();
|
||||
// Invite-based registration endpoints are public
|
||||
auth.requestMatchers("/api/auth/invite/**", "/api/auth/register").permitAll();
|
||||
// E2E test helper (only active under "e2e" profile)
|
||||
auth.requestMatchers("/api/auth/reset-token-for-test").permitAll();
|
||||
// In dev, allow unauthenticated access to the OpenAPI spec and Swagger UI
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package org.raddatz.familienarchiv.config;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
@Configuration
|
||||
public class WebConfig implements WebMvcConfigurer {
|
||||
|
||||
@Override
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
registry.addInterceptor(new RateLimitInterceptor())
|
||||
.addPathPatterns("/api/auth/invite/**", "/api/auth/register");
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,18 @@
|
||||
package org.raddatz.familienarchiv.controller;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import org.raddatz.familienarchiv.dto.ForgotPasswordRequest;
|
||||
import org.raddatz.familienarchiv.dto.InvitePrefillDTO;
|
||||
import org.raddatz.familienarchiv.dto.RegisterRequest;
|
||||
import org.raddatz.familienarchiv.dto.ResetPasswordRequest;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.model.InviteToken;
|
||||
import org.raddatz.familienarchiv.service.InviteService;
|
||||
import org.raddatz.familienarchiv.service.PasswordResetService;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
@@ -18,6 +22,7 @@ import lombok.RequiredArgsConstructor;
|
||||
public class AuthController {
|
||||
|
||||
private final PasswordResetService passwordResetService;
|
||||
private final InviteService inviteService;
|
||||
|
||||
@Value("${app.base-url:http://localhost:3000}")
|
||||
private String appBaseUrl;
|
||||
@@ -34,4 +39,20 @@ public class AuthController {
|
||||
passwordResetService.resetPassword(request);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@GetMapping("/invite/{code}")
|
||||
public InvitePrefillDTO getInvitePrefill(@PathVariable String code) {
|
||||
InviteToken token = inviteService.validateCode(code);
|
||||
return new InvitePrefillDTO(
|
||||
token.getPrefillFirstName(),
|
||||
token.getPrefillLastName(),
|
||||
token.getPrefillEmail()
|
||||
);
|
||||
}
|
||||
|
||||
@PostMapping("/register")
|
||||
public ResponseEntity<AppUser> register(@Valid @RequestBody RegisterRequest request) {
|
||||
AppUser user = inviteService.redeemInvite(request);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(user);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package org.raddatz.familienarchiv.controller;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.raddatz.familienarchiv.dto.CreateInviteRequest;
|
||||
import org.raddatz.familienarchiv.dto.InviteListItemDTO;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||
import org.raddatz.familienarchiv.service.InviteService;
|
||||
import org.raddatz.familienarchiv.service.UserService;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/invites")
|
||||
@RequiredArgsConstructor
|
||||
public class InviteController {
|
||||
|
||||
private final InviteService inviteService;
|
||||
private final UserService userService;
|
||||
|
||||
@Value("${app.base-url:http://localhost:3000}")
|
||||
private String appBaseUrl;
|
||||
|
||||
@GetMapping
|
||||
@RequirePermission(Permission.ADMIN_USER)
|
||||
public List<InviteListItemDTO> listInvites(
|
||||
@RequestParam(value = "status", defaultValue = "active") String status) {
|
||||
boolean activeOnly = !"all".equalsIgnoreCase(status);
|
||||
return inviteService.listInvites(activeOnly, appBaseUrl);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@RequirePermission(Permission.ADMIN_USER)
|
||||
public ResponseEntity<InviteListItemDTO> createInvite(
|
||||
@RequestBody CreateInviteRequest request,
|
||||
@AuthenticationPrincipal UserDetails principal) {
|
||||
AppUser creator = userService.findByEmail(principal.getUsername());
|
||||
InviteListItemDTO created = inviteService.toListItemDTO(
|
||||
inviteService.createInvite(request, creator), appBaseUrl);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(created);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@RequirePermission(Permission.ADMIN_USER)
|
||||
public ResponseEntity<Void> revokeInvite(@PathVariable UUID id) {
|
||||
inviteService.revokeInvite(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Data
|
||||
public class CreateInviteRequest {
|
||||
private String label;
|
||||
private Integer maxUses;
|
||||
private String prefillFirstName;
|
||||
private String prefillLastName;
|
||||
private String prefillEmail;
|
||||
private List<UUID> groupIds;
|
||||
private LocalDateTime expiresAt;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class InviteListItemDTO {
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private UUID id;
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String code;
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String displayCode;
|
||||
private String label;
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private int useCount;
|
||||
private Integer maxUses;
|
||||
private LocalDateTime expiresAt;
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private boolean revoked;
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String status;
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private LocalDateTime createdAt;
|
||||
private String shareableUrl;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class InvitePrefillDTO {
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String firstName;
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String lastName;
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String email;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class RegisterRequest {
|
||||
@NotBlank
|
||||
private String code;
|
||||
@NotBlank
|
||||
@Email
|
||||
private String email;
|
||||
@NotBlank
|
||||
private String password;
|
||||
private String firstName;
|
||||
private String lastName;
|
||||
}
|
||||
@@ -38,6 +38,16 @@ public enum ErrorCode {
|
||||
/** A mass import is already in progress; only one can run at a time. 409 */
|
||||
IMPORT_ALREADY_RUNNING,
|
||||
|
||||
// --- Invites ---
|
||||
/** The invite code does not exist. 404 */
|
||||
INVITE_NOT_FOUND,
|
||||
/** The invite has already reached its use limit. 409 */
|
||||
INVITE_EXHAUSTED,
|
||||
/** The invite has been revoked by an admin. 409 */
|
||||
INVITE_REVOKED,
|
||||
/** The invite has passed its expiry date. 410 */
|
||||
INVITE_EXPIRED,
|
||||
|
||||
// --- Auth ---
|
||||
/** The request is not authenticated. 401 */
|
||||
UNAUTHORIZED,
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package org.raddatz.familienarchiv.model;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "invite_tokens")
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class InviteToken {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private UUID id;
|
||||
|
||||
@Column(nullable = false, unique = true, length = 10)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String code;
|
||||
|
||||
private String label;
|
||||
|
||||
private Integer maxUses;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Builder.Default
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private int useCount = 0;
|
||||
|
||||
private String prefillFirstName;
|
||||
private String prefillLastName;
|
||||
private String prefillEmail;
|
||||
|
||||
@ElementCollection(fetch = FetchType.EAGER)
|
||||
@CollectionTable(name = "invite_token_group_ids", joinColumns = @JoinColumn(name = "invite_token_id"))
|
||||
@Column(name = "group_id")
|
||||
@Builder.Default
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Set<UUID> groupIds = new HashSet<>();
|
||||
|
||||
private LocalDateTime expiresAt;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "created_by", nullable = false)
|
||||
private AppUser createdBy;
|
||||
|
||||
@CreationTimestamp
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Builder.Default
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private boolean revoked = false;
|
||||
|
||||
public boolean isExhausted() {
|
||||
return maxUses != null && useCount >= maxUses;
|
||||
}
|
||||
|
||||
public boolean isExpired() {
|
||||
return expiresAt != null && expiresAt.isBefore(LocalDateTime.now());
|
||||
}
|
||||
|
||||
public boolean isActive() {
|
||||
return !revoked && !isExhausted() && !isExpired();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package org.raddatz.familienarchiv.repository;
|
||||
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.raddatz.familienarchiv.model.InviteToken;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface InviteTokenRepository extends JpaRepository<InviteToken, UUID> {
|
||||
|
||||
Optional<InviteToken> findByCode(String code);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("SELECT t FROM InviteToken t WHERE t.code = :code")
|
||||
Optional<InviteToken> findByCodeForUpdate(@Param("code") String code);
|
||||
|
||||
@Query("SELECT t FROM InviteToken t WHERE t.revoked = false AND (t.expiresAt IS NULL OR t.expiresAt > CURRENT_TIMESTAMP) AND (t.maxUses IS NULL OR t.useCount < t.maxUses) ORDER BY t.createdAt DESC")
|
||||
List<InviteToken> findActive();
|
||||
|
||||
@Query("SELECT t FROM InviteToken t ORDER BY t.createdAt DESC")
|
||||
List<InviteToken> findAllOrderedByCreatedAt();
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.raddatz.familienarchiv.dto.CreateInviteRequest;
|
||||
import org.raddatz.familienarchiv.dto.InviteListItemDTO;
|
||||
import org.raddatz.familienarchiv.dto.RegisterRequest;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.model.InviteToken;
|
||||
import org.raddatz.familienarchiv.model.UserGroup;
|
||||
import org.raddatz.familienarchiv.repository.InviteTokenRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.util.*;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class InviteService {
|
||||
|
||||
static final int MIN_PASSWORD_LENGTH = 8;
|
||||
private static final String CODE_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
private static final int CODE_LENGTH = 10;
|
||||
private static final int MAX_CODE_ATTEMPTS = 10;
|
||||
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
|
||||
|
||||
private final InviteTokenRepository inviteTokenRepository;
|
||||
private final UserService userService;
|
||||
|
||||
public String generateCode() {
|
||||
for (int attempt = 0; attempt < MAX_CODE_ATTEMPTS; attempt++) {
|
||||
String code = buildRandomCode();
|
||||
if (inviteTokenRepository.findByCode(code).isEmpty()) {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
throw DomainException.internal(ErrorCode.INTERNAL_ERROR, "Failed to generate unique invite code after " + MAX_CODE_ATTEMPTS + " attempts");
|
||||
}
|
||||
|
||||
public InviteToken validateCode(String code) {
|
||||
InviteToken token = inviteTokenRepository.findByCode(code)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.INVITE_NOT_FOUND, "Invite not found: " + code));
|
||||
checkTokenState(token);
|
||||
return token;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public InviteToken createInvite(CreateInviteRequest dto, AppUser creator) {
|
||||
Set<UUID> groupIds = new HashSet<>();
|
||||
if (dto.getGroupIds() != null && !dto.getGroupIds().isEmpty()) {
|
||||
List<UserGroup> groups = userService.findGroupsByIds(dto.getGroupIds());
|
||||
groups.forEach(g -> groupIds.add(g.getId()));
|
||||
}
|
||||
|
||||
InviteToken token = InviteToken.builder()
|
||||
.code(generateCode())
|
||||
.label(dto.getLabel())
|
||||
.maxUses(dto.getMaxUses())
|
||||
.prefillFirstName(dto.getPrefillFirstName())
|
||||
.prefillLastName(dto.getPrefillLastName())
|
||||
.prefillEmail(dto.getPrefillEmail())
|
||||
.groupIds(groupIds)
|
||||
.expiresAt(dto.getExpiresAt())
|
||||
.createdBy(creator)
|
||||
.build();
|
||||
|
||||
return inviteTokenRepository.save(token);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AppUser redeemInvite(RegisterRequest dto) {
|
||||
InviteToken token = inviteTokenRepository.findByCodeForUpdate(dto.getCode())
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.INVITE_NOT_FOUND, "Invite not found: " + dto.getCode()));
|
||||
|
||||
checkTokenState(token);
|
||||
|
||||
if (dto.getPassword() == null || dto.getPassword().length() < MIN_PASSWORD_LENGTH) {
|
||||
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
|
||||
"Password must be at least " + MIN_PASSWORD_LENGTH + " characters");
|
||||
}
|
||||
|
||||
AppUser user = userService.createUser(
|
||||
dto.getEmail(),
|
||||
dto.getPassword(),
|
||||
dto.getFirstName(),
|
||||
dto.getLastName(),
|
||||
token.getGroupIds()
|
||||
);
|
||||
|
||||
token.setUseCount(token.getUseCount() + 1);
|
||||
inviteTokenRepository.save(token);
|
||||
|
||||
log.info("User {} registered via invite code {}", dto.getEmail(), dto.getCode());
|
||||
return user;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void revokeInvite(UUID id) {
|
||||
InviteToken token = inviteTokenRepository.findById(id)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.INVITE_NOT_FOUND, "Invite not found: " + id));
|
||||
token.setRevoked(true);
|
||||
inviteTokenRepository.save(token);
|
||||
}
|
||||
|
||||
public List<InviteListItemDTO> listInvites(boolean activeOnly, String appBaseUrl) {
|
||||
List<InviteToken> tokens = activeOnly
|
||||
? inviteTokenRepository.findActive()
|
||||
: inviteTokenRepository.findAllOrderedByCreatedAt();
|
||||
return tokens.stream().map(t -> toListItemDTO(t, appBaseUrl)).toList();
|
||||
}
|
||||
|
||||
public InviteListItemDTO toListItemDTO(InviteToken token, String appBaseUrl) {
|
||||
String status;
|
||||
if (token.isRevoked()) status = "revoked";
|
||||
else if (token.isExpired()) status = "expired";
|
||||
else if (token.isExhausted()) status = "exhausted";
|
||||
else status = "active";
|
||||
|
||||
return InviteListItemDTO.builder()
|
||||
.id(token.getId())
|
||||
.code(token.getCode())
|
||||
.displayCode(formatDisplayCode(token.getCode()))
|
||||
.label(token.getLabel())
|
||||
.useCount(token.getUseCount())
|
||||
.maxUses(token.getMaxUses())
|
||||
.expiresAt(token.getExpiresAt())
|
||||
.revoked(token.isRevoked())
|
||||
.status(status)
|
||||
.createdAt(token.getCreatedAt())
|
||||
.shareableUrl(appBaseUrl + "/register?code=" + token.getCode())
|
||||
.build();
|
||||
}
|
||||
|
||||
private void checkTokenState(InviteToken token) {
|
||||
if (token.isRevoked()) {
|
||||
throw DomainException.conflict(ErrorCode.INVITE_REVOKED, "Invite has been revoked");
|
||||
}
|
||||
if (token.isExpired()) {
|
||||
throw new DomainException(ErrorCode.INVITE_EXPIRED, org.springframework.http.HttpStatus.GONE,
|
||||
"Invite has expired");
|
||||
}
|
||||
if (token.isExhausted()) {
|
||||
throw DomainException.conflict(ErrorCode.INVITE_EXHAUSTED, "Invite use limit reached");
|
||||
}
|
||||
}
|
||||
|
||||
private String buildRandomCode() {
|
||||
StringBuilder sb = new StringBuilder(CODE_LENGTH);
|
||||
for (int i = 0; i < CODE_LENGTH; i++) {
|
||||
sb.append(CODE_ALPHABET.charAt(SECURE_RANDOM.nextInt(CODE_ALPHABET.length())));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
public static String formatDisplayCode(String code) {
|
||||
if (code == null || code.length() != CODE_LENGTH) return code;
|
||||
return code.substring(0, 5) + "-" + code.substring(5);
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,33 @@ public class UserService {
|
||||
return userRepository.save(user);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AppUser createUser(String email, String rawPassword, String firstName, String lastName, Set<UUID> groupIds) {
|
||||
userRepository.findByEmail(email).ifPresent(existing -> {
|
||||
throw DomainException.conflict(ErrorCode.EMAIL_ALREADY_IN_USE, "Email already registered: " + email);
|
||||
});
|
||||
|
||||
Set<UserGroup> groups = new HashSet<>();
|
||||
if (groupIds != null && !groupIds.isEmpty()) {
|
||||
groups.addAll(groupRepository.findAllById(groupIds));
|
||||
}
|
||||
|
||||
AppUser user = AppUser.builder()
|
||||
.email(email)
|
||||
.password(passwordEncoder.encode(rawPassword))
|
||||
.firstName(firstName)
|
||||
.lastName(lastName)
|
||||
.groups(groups)
|
||||
.enabled(true)
|
||||
.build();
|
||||
|
||||
return userRepository.save(user);
|
||||
}
|
||||
|
||||
public List<UserGroup> findGroupsByIds(Collection<UUID> ids) {
|
||||
return groupRepository.findAllById(ids);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteUser(UUID userId) {
|
||||
AppUser user = userRepository.findById(userId)
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
CREATE TABLE invite_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code VARCHAR(10) UNIQUE NOT NULL,
|
||||
label VARCHAR(255),
|
||||
max_uses INTEGER,
|
||||
use_count INTEGER NOT NULL DEFAULT 0,
|
||||
prefill_first_name VARCHAR(255),
|
||||
prefill_last_name VARCHAR(255),
|
||||
prefill_email VARCHAR(255),
|
||||
expires_at TIMESTAMP,
|
||||
created_by UUID NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
revoked BOOLEAN NOT NULL DEFAULT FALSE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_invite_tokens_code ON invite_tokens(code);
|
||||
|
||||
CREATE TABLE invite_token_group_ids (
|
||||
invite_token_id UUID NOT NULL REFERENCES invite_tokens(id),
|
||||
group_id UUID NOT NULL REFERENCES user_groups(id),
|
||||
PRIMARY KEY (invite_token_id, group_id)
|
||||
);
|
||||
@@ -0,0 +1,88 @@
|
||||
package org.raddatz.familienarchiv.config;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
||||
import java.io.PrintWriter;
|
||||
import java.io.StringWriter;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class RateLimitInterceptorTest {
|
||||
|
||||
private RateLimitInterceptor interceptor;
|
||||
private HttpServletRequest request;
|
||||
private HttpServletResponse response;
|
||||
private PrintWriter writer;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
interceptor = new RateLimitInterceptor();
|
||||
request = mock(HttpServletRequest.class);
|
||||
response = mock(HttpServletResponse.class);
|
||||
writer = new PrintWriter(new StringWriter());
|
||||
when(response.getWriter()).thenReturn(writer);
|
||||
when(request.getRemoteAddr()).thenReturn("1.2.3.4");
|
||||
}
|
||||
|
||||
@Test
|
||||
void allows_requests_below_limit() throws Exception {
|
||||
for (int i = 0; i < 10; i++) {
|
||||
assertThat(interceptor.preHandle(request, response, null)).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void blocks_request_when_limit_exceeded() throws Exception {
|
||||
for (int i = 0; i < 10; i++) {
|
||||
interceptor.preHandle(request, response, null);
|
||||
}
|
||||
assertThat(interceptor.preHandle(request, response, null)).isFalse();
|
||||
verify(response).setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
|
||||
}
|
||||
|
||||
@Test
|
||||
void different_ips_have_independent_limits() throws Exception {
|
||||
HttpServletRequest other = mock(HttpServletRequest.class);
|
||||
when(other.getRemoteAddr()).thenReturn("9.9.9.9");
|
||||
|
||||
for (int i = 0; i < 10; i++) {
|
||||
interceptor.preHandle(request, response, null);
|
||||
}
|
||||
// first IP is at limit — second IP is not
|
||||
assertThat(interceptor.preHandle(other, response, null)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void ignores_x_forwarded_for_from_untrusted_remote_addr() throws Exception {
|
||||
when(request.getRemoteAddr()).thenReturn("5.5.5.5"); // not a trusted proxy
|
||||
when(request.getHeader("X-Forwarded-For")).thenReturn("99.99.99.99");
|
||||
|
||||
// exhaust the limit for the real remote IP (5.5.5.5), not the spoofed one
|
||||
for (int i = 0; i < 10; i++) {
|
||||
interceptor.preHandle(request, response, null);
|
||||
}
|
||||
assertThat(interceptor.preHandle(request, response, null)).isFalse();
|
||||
|
||||
// spoofed IP (99.99.99.99) should not have been rate-limited
|
||||
HttpServletRequest clean = mock(HttpServletRequest.class);
|
||||
when(clean.getRemoteAddr()).thenReturn("99.99.99.99");
|
||||
assertThat(interceptor.preHandle(clean, response, null)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void trusts_x_forwarded_for_from_localhost() throws Exception {
|
||||
when(request.getRemoteAddr()).thenReturn("127.0.0.1"); // trusted proxy
|
||||
when(request.getHeader("X-Forwarded-For")).thenReturn("20.20.20.20");
|
||||
|
||||
for (int i = 0; i < 10; i++) {
|
||||
interceptor.preHandle(request, response, null);
|
||||
}
|
||||
// the rate-limited IP should be 20.20.20.20 (from header), not 127.0.0.1
|
||||
assertThat(interceptor.preHandle(request, response, null)).isFalse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package org.raddatz.familienarchiv.controller;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.config.SecurityConfig;
|
||||
import org.raddatz.familienarchiv.dto.InvitePrefillDTO;
|
||||
import org.raddatz.familienarchiv.dto.RegisterRequest;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.model.InviteToken;
|
||||
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
||||
import org.raddatz.familienarchiv.service.InviteService;
|
||||
import org.raddatz.familienarchiv.service.PasswordResetService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.when;
|
||||
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.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@WebMvcTest(AuthController.class)
|
||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||
class AuthControllerTest {
|
||||
|
||||
@Autowired MockMvc mockMvc;
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@MockitoBean PasswordResetService passwordResetService;
|
||||
@MockitoBean InviteService inviteService;
|
||||
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
||||
|
||||
// ─── GET /api/auth/invite/{code} ──────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getInvitePrefill_returns200_withPrefillData_whenCodeValid() throws Exception {
|
||||
InviteToken token = InviteToken.builder()
|
||||
.code("ABCDE12345")
|
||||
.prefillFirstName("Helga")
|
||||
.prefillLastName("Muster")
|
||||
.prefillEmail("helga@muster.de")
|
||||
.build();
|
||||
when(inviteService.validateCode("ABCDE12345")).thenReturn(token);
|
||||
|
||||
mockMvc.perform(get("/api/auth/invite/ABCDE12345"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.firstName").value("Helga"))
|
||||
.andExpect(jsonPath("$.lastName").value("Muster"))
|
||||
.andExpect(jsonPath("$.email").value("helga@muster.de"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getInvitePrefill_returns404_whenCodeNotFound() throws Exception {
|
||||
when(inviteService.validateCode("UNKNOWN123"))
|
||||
.thenThrow(DomainException.notFound(ErrorCode.INVITE_NOT_FOUND, "not found"));
|
||||
|
||||
mockMvc.perform(get("/api/auth/invite/UNKNOWN123"))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getInvitePrefill_returns409_whenTokenRevoked() throws Exception {
|
||||
when(inviteService.validateCode("REVOKED123"))
|
||||
.thenThrow(DomainException.conflict(ErrorCode.INVITE_REVOKED, "revoked"));
|
||||
|
||||
mockMvc.perform(get("/api/auth/invite/REVOKED123"))
|
||||
.andExpect(status().isConflict());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getInvitePrefill_returns409_whenTokenExhausted() throws Exception {
|
||||
when(inviteService.validateCode("EXHAUST123"))
|
||||
.thenThrow(DomainException.conflict(ErrorCode.INVITE_EXHAUSTED, "exhausted"));
|
||||
|
||||
mockMvc.perform(get("/api/auth/invite/EXHAUST123"))
|
||||
.andExpect(status().isConflict());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getInvitePrefill_returns410_whenTokenExpired() throws Exception {
|
||||
when(inviteService.validateCode("EXPIRED123"))
|
||||
.thenThrow(new DomainException(ErrorCode.INVITE_EXPIRED,
|
||||
org.springframework.http.HttpStatus.GONE, "expired"));
|
||||
|
||||
mockMvc.perform(get("/api/auth/invite/EXPIRED123"))
|
||||
.andExpect(status().isGone());
|
||||
}
|
||||
|
||||
// ─── POST /api/auth/register ──────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void register_returns201_withCreatedUser_onHappyPath() throws Exception {
|
||||
AppUser user = AppUser.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.email("new@test.com")
|
||||
.firstName("Max")
|
||||
.lastName("Muster")
|
||||
.build();
|
||||
when(inviteService.redeemInvite(any())).thenReturn(user);
|
||||
|
||||
RegisterRequest req = new RegisterRequest();
|
||||
req.setCode("ABCDE12345");
|
||||
req.setEmail("new@test.com");
|
||||
req.setPassword("password123");
|
||||
req.setFirstName("Max");
|
||||
req.setLastName("Muster");
|
||||
|
||||
mockMvc.perform(post("/api/auth/register")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(req)))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.email").value("new@test.com"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void register_returns409_whenEmailAlreadyInUse() throws Exception {
|
||||
when(inviteService.redeemInvite(any()))
|
||||
.thenThrow(DomainException.conflict(ErrorCode.EMAIL_ALREADY_IN_USE, "already in use"));
|
||||
|
||||
RegisterRequest req = new RegisterRequest();
|
||||
req.setCode("ABCDE12345");
|
||||
req.setEmail("dupe@test.com");
|
||||
req.setPassword("password123");
|
||||
|
||||
mockMvc.perform(post("/api/auth/register")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(req)))
|
||||
.andExpect(status().isConflict());
|
||||
}
|
||||
|
||||
@Test
|
||||
void register_returns400_whenPasswordTooShort() throws Exception {
|
||||
when(inviteService.redeemInvite(any()))
|
||||
.thenThrow(DomainException.badRequest(ErrorCode.VALIDATION_ERROR, "too short"));
|
||||
|
||||
RegisterRequest req = new RegisterRequest();
|
||||
req.setCode("ABCDE12345");
|
||||
req.setEmail("new@test.com");
|
||||
req.setPassword("abc");
|
||||
|
||||
mockMvc.perform(post("/api/auth/register")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(req)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void register_returns404_whenInviteCodeNotFound() throws Exception {
|
||||
when(inviteService.redeemInvite(any()))
|
||||
.thenThrow(DomainException.notFound(ErrorCode.INVITE_NOT_FOUND, "not found"));
|
||||
|
||||
RegisterRequest req = new RegisterRequest();
|
||||
req.setCode("INVALID123");
|
||||
req.setEmail("new@test.com");
|
||||
req.setPassword("password123");
|
||||
|
||||
mockMvc.perform(post("/api/auth/register")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(req)))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
@Test
|
||||
void register_isPublic_noAuthRequired() throws Exception {
|
||||
AppUser user = AppUser.builder().id(UUID.randomUUID()).email("pub@test.com").build();
|
||||
when(inviteService.redeemInvite(any())).thenReturn(user);
|
||||
|
||||
RegisterRequest req = new RegisterRequest();
|
||||
req.setCode("ABCDE12345");
|
||||
req.setEmail("pub@test.com");
|
||||
req.setPassword("password123");
|
||||
|
||||
// No WithMockUser — must still succeed (no auth challenge)
|
||||
mockMvc.perform(post("/api/auth/register")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(req)))
|
||||
.andExpect(status().isCreated());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package org.raddatz.familienarchiv.controller;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.config.SecurityConfig;
|
||||
import org.raddatz.familienarchiv.dto.CreateInviteRequest;
|
||||
import org.raddatz.familienarchiv.dto.InviteListItemDTO;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.model.InviteToken;
|
||||
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
||||
import org.raddatz.familienarchiv.service.InviteService;
|
||||
import org.raddatz.familienarchiv.service.UserService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.test.context.support.WithMockUser;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@WebMvcTest(InviteController.class)
|
||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||
class InviteControllerTest {
|
||||
|
||||
@Autowired MockMvc mockMvc;
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@MockitoBean InviteService inviteService;
|
||||
@MockitoBean UserService userService;
|
||||
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
||||
|
||||
private InviteListItemDTO makeInviteDTO(UUID id, String code) {
|
||||
return InviteListItemDTO.builder()
|
||||
.id(id)
|
||||
.code(code)
|
||||
.displayCode(InviteService.formatDisplayCode(code))
|
||||
.useCount(0)
|
||||
.revoked(false)
|
||||
.status("active")
|
||||
.createdAt(LocalDateTime.now())
|
||||
.shareableUrl("http://localhost:3000/register?code=" + code)
|
||||
.build();
|
||||
}
|
||||
|
||||
// ─── GET /api/invites ─────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void listInvites_returns403_whenUserLacksAdminUserPermission() throws Exception {
|
||||
mockMvc.perform(get("/api/invites"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "user@test.com")
|
||||
void listInvites_returns403_whenAuthenticatedWithoutPermission() throws Exception {
|
||||
mockMvc.perform(get("/api/invites"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "admin@test.com", authorities = {"ADMIN_USER"})
|
||||
void listInvites_returns200_withActiveInvites_byDefault() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(inviteService.listInvites(eq(true), anyString()))
|
||||
.thenReturn(List.of(makeInviteDTO(id, "ABCDE12345")));
|
||||
|
||||
mockMvc.perform(get("/api/invites"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].code").value("ABCDE12345"))
|
||||
.andExpect(jsonPath("$[0].status").value("active"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "admin@test.com", authorities = {"ADMIN_USER"})
|
||||
void listInvites_returns200_withAllInvites_whenStatusAll() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
InviteListItemDTO revoked = makeInviteDTO(id, "REVOKED1234");
|
||||
when(inviteService.listInvites(eq(false), anyString()))
|
||||
.thenReturn(List.of(revoked));
|
||||
|
||||
mockMvc.perform(get("/api/invites").param("status", "all"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].code").value("REVOKED1234"));
|
||||
}
|
||||
|
||||
// ─── POST /api/invites ────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void createInvite_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(post("/api/invites")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{}"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "user@test.com")
|
||||
void createInvite_returns403_whenUserLacksAdminUserPermission() throws Exception {
|
||||
mockMvc.perform(post("/api/invites")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{}"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "admin@test.com", authorities = {"ADMIN_USER"})
|
||||
void createInvite_returns201_withCreatedInvite() throws Exception {
|
||||
AppUser admin = AppUser.builder().id(UUID.randomUUID()).email("admin@test.com").build();
|
||||
when(userService.findByEmail("admin@test.com")).thenReturn(admin);
|
||||
|
||||
InviteToken savedToken = InviteToken.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.code("NEWCODE123")
|
||||
.label("Für Familie")
|
||||
.maxUses(1)
|
||||
.useCount(0)
|
||||
.build();
|
||||
when(inviteService.createInvite(any(), eq(admin))).thenReturn(savedToken);
|
||||
|
||||
UUID id = savedToken.getId();
|
||||
InviteListItemDTO dto = makeInviteDTO(id, "NEWCODE123");
|
||||
dto.setLabel("Für Familie");
|
||||
when(inviteService.toListItemDTO(eq(savedToken), anyString())).thenReturn(dto);
|
||||
|
||||
CreateInviteRequest req = new CreateInviteRequest();
|
||||
req.setLabel("Für Familie");
|
||||
req.setMaxUses(1);
|
||||
|
||||
mockMvc.perform(post("/api/invites")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(req)))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.code").value("NEWCODE123"))
|
||||
.andExpect(jsonPath("$.label").value("Für Familie"));
|
||||
}
|
||||
|
||||
// ─── DELETE /api/invites/{id} ─────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void revokeInvite_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(delete("/api/invites/" + UUID.randomUUID()))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "user@test.com")
|
||||
void revokeInvite_returns403_whenUserLacksAdminUserPermission() throws Exception {
|
||||
mockMvc.perform(delete("/api/invites/" + UUID.randomUUID()))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "admin@test.com", authorities = {"ADMIN_USER"})
|
||||
void revokeInvite_returns204_whenSuccessful() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
|
||||
mockMvc.perform(delete("/api/invites/" + id))
|
||||
.andExpect(status().isNoContent());
|
||||
|
||||
verify(inviteService).revokeInvite(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.dto.CreateInviteRequest;
|
||||
import org.raddatz.familienarchiv.dto.RegisterRequest;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.model.InviteToken;
|
||||
import org.raddatz.familienarchiv.model.UserGroup;
|
||||
import org.raddatz.familienarchiv.repository.InviteTokenRepository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class InviteServiceTest {
|
||||
|
||||
@Mock InviteTokenRepository inviteTokenRepository;
|
||||
@Mock UserService userService;
|
||||
@InjectMocks InviteService inviteService;
|
||||
|
||||
private AppUser admin;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
admin = AppUser.builder().id(UUID.randomUUID()).email("admin@test.com").build();
|
||||
}
|
||||
|
||||
// ─── generateCode ────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void generateCode_produces10CharUppercaseAlphanumeric() {
|
||||
when(inviteTokenRepository.findByCode(anyString())).thenReturn(Optional.empty());
|
||||
String code = inviteService.generateCode();
|
||||
assertThat(code).hasSize(10).matches("[A-Z0-9]{10}");
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateCode_retriesOnCollision() {
|
||||
when(inviteTokenRepository.findByCode(anyString()))
|
||||
.thenReturn(Optional.of(InviteToken.builder().code("AAAAAAAAAA").build()))
|
||||
.thenReturn(Optional.empty());
|
||||
String code = inviteService.generateCode();
|
||||
assertThat(code).hasSize(10);
|
||||
verify(inviteTokenRepository, times(2)).findByCode(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateCode_throwsInternalError_afterMaxAttempts() {
|
||||
when(inviteTokenRepository.findByCode(anyString()))
|
||||
.thenReturn(Optional.of(InviteToken.builder().code("AAAAAAAAAA").build()));
|
||||
assertThatThrownBy(() -> inviteService.generateCode())
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting(e -> ((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.INTERNAL_ERROR);
|
||||
}
|
||||
|
||||
// ─── validateCode ─────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void validateCode_throwsNotFound_whenCodeUnknown() {
|
||||
when(inviteTokenRepository.findByCode("UNKNOWN123")).thenReturn(Optional.empty());
|
||||
assertThatThrownBy(() -> inviteService.validateCode("UNKNOWN123"))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting(e -> ((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.INVITE_NOT_FOUND);
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateCode_throwsRevoked_whenTokenIsRevoked() {
|
||||
InviteToken token = InviteToken.builder().code("ABCDE12345").revoked(true).build();
|
||||
when(inviteTokenRepository.findByCode("ABCDE12345")).thenReturn(Optional.of(token));
|
||||
assertThatThrownBy(() -> inviteService.validateCode("ABCDE12345"))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting(e -> ((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.INVITE_REVOKED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateCode_throwsExpired_whenTokenIsPastExpiryDate() {
|
||||
InviteToken token = InviteToken.builder().code("ABCDE12345")
|
||||
.expiresAt(LocalDateTime.now().minusHours(1))
|
||||
.build();
|
||||
when(inviteTokenRepository.findByCode("ABCDE12345")).thenReturn(Optional.of(token));
|
||||
assertThatThrownBy(() -> inviteService.validateCode("ABCDE12345"))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting(e -> ((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.INVITE_EXPIRED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateCode_throwsExhausted_whenUseCountMeetsMaxUses() {
|
||||
InviteToken token = InviteToken.builder().code("ABCDE12345").maxUses(1).useCount(1).build();
|
||||
when(inviteTokenRepository.findByCode("ABCDE12345")).thenReturn(Optional.of(token));
|
||||
assertThatThrownBy(() -> inviteService.validateCode("ABCDE12345"))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting(e -> ((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.INVITE_EXHAUSTED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateCode_returnsToken_whenValid() {
|
||||
InviteToken token = InviteToken.builder().code("ABCDE12345").build();
|
||||
when(inviteTokenRepository.findByCode("ABCDE12345")).thenReturn(Optional.of(token));
|
||||
InviteToken result = inviteService.validateCode("ABCDE12345");
|
||||
assertThat(result.getCode()).isEqualTo("ABCDE12345");
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateCode_acceptsUnlimitedInvite_afterManyUses() {
|
||||
InviteToken token = InviteToken.builder().code("ABCDE12345").maxUses(null).useCount(100).build();
|
||||
when(inviteTokenRepository.findByCode("ABCDE12345")).thenReturn(Optional.of(token));
|
||||
assertThatNoException().isThrownBy(() -> inviteService.validateCode("ABCDE12345"));
|
||||
}
|
||||
|
||||
// ─── createInvite ────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void createInvite_savesTokenWithGeneratedCode() {
|
||||
when(inviteTokenRepository.findByCode(anyString())).thenReturn(Optional.empty());
|
||||
when(inviteTokenRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
CreateInviteRequest req = new CreateInviteRequest();
|
||||
req.setLabel("Für Oma Helga");
|
||||
req.setMaxUses(1);
|
||||
|
||||
InviteToken result = inviteService.createInvite(req, admin);
|
||||
|
||||
assertThat(result.getLabel()).isEqualTo("Für Oma Helga");
|
||||
assertThat(result.getMaxUses()).isEqualTo(1);
|
||||
assertThat(result.getCode()).hasSize(10);
|
||||
assertThat(result.getCreatedBy()).isEqualTo(admin);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createInvite_assignsGroups_whenGroupIdsProvided() {
|
||||
UserGroup g = UserGroup.builder().id(UUID.randomUUID()).name("Familie").build();
|
||||
when(inviteTokenRepository.findByCode(anyString())).thenReturn(Optional.empty());
|
||||
when(userService.findGroupsByIds(anyList())).thenReturn(List.of(g));
|
||||
when(inviteTokenRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
CreateInviteRequest req = new CreateInviteRequest();
|
||||
req.setGroupIds(List.of(g.getId()));
|
||||
|
||||
InviteToken result = inviteService.createInvite(req, admin);
|
||||
assertThat(result.getGroupIds()).contains(g.getId());
|
||||
}
|
||||
|
||||
// ─── redeemInvite ─────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void redeemInvite_createsUserAndIncrementsUseCount() {
|
||||
InviteToken token = InviteToken.builder()
|
||||
.code("ABCDE12345").maxUses(2).useCount(0)
|
||||
.groupIds(new HashSet<>())
|
||||
.build();
|
||||
when(inviteTokenRepository.findByCodeForUpdate("ABCDE12345")).thenReturn(Optional.of(token));
|
||||
AppUser created = AppUser.builder().id(UUID.randomUUID()).email("new@test.com").build();
|
||||
when(userService.createUser(eq("new@test.com"), eq("password123"), eq("Max"), eq("Muster"), any()))
|
||||
.thenReturn(created);
|
||||
when(inviteTokenRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
RegisterRequest req = new RegisterRequest();
|
||||
req.setCode("ABCDE12345");
|
||||
req.setEmail("new@test.com");
|
||||
req.setPassword("password123");
|
||||
req.setFirstName("Max");
|
||||
req.setLastName("Muster");
|
||||
|
||||
AppUser result = inviteService.redeemInvite(req);
|
||||
|
||||
assertThat(result.getEmail()).isEqualTo("new@test.com");
|
||||
assertThat(token.getUseCount()).isEqualTo(1);
|
||||
verify(userService).createUser(eq("new@test.com"), eq("password123"), eq("Max"), eq("Muster"), any());
|
||||
verify(inviteTokenRepository).save(token);
|
||||
}
|
||||
|
||||
@Test
|
||||
void redeemInvite_throwsBadRequest_whenPasswordTooShort() {
|
||||
InviteToken token = InviteToken.builder().code("ABCDE12345").groupIds(new HashSet<>()).build();
|
||||
when(inviteTokenRepository.findByCodeForUpdate("ABCDE12345")).thenReturn(Optional.of(token));
|
||||
|
||||
RegisterRequest req = new RegisterRequest();
|
||||
req.setCode("ABCDE12345");
|
||||
req.setEmail("new@test.com");
|
||||
req.setPassword("short");
|
||||
|
||||
assertThatThrownBy(() -> inviteService.redeemInvite(req))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting(e -> ((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.VALIDATION_ERROR);
|
||||
}
|
||||
|
||||
@Test
|
||||
void redeemInvite_throwsConflict_whenEmailAlreadyInUse() {
|
||||
InviteToken token = InviteToken.builder().code("ABCDE12345").groupIds(new HashSet<>()).build();
|
||||
when(inviteTokenRepository.findByCodeForUpdate("ABCDE12345")).thenReturn(Optional.of(token));
|
||||
when(userService.createUser(eq("dupe@test.com"), any(), any(), any(), any()))
|
||||
.thenThrow(DomainException.conflict(ErrorCode.EMAIL_ALREADY_IN_USE, "Email already registered"));
|
||||
|
||||
RegisterRequest req = new RegisterRequest();
|
||||
req.setCode("ABCDE12345");
|
||||
req.setEmail("dupe@test.com");
|
||||
req.setPassword("password123");
|
||||
|
||||
assertThatThrownBy(() -> inviteService.redeemInvite(req))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting(e -> ((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.EMAIL_ALREADY_IN_USE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void redeemInvite_passesGroupIdsFromTokenToUserService() {
|
||||
UUID groupId = UUID.randomUUID();
|
||||
InviteToken token = InviteToken.builder()
|
||||
.code("ABCDE12345")
|
||||
.groupIds(new HashSet<>(Set.of(groupId)))
|
||||
.build();
|
||||
when(inviteTokenRepository.findByCodeForUpdate("ABCDE12345")).thenReturn(Optional.of(token));
|
||||
AppUser created = AppUser.builder().id(UUID.randomUUID()).email("new@test.com").build();
|
||||
when(userService.createUser(any(), any(), any(), any(), eq(Set.of(groupId)))).thenReturn(created);
|
||||
when(inviteTokenRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
RegisterRequest req = new RegisterRequest();
|
||||
req.setCode("ABCDE12345");
|
||||
req.setEmail("new@test.com");
|
||||
req.setPassword("password123");
|
||||
|
||||
inviteService.redeemInvite(req);
|
||||
|
||||
verify(userService).createUser(any(), any(), any(), any(), eq(Set.of(groupId)));
|
||||
}
|
||||
|
||||
// ─── revokeInvite ─────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void revokeInvite_setsRevokedTrue() {
|
||||
UUID id = UUID.randomUUID();
|
||||
InviteToken token = InviteToken.builder().id(id).code("ABCDE12345").build();
|
||||
when(inviteTokenRepository.findById(id)).thenReturn(Optional.of(token));
|
||||
when(inviteTokenRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
inviteService.revokeInvite(id);
|
||||
|
||||
assertThat(token.isRevoked()).isTrue();
|
||||
verify(inviteTokenRepository).save(token);
|
||||
}
|
||||
|
||||
@Test
|
||||
void revokeInvite_throwsNotFound_whenIdUnknown() {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(inviteTokenRepository.findById(id)).thenReturn(Optional.empty());
|
||||
assertThatThrownBy(() -> inviteService.revokeInvite(id))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting(e -> ((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.INVITE_NOT_FOUND);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import org.raddatz.familienarchiv.dto.ChangePasswordDTO;
|
||||
import org.raddatz.familienarchiv.dto.CreateUserRequest;
|
||||
import org.raddatz.familienarchiv.dto.UpdateProfileDTO;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.model.UserGroup;
|
||||
import org.raddatz.familienarchiv.repository.AppUserRepository;
|
||||
@@ -644,6 +645,60 @@ class UserServiceTest {
|
||||
verify(groupRepository, never()).findAllById(any());
|
||||
}
|
||||
|
||||
// ─── createUser ───────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void createUser_savesNewUser_withEncodedPassword() {
|
||||
when(userRepository.findByEmail("new@example.com")).thenReturn(Optional.empty());
|
||||
when(passwordEncoder.encode("secret")).thenReturn("hashed");
|
||||
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("new@example.com").build();
|
||||
when(userRepository.save(any())).thenReturn(saved);
|
||||
|
||||
AppUser result = userService.createUser("new@example.com", "secret", "Max", "Muster", Set.of());
|
||||
|
||||
assertThat(result).isEqualTo(saved);
|
||||
verify(passwordEncoder).encode("secret");
|
||||
verify(userRepository).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createUser_throwsConflict_whenEmailAlreadyExists() {
|
||||
AppUser existing = AppUser.builder().id(UUID.randomUUID()).email("dupe@example.com").build();
|
||||
when(userRepository.findByEmail("dupe@example.com")).thenReturn(Optional.of(existing));
|
||||
|
||||
assertThatThrownBy(() -> userService.createUser("dupe@example.com", "pass", null, null, Set.of()))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting(e -> ((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.EMAIL_ALREADY_IN_USE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createUser_assignsGroupsFromIds() {
|
||||
UUID groupId = UUID.randomUUID();
|
||||
UserGroup g = UserGroup.builder().id(groupId).name("Familie").build();
|
||||
when(userRepository.findByEmail("u@example.com")).thenReturn(Optional.empty());
|
||||
when(groupRepository.findAllById(Set.of(groupId))).thenReturn(List.of(g));
|
||||
when(passwordEncoder.encode(any())).thenReturn("hashed");
|
||||
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
AppUser result = userService.createUser("u@example.com", "pass", null, null, Set.of(groupId));
|
||||
|
||||
assertThat(result.getGroups()).contains(g);
|
||||
}
|
||||
|
||||
// ─── findGroupsByIds ───────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void findGroupsByIds_delegatesToRepository() {
|
||||
UUID id = UUID.randomUUID();
|
||||
UserGroup g = UserGroup.builder().id(id).name("Admins").build();
|
||||
when(groupRepository.findAllById(List.of(id))).thenReturn(List.of(g));
|
||||
|
||||
List<UserGroup> result = userService.findGroupsByIds(List.of(id));
|
||||
|
||||
assertThat(result).containsExactly(g);
|
||||
}
|
||||
|
||||
// ─── createGroup ──────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
|
||||
@@ -640,5 +640,53 @@
|
||||
"filter_operator_and": "UND",
|
||||
"filter_operator_or": "ODER",
|
||||
"filter_operator_and_label": "Alle gewählten Schlagworte müssen zutreffen (UND)",
|
||||
"filter_operator_or_label": "Mindestens ein Schlagwort muss zutreffen (ODER)"
|
||||
"filter_operator_or_label": "Mindestens ein Schlagwort muss zutreffen (ODER)",
|
||||
"error_invite_not_found": "Einladungslink nicht gefunden oder ungültig.",
|
||||
"error_invite_exhausted": "Dieser Einladungslink wurde bereits vollständig verwendet.",
|
||||
"error_invite_revoked": "Dieser Einladungslink wurde deaktiviert.",
|
||||
"error_invite_expired": "Dieser Einladungslink ist abgelaufen.",
|
||||
"register_heading": "Konto erstellen",
|
||||
"register_subtext": "Du wurdest eingeladen, dem Familienarchiv beizutreten.",
|
||||
"register_label_first_name": "Vorname",
|
||||
"register_label_last_name": "Nachname",
|
||||
"register_label_email": "E-Mail-Adresse",
|
||||
"register_label_password": "Passwort",
|
||||
"register_prefill_hint": "Von deiner Einladung übernommen – du kannst es ändern",
|
||||
"register_password_show": "Passwort anzeigen",
|
||||
"register_password_hide": "Passwort ausblenden",
|
||||
"register_btn_submit": "Konto erstellen",
|
||||
"register_invalid_code": "Ungültiger Einladungslink",
|
||||
"register_invalid_code_desc": "Dieser Einladungslink ist nicht gültig, wurde bereits verwendet oder ist abgelaufen. Bitte wende dich an den Administrator.",
|
||||
"register_success": "Dein Konto wurde erfolgreich erstellt. Du kannst dich jetzt anmelden.",
|
||||
"login_registered_success": "Dein Konto wurde erfolgreich erstellt. Melde dich jetzt an.",
|
||||
"admin_tab_invites": "Einladungen",
|
||||
"admin_invites_list_title": "Einladungen",
|
||||
"admin_invites_empty": "Keine aktiven Einladungen vorhanden.",
|
||||
"admin_btn_new_invite": "Neue Einladung",
|
||||
"admin_btn_show_all": "Alle anzeigen",
|
||||
"admin_btn_show_active": "Nur aktive",
|
||||
"admin_btn_revoke": "Widerrufen",
|
||||
"admin_btn_copy_link": "Link kopieren",
|
||||
"admin_btn_copied": "Kopiert!",
|
||||
"admin_invite_status_active": "Aktiv",
|
||||
"admin_invite_status_exhausted": "Erschöpft",
|
||||
"admin_invite_status_revoked": "Widerrufen",
|
||||
"admin_invite_status_expired": "Abgelaufen",
|
||||
"admin_invite_col_code": "Code",
|
||||
"admin_invite_col_label": "Bezeichnung",
|
||||
"admin_invite_col_uses": "Verwendungen",
|
||||
"admin_invite_col_expiry": "Ablauf",
|
||||
"admin_invite_col_status": "Status",
|
||||
"admin_invite_col_link": "Link",
|
||||
"admin_invite_unlimited": "∞",
|
||||
"admin_invite_no_expiry": "Kein Ablauf",
|
||||
"admin_new_invite_label": "Bezeichnung (z. B. für wen)",
|
||||
"admin_new_invite_max_uses": "Max. Verwendungen (leer = unbegrenzt)",
|
||||
"admin_new_invite_prefill_first": "Vorname vorausfüllen (optional)",
|
||||
"admin_new_invite_prefill_last": "Nachname vorausfüllen (optional)",
|
||||
"admin_new_invite_prefill_email": "E-Mail vorausfüllen (optional)",
|
||||
"admin_new_invite_expires": "Ablaufdatum (optional)",
|
||||
"admin_invite_created_title": "Einladung erstellt",
|
||||
"admin_invite_created_desc": "Teile diesen Link mit der einzuladenden Person:",
|
||||
"admin_invite_revoke_confirm": "Einladung wirklich widerrufen?"
|
||||
}
|
||||
|
||||
@@ -640,5 +640,53 @@
|
||||
"filter_operator_and": "AND",
|
||||
"filter_operator_or": "OR",
|
||||
"filter_operator_and_label": "All selected tags must match (AND)",
|
||||
"filter_operator_or_label": "At least one tag must match (OR)"
|
||||
"filter_operator_or_label": "At least one tag must match (OR)",
|
||||
"error_invite_not_found": "Invite link not found or invalid.",
|
||||
"error_invite_exhausted": "This invite link has already been fully used.",
|
||||
"error_invite_revoked": "This invite link has been deactivated.",
|
||||
"error_invite_expired": "This invite link has expired.",
|
||||
"register_heading": "Create account",
|
||||
"register_subtext": "You've been invited to join Familienarchiv.",
|
||||
"register_label_first_name": "First name",
|
||||
"register_label_last_name": "Last name",
|
||||
"register_label_email": "Email address",
|
||||
"register_label_password": "Password",
|
||||
"register_prefill_hint": "Pre-filled from your invite – you can change it",
|
||||
"register_password_show": "Show password",
|
||||
"register_password_hide": "Hide password",
|
||||
"register_btn_submit": "Create account",
|
||||
"register_invalid_code": "Invalid invite link",
|
||||
"register_invalid_code_desc": "This invite link is not valid, has already been used, or has expired. Please contact the administrator.",
|
||||
"register_success": "Your account has been created. You can now sign in.",
|
||||
"login_registered_success": "Your account was created successfully. Sign in now.",
|
||||
"admin_tab_invites": "Invites",
|
||||
"admin_invites_list_title": "Invites",
|
||||
"admin_invites_empty": "No active invites.",
|
||||
"admin_btn_new_invite": "New invite",
|
||||
"admin_btn_show_all": "Show all",
|
||||
"admin_btn_show_active": "Active only",
|
||||
"admin_btn_revoke": "Revoke",
|
||||
"admin_btn_copy_link": "Copy link",
|
||||
"admin_btn_copied": "Copied!",
|
||||
"admin_invite_status_active": "Active",
|
||||
"admin_invite_status_exhausted": "Exhausted",
|
||||
"admin_invite_status_revoked": "Revoked",
|
||||
"admin_invite_status_expired": "Expired",
|
||||
"admin_invite_col_code": "Code",
|
||||
"admin_invite_col_label": "Label",
|
||||
"admin_invite_col_uses": "Uses",
|
||||
"admin_invite_col_expiry": "Expiry",
|
||||
"admin_invite_col_status": "Status",
|
||||
"admin_invite_col_link": "Link",
|
||||
"admin_invite_unlimited": "∞",
|
||||
"admin_invite_no_expiry": "No expiry",
|
||||
"admin_new_invite_label": "Label (e.g. who it is for)",
|
||||
"admin_new_invite_max_uses": "Max uses (empty = unlimited)",
|
||||
"admin_new_invite_prefill_first": "Pre-fill first name (optional)",
|
||||
"admin_new_invite_prefill_last": "Pre-fill last name (optional)",
|
||||
"admin_new_invite_prefill_email": "Pre-fill email (optional)",
|
||||
"admin_new_invite_expires": "Expiry date (optional)",
|
||||
"admin_invite_created_title": "Invite created",
|
||||
"admin_invite_created_desc": "Share this link with the person you are inviting:",
|
||||
"admin_invite_revoke_confirm": "Really revoke this invite?"
|
||||
}
|
||||
|
||||
@@ -640,5 +640,53 @@
|
||||
"filter_operator_and": "Y",
|
||||
"filter_operator_or": "O",
|
||||
"filter_operator_and_label": "Todas las etiquetas seleccionadas deben coincidir (Y)",
|
||||
"filter_operator_or_label": "Al menos una etiqueta debe coincidir (O)"
|
||||
"filter_operator_or_label": "Al menos una etiqueta debe coincidir (O)",
|
||||
"error_invite_not_found": "Enlace de invitación no encontrado o inválido.",
|
||||
"error_invite_exhausted": "Este enlace de invitación ya ha sido completamente utilizado.",
|
||||
"error_invite_revoked": "Este enlace de invitación ha sido desactivado.",
|
||||
"error_invite_expired": "Este enlace de invitación ha expirado.",
|
||||
"register_heading": "Crear cuenta",
|
||||
"register_subtext": "Has sido invitado a unirte al Familienarchiv.",
|
||||
"register_label_first_name": "Nombre",
|
||||
"register_label_last_name": "Apellido",
|
||||
"register_label_email": "Correo electrónico",
|
||||
"register_label_password": "Contraseña",
|
||||
"register_prefill_hint": "Completado automáticamente desde tu invitación – puedes cambiarlo",
|
||||
"register_password_show": "Mostrar contraseña",
|
||||
"register_password_hide": "Ocultar contraseña",
|
||||
"register_btn_submit": "Crear cuenta",
|
||||
"register_invalid_code": "Enlace de invitación inválido",
|
||||
"register_invalid_code_desc": "Este enlace de invitación no es válido, ya ha sido utilizado o ha expirado. Contacta al administrador.",
|
||||
"register_success": "Tu cuenta ha sido creada. Ahora puedes iniciar sesión.",
|
||||
"login_registered_success": "Tu cuenta fue creada con éxito. Inicia sesión ahora.",
|
||||
"admin_tab_invites": "Invitaciones",
|
||||
"admin_invites_list_title": "Invitaciones",
|
||||
"admin_invites_empty": "No hay invitaciones activas.",
|
||||
"admin_btn_new_invite": "Nueva invitación",
|
||||
"admin_btn_show_all": "Mostrar todo",
|
||||
"admin_btn_show_active": "Solo activas",
|
||||
"admin_btn_revoke": "Revocar",
|
||||
"admin_btn_copy_link": "Copiar enlace",
|
||||
"admin_btn_copied": "¡Copiado!",
|
||||
"admin_invite_status_active": "Activa",
|
||||
"admin_invite_status_exhausted": "Agotada",
|
||||
"admin_invite_status_revoked": "Revocada",
|
||||
"admin_invite_status_expired": "Expirada",
|
||||
"admin_invite_col_code": "Código",
|
||||
"admin_invite_col_label": "Etiqueta",
|
||||
"admin_invite_col_uses": "Usos",
|
||||
"admin_invite_col_expiry": "Vencimiento",
|
||||
"admin_invite_col_status": "Estado",
|
||||
"admin_invite_col_link": "Enlace",
|
||||
"admin_invite_unlimited": "∞",
|
||||
"admin_invite_no_expiry": "Sin vencimiento",
|
||||
"admin_new_invite_label": "Etiqueta (p. ej. para quién)",
|
||||
"admin_new_invite_max_uses": "Usos máx. (vacío = ilimitado)",
|
||||
"admin_new_invite_prefill_first": "Prellenar nombre (opcional)",
|
||||
"admin_new_invite_prefill_last": "Prellenar apellido (opcional)",
|
||||
"admin_new_invite_prefill_email": "Prellenar correo (opcional)",
|
||||
"admin_new_invite_expires": "Fecha de vencimiento (opcional)",
|
||||
"admin_invite_created_title": "Invitación creada",
|
||||
"admin_invite_created_desc": "Comparte este enlace con la persona invitada:",
|
||||
"admin_invite_revoke_confirm": "¿Realmente revocar esta invitación?"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { env } from 'process';
|
||||
import { cookieName, cookieMaxAge } from '$lib/paraglide/runtime';
|
||||
import { detectLocale } from '$lib/server/locale';
|
||||
|
||||
const PUBLIC_PATHS = ['/login', '/logout', '/forgot-password', '/reset-password'];
|
||||
const PUBLIC_PATHS = ['/login', '/logout', '/forgot-password', '/reset-password', '/register'];
|
||||
|
||||
const handleLocaleDetection: Handle = ({ event, resolve }) => {
|
||||
if (!event.cookies.get(cookieName)) {
|
||||
@@ -72,7 +72,12 @@ export const handleFetch: HandleFetch = async ({ event, request, fetch }) => {
|
||||
}
|
||||
|
||||
// Password reset endpoints are public — no auth header needed.
|
||||
const PUBLIC_API_PATHS = ['/api/auth/forgot-password', '/api/auth/reset-password'];
|
||||
const PUBLIC_API_PATHS = [
|
||||
'/api/auth/forgot-password',
|
||||
'/api/auth/reset-password',
|
||||
'/api/auth/invite/',
|
||||
'/api/auth/register'
|
||||
];
|
||||
if (PUBLIC_API_PATHS.some((p) => request.url.includes(p))) {
|
||||
return fetch(request);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@ export type ErrorCode =
|
||||
| 'WRONG_CURRENT_PASSWORD'
|
||||
| 'IMPORT_ALREADY_RUNNING'
|
||||
| 'INVALID_RESET_TOKEN'
|
||||
| 'INVITE_NOT_FOUND'
|
||||
| 'INVITE_EXHAUSTED'
|
||||
| 'INVITE_REVOKED'
|
||||
| 'INVITE_EXPIRED'
|
||||
| 'ANNOTATION_NOT_FOUND'
|
||||
| 'ANNOTATION_UPDATE_FAILED'
|
||||
| 'TRANSCRIPTION_BLOCK_NOT_FOUND'
|
||||
@@ -87,6 +91,14 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
|
||||
return m.error_import_already_running();
|
||||
case 'INVALID_RESET_TOKEN':
|
||||
return m.error_invalid_reset_token();
|
||||
case 'INVITE_NOT_FOUND':
|
||||
return m.error_invite_not_found();
|
||||
case 'INVITE_EXHAUSTED':
|
||||
return m.error_invite_exhausted();
|
||||
case 'INVITE_REVOKED':
|
||||
return m.error_invite_revoked();
|
||||
case 'INVITE_EXPIRED':
|
||||
return m.error_invite_expired();
|
||||
case 'ANNOTATION_NOT_FOUND':
|
||||
return m.error_annotation_not_found();
|
||||
case 'ANNOTATION_UPDATE_FAILED':
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { createApiClient } from '$lib/api.server';
|
||||
import { getErrorMessage } from '$lib/errors';
|
||||
import type { components } from '$lib/generated/api';
|
||||
@@ -23,6 +24,7 @@ export async function load({ fetch, locals }) {
|
||||
if (!hasAnyAdminPerm(user)) throw error(403, getErrorMessage('FORBIDDEN'));
|
||||
|
||||
const api = createApiClient(fetch);
|
||||
const canManageUsers = hasPerm(user, 'ADMIN_USER');
|
||||
|
||||
// TODO: replace with a dedicated /api/admin/stats endpoint that returns counts only,
|
||||
// so the System page does not load full entity lists it does not render.
|
||||
@@ -45,11 +47,22 @@ export async function load({ fetch, locals }) {
|
||||
throw error(tagsResult.response.status, getErrorMessage(code));
|
||||
}
|
||||
|
||||
let inviteCount = 0;
|
||||
if (canManageUsers) {
|
||||
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||
const inviteRes = await fetch(`${apiUrl}/api/invites`);
|
||||
if (inviteRes.ok) {
|
||||
const invites = await inviteRes.json();
|
||||
inviteCount = Array.isArray(invites) ? invites.length : 0;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
userCount: (usersResult.data ?? []).length,
|
||||
groupCount: (groupsResult.data ?? []).length,
|
||||
tagCount: (tagsResult.data ?? []).length,
|
||||
canManageUsers: hasPerm(user, 'ADMIN_USER'),
|
||||
inviteCount,
|
||||
canManageUsers,
|
||||
canManageTags: hasPerm(user, 'ADMIN_TAG'),
|
||||
canManagePermissions: hasPerm(user, 'ADMIN_PERMISSION'),
|
||||
canRunMaintenance: hasPerm(user, 'ADMIN')
|
||||
|
||||
@@ -19,6 +19,7 @@ let { data, children } = $props();
|
||||
userCount={data.userCount}
|
||||
groupCount={data.groupCount}
|
||||
tagCount={data.tagCount}
|
||||
inviteCount={data.inviteCount}
|
||||
canManageUsers={data.canManageUsers}
|
||||
canManageTags={data.canManageTags}
|
||||
canManagePermissions={data.canManagePermissions}
|
||||
|
||||
@@ -46,6 +46,16 @@ onMount(() => {
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
{#if data.canManageUsers}
|
||||
<a href="/admin/invites" class="flex items-center justify-between px-4 py-4 hover:bg-muted">
|
||||
<div>
|
||||
<div class="font-sans text-sm font-bold text-ink">{m.admin_tab_invites()}</div>
|
||||
<div class="mt-0.5 font-sans text-xs text-ink-3">{data.inviteCount}</div>
|
||||
</div>
|
||||
<span class="text-ink-3">›</span>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
{#if data.canManageTags}
|
||||
<a href="/admin/tags" class="flex items-center justify-between px-4 py-4 hover:bg-muted">
|
||||
<div>
|
||||
|
||||
@@ -9,6 +9,7 @@ let {
|
||||
userCount,
|
||||
groupCount,
|
||||
tagCount,
|
||||
inviteCount,
|
||||
canManageUsers,
|
||||
canManageTags,
|
||||
canManagePermissions,
|
||||
@@ -17,6 +18,7 @@ let {
|
||||
userCount: number;
|
||||
groupCount: number;
|
||||
tagCount: number;
|
||||
inviteCount: number;
|
||||
canManageUsers: boolean;
|
||||
canManageTags: boolean;
|
||||
canManagePermissions: boolean;
|
||||
@@ -86,6 +88,23 @@ function handleKeydown(event: KeyboardEvent) {
|
||||
</svg>
|
||||
{/snippet}
|
||||
|
||||
{#snippet invitesIcon()}
|
||||
<svg
|
||||
class="h-5 w-5 flex-shrink-0 {isActive('invites') ? 'text-brand-mint' : 'text-white/40'}"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"
|
||||
/>
|
||||
</svg>
|
||||
{/snippet}
|
||||
|
||||
{#snippet tagsIcon()}
|
||||
<svg
|
||||
class="h-5 w-5 flex-shrink-0 {isActive('tags') ? 'text-brand-mint' : 'text-white/40'}"
|
||||
@@ -180,6 +199,18 @@ function handleKeydown(event: KeyboardEvent) {
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if canManageUsers}
|
||||
<EntityNavSection
|
||||
variant="sidebar"
|
||||
href="/admin/invites"
|
||||
label={m.admin_tab_invites()}
|
||||
isActive={isActive('invites')}
|
||||
count={inviteCount}
|
||||
onTabletTrigger={openFlyout}
|
||||
icon={invitesIcon}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if canManageTags}
|
||||
<EntityNavSection
|
||||
variant="sidebar"
|
||||
@@ -264,6 +295,18 @@ function handleKeydown(event: KeyboardEvent) {
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if canManageUsers}
|
||||
<EntityNavSection
|
||||
variant="flyout"
|
||||
href="/admin/invites"
|
||||
label={m.admin_tab_invites()}
|
||||
isActive={isActive('invites')}
|
||||
count={inviteCount}
|
||||
onFlyoutClick={closeFlyout}
|
||||
icon={invitesIcon}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if canManageTags}
|
||||
<EntityNavSection
|
||||
variant="flyout"
|
||||
|
||||
@@ -13,6 +13,7 @@ const props = {
|
||||
userCount: 5,
|
||||
groupCount: 3,
|
||||
tagCount: 8,
|
||||
inviteCount: 2,
|
||||
canManageUsers: true,
|
||||
canManageTags: true,
|
||||
canManagePermissions: true,
|
||||
|
||||
88
frontend/src/routes/admin/invites/+page.server.ts
Normal file
88
frontend/src/routes/admin/invites/+page.server.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { parseBackendError } from '$lib/errors';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
export interface InviteListItem {
|
||||
id: string;
|
||||
code: string;
|
||||
displayCode: string;
|
||||
label?: string;
|
||||
useCount: number;
|
||||
maxUses?: number;
|
||||
expiresAt?: string;
|
||||
revoked: boolean;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
shareableUrl: string;
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ url, fetch }) => {
|
||||
const status = url.searchParams.get('status') ?? 'active';
|
||||
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||
const res = await fetch(`${apiUrl}/api/invites?status=${encodeURIComponent(status)}`);
|
||||
|
||||
if (!res.ok) {
|
||||
const backendError = await parseBackendError(res);
|
||||
return {
|
||||
invites: [] as InviteListItem[],
|
||||
status,
|
||||
loadError: backendError?.code ?? 'INTERNAL_ERROR'
|
||||
};
|
||||
}
|
||||
|
||||
const invites: InviteListItem[] = await res.json();
|
||||
return { invites, status, loadError: null };
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
create: async ({ request, fetch }) => {
|
||||
const formData = await request.formData();
|
||||
const label = (formData.get('label') as string) || undefined;
|
||||
const maxUsesRaw = formData.get('maxUses') as string;
|
||||
const maxUses = maxUsesRaw ? parseInt(maxUsesRaw, 10) : undefined;
|
||||
const prefillFirstName = (formData.get('prefillFirstName') as string) || undefined;
|
||||
const prefillLastName = (formData.get('prefillLastName') as string) || undefined;
|
||||
const prefillEmail = (formData.get('prefillEmail') as string) || undefined;
|
||||
const expiresAt = (formData.get('expiresAt') as string) || undefined;
|
||||
|
||||
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||
const res = await fetch(`${apiUrl}/api/invites`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
label,
|
||||
maxUses,
|
||||
prefillFirstName,
|
||||
prefillLastName,
|
||||
prefillEmail,
|
||||
expiresAt
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const backendError = await parseBackendError(res);
|
||||
return fail(res.status, { createError: backendError?.code ?? 'INTERNAL_ERROR' });
|
||||
}
|
||||
|
||||
const created: InviteListItem = await res.json();
|
||||
return { created };
|
||||
},
|
||||
|
||||
revoke: async ({ request, fetch }) => {
|
||||
const formData = await request.formData();
|
||||
const id = formData.get('id') as string;
|
||||
|
||||
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||
const res = await fetch(`${apiUrl}/api/invites/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const backendError = await parseBackendError(res);
|
||||
return fail(res.status, { revokeError: backendError?.code ?? 'INTERNAL_ERROR' });
|
||||
}
|
||||
|
||||
return { revoked: id };
|
||||
}
|
||||
} satisfies Actions;
|
||||
373
frontend/src/routes/admin/invites/+page.svelte
Normal file
373
frontend/src/routes/admin/invites/+page.svelte
Normal file
@@ -0,0 +1,373 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { getErrorMessage } from '$lib/errors';
|
||||
import type { InviteListItem } from './+page.server.ts';
|
||||
|
||||
let {
|
||||
data,
|
||||
form
|
||||
}: {
|
||||
data: {
|
||||
invites: InviteListItem[];
|
||||
status: string;
|
||||
loadError: string | null;
|
||||
};
|
||||
form?: {
|
||||
createError?: string;
|
||||
revokeError?: string;
|
||||
created?: InviteListItem;
|
||||
revoked?: string;
|
||||
};
|
||||
} = $props();
|
||||
|
||||
let copiedId = $state<string | null>(null);
|
||||
let showNewForm = $state(false);
|
||||
|
||||
function copyLink(id: string, url: string) {
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
copiedId = id;
|
||||
setTimeout(() => {
|
||||
copiedId = null;
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
function statusLabel(status: string) {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return m.admin_invite_status_active();
|
||||
case 'exhausted':
|
||||
return m.admin_invite_status_exhausted();
|
||||
case 'revoked':
|
||||
return m.admin_invite_status_revoked();
|
||||
case 'expired':
|
||||
return m.admin_invite_status_expired();
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
function statusColor(status: string) {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'text-green-700 bg-green-50';
|
||||
case 'exhausted':
|
||||
return 'text-gray-500 bg-gray-100';
|
||||
case 'revoked':
|
||||
return 'text-red-600 bg-red-50';
|
||||
case 'expired':
|
||||
return 'text-amber-600 bg-amber-50';
|
||||
default:
|
||||
return 'text-gray-500 bg-gray-100';
|
||||
}
|
||||
}
|
||||
|
||||
function statusIcon(status: string) {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return '●';
|
||||
case 'exhausted':
|
||||
return '○';
|
||||
case 'revoked':
|
||||
return '✕';
|
||||
case 'expired':
|
||||
return '⏱';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{m.admin_tab_invites()} · Familienarchiv</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex flex-1 flex-col overflow-y-auto bg-canvas">
|
||||
<div class="flex items-center justify-between gap-4 border-b border-line bg-surface px-6 py-4">
|
||||
<h1 class="font-sans text-sm font-bold tracking-widest text-ink uppercase">
|
||||
{m.admin_invites_list_title()}
|
||||
</h1>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Status filter -->
|
||||
<div
|
||||
class="flex overflow-hidden rounded-sm border border-line font-sans text-xs font-bold tracking-widest uppercase"
|
||||
>
|
||||
<a
|
||||
href="/admin/invites"
|
||||
class="px-3 py-2 transition-colors {data.status !== 'all' ? 'bg-primary text-primary-fg' : 'bg-surface text-ink-2 hover:bg-muted'}"
|
||||
>
|
||||
{m.admin_invite_status_active()}
|
||||
</a>
|
||||
<a
|
||||
href="/admin/invites?status=all"
|
||||
class="border-l border-line px-3 py-2 transition-colors {data.status === 'all' ? 'bg-primary text-primary-fg' : 'bg-surface text-ink-2 hover:bg-muted'}"
|
||||
>
|
||||
{m.admin_btn_show_all()}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showNewForm = !showNewForm)}
|
||||
class="inline-flex items-center gap-1.5 bg-primary px-3 py-1.5 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-colors hover:bg-primary/90"
|
||||
>
|
||||
<svg
|
||||
class="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
{m.admin_btn_new_invite()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 space-y-6 overflow-y-auto px-6 py-6">
|
||||
{#if data.loadError}
|
||||
<div class="rounded-sm border border-red-200 bg-red-50 p-4 font-sans text-xs text-red-700">
|
||||
{getErrorMessage(data.loadError)}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if form?.created}
|
||||
<div class="rounded-sm border border-green-200 bg-green-50 p-4">
|
||||
<p class="mb-1 font-sans text-xs font-bold tracking-widest text-green-800 uppercase">
|
||||
{m.admin_invite_created_title()}
|
||||
</p>
|
||||
<p class="mb-2 font-serif text-sm text-green-700">{m.admin_invite_created_desc()}</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<code
|
||||
class="flex-1 rounded border border-green-200 bg-white px-3 py-1.5 font-mono text-xs break-all text-green-900"
|
||||
>
|
||||
{form.created.shareableUrl}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => copyLink(form!.created!.id, form!.created!.shareableUrl)}
|
||||
class="flex-shrink-0 rounded border border-green-300 bg-white px-3 py-1.5 font-sans text-xs font-bold text-green-700 transition-colors hover:bg-green-50"
|
||||
>
|
||||
{copiedId === form.created.id ? m.admin_btn_copied() : m.admin_btn_copy_link()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showNewForm}
|
||||
<div class="rounded-sm border border-line bg-surface p-5 shadow-sm">
|
||||
<h2 class="mb-4 font-sans text-xs font-bold tracking-widest text-ink-2 uppercase">
|
||||
{m.admin_btn_new_invite()}
|
||||
</h2>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/create"
|
||||
use:enhance
|
||||
class="grid grid-cols-1 gap-4 sm:grid-cols-2"
|
||||
>
|
||||
<div class="sm:col-span-2">
|
||||
<label
|
||||
for="invite-label"
|
||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
|
||||
>
|
||||
{m.admin_new_invite_label()}
|
||||
</label>
|
||||
<input
|
||||
id="invite-label"
|
||||
type="text"
|
||||
name="label"
|
||||
class="block w-full border border-line px-3 py-2 font-serif text-sm text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="invite-max-uses"
|
||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
|
||||
>
|
||||
{m.admin_new_invite_max_uses()}
|
||||
</label>
|
||||
<input
|
||||
id="invite-max-uses"
|
||||
type="number"
|
||||
name="maxUses"
|
||||
min="1"
|
||||
class="block w-full border border-line px-3 py-2 font-serif text-sm text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="invite-expires-at"
|
||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
|
||||
>
|
||||
{m.admin_new_invite_expires()}
|
||||
</label>
|
||||
<input
|
||||
id="invite-expires-at"
|
||||
type="datetime-local"
|
||||
name="expiresAt"
|
||||
class="block w-full border border-line px-3 py-2 font-serif text-sm text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="invite-prefill-first"
|
||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
|
||||
>
|
||||
{m.admin_new_invite_prefill_first()}
|
||||
</label>
|
||||
<input
|
||||
id="invite-prefill-first"
|
||||
type="text"
|
||||
name="prefillFirstName"
|
||||
class="block w-full border border-line px-3 py-2 font-serif text-sm text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="invite-prefill-last"
|
||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
|
||||
>
|
||||
{m.admin_new_invite_prefill_last()}
|
||||
</label>
|
||||
<input
|
||||
id="invite-prefill-last"
|
||||
type="text"
|
||||
name="prefillLastName"
|
||||
class="block w-full border border-line px-3 py-2 font-serif text-sm text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<label
|
||||
for="invite-prefill-email"
|
||||
class="mb-1 block font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
|
||||
>
|
||||
{m.admin_new_invite_prefill_email()}
|
||||
</label>
|
||||
<input
|
||||
id="invite-prefill-email"
|
||||
type="email"
|
||||
name="prefillEmail"
|
||||
class="block w-full border border-line px-3 py-2 font-serif text-sm text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
{#if form?.createError}
|
||||
<div class="font-sans text-xs font-medium text-red-600 sm:col-span-2">
|
||||
{getErrorMessage(form.createError)}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex justify-end gap-3 sm:col-span-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showNewForm = false)}
|
||||
class="px-4 py-2 font-sans text-xs font-bold tracking-widest text-ink-2 uppercase transition-colors hover:text-ink"
|
||||
>
|
||||
{m.btn_cancel()}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="bg-primary px-4 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-colors hover:bg-primary/90"
|
||||
>
|
||||
{m.admin_btn_new_invite()}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Invite table -->
|
||||
<div class="overflow-hidden rounded-sm border border-line bg-surface shadow-sm">
|
||||
{#if data.invites.length === 0}
|
||||
<p class="px-6 py-8 text-center font-serif text-sm text-ink-3">{m.admin_invites_empty()}</p>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-line">
|
||||
<th
|
||||
class="px-4 py-3 text-left font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||
>{m.admin_invite_col_code()}</th
|
||||
>
|
||||
<th
|
||||
class="px-4 py-3 text-left font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||
>{m.admin_invite_col_label()}</th
|
||||
>
|
||||
<th
|
||||
class="px-4 py-3 text-left font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||
>{m.admin_invite_col_uses()}</th
|
||||
>
|
||||
<th
|
||||
class="px-4 py-3 text-left font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||
>{m.admin_invite_col_expiry()}</th
|
||||
>
|
||||
<th
|
||||
class="px-4 py-3 text-left font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||
>{m.admin_invite_col_status()}</th
|
||||
>
|
||||
<th
|
||||
class="px-4 py-3 text-left font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||
>{m.admin_invite_col_link()}</th
|
||||
>
|
||||
<th class="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-line">
|
||||
{#each data.invites as invite (invite.id)}
|
||||
<tr class="hover:bg-muted/40">
|
||||
<td class="px-4 py-3 font-mono text-xs text-ink">{invite.displayCode}</td>
|
||||
<td class="px-4 py-3 font-serif text-sm text-ink">{invite.label ?? '–'}</td>
|
||||
<td class="px-4 py-3 font-sans text-xs text-ink-2">
|
||||
{invite.useCount} / {invite.maxUses != null ? invite.maxUses : m.admin_invite_unlimited()}
|
||||
</td>
|
||||
<td class="px-4 py-3 font-sans text-xs text-ink-2">
|
||||
{invite.expiresAt
|
||||
? new Intl.DateTimeFormat('de-DE', { dateStyle: 'medium' }).format(new Date(invite.expiresAt))
|
||||
: m.admin_invite_no_expiry()}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded px-2 py-0.5 font-sans text-xs font-bold {statusColor(invite.status)}"
|
||||
aria-label={statusLabel(invite.status)}
|
||||
>
|
||||
<span aria-hidden="true">{statusIcon(invite.status)}</span>
|
||||
{statusLabel(invite.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => copyLink(invite.id, invite.shareableUrl)}
|
||||
class="font-sans text-xs text-brand-navy/60 transition-colors hover:text-brand-navy"
|
||||
aria-label="{m.admin_btn_copy_link()}: {invite.displayCode}"
|
||||
title={invite.shareableUrl}
|
||||
>
|
||||
{copiedId === invite.id ? m.admin_btn_copied() : m.admin_btn_copy_link()}
|
||||
</button>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
{#if invite.status === 'active'}
|
||||
<form method="POST" action="?/revoke" use:enhance>
|
||||
<input type="hidden" name="id" value={invite.id} />
|
||||
<button
|
||||
type="submit"
|
||||
onclick={(e) => {
|
||||
if (!confirm(m.admin_invite_revoke_confirm())) e.preventDefault();
|
||||
}}
|
||||
class="font-sans text-xs font-bold tracking-widest text-red-500 uppercase transition-colors hover:text-red-700"
|
||||
>
|
||||
{m.admin_btn_revoke()}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -56,14 +56,20 @@ describe('admin layout load — permission check', () => {
|
||||
[{ id: 't1' }, { id: 't2' }, { id: 't3' }]
|
||||
);
|
||||
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => [{ id: 'i1' }, { id: 'i2' }]
|
||||
});
|
||||
|
||||
const result = await load({
|
||||
fetch: vi.fn() as unknown as typeof fetch,
|
||||
fetch: mockFetch as unknown as typeof fetch,
|
||||
locals: { user: adminUser }
|
||||
});
|
||||
|
||||
expect(result.userCount).toBe(2);
|
||||
expect(result.groupCount).toBe(1);
|
||||
expect(result.tagCount).toBe(3);
|
||||
expect(result.inviteCount).toBe(2);
|
||||
expect(result.canManageUsers).toBe(true);
|
||||
expect(result.canManageTags).toBe(true);
|
||||
expect(result.canManagePermissions).toBe(true);
|
||||
|
||||
@@ -17,6 +17,7 @@ const fullPerms = {
|
||||
userCount: 4,
|
||||
groupCount: 3,
|
||||
tagCount: 7,
|
||||
inviteCount: 2,
|
||||
canManageUsers: true,
|
||||
canManageTags: true,
|
||||
canManagePermissions: true,
|
||||
|
||||
@@ -14,6 +14,7 @@ const fullData = {
|
||||
userCount: 4,
|
||||
groupCount: 3,
|
||||
tagCount: 7,
|
||||
inviteCount: 2,
|
||||
canManageUsers: true,
|
||||
canManageTags: true,
|
||||
canManagePermissions: true,
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { fail, redirect, type Actions } from '@sveltejs/kit';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { getErrorMessage } from '$lib/errors';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = ({ url }) => {
|
||||
return { registered: url.searchParams.get('registered') === '1' };
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
login: async ({ request, cookies, fetch }) => {
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import AuthHeader from '../AuthHeader.svelte';
|
||||
|
||||
let { form }: { form?: { error?: string; success?: boolean } } = $props();
|
||||
let {
|
||||
data,
|
||||
form
|
||||
}: { data: { registered: boolean }; form?: { error?: string; success?: boolean } } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -25,6 +28,16 @@ let { form }: { form?: { error?: string; success?: boolean } } = $props();
|
||||
|
||||
<!-- Card -->
|
||||
<div class="rounded-sm border border-line bg-surface p-8 shadow-sm">
|
||||
{#if data.registered}
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="mb-5 rounded-sm border border-green-200 bg-green-50 px-4 py-3 font-sans text-xs font-medium text-green-800"
|
||||
>
|
||||
{m.login_registered_success()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<h1 class="mb-6 font-sans text-sm font-bold tracking-widest text-ink uppercase">
|
||||
{m.login_heading()}
|
||||
</h1>
|
||||
|
||||
@@ -9,7 +9,7 @@ afterEach(cleanup);
|
||||
|
||||
describe('Login page – rendering', () => {
|
||||
it('renders the page title', async () => {
|
||||
render(LoginPage, {});
|
||||
render(LoginPage, { data: { registered: false } });
|
||||
await expect
|
||||
.element(page.getByRole('link', { name: 'Familienarchiv' }).first())
|
||||
.toBeInTheDocument();
|
||||
@@ -17,54 +17,54 @@ describe('Login page – rendering', () => {
|
||||
});
|
||||
|
||||
it('renders the submit button', async () => {
|
||||
render(LoginPage, {});
|
||||
render(LoginPage, { data: { registered: false } });
|
||||
await expect.element(page.getByRole('button', { name: 'Anmelden' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the email input', async () => {
|
||||
render(LoginPage, {});
|
||||
render(LoginPage, { data: { registered: false } });
|
||||
await tick();
|
||||
const input = document.querySelector<HTMLInputElement>('input[name="email"]');
|
||||
expect(input).not.toBeNull();
|
||||
});
|
||||
|
||||
it('renders the password input', async () => {
|
||||
render(LoginPage, {});
|
||||
render(LoginPage, { data: { registered: false } });
|
||||
await tick();
|
||||
const input = document.querySelector<HTMLInputElement>('input[name="password"]');
|
||||
expect(input).not.toBeNull();
|
||||
});
|
||||
|
||||
it('email field is required', async () => {
|
||||
render(LoginPage, {});
|
||||
render(LoginPage, { data: { registered: false } });
|
||||
await tick();
|
||||
const input = document.querySelector<HTMLInputElement>('input[name="email"]');
|
||||
expect(input?.required).toBe(true);
|
||||
});
|
||||
|
||||
it('password field is required', async () => {
|
||||
render(LoginPage, {});
|
||||
render(LoginPage, { data: { registered: false } });
|
||||
await tick();
|
||||
const input = document.querySelector<HTMLInputElement>('input[name="password"]');
|
||||
expect(input?.required).toBe(true);
|
||||
});
|
||||
|
||||
it('email field has type="email"', async () => {
|
||||
render(LoginPage, {});
|
||||
render(LoginPage, { data: { registered: false } });
|
||||
await tick();
|
||||
const input = document.querySelector<HTMLInputElement>('input[name="email"]');
|
||||
expect(input?.type).toBe('email');
|
||||
});
|
||||
|
||||
it('password field has type="password"', async () => {
|
||||
render(LoginPage, {});
|
||||
render(LoginPage, { data: { registered: false } });
|
||||
await tick();
|
||||
const input = document.querySelector<HTMLInputElement>('input[name="password"]');
|
||||
expect(input?.type).toBe('password');
|
||||
});
|
||||
|
||||
it('form submits to the login action', async () => {
|
||||
render(LoginPage, {});
|
||||
render(LoginPage, { data: { registered: false } });
|
||||
await tick();
|
||||
const form = document.querySelector<HTMLFormElement>('form');
|
||||
expect(form?.action).toMatch(/\?\/login$/);
|
||||
@@ -73,25 +73,25 @@ describe('Login page – rendering', () => {
|
||||
|
||||
describe('Login page – error state', () => {
|
||||
it('shows no error when form is undefined', async () => {
|
||||
render(LoginPage, {});
|
||||
render(LoginPage, { data: { registered: false } });
|
||||
await tick();
|
||||
expect(document.querySelector('.text-red-600')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows no error when form has no error property', async () => {
|
||||
render(LoginPage, { form: {} });
|
||||
render(LoginPage, { data: { registered: false }, form: {} });
|
||||
await tick();
|
||||
expect(document.querySelector('.text-red-600')).toBeNull();
|
||||
});
|
||||
|
||||
it('displays the error message from the form action', async () => {
|
||||
render(LoginPage, { form: { error: 'Ungültige Anmeldedaten.' } });
|
||||
render(LoginPage, { data: { registered: false }, form: { error: 'Ungültige Anmeldedaten.' } });
|
||||
await expect.element(page.getByText('Ungültige Anmeldedaten.')).toBeInTheDocument();
|
||||
await page.screenshot({ path: 'test-results/screenshots/login-error.png' });
|
||||
});
|
||||
|
||||
it('applies red styling to the error text', async () => {
|
||||
render(LoginPage, { form: { error: 'Fehler!' } });
|
||||
render(LoginPage, { data: { registered: false }, form: { error: 'Fehler!' } });
|
||||
await tick();
|
||||
expect(document.querySelector('.text-red-600')).not.toBeNull();
|
||||
});
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.5 KiB |
53
frontend/src/routes/register/+page.server.ts
Normal file
53
frontend/src/routes/register/+page.server.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { parseBackendError } from '$lib/errors';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
interface InvitePrefill {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ url, fetch }) => {
|
||||
const code = url.searchParams.get('code');
|
||||
if (!code) {
|
||||
return { code: null, prefill: null, codeError: null };
|
||||
}
|
||||
|
||||
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||
const res = await fetch(`${apiUrl}/api/auth/invite/${encodeURIComponent(code)}`);
|
||||
|
||||
if (!res.ok) {
|
||||
const backendError = await parseBackendError(res);
|
||||
return { code, prefill: null, codeError: backendError?.code ?? 'INTERNAL_ERROR' };
|
||||
}
|
||||
|
||||
const prefill: InvitePrefill = await res.json();
|
||||
return { code, prefill, codeError: null };
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
default: async ({ request, fetch }) => {
|
||||
const formData = await request.formData();
|
||||
const code = formData.get('code') as string;
|
||||
const email = formData.get('email') as string;
|
||||
const password = formData.get('password') as string;
|
||||
const firstName = formData.get('firstName') as string;
|
||||
const lastName = formData.get('lastName') as string;
|
||||
|
||||
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||
const res = await fetch(`${apiUrl}/api/auth/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code, email, password, firstName, lastName })
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const backendError = await parseBackendError(res);
|
||||
return fail(res.status, { error: backendError?.code ?? 'INTERNAL_ERROR' });
|
||||
}
|
||||
|
||||
throw redirect(303, '/login?registered=1');
|
||||
}
|
||||
} satisfies Actions;
|
||||
199
frontend/src/routes/register/+page.svelte
Normal file
199
frontend/src/routes/register/+page.svelte
Normal file
@@ -0,0 +1,199 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { getErrorMessage } from '$lib/errors';
|
||||
import AuthHeader from '../AuthHeader.svelte';
|
||||
|
||||
let {
|
||||
data,
|
||||
form
|
||||
}: {
|
||||
data: {
|
||||
code: string | null;
|
||||
prefill: { firstName?: string; lastName?: string; email?: string } | null;
|
||||
codeError: string | null;
|
||||
};
|
||||
form?: { error?: string };
|
||||
} = $props();
|
||||
|
||||
let showPassword = $state(false);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{m.register_heading()} – Familienarchiv</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex min-h-screen flex-col bg-canvas">
|
||||
<AuthHeader />
|
||||
|
||||
<main class="flex flex-1 items-center justify-center px-4 py-8">
|
||||
<div class="w-full max-w-sm">
|
||||
<div class="mb-10 text-center">
|
||||
<a href="/" class="inline-flex items-center" aria-label="Familienarchiv">
|
||||
<span class="font-sans text-2xl font-bold tracking-widest text-ink uppercase"
|
||||
>Familienarchiv</span
|
||||
>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{#if data.codeError}
|
||||
<div class="rounded-sm border border-line bg-surface p-8 text-center shadow-sm">
|
||||
<svg
|
||||
class="mx-auto mb-4 h-10 w-10 text-red-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
<h1 class="mb-2 font-sans text-sm font-bold tracking-widest text-ink uppercase">
|
||||
{m.register_invalid_code()}
|
||||
</h1>
|
||||
<p class="mb-6 font-serif text-sm text-ink-2">{m.register_invalid_code_desc()}</p>
|
||||
<a
|
||||
href="/login"
|
||||
class="font-sans text-xs font-bold tracking-widest text-brand-navy/60 uppercase transition-colors hover:text-brand-navy"
|
||||
>
|
||||
{m.forgot_password_back_to_login()}
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded-sm border border-line bg-surface p-8 shadow-sm">
|
||||
<h1 class="mb-1 font-sans text-sm font-bold tracking-widest text-ink uppercase">
|
||||
{m.register_heading()}
|
||||
</h1>
|
||||
{#if data.code}
|
||||
<p class="mb-6 font-serif text-xs text-ink-2">{m.register_subtext()}</p>
|
||||
{/if}
|
||||
|
||||
<form method="POST" class="space-y-5">
|
||||
<input type="hidden" name="code" value={data.code ?? ''} />
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="firstName"
|
||||
class="mb-1.5 block font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
|
||||
>{m.register_label_first_name()}</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
name="firstName"
|
||||
id="firstName"
|
||||
autocomplete="given-name"
|
||||
value={data.prefill?.firstName ?? ''}
|
||||
class="block w-full border border-line px-3 py-2.5 font-serif text-sm text-ink placeholder-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="lastName"
|
||||
class="mb-1.5 block font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
|
||||
>{m.register_label_last_name()}</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
name="lastName"
|
||||
id="lastName"
|
||||
autocomplete="family-name"
|
||||
value={data.prefill?.lastName ?? ''}
|
||||
class="block w-full border border-line px-3 py-2.5 font-serif text-sm text-ink placeholder-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="email"
|
||||
class="mb-1.5 block font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
|
||||
>{m.register_label_email()}</label
|
||||
>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
id="email"
|
||||
required
|
||||
autocomplete="email"
|
||||
value={data.prefill?.email ?? ''}
|
||||
class="block w-full border border-line px-3 py-2.5 font-serif text-sm text-ink placeholder-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
{#if data.prefill?.email}
|
||||
<p class="mt-1 font-sans text-xs text-ink-3">{m.register_prefill_hint()}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="password"
|
||||
class="mb-1.5 block font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
|
||||
>{m.register_label_password()}</label
|
||||
>
|
||||
<div class="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
name="password"
|
||||
id="password"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
class="block w-full border border-line px-3 py-2.5 pr-10 font-serif text-sm text-ink placeholder-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showPassword = !showPassword)}
|
||||
class="absolute inset-y-0 right-0 flex items-center px-3 text-ink-3 hover:text-ink"
|
||||
aria-label={showPassword ? m.register_password_hide() : m.register_password_show()}
|
||||
>
|
||||
{#if showPassword}
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="font-sans text-xs font-medium text-red-600">
|
||||
{getErrorMessage(form.error)}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="mt-2 w-full bg-primary py-2.5 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-colors hover:bg-primary/90"
|
||||
>
|
||||
{m.register_btn_submit()}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div class="py-4 text-center">
|
||||
<p class="font-sans text-xs tracking-widest text-ink-3 uppercase">Familienarchiv</p>
|
||||
</div>
|
||||
</div>
|
||||
88
frontend/src/routes/register/page.server.test.ts
Normal file
88
frontend/src/routes/register/page.server.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Test the load function logic in isolation without importing the actual module
|
||||
// (which depends on SvelteKit env and $types at runtime).
|
||||
// We replicate the exact load logic here so we can test the branching behaviour.
|
||||
|
||||
interface InvitePrefill {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
async function loadFn(
|
||||
code: string | null,
|
||||
fetchImpl: (url: string) => Promise<{ ok: boolean; json: () => Promise<unknown> }>
|
||||
): Promise<{ code: string | null; prefill: InvitePrefill | null; codeError: string | null }> {
|
||||
if (!code) {
|
||||
return { code: null, prefill: null, codeError: null };
|
||||
}
|
||||
|
||||
const res = await fetchImpl(`http://localhost:8080/api/auth/invite/${encodeURIComponent(code)}`);
|
||||
|
||||
if (!res.ok) {
|
||||
const body = (await res.json()) as { code?: string } | null;
|
||||
return { code, prefill: null, codeError: body?.code ?? 'INTERNAL_ERROR' };
|
||||
}
|
||||
|
||||
const prefill = (await res.json()) as InvitePrefill;
|
||||
return { code, prefill, codeError: null };
|
||||
}
|
||||
|
||||
describe('register load', () => {
|
||||
const mockFetch = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
it('returns nulls when no code in URL', async () => {
|
||||
const result = await loadFn(null, mockFetch);
|
||||
expect(result).toEqual({ code: null, prefill: null, codeError: null });
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns prefill data when backend responds ok', async () => {
|
||||
const prefill = { firstName: 'Max', lastName: 'Muster', email: 'max@test.com' };
|
||||
mockFetch.mockResolvedValue({ ok: true, json: async () => prefill });
|
||||
|
||||
const result = await loadFn('ABCDE12345', mockFetch);
|
||||
|
||||
expect(result.code).toBe('ABCDE12345');
|
||||
expect(result.prefill).toEqual(prefill);
|
||||
expect(result.codeError).toBeNull();
|
||||
});
|
||||
|
||||
it('returns codeError when backend returns error with code', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
json: async () => ({ code: 'INVITE_REVOKED', message: 'Revoked' })
|
||||
});
|
||||
|
||||
const result = await loadFn('ABCDE12345', mockFetch);
|
||||
|
||||
expect(result.code).toBe('ABCDE12345');
|
||||
expect(result.prefill).toBeNull();
|
||||
expect(result.codeError).toBe('INVITE_REVOKED');
|
||||
});
|
||||
|
||||
it('falls back to INTERNAL_ERROR when backend error has no code', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
json: async () => null
|
||||
});
|
||||
|
||||
const result = await loadFn('ABCDE12345', mockFetch);
|
||||
|
||||
expect(result.codeError).toBe('INTERNAL_ERROR');
|
||||
});
|
||||
|
||||
it('URL-encodes the invite code in the fetch call', async () => {
|
||||
const prefill = {};
|
||||
mockFetch.mockResolvedValue({ ok: true, json: async () => prefill });
|
||||
|
||||
await loadFn('CODE WITH SPACES', mockFetch);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('CODE%20WITH%20SPACES'));
|
||||
});
|
||||
});
|
||||
106
frontend/src/routes/register/page.svelte.spec.ts
Normal file
106
frontend/src/routes/register/page.svelte.spec.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import RegisterPage from './+page.svelte';
|
||||
|
||||
const tick = () => new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const validData = {
|
||||
code: 'ABCDE12345',
|
||||
prefill: null,
|
||||
codeError: null
|
||||
};
|
||||
|
||||
const prefillData = {
|
||||
code: 'ABCDE12345',
|
||||
prefill: { firstName: 'Max', lastName: 'Muster', email: 'max@test.com' },
|
||||
codeError: null
|
||||
};
|
||||
|
||||
const errorData = {
|
||||
code: null,
|
||||
prefill: null,
|
||||
codeError: 'INVITE_NOT_FOUND'
|
||||
};
|
||||
|
||||
describe('Register page – valid code', () => {
|
||||
it('renders the heading', async () => {
|
||||
render(RegisterPage, { data: validData });
|
||||
await expect.element(page.getByRole('heading', { level: 1 })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the email input', async () => {
|
||||
render(RegisterPage, { data: validData });
|
||||
await tick();
|
||||
const input = document.querySelector<HTMLInputElement>('input[name="email"]');
|
||||
expect(input).not.toBeNull();
|
||||
expect(input?.type).toBe('email');
|
||||
expect(input?.required).toBe(true);
|
||||
});
|
||||
|
||||
it('renders the password input', async () => {
|
||||
render(RegisterPage, { data: validData });
|
||||
await tick();
|
||||
const input = document.querySelector<HTMLInputElement>('input[name="password"]');
|
||||
expect(input).not.toBeNull();
|
||||
expect(input?.required).toBe(true);
|
||||
});
|
||||
|
||||
it('renders the submit button', async () => {
|
||||
render(RegisterPage, { data: validData });
|
||||
await expect.element(page.getByRole('button', { name: 'Konto erstellen' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('prefills fields from invite data', async () => {
|
||||
render(RegisterPage, { data: prefillData });
|
||||
await tick();
|
||||
const email = document.querySelector<HTMLInputElement>('input[name="email"]');
|
||||
const firstName = document.querySelector<HTMLInputElement>('input[name="firstName"]');
|
||||
const lastName = document.querySelector<HTMLInputElement>('input[name="lastName"]');
|
||||
expect(email?.value).toBe('max@test.com');
|
||||
expect(firstName?.value).toBe('Max');
|
||||
expect(lastName?.value).toBe('Muster');
|
||||
});
|
||||
|
||||
it('has a hidden code input', async () => {
|
||||
render(RegisterPage, { data: validData });
|
||||
await tick();
|
||||
const hidden = document.querySelector<HTMLInputElement>('input[name="code"][type="hidden"]');
|
||||
expect(hidden).not.toBeNull();
|
||||
expect(hidden?.value).toBe('ABCDE12345');
|
||||
});
|
||||
|
||||
it('shows form error when action returns error', async () => {
|
||||
render(RegisterPage, { data: validData, form: { error: 'INVITE_REVOKED' } });
|
||||
await tick();
|
||||
expect(document.querySelector('.text-red-600')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('has main landmark', async () => {
|
||||
render(RegisterPage, { data: validData });
|
||||
await tick();
|
||||
expect(document.querySelector('main')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Register page – error state', () => {
|
||||
it('renders the error card when codeError is set', async () => {
|
||||
render(RegisterPage, { data: errorData });
|
||||
await tick();
|
||||
expect(document.querySelector('form')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows a back-to-login link in error state', async () => {
|
||||
render(RegisterPage, { data: errorData });
|
||||
await expect.element(page.getByRole('link', { name: /login/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('back-to-login link points to /login', async () => {
|
||||
render(RegisterPage, { data: errorData });
|
||||
await tick();
|
||||
const link = document.querySelector<HTMLAnchorElement>('a[href="/login"]');
|
||||
expect(link).not.toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user