Add password reset, logged-in change password, and Mailpit email dev/E2E.
All checks were successful
CI / Lint, type check, unit tests, coverage (push) Successful in 2m2s
CI / E2E browser tests (push) Successful in 1m55s

Operators can fix prod admin passwords without email via Byt lösenord;
end users can use forgot-password when SMTP is configured. Local and CI
use Mailpit to capture outbound mail and verify reset links end-to-end.

- Backend: V8 password_reset_tokens, PasswordResetService, EmailService,
  POST /api/auth/forgot-password, reset-password, change-password
- Optional testToken in forgot-password response (docker profile only, for E2E)
- Frontend: ForgotPasswordPage, ResetPasswordPage, ChangePasswordPage,
  routes, login link, header Byt lösenord
- Mailpit (ghcr.io/axllent/mailpit:v1.28) in docker-compose + e2e stack
- E2E: password-reset.spec.ts + Mailpit API helper tests SMTP delivery
- Separate dev/e2e Docker image names to avoid overwriting bilhej-frontend
- Docs: README email section, production-email-checklist, .env.example
- Unit/integration tests for reset, change password, and Vitest page specs

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Joakim Mörling 2026-05-21 18:05:15 +02:00
parent 45b2449b14
commit 86fb946e33
45 changed files with 2127 additions and 3 deletions

View file

@ -26,8 +26,20 @@ STRIPE_PRICE_ID=price_...
# ---------- Swish (Phase 0) ---------- # ---------- Swish (Phase 0) ----------
SWISH_NUMBER=0701234567 SWISH_NUMBER=0701234567
# ---------- App URL (password reset links in email) ----------
APP_PUBLIC_BASE_URL=http://localhost:3000
# ---------- SMTP (local Docker uses Mailpit via docker-compose.yml) ----------
# docker compose up → view mail at http://localhost:8025
# Leave MAIL_HOST unset in .env to use compose defaults (mailpit).
# Production: use Resend/Brevo SMTP — see README "Email (password reset)"
# MAIL_HOST=smtp.resend.com
# MAIL_PORT=587
# MAIL_USERNAME=
# MAIL_PASSWORD=
# MAIL_FROM=noreply@bilhej.se
# ---------- Production admin (prod profile only) ---------- # ---------- Production admin (prod profile only) ----------
# Strong password; never use test1234. Dev seeds use test@bilhej.se instead. # Strong password; never use test1234. Dev seeds use test@bilhej.se instead.
ADMIN_EMAIL=admin@bilhej.se ADMIN_EMAIL=admin@bilhej.se
ADMIN_PASSWORD=change_me_to_a_strong_password ADMIN_PASSWORD=change_me_to_a_strong_password

View file

@ -187,6 +187,12 @@ After the address is used to mail the letter, it must be deleted. The Order
entity must NOT have an address field. The address lookup and mailing are entity must NOT have an address field. The address lookup and mailing are
external/human processes in Phase 0. external/human processes in Phase 0.
### Local email (Mailpit)
`docker compose up` includes Mailpit (`ghcr.io/axllent/mailpit:v1.28`); password-reset mail appears at http://localhost:8025. E2E verifies SMTP via Mailpit API (`frontend/e2e/helpers/mailpit.ts`). Production uses transactional SMTP (Resend/Brevo)—see README.
### Password reset test token (never in production)
`app.password-reset.expose-token` must stay **false** in prod/default; it is only enabled in `application-docker.yml` for CI E2E so Playwright can read `testToken` from the forgot-password response.
### Stripe webhook signature verification ### Stripe webhook signature verification
Always verify `stripe-signature` header using `STRIPE_WEBHOOK_SECRET`. Always verify `stripe-signature` header using `STRIPE_WEBHOOK_SECRET`.
Webhook endpoint is public (no auth). Without signature verification an Webhook endpoint is public (no auth). Without signature verification an

View file

@ -41,6 +41,7 @@ The app will be available at:
- Frontend: `http://localhost:3000` - Frontend: `http://localhost:3000`
- Backend API: `http://localhost:8080` - Backend API: `http://localhost:8080`
- PostgreSQL: `localhost:5432` - PostgreSQL: `localhost:5432`
- Mailpit (dev SMTP inbox): `http://localhost:8025`
### Architecture inside Docker Compose ### Architecture inside Docker Compose
@ -64,6 +65,11 @@ The app will be available at:
│ │ postgres (16) │ │ │ postgres (16) │
│ │ :5432 │ │ │ :5432 │
│ └──────────────────┘ │ └──────────────────┘
│ ┌──────────────────┐
│ │ mailpit │
│ │ SMTP :1025 │
│ │ UI :8025 │
│ └──────────────────┘
``` ```
**Vite proxy:** The Vite dev server proxies `/api/*` requests to the backend container. **Vite proxy:** The Vite dev server proxies `/api/*` requests to the backend container.
@ -92,6 +98,12 @@ Copy `.env.example` to `.env` and fill in:
| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret | | `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret |
| `STRIPE_PRICE_ID` | Stripe price ID for single letter | | `STRIPE_PRICE_ID` | Stripe price ID for single letter |
| `SWISH_NUMBER` | Swish number for payment instructions | | `SWISH_NUMBER` | Swish number for payment instructions |
| `APP_PUBLIC_BASE_URL` | Base URL for password-reset links (dev: `http://localhost:3000`) |
| `MAIL_HOST` | SMTP host (Docker dev uses `mailpit` automatically; leave empty to log links only) |
| `MAIL_PORT` | SMTP port (`1025` for Mailpit, `587` for most providers) |
| `MAIL_USERNAME` | SMTP username (empty for Mailpit) |
| `MAIL_PASSWORD` | SMTP password (empty for Mailpit) |
| `MAIL_FROM` | From address (e.g. `noreply@bilhej.se`) |
| `ADMIN_EMAIL` | Production admin login (e.g. `admin@bilhej.se`) | | `ADMIN_EMAIL` | Production admin login (e.g. `admin@bilhej.se`) |
| `ADMIN_PASSWORD` | Strong production admin password (not `test1234`) | | `ADMIN_PASSWORD` | Strong production admin password (not `test1234`) |
@ -169,6 +181,57 @@ WHERE email IN ('test@bilhalsning.se', 'test@bilhej.se', 'admin@bilhalsning.se')
Then deploy with `ADMIN_EMAIL` / `ADMIN_PASSWORD` set — the app creates the production Then deploy with `ADMIN_EMAIL` / `ADMIN_PASSWORD` set — the app creates the production
admin on startup. No need to insert a password hash by hand. admin on startup. No need to insert a password hash by hand.
### Email (password reset)
The app only needs to **send** mail (forgot-password). You do not need Office 365 or a mailbox on
the server unless you want human addresses like `support@bilhej.se`.
**Local Docker (Mailpit):** `docker compose up` starts [Mailpit](https://mailpit.axllent.org/)
(`ghcr.io/axllent/mailpit:v1.28`). All outbound mail is captured—nothing is sent to the internet.
If `docker compose pull` fails on Docker Hub, pull explicitly:
```bash
docker pull ghcr.io/axllent/mailpit:v1.28
```
1. Open **http://localhost:8025**
2. Use **Glömt lösenord?** on the login page (or **Byt lösenord** in the header when logged in)
3. Open the message in Mailpit and click the reset link
To disable Mailpit and log links only, remove `MAIL_HOST` from the backend service in
`docker-compose.yml` or set `MAIL_HOST=` in `.env`.
**Production (transactional provider):** Use [Resend](https://resend.com) or
[Brevo](https://www.brevo.com)—not a self-hosted mail server on the VPS.
1. Sign up and add domain **bilhej.se**
2. Add the providers **SPF** and **DKIM** DNS records at your registrar (no MX needed for send-only)
3. Create SMTP credentials in the provider dashboard
4. On the production server `.env` (or Forgejo deploy secrets):
```bash
APP_PUBLIC_BASE_URL=https://bilhej.se
MAIL_HOST=smtp.resend.com # example; use your providers host
MAIL_PORT=587
MAIL_USERNAME=resend # example
MAIL_PASSWORD=re_xxxxxxxx
MAIL_FROM=noreply@bilhej.se
```
5. Deploy via **Deploy to Production**, then test forgot-password on https://bilhej.se
See [docs/production-email-checklist.md](docs/production-email-checklist.md) for a step-by-step operator checklist.
If SMTP is not configured (`MAIL_HOST` empty), the reset link is written to the backend log:
```bash
docker logs bilhej-backend-prod 2>&1 | grep "Password reset link"
```
Optional later: real inboxes (`support@bilhej.se`) via Migadu, Fastmail, or Purelymail—separate
from app `MAIL_*` (different MX records).
To generate a bcrypt hash manually (optional): To generate a bcrypt hash manually (optional):
```bash ```bash

View file

@ -22,6 +22,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-flyway' implementation 'org.springframework.boot:spring-boot-starter-flyway'
implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-mail'
implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-webmvc' implementation 'org.springframework.boot:spring-boot-starter-webmvc'
implementation 'org.flywaydb:flyway-database-postgresql' implementation 'org.flywaydb:flyway-database-postgresql'

View file

@ -34,7 +34,12 @@ public class SecurityConfig {
.csrf(csrf -> csrf.disable()) .csrf(csrf -> csrf.disable())
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/register", "/api/auth/login").permitAll() .requestMatchers(
"/api/auth/register",
"/api/auth/login",
"/api/auth/forgot-password",
"/api/auth/reset-password")
.permitAll()
.requestMatchers("/api/webhooks/**").permitAll() .requestMatchers("/api/webhooks/**").permitAll()
.requestMatchers("/api/payment/swish-info").permitAll() .requestMatchers("/api/payment/swish-info").permitAll()
.requestMatchers("/api/vehicles/**").permitAll() .requestMatchers("/api/vehicles/**").permitAll()

View file

@ -4,15 +4,23 @@ import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import se.bilhalsning.dto.AuthResponse; import se.bilhalsning.dto.AuthResponse;
import se.bilhalsning.dto.ChangePasswordRequest;
import se.bilhalsning.dto.ForgotPasswordRequest;
import se.bilhalsning.dto.LoginRequest; import se.bilhalsning.dto.LoginRequest;
import se.bilhalsning.dto.ForgotPasswordResponse;
import se.bilhalsning.dto.MessageResponse;
import se.bilhalsning.dto.RegisterRequest; import se.bilhalsning.dto.RegisterRequest;
import se.bilhalsning.dto.ResetPasswordRequest;
import se.bilhalsning.entity.User; import se.bilhalsning.entity.User;
import se.bilhalsning.security.JwtService; import se.bilhalsning.security.JwtService;
import se.bilhalsning.service.PasswordResetService;
import se.bilhalsning.service.UserService; import se.bilhalsning.service.UserService;
@RestController @RestController
@ -21,8 +29,12 @@ import se.bilhalsning.service.UserService;
public class AuthController { public class AuthController {
private final UserService userService; private final UserService userService;
private final PasswordResetService passwordResetService;
private final JwtService jwtService; 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.";
@PostMapping("/register") @PostMapping("/register")
public ResponseEntity<AuthResponse> register(@Valid @RequestBody RegisterRequest request) { public ResponseEntity<AuthResponse> register(@Valid @RequestBody RegisterRequest request) {
userService.createUser(request.email(), request.password()); userService.createUser(request.email(), request.password());
@ -36,4 +48,27 @@ public class AuthController {
String token = jwtService.generateToken(user.getEmail(), user.getRole()); String token = jwtService.generateToken(user.getEmail(), user.getRole());
return ResponseEntity.ok(new AuthResponse(token)); return ResponseEntity.ok(new AuthResponse(token));
} }
@PostMapping("/forgot-password")
public ResponseEntity<ForgotPasswordResponse> forgotPassword(
@Valid @RequestBody ForgotPasswordRequest request) {
return ResponseEntity.ok(ForgotPasswordResponse.of(
FORGOT_PASSWORD_MESSAGE, passwordResetService.requestReset(request.email())));
}
@PostMapping("/reset-password")
public ResponseEntity<MessageResponse> resetPassword(
@Valid @RequestBody ResetPasswordRequest request) {
passwordResetService.resetPassword(request.token(), request.password());
return ResponseEntity.ok(new MessageResponse("Lösenordet har uppdaterats. Du kan nu logga in."));
}
@PostMapping("/change-password")
public ResponseEntity<MessageResponse> changePassword(
@Valid @RequestBody ChangePasswordRequest request,
@AuthenticationPrincipal UserDetails principal) {
userService.changePassword(
principal.getUsername(), request.currentPassword(), request.newPassword());
return ResponseEntity.ok(new MessageResponse("Lösenordet har uppdaterats."));
}
} }

View file

@ -0,0 +1,8 @@
package se.bilhalsning.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record ChangePasswordRequest(
@NotBlank String currentPassword,
@NotBlank @Size(min = 8) String newPassword) {}

View file

@ -0,0 +1,6 @@
package se.bilhalsning.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
public record ForgotPasswordRequest(@NotBlank @Email String email) {}

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 ForgotPasswordResponse(String message, String testToken) {
public static ForgotPasswordResponse of(String message, Optional<String> testToken) {
return new ForgotPasswordResponse(message, testToken.orElse(null));
}
}

View file

@ -0,0 +1,3 @@
package se.bilhalsning.dto;
public record MessageResponse(String message) {}

View file

@ -0,0 +1,9 @@
package se.bilhalsning.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record ResetPasswordRequest(
@NotBlank String token,
@NotBlank @Size(min = 8) String password
) {}

View file

@ -0,0 +1,95 @@
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 = "password_reset_tokens")
public class PasswordResetToken {
@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 = "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 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

@ -21,6 +21,14 @@ public class GlobalExceptionHandler {
.body(new ErrorResponse(ex.getMessage())); .body(new ErrorResponse(ex.getMessage()));
} }
@ExceptionHandler(PasswordResetTokenInvalidException.class)
public ResponseEntity<ErrorResponse> handlePasswordResetTokenInvalid(
PasswordResetTokenInvalidException ex) {
return ResponseEntity
.badRequest()
.body(new ErrorResponse(ex.getMessage()));
}
@ExceptionHandler(EmailAlreadyExistsException.class) @ExceptionHandler(EmailAlreadyExistsException.class)
public ResponseEntity<ErrorResponse> handleEmailAlreadyExists(EmailAlreadyExistsException ex) { public ResponseEntity<ErrorResponse> handleEmailAlreadyExists(EmailAlreadyExistsException ex) {
return ResponseEntity return ResponseEntity

View file

@ -0,0 +1,8 @@
package se.bilhalsning.exception;
public class PasswordResetTokenInvalidException extends RuntimeException {
public PasswordResetTokenInvalidException() {
super("Återställningslänken är ogiltig eller har gått ut");
}
}

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.PasswordResetToken;
public interface PasswordResetTokenRepository extends JpaRepository<PasswordResetToken, UUID> {
Optional<PasswordResetToken> findByTokenHashAndUsedAtIsNull(String tokenHash);
@Modifying
@Query("DELETE FROM PasswordResetToken t WHERE t.user.id = :userId AND t.usedAt IS NULL")
void deleteUnusedByUserId(@Param("userId") UUID userId);
}

View file

@ -0,0 +1,61 @@
package se.bilhalsning.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.MailException;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.stereotype.Service;
@Service
@Slf4j
public class EmailService {
private final JavaMailSender mailSender;
@Value("${spring.mail.host:}")
private String mailHost;
@Value("${app.mail.from:noreply@bilhej.se}")
private String mailFrom;
public EmailService(@Autowired(required = false) JavaMailSender mailSender) {
this.mailSender = mailSender;
}
public void sendPasswordResetEmail(String toEmail, String resetUrl) {
String subject = "Återställ ditt lösenord BilHej";
String body = """
Hej,
Du har begärt att återställa lösenordet för ditt BilHej-konto.
Öppna länken nedan för att välja ett nytt lösenord (giltig i 1 timme):
%s
Om du inte begärde detta kan du ignorera det här meddelandet.
Vänliga hälsningar,
BilHej
""".formatted(resetUrl);
if (mailHost == null || mailHost.isBlank() || mailSender == null) {
log.info("SMTP not configured. Password reset link for {}: {}", toEmail, resetUrl);
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 password reset email to {}", toEmail, ex);
throw new IllegalStateException("Kunde inte skicka e-post just nu");
}
}
}

View file

@ -0,0 +1,85 @@
package se.bilhalsning.service;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.Instant;
import java.util.Base64;
import java.util.HexFormat;
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.PasswordResetToken;
import se.bilhalsning.entity.User;
import se.bilhalsning.exception.PasswordResetTokenInvalidException;
import se.bilhalsning.repository.PasswordResetTokenRepository;
@Service
@RequiredArgsConstructor
public class PasswordResetService {
private static final int TOKEN_BYTES = 32;
private static final long TOKEN_TTL_HOURS = 1;
private final UserService userService;
private final PasswordResetTokenRepository tokenRepository;
private final EmailService emailService;
private final SecureRandom secureRandom = new SecureRandom();
@Value("${app.public-base-url:http://localhost:3000}")
private String publicBaseUrl;
@Value("${app.password-reset.expose-token:false}")
private boolean exposeToken;
@Transactional
public Optional<String> requestReset(String email) {
return userService.findByEmail(email).map(user -> {
tokenRepository.deleteUnusedByUserId(user.getId());
String rawToken = generateRawToken();
PasswordResetToken entity = new PasswordResetToken();
entity.setUser(user);
entity.setTokenHash(hashToken(rawToken));
entity.setExpiresAt(Instant.now().plusSeconds(TOKEN_TTL_HOURS * 3600));
tokenRepository.save(entity);
String resetUrl = publicBaseUrl.replaceAll("/$", "")
+ "/aterstall-losenord?token="
+ rawToken;
emailService.sendPasswordResetEmail(user.getEmail(), resetUrl);
return exposeToken ? Optional.of(rawToken) : Optional.<String>empty();
}).orElse(Optional.empty());
}
@Transactional
public void resetPassword(String rawToken, String newPassword) {
PasswordResetToken token = tokenRepository
.findByTokenHashAndUsedAtIsNull(hashToken(rawToken))
.filter(t -> t.getExpiresAt().isAfter(Instant.now()))
.orElseThrow(PasswordResetTokenInvalidException::new);
User user = token.getUser();
userService.updatePassword(user, newPassword);
token.setUsedAt(Instant.now());
tokenRepository.deleteUnusedByUserId(user.getId());
tokenRepository.save(token);
}
String generateRawToken() {
byte[] bytes = new byte[TOKEN_BYTES];
secureRandom.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
static String hashToken(String rawToken) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(rawToken.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(hash);
} catch (NoSuchAlgorithmException ex) {
throw new IllegalStateException("SHA-256 not available", ex);
}
}
}

View file

@ -40,4 +40,17 @@ public class UserService {
} }
return user; return user;
} }
public void updatePassword(User user, String newPassword) {
user.setPasswordHash(passwordEncoder.encode(newPassword));
userRepository.save(user);
}
public void changePassword(String email, String currentPassword, String newPassword) {
User user = findByEmail(email).orElseThrow(InvalidCredentialsException::new);
if (!passwordEncoder.matches(currentPassword, user.getPasswordHash())) {
throw new InvalidCredentialsException();
}
updatePassword(user, newPassword);
}
} }

View file

@ -15,9 +15,21 @@ spring:
jpa: jpa:
database-platform: org.hibernate.dialect.PostgreSQLDialect database-platform: org.hibernate.dialect.PostgreSQLDialect
mail:
properties:
mail:
smtp:
auth: false
starttls:
enable: false
app: app:
payment: payment:
swish-number: ${SWISH_NUMBER:0700000000} swish-number: ${SWISH_NUMBER:0700000000}
letter-price: 49 letter-price: 49
jwt: jwt:
secret: ${JWT_SECRET} secret: ${JWT_SECRET}
public-base-url: ${APP_PUBLIC_BASE_URL:http://frontend}
# E2E only: never enable in production (see application-prod.yml).
password-reset:
expose-token: true

View file

@ -24,7 +24,22 @@ spring:
enabled: true enabled: true
locations: classpath:db/migration,classpath:db/dev-migration locations: classpath:db/migration,classpath:db/dev-migration
mail:
host: ${MAIL_HOST:}
port: ${MAIL_PORT:587}
username: ${MAIL_USERNAME:}
password: ${MAIL_PASSWORD:}
properties:
mail:
smtp:
auth: true
starttls:
enable: true
app: app:
public-base-url: ${APP_PUBLIC_BASE_URL:http://localhost:3000}
mail:
from: ${MAIL_FROM:noreply@bilhej.se}
payment: payment:
swish-number: ${SWISH_NUMBER:0700000000} swish-number: ${SWISH_NUMBER:0700000000}
letter-price: 49 letter-price: 49

View file

@ -0,0 +1,13 @@
CREATE TABLE password_reset_tokens (
id UUID NOT NULL,
user_id UUID 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_password_reset_tokens PRIMARY KEY (id),
CONSTRAINT fk_password_reset_tokens_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE INDEX idx_password_reset_tokens_user_id ON password_reset_tokens (user_id);
CREATE INDEX idx_password_reset_tokens_token_hash ON password_reset_tokens (token_hash);

View file

@ -1,5 +1,6 @@
package se.bilhalsning.controller; package se.bilhalsning.controller;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
@ -11,6 +12,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import se.bilhalsning.dto.LoginRequest; import se.bilhalsning.dto.LoginRequest;
@ -19,6 +21,8 @@ import se.bilhalsning.entity.User;
import se.bilhalsning.exception.EmailAlreadyExistsException; import se.bilhalsning.exception.EmailAlreadyExistsException;
import se.bilhalsning.exception.InvalidCredentialsException; import se.bilhalsning.exception.InvalidCredentialsException;
import se.bilhalsning.security.JwtService; import se.bilhalsning.security.JwtService;
import java.util.Optional;
import se.bilhalsning.service.PasswordResetService;
import se.bilhalsning.service.UserService; import se.bilhalsning.service.UserService;
@SpringBootTest @SpringBootTest
@ -33,6 +37,9 @@ class AuthControllerTest {
@MockitoBean @MockitoBean
private UserService userService; private UserService userService;
@MockitoBean
private PasswordResetService passwordResetService;
@MockitoBean @MockitoBean
private JwtService jwtService; private JwtService jwtService;
@ -160,4 +167,60 @@ class AuthControllerTest {
.content(objectMapper.writeValueAsString(request))) .content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
} }
@Test
void shouldReturn200WhenForgotPasswordRequested() throws Exception {
when(passwordResetService.requestReset("user@example.com")).thenReturn(Optional.empty());
mockMvc.perform(post("/api/auth/forgot-password")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"user@example.com\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.message")
.value("Om e-postadressen finns har vi skickat instruktioner för att återställa lösenordet."))
.andExpect(jsonPath("$.testToken").doesNotExist());
verify(passwordResetService).requestReset("user@example.com");
}
@Test
void shouldIncludeTestTokenWhenServiceReturnsToken() throws Exception {
when(passwordResetService.requestReset("user@example.com"))
.thenReturn(Optional.of("e2e-reset-token"));
mockMvc.perform(post("/api/auth/forgot-password")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"user@example.com\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.testToken").value("e2e-reset-token"));
}
@Test
void shouldReturn200WhenResetPasswordSucceeds() throws Exception {
mockMvc.perform(post("/api/auth/reset-password")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"token\":\"abc\",\"password\":\"newpassword123\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.message").value("Lösenordet har uppdaterats. Du kan nu logga in."));
}
@Test
@WithMockUser(username = "admin@bilhej.se")
void shouldReturn200WhenChangePasswordSucceeds() throws Exception {
mockMvc.perform(post("/api/auth/change-password")
.contentType(MediaType.APPLICATION_JSON)
.content(
"{\"currentPassword\":\"test1234\",\"newPassword\":\"newpassword123\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.message").value("Lösenordet har uppdaterats."));
}
@Test
void shouldRejectChangePasswordWithoutAuth() throws Exception {
mockMvc.perform(post("/api/auth/change-password")
.contentType(MediaType.APPLICATION_JSON)
.content(
"{\"currentPassword\":\"test1234\",\"newPassword\":\"newpassword123\"}"))
.andExpect(status().isForbidden());
}
} }

View file

@ -0,0 +1,53 @@
package se.bilhalsning.service;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.Optional;
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;
import se.bilhalsning.exception.PasswordResetTokenInvalidException;
@SpringBootTest
@ActiveProfiles("test")
@Transactional
class PasswordResetIntegrationTest {
@Autowired
private UserService userService;
@Autowired
private PasswordResetService passwordResetService;
@Test
void shouldResetPasswordAndLoginWithNewPassword() {
String email = "reset-integration-" + UUID.randomUUID() + "@bilhej.se";
String oldPassword = "oldpassword123";
String newPassword = "newpassword1234";
userService.createUser(email, oldPassword);
Optional<String> token = passwordResetService.requestReset(email);
assertTrue(token.isPresent());
passwordResetService.resetPassword(token.get(), newPassword);
assertDoesNotThrow(() -> userService.authenticate(email, newPassword));
assertThrows(
InvalidCredentialsException.class,
() -> userService.authenticate(email, oldPassword));
}
@Test
void shouldRejectInvalidToken() {
assertThrows(
PasswordResetTokenInvalidException.class,
() -> passwordResetService.resetPassword("not-a-real-token", "newpassword1234"));
}
}

View file

@ -0,0 +1,128 @@
package se.bilhalsning.service;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
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.time.Instant;
import java.util.Optional;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import se.bilhalsning.entity.PasswordResetToken;
import se.bilhalsning.entity.User;
import se.bilhalsning.exception.PasswordResetTokenInvalidException;
import se.bilhalsning.repository.PasswordResetTokenRepository;
@ExtendWith(MockitoExtension.class)
class PasswordResetServiceTest {
@Mock
private UserService userService;
@Mock
private PasswordResetTokenRepository tokenRepository;
@Mock
private EmailService emailService;
@InjectMocks
private PasswordResetService passwordResetService;
private User user;
@BeforeEach
void setUp() {
ReflectionTestUtils.setField(passwordResetService, "publicBaseUrl", "https://bilhej.se");
ReflectionTestUtils.setField(passwordResetService, "exposeToken", false);
user = new User();
user.setId(UUID.randomUUID());
user.setEmail("admin@bilhej.se");
}
@Test
void shouldSendResetEmailWhenUserExists() {
when(userService.findByEmail("admin@bilhej.se")).thenReturn(Optional.of(user));
passwordResetService.requestReset("admin@bilhej.se");
verify(tokenRepository).deleteUnusedByUserId(user.getId());
verify(tokenRepository).save(any(PasswordResetToken.class));
ArgumentCaptor<String> urlCaptor = ArgumentCaptor.forClass(String.class);
verify(emailService).sendPasswordResetEmail(eq("admin@bilhej.se"), urlCaptor.capture());
org.junit.jupiter.api.Assertions.assertTrue(
urlCaptor.getValue().startsWith("https://bilhej.se/aterstall-losenord?token="));
}
@Test
void shouldReturnEmptyOptionalWhenExposeTokenDisabled() {
when(userService.findByEmail("admin@bilhej.se")).thenReturn(Optional.of(user));
Optional<String> result = passwordResetService.requestReset("admin@bilhej.se");
assertTrue(result.isEmpty());
}
@Test
void shouldReturnRawTokenWhenExposeTokenEnabled() {
ReflectionTestUtils.setField(passwordResetService, "exposeToken", true);
when(userService.findByEmail("admin@bilhej.se")).thenReturn(Optional.of(user));
Optional<String> result = passwordResetService.requestReset("admin@bilhej.se");
assertTrue(result.isPresent());
assertTrue(result.get().length() > 20);
}
@Test
void shouldNotSendEmailWhenUserUnknown() {
when(userService.findByEmail("unknown@bilhej.se")).thenReturn(Optional.empty());
Optional<String> result = passwordResetService.requestReset("unknown@bilhej.se");
assertTrue(result.isEmpty());
verify(tokenRepository, never()).save(any());
verify(emailService, never()).sendPasswordResetEmail(any(), any());
}
@Test
void shouldResetPasswordWhenTokenValid() {
String rawToken = passwordResetService.generateRawToken();
PasswordResetToken token = new PasswordResetToken();
token.setUser(user);
token.setExpiresAt(Instant.now().plusSeconds(3600));
when(tokenRepository.findByTokenHashAndUsedAtIsNull(PasswordResetService.hashToken(rawToken)))
.thenReturn(Optional.of(token));
passwordResetService.resetPassword(rawToken, "newpassword123");
verify(userService).updatePassword(user, "newpassword123");
verify(tokenRepository).deleteUnusedByUserId(user.getId());
}
@Test
void shouldRejectExpiredToken() {
String rawToken = passwordResetService.generateRawToken();
PasswordResetToken token = new PasswordResetToken();
token.setUser(user);
token.setExpiresAt(Instant.now().minusSeconds(60));
when(tokenRepository.findByTokenHashAndUsedAtIsNull(PasswordResetService.hashToken(rawToken)))
.thenReturn(Optional.of(token));
assertThrows(
PasswordResetTokenInvalidException.class,
() -> passwordResetService.resetPassword(rawToken, "newpassword123"));
}
}

View file

@ -170,4 +170,37 @@ class UserServiceTest {
verify(userRepository).findByEmail("user@example.com"); verify(userRepository).findByEmail("user@example.com");
} }
@Test
void shouldChangePasswordWhenCurrentPasswordMatches() {
User user = new User();
user.setEmail("admin@bilhej.se");
user.setPasswordHash("old-hash");
when(userRepository.findByEmail("admin@bilhej.se")).thenReturn(Optional.of(user));
when(passwordEncoder.matches("test1234", "old-hash")).thenReturn(true);
when(passwordEncoder.encode("newpassword123")).thenReturn("new-hash");
when(userRepository.save(user)).thenReturn(user);
userService.changePassword("admin@bilhej.se", "test1234", "newpassword123");
assertEquals("new-hash", user.getPasswordHash());
verify(userRepository).save(user);
}
@Test
void shouldRejectChangePasswordWhenCurrentPasswordWrong() {
User user = new User();
user.setEmail("admin@bilhej.se");
user.setPasswordHash("old-hash");
when(userRepository.findByEmail("admin@bilhej.se")).thenReturn(Optional.of(user));
when(passwordEncoder.matches("wrong", "old-hash")).thenReturn(false);
assertThrows(
InvalidCredentialsException.class,
() -> userService.changePassword("admin@bilhej.se", "wrong", "newpassword123"));
verify(userRepository, never()).save(any(User.class));
}
} }

View file

@ -0,0 +1,3 @@
app:
password-reset:
expose-token: true

View file

@ -20,7 +20,14 @@ services:
timeout: 5s timeout: 5s
retries: 5 retries: 5
mailpit:
image: ghcr.io/axllent/mailpit:v1.28
container_name: bilhej-mailpit-e2e
networks:
- e2e
backend: backend:
image: bilhej-backend-e2e
build: build:
dockerfile: docker/backend.e2e.Dockerfile dockerfile: docker/backend.e2e.Dockerfile
context: . context: .
@ -34,13 +41,22 @@ services:
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY} STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET} STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET}
STRIPE_PRICE_ID: ${STRIPE_PRICE_ID} STRIPE_PRICE_ID: ${STRIPE_PRICE_ID}
APP_PUBLIC_BASE_URL: http://frontend
MAIL_HOST: mailpit
MAIL_PORT: "1025"
MAIL_USERNAME: ""
MAIL_PASSWORD: ""
MAIL_FROM: noreply@bilhej.se
networks: networks:
- e2e - e2e
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
mailpit:
condition: service_started
frontend: frontend:
image: bilhej-frontend-e2e
build: build:
dockerfile: docker/frontend.e2e.Dockerfile dockerfile: docker/frontend.e2e.Dockerfile
context: . context: .
@ -51,6 +67,7 @@ services:
- backend - backend
playwright: playwright:
image: bilhej-playwright-e2e
build: build:
dockerfile: docker/playwright.e2e.Dockerfile dockerfile: docker/playwright.e2e.Dockerfile
context: . context: .
@ -58,12 +75,19 @@ services:
ipc: host ipc: host
environment: environment:
PLAYWRIGHT_BASE_URL: http://frontend PLAYWRIGHT_BASE_URL: http://frontend
MAILPIT_API_URL: http://mailpit:8025
networks: networks:
- e2e - e2e
depends_on: depends_on:
- frontend - frontend
- mailpit
command: >- command: >-
sh -c " sh -c "
echo 'Waiting for mailpit...';
for i in \$(seq 1 30); do
curl -sf http://mailpit:8025/api/v1/info > /dev/null && break;
sleep 1;
done;
echo 'Waiting for backend...'; echo 'Waiting for backend...';
for i in \$(seq 1 60); do for i in \$(seq 1 60); do
curl -s http://backend:8080/api/vehicles/ZZZ999 > /dev/null && break; curl -s http://backend:8080/api/vehicles/ZZZ999 > /dev/null && break;

View file

@ -34,6 +34,12 @@ services:
SWISH_NUMBER: ${SWISH_NUMBER} SWISH_NUMBER: ${SWISH_NUMBER}
ADMIN_EMAIL: ${ADMIN_EMAIL} ADMIN_EMAIL: ${ADMIN_EMAIL}
ADMIN_PASSWORD: ${ADMIN_PASSWORD} ADMIN_PASSWORD: ${ADMIN_PASSWORD}
APP_PUBLIC_BASE_URL: ${APP_PUBLIC_BASE_URL:-https://bilhej.se}
MAIL_HOST: ${MAIL_HOST:-}
MAIL_PORT: ${MAIL_PORT:-587}
MAIL_USERNAME: ${MAIL_USERNAME:-}
MAIL_PASSWORD: ${MAIL_PASSWORD:-}
MAIL_FROM: ${MAIL_FROM:-noreply@bilhej.se}
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy

View file

@ -16,7 +16,15 @@ services:
timeout: 5s timeout: 5s
retries: 5 retries: 5
mailpit:
image: ghcr.io/axllent/mailpit:v1.28
container_name: bilhej-mailpit
ports:
- "1025:1025"
- "8025:8025"
backend: backend:
image: bilhej-backend-dev
build: build:
dockerfile: docker/backend.Dockerfile dockerfile: docker/backend.Dockerfile
context: . context: .
@ -33,9 +41,17 @@ services:
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY} STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET} STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET}
STRIPE_PRICE_ID: ${STRIPE_PRICE_ID} STRIPE_PRICE_ID: ${STRIPE_PRICE_ID}
APP_PUBLIC_BASE_URL: ${APP_PUBLIC_BASE_URL:-http://localhost:3000}
MAIL_HOST: mailpit
MAIL_PORT: "1025"
MAIL_USERNAME: ""
MAIL_PASSWORD: ""
MAIL_FROM: ${MAIL_FROM:-noreply@bilhej.se}
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
mailpit:
condition: service_started
volumes: volumes:
- .:/app - .:/app
- backend-gradle-project:/app/.gradle - backend-gradle-project:/app/.gradle
@ -43,6 +59,7 @@ services:
- gradle-cache:/root/.gradle - gradle-cache:/root/.gradle
frontend: frontend:
image: bilhej-frontend-dev
build: build:
dockerfile: docker/frontend.Dockerfile dockerfile: docker/frontend.Dockerfile
context: . context: .

View file

@ -0,0 +1,58 @@
# Production email checklist (operator)
Complete these steps on the server / Forgejo—nothing in this file is applied automatically.
## Prerequisites
- Domain **bilhej.se** DNS managed at your registrar
- BilHej deployed via Forgejo **Deploy to Production**
## 1. Choose a transactional provider
Recommended: [Resend](https://resend.com) or [Brevo](https://www.brevo.com) (EU, free tier).
## 2. Verify the domain
In the provider dashboard, add **bilhej.se** and publish the DNS records they give you:
- **SPF** (TXT)
- **DKIM** (CNAME or TXT)
- **DMARC** (TXT, optional but recommended)
You do **not** need MX records if the app only sends mail (forgot-password).
Wait until the provider shows the domain as verified.
## 3. Create SMTP credentials
Copy from the provider:
- SMTP host (e.g. `smtp.resend.com`)
- Port (`587`)
- Username / password or API key used as password
## 4. Update production `.env`
On the server (same file used by `docker-compose.prod.yml`):
```bash
APP_PUBLIC_BASE_URL=https://bilhej.se
MAIL_HOST=<provider-smtp-host>
MAIL_PORT=587
MAIL_USERNAME=<from-provider>
MAIL_PASSWORD=<from-provider>
MAIL_FROM=noreply@bilhej.se
```
## 5. Deploy
Run **Deploy to Production** in Forgejo (do not rsync or manual compose on the server).
## 6. Smoke test
1. Open https://bilhej.se/logga-in → **Glömt lösenord?**
2. Enter an email that exists in `users`
3. Check the inbox (and spam) for the reset message
4. If nothing arrives: `docker logs bilhej-backend-prod 2>&1 | grep -i mail`
Fallback without SMTP: reset links still appear in backend logs (`Password reset link for`).

View file

@ -0,0 +1,105 @@
import type { APIRequestContext } from '@playwright/test'
const mailpitApiBase =
process.env.MAILPIT_API_URL?.replace(/\/$/, '') || 'http://localhost:8025'
interface MailpitAddress {
Name: string
Address: string
}
interface MailpitMessageSummary {
ID: string
To: MailpitAddress[]
Subject: string
}
interface MailpitMessagesResponse {
messages: MailpitMessageSummary[]
}
interface MailpitMessageDetail {
Text?: string
HTML?: string
}
export async function clearMailpit(request: APIRequestContext): Promise<void> {
await request.delete(`${mailpitApiBase}/api/v1/messages`)
}
export async function countMessagesTo(
request: APIRequestContext,
recipientEmail: string,
): Promise<number> {
const listResponse = await request.get(`${mailpitApiBase}/api/v1/messages`)
if (!listResponse.ok()) return 0
const list = (await listResponse.json()) as MailpitMessagesResponse
const normalized = recipientEmail.toLowerCase().trim()
return (list.messages ?? []).filter((msg) =>
msg.To?.some((to) => to.Address.toLowerCase() === normalized),
).length
}
export async function waitForPasswordResetToken(
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 = extractResetToken(body, options.publicBaseUrl)
if (token) return token
}
await sleep(500)
}
throw new Error(
`No password reset email for ${recipientEmail} in Mailpit within ${timeoutMs}ms`,
)
}
function extractResetToken(body: string, publicBaseUrl?: string): string | null {
const pathPattern = /\/aterstall-losenord\?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}/aterstall-losenord\\?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

@ -0,0 +1,179 @@
import { test, expect } from '@playwright/test'
import {
clearMailpit,
countMessagesTo,
waitForPasswordResetToken,
} from './helpers/mailpit'
const forgotSuccessMessage =
'Om e-postadressen finns har vi skickat instruktioner för att återställa lösenordet.'
test.describe('Password reset', () => {
test('login page links to forgot password', async ({ page }) => {
await page.goto('/logga-in')
await page.getByRole('link', { name: 'Glömt lösenord?' }).click()
await expect(page).toHaveURL('/glomt-losenord')
await expect(
page.getByRole('heading', { name: 'Glömt lösenord?' }),
).toBeVisible()
})
test('forgot password page submits and shows success', async ({ page }) => {
await page.goto('/glomt-losenord')
await page.getByLabel('E-postadress').fill('test@bilhej.se')
await page
.getByRole('button', { name: 'Skicka återställningslänk' })
.click()
await expect(page.getByText(forgotSuccessMessage)).toBeVisible()
})
test('unknown email gets same success message as known user', async ({
request,
}) => {
const known = await request.post('/api/auth/forgot-password', {
data: { email: 'test@bilhej.se' },
})
const unknown = await request.post('/api/auth/forgot-password', {
data: { email: 'nobody-reset-e2e@bilhej.se' },
})
expect(known.ok()).toBeTruthy()
expect(unknown.ok()).toBeTruthy()
const knownBody = await known.json()
const unknownBody = await unknown.json()
expect(knownBody.message).toBe(forgotSuccessMessage)
expect(unknownBody.message).toBe(forgotSuccessMessage)
expect(unknownBody.testToken).toBeUndefined()
})
test('full reset flow with isolated user', async ({ page, request }) => {
const email = `reset-e2e-${Date.now()}@bilhej.se`
const oldPassword = 'oldpass1234'
const newPassword = 'resetpass1234'
const register = await request.post('/api/auth/register', {
data: { email, password: oldPassword },
})
expect(register.ok()).toBeTruthy()
const forgot = await request.post('/api/auth/forgot-password', {
data: { email },
})
expect(forgot.ok()).toBeTruthy()
const forgotBody = await forgot.json()
expect(forgotBody.testToken).toBeTruthy()
await page.goto(`/aterstall-losenord?token=${forgotBody.testToken}`)
await page.getByLabel('Nytt lösenord').fill(newPassword)
await page.getByLabel('Bekräfta lösenord').fill(newPassword)
await page.getByRole('button', { name: 'Spara nytt lösenord' }).click()
await expect(
page.getByText('Lösenordet har uppdaterats. Du kan nu logga in.'),
).toBeVisible({ timeout: 10000 })
await page.goto('/logga-in')
await page.getByLabel('E-postadress').fill(email)
await page.getByLabel('Lösenord').fill(newPassword)
await page.getByRole('button', { name: 'Logga in' }).click()
await expect(page).toHaveURL('/')
})
test('login fails with old password after reset', async ({ request }) => {
const email = `reset-oldpw-${Date.now()}@bilhej.se`
const oldPassword = 'oldpass1234'
const newPassword = 'resetpass1234'
await request.post('/api/auth/register', {
data: { email, password: oldPassword },
})
const forgot = await request.post('/api/auth/forgot-password', {
data: { email },
})
const { testToken } = await forgot.json()
await request.post('/api/auth/reset-password', {
data: { token: testToken, password: newPassword },
})
const login = await request.post('/api/auth/login', {
data: { email, password: oldPassword },
})
expect(login.status()).toBe(401)
})
test('invalid token shows error and link to request new', async ({ page }) => {
await page.goto('/aterstall-losenord?token=invalid')
await page.getByLabel('Nytt lösenord').fill('newpassword123')
await page.getByLabel('Bekräfta lösenord').fill('newpassword123')
await page.getByRole('button', { name: 'Spara nytt lösenord' }).click()
await expect(
page.getByText('Återställningslänken är ogiltig eller har gått ut'),
).toBeVisible()
await expect(
page.getByRole('link', { name: 'Begär ny länk' }),
).toHaveAttribute('href', '/glomt-losenord')
})
test('missing token shows invalid link error', async ({ page }) => {
await page.goto('/aterstall-losenord')
await expect(
page.getByText('Återställningslänken saknar en giltig kod.'),
).toBeVisible()
await expect(
page.getByRole('link', { name: 'Begär ny länk' }),
).toHaveAttribute('href', '/glomt-losenord')
})
test('delivers reset link via Mailpit SMTP', async ({ page, request }) => {
const email = `mailpit-e2e-${Date.now()}@bilhej.se`
const oldPassword = 'oldpass1234'
const newPassword = 'mailpitpass1234'
await clearMailpit(request)
const register = await request.post('/api/auth/register', {
data: { email, password: oldPassword },
})
expect(register.ok()).toBeTruthy()
const forgot = await request.post('/api/auth/forgot-password', {
data: { email },
})
expect(forgot.ok()).toBeTruthy()
const token = await waitForPasswordResetToken(request, email, {
publicBaseUrl: 'http://frontend',
})
await page.goto(`/aterstall-losenord?token=${token}`)
await page.getByLabel('Nytt lösenord').fill(newPassword)
await page.getByLabel('Bekräfta lösenord').fill(newPassword)
await page.getByRole('button', { name: 'Spara nytt lösenord' }).click()
await expect(
page.getByText('Lösenordet har uppdaterats. Du kan nu logga in.'),
).toBeVisible({ timeout: 10000 })
const login = await request.post('/api/auth/login', {
data: { email, password: newPassword },
})
expect(login.ok()).toBeTruthy()
})
test('does not send Mailpit message for unknown email', async ({
request,
}) => {
await clearMailpit(request)
const forgot = await request.post('/api/auth/forgot-password', {
data: { email: 'nobody-mailpit-e2e@bilhej.se' },
})
expect(forgot.ok()).toBeTruthy()
await new Promise((resolve) => setTimeout(resolve, 2000))
expect(await countMessagesTo(request, 'nobody-mailpit-e2e@bilhej.se')).toBe(0)
})
})

View file

@ -25,6 +25,11 @@ function createTestRouter() {
name: 'orders', name: 'orders',
component: { template: '<div>Orders</div>' }, component: { template: '<div>Orders</div>' },
}, },
{
path: '/andra-losenord',
name: 'change-password',
component: { template: '<div>Change password</div>' },
},
{ {
path: '/admin', path: '/admin',
name: 'admin', name: 'admin',
@ -166,6 +171,16 @@ describe('AppHeader', () => {
expect(ordersLink?.text()).toBe('Mina beställningar') expect(ordersLink?.text()).toBe('Mina beställningar')
}) })
it('shows change password link', () => {
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')
})
it('does not show admin link for regular user', () => { it('does not show admin link for regular user', () => {
const { wrapper } = mountAuthenticated('user') const { wrapper } = mountAuthenticated('user')
const links = wrapper.findAll('a') const links = wrapper.findAll('a')

View file

@ -0,0 +1,113 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import ForgotPasswordPage from '@/pages/ForgotPasswordPage.vue'
function mockFetchResponse(status: number, body: unknown) {
return Promise.resolve({
ok: status >= 200 && status < 300,
status,
json: () => Promise.resolve(body),
})
}
function createTestRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [
{
path: '/glomt-losenord',
name: 'forgot-password',
component: ForgotPasswordPage,
},
{
path: '/logga-in',
name: 'login',
component: { template: '<div>Login</div>' },
},
],
})
}
function mountPage() {
const router = createTestRouter()
router.push('/glomt-losenord')
return {
router,
wrapper: mount(ForgotPasswordPage, {
global: { plugins: [router] },
}),
}
}
const successMessage =
'Om e-postadressen finns har vi skickat instruktioner för att återställa lösenordet.'
describe('ForgotPasswordPage', () => {
beforeEach(() => {
globalThis.fetch = vi.fn()
vi.mocked(globalThis.fetch).mockResolvedValue(
mockFetchResponse(200, { message: successMessage }),
)
})
it('renders heading and subtitle', () => {
const { wrapper } = mountPage()
expect(wrapper.text()).toContain('Glömt lösenord?')
expect(wrapper.text()).toContain(
'Ange din e-postadress så skickar vi en länk',
)
})
it('disables submit until valid email', async () => {
const { wrapper } = mountPage()
expect(
wrapper.find('button[type="submit"]').attributes('disabled'),
).toBeDefined()
await wrapper.find('#email').setValue('not-an-email')
expect(
wrapper.find('button[type="submit"]').attributes('disabled'),
).toBeDefined()
await wrapper.find('#email').setValue('user@example.com')
expect(
wrapper.find('button[type="submit"]').attributes('disabled'),
).toBeUndefined()
})
it('shows success message after submit', async () => {
const { wrapper } = mountPage()
await wrapper.find('#email').setValue('user@example.com')
await wrapper.find('form').trigger('submit.prevent')
await vi.waitFor(() => {
expect(wrapper.text()).toContain(successMessage)
})
expect(wrapper.find('form').exists()).toBe(false)
})
it('shows error banner on API failure', async () => {
vi.mocked(globalThis.fetch).mockResolvedValue(
mockFetchResponse(500, { message: 'Serverfel' }),
)
const { wrapper } = mountPage()
await wrapper.find('#email').setValue('user@example.com')
await wrapper.find('form').trigger('submit.prevent')
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Serverfel')
})
})
it('links back to login', () => {
const { wrapper } = mountPage()
const link = wrapper.find('a[href="/logga-in"]')
expect(link.exists()).toBe(true)
expect(link.text()).toContain('Tillbaka till inloggning')
})
it('does not show success before submit', () => {
const { wrapper } = mountPage()
expect(wrapper.text()).not.toContain(successMessage)
expect(wrapper.find('form').exists()).toBe(true)
})
})

View file

@ -23,6 +23,11 @@ function createTestRouter() {
name: 'register', name: 'register',
component: { template: '<div>Register</div>' }, component: { template: '<div>Register</div>' },
}, },
{
path: '/glomt-losenord',
name: 'forgot-password',
component: { template: '<div>Forgot</div>' },
},
{ {
path: '/compose', path: '/compose',
name: 'compose', name: 'compose',
@ -168,6 +173,13 @@ describe('LoginPage', () => {
expect(wrapper.text()).toContain('Har du inget konto?') expect(wrapper.text()).toContain('Har du inget konto?')
}) })
it('renders forgot password link to /glomt-losenord', async () => {
const { wrapper } = mountPage()
const link = wrapper.find('a[href="/glomt-losenord"]')
expect(link.exists()).toBe(true)
expect(link.text()).toContain('Glömt lösenord?')
})
it('redirects to query param after login', async () => { it('redirects to query param after login', async () => {
const router = createTestRouter() const router = createTestRouter()
await router.push({ path: '/logga-in', query: { redirect: '/compose' } }) await router.push({ path: '/logga-in', query: { redirect: '/compose' } })

View file

@ -0,0 +1,122 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import ResetPasswordPage from '@/pages/ResetPasswordPage.vue'
function mockFetchResponse(status: number, body: unknown) {
return Promise.resolve({
ok: status >= 200 && status < 300,
status,
json: () => Promise.resolve(body),
})
}
function createTestRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [
{
path: '/aterstall-losenord',
name: 'reset-password',
component: ResetPasswordPage,
},
{
path: '/logga-in',
name: 'login',
component: { template: '<div>Login</div>' },
},
{
path: '/glomt-losenord',
name: 'forgot-password',
component: { template: '<div>Forgot</div>' },
},
],
})
}
async function mountPage(initialPath: string) {
const router = createTestRouter()
await router.push(initialPath)
await router.isReady()
return {
router,
wrapper: mount(ResetPasswordPage, {
global: { plugins: [router] },
}),
}
}
describe('ResetPasswordPage', () => {
beforeEach(() => {
vi.useFakeTimers()
globalThis.fetch = vi.fn()
vi.mocked(globalThis.fetch).mockResolvedValue(
mockFetchResponse(200, {
message: 'Lösenordet har uppdaterats. Du kan nu logga in.',
}),
)
})
afterEach(() => {
vi.useRealTimers()
})
it('shows error when token query is missing', async () => {
const { wrapper } = await mountPage('/aterstall-losenord')
await vi.waitFor(() => {
expect(wrapper.text()).toContain(
'Återställningslänken saknar en giltig kod.',
)
})
})
it('shows password min length hint', async () => {
const { wrapper } = await mountPage('/aterstall-losenord?token=abc')
await wrapper.find('#password').setValue('short')
expect(wrapper.text()).toContain('Lösenordet måste vara minst 8 tecken')
})
it('shows mismatch hint when confirm password differs', async () => {
const { wrapper } = await mountPage('/aterstall-losenord?token=abc')
await wrapper.find('#password').setValue('password1234')
await wrapper.find('#confirmPassword').setValue('different1234')
expect(wrapper.text()).toContain('Lösenorden matchar inte')
})
it('shows success and navigates to login after reset', async () => {
const { wrapper, router } = await mountPage('/aterstall-losenord?token=abc')
await wrapper.find('#password').setValue('newpassword123')
await wrapper.find('#confirmPassword').setValue('newpassword123')
await wrapper.find('form').trigger('submit.prevent')
await vi.waitFor(() => {
expect(wrapper.text()).toContain(
'Lösenordet har uppdaterats. Du kan nu logga in.',
)
})
await vi.advanceTimersByTimeAsync(2000)
expect(router.currentRoute.value.path).toBe('/logga-in')
})
it('shows invalid token message from backend', async () => {
vi.mocked(globalThis.fetch).mockResolvedValue(
mockFetchResponse(400, {
message: 'Återställningslänken är ogiltig eller har gått ut',
}),
)
const { wrapper } = await mountPage('/aterstall-losenord?token=bad')
await wrapper.find('#password').setValue('newpassword123')
await wrapper.find('#confirmPassword').setValue('newpassword123')
await wrapper.find('form').trigger('submit.prevent')
await vi.waitFor(() => {
expect(wrapper.text()).toContain(
'Återställningslänken är ogiltig eller har gått ut',
)
expect(wrapper.find('a[href="/glomt-losenord"]').text()).toContain(
'Begär ny länk',
)
})
})
})

View file

@ -26,6 +26,18 @@ describe('Router', () => {
expect(router.currentRoute.value.name).toBe('login') expect(router.currentRoute.value.name).toBe('login')
}) })
it('resolves /glomt-losenord to ForgotPasswordPage', async () => {
await router.push('/glomt-losenord')
await router.isReady()
expect(router.currentRoute.value.name).toBe('forgot-password')
})
it('resolves /aterstall-losenord to ResetPasswordPage', async () => {
await router.push('/aterstall-losenord?token=abc')
await router.isReady()
expect(router.currentRoute.value.name).toBe('reset-password')
})
it('resolves /orders to OrdersPage', async () => { it('resolves /orders to OrdersPage', async () => {
localStorage.setItem('auth_token', makeJwt({ role: 'user' })) localStorage.setItem('auth_token', makeJwt({ role: 'user' }))
await router.push('/orders') await router.push('/orders')
@ -33,6 +45,13 @@ describe('Router', () => {
expect(router.currentRoute.value.name).toBe('orders') expect(router.currentRoute.value.name).toBe('orders')
}) })
it('resolves /andra-losenord to ChangePasswordPage when authenticated', async () => {
localStorage.setItem('auth_token', makeJwt({ role: 'user' }))
await router.push('/andra-losenord')
await router.isReady()
expect(router.currentRoute.value.name).toBe('change-password')
})
it('resolves /admin to AdminPage for admin user', async () => { it('resolves /admin to AdminPage for admin user', async () => {
localStorage.setItem('auth_token', makeJwt({ role: 'admin' })) localStorage.setItem('auth_token', makeJwt({ role: 'admin' }))
await router.push('/admin') await router.push('/admin')
@ -67,6 +86,13 @@ describe('Router guards', () => {
expect(router.currentRoute.value.query.redirect).toBe('/orders') expect(router.currentRoute.value.query.redirect).toBe('/orders')
}) })
it('redirects unauthenticated user from /andra-losenord to /logga-in', async () => {
await router.push('/andra-losenord')
await router.isReady()
expect(router.currentRoute.value.name).toBe('login')
expect(router.currentRoute.value.query.redirect).toBe('/andra-losenord')
})
it('redirects unauthenticated user from /admin to /logga-in', async () => { it('redirects unauthenticated user from /admin to /logga-in', async () => {
await router.push('/admin') await router.push('/admin')
await router.isReady() await router.isReady()
@ -102,6 +128,26 @@ describe('Router guards', () => {
expect(router.currentRoute.value.name).toBe('home') expect(router.currentRoute.value.name).toBe('home')
}) })
it('redirects authenticated user from /glomt-losenord to home', async () => {
localStorage.setItem('auth_token', makeJwt({ role: 'user' }))
await router.push('/glomt-losenord')
await router.isReady()
expect(router.currentRoute.value.name).toBe('home')
})
it('redirects authenticated user from /aterstall-losenord to home', async () => {
localStorage.setItem('auth_token', makeJwt({ role: 'user' }))
await router.push('/aterstall-losenord?token=abc')
await router.isReady()
expect(router.currentRoute.value.name).toBe('home')
})
it('allows unauthenticated user to access reset password with token', async () => {
await router.push('/aterstall-losenord?token=abc')
await router.isReady()
expect(router.currentRoute.value.name).toBe('reset-password')
})
it('redirects non-admin user from /admin to home', async () => { it('redirects non-admin user from /admin to home', async () => {
localStorage.setItem('auth_token', makeJwt({ role: 'user' })) localStorage.setItem('auth_token', makeJwt({ role: 'user' }))
await router.push('/admin') await router.push('/admin')

View file

@ -0,0 +1,67 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { forgotPassword, resetPassword } from '@/api/auth'
import { ApiError } from '@/api/client'
function mockFetchResponse(status: number, body: unknown) {
return Promise.resolve({
ok: status >= 200 && status < 300,
status,
json: () => Promise.resolve(body),
})
}
describe('auth API', () => {
beforeEach(() => {
localStorage.clear()
globalThis.fetch = vi.fn()
})
it('forgotPassword POSTs to /auth/forgot-password with email', async () => {
vi.mocked(globalThis.fetch).mockResolvedValue(
mockFetchResponse(200, { message: 'Skickat' }),
)
const response = await forgotPassword('user@example.com')
expect(response.message).toBe('Skickat')
expect(globalThis.fetch).toHaveBeenCalledWith(
'/api/auth/forgot-password',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ email: 'user@example.com' }),
}),
)
})
it('resetPassword POSTs to /auth/reset-password with token and password', async () => {
vi.mocked(globalThis.fetch).mockResolvedValue(
mockFetchResponse(200, { message: 'Uppdaterat' }),
)
const response = await resetPassword('token-abc', 'newpassword123')
expect(response.message).toBe('Uppdaterat')
expect(globalThis.fetch).toHaveBeenCalledWith(
'/api/auth/reset-password',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
token: 'token-abc',
password: 'newpassword123',
}),
}),
)
})
it('propagates ApiError on failed forgotPassword', async () => {
vi.mocked(globalThis.fetch).mockResolvedValue(
mockFetchResponse(500, { message: 'Serverfel' }),
)
await expect(forgotPassword('user@example.com')).rejects.toThrow(ApiError)
await expect(forgotPassword('user@example.com')).rejects.toMatchObject({
status: 500,
message: 'Serverfel',
})
})
})

View file

@ -20,3 +20,39 @@ export function login(email: string, password: string): Promise<AuthResponse> {
body: JSON.stringify({ email, password }), body: JSON.stringify({ email, password }),
}) })
} }
export interface MessageResponse {
message: string
}
/** Optional testToken is returned only when backend expose-token is enabled (E2E). */
export interface ForgotPasswordResponse extends MessageResponse {
testToken?: string
}
export function forgotPassword(email: string): Promise<ForgotPasswordResponse> {
return request<ForgotPasswordResponse>('/auth/forgot-password', {
method: 'POST',
body: JSON.stringify({ email }),
})
}
export function resetPassword(
token: string,
password: string,
): Promise<MessageResponse> {
return request<MessageResponse>('/auth/reset-password', {
method: 'POST',
body: JSON.stringify({ token, password }),
})
}
export function changePassword(
currentPassword: string,
newPassword: string,
): Promise<MessageResponse> {
return request<MessageResponse>('/auth/change-password', {
method: 'POST',
body: JSON.stringify({ currentPassword, newPassword }),
})
}

View file

@ -61,6 +61,9 @@ function handleLogout() {
<RouterLink to="/orders" class="app-header__link" <RouterLink to="/orders" class="app-header__link"
>Mina beställningar</RouterLink >Mina beställningar</RouterLink
> >
<RouterLink to="/andra-losenord" class="app-header__link"
>Byt lösenord</RouterLink
>
<span class="app-header__email">{{ auth.email }}</span> <span class="app-header__email">{{ auth.email }}</span>
<button class="app-header__logout" @click="handleLogout"> <button class="app-header__logout" @click="handleLogout">
Logga ut Logga ut

View file

@ -0,0 +1,190 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { RouterLink } from 'vue-router'
import { changePassword } from '@/api/auth'
import { ApiError } from '@/api/client'
const currentPassword = ref('')
const password = ref('')
const confirmPassword = ref('')
const submitting = ref(false)
const errorMessage = ref('')
const successMessage = ref('')
const passwordError = computed(() => {
if (password.value.length === 0) return ''
return password.value.length >= 8
? ''
: 'Lösenordet måste vara minst 8 tecken'
})
const confirmPasswordError = computed(() => {
if (confirmPassword.value.length === 0) return ''
return confirmPassword.value === password.value
? ''
: 'Lösenorden matchar inte'
})
const isValid = computed(
() =>
currentPassword.value.length > 0 &&
passwordError.value === '' &&
confirmPasswordError.value === '' &&
password.value.length > 0 &&
confirmPassword.value.length > 0,
)
async function handleSubmit() {
if (!isValid.value || submitting.value) return
submitting.value = true
errorMessage.value = ''
successMessage.value = ''
try {
const response = await changePassword(currentPassword.value, password.value)
successMessage.value = response.message
currentPassword.value = ''
password.value = ''
confirmPassword.value = ''
} catch (err) {
if (err instanceof ApiError && err.status === 401) {
errorMessage.value = 'Nuvarande lösenord är felaktigt'
} 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 lösenord</h1>
<p class="page__subtitle">
Ange ditt nuvarande lösenord och välj ett nytt.
</p>
<form
v-if="!successMessage"
class="page__form"
@submit.prevent="handleSubmit"
>
<div class="field">
<label for="current-password" class="field__label"
>Nuvarande lösenord</label
>
<input
id="current-password"
v-model="currentPassword"
type="password"
name="currentPassword"
autocomplete="current-password"
class="field__input"
/>
</div>
<div class="field">
<label for="password" class="field__label">Nytt lösenord</label>
<input
id="password"
v-model="password"
type="password"
name="password"
autocomplete="new-password"
class="field__input"
placeholder="Minst 8 tecken"
/>
<p v-if="passwordError" class="field__hint field__hint--error">
{{ passwordError }}
</p>
</div>
<div class="field">
<label for="confirm-password" class="field__label"
>Bekräfta nytt lösenord</label
>
<input
id="confirm-password"
v-model="confirmPassword"
type="password"
name="confirmPassword"
autocomplete="new-password"
class="field__input"
/>
<p v-if="confirmPasswordError" class="field__hint field__hint--error">
{{ confirmPasswordError }}
</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 nytt lösenord' }}
</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,129 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { RouterLink } from 'vue-router'
import { forgotPassword } from '@/api/auth'
import { ApiError } from '@/api/client'
const email = ref('')
const submitting = ref(false)
const successMessage = ref('')
const errorMessage = ref('')
const isValid = computed(() => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.value))
async function handleSubmit() {
if (!isValid.value || submitting.value) return
submitting.value = true
errorMessage.value = ''
successMessage.value = ''
try {
const response = await forgotPassword(email.value)
successMessage.value = response.message
} catch (err) {
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">Glömt lösenord?</h1>
<p class="page__subtitle">
Ange din e-postadress skickar vi en länk för att återställa
lösenordet.
</p>
<form
v-if="!successMessage"
class="page__form"
@submit.prevent="handleSubmit"
>
<div class="field">
<label for="email" class="field__label">E-postadress</label>
<input
id="email"
v-model="email"
type="email"
name="email"
autocomplete="email"
class="field__input"
placeholder="namn@exempel.se"
/>
</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 ? 'Skickar...' : 'Skicka återställningslänk' }}
</button>
</form>
<div v-else class="message message--success">
{{ successMessage }}
</div>
<p class="page__footer-link">
<RouterLink to="/logga-in">Tillbaka till inloggning</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

@ -62,7 +62,12 @@ async function handleSubmit() {
</div> </div>
<div class="field"> <div class="field">
<label for="password" class="field__label">Lösenord</label> <div class="field__label-row">
<label for="password" class="field__label">Lösenord</label>
<RouterLink to="/glomt-losenord" class="field__link">
Glömt lösenord?
</RouterLink>
</div>
<input <input
id="password" id="password"
v-model="password" v-model="password"
@ -138,4 +143,21 @@ async function handleSubmit() {
.login__submit { .login__submit {
width: 100%; width: 100%;
} }
.field__label-row {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: var(--space-sm);
}
.field__link {
font-size: 0.8125rem;
color: var(--color-primary);
text-decoration: none;
}
.field__link:hover {
text-decoration: underline;
}
</style> </style>

View file

@ -0,0 +1,191 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter, RouterLink } from 'vue-router'
import { resetPassword } from '@/api/auth'
import { ApiError } from '@/api/client'
const route = useRoute()
const router = useRouter()
const password = ref('')
const confirmPassword = ref('')
const submitting = ref(false)
const errorMessage = ref('')
const successMessage = ref('')
const token = ref('')
const passwordError = computed(() => {
if (password.value.length === 0) return ''
return password.value.length >= 8
? ''
: 'Lösenordet måste vara minst 8 tecken'
})
const confirmPasswordError = computed(() => {
if (confirmPassword.value.length === 0) return ''
return confirmPassword.value === password.value
? ''
: 'Lösenorden matchar inte'
})
const isValid = computed(
() =>
token.value.length > 0 &&
passwordError.value === '' &&
confirmPasswordError.value === '' &&
password.value.length > 0 &&
confirmPassword.value.length > 0,
)
onMounted(() => {
const value = route.query.token
token.value = typeof value === 'string' ? value : ''
if (!token.value) {
errorMessage.value = 'Återställningslänken saknar en giltig kod.'
}
})
async function handleSubmit() {
if (!isValid.value || submitting.value) return
submitting.value = true
errorMessage.value = ''
try {
const response = await resetPassword(token.value, password.value)
successMessage.value = response.message
setTimeout(() => router.push('/logga-in'), 2000)
} catch (err) {
if (err instanceof ApiError) {
errorMessage.value = err.message
} else {
errorMessage.value = 'Något gick fel. Försök begära en ny länk.'
}
} finally {
submitting.value = false
}
}
</script>
<template>
<div class="page">
<div class="page__card">
<h1 class="page__title">Nytt lösenord</h1>
<p class="page__subtitle">Välj ett nytt lösenord för ditt konto.</p>
<form
v-if="!successMessage && token"
class="page__form"
@submit.prevent="handleSubmit"
>
<div class="field">
<label for="password" class="field__label">Nytt lösenord</label>
<input
id="password"
v-model="password"
type="password"
name="password"
autocomplete="new-password"
class="field__input"
placeholder="Minst 8 tecken"
/>
<p v-if="passwordError" class="field__hint field__hint--error">
{{ passwordError }}
</p>
</div>
<div class="field">
<label for="confirmPassword" class="field__label"
>Bekräfta lösenord</label
>
<input
id="confirmPassword"
v-model="confirmPassword"
type="password"
name="confirmPassword"
autocomplete="new-password"
class="field__input"
placeholder="Upprepa lösenordet"
/>
<p v-if="confirmPasswordError" class="field__hint field__hint--error">
{{ confirmPasswordError }}
</p>
</div>
<div v-if="errorMessage" class="message message--error">
{{ errorMessage }}
<p class="page__footer-link">
<RouterLink to="/glomt-losenord">Begär ny länk</RouterLink>
</p>
</div>
<button
type="submit"
class="btn btn--primary btn--lg page__submit"
:disabled="!isValid || submitting"
>
{{ submitting ? 'Sparar...' : 'Spara nytt lösenord' }}
</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="/glomt-losenord">Begär ny länk</RouterLink>
</p>
</div>
<p v-if="!successMessage" class="page__footer-link">
<RouterLink to="/logga-in">Tillbaka till inloggning</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

@ -5,6 +5,9 @@ import AboutPage from '@/pages/AboutPage.vue'
import ContactPage from '@/pages/ContactPage.vue' import ContactPage from '@/pages/ContactPage.vue'
import RegisterPage from '@/pages/RegisterPage.vue' import RegisterPage from '@/pages/RegisterPage.vue'
import LoginPage from '@/pages/LoginPage.vue' 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 OrdersPage from '@/pages/OrdersPage.vue' import OrdersPage from '@/pages/OrdersPage.vue'
import AdminPage from '@/pages/AdminPage.vue' import AdminPage from '@/pages/AdminPage.vue'
import PaymentRedirect from '@/pages/PaymentRedirect.vue' import PaymentRedirect from '@/pages/PaymentRedirect.vue'
@ -31,6 +34,12 @@ const router = createRouter({
component: OrdersPage, component: OrdersPage,
meta: { requiresAuth: true }, meta: { requiresAuth: true },
}, },
{
path: '/andra-losenord',
name: 'change-password',
component: ChangePasswordPage,
meta: { requiresAuth: true },
},
{ {
path: '/admin', path: '/admin',
name: 'admin', name: 'admin',
@ -55,6 +64,18 @@ const router = createRouter({
component: LoginPage, component: LoginPage,
meta: { guestOnly: true }, meta: { guestOnly: true },
}, },
{
path: '/glomt-losenord',
name: 'forgot-password',
component: ForgotPasswordPage,
meta: { guestOnly: true },
},
{
path: '/aterstall-losenord',
name: 'reset-password',
component: ResetPasswordPage,
meta: { guestOnly: true },
},
{ {
path: '/om', path: '/om',
name: 'about', name: 'about',