bilhej/backend/src/main/java/se/bilhalsning/service/EmailChangeService.java
Joakim Mörling 3532e4d486
All checks were successful
CI / Lint, type check, unit tests, coverage (pull_request) Successful in 2m9s
CI / E2E browser tests (pull_request) Successful in 1m55s
Add account settings dropdown and verified email change flow.
Replace the header "Byt lösenord" link with an Inställningar menu for
changing email or password. Email changes are two-step: request with
password, confirmation link to the new address, then password again on
confirm so a wrong inbox cannot take over the account.

- Backend: EmailChangeService, V10 email_change_tokens, confirm API
- Frontend: ChangeEmailPage, ConfirmEmailChangePage, header dropdown
- E2E: account-settings round-trips, Mailpit verification, wrong-password guard
- Flyway: V9 restore for dev DBs, CI migration checks, V10 for email tokens

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 14:33:06 +02:00

70 lines
2.7 KiB
Java

package se.bilhalsning.service;
import java.time.Instant;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import se.bilhalsning.entity.EmailChangeToken;
import se.bilhalsning.entity.User;
import se.bilhalsning.exception.EmailChangeTokenInvalidException;
import se.bilhalsning.repository.EmailChangeTokenRepository;
@Service
@RequiredArgsConstructor
public class EmailChangeService {
private static final long TOKEN_TTL_HOURS = 24;
private final UserService userService;
private final EmailChangeTokenRepository tokenRepository;
private final EmailService emailService;
private final PasswordResetService passwordResetService;
@Value("${app.public-base-url:http://localhost:3000}")
private String publicBaseUrl;
@Value("${app.email-change.expose-token:false}")
private boolean exposeToken;
@Transactional
public Optional<String> requestChange(String currentEmail, String password, String newEmail) {
User user = userService.authenticate(currentEmail, password);
userService.validateEmailAvailableForChange(user, newEmail);
String normalizedEmail = newEmail.toLowerCase().trim();
tokenRepository.deleteUnusedByUserId(user.getId());
String rawToken = passwordResetService.generateRawToken();
EmailChangeToken entity = new EmailChangeToken();
entity.setUser(user);
entity.setNewEmail(normalizedEmail);
entity.setTokenHash(PasswordResetService.hashToken(rawToken));
entity.setExpiresAt(Instant.now().plusSeconds(TOKEN_TTL_HOURS * 3600));
tokenRepository.save(entity);
String confirmUrl = publicBaseUrl.replaceAll("/$", "")
+ "/bekrafta-epost?token="
+ rawToken;
emailService.sendEmailChangeConfirmation(normalizedEmail, confirmUrl);
return exposeToken ? Optional.of(rawToken) : Optional.empty();
}
@Transactional
public User confirmChange(String rawToken, String password) {
EmailChangeToken token = tokenRepository
.findByTokenHashAndUsedAtIsNull(PasswordResetService.hashToken(rawToken))
.filter(t -> t.getExpiresAt().isAfter(Instant.now()))
.orElseThrow(EmailChangeTokenInvalidException::new);
User user = token.getUser();
userService.authenticate(user.getEmail(), password);
User updated = userService.applyEmailChange(user, token.getNewEmail());
token.setUsedAt(Instant.now());
tokenRepository.deleteUnusedByUserId(user.getId());
tokenRepository.save(token);
return updated;
}
}