Add password reset, logged-in change password, and Mailpit email dev/E2E.
Operators can fix prod admin passwords without email via Byt lösenord; end users can use forgot-password when SMTP is configured. Local and CI use Mailpit to capture outbound mail and verify reset links end-to-end. - Backend: V8 password_reset_tokens, PasswordResetService, EmailService, POST /api/auth/forgot-password, reset-password, change-password - Optional testToken in forgot-password response (docker profile only, for E2E) - Frontend: ForgotPasswordPage, ResetPasswordPage, ChangePasswordPage, routes, login link, header Byt lösenord - Mailpit (ghcr.io/axllent/mailpit:v1.28) in docker-compose + e2e stack - E2E: password-reset.spec.ts + Mailpit API helper tests SMTP delivery - Separate dev/e2e Docker image names to avoid overwriting bilhej-frontend - Docs: README email section, production-email-checklist, .env.example - Unit/integration tests for reset, change password, and Vitest page specs Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
45b2449b14
commit
86fb946e33
45 changed files with 2127 additions and 3 deletions
14
.env.example
14
.env.example
|
|
@ -26,8 +26,20 @@ STRIPE_PRICE_ID=price_...
|
|||
# ---------- Swish (Phase 0) ----------
|
||||
SWISH_NUMBER=0701234567
|
||||
|
||||
# ---------- App URL (password reset links in email) ----------
|
||||
APP_PUBLIC_BASE_URL=http://localhost:3000
|
||||
|
||||
# ---------- SMTP (local Docker uses Mailpit via docker-compose.yml) ----------
|
||||
# docker compose up → view mail at http://localhost:8025
|
||||
# Leave MAIL_HOST unset in .env to use compose defaults (mailpit).
|
||||
# Production: use Resend/Brevo SMTP — see README "Email (password reset)"
|
||||
# MAIL_HOST=smtp.resend.com
|
||||
# MAIL_PORT=587
|
||||
# MAIL_USERNAME=
|
||||
# MAIL_PASSWORD=
|
||||
# MAIL_FROM=noreply@bilhej.se
|
||||
|
||||
# ---------- Production admin (prod profile only) ----------
|
||||
# Strong password; never use test1234. Dev seeds use test@bilhej.se instead.
|
||||
ADMIN_EMAIL=admin@bilhej.se
|
||||
ADMIN_PASSWORD=change_me_to_a_strong_password
|
||||
|
||||
|
|
|
|||
|
|
@ -187,6 +187,12 @@ After the address is used to mail the letter, it must be deleted. The Order
|
|||
entity must NOT have an address field. The address lookup and mailing are
|
||||
external/human processes in Phase 0.
|
||||
|
||||
### Local email (Mailpit)
|
||||
`docker compose up` includes Mailpit (`ghcr.io/axllent/mailpit:v1.28`); password-reset mail appears at http://localhost:8025. E2E verifies SMTP via Mailpit API (`frontend/e2e/helpers/mailpit.ts`). Production uses transactional SMTP (Resend/Brevo)—see README.
|
||||
|
||||
### Password reset test token (never in production)
|
||||
`app.password-reset.expose-token` must stay **false** in prod/default; it is only enabled in `application-docker.yml` for CI E2E so Playwright can read `testToken` from the forgot-password response.
|
||||
|
||||
### Stripe webhook signature verification
|
||||
Always verify `stripe-signature` header using `STRIPE_WEBHOOK_SECRET`.
|
||||
Webhook endpoint is public (no auth). Without signature verification an
|
||||
|
|
|
|||
63
README.md
63
README.md
|
|
@ -41,6 +41,7 @@ The app will be available at:
|
|||
- Frontend: `http://localhost:3000`
|
||||
- Backend API: `http://localhost:8080`
|
||||
- PostgreSQL: `localhost:5432`
|
||||
- Mailpit (dev SMTP inbox): `http://localhost:8025`
|
||||
|
||||
### Architecture inside Docker Compose
|
||||
|
||||
|
|
@ -64,6 +65,11 @@ The app will be available at:
|
|||
│ │ postgres (16) │
|
||||
│ │ :5432 │
|
||||
│ └──────────────────┘
|
||||
│ ┌──────────────────┐
|
||||
│ │ mailpit │
|
||||
│ │ SMTP :1025 │
|
||||
│ │ UI :8025 │
|
||||
│ └──────────────────┘
|
||||
```
|
||||
|
||||
**Vite proxy:** The Vite dev server proxies `/api/*` requests to the backend container.
|
||||
|
|
@ -92,6 +98,12 @@ Copy `.env.example` to `.env` and fill in:
|
|||
| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret |
|
||||
| `STRIPE_PRICE_ID` | Stripe price ID for single letter |
|
||||
| `SWISH_NUMBER` | Swish number for payment instructions |
|
||||
| `APP_PUBLIC_BASE_URL` | Base URL for password-reset links (dev: `http://localhost:3000`) |
|
||||
| `MAIL_HOST` | SMTP host (Docker dev uses `mailpit` automatically; leave empty to log links only) |
|
||||
| `MAIL_PORT` | SMTP port (`1025` for Mailpit, `587` for most providers) |
|
||||
| `MAIL_USERNAME` | SMTP username (empty for Mailpit) |
|
||||
| `MAIL_PASSWORD` | SMTP password (empty for Mailpit) |
|
||||
| `MAIL_FROM` | From address (e.g. `noreply@bilhej.se`) |
|
||||
| `ADMIN_EMAIL` | Production admin login (e.g. `admin@bilhej.se`) |
|
||||
| `ADMIN_PASSWORD` | Strong production admin password (not `test1234`) |
|
||||
|
||||
|
|
@ -169,6 +181,57 @@ WHERE email IN ('test@bilhalsning.se', 'test@bilhej.se', 'admin@bilhalsning.se')
|
|||
Then deploy with `ADMIN_EMAIL` / `ADMIN_PASSWORD` set — the app creates the production
|
||||
admin on startup. No need to insert a password hash by hand.
|
||||
|
||||
### Email (password reset)
|
||||
|
||||
The app only needs to **send** mail (forgot-password). You do not need Office 365 or a mailbox on
|
||||
the server unless you want human addresses like `support@bilhej.se`.
|
||||
|
||||
**Local Docker (Mailpit):** `docker compose up` starts [Mailpit](https://mailpit.axllent.org/)
|
||||
(`ghcr.io/axllent/mailpit:v1.28`). All outbound mail is captured—nothing is sent to the internet.
|
||||
|
||||
If `docker compose pull` fails on Docker Hub, pull explicitly:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/axllent/mailpit:v1.28
|
||||
```
|
||||
|
||||
1. Open **http://localhost:8025**
|
||||
2. Use **Glömt lösenord?** on the login page (or **Byt lösenord** in the header when logged in)
|
||||
3. Open the message in Mailpit and click the reset link
|
||||
|
||||
To disable Mailpit and log links only, remove `MAIL_HOST` from the backend service in
|
||||
`docker-compose.yml` or set `MAIL_HOST=` in `.env`.
|
||||
|
||||
**Production (transactional provider):** Use [Resend](https://resend.com) or
|
||||
[Brevo](https://www.brevo.com)—not a self-hosted mail server on the VPS.
|
||||
|
||||
1. Sign up and add domain **bilhej.se**
|
||||
2. Add the provider’s **SPF** and **DKIM** DNS records at your registrar (no MX needed for send-only)
|
||||
3. Create SMTP credentials in the provider dashboard
|
||||
4. On the production server `.env` (or Forgejo deploy secrets):
|
||||
|
||||
```bash
|
||||
APP_PUBLIC_BASE_URL=https://bilhej.se
|
||||
MAIL_HOST=smtp.resend.com # example; use your provider’s host
|
||||
MAIL_PORT=587
|
||||
MAIL_USERNAME=resend # example
|
||||
MAIL_PASSWORD=re_xxxxxxxx
|
||||
MAIL_FROM=noreply@bilhej.se
|
||||
```
|
||||
|
||||
5. Deploy via **Deploy to Production**, then test forgot-password on https://bilhej.se
|
||||
|
||||
See [docs/production-email-checklist.md](docs/production-email-checklist.md) for a step-by-step operator checklist.
|
||||
|
||||
If SMTP is not configured (`MAIL_HOST` empty), the reset link is written to the backend log:
|
||||
|
||||
```bash
|
||||
docker logs bilhej-backend-prod 2>&1 | grep "Password reset link"
|
||||
```
|
||||
|
||||
Optional later: real inboxes (`support@bilhej.se`) via Migadu, Fastmail, or Purelymail—separate
|
||||
from app `MAIL_*` (different MX records).
|
||||
|
||||
To generate a bcrypt hash manually (optional):
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ dependencies {
|
|||
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-flyway'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-security'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-mail'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-webmvc'
|
||||
implementation 'org.flywaydb:flyway-database-postgresql'
|
||||
|
|
|
|||
|
|
@ -34,7 +34,12 @@ public class SecurityConfig {
|
|||
.csrf(csrf -> csrf.disable())
|
||||
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/api/auth/register", "/api/auth/login").permitAll()
|
||||
.requestMatchers(
|
||||
"/api/auth/register",
|
||||
"/api/auth/login",
|
||||
"/api/auth/forgot-password",
|
||||
"/api/auth/reset-password")
|
||||
.permitAll()
|
||||
.requestMatchers("/api/webhooks/**").permitAll()
|
||||
.requestMatchers("/api/payment/swish-info").permitAll()
|
||||
.requestMatchers("/api/vehicles/**").permitAll()
|
||||
|
|
|
|||
|
|
@ -4,15 +4,23 @@ import jakarta.validation.Valid;
|
|||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import se.bilhalsning.dto.AuthResponse;
|
||||
import se.bilhalsning.dto.ChangePasswordRequest;
|
||||
import se.bilhalsning.dto.ForgotPasswordRequest;
|
||||
import se.bilhalsning.dto.LoginRequest;
|
||||
import se.bilhalsning.dto.ForgotPasswordResponse;
|
||||
import se.bilhalsning.dto.MessageResponse;
|
||||
import se.bilhalsning.dto.RegisterRequest;
|
||||
import se.bilhalsning.dto.ResetPasswordRequest;
|
||||
import se.bilhalsning.entity.User;
|
||||
import se.bilhalsning.security.JwtService;
|
||||
import se.bilhalsning.service.PasswordResetService;
|
||||
import se.bilhalsning.service.UserService;
|
||||
|
||||
@RestController
|
||||
|
|
@ -21,8 +29,12 @@ import se.bilhalsning.service.UserService;
|
|||
public class AuthController {
|
||||
|
||||
private final UserService userService;
|
||||
private final PasswordResetService passwordResetService;
|
||||
private final JwtService jwtService;
|
||||
|
||||
private static final String FORGOT_PASSWORD_MESSAGE =
|
||||
"Om e-postadressen finns har vi skickat instruktioner för att återställa lösenordet.";
|
||||
|
||||
@PostMapping("/register")
|
||||
public ResponseEntity<AuthResponse> register(@Valid @RequestBody RegisterRequest request) {
|
||||
userService.createUser(request.email(), request.password());
|
||||
|
|
@ -36,4 +48,27 @@ public class AuthController {
|
|||
String token = jwtService.generateToken(user.getEmail(), user.getRole());
|
||||
return ResponseEntity.ok(new AuthResponse(token));
|
||||
}
|
||||
|
||||
@PostMapping("/forgot-password")
|
||||
public ResponseEntity<ForgotPasswordResponse> forgotPassword(
|
||||
@Valid @RequestBody ForgotPasswordRequest request) {
|
||||
return ResponseEntity.ok(ForgotPasswordResponse.of(
|
||||
FORGOT_PASSWORD_MESSAGE, passwordResetService.requestReset(request.email())));
|
||||
}
|
||||
|
||||
@PostMapping("/reset-password")
|
||||
public ResponseEntity<MessageResponse> resetPassword(
|
||||
@Valid @RequestBody ResetPasswordRequest request) {
|
||||
passwordResetService.resetPassword(request.token(), request.password());
|
||||
return ResponseEntity.ok(new MessageResponse("Lösenordet har uppdaterats. Du kan nu logga in."));
|
||||
}
|
||||
|
||||
@PostMapping("/change-password")
|
||||
public ResponseEntity<MessageResponse> changePassword(
|
||||
@Valid @RequestBody ChangePasswordRequest request,
|
||||
@AuthenticationPrincipal UserDetails principal) {
|
||||
userService.changePassword(
|
||||
principal.getUsername(), request.currentPassword(), request.newPassword());
|
||||
return ResponseEntity.ok(new MessageResponse("Lösenordet har uppdaterats."));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
package se.bilhalsning.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public record ChangePasswordRequest(
|
||||
@NotBlank String currentPassword,
|
||||
@NotBlank @Size(min = 8) String newPassword) {}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package se.bilhalsning.dto;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record ForgotPasswordRequest(@NotBlank @Email String email) {}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package se.bilhalsning.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import java.util.Optional;
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public record ForgotPasswordResponse(String message, String testToken) {
|
||||
|
||||
public static ForgotPasswordResponse of(String message, Optional<String> testToken) {
|
||||
return new ForgotPasswordResponse(message, testToken.orElse(null));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package se.bilhalsning.dto;
|
||||
|
||||
public record MessageResponse(String message) {}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package se.bilhalsning.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public record ResetPasswordRequest(
|
||||
@NotBlank String token,
|
||||
@NotBlank @Size(min = 8) String password
|
||||
) {}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
package se.bilhalsning.entity;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.Table;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "password_reset_tokens")
|
||||
public class PasswordResetToken {
|
||||
|
||||
@Id
|
||||
@Column(name = "id", columnDefinition = "uuid", nullable = false, updatable = false)
|
||||
private UUID id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "user_id", nullable = false)
|
||||
private User user;
|
||||
|
||||
@Column(name = "token_hash", nullable = false, length = 64)
|
||||
private String tokenHash;
|
||||
|
||||
@Column(name = "expires_at", nullable = false)
|
||||
private Instant expiresAt;
|
||||
|
||||
@Column(name = "used_at")
|
||||
private Instant usedAt;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
if (this.id == null) {
|
||||
this.id = UUID.randomUUID();
|
||||
}
|
||||
if (this.createdAt == null) {
|
||||
this.createdAt = Instant.now();
|
||||
}
|
||||
}
|
||||
|
||||
public UUID getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(UUID id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public User getUser() {
|
||||
return user;
|
||||
}
|
||||
|
||||
public void setUser(User user) {
|
||||
this.user = user;
|
||||
}
|
||||
|
||||
public String getTokenHash() {
|
||||
return tokenHash;
|
||||
}
|
||||
|
||||
public void setTokenHash(String tokenHash) {
|
||||
this.tokenHash = tokenHash;
|
||||
}
|
||||
|
||||
public Instant getExpiresAt() {
|
||||
return expiresAt;
|
||||
}
|
||||
|
||||
public void setExpiresAt(Instant expiresAt) {
|
||||
this.expiresAt = expiresAt;
|
||||
}
|
||||
|
||||
public Instant getUsedAt() {
|
||||
return usedAt;
|
||||
}
|
||||
|
||||
public void setUsedAt(Instant usedAt) {
|
||||
this.usedAt = usedAt;
|
||||
}
|
||||
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(Instant createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
}
|
||||
|
|
@ -21,6 +21,14 @@ public class GlobalExceptionHandler {
|
|||
.body(new ErrorResponse(ex.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(PasswordResetTokenInvalidException.class)
|
||||
public ResponseEntity<ErrorResponse> handlePasswordResetTokenInvalid(
|
||||
PasswordResetTokenInvalidException ex) {
|
||||
return ResponseEntity
|
||||
.badRequest()
|
||||
.body(new ErrorResponse(ex.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(EmailAlreadyExistsException.class)
|
||||
public ResponseEntity<ErrorResponse> handleEmailAlreadyExists(EmailAlreadyExistsException ex) {
|
||||
return ResponseEntity
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
package se.bilhalsning.exception;
|
||||
|
||||
public class PasswordResetTokenInvalidException extends RuntimeException {
|
||||
|
||||
public PasswordResetTokenInvalidException() {
|
||||
super("Återställningslänken är ogiltig eller har gått ut");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
package se.bilhalsning.repository;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import se.bilhalsning.entity.PasswordResetToken;
|
||||
|
||||
public interface PasswordResetTokenRepository extends JpaRepository<PasswordResetToken, UUID> {
|
||||
|
||||
Optional<PasswordResetToken> findByTokenHashAndUsedAtIsNull(String tokenHash);
|
||||
|
||||
@Modifying
|
||||
@Query("DELETE FROM PasswordResetToken t WHERE t.user.id = :userId AND t.usedAt IS NULL")
|
||||
void deleteUnusedByUserId(@Param("userId") UUID userId);
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
package se.bilhalsning.service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.mail.MailException;
|
||||
import org.springframework.mail.SimpleMailMessage;
|
||||
import org.springframework.mail.javamail.JavaMailSender;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class EmailService {
|
||||
|
||||
private final JavaMailSender mailSender;
|
||||
|
||||
@Value("${spring.mail.host:}")
|
||||
private String mailHost;
|
||||
|
||||
@Value("${app.mail.from:noreply@bilhej.se}")
|
||||
private String mailFrom;
|
||||
|
||||
public EmailService(@Autowired(required = false) JavaMailSender mailSender) {
|
||||
this.mailSender = mailSender;
|
||||
}
|
||||
|
||||
public void sendPasswordResetEmail(String toEmail, String resetUrl) {
|
||||
String subject = "Återställ ditt lösenord – BilHej";
|
||||
String body = """
|
||||
Hej,
|
||||
|
||||
Du har begärt att återställa lösenordet för ditt BilHej-konto.
|
||||
|
||||
Öppna länken nedan för att välja ett nytt lösenord (giltig i 1 timme):
|
||||
|
||||
%s
|
||||
|
||||
Om du inte begärde detta kan du ignorera det här meddelandet.
|
||||
|
||||
Vänliga hälsningar,
|
||||
BilHej
|
||||
""".formatted(resetUrl);
|
||||
|
||||
if (mailHost == null || mailHost.isBlank() || mailSender == null) {
|
||||
log.info("SMTP not configured. Password reset link for {}: {}", toEmail, resetUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
SimpleMailMessage message = new SimpleMailMessage();
|
||||
message.setFrom(mailFrom);
|
||||
message.setTo(toEmail);
|
||||
message.setSubject(subject);
|
||||
message.setText(body);
|
||||
try {
|
||||
mailSender.send(message);
|
||||
} catch (MailException ex) {
|
||||
log.error("Failed to send password reset email to {}", toEmail, ex);
|
||||
throw new IllegalStateException("Kunde inte skicka e-post just nu");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
package se.bilhalsning.service;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Optional;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import se.bilhalsning.entity.PasswordResetToken;
|
||||
import se.bilhalsning.entity.User;
|
||||
import se.bilhalsning.exception.PasswordResetTokenInvalidException;
|
||||
import se.bilhalsning.repository.PasswordResetTokenRepository;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class PasswordResetService {
|
||||
|
||||
private static final int TOKEN_BYTES = 32;
|
||||
private static final long TOKEN_TTL_HOURS = 1;
|
||||
|
||||
private final UserService userService;
|
||||
private final PasswordResetTokenRepository tokenRepository;
|
||||
private final EmailService emailService;
|
||||
private final SecureRandom secureRandom = new SecureRandom();
|
||||
|
||||
@Value("${app.public-base-url:http://localhost:3000}")
|
||||
private String publicBaseUrl;
|
||||
|
||||
@Value("${app.password-reset.expose-token:false}")
|
||||
private boolean exposeToken;
|
||||
|
||||
@Transactional
|
||||
public Optional<String> requestReset(String email) {
|
||||
return userService.findByEmail(email).map(user -> {
|
||||
tokenRepository.deleteUnusedByUserId(user.getId());
|
||||
String rawToken = generateRawToken();
|
||||
PasswordResetToken entity = new PasswordResetToken();
|
||||
entity.setUser(user);
|
||||
entity.setTokenHash(hashToken(rawToken));
|
||||
entity.setExpiresAt(Instant.now().plusSeconds(TOKEN_TTL_HOURS * 3600));
|
||||
tokenRepository.save(entity);
|
||||
String resetUrl = publicBaseUrl.replaceAll("/$", "")
|
||||
+ "/aterstall-losenord?token="
|
||||
+ rawToken;
|
||||
emailService.sendPasswordResetEmail(user.getEmail(), resetUrl);
|
||||
return exposeToken ? Optional.of(rawToken) : Optional.<String>empty();
|
||||
}).orElse(Optional.empty());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void resetPassword(String rawToken, String newPassword) {
|
||||
PasswordResetToken token = tokenRepository
|
||||
.findByTokenHashAndUsedAtIsNull(hashToken(rawToken))
|
||||
.filter(t -> t.getExpiresAt().isAfter(Instant.now()))
|
||||
.orElseThrow(PasswordResetTokenInvalidException::new);
|
||||
|
||||
User user = token.getUser();
|
||||
userService.updatePassword(user, newPassword);
|
||||
token.setUsedAt(Instant.now());
|
||||
tokenRepository.deleteUnusedByUserId(user.getId());
|
||||
tokenRepository.save(token);
|
||||
}
|
||||
|
||||
String generateRawToken() {
|
||||
byte[] bytes = new byte[TOKEN_BYTES];
|
||||
secureRandom.nextBytes(bytes);
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||
}
|
||||
|
||||
static String hashToken(String rawToken) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] hash = digest.digest(rawToken.getBytes(StandardCharsets.UTF_8));
|
||||
return HexFormat.of().formatHex(hash);
|
||||
} catch (NoSuchAlgorithmException ex) {
|
||||
throw new IllegalStateException("SHA-256 not available", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -40,4 +40,17 @@ public class UserService {
|
|||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
public void updatePassword(User user, String newPassword) {
|
||||
user.setPasswordHash(passwordEncoder.encode(newPassword));
|
||||
userRepository.save(user);
|
||||
}
|
||||
|
||||
public void changePassword(String email, String currentPassword, String newPassword) {
|
||||
User user = findByEmail(email).orElseThrow(InvalidCredentialsException::new);
|
||||
if (!passwordEncoder.matches(currentPassword, user.getPasswordHash())) {
|
||||
throw new InvalidCredentialsException();
|
||||
}
|
||||
updatePassword(user, newPassword);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,9 +15,21 @@ spring:
|
|||
jpa:
|
||||
database-platform: org.hibernate.dialect.PostgreSQLDialect
|
||||
|
||||
mail:
|
||||
properties:
|
||||
mail:
|
||||
smtp:
|
||||
auth: false
|
||||
starttls:
|
||||
enable: false
|
||||
|
||||
app:
|
||||
payment:
|
||||
swish-number: ${SWISH_NUMBER:0700000000}
|
||||
letter-price: 49
|
||||
jwt:
|
||||
secret: ${JWT_SECRET}
|
||||
public-base-url: ${APP_PUBLIC_BASE_URL:http://frontend}
|
||||
# E2E only: never enable in production (see application-prod.yml).
|
||||
password-reset:
|
||||
expose-token: true
|
||||
|
|
|
|||
|
|
@ -24,7 +24,22 @@ spring:
|
|||
enabled: true
|
||||
locations: classpath:db/migration,classpath:db/dev-migration
|
||||
|
||||
mail:
|
||||
host: ${MAIL_HOST:}
|
||||
port: ${MAIL_PORT:587}
|
||||
username: ${MAIL_USERNAME:}
|
||||
password: ${MAIL_PASSWORD:}
|
||||
properties:
|
||||
mail:
|
||||
smtp:
|
||||
auth: true
|
||||
starttls:
|
||||
enable: true
|
||||
|
||||
app:
|
||||
public-base-url: ${APP_PUBLIC_BASE_URL:http://localhost:3000}
|
||||
mail:
|
||||
from: ${MAIL_FROM:noreply@bilhej.se}
|
||||
payment:
|
||||
swish-number: ${SWISH_NUMBER:0700000000}
|
||||
letter-price: 49
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
CREATE TABLE password_reset_tokens (
|
||||
id UUID NOT NULL,
|
||||
user_id UUID NOT NULL,
|
||||
token_hash VARCHAR(64) NOT NULL,
|
||||
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
used_at TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT pk_password_reset_tokens PRIMARY KEY (id),
|
||||
CONSTRAINT fk_password_reset_tokens_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_password_reset_tokens_user_id ON password_reset_tokens (user_id);
|
||||
CREATE INDEX idx_password_reset_tokens_token_hash ON password_reset_tokens (token_hash);
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
package se.bilhalsning.controller;
|
||||
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
|
|
@ -11,6 +12,7 @@ import org.springframework.beans.factory.annotation.Autowired;
|
|||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.test.context.support.WithMockUser;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import se.bilhalsning.dto.LoginRequest;
|
||||
|
|
@ -19,6 +21,8 @@ import se.bilhalsning.entity.User;
|
|||
import se.bilhalsning.exception.EmailAlreadyExistsException;
|
||||
import se.bilhalsning.exception.InvalidCredentialsException;
|
||||
import se.bilhalsning.security.JwtService;
|
||||
import java.util.Optional;
|
||||
import se.bilhalsning.service.PasswordResetService;
|
||||
import se.bilhalsning.service.UserService;
|
||||
|
||||
@SpringBootTest
|
||||
|
|
@ -33,6 +37,9 @@ class AuthControllerTest {
|
|||
@MockitoBean
|
||||
private UserService userService;
|
||||
|
||||
@MockitoBean
|
||||
private PasswordResetService passwordResetService;
|
||||
|
||||
@MockitoBean
|
||||
private JwtService jwtService;
|
||||
|
||||
|
|
@ -160,4 +167,60 @@ class AuthControllerTest {
|
|||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturn200WhenForgotPasswordRequested() throws Exception {
|
||||
when(passwordResetService.requestReset("user@example.com")).thenReturn(Optional.empty());
|
||||
|
||||
mockMvc.perform(post("/api/auth/forgot-password")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"email\":\"user@example.com\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.message")
|
||||
.value("Om e-postadressen finns har vi skickat instruktioner för att återställa lösenordet."))
|
||||
.andExpect(jsonPath("$.testToken").doesNotExist());
|
||||
|
||||
verify(passwordResetService).requestReset("user@example.com");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldIncludeTestTokenWhenServiceReturnsToken() throws Exception {
|
||||
when(passwordResetService.requestReset("user@example.com"))
|
||||
.thenReturn(Optional.of("e2e-reset-token"));
|
||||
|
||||
mockMvc.perform(post("/api/auth/forgot-password")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"email\":\"user@example.com\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.testToken").value("e2e-reset-token"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturn200WhenResetPasswordSucceeds() throws Exception {
|
||||
mockMvc.perform(post("/api/auth/reset-password")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"token\":\"abc\",\"password\":\"newpassword123\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.message").value("Lösenordet har uppdaterats. Du kan nu logga in."));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "admin@bilhej.se")
|
||||
void shouldReturn200WhenChangePasswordSucceeds() throws Exception {
|
||||
mockMvc.perform(post("/api/auth/change-password")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(
|
||||
"{\"currentPassword\":\"test1234\",\"newPassword\":\"newpassword123\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.message").value("Lösenordet har uppdaterats."));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectChangePasswordWithoutAuth() throws Exception {
|
||||
mockMvc.perform(post("/api/auth/change-password")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(
|
||||
"{\"currentPassword\":\"test1234\",\"newPassword\":\"newpassword123\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
package se.bilhalsning.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import se.bilhalsning.exception.InvalidCredentialsException;
|
||||
import se.bilhalsning.exception.PasswordResetTokenInvalidException;
|
||||
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
@Transactional
|
||||
class PasswordResetIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private UserService userService;
|
||||
|
||||
@Autowired
|
||||
private PasswordResetService passwordResetService;
|
||||
|
||||
@Test
|
||||
void shouldResetPasswordAndLoginWithNewPassword() {
|
||||
String email = "reset-integration-" + UUID.randomUUID() + "@bilhej.se";
|
||||
String oldPassword = "oldpassword123";
|
||||
String newPassword = "newpassword1234";
|
||||
|
||||
userService.createUser(email, oldPassword);
|
||||
|
||||
Optional<String> token = passwordResetService.requestReset(email);
|
||||
assertTrue(token.isPresent());
|
||||
|
||||
passwordResetService.resetPassword(token.get(), newPassword);
|
||||
|
||||
assertDoesNotThrow(() -> userService.authenticate(email, newPassword));
|
||||
assertThrows(
|
||||
InvalidCredentialsException.class,
|
||||
() -> userService.authenticate(email, oldPassword));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectInvalidToken() {
|
||||
assertThrows(
|
||||
PasswordResetTokenInvalidException.class,
|
||||
() -> passwordResetService.resetPassword("not-a-real-token", "newpassword1234"));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
package se.bilhalsning.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import se.bilhalsning.entity.PasswordResetToken;
|
||||
import se.bilhalsning.entity.User;
|
||||
import se.bilhalsning.exception.PasswordResetTokenInvalidException;
|
||||
import se.bilhalsning.repository.PasswordResetTokenRepository;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class PasswordResetServiceTest {
|
||||
|
||||
@Mock
|
||||
private UserService userService;
|
||||
|
||||
@Mock
|
||||
private PasswordResetTokenRepository tokenRepository;
|
||||
|
||||
@Mock
|
||||
private EmailService emailService;
|
||||
|
||||
@InjectMocks
|
||||
private PasswordResetService passwordResetService;
|
||||
|
||||
private User user;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
ReflectionTestUtils.setField(passwordResetService, "publicBaseUrl", "https://bilhej.se");
|
||||
ReflectionTestUtils.setField(passwordResetService, "exposeToken", false);
|
||||
user = new User();
|
||||
user.setId(UUID.randomUUID());
|
||||
user.setEmail("admin@bilhej.se");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldSendResetEmailWhenUserExists() {
|
||||
when(userService.findByEmail("admin@bilhej.se")).thenReturn(Optional.of(user));
|
||||
|
||||
passwordResetService.requestReset("admin@bilhej.se");
|
||||
|
||||
verify(tokenRepository).deleteUnusedByUserId(user.getId());
|
||||
verify(tokenRepository).save(any(PasswordResetToken.class));
|
||||
ArgumentCaptor<String> urlCaptor = ArgumentCaptor.forClass(String.class);
|
||||
verify(emailService).sendPasswordResetEmail(eq("admin@bilhej.se"), urlCaptor.capture());
|
||||
org.junit.jupiter.api.Assertions.assertTrue(
|
||||
urlCaptor.getValue().startsWith("https://bilhej.se/aterstall-losenord?token="));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnEmptyOptionalWhenExposeTokenDisabled() {
|
||||
when(userService.findByEmail("admin@bilhej.se")).thenReturn(Optional.of(user));
|
||||
|
||||
Optional<String> result = passwordResetService.requestReset("admin@bilhej.se");
|
||||
|
||||
assertTrue(result.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnRawTokenWhenExposeTokenEnabled() {
|
||||
ReflectionTestUtils.setField(passwordResetService, "exposeToken", true);
|
||||
when(userService.findByEmail("admin@bilhej.se")).thenReturn(Optional.of(user));
|
||||
|
||||
Optional<String> result = passwordResetService.requestReset("admin@bilhej.se");
|
||||
|
||||
assertTrue(result.isPresent());
|
||||
assertTrue(result.get().length() > 20);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotSendEmailWhenUserUnknown() {
|
||||
when(userService.findByEmail("unknown@bilhej.se")).thenReturn(Optional.empty());
|
||||
|
||||
Optional<String> result = passwordResetService.requestReset("unknown@bilhej.se");
|
||||
|
||||
assertTrue(result.isEmpty());
|
||||
verify(tokenRepository, never()).save(any());
|
||||
verify(emailService, never()).sendPasswordResetEmail(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldResetPasswordWhenTokenValid() {
|
||||
String rawToken = passwordResetService.generateRawToken();
|
||||
PasswordResetToken token = new PasswordResetToken();
|
||||
token.setUser(user);
|
||||
token.setExpiresAt(Instant.now().plusSeconds(3600));
|
||||
|
||||
when(tokenRepository.findByTokenHashAndUsedAtIsNull(PasswordResetService.hashToken(rawToken)))
|
||||
.thenReturn(Optional.of(token));
|
||||
|
||||
passwordResetService.resetPassword(rawToken, "newpassword123");
|
||||
|
||||
verify(userService).updatePassword(user, "newpassword123");
|
||||
verify(tokenRepository).deleteUnusedByUserId(user.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectExpiredToken() {
|
||||
String rawToken = passwordResetService.generateRawToken();
|
||||
PasswordResetToken token = new PasswordResetToken();
|
||||
token.setUser(user);
|
||||
token.setExpiresAt(Instant.now().minusSeconds(60));
|
||||
|
||||
when(tokenRepository.findByTokenHashAndUsedAtIsNull(PasswordResetService.hashToken(rawToken)))
|
||||
.thenReturn(Optional.of(token));
|
||||
|
||||
assertThrows(
|
||||
PasswordResetTokenInvalidException.class,
|
||||
() -> passwordResetService.resetPassword(rawToken, "newpassword123"));
|
||||
}
|
||||
}
|
||||
|
|
@ -170,4 +170,37 @@ class UserServiceTest {
|
|||
|
||||
verify(userRepository).findByEmail("user@example.com");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldChangePasswordWhenCurrentPasswordMatches() {
|
||||
User user = new User();
|
||||
user.setEmail("admin@bilhej.se");
|
||||
user.setPasswordHash("old-hash");
|
||||
|
||||
when(userRepository.findByEmail("admin@bilhej.se")).thenReturn(Optional.of(user));
|
||||
when(passwordEncoder.matches("test1234", "old-hash")).thenReturn(true);
|
||||
when(passwordEncoder.encode("newpassword123")).thenReturn("new-hash");
|
||||
when(userRepository.save(user)).thenReturn(user);
|
||||
|
||||
userService.changePassword("admin@bilhej.se", "test1234", "newpassword123");
|
||||
|
||||
assertEquals("new-hash", user.getPasswordHash());
|
||||
verify(userRepository).save(user);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectChangePasswordWhenCurrentPasswordWrong() {
|
||||
User user = new User();
|
||||
user.setEmail("admin@bilhej.se");
|
||||
user.setPasswordHash("old-hash");
|
||||
|
||||
when(userRepository.findByEmail("admin@bilhej.se")).thenReturn(Optional.of(user));
|
||||
when(passwordEncoder.matches("wrong", "old-hash")).thenReturn(false);
|
||||
|
||||
assertThrows(
|
||||
InvalidCredentialsException.class,
|
||||
() -> userService.changePassword("admin@bilhej.se", "wrong", "newpassword123"));
|
||||
|
||||
verify(userRepository, never()).save(any(User.class));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
3
backend/src/test/resources/application-test.yml
Normal file
3
backend/src/test/resources/application-test.yml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
app:
|
||||
password-reset:
|
||||
expose-token: true
|
||||
|
|
@ -20,7 +20,14 @@ services:
|
|||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
mailpit:
|
||||
image: ghcr.io/axllent/mailpit:v1.28
|
||||
container_name: bilhej-mailpit-e2e
|
||||
networks:
|
||||
- e2e
|
||||
|
||||
backend:
|
||||
image: bilhej-backend-e2e
|
||||
build:
|
||||
dockerfile: docker/backend.e2e.Dockerfile
|
||||
context: .
|
||||
|
|
@ -34,13 +41,22 @@ services:
|
|||
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
|
||||
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET}
|
||||
STRIPE_PRICE_ID: ${STRIPE_PRICE_ID}
|
||||
APP_PUBLIC_BASE_URL: http://frontend
|
||||
MAIL_HOST: mailpit
|
||||
MAIL_PORT: "1025"
|
||||
MAIL_USERNAME: ""
|
||||
MAIL_PASSWORD: ""
|
||||
MAIL_FROM: noreply@bilhej.se
|
||||
networks:
|
||||
- e2e
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
mailpit:
|
||||
condition: service_started
|
||||
|
||||
frontend:
|
||||
image: bilhej-frontend-e2e
|
||||
build:
|
||||
dockerfile: docker/frontend.e2e.Dockerfile
|
||||
context: .
|
||||
|
|
@ -51,6 +67,7 @@ services:
|
|||
- backend
|
||||
|
||||
playwright:
|
||||
image: bilhej-playwright-e2e
|
||||
build:
|
||||
dockerfile: docker/playwright.e2e.Dockerfile
|
||||
context: .
|
||||
|
|
@ -58,12 +75,19 @@ services:
|
|||
ipc: host
|
||||
environment:
|
||||
PLAYWRIGHT_BASE_URL: http://frontend
|
||||
MAILPIT_API_URL: http://mailpit:8025
|
||||
networks:
|
||||
- e2e
|
||||
depends_on:
|
||||
- frontend
|
||||
- mailpit
|
||||
command: >-
|
||||
sh -c "
|
||||
echo 'Waiting for mailpit...';
|
||||
for i in \$(seq 1 30); do
|
||||
curl -sf http://mailpit:8025/api/v1/info > /dev/null && break;
|
||||
sleep 1;
|
||||
done;
|
||||
echo 'Waiting for backend...';
|
||||
for i in \$(seq 1 60); do
|
||||
curl -s http://backend:8080/api/vehicles/ZZZ999 > /dev/null && break;
|
||||
|
|
|
|||
|
|
@ -34,6 +34,12 @@ services:
|
|||
SWISH_NUMBER: ${SWISH_NUMBER}
|
||||
ADMIN_EMAIL: ${ADMIN_EMAIL}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||
APP_PUBLIC_BASE_URL: ${APP_PUBLIC_BASE_URL:-https://bilhej.se}
|
||||
MAIL_HOST: ${MAIL_HOST:-}
|
||||
MAIL_PORT: ${MAIL_PORT:-587}
|
||||
MAIL_USERNAME: ${MAIL_USERNAME:-}
|
||||
MAIL_PASSWORD: ${MAIL_PASSWORD:-}
|
||||
MAIL_FROM: ${MAIL_FROM:-noreply@bilhej.se}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
|
|
|||
|
|
@ -16,7 +16,15 @@ services:
|
|||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
mailpit:
|
||||
image: ghcr.io/axllent/mailpit:v1.28
|
||||
container_name: bilhej-mailpit
|
||||
ports:
|
||||
- "1025:1025"
|
||||
- "8025:8025"
|
||||
|
||||
backend:
|
||||
image: bilhej-backend-dev
|
||||
build:
|
||||
dockerfile: docker/backend.Dockerfile
|
||||
context: .
|
||||
|
|
@ -33,9 +41,17 @@ services:
|
|||
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
|
||||
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET}
|
||||
STRIPE_PRICE_ID: ${STRIPE_PRICE_ID}
|
||||
APP_PUBLIC_BASE_URL: ${APP_PUBLIC_BASE_URL:-http://localhost:3000}
|
||||
MAIL_HOST: mailpit
|
||||
MAIL_PORT: "1025"
|
||||
MAIL_USERNAME: ""
|
||||
MAIL_PASSWORD: ""
|
||||
MAIL_FROM: ${MAIL_FROM:-noreply@bilhej.se}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
mailpit:
|
||||
condition: service_started
|
||||
volumes:
|
||||
- .:/app
|
||||
- backend-gradle-project:/app/.gradle
|
||||
|
|
@ -43,6 +59,7 @@ services:
|
|||
- gradle-cache:/root/.gradle
|
||||
|
||||
frontend:
|
||||
image: bilhej-frontend-dev
|
||||
build:
|
||||
dockerfile: docker/frontend.Dockerfile
|
||||
context: .
|
||||
|
|
|
|||
58
docs/production-email-checklist.md
Normal file
58
docs/production-email-checklist.md
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
# Production email checklist (operator)
|
||||
|
||||
Complete these steps on the server / Forgejo—nothing in this file is applied automatically.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Domain **bilhej.se** DNS managed at your registrar
|
||||
- BilHej deployed via Forgejo **Deploy to Production**
|
||||
|
||||
## 1. Choose a transactional provider
|
||||
|
||||
Recommended: [Resend](https://resend.com) or [Brevo](https://www.brevo.com) (EU, free tier).
|
||||
|
||||
## 2. Verify the domain
|
||||
|
||||
In the provider dashboard, add **bilhej.se** and publish the DNS records they give you:
|
||||
|
||||
- **SPF** (TXT)
|
||||
- **DKIM** (CNAME or TXT)
|
||||
- **DMARC** (TXT, optional but recommended)
|
||||
|
||||
You do **not** need MX records if the app only sends mail (forgot-password).
|
||||
|
||||
Wait until the provider shows the domain as verified.
|
||||
|
||||
## 3. Create SMTP credentials
|
||||
|
||||
Copy from the provider:
|
||||
|
||||
- SMTP host (e.g. `smtp.resend.com`)
|
||||
- Port (`587`)
|
||||
- Username / password or API key used as password
|
||||
|
||||
## 4. Update production `.env`
|
||||
|
||||
On the server (same file used by `docker-compose.prod.yml`):
|
||||
|
||||
```bash
|
||||
APP_PUBLIC_BASE_URL=https://bilhej.se
|
||||
MAIL_HOST=<provider-smtp-host>
|
||||
MAIL_PORT=587
|
||||
MAIL_USERNAME=<from-provider>
|
||||
MAIL_PASSWORD=<from-provider>
|
||||
MAIL_FROM=noreply@bilhej.se
|
||||
```
|
||||
|
||||
## 5. Deploy
|
||||
|
||||
Run **Deploy to Production** in Forgejo (do not rsync or manual compose on the server).
|
||||
|
||||
## 6. Smoke test
|
||||
|
||||
1. Open https://bilhej.se/logga-in → **Glömt lösenord?**
|
||||
2. Enter an email that exists in `users`
|
||||
3. Check the inbox (and spam) for the reset message
|
||||
4. If nothing arrives: `docker logs bilhej-backend-prod 2>&1 | grep -i mail`
|
||||
|
||||
Fallback without SMTP: reset links still appear in backend logs (`Password reset link for`).
|
||||
105
frontend/e2e/helpers/mailpit.ts
Normal file
105
frontend/e2e/helpers/mailpit.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import type { APIRequestContext } from '@playwright/test'
|
||||
|
||||
const mailpitApiBase =
|
||||
process.env.MAILPIT_API_URL?.replace(/\/$/, '') || 'http://localhost:8025'
|
||||
|
||||
interface MailpitAddress {
|
||||
Name: string
|
||||
Address: string
|
||||
}
|
||||
|
||||
interface MailpitMessageSummary {
|
||||
ID: string
|
||||
To: MailpitAddress[]
|
||||
Subject: string
|
||||
}
|
||||
|
||||
interface MailpitMessagesResponse {
|
||||
messages: MailpitMessageSummary[]
|
||||
}
|
||||
|
||||
interface MailpitMessageDetail {
|
||||
Text?: string
|
||||
HTML?: string
|
||||
}
|
||||
|
||||
export async function clearMailpit(request: APIRequestContext): Promise<void> {
|
||||
await request.delete(`${mailpitApiBase}/api/v1/messages`)
|
||||
}
|
||||
|
||||
export async function countMessagesTo(
|
||||
request: APIRequestContext,
|
||||
recipientEmail: string,
|
||||
): Promise<number> {
|
||||
const listResponse = await request.get(`${mailpitApiBase}/api/v1/messages`)
|
||||
if (!listResponse.ok()) return 0
|
||||
|
||||
const list = (await listResponse.json()) as MailpitMessagesResponse
|
||||
const normalized = recipientEmail.toLowerCase().trim()
|
||||
return (list.messages ?? []).filter((msg) =>
|
||||
msg.To?.some((to) => to.Address.toLowerCase() === normalized),
|
||||
).length
|
||||
}
|
||||
|
||||
export async function waitForPasswordResetToken(
|
||||
request: APIRequestContext,
|
||||
recipientEmail: string,
|
||||
options: { timeoutMs?: number; publicBaseUrl?: string } = {},
|
||||
): Promise<string> {
|
||||
const timeoutMs = options.timeoutMs ?? 20_000
|
||||
const deadline = Date.now() + timeoutMs
|
||||
const normalizedRecipient = recipientEmail.toLowerCase().trim()
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const listResponse = await request.get(`${mailpitApiBase}/api/v1/messages`)
|
||||
if (!listResponse.ok()) {
|
||||
await sleep(500)
|
||||
continue
|
||||
}
|
||||
|
||||
const list = (await listResponse.json()) as MailpitMessagesResponse
|
||||
for (const summary of list.messages ?? []) {
|
||||
const matchesRecipient = summary.To?.some(
|
||||
(to) => to.Address.toLowerCase() === normalizedRecipient,
|
||||
)
|
||||
if (!matchesRecipient) continue
|
||||
|
||||
const detailResponse = await request.get(
|
||||
`${mailpitApiBase}/api/v1/message/${summary.ID}`,
|
||||
)
|
||||
if (!detailResponse.ok()) continue
|
||||
|
||||
const detail = (await detailResponse.json()) as MailpitMessageDetail
|
||||
const body = detail.Text ?? detail.HTML ?? ''
|
||||
const token = extractResetToken(body, options.publicBaseUrl)
|
||||
if (token) return token
|
||||
}
|
||||
|
||||
await sleep(500)
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`No password reset email for ${recipientEmail} in Mailpit within ${timeoutMs}ms`,
|
||||
)
|
||||
}
|
||||
|
||||
function extractResetToken(body: string, publicBaseUrl?: string): string | null {
|
||||
const pathPattern = /\/aterstall-losenord\?token=([A-Za-z0-9_-]+)/
|
||||
const pathMatch = body.match(pathPattern)
|
||||
if (pathMatch) return pathMatch[1]
|
||||
|
||||
if (publicBaseUrl) {
|
||||
const escaped = publicBaseUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const fullPattern = new RegExp(
|
||||
`${escaped}/aterstall-losenord\\?token=([A-Za-z0-9_-]+)`,
|
||||
)
|
||||
const fullMatch = body.match(fullPattern)
|
||||
if (fullMatch) return fullMatch[1]
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
179
frontend/e2e/password-reset.spec.ts
Normal file
179
frontend/e2e/password-reset.spec.ts
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
import { test, expect } from '@playwright/test'
|
||||
import {
|
||||
clearMailpit,
|
||||
countMessagesTo,
|
||||
waitForPasswordResetToken,
|
||||
} from './helpers/mailpit'
|
||||
|
||||
const forgotSuccessMessage =
|
||||
'Om e-postadressen finns har vi skickat instruktioner för att återställa lösenordet.'
|
||||
|
||||
test.describe('Password reset', () => {
|
||||
test('login page links to forgot password', async ({ page }) => {
|
||||
await page.goto('/logga-in')
|
||||
await page.getByRole('link', { name: 'Glömt lösenord?' }).click()
|
||||
await expect(page).toHaveURL('/glomt-losenord')
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Glömt lösenord?' }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('forgot password page submits and shows success', async ({ page }) => {
|
||||
await page.goto('/glomt-losenord')
|
||||
await page.getByLabel('E-postadress').fill('test@bilhej.se')
|
||||
await page
|
||||
.getByRole('button', { name: 'Skicka återställningslänk' })
|
||||
.click()
|
||||
|
||||
await expect(page.getByText(forgotSuccessMessage)).toBeVisible()
|
||||
})
|
||||
|
||||
test('unknown email gets same success message as known user', async ({
|
||||
request,
|
||||
}) => {
|
||||
const known = await request.post('/api/auth/forgot-password', {
|
||||
data: { email: 'test@bilhej.se' },
|
||||
})
|
||||
const unknown = await request.post('/api/auth/forgot-password', {
|
||||
data: { email: 'nobody-reset-e2e@bilhej.se' },
|
||||
})
|
||||
|
||||
expect(known.ok()).toBeTruthy()
|
||||
expect(unknown.ok()).toBeTruthy()
|
||||
const knownBody = await known.json()
|
||||
const unknownBody = await unknown.json()
|
||||
expect(knownBody.message).toBe(forgotSuccessMessage)
|
||||
expect(unknownBody.message).toBe(forgotSuccessMessage)
|
||||
expect(unknownBody.testToken).toBeUndefined()
|
||||
})
|
||||
|
||||
test('full reset flow with isolated user', async ({ page, request }) => {
|
||||
const email = `reset-e2e-${Date.now()}@bilhej.se`
|
||||
const oldPassword = 'oldpass1234'
|
||||
const newPassword = 'resetpass1234'
|
||||
|
||||
const register = await request.post('/api/auth/register', {
|
||||
data: { email, password: oldPassword },
|
||||
})
|
||||
expect(register.ok()).toBeTruthy()
|
||||
|
||||
const forgot = await request.post('/api/auth/forgot-password', {
|
||||
data: { email },
|
||||
})
|
||||
expect(forgot.ok()).toBeTruthy()
|
||||
const forgotBody = await forgot.json()
|
||||
expect(forgotBody.testToken).toBeTruthy()
|
||||
|
||||
await page.goto(`/aterstall-losenord?token=${forgotBody.testToken}`)
|
||||
await page.getByLabel('Nytt lösenord').fill(newPassword)
|
||||
await page.getByLabel('Bekräfta lösenord').fill(newPassword)
|
||||
await page.getByRole('button', { name: 'Spara nytt lösenord' }).click()
|
||||
|
||||
await expect(
|
||||
page.getByText('Lösenordet har uppdaterats. Du kan nu logga in.'),
|
||||
).toBeVisible({ timeout: 10000 })
|
||||
|
||||
await page.goto('/logga-in')
|
||||
await page.getByLabel('E-postadress').fill(email)
|
||||
await page.getByLabel('Lösenord').fill(newPassword)
|
||||
await page.getByRole('button', { name: 'Logga in' }).click()
|
||||
await expect(page).toHaveURL('/')
|
||||
})
|
||||
|
||||
test('login fails with old password after reset', async ({ request }) => {
|
||||
const email = `reset-oldpw-${Date.now()}@bilhej.se`
|
||||
const oldPassword = 'oldpass1234'
|
||||
const newPassword = 'resetpass1234'
|
||||
|
||||
await request.post('/api/auth/register', {
|
||||
data: { email, password: oldPassword },
|
||||
})
|
||||
const forgot = await request.post('/api/auth/forgot-password', {
|
||||
data: { email },
|
||||
})
|
||||
const { testToken } = await forgot.json()
|
||||
await request.post('/api/auth/reset-password', {
|
||||
data: { token: testToken, password: newPassword },
|
||||
})
|
||||
|
||||
const login = await request.post('/api/auth/login', {
|
||||
data: { email, password: oldPassword },
|
||||
})
|
||||
expect(login.status()).toBe(401)
|
||||
})
|
||||
|
||||
test('invalid token shows error and link to request new', async ({ page }) => {
|
||||
await page.goto('/aterstall-losenord?token=invalid')
|
||||
await page.getByLabel('Nytt lösenord').fill('newpassword123')
|
||||
await page.getByLabel('Bekräfta lösenord').fill('newpassword123')
|
||||
await page.getByRole('button', { name: 'Spara nytt lösenord' }).click()
|
||||
|
||||
await expect(
|
||||
page.getByText('Återställningslänken är ogiltig eller har gått ut'),
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('link', { name: 'Begär ny länk' }),
|
||||
).toHaveAttribute('href', '/glomt-losenord')
|
||||
})
|
||||
|
||||
test('missing token shows invalid link error', async ({ page }) => {
|
||||
await page.goto('/aterstall-losenord')
|
||||
await expect(
|
||||
page.getByText('Återställningslänken saknar en giltig kod.'),
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('link', { name: 'Begär ny länk' }),
|
||||
).toHaveAttribute('href', '/glomt-losenord')
|
||||
})
|
||||
|
||||
test('delivers reset link via Mailpit SMTP', async ({ page, request }) => {
|
||||
const email = `mailpit-e2e-${Date.now()}@bilhej.se`
|
||||
const oldPassword = 'oldpass1234'
|
||||
const newPassword = 'mailpitpass1234'
|
||||
|
||||
await clearMailpit(request)
|
||||
|
||||
const register = await request.post('/api/auth/register', {
|
||||
data: { email, password: oldPassword },
|
||||
})
|
||||
expect(register.ok()).toBeTruthy()
|
||||
|
||||
const forgot = await request.post('/api/auth/forgot-password', {
|
||||
data: { email },
|
||||
})
|
||||
expect(forgot.ok()).toBeTruthy()
|
||||
|
||||
const token = await waitForPasswordResetToken(request, email, {
|
||||
publicBaseUrl: 'http://frontend',
|
||||
})
|
||||
|
||||
await page.goto(`/aterstall-losenord?token=${token}`)
|
||||
await page.getByLabel('Nytt lösenord').fill(newPassword)
|
||||
await page.getByLabel('Bekräfta lösenord').fill(newPassword)
|
||||
await page.getByRole('button', { name: 'Spara nytt lösenord' }).click()
|
||||
|
||||
await expect(
|
||||
page.getByText('Lösenordet har uppdaterats. Du kan nu logga in.'),
|
||||
).toBeVisible({ timeout: 10000 })
|
||||
|
||||
const login = await request.post('/api/auth/login', {
|
||||
data: { email, password: newPassword },
|
||||
})
|
||||
expect(login.ok()).toBeTruthy()
|
||||
})
|
||||
|
||||
test('does not send Mailpit message for unknown email', async ({
|
||||
request,
|
||||
}) => {
|
||||
await clearMailpit(request)
|
||||
|
||||
const forgot = await request.post('/api/auth/forgot-password', {
|
||||
data: { email: 'nobody-mailpit-e2e@bilhej.se' },
|
||||
})
|
||||
expect(forgot.ok()).toBeTruthy()
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000))
|
||||
|
||||
expect(await countMessagesTo(request, 'nobody-mailpit-e2e@bilhej.se')).toBe(0)
|
||||
})
|
||||
})
|
||||
|
|
@ -25,6 +25,11 @@ function createTestRouter() {
|
|||
name: 'orders',
|
||||
component: { template: '<div>Orders</div>' },
|
||||
},
|
||||
{
|
||||
path: '/andra-losenord',
|
||||
name: 'change-password',
|
||||
component: { template: '<div>Change password</div>' },
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
name: 'admin',
|
||||
|
|
@ -166,6 +171,16 @@ describe('AppHeader', () => {
|
|||
expect(ordersLink?.text()).toBe('Mina beställningar')
|
||||
})
|
||||
|
||||
it('shows change password link', () => {
|
||||
const { wrapper } = mountAuthenticated()
|
||||
const links = wrapper.findAll('a')
|
||||
const changeLink = links.find(
|
||||
(a) => a.attributes('href') === '/andra-losenord',
|
||||
)
|
||||
expect(changeLink).toBeTruthy()
|
||||
expect(changeLink?.text()).toBe('Byt lösenord')
|
||||
})
|
||||
|
||||
it('does not show admin link for regular user', () => {
|
||||
const { wrapper } = mountAuthenticated('user')
|
||||
const links = wrapper.findAll('a')
|
||||
|
|
|
|||
113
frontend/src/__tests__/ForgotPasswordPage.spec.ts
Normal file
113
frontend/src/__tests__/ForgotPasswordPage.spec.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||
import ForgotPasswordPage from '@/pages/ForgotPasswordPage.vue'
|
||||
|
||||
function mockFetchResponse(status: number, body: unknown) {
|
||||
return Promise.resolve({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: () => Promise.resolve(body),
|
||||
})
|
||||
}
|
||||
|
||||
function createTestRouter() {
|
||||
return createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/glomt-losenord',
|
||||
name: 'forgot-password',
|
||||
component: ForgotPasswordPage,
|
||||
},
|
||||
{
|
||||
path: '/logga-in',
|
||||
name: 'login',
|
||||
component: { template: '<div>Login</div>' },
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
function mountPage() {
|
||||
const router = createTestRouter()
|
||||
router.push('/glomt-losenord')
|
||||
return {
|
||||
router,
|
||||
wrapper: mount(ForgotPasswordPage, {
|
||||
global: { plugins: [router] },
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
const successMessage =
|
||||
'Om e-postadressen finns har vi skickat instruktioner för att återställa lösenordet.'
|
||||
|
||||
describe('ForgotPasswordPage', () => {
|
||||
beforeEach(() => {
|
||||
globalThis.fetch = vi.fn()
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||
mockFetchResponse(200, { message: successMessage }),
|
||||
)
|
||||
})
|
||||
|
||||
it('renders heading and subtitle', () => {
|
||||
const { wrapper } = mountPage()
|
||||
expect(wrapper.text()).toContain('Glömt lösenord?')
|
||||
expect(wrapper.text()).toContain(
|
||||
'Ange din e-postadress så skickar vi en länk',
|
||||
)
|
||||
})
|
||||
|
||||
it('disables submit until valid email', async () => {
|
||||
const { wrapper } = mountPage()
|
||||
expect(
|
||||
wrapper.find('button[type="submit"]').attributes('disabled'),
|
||||
).toBeDefined()
|
||||
|
||||
await wrapper.find('#email').setValue('not-an-email')
|
||||
expect(
|
||||
wrapper.find('button[type="submit"]').attributes('disabled'),
|
||||
).toBeDefined()
|
||||
|
||||
await wrapper.find('#email').setValue('user@example.com')
|
||||
expect(
|
||||
wrapper.find('button[type="submit"]').attributes('disabled'),
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('shows success message after submit', async () => {
|
||||
const { wrapper } = mountPage()
|
||||
await wrapper.find('#email').setValue('user@example.com')
|
||||
await wrapper.find('form').trigger('submit.prevent')
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.text()).toContain(successMessage)
|
||||
})
|
||||
expect(wrapper.find('form').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows error banner on API failure', async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||
mockFetchResponse(500, { message: 'Serverfel' }),
|
||||
)
|
||||
const { wrapper } = mountPage()
|
||||
await wrapper.find('#email').setValue('user@example.com')
|
||||
await wrapper.find('form').trigger('submit.prevent')
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.text()).toContain('Serverfel')
|
||||
})
|
||||
})
|
||||
|
||||
it('links back to login', () => {
|
||||
const { wrapper } = mountPage()
|
||||
const link = wrapper.find('a[href="/logga-in"]')
|
||||
expect(link.exists()).toBe(true)
|
||||
expect(link.text()).toContain('Tillbaka till inloggning')
|
||||
})
|
||||
|
||||
it('does not show success before submit', () => {
|
||||
const { wrapper } = mountPage()
|
||||
expect(wrapper.text()).not.toContain(successMessage)
|
||||
expect(wrapper.find('form').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
@ -23,6 +23,11 @@ function createTestRouter() {
|
|||
name: 'register',
|
||||
component: { template: '<div>Register</div>' },
|
||||
},
|
||||
{
|
||||
path: '/glomt-losenord',
|
||||
name: 'forgot-password',
|
||||
component: { template: '<div>Forgot</div>' },
|
||||
},
|
||||
{
|
||||
path: '/compose',
|
||||
name: 'compose',
|
||||
|
|
@ -168,6 +173,13 @@ describe('LoginPage', () => {
|
|||
expect(wrapper.text()).toContain('Har du inget konto?')
|
||||
})
|
||||
|
||||
it('renders forgot password link to /glomt-losenord', async () => {
|
||||
const { wrapper } = mountPage()
|
||||
const link = wrapper.find('a[href="/glomt-losenord"]')
|
||||
expect(link.exists()).toBe(true)
|
||||
expect(link.text()).toContain('Glömt lösenord?')
|
||||
})
|
||||
|
||||
it('redirects to query param after login', async () => {
|
||||
const router = createTestRouter()
|
||||
await router.push({ path: '/logga-in', query: { redirect: '/compose' } })
|
||||
|
|
|
|||
122
frontend/src/__tests__/ResetPasswordPage.spec.ts
Normal file
122
frontend/src/__tests__/ResetPasswordPage.spec.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||
import ResetPasswordPage from '@/pages/ResetPasswordPage.vue'
|
||||
|
||||
function mockFetchResponse(status: number, body: unknown) {
|
||||
return Promise.resolve({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: () => Promise.resolve(body),
|
||||
})
|
||||
}
|
||||
|
||||
function createTestRouter() {
|
||||
return createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/aterstall-losenord',
|
||||
name: 'reset-password',
|
||||
component: ResetPasswordPage,
|
||||
},
|
||||
{
|
||||
path: '/logga-in',
|
||||
name: 'login',
|
||||
component: { template: '<div>Login</div>' },
|
||||
},
|
||||
{
|
||||
path: '/glomt-losenord',
|
||||
name: 'forgot-password',
|
||||
component: { template: '<div>Forgot</div>' },
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
async function mountPage(initialPath: string) {
|
||||
const router = createTestRouter()
|
||||
await router.push(initialPath)
|
||||
await router.isReady()
|
||||
return {
|
||||
router,
|
||||
wrapper: mount(ResetPasswordPage, {
|
||||
global: { plugins: [router] },
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
describe('ResetPasswordPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
globalThis.fetch = vi.fn()
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||
mockFetchResponse(200, {
|
||||
message: 'Lösenordet har uppdaterats. Du kan nu logga in.',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('shows error when token query is missing', async () => {
|
||||
const { wrapper } = await mountPage('/aterstall-losenord')
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.text()).toContain(
|
||||
'Återställningslänken saknar en giltig kod.',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('shows password min length hint', async () => {
|
||||
const { wrapper } = await mountPage('/aterstall-losenord?token=abc')
|
||||
await wrapper.find('#password').setValue('short')
|
||||
expect(wrapper.text()).toContain('Lösenordet måste vara minst 8 tecken')
|
||||
})
|
||||
|
||||
it('shows mismatch hint when confirm password differs', async () => {
|
||||
const { wrapper } = await mountPage('/aterstall-losenord?token=abc')
|
||||
await wrapper.find('#password').setValue('password1234')
|
||||
await wrapper.find('#confirmPassword').setValue('different1234')
|
||||
expect(wrapper.text()).toContain('Lösenorden matchar inte')
|
||||
})
|
||||
|
||||
it('shows success and navigates to login after reset', async () => {
|
||||
const { wrapper, router } = await mountPage('/aterstall-losenord?token=abc')
|
||||
await wrapper.find('#password').setValue('newpassword123')
|
||||
await wrapper.find('#confirmPassword').setValue('newpassword123')
|
||||
await wrapper.find('form').trigger('submit.prevent')
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.text()).toContain(
|
||||
'Lösenordet har uppdaterats. Du kan nu logga in.',
|
||||
)
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
expect(router.currentRoute.value.path).toBe('/logga-in')
|
||||
})
|
||||
|
||||
it('shows invalid token message from backend', async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||
mockFetchResponse(400, {
|
||||
message: 'Återställningslänken är ogiltig eller har gått ut',
|
||||
}),
|
||||
)
|
||||
const { wrapper } = await mountPage('/aterstall-losenord?token=bad')
|
||||
await wrapper.find('#password').setValue('newpassword123')
|
||||
await wrapper.find('#confirmPassword').setValue('newpassword123')
|
||||
await wrapper.find('form').trigger('submit.prevent')
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.text()).toContain(
|
||||
'Återställningslänken är ogiltig eller har gått ut',
|
||||
)
|
||||
expect(wrapper.find('a[href="/glomt-losenord"]').text()).toContain(
|
||||
'Begär ny länk',
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -26,6 +26,18 @@ describe('Router', () => {
|
|||
expect(router.currentRoute.value.name).toBe('login')
|
||||
})
|
||||
|
||||
it('resolves /glomt-losenord to ForgotPasswordPage', async () => {
|
||||
await router.push('/glomt-losenord')
|
||||
await router.isReady()
|
||||
expect(router.currentRoute.value.name).toBe('forgot-password')
|
||||
})
|
||||
|
||||
it('resolves /aterstall-losenord to ResetPasswordPage', async () => {
|
||||
await router.push('/aterstall-losenord?token=abc')
|
||||
await router.isReady()
|
||||
expect(router.currentRoute.value.name).toBe('reset-password')
|
||||
})
|
||||
|
||||
it('resolves /orders to OrdersPage', async () => {
|
||||
localStorage.setItem('auth_token', makeJwt({ role: 'user' }))
|
||||
await router.push('/orders')
|
||||
|
|
@ -33,6 +45,13 @@ describe('Router', () => {
|
|||
expect(router.currentRoute.value.name).toBe('orders')
|
||||
})
|
||||
|
||||
it('resolves /andra-losenord to ChangePasswordPage when authenticated', async () => {
|
||||
localStorage.setItem('auth_token', makeJwt({ role: 'user' }))
|
||||
await router.push('/andra-losenord')
|
||||
await router.isReady()
|
||||
expect(router.currentRoute.value.name).toBe('change-password')
|
||||
})
|
||||
|
||||
it('resolves /admin to AdminPage for admin user', async () => {
|
||||
localStorage.setItem('auth_token', makeJwt({ role: 'admin' }))
|
||||
await router.push('/admin')
|
||||
|
|
@ -67,6 +86,13 @@ describe('Router guards', () => {
|
|||
expect(router.currentRoute.value.query.redirect).toBe('/orders')
|
||||
})
|
||||
|
||||
it('redirects unauthenticated user from /andra-losenord to /logga-in', async () => {
|
||||
await router.push('/andra-losenord')
|
||||
await router.isReady()
|
||||
expect(router.currentRoute.value.name).toBe('login')
|
||||
expect(router.currentRoute.value.query.redirect).toBe('/andra-losenord')
|
||||
})
|
||||
|
||||
it('redirects unauthenticated user from /admin to /logga-in', async () => {
|
||||
await router.push('/admin')
|
||||
await router.isReady()
|
||||
|
|
@ -102,6 +128,26 @@ describe('Router guards', () => {
|
|||
expect(router.currentRoute.value.name).toBe('home')
|
||||
})
|
||||
|
||||
it('redirects authenticated user from /glomt-losenord to home', async () => {
|
||||
localStorage.setItem('auth_token', makeJwt({ role: 'user' }))
|
||||
await router.push('/glomt-losenord')
|
||||
await router.isReady()
|
||||
expect(router.currentRoute.value.name).toBe('home')
|
||||
})
|
||||
|
||||
it('redirects authenticated user from /aterstall-losenord to home', async () => {
|
||||
localStorage.setItem('auth_token', makeJwt({ role: 'user' }))
|
||||
await router.push('/aterstall-losenord?token=abc')
|
||||
await router.isReady()
|
||||
expect(router.currentRoute.value.name).toBe('home')
|
||||
})
|
||||
|
||||
it('allows unauthenticated user to access reset password with token', async () => {
|
||||
await router.push('/aterstall-losenord?token=abc')
|
||||
await router.isReady()
|
||||
expect(router.currentRoute.value.name).toBe('reset-password')
|
||||
})
|
||||
|
||||
it('redirects non-admin user from /admin to home', async () => {
|
||||
localStorage.setItem('auth_token', makeJwt({ role: 'user' }))
|
||||
await router.push('/admin')
|
||||
|
|
|
|||
67
frontend/src/__tests__/authApi.spec.ts
Normal file
67
frontend/src/__tests__/authApi.spec.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { forgotPassword, resetPassword } from '@/api/auth'
|
||||
import { ApiError } from '@/api/client'
|
||||
|
||||
function mockFetchResponse(status: number, body: unknown) {
|
||||
return Promise.resolve({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: () => Promise.resolve(body),
|
||||
})
|
||||
}
|
||||
|
||||
describe('auth API', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
globalThis.fetch = vi.fn()
|
||||
})
|
||||
|
||||
it('forgotPassword POSTs to /auth/forgot-password with email', async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||
mockFetchResponse(200, { message: 'Skickat' }),
|
||||
)
|
||||
|
||||
const response = await forgotPassword('user@example.com')
|
||||
|
||||
expect(response.message).toBe('Skickat')
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
'/api/auth/forgot-password',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email: 'user@example.com' }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('resetPassword POSTs to /auth/reset-password with token and password', async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||
mockFetchResponse(200, { message: 'Uppdaterat' }),
|
||||
)
|
||||
|
||||
const response = await resetPassword('token-abc', 'newpassword123')
|
||||
|
||||
expect(response.message).toBe('Uppdaterat')
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
'/api/auth/reset-password',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
token: 'token-abc',
|
||||
password: 'newpassword123',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('propagates ApiError on failed forgotPassword', async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||
mockFetchResponse(500, { message: 'Serverfel' }),
|
||||
)
|
||||
|
||||
await expect(forgotPassword('user@example.com')).rejects.toThrow(ApiError)
|
||||
await expect(forgotPassword('user@example.com')).rejects.toMatchObject({
|
||||
status: 500,
|
||||
message: 'Serverfel',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -20,3 +20,39 @@ export function login(email: string, password: string): Promise<AuthResponse> {
|
|||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
}
|
||||
|
||||
export interface MessageResponse {
|
||||
message: string
|
||||
}
|
||||
|
||||
/** Optional testToken is returned only when backend expose-token is enabled (E2E). */
|
||||
export interface ForgotPasswordResponse extends MessageResponse {
|
||||
testToken?: string
|
||||
}
|
||||
|
||||
export function forgotPassword(email: string): Promise<ForgotPasswordResponse> {
|
||||
return request<ForgotPasswordResponse>('/auth/forgot-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email }),
|
||||
})
|
||||
}
|
||||
|
||||
export function resetPassword(
|
||||
token: string,
|
||||
password: string,
|
||||
): Promise<MessageResponse> {
|
||||
return request<MessageResponse>('/auth/reset-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ token, password }),
|
||||
})
|
||||
}
|
||||
|
||||
export function changePassword(
|
||||
currentPassword: string,
|
||||
newPassword: string,
|
||||
): Promise<MessageResponse> {
|
||||
return request<MessageResponse>('/auth/change-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ currentPassword, newPassword }),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,6 +61,9 @@ function handleLogout() {
|
|||
<RouterLink to="/orders" class="app-header__link"
|
||||
>Mina beställningar</RouterLink
|
||||
>
|
||||
<RouterLink to="/andra-losenord" class="app-header__link"
|
||||
>Byt lösenord</RouterLink
|
||||
>
|
||||
<span class="app-header__email">{{ auth.email }}</span>
|
||||
<button class="app-header__logout" @click="handleLogout">
|
||||
Logga ut
|
||||
|
|
|
|||
190
frontend/src/pages/ChangePasswordPage.vue
Normal file
190
frontend/src/pages/ChangePasswordPage.vue
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { changePassword } from '@/api/auth'
|
||||
import { ApiError } from '@/api/client'
|
||||
|
||||
const currentPassword = ref('')
|
||||
const password = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const submitting = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const successMessage = ref('')
|
||||
|
||||
const passwordError = computed(() => {
|
||||
if (password.value.length === 0) return ''
|
||||
return password.value.length >= 8
|
||||
? ''
|
||||
: 'Lösenordet måste vara minst 8 tecken'
|
||||
})
|
||||
|
||||
const confirmPasswordError = computed(() => {
|
||||
if (confirmPassword.value.length === 0) return ''
|
||||
return confirmPassword.value === password.value
|
||||
? ''
|
||||
: 'Lösenorden matchar inte'
|
||||
})
|
||||
|
||||
const isValid = computed(
|
||||
() =>
|
||||
currentPassword.value.length > 0 &&
|
||||
passwordError.value === '' &&
|
||||
confirmPasswordError.value === '' &&
|
||||
password.value.length > 0 &&
|
||||
confirmPassword.value.length > 0,
|
||||
)
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!isValid.value || submitting.value) return
|
||||
|
||||
submitting.value = true
|
||||
errorMessage.value = ''
|
||||
successMessage.value = ''
|
||||
|
||||
try {
|
||||
const response = await changePassword(currentPassword.value, password.value)
|
||||
successMessage.value = response.message
|
||||
currentPassword.value = ''
|
||||
password.value = ''
|
||||
confirmPassword.value = ''
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
errorMessage.value = 'Nuvarande lösenord är felaktigt'
|
||||
} else if (err instanceof ApiError) {
|
||||
errorMessage.value = err.message
|
||||
} else {
|
||||
errorMessage.value = 'Något gick fel. Försök igen senare.'
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page__card">
|
||||
<h1 class="page__title">Byt lösenord</h1>
|
||||
<p class="page__subtitle">
|
||||
Ange ditt nuvarande lösenord och välj ett nytt.
|
||||
</p>
|
||||
|
||||
<form
|
||||
v-if="!successMessage"
|
||||
class="page__form"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
<div class="field">
|
||||
<label for="current-password" class="field__label"
|
||||
>Nuvarande lösenord</label
|
||||
>
|
||||
<input
|
||||
id="current-password"
|
||||
v-model="currentPassword"
|
||||
type="password"
|
||||
name="currentPassword"
|
||||
autocomplete="current-password"
|
||||
class="field__input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="password" class="field__label">Nytt lösenord</label>
|
||||
<input
|
||||
id="password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
name="password"
|
||||
autocomplete="new-password"
|
||||
class="field__input"
|
||||
placeholder="Minst 8 tecken"
|
||||
/>
|
||||
<p v-if="passwordError" class="field__hint field__hint--error">
|
||||
{{ passwordError }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="confirm-password" class="field__label"
|
||||
>Bekräfta nytt lösenord</label
|
||||
>
|
||||
<input
|
||||
id="confirm-password"
|
||||
v-model="confirmPassword"
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
autocomplete="new-password"
|
||||
class="field__input"
|
||||
/>
|
||||
<p v-if="confirmPasswordError" class="field__hint field__hint--error">
|
||||
{{ confirmPasswordError }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="errorMessage" class="message message--error">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn--primary btn--lg page__submit"
|
||||
:disabled="!isValid || submitting"
|
||||
>
|
||||
{{ submitting ? 'Sparar...' : 'Spara nytt lösenord' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div v-else class="message message--success">
|
||||
{{ successMessage }}
|
||||
</div>
|
||||
|
||||
<p class="page__footer-link">
|
||||
<RouterLink to="/">Tillbaka till startsidan</RouterLink>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
max-width: 28rem;
|
||||
margin: var(--space-3xl) auto 0;
|
||||
padding: 0 var(--space-lg);
|
||||
}
|
||||
|
||||
.page__card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--space-xl);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.page__title {
|
||||
margin: 0 0 var(--space-sm) 0;
|
||||
font-size: 1.5rem;
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
.page__subtitle {
|
||||
margin: 0 0 var(--space-xl) 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.page__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.page__footer-link {
|
||||
margin-top: var(--space-lg);
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.page__submit {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
129
frontend/src/pages/ForgotPasswordPage.vue
Normal file
129
frontend/src/pages/ForgotPasswordPage.vue
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { forgotPassword } from '@/api/auth'
|
||||
import { ApiError } from '@/api/client'
|
||||
|
||||
const email = ref('')
|
||||
const submitting = ref(false)
|
||||
const successMessage = ref('')
|
||||
const errorMessage = ref('')
|
||||
|
||||
const isValid = computed(() => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.value))
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!isValid.value || submitting.value) return
|
||||
|
||||
submitting.value = true
|
||||
errorMessage.value = ''
|
||||
successMessage.value = ''
|
||||
|
||||
try {
|
||||
const response = await forgotPassword(email.value)
|
||||
successMessage.value = response.message
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
errorMessage.value = err.message
|
||||
} else {
|
||||
errorMessage.value = 'Något gick fel. Försök igen senare.'
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page__card">
|
||||
<h1 class="page__title">Glömt lösenord?</h1>
|
||||
<p class="page__subtitle">
|
||||
Ange din e-postadress så skickar vi en länk för att återställa
|
||||
lösenordet.
|
||||
</p>
|
||||
|
||||
<form
|
||||
v-if="!successMessage"
|
||||
class="page__form"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
<div class="field">
|
||||
<label for="email" class="field__label">E-postadress</label>
|
||||
<input
|
||||
id="email"
|
||||
v-model="email"
|
||||
type="email"
|
||||
name="email"
|
||||
autocomplete="email"
|
||||
class="field__input"
|
||||
placeholder="namn@exempel.se"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="errorMessage" class="message message--error">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn--primary btn--lg page__submit"
|
||||
:disabled="!isValid || submitting"
|
||||
>
|
||||
{{ submitting ? 'Skickar...' : 'Skicka återställningslänk' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div v-else class="message message--success">
|
||||
{{ successMessage }}
|
||||
</div>
|
||||
|
||||
<p class="page__footer-link">
|
||||
<RouterLink to="/logga-in">Tillbaka till inloggning</RouterLink>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
max-width: 28rem;
|
||||
margin: var(--space-3xl) auto 0;
|
||||
padding: 0 var(--space-lg);
|
||||
}
|
||||
|
||||
.page__card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--space-xl);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.page__title {
|
||||
margin: 0 0 var(--space-sm) 0;
|
||||
font-size: 1.5rem;
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
.page__subtitle {
|
||||
margin: 0 0 var(--space-xl) 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.page__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.page__footer-link {
|
||||
margin-top: var(--space-lg);
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.page__submit {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -62,7 +62,12 @@ async function handleSubmit() {
|
|||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="field__label-row">
|
||||
<label for="password" class="field__label">Lösenord</label>
|
||||
<RouterLink to="/glomt-losenord" class="field__link">
|
||||
Glömt lösenord?
|
||||
</RouterLink>
|
||||
</div>
|
||||
<input
|
||||
id="password"
|
||||
v-model="password"
|
||||
|
|
@ -138,4 +143,21 @@ async function handleSubmit() {
|
|||
.login__submit {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.field__label-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.field__link {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.field__link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
191
frontend/src/pages/ResetPasswordPage.vue
Normal file
191
frontend/src/pages/ResetPasswordPage.vue
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter, RouterLink } from 'vue-router'
|
||||
import { resetPassword } from '@/api/auth'
|
||||
import { ApiError } from '@/api/client'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const password = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const submitting = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const successMessage = ref('')
|
||||
const token = ref('')
|
||||
|
||||
const passwordError = computed(() => {
|
||||
if (password.value.length === 0) return ''
|
||||
return password.value.length >= 8
|
||||
? ''
|
||||
: 'Lösenordet måste vara minst 8 tecken'
|
||||
})
|
||||
|
||||
const confirmPasswordError = computed(() => {
|
||||
if (confirmPassword.value.length === 0) return ''
|
||||
return confirmPassword.value === password.value
|
||||
? ''
|
||||
: 'Lösenorden matchar inte'
|
||||
})
|
||||
|
||||
const isValid = computed(
|
||||
() =>
|
||||
token.value.length > 0 &&
|
||||
passwordError.value === '' &&
|
||||
confirmPasswordError.value === '' &&
|
||||
password.value.length > 0 &&
|
||||
confirmPassword.value.length > 0,
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
const value = route.query.token
|
||||
token.value = typeof value === 'string' ? value : ''
|
||||
if (!token.value) {
|
||||
errorMessage.value = 'Återställningslänken saknar en giltig kod.'
|
||||
}
|
||||
})
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!isValid.value || submitting.value) return
|
||||
|
||||
submitting.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const response = await resetPassword(token.value, password.value)
|
||||
successMessage.value = response.message
|
||||
setTimeout(() => router.push('/logga-in'), 2000)
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
errorMessage.value = err.message
|
||||
} else {
|
||||
errorMessage.value = 'Något gick fel. Försök begära en ny länk.'
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page__card">
|
||||
<h1 class="page__title">Nytt lösenord</h1>
|
||||
<p class="page__subtitle">Välj ett nytt lösenord för ditt konto.</p>
|
||||
|
||||
<form
|
||||
v-if="!successMessage && token"
|
||||
class="page__form"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
<div class="field">
|
||||
<label for="password" class="field__label">Nytt lösenord</label>
|
||||
<input
|
||||
id="password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
name="password"
|
||||
autocomplete="new-password"
|
||||
class="field__input"
|
||||
placeholder="Minst 8 tecken"
|
||||
/>
|
||||
<p v-if="passwordError" class="field__hint field__hint--error">
|
||||
{{ passwordError }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="confirmPassword" class="field__label"
|
||||
>Bekräfta lösenord</label
|
||||
>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
v-model="confirmPassword"
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
autocomplete="new-password"
|
||||
class="field__input"
|
||||
placeholder="Upprepa lösenordet"
|
||||
/>
|
||||
<p v-if="confirmPasswordError" class="field__hint field__hint--error">
|
||||
{{ confirmPasswordError }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="errorMessage" class="message message--error">
|
||||
{{ errorMessage }}
|
||||
<p class="page__footer-link">
|
||||
<RouterLink to="/glomt-losenord">Begär ny länk</RouterLink>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn--primary btn--lg page__submit"
|
||||
:disabled="!isValid || submitting"
|
||||
>
|
||||
{{ submitting ? 'Sparar...' : 'Spara nytt lösenord' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div v-else-if="successMessage" class="message message--success">
|
||||
{{ successMessage }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="errorMessage" class="message message--error">
|
||||
{{ errorMessage }}
|
||||
<p class="page__footer-link">
|
||||
<RouterLink to="/glomt-losenord">Begär ny länk</RouterLink>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p v-if="!successMessage" class="page__footer-link">
|
||||
<RouterLink to="/logga-in">Tillbaka till inloggning</RouterLink>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
max-width: 28rem;
|
||||
margin: var(--space-3xl) auto 0;
|
||||
padding: 0 var(--space-lg);
|
||||
}
|
||||
|
||||
.page__card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--space-xl);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.page__title {
|
||||
margin: 0 0 var(--space-sm) 0;
|
||||
font-size: 1.5rem;
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
.page__subtitle {
|
||||
margin: 0 0 var(--space-xl) 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.page__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.page__footer-link {
|
||||
margin-top: var(--space-lg);
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.page__submit {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -5,6 +5,9 @@ import AboutPage from '@/pages/AboutPage.vue'
|
|||
import ContactPage from '@/pages/ContactPage.vue'
|
||||
import RegisterPage from '@/pages/RegisterPage.vue'
|
||||
import LoginPage from '@/pages/LoginPage.vue'
|
||||
import ForgotPasswordPage from '@/pages/ForgotPasswordPage.vue'
|
||||
import ResetPasswordPage from '@/pages/ResetPasswordPage.vue'
|
||||
import ChangePasswordPage from '@/pages/ChangePasswordPage.vue'
|
||||
import OrdersPage from '@/pages/OrdersPage.vue'
|
||||
import AdminPage from '@/pages/AdminPage.vue'
|
||||
import PaymentRedirect from '@/pages/PaymentRedirect.vue'
|
||||
|
|
@ -31,6 +34,12 @@ const router = createRouter({
|
|||
component: OrdersPage,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/andra-losenord',
|
||||
name: 'change-password',
|
||||
component: ChangePasswordPage,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
name: 'admin',
|
||||
|
|
@ -55,6 +64,18 @@ const router = createRouter({
|
|||
component: LoginPage,
|
||||
meta: { guestOnly: true },
|
||||
},
|
||||
{
|
||||
path: '/glomt-losenord',
|
||||
name: 'forgot-password',
|
||||
component: ForgotPasswordPage,
|
||||
meta: { guestOnly: true },
|
||||
},
|
||||
{
|
||||
path: '/aterstall-losenord',
|
||||
name: 'reset-password',
|
||||
component: ResetPasswordPage,
|
||||
meta: { guestOnly: true },
|
||||
},
|
||||
{
|
||||
path: '/om',
|
||||
name: 'about',
|
||||
|
|
|
|||
Loading…
Reference in a new issue