Compare commits

...

11 commits

Author SHA1 Message Date
96508d63cd feat: add template picker modal to compose page
- Add LetterTemplate.icon field and 7th template 'Mindre parkeringsskada' (🅿️)
- Create TemplatePicker.vue component: modal overlay with 2-column card grid,
  emits 'select' and 'close' events, closes on overlay click
- Add ' Visa mallar' pill button above textarea in ComposePage
- Clicking button opens TemplatePicker modal, selecting a template fills
  textarea and closes modal
- Style button as pill/badge with light blue background and icon
- Add 7 Vitest tests for TemplatePicker (renders cards, emits events, close
  behavior, parking damage template)
- Add 4 Vitest tests for ComposePage template picker integration
- Add 2 Playwright E2E tests (opens picker, fills textarea and closes)
2026-05-14 17:39:21 +02:00
6ab5e2f707 refactor: remove template from order flow
Templates serve as a brand shield (showing the platform facilitates all
kinds of messaging), not as a compose-flow form control. Remove them from
the data model and compose page. Templates will live as branding elements
on the landing page in a future commit.

Backend:
- Remove template field from Order entity (getter/setter removed)
- Remove template from CreateOrderRequest DTO
- Remove template from OrderResponse DTO
- Remove template param from OrderService.createOrder()
- Remove template passthrough in OrderController
- Remove /api/templates permitAll from SecurityConfig
- Edit V5 migration: remove template column from orders table
- Edit V6 migration: remove template from seed data
- Update OrderControllerTest (remove template from assertions/requests)
- Update OrderServiceTest (remove template from createOrder calls)

Frontend:
- Remove template from Order interface in api/orders.ts
- Remove template param from createOrder() function
- Remove template display from OrdersPage.vue cards
- Rewrite ComposePage.vue: remove template selector, keep textarea + preview + submit
- Update ComposePage.spec.ts (remove template tests, add preview/GDPR tests)
- Update OrdersPage.spec.ts (remove template from mock data and display test)
- Update compose.spec.ts E2E (remove template selector interactions)
- Update order-history.spec.ts E2E (remove template names test)
- Fix unused import in Router.spec.ts
- Also includes minor Prettier formatting in AppHeader.spec.ts, AdminPage.vue, authStore.ts
2026-05-14 16:55:59 +02:00
5fa903d9af feat: build out compose page with template selector, letter editor, and preview
- Add createOrder(plate, template, letterText) to frontend api/orders.ts
- Create data/templates.ts with 6 Swedish letter templates (Komplimang,
  Jag vill köpa din bil, Tips / servicebehov, Synpunkter på körbeteende,
  Tuta / frustration, Fritt meddelande) with pre-filled body text
- Rewrite ComposePage.vue with full compose flow:
  - Template selector dropdown (Fritt meddelande selected by default)
  - Textarea with 1000-char limit and live character counter
  - Inline A4 letter preview with plate, body, and GDPR Art. 14 footer
  - 'Skicka brev (49 kr)' submit button, disabled when empty
  - On success: redirects to /orders; on error: shows error message
  - Shows error with back link if no plate in route query
- Add 12 Vitest tests for ComposePage (template fill, char counter, submit
  validation, createOrder call, navigation, null template for Fritt meddelande)
- Add 8 Playwright E2E tests (auth guard, no-plate error, template selection,
  textarea edit, submit button state, order creation, preview content)
2026-05-14 16:02:14 +02:00
55f0fd8771 feat: add POST /api/orders endpoint with validation
- Create CreateOrderRequest DTO with jakarta.validation annotations
- Validate plate format (ABC123 or ABC12A) via @Pattern regex
- Validate letter text: @NotBlank, @Size(min=1, max=1000)
- Validate template name: optional, @Size(max=50)
- Add POST /api/orders endpoint to OrderController (auth required)
- Return 201 Created with OrderResponse on success
- Add 5 controller tests: no auth (403), create success, invalid plate,
  empty text, text over 1000 chars
- All messages in Swedish (Ogiltigt registreringsnummer, Brevtext krävs, etc.)
2026-05-14 15:45:47 +02:00
0c62d7e60a feat: add orders link to header nav for authenticated users
- Add 'Mina beställningar' RouterLink to AppHeader in authenticated template
- Add Vitest tests: link visible when authenticated, hidden when not
- Add Playwright E2E test: shows orders link when authenticated
- Add Playwright E2E test: can navigate from home to orders via header link
2026-05-14 15:31:06 +02:00
32b315654e feat: add order history page with API endpoint and seeded test data
- Create OrderController with GET /api/orders endpoint (authenticated)
- Add OrderResponse DTO (id, plate, template, status, trackingId, createdAt)
- Seed 3 test orders for test@bilhalsning.se via V6 migration (sent, pending_payment, delivered)
- Create OrderControllerTest with 4 tests (auth, empty list, full fields, user not found)
- Create frontend api/orders.ts with typed fetchOrders() client
- Build out OrdersPage.vue with card list: plate, template, status badge, tracking link
- Add 12 Vitest tests for OrdersPage (loading, data, badges, links, empty, error)
- Add 5 Playwright E2E tests (auth guard, seeded data, badges, tracking, templates)
2026-05-14 15:30:36 +02:00
a74bb89824 feat: add Order entity, repository, and service with TDD tests
- Create V5__create_orders_table.sql migration with orders table
  - UUID primary key, user_id FK to users, status CHECK constraint
  - Indexes on user_id and status columns
- Add OrderStatus enum (PENDING_PAYMENT, PAID, LOOKUP_STARTED, SENT, DELIVERED, FAILED)
- Add OrderStatusConverter for JPA VARCHAR persistence
- Create Order entity with fields: id, userId, plate, template, letterText, status, amountPaid, trackingId, timestamps
- Create OrderRepository with findByUserIdOrderByCreatedAtDesc and findByStatus queries
- Create OrderService with createOrder (normalizes plate, sets PENDING_PAYMENT), getOrdersByUserId, getOrderById
- Add OrderNotFoundException with 404 handler in GlobalExceptionHandler
- Write OrderServiceTest with 8 unit tests covering status, UUID generation, plate normalization, and error handling
2026-05-14 14:34:14 +02:00
6f23368749 feat: show auth state in header with conditional nav links
Update AppHeader to reflect authentication state. When not authenticated,
show Logga in and Registrera links. When authenticated, show the user's
email address and a Logga ut button. Uses v-if/v-else with template blocks
for clean conditional rendering without wrapper elements.

Changes:
- authStore: add email computed that extracts sub claim from JWT payload
- AppHeader: conditional nav with v-if/v-else (guest vs authenticated)
- AppHeader: add email display and logout button with styles
- App.spec.ts: add Pinia to test setup (required by AppHeader now)
- AppHeader.spec.ts: rewrite with tests for both auth states
- authStore.spec.ts: add tests for email extraction and clearing
- header-auth.spec.ts: 5 Playwright E2E tests for header auth state
2026-05-14 13:11:11 +02:00
0d7e672bc3 chore: add Docker build volume and configure OpenCode
Add a named volume for backend build artifacts to prevent root-owned files
created inside the container from blocking host Gradle builds. This follows
the same pattern as the existing backend-gradle-project volume.

Configure OpenCode with LSP, formatter, auto-compaction, and file watcher
settings for improved development experience.

Changes:
- docker-compose.yml: add backend-build:/app/backend/build volume
- opencode.json: enable lsp, formatter, auto-compaction, prune, and
  file watcher with ignore patterns for node_modules, .git, dist, build
2026-05-14 12:39:34 +02:00
8d07bb7ab1 feat: add Vue Router auth guards with admin role support
Implement client-side route protection with role-based access control. The auth
store now extracts the role claim from JWT tokens and exposes isAdmin. Router
guards enforce three levels of access: guestOnly (redirect authenticated users),
requiresAuth (redirect unauthenticated to login with redirect param), and
requiresAdmin (redirect non-admin users to home).

Changes:
- utils/jwt.ts: JWT payload parser using base64url decode (new file)
- authStore: add role ref, isAdmin computed, extractRole from JWT payload
- router: add route metadata (requiresAuth, requiresAdmin, guestOnly) and
  beforeEach guard with getActivePinia() safety for test environments
- OrdersPage.vue, AdminPage.vue: placeholder pages (new files)
- LoginPage.vue, RegisterPage.vue: use route.query.redirect after auth
- Router.spec.ts: 14 tests covering all guard scenarios
- authStore.spec.ts: tests for role extraction, isAdmin, role persistence
- LoginPage.spec.ts: test for redirect query param after login
- auth-guards.spec.ts: 7 Playwright E2E tests for guard behavior
- login.spec.ts: fix seed user credentials (test@bilhalsning.se)
2026-05-14 12:39:17 +02:00
8a95483fb8 feat: add admin role support to backend JWT authentication
Add role-based access control to the backend authentication system. The User
entity now carries a role field (default 'user'), JWT tokens include a 'role'
claim, and the login endpoint populates it from the database.

Changes:
- User entity: add role column (VARCHAR(20), default 'user') with getter/setter
- JwtService: add generateToken(email, role) overload and extractRole(token)
- AuthController: pass user.getRole() on login, 'user' on register
- Flyway V3: ALTER TABLE users ADD COLUMN role
- Flyway V4: seed admin user (admin@bilhalsning.se, role='admin')
- AuthControllerTest: add tests for admin role in token, role from DB on login
- JwtServiceTest: add tests for role extraction and default role
- UserServiceTest: assert role defaults to 'user' on createUser
2026-05-14 12:38:55 +02:00
50 changed files with 2773 additions and 69 deletions

View file

@ -37,7 +37,6 @@ public class SecurityConfig {
.requestMatchers("/api/auth/register", "/api/auth/login").permitAll()
.requestMatchers("/api/webhooks/**").permitAll()
.requestMatchers("/api/vehicles/**").permitAll()
.requestMatchers("/api/templates").permitAll()
.anyRequest().authenticated())
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

View file

@ -26,14 +26,14 @@ public class AuthController {
@PostMapping("/register")
public ResponseEntity<AuthResponse> register(@Valid @RequestBody RegisterRequest request) {
userService.createUser(request.email(), request.password());
String token = jwtService.generateToken(request.email().toLowerCase().trim());
String token = jwtService.generateToken(request.email().toLowerCase().trim(), "user");
return ResponseEntity.status(HttpStatus.CREATED).body(new AuthResponse(token));
}
@PostMapping("/login")
public ResponseEntity<AuthResponse> login(@Valid @RequestBody LoginRequest request) {
User user = userService.authenticate(request.email(), request.password());
String token = jwtService.generateToken(user.getEmail());
String token = jwtService.generateToken(user.getEmail(), user.getRole());
return ResponseEntity.ok(new AuthResponse(token));
}
}

View file

@ -0,0 +1,69 @@
package se.bilhalsning.controller;
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.GetMapping;
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.CreateOrderRequest;
import se.bilhalsning.dto.OrderResponse;
import se.bilhalsning.entity.Order;
import se.bilhalsning.entity.User;
import se.bilhalsning.exception.InvalidCredentialsException;
import se.bilhalsning.service.OrderService;
import se.bilhalsning.service.UserService;
import java.util.List;
@RestController
@RequestMapping("/api/orders")
@RequiredArgsConstructor
public class OrderController {
private final OrderService orderService;
private final UserService userService;
@GetMapping
public ResponseEntity<List<OrderResponse>> list(@AuthenticationPrincipal UserDetails userDetails) {
User user = userService.findByEmail(userDetails.getUsername())
.orElseThrow(InvalidCredentialsException::new);
List<OrderResponse> orders = orderService.getOrdersByUserId(user.getId()).stream()
.map(this::toResponse)
.toList();
return ResponseEntity.ok(orders);
}
@PostMapping
public ResponseEntity<OrderResponse> create(
@Valid @RequestBody CreateOrderRequest request,
@AuthenticationPrincipal UserDetails userDetails) {
User user = userService.findByEmail(userDetails.getUsername())
.orElseThrow(InvalidCredentialsException::new);
Order order = orderService.createOrder(
user.getId(),
request.plate(),
request.letterText()
);
return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(order));
}
private OrderResponse toResponse(Order order) {
return new OrderResponse(
order.getId(),
order.getPlate(),
order.getStatus().getValue(),
order.getTrackingId(),
order.getCreatedAt()
);
}
}

View file

@ -0,0 +1,15 @@
package se.bilhalsning.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
public record CreateOrderRequest(
@NotBlank(message = "Registreringsnummer krävs")
@Pattern(regexp = "^[A-Za-z]{3}\\d{2}[A-Za-z0-9]$", message = "Ogiltigt registreringsnummer")
String plate,
@NotBlank(message = "Brevtext krävs")
@Size(min = 1, max = 1000, message = "Brevtexten måste vara mellan 1 och 1000 tecken")
String letterText
) {}

View file

@ -0,0 +1,12 @@
package se.bilhalsning.dto;
import java.time.Instant;
import java.util.UUID;
public record OrderResponse(
UUID id,
String plate,
String status,
String trackingId,
Instant createdAt
) {}

View file

@ -0,0 +1,125 @@
package se.bilhalsning.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "orders")
public class Order {
@Id
@Column(name = "id", columnDefinition = "uuid", nullable = false, updatable = false)
private UUID id;
@Column(name = "user_id", nullable = false, columnDefinition = "uuid")
private UUID userId;
@Column(name = "plate", nullable = false, length = 10)
private String plate;
@Column(name = "letter_text", nullable = false, columnDefinition = "text")
private String letterText;
@Column(name = "status", nullable = false, length = 30)
private OrderStatus status = OrderStatus.PENDING_PAYMENT;
@Column(name = "amount_paid", precision = 10, scale = 2)
private BigDecimal amountPaid;
@Column(name = "tracking_id", length = 100)
private String trackingId;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
@PrePersist
void onCreate() {
if (this.id == null) {
this.id = UUID.randomUUID();
}
Instant now = Instant.now();
if (this.createdAt == null) {
this.createdAt = now;
}
this.updatedAt = now;
}
@PreUpdate
void onUpdate() {
this.updatedAt = Instant.now();
}
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public UUID getUserId() {
return userId;
}
public void setUserId(UUID userId) {
this.userId = userId;
}
public String getPlate() {
return plate;
}
public void setPlate(String plate) {
this.plate = plate;
}
public String getLetterText() {
return letterText;
}
public void setLetterText(String letterText) {
this.letterText = letterText;
}
public OrderStatus getStatus() {
return status;
}
public void setStatus(OrderStatus status) {
this.status = status;
}
public BigDecimal getAmountPaid() {
return amountPaid;
}
public void setAmountPaid(BigDecimal amountPaid) {
this.amountPaid = amountPaid;
}
public String getTrackingId() {
return trackingId;
}
public void setTrackingId(String trackingId) {
this.trackingId = trackingId;
}
public Instant getCreatedAt() {
return createdAt;
}
public Instant getUpdatedAt() {
return updatedAt;
}
}

View file

@ -0,0 +1,20 @@
package se.bilhalsning.entity;
public enum OrderStatus {
PENDING_PAYMENT("pending_payment"),
PAID("paid"),
LOOKUP_STARTED("lookup_started"),
SENT("sent"),
DELIVERED("delivered"),
FAILED("failed");
private final String value;
OrderStatus(String value) {
this.value = value;
}
public String getValue() {
return value;
}
}

View file

@ -0,0 +1,26 @@
package se.bilhalsning.entity;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
@Converter(autoApply = true)
public class OrderStatusConverter implements AttributeConverter<OrderStatus, String> {
@Override
public String convertToDatabaseColumn(OrderStatus status) {
return status != null ? status.getValue() : null;
}
@Override
public OrderStatus convertToEntityAttribute(String dbData) {
if (dbData == null) {
return null;
}
for (OrderStatus s : OrderStatus.values()) {
if (s.getValue().equals(dbData)) {
return s;
}
}
throw new IllegalArgumentException("Unknown order status value: " + dbData);
}
}

View file

@ -28,6 +28,9 @@ public class User {
@Column(name = "subscription", nullable = false, length = 20)
private Subscription subscription = Subscription.NONE;
@Column(name = "role", nullable = false, length = 20)
private String role = "user";
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@ -83,6 +86,14 @@ public class User {
this.subscription = subscription;
}
public String getRole() {
return role;
}
public void setRole(String role) {
this.role = role;
}
public Instant getCreatedAt() {
return createdAt;
}

View file

@ -28,6 +28,13 @@ public class GlobalExceptionHandler {
.body(new ErrorResponse("E-postadressen är redan registrerad"));
}
@ExceptionHandler(OrderNotFoundException.class)
public ResponseEntity<ErrorResponse> handleOrderNotFound(OrderNotFoundException ex) {
return ResponseEntity
.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse(ex.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
String message = ex.getBindingResult().getFieldErrors().stream()

View file

@ -0,0 +1,9 @@
package se.bilhalsning.exception;
import java.util.UUID;
public class OrderNotFoundException extends RuntimeException {
public OrderNotFoundException(UUID id) {
super("Order not found: " + id);
}
}

View file

@ -0,0 +1,15 @@
package se.bilhalsning.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import se.bilhalsning.entity.Order;
import se.bilhalsning.entity.OrderStatus;
import java.util.List;
import java.util.UUID;
@Repository
public interface OrderRepository extends JpaRepository<Order, UUID> {
List<Order> findByUserIdOrderByCreatedAtDesc(UUID userId);
List<Order> findByStatus(OrderStatus status);
}

View file

@ -24,8 +24,13 @@ public class JwtService {
}
public String generateToken(String email) {
return generateToken(email, "user");
}
public String generateToken(String email, String role) {
return Jwts.builder()
.subject(email)
.claim("role", role)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + expirationMs))
.signWith(secretKey)
@ -41,6 +46,15 @@ public class JwtService {
.getSubject();
}
public String extractRole(String token) {
return Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload()
.get("role", String.class);
}
public boolean isTokenValid(String token) {
try {
Jwts.parser()

View file

@ -0,0 +1,36 @@
package se.bilhalsning.service;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import se.bilhalsning.entity.Order;
import se.bilhalsning.entity.OrderStatus;
import se.bilhalsning.exception.OrderNotFoundException;
import se.bilhalsning.repository.OrderRepository;
import java.util.List;
import java.util.UUID;
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
public Order createOrder(UUID userId, String plate, String letterText) {
Order order = new Order();
order.setUserId(userId);
order.setPlate(plate.toUpperCase().trim());
order.setLetterText(letterText);
order.setStatus(OrderStatus.PENDING_PAYMENT);
return orderRepository.save(order);
}
public List<Order> getOrdersByUserId(UUID userId) {
return orderRepository.findByUserIdOrderByCreatedAtDesc(userId);
}
public Order getOrderById(UUID id) {
return orderRepository.findById(id)
.orElseThrow(() -> new OrderNotFoundException(id));
}
}

View file

@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN role VARCHAR(20) NOT NULL DEFAULT 'user';

View file

@ -0,0 +1,8 @@
INSERT INTO users (id, email, password_hash, subscription, role)
VALUES (
'b1eebc99-9c0b-4ef8-bb6d-6bb9bd380a12',
'admin@bilhalsning.se',
'$2b$12$18UFRDPgHWuw5FYeu6X1ReisFjjuxs5XxDafi6.wZbsywoU7vUaLG',
'none',
'admin'
);

View file

@ -0,0 +1,17 @@
CREATE TABLE orders (
id UUID NOT NULL,
user_id UUID NOT NULL,
plate VARCHAR(10) NOT NULL,
letter_text TEXT NOT NULL,
status VARCHAR(30) NOT NULL DEFAULT 'pending_payment',
amount_paid DECIMAL(10,2),
tracking_id VARCHAR(100),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT pk_orders PRIMARY KEY (id),
CONSTRAINT fk_orders_user FOREIGN KEY (user_id) REFERENCES users(id),
CONSTRAINT ck_orders_status CHECK (status IN ('pending_payment', 'paid', 'lookup_started', 'sent', 'delivered', 'failed'))
);
CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_orders_status ON orders(status);

View file

@ -0,0 +1,6 @@
-- Seed orders for test user (test@bilhalsning.se, id: a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11)
INSERT INTO orders (id, user_id, plate, letter_text, status, amount_paid, tracking_id, created_at, updated_at)
VALUES
('c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'ABC123', 'Hej! Jag ville bara säga att du har en väldigt fin bil. Hälsningar från en bilentusiast!', 'sent', 49.00, 'PN123456789', TIMESTAMP '2026-05-11 12:00:00', TIMESTAMP '2026-05-13 12:00:00'),
('c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12', 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'DEF456', 'Hej! Jag är intresserad av att köpa din bil. Kontakta mig gärna på test@example.com så kan vi diskutera ett pris.', 'pending_payment', NULL, NULL, TIMESTAMP '2026-05-14 13:00:00', TIMESTAMP '2026-05-14 13:00:00'),
('c3eebc99-9c0b-4ef8-bb6d-6bb9bd380a13', 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'GHI789', 'Hej! Jag noterade att ditt bakre högra hjul har lite för lågt lufttryck. Tänkte det kan vara bra att veta!', 'delivered', 49.00, 'PN987654321', TIMESTAMP '2026-05-07 10:00:00', TIMESTAMP '2026-05-12 10:00:00');

View file

@ -39,7 +39,7 @@ class AuthControllerTest {
@Test
void shouldReturn201AndTokenWhenRegisterSucceeds() throws Exception {
when(userService.createUser("new@example.com", "password123")).thenReturn(null);
when(jwtService.generateToken("new@example.com")).thenReturn("test-jwt-token");
when(jwtService.generateToken("new@example.com", "user")).thenReturn("test-jwt-token");
RegisterRequest request = new RegisterRequest("new@example.com", "password123");
mockMvc.perform(post("/api/auth/register")
@ -93,8 +93,9 @@ class AuthControllerTest {
void shouldReturn200AndTokenWhenLoginSucceeds() throws Exception {
User user = new User();
user.setEmail("user@example.com");
user.setRole("user");
when(userService.authenticate("user@example.com", "password123")).thenReturn(user);
when(jwtService.generateToken("user@example.com")).thenReturn("login-jwt-token");
when(jwtService.generateToken("user@example.com", "user")).thenReturn("login-jwt-token");
LoginRequest request = new LoginRequest("user@example.com", "password123");
mockMvc.perform(post("/api/auth/login")
@ -104,6 +105,22 @@ class AuthControllerTest {
.andExpect(jsonPath("$.token").value("login-jwt-token"));
}
@Test
void shouldReturnAdminRoleInTokenWhenAdminLogsIn() throws Exception {
User admin = new User();
admin.setEmail("admin@bilhalsning.se");
admin.setRole("admin");
when(userService.authenticate("admin@bilhalsning.se", "admin1234")).thenReturn(admin);
when(jwtService.generateToken("admin@bilhalsning.se", "admin")).thenReturn("admin-jwt-token");
LoginRequest request = new LoginRequest("admin@bilhalsning.se", "admin1234");
mockMvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.token").value("admin-jwt-token"));
}
@Test
void shouldReturn401WhenCredentialsAreInvalid() throws Exception {
when(userService.authenticate("user@example.com", "wrongpassword"))

View file

@ -0,0 +1,164 @@
package se.bilhalsning.controller;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
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.status;
import java.time.Instant;
import java.util.List;
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.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
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.OrderResponse;
import se.bilhalsning.entity.User;
import se.bilhalsning.service.OrderService;
import se.bilhalsning.service.UserService;
@SpringBootTest
@AutoConfigureMockMvc
class OrderControllerTest {
@Autowired
private MockMvc mockMvc;
@MockitoBean
private OrderService orderService;
@MockitoBean
private UserService userService;
@Test
void shouldReturn403WhenNotAuthenticated() throws Exception {
mockMvc.perform(get("/api/orders"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(username = "test@bilhalsning.se")
void shouldReturnOrdersForAuthenticatedUser() throws Exception {
UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
User user = new User();
user.setId(userId);
user.setEmail("test@bilhalsning.se");
when(userService.findByEmail("test@bilhalsning.se")).thenReturn(Optional.of(user));
when(orderService.getOrdersByUserId(userId)).thenReturn(List.of());
mockMvc.perform(get("/api/orders"))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray())
.andExpect(jsonPath("$").isEmpty());
}
@Test
@WithMockUser(username = "test@bilhalsning.se")
void shouldReturnOrderWithAllFields() throws Exception {
UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
User user = new User();
user.setId(userId);
user.setEmail("test@bilhalsning.se");
when(userService.findByEmail("test@bilhalsning.se")).thenReturn(Optional.of(user));
se.bilhalsning.entity.Order order = new se.bilhalsning.entity.Order();
order.setId(UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"));
order.setUserId(userId);
order.setPlate("ABC123");
order.setLetterText("Test letter");
order.setStatus(se.bilhalsning.entity.OrderStatus.SENT);
order.setTrackingId("PN123456789");
when(orderService.getOrdersByUserId(userId)).thenReturn(List.of(order));
mockMvc.perform(get("/api/orders"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].id").value("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"))
.andExpect(jsonPath("$[0].plate").value("ABC123"))
.andExpect(jsonPath("$[0].status").value("sent"))
.andExpect(jsonPath("$[0].trackingId").value("PN123456789"));
}
@Test
@WithMockUser(username = "unknown@example.com")
void shouldReturn401WhenUserNotFound() throws Exception {
when(userService.findByEmail("unknown@example.com")).thenReturn(Optional.empty());
mockMvc.perform(get("/api/orders"))
.andExpect(status().isUnauthorized());
}
@Test
void shouldReturn403WhenPostingWithoutAuth() throws Exception {
mockMvc.perform(post("/api/orders")
.contentType("application/json")
.content("{\"plate\":\"ABC123\",\"letterText\":\"Hej\"}"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(username = "test@bilhalsning.se")
void shouldCreateOrderSuccessfully() throws Exception {
UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
User user = new User();
user.setId(userId);
user.setEmail("test@bilhalsning.se");
when(userService.findByEmail("test@bilhalsning.se")).thenReturn(Optional.of(user));
se.bilhalsning.entity.Order savedOrder = new se.bilhalsning.entity.Order();
savedOrder.setId(UUID.fromString("d1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"));
savedOrder.setUserId(userId);
savedOrder.setPlate("ABC123");
savedOrder.setLetterText("Hej fin bil!");
savedOrder.setStatus(se.bilhalsning.entity.OrderStatus.PENDING_PAYMENT);
when(orderService.createOrder(userId, "ABC123", "Hej fin bil!"))
.thenReturn(savedOrder);
mockMvc.perform(post("/api/orders")
.contentType("application/json")
.content("{\"plate\":\"ABC123\",\"letterText\":\"Hej fin bil!\"}"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value("d1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"))
.andExpect(jsonPath("$.plate").value("ABC123"))
.andExpect(jsonPath("$.status").value("pending_payment"));
}
@Test
@WithMockUser(username = "test@bilhalsning.se")
void shouldRejectInvalidPlateFormat() throws Exception {
mockMvc.perform(post("/api/orders")
.contentType("application/json")
.content("{\"plate\":\"INVALID\",\"letterText\":\"Hej\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("Ogiltigt registreringsnummer")));
}
@Test
@WithMockUser(username = "test@bilhalsning.se")
void shouldRejectEmptyLetterText() throws Exception {
mockMvc.perform(post("/api/orders")
.contentType("application/json")
.content("{\"plate\":\"ABC123\",\"letterText\":\"\"}"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(username = "test@bilhalsning.se")
void shouldRejectLetterTextOver1000Chars() throws Exception {
String longText = "a".repeat(1001);
mockMvc.perform(post("/api/orders")
.contentType("application/json")
.content("{\"plate\":\"ABC123\",\"letterText\":\"" + longText + "\"}"))
.andExpect(status().isBadRequest());
}
}

View file

@ -71,4 +71,24 @@ class JwtServiceTest {
assertFalse(jwtService.isTokenValid(tampered));
}
@Test
void shouldExtractUserRoleFromToken() {
JwtService jwtService = new JwtService(SECRET);
String token = jwtService.generateToken(EMAIL, "admin");
String role = jwtService.extractRole(token);
assertEquals("admin", role);
}
@Test
void shouldDefaultToUserRoleWhenNoRoleSpecified() {
JwtService jwtService = new JwtService(SECRET);
String token = jwtService.generateToken(EMAIL);
String role = jwtService.extractRole(token);
assertEquals("user", role);
}
}

View file

@ -0,0 +1,130 @@
package se.bilhalsning.service;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import se.bilhalsning.entity.Order;
import se.bilhalsning.entity.OrderStatus;
import se.bilhalsning.exception.OrderNotFoundException;
import se.bilhalsning.repository.OrderRepository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock
private OrderRepository orderRepository;
@InjectMocks
private OrderService orderService;
@Captor
private ArgumentCaptor<Order> orderCaptor;
@Test
void shouldCreateOrderWithPendingPaymentStatus() {
UUID userId = UUID.randomUUID();
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
Order result = orderService.createOrder(userId, "ABC123", "Hej fin bil!");
assertEquals(OrderStatus.PENDING_PAYMENT, result.getStatus());
verify(orderRepository).save(orderCaptor.capture());
assertEquals(OrderStatus.PENDING_PAYMENT, orderCaptor.getValue().getStatus());
}
@Test
void shouldGenerateIdOnCreate() {
UUID userId = UUID.randomUUID();
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> {
Order order = inv.getArgument(0);
if (order.getId() == null) {
order.setId(UUID.randomUUID());
}
return order;
});
Order result = orderService.createOrder(userId, "ABC123", "Test text");
assertNotNull(result.getId());
}
@Test
void shouldNormalizePlateToUppercase() {
UUID userId = UUID.randomUUID();
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
Order result = orderService.createOrder(userId, "abc123", "Test text");
assertEquals("ABC123", result.getPlate());
}
@Test
void shouldTrimPlateWhitespace() {
UUID userId = UUID.randomUUID();
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
Order result = orderService.createOrder(userId, " ABC123 ", "Test text");
assertEquals("ABC123", result.getPlate());
}
@Test
void shouldSetAllFieldsOnCreate() {
UUID userId = UUID.randomUUID();
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
Order result = orderService.createOrder(userId, "ABC123", "Hej fin bil!");
assertEquals(userId, result.getUserId());
assertEquals("ABC123", result.getPlate());
assertEquals("Hej fin bil!", result.getLetterText());
assertNull(result.getAmountPaid());
assertNull(result.getTrackingId());
}
@Test
void shouldReturnOrdersByUserId() {
UUID userId = UUID.randomUUID();
Order order1 = new Order();
Order order2 = new Order();
when(orderRepository.findByUserIdOrderByCreatedAtDesc(userId))
.thenReturn(List.of(order1, order2));
List<Order> result = orderService.getOrdersByUserId(userId);
assertEquals(2, result.size());
verify(orderRepository).findByUserIdOrderByCreatedAtDesc(userId);
}
@Test
void shouldReturnOrderById() {
UUID orderId = UUID.randomUUID();
Order order = new Order();
when(orderRepository.findById(orderId)).thenReturn(Optional.of(order));
Order result = orderService.getOrderById(orderId);
assertSame(order, result);
}
@Test
void shouldThrowWhenOrderNotFound() {
UUID orderId = UUID.randomUUID();
when(orderRepository.findById(orderId)).thenReturn(Optional.empty());
assertThrows(OrderNotFoundException.class,
() -> orderService.getOrderById(orderId));
}
}

View file

@ -49,6 +49,7 @@ class UserServiceTest {
assertEquals("new@example.com", result.getEmail());
assertEquals("hashed", result.getPasswordHash());
assertEquals(Subscription.NONE, result.getSubscription());
assertEquals("user", result.getRole());
verify(userRepository).save(any(User.class));
}

View file

@ -38,6 +38,7 @@ services:
volumes:
- .:/app
- backend-gradle-project:/app/.gradle
- backend-build:/app/backend/build
- gradle-cache:/root/.gradle
frontend:
@ -58,3 +59,4 @@ volumes:
pgdata:
gradle-cache:
backend-gradle-project:
backend-build:

View file

@ -0,0 +1,73 @@
import { test, expect } from '@playwright/test'
test.describe('Auth guards', () => {
test('redirects unauthenticated user from /compose to /logga-in', async ({
page,
}) => {
await page.goto('/compose')
await expect(page).toHaveURL(/\/logga-in\?redirect=\/compose/)
await expect(page.getByRole('heading', { name: 'Logga in' })).toBeVisible()
})
test('redirects unauthenticated user from /orders to /logga-in', async ({
page,
}) => {
await page.goto('/orders')
await expect(page).toHaveURL(/\/logga-in\?redirect=\/orders/)
await expect(page.getByRole('heading', { name: 'Logga in' })).toBeVisible()
})
test('redirects unauthenticated user from /admin to /logga-in', async ({
page,
}) => {
await page.goto('/admin')
await expect(page).toHaveURL(/\/logga-in\?redirect=\/admin/)
await expect(page.getByRole('heading', { name: 'Logga in' })).toBeVisible()
})
test('redirects authenticated user from /logga-in to home', async ({
page,
}) => {
const jwt = makeJwt({ role: 'user' })
await page.goto('/')
await page.evaluate((token) => localStorage.setItem('auth_token', token), jwt)
await page.goto('/logga-in')
await expect(page).toHaveURL('/')
})
test('redirects authenticated user from /registrera to home', async ({
page,
}) => {
const jwt = makeJwt({ role: 'user' })
await page.goto('/')
await page.evaluate((token) => localStorage.setItem('auth_token', token), jwt)
await page.goto('/registrera')
await expect(page).toHaveURL('/')
})
test('redirects non-admin user from /admin to home', async ({ page }) => {
const jwt = makeJwt({ role: 'user' })
await page.goto('/')
await page.evaluate((token) => localStorage.setItem('auth_token', token), jwt)
await page.goto('/admin')
await expect(page).toHaveURL('/')
})
test('allows admin user to access /admin', async ({ page }) => {
const jwt = makeJwt({ role: 'admin' })
await page.goto('/')
await page.evaluate((token) => localStorage.setItem('auth_token', token), jwt)
await page.goto('/admin')
await expect(page).toHaveURL('/admin')
await expect(
page.getByRole('heading', { name: 'Administration' }),
).toBeVisible()
})
})
function makeJwt(payload: Record<string, unknown>): string {
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
const body = btoa(JSON.stringify(payload))
const signature = 'test-sig'
return `${header}.${body}.${signature}`
}

View file

@ -0,0 +1,122 @@
import { test, expect } from '@playwright/test'
test.describe('Compose flow', () => {
test('redirects unauthenticated user to login', async ({ page }) => {
await page.goto('/compose?plate=ABC123')
await expect(page).toHaveURL(/\/logga-in\?redirect=\/compose/)
await expect(page.getByRole('heading', { name: 'Logga in' })).toBeVisible()
})
test('shows error when no plate is provided', async ({ page }) => {
await page.goto('/logga-in')
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
await page.getByLabel('Lösenord').fill('test1234')
await page.getByRole('button', { name: 'Logga in' }).click()
await page.waitForURL('/')
await page.goto('/compose')
await expect(page.getByText('Inget registreringsnummer valt')).toBeVisible()
})
test('displays plate and textarea', async ({ page }) => {
await page.goto('/logga-in')
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
await page.getByLabel('Lösenord').fill('test1234')
await page.getByRole('button', { name: 'Logga in' }).click()
await page.waitForURL('/')
await page.goto('/compose?plate=ABC123')
await expect(
page.getByRole('heading', { name: 'Skriv ditt brev' }),
).toBeVisible()
await expect(page.getByText('ABC123')).toBeVisible()
await expect(page.getByLabel('Ditt meddelande')).toBeVisible()
})
test('submit button disabled when textarea is empty', async ({ page }) => {
await page.goto('/logga-in')
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
await page.getByLabel('Lösenord').fill('test1234')
await page.getByRole('button', { name: 'Logga in' }).click()
await page.waitForURL('/')
await page.goto('/compose?plate=ABC123')
const button = page.getByRole('button', { name: 'Skicka brev (49 kr)' })
await expect(button).toBeDisabled()
})
test('can create order and navigate to orders page', async ({ page }) => {
await page.goto('/logga-in')
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
await page.getByLabel('Lösenord').fill('test1234')
await page.getByRole('button', { name: 'Logga in' }).click()
await page.waitForURL('/')
await page.goto('/compose?plate=ABC123')
await page.getByLabel('Ditt meddelande').fill('Hej fin bil!')
const button = page.getByRole('button', { name: 'Skicka brev (49 kr)' })
await expect(button).toBeEnabled()
await button.click()
await expect(page).toHaveURL('/orders')
await expect(
page.getByRole('heading', { name: 'Mina beställningar' }),
).toBeVisible()
})
test('preview shows letter content and GDPR footer', async ({ page }) => {
await page.goto('/logga-in')
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
await page.getByLabel('Lösenord').fill('test1234')
await page.getByRole('button', { name: 'Logga in' }).click()
await page.waitForURL('/')
await page.goto('/compose?plate=ABC123')
await page.getByLabel('Ditt meddelande').fill('Testmeddelande')
await expect(
page.getByText('Detta brev skickades via BilHej.se'),
).toBeVisible()
await expect(page.getByText('Transportstyrelsens fordonsregister')).toBeVisible()
})
test('Visa mallar button opens template picker', async ({ page }) => {
await page.goto('/logga-in')
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
await page.getByLabel('Lösenord').fill('test1234')
await page.getByRole('button', { name: 'Logga in' }).click()
await page.waitForURL('/')
await page.goto('/compose?plate=ABC123')
await page.getByRole('button', { name: 'Visa mallar' }).click()
await expect(page.getByRole('heading', { name: 'Välj en mall' })).toBeVisible()
await expect(page.getByText('Komplimang')).toBeVisible()
await expect(page.getByText('Köpförfrågan')).toBeVisible()
})
test('selecting template fills textarea and closes picker', async ({
page,
}) => {
await page.goto('/logga-in')
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
await page.getByLabel('Lösenord').fill('test1234')
await page.getByRole('button', { name: 'Logga in' }).click()
await page.waitForURL('/')
await page.goto('/compose?plate=ABC123')
await page.getByRole('button', { name: 'Visa mallar' }).click()
await page.getByText('Komplimang').click()
const textarea = page.getByLabel('Ditt meddelande')
await expect(textarea).toHaveValue(/jättefin/)
await expect(page.getByRole('heading', { name: 'Välj en mall' })).not.toBeVisible()
})
})

View file

@ -0,0 +1,108 @@
import { test, expect } from '@playwright/test'
test.describe('Header auth state', () => {
test('shows login and register links when not authenticated', async ({
page,
}) => {
await page.goto('/')
const header = page.locator('header')
await expect(header.getByRole('link', { name: 'Logga in' })).toBeVisible()
await expect(
header.getByRole('link', { name: 'Registrera' }),
).toBeVisible()
})
test('does not show logout button when not authenticated', async ({
page,
}) => {
await page.goto('/')
const header = page.locator('header')
await expect(
header.getByRole('button', { name: 'Logga ut' }),
).not.toBeVisible()
})
test('shows email and logout when authenticated', async ({ page }) => {
const jwt = makeJwt({ sub: 'test@bilhalsning.se', role: 'user' })
await page.goto('/')
await page.evaluate(
(token) => localStorage.setItem('auth_token', token),
jwt,
)
await page.goto('/')
const header = page.locator('header')
await expect(header.getByText('test@bilhalsning.se')).toBeVisible()
await expect(
header.getByRole('button', { name: 'Logga ut' }),
).toBeVisible()
})
test('shows orders link when authenticated', async ({ page }) => {
const jwt = makeJwt({ sub: 'test@bilhalsning.se', role: 'user' })
await page.goto('/')
await page.evaluate(
(token) => localStorage.setItem('auth_token', token),
jwt,
)
await page.goto('/')
const header = page.locator('header')
const ordersLink = header.getByRole('link', {
name: 'Mina beställningar',
})
await expect(ordersLink).toBeVisible()
await expect(ordersLink).toHaveAttribute('href', '/orders')
})
test('hides login and register links when authenticated', async ({
page,
}) => {
const jwt = makeJwt({ sub: 'test@bilhalsning.se', role: 'user' })
await page.goto('/')
await page.evaluate(
(token) => localStorage.setItem('auth_token', token),
jwt,
)
await page.goto('/')
const header = page.locator('header')
await expect(
header.getByRole('link', { name: 'Logga in' }),
).not.toBeVisible()
await expect(
header.getByRole('link', { name: 'Registrera' }),
).not.toBeVisible()
})
test('logout restores login and register links', async ({ page }) => {
const jwt = makeJwt({ sub: 'test@bilhalsning.se', role: 'user' })
await page.goto('/')
await page.evaluate(
(token) => localStorage.setItem('auth_token', token),
jwt,
)
await page.goto('/')
const header = page.locator('header')
await header.getByRole('button', { name: 'Logga ut' }).click()
await expect(
header.getByRole('link', { name: 'Logga in' }),
).toBeVisible()
await expect(
header.getByRole('link', { name: 'Registrera' }),
).toBeVisible()
await expect(
header.getByRole('button', { name: 'Logga ut' }),
).not.toBeVisible()
await expect(header.getByText('test@bilhalsning.se')).not.toBeVisible()
})
})
function makeJwt(payload: Record<string, unknown>): string {
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
const body = btoa(JSON.stringify(payload))
const signature = 'test-sig'
return `${header}.${body}.${signature}`
}

View file

@ -17,8 +17,8 @@ test.describe('Login page', () => {
test('redirects to home after successful login', async ({ page }) => {
await page.goto('/logga-in')
await page.getByLabel('E-postadress').fill('test@example.com')
await page.getByLabel('Lösenord').fill('password123')
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
await page.getByLabel('Lösenord').fill('test1234')
await page.getByRole('button', { name: 'Logga in' }).click()
await expect(page).toHaveURL('/')

View file

@ -0,0 +1,74 @@
import { test, expect } from '@playwright/test'
test.describe('Order history', () => {
test('redirects unauthenticated user to login', async ({ page }) => {
await page.goto('/orders')
await expect(page).toHaveURL(/\/logga-in\?redirect=\/orders/)
await expect(page.getByRole('heading', { name: 'Logga in' })).toBeVisible()
})
test('can navigate from home to orders via header link', async ({
page,
}) => {
await page.goto('/logga-in')
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
await page.getByLabel('Lösenord').fill('test1234')
await page.getByRole('button', { name: 'Logga in' }).click()
await page.waitForURL('/')
const header = page.locator('header')
await header.getByRole('link', { name: 'Mina beställningar' }).click()
await expect(page).toHaveURL('/orders')
await expect(
page.getByRole('heading', { name: 'Mina beställningar' }),
).toBeVisible()
})
test('displays page heading and seeded orders', async ({ page }) => {
await page.goto('/logga-in')
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
await page.getByLabel('Lösenord').fill('test1234')
await page.getByRole('button', { name: 'Logga in' }).click()
await page.waitForURL('/')
await page.goto('/orders')
await expect(page.getByRole('heading', { name: 'Mina beställningar' })).toBeVisible()
await expect(page.getByText('ABC123')).toBeVisible()
await expect(page.getByText('DEF456')).toBeVisible()
await expect(page.getByText('GHI789')).toBeVisible()
})
test('shows correct status badges', async ({ page }) => {
await page.goto('/logga-in')
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
await page.getByLabel('Lösenord').fill('test1234')
await page.getByRole('button', { name: 'Logga in' }).click()
await page.waitForURL('/')
await page.goto('/orders')
await expect(page.getByText('Skickat')).toBeVisible()
await expect(page.getByText('Väntar på betalning')).toBeVisible()
await expect(page.getByText('Levererat')).toBeVisible()
})
test('shows tracking links for orders with tracking ID', async ({ page }) => {
await page.goto('/logga-in')
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
await page.getByLabel('Lösenord').fill('test1234')
await page.getByRole('button', { name: 'Logga in' }).click()
await page.waitForURL('/')
await page.goto('/orders')
const trackingLink1 = page.getByRole('link', { name: 'PN123456789' })
await expect(trackingLink1).toBeVisible()
await expect(trackingLink1).toHaveAttribute('href', /postnord/)
await expect(trackingLink1).toHaveAttribute('target', '_blank')
const trackingLink2 = page.getByRole('link', { name: 'PN987654321' })
await expect(trackingLink2).toBeVisible()
})
})

View file

@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import App from '@/App.vue'
import AppHeader from '@/components/AppHeader.vue'
import AppFooter from '@/components/AppFooter.vue'
@ -7,11 +8,12 @@ import router from '@/router'
describe('App', () => {
it('renders AppHeader and AppFooter', async () => {
setActivePinia(createPinia())
router.push('/')
await router.isReady()
const wrapper = mount(App, {
global: {
plugins: [router],
plugins: [router, createPinia()],
},
})
expect(wrapper.findComponent(AppHeader).exists()).toBe(true)
@ -19,11 +21,12 @@ describe('App', () => {
})
it('renders RouterView with HomePage content', async () => {
setActivePinia(createPinia())
router.push('/')
await router.isReady()
const wrapper = mount(App, {
global: {
plugins: [router],
plugins: [router, createPinia()],
},
})
expect(wrapper.text()).toContain('Skicka ett brev till en fordonsägare')

View file

@ -1,7 +1,9 @@
import { describe, it, expect } from 'vitest'
import { describe, it, expect, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import { setActivePinia, createPinia } from 'pinia'
import AppHeader from '@/components/AppHeader.vue'
import { useAuthStore } from '@/stores/authStore'
function createTestRouter() {
return createRouter({
@ -18,15 +20,32 @@ function createTestRouter() {
name: 'register',
component: { template: '<div>Register</div>' },
},
{
path: '/orders',
name: 'orders',
component: { template: '<div>Orders</div>' },
},
],
})
}
function makeJwt(payload: Record<string, unknown>): string {
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
const body = btoa(JSON.stringify(payload))
const signature = 'test-sig'
return `${header}.${body}.${signature}`
}
describe('AppHeader', () => {
beforeEach(() => {
setActivePinia(createPinia())
localStorage.clear()
})
it('renders the logo text', () => {
const router = createTestRouter()
const wrapper = mount(AppHeader, {
global: { plugins: [router] },
global: { plugins: [router, createPinia()] },
})
expect(wrapper.text()).toContain('BilHälsning')
})
@ -34,34 +53,121 @@ describe('AppHeader', () => {
it('has a link to home', () => {
const router = createTestRouter()
const wrapper = mount(AppHeader, {
global: { plugins: [router] },
global: { plugins: [router, createPinia()] },
})
const links = wrapper.findAll('a')
const homeLink = links.find((a) => a.attributes('href') === '/')
expect(homeLink).toBeTruthy()
})
it('has a link to register', () => {
const router = createTestRouter()
const wrapper = mount(AppHeader, {
global: { plugins: [router] },
describe('when not authenticated', () => {
it('shows login link', () => {
const router = createTestRouter()
const wrapper = mount(AppHeader, {
global: { plugins: [router, createPinia()] },
})
const links = wrapper.findAll('a')
const loginLink = links.find((a) => a.attributes('href') === '/logga-in')
expect(loginLink).toBeTruthy()
expect(loginLink?.text()).toBe('Logga in')
})
it('shows register link', () => {
const router = createTestRouter()
const wrapper = mount(AppHeader, {
global: { plugins: [router, createPinia()] },
})
const links = wrapper.findAll('a')
const registerLink = links.find(
(a) => a.attributes('href') === '/registrera',
)
expect(registerLink).toBeTruthy()
expect(registerLink?.text()).toBe('Registrera')
})
it('does not show logout button', () => {
const router = createTestRouter()
const wrapper = mount(AppHeader, {
global: { plugins: [router, createPinia()] },
})
expect(wrapper.find('button').exists()).toBe(false)
})
it('does not show user email', () => {
const router = createTestRouter()
const wrapper = mount(AppHeader, {
global: { plugins: [router, createPinia()] },
})
expect(wrapper.text()).not.toContain('@bilhalsning.se')
})
it('does not show orders link', () => {
const router = createTestRouter()
const wrapper = mount(AppHeader, {
global: { plugins: [router, createPinia()] },
})
const links = wrapper.findAll('a')
const ordersLink = links.find((a) => a.attributes('href') === '/orders')
expect(ordersLink).toBeUndefined()
})
const links = wrapper.findAll('a')
const registerLink = links.find(
(a) => a.attributes('href') === '/registrera',
)
expect(registerLink).toBeTruthy()
expect(registerLink?.text()).toBe('Registrera')
})
it('has a link to login', () => {
const router = createTestRouter()
const wrapper = mount(AppHeader, {
global: { plugins: [router] },
describe('when authenticated', () => {
function mountAuthenticated() {
const jwt = makeJwt({ sub: 'test@bilhalsning.se', role: 'user' })
localStorage.setItem('auth_token', jwt)
const pinia = createPinia()
setActivePinia(pinia)
const router = createTestRouter()
return mount(AppHeader, {
global: { plugins: [router, pinia] },
})
}
it('shows user email', () => {
const wrapper = mountAuthenticated()
expect(wrapper.text()).toContain('test@bilhalsning.se')
})
it('shows logout button', () => {
const wrapper = mountAuthenticated()
const logoutButton = wrapper.find('button')
expect(logoutButton.exists()).toBe(true)
expect(logoutButton.text()).toBe('Logga ut')
})
it('does not show login link', () => {
const wrapper = mountAuthenticated()
const links = wrapper.findAll('a')
const loginLink = links.find((a) => a.attributes('href') === '/logga-in')
expect(loginLink).toBeUndefined()
})
it('does not show register link', () => {
const wrapper = mountAuthenticated()
const links = wrapper.findAll('a')
const registerLink = links.find(
(a) => a.attributes('href') === '/registrera',
)
expect(registerLink).toBeUndefined()
})
it('shows orders link', () => {
const wrapper = mountAuthenticated()
const links = wrapper.findAll('a')
const ordersLink = links.find((a) => a.attributes('href') === '/orders')
expect(ordersLink).toBeTruthy()
expect(ordersLink?.text()).toBe('Mina beställningar')
})
it('calls logout when clicking logout button', async () => {
const wrapper = mountAuthenticated()
const auth = useAuthStore()
expect(auth.isAuthenticated).toBe(true)
await wrapper.find('button').trigger('click')
expect(auth.isAuthenticated).toBe(false)
})
const links = wrapper.findAll('a')
const loginLink = links.find((a) => a.attributes('href') === '/logga-in')
expect(loginLink).toBeTruthy()
expect(loginLink?.text()).toBe('Logga in')
})
})

View file

@ -1,43 +1,210 @@
import { describe, it, expect } from 'vitest'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { createRouter, createMemoryHistory } from 'vue-router'
import ComposePage from '@/pages/ComposePage.vue'
vi.mock('@/api/orders', () => ({
createOrder: vi.fn(),
}))
import { createOrder } from '@/api/orders'
const mockCreateOrder = vi.mocked(createOrder)
function createTestRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [{ path: '/compose', name: 'compose', component: ComposePage }],
routes: [
{
path: '/',
name: 'home',
component: { template: '<div>Home</div>' },
},
{
path: '/compose',
name: 'compose',
component: ComposePage,
},
{
path: '/orders',
name: 'orders',
component: { template: '<div>Orders</div>' },
},
],
})
}
async function mountPage(plate = 'ABC123') {
const pinia = createPinia()
setActivePinia(pinia)
const router = createTestRouter()
await router.push({ name: 'compose', query: { plate } })
await router.isReady()
const wrapper = mount(ComposePage, {
global: {
plugins: [router, pinia],
},
})
return { wrapper, router }
}
describe('ComposePage', () => {
it('renders heading', async () => {
const router = createTestRouter()
router.push('/compose')
await router.isReady()
const wrapper = mount(ComposePage, {
global: { plugins: [router] },
})
expect(wrapper.text()).toContain('Skriv ditt brev')
beforeEach(() => {
vi.clearAllMocks()
})
it('displays plate from query param', async () => {
const router = createTestRouter()
router.push({ path: '/compose', query: { plate: 'ABC123' } })
await router.isReady()
const wrapper = mount(ComposePage, {
global: { plugins: [router] },
})
expect(wrapper.text()).toContain('ABC123')
it('shows plate from route query', async () => {
const { wrapper } = await mountPage('XYZ789')
expect(wrapper.text()).toContain('XYZ789')
})
it('does not show plate when no query param', async () => {
const router = createTestRouter()
router.push('/compose')
await router.isReady()
const wrapper = mount(ComposePage, {
global: { plugins: [router] },
it('shows error when no plate is provided', async () => {
const { wrapper } = await mountPage('')
expect(wrapper.text()).toContain('Inget registreringsnummer valt')
})
it('shows textarea for letter input', async () => {
const { wrapper } = await mountPage()
const textarea = wrapper.find('textarea')
expect(textarea.exists()).toBe(true)
})
it('updates character counter on input', async () => {
const { wrapper } = await mountPage()
const textarea = wrapper.find('textarea')
await textarea.setValue('Hej!')
expect(wrapper.text()).toContain('4 / 1000 tecken')
})
it('shows warning when character count exceeds 900', async () => {
const { wrapper } = await mountPage()
const textarea = wrapper.find('textarea')
await textarea.setValue('a'.repeat(901))
const counter = wrapper.find('.compose__counter')
expect(counter.classes()).toContain('compose__counter--warn')
})
it('disables submit button when textarea is empty', async () => {
const { wrapper } = await mountPage()
const button = wrapper.find('button[type="submit"]')
expect(button.attributes('disabled')).toBeDefined()
})
it('enables submit button when textarea has text', async () => {
const { wrapper } = await mountPage()
const textarea = wrapper.find('textarea')
await textarea.setValue('Hej!')
const button = wrapper.find('button[type="submit"]')
expect(button.attributes('disabled')).toBeUndefined()
})
it('calls createOrder on submit', async () => {
mockCreateOrder.mockResolvedValue({
id: 'order-1',
plate: 'ABC123',
status: 'pending_payment',
trackingId: null,
createdAt: '2025-01-01T00:00:00Z',
})
expect(wrapper.find('.compose__plate').exists()).toBe(false)
const { wrapper } = await mountPage()
const textarea = wrapper.find('textarea')
await textarea.setValue('Hej fin bil!')
const button = wrapper.find('button[type="submit"]')
await button.trigger('submit')
await vi.waitFor(() => {
expect(mockCreateOrder).toHaveBeenCalledWith('ABC123', 'Hej fin bil!')
})
})
it('navigates to /orders on success', async () => {
mockCreateOrder.mockResolvedValue({
id: 'order-1',
plate: 'ABC123',
status: 'pending_payment',
trackingId: null,
createdAt: '2025-01-01T00:00:00Z',
})
const { wrapper, router } = await mountPage()
const textarea = wrapper.find('textarea')
await textarea.setValue('Test letter')
const button = wrapper.find('button[type="submit"]')
await button.trigger('submit')
await vi.waitFor(() => {
expect(router.currentRoute.value.name).toBe('orders')
})
})
it('shows error message on API failure', async () => {
mockCreateOrder.mockRejectedValue(new Error('Network error'))
const { wrapper } = await mountPage()
const textarea = wrapper.find('textarea')
await textarea.setValue('Test letter')
const button = wrapper.find('button[type="submit"]')
await button.trigger('submit')
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Kunde inte skapa beställningen')
})
})
it('shows preview with letter content', async () => {
const { wrapper } = await mountPage()
const textarea = wrapper.find('textarea')
await textarea.setValue('Hej!')
expect(wrapper.text()).toContain('Förhandsvisning')
expect(wrapper.text()).toContain('Hej!')
})
it('shows GDPR footer in preview', async () => {
const { wrapper } = await mountPage()
expect(wrapper.text()).toContain('Detta brev skickades via BilHej.se')
})
it('shows Visa mallar button', async () => {
const { wrapper } = await mountPage()
const btn = wrapper.find('.compose__templates-btn')
expect(btn.exists()).toBe(true)
expect(btn.text()).toContain('Visa mallar')
})
it('opens template picker when Visa mallar is clicked', async () => {
const { wrapper } = await mountPage()
const btn = wrapper.find('.compose__templates-btn')
await btn.trigger('click')
expect(wrapper.text()).toContain('Välj en mall')
expect(wrapper.text()).toContain('Komplimang')
})
it('fills textarea when template is selected', async () => {
const { wrapper } = await mountPage()
const btn = wrapper.find('.compose__templates-btn')
await btn.trigger('click')
const cards = wrapper.findAll('.modal__card')
await cards[0].trigger('click')
const textarea = wrapper.find('textarea')
expect(textarea.element.value).toContain('jättefin')
})
it('closes picker after template is selected', async () => {
const { wrapper } = await mountPage()
const btn = wrapper.find('.compose__templates-btn')
await btn.trigger('click')
const cards = wrapper.findAll('.modal__card')
await cards[0].trigger('click')
expect(wrapper.find('.modal-overlay').exists()).toBe(false)
})
})

View file

@ -23,6 +23,11 @@ function createTestRouter() {
name: 'register',
component: { template: '<div>Register</div>' },
},
{
path: '/compose',
name: 'compose',
component: { template: '<div>Compose</div>' },
},
],
})
}
@ -145,4 +150,21 @@ describe('LoginPage', () => {
const { wrapper } = mountPage()
expect(wrapper.text()).toContain('Har du inget konto?')
})
it('redirects to query param after login', async () => {
const router = createTestRouter()
await router.push({ path: '/logga-in', query: { redirect: '/compose' } })
const pinia = createPinia()
const wrapper = mount(LoginPage, {
global: { plugins: [router, pinia] },
})
await wrapper.find('#email').setValue('test@example.com')
await wrapper.find('#password').setValue('password123')
await wrapper.find('form').trigger('submit.prevent')
await new Promise((resolve) => setTimeout(resolve, 50))
expect(router.currentRoute.value.fullPath).toBe('/compose')
})
})

View file

@ -0,0 +1,158 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import { createPinia } from 'pinia'
import OrdersPage from '@/pages/OrdersPage.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: '/orders', name: 'orders', component: OrdersPage },
{ path: '/', name: 'home', component: { template: '<div>Home</div>' } },
],
})
}
function mountPage() {
const router = createTestRouter()
const pinia = createPinia()
router.push('/orders')
return {
router,
wrapper: mount(OrdersPage, {
global: { plugins: [router, pinia] },
}),
}
}
const mockOrders = [
{
id: 'c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11',
plate: 'ABC123',
status: 'sent',
trackingId: 'PN123456789',
createdAt: '2026-05-11T12:00:00Z',
},
{
id: 'c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12',
plate: 'DEF456',
status: 'pending_payment',
trackingId: null,
createdAt: '2026-05-14T13:00:00Z',
},
]
describe('OrdersPage', () => {
beforeEach(() => {
localStorage.clear()
globalThis.fetch = vi.fn()
vi.mocked(globalThis.fetch).mockResolvedValue(
mockFetchResponse(200, mockOrders),
)
})
it('renders heading and subtitle', async () => {
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).toContain('Mina beställningar')
expect(wrapper.text()).toContain(
'Här kan du se dina tidigare beställningar',
)
})
it('shows loading state initially', async () => {
globalThis.fetch = vi.fn().mockImplementation(() => new Promise(() => {}))
const { wrapper } = mountPage()
expect(wrapper.text()).toContain('Laddar beställningar...')
})
it('fetches orders from API on mount', async () => {
mountPage()
await new Promise((r) => setTimeout(r, 50))
expect(globalThis.fetch).toHaveBeenCalledWith(
'/api/orders',
expect.objectContaining({ headers: expect.any(Object) }),
)
})
it('renders order cards with plate numbers', async () => {
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).toContain('ABC123')
expect(wrapper.text()).toContain('DEF456')
})
it('renders Swedish status labels', async () => {
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).toContain('Skickat')
expect(wrapper.text()).toContain('Väntar på betalning')
})
it('renders tracking link when trackingId exists', async () => {
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
const link = wrapper.find('a[href*="postnord"]')
expect(link.exists()).toBe(true)
expect(link.text()).toContain('PN123456789')
expect(link.attributes('target')).toBe('_blank')
})
it('does not render tracking link when trackingId is null', async () => {
const ordersWithoutTracking = [
{
id: 'c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12',
plate: 'DEF456',
status: 'pending_payment',
trackingId: null,
createdAt: '2026-05-14T13:00:00Z',
},
]
vi.mocked(globalThis.fetch).mockResolvedValue(
mockFetchResponse(200, ordersWithoutTracking),
)
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
const link = wrapper.find('a[href*="postnord"]')
expect(link.exists()).toBe(false)
})
it('renders formatted date', async () => {
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).toContain('2026')
})
it('shows empty state when no orders', async () => {
vi.mocked(globalThis.fetch).mockResolvedValue(mockFetchResponse(200, []))
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).toContain('Du har inga beställningar ännu')
})
it('shows error state on API failure', async () => {
vi.mocked(globalThis.fetch).mockResolvedValue(
mockFetchResponse(500, { message: 'Internal server error' }),
)
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).toContain('Kunde inte hämta beställningar')
})
it('applies correct badge class for status', async () => {
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
const badges = wrapper.findAll('.orders__badge')
expect(badges[0].classes()).toContain('badge--green')
expect(badges[1].classes()).toContain('badge--gray')
})
})

View file

@ -1,7 +1,13 @@
import { describe, it, expect } from 'vitest'
import { describe, it, expect, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import router from '@/router'
describe('Router', () => {
beforeEach(() => {
setActivePinia(createPinia())
localStorage.clear()
})
it('resolves / to HomePage', async () => {
await router.push('/')
await router.isReady()
@ -20,9 +26,100 @@ describe('Router', () => {
expect(router.currentRoute.value.name).toBe('login')
})
it('resolves /orders to OrdersPage', async () => {
localStorage.setItem('auth_token', makeJwt({ role: 'user' }))
await router.push('/orders')
await router.isReady()
expect(router.currentRoute.value.name).toBe('orders')
})
it('resolves /admin to AdminPage for admin user', async () => {
localStorage.setItem('auth_token', makeJwt({ role: 'admin' }))
await router.push('/admin')
await router.isReady()
expect(router.currentRoute.value.name).toBe('admin')
})
it('does not crash on unknown route', async () => {
await router.push('/nonexistent')
await router.isReady()
expect(router.currentRoute.value.matched.length).toBe(0)
})
})
describe('Router guards', () => {
beforeEach(() => {
setActivePinia(createPinia())
localStorage.clear()
})
it('redirects unauthenticated user from /compose to /logga-in', async () => {
await router.push('/compose')
await router.isReady()
expect(router.currentRoute.value.name).toBe('login')
expect(router.currentRoute.value.query.redirect).toBe('/compose')
})
it('redirects unauthenticated user from /orders to /logga-in', async () => {
await router.push('/orders')
await router.isReady()
expect(router.currentRoute.value.name).toBe('login')
expect(router.currentRoute.value.query.redirect).toBe('/orders')
})
it('redirects unauthenticated user from /admin to /logga-in', async () => {
await router.push('/admin')
await router.isReady()
expect(router.currentRoute.value.name).toBe('login')
expect(router.currentRoute.value.query.redirect).toBe('/admin')
})
it('allows authenticated user to access /compose', async () => {
localStorage.setItem('auth_token', makeJwt({ role: 'user' }))
await router.push('/compose')
await router.isReady()
expect(router.currentRoute.value.name).toBe('compose')
})
it('allows authenticated user to access /orders', async () => {
localStorage.setItem('auth_token', makeJwt({ role: 'user' }))
await router.push('/orders')
await router.isReady()
expect(router.currentRoute.value.name).toBe('orders')
})
it('redirects authenticated user from /logga-in to home', async () => {
localStorage.setItem('auth_token', makeJwt({ role: 'user' }))
await router.push('/logga-in')
await router.isReady()
expect(router.currentRoute.value.name).toBe('home')
})
it('redirects authenticated user from /registrera to home', async () => {
localStorage.setItem('auth_token', makeJwt({ role: 'user' }))
await router.push('/registrera')
await router.isReady()
expect(router.currentRoute.value.name).toBe('home')
})
it('redirects non-admin user from /admin to home', async () => {
localStorage.setItem('auth_token', makeJwt({ role: 'user' }))
await router.push('/admin')
await router.isReady()
expect(router.currentRoute.value.name).toBe('home')
})
it('allows admin user to access /admin', async () => {
localStorage.setItem('auth_token', makeJwt({ role: 'admin' }))
await router.push('/admin')
await router.isReady()
expect(router.currentRoute.value.name).toBe('admin')
})
})
function makeJwt(payload: Record<string, unknown>): string {
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
const body = btoa(JSON.stringify(payload))
const signature = 'test-sig'
return `${header}.${body}.${signature}`
}

View file

@ -0,0 +1,59 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import TemplatePicker from '@/components/TemplatePicker.vue'
describe('TemplatePicker', () => {
it('renders all template cards', () => {
const wrapper = mount(TemplatePicker)
const cards = wrapper.findAll('.modal__card')
expect(cards).toHaveLength(7)
})
it('shows template names', () => {
const wrapper = mount(TemplatePicker)
expect(wrapper.text()).toContain('Komplimang')
expect(wrapper.text()).toContain('Köpförfrågan')
expect(wrapper.text()).toContain('Fritt meddelande')
})
it('emits select event with template data when card is clicked', async () => {
const wrapper = mount(TemplatePicker)
const cards = wrapper.findAll('.modal__card')
await cards[0].trigger('click')
expect(wrapper.emitted('select')).toHaveLength(1)
expect(wrapper.emitted('select')![0][0]).toMatchObject({
name: 'Komplimang',
icon: '🌟',
})
})
it('emits close event when card is clicked', async () => {
const wrapper = mount(TemplatePicker)
const cards = wrapper.findAll('.modal__card')
await cards[0].trigger('click')
expect(wrapper.emitted('close')).toHaveLength(1)
})
it('emits close event when close button is clicked', async () => {
const wrapper = mount(TemplatePicker)
const closeBtn = wrapper.find('.modal__close')
await closeBtn.trigger('click')
expect(wrapper.emitted('close')).toHaveLength(1)
})
it('emits close event when overlay is clicked', async () => {
const wrapper = mount(TemplatePicker)
const overlay = wrapper.find('.modal-overlay')
await overlay.trigger('click')
expect(wrapper.emitted('close')).toHaveLength(1)
})
it('includes new parking damage template', () => {
const wrapper = mount(TemplatePicker)
expect(wrapper.text()).toContain('Mindre parkeringsskada')
})
})

View file

@ -129,4 +129,92 @@ describe('authStore', () => {
const registerCall = calls.find((call) => call[0] === '/api/auth/register')
expect(registerCall).toBeUndefined()
})
it('extracts role from JWT token', async () => {
const jwt = makeJwt({ role: 'admin' })
vi.mocked(globalThis.fetch).mockResolvedValue(
mockFetchResponse(200, { token: jwt }),
)
const store = useAuthStore()
await store.loginUser('admin@example.com', 'password123')
expect(store.role).toBe('admin')
expect(store.isAdmin).toBe(true)
})
it('defaults to null role for user role', async () => {
const jwt = makeJwt({ role: 'user' })
vi.mocked(globalThis.fetch).mockResolvedValue(
mockFetchResponse(200, { token: jwt }),
)
const store = useAuthStore()
await store.loginUser('user@example.com', 'password123')
expect(store.role).toBe('user')
expect(store.isAdmin).toBe(false)
})
it('clears role on logout', async () => {
const jwt = makeJwt({ role: 'admin' })
vi.mocked(globalThis.fetch).mockResolvedValue(
mockFetchResponse(200, { token: jwt }),
)
const store = useAuthStore()
await store.loginUser('admin@example.com', 'password123')
expect(store.isAdmin).toBe(true)
store.logout()
expect(store.role).toBeNull()
expect(store.isAdmin).toBe(false)
})
it('restores role from localStorage on init', () => {
const jwt = makeJwt({ role: 'admin' })
localStorage.setItem('auth_token', jwt)
const store = useAuthStore()
expect(store.role).toBe('admin')
expect(store.isAdmin).toBe(true)
})
it('extracts email from JWT sub claim', async () => {
const jwt = makeJwt({ sub: 'test@bilhalsning.se', role: 'user' })
vi.mocked(globalThis.fetch).mockResolvedValue(
mockFetchResponse(200, { token: jwt }),
)
const store = useAuthStore()
await store.loginUser('test@bilhalsning.se', 'test1234')
expect(store.email).toBe('test@bilhalsning.se')
})
it('returns null email when not authenticated', () => {
const store = useAuthStore()
expect(store.email).toBeNull()
})
it('clears email on logout', async () => {
const jwt = makeJwt({ sub: 'test@bilhalsning.se', role: 'user' })
vi.mocked(globalThis.fetch).mockResolvedValue(
mockFetchResponse(200, { token: jwt }),
)
const store = useAuthStore()
await store.loginUser('test@bilhalsning.se', 'test1234')
expect(store.email).toBe('test@bilhalsning.se')
store.logout()
expect(store.email).toBeNull()
})
})
function makeJwt(payload: Record<string, unknown>): string {
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
const body = btoa(JSON.stringify(payload))
const signature = 'test-sig'
return `${header}.${body}.${signature}`
}

View file

@ -0,0 +1,20 @@
import { request } from './client'
export interface Order {
id: string
plate: string
status: string
trackingId: string | null
createdAt: string
}
export function fetchOrders(): Promise<Order[]> {
return request<Order[]>('/orders')
}
export function createOrder(plate: string, letterText: string): Promise<Order> {
return request<Order>('/orders', {
method: 'POST',
body: JSON.stringify({ plate, letterText }),
})
}

View file

@ -1,5 +1,8 @@
<script setup lang="ts">
import { RouterLink } from 'vue-router'
import { useAuthStore } from '@/stores/authStore'
const auth = useAuthStore()
</script>
<template>
@ -7,10 +10,23 @@ import { RouterLink } from 'vue-router'
<RouterLink to="/" class="app-header__logo">BilHälsning</RouterLink>
<nav class="app-header__nav">
<RouterLink to="/" class="app-header__link">Hem</RouterLink>
<RouterLink to="/logga-in" class="app-header__link">Logga in</RouterLink>
<RouterLink to="/registrera" class="app-header__link"
>Registrera</RouterLink
>
<template v-if="!auth.isAuthenticated">
<RouterLink to="/logga-in" class="app-header__link"
>Logga in</RouterLink
>
<RouterLink to="/registrera" class="app-header__link"
>Registrera</RouterLink
>
</template>
<template v-else>
<RouterLink to="/orders" class="app-header__link"
>Mina beställningar</RouterLink
>
<span class="app-header__email">{{ auth.email }}</span>
<button class="app-header__logout" @click="auth.logout()">
Logga ut
</button>
</template>
</nav>
</header>
</template>
@ -46,4 +62,22 @@ import { RouterLink } from 'vue-router'
.app-header__link:hover {
color: #1a202c;
}
.app-header__email {
color: #4a5568;
font-size: 0.875rem;
}
.app-header__logout {
background: none;
border: none;
color: #4a5568;
font-size: 0.875rem;
cursor: pointer;
padding: 0;
}
.app-header__logout:hover {
color: #1a202c;
}
</style>

View file

@ -0,0 +1,151 @@
<script setup lang="ts">
import { templates, type LetterTemplate } from '@/data/templates'
const emit = defineEmits<{
(e: 'select', template: LetterTemplate): void
(e: 'close'): void
}>()
function handleSelect(template: LetterTemplate) {
emit('select', template)
emit('close')
}
</script>
<template>
<div class="modal-overlay" @click.self="emit('close')">
<div class="modal">
<div class="modal__header">
<h2 class="modal__title">Välj en mall</h2>
<button class="modal__close" @click="emit('close')">&times;</button>
</div>
<p class="modal__subtitle">
Klicka en mall för att fylla i meddelandetexten.
</p>
<div class="modal__grid">
<button
v-for="t in templates"
:key="t.name"
class="modal__card"
@click="handleSelect(t)"
>
<span class="modal__card-icon">{{ t.icon }}</span>
<span class="modal__card-name">{{ t.name }}</span>
</button>
</div>
</div>
</div>
</template>
<style scoped>
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.modal {
background: #fff;
border-radius: 1rem;
width: 100%;
max-width: 28rem;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
}
.modal__header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem 1.5rem 0;
}
.modal__title {
margin: 0;
font-size: 1.25rem;
color: #1a202c;
}
.modal__close {
background: none;
border: none;
font-size: 1.5rem;
color: #a0aec0;
cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
transition:
color 0.15s,
background 0.15s;
}
.modal__close:hover {
color: #4a5568;
background: #f7fafc;
}
.modal__subtitle {
margin: 0.5rem 0 0;
padding: 0 1.5rem;
font-size: 0.875rem;
color: #718096;
}
.modal__grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
padding: 1.25rem 1.5rem 1.5rem;
}
.modal__card {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 1.25rem 0.75rem;
background: #f7fafc;
border: 2px solid #e2e8f0;
border-radius: 0.75rem;
cursor: pointer;
transition:
border-color 0.15s,
background 0.15s,
transform 0.1s;
font-family: inherit;
}
.modal__card:hover {
border-color: #4299e1;
background: #ebf8ff;
transform: translateY(-1px);
}
.modal__card:active {
transform: translateY(0);
}
.modal__card-icon {
font-size: 2rem;
}
.modal__card-name {
font-size: 0.8125rem;
font-weight: 600;
color: #4a5568;
text-align: center;
line-height: 1.3;
}
@media (max-width: 400px) {
.modal__grid {
grid-template-columns: 1fr;
}
}
</style>

View file

@ -0,0 +1,73 @@
export interface LetterTemplate {
name: string
icon: string
body: string
}
export const templates: LetterTemplate[] = [
{
name: 'Komplimang',
icon: '🌟',
body: `Hej!
Jag ville bara säga att din bil är jättefin! Det syns att den är väl omhändertagen och jag uppskattar verkligen att du tar hand om den bra.
Ha en trevlig dag!`,
},
{
name: 'Köpförfrågan',
icon: '🚗',
body: `Hej!
Jag är intresserad av att köpa din bil. Om du någon gång funderar att sälja den, får du gärna höra av dig.
Du kan mig : [din e-postadress eller telefonnummer]
Vänliga hälsningar,
[Ditt namn]`,
},
{
name: 'Tips / servicebehov',
icon: '🔧',
body: `Hej!
Jag ville tipsa dig om att jag märkte att din bil behöver lite uppmärksamhet. Det kan vara bra att kolla upp det snart som möjligt.
Hoppas detta var till hjälp!`,
},
{
name: 'Körbeteende',
icon: '🛣️',
body: `Hej!
Jag ville uppmärksamma dig en situation i trafiken där jag reagerade ditt körbeteende. Jag menar inget illa utan vill bara ge en vänlig påminnelse om att vara extra uppmärksam.
Tack för att du lyssnar!`,
},
{
name: 'Tuta / frustration',
icon: '📢',
body: `Hej!
Jag ville nämna en situation i trafiken där vi båda kanske blev lite frustrerade. Det är lätt att det blir ibland, men jag ville ut för att lösa det ett trevligt sätt.
Ha det bra!`,
},
{
name: 'Mindre parkeringsskada',
icon: '🅿️',
body: `Hej!
Jag råkade skada din bil lite när jag parkerade. Det var inte meningen och jag ber om ursäkt. Jag vill gärna att vi löser det här tillsammans.
Du kan mig : [din e-postadress eller telefonnummer]
Vänliga hälsningar,
[Ditt namn]`,
},
{
name: 'Fritt meddelande',
icon: '✏️',
body: '',
},
]

View file

@ -0,0 +1,28 @@
<script setup lang="ts"></script>
<template>
<div class="admin">
<h1 class="admin__title">Administration</h1>
<p class="admin__subtitle">Hantera beställningar, mallar och användare.</p>
</div>
</template>
<style scoped>
.admin {
max-width: 48rem;
margin: 3rem auto 0;
padding: 0 1rem;
}
.admin__title {
margin: 0 0 0.25rem 0;
font-size: 1.5rem;
color: #1a202c;
}
.admin__subtitle {
margin: 0;
color: #718096;
font-size: 0.875rem;
}
</style>

View file

@ -1,14 +1,112 @@
<script setup lang="ts">
import { useRoute } from 'vue-router'
import { ref, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { createOrder } from '@/api/orders'
import { type LetterTemplate } from '@/data/templates'
import TemplatePicker from '@/components/TemplatePicker.vue'
const router = useRouter()
const route = useRoute()
const plate = (route.query.plate as string) || ''
const plate = computed(() => (route.query.plate as string) || '')
const letterText = ref('')
const submitting = ref(false)
const errorMessage = ref('')
const showPicker = ref(false)
const charCount = computed(() => letterText.value.length)
const maxChars = 1000
const canSubmit = computed(
() => letterText.value.trim().length > 0 && !submitting.value,
)
const GDPR_FOOTER =
'Detta brev skickades via BilHej.se. Din adress hämtades från Transportstyrelsens fordonsregister och har raderats efter utskick. För frågor: hej@bilhalsning.se'
function handleTemplateSelect(template: LetterTemplate) {
letterText.value = template.body
}
async function handleSubmit() {
if (!canSubmit.value) return
submitting.value = true
errorMessage.value = ''
try {
await createOrder(plate.value, letterText.value)
await router.push({ name: 'orders' })
} catch {
errorMessage.value = 'Kunde inte skapa beställningen. Försök igen senare.'
} finally {
submitting.value = false
}
}
</script>
<template>
<div class="compose">
<h1>Skriv ditt brev</h1>
<p v-if="plate" class="compose__plate">Registreringsnummer: {{ plate }}</p>
<h1 class="compose__title">Skriv ditt brev</h1>
<p v-if="plate" class="compose__plate">
Registreringsnummer: <strong>{{ plate }}</strong>
</p>
<p v-if="!plate" class="compose__error">
Inget registreringsnummer valt.
<RouterLink to="/"> tillbaka</RouterLink>
</p>
<form v-if="plate" class="compose__form" @submit.prevent="handleSubmit">
<div class="compose__field">
<div class="compose__label-row">
<label for="letter" class="compose__label">Ditt meddelande</label>
<button
type="button"
class="compose__templates-btn"
@click="showPicker = true"
>
Visa mallar
</button>
</div>
<textarea
id="letter"
v-model="letterText"
class="compose__textarea"
:maxlength="maxChars"
rows="10"
placeholder="Skriv ditt meddelande här..."
></textarea>
<p
class="compose__counter"
:class="{ 'compose__counter--warn': charCount > 900 }"
>
{{ charCount }} / {{ maxChars }} tecken
</p>
</div>
<div class="compose__preview">
<h2 class="compose__preview-title">Förhandsvisning</h2>
<div class="compose__preview-page">
<p class="compose__preview-plate">Registreringsnummer: {{ plate }}</p>
<p class="compose__preview-body" style="white-space: pre-wrap">
{{ letterText }}
</p>
<hr class="compose__preview-divider" />
<p class="compose__preview-footer">{{ GDPR_FOOTER }}</p>
</div>
</div>
<p v-if="errorMessage" class="compose__api-error">{{ errorMessage }}</p>
<button type="submit" class="compose__submit" :disabled="!canSubmit">
{{ submitting ? 'Skickar...' : 'Skicka brev (49 kr)' }}
</button>
</form>
<TemplatePicker
v-if="showPicker"
@select="handleTemplateSelect"
@close="showPicker = false"
/>
</div>
</template>
@ -19,8 +117,185 @@ const plate = (route.query.plate as string) || ''
padding: 0 1rem;
}
.compose__title {
margin: 0 0 0.25rem 0;
font-size: 1.5rem;
color: #1a202c;
}
.compose__plate {
margin: 0 0 1.5rem 0;
color: #4a5568;
font-size: 0.875rem;
}
.compose__error {
margin: 2rem 0;
padding: 1rem;
background: #fff5f5;
border: 1px solid #fed7d7;
border-radius: 0.5rem;
color: #c53030;
font-size: 0.875rem;
}
.compose__error a {
color: #4299e1;
text-decoration: none;
}
.compose__error a:hover {
text-decoration: underline;
}
.compose__form {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.compose__field {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.compose__label {
font-size: 0.875rem;
font-weight: 500;
color: #4a5568;
}
.compose__label-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.compose__templates-btn {
background: #ebf8ff;
border: 1px solid #bee3f8;
color: #2b6cb0;
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
padding: 0.375rem 0.875rem;
border-radius: 9999px;
transition:
background 0.15s,
border-color 0.15s;
}
.compose__templates-btn:hover {
background: #bee3f8;
border-color: #90cdf4;
}
.compose__textarea {
width: 100%;
padding: 0.75rem 1rem;
font-size: 1rem;
font-family: inherit;
border: 2px solid #cbd5e0;
border-radius: 0.5rem;
outline: none;
resize: vertical;
transition: border-color 0.15s ease;
box-sizing: border-box;
}
.compose__textarea:focus {
border-color: #4299e1;
box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.25);
}
.compose__counter {
margin: 0;
font-size: 0.75rem;
color: #a0aec0;
text-align: right;
}
.compose__counter--warn {
color: #e53e3e;
}
.compose__preview {
margin-top: 0.5rem;
}
.compose__preview-title {
margin: 0 0 0.75rem 0;
font-size: 1rem;
color: #4a5568;
}
.compose__preview-page {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 0.5rem;
padding: 2rem 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
font-family: Georgia, 'Times New Roman', serif;
font-size: 0.9375rem;
line-height: 1.6;
color: #2d3748;
}
.compose__preview-plate {
margin: 0 0 1.5rem 0;
font-family: monospace;
font-size: 0.875rem;
color: #718096;
}
.compose__preview-body {
margin: 0 0 1.5rem 0;
min-height: 4rem;
}
.compose__preview-divider {
margin: 1.5rem 0;
border: none;
border-top: 1px solid #e2e8f0;
}
.compose__preview-footer {
margin: 0;
font-size: 0.75rem;
color: #a0aec0;
line-height: 1.5;
}
.compose__api-error {
margin: 0;
padding: 0.75rem 1rem;
background: #fff5f5;
border: 1px solid #fed7d7;
border-radius: 0.5rem;
color: #c53030;
font-size: 0.875rem;
}
.compose__submit {
width: 100%;
padding: 0.875rem 1.5rem;
background: #38a169;
color: #fff;
border: none;
border-radius: 0.5rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s ease;
}
.compose__submit:hover:not(:disabled) {
background: #2f855a;
}
.compose__submit:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>

View file

@ -1,9 +1,10 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter, RouterLink } from 'vue-router'
import { useRouter, useRoute, RouterLink } from 'vue-router'
import { useAuthStore } from '@/stores/authStore'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const email = ref('')
@ -23,7 +24,8 @@ async function handleSubmit() {
try {
await authStore.loginUser(email.value, password.value)
await router.push('/')
const redirect = route.query.redirect as string | undefined
await router.push(redirect || '/')
} catch {
errorMessage.value = 'Felaktig e-post eller lösenord'
} finally {

View file

@ -0,0 +1,231 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { fetchOrders, type Order } from '@/api/orders'
const orders = ref<Order[]>([])
const loading = ref(true)
const error = ref('')
const statusLabels: Record<string, string> = {
pending_payment: 'Väntar på betalning',
paid: 'Betalad',
lookup_started: 'Hanteras',
sent: 'Skickat',
delivered: 'Levererat',
failed: 'Misslyckad',
}
const statusClasses: Record<string, string> = {
pending_payment: 'badge--gray',
paid: 'badge--blue',
lookup_started: 'badge--blue',
sent: 'badge--green',
delivered: 'badge--green',
failed: 'badge--red',
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('sv-SE', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
onMounted(async () => {
try {
orders.value = await fetchOrders()
} catch {
error.value = 'Kunde inte hämta beställningar. Försök igen senare.'
} finally {
loading.value = false
}
})
</script>
<template>
<div class="orders">
<h1 class="orders__title">Mina beställningar</h1>
<p class="orders__subtitle">Här kan du se dina tidigare beställningar.</p>
<p v-if="loading" class="orders__loading">Laddar beställningar...</p>
<p v-else-if="error" class="orders__error">{{ error }}</p>
<p v-else-if="orders.length === 0" class="orders__empty">
Du har inga beställningar ännu.
</p>
<div v-else class="orders__list">
<div v-for="order in orders" :key="order.id" class="orders__card">
<div class="orders__card-header">
<span class="orders__plate">{{ order.plate }}</span>
<span
class="orders__badge"
:class="statusClasses[order.status] || 'badge--gray'"
>
{{ statusLabels[order.status] || order.status }}
</span>
</div>
<div class="orders__card-body">
<div class="orders__detail">
<span class="orders__label">Datum</span>
<span class="orders__value">{{ formatDate(order.createdAt) }}</span>
</div>
<div v-if="order.trackingId" class="orders__detail">
<span class="orders__label">Spårning</span>
<a
class="orders__tracking-link"
:href="`https://www.postnord.se/verktyg/spara/?id=${order.trackingId}`"
target="_blank"
rel="noopener noreferrer"
>
{{ order.trackingId }}
</a>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.orders {
max-width: 48rem;
margin: 3rem auto 0;
padding: 0 1rem;
}
.orders__title {
margin: 0 0 0.25rem 0;
font-size: 1.5rem;
color: #1a202c;
}
.orders__subtitle {
margin: 0 0 1.5rem 0;
color: #718096;
font-size: 0.875rem;
}
.orders__loading,
.orders__error,
.orders__empty {
margin: 2rem 0;
padding: 1rem;
border-radius: 0.5rem;
font-size: 0.875rem;
text-align: center;
}
.orders__loading {
color: #718096;
}
.orders__error {
background: #fff5f5;
border: 1px solid #fed7d7;
color: #c53030;
}
.orders__empty {
background: #f7fafc;
border: 1px solid #e2e8f0;
color: #718096;
}
.orders__list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.orders__card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 0.75rem;
overflow: hidden;
}
.orders__card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.25rem;
background: #f7fafc;
border-bottom: 1px solid #e2e8f0;
}
.orders__plate {
font-size: 1.125rem;
font-weight: 600;
color: #1a202c;
letter-spacing: 0.05em;
}
.orders__badge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.badge--gray {
background: #edf2f7;
color: #718096;
}
.badge--blue {
background: #ebf8ff;
color: #2b6cb0;
}
.badge--green {
background: #f0fff4;
color: #276749;
}
.badge--red {
background: #fff5f5;
color: #c53030;
}
.orders__card-body {
padding: 1rem 1.25rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.orders__detail {
display: flex;
gap: 0.75rem;
}
.orders__label {
min-width: 5rem;
font-size: 0.8125rem;
color: #a0aec0;
font-weight: 500;
}
.orders__value {
font-size: 0.875rem;
color: #4a5568;
}
.orders__tracking-link {
font-size: 0.875rem;
color: #4299e1;
text-decoration: none;
}
.orders__tracking-link:hover {
text-decoration: underline;
}
</style>

View file

@ -1,10 +1,11 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter, RouterLink } from 'vue-router'
import { useRouter, useRoute, RouterLink } from 'vue-router'
import { useAuthStore } from '@/stores/authStore'
import { ApiError } from '@/api/client'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const email = ref('')
@ -54,7 +55,8 @@ async function handleSubmit() {
try {
await authStore.registerUser(email.value, password.value)
await router.push('/')
const redirect = route.query.redirect as string | undefined
await router.push(redirect || '/')
} catch (err) {
if (err instanceof ApiError) {
errorMessage.value = err.message

View file

@ -5,6 +5,10 @@ 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 OrdersPage from '@/pages/OrdersPage.vue'
import AdminPage from '@/pages/AdminPage.vue'
import { useAuthStore } from '@/stores/authStore'
import { getActivePinia } from 'pinia'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
@ -18,16 +22,31 @@ const router = createRouter({
path: '/compose',
name: 'compose',
component: ComposePage,
meta: { requiresAuth: true },
},
{
path: '/orders',
name: 'orders',
component: OrdersPage,
meta: { requiresAuth: true },
},
{
path: '/admin',
name: 'admin',
component: AdminPage,
meta: { requiresAuth: true, requiresAdmin: true },
},
{
path: '/registrera',
name: 'register',
component: RegisterPage,
meta: { guestOnly: true },
},
{
path: '/logga-in',
name: 'login',
component: LoginPage,
meta: { guestOnly: true },
},
{
path: '/om',
@ -42,4 +61,16 @@ const router = createRouter({
],
})
router.beforeEach((to) => {
if (!getActivePinia()) return
const auth = useAuthStore()
if (to.meta.guestOnly && auth.isAuthenticated) return { name: 'home' }
if (to.meta.requiresAuth && !auth.isAuthenticated) {
return { name: 'login', query: { redirect: to.fullPath } }
}
if (to.meta.requiresAdmin && !auth.isAdmin) return { name: 'home' }
})
export default router

View file

@ -1,19 +1,35 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { register, login } from '@/api/auth'
import { parseJwtPayload } from '@/utils/jwt'
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(localStorage.getItem('auth_token'))
const role = ref<string | null>(extractRole(token.value))
const isAuthenticated = computed(() => token.value !== null)
const isAdmin = computed(() => role.value === 'admin')
const email = computed(() => {
if (!token.value) return null
const payload = parseJwtPayload(token.value)
return payload.sub ?? null
})
function extractRole(jwt: string | null): string | null {
if (!jwt) return null
const payload = parseJwtPayload(jwt)
return payload.role ?? null
}
function setToken(newToken: string) {
token.value = newToken
role.value = extractRole(newToken)
localStorage.setItem('auth_token', newToken)
}
function clearToken() {
token.value = null
role.value = null
localStorage.removeItem('auth_token')
}
@ -31,5 +47,14 @@ export const useAuthStore = defineStore('auth', () => {
clearToken()
}
return { token, isAuthenticated, registerUser, loginUser, logout }
return {
token,
role,
email,
isAuthenticated,
isAdmin,
registerUser,
loginUser,
logout,
}
})

22
frontend/src/utils/jwt.ts Normal file
View file

@ -0,0 +1,22 @@
export interface JwtPayload {
sub?: string
role?: string
exp?: number
iat?: number
}
export function parseJwtPayload(token: string): JwtPayload {
try {
const base64Url = token.split('.')[1]
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/')
const jsonPayload = decodeURIComponent(
atob(base64)
.split('')
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
.join(''),
)
return JSON.parse(jsonPayload)
} catch {
return {}
}
}

View file

@ -1,6 +1,8 @@
{
"$schema": "https://opencode.ai/config.json",
"instructions": ["CODING_GUIDELINES.md", "REQUIREMENTS.md"],
"lsp": true,
"formatter": true,
"permission": {
"edit": "ask",
"bash": "ask"
@ -8,5 +10,12 @@
"tools": {
"websearch": true,
"codesearch": true
},
"compaction": {
"auto": true,
"prune": true
},
"watcher": {
"ignore": ["node_modules/**", ".git/**", "dist/**", "build/**", "**/target/**"]
}
}