feat: password reset via email (#36) #49

Merged
marcel merged 7 commits from feat/36-password-reset into main 2026-03-23 10:13:56 +01:00
33 changed files with 1195 additions and 67 deletions

View File

@@ -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()

View File

@@ -119,6 +119,10 @@
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>

View File

@@ -6,10 +6,12 @@ import java.util.concurrent.ThreadPoolExecutor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
@Configuration
@EnableAsync
@EnableScheduling
public class AsyncConfig {
@Bean
public Executor taskExecutor() {

View File

@@ -43,8 +43,8 @@ public class DataInitializer {
@Bean
public CommandLineRunner initAdminUser(PasswordEncoder passwordEncoder) {
return args -> {
if (userRepository.count() == 0) {
log.info("Keine User gefunden. Erstelle Default-Admin...");
if (userRepository.findByUsername(adminUsername).isEmpty()) {
log.info("Kein Admin-User '{}' gefunden. Erstelle Default-Admin...", adminUsername);
// 1. Admin Gruppe erstellen
UserGroup adminGroup = UserGroup.builder()

View File

@@ -48,6 +48,10 @@ public class SecurityConfig {
.authorizeHttpRequests(auth -> {
// Health endpoint must be open so CI/Docker health checks work without credentials
auth.requestMatchers("/actuator/health").permitAll();
// Password reset endpoints are unauthenticated by nature
auth.requestMatchers("/api/auth/forgot-password", "/api/auth/reset-password").permitAll();
// E2E test helper (only active under "e2e" profile)
auth.requestMatchers("/api/auth/reset-token-for-test").permitAll();
// In dev, allow unauthenticated access to the OpenAPI spec and Swagger UI
if (environment.matchesProfiles("dev")) {
auth.requestMatchers(

View File

@@ -0,0 +1,37 @@
package org.raddatz.familienarchiv.controller;
import org.raddatz.familienarchiv.dto.ForgotPasswordRequest;
import org.raddatz.familienarchiv.dto.ResetPasswordRequest;
import org.raddatz.familienarchiv.service.PasswordResetService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import lombok.RequiredArgsConstructor;
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final PasswordResetService passwordResetService;
@Value("${app.base-url:http://localhost:3000}")
private String appBaseUrl;
@PostMapping("/forgot-password")
public ResponseEntity<Void> forgotPassword(@RequestBody ForgotPasswordRequest request) {
passwordResetService.requestReset(request.getEmail(), appBaseUrl);
// Always return 204 — never disclose whether the email exists
return ResponseEntity.noContent().build();
}
@PostMapping("/reset-password")
public ResponseEntity<Void> resetPassword(@RequestBody ResetPasswordRequest request) {
passwordResetService.resetPassword(request);
return ResponseEntity.noContent().build();
}
}

View File

@@ -0,0 +1,33 @@
package org.raddatz.familienarchiv.controller;
import java.time.LocalDateTime;
import org.raddatz.familienarchiv.repository.PasswordResetTokenRepository;
import org.springframework.context.annotation.Profile;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import lombok.RequiredArgsConstructor;
/**
* Test-only endpoint to retrieve a password reset token by email.
* Only active under the "e2e" Spring profile.
*/
@RestController
@RequestMapping("/api/auth")
@Profile("e2e")
@RequiredArgsConstructor
public class AuthE2EController {
private final PasswordResetTokenRepository tokenRepository;
@GetMapping("/reset-token-for-test")
public ResponseEntity<String> getResetTokenForTest(@RequestParam String email) {
return tokenRepository.findLatestActiveTokenByEmail(email, LocalDateTime.now())
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
}

View File

@@ -0,0 +1,8 @@
package org.raddatz.familienarchiv.dto;
import lombok.Data;
@Data
public class ForgotPasswordRequest {
private String email;
}

View File

@@ -0,0 +1,9 @@
package org.raddatz.familienarchiv.dto;
import lombok.Data;
@Data
public class ResetPasswordRequest {
private String token;
private String newPassword;
}

View File

@@ -35,6 +35,8 @@ public enum ErrorCode {
UNAUTHORIZED,
/** The authenticated user lacks the required permission. 403 */
FORBIDDEN,
/** The password-reset token is missing, expired, or already used. 400 */
INVALID_RESET_TOKEN,
// --- Generic ---
/** Request validation failed (missing or malformed fields). 400 */

View File

@@ -0,0 +1,45 @@
package org.raddatz.familienarchiv.model;
import java.time.LocalDateTime;
import java.util.UUID;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Entity
@Table(name = "password_reset_tokens")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class PasswordResetToken {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private AppUser user;
@Column(nullable = false, unique = true, length = 64)
private String token;
@Column(name = "expires_at", nullable = false)
private LocalDateTime expiresAt;
@Column(nullable = false)
@Builder.Default
private boolean used = false;
}

View File

@@ -0,0 +1,22 @@
package org.raddatz.familienarchiv.repository;
import java.time.LocalDateTime;
import java.util.Optional;
import java.util.UUID;
import org.raddatz.familienarchiv.model.PasswordResetToken;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
public interface PasswordResetTokenRepository extends JpaRepository<PasswordResetToken, UUID> {
Optional<PasswordResetToken> findByToken(String token);
@Query("SELECT t.token FROM PasswordResetToken t WHERE t.user.email = :email AND t.used = false AND t.expiresAt > :now ORDER BY t.expiresAt DESC LIMIT 1")
Optional<String> findLatestActiveTokenByEmail(String email, LocalDateTime now);
@Modifying
@Query("DELETE FROM PasswordResetToken t WHERE t.expiresAt < :now OR t.used = true")
void deleteExpiredAndUsed(LocalDateTime now);
}

View File

@@ -0,0 +1,132 @@
package org.raddatz.familienarchiv.service;
import java.security.SecureRandom;
import java.time.LocalDateTime;
import java.util.HexFormat;
import java.util.Optional;
import org.raddatz.familienarchiv.dto.ResetPasswordRequest;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.PasswordResetToken;
import org.raddatz.familienarchiv.repository.AppUserRepository;
import org.raddatz.familienarchiv.repository.PasswordResetTokenRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.MailException;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Service
@RequiredArgsConstructor
@Slf4j
public class PasswordResetService {
private final AppUserRepository userRepository;
private final PasswordResetTokenRepository tokenRepository;
private final PasswordEncoder passwordEncoder;
@Autowired(required = false)
private JavaMailSender mailSender;
@Value("${app.mail.from:noreply@familienarchiv.local}")
private String mailFrom;
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
private static final int TOKEN_EXPIRY_HOURS = 1;
/**
* Creates a reset token for the given email address and sends it via email.
* If the email is not found, silently does nothing (prevents user enumeration).
* If no mail sender is configured, logs a warning.
*/
public void requestReset(String email, String appBaseUrl) {
Optional<AppUser> userOpt = userRepository.findByEmail(email);
if (userOpt.isEmpty()) {
log.debug("Password reset requested for unknown email: {}", email);
return;
}
AppUser user = userOpt.get();
String token = generateToken();
tokenRepository.save(PasswordResetToken.builder()
.user(user)
.token(token)
.expiresAt(LocalDateTime.now().plusHours(TOKEN_EXPIRY_HOURS))
.build());
sendResetEmail(user.getEmail(), token, appBaseUrl);
}
/**
* Validates the token and updates the user's password.
*/
@Transactional
public void resetPassword(ResetPasswordRequest request) {
PasswordResetToken resetToken = tokenRepository.findByToken(request.getToken())
.orElseThrow(() -> DomainException.badRequest(
ErrorCode.INVALID_RESET_TOKEN, "Invalid or unknown reset token"));
if (resetToken.isUsed() || resetToken.getExpiresAt().isBefore(LocalDateTime.now())) {
throw DomainException.badRequest(ErrorCode.INVALID_RESET_TOKEN, "Token expired or already used");
}
AppUser user = resetToken.getUser();
user.setPassword(passwordEncoder.encode(request.getNewPassword()));
userRepository.save(user);
resetToken.setUsed(true);
tokenRepository.save(resetToken);
}
/** Nightly cleanup of expired and used tokens. */
@Scheduled(cron = "0 0 3 * * *")
@Transactional
public void cleanupExpiredTokens() {
tokenRepository.deleteExpiredAndUsed(LocalDateTime.now());
log.info("Cleaned up expired password reset tokens");
}
private String generateToken() {
byte[] bytes = new byte[32];
SECURE_RANDOM.nextBytes(bytes);
return HexFormat.of().formatHex(bytes);
}
private void sendResetEmail(String to, String token, String appBaseUrl) {
if (mailSender == null) {
log.warn("Mail sender not configured — skipping password reset email to {}", to);
return;
}
String resetUrl = appBaseUrl + "/reset-password?token=" + token;
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(mailFrom);
message.setTo(to);
message.setSubject("Passwort zurücksetzen — Familienarchiv");
message.setText(
"Hallo,\n\n"
+ "Sie haben eine Passwort-Zurücksetzung beantragt.\n\n"
+ "Klicken Sie auf den folgenden Link, um Ihr Passwort zurückzusetzen:\n"
+ resetUrl + "\n\n"
+ "Der Link ist " + TOKEN_EXPIRY_HOURS + " Stunde(n) gültig.\n\n"
+ "Falls Sie diese Anfrage nicht gestellt haben, ignorieren Sie diese E-Mail.\n\n"
+ "Ihr Familienarchiv-Team");
try {
mailSender.send(message);
log.info("Password reset email sent to {}", to);
} catch (MailException e) {
log.error("Failed to send password reset email to {}: {}", to, e.getMessage());
}
}
}

View File

@@ -24,6 +24,23 @@ spring:
max-file-size: 50MB
max-request-size: 50MB
mail:
host: ${MAIL_HOST:}
port: ${MAIL_PORT:587}
username: ${MAIL_USERNAME:}
password: ${MAIL_PASSWORD:}
properties:
mail:
smtp:
auth: true
starttls:
enable: true
management:
health:
mail:
enabled: false
springdoc:
api-docs:
enabled: false
@@ -38,6 +55,11 @@ app:
bucket: ${S3_BUCKET_NAME}
region: ${S3_REGION}
base-url: ${APP_BASE_URL:http://localhost:3000}
mail:
from: ${APP_MAIL_FROM:noreply@familienarchiv.local}
admin:
username: ${APP_ADMIN_USERNAME:admin}
password: ${APP_ADMIN_PASSWORD:admin123}

View File

@@ -0,0 +1,10 @@
CREATE TABLE password_reset_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token VARCHAR(64) NOT NULL UNIQUE,
expires_at TIMESTAMP NOT NULL,
used BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT now()
);
CREATE INDEX idx_prt_token ON password_reset_tokens(token);

View File

@@ -0,0 +1,126 @@
package org.raddatz.familienarchiv.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.time.LocalDateTime;
import java.util.Optional;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.dto.ResetPasswordRequest;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.PasswordResetToken;
import org.raddatz.familienarchiv.repository.AppUserRepository;
import org.raddatz.familienarchiv.repository.PasswordResetTokenRepository;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.security.crypto.password.PasswordEncoder;
@ExtendWith(MockitoExtension.class)
class PasswordResetServiceTest {
@Mock AppUserRepository userRepository;
@Mock PasswordResetTokenRepository tokenRepository;
@Mock PasswordEncoder passwordEncoder;
@Mock JavaMailSender mailSender;
@InjectMocks PasswordResetService service;
private AppUser makeUser(String email) {
return AppUser.builder()
.id(UUID.randomUUID())
.username("testuser")
.email(email)
.password("hashed")
.build();
}
// ─── requestReset ─────────────────────────────────────────────────────────
@Test
void requestReset_savesTokenForKnownEmail() {
AppUser user = makeUser("user@example.com");
when(userRepository.findByEmail("user@example.com")).thenReturn(Optional.of(user));
service.requestReset("user@example.com", "http://localhost:3000");
verify(tokenRepository).save(argThat(t ->
t.getUser().equals(user)
&& t.getToken().length() == 64
&& !t.isUsed()));
}
@Test
void requestReset_doesNothingForUnknownEmail() {
when(userRepository.findByEmail("ghost@example.com")).thenReturn(Optional.empty());
service.requestReset("ghost@example.com", "http://localhost:3000");
verify(tokenRepository, never()).save(any());
}
// ─── resetPassword ────────────────────────────────────────────────────────
@Test
void resetPassword_updatesPasswordForValidToken() {
AppUser user = makeUser("user@example.com");
PasswordResetToken token = PasswordResetToken.builder()
.id(UUID.randomUUID())
.token("validtoken123")
.user(user)
.expiresAt(LocalDateTime.now().plusHours(1))
.used(false)
.build();
when(tokenRepository.findByToken("validtoken123")).thenReturn(Optional.of(token));
when(passwordEncoder.encode("newpass")).thenReturn("hashed-newpass");
ResetPasswordRequest req = new ResetPasswordRequest();
req.setToken("validtoken123");
req.setNewPassword("newpass");
service.resetPassword(req);
verify(passwordEncoder).encode("newpass");
verify(userRepository).save(argThat(u -> u.getPassword().equals("hashed-newpass")));
assertThat(token.isUsed()).isTrue();
}
@Test
void resetPassword_throwsForExpiredToken() {
AppUser user = makeUser("user@example.com");
PasswordResetToken token = PasswordResetToken.builder()
.token("expiredtoken")
.user(user)
.expiresAt(LocalDateTime.now().minusMinutes(1))
.used(false)
.build();
when(tokenRepository.findByToken("expiredtoken")).thenReturn(Optional.of(token));
ResetPasswordRequest req = new ResetPasswordRequest();
req.setToken("expiredtoken");
req.setNewPassword("newpass");
assertThatThrownBy(() -> service.resetPassword(req))
.isInstanceOf(DomainException.class);
}
@Test
void resetPassword_throwsForUnknownToken() {
when(tokenRepository.findByToken("nosuchtoken")).thenReturn(Optional.empty());
ResetPasswordRequest req = new ResetPasswordRequest();
req.setToken("nosuchtoken");
req.setNewPassword("newpass");
assertThatThrownBy(() -> service.resetPassword(req))
.isInstanceOf(DomainException.class);
}
}

View File

@@ -58,6 +58,19 @@ services:
networks:
- archive-net
# --- Mail catcher: Mailpit (dev only) ---
# Catches all outgoing emails and displays them in a web UI.
# Access the inbox at http://localhost:${PORT_MAILPIT_UI} after starting the stack.
mailpit:
image: axllent/mailpit:latest
container_name: archive-mailpit
restart: unless-stopped
ports:
- "${PORT_MAILPIT_UI:-8025}:8025" # Web UI
- "${PORT_MAILPIT_SMTP:-1025}:1025" # SMTP
networks:
- archive-net
# --- Backend: Spring Boot ---
backend:
build:
@@ -74,6 +87,8 @@ services:
condition: service_healthy
minio:
condition: service_healthy
mailpit:
condition: service_started
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/${POSTGRES_DB}
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER}
@@ -83,6 +98,16 @@ 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}
# Defaults to the local Mailpit catcher — override in .env for production SMTP
MAIL_HOST: ${MAIL_HOST:-mailpit}
MAIL_PORT: ${MAIL_PORT:-1025}
MAIL_USERNAME: ${MAIL_USERNAME:-}
MAIL_PASSWORD: ${MAIL_PASSWORD:-}
APP_MAIL_FROM: ${APP_MAIL_FROM:-noreply@familienarchiv.local}
# Mailpit needs no auth or STARTTLS; production SMTP overrides these via .env
SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH: ${MAIL_SMTP_AUTH:-false}
SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE: ${MAIL_STARTTLS_ENABLE:-false}
ports:
- "${PORT_BACKEND}:8080"
networks:

96
docs/mail.md Normal file
View File

@@ -0,0 +1,96 @@
# Mail configuration
Familienarchiv uses Spring Mail to send password reset emails. The mail sender is **optional** — if no SMTP host is configured, the feature degrades gracefully: a reset token is still created in the database, but no email is sent and a warning is logged.
## How it works in each environment
| Environment | Default behaviour |
|---|---|
| `docker-compose up` (dev) | Mailpit catches all emails — nothing leaves your machine |
| CI | No mail host set — emails are silently skipped, tokens tested via the `/api/auth/reset-token-for-test` endpoint |
| Production | Real SMTP server configured via environment variables |
---
## Development — Mailpit
[Mailpit](https://github.com/axllent/mailpit) is included in `docker-compose.yml` as a local mail catcher. It accepts SMTP connections from the backend and displays all caught emails in a web inbox. No credentials or external network access required.
**Start the stack as usual:**
```bash
docker-compose up -d
```
**Open the inbox:**
```
http://localhost:8025
```
All password reset emails appear here. Copy the reset link from the email body and open it in your browser to complete the flow end-to-end locally.
**Ports (configurable in `.env`):**
| Variable | Default | Purpose |
|---|---|---|
| `PORT_MAILPIT_UI` | `8025` | Mailpit web inbox |
| `PORT_MAILPIT_SMTP` | `1025` | SMTP port (used internally by the backend) |
---
## Production — real SMTP
To send real emails, set the following variables in your `.env` file (or as host environment variables). The `MAIL_HOST` variable is the switch — leaving it empty disables outgoing mail entirely.
```dotenv
# Required
APP_BASE_URL=https://your-domain.example.com # Base URL inserted into reset links
MAIL_HOST=smtp.example.com
MAIL_PORT=587
MAIL_USERNAME=your-smtp-user
MAIL_PASSWORD=your-smtp-password
# Optional — adjust if your provider uses different settings
MAIL_SMTP_AUTH=true # default: false (Mailpit needs false)
MAIL_STARTTLS_ENABLE=true # default: false (Mailpit needs false)
APP_MAIL_FROM=noreply@your-domain.example.com
```
**Common provider settings:**
| Provider | Host | Port | Auth | STARTTLS |
|---|---|---|---|---|
| Gmail (App Password) | `smtp.gmail.com` | `587` | `true` | `true` |
| Mailgun | `smtp.mailgun.org` | `587` | `true` | `true` |
| Hetzner | `mail.your-server.de` | `587` | `true` | `true` |
| Self-hosted Postfix | your server IP/hostname | `587` | `true` | `true` |
> **Gmail note:** You must use an [App Password](https://support.google.com/accounts/answer/185833), not your regular account password. 2-Step Verification must be enabled on the account.
---
## Environment variable reference
All variables have safe defaults so the app starts without any mail configuration.
| Variable | Default (docker-compose) | Description |
|---|---|---|
| `MAIL_HOST` | `mailpit` | SMTP hostname. Empty string disables mail entirely. |
| `MAIL_PORT` | `1025` | SMTP port. |
| `MAIL_USERNAME` | *(empty)* | SMTP username. Leave empty if your server needs no auth. |
| `MAIL_PASSWORD` | *(empty)* | SMTP password. |
| `MAIL_SMTP_AUTH` | `false` | Enable SMTP authentication (`true` for real servers). |
| `MAIL_STARTTLS_ENABLE` | `false` | Enable STARTTLS (`true` for real servers on port 587). |
| `APP_MAIL_FROM` | `noreply@familienarchiv.local` | The `From:` address on outgoing emails. |
| `APP_BASE_URL` | `http://localhost:3000` | Base URL prepended to password reset links. |
---
## Disabling mail entirely
Set `MAIL_HOST` to an empty string. Spring Boot will not create a mail sender bean and no emails will be sent. Password reset tokens are still written to the database — useful if you want to test the reset flow via the API directly.
```dotenv
MAIL_HOST=
```

View File

@@ -61,6 +61,9 @@ test.describe('Authentication', () => {
test('logout clears the session and redirects to /login', async ({ page }) => {
await login(page);
// Wait for hydration before interacting with the nav — onclick handlers are
// only wired up after SvelteKit finishes hydrating the page client-side.
await page.waitForSelector('[data-hydrated]');
// Logout is inside the user avatar dropdown — open it first.
// Wait for the dropdown button to be visible before clicking Abmelden,
// since the {#if userMenuOpen} block renders asynchronously in Svelte.

View 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' });
});
});

View File

@@ -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?"
}

View File

@@ -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?"
}

View File

@@ -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?"
}

View File

@@ -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
},

View File

@@ -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) {

View File

@@ -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':

View File

@@ -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?: {

View File

@@ -46,7 +46,7 @@ function clickOutside(node: HTMLElement) {
</script>
<div class="min-h-screen bg-white" data-hydrated={hydrated || undefined}>
{#if !page.url.pathname.startsWith('/login')}
{#if !['/login', '/forgot-password', '/reset-password'].some((p) => page.url.pathname.startsWith(p))}
<header class="sticky top-0 z-50 border-b border-gray-100 bg-white">
<!-- De Gruyter Brill purple accent strip -->
<div class="h-1 bg-brand-purple"></div>

View 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;

View 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>

View File

@@ -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>

View 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;

View 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>