feat(invites): assign groups when creating an invite link (#566) #582
@@ -159,7 +159,7 @@ Input DTOs live flat in the domain package. Response types are the model entitie
|
|||||||
|
|
||||||
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
|
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
|
||||||
|
|
||||||
**LLM reminder:** use `DomainException.notFound/forbidden/conflict/internal()` from service methods — never throw raw exceptions. When adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) mirror in `frontend/src/lib/shared/errors.ts`, (3) add i18n keys in `messages/{de,en,es}.json`.
|
**LLM reminder:** use `DomainException.notFound/forbidden/conflict/internal()` from service methods — never throw raw exceptions. When adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`.
|
||||||
|
|
||||||
### Security / Permissions
|
### Security / Permissions
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ public enum ErrorCode {
|
|||||||
// --- Users ---
|
// --- Users ---
|
||||||
/** A user with the given ID or username does not exist. 404 */
|
/** A user with the given ID or username does not exist. 404 */
|
||||||
USER_NOT_FOUND,
|
USER_NOT_FOUND,
|
||||||
|
/** A group with the given ID does not exist. 404 */
|
||||||
|
GROUP_NOT_FOUND,
|
||||||
/** The supplied email address is already used by another account. 409 */
|
/** The supplied email address is already used by another account. 409 */
|
||||||
EMAIL_ALREADY_IN_USE,
|
EMAIL_ALREADY_IN_USE,
|
||||||
/** The supplied current password does not match the stored hash. 400 */
|
/** The supplied current password does not match the stored hash. 400 */
|
||||||
@@ -52,6 +54,8 @@ public enum ErrorCode {
|
|||||||
INVITE_REVOKED,
|
INVITE_REVOKED,
|
||||||
/** The invite has passed its expiry date. 410 */
|
/** The invite has passed its expiry date. 410 */
|
||||||
INVITE_EXPIRED,
|
INVITE_EXPIRED,
|
||||||
|
/** A group cannot be deleted because one or more active invites reference it. 409 */
|
||||||
|
GROUP_HAS_ACTIVE_INVITES,
|
||||||
|
|
||||||
// --- Auth ---
|
// --- Auth ---
|
||||||
/** The request is not authenticated. 401 */
|
/** The request is not authenticated. 401 */
|
||||||
|
|||||||
@@ -52,7 +52,11 @@ public class InviteService {
|
|||||||
public InviteToken createInvite(CreateInviteRequest dto, AppUser creator) {
|
public InviteToken createInvite(CreateInviteRequest dto, AppUser creator) {
|
||||||
Set<UUID> groupIds = new HashSet<>();
|
Set<UUID> groupIds = new HashSet<>();
|
||||||
if (dto.getGroupIds() != null && !dto.getGroupIds().isEmpty()) {
|
if (dto.getGroupIds() != null && !dto.getGroupIds().isEmpty()) {
|
||||||
List<UserGroup> groups = userService.findGroupsByIds(dto.getGroupIds());
|
Set<UUID> uniqueIds = new HashSet<>(dto.getGroupIds());
|
||||||
|
List<UserGroup> groups = userService.findGroupsByIds(new ArrayList<>(uniqueIds));
|
||||||
|
if (groups.size() != uniqueIds.size()) {
|
||||||
|
throw DomainException.notFound(ErrorCode.GROUP_NOT_FOUND, "One or more group IDs do not exist");
|
||||||
|
}
|
||||||
groups.forEach(g -> groupIds.add(g.getId()));
|
groups.forEach(g -> groupIds.add(g.getId()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,4 +24,7 @@ public interface InviteTokenRepository extends JpaRepository<InviteToken, UUID>
|
|||||||
|
|
||||||
@Query("SELECT t FROM InviteToken t ORDER BY t.createdAt DESC")
|
@Query("SELECT t FROM InviteToken t ORDER BY t.createdAt DESC")
|
||||||
List<InviteToken> findAllOrderedByCreatedAt();
|
List<InviteToken> findAllOrderedByCreatedAt();
|
||||||
|
|
||||||
|
@Query("SELECT CASE WHEN COUNT(t) > 0 THEN true ELSE false END FROM InviteToken t JOIN t.groupIds g WHERE g = :groupId AND t.revoked = false AND (t.expiresAt IS NULL OR t.expiresAt > CURRENT_TIMESTAMP) AND (t.maxUses IS NULL OR t.useCount < t.maxUses)")
|
||||||
|
boolean existsActiveWithGroupId(@Param("groupId") UUID groupId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ public class UserService {
|
|||||||
|
|
||||||
private final AppUserRepository userRepository;
|
private final AppUserRepository userRepository;
|
||||||
private final UserGroupRepository groupRepository;
|
private final UserGroupRepository groupRepository;
|
||||||
|
// Injected directly (not via InviteService) to avoid a constructor injection cycle:
|
||||||
|
// InviteService → UserService → InviteService. Spring Framework 7 forbids such cycles.
|
||||||
|
private final InviteTokenRepository inviteTokenRepository;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
private final AuditService auditService;
|
private final AuditService auditService;
|
||||||
|
|
||||||
@@ -288,6 +291,10 @@ public class UserService {
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void deleteGroup(UUID id) {
|
public void deleteGroup(UUID id) {
|
||||||
|
if (inviteTokenRepository.existsActiveWithGroupId(id)) {
|
||||||
|
throw DomainException.conflict(ErrorCode.GROUP_HAS_ACTIVE_INVITES,
|
||||||
|
"Cannot delete group " + id + " — referenced by one or more active invites");
|
||||||
|
}
|
||||||
groupRepository.deleteById(id);
|
groupRepository.deleteById(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- The composite PK (invite_token_id, group_id) does not support efficient lookups by group_id alone.
|
||||||
|
-- Add a dedicated index to support existsActiveWithGroupId queries.
|
||||||
|
CREATE INDEX idx_itg_group_id ON invite_token_group_ids (group_id);
|
||||||
@@ -20,10 +20,13 @@ import org.springframework.security.test.context.support.WithMockUser;
|
|||||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
import org.springframework.test.web.servlet.MockMvc;
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.mockito.ArgumentMatchers.*;
|
import static org.mockito.ArgumentMatchers.*;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
@@ -147,6 +150,30 @@ class InviteControllerTest {
|
|||||||
.andExpect(jsonPath("$.label").value("Für Familie"));
|
.andExpect(jsonPath("$.label").value("Für Familie"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "admin@test.com", authorities = {"ADMIN_USER"})
|
||||||
|
void createInvite_forwardsGroupIdsToService() throws Exception {
|
||||||
|
UUID groupId = UUID.randomUUID();
|
||||||
|
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("ABCDE12345").useCount(0).build();
|
||||||
|
when(inviteService.createInvite(any(), eq(admin))).thenReturn(savedToken);
|
||||||
|
when(inviteService.toListItemDTO(any(), anyString()))
|
||||||
|
.thenReturn(makeInviteDTO(savedToken.getId(), "ABCDE12345"));
|
||||||
|
|
||||||
|
String body = "{\"groupIds\":[\"" + groupId + "\"]}";
|
||||||
|
mockMvc.perform(post("/api/invites")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(body))
|
||||||
|
.andExpect(status().isCreated());
|
||||||
|
|
||||||
|
ArgumentCaptor<CreateInviteRequest> captor = ArgumentCaptor.forClass(CreateInviteRequest.class);
|
||||||
|
verify(inviteService).createInvite(captor.capture(), eq(admin));
|
||||||
|
assertThat(captor.getValue().getGroupIds()).containsExactly(groupId);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── DELETE /api/invites/{id} ─────────────────────────────────────────────
|
// ─── DELETE /api/invites/{id} ─────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -156,6 +156,35 @@ class InviteServiceTest {
|
|||||||
assertThat(result.getGroupIds()).contains(g.getId());
|
assertThat(result.getGroupIds()).contains(g.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createInvite_throwsGroupNotFound_whenSubmittedGroupIdDoesNotExist() {
|
||||||
|
UUID unknownGroupId = UUID.randomUUID();
|
||||||
|
when(userService.findGroupsByIds(anyList())).thenReturn(List.of());
|
||||||
|
|
||||||
|
CreateInviteRequest req = new CreateInviteRequest();
|
||||||
|
req.setGroupIds(List.of(unknownGroupId));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> inviteService.createInvite(req, admin))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting(e -> ((DomainException) e).getCode())
|
||||||
|
.isEqualTo(ErrorCode.GROUP_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createInvite_doesNotThrowGroupNotFound_whenDuplicateGroupIdsSubmitted() {
|
||||||
|
UUID groupId = UUID.randomUUID();
|
||||||
|
UserGroup group = UserGroup.builder().id(groupId).name("Familie").build();
|
||||||
|
when(inviteTokenRepository.findByCode(anyString())).thenReturn(Optional.empty());
|
||||||
|
when(userService.findGroupsByIds(anyList())).thenReturn(List.of(group));
|
||||||
|
when(inviteTokenRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
CreateInviteRequest req = new CreateInviteRequest();
|
||||||
|
req.setGroupIds(List.of(groupId, groupId)); // same UUID submitted twice
|
||||||
|
|
||||||
|
// before deduplication: size(groups)==1 != size(submitted)==2 → false GROUP_NOT_FOUND
|
||||||
|
assertThatCode(() -> inviteService.createInvite(req, admin)).doesNotThrowAnyException();
|
||||||
|
}
|
||||||
|
|
||||||
// ─── redeemInvite ─────────────────────────────────────────────────────────
|
// ─── redeemInvite ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package org.raddatz.familienarchiv.user;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||||
|
import org.raddatz.familienarchiv.config.FlywayConfig;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
||||||
|
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
@DataJpaTest
|
||||||
|
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||||
|
@Import({PostgresContainerConfig.class, FlywayConfig.class})
|
||||||
|
class InviteTokenRepositoryIntegrationTest {
|
||||||
|
|
||||||
|
@Autowired InviteTokenRepository inviteTokenRepository;
|
||||||
|
@Autowired UserGroupRepository userGroupRepository;
|
||||||
|
@Autowired AppUserRepository appUserRepository;
|
||||||
|
|
||||||
|
private UserGroup group;
|
||||||
|
private AppUser admin;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
inviteTokenRepository.deleteAll();
|
||||||
|
userGroupRepository.deleteAll();
|
||||||
|
appUserRepository.deleteAll();
|
||||||
|
admin = appUserRepository.save(AppUser.builder().email("admin@test.com").password("pw").build());
|
||||||
|
group = userGroupRepository.save(UserGroup.builder().name("Familie").build());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── existsActiveWithGroupId ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void existsActiveWithGroupId_returnsTrueForActiveInviteLinkedToGroup() {
|
||||||
|
inviteTokenRepository.save(token(t -> t));
|
||||||
|
|
||||||
|
assertThat(inviteTokenRepository.existsActiveWithGroupId(group.getId())).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void existsActiveWithGroupId_returnsFalseWhenInviteIsRevoked() {
|
||||||
|
inviteTokenRepository.save(token(t -> t.revoked(true)));
|
||||||
|
|
||||||
|
assertThat(inviteTokenRepository.existsActiveWithGroupId(group.getId())).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void existsActiveWithGroupId_returnsFalseWhenInviteIsExpired() {
|
||||||
|
inviteTokenRepository.save(token(t -> t.expiresAt(LocalDateTime.now().minusDays(1))));
|
||||||
|
|
||||||
|
assertThat(inviteTokenRepository.existsActiveWithGroupId(group.getId())).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void existsActiveWithGroupId_returnsFalseWhenInviteIsExhausted() {
|
||||||
|
inviteTokenRepository.save(token(t -> t.maxUses(1).useCount(1)));
|
||||||
|
|
||||||
|
assertThat(inviteTokenRepository.existsActiveWithGroupId(group.getId())).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private InviteToken token(java.util.function.UnaryOperator<InviteToken.InviteTokenBuilder> customizer) {
|
||||||
|
InviteToken.InviteTokenBuilder builder = InviteToken.builder()
|
||||||
|
.code(UUID.randomUUID().toString().replace("-", "").substring(0, 10))
|
||||||
|
.groupIds(new java.util.HashSet<>(Set.of(group.getId())))
|
||||||
|
.createdBy(admin);
|
||||||
|
return customizer.apply(builder).build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ class UserServiceTest {
|
|||||||
|
|
||||||
@Mock AppUserRepository userRepository;
|
@Mock AppUserRepository userRepository;
|
||||||
@Mock UserGroupRepository groupRepository;
|
@Mock UserGroupRepository groupRepository;
|
||||||
|
@Mock InviteTokenRepository inviteTokenRepository;
|
||||||
@Mock PasswordEncoder passwordEncoder;
|
@Mock PasswordEncoder passwordEncoder;
|
||||||
@Mock AuditService auditService;
|
@Mock AuditService auditService;
|
||||||
@InjectMocks UserService userService;
|
@InjectMocks UserService userService;
|
||||||
@@ -903,6 +904,29 @@ class UserServiceTest {
|
|||||||
assertThat(result.getPermissions()).containsExactlyInAnyOrder("READ_ALL", "WRITE_ALL");
|
assertThat(result.getPermissions()).containsExactlyInAnyOrder("READ_ALL", "WRITE_ALL");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── deleteGroup ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteGroup_throwsConflict_whenActiveInviteReferencesGroup() {
|
||||||
|
UUID groupId = UUID.randomUUID();
|
||||||
|
when(inviteTokenRepository.existsActiveWithGroupId(groupId)).thenReturn(true);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> userService.deleteGroup(groupId))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting(e -> ((DomainException) e).getCode())
|
||||||
|
.isEqualTo(ErrorCode.GROUP_HAS_ACTIVE_INVITES);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteGroup_deletesGroup_whenNoActiveInviteReferencesGroup() {
|
||||||
|
UUID groupId = UUID.randomUUID();
|
||||||
|
when(inviteTokenRepository.existsActiveWithGroupId(groupId)).thenReturn(false);
|
||||||
|
|
||||||
|
userService.deleteGroup(groupId);
|
||||||
|
|
||||||
|
verify(groupRepository).deleteById(groupId);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createGroup_withNullPermissions_savesGroupWithEmptyPermissionSet() {
|
void createGroup_withNullPermissions_savesGroupWithEmptyPermissionSet() {
|
||||||
org.raddatz.familienarchiv.user.GroupDTO dto = new org.raddatz.familienarchiv.user.GroupDTO();
|
org.raddatz.familienarchiv.user.GroupDTO dto = new org.raddatz.familienarchiv.user.GroupDTO();
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ Members of the cross-cutting layer have no entity of their own, no user-facing C
|
|||||||
| `audit` | Append-only event store (`audit_log`) for all domain mutations. Feeds the activity feed and Family Pulse dashboard. | Consumed by 5+ domains; no user-facing CRUD of its own |
|
| `audit` | Append-only event store (`audit_log`) for all domain mutations. Feeds the activity feed and Family Pulse dashboard. | Consumed by 5+ domains; no user-facing CRUD of its own |
|
||||||
| `config` | Infrastructure bean definitions: `MinioConfig`, `AsyncConfig`, `WebConfig` | Framework infra; no business logic |
|
| `config` | Infrastructure bean definitions: `MinioConfig`, `AsyncConfig`, `WebConfig` | Framework infra; no business logic |
|
||||||
| `dashboard` | Stats aggregation for the admin dashboard and Family Pulse widget | Aggregates from 3+ domains; no owned entities |
|
| `dashboard` | Stats aggregation for the admin dashboard and Family Pulse widget | Aggregates from 3+ domains; no owned entities |
|
||||||
| `exception` | `DomainException`, `ErrorCode` enum, `GlobalExceptionHandler` | Framework infra; consumed by every controller and service |
|
| `exception` | `DomainException`, `ErrorCode` enum, `GlobalExceptionHandler` | Framework infra; consumed by every controller and service. Adding a new `ErrorCode` requires matching updates in `frontend/src/lib/shared/errors.ts` and all three `messages/*.json` locale files. |
|
||||||
| `filestorage` | `FileService` — MinIO/S3 upload, download, presigned-URL generation | Generic service; consumed by `document` and `ocr` |
|
| `filestorage` | `FileService` — MinIO/S3 upload, download, presigned-URL generation | Generic service; consumed by `document` and `ocr` |
|
||||||
| `importing` | `MassImportService` — async ODS/Excel batch import | Orchestrates across `person`, `tag`, `document` |
|
| `importing` | `MassImportService` — async ODS/Excel batch import | Orchestrates across `person`, `tag`, `document` |
|
||||||
| `security` | `SecurityConfig`, `Permission` enum, `@RequirePermission` annotation, `PermissionAspect` (AOP) | Framework infra; enforced globally across all controllers |
|
| `security` | `SecurityConfig`, `Permission` enum, `@RequirePermission` annotation, `PermissionAspect` (AOP) | Framework infra; enforced globally across all controllers |
|
||||||
|
|||||||
@@ -703,6 +703,8 @@
|
|||||||
"error_invite_exhausted": "Dieser Einladungslink wurde bereits vollständig verwendet.",
|
"error_invite_exhausted": "Dieser Einladungslink wurde bereits vollständig verwendet.",
|
||||||
"error_invite_revoked": "Dieser Einladungslink wurde deaktiviert.",
|
"error_invite_revoked": "Dieser Einladungslink wurde deaktiviert.",
|
||||||
"error_invite_expired": "Dieser Einladungslink ist abgelaufen.",
|
"error_invite_expired": "Dieser Einladungslink ist abgelaufen.",
|
||||||
|
"error_group_has_active_invites": "Diese Gruppe kann nicht gelöscht werden, da sie in einer aktiven Einladung verwendet wird.",
|
||||||
|
"error_group_not_found": "Die angegebene Gruppe existiert nicht.",
|
||||||
"register_heading": "Konto erstellen",
|
"register_heading": "Konto erstellen",
|
||||||
"register_subtext": "Du wurdest eingeladen, dem Familienarchiv beizutreten.",
|
"register_subtext": "Du wurdest eingeladen, dem Familienarchiv beizutreten.",
|
||||||
"register_label_first_name": "Vorname",
|
"register_label_first_name": "Vorname",
|
||||||
@@ -762,6 +764,9 @@
|
|||||||
"admin_new_invite_prefill_last": "Nachname 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_prefill_email": "E-Mail vorausfüllen (optional)",
|
||||||
"admin_new_invite_expires": "Ablaufdatum (optional)",
|
"admin_new_invite_expires": "Ablaufdatum (optional)",
|
||||||
|
"admin_new_invite_groups": "Gruppen (optional)",
|
||||||
|
"admin_new_invite_no_groups": "Keine Gruppen vorhanden.",
|
||||||
|
"admin_invite_groups_load_error": "Gruppen konnten nicht geladen werden. Die Einladung kann ohne Gruppenauswahl erstellt werden.",
|
||||||
"admin_invite_created_title": "Einladung erstellt",
|
"admin_invite_created_title": "Einladung erstellt",
|
||||||
"admin_invite_created_desc": "Teile diesen Link mit der einzuladenden Person:",
|
"admin_invite_created_desc": "Teile diesen Link mit der einzuladenden Person:",
|
||||||
"admin_invite_revoke_confirm": "Einladung wirklich widerrufen?",
|
"admin_invite_revoke_confirm": "Einladung wirklich widerrufen?",
|
||||||
|
|||||||
@@ -703,6 +703,8 @@
|
|||||||
"error_invite_exhausted": "This invite link has already been fully used.",
|
"error_invite_exhausted": "This invite link has already been fully used.",
|
||||||
"error_invite_revoked": "This invite link has been deactivated.",
|
"error_invite_revoked": "This invite link has been deactivated.",
|
||||||
"error_invite_expired": "This invite link has expired.",
|
"error_invite_expired": "This invite link has expired.",
|
||||||
|
"error_group_has_active_invites": "This group cannot be deleted because it is referenced by one or more active invite links.",
|
||||||
|
"error_group_not_found": "The specified group does not exist.",
|
||||||
"register_heading": "Create account",
|
"register_heading": "Create account",
|
||||||
"register_subtext": "You've been invited to join Familienarchiv.",
|
"register_subtext": "You've been invited to join Familienarchiv.",
|
||||||
"register_label_first_name": "First name",
|
"register_label_first_name": "First name",
|
||||||
@@ -762,6 +764,9 @@
|
|||||||
"admin_new_invite_prefill_last": "Pre-fill last name (optional)",
|
"admin_new_invite_prefill_last": "Pre-fill last name (optional)",
|
||||||
"admin_new_invite_prefill_email": "Pre-fill email (optional)",
|
"admin_new_invite_prefill_email": "Pre-fill email (optional)",
|
||||||
"admin_new_invite_expires": "Expiry date (optional)",
|
"admin_new_invite_expires": "Expiry date (optional)",
|
||||||
|
"admin_new_invite_groups": "Groups (optional)",
|
||||||
|
"admin_new_invite_no_groups": "No groups exist.",
|
||||||
|
"admin_invite_groups_load_error": "Groups could not be loaded. The invite can still be created without group assignment.",
|
||||||
"admin_invite_created_title": "Invite created",
|
"admin_invite_created_title": "Invite created",
|
||||||
"admin_invite_created_desc": "Share this link with the person you are inviting:",
|
"admin_invite_created_desc": "Share this link with the person you are inviting:",
|
||||||
"admin_invite_revoke_confirm": "Really revoke this invite?",
|
"admin_invite_revoke_confirm": "Really revoke this invite?",
|
||||||
|
|||||||
@@ -703,6 +703,8 @@
|
|||||||
"error_invite_exhausted": "Este enlace de invitación ya ha sido completamente utilizado.",
|
"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_revoked": "Este enlace de invitación ha sido desactivado.",
|
||||||
"error_invite_expired": "Este enlace de invitación ha expirado.",
|
"error_invite_expired": "Este enlace de invitación ha expirado.",
|
||||||
|
"error_group_has_active_invites": "Este grupo no puede eliminarse porque está referenciado por uno o más enlaces de invitación activos.",
|
||||||
|
"error_group_not_found": "El grupo especificado no existe.",
|
||||||
"register_heading": "Crear cuenta",
|
"register_heading": "Crear cuenta",
|
||||||
"register_subtext": "Has sido invitado a unirte al Familienarchiv.",
|
"register_subtext": "Has sido invitado a unirte al Familienarchiv.",
|
||||||
"register_label_first_name": "Nombre",
|
"register_label_first_name": "Nombre",
|
||||||
@@ -762,6 +764,9 @@
|
|||||||
"admin_new_invite_prefill_last": "Prellenar apellido (opcional)",
|
"admin_new_invite_prefill_last": "Prellenar apellido (opcional)",
|
||||||
"admin_new_invite_prefill_email": "Prellenar correo (opcional)",
|
"admin_new_invite_prefill_email": "Prellenar correo (opcional)",
|
||||||
"admin_new_invite_expires": "Fecha de vencimiento (opcional)",
|
"admin_new_invite_expires": "Fecha de vencimiento (opcional)",
|
||||||
|
"admin_new_invite_groups": "Grupos (opcional)",
|
||||||
|
"admin_new_invite_no_groups": "No hay grupos disponibles.",
|
||||||
|
"admin_invite_groups_load_error": "No se pudieron cargar los grupos. La invitación puede crearse sin asignar grupos.",
|
||||||
"admin_invite_created_title": "Invitación creada",
|
"admin_invite_created_title": "Invitación creada",
|
||||||
"admin_invite_created_desc": "Comparte este enlace con la persona invitada:",
|
"admin_invite_created_desc": "Comparte este enlace con la persona invitada:",
|
||||||
"admin_invite_revoke_confirm": "¿Realmente revocar esta invitación?",
|
"admin_invite_revoke_confirm": "¿Realmente revocar esta invitación?",
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export type ErrorCode =
|
|||||||
| 'INVITE_EXHAUSTED'
|
| 'INVITE_EXHAUSTED'
|
||||||
| 'INVITE_REVOKED'
|
| 'INVITE_REVOKED'
|
||||||
| 'INVITE_EXPIRED'
|
| 'INVITE_EXPIRED'
|
||||||
|
| 'GROUP_HAS_ACTIVE_INVITES'
|
||||||
|
| 'GROUP_NOT_FOUND'
|
||||||
| 'ANNOTATION_NOT_FOUND'
|
| 'ANNOTATION_NOT_FOUND'
|
||||||
| 'ANNOTATION_UPDATE_FAILED'
|
| 'ANNOTATION_UPDATE_FAILED'
|
||||||
| 'TRANSCRIPTION_BLOCK_NOT_FOUND'
|
| 'TRANSCRIPTION_BLOCK_NOT_FOUND'
|
||||||
@@ -108,6 +110,10 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
|
|||||||
return m.error_invite_revoked();
|
return m.error_invite_revoked();
|
||||||
case 'INVITE_EXPIRED':
|
case 'INVITE_EXPIRED':
|
||||||
return m.error_invite_expired();
|
return m.error_invite_expired();
|
||||||
|
case 'GROUP_HAS_ACTIVE_INVITES':
|
||||||
|
return m.error_group_has_active_invites();
|
||||||
|
case 'GROUP_NOT_FOUND':
|
||||||
|
return m.error_group_not_found();
|
||||||
case 'ANNOTATION_NOT_FOUND':
|
case 'ANNOTATION_NOT_FOUND':
|
||||||
return m.error_annotation_not_found();
|
return m.error_annotation_not_found();
|
||||||
case 'ANNOTATION_UPDATE_FAILED':
|
case 'ANNOTATION_UPDATE_FAILED':
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { untrack } from 'svelte';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
groups,
|
groups,
|
||||||
selectedGroupIds = []
|
selectedGroupIds = []
|
||||||
@@ -7,12 +10,13 @@ let {
|
|||||||
selectedGroupIds?: string[];
|
selectedGroupIds?: string[];
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let selected = $derived([...selectedGroupIds]);
|
let selected = $state<string[]>(untrack(() => [...selectedGroupIds]));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-3">
|
<fieldset class="flex flex-wrap gap-3 border-none p-0">
|
||||||
|
<legend class="sr-only">{m.admin_new_invite_groups()}</legend>
|
||||||
{#each groups as group (group.id)}
|
{#each groups as group (group.id)}
|
||||||
<label class="inline-flex items-center gap-2 text-sm text-ink-2">
|
<label class="inline-flex min-h-[44px] items-center gap-2 text-sm text-ink-2">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
name="groupIds"
|
name="groupIds"
|
||||||
@@ -23,4 +27,4 @@ let selected = $derived([...selectedGroupIds]);
|
|||||||
{group.name}
|
{group.name}
|
||||||
</label>
|
</label>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</fieldset>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { fail } from '@sveltejs/kit';
|
|||||||
import { env } from '$env/dynamic/private';
|
import { env } from '$env/dynamic/private';
|
||||||
import { parseBackendError } from '$lib/shared/errors';
|
import { parseBackendError } from '$lib/shared/errors';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
export interface InviteListItem {
|
export interface InviteListItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -17,22 +18,37 @@ export interface InviteListItem {
|
|||||||
shareableUrl: string;
|
shareableUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type UserGroup = components['schemas']['UserGroup'];
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ url, fetch }) => {
|
export const load: PageServerLoad = async ({ url, fetch }) => {
|
||||||
const status = url.searchParams.get('status') ?? 'active';
|
const status = url.searchParams.get('status') ?? 'active';
|
||||||
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||||
const res = await fetch(`${apiUrl}/api/invites?status=${encodeURIComponent(status)}`);
|
|
||||||
|
|
||||||
if (!res.ok) {
|
const [invitesRes, groupsRes] = await Promise.all([
|
||||||
const backendError = await parseBackendError(res);
|
fetch(`${apiUrl}/api/invites?status=${encodeURIComponent(status)}`),
|
||||||
return {
|
fetch(`${apiUrl}/api/groups`)
|
||||||
invites: [] as InviteListItem[],
|
]);
|
||||||
status,
|
|
||||||
loadError: backendError?.code ?? 'INTERNAL_ERROR'
|
let invites: InviteListItem[] = [];
|
||||||
};
|
let loadError: string | null = null;
|
||||||
|
if (!invitesRes.ok) {
|
||||||
|
const backendError = await parseBackendError(invitesRes);
|
||||||
|
loadError = backendError?.code ?? 'INTERNAL_ERROR';
|
||||||
|
} else {
|
||||||
|
invites = await invitesRes.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
const invites: InviteListItem[] = await res.json();
|
let groups: UserGroup[] = [];
|
||||||
return { invites, status, loadError: null };
|
let groupsLoadError: string | null = null;
|
||||||
|
if (!groupsRes.ok) {
|
||||||
|
const backendError = await parseBackendError(groupsRes);
|
||||||
|
groupsLoadError = backendError?.code ?? 'INTERNAL_ERROR';
|
||||||
|
} else {
|
||||||
|
const raw: UserGroup[] = await groupsRes.json();
|
||||||
|
groups = [...raw].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { invites, status, loadError, groups, groupsLoadError };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
@@ -45,6 +61,7 @@ export const actions = {
|
|||||||
const prefillLastName = (formData.get('prefillLastName') as string) || undefined;
|
const prefillLastName = (formData.get('prefillLastName') as string) || undefined;
|
||||||
const prefillEmail = (formData.get('prefillEmail') as string) || undefined;
|
const prefillEmail = (formData.get('prefillEmail') as string) || undefined;
|
||||||
const expiresAt = (formData.get('expiresAt') as string) || undefined;
|
const expiresAt = (formData.get('expiresAt') as string) || undefined;
|
||||||
|
const groupIds = formData.getAll('groupIds') as string[];
|
||||||
|
|
||||||
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||||
const res = await fetch(`${apiUrl}/api/invites`, {
|
const res = await fetch(`${apiUrl}/api/invites`, {
|
||||||
@@ -56,7 +73,8 @@ export const actions = {
|
|||||||
prefillFirstName,
|
prefillFirstName,
|
||||||
prefillLastName,
|
prefillLastName,
|
||||||
prefillEmail,
|
prefillEmail,
|
||||||
expiresAt
|
expiresAt,
|
||||||
|
groupIds
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { getErrorMessage } from '$lib/shared/errors';
|
import { getErrorMessage } from '$lib/shared/errors';
|
||||||
import type { InviteListItem } from './+page.server.ts';
|
import UserGroupsSection from '$lib/user/UserGroupsSection.svelte';
|
||||||
|
import type { InviteListItem, UserGroup } from './+page.server.ts';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
data,
|
data,
|
||||||
@@ -12,6 +13,8 @@ let {
|
|||||||
invites: InviteListItem[];
|
invites: InviteListItem[];
|
||||||
status: string;
|
status: string;
|
||||||
loadError: string | null;
|
loadError: string | null;
|
||||||
|
groups: UserGroup[];
|
||||||
|
groupsLoadError: string | null;
|
||||||
};
|
};
|
||||||
form?: {
|
form?: {
|
||||||
createError?: string;
|
createError?: string;
|
||||||
@@ -253,6 +256,23 @@ function statusIcon(status: string) {
|
|||||||
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"
|
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>
|
||||||
|
<div class="sm:col-span-2">
|
||||||
|
<p class="mb-2 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
|
{m.admin_new_invite_groups()}
|
||||||
|
</p>
|
||||||
|
{#if data.groupsLoadError}
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
class="rounded-sm border border-amber-200 bg-amber-50 px-3 py-2 font-sans text-xs text-amber-700"
|
||||||
|
>
|
||||||
|
{m.admin_invite_groups_load_error()}
|
||||||
|
</div>
|
||||||
|
{:else if data.groups.length === 0}
|
||||||
|
<p class="font-sans text-xs text-ink-3 italic">{m.admin_new_invite_no_groups()}</p>
|
||||||
|
{:else}
|
||||||
|
<UserGroupsSection groups={data.groups} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{#if form?.createError}
|
{#if form?.createError}
|
||||||
<div class="font-sans text-xs font-medium text-red-600 sm:col-span-2">
|
<div class="font-sans text-xs font-medium text-red-600 sm:col-span-2">
|
||||||
{getErrorMessage(form.createError)}
|
{getErrorMessage(form.createError)}
|
||||||
|
|||||||
155
frontend/src/routes/admin/invites/page.server.test.ts
Normal file
155
frontend/src/routes/admin/invites/page.server.test.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('$env/dynamic/private', () => ({
|
||||||
|
env: { API_INTERNAL_URL: 'http://localhost:8080' }
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { load, actions } from './+page.server';
|
||||||
|
import type { UserGroup } from './+page.server';
|
||||||
|
|
||||||
|
// PageServerLoad annotates the return as `void | (...)`. This explicit shape avoids
|
||||||
|
// the void and the Record<string, any> from the generic constraint.
|
||||||
|
type LoadData = {
|
||||||
|
invites: unknown[];
|
||||||
|
status: string;
|
||||||
|
loadError: string | null;
|
||||||
|
groups: UserGroup[];
|
||||||
|
groupsLoadError: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
type AnyFetch = (...args: any[]) => any;
|
||||||
|
|
||||||
|
function mockResponse(ok: boolean, body: unknown, status = 200) {
|
||||||
|
return {
|
||||||
|
ok,
|
||||||
|
status,
|
||||||
|
json: async () => body,
|
||||||
|
text: async () => JSON.stringify(body),
|
||||||
|
headers: new Headers({ 'content-type': 'application/json' })
|
||||||
|
} as unknown as Response;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('admin/invites load()', () => {
|
||||||
|
const mockFetch = vi.fn<AnyFetch>();
|
||||||
|
|
||||||
|
beforeEach(() => mockFetch.mockReset());
|
||||||
|
|
||||||
|
function event(status = 'active') {
|
||||||
|
return {
|
||||||
|
url: new URL(`http://localhost/admin/invites?status=${status}`),
|
||||||
|
fetch: mockFetch as unknown as typeof fetch
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('returns groups array alongside invites when both succeed', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(mockResponse(true, [])).mockResolvedValueOnce(
|
||||||
|
mockResponse(true, [
|
||||||
|
{ id: 'g-1', name: 'Familie', permissions: ['READ_ALL'] },
|
||||||
|
{ id: 'g-2', name: 'Administratoren', permissions: ['ADMIN'] }
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = (await load(event())) as LoadData;
|
||||||
|
|
||||||
|
expect(result.groups).toHaveLength(2);
|
||||||
|
expect(result.groupsLoadError).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns groups sorted alphabetically by name', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(mockResponse(true, [])).mockResolvedValueOnce(
|
||||||
|
mockResponse(true, [
|
||||||
|
{ id: 'g-1', name: 'Zebra', permissions: [] },
|
||||||
|
{ id: 'g-2', name: 'Alfa', permissions: [] },
|
||||||
|
{ id: 'g-3', name: 'Mitte', permissions: [] }
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = (await load(event())) as LoadData;
|
||||||
|
|
||||||
|
expect(result.groups.map((g) => g.name)).toEqual(['Alfa', 'Mitte', 'Zebra']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns groups: [] and non-null groupsLoadError when groups fetch is non-OK', async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(mockResponse(true, []))
|
||||||
|
.mockResolvedValueOnce(mockResponse(false, { code: 'FORBIDDEN' }, 403));
|
||||||
|
|
||||||
|
const result = (await load(event())) as LoadData;
|
||||||
|
|
||||||
|
expect(result.groups).toEqual([]);
|
||||||
|
expect(result.groupsLoadError).toBe('FORBIDDEN');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to INTERNAL_ERROR when groups error body has no code', async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(mockResponse(true, []))
|
||||||
|
.mockResolvedValueOnce(mockResponse(false, null, 500));
|
||||||
|
|
||||||
|
const result = (await load(event())) as LoadData;
|
||||||
|
|
||||||
|
expect(result.groupsLoadError).toBe('INTERNAL_ERROR');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches invites and groups in parallel (both URLs called)', async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(mockResponse(true, []))
|
||||||
|
.mockResolvedValueOnce(mockResponse(true, []));
|
||||||
|
|
||||||
|
await load(event());
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('/api/invites'));
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('/api/groups'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('admin/invites create action', () => {
|
||||||
|
const mockFetch = vi.fn<AnyFetch>();
|
||||||
|
|
||||||
|
beforeEach(() => mockFetch.mockReset());
|
||||||
|
|
||||||
|
const successBody = {
|
||||||
|
id: 'inv-1',
|
||||||
|
code: 'ABCDE12345',
|
||||||
|
displayCode: 'ABCDE-12345',
|
||||||
|
status: 'active',
|
||||||
|
revoked: false,
|
||||||
|
useCount: 0,
|
||||||
|
createdAt: '2026-01-01T00:00:00Z',
|
||||||
|
shareableUrl: 'http://localhost/register?code=ABCDE12345'
|
||||||
|
};
|
||||||
|
|
||||||
|
it('includes groupIds array in POST body when checkboxes are checked', async () => {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('groupIds', 'g-1');
|
||||||
|
fd.append('groupIds', 'g-2');
|
||||||
|
mockFetch.mockResolvedValueOnce(mockResponse(true, successBody, 201));
|
||||||
|
|
||||||
|
await actions.create({
|
||||||
|
request: new Request('http://localhost', { method: 'POST', body: fd }),
|
||||||
|
fetch: mockFetch as unknown as typeof fetch
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||||
|
const sent = JSON.parse(init.body as string);
|
||||||
|
expect(sent.groupIds).toEqual(['g-1', 'g-2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends groupIds: [] when no checkboxes are checked', async () => {
|
||||||
|
const fd = new FormData();
|
||||||
|
mockFetch.mockResolvedValueOnce(mockResponse(true, successBody, 201));
|
||||||
|
|
||||||
|
await actions.create({
|
||||||
|
request: new Request('http://localhost', { method: 'POST', body: fd }),
|
||||||
|
fetch: mockFetch as unknown as typeof fetch
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||||
|
const sent = JSON.parse(init.body as string);
|
||||||
|
expect(sent.groupIds).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,12 +7,15 @@ afterEach(cleanup);
|
|||||||
|
|
||||||
const makeInvite = (overrides: Record<string, unknown> = {}) => ({
|
const makeInvite = (overrides: Record<string, unknown> = {}) => ({
|
||||||
id: 'i-1',
|
id: 'i-1',
|
||||||
|
code: 'XYZ1234567',
|
||||||
displayCode: 'XYZ-1234',
|
displayCode: 'XYZ-1234',
|
||||||
label: 'Familie',
|
label: 'Familie',
|
||||||
useCount: 0,
|
useCount: 0,
|
||||||
maxUses: 5,
|
maxUses: 5,
|
||||||
expiresAt: '2027-01-01T00:00:00Z',
|
expiresAt: '2027-01-01T00:00:00Z',
|
||||||
|
revoked: false,
|
||||||
status: 'active' as string,
|
status: 'active' as string,
|
||||||
|
createdAt: '2025-01-01T00:00:00Z',
|
||||||
shareableUrl: 'http://example.com/i/i-1',
|
shareableUrl: 'http://example.com/i/i-1',
|
||||||
...overrides
|
...overrides
|
||||||
});
|
});
|
||||||
@@ -22,11 +25,15 @@ const baseData = (
|
|||||||
invites: ReturnType<typeof makeInvite>[];
|
invites: ReturnType<typeof makeInvite>[];
|
||||||
status: string;
|
status: string;
|
||||||
loadError: string | null;
|
loadError: string | null;
|
||||||
|
groups: { id: string; name: string; permissions: string[] }[];
|
||||||
|
groupsLoadError: string | null;
|
||||||
}> = {}
|
}> = {}
|
||||||
) => ({
|
) => ({
|
||||||
invites: [],
|
invites: [],
|
||||||
status: 'active',
|
status: 'active',
|
||||||
loadError: null,
|
loadError: null,
|
||||||
|
groups: [],
|
||||||
|
groupsLoadError: null,
|
||||||
...overrides
|
...overrides
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -253,4 +260,115 @@ describe('admin/invites page', () => {
|
|||||||
const banner = document.querySelector('.bg-red-50');
|
const banner = document.querySelector('.bg-red-50');
|
||||||
expect(banner).not.toBeNull();
|
expect(banner).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── groups section ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('shows a groups-load warning banner when data.groupsLoadError is set', async () => {
|
||||||
|
render(AdminInvitesPage, {
|
||||||
|
props: { data: { ...baseData(), groups: [], groupsLoadError: 'INTERNAL_ERROR' } }
|
||||||
|
});
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: /neue einladung/i })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
|
||||||
|
const banner = document.querySelector('.bg-amber-50');
|
||||||
|
expect(banner).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders group checkboxes inside the new-invite form when groups are provided', async () => {
|
||||||
|
render(AdminInvitesPage, {
|
||||||
|
props: {
|
||||||
|
data: {
|
||||||
|
...baseData(),
|
||||||
|
groups: [
|
||||||
|
{ id: 'g-1', name: 'Administratoren', permissions: ['ADMIN'] },
|
||||||
|
{ id: 'g-2', name: 'Familie', permissions: ['READ_ALL'] }
|
||||||
|
],
|
||||||
|
groupsLoadError: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: /neue einladung/i })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
|
||||||
|
await expect.element(page.getByRole('checkbox', { name: 'Administratoren' })).toBeVisible();
|
||||||
|
await expect.element(page.getByRole('checkbox', { name: 'Familie' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('group checkbox stays checked after being clicked', async () => {
|
||||||
|
render(AdminInvitesPage, {
|
||||||
|
props: {
|
||||||
|
data: {
|
||||||
|
...baseData(),
|
||||||
|
groups: [{ id: 'g-1', name: 'Familie', permissions: ['READ_ALL'] }],
|
||||||
|
groupsLoadError: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: /neue einladung/i })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
|
||||||
|
const checkbox = page.getByRole('checkbox', { name: 'Familie' });
|
||||||
|
await checkbox.click();
|
||||||
|
await expect.element(checkbox).toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('amber warning banner has role="alert"', async () => {
|
||||||
|
render(AdminInvitesPage, {
|
||||||
|
props: { data: { ...baseData(), groups: [], groupsLoadError: 'INTERNAL_ERROR' } }
|
||||||
|
});
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: /neue einladung/i })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
|
||||||
|
const alert = document.querySelector('[role="alert"]');
|
||||||
|
expect(alert).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checkbox group fieldset has accessible name from i18n key (not hardcoded German)', async () => {
|
||||||
|
render(AdminInvitesPage, {
|
||||||
|
props: {
|
||||||
|
data: {
|
||||||
|
...baseData(),
|
||||||
|
groups: [{ id: 'g-1', name: 'Familie', permissions: ['READ_ALL'] }],
|
||||||
|
groupsLoadError: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: /neue einladung/i })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
|
||||||
|
// m.admin_new_invite_groups() returns "Gruppen (optional)" in de locale
|
||||||
|
// The hardcoded legend "Gruppen" would not match this accessible name
|
||||||
|
await expect.element(page.getByRole('group', { name: 'Gruppen (optional)' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows no checkboxes and no warning when groups list is empty and no error', async () => {
|
||||||
|
render(AdminInvitesPage, {
|
||||||
|
props: { data: { ...baseData(), groups: [], groupsLoadError: null } }
|
||||||
|
});
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: /neue einladung/i })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
|
||||||
|
expect(document.querySelectorAll('input[name="groupIds"]')).toHaveLength(0);
|
||||||
|
expect(document.querySelector('.bg-amber-50')).toBeNull();
|
||||||
|
// empty-state message visible — "Keine Gruppen vorhanden." in de locale
|
||||||
|
await expect.element(page.getByText(/keine gruppen/i)).toBeVisible();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user