From d7fcbfd4d9b4d26cacc4589d394a56fd3189ca03 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 14 May 2026 14:57:06 +0200 Subject: [PATCH 01/16] feat(groups): prevent deletion of groups referenced by active invites Adds GROUP_HAS_ACTIVE_INVITES error code and guards UserService.deleteGroup() with a 409 conflict when any active (non-revoked, non-expired, non-exhausted) invite token still holds the group UUID. Co-Authored-By: Claude Sonnet 4.6 --- .../familienarchiv/exception/ErrorCode.java | 2 ++ .../user/InviteTokenRepository.java | 3 +++ .../familienarchiv/user/UserService.java | 5 ++++ .../familienarchiv/user/UserServiceTest.java | 24 +++++++++++++++++++ 4 files changed, 34 insertions(+) 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..46ad1cd0 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java @@ -52,6 +52,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/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..96cb98df 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,7 @@ public class UserService { private final AppUserRepository userRepository; private final UserGroupRepository groupRepository; + private final InviteTokenRepository inviteTokenRepository; private final PasswordEncoder passwordEncoder; private final AuditService auditService; @@ -288,6 +289,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/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(); -- 2.49.1 From a1b319a535bf00265e11898f928eaa91fc931280 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 14 May 2026 15:19:59 +0200 Subject: [PATCH 02/16] feat(frontend): add GROUP_HAS_ACTIVE_INVITES error code + i18n keys Adds the error code to the ErrorCode union and getErrorMessage() switch. Adds admin_new_invite_groups, admin_invite_groups_load_error, and error_group_has_active_invites to all three locale files (de/en/es). Co-Authored-By: Claude Sonnet 4.6 --- frontend/messages/de.json | 3 +++ frontend/messages/en.json | 3 +++ frontend/messages/es.json | 3 +++ frontend/src/lib/shared/errors.ts | 3 +++ 4 files changed, 12 insertions(+) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index e8af8a25..727995da 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -703,6 +703,7 @@ "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.", "register_heading": "Konto erstellen", "register_subtext": "Du wurdest eingeladen, dem Familienarchiv beizutreten.", "register_label_first_name": "Vorname", @@ -762,6 +763,8 @@ "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_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..7bbc2ad2 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -703,6 +703,7 @@ "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.", "register_heading": "Create account", "register_subtext": "You've been invited to join Familienarchiv.", "register_label_first_name": "First name", @@ -762,6 +763,8 @@ "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_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..aa5516d1 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -703,6 +703,7 @@ "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.", "register_heading": "Crear cuenta", "register_subtext": "Has sido invitado a unirte al Familienarchiv.", "register_label_first_name": "Nombre", @@ -762,6 +763,8 @@ "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_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..011be07e 100644 --- a/frontend/src/lib/shared/errors.ts +++ b/frontend/src/lib/shared/errors.ts @@ -22,6 +22,7 @@ export type ErrorCode = | 'INVITE_EXHAUSTED' | 'INVITE_REVOKED' | 'INVITE_EXPIRED' + | 'GROUP_HAS_ACTIVE_INVITES' | 'ANNOTATION_NOT_FOUND' | 'ANNOTATION_UPDATE_FAILED' | 'TRANSCRIPTION_BLOCK_NOT_FOUND' @@ -108,6 +109,8 @@ 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 'ANNOTATION_NOT_FOUND': return m.error_annotation_not_found(); case 'ANNOTATION_UPDATE_FAILED': -- 2.49.1 From 82e61291d4749ae29e588917a73a0eae55a971b8 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 14 May 2026 15:20:53 +0200 Subject: [PATCH 03/16] feat(invites): group picker in new-invite form - load() fetches /api/groups in parallel with /api/invites; returns sorted groups array and groupsLoadError for partial failures - create action forwards groupIds[] to POST /api/invites so invited users are placed in the selected groups on registration - +page.svelte: group checkboxes via UserGroupsSection inside the form; amber warning banner when groups could not be loaded - page.svelte.test.ts: groups checkboxes + warning banner tests - page.server.test.ts: parallel fetch, sorting, error fallback, groupIds in POST body Co-Authored-By: Claude Sonnet 4.6 --- .../src/routes/admin/invites/+page.server.ts | 40 ++- .../src/routes/admin/invites/+page.svelte | 19 +- .../routes/admin/invites/page.server.test.ts | 231 ++++++++++++++++++ .../routes/admin/invites/page.svelte.test.ts | 46 ++++ 4 files changed, 324 insertions(+), 12 deletions(-) create mode 100644 frontend/src/routes/admin/invites/page.server.test.ts 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..9ff2efa4 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,20 @@ 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} +
+ {m.admin_invite_groups_load_error()} +
+ {: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..1ba43a93 --- /dev/null +++ b/frontend/src/routes/admin/invites/page.server.test.ts @@ -0,0 +1,231 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Test the load and create action logic in isolation without importing the actual module +// (which depends on SvelteKit env and $types at runtime). +// We replicate the exact logic here to test the branching behaviour. + +const API_URL = 'http://localhost:8080'; + +interface InviteListItem { + id: string; + code: string; + displayCode: string; + label?: string; + useCount: number; + maxUses?: number; + expiresAt?: string; + revoked: boolean; + status: string; + createdAt: string; + shareableUrl: string; +} + +interface UserGroup { + id: string; + name: string; + permissions: string[]; +} + +interface MockResponse { + ok: boolean; + status?: number; + json: () => Promise; +} + +async function loadFn( + status: string, + fetchImpl: (url: string) => Promise +): Promise<{ + invites: InviteListItem[]; + status: string; + loadError: string | null; + groups: UserGroup[]; + groupsLoadError: string | null; +}> { + const [invitesRes, groupsRes] = await Promise.all([ + fetchImpl(`${API_URL}/api/invites?status=${encodeURIComponent(status)}`), + fetchImpl(`${API_URL}/api/groups`) + ]); + + let invites: InviteListItem[] = []; + let loadError: string | null = null; + if (!invitesRes.ok) { + const body = (await invitesRes.json()) as { code?: string } | null; + loadError = body?.code ?? 'INTERNAL_ERROR'; + } else { + invites = (await invitesRes.json()) as InviteListItem[]; + } + + let groups: UserGroup[] = []; + let groupsLoadError: string | null = null; + if (!groupsRes.ok) { + const body = (await groupsRes.json()) as { code?: string } | null; + groupsLoadError = body?.code ?? 'INTERNAL_ERROR'; + } else { + const raw = (await groupsRes.json()) as UserGroup[]; + groups = [...raw].sort((a, b) => a.name.localeCompare(b.name)); + } + + return { invites, status, loadError, groups, groupsLoadError }; +} + +async function createActionFn( + formData: FormData, + fetchImpl: (url: string, init: RequestInit) => Promise +): Promise<{ ok: boolean; body: unknown }> { + 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 groupIds = formData.getAll('groupIds') as string[]; + + const res = await fetchImpl(`${API_URL}/api/invites`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + label, + maxUses, + prefillFirstName, + prefillLastName, + prefillEmail, + expiresAt, + groupIds + }) + }); + + const body = await res.json(); + return { ok: res.ok, body }; +} + +describe('admin/invites load()', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const mockFetch = vi.fn(); + + beforeEach(() => mockFetch.mockReset()); + + it('returns groups array alongside invites when both succeed', async () => { + const invites: InviteListItem[] = []; + const groups: UserGroup[] = [ + { id: 'g-1', name: 'Familie', permissions: ['READ_ALL'] }, + { id: 'g-2', name: 'Administratoren', permissions: ['ADMIN'] } + ]; + + mockFetch + .mockResolvedValueOnce({ ok: true, json: async () => invites }) + .mockResolvedValueOnce({ ok: true, json: async () => groups }); + + const result = await loadFn( + 'active', + mockFetch as unknown as (url: string) => Promise + ); + + expect(result.groups).toHaveLength(2); + expect(result.groupsLoadError).toBeNull(); + }); + + it('returns groups sorted alphabetically by name', async () => { + const groups: UserGroup[] = [ + { id: 'g-1', name: 'Zebra', permissions: [] }, + { id: 'g-2', name: 'Alfa', permissions: [] }, + { id: 'g-3', name: 'Mitte', permissions: [] } + ]; + + mockFetch + .mockResolvedValueOnce({ ok: true, json: async () => [] }) + .mockResolvedValueOnce({ ok: true, json: async () => groups }); + + const result = await loadFn( + 'active', + mockFetch as unknown as (url: string) => Promise + ); + + 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({ ok: true, json: async () => [] }) + .mockResolvedValueOnce({ ok: false, json: async () => ({ code: 'FORBIDDEN' }) }); + + const result = await loadFn( + 'active', + mockFetch as unknown as (url: string) => Promise + ); + + 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({ ok: true, json: async () => [] }) + .mockResolvedValueOnce({ ok: false, json: async () => null }); + + const result = await loadFn( + 'active', + mockFetch as unknown as (url: string) => Promise + ); + + expect(result.groupsLoadError).toBe('INTERNAL_ERROR'); + }); + + it('fetches invites and groups in parallel (both URLs called)', async () => { + mockFetch + .mockResolvedValueOnce({ ok: true, json: async () => [] }) + .mockResolvedValueOnce({ ok: true, json: async () => [] }); + + await loadFn('active', mockFetch as unknown as (url: string) => Promise); + + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('/api/invites')); + expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('/api/groups')); + }); +}); + +describe('admin/invites create action', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const mockFetch = vi.fn(); + + beforeEach(() => mockFetch.mockReset()); + + 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({ + ok: true, + json: async () => ({ id: 'inv-1', code: 'ABCDE12345' }) + }); + + await createActionFn( + fd, + mockFetch as unknown as (url: string, init: RequestInit) => Promise + ); + + 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({ + ok: true, + json: async () => ({ id: 'inv-1', code: 'ABCDE12345' }) + }); + + await createActionFn( + fd, + mockFetch as unknown as (url: string, init: RequestInit) => Promise + ); + + 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..61ec8801 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,43 @@ 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(); + }); }); -- 2.49.1 From 1e31db57a9907e4cae2972a0803f9e54c4e7e611 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 14 May 2026 15:59:37 +0200 Subject: [PATCH 04/16] =?UTF-8?q?fix(invites):=20make=20group=20checkboxes?= =?UTF-8?q?=20writable=20=E2=80=94=20$derived=20=E2=86=92=20$state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bind:group requires a writable $state variable; $derived is read-only in Svelte 5, so every click was silently reset to unchecked, making the group picker non-functional. Also wraps checkboxes in
/ for WCAG 1.3.1 compliance. Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/user/UserGroupsSection.svelte | 7 ++-- .../routes/admin/invites/page.svelte.test.ts | 35 +++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/user/UserGroupsSection.svelte b/frontend/src/lib/user/UserGroupsSection.svelte index 590c3295..e9b2cd5c 100644 --- a/frontend/src/lib/user/UserGroupsSection.svelte +++ b/frontend/src/lib/user/UserGroupsSection.svelte @@ -7,10 +7,11 @@ let { selectedGroupIds?: string[]; } = $props(); -let selected = $derived([...selectedGroupIds]); +let selected = $state([...selectedGroupIds]); -
+
+ Gruppen {#each groups as group (group.id)}
+
diff --git a/frontend/src/routes/admin/invites/page.svelte.test.ts b/frontend/src/routes/admin/invites/page.svelte.test.ts index 61ec8801..84304006 100644 --- a/frontend/src/routes/admin/invites/page.svelte.test.ts +++ b/frontend/src/routes/admin/invites/page.svelte.test.ts @@ -299,4 +299,39 @@ describe('admin/invites page', () => { 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(); + }); }); -- 2.49.1 From 79698f8eb2918bbbc544a38677e85620baa478e7 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 14 May 2026 16:00:19 +0200 Subject: [PATCH 05/16] fix(invites): add role="alert" to groups-load-error banner Screen readers now announce the amber warning when it appears after the form expands, without requiring the user to navigate to it. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/admin/invites/+page.svelte | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/routes/admin/invites/+page.svelte b/frontend/src/routes/admin/invites/+page.svelte index 9ff2efa4..bd4c8f3e 100644 --- a/frontend/src/routes/admin/invites/+page.svelte +++ b/frontend/src/routes/admin/invites/+page.svelte @@ -262,6 +262,7 @@ function statusIcon(status: string) {

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

{m.admin_new_invite_no_groups()}

{:else} {/if} diff --git a/frontend/src/routes/admin/invites/page.svelte.test.ts b/frontend/src/routes/admin/invites/page.svelte.test.ts index 4c42f868..7fd0cf38 100644 --- a/frontend/src/routes/admin/invites/page.svelte.test.ts +++ b/frontend/src/routes/admin/invites/page.svelte.test.ts @@ -368,5 +368,7 @@ describe('admin/invites page', () => { 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(); }); }); -- 2.49.1 From 688ece814aad9b16cd6bbb406796d2eaa557065f Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 14 May 2026 16:54:04 +0200 Subject: [PATCH 15/16] test(invites): verify groupIds are forwarded from request body in InviteController Co-Authored-By: Claude Sonnet 4.6 --- .../user/InviteControllerTest.java | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) 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 -- 2.49.1 From acd66b15516dd14a2d7f5cfbe9bc74762e73061a Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 14 May 2026 17:03:14 +0200 Subject: [PATCH 16/16] fix(invites): resolve svelte-check warnings in UserGroupsSection and page.server.test Use untrack() for intentional one-time prop seed in UserGroupsSection. Add explicit LoadData type alias in page.server.test to avoid void|Record union. Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/user/UserGroupsSection.svelte | 3 ++- .../routes/admin/invites/page.server.test.ts | 19 +++++++++++++++---- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/frontend/src/lib/user/UserGroupsSection.svelte b/frontend/src/lib/user/UserGroupsSection.svelte index 2b3444eb..6e8db2d6 100644 --- a/frontend/src/lib/user/UserGroupsSection.svelte +++ b/frontend/src/lib/user/UserGroupsSection.svelte @@ -1,4 +1,5 @@
diff --git a/frontend/src/routes/admin/invites/page.server.test.ts b/frontend/src/routes/admin/invites/page.server.test.ts index 060389e7..8e66e9b0 100644 --- a/frontend/src/routes/admin/invites/page.server.test.ts +++ b/frontend/src/routes/admin/invites/page.server.test.ts @@ -5,6 +5,17 @@ vi.mock('$env/dynamic/private', () => ({ })); 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; @@ -40,7 +51,7 @@ describe('admin/invites load()', () => { ]) ); - const result = await load(event()); + const result = (await load(event())) as LoadData; expect(result.groups).toHaveLength(2); expect(result.groupsLoadError).toBeNull(); @@ -55,7 +66,7 @@ describe('admin/invites load()', () => { ]) ); - const result = await load(event()); + const result = (await load(event())) as LoadData; expect(result.groups.map((g) => g.name)).toEqual(['Alfa', 'Mitte', 'Zebra']); }); @@ -65,7 +76,7 @@ describe('admin/invites load()', () => { .mockResolvedValueOnce(mockResponse(true, [])) .mockResolvedValueOnce(mockResponse(false, { code: 'FORBIDDEN' }, 403)); - const result = await load(event()); + const result = (await load(event())) as LoadData; expect(result.groups).toEqual([]); expect(result.groupsLoadError).toBe('FORBIDDEN'); @@ -76,7 +87,7 @@ describe('admin/invites load()', () => { .mockResolvedValueOnce(mockResponse(true, [])) .mockResolvedValueOnce(mockResponse(false, null, 500)); - const result = await load(event()); + const result = (await load(event())) as LoadData; expect(result.groupsLoadError).toBe('INTERNAL_ERROR'); }); -- 2.49.1