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>
This commit is contained in:
parent
082139d266
commit
3532e4d486
40 changed files with 1848 additions and 17 deletions
|
|
@ -17,6 +17,10 @@ jobs:
|
||||||
git remote add origin https://x-access-token:${FORGEJO_TOKEN}@srvr.nu/git/jocke/bilhej.git
|
git remote add origin https://x-access-token:${FORGEJO_TOKEN}@srvr.nu/git/jocke/bilhej.git
|
||||||
git fetch --depth 1 origin ${GITHUB_SHA}
|
git fetch --depth 1 origin ${GITHUB_SHA}
|
||||||
git checkout FETCH_HEAD
|
git checkout FETCH_HEAD
|
||||||
|
git fetch --depth 1 origin master
|
||||||
|
|
||||||
|
- name: Check Flyway migrations
|
||||||
|
run: bash scripts/check-flyway-migrations.sh origin/master
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
|
|
|
||||||
|
|
@ -80,8 +80,15 @@ jacocoTestCoverageVerification {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tasks.register('flywayMigrationCheck', Exec) {
|
||||||
|
group = 'verification'
|
||||||
|
description = 'Ensure Flyway migrations are unique, immutable, and use new version numbers'
|
||||||
|
workingDir = rootProject.projectDir
|
||||||
|
commandLine 'bash', 'scripts/check-flyway-migrations.sh'
|
||||||
|
}
|
||||||
|
|
||||||
tasks.named('check').configure {
|
tasks.named('check').configure {
|
||||||
dependsOn jacocoTestCoverageVerification
|
dependsOn jacocoTestCoverageVerification, flywayMigrationCheck
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.register('hashPassword', JavaExec) {
|
tasks.register('hashPassword', JavaExec) {
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,8 @@ public class SecurityConfig {
|
||||||
"/api/auth/register",
|
"/api/auth/register",
|
||||||
"/api/auth/login",
|
"/api/auth/login",
|
||||||
"/api/auth/forgot-password",
|
"/api/auth/forgot-password",
|
||||||
"/api/auth/reset-password")
|
"/api/auth/reset-password",
|
||||||
|
"/api/auth/confirm-email-change")
|
||||||
.permitAll()
|
.permitAll()
|
||||||
.requestMatchers("/api/webhooks/**").permitAll()
|
.requestMatchers("/api/webhooks/**").permitAll()
|
||||||
.requestMatchers("/api/payment/swish-info").permitAll()
|
.requestMatchers("/api/payment/swish-info").permitAll()
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package se.bilhalsning.controller;
|
package se.bilhalsning.controller;
|
||||||
|
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
|
import java.util.Optional;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
|
@ -11,7 +12,10 @@ import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
import se.bilhalsning.dto.AuthResponse;
|
import se.bilhalsning.dto.AuthResponse;
|
||||||
|
import se.bilhalsning.dto.ChangeEmailRequest;
|
||||||
|
import se.bilhalsning.dto.ChangeEmailResponse;
|
||||||
import se.bilhalsning.dto.ChangePasswordRequest;
|
import se.bilhalsning.dto.ChangePasswordRequest;
|
||||||
|
import se.bilhalsning.dto.ConfirmEmailChangeRequest;
|
||||||
import se.bilhalsning.dto.ForgotPasswordRequest;
|
import se.bilhalsning.dto.ForgotPasswordRequest;
|
||||||
import se.bilhalsning.dto.LoginRequest;
|
import se.bilhalsning.dto.LoginRequest;
|
||||||
import se.bilhalsning.dto.ForgotPasswordResponse;
|
import se.bilhalsning.dto.ForgotPasswordResponse;
|
||||||
|
|
@ -20,6 +24,7 @@ import se.bilhalsning.dto.RegisterRequest;
|
||||||
import se.bilhalsning.dto.ResetPasswordRequest;
|
import se.bilhalsning.dto.ResetPasswordRequest;
|
||||||
import se.bilhalsning.entity.User;
|
import se.bilhalsning.entity.User;
|
||||||
import se.bilhalsning.security.JwtService;
|
import se.bilhalsning.security.JwtService;
|
||||||
|
import se.bilhalsning.service.EmailChangeService;
|
||||||
import se.bilhalsning.service.PasswordResetService;
|
import se.bilhalsning.service.PasswordResetService;
|
||||||
import se.bilhalsning.service.UserService;
|
import se.bilhalsning.service.UserService;
|
||||||
|
|
||||||
|
|
@ -30,11 +35,15 @@ public class AuthController {
|
||||||
|
|
||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
private final PasswordResetService passwordResetService;
|
private final PasswordResetService passwordResetService;
|
||||||
|
private final EmailChangeService emailChangeService;
|
||||||
private final JwtService jwtService;
|
private final JwtService jwtService;
|
||||||
|
|
||||||
private static final String FORGOT_PASSWORD_MESSAGE =
|
private static final String FORGOT_PASSWORD_MESSAGE =
|
||||||
"Om e-postadressen finns har vi skickat instruktioner för att återställa lösenordet.";
|
"Om e-postadressen finns har vi skickat instruktioner för att återställa lösenordet.";
|
||||||
|
|
||||||
|
private static final String CHANGE_EMAIL_MESSAGE =
|
||||||
|
"Vi har skickat en bekräftelselänk till din nya e-postadress.";
|
||||||
|
|
||||||
@PostMapping("/register")
|
@PostMapping("/register")
|
||||||
public ResponseEntity<AuthResponse> register(@Valid @RequestBody RegisterRequest request) {
|
public ResponseEntity<AuthResponse> register(@Valid @RequestBody RegisterRequest request) {
|
||||||
userService.createUser(request.email(), request.password());
|
userService.createUser(request.email(), request.password());
|
||||||
|
|
@ -71,4 +80,21 @@ public class AuthController {
|
||||||
principal.getUsername(), request.currentPassword(), request.newPassword());
|
principal.getUsername(), request.currentPassword(), request.newPassword());
|
||||||
return ResponseEntity.ok(new MessageResponse("Lösenordet har uppdaterats."));
|
return ResponseEntity.ok(new MessageResponse("Lösenordet har uppdaterats."));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/change-email")
|
||||||
|
public ResponseEntity<ChangeEmailResponse> changeEmail(
|
||||||
|
@Valid @RequestBody ChangeEmailRequest request,
|
||||||
|
@AuthenticationPrincipal UserDetails principal) {
|
||||||
|
Optional<String> testToken = emailChangeService.requestChange(
|
||||||
|
principal.getUsername(), request.password(), request.newEmail());
|
||||||
|
return ResponseEntity.ok(ChangeEmailResponse.of(CHANGE_EMAIL_MESSAGE, testToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/confirm-email-change")
|
||||||
|
public ResponseEntity<AuthResponse> confirmEmailChange(
|
||||||
|
@Valid @RequestBody ConfirmEmailChangeRequest request) {
|
||||||
|
User user = emailChangeService.confirmChange(request.token(), request.password());
|
||||||
|
String token = jwtService.generateToken(user.getEmail(), user.getRole());
|
||||||
|
return ResponseEntity.ok(new AuthResponse(token));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
package se.bilhalsning.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.Email;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
public record ChangeEmailRequest(
|
||||||
|
@NotBlank @Email String newEmail, @NotBlank String password) {}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
package se.bilhalsning.dto;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
|
public record ChangeEmailResponse(String message, String testToken) {
|
||||||
|
|
||||||
|
public static ChangeEmailResponse of(String message, Optional<String> testToken) {
|
||||||
|
return new ChangeEmailResponse(message, testToken.orElse(null));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
package se.bilhalsning.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
public record ConfirmEmailChangeRequest(@NotBlank String token, @NotBlank String password) {}
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
package se.bilhalsning.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.FetchType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.JoinColumn;
|
||||||
|
import jakarta.persistence.ManyToOne;
|
||||||
|
import jakarta.persistence.PrePersist;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "email_change_tokens")
|
||||||
|
public class EmailChangeToken {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "id", columnDefinition = "uuid", nullable = false, updatable = false)
|
||||||
|
private UUID id;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||||
|
@JoinColumn(name = "user_id", nullable = false)
|
||||||
|
private User user;
|
||||||
|
|
||||||
|
@Column(name = "new_email", nullable = false)
|
||||||
|
private String newEmail;
|
||||||
|
|
||||||
|
@Column(name = "token_hash", nullable = false, length = 64)
|
||||||
|
private String tokenHash;
|
||||||
|
|
||||||
|
@Column(name = "expires_at", nullable = false)
|
||||||
|
private Instant expiresAt;
|
||||||
|
|
||||||
|
@Column(name = "used_at")
|
||||||
|
private Instant usedAt;
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false)
|
||||||
|
private Instant createdAt;
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
void onCreate() {
|
||||||
|
if (this.id == null) {
|
||||||
|
this.id = UUID.randomUUID();
|
||||||
|
}
|
||||||
|
if (this.createdAt == null) {
|
||||||
|
this.createdAt = Instant.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public UUID getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(UUID id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public User getUser() {
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUser(User user) {
|
||||||
|
this.user = user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getNewEmail() {
|
||||||
|
return newEmail;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNewEmail(String newEmail) {
|
||||||
|
this.newEmail = newEmail;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTokenHash() {
|
||||||
|
return tokenHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTokenHash(String tokenHash) {
|
||||||
|
this.tokenHash = tokenHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Instant getExpiresAt() {
|
||||||
|
return expiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setExpiresAt(Instant expiresAt) {
|
||||||
|
this.expiresAt = expiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Instant getUsedAt() {
|
||||||
|
return usedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUsedAt(Instant usedAt) {
|
||||||
|
this.usedAt = usedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Instant getCreatedAt() {
|
||||||
|
return createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCreatedAt(Instant createdAt) {
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
package se.bilhalsning.exception;
|
||||||
|
|
||||||
|
public class EmailChangeTokenInvalidException extends RuntimeException {
|
||||||
|
|
||||||
|
public EmailChangeTokenInvalidException() {
|
||||||
|
super("Bekräftelselänken är ogiltig eller har gått ut.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -29,6 +29,21 @@ public class GlobalExceptionHandler {
|
||||||
.body(new ErrorResponse(ex.getMessage()));
|
.body(new ErrorResponse(ex.getMessage()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(EmailChangeTokenInvalidException.class)
|
||||||
|
public ResponseEntity<ErrorResponse> handleEmailChangeTokenInvalid(
|
||||||
|
EmailChangeTokenInvalidException ex) {
|
||||||
|
return ResponseEntity
|
||||||
|
.badRequest()
|
||||||
|
.body(new ErrorResponse(ex.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(IllegalArgumentException.class)
|
||||||
|
public ResponseEntity<ErrorResponse> handleIllegalArgument(IllegalArgumentException ex) {
|
||||||
|
return ResponseEntity
|
||||||
|
.badRequest()
|
||||||
|
.body(new ErrorResponse(ex.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
@ExceptionHandler(EmailAlreadyExistsException.class)
|
@ExceptionHandler(EmailAlreadyExistsException.class)
|
||||||
public ResponseEntity<ErrorResponse> handleEmailAlreadyExists(EmailAlreadyExistsException ex) {
|
public ResponseEntity<ErrorResponse> handleEmailAlreadyExists(EmailAlreadyExistsException ex) {
|
||||||
return ResponseEntity
|
return ResponseEntity
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
package se.bilhalsning.repository;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import se.bilhalsning.entity.EmailChangeToken;
|
||||||
|
|
||||||
|
public interface EmailChangeTokenRepository extends JpaRepository<EmailChangeToken, UUID> {
|
||||||
|
|
||||||
|
Optional<EmailChangeToken> findByTokenHashAndUsedAtIsNull(String tokenHash);
|
||||||
|
|
||||||
|
@Modifying
|
||||||
|
@Query("DELETE FROM EmailChangeToken t WHERE t.user.id = :userId AND t.usedAt IS NULL")
|
||||||
|
void deleteUnusedByUserId(@Param("userId") UUID userId);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -58,4 +58,39 @@ public class EmailService {
|
||||||
throw new IllegalStateException("Kunde inte skicka e-post just nu");
|
throw new IllegalStateException("Kunde inte skicka e-post just nu");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void sendEmailChangeConfirmation(String toEmail, String confirmUrl) {
|
||||||
|
String subject = "Bekräfta din nya e-postadress – BilHej";
|
||||||
|
String body = """
|
||||||
|
Hej,
|
||||||
|
|
||||||
|
Du har begärt att byta e-postadress för ditt BilHej-konto.
|
||||||
|
|
||||||
|
Öppna länken nedan och ange ditt lösenord för att bekräfta den nya adressen (giltig i 24 timmar):
|
||||||
|
|
||||||
|
%s
|
||||||
|
|
||||||
|
Om du inte begärde detta kan du ignorera det här meddelandet.
|
||||||
|
|
||||||
|
Vänliga hälsningar,
|
||||||
|
BilHej
|
||||||
|
""".formatted(confirmUrl);
|
||||||
|
|
||||||
|
if (mailHost == null || mailHost.isBlank() || mailSender == null) {
|
||||||
|
log.info("SMTP not configured. Email change confirmation link for {}: {}", toEmail, confirmUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SimpleMailMessage message = new SimpleMailMessage();
|
||||||
|
message.setFrom(mailFrom);
|
||||||
|
message.setTo(toEmail);
|
||||||
|
message.setSubject(subject);
|
||||||
|
message.setText(body);
|
||||||
|
try {
|
||||||
|
mailSender.send(message);
|
||||||
|
} catch (MailException ex) {
|
||||||
|
log.error("Failed to send email change confirmation to {}", toEmail, ex);
|
||||||
|
throw new IllegalStateException("Kunde inte skicka e-post just nu");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -53,4 +53,26 @@ public class UserService {
|
||||||
}
|
}
|
||||||
updatePassword(user, newPassword);
|
updatePassword(user, newPassword);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void validateEmailAvailableForChange(User user, String newEmail) {
|
||||||
|
String normalizedEmail = newEmail.toLowerCase().trim();
|
||||||
|
if (normalizedEmail.equals(user.getEmail())) {
|
||||||
|
throw new IllegalArgumentException("Ny e-postadress måste skilja sig från nuvarande");
|
||||||
|
}
|
||||||
|
if (userRepository.existsByEmail(normalizedEmail)) {
|
||||||
|
throw new EmailAlreadyExistsException(normalizedEmail);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public User applyEmailChange(User user, String newEmail) {
|
||||||
|
String normalizedEmail = newEmail.toLowerCase().trim();
|
||||||
|
if (normalizedEmail.equals(user.getEmail())) {
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
if (userRepository.existsByEmail(normalizedEmail)) {
|
||||||
|
throw new EmailAlreadyExistsException(normalizedEmail);
|
||||||
|
}
|
||||||
|
user.setEmail(normalizedEmail);
|
||||||
|
return userRepository.save(user);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,3 +33,5 @@ app:
|
||||||
# E2E only: never enable in production (see application-prod.yml).
|
# E2E only: never enable in production (see application-prod.yml).
|
||||||
password-reset:
|
password-reset:
|
||||||
expose-token: true
|
expose-token: true
|
||||||
|
email-change:
|
||||||
|
expose-token: true
|
||||||
|
|
|
||||||
|
|
@ -17,3 +17,5 @@ app:
|
||||||
password: ${ADMIN_PASSWORD}
|
password: ${ADMIN_PASSWORD}
|
||||||
password-reset:
|
password-reset:
|
||||||
expose-token: false
|
expose-token: false
|
||||||
|
email-change:
|
||||||
|
expose-token: false
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
CREATE TABLE email_change_tokens (
|
||||||
|
id UUID NOT NULL,
|
||||||
|
user_id UUID NOT NULL,
|
||||||
|
new_email VARCHAR(255) NOT NULL,
|
||||||
|
token_hash VARCHAR(64) NOT NULL,
|
||||||
|
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
|
used_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT pk_email_change_tokens PRIMARY KEY (id),
|
||||||
|
CONSTRAINT fk_email_change_tokens_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_email_change_tokens_user_id ON email_change_tokens (user_id);
|
||||||
|
CREATE INDEX idx_email_change_tokens_token_hash ON email_change_tokens (token_hash);
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
ALTER TABLE orders DROP CONSTRAINT ck_orders_status;
|
||||||
|
|
||||||
|
ALTER TABLE orders
|
||||||
|
ADD CONSTRAINT ck_orders_status CHECK (
|
||||||
|
status IN (
|
||||||
|
'pending_payment',
|
||||||
|
'paid',
|
||||||
|
'processing',
|
||||||
|
'sent',
|
||||||
|
'delivered',
|
||||||
|
'failed',
|
||||||
|
'cancelled'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
package se.bilhalsning;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class FlywayMigrationFilesTest {
|
||||||
|
|
||||||
|
private static final Pattern VERSION_PATTERN = Pattern.compile("^V(\\d+)__.+\\.sql$");
|
||||||
|
private static final Path MIGRATION_DIR = Path.of("src/main/resources/db/migration");
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldUseUniqueVersionNumbersAndValidNames() throws IOException {
|
||||||
|
assertTrue(
|
||||||
|
Files.isDirectory(MIGRATION_DIR),
|
||||||
|
() -> "Expected migration directory at " + MIGRATION_DIR.toAbsolutePath());
|
||||||
|
|
||||||
|
Set<Integer> versions = new HashSet<>();
|
||||||
|
|
||||||
|
try (Stream<Path> files = Files.list(MIGRATION_DIR)) {
|
||||||
|
for (Path file : files.filter(path -> path.getFileName().toString().endsWith(".sql")).toList()) {
|
||||||
|
String name = file.getFileName().toString();
|
||||||
|
Matcher matcher = VERSION_PATTERN.matcher(name);
|
||||||
|
assertTrue(matcher.matches(), () -> "Invalid migration filename: " + name);
|
||||||
|
|
||||||
|
int version = Integer.parseInt(matcher.group(1));
|
||||||
|
assertFalse(
|
||||||
|
versions.contains(version),
|
||||||
|
() -> "Duplicate Flyway version V" + version + " in " + MIGRATION_DIR);
|
||||||
|
versions.add(version);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assertFalse(versions.isEmpty(), "Expected at least one schema migration");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -22,6 +22,7 @@ import se.bilhalsning.exception.EmailAlreadyExistsException;
|
||||||
import se.bilhalsning.exception.InvalidCredentialsException;
|
import se.bilhalsning.exception.InvalidCredentialsException;
|
||||||
import se.bilhalsning.security.JwtService;
|
import se.bilhalsning.security.JwtService;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import se.bilhalsning.service.EmailChangeService;
|
||||||
import se.bilhalsning.service.PasswordResetService;
|
import se.bilhalsning.service.PasswordResetService;
|
||||||
import se.bilhalsning.service.UserService;
|
import se.bilhalsning.service.UserService;
|
||||||
|
|
||||||
|
|
@ -40,6 +41,9 @@ class AuthControllerTest {
|
||||||
@MockitoBean
|
@MockitoBean
|
||||||
private PasswordResetService passwordResetService;
|
private PasswordResetService passwordResetService;
|
||||||
|
|
||||||
|
@MockitoBean
|
||||||
|
private EmailChangeService emailChangeService;
|
||||||
|
|
||||||
@MockitoBean
|
@MockitoBean
|
||||||
private JwtService jwtService;
|
private JwtService jwtService;
|
||||||
|
|
||||||
|
|
@ -223,4 +227,42 @@ class AuthControllerTest {
|
||||||
"{\"currentPassword\":\"test1234\",\"newPassword\":\"newpassword123\"}"))
|
"{\"currentPassword\":\"test1234\",\"newPassword\":\"newpassword123\"}"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "user@example.com")
|
||||||
|
void shouldReturn200WhenChangeEmailRequestSucceeds() throws Exception {
|
||||||
|
when(emailChangeService.requestChange("user@example.com", "password123", "new@example.com"))
|
||||||
|
.thenReturn(Optional.of("test-token"));
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/auth/change-email")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"newEmail\":\"new@example.com\",\"password\":\"password123\"}"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.message")
|
||||||
|
.value("Vi har skickat en bekräftelselänk till din nya e-postadress."))
|
||||||
|
.andExpect(jsonPath("$.testToken").value("test-token"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReturn200AndNewTokenWhenConfirmEmailChangeSucceeds() throws Exception {
|
||||||
|
User user = new User();
|
||||||
|
user.setEmail("new@example.com");
|
||||||
|
user.setRole("user");
|
||||||
|
when(emailChangeService.confirmChange("confirm-token", "password123")).thenReturn(user);
|
||||||
|
when(jwtService.generateToken("new@example.com", "user")).thenReturn("new-jwt-token");
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/auth/confirm-email-change")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"token\":\"confirm-token\",\"password\":\"password123\"}"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.token").value("new-jwt-token"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldRejectChangeEmailWithoutAuth() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/auth/change-email")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"newEmail\":\"new@example.com\",\"password\":\"password123\"}"))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
package se.bilhalsning.service;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import se.bilhalsning.exception.InvalidCredentialsException;
|
||||||
|
|
||||||
|
@SpringBootTest
|
||||||
|
@ActiveProfiles("test")
|
||||||
|
@Transactional
|
||||||
|
class AccountSettingsIntegrationTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private UserService userService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private EmailChangeService emailChangeService;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldChangePasswordAndChangeBack() {
|
||||||
|
String email = "pw-settings-" + UUID.randomUUID() + "@bilhej.se";
|
||||||
|
String originalPassword = "original1234";
|
||||||
|
String changedPassword = "changed12345";
|
||||||
|
|
||||||
|
userService.createUser(email, originalPassword);
|
||||||
|
|
||||||
|
userService.changePassword(email, originalPassword, changedPassword);
|
||||||
|
assertDoesNotThrow(() -> userService.authenticate(email, changedPassword));
|
||||||
|
assertThrows(
|
||||||
|
InvalidCredentialsException.class,
|
||||||
|
() -> userService.authenticate(email, originalPassword));
|
||||||
|
|
||||||
|
userService.changePassword(email, changedPassword, originalPassword);
|
||||||
|
assertDoesNotThrow(() -> userService.authenticate(email, originalPassword));
|
||||||
|
assertThrows(
|
||||||
|
InvalidCredentialsException.class,
|
||||||
|
() -> userService.authenticate(email, changedPassword));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldChangeEmailAfterConfirmationAndChangeBack() {
|
||||||
|
String suffix = UUID.randomUUID().toString();
|
||||||
|
String originalEmail = "email-settings-" + suffix + "@bilhej.se";
|
||||||
|
String tempEmail = "email-settings-" + suffix + "-new@bilhej.se";
|
||||||
|
String password = "password1234";
|
||||||
|
|
||||||
|
userService.createUser(originalEmail, password);
|
||||||
|
|
||||||
|
var firstToken = emailChangeService
|
||||||
|
.requestChange(originalEmail, password, tempEmail)
|
||||||
|
.orElseThrow();
|
||||||
|
emailChangeService.confirmChange(firstToken, password);
|
||||||
|
|
||||||
|
assertEquals(tempEmail, userService.findByEmail(tempEmail).orElseThrow().getEmail());
|
||||||
|
assertThrows(
|
||||||
|
InvalidCredentialsException.class,
|
||||||
|
() -> userService.authenticate(originalEmail, password));
|
||||||
|
|
||||||
|
var secondToken = emailChangeService
|
||||||
|
.requestChange(tempEmail, password, originalEmail)
|
||||||
|
.orElseThrow();
|
||||||
|
emailChangeService.confirmChange(secondToken, password);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
originalEmail, userService.findByEmail(originalEmail).orElseThrow().getEmail());
|
||||||
|
assertThrows(
|
||||||
|
InvalidCredentialsException.class, () -> userService.authenticate(tempEmail, password));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldRejectEmailChangeConfirmWhenPasswordWrong() {
|
||||||
|
String suffix = UUID.randomUUID().toString();
|
||||||
|
String originalEmail = "email-wrongpw-" + suffix + "@bilhej.se";
|
||||||
|
String tempEmail = "email-wrongpw-" + suffix + "-new@bilhej.se";
|
||||||
|
String password = "password1234";
|
||||||
|
|
||||||
|
userService.createUser(originalEmail, password);
|
||||||
|
|
||||||
|
var token = emailChangeService
|
||||||
|
.requestChange(originalEmail, password, tempEmail)
|
||||||
|
.orElseThrow();
|
||||||
|
|
||||||
|
assertThrows(
|
||||||
|
InvalidCredentialsException.class,
|
||||||
|
() -> emailChangeService.confirmChange(token, "wrongpassword"));
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
originalEmail, userService.findByEmail(originalEmail).orElseThrow().getEmail());
|
||||||
|
assertThrows(
|
||||||
|
InvalidCredentialsException.class,
|
||||||
|
() -> userService.authenticate(tempEmail, password));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,140 @@
|
||||||
|
package se.bilhalsning.service;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
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.springframework.test.util.ReflectionTestUtils;
|
||||||
|
import se.bilhalsning.entity.EmailChangeToken;
|
||||||
|
import se.bilhalsning.entity.User;
|
||||||
|
import se.bilhalsning.exception.EmailChangeTokenInvalidException;
|
||||||
|
import se.bilhalsning.exception.InvalidCredentialsException;
|
||||||
|
import se.bilhalsning.repository.EmailChangeTokenRepository;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class EmailChangeServiceTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private UserService userService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private EmailChangeTokenRepository tokenRepository;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private EmailService emailService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private PasswordResetService passwordResetService;
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
private EmailChangeService emailChangeService;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
ReflectionTestUtils.setField(emailChangeService, "publicBaseUrl", "http://localhost:3000");
|
||||||
|
ReflectionTestUtils.setField(emailChangeService, "exposeToken", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldSendConfirmationEmailWhenRequestIsValid() {
|
||||||
|
User user = new User();
|
||||||
|
user.setEmail("old@example.com");
|
||||||
|
user.setRole("user");
|
||||||
|
|
||||||
|
when(userService.authenticate("old@example.com", "password123")).thenReturn(user);
|
||||||
|
when(passwordResetService.generateRawToken()).thenReturn("raw-token");
|
||||||
|
|
||||||
|
Optional<String> testToken =
|
||||||
|
emailChangeService.requestChange("old@example.com", "password123", "new@example.com");
|
||||||
|
|
||||||
|
assertEquals(Optional.of("raw-token"), testToken);
|
||||||
|
verify(userService).validateEmailAvailableForChange(user, "new@example.com");
|
||||||
|
verify(tokenRepository).deleteUnusedByUserId(user.getId());
|
||||||
|
verify(tokenRepository).save(any(EmailChangeToken.class));
|
||||||
|
verify(emailService)
|
||||||
|
.sendEmailChangeConfirmation(
|
||||||
|
eq("new@example.com"),
|
||||||
|
eq("http://localhost:3000/bekrafta-epost?token=raw-token"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldRejectRequestWhenPasswordWrong() {
|
||||||
|
when(userService.authenticate("old@example.com", "wrong"))
|
||||||
|
.thenThrow(new InvalidCredentialsException());
|
||||||
|
|
||||||
|
assertThrows(
|
||||||
|
InvalidCredentialsException.class,
|
||||||
|
() -> emailChangeService.requestChange("old@example.com", "wrong", "new@example.com"));
|
||||||
|
|
||||||
|
verify(tokenRepository, never()).save(any(EmailChangeToken.class));
|
||||||
|
verify(emailService, never()).sendEmailChangeConfirmation(any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldConfirmEmailChangeWhenTokenIsValid() {
|
||||||
|
User user = new User();
|
||||||
|
user.setEmail("old@example.com");
|
||||||
|
user.setRole("user");
|
||||||
|
|
||||||
|
EmailChangeToken token = new EmailChangeToken();
|
||||||
|
token.setUser(user);
|
||||||
|
token.setNewEmail("new@example.com");
|
||||||
|
token.setTokenHash(PasswordResetService.hashToken("raw-token"));
|
||||||
|
token.setExpiresAt(java.time.Instant.now().plusSeconds(3600));
|
||||||
|
|
||||||
|
when(tokenRepository.findByTokenHashAndUsedAtIsNull(PasswordResetService.hashToken("raw-token")))
|
||||||
|
.thenReturn(Optional.of(token));
|
||||||
|
when(userService.authenticate("old@example.com", "password123")).thenReturn(user);
|
||||||
|
when(userService.applyEmailChange(user, "new@example.com")).thenReturn(user);
|
||||||
|
|
||||||
|
User result = emailChangeService.confirmChange("raw-token", "password123");
|
||||||
|
|
||||||
|
assertEquals(user, result);
|
||||||
|
verify(userService).authenticate("old@example.com", "password123");
|
||||||
|
verify(tokenRepository).deleteUnusedByUserId(user.getId());
|
||||||
|
verify(tokenRepository).save(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldRejectConfirmWhenPasswordWrong() {
|
||||||
|
User user = new User();
|
||||||
|
user.setEmail("old@example.com");
|
||||||
|
|
||||||
|
EmailChangeToken token = new EmailChangeToken();
|
||||||
|
token.setUser(user);
|
||||||
|
token.setNewEmail("new@example.com");
|
||||||
|
token.setTokenHash(PasswordResetService.hashToken("raw-token"));
|
||||||
|
token.setExpiresAt(java.time.Instant.now().plusSeconds(3600));
|
||||||
|
|
||||||
|
when(tokenRepository.findByTokenHashAndUsedAtIsNull(PasswordResetService.hashToken("raw-token")))
|
||||||
|
.thenReturn(Optional.of(token));
|
||||||
|
when(userService.authenticate("old@example.com", "wrong"))
|
||||||
|
.thenThrow(new InvalidCredentialsException());
|
||||||
|
|
||||||
|
assertThrows(
|
||||||
|
InvalidCredentialsException.class,
|
||||||
|
() -> emailChangeService.confirmChange("raw-token", "wrong"));
|
||||||
|
|
||||||
|
verify(userService, never()).applyEmailChange(any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldRejectConfirmWhenTokenInvalid() {
|
||||||
|
when(tokenRepository.findByTokenHashAndUsedAtIsNull(any())).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertThrows(
|
||||||
|
EmailChangeTokenInvalidException.class,
|
||||||
|
() -> emailChangeService.confirmChange("bad-token", "password123"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -203,4 +203,35 @@ class UserServiceTest {
|
||||||
|
|
||||||
verify(userRepository, never()).save(any(User.class));
|
verify(userRepository, never()).save(any(User.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldApplyEmailChangeWhenNewEmailAvailable() {
|
||||||
|
User user = new User();
|
||||||
|
user.setEmail("old@example.com");
|
||||||
|
user.setPasswordHash("hash");
|
||||||
|
user.setRole("user");
|
||||||
|
|
||||||
|
when(userRepository.existsByEmail("new@example.com")).thenReturn(false);
|
||||||
|
when(userRepository.save(user)).thenReturn(user);
|
||||||
|
|
||||||
|
User result = userService.applyEmailChange(user, "new@example.com");
|
||||||
|
|
||||||
|
assertEquals("new@example.com", result.getEmail());
|
||||||
|
verify(userRepository).save(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldRejectApplyEmailChangeWhenNewEmailTaken() {
|
||||||
|
User user = new User();
|
||||||
|
user.setEmail("old@example.com");
|
||||||
|
user.setPasswordHash("hash");
|
||||||
|
|
||||||
|
when(userRepository.existsByEmail("taken@example.com")).thenReturn(true);
|
||||||
|
|
||||||
|
assertThrows(
|
||||||
|
EmailAlreadyExistsException.class,
|
||||||
|
() -> userService.applyEmailChange(user, "taken@example.com"));
|
||||||
|
|
||||||
|
verify(userRepository, never()).save(any(User.class));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
app:
|
app:
|
||||||
password-reset:
|
password-reset:
|
||||||
expose-token: true
|
expose-token: true
|
||||||
|
email-change:
|
||||||
|
expose-token: true
|
||||||
|
|
|
||||||
182
frontend/e2e/account-settings.spec.ts
Normal file
182
frontend/e2e/account-settings.spec.ts
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
import { test, expect, type Page, type APIRequestContext } from '@playwright/test'
|
||||||
|
import {
|
||||||
|
clearMailpit,
|
||||||
|
countMessagesTo,
|
||||||
|
waitForEmailChangeToken,
|
||||||
|
} from './helpers/mailpit'
|
||||||
|
|
||||||
|
test.describe('Account settings', () => {
|
||||||
|
test('can change password and change back', async ({ page, request }) => {
|
||||||
|
const email = `pw-change-${Date.now()}@bilhej.se`
|
||||||
|
const originalPassword = 'original1234'
|
||||||
|
const changedPassword = 'changed12345'
|
||||||
|
|
||||||
|
await registerUser(request, email, originalPassword)
|
||||||
|
await loginViaUi(page, email, originalPassword)
|
||||||
|
|
||||||
|
await changePasswordViaUi(page, originalPassword, changedPassword)
|
||||||
|
await expect(page.getByText('Lösenordet har uppdaterats.')).toBeVisible()
|
||||||
|
|
||||||
|
await logoutViaHeader(page)
|
||||||
|
await expectLoginFails(page, email, originalPassword)
|
||||||
|
|
||||||
|
await loginViaUi(page, email, changedPassword)
|
||||||
|
|
||||||
|
await changePasswordViaUi(page, changedPassword, originalPassword)
|
||||||
|
await expect(page.getByText('Lösenordet har uppdaterats.')).toBeVisible()
|
||||||
|
|
||||||
|
await logoutViaHeader(page)
|
||||||
|
await expectLoginFails(page, email, changedPassword)
|
||||||
|
await loginViaUi(page, email, originalPassword)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('can change email after confirming link sent to new address', async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const suffix = Date.now()
|
||||||
|
const originalEmail = `email-change-${suffix}@bilhej.se`
|
||||||
|
const tempEmail = `email-change-${suffix}-new@bilhej.se`
|
||||||
|
const password = 'password1234'
|
||||||
|
|
||||||
|
await clearMailpit(request)
|
||||||
|
await registerUser(request, originalEmail, password)
|
||||||
|
await loginViaUi(page, originalEmail, password)
|
||||||
|
|
||||||
|
await page.goto('/andra-epost')
|
||||||
|
await changeEmailViaUi(page, tempEmail, password)
|
||||||
|
await expect(
|
||||||
|
page.getByText(
|
||||||
|
'Vi har skickat en bekräftelselänk till din nya e-postadress.',
|
||||||
|
),
|
||||||
|
).toBeVisible()
|
||||||
|
expect(await countMessagesTo(request, tempEmail)).toBe(1)
|
||||||
|
expect(await countMessagesTo(request, originalEmail)).toBe(0)
|
||||||
|
|
||||||
|
const token = await waitForEmailChangeToken(request, tempEmail, {
|
||||||
|
publicBaseUrl: 'http://frontend',
|
||||||
|
})
|
||||||
|
await confirmEmailChangeViaUi(page, token, password)
|
||||||
|
await expect(
|
||||||
|
page.getByText('Din e-postadress har uppdaterats.'),
|
||||||
|
).toBeVisible()
|
||||||
|
await expect(page.locator('header')).toContainText(tempEmail)
|
||||||
|
|
||||||
|
await clearMailpit(request)
|
||||||
|
await page.goto('/andra-epost')
|
||||||
|
await changeEmailViaUi(page, originalEmail, password)
|
||||||
|
await expect(
|
||||||
|
page.getByText(
|
||||||
|
'Vi har skickat en bekräftelselänk till din nya e-postadress.',
|
||||||
|
),
|
||||||
|
).toBeVisible()
|
||||||
|
expect(await countMessagesTo(request, originalEmail)).toBe(1)
|
||||||
|
|
||||||
|
const restoreToken = await waitForEmailChangeToken(request, originalEmail, {
|
||||||
|
publicBaseUrl: 'http://frontend',
|
||||||
|
})
|
||||||
|
await confirmEmailChangeViaUi(page, restoreToken, password)
|
||||||
|
await expect(
|
||||||
|
page.getByText('Din e-postadress har uppdaterats.'),
|
||||||
|
).toBeVisible()
|
||||||
|
await expect(page.locator('header')).toContainText(originalEmail)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not change email when confirm password is wrong', async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const suffix = Date.now()
|
||||||
|
const originalEmail = `email-wrongpw-e2e-${suffix}@bilhej.se`
|
||||||
|
const tempEmail = `email-wrongpw-e2e-${suffix}-new@bilhej.se`
|
||||||
|
const password = 'password1234'
|
||||||
|
|
||||||
|
await clearMailpit(request)
|
||||||
|
await registerUser(request, originalEmail, password)
|
||||||
|
await loginViaUi(page, originalEmail, password)
|
||||||
|
|
||||||
|
await page.goto('/andra-epost')
|
||||||
|
await changeEmailViaUi(page, tempEmail, password)
|
||||||
|
|
||||||
|
const token = await waitForEmailChangeToken(request, tempEmail, {
|
||||||
|
publicBaseUrl: 'http://frontend',
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.goto(`/bekrafta-epost?token=${token}`)
|
||||||
|
await page.locator('#password').fill('wrongpassword')
|
||||||
|
await page.getByRole('button', { name: 'Bekräfta ny e-postadress' }).click()
|
||||||
|
|
||||||
|
await expect(page.getByText('Lösenordet är felaktigt')).toBeVisible()
|
||||||
|
await expect(page.locator('header')).toContainText(originalEmail)
|
||||||
|
|
||||||
|
const login = await request.post('/api/auth/login', {
|
||||||
|
data: { email: originalEmail, password },
|
||||||
|
})
|
||||||
|
expect(login.ok()).toBeTruthy()
|
||||||
|
|
||||||
|
const loginWithNewEmail = await request.post('/api/auth/login', {
|
||||||
|
data: { email: tempEmail, password },
|
||||||
|
})
|
||||||
|
expect(loginWithNewEmail.ok()).toBeFalsy()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
async function registerUser(
|
||||||
|
request: APIRequestContext,
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
) {
|
||||||
|
const response = await request.post('/api/auth/register', {
|
||||||
|
data: { email, password },
|
||||||
|
})
|
||||||
|
expect(response.ok()).toBeTruthy()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginViaUi(page: Page, email: string, password: string) {
|
||||||
|
await page.goto('/logga-in')
|
||||||
|
await page.getByLabel('E-postadress').fill(email)
|
||||||
|
await page.getByLabel('Lösenord').fill(password)
|
||||||
|
await page.getByRole('button', { name: 'Logga in' }).click()
|
||||||
|
await expect(page).toHaveURL('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectLoginFails(page: Page, email: string, password: string) {
|
||||||
|
await page.goto('/logga-in')
|
||||||
|
await page.getByLabel('E-postadress').fill(email)
|
||||||
|
await page.getByLabel('Lösenord').fill(password)
|
||||||
|
await page.getByRole('button', { name: 'Logga in' }).click()
|
||||||
|
await expect(page.getByText('Felaktig e-post eller lösenord')).toBeVisible()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logoutViaHeader(page: Page) {
|
||||||
|
await page.locator('header').getByRole('button', { name: 'Logga ut' }).click()
|
||||||
|
await expect(page).toHaveURL('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changePasswordViaUi(
|
||||||
|
page: Page,
|
||||||
|
currentPassword: string,
|
||||||
|
newPassword: string,
|
||||||
|
) {
|
||||||
|
await page.goto('/andra-losenord')
|
||||||
|
await page.locator('#current-password').fill(currentPassword)
|
||||||
|
await page.locator('#password').fill(newPassword)
|
||||||
|
await page.locator('#confirm-password').fill(newPassword)
|
||||||
|
await page.getByRole('button', { name: 'Spara nytt lösenord' }).click()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changeEmailViaUi(page: Page, newEmail: string, password: string) {
|
||||||
|
await page.locator('#new-email').fill(newEmail)
|
||||||
|
await page.locator('#password').fill(password)
|
||||||
|
await page.getByRole('button', { name: 'Spara ny e-postadress' }).click()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmEmailChangeViaUi(
|
||||||
|
page: Page,
|
||||||
|
token: string,
|
||||||
|
password: string,
|
||||||
|
) {
|
||||||
|
await page.goto(`/bekrafta-epost?token=${token}`)
|
||||||
|
await page.locator('#password').fill(password)
|
||||||
|
await page.getByRole('button', { name: 'Bekräfta ny e-postadress' }).click()
|
||||||
|
}
|
||||||
|
|
@ -25,6 +25,22 @@ test.describe('Auth guards', () => {
|
||||||
await expect(page.getByRole('heading', { name: 'Logga in' })).toBeVisible()
|
await expect(page.getByRole('heading', { name: 'Logga in' })).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('redirects unauthenticated user from /andra-losenord to /logga-in', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto('/andra-losenord')
|
||||||
|
await expect(page).toHaveURL(/\/logga-in\?redirect=\/andra-losenord/)
|
||||||
|
await expect(page.getByRole('heading', { name: 'Logga in' })).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('redirects unauthenticated user from /andra-epost to /logga-in', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto('/andra-epost')
|
||||||
|
await expect(page).toHaveURL(/\/logga-in\?redirect=\/andra-epost/)
|
||||||
|
await expect(page.getByRole('heading', { name: 'Logga in' })).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
test('redirects authenticated user from /logga-in to home', async ({
|
test('redirects authenticated user from /logga-in to home', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|
|
||||||
|
|
@ -143,8 +143,70 @@ test.describe('Header auth state', () => {
|
||||||
header.getByRole('link', { name: 'Admin' }),
|
header.getByRole('link', { name: 'Admin' }),
|
||||||
).not.toBeVisible()
|
).not.toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('shows settings button when authenticated', async ({ page }) => {
|
||||||
|
await authenticateUser(page)
|
||||||
|
|
||||||
|
const header = page.locator('header')
|
||||||
|
await expect(
|
||||||
|
header.getByRole('button', { name: 'Inställningar' }),
|
||||||
|
).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('settings menu links to change email and password pages', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await authenticateUser(page)
|
||||||
|
|
||||||
|
const header = page.locator('header')
|
||||||
|
const settingsButton = header.getByRole('button', { name: 'Inställningar' })
|
||||||
|
await settingsButton.click()
|
||||||
|
|
||||||
|
const menu = header.getByRole('menu')
|
||||||
|
await expect(
|
||||||
|
menu.getByRole('menuitem', { name: 'Byt e-postadress' }),
|
||||||
|
).toHaveAttribute('href', '/andra-epost')
|
||||||
|
await expect(
|
||||||
|
menu.getByRole('menuitem', { name: 'Byt lösenord' }),
|
||||||
|
).toHaveAttribute('href', '/andra-losenord')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('highlights settings button on change password page', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await authenticateUser(page)
|
||||||
|
await page.goto('/andra-losenord')
|
||||||
|
|
||||||
|
const settingsButton = page
|
||||||
|
.locator('header')
|
||||||
|
.getByRole('button', { name: 'Inställningar' })
|
||||||
|
await expect(settingsButton).toHaveClass(/app-header__settings-trigger--active/)
|
||||||
|
await expect(
|
||||||
|
page.getByRole('heading', { name: 'Byt lösenord' }),
|
||||||
|
).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('highlights settings button on change email page', async ({ page }) => {
|
||||||
|
await authenticateUser(page)
|
||||||
|
await page.goto('/andra-epost')
|
||||||
|
|
||||||
|
const settingsButton = page
|
||||||
|
.locator('header')
|
||||||
|
.getByRole('button', { name: 'Inställningar' })
|
||||||
|
await expect(settingsButton).toHaveClass(/app-header__settings-trigger--active/)
|
||||||
|
await expect(
|
||||||
|
page.getByRole('heading', { name: 'Byt e-postadress' }),
|
||||||
|
).toBeVisible()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
async function authenticateUser(page: import('@playwright/test').Page) {
|
||||||
|
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user' })
|
||||||
|
await page.goto('/')
|
||||||
|
await page.evaluate((token) => localStorage.setItem('auth_token', token), jwt)
|
||||||
|
await page.goto('/')
|
||||||
|
}
|
||||||
|
|
||||||
function makeJwt(payload: Record<string, unknown>): string {
|
function makeJwt(payload: Record<string, unknown>): string {
|
||||||
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
|
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
|
||||||
const body = btoa(JSON.stringify(payload))
|
const body = btoa(JSON.stringify(payload))
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,65 @@ function extractResetToken(body: string, publicBaseUrl?: string): string | null
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function waitForEmailChangeToken(
|
||||||
|
request: APIRequestContext,
|
||||||
|
recipientEmail: string,
|
||||||
|
options: { timeoutMs?: number; publicBaseUrl?: string } = {},
|
||||||
|
): Promise<string> {
|
||||||
|
const timeoutMs = options.timeoutMs ?? 20_000
|
||||||
|
const deadline = Date.now() + timeoutMs
|
||||||
|
const normalizedRecipient = recipientEmail.toLowerCase().trim()
|
||||||
|
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const listResponse = await request.get(`${mailpitApiBase}/api/v1/messages`)
|
||||||
|
if (!listResponse.ok()) {
|
||||||
|
await sleep(500)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = (await listResponse.json()) as MailpitMessagesResponse
|
||||||
|
for (const summary of list.messages ?? []) {
|
||||||
|
const matchesRecipient = summary.To?.some(
|
||||||
|
(to) => to.Address.toLowerCase() === normalizedRecipient,
|
||||||
|
)
|
||||||
|
if (!matchesRecipient) continue
|
||||||
|
|
||||||
|
const detailResponse = await request.get(
|
||||||
|
`${mailpitApiBase}/api/v1/message/${summary.ID}`,
|
||||||
|
)
|
||||||
|
if (!detailResponse.ok()) continue
|
||||||
|
|
||||||
|
const detail = (await detailResponse.json()) as MailpitMessageDetail
|
||||||
|
const body = detail.Text ?? detail.HTML ?? ''
|
||||||
|
const token = extractEmailChangeToken(body, options.publicBaseUrl)
|
||||||
|
if (token) return token
|
||||||
|
}
|
||||||
|
|
||||||
|
await sleep(500)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`No email change confirmation for ${recipientEmail} in Mailpit within ${timeoutMs}ms`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractEmailChangeToken(body: string, publicBaseUrl?: string): string | null {
|
||||||
|
const pathPattern = /\/bekrafta-epost\?token=([A-Za-z0-9_-]+)/
|
||||||
|
const pathMatch = body.match(pathPattern)
|
||||||
|
if (pathMatch) return pathMatch[1]
|
||||||
|
|
||||||
|
if (publicBaseUrl) {
|
||||||
|
const escaped = publicBaseUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
|
const fullPattern = new RegExp(
|
||||||
|
`${escaped}/bekrafta-epost\\?token=([A-Za-z0-9_-]+)`,
|
||||||
|
)
|
||||||
|
const fullMatch = body.match(fullPattern)
|
||||||
|
if (fullMatch) return fullMatch[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
function sleep(ms: number): Promise<void> {
|
function sleep(ms: number): Promise<void> {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,11 @@ function createTestRouter() {
|
||||||
name: 'change-password',
|
name: 'change-password',
|
||||||
component: { template: '<div>Change password</div>' },
|
component: { template: '<div>Change password</div>' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/andra-epost',
|
||||||
|
name: 'change-email',
|
||||||
|
component: { template: '<div>Change email</div>' },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/admin',
|
path: '/admin',
|
||||||
name: 'admin',
|
name: 'admin',
|
||||||
|
|
@ -142,7 +147,7 @@ describe('AppHeader', () => {
|
||||||
|
|
||||||
it('shows logout button', () => {
|
it('shows logout button', () => {
|
||||||
const { wrapper } = mountAuthenticated()
|
const { wrapper } = mountAuthenticated()
|
||||||
const logoutButton = wrapper.find('button')
|
const logoutButton = wrapper.find('.app-header__logout')
|
||||||
expect(logoutButton.exists()).toBe(true)
|
expect(logoutButton.exists()).toBe(true)
|
||||||
expect(logoutButton.text()).toBe('Logga ut')
|
expect(logoutButton.text()).toBe('Logga ut')
|
||||||
})
|
})
|
||||||
|
|
@ -171,14 +176,51 @@ describe('AppHeader', () => {
|
||||||
expect(ordersLink?.text()).toBe('Mina beställningar')
|
expect(ordersLink?.text()).toBe('Mina beställningar')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows change password link', () => {
|
it('shows settings menu with account links', async () => {
|
||||||
const { wrapper } = mountAuthenticated()
|
const { wrapper } = mountAuthenticated()
|
||||||
const links = wrapper.findAll('a')
|
expect(wrapper.text()).not.toContain('Byt lösenord')
|
||||||
const changeLink = links.find(
|
|
||||||
(a) => a.attributes('href') === '/andra-losenord',
|
await wrapper.find('.app-header__settings-trigger').trigger('click')
|
||||||
)
|
|
||||||
expect(changeLink).toBeTruthy()
|
const links = wrapper.findAll('.app-header__settings-item')
|
||||||
expect(changeLink?.text()).toBe('Byt lösenord')
|
expect(links).toHaveLength(2)
|
||||||
|
expect(links[0].attributes('href')).toBe('/andra-epost')
|
||||||
|
expect(links[0].text()).toBe('Byt e-postadress')
|
||||||
|
expect(links[1].attributes('href')).toBe('/andra-losenord')
|
||||||
|
expect(links[1].text()).toBe('Byt lösenord')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('highlights settings trigger on change password page', async () => {
|
||||||
|
const { wrapper, router } = mountAuthenticated()
|
||||||
|
await router.push('/andra-losenord')
|
||||||
|
await router.isReady()
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
expect(
|
||||||
|
wrapper.find('.app-header__settings-trigger').classes(),
|
||||||
|
).toContain('app-header__settings-trigger--active')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('highlights settings trigger on change email page', async () => {
|
||||||
|
const { wrapper, router } = mountAuthenticated()
|
||||||
|
await router.push('/andra-epost')
|
||||||
|
await router.isReady()
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
expect(
|
||||||
|
wrapper.find('.app-header__settings-trigger').classes(),
|
||||||
|
).toContain('app-header__settings-trigger--active')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not highlight settings trigger on other pages', async () => {
|
||||||
|
const { wrapper, router } = mountAuthenticated()
|
||||||
|
await router.push('/orders')
|
||||||
|
await router.isReady()
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
expect(
|
||||||
|
wrapper.find('.app-header__settings-trigger').classes(),
|
||||||
|
).not.toContain('app-header__settings-trigger--active')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not show admin link for regular user', () => {
|
it('does not show admin link for regular user', () => {
|
||||||
|
|
@ -210,7 +252,7 @@ describe('AppHeader', () => {
|
||||||
resolve()
|
resolve()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
await wrapper.find('button').trigger('click')
|
await wrapper.find('.app-header__logout').trigger('click')
|
||||||
await navigationDone
|
await navigationDone
|
||||||
|
|
||||||
expect(auth.isAuthenticated).toBe(false)
|
expect(auth.isAuthenticated).toBe(false)
|
||||||
|
|
|
||||||
51
frontend/src/__tests__/ChangeEmailPage.spec.ts
Normal file
51
frontend/src/__tests__/ChangeEmailPage.spec.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||||
|
import { setActivePinia, createPinia } from 'pinia'
|
||||||
|
import ChangeEmailPage from '@/pages/ChangeEmailPage.vue'
|
||||||
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
|
|
||||||
|
function makeJwt(payload: Record<string, unknown>): string {
|
||||||
|
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
|
||||||
|
const body = btoa(JSON.stringify(payload))
|
||||||
|
return `${header}.${body}.test-sig`
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ChangeEmailPage', () => {
|
||||||
|
it('renders current email and form fields', () => {
|
||||||
|
const pinia = createPinia()
|
||||||
|
setActivePinia(pinia)
|
||||||
|
localStorage.setItem('auth_token', makeJwt({ sub: 'test@bilhej.se', role: 'user' }))
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createMemoryHistory(),
|
||||||
|
routes: [{ path: '/andra-epost', component: ChangeEmailPage }],
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapper = mount(ChangeEmailPage, {
|
||||||
|
global: { plugins: [router, pinia] },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Byt e-postadress')
|
||||||
|
expect(wrapper.text()).toContain('test@bilhej.se')
|
||||||
|
expect(wrapper.find('#new-email').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('#password').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows auth email from store', () => {
|
||||||
|
const pinia = createPinia()
|
||||||
|
setActivePinia(pinia)
|
||||||
|
localStorage.setItem('auth_token', makeJwt({ sub: 'user@example.com', role: 'user' }))
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createMemoryHistory(),
|
||||||
|
routes: [{ path: '/andra-epost', component: ChangeEmailPage }],
|
||||||
|
})
|
||||||
|
|
||||||
|
mount(ChangeEmailPage, {
|
||||||
|
global: { plugins: [router, pinia] },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(useAuthStore().email).toBe('user@example.com')
|
||||||
|
})
|
||||||
|
})
|
||||||
50
frontend/src/__tests__/ConfirmEmailChangePage.spec.ts
Normal file
50
frontend/src/__tests__/ConfirmEmailChangePage.spec.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
|
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||||
|
import { setActivePinia, createPinia } from 'pinia'
|
||||||
|
import ConfirmEmailChangePage from '@/pages/ConfirmEmailChangePage.vue'
|
||||||
|
|
||||||
|
function createTestRouter() {
|
||||||
|
return createRouter({
|
||||||
|
history: createMemoryHistory(),
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: '/bekrafta-epost',
|
||||||
|
name: 'confirm-email-change',
|
||||||
|
component: ConfirmEmailChangePage,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mountPage(initialPath: string) {
|
||||||
|
const pinia = createPinia()
|
||||||
|
setActivePinia(pinia)
|
||||||
|
const router = createTestRouter()
|
||||||
|
await router.push(initialPath)
|
||||||
|
await router.isReady()
|
||||||
|
const wrapper = mount(ConfirmEmailChangePage, {
|
||||||
|
global: { plugins: [router, pinia] },
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
return { wrapper, router }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ConfirmEmailChangePage', () => {
|
||||||
|
it('shows password form when token is present', async () => {
|
||||||
|
const { wrapper } = await mountPage('/bekrafta-epost?token=test-token')
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Bekräfta e-postadress')
|
||||||
|
expect(wrapper.text()).toContain('Ange ditt lösenord')
|
||||||
|
expect(wrapper.find('#password').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('button[type="submit"]').text()).toBe(
|
||||||
|
'Bekräfta ny e-postadress',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows error when token is missing', async () => {
|
||||||
|
const { wrapper } = await mountPage('/bekrafta-epost')
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Bekräftelselänken saknar en giltig kod.')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -52,6 +52,13 @@ describe('Router', () => {
|
||||||
expect(router.currentRoute.value.name).toBe('change-password')
|
expect(router.currentRoute.value.name).toBe('change-password')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('resolves /andra-epost to ChangeEmailPage when authenticated', async () => {
|
||||||
|
localStorage.setItem('auth_token', makeJwt({ role: 'user' }))
|
||||||
|
await router.push('/andra-epost')
|
||||||
|
await router.isReady()
|
||||||
|
expect(router.currentRoute.value.name).toBe('change-email')
|
||||||
|
})
|
||||||
|
|
||||||
it('resolves /admin to AdminPage for admin user', async () => {
|
it('resolves /admin to AdminPage for admin user', async () => {
|
||||||
localStorage.setItem('auth_token', makeJwt({ role: 'admin' }))
|
localStorage.setItem('auth_token', makeJwt({ role: 'admin' }))
|
||||||
await router.push('/admin')
|
await router.push('/admin')
|
||||||
|
|
@ -93,6 +100,19 @@ describe('Router guards', () => {
|
||||||
expect(router.currentRoute.value.query.redirect).toBe('/andra-losenord')
|
expect(router.currentRoute.value.query.redirect).toBe('/andra-losenord')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('redirects unauthenticated user from /andra-epost to /logga-in', async () => {
|
||||||
|
await router.push('/andra-epost')
|
||||||
|
await router.isReady()
|
||||||
|
expect(router.currentRoute.value.name).toBe('login')
|
||||||
|
expect(router.currentRoute.value.query.redirect).toBe('/andra-epost')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves /bekrafta-epost to ConfirmEmailChangePage', async () => {
|
||||||
|
await router.push('/bekrafta-epost?token=abc')
|
||||||
|
await router.isReady()
|
||||||
|
expect(router.currentRoute.value.name).toBe('confirm-email-change')
|
||||||
|
})
|
||||||
|
|
||||||
it('redirects unauthenticated user from /admin to /logga-in', async () => {
|
it('redirects unauthenticated user from /admin to /logga-in', async () => {
|
||||||
await router.push('/admin')
|
await router.push('/admin')
|
||||||
await router.isReady()
|
await router.isReady()
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,11 @@ export interface MessageResponse {
|
||||||
message: string
|
message: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Optional testToken is returned only when backend expose-token is enabled (E2E). */
|
||||||
|
export interface ChangeEmailResponse extends MessageResponse {
|
||||||
|
testToken?: string
|
||||||
|
}
|
||||||
|
|
||||||
/** Optional testToken is returned only when backend expose-token is enabled (E2E). */
|
/** Optional testToken is returned only when backend expose-token is enabled (E2E). */
|
||||||
export interface ForgotPasswordResponse extends MessageResponse {
|
export interface ForgotPasswordResponse extends MessageResponse {
|
||||||
testToken?: string
|
testToken?: string
|
||||||
|
|
@ -56,3 +61,23 @@ export function changePassword(
|
||||||
body: JSON.stringify({ currentPassword, newPassword }),
|
body: JSON.stringify({ currentPassword, newPassword }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function changeEmail(
|
||||||
|
newEmail: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<ChangeEmailResponse> {
|
||||||
|
return request<ChangeEmailResponse>('/auth/change-email', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ newEmail, password }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function confirmEmailChange(
|
||||||
|
token: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<AuthResponse> {
|
||||||
|
return request<AuthResponse>('/auth/confirm-email-change', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ token, password }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,44 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { RouterLink, useRouter } from 'vue-router'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { RouterLink, useRouter, useRoute } from 'vue-router'
|
||||||
import { useAuthStore } from '@/stores/authStore'
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
const isSettingsActive = computed(
|
||||||
|
() =>
|
||||||
|
route.name === 'change-email' || route.name === 'change-password',
|
||||||
|
)
|
||||||
|
const settingsOpen = ref(false)
|
||||||
|
const settingsRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
function toggleSettings() {
|
||||||
|
settingsOpen.value = !settingsOpen.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSettings() {
|
||||||
|
settingsOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDocumentClick(event: MouseEvent) {
|
||||||
|
if (!settingsRef.value?.contains(event.target as Node)) {
|
||||||
|
settingsOpen.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleLogout() {
|
function handleLogout() {
|
||||||
auth.logout()
|
auth.logout()
|
||||||
router.push('/')
|
router.push('/')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('click', handleDocumentClick)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('click', handleDocumentClick)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -61,13 +91,59 @@ function handleLogout() {
|
||||||
<RouterLink to="/orders" class="app-header__link"
|
<RouterLink to="/orders" class="app-header__link"
|
||||||
>Mina beställningar</RouterLink
|
>Mina beställningar</RouterLink
|
||||||
>
|
>
|
||||||
<RouterLink to="/andra-losenord" class="app-header__link"
|
<div ref="settingsRef" class="app-header__settings">
|
||||||
>Byt lösenord</RouterLink
|
<button
|
||||||
>
|
type="button"
|
||||||
<span class="app-header__email">{{ auth.email }}</span>
|
class="app-header__settings-trigger"
|
||||||
|
:class="{
|
||||||
|
'app-header__settings-trigger--active': isSettingsActive,
|
||||||
|
}"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
:aria-expanded="settingsOpen"
|
||||||
|
@click.stop="toggleSettings"
|
||||||
|
>
|
||||||
|
Inställningar
|
||||||
|
<svg
|
||||||
|
class="app-header__settings-chevron"
|
||||||
|
:class="{ 'app-header__settings-chevron--open': settingsOpen }"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.94a.75.75 0 111.08 1.04l-4.24 4.5a.75.75 0 01-1.08 0l-4.24-4.5a.75.75 0 01.02-1.06z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
v-if="settingsOpen"
|
||||||
|
class="app-header__settings-menu"
|
||||||
|
role="menu"
|
||||||
|
>
|
||||||
|
<RouterLink
|
||||||
|
to="/andra-epost"
|
||||||
|
class="app-header__settings-item"
|
||||||
|
role="menuitem"
|
||||||
|
@click="closeSettings"
|
||||||
|
>
|
||||||
|
Byt e-postadress
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink
|
||||||
|
to="/andra-losenord"
|
||||||
|
class="app-header__settings-item"
|
||||||
|
role="menuitem"
|
||||||
|
@click="closeSettings"
|
||||||
|
>
|
||||||
|
Byt lösenord
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<button class="app-header__logout" @click="handleLogout">
|
<button class="app-header__logout" @click="handleLogout">
|
||||||
Logga ut
|
Logga ut
|
||||||
</button>
|
</button>
|
||||||
|
<span class="app-header__email">{{ auth.email }}</span>
|
||||||
</template>
|
</template>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -150,6 +226,73 @@ function handleLogout() {
|
||||||
padding: 0 0.5rem;
|
padding: 0 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-header__settings {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header__settings-trigger {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.4rem 0.875rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-muted);
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
color var(--transition-fast),
|
||||||
|
border-color var(--transition-fast),
|
||||||
|
background var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header__settings-trigger:hover,
|
||||||
|
.app-header__settings-trigger--active,
|
||||||
|
.app-header__settings-trigger[aria-expanded='true'] {
|
||||||
|
color: var(--color-primary-dark);
|
||||||
|
border-color: #bfdbfe;
|
||||||
|
background: var(--color-primary-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header__settings-chevron {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
transition: transform var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header__settings-chevron--open {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header__settings-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 0.5rem);
|
||||||
|
right: 0;
|
||||||
|
min-width: 12rem;
|
||||||
|
padding: 0.35rem;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header__settings-item {
|
||||||
|
display: block;
|
||||||
|
padding: 0.625rem 0.875rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-ink);
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
transition: background var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header__settings-item:hover {
|
||||||
|
background: var(--color-border-light);
|
||||||
|
}
|
||||||
|
|
||||||
.app-header__logout {
|
.app-header__logout {
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
|
|
|
||||||
166
frontend/src/pages/ChangeEmailPage.vue
Normal file
166
frontend/src/pages/ChangeEmailPage.vue
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
import { ApiError } from '@/api/client'
|
||||||
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
|
||||||
|
const newEmail = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const submitting = ref(false)
|
||||||
|
const errorMessage = ref('')
|
||||||
|
const successMessage = ref('')
|
||||||
|
|
||||||
|
const emailError = computed(() => {
|
||||||
|
if (newEmail.value.length === 0) return ''
|
||||||
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(newEmail.value)
|
||||||
|
? ''
|
||||||
|
: 'Ange en giltig e-postadress'
|
||||||
|
})
|
||||||
|
|
||||||
|
const isValid = computed(
|
||||||
|
() =>
|
||||||
|
emailError.value === '' &&
|
||||||
|
newEmail.value.length > 0 &&
|
||||||
|
password.value.length > 0 &&
|
||||||
|
newEmail.value.toLowerCase().trim() !== auth.email?.toLowerCase(),
|
||||||
|
)
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!isValid.value || submitting.value) return
|
||||||
|
|
||||||
|
submitting.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
successMessage.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
await auth.changeUserEmail(newEmail.value, password.value)
|
||||||
|
successMessage.value =
|
||||||
|
'Vi har skickat en bekräftelselänk till din nya e-postadress. Öppna länken i mejlet för att slutföra bytet.'
|
||||||
|
newEmail.value = ''
|
||||||
|
password.value = ''
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError && err.status === 401) {
|
||||||
|
errorMessage.value = 'Lösenordet är felaktigt'
|
||||||
|
} else if (err instanceof ApiError && err.status === 409) {
|
||||||
|
errorMessage.value = 'E-postadressen är redan registrerad'
|
||||||
|
} else if (err instanceof ApiError) {
|
||||||
|
errorMessage.value = err.message
|
||||||
|
} else {
|
||||||
|
errorMessage.value = 'Något gick fel. Försök igen senare.'
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page">
|
||||||
|
<div class="page__card">
|
||||||
|
<h1 class="page__title">Byt e-postadress</h1>
|
||||||
|
<p class="page__subtitle">
|
||||||
|
Nuvarande adress: <strong>{{ auth.email }}</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form
|
||||||
|
v-if="!successMessage"
|
||||||
|
class="page__form"
|
||||||
|
@submit.prevent="handleSubmit"
|
||||||
|
>
|
||||||
|
<div class="field">
|
||||||
|
<label for="new-email" class="field__label">Ny e-postadress</label>
|
||||||
|
<input
|
||||||
|
id="new-email"
|
||||||
|
v-model="newEmail"
|
||||||
|
type="email"
|
||||||
|
name="newEmail"
|
||||||
|
autocomplete="email"
|
||||||
|
class="field__input"
|
||||||
|
/>
|
||||||
|
<p v-if="emailError" class="field__hint field__hint--error">
|
||||||
|
{{ emailError }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="password" class="field__label">Lösenord</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
v-model="password"
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
class="field__input"
|
||||||
|
/>
|
||||||
|
<p class="field__hint">Bekräfta med ditt nuvarande lösenord.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="errorMessage" class="message message--error">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn--primary btn--lg page__submit"
|
||||||
|
:disabled="!isValid || submitting"
|
||||||
|
>
|
||||||
|
{{ submitting ? 'Sparar...' : 'Spara ny e-postadress' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div v-else class="message message--success">
|
||||||
|
{{ successMessage }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="page__footer-link">
|
||||||
|
<RouterLink to="/">Tillbaka till startsidan</RouterLink>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page {
|
||||||
|
max-width: 28rem;
|
||||||
|
margin: var(--space-3xl) auto 0;
|
||||||
|
padding: 0 var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page__card {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
padding: var(--space-xl);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page__title {
|
||||||
|
margin: 0 0 var(--space-sm) 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: var(--color-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page__subtitle {
|
||||||
|
margin: 0 0 var(--space-xl) 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page__form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page__footer-link {
|
||||||
|
margin-top: var(--space-lg);
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page__submit {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
148
frontend/src/pages/ConfirmEmailChangePage.vue
Normal file
148
frontend/src/pages/ConfirmEmailChangePage.vue
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRoute, useRouter, RouterLink } from 'vue-router'
|
||||||
|
import { ApiError } from '@/api/client'
|
||||||
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
|
||||||
|
const token = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const submitting = ref(false)
|
||||||
|
const errorMessage = ref('')
|
||||||
|
const successMessage = ref('')
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const value = route.query.token
|
||||||
|
token.value = typeof value === 'string' ? value : ''
|
||||||
|
if (!token.value) {
|
||||||
|
errorMessage.value = 'Bekräftelselänken saknar en giltig kod.'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!token.value || password.value.length === 0 || submitting.value) return
|
||||||
|
|
||||||
|
submitting.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
await auth.confirmUserEmailChange(token.value, password.value)
|
||||||
|
successMessage.value = 'Din e-postadress har uppdaterats.'
|
||||||
|
setTimeout(() => router.push('/'), 2000)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError && err.status === 401) {
|
||||||
|
errorMessage.value = 'Lösenordet är felaktigt'
|
||||||
|
} else if (err instanceof ApiError) {
|
||||||
|
errorMessage.value = err.message
|
||||||
|
} else {
|
||||||
|
errorMessage.value = 'Något gick fel. Begär en ny bekräftelselänk från inställningar.'
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page">
|
||||||
|
<div class="page__card">
|
||||||
|
<h1 class="page__title">Bekräfta e-postadress</h1>
|
||||||
|
|
||||||
|
<p v-if="token && !successMessage" class="page__subtitle">
|
||||||
|
Ange ditt lösenord för att slutföra bytet av e-postadress.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form
|
||||||
|
v-if="token && !successMessage"
|
||||||
|
class="page__form"
|
||||||
|
@submit.prevent="handleSubmit"
|
||||||
|
>
|
||||||
|
<div class="field">
|
||||||
|
<label for="password" class="field__label">Lösenord</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
v-model="password"
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
class="field__input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="errorMessage" class="message message--error">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn--primary btn--lg page__submit"
|
||||||
|
:disabled="password.length === 0 || submitting"
|
||||||
|
>
|
||||||
|
{{ submitting ? 'Bekräftar...' : 'Bekräfta ny e-postadress' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div v-else-if="successMessage" class="message message--success">
|
||||||
|
{{ successMessage }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="errorMessage" class="message message--error">
|
||||||
|
{{ errorMessage }}
|
||||||
|
<p class="page__footer-link">
|
||||||
|
<RouterLink to="/andra-epost">Gå till inställningar</RouterLink>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="token && !successMessage" class="page__footer-link">
|
||||||
|
<RouterLink to="/">Tillbaka till startsidan</RouterLink>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page {
|
||||||
|
max-width: 28rem;
|
||||||
|
margin: var(--space-3xl) auto 0;
|
||||||
|
padding: 0 var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page__card {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
padding: var(--space-xl);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page__title {
|
||||||
|
margin: 0 0 var(--space-sm) 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: var(--color-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page__subtitle {
|
||||||
|
margin: 0 0 var(--space-xl) 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page__form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page__footer-link {
|
||||||
|
margin-top: var(--space-lg);
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page__submit {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -8,6 +8,8 @@ import LoginPage from '@/pages/LoginPage.vue'
|
||||||
import ForgotPasswordPage from '@/pages/ForgotPasswordPage.vue'
|
import ForgotPasswordPage from '@/pages/ForgotPasswordPage.vue'
|
||||||
import ResetPasswordPage from '@/pages/ResetPasswordPage.vue'
|
import ResetPasswordPage from '@/pages/ResetPasswordPage.vue'
|
||||||
import ChangePasswordPage from '@/pages/ChangePasswordPage.vue'
|
import ChangePasswordPage from '@/pages/ChangePasswordPage.vue'
|
||||||
|
import ChangeEmailPage from '@/pages/ChangeEmailPage.vue'
|
||||||
|
import ConfirmEmailChangePage from '@/pages/ConfirmEmailChangePage.vue'
|
||||||
import OrdersPage from '@/pages/OrdersPage.vue'
|
import OrdersPage from '@/pages/OrdersPage.vue'
|
||||||
import AdminPage from '@/pages/AdminPage.vue'
|
import AdminPage from '@/pages/AdminPage.vue'
|
||||||
import PaymentRedirect from '@/pages/PaymentRedirect.vue'
|
import PaymentRedirect from '@/pages/PaymentRedirect.vue'
|
||||||
|
|
@ -40,6 +42,12 @@ const router = createRouter({
|
||||||
component: ChangePasswordPage,
|
component: ChangePasswordPage,
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/andra-epost',
|
||||||
|
name: 'change-email',
|
||||||
|
component: ChangeEmailPage,
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/admin',
|
path: '/admin',
|
||||||
name: 'admin',
|
name: 'admin',
|
||||||
|
|
@ -76,6 +84,11 @@ const router = createRouter({
|
||||||
component: ResetPasswordPage,
|
component: ResetPasswordPage,
|
||||||
meta: { guestOnly: true },
|
meta: { guestOnly: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/bekrafta-epost',
|
||||||
|
name: 'confirm-email-change',
|
||||||
|
component: ConfirmEmailChangePage,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/om',
|
path: '/om',
|
||||||
name: 'about',
|
name: 'about',
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { register, login } from '@/api/auth'
|
import { register, login, changeEmail, confirmEmailChange } from '@/api/auth'
|
||||||
import { parseJwtPayload } from '@/utils/jwt'
|
import { parseJwtPayload } from '@/utils/jwt'
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
|
|
@ -43,6 +43,22 @@ export const useAuthStore = defineStore('auth', () => {
|
||||||
setToken(response.token)
|
setToken(response.token)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function changeUserEmail(
|
||||||
|
newEmail: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
const response = await changeEmail(newEmail, password)
|
||||||
|
return response.testToken
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmUserEmailChange(
|
||||||
|
token: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const response = await confirmEmailChange(token, password)
|
||||||
|
setToken(response.token)
|
||||||
|
}
|
||||||
|
|
||||||
function logout() {
|
function logout() {
|
||||||
clearToken()
|
clearToken()
|
||||||
}
|
}
|
||||||
|
|
@ -55,6 +71,8 @@ export const useAuthStore = defineStore('auth', () => {
|
||||||
isAdmin,
|
isAdmin,
|
||||||
registerUser,
|
registerUser,
|
||||||
loginUser,
|
loginUser,
|
||||||
|
changeUserEmail,
|
||||||
|
confirmUserEmailChange,
|
||||||
logout,
|
logout,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
86
scripts/check-flyway-migrations.sh
Executable file
86
scripts/check-flyway-migrations.sh
Executable file
|
|
@ -0,0 +1,86 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Validates Flyway schema migrations under db/migration/.
|
||||||
|
# - No duplicate version numbers in the repo
|
||||||
|
# - Migrations already on the base branch are immutable
|
||||||
|
# - New migrations must use a version number greater than the highest on the base branch
|
||||||
|
#
|
||||||
|
# Usage: scripts/check-flyway-migrations.sh [base-ref]
|
||||||
|
# Default base ref: origin/master (override with FLYWAY_BASE_REF)
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
MIGRATION_DIR="backend/src/main/resources/db/migration"
|
||||||
|
BASE_REF="${1:-${FLYWAY_BASE_REF:-origin/master}}"
|
||||||
|
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
if [[ ! -d "$MIGRATION_DIR" ]]; then
|
||||||
|
echo "Missing migration directory: $MIGRATION_DIR" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
declare -A seen_versions=()
|
||||||
|
max_local=0
|
||||||
|
|
||||||
|
for file in "$MIGRATION_DIR"/V*.sql; do
|
||||||
|
[[ -e "$file" ]] || continue
|
||||||
|
name="$(basename "$file")"
|
||||||
|
if [[ ! "$name" =~ ^V([0-9]+)__.+\.sql$ ]]; then
|
||||||
|
echo "ERROR: Invalid migration filename (expected V<number>__description.sql): $name" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
version="${BASH_REMATCH[1]}"
|
||||||
|
version=$((10#$version))
|
||||||
|
if [[ -n "${seen_versions[$version]:-}" ]]; then
|
||||||
|
echo "ERROR: Duplicate Flyway version V${version}:" >&2
|
||||||
|
echo " ${seen_versions[$version]}" >&2
|
||||||
|
echo " $name" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
seen_versions[$version]="$name"
|
||||||
|
if (( version > max_local )); then
|
||||||
|
max_local=$version
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if ! git rev-parse --verify "$BASE_REF" >/dev/null 2>&1; then
|
||||||
|
echo "Flyway check: unique versions OK (max V${max_local}). Skipping base-branch rules (${BASE_REF} not found)."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
max_base=0
|
||||||
|
while IFS= read -r path; do
|
||||||
|
[[ -z "$path" ]] && continue
|
||||||
|
name="$(basename "$path")"
|
||||||
|
if [[ "$name" =~ ^V([0-9]+)__.+\.sql$ ]]; then
|
||||||
|
version="${BASH_REMATCH[1]}"
|
||||||
|
version=$((10#$version))
|
||||||
|
if (( version > max_base )); then
|
||||||
|
max_base=$version
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done < <(git ls-tree -r --name-only "$BASE_REF" -- "$MIGRATION_DIR" 2>/dev/null | grep -E '/V[0-9]+__.*\.sql$' || true)
|
||||||
|
|
||||||
|
while IFS= read -r file; do
|
||||||
|
[[ -z "$file" ]] && continue
|
||||||
|
if git cat-file -e "$BASE_REF:$file" 2>/dev/null; then
|
||||||
|
if ! git diff --quiet "$BASE_REF" -- "$file"; then
|
||||||
|
echo "ERROR: Modified existing migration (immutable after merge): $file" >&2
|
||||||
|
echo "Create a new V$((max_base + 1))__... migration instead of editing $file." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
name="$(basename "$file")"
|
||||||
|
version="${name#V}"
|
||||||
|
version="${version%%__*}"
|
||||||
|
version=$((10#$version))
|
||||||
|
if (( version <= max_base )); then
|
||||||
|
echo "ERROR: New migration $name uses V${version}, but ${BASE_REF} already has migrations up to V${max_base}." >&2
|
||||||
|
echo "Use V$((max_base + 1))__your_description.sql or rebase and renumber." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done < <(git ls-files "$MIGRATION_DIR"/V*.sql)
|
||||||
|
|
||||||
|
echo "Flyway check OK (local max V${max_local}, ${BASE_REF} max V${max_base})."
|
||||||
22
scripts/next-flyway-version.sh
Executable file
22
scripts/next-flyway-version.sh
Executable file
|
|
@ -0,0 +1,22 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Prints the next available Flyway version for db/migration/.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
MIGRATION_DIR="$ROOT/backend/src/main/resources/db/migration"
|
||||||
|
|
||||||
|
max=0
|
||||||
|
for file in "$MIGRATION_DIR"/V*.sql; do
|
||||||
|
[[ -e "$file" ]] || continue
|
||||||
|
name="$(basename "$file")"
|
||||||
|
if [[ "$name" =~ ^V([0-9]+)__ ]]; then
|
||||||
|
version="${BASH_REMATCH[1]}"
|
||||||
|
version=$((10#$version))
|
||||||
|
if (( version > max )); then
|
||||||
|
max=$version
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
next=$((max + 1))
|
||||||
|
echo "Next migration: V${next}__your_description.sql"
|
||||||
Loading…
Reference in a new issue