diff --git a/CLAUDE.md b/CLAUDE.md index 09e8d2c8..5a83c6e8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) -**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 diff --git a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java index 63b4afe4..07751700 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java @@ -30,6 +30,8 @@ public enum ErrorCode { // --- Users --- /** A user with the given ID or username does not exist. 404 */ 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 */ EMAIL_ALREADY_IN_USE, /** The supplied current password does not match the stored hash. 400 */ @@ -52,6 +54,8 @@ public enum ErrorCode { INVITE_REVOKED, /** The invite has passed its expiry date. 410 */ INVITE_EXPIRED, + /** A group cannot be deleted because one or more active invites reference it. 409 */ + GROUP_HAS_ACTIVE_INVITES, // --- Auth --- /** The request is not authenticated. 401 */ diff --git a/backend/src/main/java/org/raddatz/familienarchiv/user/InviteService.java b/backend/src/main/java/org/raddatz/familienarchiv/user/InviteService.java index e1cedf02..4cf89625 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/user/InviteService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/user/InviteService.java @@ -52,7 +52,11 @@ public class InviteService { public InviteToken createInvite(CreateInviteRequest dto, AppUser creator) { Set groupIds = new HashSet<>(); if (dto.getGroupIds() != null && !dto.getGroupIds().isEmpty()) { - List groups = userService.findGroupsByIds(dto.getGroupIds()); + Set uniqueIds = new HashSet<>(dto.getGroupIds()); + List 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())); } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/user/InviteTokenRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/user/InviteTokenRepository.java index 07771f28..7e385c34 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/user/InviteTokenRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/user/InviteTokenRepository.java @@ -24,4 +24,7 @@ public interface InviteTokenRepository extends JpaRepository @Query("SELECT t FROM InviteToken t ORDER BY t.createdAt DESC") List 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); } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/user/UserService.java b/backend/src/main/java/org/raddatz/familienarchiv/user/UserService.java index 09e3493e..897e67c9 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/user/UserService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/user/UserService.java @@ -37,6 +37,9 @@ public class UserService { private final AppUserRepository userRepository; 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 AuditService auditService; @@ -288,6 +291,10 @@ public class UserService { @Transactional 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); } } diff --git a/backend/src/main/resources/db/migration/V66__add_invite_token_group_id_index.sql b/backend/src/main/resources/db/migration/V66__add_invite_token_group_id_index.sql new file mode 100644 index 00000000..8b99b377 --- /dev/null +++ b/backend/src/main/resources/db/migration/V66__add_invite_token_group_id_index.sql @@ -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); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/user/InviteControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/user/InviteControllerTest.java index 0af2a999..03b2e641 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/user/InviteControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/user/InviteControllerTest.java @@ -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.web.servlet.MockMvc; +import org.mockito.ArgumentCaptor; + import java.time.LocalDateTime; import java.util.List; import java.util.UUID; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -147,6 +150,30 @@ class InviteControllerTest { .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 captor = ArgumentCaptor.forClass(CreateInviteRequest.class); + verify(inviteService).createInvite(captor.capture(), eq(admin)); + assertThat(captor.getValue().getGroupIds()).containsExactly(groupId); + } + // ─── DELETE /api/invites/{id} ───────────────────────────────────────────── @Test diff --git a/backend/src/test/java/org/raddatz/familienarchiv/user/InviteServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/user/InviteServiceTest.java index 8826dfd4..df74378a 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/user/InviteServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/user/InviteServiceTest.java @@ -156,6 +156,35 @@ class InviteServiceTest { 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 ───────────────────────────────────────────────────────── @Test diff --git a/backend/src/test/java/org/raddatz/familienarchiv/user/InviteTokenRepositoryIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/user/InviteTokenRepositoryIntegrationTest.java new file mode 100644 index 00000000..f5ccbd4e --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/user/InviteTokenRepositoryIntegrationTest.java @@ -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 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(); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/user/UserServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/user/UserServiceTest.java index eee87ed9..46341231 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/user/UserServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/user/UserServiceTest.java @@ -36,6 +36,7 @@ class UserServiceTest { @Mock AppUserRepository userRepository; @Mock UserGroupRepository groupRepository; + @Mock InviteTokenRepository inviteTokenRepository; @Mock PasswordEncoder passwordEncoder; @Mock AuditService auditService; @InjectMocks UserService userService; @@ -903,6 +904,29 @@ class UserServiceTest { 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 void createGroup_withNullPermissions_savesGroupWithEmptyPermissionSet() { org.raddatz.familienarchiv.user.GroupDTO dto = new org.raddatz.familienarchiv.user.GroupDTO(); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index abef192b..20af1a90 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 | | `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 | -| `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` | | `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 | diff --git a/frontend/messages/de.json b/frontend/messages/de.json index e8af8a25..b902226d 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -703,6 +703,8 @@ "error_invite_exhausted": "Dieser Einladungslink wurde bereits vollständig verwendet.", "error_invite_revoked": "Dieser Einladungslink wurde deaktiviert.", "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_subtext": "Du wurdest eingeladen, dem Familienarchiv beizutreten.", "register_label_first_name": "Vorname", @@ -762,6 +764,9 @@ "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_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_desc": "Teile diesen Link mit der einzuladenden Person:", "admin_invite_revoke_confirm": "Einladung wirklich widerrufen?", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index e448d26c..ecca8207 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -703,6 +703,8 @@ "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.", + "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_subtext": "You've been invited to join Familienarchiv.", "register_label_first_name": "First name", @@ -762,6 +764,9 @@ "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_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_desc": "Share this link with the person you are inviting:", "admin_invite_revoke_confirm": "Really revoke this invite?", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index f8f576b8..328b8c7f 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -703,6 +703,8 @@ "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.", + "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_subtext": "Has sido invitado a unirte al Familienarchiv.", "register_label_first_name": "Nombre", @@ -762,6 +764,9 @@ "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_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_desc": "Comparte este enlace con la persona invitada:", "admin_invite_revoke_confirm": "¿Realmente revocar esta invitación?", diff --git a/frontend/src/lib/shared/errors.ts b/frontend/src/lib/shared/errors.ts index cbb137c2..9703bbd9 100644 --- a/frontend/src/lib/shared/errors.ts +++ b/frontend/src/lib/shared/errors.ts @@ -22,6 +22,8 @@ export type ErrorCode = | 'INVITE_EXHAUSTED' | 'INVITE_REVOKED' | 'INVITE_EXPIRED' + | 'GROUP_HAS_ACTIVE_INVITES' + | 'GROUP_NOT_FOUND' | 'ANNOTATION_NOT_FOUND' | 'ANNOTATION_UPDATE_FAILED' | 'TRANSCRIPTION_BLOCK_NOT_FOUND' @@ -108,6 +110,10 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string { return m.error_invite_revoked(); case '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': return m.error_annotation_not_found(); case 'ANNOTATION_UPDATE_FAILED': diff --git a/frontend/src/lib/user/UserGroupsSection.svelte b/frontend/src/lib/user/UserGroupsSection.svelte index 590c3295..6e8db2d6 100644 --- a/frontend/src/lib/user/UserGroupsSection.svelte +++ b/frontend/src/lib/user/UserGroupsSection.svelte @@ -1,4 +1,7 @@ -
+
+ {m.admin_new_invite_groups()} {#each groups as group (group.id)} -
+ diff --git a/frontend/src/routes/admin/invites/+page.server.ts b/frontend/src/routes/admin/invites/+page.server.ts index e41c4922..cb33d962 100644 --- a/frontend/src/routes/admin/invites/+page.server.ts +++ b/frontend/src/routes/admin/invites/+page.server.ts @@ -2,6 +2,7 @@ import { fail } from '@sveltejs/kit'; import { env } from '$env/dynamic/private'; import { parseBackendError } from '$lib/shared/errors'; import type { Actions, PageServerLoad } from './$types'; +import type { components } from '$lib/generated/api'; export interface InviteListItem { id: string; @@ -17,22 +18,37 @@ export interface InviteListItem { shareableUrl: string; } +export type UserGroup = components['schemas']['UserGroup']; + 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 [invitesRes, groupsRes] = await Promise.all([ + fetch(`${apiUrl}/api/invites?status=${encodeURIComponent(status)}`), + fetch(`${apiUrl}/api/groups`) + ]); + + 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(); - return { invites, status, loadError: null }; + let groups: UserGroup[] = []; + 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 = { @@ -45,6 +61,7 @@ export const actions = { 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 groupIds = formData.getAll('groupIds') as string[]; const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080'; const res = await fetch(`${apiUrl}/api/invites`, { @@ -56,7 +73,8 @@ export const actions = { prefillFirstName, prefillLastName, prefillEmail, - expiresAt + expiresAt, + groupIds }) }); diff --git a/frontend/src/routes/admin/invites/+page.svelte b/frontend/src/routes/admin/invites/+page.svelte index 85ac7a5d..3fb46a3f 100644 --- a/frontend/src/routes/admin/invites/+page.svelte +++ b/frontend/src/routes/admin/invites/+page.svelte @@ -2,7 +2,8 @@ import { enhance } from '$app/forms'; import { m } from '$lib/paraglide/messages.js'; 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 { data, @@ -12,6 +13,8 @@ let { invites: InviteListItem[]; status: string; loadError: string | null; + groups: UserGroup[]; + groupsLoadError: string | null; }; form?: { 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" /> +
+

+ {m.admin_new_invite_groups()} +

+ {#if data.groupsLoadError} + + {:else if data.groups.length === 0} +

{m.admin_new_invite_no_groups()}

+ {:else} + + {/if} +
{#if form?.createError}
{getErrorMessage(form.createError)} diff --git a/frontend/src/routes/admin/invites/page.server.test.ts b/frontend/src/routes/admin/invites/page.server.test.ts new file mode 100644 index 00000000..8e66e9b0 --- /dev/null +++ b/frontend/src/routes/admin/invites/page.server.test.ts @@ -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 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(); + + 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(); + + 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([]); + }); +}); diff --git a/frontend/src/routes/admin/invites/page.svelte.test.ts b/frontend/src/routes/admin/invites/page.svelte.test.ts index 66f2f6ca..7fd0cf38 100644 --- a/frontend/src/routes/admin/invites/page.svelte.test.ts +++ b/frontend/src/routes/admin/invites/page.svelte.test.ts @@ -7,12 +7,15 @@ afterEach(cleanup); const makeInvite = (overrides: Record = {}) => ({ id: 'i-1', + code: 'XYZ1234567', displayCode: 'XYZ-1234', label: 'Familie', useCount: 0, maxUses: 5, expiresAt: '2027-01-01T00:00:00Z', + revoked: false, status: 'active' as string, + createdAt: '2025-01-01T00:00:00Z', shareableUrl: 'http://example.com/i/i-1', ...overrides }); @@ -22,11 +25,15 @@ const baseData = ( invites: ReturnType[]; status: string; loadError: string | null; + groups: { id: string; name: string; permissions: string[] }[]; + groupsLoadError: string | null; }> = {} ) => ({ invites: [], status: 'active', loadError: null, + groups: [], + groupsLoadError: null, ...overrides }); @@ -253,4 +260,115 @@ describe('admin/invites page', () => { const banner = document.querySelector('.bg-red-50'); 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(); + }); });