diff --git a/frontend/src/routes/admin/EntityNav.svelte b/frontend/src/routes/admin/EntityNav.svelte
index 41cd32b6..668c36a3 100644
--- a/frontend/src/routes/admin/EntityNav.svelte
+++ b/frontend/src/routes/admin/EntityNav.svelte
@@ -9,6 +9,7 @@ let {
userCount,
groupCount,
tagCount,
+ inviteCount,
canManageUsers,
canManageTags,
canManagePermissions,
@@ -17,6 +18,7 @@ let {
userCount: number;
groupCount: number;
tagCount: number;
+ inviteCount: number;
canManageUsers: boolean;
canManageTags: boolean;
canManagePermissions: boolean;
@@ -86,6 +88,23 @@ function handleKeydown(event: KeyboardEvent) {
{/snippet}
+{#snippet invitesIcon()}
+
+{/snippet}
+
{#snippet tagsIcon()}
{/if}
+ {#if canManageUsers}
+
+ {/if}
+
{#if canManageTags}
{/if}
+ {#if canManageUsers}
+
+ {/if}
+
{#if canManageTags}
{
+ 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 invites: InviteListItem[] = await res.json();
+ return { invites, status, loadError: null };
+};
+
+export const actions = {
+ create: async ({ request, fetch }) => {
+ const formData = await request.formData();
+ 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 apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
+ const res = await fetch(`${apiUrl}/api/invites`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ label,
+ maxUses,
+ prefillFirstName,
+ prefillLastName,
+ prefillEmail,
+ expiresAt
+ })
+ });
+
+ if (!res.ok) {
+ const backendError = await parseBackendError(res);
+ return fail(res.status, { createError: backendError?.code ?? 'INTERNAL_ERROR' });
+ }
+
+ const created: InviteListItem = await res.json();
+ return { created };
+ },
+
+ revoke: async ({ request, fetch }) => {
+ const formData = await request.formData();
+ const id = formData.get('id') as string;
+
+ const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
+ const res = await fetch(`${apiUrl}/api/invites/${encodeURIComponent(id)}`, {
+ method: 'DELETE'
+ });
+
+ if (!res.ok) {
+ const backendError = await parseBackendError(res);
+ return fail(res.status, { revokeError: backendError?.code ?? 'INTERNAL_ERROR' });
+ }
+
+ return { revoked: id };
+ }
+} satisfies Actions;
diff --git a/frontend/src/routes/admin/invites/+page.svelte b/frontend/src/routes/admin/invites/+page.svelte
new file mode 100644
index 00000000..bdf37878
--- /dev/null
+++ b/frontend/src/routes/admin/invites/+page.svelte
@@ -0,0 +1,373 @@
+
+
+
+ {m.admin_tab_invites()} · Familienarchiv
+
+
+
+
+
+ {m.admin_invites_list_title()}
+
+
+
+
+
+
+
+
+
+
+
+ {#if data.loadError}
+
+ {getErrorMessage(data.loadError)}
+
+ {/if}
+
+ {#if form?.created}
+
+
+ {m.admin_invite_created_title()}
+
+
{m.admin_invite_created_desc()}
+
+
+ {form.created.shareableUrl}
+
+
+
+
+ {/if}
+
+ {#if showNewForm}
+
+
+ {m.admin_btn_new_invite()}
+
+
+
+ {/if}
+
+
+
+ {#if data.invites.length === 0}
+
{m.admin_invites_empty()}
+ {:else}
+
+
+
+
+ | {m.admin_invite_col_code()} |
+ {m.admin_invite_col_label()} |
+ {m.admin_invite_col_uses()} |
+ {m.admin_invite_col_expiry()} |
+ {m.admin_invite_col_status()} |
+ {m.admin_invite_col_link()} |
+ |
+
+
+
+ {#each data.invites as invite (invite.id)}
+
+ | {invite.displayCode} |
+ {invite.label ?? '–'} |
+
+ {invite.useCount} / {invite.maxUses != null ? invite.maxUses : m.admin_invite_unlimited()}
+ |
+
+ {invite.expiresAt
+ ? new Intl.DateTimeFormat('de-DE', { dateStyle: 'medium' }).format(new Date(invite.expiresAt))
+ : m.admin_invite_no_expiry()}
+ |
+
+
+ {statusIcon(invite.status)}
+ {statusLabel(invite.status)}
+
+ |
+
+
+ |
+
+ {#if invite.status === 'active'}
+
+ {/if}
+ |
+
+ {/each}
+
+
+
+ {/if}
+
+
+
diff --git a/frontend/src/routes/admin/layout.server.spec.ts b/frontend/src/routes/admin/layout.server.spec.ts
index 5f911af9..c6df2f28 100644
--- a/frontend/src/routes/admin/layout.server.spec.ts
+++ b/frontend/src/routes/admin/layout.server.spec.ts
@@ -56,14 +56,20 @@ describe('admin layout load — permission check', () => {
[{ id: 't1' }, { id: 't2' }, { id: 't3' }]
);
+ const mockFetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => [{ id: 'i1' }, { id: 'i2' }]
+ });
+
const result = await load({
- fetch: vi.fn() as unknown as typeof fetch,
+ fetch: mockFetch as unknown as typeof fetch,
locals: { user: adminUser }
});
expect(result.userCount).toBe(2);
expect(result.groupCount).toBe(1);
expect(result.tagCount).toBe(3);
+ expect(result.inviteCount).toBe(2);
expect(result.canManageUsers).toBe(true);
expect(result.canManageTags).toBe(true);
expect(result.canManagePermissions).toBe(true);
diff --git a/frontend/src/routes/admin/layout.svelte.spec.ts b/frontend/src/routes/admin/layout.svelte.spec.ts
index 1a3c9770..5797ccac 100644
--- a/frontend/src/routes/admin/layout.svelte.spec.ts
+++ b/frontend/src/routes/admin/layout.svelte.spec.ts
@@ -17,6 +17,7 @@ const fullPerms = {
userCount: 4,
groupCount: 3,
tagCount: 7,
+ inviteCount: 2,
canManageUsers: true,
canManageTags: true,
canManagePermissions: true,
diff --git a/frontend/src/routes/admin/page.svelte.spec.ts b/frontend/src/routes/admin/page.svelte.spec.ts
index 679e797d..ef44cc4b 100644
--- a/frontend/src/routes/admin/page.svelte.spec.ts
+++ b/frontend/src/routes/admin/page.svelte.spec.ts
@@ -14,6 +14,7 @@ const fullData = {
userCount: 4,
groupCount: 3,
tagCount: 7,
+ inviteCount: 2,
canManageUsers: true,
canManageTags: true,
canManagePermissions: true,
diff --git a/frontend/src/routes/login/+page.server.ts b/frontend/src/routes/login/+page.server.ts
index 6acbf003..4c29b511 100644
--- a/frontend/src/routes/login/+page.server.ts
+++ b/frontend/src/routes/login/+page.server.ts
@@ -1,6 +1,11 @@
import { fail, redirect, type Actions } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
import { getErrorMessage } from '$lib/errors';
+import type { PageServerLoad } from './$types';
+
+export const load: PageServerLoad = ({ url }) => {
+ return { registered: url.searchParams.get('registered') === '1' };
+};
export const actions = {
login: async ({ request, cookies, fetch }) => {
diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte
index db70c763..95607e1c 100644
--- a/frontend/src/routes/login/+page.svelte
+++ b/frontend/src/routes/login/+page.svelte
@@ -2,7 +2,10 @@
import { m } from '$lib/paraglide/messages.js';
import AuthHeader from '../AuthHeader.svelte';
-let { form }: { form?: { error?: string; success?: boolean } } = $props();
+let {
+ data,
+ form
+}: { data: { registered: boolean }; form?: { error?: string; success?: boolean } } = $props();
@@ -25,6 +28,16 @@ let { form }: { form?: { error?: string; success?: boolean } } = $props();
+ {#if data.registered}
+
+ {m.login_registered_success()}
+
+ {/if}
+
{m.login_heading()}
diff --git a/frontend/src/routes/login/page.svelte.spec.ts b/frontend/src/routes/login/page.svelte.spec.ts
index 64681752..9a162be7 100644
--- a/frontend/src/routes/login/page.svelte.spec.ts
+++ b/frontend/src/routes/login/page.svelte.spec.ts
@@ -9,7 +9,7 @@ afterEach(cleanup);
describe('Login page – rendering', () => {
it('renders the page title', async () => {
- render(LoginPage, {});
+ render(LoginPage, { data: { registered: false } });
await expect
.element(page.getByRole('link', { name: 'Familienarchiv' }).first())
.toBeInTheDocument();
@@ -17,54 +17,54 @@ describe('Login page – rendering', () => {
});
it('renders the submit button', async () => {
- render(LoginPage, {});
+ render(LoginPage, { data: { registered: false } });
await expect.element(page.getByRole('button', { name: 'Anmelden' })).toBeInTheDocument();
});
it('renders the email input', async () => {
- render(LoginPage, {});
+ render(LoginPage, { data: { registered: false } });
await tick();
const input = document.querySelector
('input[name="email"]');
expect(input).not.toBeNull();
});
it('renders the password input', async () => {
- render(LoginPage, {});
+ render(LoginPage, { data: { registered: false } });
await tick();
const input = document.querySelector('input[name="password"]');
expect(input).not.toBeNull();
});
it('email field is required', async () => {
- render(LoginPage, {});
+ render(LoginPage, { data: { registered: false } });
await tick();
const input = document.querySelector('input[name="email"]');
expect(input?.required).toBe(true);
});
it('password field is required', async () => {
- render(LoginPage, {});
+ render(LoginPage, { data: { registered: false } });
await tick();
const input = document.querySelector('input[name="password"]');
expect(input?.required).toBe(true);
});
it('email field has type="email"', async () => {
- render(LoginPage, {});
+ render(LoginPage, { data: { registered: false } });
await tick();
const input = document.querySelector('input[name="email"]');
expect(input?.type).toBe('email');
});
it('password field has type="password"', async () => {
- render(LoginPage, {});
+ render(LoginPage, { data: { registered: false } });
await tick();
const input = document.querySelector('input[name="password"]');
expect(input?.type).toBe('password');
});
it('form submits to the login action', async () => {
- render(LoginPage, {});
+ render(LoginPage, { data: { registered: false } });
await tick();
const form = document.querySelector('form');
expect(form?.action).toMatch(/\?\/login$/);
@@ -73,25 +73,25 @@ describe('Login page – rendering', () => {
describe('Login page – error state', () => {
it('shows no error when form is undefined', async () => {
- render(LoginPage, {});
+ render(LoginPage, { data: { registered: false } });
await tick();
expect(document.querySelector('.text-red-600')).toBeNull();
});
it('shows no error when form has no error property', async () => {
- render(LoginPage, { form: {} });
+ render(LoginPage, { data: { registered: false }, form: {} });
await tick();
expect(document.querySelector('.text-red-600')).toBeNull();
});
it('displays the error message from the form action', async () => {
- render(LoginPage, { form: { error: 'Ungültige Anmeldedaten.' } });
+ render(LoginPage, { data: { registered: false }, form: { error: 'Ungültige Anmeldedaten.' } });
await expect.element(page.getByText('Ungültige Anmeldedaten.')).toBeInTheDocument();
await page.screenshot({ path: 'test-results/screenshots/login-error.png' });
});
it('applies red styling to the error text', async () => {
- render(LoginPage, { form: { error: 'Fehler!' } });
+ render(LoginPage, { data: { registered: false }, form: { error: 'Fehler!' } });
await tick();
expect(document.querySelector('.text-red-600')).not.toBeNull();
});
diff --git a/frontend/src/routes/login/test-results/screenshots/login-default.png b/frontend/src/routes/login/test-results/screenshots/login-default.png
new file mode 100644
index 00000000..6b15416c
Binary files /dev/null and b/frontend/src/routes/login/test-results/screenshots/login-default.png differ
diff --git a/frontend/src/routes/login/test-results/screenshots/login-error.png b/frontend/src/routes/login/test-results/screenshots/login-error.png
new file mode 100644
index 00000000..3765f5ff
Binary files /dev/null and b/frontend/src/routes/login/test-results/screenshots/login-error.png differ
diff --git a/frontend/src/routes/register/+page.server.ts b/frontend/src/routes/register/+page.server.ts
new file mode 100644
index 00000000..0b67ea44
--- /dev/null
+++ b/frontend/src/routes/register/+page.server.ts
@@ -0,0 +1,53 @@
+import { fail, redirect } from '@sveltejs/kit';
+import { env } from '$env/dynamic/private';
+import { parseBackendError } from '$lib/errors';
+import type { Actions, PageServerLoad } from './$types';
+
+interface InvitePrefill {
+ firstName?: string;
+ lastName?: string;
+ email?: string;
+}
+
+export const load: PageServerLoad = async ({ url, fetch }) => {
+ const code = url.searchParams.get('code');
+ if (!code) {
+ return { code: null, prefill: null, codeError: null };
+ }
+
+ const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
+ const res = await fetch(`${apiUrl}/api/auth/invite/${encodeURIComponent(code)}`);
+
+ if (!res.ok) {
+ const backendError = await parseBackendError(res);
+ return { code, prefill: null, codeError: backendError?.code ?? 'INTERNAL_ERROR' };
+ }
+
+ const prefill: InvitePrefill = await res.json();
+ return { code, prefill, codeError: null };
+};
+
+export const actions = {
+ default: async ({ request, fetch }) => {
+ const formData = await request.formData();
+ const code = formData.get('code') as string;
+ const email = formData.get('email') as string;
+ const password = formData.get('password') as string;
+ const firstName = formData.get('firstName') as string;
+ const lastName = formData.get('lastName') as string;
+
+ const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
+ const res = await fetch(`${apiUrl}/api/auth/register`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ code, email, password, firstName, lastName })
+ });
+
+ if (!res.ok) {
+ const backendError = await parseBackendError(res);
+ return fail(res.status, { error: backendError?.code ?? 'INTERNAL_ERROR' });
+ }
+
+ throw redirect(303, '/login?registered=1');
+ }
+} satisfies Actions;
diff --git a/frontend/src/routes/register/+page.svelte b/frontend/src/routes/register/+page.svelte
new file mode 100644
index 00000000..7724377c
--- /dev/null
+++ b/frontend/src/routes/register/+page.svelte
@@ -0,0 +1,199 @@
+
+
+
+ {m.register_heading()} – Familienarchiv
+
+
+
+
+
+
+
+
+
+ {#if data.codeError}
+
+
+
+ {m.register_invalid_code()}
+
+
{m.register_invalid_code_desc()}
+
+ {m.forgot_password_back_to_login()}
+
+
+ {:else}
+
+
+ {m.register_heading()}
+
+ {#if data.code}
+
{m.register_subtext()}
+ {/if}
+
+
+
+ {/if}
+
+
+
+
+
diff --git a/frontend/src/routes/register/page.server.test.ts b/frontend/src/routes/register/page.server.test.ts
new file mode 100644
index 00000000..764296c9
--- /dev/null
+++ b/frontend/src/routes/register/page.server.test.ts
@@ -0,0 +1,88 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+// Test the load function logic in isolation without importing the actual module
+// (which depends on SvelteKit env and $types at runtime).
+// We replicate the exact load logic here so we can test the branching behaviour.
+
+interface InvitePrefill {
+ firstName?: string;
+ lastName?: string;
+ email?: string;
+}
+
+async function loadFn(
+ code: string | null,
+ fetchImpl: (url: string) => Promise<{ ok: boolean; json: () => Promise }>
+): Promise<{ code: string | null; prefill: InvitePrefill | null; codeError: string | null }> {
+ if (!code) {
+ return { code: null, prefill: null, codeError: null };
+ }
+
+ const res = await fetchImpl(`http://localhost:8080/api/auth/invite/${encodeURIComponent(code)}`);
+
+ if (!res.ok) {
+ const body = (await res.json()) as { code?: string } | null;
+ return { code, prefill: null, codeError: body?.code ?? 'INTERNAL_ERROR' };
+ }
+
+ const prefill = (await res.json()) as InvitePrefill;
+ return { code, prefill, codeError: null };
+}
+
+describe('register load', () => {
+ const mockFetch = vi.fn();
+
+ beforeEach(() => {
+ mockFetch.mockReset();
+ });
+
+ it('returns nulls when no code in URL', async () => {
+ const result = await loadFn(null, mockFetch);
+ expect(result).toEqual({ code: null, prefill: null, codeError: null });
+ expect(mockFetch).not.toHaveBeenCalled();
+ });
+
+ it('returns prefill data when backend responds ok', async () => {
+ const prefill = { firstName: 'Max', lastName: 'Muster', email: 'max@test.com' };
+ mockFetch.mockResolvedValue({ ok: true, json: async () => prefill });
+
+ const result = await loadFn('ABCDE12345', mockFetch);
+
+ expect(result.code).toBe('ABCDE12345');
+ expect(result.prefill).toEqual(prefill);
+ expect(result.codeError).toBeNull();
+ });
+
+ it('returns codeError when backend returns error with code', async () => {
+ mockFetch.mockResolvedValue({
+ ok: false,
+ json: async () => ({ code: 'INVITE_REVOKED', message: 'Revoked' })
+ });
+
+ const result = await loadFn('ABCDE12345', mockFetch);
+
+ expect(result.code).toBe('ABCDE12345');
+ expect(result.prefill).toBeNull();
+ expect(result.codeError).toBe('INVITE_REVOKED');
+ });
+
+ it('falls back to INTERNAL_ERROR when backend error has no code', async () => {
+ mockFetch.mockResolvedValue({
+ ok: false,
+ json: async () => null
+ });
+
+ const result = await loadFn('ABCDE12345', mockFetch);
+
+ expect(result.codeError).toBe('INTERNAL_ERROR');
+ });
+
+ it('URL-encodes the invite code in the fetch call', async () => {
+ const prefill = {};
+ mockFetch.mockResolvedValue({ ok: true, json: async () => prefill });
+
+ await loadFn('CODE WITH SPACES', mockFetch);
+
+ expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('CODE%20WITH%20SPACES'));
+ });
+});
diff --git a/frontend/src/routes/register/page.svelte.spec.ts b/frontend/src/routes/register/page.svelte.spec.ts
new file mode 100644
index 00000000..7ee8bd34
--- /dev/null
+++ b/frontend/src/routes/register/page.svelte.spec.ts
@@ -0,0 +1,106 @@
+import { afterEach, describe, expect, it } from 'vitest';
+import { cleanup, render } from 'vitest-browser-svelte';
+import { page } from 'vitest/browser';
+import RegisterPage from './+page.svelte';
+
+const tick = () => new Promise((r) => setTimeout(r, 0));
+
+afterEach(cleanup);
+
+const validData = {
+ code: 'ABCDE12345',
+ prefill: null,
+ codeError: null
+};
+
+const prefillData = {
+ code: 'ABCDE12345',
+ prefill: { firstName: 'Max', lastName: 'Muster', email: 'max@test.com' },
+ codeError: null
+};
+
+const errorData = {
+ code: null,
+ prefill: null,
+ codeError: 'INVITE_NOT_FOUND'
+};
+
+describe('Register page – valid code', () => {
+ it('renders the heading', async () => {
+ render(RegisterPage, { data: validData });
+ await expect.element(page.getByRole('heading', { level: 1 })).toBeInTheDocument();
+ });
+
+ it('renders the email input', async () => {
+ render(RegisterPage, { data: validData });
+ await tick();
+ const input = document.querySelector('input[name="email"]');
+ expect(input).not.toBeNull();
+ expect(input?.type).toBe('email');
+ expect(input?.required).toBe(true);
+ });
+
+ it('renders the password input', async () => {
+ render(RegisterPage, { data: validData });
+ await tick();
+ const input = document.querySelector('input[name="password"]');
+ expect(input).not.toBeNull();
+ expect(input?.required).toBe(true);
+ });
+
+ it('renders the submit button', async () => {
+ render(RegisterPage, { data: validData });
+ await expect.element(page.getByRole('button', { name: 'Konto erstellen' })).toBeInTheDocument();
+ });
+
+ it('prefills fields from invite data', async () => {
+ render(RegisterPage, { data: prefillData });
+ await tick();
+ const email = document.querySelector('input[name="email"]');
+ const firstName = document.querySelector('input[name="firstName"]');
+ const lastName = document.querySelector('input[name="lastName"]');
+ expect(email?.value).toBe('max@test.com');
+ expect(firstName?.value).toBe('Max');
+ expect(lastName?.value).toBe('Muster');
+ });
+
+ it('has a hidden code input', async () => {
+ render(RegisterPage, { data: validData });
+ await tick();
+ const hidden = document.querySelector('input[name="code"][type="hidden"]');
+ expect(hidden).not.toBeNull();
+ expect(hidden?.value).toBe('ABCDE12345');
+ });
+
+ it('shows form error when action returns error', async () => {
+ render(RegisterPage, { data: validData, form: { error: 'INVITE_REVOKED' } });
+ await tick();
+ expect(document.querySelector('.text-red-600')).not.toBeNull();
+ });
+
+ it('has main landmark', async () => {
+ render(RegisterPage, { data: validData });
+ await tick();
+ expect(document.querySelector('main')).not.toBeNull();
+ });
+});
+
+describe('Register page – error state', () => {
+ it('renders the error card when codeError is set', async () => {
+ render(RegisterPage, { data: errorData });
+ await tick();
+ expect(document.querySelector('form')).toBeNull();
+ });
+
+ it('shows a back-to-login link in error state', async () => {
+ render(RegisterPage, { data: errorData });
+ await expect.element(page.getByRole('link', { name: /login/i })).toBeInTheDocument();
+ });
+
+ it('back-to-login link points to /login', async () => {
+ render(RegisterPage, { data: errorData });
+ await tick();
+ const link = document.querySelector('a[href="/login"]');
+ expect(link).not.toBeNull();
+ });
+});