From 5f49a5787cca26403f6392b7c6c49a92716e683b Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 22 Mar 2026 23:56:26 +0100 Subject: [PATCH 1/7] feat(backend): add password reset via email - Add PasswordResetToken entity, repository (Flyway V8 migration) - PasswordResetService: token generation, validation, nightly cleanup - AuthController: POST /api/auth/forgot-password and /api/auth/reset-password (both permitAll) - AuthE2EController (@Profile("e2e")): GET /api/auth/reset-token-for-test for CI testing - spring-boot-starter-mail dependency; JavaMailSender optional (@Autowired required=false) - mail health indicator disabled; mail config via MAIL_HOST/PORT/USERNAME/PASSWORD env vars - 5 unit tests written TDD-style (all pass) Co-Authored-By: Claude Sonnet 4.6 --- backend/pom.xml | 4 + .../familienarchiv/config/AsyncConfig.java | 2 + .../familienarchiv/config/SecurityConfig.java | 4 + .../controller/AuthController.java | 37 +++++ .../controller/AuthE2EController.java | 33 +++++ .../dto/ForgotPasswordRequest.java | 8 ++ .../dto/ResetPasswordRequest.java | 9 ++ .../familienarchiv/exception/ErrorCode.java | 2 + .../model/PasswordResetToken.java | 45 ++++++ .../PasswordResetTokenRepository.java | 22 +++ .../service/PasswordResetService.java | 132 ++++++++++++++++++ backend/src/main/resources/application.yaml | 22 +++ .../V8__add_password_reset_tokens.sql | 10 ++ .../service/PasswordResetServiceTest.java | 126 +++++++++++++++++ 14 files changed, 456 insertions(+) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/controller/AuthController.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/controller/AuthE2EController.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/dto/ForgotPasswordRequest.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/dto/ResetPasswordRequest.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/model/PasswordResetToken.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/repository/PasswordResetTokenRepository.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/service/PasswordResetService.java create mode 100644 backend/src/main/resources/db/migration/V8__add_password_reset_tokens.sql create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/service/PasswordResetServiceTest.java diff --git a/backend/pom.xml b/backend/pom.xml index 75c722b6..ad068e07 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -119,6 +119,10 @@ lombok true + + org.springframework.boot + spring-boot-starter-mail + org.flywaydb flyway-core diff --git a/backend/src/main/java/org/raddatz/familienarchiv/config/AsyncConfig.java b/backend/src/main/java/org/raddatz/familienarchiv/config/AsyncConfig.java index db23dca9..7b8158af 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/config/AsyncConfig.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/config/AsyncConfig.java @@ -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() { diff --git a/backend/src/main/java/org/raddatz/familienarchiv/config/SecurityConfig.java b/backend/src/main/java/org/raddatz/familienarchiv/config/SecurityConfig.java index cb4d1a87..1390d591 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/config/SecurityConfig.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/config/SecurityConfig.java @@ -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( diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/AuthController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/AuthController.java new file mode 100644 index 00000000..704ad8d7 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/AuthController.java @@ -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 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 resetPassword(@RequestBody ResetPasswordRequest request) { + passwordResetService.resetPassword(request); + return ResponseEntity.noContent().build(); + } +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/AuthE2EController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/AuthE2EController.java new file mode 100644 index 00000000..312666e4 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/AuthE2EController.java @@ -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 getResetTokenForTest(@RequestParam String email) { + return tokenRepository.findLatestActiveTokenByEmail(email, LocalDateTime.now()) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); + } +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/ForgotPasswordRequest.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/ForgotPasswordRequest.java new file mode 100644 index 00000000..82e8dae2 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/ForgotPasswordRequest.java @@ -0,0 +1,8 @@ +package org.raddatz.familienarchiv.dto; + +import lombok.Data; + +@Data +public class ForgotPasswordRequest { + private String email; +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/ResetPasswordRequest.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/ResetPasswordRequest.java new file mode 100644 index 00000000..d46a6a6b --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/ResetPasswordRequest.java @@ -0,0 +1,9 @@ +package org.raddatz.familienarchiv.dto; + +import lombok.Data; + +@Data +public class ResetPasswordRequest { + private String token; + private String newPassword; +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java index 7e4a1ac6..c7968d27 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java @@ -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 */ diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/PasswordResetToken.java b/backend/src/main/java/org/raddatz/familienarchiv/model/PasswordResetToken.java new file mode 100644 index 00000000..7e4d956d --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/PasswordResetToken.java @@ -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; +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/PasswordResetTokenRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/PasswordResetTokenRepository.java new file mode 100644 index 00000000..d2c5fa28 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/PasswordResetTokenRepository.java @@ -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 { + + Optional 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 findLatestActiveTokenByEmail(String email, LocalDateTime now); + + @Modifying + @Query("DELETE FROM PasswordResetToken t WHERE t.expiresAt < :now OR t.used = true") + void deleteExpiredAndUsed(LocalDateTime now); +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/PasswordResetService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/PasswordResetService.java new file mode 100644 index 00000000..6f15ddb8 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/PasswordResetService.java @@ -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 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()); + } + } +} diff --git a/backend/src/main/resources/application.yaml b/backend/src/main/resources/application.yaml index 5839b767..fb1237fb 100644 --- a/backend/src/main/resources/application.yaml +++ b/backend/src/main/resources/application.yaml @@ -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} diff --git a/backend/src/main/resources/db/migration/V8__add_password_reset_tokens.sql b/backend/src/main/resources/db/migration/V8__add_password_reset_tokens.sql new file mode 100644 index 00000000..a110afb9 --- /dev/null +++ b/backend/src/main/resources/db/migration/V8__add_password_reset_tokens.sql @@ -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); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/PasswordResetServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/PasswordResetServiceTest.java new file mode 100644 index 00000000..762efb9c --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/PasswordResetServiceTest.java @@ -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); + } +} -- 2.49.1 From 908221f04d80ecde50044d2fb76ade9d1b3e1d3c Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 22 Mar 2026 23:57:01 +0100 Subject: [PATCH 2/7] 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

+
+
-- 2.49.1 From b9aff799fa14381b5ab7b136efc57b02d98af23f Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 23 Mar 2026 08:45:35 +0100 Subject: [PATCH 3/7] fix(e2e): use username check instead of count() for admin user creation When the e2e profile is active, initE2EData (which creates a reader user) can run before initAdminUser. The old count() == 0 guard then skips admin creation entirely, causing every login test to fail with 401. Switch to findByUsername(adminUsername).isEmpty() so the admin is created regardless of which CommandLineRunner runs first. Co-Authored-By: Claude Sonnet 4.6 --- .../org/raddatz/familienarchiv/config/DataInitializer.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/config/DataInitializer.java b/backend/src/main/java/org/raddatz/familienarchiv/config/DataInitializer.java index c4164a66..25f69894 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/config/DataInitializer.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/config/DataInitializer.java @@ -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() -- 2.49.1 From c18cdbfac1964de1116235d5da1657595e82837f Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 23 Mar 2026 09:10:17 +0100 Subject: [PATCH 4/7] feat(dev): add Mailpit mail catcher to docker-compose Adds a Mailpit container that catches all outgoing emails locally so password reset links can be tested without a real SMTP server. - Backend defaults to MAIL_HOST=mailpit / MAIL_PORT=1025 in compose - SMTP auth and STARTTLS disabled for Mailpit (no credentials needed) - Web inbox available at http://localhost:8025 - Production SMTP still works by overriding MAIL_HOST, MAIL_PORT, MAIL_USERNAME, MAIL_SMTP_AUTH, and MAIL_STARTTLS_ENABLE in .env Co-Authored-By: Claude Sonnet 4.6 --- docker-compose.yml | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index bf9b8b14..16fef739 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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} @@ -84,11 +99,15 @@ services: 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} + # 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: -- 2.49.1 From 88e3fb32b3e322477472760271ac0a1b48056c8e Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 23 Mar 2026 09:20:43 +0100 Subject: [PATCH 5/7] docs: add mail configuration guide Covers dev (Mailpit), production SMTP, all env vars with defaults, common provider settings, and how to disable mail entirely. Co-Authored-By: Claude Sonnet 4.6 --- docs/mail.md | 96 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 docs/mail.md diff --git a/docs/mail.md b/docs/mail.md new file mode 100644 index 00000000..9d84b3cb --- /dev/null +++ b/docs/mail.md @@ -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= +``` -- 2.49.1 From 17db73d90032fcfc596afb39e3bc4814b9138f9a Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 23 Mar 2026 09:28:03 +0100 Subject: [PATCH 6/7] fix(frontend): hide nav header on forgot-password and reset-password routes Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/+layout.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 3dd6aea5..536ab4d6 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -46,7 +46,7 @@ function clickOutside(node: HTMLElement) {
- {#if !page.url.pathname.startsWith('/login')} + {#if !['/login', '/forgot-password', '/reset-password'].some((p) => page.url.pathname.startsWith(p))}
-- 2.49.1 From 43defa41c4962a2b0d716ee9f515b8830b3dfa56 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 23 Mar 2026 09:48:05 +0100 Subject: [PATCH 7/7] fix(e2e): wait for hydration before clicking nav dropdown in logout test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit waitForURL('/') resolves as soon as the URL changes but before SvelteKit finishes hydrating — the avatar button's onclick is not yet registered, so the click has no effect and the dropdown never opens. Co-Authored-By: Claude Sonnet 4.6 --- frontend/e2e/auth.spec.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frontend/e2e/auth.spec.ts b/frontend/e2e/auth.spec.ts index 767da083..da5c4118 100644 --- a/frontend/e2e/auth.spec.ts +++ b/frontend/e2e/auth.spec.ts @@ -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. -- 2.49.1