From 8e495672d3cb3e310d90d297f2b3a5b49c1f3ae0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20M=C3=B6rling?= Date: Fri, 1 May 2026 19:37:39 +0200 Subject: [PATCH] feat: add user registration flow (backend + frontend) Implement end-to-end registration: POST /api/auth/register creates a user, returns a JWT, and the frontend RegisterPage stores the token and redirects to home. Backend: - Add AuthController with POST /api/auth/register endpoint - Add RegisterRequest record (@Email, @NotBlank, @Size(min=8)) - Add AuthResponse and ErrorResponse DTOs - Add GlobalExceptionHandler (@RestControllerAdvice with logging) - EmailAlreadyExistsException -> 409 (Swedish message) - MethodArgumentNotValidException -> 400 (field errors) - Generic Exception -> 500 (Swedish message + server-side log) Frontend: - Add api/client.ts: centralized fetch wrapper with Bearer token interceptor, ApiError class, JSON error parsing - Add api/auth.ts: register() function - Add stores/authStore.ts: Pinia store with token persistence via localStorage, registerUser/logout/isAuthenticated - Add pages/RegisterPage.vue: email + password + confirm password form with client-side validation, submit handler, error display, redirect to home on success - Add route /registrera pointing to RegisterPage - Add 'Registrera' link to AppHeader navigation Infrastructure: - Add __tests__/setup.ts: localStorage polyfill for jsdom 29 (jsdom 29 lacks standard Storage method implementations) - Register polyfill via vitest config setupFiles Tests (17 new, 2 extended): - AuthControllerTest (@SpringBootTest + @AutoConfigureMockMvc): 5 backend tests (success 201, duplicate 409, invalid email 400, short password 400, missing email 400) - authStore.spec.ts: 5 tests (unauthenticated start, localStorage restore, register success, register failure, logout) - RegisterPage.spec.ts: 12 tests (render, validation, submit, redirect, error display, login link) - AppHeader.spec.ts: added 'Registrera' link test - Router.spec.ts: added /registrera route resolution test Build: 95 tests pass (57 frontend + 38 backend), lint clean. --- .../controller/AuthController.java | 30 ++ .../java/se/bilhalsning/dto/AuthResponse.java | 3 + .../se/bilhalsning/dto/ErrorResponse.java | 3 + .../se/bilhalsning/dto/RegisterRequest.java | 10 + .../exception/GlobalExceptionHandler.java | 42 +++ .../controller/AuthControllerTest.java | 88 ++++++ frontend/src/__tests__/AppHeader.spec.ts | 13 + frontend/src/__tests__/RegisterPage.spec.ts | 155 +++++++++++ frontend/src/__tests__/Router.spec.ts | 6 + frontend/src/__tests__/authStore.spec.ts | 72 +++++ frontend/src/__tests__/setup.ts | 25 ++ frontend/src/api/auth.ts | 15 + frontend/src/api/client.ts | 42 +++ frontend/src/components/AppHeader.vue | 3 + frontend/src/pages/RegisterPage.vue | 260 ++++++++++++++++++ frontend/src/router/index.ts | 6 + frontend/src/stores/authStore.ts | 30 ++ frontend/vite.config.ts | 1 + 18 files changed, 804 insertions(+) create mode 100644 backend/src/main/java/se/bilhalsning/controller/AuthController.java create mode 100644 backend/src/main/java/se/bilhalsning/dto/AuthResponse.java create mode 100644 backend/src/main/java/se/bilhalsning/dto/ErrorResponse.java create mode 100644 backend/src/main/java/se/bilhalsning/dto/RegisterRequest.java create mode 100644 backend/src/main/java/se/bilhalsning/exception/GlobalExceptionHandler.java create mode 100644 backend/src/test/java/se/bilhalsning/controller/AuthControllerTest.java create mode 100644 frontend/src/__tests__/RegisterPage.spec.ts create mode 100644 frontend/src/__tests__/authStore.spec.ts create mode 100644 frontend/src/__tests__/setup.ts create mode 100644 frontend/src/api/auth.ts create mode 100644 frontend/src/api/client.ts create mode 100644 frontend/src/pages/RegisterPage.vue create mode 100644 frontend/src/stores/authStore.ts diff --git a/backend/src/main/java/se/bilhalsning/controller/AuthController.java b/backend/src/main/java/se/bilhalsning/controller/AuthController.java new file mode 100644 index 0000000..b56aea3 --- /dev/null +++ b/backend/src/main/java/se/bilhalsning/controller/AuthController.java @@ -0,0 +1,30 @@ +package se.bilhalsning.controller; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import se.bilhalsning.dto.AuthResponse; +import se.bilhalsning.dto.RegisterRequest; +import se.bilhalsning.security.JwtService; +import se.bilhalsning.service.UserService; + +@RestController +@RequestMapping("/api/auth") +@RequiredArgsConstructor +public class AuthController { + + private final UserService userService; + private final JwtService jwtService; + + @PostMapping("/register") + public ResponseEntity register(@Valid @RequestBody RegisterRequest request) { + userService.createUser(request.email(), request.password()); + String token = jwtService.generateToken(request.email().toLowerCase().trim()); + return ResponseEntity.status(HttpStatus.CREATED).body(new AuthResponse(token)); + } +} diff --git a/backend/src/main/java/se/bilhalsning/dto/AuthResponse.java b/backend/src/main/java/se/bilhalsning/dto/AuthResponse.java new file mode 100644 index 0000000..11830c7 --- /dev/null +++ b/backend/src/main/java/se/bilhalsning/dto/AuthResponse.java @@ -0,0 +1,3 @@ +package se.bilhalsning.dto; + +public record AuthResponse(String token) {} diff --git a/backend/src/main/java/se/bilhalsning/dto/ErrorResponse.java b/backend/src/main/java/se/bilhalsning/dto/ErrorResponse.java new file mode 100644 index 0000000..3d9f404 --- /dev/null +++ b/backend/src/main/java/se/bilhalsning/dto/ErrorResponse.java @@ -0,0 +1,3 @@ +package se.bilhalsning.dto; + +public record ErrorResponse(String message) {} diff --git a/backend/src/main/java/se/bilhalsning/dto/RegisterRequest.java b/backend/src/main/java/se/bilhalsning/dto/RegisterRequest.java new file mode 100644 index 0000000..3ab3796 --- /dev/null +++ b/backend/src/main/java/se/bilhalsning/dto/RegisterRequest.java @@ -0,0 +1,10 @@ +package se.bilhalsning.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record RegisterRequest( + @NotBlank @Email String email, + @NotBlank @Size(min = 8) String password +) {} diff --git a/backend/src/main/java/se/bilhalsning/exception/GlobalExceptionHandler.java b/backend/src/main/java/se/bilhalsning/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..8643a3f --- /dev/null +++ b/backend/src/main/java/se/bilhalsning/exception/GlobalExceptionHandler.java @@ -0,0 +1,42 @@ +package se.bilhalsning.exception; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import se.bilhalsning.dto.ErrorResponse; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + @ExceptionHandler(EmailAlreadyExistsException.class) + public ResponseEntity handleEmailAlreadyExists(EmailAlreadyExistsException ex) { + return ResponseEntity + .status(HttpStatus.CONFLICT) + .body(new ErrorResponse("E-postadressen är redan registrerad")); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidation(MethodArgumentNotValidException ex) { + String message = ex.getBindingResult().getFieldErrors().stream() + .map(e -> e.getField() + ": " + e.getDefaultMessage()) + .reduce((a, b) -> a + ", " + b) + .orElse("Ogiltig indata"); + return ResponseEntity + .badRequest() + .body(new ErrorResponse(message)); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleGeneral(Exception ex) { + log.error("Unhandled exception", ex); + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(new ErrorResponse("Ett internt fel uppstod")); + } +} diff --git a/backend/src/test/java/se/bilhalsning/controller/AuthControllerTest.java b/backend/src/test/java/se/bilhalsning/controller/AuthControllerTest.java new file mode 100644 index 0000000..a3a0678 --- /dev/null +++ b/backend/src/test/java/se/bilhalsning/controller/AuthControllerTest.java @@ -0,0 +1,88 @@ +package se.bilhalsning.controller; + +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +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.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import se.bilhalsning.dto.RegisterRequest; +import se.bilhalsning.exception.EmailAlreadyExistsException; +import se.bilhalsning.security.JwtService; +import se.bilhalsning.service.UserService; + +@SpringBootTest +@AutoConfigureMockMvc +class AuthControllerTest { + + @Autowired + private MockMvc mockMvc; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @MockitoBean + private UserService userService; + + @MockitoBean + private JwtService jwtService; + + @Test + void shouldReturn201AndTokenWhenRegisterSucceeds() throws Exception { + when(userService.createUser("new@example.com", "password123")).thenReturn(null); + when(jwtService.generateToken("new@example.com")).thenReturn("test-jwt-token"); + + RegisterRequest request = new RegisterRequest("new@example.com", "password123"); + mockMvc.perform(post("/api/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.token").value("test-jwt-token")); + } + + @Test + void shouldReturn409WhenEmailAlreadyExists() throws Exception { + when(userService.createUser("taken@example.com", "password123")) + .thenThrow(new EmailAlreadyExistsException("taken@example.com")); + + RegisterRequest request = new RegisterRequest("taken@example.com", "password123"); + mockMvc.perform(post("/api/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.message").value("E-postadressen är redan registrerad")); + } + + @Test + void shouldReturn400WhenEmailIsInvalid() throws Exception { + RegisterRequest request = new RegisterRequest("not-an-email", "password123"); + mockMvc.perform(post("/api/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + void shouldReturn400WhenPasswordIsTooShort() throws Exception { + RegisterRequest request = new RegisterRequest("new@example.com", "1234567"); + mockMvc.perform(post("/api/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + void shouldReturn400WhenEmailIsMissing() throws Exception { + RegisterRequest request = new RegisterRequest("", "password123"); + mockMvc.perform(post("/api/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } +} diff --git a/frontend/src/__tests__/AppHeader.spec.ts b/frontend/src/__tests__/AppHeader.spec.ts index a4bd878..6a933e6 100644 --- a/frontend/src/__tests__/AppHeader.spec.ts +++ b/frontend/src/__tests__/AppHeader.spec.ts @@ -30,4 +30,17 @@ describe('AppHeader', () => { 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] }, + }) + const links = wrapper.findAll('a') + const registerLink = links.find( + (a) => a.attributes('href') === '/registrera', + ) + expect(registerLink).toBeTruthy() + expect(registerLink?.text()).toBe('Registrera') + }) }) diff --git a/frontend/src/__tests__/RegisterPage.spec.ts b/frontend/src/__tests__/RegisterPage.spec.ts new file mode 100644 index 0000000..fe854dc --- /dev/null +++ b/frontend/src/__tests__/RegisterPage.spec.ts @@ -0,0 +1,155 @@ +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 RegisterPage from '@/pages/RegisterPage.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: '/registrera', name: 'register', component: RegisterPage }, + { path: '/', name: 'home', component: { template: '
Home
' } }, + ], + }) +} + +function mountPage() { + const router = createTestRouter() + const pinia = createPinia() + router.push('/registrera') + return { + router, + wrapper: mount(RegisterPage, { + global: { plugins: [router, pinia] }, + }), + } +} + +describe('RegisterPage', () => { + beforeEach(() => { + localStorage.clear() + globalThis.fetch = vi.fn() + vi.mocked(globalThis.fetch).mockResolvedValue( + mockFetchResponse(201, { token: 'test-token' }), + ) + }) + + it('renders heading and subtitle', async () => { + const { wrapper } = mountPage() + expect(wrapper.text()).toContain('Skapa konto') + expect(wrapper.text()).toContain('Ange din e-postadress och ett lösenord') + }) + + it('renders all three input fields', async () => { + const { wrapper } = mountPage() + expect(wrapper.find('#email').exists()).toBe(true) + expect(wrapper.find('#password').exists()).toBe(true) + expect(wrapper.find('#confirm-password').exists()).toBe(true) + }) + + it('shows email validation error for invalid email', async () => { + const { wrapper } = mountPage() + await wrapper.find('#email').setValue('not-an-email') + expect(wrapper.text()).toContain('Ange en giltig e-postadress') + }) + + it('shows password validation error when too short', async () => { + const { wrapper } = mountPage() + await wrapper.find('#password').setValue('1234567') + expect(wrapper.text()).toContain('minst 8 tecken') + }) + + it('shows confirm password error when passwords do not match', async () => { + const { wrapper } = mountPage() + await wrapper.find('#password').setValue('password123') + await wrapper.find('#confirm-password').setValue('different') + expect(wrapper.text()).toContain('matchar inte') + }) + + it('does not show errors before user interacts', async () => { + const { wrapper } = mountPage() + expect(wrapper.text()).not.toContain('Ange en giltig e-postadress') + expect(wrapper.text()).not.toContain('minst 8 tecken') + expect(wrapper.text()).not.toContain('matchar inte') + }) + + it('disables submit button when form is invalid', async () => { + const { wrapper } = mountPage() + const button = wrapper.find('button[type="submit"]') + expect(button.attributes('disabled')).toBeDefined() + }) + + it('enables submit button when form is valid', async () => { + const { wrapper } = mountPage() + await wrapper.find('#email').setValue('test@example.com') + await wrapper.find('#password').setValue('password123') + await wrapper.find('#confirm-password').setValue('password123') + const button = wrapper.find('button[type="submit"]') + expect(button.attributes('disabled')).toBeUndefined() + }) + + it('shows submitting text while form is being submitted', 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('#confirm-password').setValue('password123') + await wrapper.find('form').trigger('submit.prevent') + expect(wrapper.text()).toContain('Skapar konto...') + }) + + it('calls register 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('#confirm-password').setValue('password123') + await wrapper.find('form').trigger('submit.prevent') + + await new Promise((resolve) => setTimeout(resolve, 50)) + + expect(globalThis.fetch).toHaveBeenCalledWith( + '/api/auth/register', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + email: 'test@example.com', + password: 'password123', + }), + }), + ) + expect(router.currentRoute.value.name).toBe('home') + }) + + it('shows error message when registration fails', async () => { + vi.mocked(globalThis.fetch).mockResolvedValue( + mockFetchResponse(409, { + message: 'E-postadressen är redan registrerad', + }), + ) + const { wrapper } = mountPage() + + await wrapper.find('#email').setValue('test@example.com') + await wrapper.find('#password').setValue('password123') + await wrapper.find('#confirm-password').setValue('password123') + await wrapper.find('form').trigger('submit.prevent') + + await new Promise((resolve) => setTimeout(resolve, 50)) + + expect(wrapper.text()).toContain('E-postadressen är redan registrerad') + }) + + it('renders login link', async () => { + const { wrapper } = mountPage() + expect(wrapper.text()).toContain('Har du redan ett konto?') + }) +}) diff --git a/frontend/src/__tests__/Router.spec.ts b/frontend/src/__tests__/Router.spec.ts index e27c96f..cf9ae3b 100644 --- a/frontend/src/__tests__/Router.spec.ts +++ b/frontend/src/__tests__/Router.spec.ts @@ -8,6 +8,12 @@ describe('Router', () => { expect(router.currentRoute.value.name).toBe('home') }) + it('resolves /registrera to RegisterPage', async () => { + await router.push('/registrera') + await router.isReady() + expect(router.currentRoute.value.name).toBe('register') + }) + it('does not crash on unknown route', async () => { await router.push('/nonexistent') await router.isReady() diff --git a/frontend/src/__tests__/authStore.spec.ts b/frontend/src/__tests__/authStore.spec.ts new file mode 100644 index 0000000..7d099a4 --- /dev/null +++ b/frontend/src/__tests__/authStore.spec.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { useAuthStore } from '@/stores/authStore' + +function mockFetchResponse(status: number, body: unknown) { + return Promise.resolve({ + ok: status >= 200 && status < 300, + status, + json: () => Promise.resolve(body), + }) +} + +describe('authStore', () => { + beforeEach(() => { + setActivePinia(createPinia()) + localStorage.clear() + globalThis.fetch = vi.fn() + vi.mocked(globalThis.fetch).mockResolvedValue( + mockFetchResponse(201, { token: 'test-token' }), + ) + }) + + it('starts unauthenticated', () => { + const store = useAuthStore() + expect(store.isAuthenticated).toBe(false) + expect(store.token).toBeNull() + }) + + it('restores token from localStorage', () => { + localStorage.setItem('auth_token', 'saved-token') + const store = useAuthStore() + expect(store.token).toBe('saved-token') + expect(store.isAuthenticated).toBe(true) + }) + + it('sets token on registerUser success', async () => { + const store = useAuthStore() + + await store.registerUser('test@example.com', 'password123') + + expect(store.token).toBe('test-token') + expect(store.isAuthenticated).toBe(true) + expect(localStorage.getItem('auth_token')).toBe('test-token') + }) + + it('rejects when register API fails', async () => { + vi.mocked(globalThis.fetch).mockResolvedValue( + mockFetchResponse(409, { + message: 'E-postadressen är redan registrerad', + }), + ) + const store = useAuthStore() + + await expect( + store.registerUser('taken@example.com', 'password123'), + ).rejects.toThrow('E-postadressen är redan registrerad') + + expect(store.isAuthenticated).toBe(false) + expect(store.token).toBeNull() + }) + + it('clears token on logout', () => { + localStorage.setItem('auth_token', 'some-token') + const store = useAuthStore() + + store.logout() + + expect(store.token).toBeNull() + expect(store.isAuthenticated).toBe(false) + expect(localStorage.getItem('auth_token')).toBeNull() + }) +}) diff --git a/frontend/src/__tests__/setup.ts b/frontend/src/__tests__/setup.ts new file mode 100644 index 0000000..164d5a9 --- /dev/null +++ b/frontend/src/__tests__/setup.ts @@ -0,0 +1,25 @@ +const store: Record = {} + +globalThis.localStorage = { + getItem(key: string): string | null { + return Object.prototype.hasOwnProperty.call(store, key) ? store[key] : null + }, + setItem(key: string, value: string): void { + store[key] = String(value) + }, + removeItem(key: string): void { + delete store[key] + }, + clear(): void { + const keys = Object.keys(store) + for (const key of keys) { + delete store[key] + } + }, + get length(): number { + return Object.keys(store).length + }, + key(index: number): string | null { + return Object.keys(store)[index] ?? null + }, +} as Storage diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts new file mode 100644 index 0000000..82d0a71 --- /dev/null +++ b/frontend/src/api/auth.ts @@ -0,0 +1,15 @@ +import { request } from './client' + +export interface AuthResponse { + token: string +} + +export function register( + email: string, + password: string, +): Promise { + return request('/auth/register', { + method: 'POST', + body: JSON.stringify({ email, password }), + }) +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..b9c6f56 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,42 @@ +const API_BASE = import.meta.env.VITE_API_URL || '/api' + +export class ApiError extends Error { + constructor( + public status: number, + message: string, + ) { + super(message) + this.name = 'ApiError' + } +} + +function getToken(): string | null { + return localStorage.getItem('auth_token') +} + +export async function request( + url: string, + options: RequestInit = {}, +): Promise { + const headers: Record = { + 'Content-Type': 'application/json', + ...(options.headers as Record), + } + + const token = getToken() + if (token) { + headers['Authorization'] = `Bearer ${token}` + } + + const response = await fetch(`${API_BASE}${url}`, { + ...options, + headers, + }) + + if (!response.ok) { + const body = await response.json().catch(() => ({})) + throw new ApiError(response.status, body.message || 'Något gick fel') + } + + return response.json() +} diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue index e135b32..06a438d 100644 --- a/frontend/src/components/AppHeader.vue +++ b/frontend/src/components/AppHeader.vue @@ -7,6 +7,9 @@ import { RouterLink } from 'vue-router' diff --git a/frontend/src/pages/RegisterPage.vue b/frontend/src/pages/RegisterPage.vue new file mode 100644 index 0000000..937b336 --- /dev/null +++ b/frontend/src/pages/RegisterPage.vue @@ -0,0 +1,260 @@ + + + + + diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 3b48df8..2e66a12 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -3,6 +3,7 @@ import HomePage from '@/pages/HomePage.vue' import ComposePage from '@/pages/ComposePage.vue' import AboutPage from '@/pages/AboutPage.vue' import ContactPage from '@/pages/ContactPage.vue' +import RegisterPage from '@/pages/RegisterPage.vue' const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), @@ -17,6 +18,11 @@ const router = createRouter({ name: 'compose', component: ComposePage, }, + { + path: '/registrera', + name: 'register', + component: RegisterPage, + }, { path: '/om', name: 'about', diff --git a/frontend/src/stores/authStore.ts b/frontend/src/stores/authStore.ts new file mode 100644 index 0000000..496ebde --- /dev/null +++ b/frontend/src/stores/authStore.ts @@ -0,0 +1,30 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { register } from '@/api/auth' + +export const useAuthStore = defineStore('auth', () => { + const token = ref(localStorage.getItem('auth_token')) + + const isAuthenticated = computed(() => token.value !== null) + + function setToken(newToken: string) { + token.value = newToken + localStorage.setItem('auth_token', newToken) + } + + function clearToken() { + token.value = null + localStorage.removeItem('auth_token') + } + + async function registerUser(email: string, password: string): Promise { + const response = await register(email, password) + setToken(response.token) + } + + function logout() { + clearToken() + } + + return { token, isAuthenticated, registerUser, logout } +}) diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 4e97eef..6d0b986 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -18,5 +18,6 @@ export default defineConfig({ }, test: { environment: 'jsdom', + setupFiles: ['src/__tests__/setup.ts'], }, })