From 66cf53845419d5024ee5e30b035904cb370132fb Mon Sep 17 00:00:00 2001
From: Marcel Raddatz
Date: Thu, 2 Apr 2026 14:38:30 +0200
Subject: [PATCH 01/25] refactor(auth): make (public) layout bare, move brand
panel into login page
The signup page needs its own brand panel, so the shared layout
becomes a simple slot. Login page now owns its brand panel markup.
Co-Authored-By: Claude Opus 4.6
---
frontend/src/routes/(public)/+layout.svelte | 9 +--------
frontend/src/routes/(public)/login/+page.svelte | 13 +++++++++++--
2 files changed, 12 insertions(+), 10 deletions(-)
diff --git a/frontend/src/routes/(public)/+layout.svelte b/frontend/src/routes/(public)/+layout.svelte
index d90949a..a54cfdc 100644
--- a/frontend/src/routes/(public)/+layout.svelte
+++ b/frontend/src/routes/(public)/+layout.svelte
@@ -2,11 +2,4 @@
let { children } = $props();
-
-
- Mealprep
-
-
- {@render children()}
-
-
+{@render children()}
diff --git a/frontend/src/routes/(public)/login/+page.svelte b/frontend/src/routes/(public)/login/+page.svelte
index 7b4a440..d2406d2 100644
--- a/frontend/src/routes/(public)/login/+page.svelte
+++ b/frontend/src/routes/(public)/login/+page.svelte
@@ -1,2 +1,11 @@
-Anmelden
-Login-Formular folgt.
+
+
+ Mealprep
+
+
+
+
Anmelden
+
Login-Formular folgt.
+
+
+
--
2.49.1
From 56fc7e605290cdb66267b254c21c7e13345eb24b Mon Sep 17 00:00:00 2001
From: Marcel Raddatz
Date: Thu, 2 Apr 2026 14:39:17 +0200
Subject: [PATCH 02/25] feat(auth): add /signup to public routes
Allow unauthenticated access to the signup page.
Co-Authored-By: Claude Opus 4.6
---
frontend/src/hooks.server.test.ts | 2 +-
frontend/src/hooks.server.ts | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/frontend/src/hooks.server.test.ts b/frontend/src/hooks.server.test.ts
index 708071e..68fab8a 100644
--- a/frontend/src/hooks.server.test.ts
+++ b/frontend/src/hooks.server.test.ts
@@ -42,7 +42,7 @@ describe('auth guard (hooks.server.ts handle)', () => {
expect(resolve).toHaveBeenCalledWith(event);
});
- it.each(['/login', '/login/', '/register', '/invite/abc123'])(
+ it.each(['/login', '/login/', '/register', '/signup', '/signup/', '/invite/abc123'])(
'allows public route %s without auth',
async (path) => {
const { event, resolve } = createEvent(path);
diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts
index 0f60418..df32645 100644
--- a/frontend/src/hooks.server.ts
+++ b/frontend/src/hooks.server.ts
@@ -2,7 +2,7 @@ import type { Handle } from '@sveltejs/kit';
import { redirect } from '@sveltejs/kit';
import { apiClient } from '$lib/server/api';
-const PUBLIC_ROUTES = ['/login', '/register', '/invite'];
+const PUBLIC_ROUTES = ['/login', '/register', '/signup', '/invite'];
const STATIC_PREFIXES = ['/_app/', '/favicon'];
--
2.49.1
From e8fe69a5435ae829ca7daa0621da031f447f6910 Mon Sep 17 00:00:00 2001
From: Marcel Raddatz
Date: Thu, 2 Apr 2026 14:41:10 +0200
Subject: [PATCH 03/25] feat(auth): add BrandPanel component for signup screen
Renders brand identity with logo, app name, tagline, and feature icons
on green-dark background. Responsive: banner on mobile, 440px column
on desktop.
Co-Authored-By: Claude Opus 4.6
---
frontend/src/lib/auth/BrandPanel.svelte | 32 ++++++++++++++++++++++
frontend/src/lib/auth/BrandPanel.test.ts | 34 ++++++++++++++++++++++++
2 files changed, 66 insertions(+)
create mode 100644 frontend/src/lib/auth/BrandPanel.svelte
create mode 100644 frontend/src/lib/auth/BrandPanel.test.ts
diff --git a/frontend/src/lib/auth/BrandPanel.svelte b/frontend/src/lib/auth/BrandPanel.svelte
new file mode 100644
index 0000000..a28d8ab
--- /dev/null
+++ b/frontend/src/lib/auth/BrandPanel.svelte
@@ -0,0 +1,32 @@
+
+
+
+
🥗
+
+
+ Mealprep
+
+
+
+ Plan meals, eat well, waste less
+
+
+
+ {#each [{ emoji: '📅', label: 'Plan' }, { emoji: '🍳', label: 'Cook' }, { emoji: '🛒', label: 'Shop' }] as feature (feature.label)}
+
+ {feature.emoji}
+ {feature.label}
+
+ {/each}
+
+
diff --git a/frontend/src/lib/auth/BrandPanel.test.ts b/frontend/src/lib/auth/BrandPanel.test.ts
new file mode 100644
index 0000000..38b9dd3
--- /dev/null
+++ b/frontend/src/lib/auth/BrandPanel.test.ts
@@ -0,0 +1,34 @@
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/svelte';
+import BrandPanel from './BrandPanel.svelte';
+
+describe('BrandPanel', () => {
+ it('renders the app name', () => {
+ render(BrandPanel);
+ expect(screen.getByText('Mealprep')).toBeInTheDocument();
+ });
+
+ it('renders the tagline', () => {
+ render(BrandPanel);
+ expect(screen.getByText('Plan meals, eat well, waste less')).toBeInTheDocument();
+ });
+
+ it('renders the logo emoji', () => {
+ render(BrandPanel);
+ expect(screen.getByText('🥗')).toBeInTheDocument();
+ });
+
+ it('renders three feature icons with labels', () => {
+ render(BrandPanel);
+ expect(screen.getByText('Plan')).toBeInTheDocument();
+ expect(screen.getByText('Cook')).toBeInTheDocument();
+ expect(screen.getByText('Shop')).toBeInTheDocument();
+ });
+
+ it('renders feature emojis', () => {
+ render(BrandPanel);
+ expect(screen.getByText('📅')).toBeInTheDocument();
+ expect(screen.getByText('🍳')).toBeInTheDocument();
+ expect(screen.getByText('🛒')).toBeInTheDocument();
+ });
+});
--
2.49.1
From d5d85d1156643efea529c6cf5a1fee4ae5ee26c0 Mon Sep 17 00:00:00 2001
From: Marcel Raddatz
Date: Thu, 2 Apr 2026 14:45:11 +0200
Subject: [PATCH 04/25] rename backend service
---
docker-compose.yml | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/docker-compose.yml b/docker-compose.yml
index 10fa0dc..3b7e8a5 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -16,11 +16,11 @@ services:
timeout: 3s
retries: 5
- app:
+ backend:
build:
context: ./backend
dockerfile: Dockerfile
- container_name: mealprep-app
+ container_name: mealprep-backend
ports:
- "8080:8080"
environment:
@@ -40,9 +40,9 @@ services:
ports:
- "3000:3000"
environment:
- BACKEND_URL: http://app:8080
+ BACKEND_URL: http://backend:8080
depends_on:
- - app
+ - backend
volumes:
pgdata:
--
2.49.1
From d3a851829854a49a65d067981233de6b5a6038ff Mon Sep 17 00:00:00 2001
From: Marcel Raddatz
Date: Thu, 2 Apr 2026 14:45:54 +0200
Subject: [PATCH 05/25] feat(auth): add SignupForm component with validation
and password toggle
Form with name/email/password fields, client-side validation,
inline error messages, and password show/hide toggle.
Uses native form action for progressive enhancement.
Co-Authored-By: Claude Opus 4.6
---
frontend/package-lock.json | 15 +++
frontend/package.json | 1 +
frontend/src/lib/auth/SignupForm.svelte | 154 +++++++++++++++++++++++
frontend/src/lib/auth/SignupForm.test.ts | 125 ++++++++++++++++++
4 files changed, 295 insertions(+)
create mode 100644 frontend/src/lib/auth/SignupForm.svelte
create mode 100644 frontend/src/lib/auth/SignupForm.test.ts
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 7df3349..2eea8fb 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -19,6 +19,7 @@
"@tailwindcss/vite": "^4.2.2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/svelte": "^5.3.1",
+ "@testing-library/user-event": "^14.6.1",
"@types/node": "^25.5.0",
"@vitest/ui": "^4.1.2",
"jsdom": "^29.0.1",
@@ -1809,6 +1810,20 @@
"svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0"
}
},
+ "node_modules/@testing-library/user-event": {
+ "version": "14.6.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz",
+ "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": ">=7.21.4"
+ }
+ },
"node_modules/@types/aria-query": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index ee0cbb9..5733cbc 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -25,6 +25,7 @@
"@tailwindcss/vite": "^4.2.2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/svelte": "^5.3.1",
+ "@testing-library/user-event": "^14.6.1",
"@types/node": "^25.5.0",
"@vitest/ui": "^4.1.2",
"jsdom": "^29.0.1",
diff --git a/frontend/src/lib/auth/SignupForm.svelte b/frontend/src/lib/auth/SignupForm.svelte
new file mode 100644
index 0000000..5637dd9
--- /dev/null
+++ b/frontend/src/lib/auth/SignupForm.svelte
@@ -0,0 +1,154 @@
+
+
+
diff --git a/frontend/src/lib/auth/SignupForm.test.ts b/frontend/src/lib/auth/SignupForm.test.ts
new file mode 100644
index 0000000..e7fdddb
--- /dev/null
+++ b/frontend/src/lib/auth/SignupForm.test.ts
@@ -0,0 +1,125 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/svelte';
+import { userEvent } from '@testing-library/user-event';
+import SignupForm from './SignupForm.svelte';
+
+describe('SignupForm', () => {
+ it('renders all form fields with correct labels', () => {
+ render(SignupForm);
+ expect(screen.getByLabelText('Dein Name')).toBeInTheDocument();
+ expect(screen.getByLabelText('E-Mail')).toBeInTheDocument();
+ expect(screen.getByLabelText('Passwort')).toBeInTheDocument();
+ });
+
+ it('renders submit button with correct text', () => {
+ render(SignupForm);
+ expect(screen.getByRole('button', { name: /konto erstellen/i })).toBeInTheDocument();
+ });
+
+ it('renders login link', () => {
+ render(SignupForm);
+ const link = screen.getByRole('link', { name: /anmelden/i });
+ expect(link).toHaveAttribute('href', '/login');
+ });
+
+ it('renders heading and subtitle', () => {
+ render(SignupForm);
+ expect(screen.getByText('Konto erstellen')).toBeInTheDocument();
+ expect(screen.getByText('Danach richtest du deinen Haushalt ein.')).toBeInTheDocument();
+ });
+
+ it('password field is initially of type password', () => {
+ render(SignupForm);
+ const input = screen.getByLabelText('Passwort');
+ expect(input).toHaveAttribute('type', 'password');
+ });
+
+ it('password toggle switches to text and back', async () => {
+ const user = userEvent.setup();
+ render(SignupForm);
+
+ const input = screen.getByLabelText('Passwort');
+ const toggle = screen.getByRole('button', { name: /passwort anzeigen/i });
+
+ await user.click(toggle);
+ expect(input).toHaveAttribute('type', 'text');
+
+ await user.click(toggle);
+ expect(input).toHaveAttribute('type', 'password');
+ });
+
+ it('shows validation error for empty name on submit', async () => {
+ const user = userEvent.setup();
+ render(SignupForm);
+
+ const email = screen.getByLabelText('E-Mail');
+ const password = screen.getByLabelText('Passwort');
+ await user.type(email, 'test@example.com');
+ await user.type(password, 'password123');
+
+ const submit = screen.getByRole('button', { name: /konto erstellen/i });
+ await user.click(submit);
+
+ expect(screen.getByText('Name ist erforderlich')).toBeInTheDocument();
+ });
+
+ it('shows validation error for invalid email on submit', async () => {
+ const user = userEvent.setup();
+ render(SignupForm);
+
+ const name = screen.getByLabelText('Dein Name');
+ const email = screen.getByLabelText('E-Mail');
+ const password = screen.getByLabelText('Passwort');
+ await user.type(name, 'Sarah');
+ await user.type(email, 'notanemail');
+ await user.type(password, 'password123');
+
+ const submit = screen.getByRole('button', { name: /konto erstellen/i });
+ await user.click(submit);
+
+ expect(screen.getByText('Ungültige E-Mail-Adresse')).toBeInTheDocument();
+ });
+
+ it('shows validation error for short password on submit', async () => {
+ const user = userEvent.setup();
+ render(SignupForm);
+
+ const name = screen.getByLabelText('Dein Name');
+ const email = screen.getByLabelText('E-Mail');
+ const password = screen.getByLabelText('Passwort');
+ await user.type(name, 'Sarah');
+ await user.type(email, 'test@example.com');
+ await user.type(password, 'short');
+
+ const submit = screen.getByRole('button', { name: /konto erstellen/i });
+ await user.click(submit);
+
+ expect(screen.getByText('Mindestens 8 Zeichen')).toBeInTheDocument();
+ });
+
+ it('shows no validation errors when all fields are valid', async () => {
+ const user = userEvent.setup();
+ render(SignupForm);
+
+ const name = screen.getByLabelText('Dein Name');
+ const email = screen.getByLabelText('E-Mail');
+ const password = screen.getByLabelText('Passwort');
+ await user.type(name, 'Sarah');
+ await user.type(email, 'test@example.com');
+ await user.type(password, 'password123');
+
+ const submit = screen.getByRole('button', { name: /konto erstellen/i });
+ await user.click(submit);
+
+ expect(screen.queryByText('Name ist erforderlich')).not.toBeInTheDocument();
+ expect(screen.queryByText('Ungültige E-Mail-Adresse')).not.toBeInTheDocument();
+ expect(screen.queryByText('Mindestens 8 Zeichen')).not.toBeInTheDocument();
+ });
+
+ it('renders placeholders on inputs', () => {
+ render(SignupForm);
+ expect(screen.getByPlaceholderText('z.B. Sarah')).toBeInTheDocument();
+ expect(screen.getByPlaceholderText('du@beispiel.de')).toBeInTheDocument();
+ expect(screen.getByPlaceholderText('Mindestens 8 Zeichen')).toBeInTheDocument();
+ });
+});
--
2.49.1
From 596652d6e4666a61b641557f8d991fee306acff8 Mon Sep 17 00:00:00 2001
From: Marcel Raddatz
Date: Thu, 2 Apr 2026 14:47:36 +0200
Subject: [PATCH 06/25] feat(auth): add signup page with form action
Composes BrandPanel + SignupForm in responsive split layout.
Server action POSTs to /v1/auth/signup and redirects to
/household/setup on success.
Co-Authored-By: Claude Opus 4.6
---
.../routes/(public)/signup/+page.server.ts | 23 +++++
.../src/routes/(public)/signup/+page.svelte | 14 +++
.../(public)/signup/page.server.test.ts | 85 +++++++++++++++++++
3 files changed, 122 insertions(+)
create mode 100644 frontend/src/routes/(public)/signup/+page.server.ts
create mode 100644 frontend/src/routes/(public)/signup/+page.svelte
create mode 100644 frontend/src/routes/(public)/signup/page.server.test.ts
diff --git a/frontend/src/routes/(public)/signup/+page.server.ts b/frontend/src/routes/(public)/signup/+page.server.ts
new file mode 100644
index 0000000..383c855
--- /dev/null
+++ b/frontend/src/routes/(public)/signup/+page.server.ts
@@ -0,0 +1,23 @@
+import { redirect, fail } from '@sveltejs/kit';
+import { apiClient } from '$lib/server/api';
+import type { Actions } from './$types';
+
+export const actions = {
+ default: async ({ request, fetch }) => {
+ const formData = await request.formData();
+ const displayName = formData.get('displayName') as string;
+ const email = formData.get('email') as string;
+ const password = formData.get('password') as string;
+
+ const api = apiClient(fetch);
+ const { data, error } = await api.POST('/v1/auth/signup', {
+ body: { displayName, email, password }
+ });
+
+ if (error) {
+ return fail(400, { error: 'Registrierung fehlgeschlagen. Bitte versuche es erneut.' });
+ }
+
+ redirect(303, '/household/setup');
+ }
+} satisfies Actions;
diff --git a/frontend/src/routes/(public)/signup/+page.svelte b/frontend/src/routes/(public)/signup/+page.svelte
new file mode 100644
index 0000000..70027d2
--- /dev/null
+++ b/frontend/src/routes/(public)/signup/+page.svelte
@@ -0,0 +1,14 @@
+
+
+
+
diff --git a/frontend/src/routes/(public)/signup/page.server.test.ts b/frontend/src/routes/(public)/signup/page.server.test.ts
new file mode 100644
index 0000000..0a18c07
--- /dev/null
+++ b/frontend/src/routes/(public)/signup/page.server.test.ts
@@ -0,0 +1,85 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+vi.mock('$env/dynamic/private', () => ({
+ env: { BACKEND_URL: 'http://localhost:8080' }
+}));
+
+const mockPost = vi.fn();
+vi.mock('$lib/server/api', () => ({
+ apiClient: () => ({ POST: mockPost })
+}));
+
+describe('signup form action', () => {
+ let actions: any;
+
+ beforeEach(async () => {
+ mockPost.mockReset();
+ const mod = await import('./+page.server');
+ actions = mod.actions;
+ });
+
+ function createRequest(formData: Record) {
+ const fd = new FormData();
+ for (const [key, value] of Object.entries(formData)) {
+ fd.append(key, value);
+ }
+ return {
+ request: { formData: () => Promise.resolve(fd) },
+ fetch: vi.fn(),
+ cookies: { get: vi.fn(), set: vi.fn() }
+ } as any;
+ }
+
+ it('calls POST /v1/auth/signup with form data', async () => {
+ mockPost.mockResolvedValue({ data: { data: { id: '123' } }, error: undefined });
+
+ try {
+ await actions.default(createRequest({
+ displayName: 'Sarah',
+ email: 'sarah@example.com',
+ password: 'password123'
+ }));
+ } catch {
+ // redirect throws
+ }
+
+ expect(mockPost).toHaveBeenCalledWith('/v1/auth/signup', {
+ body: {
+ displayName: 'Sarah',
+ email: 'sarah@example.com',
+ password: 'password123'
+ }
+ });
+ });
+
+ it('redirects to /household/setup on success', async () => {
+ mockPost.mockResolvedValue({ data: { data: { id: '123' } }, error: undefined });
+
+ try {
+ await actions.default(createRequest({
+ displayName: 'Sarah',
+ email: 'sarah@example.com',
+ password: 'password123'
+ }));
+ expect.unreachable();
+ } catch (e: any) {
+ expect(e.status).toBe(303);
+ expect(e.location).toBe('/household/setup');
+ }
+ });
+
+ it('returns fail with error message on API error', async () => {
+ mockPost.mockResolvedValue({
+ data: undefined,
+ error: { status: 409, message: 'Email already registered' }
+ });
+
+ const result = await actions.default(createRequest({
+ displayName: 'Sarah',
+ email: 'sarah@example.com',
+ password: 'password123'
+ }));
+
+ expect(result.status).toBe(400);
+ });
+});
--
2.49.1
From bfa8f20fe3b70a9c97a29b93d8d184961f1fa95e Mon Sep 17 00:00:00 2001
From: Marcel Raddatz
Date: Thu, 2 Apr 2026 14:48:23 +0200
Subject: [PATCH 07/25] test(auth): add no-nav-chrome regression test for
signup page
Verifies signup page renders form and brand panel but no
navigation elements (tabs, sidebar, links to app routes).
Co-Authored-By: Claude Opus 4.6
---
.../src/routes/(public)/signup/page.test.ts | 33 +++++++++++++++++++
1 file changed, 33 insertions(+)
create mode 100644 frontend/src/routes/(public)/signup/page.test.ts
diff --git a/frontend/src/routes/(public)/signup/page.test.ts b/frontend/src/routes/(public)/signup/page.test.ts
new file mode 100644
index 0000000..9efe985
--- /dev/null
+++ b/frontend/src/routes/(public)/signup/page.test.ts
@@ -0,0 +1,33 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/svelte';
+import Page from './+page.svelte';
+
+vi.mock('$app/stores', async () => {
+ const { readable } = await import('svelte/store');
+ return {
+ page: readable({ url: new URL('http://localhost/signup') })
+ };
+});
+
+describe('signup page', () => {
+ it('renders the signup form', () => {
+ render(Page);
+ expect(screen.getByText('Konto erstellen')).toBeInTheDocument();
+ });
+
+ it('renders the brand panel', () => {
+ render(Page);
+ expect(screen.getByText('Mealprep')).toBeInTheDocument();
+ });
+
+ it('does not render any navigation chrome', () => {
+ render(Page);
+ // No nav element should exist
+ expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
+ // No app shell nav links
+ expect(screen.queryByText('Planer')).not.toBeInTheDocument();
+ expect(screen.queryByText('Rezepte')).not.toBeInTheDocument();
+ expect(screen.queryByText('Einkauf')).not.toBeInTheDocument();
+ expect(screen.queryByText('Einstellungen')).not.toBeInTheDocument();
+ });
+});
--
2.49.1
From b71c98662b3a9e92ca1d5c8d31aabeac73ad1e97 Mon Sep 17 00:00:00 2001
From: Marcel Raddatz
Date: Thu, 2 Apr 2026 14:56:49 +0200
Subject: [PATCH 08/25] fix(auth): use --green-dark on submit button for WCAG
AA contrast
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
--green (#3D8C4A) gives 4.16:1 against white — fails AA.
--green-dark (#2E6E39) passes comfortably.
Co-Authored-By: Claude Opus 4.6
---
frontend/src/lib/auth/SignupForm.svelte | 2 +-
frontend/src/lib/auth/SignupForm.test.ts | 6 ++++++
2 files changed, 7 insertions(+), 1 deletion(-)
diff --git a/frontend/src/lib/auth/SignupForm.svelte b/frontend/src/lib/auth/SignupForm.svelte
index 5637dd9..00c440b 100644
--- a/frontend/src/lib/auth/SignupForm.svelte
+++ b/frontend/src/lib/auth/SignupForm.svelte
@@ -139,7 +139,7 @@
diff --git a/frontend/src/lib/auth/SignupForm.test.ts b/frontend/src/lib/auth/SignupForm.test.ts
index e7fdddb..fc92dcf 100644
--- a/frontend/src/lib/auth/SignupForm.test.ts
+++ b/frontend/src/lib/auth/SignupForm.test.ts
@@ -116,6 +116,12 @@ describe('SignupForm', () => {
expect(screen.queryByText('Mindestens 8 Zeichen')).not.toBeInTheDocument();
});
+ it('submit button uses --green-dark for WCAG AA contrast', () => {
+ render(SignupForm);
+ const button = screen.getByRole('button', { name: /konto erstellen/i });
+ expect(button.className).toContain('bg-[var(--green-dark)]');
+ });
+
it('renders placeholders on inputs', () => {
render(SignupForm);
expect(screen.getByPlaceholderText('z.B. Sarah')).toBeInTheDocument();
--
2.49.1
From 75a13d9df184da14cb1c406c3fd8b28bcc0a30d1 Mon Sep 17 00:00:00 2001
From: Marcel Raddatz
Date: Thu, 2 Apr 2026 14:57:47 +0200
Subject: [PATCH 09/25] fix(auth): style login link green/font-medium per spec
Spec shows green text with font-weight 500, no underline by default.
Was dark text with underline.
Co-Authored-By: Claude Opus 4.6
---
frontend/src/lib/auth/SignupForm.svelte | 2 +-
frontend/src/lib/auth/SignupForm.test.ts | 4 +++-
2 files changed, 4 insertions(+), 2 deletions(-)
diff --git a/frontend/src/lib/auth/SignupForm.svelte b/frontend/src/lib/auth/SignupForm.svelte
index 00c440b..ec1cf02 100644
--- a/frontend/src/lib/auth/SignupForm.svelte
+++ b/frontend/src/lib/auth/SignupForm.svelte
@@ -149,6 +149,6 @@
class="mt-[16px] text-center text-[12px] text-[var(--color-text-muted)]
md:text-[13px]"
>
- Du hast bereits ein Konto? Anmelden
+ Du hast bereits ein Konto? Anmelden
diff --git a/frontend/src/lib/auth/SignupForm.test.ts b/frontend/src/lib/auth/SignupForm.test.ts
index fc92dcf..f891b69 100644
--- a/frontend/src/lib/auth/SignupForm.test.ts
+++ b/frontend/src/lib/auth/SignupForm.test.ts
@@ -16,10 +16,12 @@ describe('SignupForm', () => {
expect(screen.getByRole('button', { name: /konto erstellen/i })).toBeInTheDocument();
});
- it('renders login link', () => {
+ it('renders login link with correct href and styling', () => {
render(SignupForm);
const link = screen.getByRole('link', { name: /anmelden/i });
expect(link).toHaveAttribute('href', '/login');
+ expect(link.className).toContain('text-[var(--green)]');
+ expect(link.className).toContain('font-medium');
});
it('renders heading and subtitle', () => {
--
2.49.1
From afcea6590dc4df45e6652796f4bfa542cd60dae5 Mon Sep 17 00:00:00 2001
From: Marcel Raddatz
Date: Thu, 2 Apr 2026 14:58:59 +0200
Subject: [PATCH 10/25] feat(auth): add autocomplete attributes to signup form
inputs
name, email, new-password for better browser/password-manager support.
Co-Authored-By: Claude Opus 4.6
---
frontend/src/lib/auth/SignupForm.svelte | 3 +++
frontend/src/lib/auth/SignupForm.test.ts | 7 +++++++
2 files changed, 10 insertions(+)
diff --git a/frontend/src/lib/auth/SignupForm.svelte b/frontend/src/lib/auth/SignupForm.svelte
index ec1cf02..8ba5125 100644
--- a/frontend/src/lib/auth/SignupForm.svelte
+++ b/frontend/src/lib/auth/SignupForm.svelte
@@ -67,6 +67,7 @@
id="displayName"
name="displayName"
placeholder="z.B. Sarah"
+ autocomplete="name"
bind:value={displayName}
class="w-full rounded-[var(--radius-md)] border bg-[var(--color-page)] px-[12px] py-[10px] font-[var(--font-sans)] text-[14px] text-[var(--color-text)] outline-none
{errors.displayName ? 'border-[var(--color-error)]' : 'border-[var(--color-border)]'}"
@@ -91,6 +92,7 @@
id="email"
name="email"
placeholder="du@beispiel.de"
+ autocomplete="email"
bind:value={email}
class="w-full rounded-[var(--radius-md)] border bg-[var(--color-page)] px-[12px] py-[10px] font-[var(--font-sans)] text-[14px] text-[var(--color-text)] outline-none
{errors.email ? 'border-[var(--color-error)]' : 'border-[var(--color-border)]'}"
@@ -116,6 +118,7 @@
id="password"
name="password"
placeholder="Mindestens 8 Zeichen"
+ autocomplete="new-password"
bind:value={password}
class="w-full rounded-[var(--radius-md)] border bg-[var(--color-page)] px-[12px] py-[10px] pr-[44px] font-[var(--font-sans)] text-[14px] text-[var(--color-text)] outline-none
{errors.password ? 'border-[var(--color-error)]' : 'border-[var(--color-border)]'}"
diff --git a/frontend/src/lib/auth/SignupForm.test.ts b/frontend/src/lib/auth/SignupForm.test.ts
index f891b69..20778df 100644
--- a/frontend/src/lib/auth/SignupForm.test.ts
+++ b/frontend/src/lib/auth/SignupForm.test.ts
@@ -124,6 +124,13 @@ describe('SignupForm', () => {
expect(button.className).toContain('bg-[var(--green-dark)]');
});
+ it('inputs have correct autocomplete attributes', () => {
+ render(SignupForm);
+ expect(screen.getByLabelText('Dein Name')).toHaveAttribute('autocomplete', 'name');
+ expect(screen.getByLabelText('E-Mail')).toHaveAttribute('autocomplete', 'email');
+ expect(screen.getByLabelText('Passwort')).toHaveAttribute('autocomplete', 'new-password');
+ });
+
it('renders placeholders on inputs', () => {
render(SignupForm);
expect(screen.getByPlaceholderText('z.B. Sarah')).toBeInTheDocument();
--
2.49.1
From 845e669cde174c9c8280afc08dd8081600065c13 Mon Sep 17 00:00:00 2001
From: Marcel Raddatz
Date: Thu, 2 Apr 2026 15:00:02 +0200
Subject: [PATCH 11/25] feat(auth): add page title to signup screen
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Sets Konto erstellen — Mealprep via svelte:head
for browser tab and accessibility.
Co-Authored-By: Claude Opus 4.6
---
frontend/src/routes/(public)/signup/+page.svelte | 4 ++++
frontend/src/routes/(public)/signup/page.test.ts | 5 +++++
2 files changed, 9 insertions(+)
diff --git a/frontend/src/routes/(public)/signup/+page.svelte b/frontend/src/routes/(public)/signup/+page.svelte
index 70027d2..984298d 100644
--- a/frontend/src/routes/(public)/signup/+page.svelte
+++ b/frontend/src/routes/(public)/signup/+page.svelte
@@ -3,6 +3,10 @@
import SignupForm from '$lib/auth/SignupForm.svelte';
+
+ Konto erstellen — Mealprep
+
+
diff --git a/frontend/src/routes/(public)/signup/page.test.ts b/frontend/src/routes/(public)/signup/page.test.ts
index 9efe985..c8fde4d 100644
--- a/frontend/src/routes/(public)/signup/page.test.ts
+++ b/frontend/src/routes/(public)/signup/page.test.ts
@@ -20,6 +20,11 @@ describe('signup page', () => {
expect(screen.getByText('Mealprep')).toBeInTheDocument();
});
+ it('sets the page title', () => {
+ render(Page);
+ expect(document.title).toBe('Konto erstellen — Mealprep');
+ });
+
it('does not render any navigation chrome', () => {
render(Page);
// No nav element should exist
--
2.49.1
From 82840bb42091dd8b88e940b8d26c2e33075ed269 Mon Sep 17 00:00:00 2001
From: Marcel Raddatz
Date: Thu, 2 Apr 2026 15:01:03 +0200
Subject: [PATCH 12/25] fix(auth): center signup form on wide desktop screens
Form container now horizontally centered on md+ viewports,
left-aligned on mobile for full-width usage.
Co-Authored-By: Claude Opus 4.6
---
frontend/src/routes/(public)/signup/+page.svelte | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/frontend/src/routes/(public)/signup/+page.svelte b/frontend/src/routes/(public)/signup/+page.svelte
index 984298d..db668d6 100644
--- a/frontend/src/routes/(public)/signup/+page.svelte
+++ b/frontend/src/routes/(public)/signup/+page.svelte
@@ -10,8 +10,8 @@
-
-
+
--
2.49.1
From bd9e1334e0ca669ba7527314433883e378c3e861 Mon Sep 17 00:00:00 2001
From: Marcel Raddatz
Date: Thu, 2 Apr 2026 15:02:33 +0200
Subject: [PATCH 13/25] feat(auth): add server-side validation to signup form
action
Validates displayName, email, password server-side before calling
the backend API. Handles null from formData.get() safely.
Returns structured field errors via fail(400, { errors }).
Co-Authored-By: Claude Opus 4.6
---
.../routes/(public)/signup/+page.server.ts | 33 ++++++++++--
.../(public)/signup/page.server.test.ts | 51 +++++++++++++++++++
2 files changed, 79 insertions(+), 5 deletions(-)
diff --git a/frontend/src/routes/(public)/signup/+page.server.ts b/frontend/src/routes/(public)/signup/+page.server.ts
index 383c855..8fa6ff5 100644
--- a/frontend/src/routes/(public)/signup/+page.server.ts
+++ b/frontend/src/routes/(public)/signup/+page.server.ts
@@ -5,17 +5,40 @@ import type { Actions } from './$types';
export const actions = {
default: async ({ request, fetch }) => {
const formData = await request.formData();
- const displayName = formData.get('displayName') as string;
- const email = formData.get('email') as string;
- const password = formData.get('password') as string;
+ const displayName = (formData.get('displayName') ?? '').toString().trim();
+ const email = (formData.get('email') ?? '').toString().trim();
+ const password = (formData.get('password') ?? '').toString();
+
+ const errors: Record = {};
+
+ if (!displayName) {
+ errors.displayName = 'Name ist erforderlich';
+ }
+
+ const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ if (!emailPattern.test(email)) {
+ errors.email = 'Ungültige E-Mail-Adresse';
+ }
+
+ if (password.length < 8) {
+ errors.password = 'Mindestens 8 Zeichen';
+ }
+
+ if (Object.keys(errors).length > 0) {
+ return fail(400, { errors, displayName, email });
+ }
const api = apiClient(fetch);
- const { data, error } = await api.POST('/v1/auth/signup', {
+ const { error } = await api.POST('/v1/auth/signup', {
body: { displayName, email, password }
});
if (error) {
- return fail(400, { error: 'Registrierung fehlgeschlagen. Bitte versuche es erneut.' });
+ return fail(400, {
+ errors: { form: 'Registrierung fehlgeschlagen. Bitte versuche es erneut.' },
+ displayName,
+ email
+ });
}
redirect(303, '/household/setup');
diff --git a/frontend/src/routes/(public)/signup/page.server.test.ts b/frontend/src/routes/(public)/signup/page.server.test.ts
index 0a18c07..defc113 100644
--- a/frontend/src/routes/(public)/signup/page.server.test.ts
+++ b/frontend/src/routes/(public)/signup/page.server.test.ts
@@ -68,6 +68,56 @@ describe('signup form action', () => {
}
});
+ it('rejects empty displayName with validation error', async () => {
+ const result = await actions.default(createRequest({
+ displayName: '',
+ email: 'sarah@example.com',
+ password: 'password123'
+ }));
+
+ expect(result.status).toBe(400);
+ expect(result.data.errors.displayName).toBe('Name ist erforderlich');
+ expect(mockPost).not.toHaveBeenCalled();
+ });
+
+ it('rejects invalid email with validation error', async () => {
+ const result = await actions.default(createRequest({
+ displayName: 'Sarah',
+ email: 'notanemail',
+ password: 'password123'
+ }));
+
+ expect(result.status).toBe(400);
+ expect(result.data.errors.email).toBe('Ungültige E-Mail-Adresse');
+ expect(mockPost).not.toHaveBeenCalled();
+ });
+
+ it('rejects short password with validation error', async () => {
+ const result = await actions.default(createRequest({
+ displayName: 'Sarah',
+ email: 'sarah@example.com',
+ password: 'short'
+ }));
+
+ expect(result.status).toBe(400);
+ expect(result.data.errors.password).toBe('Mindestens 8 Zeichen');
+ expect(mockPost).not.toHaveBeenCalled();
+ });
+
+ it('handles missing form fields without crashing', async () => {
+ const fd = new FormData();
+ const event = {
+ request: { formData: () => Promise.resolve(fd) },
+ fetch: vi.fn(),
+ cookies: { get: vi.fn(), set: vi.fn() }
+ } as any;
+
+ const result = await actions.default(event);
+ expect(result.status).toBe(400);
+ expect(result.data.errors.displayName).toBe('Name ist erforderlich');
+ expect(mockPost).not.toHaveBeenCalled();
+ });
+
it('returns fail with error message on API error', async () => {
mockPost.mockResolvedValue({
data: undefined,
@@ -81,5 +131,6 @@ describe('signup form action', () => {
}));
expect(result.status).toBe(400);
+ expect(result.data.errors.form).toBe('Registrierung fehlgeschlagen. Bitte versuche es erneut.');
});
});
--
2.49.1
From 6d0f00c8fb155e08f2a21e8781f5bda2261b54cf Mon Sep 17 00:00:00 2001
From: Marcel Raddatz
Date: Thu, 2 Apr 2026 15:06:21 +0200
Subject: [PATCH 14/25] feat(auth): add use:enhance and server error display to
signup form
SignupForm now uses use:enhance for progressive enhancement.
Accepts form prop for server-side error display. Shows general
form errors in a banner and field-specific errors inline.
Co-Authored-By: Claude Opus 4.6
---
frontend/src/lib/auth/SignupForm.svelte | 39 +++++++++++++++++--
frontend/src/lib/auth/SignupForm.test.ts | 32 +++++++++++++++
.../src/routes/(public)/signup/+page.svelte | 4 +-
3 files changed, 71 insertions(+), 4 deletions(-)
diff --git a/frontend/src/lib/auth/SignupForm.svelte b/frontend/src/lib/auth/SignupForm.svelte
index 8ba5125..d04e657 100644
--- a/frontend/src/lib/auth/SignupForm.svelte
+++ b/frontend/src/lib/auth/SignupForm.svelte
@@ -1,8 +1,19 @@
-
--
2.49.1
From 7de18740f216e4012a814b6ff8f6f33be3f100e6 Mon Sep 17 00:00:00 2001
From: Marcel Raddatz
Date: Thu, 2 Apr 2026 15:07:07 +0200
Subject: [PATCH 15/25] test(auth): add multi-error test for empty form
submission
Verifies all three validation errors (name, email, password) appear
simultaneously when submitting a completely empty form.
Co-Authored-By: Claude Opus 4.6
---
frontend/src/lib/auth/SignupForm.test.ts | 12 ++++++++++++
1 file changed, 12 insertions(+)
diff --git a/frontend/src/lib/auth/SignupForm.test.ts b/frontend/src/lib/auth/SignupForm.test.ts
index 8c375b8..842200f 100644
--- a/frontend/src/lib/auth/SignupForm.test.ts
+++ b/frontend/src/lib/auth/SignupForm.test.ts
@@ -163,6 +163,18 @@ describe('SignupForm', () => {
expect(screen.getByText('Ungültige E-Mail-Adresse')).toBeInTheDocument();
});
+ it('shows all three validation errors when form submitted empty', async () => {
+ const user = userEvent.setup();
+ render(SignupForm);
+
+ const submit = screen.getByRole('button', { name: /konto erstellen/i });
+ await user.click(submit);
+
+ expect(screen.getByText('Name ist erforderlich')).toBeInTheDocument();
+ expect(screen.getByText('Ungültige E-Mail-Adresse')).toBeInTheDocument();
+ expect(screen.getByText('Mindestens 8 Zeichen')).toBeInTheDocument();
+ });
+
it('renders placeholders on inputs', () => {
render(SignupForm);
expect(screen.getByPlaceholderText('z.B. Sarah')).toBeInTheDocument();
--
2.49.1
From b3607ca47a8359cedd9787220a1f231354d5c4cd Mon Sep 17 00:00:00 2001
From: Marcel Raddatz
Date: Thu, 2 Apr 2026 15:07:42 +0200
Subject: [PATCH 16/25] test(auth): add password length boundary tests (7
fails, 8 passes)
Parameterized test verifying the exact boundary of the 8-character
minimum password requirement.
Co-Authored-By: Claude Opus 4.6
---
frontend/src/lib/auth/SignupForm.test.ts | 19 +++++++++++++++++++
1 file changed, 19 insertions(+)
diff --git a/frontend/src/lib/auth/SignupForm.test.ts b/frontend/src/lib/auth/SignupForm.test.ts
index 842200f..897579d 100644
--- a/frontend/src/lib/auth/SignupForm.test.ts
+++ b/frontend/src/lib/auth/SignupForm.test.ts
@@ -175,6 +175,25 @@ describe('SignupForm', () => {
expect(screen.getByText('Mindestens 8 Zeichen')).toBeInTheDocument();
});
+ it.each([
+ { length: 7, shouldFail: true },
+ { length: 8, shouldFail: false }
+ ])('password with $length chars $shouldFail ? fails : passes validation', async ({ length, shouldFail }) => {
+ const user = userEvent.setup();
+ render(SignupForm);
+
+ await user.type(screen.getByLabelText('Dein Name'), 'Sarah');
+ await user.type(screen.getByLabelText('E-Mail'), 'test@example.com');
+ await user.type(screen.getByLabelText('Passwort'), 'a'.repeat(length));
+ await user.click(screen.getByRole('button', { name: /konto erstellen/i }));
+
+ if (shouldFail) {
+ expect(screen.getByText('Mindestens 8 Zeichen')).toBeInTheDocument();
+ } else {
+ expect(screen.queryByText('Mindestens 8 Zeichen')).not.toBeInTheDocument();
+ }
+ });
+
it('renders placeholders on inputs', () => {
render(SignupForm);
expect(screen.getByPlaceholderText('z.B. Sarah')).toBeInTheDocument();
--
2.49.1
From c27c97ff7ddecc00bbcc23d884a2df1b7903709d Mon Sep 17 00:00:00 2001
From: Marcel Raddatz
Date: Thu, 2 Apr 2026 16:18:49 +0200
Subject: [PATCH 17/25] feat(auth): add LoginForm component with validation and
password toggle
Email/password fields, client-side validation, password show/hide,
server error display via form prop, signup link.
Co-Authored-By: Claude Opus 4.6
---
frontend/src/lib/auth/LoginForm.svelte | 154 ++++++++++++++++++++++++
frontend/src/lib/auth/LoginForm.test.ts | 117 ++++++++++++++++++
2 files changed, 271 insertions(+)
create mode 100644 frontend/src/lib/auth/LoginForm.svelte
create mode 100644 frontend/src/lib/auth/LoginForm.test.ts
diff --git a/frontend/src/lib/auth/LoginForm.svelte b/frontend/src/lib/auth/LoginForm.svelte
new file mode 100644
index 0000000..166bf51
--- /dev/null
+++ b/frontend/src/lib/auth/LoginForm.svelte
@@ -0,0 +1,154 @@
+
+
+
diff --git a/frontend/src/lib/auth/LoginForm.test.ts b/frontend/src/lib/auth/LoginForm.test.ts
new file mode 100644
index 0000000..d338b02
--- /dev/null
+++ b/frontend/src/lib/auth/LoginForm.test.ts
@@ -0,0 +1,117 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/svelte';
+import { userEvent } from '@testing-library/user-event';
+import LoginForm from './LoginForm.svelte';
+
+vi.mock('$app/forms', () => ({
+ enhance: () => ({ destroy: () => {} })
+}));
+
+describe('LoginForm', () => {
+ it('renders email and password fields with correct labels', () => {
+ render(LoginForm);
+ expect(screen.getByLabelText('E-Mail')).toBeInTheDocument();
+ expect(screen.getByLabelText('Passwort')).toBeInTheDocument();
+ });
+
+ it('renders heading and subtitle', () => {
+ render(LoginForm);
+ expect(screen.getByText('Willkommen zurück')).toBeInTheDocument();
+ expect(screen.getByText('Melde dich an, um fortzufahren.')).toBeInTheDocument();
+ });
+
+ it('renders submit button', () => {
+ render(LoginForm);
+ expect(screen.getByRole('button', { name: /anmelden/i })).toBeInTheDocument();
+ });
+
+ it('renders signup link', () => {
+ render(LoginForm);
+ const link = screen.getByRole('link', { name: /registrieren/i });
+ expect(link).toHaveAttribute('href', '/signup');
+ expect(link.className).toContain('text-[var(--green)]');
+ expect(link.className).toContain('font-medium');
+ });
+
+ it('submit button uses --green-dark for WCAG AA', () => {
+ render(LoginForm);
+ const button = screen.getByRole('button', { name: /anmelden/i });
+ expect(button.className).toContain('bg-[var(--green-dark)]');
+ });
+
+ it('password field is initially of type password', () => {
+ render(LoginForm);
+ expect(screen.getByLabelText('Passwort')).toHaveAttribute('type', 'password');
+ });
+
+ it('password toggle switches type', async () => {
+ const user = userEvent.setup();
+ render(LoginForm);
+
+ const input = screen.getByLabelText('Passwort');
+ const toggle = screen.getByRole('button', { name: /passwort anzeigen/i });
+
+ await user.click(toggle);
+ expect(input).toHaveAttribute('type', 'text');
+
+ await user.click(toggle);
+ expect(input).toHaveAttribute('type', 'password');
+ });
+
+ it('inputs have correct autocomplete attributes', () => {
+ render(LoginForm);
+ expect(screen.getByLabelText('E-Mail')).toHaveAttribute('autocomplete', 'email');
+ expect(screen.getByLabelText('Passwort')).toHaveAttribute('autocomplete', 'current-password');
+ });
+
+ it('shows validation error for invalid email on submit', async () => {
+ const user = userEvent.setup();
+ render(LoginForm);
+
+ await user.type(screen.getByLabelText('E-Mail'), 'notanemail');
+ await user.type(screen.getByLabelText('Passwort'), 'password123');
+ await user.click(screen.getByRole('button', { name: /anmelden/i }));
+
+ expect(screen.getByText('Ungültige E-Mail-Adresse')).toBeInTheDocument();
+ });
+
+ it('shows validation error for empty password on submit', async () => {
+ const user = userEvent.setup();
+ render(LoginForm);
+
+ await user.type(screen.getByLabelText('E-Mail'), 'test@example.com');
+ await user.click(screen.getByRole('button', { name: /anmelden/i }));
+
+ expect(screen.getByText('Passwort ist erforderlich')).toBeInTheDocument();
+ });
+
+ it('shows no errors when fields are valid', async () => {
+ const user = userEvent.setup();
+ render(LoginForm);
+
+ await user.type(screen.getByLabelText('E-Mail'), 'test@example.com');
+ await user.type(screen.getByLabelText('Passwort'), 'password123');
+ await user.click(screen.getByRole('button', { name: /anmelden/i }));
+
+ expect(screen.queryByText('Ungültige E-Mail-Adresse')).not.toBeInTheDocument();
+ expect(screen.queryByText('Passwort ist erforderlich')).not.toBeInTheDocument();
+ });
+
+ it('displays server-side form error from form prop', () => {
+ render(LoginForm, {
+ props: {
+ form: {
+ errors: { form: 'E-Mail oder Passwort ist falsch.' },
+ email: 'test@example.com'
+ }
+ }
+ });
+ expect(screen.getByText('E-Mail oder Passwort ist falsch.')).toBeInTheDocument();
+ });
+
+ it('renders placeholders', () => {
+ render(LoginForm);
+ expect(screen.getByPlaceholderText('du@beispiel.de')).toBeInTheDocument();
+ expect(screen.getByPlaceholderText('Dein Passwort')).toBeInTheDocument();
+ });
+});
--
2.49.1
From 73acc0c638162f69e591205d569c4e3fa90b0351 Mon Sep 17 00:00:00 2001
From: Marcel Raddatz
Date: Thu, 2 Apr 2026 16:20:02 +0200
Subject: [PATCH 18/25] feat(auth): add login server action with validation and
redirect
POSTs to /v1/auth/login, validates email/password server-side,
redirects to ?redirect param or /planner on success.
Returns generic error on bad credentials to prevent enumeration.
Co-Authored-By: Claude Opus 4.6
---
.../src/routes/(public)/login/+page.server.ts | 41 ++++++
.../routes/(public)/login/page.server.test.ts | 120 ++++++++++++++++++
2 files changed, 161 insertions(+)
create mode 100644 frontend/src/routes/(public)/login/+page.server.ts
create mode 100644 frontend/src/routes/(public)/login/page.server.test.ts
diff --git a/frontend/src/routes/(public)/login/+page.server.ts b/frontend/src/routes/(public)/login/+page.server.ts
new file mode 100644
index 0000000..dd6912b
--- /dev/null
+++ b/frontend/src/routes/(public)/login/+page.server.ts
@@ -0,0 +1,41 @@
+import { redirect, fail } from '@sveltejs/kit';
+import { apiClient } from '$lib/server/api';
+import type { Actions } from './$types';
+
+export const actions = {
+ default: async ({ request, url, fetch }) => {
+ const formData = await request.formData();
+ const email = (formData.get('email') ?? '').toString().trim();
+ const password = (formData.get('password') ?? '').toString();
+
+ const errors: Record = {};
+
+ const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ if (!emailPattern.test(email)) {
+ errors.email = 'Ungültige E-Mail-Adresse';
+ }
+
+ if (!password) {
+ errors.password = 'Passwort ist erforderlich';
+ }
+
+ if (Object.keys(errors).length > 0) {
+ return fail(400, { errors, email });
+ }
+
+ const api = apiClient(fetch);
+ const { error } = await api.POST('/v1/auth/login', {
+ body: { email, password }
+ });
+
+ if (error) {
+ return fail(400, {
+ errors: { form: 'E-Mail oder Passwort ist falsch.' },
+ email
+ });
+ }
+
+ const redirectTo = url.searchParams.get('redirect') || '/planner';
+ redirect(303, redirectTo);
+ }
+} satisfies Actions;
diff --git a/frontend/src/routes/(public)/login/page.server.test.ts b/frontend/src/routes/(public)/login/page.server.test.ts
new file mode 100644
index 0000000..a3f636e
--- /dev/null
+++ b/frontend/src/routes/(public)/login/page.server.test.ts
@@ -0,0 +1,120 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+vi.mock('$env/dynamic/private', () => ({
+ env: { BACKEND_URL: 'http://localhost:8080' }
+}));
+
+const mockPost = vi.fn();
+vi.mock('$lib/server/api', () => ({
+ apiClient: () => ({ POST: mockPost })
+}));
+
+describe('login form action', () => {
+ let actions: any;
+
+ beforeEach(async () => {
+ mockPost.mockReset();
+ const mod = await import('./+page.server');
+ actions = mod.actions;
+ });
+
+ function createEvent(formData: Record, searchParams = '') {
+ const fd = new FormData();
+ for (const [key, value] of Object.entries(formData)) {
+ fd.append(key, value);
+ }
+ return {
+ request: { formData: () => Promise.resolve(fd) },
+ url: new URL(`http://localhost/login${searchParams}`),
+ fetch: vi.fn(),
+ cookies: { get: vi.fn(), set: vi.fn() }
+ } as any;
+ }
+
+ it('calls POST /v1/auth/login with form data', async () => {
+ mockPost.mockResolvedValue({ data: { data: { id: '123' } }, error: undefined });
+
+ try {
+ await actions.default(createEvent({
+ email: 'sarah@example.com',
+ password: 'password123'
+ }));
+ } catch {
+ // redirect throws
+ }
+
+ expect(mockPost).toHaveBeenCalledWith('/v1/auth/login', {
+ body: {
+ email: 'sarah@example.com',
+ password: 'password123'
+ }
+ });
+ });
+
+ it('redirects to /planner on success by default', async () => {
+ mockPost.mockResolvedValue({ data: { data: { id: '123' } }, error: undefined });
+
+ try {
+ await actions.default(createEvent({
+ email: 'sarah@example.com',
+ password: 'password123'
+ }));
+ expect.unreachable();
+ } catch (e: any) {
+ expect(e.status).toBe(303);
+ expect(e.location).toBe('/planner');
+ }
+ });
+
+ it('redirects to ?redirect param when present', async () => {
+ mockPost.mockResolvedValue({ data: { data: { id: '123' } }, error: undefined });
+
+ try {
+ await actions.default(createEvent(
+ { email: 'sarah@example.com', password: 'password123' },
+ '?redirect=%2Frecipes%2Fabc'
+ ));
+ expect.unreachable();
+ } catch (e: any) {
+ expect(e.status).toBe(303);
+ expect(e.location).toBe('/recipes/abc');
+ }
+ });
+
+ it('rejects empty email with validation error', async () => {
+ const result = await actions.default(createEvent({
+ email: '',
+ password: 'password123'
+ }));
+
+ expect(result.status).toBe(400);
+ expect(result.data.errors.email).toBe('Ungültige E-Mail-Adresse');
+ expect(mockPost).not.toHaveBeenCalled();
+ });
+
+ it('rejects empty password with validation error', async () => {
+ const result = await actions.default(createEvent({
+ email: 'sarah@example.com',
+ password: ''
+ }));
+
+ expect(result.status).toBe(400);
+ expect(result.data.errors.password).toBe('Passwort ist erforderlich');
+ expect(mockPost).not.toHaveBeenCalled();
+ });
+
+ it('returns fail with form error on API error', async () => {
+ mockPost.mockResolvedValue({
+ data: undefined,
+ error: { status: 401, message: 'Invalid credentials' }
+ });
+
+ const result = await actions.default(createEvent({
+ email: 'sarah@example.com',
+ password: 'password123'
+ }));
+
+ expect(result.status).toBe(400);
+ expect(result.data.errors.form).toBe('E-Mail oder Passwort ist falsch.');
+ });
+});
--
2.49.1
From 999e54de86e26d346fa8a8357b66455d32f74575 Mon Sep 17 00:00:00 2001
From: Marcel Raddatz
Date: Thu, 2 Apr 2026 16:21:31 +0200
Subject: [PATCH 19/25] feat(auth): build login page with LoginForm, brand
panel, and title
Replaces placeholder with full login page: brand panel left,
LoginForm right, svelte:head title, signup link, no-nav-chrome.
Co-Authored-By: Claude Opus 4.6
---
.../src/routes/(public)/login/+page.svelte | 26 ++++++++---
.../src/routes/(public)/login/page.test.ts | 44 +++++++++++++++++++
2 files changed, 64 insertions(+), 6 deletions(-)
create mode 100644 frontend/src/routes/(public)/login/page.test.ts
diff --git a/frontend/src/routes/(public)/login/+page.svelte b/frontend/src/routes/(public)/login/+page.svelte
index d2406d2..79a85b8 100644
--- a/frontend/src/routes/(public)/login/+page.svelte
+++ b/frontend/src/routes/(public)/login/+page.svelte
@@ -1,11 +1,25 @@
-
-
+
+
+
+ Anmelden — Mealprep
+
+
+
+
+
Mealprep
-
-
-
Anmelden
-
Login-Formular folgt.
+
diff --git a/frontend/src/routes/(public)/login/page.test.ts b/frontend/src/routes/(public)/login/page.test.ts
new file mode 100644
index 0000000..29485a0
--- /dev/null
+++ b/frontend/src/routes/(public)/login/page.test.ts
@@ -0,0 +1,44 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/svelte';
+import Page from './+page.svelte';
+
+vi.mock('$app/stores', async () => {
+ const { readable } = await import('svelte/store');
+ return {
+ page: readable({ url: new URL('http://localhost/login') })
+ };
+});
+
+vi.mock('$app/forms', () => ({
+ enhance: () => ({ destroy: () => {} })
+}));
+
+describe('login page', () => {
+ it('renders the login form', () => {
+ render(Page);
+ expect(screen.getByText('Willkommen zurück')).toBeInTheDocument();
+ });
+
+ it('renders the brand panel', () => {
+ render(Page);
+ expect(screen.getByText('Mealprep')).toBeInTheDocument();
+ });
+
+ it('sets the page title', () => {
+ render(Page);
+ expect(document.title).toBe('Anmelden — Mealprep');
+ });
+
+ it('does not render any navigation chrome', () => {
+ render(Page);
+ expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
+ expect(screen.queryByText('Planer')).not.toBeInTheDocument();
+ expect(screen.queryByText('Rezepte')).not.toBeInTheDocument();
+ });
+
+ it('renders a link to the signup page', () => {
+ render(Page);
+ const link = screen.getByRole('link', { name: /registrieren/i });
+ expect(link).toHaveAttribute('href', '/signup');
+ });
+});
--
2.49.1
From ab3363eeec95a5abb464bf8221b5fa22477c6b94 Mon Sep 17 00:00:00 2001
From: Marcel Raddatz
Date: Thu, 2 Apr 2026 16:45:22 +0200
Subject: [PATCH 20/25] refactor(auth): use shared BrandPanel on login page
Login page now uses the same BrandPanel component as signup
instead of an inline brand panel.
Co-Authored-By: Claude Opus 4.6
---
frontend/src/routes/(public)/login/+page.svelte | 9 ++-------
1 file changed, 2 insertions(+), 7 deletions(-)
diff --git a/frontend/src/routes/(public)/login/+page.svelte b/frontend/src/routes/(public)/login/+page.svelte
index 79a85b8..dc77604 100644
--- a/frontend/src/routes/(public)/login/+page.svelte
+++ b/frontend/src/routes/(public)/login/+page.svelte
@@ -1,4 +1,5 @@