From 6950b3d8dbdd631a88efbcba6ce02e7ffab3dfbe Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 21:31:03 +0200 Subject: [PATCH] feat(join): implement A4 join household page (/join/[token]) - schema.d.ts: add GET /v1/invites/{code}, InviteInfoResponse, AcceptInviteRequest; update acceptInvite operation - hooks.server.ts: add /join to PUBLIC_ROUTES; redirect authenticated users on /join/* to / - +page.server.ts: load validates token (invalid:true on 404); action creates account + joins + sets session cookie - HouseholdIdentityPanel.svelte: logo, household name (Fraunces), inviter text, static permissions list - JoinForm.svelte: name/email/password + show/hide toggle, "Haushalt beitreten" CTA, field errors, pre-fill - +page.svelte: no-chrome layout, mobile (banner+form stacked) / desktop (400px panel + flex:1) split, invalid-token inline state Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/hooks.server.test.ts | 13 +- frontend/src/hooks.server.ts | 6 +- frontend/src/lib/api/schema.d.ts | 73 ++++++- .../(public)/join/[token]/+page.server.ts | 83 ++++++++ .../routes/(public)/join/[token]/+page.svelte | 46 ++++ .../[token]/HouseholdIdentityPanel.svelte | 41 ++++ .../[token]/HouseholdIdentityPanel.test.ts | 34 +++ .../(public)/join/[token]/JoinForm.svelte | 113 ++++++++++ .../(public)/join/[token]/JoinForm.test.ts | 74 +++++++ .../(public)/join/[token]/page.server.test.ts | 200 ++++++++++++++++++ 10 files changed, 680 insertions(+), 3 deletions(-) create mode 100644 frontend/src/routes/(public)/join/[token]/+page.server.ts create mode 100644 frontend/src/routes/(public)/join/[token]/+page.svelte create mode 100644 frontend/src/routes/(public)/join/[token]/HouseholdIdentityPanel.svelte create mode 100644 frontend/src/routes/(public)/join/[token]/HouseholdIdentityPanel.test.ts create mode 100644 frontend/src/routes/(public)/join/[token]/JoinForm.svelte create mode 100644 frontend/src/routes/(public)/join/[token]/JoinForm.test.ts create mode 100644 frontend/src/routes/(public)/join/[token]/page.server.test.ts diff --git a/frontend/src/hooks.server.test.ts b/frontend/src/hooks.server.test.ts index 4a59d78..4da0791 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', '/signup', '/signup/', '/invite/abc123'])( + it.each(['/login', '/login/', '/register', '/signup', '/signup/', '/invite/abc123', '/join/ABC12XYZ'])( 'allows public route %s without auth', async (path) => { const { event, resolve } = createEvent(path); @@ -51,6 +51,17 @@ describe('auth guard (hooks.server.ts handle)', () => { } ); + it('redirects authenticated user on /join/[token] to /', async () => { + const { event, resolve } = createEvent('/join/ABC12XYZ', 'valid-session'); + try { + await handle({ event, resolve }); + expect.unreachable(); + } catch (e: any) { + expect(e.status).toBe(302); + expect(e.location).toBe('/'); + } + }); + it.each(['/_app/immutable/chunks/app.js', '/favicon.ico'])( 'allows static asset %s without auth', async (path) => { diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index 6f2ea43..705b340 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', '/signup', '/invite']; +const PUBLIC_ROUTES = ['/login', '/register', '/signup', '/invite', '/join']; const STATIC_PREFIXES = ['/_app/', '/favicon']; @@ -20,6 +20,10 @@ function loginRedirect(pathname: string): never { export const handle: Handle = async ({ event, resolve }) => { if (isPublicRoute(event.url.pathname)) { + const isJoinRoute = event.url.pathname.startsWith('/join/'); + if (isJoinRoute && event.cookies.get('JSESSIONID')) { + throw redirect(302, '/'); + } return resolve(event); } diff --git a/frontend/src/lib/api/schema.d.ts b/frontend/src/lib/api/schema.d.ts index a6cc337..d80b106 100644 --- a/frontend/src/lib/api/schema.d.ts +++ b/frontend/src/lib/api/schema.d.ts @@ -148,6 +148,22 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/invites/{code}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getInviteInfo"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/invites/{code}/accept": { parameters: { query?: never; @@ -739,6 +755,20 @@ export interface components { data?: components["schemas"]["AcceptInviteResponse"]; meta?: components["schemas"]["Meta"]; }; + InviteInfoResponse: { + householdName?: string; + inviterName?: string; + }; + ApiResponseInviteInfoResponse: { + status?: string; + data?: components["schemas"]["InviteInfoResponse"]; + meta?: components["schemas"]["Meta"]; + }; + AcceptInviteRequest: { + name: string; + email: string; + password: string; + }; Meta: { pagination?: components["schemas"]["Pagination"]; }; @@ -1345,7 +1375,7 @@ export interface operations { }; }; }; - acceptInvite: { + getInviteInfo: { parameters: { query?: never; header?: never; @@ -1355,6 +1385,37 @@ export interface operations { cookie?: never; }; requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseInviteInfoResponse"]; + }; + }; + /** @description Not found */ + 404: { + headers: { [name: string]: unknown }; + content: { "*/*": components["schemas"]["ApiError"] }; + }; + }; + }; + acceptInvite: { + parameters: { + query?: never; + header?: never; + path: { + code: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AcceptInviteRequest"]; + }; + }; responses: { /** @description OK */ 200: { @@ -1365,6 +1426,16 @@ export interface operations { "*/*": components["schemas"]["ApiResponseAcceptInviteResponse"]; }; }; + /** @description Email already registered */ + 409: { + headers: { [name: string]: unknown }; + content: { "*/*": components["schemas"]["ApiError"] }; + }; + /** @description Invite not found or invalid */ + 404: { + headers: { [name: string]: unknown }; + content: { "*/*": components["schemas"]["ApiError"] }; + }; }; }; listCategories: { diff --git a/frontend/src/routes/(public)/join/[token]/+page.server.ts b/frontend/src/routes/(public)/join/[token]/+page.server.ts new file mode 100644 index 0000000..5ba5970 --- /dev/null +++ b/frontend/src/routes/(public)/join/[token]/+page.server.ts @@ -0,0 +1,83 @@ +import { fail, redirect } from '@sveltejs/kit'; +import { apiClient } from '$lib/server/api'; +import type { Actions, PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ params, fetch }) => { + const api = apiClient(fetch); + const { data, error } = await api.GET('/v1/invites/{code}', { + params: { path: { code: params.token } } + }); + + if (error || !data?.data) { + return { invalid: true }; + } + + return { + invalid: false, + householdName: data.data.householdName ?? '', + inviterName: data.data.inviterName ?? '' + }; +}; + +export const actions = { + default: async ({ params, request, fetch, cookies }) => { + const formData = await request.formData(); + const name = (formData.get('name') ?? '').toString().trim(); + const email = (formData.get('email') ?? '').toString().trim(); + const password = (formData.get('password') ?? '').toString(); + + const errors: Record = {}; + + if (!name) { + errors.name = '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, name, email }); + } + + const api = apiClient(fetch); + const { error, response } = await api.POST('/v1/invites/{code}/accept', { + params: { path: { code: params.token } }, + body: { name, email, password } + }); + + if (error) { + if (error.status === 409) { + return fail(409, { + errors: { + email: 'Diese E-Mail-Adresse ist bereits registriert. Anmelden →' + }, + name, + email + }); + } + return fail(400, { + errors: { form: 'Einladung ungültig oder abgelaufen.' }, + name, + email + }); + } + + const sessionId = response?.headers.get('set-cookie')?.match(/JSESSIONID=([^;]+)/i)?.[1]; + if (sessionId) { + cookies.set('JSESSIONID', sessionId, { + path: '/', + httpOnly: true, + sameSite: 'lax', + secure: true + }); + } + + redirect(303, '/'); + } +} satisfies Actions; diff --git a/frontend/src/routes/(public)/join/[token]/+page.svelte b/frontend/src/routes/(public)/join/[token]/+page.svelte new file mode 100644 index 0000000..9b52121 --- /dev/null +++ b/frontend/src/routes/(public)/join/[token]/+page.svelte @@ -0,0 +1,46 @@ + + + + Haushalt beitreten — Mealplan + + +{#if data.invalid} +
+
+

+ Einladung ungültig oder abgelaufen +

+

+ Bitte bitte den Einladenden, einen neuen Link zu senden. +

+
+
+{:else} + + +
+ +
+ +
+ + +
+
+

+ Konto erstellen & beitreten +

+ +
+
+
+{/if} diff --git a/frontend/src/routes/(public)/join/[token]/HouseholdIdentityPanel.svelte b/frontend/src/routes/(public)/join/[token]/HouseholdIdentityPanel.svelte new file mode 100644 index 0000000..a2027dd --- /dev/null +++ b/frontend/src/routes/(public)/join/[token]/HouseholdIdentityPanel.svelte @@ -0,0 +1,41 @@ + + +
+ + + + +
+

+ {householdName} +

+

+ Eingeladen von {inviterName} +

+
+ + +
+

+ Als Mitglied kannst du +

+
    +
  • + + Wochenplan einsehen +
  • +
  • + + Einkaufsliste abhaken +
  • +
  • + + Artikel zur Liste hinzufügen +
  • +
+
+
diff --git a/frontend/src/routes/(public)/join/[token]/HouseholdIdentityPanel.test.ts b/frontend/src/routes/(public)/join/[token]/HouseholdIdentityPanel.test.ts new file mode 100644 index 0000000..f114729 --- /dev/null +++ b/frontend/src/routes/(public)/join/[token]/HouseholdIdentityPanel.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import HouseholdIdentityPanel from './HouseholdIdentityPanel.svelte'; + +describe('HouseholdIdentityPanel', () => { + it('renders household name', () => { + render(HouseholdIdentityPanel, { + props: { householdName: 'Smith family', inviterName: 'Sarah' } + }); + expect(screen.getByText('Smith family')).toBeInTheDocument(); + }); + + it('renders inviter name', () => { + render(HouseholdIdentityPanel, { + props: { householdName: 'Smith family', inviterName: 'Sarah' } + }); + expect(screen.getByText(/Sarah/)).toBeInTheDocument(); + }); + + it('renders all three member permissions', () => { + render(HouseholdIdentityPanel, { + props: { householdName: 'Smith family', inviterName: 'Sarah' } + }); + expect(screen.getByText(/Wochenplan/i)).toBeInTheDocument(); + expect(screen.getByText(/Einkaufsliste/i)).toBeInTheDocument(); + }); + + it('renders app logo', () => { + render(HouseholdIdentityPanel, { + props: { householdName: 'Smith family', inviterName: 'Sarah' } + }); + expect(screen.getByText('🥗')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/routes/(public)/join/[token]/JoinForm.svelte b/frontend/src/routes/(public)/join/[token]/JoinForm.svelte new file mode 100644 index 0000000..059602c --- /dev/null +++ b/frontend/src/routes/(public)/join/[token]/JoinForm.svelte @@ -0,0 +1,113 @@ + + +
+ + {#if form?.errors?.form} +

+ {form.errors.form} +

+ {/if} + + +
+ + + {#if form?.errors?.name} +

+ {form.errors.name} +

+ {/if} +
+ + +
+ + + {#if form?.errors?.email} +

+ {form.errors.email} +

+ {/if} +
+ + +
+ +
+ + +
+ {#if form?.errors?.password} +

+ {form.errors.password} +

+ {/if} +
+ + + +
diff --git a/frontend/src/routes/(public)/join/[token]/JoinForm.test.ts b/frontend/src/routes/(public)/join/[token]/JoinForm.test.ts new file mode 100644 index 0000000..7fbae6c --- /dev/null +++ b/frontend/src/routes/(public)/join/[token]/JoinForm.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { userEvent } from '@testing-library/user-event'; +import JoinForm from './JoinForm.svelte'; + +vi.mock('$app/forms', () => ({ + enhance: () => ({ destroy: () => {} }) +})); + +describe('JoinForm', () => { + it('renders name, email and password fields', () => { + render(JoinForm); + expect(screen.getByLabelText('Name')).toBeInTheDocument(); + expect(screen.getByLabelText('E-Mail')).toBeInTheDocument(); + expect(screen.getByLabelText('Passwort')).toBeInTheDocument(); + }); + + it('renders "Haushalt beitreten" submit button', () => { + render(JoinForm); + expect(screen.getByRole('button', { name: /Haushalt beitreten/i })).toBeInTheDocument(); + }); + + it('password field is initially of type password', () => { + render(JoinForm); + expect(screen.getByLabelText('Passwort')).toHaveAttribute('type', 'password'); + }); + + it('password toggle switches type to text', async () => { + const user = userEvent.setup(); + render(JoinForm); + + const toggle = screen.getByRole('button', { name: /passwort anzeigen/i }); + await user.click(toggle); + expect(screen.getByLabelText('Passwort')).toHaveAttribute('type', 'text'); + }); + + it('shows form-level error from form prop', () => { + render(JoinForm, { + props: { + form: { + errors: { form: 'Einladung ungültig oder abgelaufen.' } + } + } + }); + expect(screen.getByText('Einladung ungültig oder abgelaufen.')).toBeInTheDocument(); + }); + + it('shows email-taken error with login link', () => { + render(JoinForm, { + props: { + form: { + errors: { + email: 'Diese E-Mail-Adresse ist bereits registriert. Anmelden →' + } + } + } + }); + expect(screen.getByText(/bereits registriert/)).toBeInTheDocument(); + }); + + it('pre-fills name and email from form prop', () => { + render(JoinForm, { + props: { + form: { + errors: {}, + name: 'Tom', + email: 'tom@example.com' + } + } + }); + expect(screen.getByLabelText('Name')).toHaveValue('Tom'); + expect(screen.getByLabelText('E-Mail')).toHaveValue('tom@example.com'); + }); +}); diff --git a/frontend/src/routes/(public)/join/[token]/page.server.test.ts b/frontend/src/routes/(public)/join/[token]/page.server.test.ts new file mode 100644 index 0000000..db0ff2f --- /dev/null +++ b/frontend/src/routes/(public)/join/[token]/page.server.test.ts @@ -0,0 +1,200 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('$env/dynamic/private', () => ({ + env: { BACKEND_URL: 'http://localhost:8080' } +})); + +const mockGet = vi.fn(); +const mockPost = vi.fn(); +vi.mock('$lib/server/api', () => ({ + apiClient: () => ({ GET: mockGet, POST: mockPost }) +})); + +describe('join page load function', () => { + let load: any; + + beforeEach(async () => { + mockGet.mockReset(); + vi.resetModules(); + const mod = await import('./+page.server'); + load = mod.load; + }); + + function createLoadEvent(token: string) { + return { + params: { token }, + fetch: vi.fn() + } as any; + } + + it('returns householdName and inviterName for valid token', async () => { + mockGet.mockResolvedValue({ + data: { data: { householdName: 'Smith family', inviterName: 'Sarah' } }, + error: undefined + }); + + const result = await load(createLoadEvent('ABC12XYZ')); + + expect(result.invalid).toBeFalsy(); + expect(result.householdName).toBe('Smith family'); + expect(result.inviterName).toBe('Sarah'); + }); + + it('returns invalid:true on 404 (expired/used/unknown token)', async () => { + mockGet.mockResolvedValue({ + data: undefined, + error: { status: 404 } + }); + + const result = await load(createLoadEvent('BADTOKEN')); + + expect(result.invalid).toBe(true); + }); +}); + +describe('join page form action', () => { + let actions: any; + + beforeEach(async () => { + mockPost.mockReset(); + vi.resetModules(); + const mod = await import('./+page.server'); + actions = mod.actions; + }); + + function createRequest(token: string, formData: Record) { + const fd = new FormData(); + for (const [key, value] of Object.entries(formData)) { + fd.append(key, value); + } + return { + params: { token }, + request: { formData: () => Promise.resolve(fd) }, + fetch: vi.fn(), + cookies: { get: vi.fn(), set: vi.fn() } + } as any; + } + + it('calls POST /v1/invites/{token}/accept with form data', async () => { + mockPost.mockResolvedValue({ + data: { data: { householdName: 'Smith family', role: 'member' } }, + error: undefined, + response: { headers: { get: vi.fn().mockReturnValue(null) } } + }); + + try { + await actions.default(createRequest('ABC12XYZ', { + name: 'Tom', + email: 'tom@example.com', + password: 'secret123' + })); + } catch { + // redirect throws + } + + expect(mockPost).toHaveBeenCalledWith('/v1/invites/{code}/accept', { + params: { path: { code: 'ABC12XYZ' } }, + body: { name: 'Tom', email: 'tom@example.com', password: 'secret123' } + }); + }); + + it('sets JSESSIONID cookie and redirects to / on success', async () => { + mockPost.mockResolvedValue({ + data: { data: { householdName: 'Smith family', role: 'member' } }, + error: undefined, + response: { + headers: { get: vi.fn().mockReturnValue('JSESSIONID=abc123; Path=/; HttpOnly') } + } + }); + + const event = createRequest('ABC12XYZ', { + name: 'Tom', + email: 'tom@example.com', + password: 'secret123' + }); + + try { + await actions.default(event); + expect.unreachable(); + } catch (e: any) { + expect(e.status).toBe(303); + expect(e.location).toBe('/'); + } + + expect(event.cookies.set).toHaveBeenCalledWith( + 'JSESSIONID', + 'abc123', + expect.objectContaining({ path: '/', secure: true }) + ); + }); + + it('returns 409 fail with email-taken message on conflict', async () => { + mockPost.mockResolvedValue({ + data: undefined, + error: { status: 409 }, + response: { headers: { get: vi.fn().mockReturnValue(null) } } + }); + + const result = await actions.default(createRequest('ABC12XYZ', { + name: 'Tom', + email: 'tom@example.com', + password: 'secret123' + })); + + expect(result.status).toBe(409); + expect(result.data.errors.email).toContain('registriert'); + }); + + it('returns 400 fail on invalid token (404 from backend)', async () => { + mockPost.mockResolvedValue({ + data: undefined, + error: { status: 404 }, + response: { headers: { get: vi.fn().mockReturnValue(null) } } + }); + + const result = await actions.default(createRequest('BADTOKEN', { + name: 'Tom', + email: 'tom@example.com', + password: 'secret123' + })); + + expect(result.status).toBe(400); + expect(result.data.errors.form).toBeTruthy(); + }); + + it('rejects empty name with validation error', async () => { + const result = await actions.default(createRequest('ABC12XYZ', { + name: '', + email: 'tom@example.com', + password: 'secret123' + })); + + expect(result.status).toBe(400); + expect(result.data.errors.name).toBeTruthy(); + expect(mockPost).not.toHaveBeenCalled(); + }); + + it('rejects invalid email with validation error', async () => { + const result = await actions.default(createRequest('ABC12XYZ', { + name: 'Tom', + email: 'notanemail', + password: 'secret123' + })); + + expect(result.status).toBe(400); + expect(result.data.errors.email).toBeTruthy(); + expect(mockPost).not.toHaveBeenCalled(); + }); + + it('rejects short password with validation error', async () => { + const result = await actions.default(createRequest('ABC12XYZ', { + name: 'Tom', + email: 'tom@example.com', + password: 'short' + })); + + expect(result.status).toBe(400); + expect(result.data.errors.password).toBeTruthy(); + expect(mockPost).not.toHaveBeenCalled(); + }); +});