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:
parent
c7d443f236
commit
8e495672d3
18 changed files with 804 additions and 0 deletions
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package se.bilhalsning.dto;
|
||||
|
||||
public record AuthResponse(String token) {}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package se.bilhalsning.dto;
|
||||
|
||||
public record ErrorResponse(String message) {}
|
||||
|
|
@ -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
|
||||
) {}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
155
frontend/src/__tests__/RegisterPage.spec.ts
Normal file
155
frontend/src/__tests__/RegisterPage.spec.ts
Normal 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?')
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
72
frontend/src/__tests__/authStore.spec.ts
Normal file
72
frontend/src/__tests__/authStore.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
25
frontend/src/__tests__/setup.ts
Normal file
25
frontend/src/__tests__/setup.ts
Normal 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
15
frontend/src/api/auth.ts
Normal 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 }),
|
||||
})
|
||||
}
|
||||
42
frontend/src/api/client.ts
Normal file
42
frontend/src/api/client.ts
Normal 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()
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
260
frontend/src/pages/RegisterPage.vue
Normal file
260
frontend/src/pages/RegisterPage.vue
Normal 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>
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
30
frontend/src/stores/authStore.ts
Normal file
30
frontend/src/stores/authStore.ts
Normal 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 }
|
||||
})
|
||||
|
|
@ -18,5 +18,6 @@ export default defineConfig({
|
|||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['src/__tests__/setup.ts'],
|
||||
},
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue