Add password reset, logged-in change password, and Mailpit email dev/E2E.
Operators can fix prod admin passwords without email via Byt lösenord; end users can use forgot-password when SMTP is configured. Local and CI use Mailpit to capture outbound mail and verify reset links end-to-end. - Backend: V8 password_reset_tokens, PasswordResetService, EmailService, POST /api/auth/forgot-password, reset-password, change-password - Optional testToken in forgot-password response (docker profile only, for E2E) - Frontend: ForgotPasswordPage, ResetPasswordPage, ChangePasswordPage, routes, login link, header Byt lösenord - Mailpit (ghcr.io/axllent/mailpit:v1.28) in docker-compose + e2e stack - E2E: password-reset.spec.ts + Mailpit API helper tests SMTP delivery - Separate dev/e2e Docker image names to avoid overwriting bilhej-frontend - Docs: README email section, production-email-checklist, .env.example - Unit/integration tests for reset, change password, and Vitest page specs Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
45b2449b14
commit
86fb946e33
45 changed files with 2127 additions and 3 deletions
14
.env.example
14
.env.example
|
|
@ -26,8 +26,20 @@ STRIPE_PRICE_ID=price_...
|
||||||
# ---------- Swish (Phase 0) ----------
|
# ---------- Swish (Phase 0) ----------
|
||||||
SWISH_NUMBER=0701234567
|
SWISH_NUMBER=0701234567
|
||||||
|
|
||||||
|
# ---------- App URL (password reset links in email) ----------
|
||||||
|
APP_PUBLIC_BASE_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# ---------- SMTP (local Docker uses Mailpit via docker-compose.yml) ----------
|
||||||
|
# docker compose up → view mail at http://localhost:8025
|
||||||
|
# Leave MAIL_HOST unset in .env to use compose defaults (mailpit).
|
||||||
|
# Production: use Resend/Brevo SMTP — see README "Email (password reset)"
|
||||||
|
# MAIL_HOST=smtp.resend.com
|
||||||
|
# MAIL_PORT=587
|
||||||
|
# MAIL_USERNAME=
|
||||||
|
# MAIL_PASSWORD=
|
||||||
|
# MAIL_FROM=noreply@bilhej.se
|
||||||
|
|
||||||
# ---------- Production admin (prod profile only) ----------
|
# ---------- Production admin (prod profile only) ----------
|
||||||
# Strong password; never use test1234. Dev seeds use test@bilhej.se instead.
|
# Strong password; never use test1234. Dev seeds use test@bilhej.se instead.
|
||||||
ADMIN_EMAIL=admin@bilhej.se
|
ADMIN_EMAIL=admin@bilhej.se
|
||||||
ADMIN_PASSWORD=change_me_to_a_strong_password
|
ADMIN_PASSWORD=change_me_to_a_strong_password
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -187,6 +187,12 @@ After the address is used to mail the letter, it must be deleted. The Order
|
||||||
entity must NOT have an address field. The address lookup and mailing are
|
entity must NOT have an address field. The address lookup and mailing are
|
||||||
external/human processes in Phase 0.
|
external/human processes in Phase 0.
|
||||||
|
|
||||||
|
### Local email (Mailpit)
|
||||||
|
`docker compose up` includes Mailpit (`ghcr.io/axllent/mailpit:v1.28`); password-reset mail appears at http://localhost:8025. E2E verifies SMTP via Mailpit API (`frontend/e2e/helpers/mailpit.ts`). Production uses transactional SMTP (Resend/Brevo)—see README.
|
||||||
|
|
||||||
|
### Password reset test token (never in production)
|
||||||
|
`app.password-reset.expose-token` must stay **false** in prod/default; it is only enabled in `application-docker.yml` for CI E2E so Playwright can read `testToken` from the forgot-password response.
|
||||||
|
|
||||||
### Stripe webhook signature verification
|
### Stripe webhook signature verification
|
||||||
Always verify `stripe-signature` header using `STRIPE_WEBHOOK_SECRET`.
|
Always verify `stripe-signature` header using `STRIPE_WEBHOOK_SECRET`.
|
||||||
Webhook endpoint is public (no auth). Without signature verification an
|
Webhook endpoint is public (no auth). Without signature verification an
|
||||||
|
|
|
||||||
63
README.md
63
README.md
|
|
@ -41,6 +41,7 @@ The app will be available at:
|
||||||
- Frontend: `http://localhost:3000`
|
- Frontend: `http://localhost:3000`
|
||||||
- Backend API: `http://localhost:8080`
|
- Backend API: `http://localhost:8080`
|
||||||
- PostgreSQL: `localhost:5432`
|
- PostgreSQL: `localhost:5432`
|
||||||
|
- Mailpit (dev SMTP inbox): `http://localhost:8025`
|
||||||
|
|
||||||
### Architecture inside Docker Compose
|
### Architecture inside Docker Compose
|
||||||
|
|
||||||
|
|
@ -64,6 +65,11 @@ The app will be available at:
|
||||||
│ │ postgres (16) │
|
│ │ postgres (16) │
|
||||||
│ │ :5432 │
|
│ │ :5432 │
|
||||||
│ └──────────────────┘
|
│ └──────────────────┘
|
||||||
|
│ ┌──────────────────┐
|
||||||
|
│ │ mailpit │
|
||||||
|
│ │ SMTP :1025 │
|
||||||
|
│ │ UI :8025 │
|
||||||
|
│ └──────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
**Vite proxy:** The Vite dev server proxies `/api/*` requests to the backend container.
|
**Vite proxy:** The Vite dev server proxies `/api/*` requests to the backend container.
|
||||||
|
|
@ -92,6 +98,12 @@ Copy `.env.example` to `.env` and fill in:
|
||||||
| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret |
|
| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret |
|
||||||
| `STRIPE_PRICE_ID` | Stripe price ID for single letter |
|
| `STRIPE_PRICE_ID` | Stripe price ID for single letter |
|
||||||
| `SWISH_NUMBER` | Swish number for payment instructions |
|
| `SWISH_NUMBER` | Swish number for payment instructions |
|
||||||
|
| `APP_PUBLIC_BASE_URL` | Base URL for password-reset links (dev: `http://localhost:3000`) |
|
||||||
|
| `MAIL_HOST` | SMTP host (Docker dev uses `mailpit` automatically; leave empty to log links only) |
|
||||||
|
| `MAIL_PORT` | SMTP port (`1025` for Mailpit, `587` for most providers) |
|
||||||
|
| `MAIL_USERNAME` | SMTP username (empty for Mailpit) |
|
||||||
|
| `MAIL_PASSWORD` | SMTP password (empty for Mailpit) |
|
||||||
|
| `MAIL_FROM` | From address (e.g. `noreply@bilhej.se`) |
|
||||||
| `ADMIN_EMAIL` | Production admin login (e.g. `admin@bilhej.se`) |
|
| `ADMIN_EMAIL` | Production admin login (e.g. `admin@bilhej.se`) |
|
||||||
| `ADMIN_PASSWORD` | Strong production admin password (not `test1234`) |
|
| `ADMIN_PASSWORD` | Strong production admin password (not `test1234`) |
|
||||||
|
|
||||||
|
|
@ -169,6 +181,57 @@ WHERE email IN ('test@bilhalsning.se', 'test@bilhej.se', 'admin@bilhalsning.se')
|
||||||
Then deploy with `ADMIN_EMAIL` / `ADMIN_PASSWORD` set — the app creates the production
|
Then deploy with `ADMIN_EMAIL` / `ADMIN_PASSWORD` set — the app creates the production
|
||||||
admin on startup. No need to insert a password hash by hand.
|
admin on startup. No need to insert a password hash by hand.
|
||||||
|
|
||||||
|
### Email (password reset)
|
||||||
|
|
||||||
|
The app only needs to **send** mail (forgot-password). You do not need Office 365 or a mailbox on
|
||||||
|
the server unless you want human addresses like `support@bilhej.se`.
|
||||||
|
|
||||||
|
**Local Docker (Mailpit):** `docker compose up` starts [Mailpit](https://mailpit.axllent.org/)
|
||||||
|
(`ghcr.io/axllent/mailpit:v1.28`). All outbound mail is captured—nothing is sent to the internet.
|
||||||
|
|
||||||
|
If `docker compose pull` fails on Docker Hub, pull explicitly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker pull ghcr.io/axllent/mailpit:v1.28
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Open **http://localhost:8025**
|
||||||
|
2. Use **Glömt lösenord?** on the login page (or **Byt lösenord** in the header when logged in)
|
||||||
|
3. Open the message in Mailpit and click the reset link
|
||||||
|
|
||||||
|
To disable Mailpit and log links only, remove `MAIL_HOST` from the backend service in
|
||||||
|
`docker-compose.yml` or set `MAIL_HOST=` in `.env`.
|
||||||
|
|
||||||
|
**Production (transactional provider):** Use [Resend](https://resend.com) or
|
||||||
|
[Brevo](https://www.brevo.com)—not a self-hosted mail server on the VPS.
|
||||||
|
|
||||||
|
1. Sign up and add domain **bilhej.se**
|
||||||
|
2. Add the 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):
|
To generate a bcrypt hash manually (optional):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ dependencies {
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
|
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-flyway'
|
implementation 'org.springframework.boot:spring-boot-starter-flyway'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-security'
|
implementation 'org.springframework.boot:spring-boot-starter-security'
|
||||||
|
implementation 'org.springframework.boot:spring-boot-starter-mail'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-webmvc'
|
implementation 'org.springframework.boot:spring-boot-starter-webmvc'
|
||||||
implementation 'org.flywaydb:flyway-database-postgresql'
|
implementation 'org.flywaydb:flyway-database-postgresql'
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,12 @@ public class SecurityConfig {
|
||||||
.csrf(csrf -> csrf.disable())
|
.csrf(csrf -> csrf.disable())
|
||||||
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||||
.authorizeHttpRequests(auth -> auth
|
.authorizeHttpRequests(auth -> auth
|
||||||
.requestMatchers("/api/auth/register", "/api/auth/login").permitAll()
|
.requestMatchers(
|
||||||
|
"/api/auth/register",
|
||||||
|
"/api/auth/login",
|
||||||
|
"/api/auth/forgot-password",
|
||||||
|
"/api/auth/reset-password")
|
||||||
|
.permitAll()
|
||||||
.requestMatchers("/api/webhooks/**").permitAll()
|
.requestMatchers("/api/webhooks/**").permitAll()
|
||||||
.requestMatchers("/api/payment/swish-info").permitAll()
|
.requestMatchers("/api/payment/swish-info").permitAll()
|
||||||
.requestMatchers("/api/vehicles/**").permitAll()
|
.requestMatchers("/api/vehicles/**").permitAll()
|
||||||
|
|
|
||||||
|
|
@ -4,15 +4,23 @@ import jakarta.validation.Valid;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
import se.bilhalsning.dto.AuthResponse;
|
import se.bilhalsning.dto.AuthResponse;
|
||||||
|
import se.bilhalsning.dto.ChangePasswordRequest;
|
||||||
|
import se.bilhalsning.dto.ForgotPasswordRequest;
|
||||||
import se.bilhalsning.dto.LoginRequest;
|
import se.bilhalsning.dto.LoginRequest;
|
||||||
|
import se.bilhalsning.dto.ForgotPasswordResponse;
|
||||||
|
import se.bilhalsning.dto.MessageResponse;
|
||||||
import se.bilhalsning.dto.RegisterRequest;
|
import se.bilhalsning.dto.RegisterRequest;
|
||||||
|
import se.bilhalsning.dto.ResetPasswordRequest;
|
||||||
import se.bilhalsning.entity.User;
|
import se.bilhalsning.entity.User;
|
||||||
import se.bilhalsning.security.JwtService;
|
import se.bilhalsning.security.JwtService;
|
||||||
|
import se.bilhalsning.service.PasswordResetService;
|
||||||
import se.bilhalsning.service.UserService;
|
import se.bilhalsning.service.UserService;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
|
|
@ -21,8 +29,12 @@ import se.bilhalsning.service.UserService;
|
||||||
public class AuthController {
|
public class AuthController {
|
||||||
|
|
||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
|
private final PasswordResetService passwordResetService;
|
||||||
private final JwtService jwtService;
|
private final JwtService jwtService;
|
||||||
|
|
||||||
|
private static final String FORGOT_PASSWORD_MESSAGE =
|
||||||
|
"Om e-postadressen finns har vi skickat instruktioner för att återställa lösenordet.";
|
||||||
|
|
||||||
@PostMapping("/register")
|
@PostMapping("/register")
|
||||||
public ResponseEntity<AuthResponse> register(@Valid @RequestBody RegisterRequest request) {
|
public ResponseEntity<AuthResponse> register(@Valid @RequestBody RegisterRequest request) {
|
||||||
userService.createUser(request.email(), request.password());
|
userService.createUser(request.email(), request.password());
|
||||||
|
|
@ -36,4 +48,27 @@ public class AuthController {
|
||||||
String token = jwtService.generateToken(user.getEmail(), user.getRole());
|
String token = jwtService.generateToken(user.getEmail(), user.getRole());
|
||||||
return ResponseEntity.ok(new AuthResponse(token));
|
return ResponseEntity.ok(new AuthResponse(token));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/forgot-password")
|
||||||
|
public ResponseEntity<ForgotPasswordResponse> forgotPassword(
|
||||||
|
@Valid @RequestBody ForgotPasswordRequest request) {
|
||||||
|
return ResponseEntity.ok(ForgotPasswordResponse.of(
|
||||||
|
FORGOT_PASSWORD_MESSAGE, passwordResetService.requestReset(request.email())));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/reset-password")
|
||||||
|
public ResponseEntity<MessageResponse> resetPassword(
|
||||||
|
@Valid @RequestBody ResetPasswordRequest request) {
|
||||||
|
passwordResetService.resetPassword(request.token(), request.password());
|
||||||
|
return ResponseEntity.ok(new MessageResponse("Lösenordet har uppdaterats. Du kan nu logga in."));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/change-password")
|
||||||
|
public ResponseEntity<MessageResponse> changePassword(
|
||||||
|
@Valid @RequestBody ChangePasswordRequest request,
|
||||||
|
@AuthenticationPrincipal UserDetails principal) {
|
||||||
|
userService.changePassword(
|
||||||
|
principal.getUsername(), request.currentPassword(), request.newPassword());
|
||||||
|
return ResponseEntity.ok(new MessageResponse("Lösenordet har uppdaterats."));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {}
|
||||||
|
|
@ -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) {}
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
package se.bilhalsning.dto;
|
||||||
|
|
||||||
|
public record MessageResponse(String message) {}
|
||||||
|
|
@ -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
|
||||||
|
) {}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -21,6 +21,14 @@ public class GlobalExceptionHandler {
|
||||||
.body(new ErrorResponse(ex.getMessage()));
|
.body(new ErrorResponse(ex.getMessage()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(PasswordResetTokenInvalidException.class)
|
||||||
|
public ResponseEntity<ErrorResponse> handlePasswordResetTokenInvalid(
|
||||||
|
PasswordResetTokenInvalidException ex) {
|
||||||
|
return ResponseEntity
|
||||||
|
.badRequest()
|
||||||
|
.body(new ErrorResponse(ex.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
@ExceptionHandler(EmailAlreadyExistsException.class)
|
@ExceptionHandler(EmailAlreadyExistsException.class)
|
||||||
public ResponseEntity<ErrorResponse> handleEmailAlreadyExists(EmailAlreadyExistsException ex) {
|
public ResponseEntity<ErrorResponse> handleEmailAlreadyExists(EmailAlreadyExistsException ex) {
|
||||||
return ResponseEntity
|
return ResponseEntity
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -40,4 +40,17 @@ public class UserService {
|
||||||
}
|
}
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void updatePassword(User user, String newPassword) {
|
||||||
|
user.setPasswordHash(passwordEncoder.encode(newPassword));
|
||||||
|
userRepository.save(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void changePassword(String email, String currentPassword, String newPassword) {
|
||||||
|
User user = findByEmail(email).orElseThrow(InvalidCredentialsException::new);
|
||||||
|
if (!passwordEncoder.matches(currentPassword, user.getPasswordHash())) {
|
||||||
|
throw new InvalidCredentialsException();
|
||||||
|
}
|
||||||
|
updatePassword(user, newPassword);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,21 @@ spring:
|
||||||
jpa:
|
jpa:
|
||||||
database-platform: org.hibernate.dialect.PostgreSQLDialect
|
database-platform: org.hibernate.dialect.PostgreSQLDialect
|
||||||
|
|
||||||
|
mail:
|
||||||
|
properties:
|
||||||
|
mail:
|
||||||
|
smtp:
|
||||||
|
auth: false
|
||||||
|
starttls:
|
||||||
|
enable: false
|
||||||
|
|
||||||
app:
|
app:
|
||||||
payment:
|
payment:
|
||||||
swish-number: ${SWISH_NUMBER:0700000000}
|
swish-number: ${SWISH_NUMBER:0700000000}
|
||||||
letter-price: 49
|
letter-price: 49
|
||||||
jwt:
|
jwt:
|
||||||
secret: ${JWT_SECRET}
|
secret: ${JWT_SECRET}
|
||||||
|
public-base-url: ${APP_PUBLIC_BASE_URL:http://frontend}
|
||||||
|
# E2E only: never enable in production (see application-prod.yml).
|
||||||
|
password-reset:
|
||||||
|
expose-token: true
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,22 @@ spring:
|
||||||
enabled: true
|
enabled: true
|
||||||
locations: classpath:db/migration,classpath:db/dev-migration
|
locations: classpath:db/migration,classpath:db/dev-migration
|
||||||
|
|
||||||
|
mail:
|
||||||
|
host: ${MAIL_HOST:}
|
||||||
|
port: ${MAIL_PORT:587}
|
||||||
|
username: ${MAIL_USERNAME:}
|
||||||
|
password: ${MAIL_PASSWORD:}
|
||||||
|
properties:
|
||||||
|
mail:
|
||||||
|
smtp:
|
||||||
|
auth: true
|
||||||
|
starttls:
|
||||||
|
enable: true
|
||||||
|
|
||||||
app:
|
app:
|
||||||
|
public-base-url: ${APP_PUBLIC_BASE_URL:http://localhost:3000}
|
||||||
|
mail:
|
||||||
|
from: ${MAIL_FROM:noreply@bilhej.se}
|
||||||
payment:
|
payment:
|
||||||
swish-number: ${SWISH_NUMBER:0700000000}
|
swish-number: ${SWISH_NUMBER:0700000000}
|
||||||
letter-price: 49
|
letter-price: 49
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package se.bilhalsning.controller;
|
package se.bilhalsning.controller;
|
||||||
|
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
|
|
@ -11,6 +12,7 @@ import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.security.test.context.support.WithMockUser;
|
||||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
import org.springframework.test.web.servlet.MockMvc;
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
import se.bilhalsning.dto.LoginRequest;
|
import se.bilhalsning.dto.LoginRequest;
|
||||||
|
|
@ -19,6 +21,8 @@ import se.bilhalsning.entity.User;
|
||||||
import se.bilhalsning.exception.EmailAlreadyExistsException;
|
import se.bilhalsning.exception.EmailAlreadyExistsException;
|
||||||
import se.bilhalsning.exception.InvalidCredentialsException;
|
import se.bilhalsning.exception.InvalidCredentialsException;
|
||||||
import se.bilhalsning.security.JwtService;
|
import se.bilhalsning.security.JwtService;
|
||||||
|
import java.util.Optional;
|
||||||
|
import se.bilhalsning.service.PasswordResetService;
|
||||||
import se.bilhalsning.service.UserService;
|
import se.bilhalsning.service.UserService;
|
||||||
|
|
||||||
@SpringBootTest
|
@SpringBootTest
|
||||||
|
|
@ -33,6 +37,9 @@ class AuthControllerTest {
|
||||||
@MockitoBean
|
@MockitoBean
|
||||||
private UserService userService;
|
private UserService userService;
|
||||||
|
|
||||||
|
@MockitoBean
|
||||||
|
private PasswordResetService passwordResetService;
|
||||||
|
|
||||||
@MockitoBean
|
@MockitoBean
|
||||||
private JwtService jwtService;
|
private JwtService jwtService;
|
||||||
|
|
||||||
|
|
@ -160,4 +167,60 @@ class AuthControllerTest {
|
||||||
.content(objectMapper.writeValueAsString(request)))
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReturn200WhenForgotPasswordRequested() throws Exception {
|
||||||
|
when(passwordResetService.requestReset("user@example.com")).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/auth/forgot-password")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"email\":\"user@example.com\"}"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.message")
|
||||||
|
.value("Om e-postadressen finns har vi skickat instruktioner för att återställa lösenordet."))
|
||||||
|
.andExpect(jsonPath("$.testToken").doesNotExist());
|
||||||
|
|
||||||
|
verify(passwordResetService).requestReset("user@example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldIncludeTestTokenWhenServiceReturnsToken() throws Exception {
|
||||||
|
when(passwordResetService.requestReset("user@example.com"))
|
||||||
|
.thenReturn(Optional.of("e2e-reset-token"));
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/auth/forgot-password")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"email\":\"user@example.com\"}"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.testToken").value("e2e-reset-token"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReturn200WhenResetPasswordSucceeds() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/auth/reset-password")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"token\":\"abc\",\"password\":\"newpassword123\"}"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.message").value("Lösenordet har uppdaterats. Du kan nu logga in."));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "admin@bilhej.se")
|
||||||
|
void shouldReturn200WhenChangePasswordSucceeds() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/auth/change-password")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(
|
||||||
|
"{\"currentPassword\":\"test1234\",\"newPassword\":\"newpassword123\"}"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.message").value("Lösenordet har uppdaterats."));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldRejectChangePasswordWithoutAuth() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/auth/change-password")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(
|
||||||
|
"{\"currentPassword\":\"test1234\",\"newPassword\":\"newpassword123\"}"))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -170,4 +170,37 @@ class UserServiceTest {
|
||||||
|
|
||||||
verify(userRepository).findByEmail("user@example.com");
|
verify(userRepository).findByEmail("user@example.com");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldChangePasswordWhenCurrentPasswordMatches() {
|
||||||
|
User user = new User();
|
||||||
|
user.setEmail("admin@bilhej.se");
|
||||||
|
user.setPasswordHash("old-hash");
|
||||||
|
|
||||||
|
when(userRepository.findByEmail("admin@bilhej.se")).thenReturn(Optional.of(user));
|
||||||
|
when(passwordEncoder.matches("test1234", "old-hash")).thenReturn(true);
|
||||||
|
when(passwordEncoder.encode("newpassword123")).thenReturn("new-hash");
|
||||||
|
when(userRepository.save(user)).thenReturn(user);
|
||||||
|
|
||||||
|
userService.changePassword("admin@bilhej.se", "test1234", "newpassword123");
|
||||||
|
|
||||||
|
assertEquals("new-hash", user.getPasswordHash());
|
||||||
|
verify(userRepository).save(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldRejectChangePasswordWhenCurrentPasswordWrong() {
|
||||||
|
User user = new User();
|
||||||
|
user.setEmail("admin@bilhej.se");
|
||||||
|
user.setPasswordHash("old-hash");
|
||||||
|
|
||||||
|
when(userRepository.findByEmail("admin@bilhej.se")).thenReturn(Optional.of(user));
|
||||||
|
when(passwordEncoder.matches("wrong", "old-hash")).thenReturn(false);
|
||||||
|
|
||||||
|
assertThrows(
|
||||||
|
InvalidCredentialsException.class,
|
||||||
|
() -> userService.changePassword("admin@bilhej.se", "wrong", "newpassword123"));
|
||||||
|
|
||||||
|
verify(userRepository, never()).save(any(User.class));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
3
backend/src/test/resources/application-test.yml
Normal file
3
backend/src/test/resources/application-test.yml
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
app:
|
||||||
|
password-reset:
|
||||||
|
expose-token: true
|
||||||
|
|
@ -20,7 +20,14 @@ services:
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
|
mailpit:
|
||||||
|
image: ghcr.io/axllent/mailpit:v1.28
|
||||||
|
container_name: bilhej-mailpit-e2e
|
||||||
|
networks:
|
||||||
|
- e2e
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
|
image: bilhej-backend-e2e
|
||||||
build:
|
build:
|
||||||
dockerfile: docker/backend.e2e.Dockerfile
|
dockerfile: docker/backend.e2e.Dockerfile
|
||||||
context: .
|
context: .
|
||||||
|
|
@ -34,13 +41,22 @@ services:
|
||||||
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
|
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
|
||||||
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET}
|
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET}
|
||||||
STRIPE_PRICE_ID: ${STRIPE_PRICE_ID}
|
STRIPE_PRICE_ID: ${STRIPE_PRICE_ID}
|
||||||
|
APP_PUBLIC_BASE_URL: http://frontend
|
||||||
|
MAIL_HOST: mailpit
|
||||||
|
MAIL_PORT: "1025"
|
||||||
|
MAIL_USERNAME: ""
|
||||||
|
MAIL_PASSWORD: ""
|
||||||
|
MAIL_FROM: noreply@bilhej.se
|
||||||
networks:
|
networks:
|
||||||
- e2e
|
- e2e
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
mailpit:
|
||||||
|
condition: service_started
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
|
image: bilhej-frontend-e2e
|
||||||
build:
|
build:
|
||||||
dockerfile: docker/frontend.e2e.Dockerfile
|
dockerfile: docker/frontend.e2e.Dockerfile
|
||||||
context: .
|
context: .
|
||||||
|
|
@ -51,6 +67,7 @@ services:
|
||||||
- backend
|
- backend
|
||||||
|
|
||||||
playwright:
|
playwright:
|
||||||
|
image: bilhej-playwright-e2e
|
||||||
build:
|
build:
|
||||||
dockerfile: docker/playwright.e2e.Dockerfile
|
dockerfile: docker/playwright.e2e.Dockerfile
|
||||||
context: .
|
context: .
|
||||||
|
|
@ -58,12 +75,19 @@ services:
|
||||||
ipc: host
|
ipc: host
|
||||||
environment:
|
environment:
|
||||||
PLAYWRIGHT_BASE_URL: http://frontend
|
PLAYWRIGHT_BASE_URL: http://frontend
|
||||||
|
MAILPIT_API_URL: http://mailpit:8025
|
||||||
networks:
|
networks:
|
||||||
- e2e
|
- e2e
|
||||||
depends_on:
|
depends_on:
|
||||||
- frontend
|
- frontend
|
||||||
|
- mailpit
|
||||||
command: >-
|
command: >-
|
||||||
sh -c "
|
sh -c "
|
||||||
|
echo 'Waiting for mailpit...';
|
||||||
|
for i in \$(seq 1 30); do
|
||||||
|
curl -sf http://mailpit:8025/api/v1/info > /dev/null && break;
|
||||||
|
sleep 1;
|
||||||
|
done;
|
||||||
echo 'Waiting for backend...';
|
echo 'Waiting for backend...';
|
||||||
for i in \$(seq 1 60); do
|
for i in \$(seq 1 60); do
|
||||||
curl -s http://backend:8080/api/vehicles/ZZZ999 > /dev/null && break;
|
curl -s http://backend:8080/api/vehicles/ZZZ999 > /dev/null && break;
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,12 @@ services:
|
||||||
SWISH_NUMBER: ${SWISH_NUMBER}
|
SWISH_NUMBER: ${SWISH_NUMBER}
|
||||||
ADMIN_EMAIL: ${ADMIN_EMAIL}
|
ADMIN_EMAIL: ${ADMIN_EMAIL}
|
||||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||||
|
APP_PUBLIC_BASE_URL: ${APP_PUBLIC_BASE_URL:-https://bilhej.se}
|
||||||
|
MAIL_HOST: ${MAIL_HOST:-}
|
||||||
|
MAIL_PORT: ${MAIL_PORT:-587}
|
||||||
|
MAIL_USERNAME: ${MAIL_USERNAME:-}
|
||||||
|
MAIL_PASSWORD: ${MAIL_PASSWORD:-}
|
||||||
|
MAIL_FROM: ${MAIL_FROM:-noreply@bilhej.se}
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,15 @@ services:
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
|
mailpit:
|
||||||
|
image: ghcr.io/axllent/mailpit:v1.28
|
||||||
|
container_name: bilhej-mailpit
|
||||||
|
ports:
|
||||||
|
- "1025:1025"
|
||||||
|
- "8025:8025"
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
|
image: bilhej-backend-dev
|
||||||
build:
|
build:
|
||||||
dockerfile: docker/backend.Dockerfile
|
dockerfile: docker/backend.Dockerfile
|
||||||
context: .
|
context: .
|
||||||
|
|
@ -33,9 +41,17 @@ services:
|
||||||
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
|
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
|
||||||
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET}
|
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET}
|
||||||
STRIPE_PRICE_ID: ${STRIPE_PRICE_ID}
|
STRIPE_PRICE_ID: ${STRIPE_PRICE_ID}
|
||||||
|
APP_PUBLIC_BASE_URL: ${APP_PUBLIC_BASE_URL:-http://localhost:3000}
|
||||||
|
MAIL_HOST: mailpit
|
||||||
|
MAIL_PORT: "1025"
|
||||||
|
MAIL_USERNAME: ""
|
||||||
|
MAIL_PASSWORD: ""
|
||||||
|
MAIL_FROM: ${MAIL_FROM:-noreply@bilhej.se}
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
mailpit:
|
||||||
|
condition: service_started
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
- backend-gradle-project:/app/.gradle
|
- backend-gradle-project:/app/.gradle
|
||||||
|
|
@ -43,6 +59,7 @@ services:
|
||||||
- gradle-cache:/root/.gradle
|
- gradle-cache:/root/.gradle
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
|
image: bilhej-frontend-dev
|
||||||
build:
|
build:
|
||||||
dockerfile: docker/frontend.Dockerfile
|
dockerfile: docker/frontend.Dockerfile
|
||||||
context: .
|
context: .
|
||||||
|
|
|
||||||
58
docs/production-email-checklist.md
Normal file
58
docs/production-email-checklist.md
Normal 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`).
|
||||||
105
frontend/e2e/helpers/mailpit.ts
Normal file
105
frontend/e2e/helpers/mailpit.ts
Normal 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))
|
||||||
|
}
|
||||||
179
frontend/e2e/password-reset.spec.ts
Normal file
179
frontend/e2e/password-reset.spec.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -25,6 +25,11 @@ function createTestRouter() {
|
||||||
name: 'orders',
|
name: 'orders',
|
||||||
component: { template: '<div>Orders</div>' },
|
component: { template: '<div>Orders</div>' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/andra-losenord',
|
||||||
|
name: 'change-password',
|
||||||
|
component: { template: '<div>Change password</div>' },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/admin',
|
path: '/admin',
|
||||||
name: 'admin',
|
name: 'admin',
|
||||||
|
|
@ -166,6 +171,16 @@ describe('AppHeader', () => {
|
||||||
expect(ordersLink?.text()).toBe('Mina beställningar')
|
expect(ordersLink?.text()).toBe('Mina beställningar')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('shows change password link', () => {
|
||||||
|
const { wrapper } = mountAuthenticated()
|
||||||
|
const links = wrapper.findAll('a')
|
||||||
|
const changeLink = links.find(
|
||||||
|
(a) => a.attributes('href') === '/andra-losenord',
|
||||||
|
)
|
||||||
|
expect(changeLink).toBeTruthy()
|
||||||
|
expect(changeLink?.text()).toBe('Byt lösenord')
|
||||||
|
})
|
||||||
|
|
||||||
it('does not show admin link for regular user', () => {
|
it('does not show admin link for regular user', () => {
|
||||||
const { wrapper } = mountAuthenticated('user')
|
const { wrapper } = mountAuthenticated('user')
|
||||||
const links = wrapper.findAll('a')
|
const links = wrapper.findAll('a')
|
||||||
|
|
|
||||||
113
frontend/src/__tests__/ForgotPasswordPage.spec.ts
Normal file
113
frontend/src/__tests__/ForgotPasswordPage.spec.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -23,6 +23,11 @@ function createTestRouter() {
|
||||||
name: 'register',
|
name: 'register',
|
||||||
component: { template: '<div>Register</div>' },
|
component: { template: '<div>Register</div>' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/glomt-losenord',
|
||||||
|
name: 'forgot-password',
|
||||||
|
component: { template: '<div>Forgot</div>' },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/compose',
|
path: '/compose',
|
||||||
name: 'compose',
|
name: 'compose',
|
||||||
|
|
@ -168,6 +173,13 @@ describe('LoginPage', () => {
|
||||||
expect(wrapper.text()).toContain('Har du inget konto?')
|
expect(wrapper.text()).toContain('Har du inget konto?')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('renders forgot password link to /glomt-losenord', async () => {
|
||||||
|
const { wrapper } = mountPage()
|
||||||
|
const link = wrapper.find('a[href="/glomt-losenord"]')
|
||||||
|
expect(link.exists()).toBe(true)
|
||||||
|
expect(link.text()).toContain('Glömt lösenord?')
|
||||||
|
})
|
||||||
|
|
||||||
it('redirects to query param after login', async () => {
|
it('redirects to query param after login', async () => {
|
||||||
const router = createTestRouter()
|
const router = createTestRouter()
|
||||||
await router.push({ path: '/logga-in', query: { redirect: '/compose' } })
|
await router.push({ path: '/logga-in', query: { redirect: '/compose' } })
|
||||||
|
|
|
||||||
122
frontend/src/__tests__/ResetPasswordPage.spec.ts
Normal file
122
frontend/src/__tests__/ResetPasswordPage.spec.ts
Normal 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',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -26,6 +26,18 @@ describe('Router', () => {
|
||||||
expect(router.currentRoute.value.name).toBe('login')
|
expect(router.currentRoute.value.name).toBe('login')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('resolves /glomt-losenord to ForgotPasswordPage', async () => {
|
||||||
|
await router.push('/glomt-losenord')
|
||||||
|
await router.isReady()
|
||||||
|
expect(router.currentRoute.value.name).toBe('forgot-password')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves /aterstall-losenord to ResetPasswordPage', async () => {
|
||||||
|
await router.push('/aterstall-losenord?token=abc')
|
||||||
|
await router.isReady()
|
||||||
|
expect(router.currentRoute.value.name).toBe('reset-password')
|
||||||
|
})
|
||||||
|
|
||||||
it('resolves /orders to OrdersPage', async () => {
|
it('resolves /orders to OrdersPage', async () => {
|
||||||
localStorage.setItem('auth_token', makeJwt({ role: 'user' }))
|
localStorage.setItem('auth_token', makeJwt({ role: 'user' }))
|
||||||
await router.push('/orders')
|
await router.push('/orders')
|
||||||
|
|
@ -33,6 +45,13 @@ describe('Router', () => {
|
||||||
expect(router.currentRoute.value.name).toBe('orders')
|
expect(router.currentRoute.value.name).toBe('orders')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('resolves /andra-losenord to ChangePasswordPage when authenticated', async () => {
|
||||||
|
localStorage.setItem('auth_token', makeJwt({ role: 'user' }))
|
||||||
|
await router.push('/andra-losenord')
|
||||||
|
await router.isReady()
|
||||||
|
expect(router.currentRoute.value.name).toBe('change-password')
|
||||||
|
})
|
||||||
|
|
||||||
it('resolves /admin to AdminPage for admin user', async () => {
|
it('resolves /admin to AdminPage for admin user', async () => {
|
||||||
localStorage.setItem('auth_token', makeJwt({ role: 'admin' }))
|
localStorage.setItem('auth_token', makeJwt({ role: 'admin' }))
|
||||||
await router.push('/admin')
|
await router.push('/admin')
|
||||||
|
|
@ -67,6 +86,13 @@ describe('Router guards', () => {
|
||||||
expect(router.currentRoute.value.query.redirect).toBe('/orders')
|
expect(router.currentRoute.value.query.redirect).toBe('/orders')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('redirects unauthenticated user from /andra-losenord to /logga-in', async () => {
|
||||||
|
await router.push('/andra-losenord')
|
||||||
|
await router.isReady()
|
||||||
|
expect(router.currentRoute.value.name).toBe('login')
|
||||||
|
expect(router.currentRoute.value.query.redirect).toBe('/andra-losenord')
|
||||||
|
})
|
||||||
|
|
||||||
it('redirects unauthenticated user from /admin to /logga-in', async () => {
|
it('redirects unauthenticated user from /admin to /logga-in', async () => {
|
||||||
await router.push('/admin')
|
await router.push('/admin')
|
||||||
await router.isReady()
|
await router.isReady()
|
||||||
|
|
@ -102,6 +128,26 @@ describe('Router guards', () => {
|
||||||
expect(router.currentRoute.value.name).toBe('home')
|
expect(router.currentRoute.value.name).toBe('home')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('redirects authenticated user from /glomt-losenord to home', async () => {
|
||||||
|
localStorage.setItem('auth_token', makeJwt({ role: 'user' }))
|
||||||
|
await router.push('/glomt-losenord')
|
||||||
|
await router.isReady()
|
||||||
|
expect(router.currentRoute.value.name).toBe('home')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('redirects authenticated user from /aterstall-losenord to home', async () => {
|
||||||
|
localStorage.setItem('auth_token', makeJwt({ role: 'user' }))
|
||||||
|
await router.push('/aterstall-losenord?token=abc')
|
||||||
|
await router.isReady()
|
||||||
|
expect(router.currentRoute.value.name).toBe('home')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows unauthenticated user to access reset password with token', async () => {
|
||||||
|
await router.push('/aterstall-losenord?token=abc')
|
||||||
|
await router.isReady()
|
||||||
|
expect(router.currentRoute.value.name).toBe('reset-password')
|
||||||
|
})
|
||||||
|
|
||||||
it('redirects non-admin user from /admin to home', async () => {
|
it('redirects non-admin user from /admin to home', async () => {
|
||||||
localStorage.setItem('auth_token', makeJwt({ role: 'user' }))
|
localStorage.setItem('auth_token', makeJwt({ role: 'user' }))
|
||||||
await router.push('/admin')
|
await router.push('/admin')
|
||||||
|
|
|
||||||
67
frontend/src/__tests__/authApi.spec.ts
Normal file
67
frontend/src/__tests__/authApi.spec.ts
Normal 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',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -20,3 +20,39 @@ export function login(email: string, password: string): Promise<AuthResponse> {
|
||||||
body: JSON.stringify({ email, password }),
|
body: JSON.stringify({ email, password }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MessageResponse {
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Optional testToken is returned only when backend expose-token is enabled (E2E). */
|
||||||
|
export interface ForgotPasswordResponse extends MessageResponse {
|
||||||
|
testToken?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function forgotPassword(email: string): Promise<ForgotPasswordResponse> {
|
||||||
|
return request<ForgotPasswordResponse>('/auth/forgot-password', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetPassword(
|
||||||
|
token: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<MessageResponse> {
|
||||||
|
return request<MessageResponse>('/auth/reset-password', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ token, password }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function changePassword(
|
||||||
|
currentPassword: string,
|
||||||
|
newPassword: string,
|
||||||
|
): Promise<MessageResponse> {
|
||||||
|
return request<MessageResponse>('/auth/change-password', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ currentPassword, newPassword }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,9 @@ function handleLogout() {
|
||||||
<RouterLink to="/orders" class="app-header__link"
|
<RouterLink to="/orders" class="app-header__link"
|
||||||
>Mina beställningar</RouterLink
|
>Mina beställningar</RouterLink
|
||||||
>
|
>
|
||||||
|
<RouterLink to="/andra-losenord" class="app-header__link"
|
||||||
|
>Byt lösenord</RouterLink
|
||||||
|
>
|
||||||
<span class="app-header__email">{{ auth.email }}</span>
|
<span class="app-header__email">{{ auth.email }}</span>
|
||||||
<button class="app-header__logout" @click="handleLogout">
|
<button class="app-header__logout" @click="handleLogout">
|
||||||
Logga ut
|
Logga ut
|
||||||
|
|
|
||||||
190
frontend/src/pages/ChangePasswordPage.vue
Normal file
190
frontend/src/pages/ChangePasswordPage.vue
Normal 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>
|
||||||
129
frontend/src/pages/ForgotPasswordPage.vue
Normal file
129
frontend/src/pages/ForgotPasswordPage.vue
Normal 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 så 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>
|
||||||
|
|
@ -62,7 +62,12 @@ async function handleSubmit() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
|
<div class="field__label-row">
|
||||||
<label for="password" class="field__label">Lösenord</label>
|
<label for="password" class="field__label">Lösenord</label>
|
||||||
|
<RouterLink to="/glomt-losenord" class="field__link">
|
||||||
|
Glömt lösenord?
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
id="password"
|
id="password"
|
||||||
v-model="password"
|
v-model="password"
|
||||||
|
|
@ -138,4 +143,21 @@ async function handleSubmit() {
|
||||||
.login__submit {
|
.login__submit {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.field__label-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field__link {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field__link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
191
frontend/src/pages/ResetPasswordPage.vue
Normal file
191
frontend/src/pages/ResetPasswordPage.vue
Normal 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>
|
||||||
|
|
@ -5,6 +5,9 @@ import AboutPage from '@/pages/AboutPage.vue'
|
||||||
import ContactPage from '@/pages/ContactPage.vue'
|
import ContactPage from '@/pages/ContactPage.vue'
|
||||||
import RegisterPage from '@/pages/RegisterPage.vue'
|
import RegisterPage from '@/pages/RegisterPage.vue'
|
||||||
import LoginPage from '@/pages/LoginPage.vue'
|
import LoginPage from '@/pages/LoginPage.vue'
|
||||||
|
import ForgotPasswordPage from '@/pages/ForgotPasswordPage.vue'
|
||||||
|
import ResetPasswordPage from '@/pages/ResetPasswordPage.vue'
|
||||||
|
import ChangePasswordPage from '@/pages/ChangePasswordPage.vue'
|
||||||
import OrdersPage from '@/pages/OrdersPage.vue'
|
import OrdersPage from '@/pages/OrdersPage.vue'
|
||||||
import AdminPage from '@/pages/AdminPage.vue'
|
import AdminPage from '@/pages/AdminPage.vue'
|
||||||
import PaymentRedirect from '@/pages/PaymentRedirect.vue'
|
import PaymentRedirect from '@/pages/PaymentRedirect.vue'
|
||||||
|
|
@ -31,6 +34,12 @@ const router = createRouter({
|
||||||
component: OrdersPage,
|
component: OrdersPage,
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/andra-losenord',
|
||||||
|
name: 'change-password',
|
||||||
|
component: ChangePasswordPage,
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/admin',
|
path: '/admin',
|
||||||
name: 'admin',
|
name: 'admin',
|
||||||
|
|
@ -55,6 +64,18 @@ const router = createRouter({
|
||||||
component: LoginPage,
|
component: LoginPage,
|
||||||
meta: { guestOnly: true },
|
meta: { guestOnly: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/glomt-losenord',
|
||||||
|
name: 'forgot-password',
|
||||||
|
component: ForgotPasswordPage,
|
||||||
|
meta: { guestOnly: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/aterstall-losenord',
|
||||||
|
name: 'reset-password',
|
||||||
|
component: ResetPasswordPage,
|
||||||
|
meta: { guestOnly: true },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/om',
|
path: '/om',
|
||||||
name: 'about',
|
name: 'about',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue