diff --git a/backend/src/main/resources/db/migration/V006__add_invite_invalidated_at.sql b/backend/src/main/resources/db/migration/V026__add_invite_invalidated_at.sql
similarity index 100%
rename from backend/src/main/resources/db/migration/V006__add_invite_invalidated_at.sql
rename to backend/src/main/resources/db/migration/V026__add_invite_invalidated_at.sql
diff --git a/frontend/src/lib/components/SegmentedControl.svelte b/frontend/src/lib/components/SegmentedControl.svelte
new file mode 100644
index 0000000..a83104b
--- /dev/null
+++ b/frontend/src/lib/components/SegmentedControl.svelte
@@ -0,0 +1,50 @@
+
+
+
+ {#each options as option (option.value)}
+
+ {/each}
+
+
+
diff --git a/frontend/src/lib/components/SegmentedControl.test.ts b/frontend/src/lib/components/SegmentedControl.test.ts
new file mode 100644
index 0000000..708e21c
--- /dev/null
+++ b/frontend/src/lib/components/SegmentedControl.test.ts
@@ -0,0 +1,30 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/svelte';
+import userEvent from '@testing-library/user-event';
+import SegmentedControl from './SegmentedControl.svelte';
+
+const options = [
+ { value: 'planner', label: 'Planer' },
+ { value: 'member', label: 'Mitglied' }
+];
+
+describe('SegmentedControl', () => {
+ it('renders all option labels', () => {
+ render(SegmentedControl, { props: { options, value: 'planner', onchange: vi.fn() } });
+ expect(screen.getByRole('button', { name: 'Planer' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Mitglied' })).toBeInTheDocument();
+ });
+
+ it('marks the active option with aria-pressed', () => {
+ render(SegmentedControl, { props: { options, value: 'planner', onchange: vi.fn() } });
+ expect(screen.getByRole('button', { name: 'Planer' })).toHaveAttribute('aria-pressed', 'true');
+ expect(screen.getByRole('button', { name: 'Mitglied' })).toHaveAttribute('aria-pressed', 'false');
+ });
+
+ it('calls onchange with the new value when an option is clicked', async () => {
+ const onchange = vi.fn();
+ render(SegmentedControl, { props: { options, value: 'planner', onchange } });
+ await userEvent.click(screen.getByRole('button', { name: 'Mitglied' }));
+ expect(onchange).toHaveBeenCalledWith('member');
+ });
+});
diff --git a/frontend/src/lib/components/Toast.svelte b/frontend/src/lib/components/Toast.svelte
new file mode 100644
index 0000000..47fb71a
--- /dev/null
+++ b/frontend/src/lib/components/Toast.svelte
@@ -0,0 +1,31 @@
+
+
+{#if visible}
+
+ {message}
+
+
+{/if}
diff --git a/frontend/src/lib/components/Toast.test.ts b/frontend/src/lib/components/Toast.test.ts
new file mode 100644
index 0000000..114b29b
--- /dev/null
+++ b/frontend/src/lib/components/Toast.test.ts
@@ -0,0 +1,23 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/svelte';
+import userEvent from '@testing-library/user-event';
+import Toast from './Toast.svelte';
+
+describe('Toast', () => {
+ it('is not mounted when visible is false', () => {
+ render(Toast, { props: { message: 'Hallo', visible: false } });
+ expect(screen.queryByRole('status')).toBeNull();
+ });
+
+ it('shows the message when visible is true', () => {
+ render(Toast, { props: { message: 'Gespeichert', visible: true } });
+ expect(screen.getByRole('status')).toHaveTextContent('Gespeichert');
+ });
+
+ it('calls ondismiss when close button is clicked', async () => {
+ const ondismiss = vi.fn();
+ render(Toast, { props: { message: 'Fehler', visible: true, ondismiss } });
+ await userEvent.click(screen.getByRole('button', { name: /schließen/i }));
+ expect(ondismiss).toHaveBeenCalledOnce();
+ });
+});
diff --git a/frontend/src/routes/(app)/members/+page.server.ts b/frontend/src/routes/(app)/members/+page.server.ts
new file mode 100644
index 0000000..6e63458
--- /dev/null
+++ b/frontend/src/routes/(app)/members/+page.server.ts
@@ -0,0 +1,17 @@
+import type { PageServerLoad } from './$types';
+import { apiClient } from '$lib/server/api';
+
+export const load: PageServerLoad = async ({ fetch, locals }) => {
+ const api = apiClient(fetch);
+
+ const [membersRes, inviteRes] = await Promise.all([
+ api.GET('/v1/households/mine/members'),
+ api.GET('/v1/households/mine/invites')
+ ]);
+
+ return {
+ members: membersRes.data ?? [],
+ currentUserId: locals.benutzer!.id,
+ activeInvite: inviteRes.data?.data ?? null
+ };
+};
diff --git a/frontend/src/routes/(app)/members/+page.svelte b/frontend/src/routes/(app)/members/+page.svelte
index a4722af..5e54c8f 100644
--- a/frontend/src/routes/(app)/members/+page.svelte
+++ b/frontend/src/routes/(app)/members/+page.svelte
@@ -1 +1,97 @@
-Mitglieder
+
+
+Mitglieder — Mealprep
+
+
+ Mitglieder
+
+ (showInvitePanel = !showInvitePanel)}
+ />
+
+ {#if showInvitePanel && isPlanner && activeInvite}
+
+ {/if}
+
+ (removeTarget = null)}
+ />
+
+ (toastVisible = false)} />
+
diff --git a/frontend/src/routes/(app)/members/InviteCard.svelte b/frontend/src/routes/(app)/members/InviteCard.svelte
new file mode 100644
index 0000000..d5d0e55
--- /dev/null
+++ b/frontend/src/routes/(app)/members/InviteCard.svelte
@@ -0,0 +1,17 @@
+
+
+
diff --git a/frontend/src/routes/(app)/members/InviteCard.test.ts b/frontend/src/routes/(app)/members/InviteCard.test.ts
new file mode 100644
index 0000000..5974e5d
--- /dev/null
+++ b/frontend/src/routes/(app)/members/InviteCard.test.ts
@@ -0,0 +1,23 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/svelte';
+import userEvent from '@testing-library/user-event';
+import InviteCard from './InviteCard.svelte';
+
+describe('InviteCard', () => {
+ it('renders the invite tile', () => {
+ render(InviteCard, { props: { onclick: vi.fn() } });
+ expect(screen.getByTestId('invite-card')).toBeInTheDocument();
+ });
+
+ it('shows a descriptive label', () => {
+ render(InviteCard, { props: { onclick: vi.fn() } });
+ expect(screen.getByText(/einladen/i)).toBeInTheDocument();
+ });
+
+ it('calls onclick when tile is clicked', async () => {
+ const onclick = vi.fn();
+ render(InviteCard, { props: { onclick } });
+ await userEvent.click(screen.getByTestId('invite-card'));
+ expect(onclick).toHaveBeenCalledOnce();
+ });
+});
diff --git a/frontend/src/routes/(app)/members/InvitePanel.svelte b/frontend/src/routes/(app)/members/InvitePanel.svelte
new file mode 100644
index 0000000..0eb9075
--- /dev/null
+++ b/frontend/src/routes/(app)/members/InvitePanel.svelte
@@ -0,0 +1,50 @@
+
+
+
+
+ {invite.shareUrl || invite.inviteCode}
+
+
+
+
+
+
+
+
+
+ Läuft ab: {formatExpiry(invite.expiresAt)}
+
+
diff --git a/frontend/src/routes/(app)/members/InvitePanel.test.ts b/frontend/src/routes/(app)/members/InvitePanel.test.ts
new file mode 100644
index 0000000..77ba4f3
--- /dev/null
+++ b/frontend/src/routes/(app)/members/InvitePanel.test.ts
@@ -0,0 +1,34 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/svelte';
+import userEvent from '@testing-library/user-event';
+import InvitePanel from './InvitePanel.svelte';
+
+const invite = {
+ inviteCode: 'ABC123XY',
+ shareUrl: 'https://example.com/join/ABC123XY',
+ expiresAt: '2026-12-01T00:00:00Z'
+};
+
+describe('InvitePanel', () => {
+ it('shows the invite URL', () => {
+ render(InvitePanel, { props: { invite, onregenerate: vi.fn() } });
+ expect(screen.getByText(/ABC123XY/)).toBeInTheDocument();
+ });
+
+ it('has a copy button', () => {
+ render(InvitePanel, { props: { invite, onregenerate: vi.fn() } });
+ expect(screen.getByTestId('copy-btn')).toBeInTheDocument();
+ });
+
+ it('has a regenerate button', () => {
+ render(InvitePanel, { props: { invite, onregenerate: vi.fn() } });
+ expect(screen.getByTestId('regenerate-btn')).toBeInTheDocument();
+ });
+
+ it('calls onregenerate when regenerate button is clicked', async () => {
+ const onregenerate = vi.fn();
+ render(InvitePanel, { props: { invite, onregenerate } });
+ await userEvent.click(screen.getByTestId('regenerate-btn'));
+ expect(onregenerate).toHaveBeenCalledOnce();
+ });
+});
diff --git a/frontend/src/routes/(app)/members/MemberCard.svelte b/frontend/src/routes/(app)/members/MemberCard.svelte
new file mode 100644
index 0000000..cabcd40
--- /dev/null
+++ b/frontend/src/routes/(app)/members/MemberCard.svelte
@@ -0,0 +1,215 @@
+
+
+
+
+
+ {initials}
+
+
+
+
+ {member.displayName}
+ {#if isCurrentUser}
+ Du
+ {/if}
+
+
+
+ {#if editingRole}
+ {
+ onrolechange(member, newValue);
+ editingRole = false;
+ }}
+ />
+ {:else}
+
+ {member.role === 'planner' ? 'Planer' : 'Mitglied'}
+
+ {/if}
+
+
+ {#if isPlanner && !isCurrentUser}
+
+
+
+ {#if menuOpen}
+
+
+
+
+ {/if}
+ {/if}
+
+
+
diff --git a/frontend/src/routes/(app)/members/MemberCard.test.ts b/frontend/src/routes/(app)/members/MemberCard.test.ts
new file mode 100644
index 0000000..a63bf41
--- /dev/null
+++ b/frontend/src/routes/(app)/members/MemberCard.test.ts
@@ -0,0 +1,147 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/svelte';
+import userEvent from '@testing-library/user-event';
+import MemberCard from './MemberCard.svelte';
+
+const plannerMember = {
+ userId: 'u1',
+ displayName: 'Sarah',
+ role: 'planner',
+ joinedAt: '2024-01-01T00:00:00Z'
+};
+
+const regularMember = {
+ userId: 'u2',
+ displayName: 'Tom',
+ role: 'member',
+ joinedAt: '2024-02-01T00:00:00Z'
+};
+
+describe('MemberCard', () => {
+ it('shows the member display name', () => {
+ render(MemberCard, {
+ props: {
+ member: plannerMember,
+ isCurrentUser: false,
+ isPlanner: false,
+ onremove: vi.fn(),
+ onrolechange: vi.fn()
+ }
+ });
+ expect(screen.getByText('Sarah')).toBeInTheDocument();
+ });
+
+ it('shows "Du"-badge when isCurrentUser is true', () => {
+ render(MemberCard, {
+ props: {
+ member: plannerMember,
+ isCurrentUser: true,
+ isPlanner: false,
+ onremove: vi.fn(),
+ onrolechange: vi.fn()
+ }
+ });
+ expect(screen.getByText('Du')).toBeInTheDocument();
+ });
+
+ it('does not show kebab button when isCurrentUser is true', () => {
+ render(MemberCard, {
+ props: {
+ member: plannerMember,
+ isCurrentUser: true,
+ isPlanner: true,
+ onremove: vi.fn(),
+ onrolechange: vi.fn()
+ }
+ });
+ expect(screen.queryByTestId('kebab-btn')).toBeNull();
+ });
+
+ it('does not show kebab button when viewer is not a planner', () => {
+ render(MemberCard, {
+ props: {
+ member: regularMember,
+ isCurrentUser: false,
+ isPlanner: false,
+ onremove: vi.fn(),
+ onrolechange: vi.fn()
+ }
+ });
+ expect(screen.queryByTestId('kebab-btn')).toBeNull();
+ });
+
+ it('shows kebab button for other members when viewer is planner', () => {
+ render(MemberCard, {
+ props: {
+ member: regularMember,
+ isCurrentUser: false,
+ isPlanner: true,
+ onremove: vi.fn(),
+ onrolechange: vi.fn()
+ }
+ });
+ expect(screen.getByTestId('kebab-btn')).toBeInTheDocument();
+ });
+
+ it('opens dropdown when kebab is clicked', async () => {
+ render(MemberCard, {
+ props: {
+ member: regularMember,
+ isCurrentUser: false,
+ isPlanner: true,
+ onremove: vi.fn(),
+ onrolechange: vi.fn()
+ }
+ });
+ await userEvent.click(screen.getByTestId('kebab-btn'));
+ expect(screen.getByText('Rolle ändern')).toBeInTheDocument();
+ expect(screen.getByText('Entfernen')).toBeInTheDocument();
+ });
+
+ it('calls onremove when "Entfernen" is clicked in dropdown', async () => {
+ const onremove = vi.fn();
+ render(MemberCard, {
+ props: {
+ member: regularMember,
+ isCurrentUser: false,
+ isPlanner: true,
+ onremove,
+ onrolechange: vi.fn()
+ }
+ });
+ await userEvent.click(screen.getByTestId('kebab-btn'));
+ await userEvent.click(screen.getByText('Entfernen'));
+ expect(onremove).toHaveBeenCalledWith(regularMember);
+ });
+
+ it('shows SegmentedControl when "Rolle ändern" is clicked', async () => {
+ render(MemberCard, {
+ props: {
+ member: regularMember,
+ isCurrentUser: false,
+ isPlanner: true,
+ onremove: vi.fn(),
+ onrolechange: vi.fn()
+ }
+ });
+ await userEvent.click(screen.getByTestId('kebab-btn'));
+ await userEvent.click(screen.getByText('Rolle ändern'));
+ expect(screen.getByRole('group')).toBeInTheDocument();
+ });
+
+ it('closes dropdown on Escape key', async () => {
+ render(MemberCard, {
+ props: {
+ member: regularMember,
+ isCurrentUser: false,
+ isPlanner: true,
+ onremove: vi.fn(),
+ onrolechange: vi.fn()
+ }
+ });
+ await userEvent.click(screen.getByTestId('kebab-btn'));
+ expect(screen.getByText('Entfernen')).toBeInTheDocument();
+ await userEvent.keyboard('{Escape}');
+ expect(screen.queryByText('Entfernen')).toBeNull();
+ });
+});
diff --git a/frontend/src/routes/(app)/members/MemberGrid.svelte b/frontend/src/routes/(app)/members/MemberGrid.svelte
new file mode 100644
index 0000000..9e994ca
--- /dev/null
+++ b/frontend/src/routes/(app)/members/MemberGrid.svelte
@@ -0,0 +1,67 @@
+
+
+
+ {#each sortedMembers as m (m.userId)}
+ onrolechange(m, role)}
+ />
+ {/each}
+ {#if isPlanner && showInviteCard}
+
+ {/if}
+
+
+
diff --git a/frontend/src/routes/(app)/members/MemberGrid.test.ts b/frontend/src/routes/(app)/members/MemberGrid.test.ts
new file mode 100644
index 0000000..1745123
--- /dev/null
+++ b/frontend/src/routes/(app)/members/MemberGrid.test.ts
@@ -0,0 +1,73 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/svelte';
+import MemberGrid from './MemberGrid.svelte';
+
+const members = [
+ { userId: 'u1', displayName: 'Sarah', role: 'planner', joinedAt: '2024-01-01T00:00:00Z' },
+ { userId: 'u2', displayName: 'Tom', role: 'member', joinedAt: '2024-02-01T00:00:00Z' },
+ { userId: 'u3', displayName: 'Anna', role: 'member', joinedAt: '2024-03-01T00:00:00Z' }
+];
+
+describe('MemberGrid', () => {
+ it('renders all member cards', () => {
+ render(MemberGrid, {
+ props: {
+ members,
+ currentUserId: 'u1',
+ isPlanner: true,
+ showInviteCard: true,
+ onremove: vi.fn(),
+ onrolechange: vi.fn(),
+ oninviteclick: vi.fn()
+ }
+ });
+ expect(screen.getByText('Sarah')).toBeInTheDocument();
+ expect(screen.getByText('Tom')).toBeInTheDocument();
+ expect(screen.getByText('Anna')).toBeInTheDocument();
+ });
+
+ it('shows invite card when showInviteCard is true and isPlanner is true', () => {
+ render(MemberGrid, {
+ props: {
+ members,
+ currentUserId: 'u1',
+ isPlanner: true,
+ showInviteCard: true,
+ onremove: vi.fn(),
+ onrolechange: vi.fn(),
+ oninviteclick: vi.fn()
+ }
+ });
+ expect(screen.getByTestId('invite-card')).toBeInTheDocument();
+ });
+
+ it('hides invite card when isPlanner is false', () => {
+ render(MemberGrid, {
+ props: {
+ members,
+ currentUserId: 'u2',
+ isPlanner: false,
+ showInviteCard: true,
+ onremove: vi.fn(),
+ onrolechange: vi.fn(),
+ oninviteclick: vi.fn()
+ }
+ });
+ expect(screen.queryByTestId('invite-card')).toBeNull();
+ });
+
+ it('shows "Du"-badge on the current user card', () => {
+ render(MemberGrid, {
+ props: {
+ members,
+ currentUserId: 'u1',
+ isPlanner: true,
+ showInviteCard: false,
+ onremove: vi.fn(),
+ onrolechange: vi.fn(),
+ oninviteclick: vi.fn()
+ }
+ });
+ expect(screen.getByText('Du')).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/routes/(app)/members/RemoveDialog.svelte b/frontend/src/routes/(app)/members/RemoveDialog.svelte
new file mode 100644
index 0000000..996db2f
--- /dev/null
+++ b/frontend/src/routes/(app)/members/RemoveDialog.svelte
@@ -0,0 +1,86 @@
+
+
+{#if show}
+ {#if isMobile()}
+
+
+
Mitglied entfernen
+
+ Soll {member.displayName} wirklich entfernt werden?
+
+
+
+
+
+
+
+ {:else}
+
+
e.stopPropagation()}
+ onkeydown={(e) => e.stopPropagation()}
+ >
+
Mitglied entfernen
+
+ Soll {member.displayName} wirklich entfernt werden?
+
+
+
+
+
+
+
+ {/if}
+{/if}
diff --git a/frontend/src/routes/(app)/members/RemoveDialog.test.ts b/frontend/src/routes/(app)/members/RemoveDialog.test.ts
new file mode 100644
index 0000000..9da8f9b
--- /dev/null
+++ b/frontend/src/routes/(app)/members/RemoveDialog.test.ts
@@ -0,0 +1,56 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/svelte';
+import userEvent from '@testing-library/user-event';
+import RemoveDialog from './RemoveDialog.svelte';
+
+const member = {
+ userId: 'u2',
+ displayName: 'Tom',
+ role: 'member',
+ joinedAt: '2024-02-01T00:00:00Z'
+};
+
+describe('RemoveDialog', () => {
+ it('is not rendered when show is false', () => {
+ render(RemoveDialog, {
+ props: { show: false, member, onconfirm: vi.fn(), oncancel: vi.fn() }
+ });
+ expect(screen.queryByTestId('remove-dialog')).toBeNull();
+ });
+
+ it('shows the member displayName in dialog', () => {
+ render(RemoveDialog, {
+ props: { show: true, member, onconfirm: vi.fn(), oncancel: vi.fn() }
+ });
+ expect(screen.getByTestId('remove-dialog')).toBeInTheDocument();
+ expect(screen.getByText(/Tom/)).toBeInTheDocument();
+ });
+
+ it('calls onconfirm when confirm button is clicked', async () => {
+ const onconfirm = vi.fn();
+ render(RemoveDialog, {
+ props: { show: true, member, onconfirm, oncancel: vi.fn() }
+ });
+ await userEvent.click(screen.getByTestId('confirm-remove-btn'));
+ expect(onconfirm).toHaveBeenCalledOnce();
+ });
+
+ it('calls oncancel when cancel button is clicked', async () => {
+ const oncancel = vi.fn();
+ render(RemoveDialog, {
+ props: { show: true, member, onconfirm: vi.fn(), oncancel }
+ });
+ await userEvent.click(screen.getByRole('button', { name: /abbrechen/i }));
+ expect(oncancel).toHaveBeenCalledOnce();
+ });
+
+ it('does NOT call oncancel when backdrop is clicked', async () => {
+ const oncancel = vi.fn();
+ render(RemoveDialog, {
+ props: { show: true, member, onconfirm: vi.fn(), oncancel }
+ });
+ const backdrop = screen.getByTestId('dialog-backdrop');
+ await userEvent.click(backdrop);
+ expect(oncancel).not.toHaveBeenCalled();
+ });
+});
diff --git a/frontend/src/routes/(app)/members/[userId]/+server.ts b/frontend/src/routes/(app)/members/[userId]/+server.ts
new file mode 100644
index 0000000..d53d711
--- /dev/null
+++ b/frontend/src/routes/(app)/members/[userId]/+server.ts
@@ -0,0 +1,21 @@
+import { json } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { apiClient } from '$lib/server/api';
+
+export const DELETE: RequestHandler = async ({ fetch, params }) => {
+ const api = apiClient(fetch);
+ const { response } = await api.DELETE('/v1/households/mine/members/{userId}', {
+ params: { path: { userId: params.userId } }
+ });
+ return new Response(null, { status: response?.status ?? 204 });
+};
+
+export const PATCH: RequestHandler = async ({ fetch, params, request }) => {
+ const body = await request.json();
+ const api = apiClient(fetch);
+ const { data, response } = await api.PATCH('/v1/households/mine/members/{userId}', {
+ params: { path: { userId: params.userId } },
+ body
+ });
+ return json(data, { status: response?.status ?? 200 });
+};
diff --git a/frontend/src/routes/(app)/members/[userId]/server.test.ts b/frontend/src/routes/(app)/members/[userId]/server.test.ts
new file mode 100644
index 0000000..6bc2b06
--- /dev/null
+++ b/frontend/src/routes/(app)/members/[userId]/server.test.ts
@@ -0,0 +1,50 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+vi.mock('$env/dynamic/private', () => ({ env: { BACKEND_URL: 'http://localhost:8080' } }));
+
+const mockDelete = vi.fn();
+const mockPatch = vi.fn();
+vi.mock('$lib/server/api', () => ({
+ apiClient: () => ({ DELETE: mockDelete, PATCH: mockPatch })
+}));
+
+const USER_UUID = '22222222-2222-2222-2222-222222222222';
+
+describe('members server routes', () => {
+ let DELETE: any;
+ let PATCH: any;
+
+ beforeEach(async () => {
+ mockDelete.mockReset();
+ mockPatch.mockReset();
+ vi.resetModules();
+ const mod = await import('./+server');
+ DELETE = mod.DELETE;
+ PATCH = mod.PATCH;
+ });
+
+ it('DELETE proxies to backend and returns 204', async () => {
+ mockDelete.mockResolvedValue({ response: { status: 204 } });
+ const event = {
+ fetch: vi.fn(),
+ params: { userId: USER_UUID },
+ request: { json: vi.fn() }
+ } as any;
+ const res = await DELETE(event);
+ expect(res.status).toBe(204);
+ });
+
+ it('PATCH proxies to backend and returns member response', async () => {
+ mockPatch.mockResolvedValue({
+ data: { status: 'success', data: { userId: USER_UUID, displayName: 'Tom', role: 'planner', joinedAt: '' } },
+ response: { status: 200 }
+ });
+ const event = {
+ fetch: vi.fn(),
+ params: { userId: USER_UUID },
+ request: { json: async () => ({ role: 'planner' }) }
+ } as any;
+ const res = await PATCH(event);
+ expect(res.status).toBe(200);
+ });
+});
diff --git a/frontend/src/routes/(app)/members/invites/+server.ts b/frontend/src/routes/(app)/members/invites/+server.ts
new file mode 100644
index 0000000..76d1f44
--- /dev/null
+++ b/frontend/src/routes/(app)/members/invites/+server.ts
@@ -0,0 +1,9 @@
+import { json } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { apiClient } from '$lib/server/api';
+
+export const POST: RequestHandler = async ({ fetch }) => {
+ const api = apiClient(fetch);
+ const { data, response } = await api.POST('/v1/households/mine/invites');
+ return json(data, { status: response?.status ?? 201 });
+};
diff --git a/frontend/src/routes/(app)/members/page.server.test.ts b/frontend/src/routes/(app)/members/page.server.test.ts
new file mode 100644
index 0000000..55f2bc5
--- /dev/null
+++ b/frontend/src/routes/(app)/members/page.server.test.ts
@@ -0,0 +1,74 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+vi.mock('$lib/server/api', () => ({
+ apiClient: vi.fn(() => ({
+ GET: vi.fn()
+ }))
+}));
+
+vi.mock('$env/dynamic/private', () => ({ env: { BACKEND_URL: 'http://localhost:8080' } }));
+
+describe('members page.server load', () => {
+ let load: any;
+
+ beforeEach(async () => {
+ vi.resetModules();
+ const mod = await import('./+page.server');
+ load = mod.load;
+ });
+
+ it('returns members and currentUserId', async () => {
+ const mockGet = vi.fn().mockImplementation((path: string) => {
+ if (path === '/v1/households/mine/members') {
+ return {
+ data: [
+ { userId: 'u1', displayName: 'Sarah', role: 'planner', joinedAt: '2024-01-01T00:00:00Z' },
+ { userId: 'u2', displayName: 'Tom', role: 'member', joinedAt: '2024-02-01T00:00:00Z' }
+ ]
+ };
+ }
+ if (path === '/v1/households/mine/invites') {
+ return {
+ data: {
+ data: {
+ inviteCode: 'ABC123',
+ shareUrl: 'https://x.com/join/ABC123',
+ expiresAt: '2024-12-01T00:00:00Z'
+ }
+ }
+ };
+ }
+ return { data: null };
+ });
+
+ const { apiClient } = await import('$lib/server/api');
+ (apiClient as ReturnType).mockReturnValue({ GET: mockGet });
+
+ const result = await load({
+ fetch: vi.fn(),
+ locals: { benutzer: { id: 'u1', name: 'Sarah', email: 'sarah@example.com' }, haushalt: {} }
+ } as any);
+
+ expect(result.members).toHaveLength(2);
+ expect(result.currentUserId).toBe('u1');
+ expect(result.activeInvite).toBeDefined();
+ });
+
+ it('returns null activeInvite when no active invite exists', async () => {
+ const mockGet = vi.fn().mockImplementation((path: string) => {
+ if (path === '/v1/households/mine/members') return { data: [] };
+ if (path === '/v1/households/mine/invites') return { data: null };
+ return { data: null };
+ });
+
+ const { apiClient } = await import('$lib/server/api');
+ (apiClient as ReturnType).mockReturnValue({ GET: mockGet });
+
+ const result = await load({
+ fetch: vi.fn(),
+ locals: { benutzer: { id: 'u1', name: 'Sarah', email: 'sarah@example.com' }, haushalt: {} }
+ } as any);
+
+ expect(result.activeInvite).toBeNull();
+ });
+});