feat: password reset via email (#36) #49
@@ -190,6 +190,7 @@ jobs:
|
|||||||
E2E_BASE_URL: http://localhost:3000
|
E2E_BASE_URL: http://localhost:3000
|
||||||
E2E_USERNAME: admin
|
E2E_USERNAME: admin
|
||||||
E2E_PASSWORD: admin123
|
E2E_PASSWORD: admin123
|
||||||
|
E2E_BACKEND_URL: http://localhost:8080
|
||||||
|
|
||||||
- name: Upload E2E results
|
- name: Upload E2E results
|
||||||
if: always()
|
if: always()
|
||||||
|
|||||||
@@ -119,6 +119,10 @@
|
|||||||
<artifactId>lombok</artifactId>
|
<artifactId>lombok</artifactId>
|
||||||
<optional>true</optional>
|
<optional>true</optional>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-mail</artifactId>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.flywaydb</groupId>
|
<groupId>org.flywaydb</groupId>
|
||||||
<artifactId>flyway-core</artifactId>
|
<artifactId>flyway-core</artifactId>
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ import java.util.concurrent.ThreadPoolExecutor;
|
|||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.scheduling.annotation.EnableAsync;
|
import org.springframework.scheduling.annotation.EnableAsync;
|
||||||
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableAsync
|
@EnableAsync
|
||||||
|
@EnableScheduling
|
||||||
public class AsyncConfig {
|
public class AsyncConfig {
|
||||||
@Bean
|
@Bean
|
||||||
public Executor taskExecutor() {
|
public Executor taskExecutor() {
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ public class DataInitializer {
|
|||||||
@Bean
|
@Bean
|
||||||
public CommandLineRunner initAdminUser(PasswordEncoder passwordEncoder) {
|
public CommandLineRunner initAdminUser(PasswordEncoder passwordEncoder) {
|
||||||
return args -> {
|
return args -> {
|
||||||
if (userRepository.count() == 0) {
|
if (userRepository.findByUsername(adminUsername).isEmpty()) {
|
||||||
log.info("Keine User gefunden. Erstelle Default-Admin...");
|
log.info("Kein Admin-User '{}' gefunden. Erstelle Default-Admin...", adminUsername);
|
||||||
|
|
||||||
// 1. Admin Gruppe erstellen
|
// 1. Admin Gruppe erstellen
|
||||||
UserGroup adminGroup = UserGroup.builder()
|
UserGroup adminGroup = UserGroup.builder()
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ public class SecurityConfig {
|
|||||||
.authorizeHttpRequests(auth -> {
|
.authorizeHttpRequests(auth -> {
|
||||||
// Health endpoint must be open so CI/Docker health checks work without credentials
|
// Health endpoint must be open so CI/Docker health checks work without credentials
|
||||||
auth.requestMatchers("/actuator/health").permitAll();
|
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
|
// In dev, allow unauthenticated access to the OpenAPI spec and Swagger UI
|
||||||
if (environment.matchesProfiles("dev")) {
|
if (environment.matchesProfiles("dev")) {
|
||||||
auth.requestMatchers(
|
auth.requestMatchers(
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package org.raddatz.familienarchiv.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class ForgotPasswordRequest {
|
||||||
|
private String email;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package org.raddatz.familienarchiv.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class ResetPasswordRequest {
|
||||||
|
private String token;
|
||||||
|
private String newPassword;
|
||||||
|
}
|
||||||
@@ -35,6 +35,8 @@ public enum ErrorCode {
|
|||||||
UNAUTHORIZED,
|
UNAUTHORIZED,
|
||||||
/** The authenticated user lacks the required permission. 403 */
|
/** The authenticated user lacks the required permission. 403 */
|
||||||
FORBIDDEN,
|
FORBIDDEN,
|
||||||
|
/** The password-reset token is missing, expired, or already used. 400 */
|
||||||
|
INVALID_RESET_TOKEN,
|
||||||
|
|
||||||
// --- Generic ---
|
// --- Generic ---
|
||||||
/** Request validation failed (missing or malformed fields). 400 */
|
/** Request validation failed (missing or malformed fields). 400 */
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,23 @@ spring:
|
|||||||
max-file-size: 50MB
|
max-file-size: 50MB
|
||||||
max-request-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:
|
springdoc:
|
||||||
api-docs:
|
api-docs:
|
||||||
enabled: false
|
enabled: false
|
||||||
@@ -38,6 +55,11 @@ app:
|
|||||||
bucket: ${S3_BUCKET_NAME}
|
bucket: ${S3_BUCKET_NAME}
|
||||||
region: ${S3_REGION}
|
region: ${S3_REGION}
|
||||||
|
|
||||||
|
base-url: ${APP_BASE_URL:http://localhost:3000}
|
||||||
|
|
||||||
|
mail:
|
||||||
|
from: ${APP_MAIL_FROM:noreply@familienarchiv.local}
|
||||||
|
|
||||||
admin:
|
admin:
|
||||||
username: ${APP_ADMIN_USERNAME:admin}
|
username: ${APP_ADMIN_USERNAME:admin}
|
||||||
password: ${APP_ADMIN_PASSWORD:admin123}
|
password: ${APP_ADMIN_PASSWORD:admin123}
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,6 +58,19 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- archive-net
|
- 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: Spring Boot ---
|
||||||
backend:
|
backend:
|
||||||
build:
|
build:
|
||||||
@@ -74,6 +87,8 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
minio:
|
minio:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
mailpit:
|
||||||
|
condition: service_started
|
||||||
environment:
|
environment:
|
||||||
SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/${POSTGRES_DB}
|
SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/${POSTGRES_DB}
|
||||||
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER}
|
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER}
|
||||||
@@ -83,6 +98,16 @@ services:
|
|||||||
S3_SECRET_KEY: ${MINIO_ROOT_PASSWORD}
|
S3_SECRET_KEY: ${MINIO_ROOT_PASSWORD}
|
||||||
S3_BUCKET_NAME: ${MINIO_DEFAULT_BUCKETS}
|
S3_BUCKET_NAME: ${MINIO_DEFAULT_BUCKETS}
|
||||||
S3_REGION: us-east-1
|
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:
|
ports:
|
||||||
- "${PORT_BACKEND}:8080"
|
- "${PORT_BACKEND}:8080"
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
96
docs/mail.md
Normal file
96
docs/mail.md
Normal 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=
|
||||||
|
```
|
||||||
@@ -61,6 +61,9 @@ test.describe('Authentication', () => {
|
|||||||
|
|
||||||
test('logout clears the session and redirects to /login', async ({ page }) => {
|
test('logout clears the session and redirects to /login', async ({ page }) => {
|
||||||
await login(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.
|
// Logout is inside the user avatar dropdown — open it first.
|
||||||
// Wait for the dropdown button to be visible before clicking Abmelden,
|
// Wait for the dropdown button to be visible before clicking Abmelden,
|
||||||
// since the {#if userMenuOpen} block renders asynchronously in Svelte.
|
// since the {#if userMenuOpen} block renders asynchronously in Svelte.
|
||||||
|
|||||||
113
frontend/e2e/password-reset.spec.ts
Normal file
113
frontend/e2e/password-reset.spec.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Password-reset E2E tests.
|
||||||
|
*
|
||||||
|
* These tests run WITHOUT a stored session because they test unauthenticated flows.
|
||||||
|
*
|
||||||
|
* They rely on the "e2e" Spring profile being active in CI (see playwright.config.ts /
|
||||||
|
* docker-compose.e2e.yml). The profile exposes GET /api/auth/reset-token-for-test?email=
|
||||||
|
* so we can retrieve the generated token without a real mail server.
|
||||||
|
*/
|
||||||
|
test.use({ storageState: { cookies: [], origins: [] } });
|
||||||
|
|
||||||
|
// The backend is accessible directly for E2E helper calls (no SvelteKit proxy needed).
|
||||||
|
const BACKEND_URL = process.env.E2E_BACKEND_URL ?? 'http://localhost:8080';
|
||||||
|
|
||||||
|
async function getResetToken(email: string): Promise<string> {
|
||||||
|
const res = await fetch(
|
||||||
|
`${BACKEND_URL}/api/auth/reset-token-for-test?email=${encodeURIComponent(email)}`
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error(`Could not retrieve reset token for ${email}: ${res.status}`);
|
||||||
|
return res.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Password reset', () => {
|
||||||
|
test('forgot-password page is accessible without login', async ({ page }) => {
|
||||||
|
await page.goto('/forgot-password');
|
||||||
|
await expect(page).toHaveURL('/forgot-password');
|
||||||
|
await expect(page.getByRole('heading', { name: /Passwort vergessen/i })).toBeVisible();
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/password-reset-form.png' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('forgot-password shows success banner for any email (prevents user enumeration)', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
await page.goto('/forgot-password');
|
||||||
|
await page.getByLabel(/E-Mail/i).fill('nonexistent@example.com');
|
||||||
|
await page.getByRole('button', { name: /Link anfordern/i }).click();
|
||||||
|
// Always shows success — never reveals whether the email exists
|
||||||
|
await expect(page.locator('.bg-green-50')).toBeVisible();
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/password-reset-success-banner.png' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('full password reset flow', async ({ page }) => {
|
||||||
|
const testEmail = process.env.E2E_EMAIL ?? 'admin@familyarchive.local';
|
||||||
|
const originalPassword = process.env.E2E_PASSWORD ?? 'admin123';
|
||||||
|
const newPassword = 'NewP@ssw0rd_E2E!';
|
||||||
|
|
||||||
|
// 1. Request reset
|
||||||
|
await page.goto('/forgot-password');
|
||||||
|
await page.getByLabel(/E-Mail/i).fill(testEmail);
|
||||||
|
await page.getByRole('button', { name: /Link anfordern/i }).click();
|
||||||
|
await expect(page.locator('.bg-green-50')).toBeVisible();
|
||||||
|
|
||||||
|
// 2. Fetch the token via the test helper endpoint
|
||||||
|
const token = await getResetToken(testEmail);
|
||||||
|
expect(token.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// 3. Open the reset-password page with the token
|
||||||
|
await page.goto(`/reset-password?token=${token}`);
|
||||||
|
await expect(page.getByRole('heading', { name: /Neues Passwort/i })).toBeVisible();
|
||||||
|
await page.getByLabel(/^Neues Passwort$/i).fill(newPassword);
|
||||||
|
await page.getByLabel(/Passwort bestätigen/i).fill(newPassword);
|
||||||
|
await page.getByRole('button', { name: /Passwort speichern/i }).click();
|
||||||
|
|
||||||
|
// 4. Success banner — then navigate to login
|
||||||
|
await expect(page.locator('.bg-green-50')).toBeVisible();
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/password-reset-changed.png' });
|
||||||
|
await page.getByRole('link', { name: /Zurück zum Login/i }).click();
|
||||||
|
|
||||||
|
// 5. Log in with new password
|
||||||
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
await page.getByLabel('Benutzername').fill(process.env.E2E_USERNAME ?? 'admin');
|
||||||
|
await page.getByLabel('Passwort').fill(newPassword);
|
||||||
|
await page.getByRole('button', { name: 'Anmelden' }).click();
|
||||||
|
await expect(page).toHaveURL('/');
|
||||||
|
|
||||||
|
// 6. Restore original password via profile page
|
||||||
|
await page.goto('/profile');
|
||||||
|
await page.locator('input[name="currentPassword"]').fill(newPassword);
|
||||||
|
await page.locator('input[name="newPassword"]').fill(originalPassword);
|
||||||
|
await page.locator('input[name="confirmPassword"]').fill(originalPassword);
|
||||||
|
// Profile page has two "Speichern" buttons — the password form is the last one
|
||||||
|
await page.locator('button[type="submit"]').last().click();
|
||||||
|
// After changing password, auth_token is stale → redirect to login
|
||||||
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
|
||||||
|
// 7. Log back in with original password to confirm restore worked
|
||||||
|
await page.getByLabel('Benutzername').fill(process.env.E2E_USERNAME ?? 'admin');
|
||||||
|
await page.getByLabel('Passwort').fill(originalPassword);
|
||||||
|
await page.getByRole('button', { name: 'Anmelden' }).click();
|
||||||
|
await expect(page).toHaveURL('/');
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/password-reset-restored.png' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reset-password page shows error for invalid token', async ({ page }) => {
|
||||||
|
await page.goto('/reset-password?token=invalidtoken000');
|
||||||
|
await page.getByLabel(/^Neues Passwort$/i).fill('somepassword');
|
||||||
|
await page.getByLabel(/Passwort bestätigen/i).fill('somepassword');
|
||||||
|
await page.getByRole('button', { name: /Passwort speichern/i }).click();
|
||||||
|
await expect(page.locator('.text-red-600')).toBeVisible();
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/password-reset-invalid-token.png' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reset-password page shows mismatch error when passwords differ', async ({ page }) => {
|
||||||
|
await page.goto('/reset-password?token=anytoken');
|
||||||
|
await page.getByLabel(/^Neues Passwort$/i).fill('password1');
|
||||||
|
await page.getByLabel(/Passwort bestätigen/i).fill('password2');
|
||||||
|
await page.getByRole('button', { name: /Passwort speichern/i }).click();
|
||||||
|
await expect(page.locator('.text-red-600')).toBeVisible();
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/password-reset-mismatch.png' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -202,5 +202,18 @@
|
|||||||
"profile_password_mismatch": "Die neuen Passwörter stimmen nicht überein.",
|
"profile_password_mismatch": "Die neuen Passwörter stimmen nicht überein.",
|
||||||
"profile_saved": "Gespeichert.",
|
"profile_saved": "Gespeichert.",
|
||||||
"profile_password_changed": "Passwort erfolgreich geändert.",
|
"profile_password_changed": "Passwort erfolgreich geändert.",
|
||||||
"user_profile_heading": "Profil von"
|
"user_profile_heading": "Profil von",
|
||||||
|
"error_invalid_reset_token": "Der Link ist ungültig oder abgelaufen.",
|
||||||
|
"forgot_password_heading": "Passwort vergessen",
|
||||||
|
"forgot_password_email_label": "E-Mail-Adresse",
|
||||||
|
"forgot_password_submit": "Link anfordern",
|
||||||
|
"forgot_password_success": "Falls ein Konto mit dieser E-Mail-Adresse existiert, erhalten Sie in Kürze eine E-Mail mit einem Link zum Zurücksetzen Ihres Passworts.",
|
||||||
|
"forgot_password_back_to_login": "Zurück zum Login",
|
||||||
|
"reset_password_heading": "Neues Passwort festlegen",
|
||||||
|
"reset_password_label": "Neues Passwort",
|
||||||
|
"reset_password_confirm_label": "Passwort bestätigen",
|
||||||
|
"reset_password_submit": "Passwort speichern",
|
||||||
|
"reset_password_mismatch": "Die Passwörter stimmen nicht überein.",
|
||||||
|
"reset_password_success": "Ihr Passwort wurde erfolgreich geändert. Sie können sich jetzt anmelden.",
|
||||||
|
"login_forgot_password": "Passwort vergessen?"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -202,5 +202,18 @@
|
|||||||
"profile_password_mismatch": "The new passwords do not match.",
|
"profile_password_mismatch": "The new passwords do not match.",
|
||||||
"profile_saved": "Saved.",
|
"profile_saved": "Saved.",
|
||||||
"profile_password_changed": "Password changed successfully.",
|
"profile_password_changed": "Password changed successfully.",
|
||||||
"user_profile_heading": "Profile of"
|
"user_profile_heading": "Profile of",
|
||||||
|
"error_invalid_reset_token": "The link is invalid or has expired.",
|
||||||
|
"forgot_password_heading": "Forgot password",
|
||||||
|
"forgot_password_email_label": "Email address",
|
||||||
|
"forgot_password_submit": "Request link",
|
||||||
|
"forgot_password_success": "If an account with this email address exists, you will shortly receive an email with a link to reset your password.",
|
||||||
|
"forgot_password_back_to_login": "Back to login",
|
||||||
|
"reset_password_heading": "Set new password",
|
||||||
|
"reset_password_label": "New password",
|
||||||
|
"reset_password_confirm_label": "Confirm password",
|
||||||
|
"reset_password_submit": "Save password",
|
||||||
|
"reset_password_mismatch": "The passwords do not match.",
|
||||||
|
"reset_password_success": "Your password has been changed successfully. You can now log in.",
|
||||||
|
"login_forgot_password": "Forgot password?"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -202,5 +202,18 @@
|
|||||||
"profile_password_mismatch": "Las nuevas contraseñas no coinciden.",
|
"profile_password_mismatch": "Las nuevas contraseñas no coinciden.",
|
||||||
"profile_saved": "Guardado.",
|
"profile_saved": "Guardado.",
|
||||||
"profile_password_changed": "Contraseña cambiada con éxito.",
|
"profile_password_changed": "Contraseña cambiada con éxito.",
|
||||||
"user_profile_heading": "Perfil de"
|
"user_profile_heading": "Perfil de",
|
||||||
|
"error_invalid_reset_token": "El enlace no es válido o ha expirado.",
|
||||||
|
"forgot_password_heading": "Contraseña olvidada",
|
||||||
|
"forgot_password_email_label": "Correo electrónico",
|
||||||
|
"forgot_password_submit": "Solicitar enlace",
|
||||||
|
"forgot_password_success": "Si existe una cuenta con esta dirección de correo electrónico, recibirá en breve un correo con un enlace para restablecer su contraseña.",
|
||||||
|
"forgot_password_back_to_login": "Volver al inicio de sesión",
|
||||||
|
"reset_password_heading": "Establecer nueva contraseña",
|
||||||
|
"reset_password_label": "Nueva contraseña",
|
||||||
|
"reset_password_confirm_label": "Confirmar contraseña",
|
||||||
|
"reset_password_submit": "Guardar contraseña",
|
||||||
|
"reset_password_mismatch": "Las contraseñas no coinciden.",
|
||||||
|
"reset_password_success": "Su contraseña ha sido cambiada con éxito. Ahora puede iniciar sesión.",
|
||||||
|
"login_forgot_password": "¿Olvidó su contraseña?"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ export default defineConfig({
|
|||||||
// The backend + DB + MinIO must be started separately (see README or CI workflow).
|
// The backend + DB + MinIO must be started separately (see README or CI workflow).
|
||||||
webServer: {
|
webServer: {
|
||||||
command: 'npm run dev -- --port 3000',
|
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,
|
reuseExistingServer: true,
|
||||||
timeout: 120_000
|
timeout: 120_000
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { env } from 'process';
|
|||||||
import { cookieName, cookieMaxAge } from '$lib/paraglide/runtime';
|
import { cookieName, cookieMaxAge } from '$lib/paraglide/runtime';
|
||||||
import { detectLocale } from '$lib/server/locale';
|
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 }) => {
|
const handleLocaleDetection: Handle = ({ event, resolve }) => {
|
||||||
if (!event.cookies.get(cookieName)) {
|
if (!event.cookies.get(cookieName)) {
|
||||||
@@ -71,6 +71,12 @@ export const handleFetch: HandleFetch = async ({ event, request, fetch }) => {
|
|||||||
return fetch(request);
|
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');
|
const token = event.cookies.get('auth_token');
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export type ErrorCode =
|
|||||||
| 'EMAIL_ALREADY_IN_USE'
|
| 'EMAIL_ALREADY_IN_USE'
|
||||||
| 'WRONG_CURRENT_PASSWORD'
|
| 'WRONG_CURRENT_PASSWORD'
|
||||||
| 'IMPORT_ALREADY_RUNNING'
|
| 'IMPORT_ALREADY_RUNNING'
|
||||||
|
| 'INVALID_RESET_TOKEN'
|
||||||
| 'UNAUTHORIZED'
|
| 'UNAUTHORIZED'
|
||||||
| 'FORBIDDEN'
|
| 'FORBIDDEN'
|
||||||
| 'VALIDATION_ERROR'
|
| 'VALIDATION_ERROR'
|
||||||
@@ -58,6 +59,8 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
|
|||||||
return m.error_wrong_current_password();
|
return m.error_wrong_current_password();
|
||||||
case 'IMPORT_ALREADY_RUNNING':
|
case 'IMPORT_ALREADY_RUNNING':
|
||||||
return m.error_import_already_running();
|
return m.error_import_already_running();
|
||||||
|
case 'INVALID_RESET_TOKEN':
|
||||||
|
return m.error_invalid_reset_token();
|
||||||
case 'UNAUTHORIZED':
|
case 'UNAUTHORIZED':
|
||||||
return m.error_unauthorized();
|
return m.error_unauthorized();
|
||||||
case 'FORBIDDEN':
|
case 'FORBIDDEN':
|
||||||
|
|||||||
@@ -4,6 +4,22 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export interface paths {
|
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": {
|
"/api/users/me": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -164,6 +180,38 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/api/admin/trigger-import": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -196,22 +244,6 @@ export interface paths {
|
|||||||
patch: operations["updateGroup"];
|
patch: operations["updateGroup"];
|
||||||
trace?: never;
|
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": {
|
"/api/tags": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -344,13 +376,15 @@ export interface paths {
|
|||||||
export type webhooks = Record<string, never>;
|
export type webhooks = Record<string, never>;
|
||||||
export interface components {
|
export interface components {
|
||||||
schemas: {
|
schemas: {
|
||||||
UpdateProfileDTO: {
|
AdminUpdateUserRequest: {
|
||||||
firstName?: string;
|
firstName?: string;
|
||||||
lastName?: string;
|
lastName?: string;
|
||||||
/** Format: date */
|
/** Format: date */
|
||||||
birthDate?: string;
|
birthDate?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
contact?: string;
|
contact?: string;
|
||||||
|
newPassword?: string;
|
||||||
|
groupIds?: string[];
|
||||||
};
|
};
|
||||||
AppUser: {
|
AppUser: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
@@ -374,6 +408,14 @@ export interface components {
|
|||||||
name: string;
|
name: string;
|
||||||
permissions: string[];
|
permissions: string[];
|
||||||
};
|
};
|
||||||
|
UpdateProfileDTO: {
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
/** Format: date */
|
||||||
|
birthDate?: string;
|
||||||
|
email?: string;
|
||||||
|
contact?: string;
|
||||||
|
};
|
||||||
Tag: {
|
Tag: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
id: string;
|
id: string;
|
||||||
@@ -444,6 +486,11 @@ export interface components {
|
|||||||
email?: string;
|
email?: string;
|
||||||
initialPassword?: string;
|
initialPassword?: string;
|
||||||
groupIds?: string[];
|
groupIds?: string[];
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
/** Format: date */
|
||||||
|
birthDate?: string;
|
||||||
|
contact?: string;
|
||||||
};
|
};
|
||||||
ChangePasswordDTO: {
|
ChangePasswordDTO: {
|
||||||
currentPassword?: string;
|
currentPassword?: string;
|
||||||
@@ -453,6 +500,13 @@ export interface components {
|
|||||||
name?: string;
|
name?: string;
|
||||||
permissions?: string[];
|
permissions?: string[];
|
||||||
};
|
};
|
||||||
|
ResetPasswordRequest: {
|
||||||
|
token?: string;
|
||||||
|
newPassword?: string;
|
||||||
|
};
|
||||||
|
ForgotPasswordRequest: {
|
||||||
|
email?: string;
|
||||||
|
};
|
||||||
ImportStatus: {
|
ImportStatus: {
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
state?: "IDLE" | "RUNNING" | "DONE" | "FAILED";
|
state?: "IDLE" | "RUNNING" | "DONE" | "FAILED";
|
||||||
@@ -471,6 +525,74 @@ export interface components {
|
|||||||
}
|
}
|
||||||
export type $defs = Record<string, never>;
|
export type $defs = Record<string, never>;
|
||||||
export interface operations {
|
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: {
|
getCurrentUser: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
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: {
|
triggerMassImport: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
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: {
|
searchTags: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ function clickOutside(node: HTMLElement) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="min-h-screen bg-white" data-hydrated={hydrated || undefined}>
|
<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">
|
<header class="sticky top-0 z-50 border-b border-gray-100 bg-white">
|
||||||
<!-- De Gruyter Brill purple accent strip -->
|
<!-- De Gruyter Brill purple accent strip -->
|
||||||
<div class="h-1 bg-brand-purple"></div>
|
<div class="h-1 bg-brand-purple"></div>
|
||||||
|
|||||||
20
frontend/src/routes/forgot-password/+page.server.ts
Normal file
20
frontend/src/routes/forgot-password/+page.server.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { fail } from '@sveltejs/kit';
|
||||||
|
import type { Actions } from './$types';
|
||||||
|
import { createApiClient } from '$lib/api.server';
|
||||||
|
|
||||||
|
export const actions = {
|
||||||
|
default: async ({ request, fetch }) => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const email = formData.get('email') as string;
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
return fail(400, { error: 'Email is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = createApiClient(fetch);
|
||||||
|
await api.POST('/api/auth/forgot-password', { body: { email } });
|
||||||
|
|
||||||
|
// Always return success — never disclose whether the email exists
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
} satisfies Actions;
|
||||||
84
frontend/src/routes/forgot-password/+page.svelte
Normal file
84
frontend/src/routes/forgot-password/+page.svelte
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
let { form }: { form?: { error?: string; success?: boolean } } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="relative flex min-h-screen flex-col bg-white">
|
||||||
|
<!-- Accent strip -->
|
||||||
|
<div class="h-1 bg-brand-purple"></div>
|
||||||
|
|
||||||
|
<div class="flex flex-1 items-center justify-center px-4">
|
||||||
|
<div class="w-full max-w-sm">
|
||||||
|
<!-- Logo -->
|
||||||
|
<div class="mb-10 text-center">
|
||||||
|
<a href="/" class="inline-flex items-center" aria-label="Familienarchiv">
|
||||||
|
<span class="font-sans text-2xl font-bold tracking-widest text-brand-navy uppercase"
|
||||||
|
>Familienarchiv</span
|
||||||
|
>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card -->
|
||||||
|
<div class="rounded-sm border border-brand-sand bg-white p-8 shadow-sm">
|
||||||
|
<h1 class="mb-6 font-sans text-sm font-bold tracking-widest text-brand-navy uppercase">
|
||||||
|
{m.forgot_password_heading()}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{#if form?.success}
|
||||||
|
<div class="mb-5 rounded-sm border border-green-200 bg-green-50 px-4 py-3">
|
||||||
|
<p class="font-sans text-xs text-green-700">{m.forgot_password_success()}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/login"
|
||||||
|
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
|
||||||
|
>{m.forgot_password_back_to_login()}</a
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<form method="POST" class="space-y-5">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="email"
|
||||||
|
class="mb-1.5 block font-sans text-xs font-bold tracking-widest text-gray-500 uppercase"
|
||||||
|
>{m.forgot_password_email_label()}</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
id="email"
|
||||||
|
required
|
||||||
|
autocomplete="email"
|
||||||
|
class="block w-full border border-gray-300 px-3 py-2.5 font-serif text-sm text-brand-navy placeholder-gray-400 focus:border-brand-navy focus:ring-1 focus:ring-brand-navy focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if form?.error}
|
||||||
|
<div class="text-center font-sans text-xs font-medium text-red-600">{form.error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="mt-2 w-full bg-brand-navy py-2.5 font-sans text-xs font-bold tracking-widest text-white uppercase transition-colors hover:bg-brand-navy/90"
|
||||||
|
>
|
||||||
|
{m.forgot_password_submit()}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="mt-4 text-center">
|
||||||
|
<a
|
||||||
|
href="/login"
|
||||||
|
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
|
||||||
|
>{m.forgot_password_back_to_login()}</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="py-4 text-center">
|
||||||
|
<p class="font-sans text-xs tracking-widest text-gray-300 uppercase">Familienarchiv</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -89,6 +89,14 @@ const activeLocale = $derived(getLocale().toUpperCase());
|
|||||||
>
|
>
|
||||||
{m.login_btn_submit()}
|
{m.login_btn_submit()}
|
||||||
</button>
|
</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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
34
frontend/src/routes/reset-password/+page.server.ts
Normal file
34
frontend/src/routes/reset-password/+page.server.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { fail } from '@sveltejs/kit';
|
||||||
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
import { createApiClient } from '$lib/api.server';
|
||||||
|
import { parseBackendError } from '$lib/errors';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ url }) => {
|
||||||
|
const token = url.searchParams.get('token');
|
||||||
|
return { token };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions = {
|
||||||
|
default: async ({ request, fetch }) => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const token = formData.get('token') as string;
|
||||||
|
const newPassword = formData.get('newPassword') as string;
|
||||||
|
const confirmPassword = formData.get('confirmPassword') as string;
|
||||||
|
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
return fail(400, { error: 'MISMATCH' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = createApiClient(fetch);
|
||||||
|
const result = await api.POST('/api/auth/reset-password', {
|
||||||
|
body: { token, newPassword }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.response.ok) {
|
||||||
|
const backendError = await parseBackendError(result.response);
|
||||||
|
return fail(400, { error: backendError?.code ?? 'INTERNAL_ERROR' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
} satisfies Actions;
|
||||||
113
frontend/src/routes/reset-password/+page.svelte
Normal file
113
frontend/src/routes/reset-password/+page.svelte
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import { getErrorMessage } from '$lib/errors';
|
||||||
|
|
||||||
|
let {
|
||||||
|
data,
|
||||||
|
form
|
||||||
|
}: {
|
||||||
|
data: { token: string | null };
|
||||||
|
form?: { error?: string; success?: boolean };
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="relative flex min-h-screen flex-col bg-white">
|
||||||
|
<!-- Accent strip -->
|
||||||
|
<div class="h-1 bg-brand-purple"></div>
|
||||||
|
|
||||||
|
<div class="flex flex-1 items-center justify-center px-4">
|
||||||
|
<div class="w-full max-w-sm">
|
||||||
|
<!-- Logo -->
|
||||||
|
<div class="mb-10 text-center">
|
||||||
|
<a href="/" class="inline-flex items-center" aria-label="Familienarchiv">
|
||||||
|
<span class="font-sans text-2xl font-bold tracking-widest text-brand-navy uppercase"
|
||||||
|
>Familienarchiv</span
|
||||||
|
>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card -->
|
||||||
|
<div class="rounded-sm border border-brand-sand bg-white p-8 shadow-sm">
|
||||||
|
<h1 class="mb-6 font-sans text-sm font-bold tracking-widest text-brand-navy uppercase">
|
||||||
|
{m.reset_password_heading()}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{#if form?.success}
|
||||||
|
<div class="mb-5 rounded-sm border border-green-200 bg-green-50 px-4 py-3">
|
||||||
|
<p class="font-sans text-xs text-green-700">{m.reset_password_success()}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/login"
|
||||||
|
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
|
||||||
|
>{m.forgot_password_back_to_login()}</a
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<form method="POST" class="space-y-5">
|
||||||
|
<input type="hidden" name="token" value={data.token ?? ''} />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="newPassword"
|
||||||
|
class="mb-1.5 block font-sans text-xs font-bold tracking-widest text-gray-500 uppercase"
|
||||||
|
>{m.reset_password_label()}</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="newPassword"
|
||||||
|
id="newPassword"
|
||||||
|
required
|
||||||
|
autocomplete="new-password"
|
||||||
|
class="block w-full border border-gray-300 px-3 py-2.5 font-serif text-sm text-brand-navy placeholder-gray-400 focus:border-brand-navy focus:ring-1 focus:ring-brand-navy focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="confirmPassword"
|
||||||
|
class="mb-1.5 block font-sans text-xs font-bold tracking-widest text-gray-500 uppercase"
|
||||||
|
>{m.reset_password_confirm_label()}</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="confirmPassword"
|
||||||
|
id="confirmPassword"
|
||||||
|
required
|
||||||
|
autocomplete="new-password"
|
||||||
|
class="block w-full border border-gray-300 px-3 py-2.5 font-serif text-sm text-brand-navy placeholder-gray-400 focus:border-brand-navy focus:ring-1 focus:ring-brand-navy focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if form?.error}
|
||||||
|
<div class="text-center font-sans text-xs font-medium text-red-600">
|
||||||
|
{form.error === 'MISMATCH'
|
||||||
|
? m.reset_password_mismatch()
|
||||||
|
: getErrorMessage(form.error)}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="mt-2 w-full bg-brand-navy py-2.5 font-sans text-xs font-bold tracking-widest text-white uppercase transition-colors hover:bg-brand-navy/90"
|
||||||
|
>
|
||||||
|
{m.reset_password_submit()}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="mt-4 text-center">
|
||||||
|
<a
|
||||||
|
href="/login"
|
||||||
|
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
|
||||||
|
>{m.forgot_password_back_to_login()}</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="py-4 text-center">
|
||||||
|
<p class="font-sans text-xs tracking-widest text-gray-300 uppercase">Familienarchiv</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user