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.
This commit is contained in:
Joakim Mörling 2026-05-01 19:37:39 +02:00
parent c7d443f236
commit 8e495672d3
18 changed files with 804 additions and 0 deletions

View file

@ -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<AuthResponse> 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));
}
}

View file

@ -0,0 +1,3 @@
package se.bilhalsning.dto;
public record AuthResponse(String token) {}

View file

@ -0,0 +1,3 @@
package se.bilhalsning.dto;
public record ErrorResponse(String message) {}

View file

@ -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
) {}

View file

@ -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<ErrorResponse> handleEmailAlreadyExists(EmailAlreadyExistsException ex) {
return ResponseEntity
.status(HttpStatus.CONFLICT)
.body(new ErrorResponse("E-postadressen är redan registrerad"));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> 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<ErrorResponse> handleGeneral(Exception ex) {
log.error("Unhandled exception", ex);
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse("Ett internt fel uppstod"));
}
}

View file

@ -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());
}
}

View file

@ -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')
})
})

View file

@ -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: '<div>Home</div>' } },
],
})
}
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?')
})
})

View file

@ -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()

View file

@ -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()
})
})

View file

@ -0,0 +1,25 @@
const store: Record<string, string> = {}
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

15
frontend/src/api/auth.ts Normal file
View file

@ -0,0 +1,15 @@
import { request } from './client'
export interface AuthResponse {
token: string
}
export function register(
email: string,
password: string,
): Promise<AuthResponse> {
return request<AuthResponse>('/auth/register', {
method: 'POST',
body: JSON.stringify({ email, password }),
})
}

View file

@ -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<T>(
url: string,
options: RequestInit = {},
): Promise<T> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string>),
}
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()
}

View file

@ -7,6 +7,9 @@ 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="/registrera" class="app-header__link"
>Registrera</RouterLink
>
</nav>
</header>
</template>

View file

@ -0,0 +1,260 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter, RouterLink } from 'vue-router'
import { useAuthStore } from '@/stores/authStore'
import { ApiError } from '@/api/client'
const router = useRouter()
const authStore = useAuthStore()
const email = ref('')
const password = ref('')
const confirmPassword = ref('')
const submitting = ref(false)
const errorMessage = ref('')
const touched = ref(false)
const emailError = computed(() => {
if (!touched.value || email.value.length === 0) return ''
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.value)
? ''
: 'Ange en giltig e-postadress'
})
const passwordError = computed(() => {
if (!touched.value || password.value.length === 0) return ''
return password.value.length >= 8
? ''
: 'Lösenordet måste vara minst 8 tecken'
})
const confirmPasswordError = computed(() => {
if (!touched.value || confirmPassword.value.length === 0) return ''
return confirmPassword.value === password.value
? ''
: 'Lösenorden matchar inte'
})
const isValid = computed(() => {
return (
emailError.value === '' &&
passwordError.value === '' &&
confirmPasswordError.value === '' &&
email.value.length > 0 &&
password.value.length > 0 &&
confirmPassword.value.length > 0
)
})
async function handleSubmit() {
if (!isValid.value || submitting.value) return
submitting.value = true
errorMessage.value = ''
try {
await authStore.registerUser(email.value, password.value)
await router.push('/')
} catch (err) {
if (err instanceof ApiError) {
errorMessage.value = err.message
} else {
errorMessage.value = 'Något gick fel. Försök igen.'
}
} finally {
submitting.value = false
}
}
</script>
<template>
<div class="register">
<h1 class="register__title">Skapa konto</h1>
<p class="register__subtitle">
Ange din e-postadress och ett lösenord för att skapa ett konto.
</p>
<form class="register__form" @submit.prevent="handleSubmit">
<div class="register__field">
<label for="email" class="register__label">E-postadress</label>
<input
id="email"
v-model="email"
type="email"
autocomplete="email"
class="register__input"
:class="{ 'register__input--error': emailError }"
placeholder="namn@exempel.se"
@input="touched = true"
/>
<p v-if="emailError" class="register__error">{{ emailError }}</p>
</div>
<div class="register__field">
<label for="password" class="register__label">Lösenord</label>
<input
id="password"
v-model="password"
type="password"
autocomplete="new-password"
class="register__input"
:class="{ 'register__input--error': passwordError }"
placeholder="Minst 8 tecken"
@input="touched = true"
/>
<p v-if="passwordError" class="register__error">{{ passwordError }}</p>
</div>
<div class="register__field">
<label for="confirm-password" class="register__label"
>Bekräfta lösenord</label
>
<input
id="confirm-password"
v-model="confirmPassword"
type="password"
autocomplete="new-password"
class="register__input"
:class="{ 'register__input--error': confirmPasswordError }"
placeholder="Upprepa lösenord"
@input="touched = true"
/>
<p v-if="confirmPasswordError" class="register__error">
{{ confirmPasswordError }}
</p>
</div>
<p v-if="errorMessage" class="register__api-error">{{ errorMessage }}</p>
<button
type="submit"
class="register__submit"
:disabled="!isValid || submitting"
>
{{ submitting ? 'Skapar konto...' : 'Skapa konto' }}
</button>
</form>
<p class="register__login-link">
Har du redan ett konto?
<RouterLink to="/logga-in">Logga in</RouterLink>
</p>
</div>
</template>
<style scoped>
.register {
max-width: 28rem;
margin: 3rem auto 0;
padding: 0 1rem;
}
.register__title {
margin: 0 0 0.25rem 0;
font-size: 1.5rem;
color: #1a202c;
}
.register__subtitle {
margin: 0 0 1.5rem 0;
color: #718096;
font-size: 0.875rem;
}
.register__form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.register__field {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.register__label {
font-size: 0.875rem;
font-weight: 500;
color: #4a5568;
}
.register__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;
}
.register__input:focus {
border-color: #4299e1;
box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.25);
}
.register__input--error {
border-color: #e53e3e;
}
.register__input--error:focus {
border-color: #e53e3e;
box-shadow: 0 0 0 3px rgba(229, 62, 62, 0.25);
}
.register__error {
margin: 0;
font-size: 0.8125rem;
color: #e53e3e;
}
.register__api-error {
margin: 0;
padding: 0.75rem 1rem;
background: #fff5f5;
border: 1px solid #fed7d7;
border-radius: 0.5rem;
color: #c53030;
font-size: 0.875rem;
}
.register__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;
}
.register__submit:hover:not(:disabled) {
background: #2f855a;
}
.register__submit:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.register__login-link {
margin-top: 1.5rem;
text-align: center;
font-size: 0.875rem;
color: #718096;
}
.register__login-link a {
color: #4299e1;
text-decoration: none;
}
.register__login-link a:hover {
text-decoration: underline;
}
</style>

View file

@ -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',

View file

@ -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<string | null>(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<void> {
const response = await register(email, password)
setToken(response.token)
}
function logout() {
clearToken()
}
return { token, isAuthenticated, registerUser, logout }
})

View file

@ -18,5 +18,6 @@ export default defineConfig({
},
test: {
environment: 'jsdom',
setupFiles: ['src/__tests__/setup.ts'],
},
})