From c27c97ff7ddecc00bbcc23d884a2df1b7903709d Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 2 Apr 2026 16:18:49 +0200 Subject: [PATCH] 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 @@ + + +
+

+ Willkommen zurück +

+ +

+ Melde dich an, um fortzufahren. +

+ + +
+ + + {#if errors.email} +

+ {errors.email} +

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

+ {errors.password} +

+ {/if} +
+ + {#if formError} +

+ {formError} +

+ {/if} + + + + + +

+ Noch kein Konto? Registrieren +

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