From 86fb946e336a0961d4498dbb20d2b3e2bf2a9e15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20M=C3=B6rling?= Date: Thu, 21 May 2026 18:05:15 +0200 Subject: [PATCH] Add password reset, logged-in change password, and Mailpit email dev/E2E. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 14 +- AGENTS.md | 6 + README.md | 63 ++++++ backend/build.gradle | 1 + .../se/bilhalsning/config/SecurityConfig.java | 7 +- .../controller/AuthController.java | 35 ++++ .../dto/ChangePasswordRequest.java | 8 + .../dto/ForgotPasswordRequest.java | 6 + .../dto/ForgotPasswordResponse.java | 12 ++ .../se/bilhalsning/dto/MessageResponse.java | 3 + .../bilhalsning/dto/ResetPasswordRequest.java | 9 + .../entity/PasswordResetToken.java | 95 +++++++++ .../exception/GlobalExceptionHandler.java | 8 + .../PasswordResetTokenInvalidException.java | 8 + .../PasswordResetTokenRepository.java | 18 ++ .../se/bilhalsning/service/EmailService.java | 61 ++++++ .../service/PasswordResetService.java | 85 ++++++++ .../se/bilhalsning/service/UserService.java | 13 ++ .../src/main/resources/application-docker.yml | 12 ++ backend/src/main/resources/application.yml | 15 ++ .../V8__create_password_reset_tokens.sql | 13 ++ .../controller/AuthControllerTest.java | 63 ++++++ .../service/PasswordResetIntegrationTest.java | 53 +++++ .../service/PasswordResetServiceTest.java | 128 ++++++++++++ .../bilhalsning/service/UserServiceTest.java | 33 +++ .../src/test/resources/application-test.yml | 3 + docker-compose.e2e.yml | 24 +++ docker-compose.prod.yml | 6 + docker-compose.yml | 17 ++ docs/production-email-checklist.md | 58 ++++++ frontend/e2e/helpers/mailpit.ts | 105 ++++++++++ frontend/e2e/password-reset.spec.ts | 179 ++++++++++++++++ frontend/src/__tests__/AppHeader.spec.ts | 15 ++ .../src/__tests__/ForgotPasswordPage.spec.ts | 113 +++++++++++ frontend/src/__tests__/LoginPage.spec.ts | 12 ++ .../src/__tests__/ResetPasswordPage.spec.ts | 122 +++++++++++ frontend/src/__tests__/Router.spec.ts | 46 +++++ frontend/src/__tests__/authApi.spec.ts | 67 ++++++ frontend/src/api/auth.ts | 36 ++++ frontend/src/components/AppHeader.vue | 3 + frontend/src/pages/ChangePasswordPage.vue | 190 +++++++++++++++++ frontend/src/pages/ForgotPasswordPage.vue | 129 ++++++++++++ frontend/src/pages/LoginPage.vue | 24 ++- frontend/src/pages/ResetPasswordPage.vue | 191 ++++++++++++++++++ frontend/src/router/index.ts | 21 ++ 45 files changed, 2127 insertions(+), 3 deletions(-) create mode 100644 backend/src/main/java/se/bilhalsning/dto/ChangePasswordRequest.java create mode 100644 backend/src/main/java/se/bilhalsning/dto/ForgotPasswordRequest.java create mode 100644 backend/src/main/java/se/bilhalsning/dto/ForgotPasswordResponse.java create mode 100644 backend/src/main/java/se/bilhalsning/dto/MessageResponse.java create mode 100644 backend/src/main/java/se/bilhalsning/dto/ResetPasswordRequest.java create mode 100644 backend/src/main/java/se/bilhalsning/entity/PasswordResetToken.java create mode 100644 backend/src/main/java/se/bilhalsning/exception/PasswordResetTokenInvalidException.java create mode 100644 backend/src/main/java/se/bilhalsning/repository/PasswordResetTokenRepository.java create mode 100644 backend/src/main/java/se/bilhalsning/service/EmailService.java create mode 100644 backend/src/main/java/se/bilhalsning/service/PasswordResetService.java create mode 100644 backend/src/main/resources/db/migration/V8__create_password_reset_tokens.sql create mode 100644 backend/src/test/java/se/bilhalsning/service/PasswordResetIntegrationTest.java create mode 100644 backend/src/test/java/se/bilhalsning/service/PasswordResetServiceTest.java create mode 100644 backend/src/test/resources/application-test.yml create mode 100644 docs/production-email-checklist.md create mode 100644 frontend/e2e/helpers/mailpit.ts create mode 100644 frontend/e2e/password-reset.spec.ts create mode 100644 frontend/src/__tests__/ForgotPasswordPage.spec.ts create mode 100644 frontend/src/__tests__/ResetPasswordPage.spec.ts create mode 100644 frontend/src/__tests__/authApi.spec.ts create mode 100644 frontend/src/pages/ChangePasswordPage.vue create mode 100644 frontend/src/pages/ForgotPasswordPage.vue create mode 100644 frontend/src/pages/ResetPasswordPage.vue diff --git a/.env.example b/.env.example index 7bcbf52..d12200c 100644 --- a/.env.example +++ b/.env.example @@ -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 - diff --git a/AGENTS.md b/AGENTS.md index e15737b..85eeb07 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/README.md b/README.md index 93ab3e4..9e299c4 100644 --- a/README.md +++ b/README.md @@ -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 provider’s **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 provider’s 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 diff --git a/backend/build.gradle b/backend/build.gradle index e8b571a..74380cb 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -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' diff --git a/backend/src/main/java/se/bilhalsning/config/SecurityConfig.java b/backend/src/main/java/se/bilhalsning/config/SecurityConfig.java index 2cb96c6..38700e1 100644 --- a/backend/src/main/java/se/bilhalsning/config/SecurityConfig.java +++ b/backend/src/main/java/se/bilhalsning/config/SecurityConfig.java @@ -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() diff --git a/backend/src/main/java/se/bilhalsning/controller/AuthController.java b/backend/src/main/java/se/bilhalsning/controller/AuthController.java index 9a6b96e..dca99fb 100644 --- a/backend/src/main/java/se/bilhalsning/controller/AuthController.java +++ b/backend/src/main/java/se/bilhalsning/controller/AuthController.java @@ -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 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 forgotPassword( + @Valid @RequestBody ForgotPasswordRequest request) { + return ResponseEntity.ok(ForgotPasswordResponse.of( + FORGOT_PASSWORD_MESSAGE, passwordResetService.requestReset(request.email()))); + } + + @PostMapping("/reset-password") + public ResponseEntity 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 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.")); + } } diff --git a/backend/src/main/java/se/bilhalsning/dto/ChangePasswordRequest.java b/backend/src/main/java/se/bilhalsning/dto/ChangePasswordRequest.java new file mode 100644 index 0000000..d228de9 --- /dev/null +++ b/backend/src/main/java/se/bilhalsning/dto/ChangePasswordRequest.java @@ -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) {} diff --git a/backend/src/main/java/se/bilhalsning/dto/ForgotPasswordRequest.java b/backend/src/main/java/se/bilhalsning/dto/ForgotPasswordRequest.java new file mode 100644 index 0000000..a9cabf8 --- /dev/null +++ b/backend/src/main/java/se/bilhalsning/dto/ForgotPasswordRequest.java @@ -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) {} diff --git a/backend/src/main/java/se/bilhalsning/dto/ForgotPasswordResponse.java b/backend/src/main/java/se/bilhalsning/dto/ForgotPasswordResponse.java new file mode 100644 index 0000000..f75c8c8 --- /dev/null +++ b/backend/src/main/java/se/bilhalsning/dto/ForgotPasswordResponse.java @@ -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 testToken) { + return new ForgotPasswordResponse(message, testToken.orElse(null)); + } +} diff --git a/backend/src/main/java/se/bilhalsning/dto/MessageResponse.java b/backend/src/main/java/se/bilhalsning/dto/MessageResponse.java new file mode 100644 index 0000000..1e8a0ee --- /dev/null +++ b/backend/src/main/java/se/bilhalsning/dto/MessageResponse.java @@ -0,0 +1,3 @@ +package se.bilhalsning.dto; + +public record MessageResponse(String message) {} diff --git a/backend/src/main/java/se/bilhalsning/dto/ResetPasswordRequest.java b/backend/src/main/java/se/bilhalsning/dto/ResetPasswordRequest.java new file mode 100644 index 0000000..4bd3d22 --- /dev/null +++ b/backend/src/main/java/se/bilhalsning/dto/ResetPasswordRequest.java @@ -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 +) {} diff --git a/backend/src/main/java/se/bilhalsning/entity/PasswordResetToken.java b/backend/src/main/java/se/bilhalsning/entity/PasswordResetToken.java new file mode 100644 index 0000000..8420ddd --- /dev/null +++ b/backend/src/main/java/se/bilhalsning/entity/PasswordResetToken.java @@ -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; + } +} diff --git a/backend/src/main/java/se/bilhalsning/exception/GlobalExceptionHandler.java b/backend/src/main/java/se/bilhalsning/exception/GlobalExceptionHandler.java index 366c78a..5fd4054 100644 --- a/backend/src/main/java/se/bilhalsning/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/se/bilhalsning/exception/GlobalExceptionHandler.java @@ -21,6 +21,14 @@ public class GlobalExceptionHandler { .body(new ErrorResponse(ex.getMessage())); } + @ExceptionHandler(PasswordResetTokenInvalidException.class) + public ResponseEntity handlePasswordResetTokenInvalid( + PasswordResetTokenInvalidException ex) { + return ResponseEntity + .badRequest() + .body(new ErrorResponse(ex.getMessage())); + } + @ExceptionHandler(EmailAlreadyExistsException.class) public ResponseEntity handleEmailAlreadyExists(EmailAlreadyExistsException ex) { return ResponseEntity diff --git a/backend/src/main/java/se/bilhalsning/exception/PasswordResetTokenInvalidException.java b/backend/src/main/java/se/bilhalsning/exception/PasswordResetTokenInvalidException.java new file mode 100644 index 0000000..94718d9 --- /dev/null +++ b/backend/src/main/java/se/bilhalsning/exception/PasswordResetTokenInvalidException.java @@ -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"); + } +} diff --git a/backend/src/main/java/se/bilhalsning/repository/PasswordResetTokenRepository.java b/backend/src/main/java/se/bilhalsning/repository/PasswordResetTokenRepository.java new file mode 100644 index 0000000..c69efd7 --- /dev/null +++ b/backend/src/main/java/se/bilhalsning/repository/PasswordResetTokenRepository.java @@ -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 { + + Optional 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); +} diff --git a/backend/src/main/java/se/bilhalsning/service/EmailService.java b/backend/src/main/java/se/bilhalsning/service/EmailService.java new file mode 100644 index 0000000..699abc5 --- /dev/null +++ b/backend/src/main/java/se/bilhalsning/service/EmailService.java @@ -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"); + } + } +} diff --git a/backend/src/main/java/se/bilhalsning/service/PasswordResetService.java b/backend/src/main/java/se/bilhalsning/service/PasswordResetService.java new file mode 100644 index 0000000..3c6763d --- /dev/null +++ b/backend/src/main/java/se/bilhalsning/service/PasswordResetService.java @@ -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 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.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); + } + } +} diff --git a/backend/src/main/java/se/bilhalsning/service/UserService.java b/backend/src/main/java/se/bilhalsning/service/UserService.java index 103a746..b0d2f3e 100644 --- a/backend/src/main/java/se/bilhalsning/service/UserService.java +++ b/backend/src/main/java/se/bilhalsning/service/UserService.java @@ -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); + } } diff --git a/backend/src/main/resources/application-docker.yml b/backend/src/main/resources/application-docker.yml index 95af7c8..82e15d2 100644 --- a/backend/src/main/resources/application-docker.yml +++ b/backend/src/main/resources/application-docker.yml @@ -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 diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 61b6c12..834398d 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -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 diff --git a/backend/src/main/resources/db/migration/V8__create_password_reset_tokens.sql b/backend/src/main/resources/db/migration/V8__create_password_reset_tokens.sql new file mode 100644 index 0000000..239461b --- /dev/null +++ b/backend/src/main/resources/db/migration/V8__create_password_reset_tokens.sql @@ -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); diff --git a/backend/src/test/java/se/bilhalsning/controller/AuthControllerTest.java b/backend/src/test/java/se/bilhalsning/controller/AuthControllerTest.java index b13d70c..d26bff0 100644 --- a/backend/src/test/java/se/bilhalsning/controller/AuthControllerTest.java +++ b/backend/src/test/java/se/bilhalsning/controller/AuthControllerTest.java @@ -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()); + } } diff --git a/backend/src/test/java/se/bilhalsning/service/PasswordResetIntegrationTest.java b/backend/src/test/java/se/bilhalsning/service/PasswordResetIntegrationTest.java new file mode 100644 index 0000000..08c27eb --- /dev/null +++ b/backend/src/test/java/se/bilhalsning/service/PasswordResetIntegrationTest.java @@ -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 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")); + } +} diff --git a/backend/src/test/java/se/bilhalsning/service/PasswordResetServiceTest.java b/backend/src/test/java/se/bilhalsning/service/PasswordResetServiceTest.java new file mode 100644 index 0000000..3782b8c --- /dev/null +++ b/backend/src/test/java/se/bilhalsning/service/PasswordResetServiceTest.java @@ -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 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 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 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 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")); + } +} diff --git a/backend/src/test/java/se/bilhalsning/service/UserServiceTest.java b/backend/src/test/java/se/bilhalsning/service/UserServiceTest.java index e80c8f2..f05d671 100644 --- a/backend/src/test/java/se/bilhalsning/service/UserServiceTest.java +++ b/backend/src/test/java/se/bilhalsning/service/UserServiceTest.java @@ -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)); + } } diff --git a/backend/src/test/resources/application-test.yml b/backend/src/test/resources/application-test.yml new file mode 100644 index 0000000..bb80da3 --- /dev/null +++ b/backend/src/test/resources/application-test.yml @@ -0,0 +1,3 @@ +app: + password-reset: + expose-token: true diff --git a/docker-compose.e2e.yml b/docker-compose.e2e.yml index 710d88f..500255e 100644 --- a/docker-compose.e2e.yml +++ b/docker-compose.e2e.yml @@ -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; diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index be356ef..a851d5c 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 149190a..552df01 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: . diff --git a/docs/production-email-checklist.md b/docs/production-email-checklist.md new file mode 100644 index 0000000..e593785 --- /dev/null +++ b/docs/production-email-checklist.md @@ -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= +MAIL_PORT=587 +MAIL_USERNAME= +MAIL_PASSWORD= +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`). diff --git a/frontend/e2e/helpers/mailpit.ts b/frontend/e2e/helpers/mailpit.ts new file mode 100644 index 0000000..74326ea --- /dev/null +++ b/frontend/e2e/helpers/mailpit.ts @@ -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 { + await request.delete(`${mailpitApiBase}/api/v1/messages`) +} + +export async function countMessagesTo( + request: APIRequestContext, + recipientEmail: string, +): Promise { + 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 { + 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 { + return new Promise((resolve) => setTimeout(resolve, ms)) +} diff --git a/frontend/e2e/password-reset.spec.ts b/frontend/e2e/password-reset.spec.ts new file mode 100644 index 0000000..a169f56 --- /dev/null +++ b/frontend/e2e/password-reset.spec.ts @@ -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) + }) +}) diff --git a/frontend/src/__tests__/AppHeader.spec.ts b/frontend/src/__tests__/AppHeader.spec.ts index 7a5ebc9..7aea7fd 100644 --- a/frontend/src/__tests__/AppHeader.spec.ts +++ b/frontend/src/__tests__/AppHeader.spec.ts @@ -25,6 +25,11 @@ function createTestRouter() { name: 'orders', component: { template: '
Orders
' }, }, + { + path: '/andra-losenord', + name: 'change-password', + component: { template: '
Change password
' }, + }, { 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') diff --git a/frontend/src/__tests__/ForgotPasswordPage.spec.ts b/frontend/src/__tests__/ForgotPasswordPage.spec.ts new file mode 100644 index 0000000..d83dde7 --- /dev/null +++ b/frontend/src/__tests__/ForgotPasswordPage.spec.ts @@ -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: '
Login
' }, + }, + ], + }) +} + +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) + }) +}) diff --git a/frontend/src/__tests__/LoginPage.spec.ts b/frontend/src/__tests__/LoginPage.spec.ts index 7fb7c51..5aa2c23 100644 --- a/frontend/src/__tests__/LoginPage.spec.ts +++ b/frontend/src/__tests__/LoginPage.spec.ts @@ -23,6 +23,11 @@ function createTestRouter() { name: 'register', component: { template: '
Register
' }, }, + { + path: '/glomt-losenord', + name: 'forgot-password', + component: { template: '
Forgot
' }, + }, { 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' } }) diff --git a/frontend/src/__tests__/ResetPasswordPage.spec.ts b/frontend/src/__tests__/ResetPasswordPage.spec.ts new file mode 100644 index 0000000..802bc3b --- /dev/null +++ b/frontend/src/__tests__/ResetPasswordPage.spec.ts @@ -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: '
Login
' }, + }, + { + path: '/glomt-losenord', + name: 'forgot-password', + component: { template: '
Forgot
' }, + }, + ], + }) +} + +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', + ) + }) + }) +}) diff --git a/frontend/src/__tests__/Router.spec.ts b/frontend/src/__tests__/Router.spec.ts index 85d5779..257431a 100644 --- a/frontend/src/__tests__/Router.spec.ts +++ b/frontend/src/__tests__/Router.spec.ts @@ -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') diff --git a/frontend/src/__tests__/authApi.spec.ts b/frontend/src/__tests__/authApi.spec.ts new file mode 100644 index 0000000..6c04234 --- /dev/null +++ b/frontend/src/__tests__/authApi.spec.ts @@ -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', + }) + }) +}) diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 6060a34..c621ec6 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -20,3 +20,39 @@ export function login(email: string, password: string): Promise { 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 { + return request('/auth/forgot-password', { + method: 'POST', + body: JSON.stringify({ email }), + }) +} + +export function resetPassword( + token: string, + password: string, +): Promise { + return request('/auth/reset-password', { + method: 'POST', + body: JSON.stringify({ token, password }), + }) +} + +export function changePassword( + currentPassword: string, + newPassword: string, +): Promise { + return request('/auth/change-password', { + method: 'POST', + body: JSON.stringify({ currentPassword, newPassword }), + }) +} diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue index fc251b1..ced8ba1 100644 --- a/frontend/src/components/AppHeader.vue +++ b/frontend/src/components/AppHeader.vue @@ -61,6 +61,9 @@ function handleLogout() { Mina beställningar + Byt lösenord