Compare commits
5 commits
8e495672d3
...
bb4bb0c6c6
| Author | SHA1 | Date | |
|---|---|---|---|
| bb4bb0c6c6 | |||
| ca21c5b659 | |||
| e05f74bd82 | |||
| 491dc99c55 | |||
| 3d4a6daee9 |
34 changed files with 957 additions and 13 deletions
5
.dockerignore
Normal file
5
.dockerignore
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
.gradle
|
||||
.env
|
||||
.git
|
||||
frontend/node_modules
|
||||
backend/build
|
||||
17
AGENTS.md
17
AGENTS.md
|
|
@ -152,12 +152,12 @@ Full details in `@CODING_GUIDELINES.md`. Key rules:
|
|||
- API calls live in `api/` modules, never in components.
|
||||
- Component styles are scoped.
|
||||
|
||||
### Backend (Spring Boot 3)
|
||||
### Backend (Spring Boot 4)
|
||||
- Constructor injection with `@RequiredArgsConstructor`. No `@Autowired`.
|
||||
- DTOs: prefer Java records. No bare entities in responses.
|
||||
- Controllers stay thin. All logic in services.
|
||||
- Use `@ControllerAdvice` for consistent error responses (`{ "message": "..." }`).
|
||||
- No Lombok beyond `@RequiredArgsConstructor`.
|
||||
- Lombok: `@RequiredArgsConstructor`, `@Getter`, `@Setter`, `@NoArgsConstructor` are all fine. Prefer records for DTOs.
|
||||
|
||||
### Database
|
||||
- Table names: snake_case, plural. PKs: UUID, generated in code.
|
||||
|
|
@ -200,6 +200,10 @@ public vehicle info) must be excluded from the Spring Security filter chain.
|
|||
|
||||
## Testing Approach
|
||||
|
||||
This project follows **Test-Driven Development (TDD)**. Write tests before
|
||||
or alongside implementation. Every feature ticket should include tests in
|
||||
the same PR — never merge code without corresponding tests.
|
||||
|
||||
### Backend
|
||||
- JUnit 5 + Mockito for service layer tests.
|
||||
- `@WebMvcTest` for controller tests.
|
||||
|
|
@ -210,7 +214,14 @@ public vehicle info) must be excluded from the Spring Security filter chain.
|
|||
### Frontend
|
||||
- Vitest for composables and utility functions.
|
||||
- Component tests with Vue Test Utils where needed.
|
||||
- E2E tests deferred to Phase 1.
|
||||
- E2E tests with Playwright in `frontend/e2e/`.
|
||||
|
||||
### E2E (Playwright)
|
||||
- `npm run test:e2e` — runs all Playwright tests (headless Chromium).
|
||||
- Requires `docker compose up` (backend + frontend running).
|
||||
- Config: `frontend/playwright.config.ts`.
|
||||
- Tests: `frontend/e2e/*.spec.ts`.
|
||||
- Docker CI: `npm run test:e2e:ci` — runs tests inside official Playwright Docker container. Starts postgres, backend, frontend, and Playwright via `docker-compose.ci.yml`. Use for consistent environment or CI pipelines.
|
||||
|
||||
### CI (future)
|
||||
- `./gradlew check` and `npm run test && npm run lint` must pass before merge.
|
||||
|
|
|
|||
|
|
@ -145,7 +145,7 @@ async function handleSubmit() {
|
|||
|
||||
---
|
||||
|
||||
## 4. Backend — Spring Boot 3
|
||||
## 4. Backend — Spring Boot 4
|
||||
|
||||
### Package Structure
|
||||
|
||||
|
|
@ -209,7 +209,7 @@ public class OrderController {
|
|||
- All responses: `ResponseEntity<T>`. Never return bare entities.
|
||||
- Entity fields use `snake_case` column naming explicitly (`@Column(name = "created_at")`).
|
||||
- Database migrations: Flyway. All schema changes go through SQL migration files in `db/migration/`.
|
||||
- No Lombok beyond `@RequiredArgsConstructor`. Prefer explicit getters/setters or records.
|
||||
- Lombok: `@RequiredArgsConstructor`, `@Getter`, `@Setter`, `@NoArgsConstructor` are all fine. Prefer records for DTOs.
|
||||
|
||||
### API Path Conventions
|
||||
|
||||
|
|
@ -289,8 +289,13 @@ public class GlobalExceptionHandler {
|
|||
|
||||
## 7. Testing
|
||||
|
||||
This project follows **Test-Driven Development (TDD)**. Write tests before
|
||||
or alongside implementation. Every feature ticket should include tests in
|
||||
the same PR — never merge code without corresponding tests.
|
||||
|
||||
- Backend: JUnit 5 + Mockito. Service layer tests as unit tests. Controller tests with `@WebMvcTest`.
|
||||
- Frontend: Vitest for composables and utility functions. Cypress or Playwright for E2E (Phase 1).
|
||||
- Frontend: Vitest for composables and utility functions. Component tests with Vue Test Utils.
|
||||
- E2E: Playwright (`npm run test:e2e`). Tests in `frontend/e2e/`. Requires `docker compose up`.
|
||||
- Test naming: `shouldXxxWhenYyy` — e.g., `shouldReturn404WhenPlateNotFound`.
|
||||
- Aim for test coverage on business logic, not on getters/setters/boilerplate.
|
||||
- All database interaction in tests must go through JPA repositories
|
||||
|
|
|
|||
|
|
@ -178,7 +178,7 @@ the user assumes full responsibility for content.
|
|||
| Layer | Technology |
|
||||
|-------|-----------|
|
||||
| Frontend | Vue.js 3 (Composition API), Vite, Pinia state management, Vue Router |
|
||||
| Backend API | Java 21, Spring Boot 3, Spring Security (JWT), Spring Data JPA |
|
||||
| Backend API | Java 21, Spring Boot 4, Spring Security (JWT), Spring Data JPA |
|
||||
| Database | PostgreSQL 16 |
|
||||
| Deployment | Docker, Docker Compose |
|
||||
| Hosting (Phase 0) | Home server via dynamic DNS or static IP, Let's Encrypt SSL |
|
||||
|
|
@ -209,7 +209,7 @@ the user assumes full responsibility for content.
|
|||
└──────────────────┬───────────────────────┘
|
||||
│ REST API calls
|
||||
┌──────────────────▼───────────────────────┐
|
||||
│ Spring Boot 3 (Java 21) │
|
||||
│ Spring Boot 4 (Java 21) │
|
||||
│ Port: 8080 │
|
||||
│ ┌────────────┐ ┌────────────────────┐ │
|
||||
│ │ Spring │ │ Service Layer │ │
|
||||
|
|
@ -556,7 +556,7 @@ For Phase 0 with manual processing, staying unregistered is workable. If revenue
|
|||
|
||||
```
|
||||
Frontend: Vue.js 3, Vite, Pinia, Vue Router
|
||||
Backend: Java 21, Spring Boot 3, Spring Security (JWT), JPA/Hibernate
|
||||
Backend: Java 21, Spring Boot 4, Spring Security (JWT), JPA/Hibernate
|
||||
Database: PostgreSQL 16
|
||||
Deploy: Docker, Docker Compose, Nginx reverse proxy
|
||||
Hosting: Home server (Phase 0) → Swedish VPS (Phase 1)
|
||||
|
|
|
|||
|
|
@ -9,7 +9,9 @@ import org.springframework.web.bind.annotation.RequestBody;
|
|||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import se.bilhalsning.dto.AuthResponse;
|
||||
import se.bilhalsning.dto.LoginRequest;
|
||||
import se.bilhalsning.dto.RegisterRequest;
|
||||
import se.bilhalsning.entity.User;
|
||||
import se.bilhalsning.security.JwtService;
|
||||
import se.bilhalsning.service.UserService;
|
||||
|
||||
|
|
@ -27,4 +29,11 @@ public class AuthController {
|
|||
String token = jwtService.generateToken(request.email().toLowerCase().trim());
|
||||
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());
|
||||
return ResponseEntity.ok(new AuthResponse(token));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
package se.bilhalsning.dto;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record LoginRequest(
|
||||
@NotBlank @Email String email,
|
||||
@NotBlank String password
|
||||
) {}
|
||||
|
|
@ -14,6 +14,13 @@ public class GlobalExceptionHandler {
|
|||
|
||||
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
||||
|
||||
@ExceptionHandler(InvalidCredentialsException.class)
|
||||
public ResponseEntity<ErrorResponse> handleInvalidCredentials(InvalidCredentialsException ex) {
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(new ErrorResponse(ex.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(EmailAlreadyExistsException.class)
|
||||
public ResponseEntity<ErrorResponse> handleEmailAlreadyExists(EmailAlreadyExistsException ex) {
|
||||
return ResponseEntity
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
package se.bilhalsning.exception;
|
||||
|
||||
public class InvalidCredentialsException extends RuntimeException {
|
||||
public InvalidCredentialsException() {
|
||||
super("Felaktig e-post eller lösenord");
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import jakarta.servlet.FilterChain;
|
|||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import io.jsonwebtoken.JwtException;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
|
@ -32,7 +33,13 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
|||
}
|
||||
|
||||
String token = authHeader.substring(7);
|
||||
String username = jwtService.extractUsername(token);
|
||||
String username;
|
||||
try {
|
||||
username = jwtService.extractUsername(token);
|
||||
} catch (JwtException e) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
|
||||
var userDetails = userDetailsService.loadUserByUsername(username);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import org.springframework.security.crypto.password.PasswordEncoder;
|
|||
import org.springframework.stereotype.Service;
|
||||
import se.bilhalsning.entity.User;
|
||||
import se.bilhalsning.exception.EmailAlreadyExistsException;
|
||||
import se.bilhalsning.exception.InvalidCredentialsException;
|
||||
import se.bilhalsning.repository.UserRepository;
|
||||
|
||||
@Service
|
||||
|
|
@ -29,4 +30,14 @@ public class UserService {
|
|||
user.setPasswordHash(passwordEncoder.encode(password));
|
||||
return userRepository.save(user);
|
||||
}
|
||||
|
||||
public User authenticate(String email, String password) {
|
||||
String normalizedEmail = email.toLowerCase().trim();
|
||||
User user = userRepository.findByEmail(normalizedEmail)
|
||||
.orElseThrow(InvalidCredentialsException::new);
|
||||
if (!passwordEncoder.matches(password, user.getPasswordHash())) {
|
||||
throw new InvalidCredentialsException();
|
||||
}
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
INSERT INTO users (id, email, password_hash, subscription)
|
||||
VALUES (
|
||||
'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11',
|
||||
'test@bilhalsning.se',
|
||||
'$2b$12$18UFRDPgHWuw5FYeu6X1ReisFjjuxs5XxDafi6.wZbsywoU7vUaLG',
|
||||
'none'
|
||||
);
|
||||
|
|
@ -13,8 +13,11 @@ import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
|||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import se.bilhalsning.dto.LoginRequest;
|
||||
import se.bilhalsning.dto.RegisterRequest;
|
||||
import se.bilhalsning.entity.User;
|
||||
import se.bilhalsning.exception.EmailAlreadyExistsException;
|
||||
import se.bilhalsning.exception.InvalidCredentialsException;
|
||||
import se.bilhalsning.security.JwtService;
|
||||
import se.bilhalsning.service.UserService;
|
||||
|
||||
|
|
@ -85,4 +88,59 @@ class AuthControllerTest {
|
|||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturn200AndTokenWhenLoginSucceeds() throws Exception {
|
||||
User user = new User();
|
||||
user.setEmail("user@example.com");
|
||||
when(userService.authenticate("user@example.com", "password123")).thenReturn(user);
|
||||
when(jwtService.generateToken("user@example.com")).thenReturn("login-jwt-token");
|
||||
|
||||
LoginRequest request = new LoginRequest("user@example.com", "password123");
|
||||
mockMvc.perform(post("/api/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.token").value("login-jwt-token"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturn401WhenCredentialsAreInvalid() throws Exception {
|
||||
when(userService.authenticate("user@example.com", "wrongpassword"))
|
||||
.thenThrow(new InvalidCredentialsException());
|
||||
|
||||
LoginRequest request = new LoginRequest("user@example.com", "wrongpassword");
|
||||
mockMvc.perform(post("/api/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.message").value("Felaktig e-post eller lösenord"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturn400WhenLoginEmailIsBlank() throws Exception {
|
||||
LoginRequest request = new LoginRequest("", "password123");
|
||||
mockMvc.perform(post("/api/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturn400WhenLoginPasswordIsBlank() throws Exception {
|
||||
LoginRequest request = new LoginRequest("user@example.com", "");
|
||||
mockMvc.perform(post("/api/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturn400WhenLoginEmailIsInvalid() throws Exception {
|
||||
LoginRequest request = new LoginRequest("not-an-email", "password123");
|
||||
mockMvc.perform(post("/api/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import static org.mockito.Mockito.never;
|
|||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import io.jsonwebtoken.ExpiredJwtException;
|
||||
import io.jsonwebtoken.MalformedJwtException;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import java.io.IOException;
|
||||
|
|
@ -135,4 +137,40 @@ class JwtAuthenticationFilterTest {
|
|||
verify(jwtService, never()).isTokenValid(anyString());
|
||||
verify(filterChain).doFilter(request, response);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldPassThroughWhenTokenExpired() throws ServletException, IOException {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.addHeader("Authorization", "Bearer expired.token");
|
||||
request.setRequestURI("/api/auth/register");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
when(jwtService.extractUsername("expired.token"))
|
||||
.thenThrow(new ExpiredJwtException(null, null, "Token expired"));
|
||||
|
||||
filter.doFilterInternal(request, response, filterChain);
|
||||
|
||||
assertNull(SecurityContextHolder.getContext().getAuthentication());
|
||||
verify(jwtService, never()).isTokenValid(anyString());
|
||||
verify(userDetailsService, never()).loadUserByUsername(anyString());
|
||||
verify(filterChain).doFilter(request, response);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldPassThroughWhenTokenMalformed() throws ServletException, IOException {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.addHeader("Authorization", "Bearer not.a.jwt");
|
||||
request.setRequestURI("/api/auth/login");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
when(jwtService.extractUsername("not.a.jwt"))
|
||||
.thenThrow(new MalformedJwtException("Invalid JWT"));
|
||||
|
||||
filter.doFilterInternal(request, response, filterChain);
|
||||
|
||||
assertNull(SecurityContextHolder.getContext().getAuthentication());
|
||||
verify(jwtService, never()).isTokenValid(anyString());
|
||||
verify(userDetailsService, never()).loadUserByUsername(anyString());
|
||||
verify(filterChain).doFilter(request, response);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import org.springframework.security.crypto.password.PasswordEncoder;
|
|||
import se.bilhalsning.entity.Subscription;
|
||||
import se.bilhalsning.entity.User;
|
||||
import se.bilhalsning.exception.EmailAlreadyExistsException;
|
||||
import se.bilhalsning.exception.InvalidCredentialsException;
|
||||
import se.bilhalsning.repository.UserRepository;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
|
|
@ -118,4 +119,54 @@ class UserServiceTest {
|
|||
|
||||
verify(userRepository).findByEmail("user@example.com");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnUserWhenCredentialsAreValid() {
|
||||
User user = new User();
|
||||
user.setEmail("user@example.com");
|
||||
user.setPasswordHash("hashed");
|
||||
|
||||
when(userRepository.findByEmail("user@example.com")).thenReturn(Optional.of(user));
|
||||
when(passwordEncoder.matches("password123", "hashed")).thenReturn(true);
|
||||
|
||||
User result = userService.authenticate("user@example.com", "password123");
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals("user@example.com", result.getEmail());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldThrowWhenEmailNotFoundOnAuthenticate() {
|
||||
when(userRepository.findByEmail("unknown@example.com")).thenReturn(Optional.empty());
|
||||
|
||||
assertThrows(InvalidCredentialsException.class, () ->
|
||||
userService.authenticate("unknown@example.com", "password123"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldThrowWhenPasswordDoesNotMatch() {
|
||||
User user = new User();
|
||||
user.setEmail("user@example.com");
|
||||
user.setPasswordHash("hashed");
|
||||
|
||||
when(userRepository.findByEmail("user@example.com")).thenReturn(Optional.of(user));
|
||||
when(passwordEncoder.matches("wrongpassword", "hashed")).thenReturn(false);
|
||||
|
||||
assertThrows(InvalidCredentialsException.class, () ->
|
||||
userService.authenticate("user@example.com", "wrongpassword"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNormalizeEmailBeforeAuthenticating() {
|
||||
User user = new User();
|
||||
user.setEmail("user@example.com");
|
||||
user.setPasswordHash("hashed");
|
||||
|
||||
when(userRepository.findByEmail("user@example.com")).thenReturn(Optional.of(user));
|
||||
when(passwordEncoder.matches("password123", "hashed")).thenReturn(true);
|
||||
|
||||
userService.authenticate(" User@Example.COM ", "password123");
|
||||
|
||||
verify(userRepository).findByEmail("user@example.com");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,12 @@ tasks.register('frontendTest', Exec) {
|
|||
commandLine 'npm', 'run', 'test'
|
||||
}
|
||||
|
||||
tasks.register('frontendE2E', Exec) {
|
||||
description = 'Run Playwright E2E tests in Docker (CI mode)'
|
||||
workingDir = file("${rootProject.projectDir}/frontend")
|
||||
commandLine 'npm', 'run', 'test:e2e:ci'
|
||||
}
|
||||
|
||||
tasks.named('check').configure {
|
||||
dependsOn frontendLint, frontendTest
|
||||
}
|
||||
|
|
|
|||
63
docker-compose.ci.yml
Normal file
63
docker-compose.ci.yml
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
container_name: bilhej-postgres-ci
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
backend:
|
||||
build:
|
||||
dockerfile: docker/backend.Dockerfile
|
||||
context: .
|
||||
container_name: bilhej-backend-ci
|
||||
environment:
|
||||
SPRING_PROFILES_ACTIVE: docker
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
|
||||
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET}
|
||||
STRIPE_PRICE_ID: ${STRIPE_PRICE_ID}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- .:/app
|
||||
- gradle-cache:/root/.gradle
|
||||
|
||||
frontend:
|
||||
build:
|
||||
dockerfile: docker/frontend.Dockerfile
|
||||
context: .
|
||||
container_name: bilhej-frontend-ci
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
playwright:
|
||||
image: mcr.microsoft.com/playwright:v1.60.0-noble
|
||||
container_name: bilhej-playwright
|
||||
ipc: host
|
||||
working_dir: /app
|
||||
environment:
|
||||
PLAYWRIGHT_BASE_URL: http://frontend:3000
|
||||
volumes:
|
||||
- ./frontend/package.json:/app/package.json
|
||||
- ./frontend/package-lock.json:/app/package-lock.json
|
||||
- ./frontend/playwright.config.ts:/app/playwright.config.ts
|
||||
- ./frontend/e2e:/app/e2e
|
||||
- ./frontend/node_modules:/app/node_modules
|
||||
depends_on:
|
||||
- frontend
|
||||
command: >
|
||||
sh -c "npm ci --ignore-scripts && npx playwright test --reporter=list"
|
||||
|
||||
volumes:
|
||||
gradle-cache:
|
||||
|
|
@ -37,6 +37,7 @@ services:
|
|||
condition: service_healthy
|
||||
volumes:
|
||||
- .:/app
|
||||
- backend-gradle-project:/app/.gradle
|
||||
- gradle-cache:/root/.gradle
|
||||
|
||||
frontend:
|
||||
|
|
@ -56,3 +57,4 @@ services:
|
|||
volumes:
|
||||
pgdata:
|
||||
gradle-cache:
|
||||
backend-gradle-project:
|
||||
|
|
|
|||
4
frontend/.gitignore
vendored
4
frontend/.gitignore
vendored
|
|
@ -22,3 +22,7 @@ dist-ssr
|
|||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Playwright
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
|
|
|||
48
frontend/e2e/login.spec.ts
Normal file
48
frontend/e2e/login.spec.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Login page', () => {
|
||||
test('can navigate to login page', async ({ page }) => {
|
||||
await page.goto('/logga-in')
|
||||
await expect(page.getByRole('heading', { name: 'Logga in' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('shows error for invalid credentials', async ({ page }) => {
|
||||
await page.goto('/logga-in')
|
||||
await page.getByLabel('E-postadress').fill('user@example.com')
|
||||
await page.getByLabel('Lösenord').fill('wrongpassword')
|
||||
await page.getByRole('button', { name: 'Logga in' }).click()
|
||||
|
||||
await expect(page.getByText('Felaktig e-post eller lösenord')).toBeVisible()
|
||||
})
|
||||
|
||||
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.getByRole('button', { name: 'Logga in' }).click()
|
||||
|
||||
await expect(page).toHaveURL('/')
|
||||
})
|
||||
|
||||
test('can navigate from login to register', async ({ page }) => {
|
||||
await page.goto('/logga-in')
|
||||
await page.getByRole('link', { name: 'Skapa konto' }).click()
|
||||
|
||||
await expect(page).toHaveURL('/registrera')
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Skapa konto' }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('login form has correct input types', async ({ page }) => {
|
||||
await page.goto('/logga-in')
|
||||
await expect(page.getByLabel('E-postadress')).toHaveAttribute(
|
||||
'type',
|
||||
'email',
|
||||
)
|
||||
await expect(page.getByLabel('Lösenord')).toHaveAttribute(
|
||||
'type',
|
||||
'password',
|
||||
)
|
||||
})
|
||||
})
|
||||
56
frontend/e2e/register.spec.ts
Normal file
56
frontend/e2e/register.spec.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Register page', () => {
|
||||
test('can navigate to register page', async ({ page }) => {
|
||||
await page.goto('/registrera')
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Skapa konto' }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('registers a new user and redirects to home', async ({ page }) => {
|
||||
const uniqueEmail = `playwright-${Date.now()}@test.com`
|
||||
|
||||
await page.goto('/registrera')
|
||||
await page.getByLabel('E-postadress').fill(uniqueEmail)
|
||||
await page.getByLabel('Lösenord').first().fill('password123')
|
||||
await page.getByLabel('Bekräfta lösenord').fill('password123')
|
||||
await page.getByRole('button', { name: 'Skapa konto' }).click()
|
||||
|
||||
await expect(page).toHaveURL('/')
|
||||
})
|
||||
|
||||
test('shows validation error for invalid email', async ({ page }) => {
|
||||
await page.goto('/registrera')
|
||||
await page.getByLabel('E-postadress').fill('not-an-email')
|
||||
|
||||
await expect(page.getByText('Ange en giltig e-postadress')).toBeVisible()
|
||||
})
|
||||
|
||||
test('shows validation error for short password', async ({ page }) => {
|
||||
await page.goto('/registrera')
|
||||
await page.getByLabel('Lösenord').first().fill('short')
|
||||
|
||||
await expect(
|
||||
page.getByText('Lösenordet måste vara minst 8 tecken'),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('shows validation error for mismatched passwords', async ({ page }) => {
|
||||
await page.goto('/registrera')
|
||||
await page.getByLabel('Lösenord').first().fill('password123')
|
||||
await page.getByLabel('Bekräfta lösenord').fill('different123')
|
||||
|
||||
await expect(page.getByText('Lösenorden matchar inte')).toBeVisible()
|
||||
})
|
||||
|
||||
test('can navigate from register to login', async ({ page }) => {
|
||||
await page.goto('/registrera')
|
||||
await page
|
||||
.getByRole('main')
|
||||
.getByRole('link', { name: 'Logga in' })
|
||||
.click()
|
||||
|
||||
await expect(page).toHaveURL('/logga-in')
|
||||
})
|
||||
})
|
||||
64
frontend/package-lock.json
generated
64
frontend/package-lock.json
generated
|
|
@ -13,6 +13,7 @@
|
|||
"vue-router": "^5.0.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@rushstack/eslint-patch": "^1.16.1",
|
||||
"@types/node": "^24.12.2",
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
|
|
@ -670,6 +671,22 @@
|
|||
"url": "https://opencollective.com/pkgr"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
|
||||
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.60.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz",
|
||||
|
|
@ -3717,6 +3734,53 @@
|
|||
"pathe": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
|
||||
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.60.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
|
||||
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.13",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz",
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@
|
|||
"lint": "eslint src/ --fix",
|
||||
"format": "prettier --write src/",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ci": "docker compose -f ../docker-compose.ci.yml up --build --abort-on-container-exit --exit-code-from playwright"
|
||||
},
|
||||
"dependencies": {
|
||||
"pinia": "^3.0.4",
|
||||
|
|
@ -18,6 +20,7 @@
|
|||
"vue-router": "^5.0.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@rushstack/eslint-patch": "^1.16.1",
|
||||
"@types/node": "^24.12.2",
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
|
|
|
|||
29
frontend/playwright.config.ts
Normal file
29
frontend/playwright.config.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { defineConfig } from '@playwright/test'
|
||||
|
||||
const isCI = !!process.env.PLAYWRIGHT_BASE_URL
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
timeout: 30_000,
|
||||
retries: 0,
|
||||
use: {
|
||||
baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000',
|
||||
headless: true,
|
||||
},
|
||||
...(isCI
|
||||
? {}
|
||||
: {
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
port: 3000,
|
||||
reuseExistingServer: true,
|
||||
timeout: 30_000,
|
||||
},
|
||||
}),
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { browserName: 'chromium' },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
|
@ -8,6 +8,16 @@ function createTestRouter() {
|
|||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/', name: 'home', component: { template: '<div>Home</div>' } },
|
||||
{
|
||||
path: '/logga-in',
|
||||
name: 'login',
|
||||
component: { template: '<div>Login</div>' },
|
||||
},
|
||||
{
|
||||
path: '/registrera',
|
||||
name: 'register',
|
||||
component: { template: '<div>Register</div>' },
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
|
@ -43,4 +53,15 @@ describe('AppHeader', () => {
|
|||
expect(registerLink).toBeTruthy()
|
||||
expect(registerLink?.text()).toBe('Registrera')
|
||||
})
|
||||
|
||||
it('has a link to login', () => {
|
||||
const router = createTestRouter()
|
||||
const wrapper = mount(AppHeader, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
const links = wrapper.findAll('a')
|
||||
const loginLink = links.find((a) => a.attributes('href') === '/logga-in')
|
||||
expect(loginLink).toBeTruthy()
|
||||
expect(loginLink?.text()).toBe('Logga in')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
148
frontend/src/__tests__/LoginPage.spec.ts
Normal file
148
frontend/src/__tests__/LoginPage.spec.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
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 LoginPage from '@/pages/LoginPage.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: '/logga-in', name: 'login', component: LoginPage },
|
||||
{ path: '/', name: 'home', component: { template: '<div>Home</div>' } },
|
||||
{
|
||||
path: '/registrera',
|
||||
name: 'register',
|
||||
component: { template: '<div>Register</div>' },
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
function mountPage() {
|
||||
const router = createTestRouter()
|
||||
const pinia = createPinia()
|
||||
router.push('/logga-in')
|
||||
return {
|
||||
router,
|
||||
wrapper: mount(LoginPage, {
|
||||
global: { plugins: [router, pinia] },
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
describe('LoginPage', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
globalThis.fetch = vi.fn()
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||
mockFetchResponse(200, { token: 'test-token' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('renders heading and subtitle', async () => {
|
||||
const { wrapper } = mountPage()
|
||||
expect(wrapper.text()).toContain('Logga in')
|
||||
expect(wrapper.text()).toContain('Ange din e-postadress och ditt lösenord')
|
||||
})
|
||||
|
||||
it('renders email and password fields', async () => {
|
||||
const { wrapper } = mountPage()
|
||||
expect(wrapper.find('#email').exists()).toBe(true)
|
||||
expect(wrapper.find('#password').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('does not render confirm password field', async () => {
|
||||
const { wrapper } = mountPage()
|
||||
expect(wrapper.find('#confirm-password').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('disables submit when fields are empty', async () => {
|
||||
const { wrapper } = mountPage()
|
||||
const button = wrapper.find('button[type="submit"]')
|
||||
expect(button.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('enables submit when both fields have values', async () => {
|
||||
const { wrapper } = mountPage()
|
||||
await wrapper.find('#email').setValue('test@example.com')
|
||||
await wrapper.find('#password').setValue('password123')
|
||||
const button = wrapper.find('button[type="submit"]')
|
||||
expect(button.attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('shows loading text while submitting', async () => {
|
||||
globalThis.fetch = vi.fn().mockImplementation(() => new Promise(() => {}))
|
||||
const { wrapper } = mountPage()
|
||||
await wrapper.find('#email').setValue('test@example.com')
|
||||
await wrapper.find('#password').setValue('password123')
|
||||
await wrapper.find('form').trigger('submit.prevent')
|
||||
expect(wrapper.text()).toContain('Loggar in...')
|
||||
})
|
||||
|
||||
it('calls login API and redirects to home on success', async () => {
|
||||
const { wrapper, router } = mountPage()
|
||||
|
||||
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(globalThis.fetch).toHaveBeenCalledWith(
|
||||
'/api/auth/login',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
expect(router.currentRoute.value.name).toBe('home')
|
||||
})
|
||||
|
||||
it('shows generic error on login failure', async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||
mockFetchResponse(401, { message: 'Felaktig e-post eller lösenord' }),
|
||||
)
|
||||
const { wrapper } = mountPage()
|
||||
|
||||
await wrapper.find('#email').setValue('test@example.com')
|
||||
await wrapper.find('#password').setValue('wrongpassword')
|
||||
await wrapper.find('form').trigger('submit.prevent')
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
expect(wrapper.text()).toContain('Felaktig e-post eller lösenord')
|
||||
})
|
||||
|
||||
it('does not leak specific error messages', async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||
mockFetchResponse(500, { message: 'Internal server error' }),
|
||||
)
|
||||
const { wrapper } = mountPage()
|
||||
|
||||
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(wrapper.text()).toContain('Felaktig e-post eller lösenord')
|
||||
expect(wrapper.text()).not.toContain('Internal server error')
|
||||
})
|
||||
|
||||
it('renders register link', async () => {
|
||||
const { wrapper } = mountPage()
|
||||
expect(wrapper.text()).toContain('Har du inget konto?')
|
||||
})
|
||||
})
|
||||
|
|
@ -14,6 +14,12 @@ describe('Router', () => {
|
|||
expect(router.currentRoute.value.name).toBe('register')
|
||||
})
|
||||
|
||||
it('resolves /logga-in to LoginPage', async () => {
|
||||
await router.push('/logga-in')
|
||||
await router.isReady()
|
||||
expect(router.currentRoute.value.name).toBe('login')
|
||||
})
|
||||
|
||||
it('does not crash on unknown route', async () => {
|
||||
await router.push('/nonexistent')
|
||||
await router.isReady()
|
||||
|
|
|
|||
|
|
@ -69,4 +69,64 @@ describe('authStore', () => {
|
|||
expect(store.isAuthenticated).toBe(false)
|
||||
expect(localStorage.getItem('auth_token')).toBeNull()
|
||||
})
|
||||
|
||||
it('sets token on loginUser success', async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||
mockFetchResponse(200, { token: 'login-token' }),
|
||||
)
|
||||
const store = useAuthStore()
|
||||
|
||||
await store.loginUser('user@example.com', 'password123')
|
||||
|
||||
expect(store.token).toBe('login-token')
|
||||
expect(store.isAuthenticated).toBe(true)
|
||||
expect(localStorage.getItem('auth_token')).toBe('login-token')
|
||||
})
|
||||
|
||||
it('rejects when login API fails', async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||
mockFetchResponse(401, { message: 'Felaktig e-post eller lösenord' }),
|
||||
)
|
||||
const store = useAuthStore()
|
||||
|
||||
await expect(
|
||||
store.loginUser('user@example.com', 'wrongpassword'),
|
||||
).rejects.toThrow('Felaktig e-post eller lösenord')
|
||||
|
||||
expect(store.isAuthenticated).toBe(false)
|
||||
expect(store.token).toBeNull()
|
||||
})
|
||||
|
||||
it('calls login endpoint with correct credentials', async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||
mockFetchResponse(200, { token: 'login-token' }),
|
||||
)
|
||||
const store = useAuthStore()
|
||||
|
||||
await store.loginUser('user@example.com', 'password123')
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
'/api/auth/login',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
email: 'user@example.com',
|
||||
password: 'password123',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('does not call register endpoint on login', async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||
mockFetchResponse(200, { token: 'login-token' }),
|
||||
)
|
||||
const store = useAuthStore()
|
||||
|
||||
await store.loginUser('user@example.com', 'password123')
|
||||
|
||||
const calls = vi.mocked(globalThis.fetch).mock.calls
|
||||
const registerCall = calls.find((call) => call[0] === '/api/auth/register')
|
||||
expect(registerCall).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -13,3 +13,10 @@ export function register(
|
|||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
}
|
||||
|
||||
export function login(email: string, password: string): Promise<AuthResponse> {
|
||||
return request<AuthResponse>('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ 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
|
||||
>
|
||||
|
|
|
|||
185
frontend/src/pages/LoginPage.vue
Normal file
185
frontend/src/pages/LoginPage.vue
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter, RouterLink } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const submitting = ref(false)
|
||||
const errorMessage = ref('')
|
||||
|
||||
const isValid = computed(
|
||||
() => email.value.length > 0 && password.value.length > 0,
|
||||
)
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!isValid.value || submitting.value) return
|
||||
|
||||
submitting.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
await authStore.loginUser(email.value, password.value)
|
||||
await router.push('/')
|
||||
} catch {
|
||||
errorMessage.value = 'Felaktig e-post eller lösenord'
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login">
|
||||
<h1 class="login__title">Logga in</h1>
|
||||
<p class="login__subtitle">
|
||||
Ange din e-postadress och ditt lösenord för att logga in.
|
||||
</p>
|
||||
|
||||
<form class="login__form" @submit.prevent="handleSubmit">
|
||||
<div class="login__field">
|
||||
<label for="email" class="login__label">E-postadress</label>
|
||||
<input
|
||||
id="email"
|
||||
v-model="email"
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
class="login__input"
|
||||
placeholder="namn@exempel.se"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="login__field">
|
||||
<label for="password" class="login__label">Lösenord</label>
|
||||
<input
|
||||
id="password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
class="login__input"
|
||||
placeholder="Ditt lösenord"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="errorMessage" class="login__api-error">{{ errorMessage }}</p>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="login__submit"
|
||||
:disabled="!isValid || submitting"
|
||||
>
|
||||
{{ submitting ? 'Loggar in...' : 'Logga in' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="login__register-link">
|
||||
Har du inget konto?
|
||||
<RouterLink to="/registrera">Skapa konto</RouterLink>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login {
|
||||
max-width: 28rem;
|
||||
margin: 3rem auto 0;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.login__title {
|
||||
margin: 0 0 0.25rem 0;
|
||||
font-size: 1.5rem;
|
||||
color: #1a202c;
|
||||
}
|
||||
|
||||
.login__subtitle {
|
||||
margin: 0 0 1.5rem 0;
|
||||
color: #718096;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.login__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.login__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.login__label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
.login__input {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 1rem;
|
||||
border: 2px solid #cbd5e0;
|
||||
border-radius: 0.5rem;
|
||||
outline: none;
|
||||
transition: border-color 0.15s ease;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.login__input:focus {
|
||||
border-color: #4299e1;
|
||||
box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.25);
|
||||
}
|
||||
|
||||
.login__api-error {
|
||||
margin: 0;
|
||||
padding: 0.75rem 1rem;
|
||||
background: #fff5f5;
|
||||
border: 1px solid #fed7d7;
|
||||
border-radius: 0.5rem;
|
||||
color: #c53030;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.login__submit {
|
||||
width: 100%;
|
||||
padding: 0.875rem 1.5rem;
|
||||
background: #4299e1;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.login__submit:hover:not(:disabled) {
|
||||
background: #3182ce;
|
||||
}
|
||||
|
||||
.login__submit:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.login__register-link {
|
||||
margin-top: 1.5rem;
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
.login__register-link a {
|
||||
color: #4299e1;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.login__register-link a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -4,6 +4,7 @@ import ComposePage from '@/pages/ComposePage.vue'
|
|||
import AboutPage from '@/pages/AboutPage.vue'
|
||||
import ContactPage from '@/pages/ContactPage.vue'
|
||||
import RegisterPage from '@/pages/RegisterPage.vue'
|
||||
import LoginPage from '@/pages/LoginPage.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
|
|
@ -23,6 +24,11 @@ const router = createRouter({
|
|||
name: 'register',
|
||||
component: RegisterPage,
|
||||
},
|
||||
{
|
||||
path: '/logga-in',
|
||||
name: 'login',
|
||||
component: LoginPage,
|
||||
},
|
||||
{
|
||||
path: '/om',
|
||||
name: 'about',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { register } from '@/api/auth'
|
||||
import { register, login } from '@/api/auth'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const token = ref<string | null>(localStorage.getItem('auth_token'))
|
||||
|
|
@ -22,9 +22,14 @@ export const useAuthStore = defineStore('auth', () => {
|
|||
setToken(response.token)
|
||||
}
|
||||
|
||||
async function loginUser(email: string, password: string): Promise<void> {
|
||||
const response = await login(email, password)
|
||||
setToken(response.token)
|
||||
}
|
||||
|
||||
function logout() {
|
||||
clearToken()
|
||||
}
|
||||
|
||||
return { token, isAuthenticated, registerUser, logout }
|
||||
return { token, isAuthenticated, registerUser, loginUser, logout }
|
||||
})
|
||||
|
|
|
|||
|
|
@ -19,5 +19,6 @@ export default defineConfig({
|
|||
test: {
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['src/__tests__/setup.ts'],
|
||||
exclude: ['e2e/**', 'node_modules/**'],
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,5 +4,9 @@
|
|||
"permission": {
|
||||
"edit": "ask",
|
||||
"bash": "ask"
|
||||
},
|
||||
"tools": {
|
||||
"websearch": true,
|
||||
"codesearch": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue