Add email verification and password reset
mindflux-ci / build (push) Successful in 2m26s

This commit is contained in:
AliceDarkest
2026-07-15 11:10:07 +02:00
parent 80f556b1d0
commit ce1975b47a
19 changed files with 467 additions and 13 deletions
@@ -20,7 +20,7 @@ public class SecurityConfig {
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/login", "/register", "/impressum", "/nutzungsbedingungen", "/datenschutz", "/richtlinien", "/css/**", "/js/**", "/img/**").permitAll()
.requestMatchers("/", "/login", "/register", "/forgot-password", "/reset-password", "/verify-email", "/verification/resend", "/impressum", "/nutzungsbedingungen", "/datenschutz", "/richtlinien", "/css/**", "/js/**", "/img/**").permitAll()
.requestMatchers("/actuator/health").permitAll() // Container-Healthcheck
.requestMatchers("/admin/**").hasRole("ADMIN")
.requestMatchers("/api/users/**").hasRole("ADMIN")
@@ -63,6 +63,7 @@ public class SecurityConfig {
.withUsername(user.getUsername())
.password(user.getPassword())
.roles(user.getRole()) // Achtung: Nur ein String, z.B. "USER" oder "ADMIN"
.disabled(!user.isEmailVerified())
.build();
};
}
@@ -0,0 +1,75 @@
package com.example.mindflux.controller;
import com.example.mindflux.service.AccountMailService;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
@Controller
public class AccountRecoveryController {
private final AccountMailService accountMailService;
public AccountRecoveryController(AccountMailService accountMailService) {
this.accountMailService = accountMailService;
}
@GetMapping("/forgot-password")
public String forgotPassword() {
return "forgot-password";
}
@PostMapping("/forgot-password")
public String requestPasswordReset(@RequestParam String email) {
try {
accountMailService.requestPasswordReset(email);
} catch (RuntimeException ignored) {
// Die Antwort bleibt absichtlich identisch: keine E-Mail- oder Account-Informationen preisgeben.
}
// Einheitliche Antwort, damit nicht erkennbar ist, welche Adressen registriert sind.
return "redirect:/forgot-password?sent";
}
@PostMapping("/verification/resend")
public String resendVerification(@RequestParam String email) {
try {
accountMailService.resendVerification(email);
} catch (RuntimeException ignored) {
// Auch bei einem SMTP-Fehler keine Information über einen möglichen Account verraten.
}
return "redirect:/?verificationSent";
}
@GetMapping("/verify-email")
public String verifyEmail(@RequestParam(required = false) String token) {
return accountMailService.verifyEmail(token)
? "redirect:/?emailVerified"
: "redirect:/?verificationInvalid";
}
@GetMapping("/reset-password")
public String showResetPassword(@RequestParam(required = false) String token, Model model) {
if (!accountMailService.isResetTokenValid(token)) {
return "redirect:/?resetInvalid";
}
model.addAttribute("token", token);
return "reset-password";
}
@PostMapping("/reset-password")
public String resetPassword(@RequestParam String token,
@RequestParam String password,
@RequestParam String confirmPassword) {
if (password.length() < 8) {
return "redirect:/reset-password?token=" + token + "&error=policy";
}
if (!password.equals(confirmPassword)) {
return "redirect:/reset-password?token=" + token + "&error=mismatch";
}
return accountMailService.resetPassword(token, password, confirmPassword)
? "redirect:/?passwordReset"
: "redirect:/?resetInvalid";
}
}
@@ -64,12 +64,13 @@ public class AdminUserController {
/** Direktes Zurücksetzen des Passworts eines Nutzers (Admin-Recovery, ohne E-Mail). */
@PostMapping("/users/{id}/reset-password")
public String resetPassword(@PathVariable Long id, @RequestParam String newPassword) {
if (newPassword != null && !newPassword.isBlank()) {
User user = userService.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Ungültige User-ID: " + id));
user.setPassword(passwordEncoder.encode(newPassword.trim()));
userService.save(user);
if (newPassword == null || newPassword.trim().length() < 8) {
return "redirect:/admin/users?passwordPolicy";
}
User user = userService.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Ungültige User-ID: " + id));
user.setPassword(passwordEncoder.encode(newPassword.trim()));
userService.save(user);
return "redirect:/admin/users";
}
@@ -1,6 +1,7 @@
package com.example.mindflux.controller;
import com.example.mindflux.service.RegistrationResult;
import com.example.mindflux.service.AccountMailService;
import com.example.mindflux.service.UserService;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@@ -11,9 +12,11 @@ import org.springframework.web.bind.annotation.RequestParam;
public class RegisterController {
private final UserService userService;
private final AccountMailService accountMailService;
public RegisterController(UserService userService) {
public RegisterController(UserService userService, AccountMailService accountMailService) {
this.userService = userService;
this.accountMailService = accountMailService;
}
@GetMapping("/register")
@@ -29,10 +32,18 @@ public class RegisterController {
@RequestParam(required = false) String confirmPassword) {
RegistrationResult result = userService.register(username, email, password, confirmPassword);
return switch (result) {
case SUCCESS -> "redirect:/?registerSuccess";
case SUCCESS -> {
try {
accountMailService.sendVerificationFor(userService.findByUsernameIgnoreCase(username.trim()));
yield "redirect:/?registerSuccess";
} catch (RuntimeException e) {
yield "redirect:/?registerMailError";
}
}
case PASSWORD_MISMATCH -> "redirect:/?passwordMismatch";
case PASSWORD_TOO_SHORT -> "redirect:/?passwordPolicy";
case EMAIL_INVALID -> "redirect:/?emailInvalid";
case EMAIL_TAKEN -> "redirect:/?emailTaken";
case USERNAME_INVALID, USERNAME_TAKEN -> "redirect:/?registerError";
};
}
@@ -0,0 +1,43 @@
package com.example.mindflux.model;
import jakarta.persistence.*;
import java.time.Instant;
@Entity
@Table(name = "account_email_tokens")
public class AccountEmailToken {
public enum Purpose { EMAIL_VERIFICATION, PASSWORD_RESET }
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(optional = false, fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@Column(name = "token_hash", nullable = false, unique = true, length = 64)
private String tokenHash;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 32)
private Purpose purpose;
@Column(name = "expires_at", nullable = false)
private Instant expiresAt;
@Column(name = "consumed_at")
private Instant consumedAt;
public Long getId() { return id; }
public User getUser() { return user; }
public void setUser(User user) { this.user = user; }
public String getTokenHash() { return tokenHash; }
public void setTokenHash(String tokenHash) { this.tokenHash = tokenHash; }
public Purpose getPurpose() { return purpose; }
public void setPurpose(Purpose purpose) { this.purpose = purpose; }
public Instant getExpiresAt() { return expiresAt; }
public void setExpiresAt(Instant expiresAt) { this.expiresAt = expiresAt; }
public Instant getConsumedAt() { return consumedAt; }
public void setConsumedAt(Instant consumedAt) { this.consumedAt = consumedAt; }
}
@@ -16,6 +16,9 @@ public class User {
private String email;
private String role;
@Column(name = "email_verified", nullable = false)
private boolean emailVerified = true;
@Column(name = "profile_picture")
private String profilePicture;
@@ -47,6 +50,9 @@ public class User {
public String getRole() { return role; }
public void setRole(String r) { this.role = r; }
public boolean isEmailVerified() { return emailVerified; }
public void setEmailVerified(boolean emailVerified) { this.emailVerified = emailVerified; }
public String getProfilePicture() { return profilePicture; }
public void setProfilePicture(String pictureUrl) { this.profilePicture = pictureUrl; }
}
@@ -0,0 +1,14 @@
package com.example.mindflux.repository;
import com.example.mindflux.model.AccountEmailToken;
import com.example.mindflux.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface AccountEmailTokenRepository extends JpaRepository<AccountEmailToken, Long> {
Optional<AccountEmailToken> findByTokenHashAndPurposeAndConsumedAtIsNull(
String tokenHash, AccountEmailToken.Purpose purpose);
void deleteByUserAndPurpose(User user, AccountEmailToken.Purpose purpose);
}
@@ -2,6 +2,7 @@ package com.example.mindflux.repository;
import com.example.mindflux.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Long> {
@@ -12,6 +13,8 @@ public interface UserRepository extends JpaRepository<User, Long> {
// Case-insensitive Suche: Login und Registrierung sollen unabhängig von Groß-/Kleinschreibung greifen
Optional<User> findByUsernameIgnoreCase(String username);
List<User> findAllByEmailIgnoreCase(String email);
// Benutzer anhand des Benutzernamens löschen
void deleteByUsername(String username);
}
}
@@ -0,0 +1,167 @@
package com.example.mindflux.service;
import com.example.mindflux.model.AccountEmailToken;
import com.example.mindflux.model.User;
import com.example.mindflux.repository.AccountEmailTokenRepository;
import com.example.mindflux.repository.UserRepository;
import jakarta.transaction.Transactional;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.List;
import java.util.Optional;
@Service
public class AccountMailService {
private static final Duration TOKEN_LIFETIME = Duration.ofHours(24);
private final AccountEmailTokenRepository tokenRepository;
private final UserRepository userRepository;
private final JavaMailSender mailSender;
private final BCryptPasswordEncoder passwordEncoder;
private final SecureRandom secureRandom = new SecureRandom();
private final String publicUrl;
private final String fromAddress;
public AccountMailService(AccountEmailTokenRepository tokenRepository,
UserRepository userRepository,
JavaMailSender mailSender,
BCryptPasswordEncoder passwordEncoder,
@Value("${mindflux.public-url}") String publicUrl,
@Value("${mindflux.mail.from}") String fromAddress) {
this.tokenRepository = tokenRepository;
this.userRepository = userRepository;
this.mailSender = mailSender;
this.passwordEncoder = passwordEncoder;
this.publicUrl = publicUrl.replaceAll("/+$", "");
this.fromAddress = fromAddress;
}
@Transactional
public void sendVerificationFor(User user) {
if (user == null || user.isEmailVerified()) {
return;
}
String token = issueToken(user, AccountEmailToken.Purpose.EMAIL_VERIFICATION);
send(user.getEmail(), "Bestätige deinen Mindflux-Account",
"Hallo " + user.getUsername() + ",\n\n"
+ "bitte bestätige deine E-Mail-Adresse, um deinen Account freizuschalten:\n"
+ publicUrl + "/verify-email?token=" + token + "\n\n"
+ "Der Link ist 24 Stunden gültig und kann nur einmal verwendet werden.\n\n"
+ "Falls du dich nicht registriert hast, kannst du diese E-Mail ignorieren.");
}
@Transactional
public void resendVerification(String email) {
uniqueUserForEmail(email).filter(user -> !user.isEmailVerified()).ifPresent(this::sendVerificationFor);
}
@Transactional
public void requestPasswordReset(String email) {
uniqueUserForEmail(email).filter(User::isEmailVerified).ifPresent(user -> {
String token = issueToken(user, AccountEmailToken.Purpose.PASSWORD_RESET);
send(user.getEmail(), "Passwort für deinen Mindflux-Account zurücksetzen",
"Hallo " + user.getUsername() + ",\n\n"
+ "über diesen Link kannst du ein neues Passwort vergeben:\n"
+ publicUrl + "/reset-password?token=" + token + "\n\n"
+ "Der Link ist 24 Stunden gültig und kann nur einmal verwendet werden.\n\n"
+ "Falls du diese Anfrage nicht gestellt hast, kannst du diese E-Mail ignorieren.");
});
}
@Transactional
public boolean verifyEmail(String rawToken) {
Optional<AccountEmailToken> token = validToken(rawToken, AccountEmailToken.Purpose.EMAIL_VERIFICATION);
if (token.isEmpty()) {
return false;
}
AccountEmailToken value = token.get();
User user = value.getUser();
user.setEmailVerified(true);
value.setConsumedAt(Instant.now());
userRepository.save(user);
tokenRepository.save(value);
return true;
}
@Transactional
public boolean resetPassword(String rawToken, String password, String confirmPassword) {
if (password == null || password.length() < 8 || !password.equals(confirmPassword)) {
return false;
}
Optional<AccountEmailToken> token = validToken(rawToken, AccountEmailToken.Purpose.PASSWORD_RESET);
if (token.isEmpty()) {
return false;
}
AccountEmailToken value = token.get();
User user = value.getUser();
user.setPassword(passwordEncoder.encode(password));
value.setConsumedAt(Instant.now());
userRepository.save(user);
tokenRepository.save(value);
return true;
}
public boolean isResetTokenValid(String rawToken) {
return validToken(rawToken, AccountEmailToken.Purpose.PASSWORD_RESET).isPresent();
}
private Optional<User> uniqueUserForEmail(String email) {
if (email == null || email.isBlank()) {
return Optional.empty();
}
List<User> users = userRepository.findAllByEmailIgnoreCase(email.trim());
return users.size() == 1 ? Optional.of(users.getFirst()) : Optional.empty();
}
private String issueToken(User user, AccountEmailToken.Purpose purpose) {
tokenRepository.deleteByUserAndPurpose(user, purpose);
byte[] bytes = new byte[32];
secureRandom.nextBytes(bytes);
String rawToken = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
AccountEmailToken token = new AccountEmailToken();
token.setUser(user);
token.setPurpose(purpose);
token.setTokenHash(hash(rawToken));
token.setExpiresAt(Instant.now().plus(TOKEN_LIFETIME));
tokenRepository.save(token);
return rawToken;
}
private Optional<AccountEmailToken> validToken(String rawToken, AccountEmailToken.Purpose purpose) {
if (rawToken == null || rawToken.isBlank()) {
return Optional.empty();
}
return tokenRepository.findByTokenHashAndPurposeAndConsumedAtIsNull(hash(rawToken), purpose)
.filter(token -> token.getExpiresAt().isAfter(Instant.now()));
}
private void send(String recipient, String subject, String body) {
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(fromAddress);
message.setTo(recipient);
message.setSubject(subject);
message.setText(body);
mailSender.send(message);
}
private String hash(String value) {
try {
byte[] digest = MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8));
return java.util.HexFormat.of().formatHex(digest);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 ist nicht verfügbar", e);
}
}
}
@@ -6,6 +6,7 @@ public enum RegistrationResult {
USERNAME_INVALID,
USERNAME_TAKEN,
EMAIL_INVALID,
EMAIL_TAKEN,
PASSWORD_MISMATCH,
PASSWORD_TOO_SHORT
}
@@ -84,7 +84,11 @@ public class UserServiceImpl implements UserService {
if (userRepository.findByUsernameIgnoreCase(name).isPresent()) {
return RegistrationResult.USERNAME_TAKEN;
}
if (!userRepository.findAllByEmailIgnoreCase(email.trim()).isEmpty()) {
return RegistrationResult.EMAIL_TAKEN;
}
User user = new User(name, passwordEncoder.encode(password), email.trim(), "USER");
user.setEmailVerified(false);
try {
userRepository.save(user);
} catch (DataIntegrityViolationException e) {
+15
View File
@@ -41,6 +41,21 @@ spring.output.ansi.enabled=ALWAYS
# Öffentliche Basis-URL, z. B. für künftig erzeugte absolute Links.
mindflux.public-url=${MINDFLUX_PUBLIC_URL:http://localhost:8282}
mindflux.mail.from=${MAIL_USERNAME:noreply@sysnaptik.de}
# =======================
# E-Mail-Versand
# =======================
spring.mail.host=${MAIL_HOST:localhost}
spring.mail.port=${MAIL_PORT:587}
spring.mail.username=${MAIL_USERNAME:}
spring.mail.password=${MAIL_PASSWORD:}
spring.mail.properties.mail.smtp.auth=true
spring.mail.properties.mail.smtp.starttls.enable=true
spring.mail.properties.mail.smtp.starttls.required=true
spring.mail.properties.mail.smtp.connectiontimeout=5000
spring.mail.properties.mail.smtp.timeout=5000
spring.mail.properties.mail.smtp.writetimeout=5000
# =======================
# Datei-Uploads (Profilbilder)
@@ -0,0 +1,17 @@
ALTER TABLE users
ADD COLUMN email_verified BOOLEAN NOT NULL DEFAULT TRUE;
CREATE TABLE account_email_tokens (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash CHAR(64) NOT NULL UNIQUE,
purpose VARCHAR(32) NOT NULL,
expires_at TIMESTAMP NOT NULL,
consumed_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT account_email_tokens_purpose_check
CHECK (purpose IN ('EMAIL_VERIFICATION', 'PASSWORD_RESET'))
);
CREATE INDEX idx_account_email_tokens_lookup
ON account_email_tokens (token_hash, purpose);
@@ -28,6 +28,7 @@
</header>
<main>
<div th:if="${param.passwordPolicy}" class="mf-alert err">Das neue Passwort muss mindestens 8 Zeichen lang sein.</div>
<div class="mf-panel">
<table class="mf-table">
<thead>
@@ -48,8 +49,8 @@
<form th:action="@{'/admin/users/' + ${user.id} + '/reset-password'}" method="post"
style="display:flex; gap:.35rem; align-items:center;"
onsubmit="return confirm('Passwort für diesen Nutzer wirklich neu setzen?');">
<input class="mf-input" type="text" name="newPassword" placeholder="Neues Passwort"
required minlength="4" style="width:150px; padding:.45rem .6rem;">
<input class="mf-input" type="password" name="newPassword" placeholder="Neues Passwort"
required minlength="8" style="width:150px; padding:.45rem .6rem;">
<button type="submit" class="mf-btn">PW zurücksetzen</button>
</form>
<form th:action="@{'/admin/users/delete/' + ${user.id}}" method="post" style="display:inline;"
@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="de" data-theme="dark" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Passwort zurücksetzen · Mindflux</title>
<link rel="stylesheet" href="/css/theme.css?v=3">
<script src="/js/theme.js?v=4"></script>
</head>
<body>
<div class="mf-theme-float"><button class="mf-theme"></button></div>
<main class="mf-container" style="max-width:34rem; padding-top:5rem">
<section class="mf-panel">
<div class="mf-eyebrow">Account-Zugang</div>
<h1>Passwort zurücksetzen</h1>
<p>Gib deine E-Mail-Adresse ein. Falls ein bestätigter Account dazugehört, senden wir dir einen Link zum Setzen eines neuen Passworts.</p>
<div th:if="${param.sent}" class="mf-alert ok">Falls ein passender Account existiert, wurde eine E-Mail versendet.</div>
<form th:action="@{/forgot-password}" method="post">
<div class="mf-field">
<label class="mf-label" for="email">E-Mail-Adresse</label>
<input id="email" class="mf-input" type="email" name="email" required autocomplete="email">
</div>
<button class="mf-btn" type="submit">Reset-Link senden</button>
</form>
<p style="margin-top:1rem"><a href="/">Zur Anmeldung</a></p>
</section>
</main>
</body>
</html>
+19 -2
View File
@@ -29,7 +29,12 @@
<div th:if="${param.error}" class="mf-alert err">Benutzername oder Passwort sind falsch.</div>
<div th:if="${param.logout}" class="mf-alert ok">Du hast dich erfolgreich abgemeldet.</div>
<div th:if="${param.registerSuccess}" class="mf-alert ok">Registrierung erfolgreich! Du kannst dich jetzt einloggen.</div>
<div th:if="${param.registerSuccess}" class="mf-alert ok">Fast geschafft: Wir haben dir einen Bestätigungslink per E-Mail gesendet.</div>
<div th:if="${param.emailVerified}" class="mf-alert ok">Deine E-Mail-Adresse wurde bestätigt. Du kannst dich jetzt anmelden.</div>
<div th:if="${param.passwordReset}" class="mf-alert ok">Dein Passwort wurde geändert. Du kannst dich jetzt anmelden.</div>
<div th:if="${param.verificationSent}" class="mf-alert ok">Falls ein unbestätigter Account zu dieser Adresse existiert, wurde ein neuer Link versendet.</div>
<div th:if="${param.verificationInvalid}" class="mf-alert err">Dieser Bestätigungslink ist ungültig oder abgelaufen.</div>
<div th:if="${param.resetInvalid}" class="mf-alert err">Dieser Passwort-Link ist ungültig oder abgelaufen.</div>
<form th:action="@{/login}" method="post">
<div class="mf-field">
@@ -43,6 +48,8 @@
<button class="neon-btn" type="submit">Login</button>
</form>
<p class="mobile-switch"><a href="/forgot-password">Passwort vergessen?</a></p>
<p class="mobile-switch">Noch keinen Account? <a data-goto="signup">Jetzt registrieren</a></p>
</div>
</div>
@@ -61,6 +68,8 @@
<div th:if="${param.passwordMismatch}" class="mf-alert err">Die Passwörter stimmen nicht überein. Bitte erneut eingeben.</div>
<div th:if="${param.passwordPolicy}" class="mf-alert err">Das Passwort muss mindestens 8 Zeichen lang sein.</div>
<div th:if="${param.emailInvalid}" class="mf-alert err">Bitte gib eine gültige E-Mail-Adresse an.</div>
<div th:if="${param.emailTaken}" class="mf-alert err">Diese E-Mail-Adresse wird bereits verwendet.</div>
<div th:if="${param.registerMailError}" class="mf-alert err">Der Account wurde angelegt, aber die Bestätigungs-E-Mail konnte nicht gesendet werden. Fordere unten einen neuen Link an.</div>
<form th:action="@{/register}" method="post">
<div class="mf-field">
@@ -83,6 +92,14 @@
<button class="neon-btn" type="submit">Account erstellen</button>
</form>
<form th:action="@{/verification/resend}" method="post" style="margin-top:1rem">
<label class="mf-label" for="resend-email">Bestätigungslink erneut senden</label>
<div style="display:flex; gap:.5rem; flex-wrap:wrap; margin-top:.35rem">
<input id="resend-email" class="mf-input" type="email" name="email" placeholder="E-Mail-Adresse" required>
<button class="mf-btn outline" type="submit">Link senden</button>
</div>
</form>
<p class="mobile-switch">Schon dabei? <a data-goto="login">Zum Login</a></p>
</div>
</div>
@@ -138,7 +155,7 @@
// Bei Registrierungs-/Passwortfehler direkt das Signup-Panel öffnen
var qp = new URLSearchParams(window.location.search);
if (qp.has('registerError') || qp.has('passwordMismatch')) {
if (qp.has('registerError') || qp.has('passwordMismatch') || qp.has('passwordPolicy') || qp.has('emailInvalid') || qp.has('emailTaken') || qp.has('registerMailError')) {
c.classList.add('active');
}
@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="de" data-theme="dark" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Neues Passwort · Mindflux</title>
<link rel="stylesheet" href="/css/theme.css?v=3">
<script src="/js/theme.js?v=4"></script>
</head>
<body>
<div class="mf-theme-float"><button class="mf-theme"></button></div>
<main class="mf-container" style="max-width:34rem; padding-top:5rem">
<section class="mf-panel">
<div class="mf-eyebrow">Account-Zugang</div>
<h1>Neues Passwort vergeben</h1>
<div th:if="${param.error == 'policy'}" class="mf-alert err">Das Passwort muss mindestens 8 Zeichen lang sein.</div>
<div th:if="${param.error == 'mismatch'}" class="mf-alert err">Die Passwörter stimmen nicht überein.</div>
<form th:action="@{/reset-password}" method="post">
<input type="hidden" name="token" th:value="${token}">
<div class="mf-field">
<label class="mf-label" for="password">Neues Passwort</label>
<input id="password" class="mf-input" type="password" name="password" required minlength="8" autocomplete="new-password">
</div>
<div class="mf-field">
<label class="mf-label" for="confirm-password">Passwort wiederholen</label>
<input id="confirm-password" class="mf-input" type="password" name="confirmPassword" required minlength="8" autocomplete="new-password">
</div>
<button class="mf-btn" type="submit">Passwort speichern</button>
</form>
</section>
</main>
</body>
</html>