Compare commits
No commits in common. "86fb946e336a0961d4498dbb20d2b3e2bf2a9e15" and "fb9713d8d8ac283e866f51b0aefb83a4d8c0ebcf" have entirely different histories.
86fb946e33
...
fb9713d8d8
46 changed files with 8 additions and 2155 deletions
14
.env.example
14
.env.example
|
|
@ -26,20 +26,8 @@ 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,12 +187,6 @@ 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
|
||||||
|
|
|
||||||
81
README.md
81
README.md
|
|
@ -41,7 +41,6 @@ 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
|
||||||
|
|
||||||
|
|
@ -65,11 +64,6 @@ 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.
|
||||||
|
|
@ -98,12 +92,6 @@ 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`) |
|
||||||
|
|
||||||
|
|
@ -181,57 +169,6 @@ 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
|
||||||
|
|
@ -348,18 +285,9 @@ Before the first deploy, complete these steps on the production server (`srvr.nu
|
||||||
|
|
||||||
Set `bilhej.se` (and `www.bilhej.se`) A record to the server's public IP.
|
Set `bilhej.se` (and `www.bilhej.se`) A record to the server's public IP.
|
||||||
|
|
||||||
3. **Add HTTP-only Nginx vhost** (required before certs exist)
|
3. **Obtain SSL Certificate**
|
||||||
|
|
||||||
The full [`docker/bilhej.nginx.conf`](docker/bilhej.nginx.conf) references TLS files that do not
|
Run certbot in the nginx container:
|
||||||
exist yet. Deploy the HTTP-only config first:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker cp docker/bilhej.nginx.http.conf nginx:/etc/nginx/conf.d/bilhej.conf
|
|
||||||
docker exec nginx nginx -t
|
|
||||||
docker exec nginx nginx -s reload
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Obtain SSL Certificate**
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker exec certbot certbot certonly \
|
docker exec certbot certbot certonly \
|
||||||
|
|
@ -367,11 +295,12 @@ Before the first deploy, complete these steps on the production server (`srvr.nu
|
||||||
-d bilhej.se -d www.bilhej.se
|
-d bilhej.se -d www.bilhej.se
|
||||||
```
|
```
|
||||||
|
|
||||||
5. **Enable HTTPS proxy to the frontend**
|
4. **Add Nginx Config**
|
||||||
|
|
||||||
|
Copy the Bilhej server block into the nginx container:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker cp docker/bilhej.nginx.conf nginx:/etc/nginx/conf.d/bilhej.conf
|
docker cp docker/bilhej.nginx.conf nginx:/etc/nginx/conf.d/bilhej.conf
|
||||||
docker exec nginx nginx -t
|
|
||||||
docker exec nginx nginx -s reload
|
docker exec nginx nginx -s reload
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@ 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,12 +34,7 @@ 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(
|
.requestMatchers("/api/auth/register", "/api/auth/login").permitAll()
|
||||||
"/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,23 +4,15 @@ 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
|
||||||
|
|
@ -29,12 +21,8 @@ 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());
|
||||||
|
|
@ -48,27 +36,4 @@ 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."));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
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) {}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
package se.bilhalsning.dto;
|
|
||||||
|
|
||||||
import jakarta.validation.constraints.Email;
|
|
||||||
import jakarta.validation.constraints.NotBlank;
|
|
||||||
|
|
||||||
public record ForgotPasswordRequest(@NotBlank @Email String email) {}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
package se.bilhalsning.dto;
|
|
||||||
|
|
||||||
public record MessageResponse(String message) {}
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
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
|
|
||||||
) {}
|
|
||||||
|
|
@ -1,95 +0,0 @@
|
||||||
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,14 +21,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
package se.bilhalsning.exception;
|
|
||||||
|
|
||||||
public class PasswordResetTokenInvalidException extends RuntimeException {
|
|
||||||
|
|
||||||
public PasswordResetTokenInvalidException() {
|
|
||||||
super("Återställningslänken är ogiltig eller har gått ut");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
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,17 +40,4 @@ 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,21 +15,9 @@ 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,22 +24,7 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
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,6 +1,5 @@
|
||||||
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;
|
||||||
|
|
@ -12,7 +11,6 @@ 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;
|
||||||
|
|
@ -21,8 +19,6 @@ 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
|
||||||
|
|
@ -37,9 +33,6 @@ class AuthControllerTest {
|
||||||
@MockitoBean
|
@MockitoBean
|
||||||
private UserService userService;
|
private UserService userService;
|
||||||
|
|
||||||
@MockitoBean
|
|
||||||
private PasswordResetService passwordResetService;
|
|
||||||
|
|
||||||
@MockitoBean
|
@MockitoBean
|
||||||
private JwtService jwtService;
|
private JwtService jwtService;
|
||||||
|
|
||||||
|
|
@ -167,60 +160,4 @@ 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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
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"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,128 +0,0 @@
|
||||||
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,37 +170,4 @@ 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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
app:
|
|
||||||
password-reset:
|
|
||||||
expose-token: true
|
|
||||||
|
|
@ -20,14 +20,7 @@ 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: .
|
||||||
|
|
@ -41,22 +34,13 @@ 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: .
|
||||||
|
|
@ -67,7 +51,6 @@ services:
|
||||||
- backend
|
- backend
|
||||||
|
|
||||||
playwright:
|
playwright:
|
||||||
image: bilhej-playwright-e2e
|
|
||||||
build:
|
build:
|
||||||
dockerfile: docker/playwright.e2e.Dockerfile
|
dockerfile: docker/playwright.e2e.Dockerfile
|
||||||
context: .
|
context: .
|
||||||
|
|
@ -75,19 +58,12 @@ 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,12 +34,6 @@ 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,15 +16,7 @@ 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: .
|
||||||
|
|
@ -41,17 +33,9 @@ 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
|
||||||
|
|
@ -59,7 +43,6 @@ 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: .
|
||||||
|
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
# Phase 1: HTTP only — use before Let's Encrypt certs exist.
|
|
||||||
# After certbot, replace with bilhej.nginx.conf (includes HTTPS).
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name bilhej.se www.bilhej.se;
|
|
||||||
|
|
||||||
location /.well-known/acme-challenge/ {
|
|
||||||
root /var/www/certbot;
|
|
||||||
}
|
|
||||||
|
|
||||||
location / {
|
|
||||||
return 301 https://$host$request_uri;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
# 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`).
|
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
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))
|
|
||||||
}
|
|
||||||
|
|
@ -1,179 +0,0 @@
|
||||||
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,11 +25,6 @@ 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',
|
||||||
|
|
@ -171,16 +166,6 @@ 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')
|
||||||
|
|
|
||||||
|
|
@ -1,113 +0,0 @@
|
||||||
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,11 +23,6 @@ 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',
|
||||||
|
|
@ -173,13 +168,6 @@ 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' } })
|
||||||
|
|
|
||||||
|
|
@ -1,122 +0,0 @@
|
||||||
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,18 +26,6 @@ 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')
|
||||||
|
|
@ -45,13 +33,6 @@ 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')
|
||||||
|
|
@ -86,13 +67,6 @@ 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()
|
||||||
|
|
@ -128,26 +102,6 @@ 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')
|
||||||
|
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
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,39 +20,3 @@ 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,9 +61,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -1,190 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,129 +0,0 @@
|
||||||
<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,12 +62,7 @@ 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"
|
||||||
|
|
@ -143,21 +138,4 @@ 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>
|
||||||
|
|
|
||||||
|
|
@ -1,191 +0,0 @@
|
||||||
<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,9 +5,6 @@ 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'
|
||||||
|
|
@ -34,12 +31,6 @@ 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',
|
||||||
|
|
@ -64,18 +55,6 @@ 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