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(); + }); });