Compare commits

...

2 commits

Author SHA1 Message Date
86fb946e33 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>
2026-05-21 18:05:15 +02:00
45b2449b14 Add phased nginx setup for bilhej.se TLS on srvr.nu.
First-time host nginx setup needs HTTP-only vhost before certbot can
issue certs; the full bilhej.nginx.conf 443 block fails nginx -t until
those files exist.

- Add docker/bilhej.nginx.http.conf for ACME phase
- Reorder README one-time setup: HTTP vhost, certbot, then full config
2026-05-21 17:06:21 +02:00
46 changed files with 2155 additions and 8 deletions

View file

@ -26,8 +26,20 @@ STRIPE_PRICE_ID=price_...
# ---------- Swish (Phase 0) ----------
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) ----------
# Strong password; never use test1234. Dev seeds use test@bilhej.se instead.
ADMIN_EMAIL=admin@bilhej.se
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
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
Always verify `stripe-signature` header using `STRIPE_WEBHOOK_SECRET`.
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`
- Backend API: `http://localhost:8080`
- PostgreSQL: `localhost:5432`
- Mailpit (dev SMTP inbox): `http://localhost:8025`
### Architecture inside Docker Compose
@ -64,6 +65,11 @@ The app will be available at:
│ │ postgres (16) │
│ │ :5432 │
│ └──────────────────┘
│ ┌──────────────────┐
│ │ mailpit │
│ │ SMTP :1025 │
│ │ UI :8025 │
│ └──────────────────┘
```
**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_PRICE_ID` | Stripe price ID for single letter |
| `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_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
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):
```bash
@ -285,9 +348,18 @@ Before the first deploy, complete these steps on the production server (`srvr.nu
Set `bilhej.se` (and `www.bilhej.se`) A record to the server's public IP.
3. **Obtain SSL Certificate**
3. **Add HTTP-only Nginx vhost** (required before certs exist)
Run certbot in the nginx container:
The full [`docker/bilhej.nginx.conf`](docker/bilhej.nginx.conf) references TLS files that do not
exist yet. Deploy the HTTP-only config first:
```bash
docker cp docker/bilhej.nginx.http.conf nginx:/etc/nginx/conf.d/bilhej.conf
docker exec nginx nginx -t
docker exec nginx nginx -s reload
```
4. **Obtain SSL Certificate**
```bash
docker exec certbot certbot certonly \
@ -295,12 +367,11 @@ Before the first deploy, complete these steps on the production server (`srvr.nu
-d bilhej.se -d www.bilhej.se
```
4. **Add Nginx Config**
Copy the Bilhej server block into the nginx container:
5. **Enable HTTPS proxy to the frontend**
```bash
docker cp docker/bilhej.nginx.conf nginx:/etc/nginx/conf.d/bilhej.conf
docker exec nginx nginx -t
docker exec nginx nginx -s reload
```

View file

@ -22,6 +22,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-flyway'
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-webmvc'
implementation 'org.flywaydb:flyway-database-postgresql'

View file

@ -34,7 +34,12 @@ public class SecurityConfig {
.csrf(csrf -> csrf.disable())
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.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/payment/swish-info").permitAll()
.requestMatchers("/api/vehicles/**").permitAll()

View file

@ -4,15 +4,23 @@ import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
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.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import se.bilhalsning.dto.AuthResponse;
import se.bilhalsning.dto.ChangePasswordRequest;
import se.bilhalsning.dto.ForgotPasswordRequest;
import se.bilhalsning.dto.LoginRequest;
import se.bilhalsning.dto.ForgotPasswordResponse;
import se.bilhalsning.dto.MessageResponse;
import se.bilhalsning.dto.RegisterRequest;
import se.bilhalsning.dto.ResetPasswordRequest;
import se.bilhalsning.entity.User;
import se.bilhalsning.security.JwtService;
import se.bilhalsning.service.PasswordResetService;
import se.bilhalsning.service.UserService;
@RestController
@ -21,8 +29,12 @@ import se.bilhalsning.service.UserService;
public class AuthController {
private final UserService userService;
private final PasswordResetService passwordResetService;
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")
public ResponseEntity<AuthResponse> register(@Valid @RequestBody RegisterRequest request) {
userService.createUser(request.email(), request.password());
@ -36,4 +48,27 @@ public class AuthController {
String token = jwtService.generateToken(user.getEmail(), user.getRole());
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()));
}
@ExceptionHandler(PasswordResetTokenInvalidException.class)
public ResponseEntity<ErrorResponse> handlePasswordResetTokenInvalid(
PasswordResetTokenInvalidException ex) {
return ResponseEntity
.badRequest()
.body(new ErrorResponse(ex.getMessage()));
}
@ExceptionHandler(EmailAlreadyExistsException.class)
public ResponseEntity<ErrorResponse> handleEmailAlreadyExists(EmailAlreadyExistsException ex) {
return ResponseEntity

View file

@ -0,0 +1,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;
}
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:
database-platform: org.hibernate.dialect.PostgreSQLDialect
mail:
properties:
mail:
smtp:
auth: false
starttls:
enable: false
app:
payment:
swish-number: ${SWISH_NUMBER:0700000000}
letter-price: 49
jwt:
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
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:
public-base-url: ${APP_PUBLIC_BASE_URL:http://localhost:3000}
mail:
from: ${MAIL_FROM:noreply@bilhej.se}
payment:
swish-number: ${SWISH_NUMBER:0700000000}
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;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
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.webmvc.test.autoconfigure.AutoConfigureMockMvc;
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.web.servlet.MockMvc;
import se.bilhalsning.dto.LoginRequest;
@ -19,6 +21,8 @@ import se.bilhalsning.entity.User;
import se.bilhalsning.exception.EmailAlreadyExistsException;
import se.bilhalsning.exception.InvalidCredentialsException;
import se.bilhalsning.security.JwtService;
import java.util.Optional;
import se.bilhalsning.service.PasswordResetService;
import se.bilhalsning.service.UserService;
@SpringBootTest
@ -33,6 +37,9 @@ class AuthControllerTest {
@MockitoBean
private UserService userService;
@MockitoBean
private PasswordResetService passwordResetService;
@MockitoBean
private JwtService jwtService;
@ -160,4 +167,60 @@ class AuthControllerTest {
.content(objectMapper.writeValueAsString(request)))
.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");
}
@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
retries: 5
mailpit:
image: ghcr.io/axllent/mailpit:v1.28
container_name: bilhej-mailpit-e2e
networks:
- e2e
backend:
image: bilhej-backend-e2e
build:
dockerfile: docker/backend.e2e.Dockerfile
context: .
@ -34,13 +41,22 @@ services:
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET}
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:
- e2e
depends_on:
postgres:
condition: service_healthy
mailpit:
condition: service_started
frontend:
image: bilhej-frontend-e2e
build:
dockerfile: docker/frontend.e2e.Dockerfile
context: .
@ -51,6 +67,7 @@ services:
- backend
playwright:
image: bilhej-playwright-e2e
build:
dockerfile: docker/playwright.e2e.Dockerfile
context: .
@ -58,12 +75,19 @@ services:
ipc: host
environment:
PLAYWRIGHT_BASE_URL: http://frontend
MAILPIT_API_URL: http://mailpit:8025
networks:
- e2e
depends_on:
- frontend
- mailpit
command: >-
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...';
for i in \$(seq 1 60); do
curl -s http://backend:8080/api/vehicles/ZZZ999 > /dev/null && break;

View file

@ -34,6 +34,12 @@ services:
SWISH_NUMBER: ${SWISH_NUMBER}
ADMIN_EMAIL: ${ADMIN_EMAIL}
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:
postgres:
condition: service_healthy

View file

@ -16,7 +16,15 @@ services:
timeout: 5s
retries: 5
mailpit:
image: ghcr.io/axllent/mailpit:v1.28
container_name: bilhej-mailpit
ports:
- "1025:1025"
- "8025:8025"
backend:
image: bilhej-backend-dev
build:
dockerfile: docker/backend.Dockerfile
context: .
@ -33,9 +41,17 @@ services:
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET}
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:
postgres:
condition: service_healthy
mailpit:
condition: service_started
volumes:
- .:/app
- backend-gradle-project:/app/.gradle
@ -43,6 +59,7 @@ services:
- gradle-cache:/root/.gradle
frontend:
image: bilhej-frontend-dev
build:
dockerfile: docker/frontend.Dockerfile
context: .

View file

@ -0,0 +1,15 @@
# Phase 1: HTTP only — use before Let's Encrypt certs exist.
# After certbot, replace with bilhej.nginx.conf (includes HTTPS).
server {
listen 80;
server_name bilhej.se www.bilhej.se;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}

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',
component: { template: '<div>Orders</div>' },
},
{
path: '/andra-losenord',
name: 'change-password',
component: { template: '<div>Change password</div>' },
},
{
path: '/admin',
name: 'admin',
@ -166,6 +171,16 @@ describe('AppHeader', () => {
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', () => {
const { wrapper } = mountAuthenticated('user')
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',
component: { template: '<div>Register</div>' },
},
{
path: '/glomt-losenord',
name: 'forgot-password',
component: { template: '<div>Forgot</div>' },
},
{
path: '/compose',
name: 'compose',
@ -168,6 +173,13 @@ describe('LoginPage', () => {
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 () => {
const router = createTestRouter()
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')
})
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 () => {
localStorage.setItem('auth_token', makeJwt({ role: 'user' }))
await router.push('/orders')
@ -33,6 +45,13 @@ describe('Router', () => {
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 () => {
localStorage.setItem('auth_token', makeJwt({ role: 'admin' }))
await router.push('/admin')
@ -67,6 +86,13 @@ describe('Router guards', () => {
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 () => {
await router.push('/admin')
await router.isReady()
@ -102,6 +128,26 @@ describe('Router guards', () => {
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 () => {
localStorage.setItem('auth_token', makeJwt({ role: 'user' }))
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 }),
})
}
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"
>Mina beställningar</RouterLink
>
<RouterLink to="/andra-losenord" class="app-header__link"
>Byt lösenord</RouterLink
>
<span class="app-header__email">{{ auth.email }}</span>
<button class="app-header__logout" @click="handleLogout">
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 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
id="password"
v-model="password"
@ -138,4 +143,21 @@ async function handleSubmit() {
.login__submit {
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>

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