Add account settings dropdown and verified email change flow. #5
39 changed files with 1834 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 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:
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
}
|
||||
|
||||
@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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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).
|
||||
password-reset:
|
||||
expose-token: true
|
||||
email-change:
|
||||
expose-token: true
|
||||
|
|
|
|||
|
|
@ -17,3 +17,5 @@ app:
|
|||
password: ${ADMIN_PASSWORD}
|
||||
password-reset:
|
||||
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,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.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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
@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:
|
||||
password-reset:
|
||||
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()
|
||||
})
|
||||
|
||||
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,
|
||||
}) => {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
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.')
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
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>
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
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