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