From 908221f04d80ecde50044d2fb76ade9d1b3e1d3c Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 22 Mar 2026 23:57:01 +0100 Subject: [PATCH] feat(frontend): add forgot-password and reset-password pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /forgot-password: email form → sends POST /api/auth/forgot-password → success banner - /reset-password: password form reads token from URL → sends POST /api/auth/reset-password - Login page: add "Passwort vergessen?" link - hooks.server.ts: add /forgot-password and /reset-password to PUBLIC_PATHS; skip auth injection for public auth API endpoints - errors.ts: add INVALID_RESET_TOKEN error code - i18n: add all new message keys in de/en/es - playwright.config.ts: use E2E_BASE_URL for webServer check URL (allows reusing docker dev server at port 5173 locally) - ci.yml: pass E2E_BACKEND_URL=http://localhost:8080 to E2E test step - e2e/password-reset.spec.ts: 5 tests (4 pass locally, full flow requires e2e profile in CI) - Regenerated OpenAPI types including new /api/auth/* endpoints Co-Authored-By: Claude Sonnet 4.6 --- .gitea/workflows/ci.yml | 1 + docker-compose.yml | 6 + frontend/e2e/password-reset.spec.ts | 113 ++++++++ frontend/messages/de.json | 15 +- frontend/messages/en.json | 15 +- frontend/messages/es.json | 15 +- frontend/playwright.config.ts | 5 +- frontend/src/hooks.server.ts | 8 +- frontend/src/lib/errors.ts | 3 + frontend/src/lib/generated/api.ts | 242 +++++++++++++----- .../routes/forgot-password/+page.server.ts | 20 ++ .../src/routes/forgot-password/+page.svelte | 84 ++++++ frontend/src/routes/login/+page.svelte | 8 + .../src/routes/reset-password/+page.server.ts | 34 +++ .../src/routes/reset-password/+page.svelte | 113 ++++++++ 15 files changed, 618 insertions(+), 64 deletions(-) create mode 100644 frontend/e2e/password-reset.spec.ts create mode 100644 frontend/src/routes/forgot-password/+page.server.ts create mode 100644 frontend/src/routes/forgot-password/+page.svelte create mode 100644 frontend/src/routes/reset-password/+page.server.ts create mode 100644 frontend/src/routes/reset-password/+page.svelte diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index c4380efc..d92bfd5d 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -190,6 +190,7 @@ jobs: E2E_BASE_URL: http://localhost:3000 E2E_USERNAME: admin E2E_PASSWORD: admin123 + E2E_BACKEND_URL: http://localhost:8080 - name: Upload E2E results if: always() diff --git a/docker-compose.yml b/docker-compose.yml index f9008b4c..bf9b8b14 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -83,6 +83,12 @@ services: S3_SECRET_KEY: ${MINIO_ROOT_PASSWORD} S3_BUCKET_NAME: ${MINIO_DEFAULT_BUCKETS} S3_REGION: us-east-1 + APP_BASE_URL: ${APP_BASE_URL:-http://localhost:3000} + MAIL_HOST: ${MAIL_HOST:-} + MAIL_PORT: ${MAIL_PORT:-587} + MAIL_USERNAME: ${MAIL_USERNAME:-} + MAIL_PASSWORD: ${MAIL_PASSWORD:-} + APP_MAIL_FROM: ${APP_MAIL_FROM:-noreply@familienarchiv.local} ports: - "${PORT_BACKEND}:8080" networks: diff --git a/frontend/e2e/password-reset.spec.ts b/frontend/e2e/password-reset.spec.ts new file mode 100644 index 00000000..d2dd5366 --- /dev/null +++ b/frontend/e2e/password-reset.spec.ts @@ -0,0 +1,113 @@ +import { test, expect } from '@playwright/test'; + +/** + * Password-reset E2E tests. + * + * These tests run WITHOUT a stored session because they test unauthenticated flows. + * + * They rely on the "e2e" Spring profile being active in CI (see playwright.config.ts / + * docker-compose.e2e.yml). The profile exposes GET /api/auth/reset-token-for-test?email= + * so we can retrieve the generated token without a real mail server. + */ +test.use({ storageState: { cookies: [], origins: [] } }); + +// The backend is accessible directly for E2E helper calls (no SvelteKit proxy needed). +const BACKEND_URL = process.env.E2E_BACKEND_URL ?? 'http://localhost:8080'; + +async function getResetToken(email: string): Promise { + const res = await fetch( + `${BACKEND_URL}/api/auth/reset-token-for-test?email=${encodeURIComponent(email)}` + ); + if (!res.ok) throw new Error(`Could not retrieve reset token for ${email}: ${res.status}`); + return res.text(); +} + +test.describe('Password reset', () => { + test('forgot-password page is accessible without login', async ({ page }) => { + await page.goto('/forgot-password'); + await expect(page).toHaveURL('/forgot-password'); + await expect(page.getByRole('heading', { name: /Passwort vergessen/i })).toBeVisible(); + await page.screenshot({ path: 'test-results/e2e/password-reset-form.png' }); + }); + + test('forgot-password shows success banner for any email (prevents user enumeration)', async ({ + page + }) => { + await page.goto('/forgot-password'); + await page.getByLabel(/E-Mail/i).fill('nonexistent@example.com'); + await page.getByRole('button', { name: /Link anfordern/i }).click(); + // Always shows success — never reveals whether the email exists + await expect(page.locator('.bg-green-50')).toBeVisible(); + await page.screenshot({ path: 'test-results/e2e/password-reset-success-banner.png' }); + }); + + test('full password reset flow', async ({ page }) => { + const testEmail = process.env.E2E_EMAIL ?? 'admin@familyarchive.local'; + const originalPassword = process.env.E2E_PASSWORD ?? 'admin123'; + const newPassword = 'NewP@ssw0rd_E2E!'; + + // 1. Request reset + await page.goto('/forgot-password'); + await page.getByLabel(/E-Mail/i).fill(testEmail); + await page.getByRole('button', { name: /Link anfordern/i }).click(); + await expect(page.locator('.bg-green-50')).toBeVisible(); + + // 2. Fetch the token via the test helper endpoint + const token = await getResetToken(testEmail); + expect(token.length).toBeGreaterThan(0); + + // 3. Open the reset-password page with the token + await page.goto(`/reset-password?token=${token}`); + await expect(page.getByRole('heading', { name: /Neues Passwort/i })).toBeVisible(); + await page.getByLabel(/^Neues Passwort$/i).fill(newPassword); + await page.getByLabel(/Passwort bestätigen/i).fill(newPassword); + await page.getByRole('button', { name: /Passwort speichern/i }).click(); + + // 4. Success banner — then navigate to login + await expect(page.locator('.bg-green-50')).toBeVisible(); + await page.screenshot({ path: 'test-results/e2e/password-reset-changed.png' }); + await page.getByRole('link', { name: /Zurück zum Login/i }).click(); + + // 5. Log in with new password + await expect(page).toHaveURL(/\/login/); + await page.getByLabel('Benutzername').fill(process.env.E2E_USERNAME ?? 'admin'); + await page.getByLabel('Passwort').fill(newPassword); + await page.getByRole('button', { name: 'Anmelden' }).click(); + await expect(page).toHaveURL('/'); + + // 6. Restore original password via profile page + await page.goto('/profile'); + await page.locator('input[name="currentPassword"]').fill(newPassword); + await page.locator('input[name="newPassword"]').fill(originalPassword); + await page.locator('input[name="confirmPassword"]').fill(originalPassword); + // Profile page has two "Speichern" buttons — the password form is the last one + await page.locator('button[type="submit"]').last().click(); + // After changing password, auth_token is stale → redirect to login + await expect(page).toHaveURL(/\/login/); + + // 7. Log back in with original password to confirm restore worked + await page.getByLabel('Benutzername').fill(process.env.E2E_USERNAME ?? 'admin'); + await page.getByLabel('Passwort').fill(originalPassword); + await page.getByRole('button', { name: 'Anmelden' }).click(); + await expect(page).toHaveURL('/'); + await page.screenshot({ path: 'test-results/e2e/password-reset-restored.png' }); + }); + + test('reset-password page shows error for invalid token', async ({ page }) => { + await page.goto('/reset-password?token=invalidtoken000'); + await page.getByLabel(/^Neues Passwort$/i).fill('somepassword'); + await page.getByLabel(/Passwort bestätigen/i).fill('somepassword'); + await page.getByRole('button', { name: /Passwort speichern/i }).click(); + await expect(page.locator('.text-red-600')).toBeVisible(); + await page.screenshot({ path: 'test-results/e2e/password-reset-invalid-token.png' }); + }); + + test('reset-password page shows mismatch error when passwords differ', async ({ page }) => { + await page.goto('/reset-password?token=anytoken'); + await page.getByLabel(/^Neues Passwort$/i).fill('password1'); + await page.getByLabel(/Passwort bestätigen/i).fill('password2'); + await page.getByRole('button', { name: /Passwort speichern/i }).click(); + await expect(page.locator('.text-red-600')).toBeVisible(); + await page.screenshot({ path: 'test-results/e2e/password-reset-mismatch.png' }); + }); +}); diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 7267aa79..e614ac93 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -202,5 +202,18 @@ "profile_password_mismatch": "Die neuen Passwörter stimmen nicht überein.", "profile_saved": "Gespeichert.", "profile_password_changed": "Passwort erfolgreich geändert.", - "user_profile_heading": "Profil von" + "user_profile_heading": "Profil von", + "error_invalid_reset_token": "Der Link ist ungültig oder abgelaufen.", + "forgot_password_heading": "Passwort vergessen", + "forgot_password_email_label": "E-Mail-Adresse", + "forgot_password_submit": "Link anfordern", + "forgot_password_success": "Falls ein Konto mit dieser E-Mail-Adresse existiert, erhalten Sie in Kürze eine E-Mail mit einem Link zum Zurücksetzen Ihres Passworts.", + "forgot_password_back_to_login": "Zurück zum Login", + "reset_password_heading": "Neues Passwort festlegen", + "reset_password_label": "Neues Passwort", + "reset_password_confirm_label": "Passwort bestätigen", + "reset_password_submit": "Passwort speichern", + "reset_password_mismatch": "Die Passwörter stimmen nicht überein.", + "reset_password_success": "Ihr Passwort wurde erfolgreich geändert. Sie können sich jetzt anmelden.", + "login_forgot_password": "Passwort vergessen?" } diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 93a39f72..79d96300 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -202,5 +202,18 @@ "profile_password_mismatch": "The new passwords do not match.", "profile_saved": "Saved.", "profile_password_changed": "Password changed successfully.", - "user_profile_heading": "Profile of" + "user_profile_heading": "Profile of", + "error_invalid_reset_token": "The link is invalid or has expired.", + "forgot_password_heading": "Forgot password", + "forgot_password_email_label": "Email address", + "forgot_password_submit": "Request link", + "forgot_password_success": "If an account with this email address exists, you will shortly receive an email with a link to reset your password.", + "forgot_password_back_to_login": "Back to login", + "reset_password_heading": "Set new password", + "reset_password_label": "New password", + "reset_password_confirm_label": "Confirm password", + "reset_password_submit": "Save password", + "reset_password_mismatch": "The passwords do not match.", + "reset_password_success": "Your password has been changed successfully. You can now log in.", + "login_forgot_password": "Forgot password?" } diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 211e84fc..f12d6e40 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -202,5 +202,18 @@ "profile_password_mismatch": "Las nuevas contraseñas no coinciden.", "profile_saved": "Guardado.", "profile_password_changed": "Contraseña cambiada con éxito.", - "user_profile_heading": "Perfil de" + "user_profile_heading": "Perfil de", + "error_invalid_reset_token": "El enlace no es válido o ha expirado.", + "forgot_password_heading": "Contraseña olvidada", + "forgot_password_email_label": "Correo electrónico", + "forgot_password_submit": "Solicitar enlace", + "forgot_password_success": "Si existe una cuenta con esta dirección de correo electrónico, recibirá en breve un correo con un enlace para restablecer su contraseña.", + "forgot_password_back_to_login": "Volver al inicio de sesión", + "reset_password_heading": "Establecer nueva contraseña", + "reset_password_label": "Nueva contraseña", + "reset_password_confirm_label": "Confirmar contraseña", + "reset_password_submit": "Guardar contraseña", + "reset_password_mismatch": "Las contraseñas no coinciden.", + "reset_password_success": "Su contraseña ha sido cambiada con éxito. Ahora puede iniciar sesión.", + "login_forgot_password": "¿Olvidó su contraseña?" } diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 20bd2fcb..87e0c57a 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -12,7 +12,10 @@ export default defineConfig({ // The backend + DB + MinIO must be started separately (see README or CI workflow). webServer: { command: 'npm run dev -- --port 3000', - url: 'http://localhost:3000', + // Use the E2E_BASE_URL so that a pre-running server (e.g. the docker dev server + // on port 5173 during local development) is detected and reused without starting + // a new one. In CI the default is localhost:3000 where a fresh server is started. + url: process.env.E2E_BASE_URL ?? 'http://localhost:3000', reuseExistingServer: true, timeout: 120_000 }, diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index 37d4823e..8fa79aa5 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -5,7 +5,7 @@ import { env } from 'process'; import { cookieName, cookieMaxAge } from '$lib/paraglide/runtime'; import { detectLocale } from '$lib/server/locale'; -const PUBLIC_PATHS = ['/login', '/logout']; +const PUBLIC_PATHS = ['/login', '/logout', '/forgot-password', '/reset-password']; const handleLocaleDetection: Handle = ({ event, resolve }) => { if (!event.cookies.get(cookieName)) { @@ -71,6 +71,12 @@ export const handleFetch: HandleFetch = async ({ event, request, fetch }) => { return fetch(request); } + // Password reset endpoints are public — no auth header needed. + const PUBLIC_API_PATHS = ['/api/auth/forgot-password', '/api/auth/reset-password']; + if (PUBLIC_API_PATHS.some((p) => request.url.includes(p))) { + return fetch(request); + } + const token = event.cookies.get('auth_token'); if (!token) { diff --git a/frontend/src/lib/errors.ts b/frontend/src/lib/errors.ts index beb18d90..227d20a1 100644 --- a/frontend/src/lib/errors.ts +++ b/frontend/src/lib/errors.ts @@ -13,6 +13,7 @@ export type ErrorCode = | 'EMAIL_ALREADY_IN_USE' | 'WRONG_CURRENT_PASSWORD' | 'IMPORT_ALREADY_RUNNING' + | 'INVALID_RESET_TOKEN' | 'UNAUTHORIZED' | 'FORBIDDEN' | 'VALIDATION_ERROR' @@ -58,6 +59,8 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string { return m.error_wrong_current_password(); case 'IMPORT_ALREADY_RUNNING': return m.error_import_already_running(); + case 'INVALID_RESET_TOKEN': + return m.error_invalid_reset_token(); case 'UNAUTHORIZED': return m.error_unauthorized(); case 'FORBIDDEN': diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index 9d693743..46539816 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -4,6 +4,22 @@ */ export interface paths { + "/api/users/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getUser"]; + put: operations["adminUpdateUser"]; + post?: never; + delete: operations["deleteUser"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/users/me": { parameters: { query?: never; @@ -164,6 +180,38 @@ export interface paths { patch?: never; trace?: never; }; + "/api/auth/reset-password": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["resetPassword"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/auth/forgot-password": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["forgotPassword"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/admin/trigger-import": { parameters: { query?: never; @@ -196,22 +244,6 @@ export interface paths { patch: operations["updateGroup"]; trace?: never; }; - "/api/users/{id}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: operations["getUser"]; - put?: never; - post?: never; - delete: operations["deleteUser"]; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/api/tags": { parameters: { query?: never; @@ -344,13 +376,15 @@ export interface paths { export type webhooks = Record; export interface components { schemas: { - UpdateProfileDTO: { + AdminUpdateUserRequest: { firstName?: string; lastName?: string; /** Format: date */ birthDate?: string; email?: string; contact?: string; + newPassword?: string; + groupIds?: string[]; }; AppUser: { /** Format: uuid */ @@ -374,6 +408,14 @@ export interface components { name: string; permissions: string[]; }; + UpdateProfileDTO: { + firstName?: string; + lastName?: string; + /** Format: date */ + birthDate?: string; + email?: string; + contact?: string; + }; Tag: { /** Format: uuid */ id: string; @@ -444,6 +486,11 @@ export interface components { email?: string; initialPassword?: string; groupIds?: string[]; + firstName?: string; + lastName?: string; + /** Format: date */ + birthDate?: string; + contact?: string; }; ChangePasswordDTO: { currentPassword?: string; @@ -453,6 +500,13 @@ export interface components { name?: string; permissions?: string[]; }; + ResetPasswordRequest: { + token?: string; + newPassword?: string; + }; + ForgotPasswordRequest: { + email?: string; + }; ImportStatus: { /** @enum {string} */ state?: "IDLE" | "RUNNING" | "DONE" | "FAILED"; @@ -471,6 +525,74 @@ export interface components { } export type $defs = Record; export interface operations { + getUser: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AppUser"]; + }; + }; + }; + }; + adminUpdateUser: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AdminUpdateUserRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["AppUser"]; + }; + }; + }; + }; + deleteUser: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; getCurrentUser: { parameters: { query?: never; @@ -867,6 +989,50 @@ export interface operations { }; }; }; + resetPassword: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ResetPasswordRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + forgotPassword: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ForgotPasswordRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; triggerMassImport: { parameters: { query?: never; @@ -933,48 +1099,6 @@ export interface operations { }; }; }; - getUser: { - parameters: { - query?: never; - header?: never; - path: { - id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "*/*": components["schemas"]["AppUser"]; - }; - }; - }; - }; - deleteUser: { - parameters: { - query?: never; - header?: never; - path: { - id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; searchTags: { parameters: { query?: { diff --git a/frontend/src/routes/forgot-password/+page.server.ts b/frontend/src/routes/forgot-password/+page.server.ts new file mode 100644 index 00000000..19914658 --- /dev/null +++ b/frontend/src/routes/forgot-password/+page.server.ts @@ -0,0 +1,20 @@ +import { fail } from '@sveltejs/kit'; +import type { Actions } from './$types'; +import { createApiClient } from '$lib/api.server'; + +export const actions = { + default: async ({ request, fetch }) => { + const formData = await request.formData(); + const email = formData.get('email') as string; + + if (!email) { + return fail(400, { error: 'Email is required' }); + } + + const api = createApiClient(fetch); + await api.POST('/api/auth/forgot-password', { body: { email } }); + + // Always return success — never disclose whether the email exists + return { success: true }; + } +} satisfies Actions; diff --git a/frontend/src/routes/forgot-password/+page.svelte b/frontend/src/routes/forgot-password/+page.svelte new file mode 100644 index 00000000..2f9e3258 --- /dev/null +++ b/frontend/src/routes/forgot-password/+page.svelte @@ -0,0 +1,84 @@ + + +
+ +
+ +
+
+ + + + +
+

+ {m.forgot_password_heading()} +

+ + {#if form?.success} +
+

{m.forgot_password_success()}

+
+ + {m.forgot_password_back_to_login()} + {:else} +
+
+ + +
+ + {#if form?.error} +
{form.error}
+ {/if} + + + + +
+ {/if} +
+
+
+ + +
+

Familienarchiv

+
+
diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte index 53cff095..bb6e6f92 100644 --- a/frontend/src/routes/login/+page.svelte +++ b/frontend/src/routes/login/+page.svelte @@ -89,6 +89,14 @@ const activeLocale = $derived(getLocale().toUpperCase()); > {m.login_btn_submit()} + + diff --git a/frontend/src/routes/reset-password/+page.server.ts b/frontend/src/routes/reset-password/+page.server.ts new file mode 100644 index 00000000..200067e6 --- /dev/null +++ b/frontend/src/routes/reset-password/+page.server.ts @@ -0,0 +1,34 @@ +import { fail } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; +import { createApiClient } from '$lib/api.server'; +import { parseBackendError } from '$lib/errors'; + +export const load: PageServerLoad = async ({ url }) => { + const token = url.searchParams.get('token'); + return { token }; +}; + +export const actions = { + default: async ({ request, fetch }) => { + const formData = await request.formData(); + const token = formData.get('token') as string; + const newPassword = formData.get('newPassword') as string; + const confirmPassword = formData.get('confirmPassword') as string; + + if (newPassword !== confirmPassword) { + return fail(400, { error: 'MISMATCH' }); + } + + const api = createApiClient(fetch); + const result = await api.POST('/api/auth/reset-password', { + body: { token, newPassword } + }); + + if (!result.response.ok) { + const backendError = await parseBackendError(result.response); + return fail(400, { error: backendError?.code ?? 'INTERNAL_ERROR' }); + } + + return { success: true }; + } +} satisfies Actions; diff --git a/frontend/src/routes/reset-password/+page.svelte b/frontend/src/routes/reset-password/+page.svelte new file mode 100644 index 00000000..9e209c9d --- /dev/null +++ b/frontend/src/routes/reset-password/+page.svelte @@ -0,0 +1,113 @@ + + +
+ +
+ +
+
+ + + + +
+

+ {m.reset_password_heading()} +

+ + {#if form?.success} +
+

{m.reset_password_success()}

+
+ + {m.forgot_password_back_to_login()} + {:else} +
+ + +
+ + +
+ +
+ + +
+ + {#if form?.error} +
+ {form.error === 'MISMATCH' + ? m.reset_password_mismatch() + : getErrorMessage(form.error)} +
+ {/if} + + + + +
+ {/if} +
+
+
+ + +
+

Familienarchiv

+
+