feat(frontend): add forgot-password and reset-password pages
Some checks failed
CI / Unit & Component Tests (push) Successful in 2m7s
CI / Backend Unit Tests (push) Successful in 2m3s
CI / E2E Tests (push) Failing after 14m54s
CI / Unit & Component Tests (pull_request) Successful in 2m4s
CI / E2E Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
Some checks failed
CI / Unit & Component Tests (push) Successful in 2m7s
CI / Backend Unit Tests (push) Successful in 2m3s
CI / E2E Tests (push) Failing after 14m54s
CI / Unit & Component Tests (pull_request) Successful in 2m4s
CI / E2E Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
- /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 <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
113
frontend/e2e/password-reset.spec.ts
Normal file
113
frontend/e2e/password-reset.spec.ts
Normal file
@@ -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<string> {
|
||||
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' });
|
||||
});
|
||||
});
|
||||
@@ -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?"
|
||||
}
|
||||
|
||||
@@ -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?"
|
||||
}
|
||||
|
||||
@@ -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?"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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<string, never>;
|
||||
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<string, never>;
|
||||
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?: {
|
||||
|
||||
20
frontend/src/routes/forgot-password/+page.server.ts
Normal file
20
frontend/src/routes/forgot-password/+page.server.ts
Normal file
@@ -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;
|
||||
84
frontend/src/routes/forgot-password/+page.svelte
Normal file
84
frontend/src/routes/forgot-password/+page.svelte
Normal file
@@ -0,0 +1,84 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
let { form }: { form?: { error?: string; success?: boolean } } = $props();
|
||||
</script>
|
||||
|
||||
<div class="relative flex min-h-screen flex-col bg-white">
|
||||
<!-- Accent strip -->
|
||||
<div class="h-1 bg-brand-purple"></div>
|
||||
|
||||
<div class="flex flex-1 items-center justify-center px-4">
|
||||
<div class="w-full max-w-sm">
|
||||
<!-- Logo -->
|
||||
<div class="mb-10 text-center">
|
||||
<a href="/" class="inline-flex items-center" aria-label="Familienarchiv">
|
||||
<span class="font-sans text-2xl font-bold tracking-widest text-brand-navy uppercase"
|
||||
>Familienarchiv</span
|
||||
>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Card -->
|
||||
<div class="rounded-sm border border-brand-sand bg-white p-8 shadow-sm">
|
||||
<h1 class="mb-6 font-sans text-sm font-bold tracking-widest text-brand-navy uppercase">
|
||||
{m.forgot_password_heading()}
|
||||
</h1>
|
||||
|
||||
{#if form?.success}
|
||||
<div class="mb-5 rounded-sm border border-green-200 bg-green-50 px-4 py-3">
|
||||
<p class="font-sans text-xs text-green-700">{m.forgot_password_success()}</p>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="/login"
|
||||
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
|
||||
>{m.forgot_password_back_to_login()}</a
|
||||
>
|
||||
{:else}
|
||||
<form method="POST" class="space-y-5">
|
||||
<div>
|
||||
<label
|
||||
for="email"
|
||||
class="mb-1.5 block font-sans text-xs font-bold tracking-widest text-gray-500 uppercase"
|
||||
>{m.forgot_password_email_label()}</label
|
||||
>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
id="email"
|
||||
required
|
||||
autocomplete="email"
|
||||
class="block w-full border border-gray-300 px-3 py-2.5 font-serif text-sm text-brand-navy placeholder-gray-400 focus:border-brand-navy focus:ring-1 focus:ring-brand-navy focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="text-center font-sans text-xs font-medium text-red-600">{form.error}</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="mt-2 w-full bg-brand-navy py-2.5 font-sans text-xs font-bold tracking-widest text-white uppercase transition-colors hover:bg-brand-navy/90"
|
||||
>
|
||||
{m.forgot_password_submit()}
|
||||
</button>
|
||||
|
||||
<div class="mt-4 text-center">
|
||||
<a
|
||||
href="/login"
|
||||
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
|
||||
>{m.forgot_password_back_to_login()}</a
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="py-4 text-center">
|
||||
<p class="font-sans text-xs tracking-widest text-gray-300 uppercase">Familienarchiv</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -89,6 +89,14 @@ const activeLocale = $derived(getLocale().toUpperCase());
|
||||
>
|
||||
{m.login_btn_submit()}
|
||||
</button>
|
||||
|
||||
<div class="mt-4 text-center">
|
||||
<a
|
||||
href="/forgot-password"
|
||||
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
|
||||
>{m.login_forgot_password()}</a
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
34
frontend/src/routes/reset-password/+page.server.ts
Normal file
34
frontend/src/routes/reset-password/+page.server.ts
Normal file
@@ -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;
|
||||
113
frontend/src/routes/reset-password/+page.svelte
Normal file
113
frontend/src/routes/reset-password/+page.svelte
Normal file
@@ -0,0 +1,113 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { getErrorMessage } from '$lib/errors';
|
||||
|
||||
let {
|
||||
data,
|
||||
form
|
||||
}: {
|
||||
data: { token: string | null };
|
||||
form?: { error?: string; success?: boolean };
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="relative flex min-h-screen flex-col bg-white">
|
||||
<!-- Accent strip -->
|
||||
<div class="h-1 bg-brand-purple"></div>
|
||||
|
||||
<div class="flex flex-1 items-center justify-center px-4">
|
||||
<div class="w-full max-w-sm">
|
||||
<!-- Logo -->
|
||||
<div class="mb-10 text-center">
|
||||
<a href="/" class="inline-flex items-center" aria-label="Familienarchiv">
|
||||
<span class="font-sans text-2xl font-bold tracking-widest text-brand-navy uppercase"
|
||||
>Familienarchiv</span
|
||||
>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Card -->
|
||||
<div class="rounded-sm border border-brand-sand bg-white p-8 shadow-sm">
|
||||
<h1 class="mb-6 font-sans text-sm font-bold tracking-widest text-brand-navy uppercase">
|
||||
{m.reset_password_heading()}
|
||||
</h1>
|
||||
|
||||
{#if form?.success}
|
||||
<div class="mb-5 rounded-sm border border-green-200 bg-green-50 px-4 py-3">
|
||||
<p class="font-sans text-xs text-green-700">{m.reset_password_success()}</p>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="/login"
|
||||
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
|
||||
>{m.forgot_password_back_to_login()}</a
|
||||
>
|
||||
{:else}
|
||||
<form method="POST" class="space-y-5">
|
||||
<input type="hidden" name="token" value={data.token ?? ''} />
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="newPassword"
|
||||
class="mb-1.5 block font-sans text-xs font-bold tracking-widest text-gray-500 uppercase"
|
||||
>{m.reset_password_label()}</label
|
||||
>
|
||||
<input
|
||||
type="password"
|
||||
name="newPassword"
|
||||
id="newPassword"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
class="block w-full border border-gray-300 px-3 py-2.5 font-serif text-sm text-brand-navy placeholder-gray-400 focus:border-brand-navy focus:ring-1 focus:ring-brand-navy focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="confirmPassword"
|
||||
class="mb-1.5 block font-sans text-xs font-bold tracking-widest text-gray-500 uppercase"
|
||||
>{m.reset_password_confirm_label()}</label
|
||||
>
|
||||
<input
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
id="confirmPassword"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
class="block w-full border border-gray-300 px-3 py-2.5 font-serif text-sm text-brand-navy placeholder-gray-400 focus:border-brand-navy focus:ring-1 focus:ring-brand-navy focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="text-center font-sans text-xs font-medium text-red-600">
|
||||
{form.error === 'MISMATCH'
|
||||
? m.reset_password_mismatch()
|
||||
: getErrorMessage(form.error)}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="mt-2 w-full bg-brand-navy py-2.5 font-sans text-xs font-bold tracking-widest text-white uppercase transition-colors hover:bg-brand-navy/90"
|
||||
>
|
||||
{m.reset_password_submit()}
|
||||
</button>
|
||||
|
||||
<div class="mt-4 text-center">
|
||||
<a
|
||||
href="/login"
|
||||
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
|
||||
>{m.forgot_password_back_to_login()}</a
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="py-4 text-center">
|
||||
<p class="font-sans text-xs tracking-widest text-gray-300 uppercase">Familienarchiv</p>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user