Merge pull request 'Add account settings dropdown and verified email change flow.' (#5) from feature/account-settings-dropdown into master
All checks were successful
CI / Lint, type check, unit tests, coverage (push) Successful in 2m13s
CI / E2E browser tests (push) Successful in 53s

Reviewed-on: https://srvr.nu/git/git/jocke/bilhej/pulls/5
This commit is contained in:
jocke 2026-05-22 12:35:46 +00:00
commit 71a3225a11
39 changed files with 1834 additions and 17 deletions

View file

@ -17,6 +17,10 @@ jobs:
git remote add origin https://x-access-token:${FORGEJO_TOKEN}@srvr.nu/git/jocke/bilhej.git
git fetch --depth 1 origin ${GITHUB_SHA}
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
with:

View file

@ -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 {
dependsOn jacocoTestCoverageVerification
dependsOn jacocoTestCoverageVerification, flywayMigrationCheck
}
tasks.register('hashPassword', JavaExec) {

View file

@ -38,7 +38,8 @@ public class SecurityConfig {
"/api/auth/register",
"/api/auth/login",
"/api/auth/forgot-password",
"/api/auth/reset-password")
"/api/auth/reset-password",
"/api/auth/confirm-email-change")
.permitAll()
.requestMatchers("/api/webhooks/**").permitAll()
.requestMatchers("/api/payment/swish-info").permitAll()

View file

@ -1,6 +1,7 @@
package se.bilhalsning.controller;
import jakarta.validation.Valid;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
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.RestController;
import se.bilhalsning.dto.AuthResponse;
import se.bilhalsning.dto.ChangeEmailRequest;
import se.bilhalsning.dto.ChangeEmailResponse;
import se.bilhalsning.dto.ChangePasswordRequest;
import se.bilhalsning.dto.ConfirmEmailChangeRequest;
import se.bilhalsning.dto.ForgotPasswordRequest;
import se.bilhalsning.dto.LoginRequest;
import se.bilhalsning.dto.ForgotPasswordResponse;
@ -20,6 +24,7 @@ import se.bilhalsning.dto.RegisterRequest;
import se.bilhalsning.dto.ResetPasswordRequest;
import se.bilhalsning.entity.User;
import se.bilhalsning.security.JwtService;
import se.bilhalsning.service.EmailChangeService;
import se.bilhalsning.service.PasswordResetService;
import se.bilhalsning.service.UserService;
@ -30,11 +35,15 @@ public class AuthController {
private final UserService userService;
private final PasswordResetService passwordResetService;
private final EmailChangeService emailChangeService;
private final JwtService jwtService;
private static final String FORGOT_PASSWORD_MESSAGE =
"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")
public ResponseEntity<AuthResponse> register(@Valid @RequestBody RegisterRequest request) {
userService.createUser(request.email(), request.password());
@ -71,4 +80,21 @@ public class AuthController {
principal.getUsername(), request.currentPassword(), request.newPassword());
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));
}
}

View file

@ -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) {}

View file

@ -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));
}
}

View file

@ -0,0 +1,5 @@
package se.bilhalsning.dto;
import jakarta.validation.constraints.NotBlank;
public record ConfirmEmailChangeRequest(@NotBlank String token, @NotBlank String password) {}

View file

@ -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;
}
}

View file

@ -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.");
}
}

View file

@ -29,6 +29,21 @@ public class GlobalExceptionHandler {
.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)
public ResponseEntity<ErrorResponse> handleEmailAlreadyExists(EmailAlreadyExistsException ex) {
return ResponseEntity

View file

@ -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);
}

View file

@ -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;
}
}

View file

@ -58,4 +58,39 @@ public class EmailService {
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");
}
}
}

View file

@ -53,4 +53,26 @@ public class UserService {
}
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);
}
}

View file

@ -33,3 +33,5 @@ app:
# E2E only: never enable in production (see application-prod.yml).
password-reset:
expose-token: true
email-change:
expose-token: true

View file

@ -17,3 +17,5 @@ app:
password: ${ADMIN_PASSWORD}
password-reset:
expose-token: false
email-change:
expose-token: false

View file

@ -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);

View file

@ -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");
}
}

View file

@ -22,6 +22,7 @@ import se.bilhalsning.exception.EmailAlreadyExistsException;
import se.bilhalsning.exception.InvalidCredentialsException;
import se.bilhalsning.security.JwtService;
import java.util.Optional;
import se.bilhalsning.service.EmailChangeService;
import se.bilhalsning.service.PasswordResetService;
import se.bilhalsning.service.UserService;
@ -40,6 +41,9 @@ class AuthControllerTest {
@MockitoBean
private PasswordResetService passwordResetService;
@MockitoBean
private EmailChangeService emailChangeService;
@MockitoBean
private JwtService jwtService;
@ -223,4 +227,42 @@ class AuthControllerTest {
"{\"currentPassword\":\"test1234\",\"newPassword\":\"newpassword123\"}"))
.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());
}
}

View file

@ -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));
}
}

View file

@ -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"));
}
}

View file

@ -203,4 +203,35 @@ class UserServiceTest {
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));
}
}

View file

@ -1,3 +1,5 @@
app:
password-reset:
expose-token: true
email-change:
expose-token: true

View 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()
}

View file

@ -25,6 +25,22 @@ test.describe('Auth guards', () => {
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 ({
page,
}) => {

View file

@ -143,8 +143,70 @@ test.describe('Header auth state', () => {
header.getByRole('link', { name: 'Admin' }),
).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 {
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
const body = btoa(JSON.stringify(payload))

View file

@ -100,6 +100,65 @@ function extractResetToken(body: string, publicBaseUrl?: string): string | 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> {
return new Promise((resolve) => setTimeout(resolve, ms))
}

View file

@ -30,6 +30,11 @@ function createTestRouter() {
name: 'change-password',
component: { template: '<div>Change password</div>' },
},
{
path: '/andra-epost',
name: 'change-email',
component: { template: '<div>Change email</div>' },
},
{
path: '/admin',
name: 'admin',
@ -142,7 +147,7 @@ describe('AppHeader', () => {
it('shows logout button', () => {
const { wrapper } = mountAuthenticated()
const logoutButton = wrapper.find('button')
const logoutButton = wrapper.find('.app-header__logout')
expect(logoutButton.exists()).toBe(true)
expect(logoutButton.text()).toBe('Logga ut')
})
@ -171,14 +176,51 @@ describe('AppHeader', () => {
expect(ordersLink?.text()).toBe('Mina beställningar')
})
it('shows change password link', () => {
it('shows settings menu with account links', async () => {
const { wrapper } = mountAuthenticated()
const links = wrapper.findAll('a')
const changeLink = links.find(
(a) => a.attributes('href') === '/andra-losenord',
)
expect(changeLink).toBeTruthy()
expect(changeLink?.text()).toBe('Byt lösenord')
expect(wrapper.text()).not.toContain('Byt lösenord')
await wrapper.find('.app-header__settings-trigger').trigger('click')
const links = wrapper.findAll('.app-header__settings-item')
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', () => {
@ -210,7 +252,7 @@ describe('AppHeader', () => {
resolve()
})
})
await wrapper.find('button').trigger('click')
await wrapper.find('.app-header__logout').trigger('click')
await navigationDone
expect(auth.isAuthenticated).toBe(false)

View 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')
})
})

View 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.')
})
})

View file

@ -64,6 +64,13 @@ describe('Router', () => {
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 () => {
localStorage.setItem('auth_token', makeJwt({ role: 'admin' }))
await router.push('/admin')
@ -105,6 +112,19 @@ describe('Router guards', () => {
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 () => {
await router.push('/admin')
await router.isReady()

View file

@ -25,6 +25,11 @@ export interface MessageResponse {
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). */
export interface ForgotPasswordResponse extends MessageResponse {
testToken?: string
@ -56,3 +61,23 @@ export function changePassword(
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 }),
})
}

View file

@ -1,14 +1,44 @@
<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'
const router = useRouter()
const route = useRoute()
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() {
auth.logout()
router.push('/')
}
onMounted(() => {
document.addEventListener('click', handleDocumentClick)
})
onUnmounted(() => {
document.removeEventListener('click', handleDocumentClick)
})
</script>
<template>
@ -61,13 +91,59 @@ function handleLogout() {
<RouterLink to="/orders" class="app-header__link"
>Mina beställningar</RouterLink
>
<RouterLink to="/andra-losenord" class="app-header__link"
>Byt lösenord</RouterLink
>
<span class="app-header__email">{{ auth.email }}</span>
<div ref="settingsRef" class="app-header__settings">
<button
type="button"
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">
Logga ut
</button>
<span class="app-header__email">{{ auth.email }}</span>
</template>
</nav>
</div>
@ -150,6 +226,73 @@ function handleLogout() {
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 {
background: none;
border: 1px solid var(--color-border);

View 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>

View 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"> 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>

View file

@ -10,6 +10,8 @@ import LoginPage from '@/pages/LoginPage.vue'
import ForgotPasswordPage from '@/pages/ForgotPasswordPage.vue'
import ResetPasswordPage from '@/pages/ResetPasswordPage.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 EditOrderPage from '@/pages/EditOrderPage.vue'
import AdminPage from '@/pages/AdminPage.vue'
@ -49,6 +51,12 @@ const router = createRouter({
component: ChangePasswordPage,
meta: { requiresAuth: true },
},
{
path: '/andra-epost',
name: 'change-email',
component: ChangeEmailPage,
meta: { requiresAuth: true },
},
{
path: '/admin',
name: 'admin',
@ -85,6 +93,11 @@ const router = createRouter({
component: ResetPasswordPage,
meta: { guestOnly: true },
},
{
path: '/bekrafta-epost',
name: 'confirm-email-change',
component: ConfirmEmailChangePage,
},
{
path: '/om-oss',
name: 'about',

View file

@ -1,6 +1,6 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { register, login } from '@/api/auth'
import { register, login, changeEmail, confirmEmailChange } from '@/api/auth'
import { parseJwtPayload } from '@/utils/jwt'
export const useAuthStore = defineStore('auth', () => {
@ -43,6 +43,22 @@ export const useAuthStore = defineStore('auth', () => {
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() {
clearToken()
}
@ -55,6 +71,8 @@ export const useAuthStore = defineStore('auth', () => {
isAdmin,
registerUser,
loginUser,
changeUserEmail,
confirmUserEmailChange,
logout,
}
})

View 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
View 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"