diff --git a/backend/src/main/java/se/bilhalsning/config/SecurityConfig.java b/backend/src/main/java/se/bilhalsning/config/SecurityConfig.java index 20e53f6..cdc2962 100644 --- a/backend/src/main/java/se/bilhalsning/config/SecurityConfig.java +++ b/backend/src/main/java/se/bilhalsning/config/SecurityConfig.java @@ -1,8 +1,12 @@ package se.bilhalsning.config; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletResponse; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.http.SessionCreationPolicy; @@ -10,6 +14,7 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import se.bilhalsning.dto.ErrorResponse; import se.bilhalsning.security.JwtAuthenticationFilter; import se.bilhalsning.security.JwtService; @@ -17,6 +22,13 @@ import se.bilhalsning.security.JwtService; @EnableWebSecurity public class SecurityConfig { + static final String UNAUTHENTICATED_MESSAGE = + "Din session har löpt ut eller är ogiltig. Logga in igen."; + static final String FORBIDDEN_MESSAGE = + "Du har inte behörighet att utföra denna åtgärd."; + + private final ObjectMapper objectMapper = new ObjectMapper(); + @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); @@ -46,8 +58,21 @@ public class SecurityConfig { .requestMatchers("/api/vehicles/**").permitAll() .requestMatchers("/api/admin/**").hasRole("ADMIN") .anyRequest().authenticated()) + .exceptionHandling(eh -> eh + .authenticationEntryPoint((request, response, ex) -> + writeError(response, HttpStatus.UNAUTHORIZED, UNAUTHENTICATED_MESSAGE)) + .accessDeniedHandler((request, response, ex) -> + writeError(response, HttpStatus.FORBIDDEN, FORBIDDEN_MESSAGE))) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } + + private void writeError(HttpServletResponse response, HttpStatus status, String message) + throws java.io.IOException { + response.setStatus(status.value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + response.getWriter().write(objectMapper.writeValueAsString(new ErrorResponse(message))); + } } diff --git a/backend/src/main/java/se/bilhalsning/security/JwtService.java b/backend/src/main/java/se/bilhalsning/security/JwtService.java index 4d3a726..2b96fc6 100644 --- a/backend/src/main/java/se/bilhalsning/security/JwtService.java +++ b/backend/src/main/java/se/bilhalsning/security/JwtService.java @@ -18,7 +18,7 @@ public class JwtService { this(secret, DEFAULT_EXPIRATION_MS); } - JwtService(String secret, long expirationMs) { + public JwtService(String secret, long expirationMs) { this.secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); this.expirationMs = expirationMs; } diff --git a/backend/src/test/java/se/bilhalsning/controller/AdminControllerTest.java b/backend/src/test/java/se/bilhalsning/controller/AdminControllerTest.java index 37026b5..0888619 100644 --- a/backend/src/test/java/se/bilhalsning/controller/AdminControllerTest.java +++ b/backend/src/test/java/se/bilhalsning/controller/AdminControllerTest.java @@ -42,16 +42,18 @@ class AdminControllerTest { private AdminOrderWorkflowService adminOrderWorkflowService; @Test - void shouldReturn403WhenNotAuthenticated() throws Exception { + void shouldReturn401WhenNotAuthenticated() throws Exception { mockMvc.perform(get("/api/admin/orders")) - .andExpect(status().isForbidden()); + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.message").exists()); } @Test @WithMockUser(username = "test@bilhej.se", roles = "USER") void shouldReturn403ForNonAdminUser() throws Exception { mockMvc.perform(get("/api/admin/orders")) - .andExpect(status().isForbidden()); + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.message").exists()); } @Test diff --git a/backend/src/test/java/se/bilhalsning/controller/AuthControllerTest.java b/backend/src/test/java/se/bilhalsning/controller/AuthControllerTest.java index 6bfc7cb..ac81733 100644 --- a/backend/src/test/java/se/bilhalsning/controller/AuthControllerTest.java +++ b/backend/src/test/java/se/bilhalsning/controller/AuthControllerTest.java @@ -225,7 +225,8 @@ class AuthControllerTest { .contentType(MediaType.APPLICATION_JSON) .content( "{\"currentPassword\":\"test1234\",\"newPassword\":\"newpassword123\"}")) - .andExpect(status().isForbidden()); + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.message").exists()); } @Test @@ -263,6 +264,7 @@ class AuthControllerTest { mockMvc.perform(post("/api/auth/change-email") .contentType(MediaType.APPLICATION_JSON) .content("{\"newEmail\":\"new@example.com\",\"password\":\"password123\"}")) - .andExpect(status().isForbidden()); + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.message").exists()); } } diff --git a/backend/src/test/java/se/bilhalsning/controller/OrderControllerTest.java b/backend/src/test/java/se/bilhalsning/controller/OrderControllerTest.java index a42e9ab..71b94ce 100644 --- a/backend/src/test/java/se/bilhalsning/controller/OrderControllerTest.java +++ b/backend/src/test/java/se/bilhalsning/controller/OrderControllerTest.java @@ -18,16 +18,22 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.TestPropertySource; import org.springframework.test.web.servlet.MockMvc; import se.bilhalsning.dto.OrderResponse; import se.bilhalsning.entity.User; +import se.bilhalsning.security.JwtService; import se.bilhalsning.service.OrderService; import se.bilhalsning.service.UserService; @SpringBootTest @AutoConfigureMockMvc +@TestPropertySource(properties = "app.jwt.secret=this-is-a-test-secret-that-is-at-least-32-bytes-long!!") class OrderControllerTest { + private static final String TEST_SECRET = + "this-is-a-test-secret-that-is-at-least-32-bytes-long!!"; + @Autowired private MockMvc mockMvc; @@ -38,9 +44,10 @@ class OrderControllerTest { private UserService userService; @Test - void shouldReturn403WhenNotAuthenticated() throws Exception { + void shouldReturn401WhenNotAuthenticated() throws Exception { mockMvc.perform(get("/api/orders")) - .andExpect(status().isForbidden()); + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.message").exists()); } @Test @@ -100,11 +107,31 @@ class OrderControllerTest { } @Test - void shouldReturn403WhenPostingWithoutAuth() throws Exception { + void shouldReturn401WhenPostingWithoutAuth() throws Exception { mockMvc.perform(post("/api/orders") .contentType("application/json") .content("{\"plate\":\"ABC123\",\"letterText\":\"Hej\"}")) - .andExpect(status().isForbidden()); + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.message").exists()); + } + + @Test + void shouldReturn401WithSwedishMessageWhenTokenExpired() throws Exception { + JwtService expiredJwtService = new JwtService(TEST_SECRET, -1000L); + String expiredToken = expiredJwtService.generateToken("test@bilhej.se"); + + mockMvc.perform(get("/api/orders") + .header("Authorization", "Bearer " + expiredToken)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.message").exists()); + } + + @Test + void shouldReturn401WithMessageWhenNoAuthHeader() throws Exception { + mockMvc.perform(get("/api/orders")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.message") + .value(org.hamcrest.Matchers.containsString("session"))); } @Test diff --git a/backend/src/test/java/se/bilhalsning/controller/PaymentControllerTest.java b/backend/src/test/java/se/bilhalsning/controller/PaymentControllerTest.java index be187ad..92b77f9 100644 --- a/backend/src/test/java/se/bilhalsning/controller/PaymentControllerTest.java +++ b/backend/src/test/java/se/bilhalsning/controller/PaymentControllerTest.java @@ -39,10 +39,11 @@ class PaymentControllerTest { private UserService userService; @Test - void shouldReturn403WhenNotAuthenticated() throws Exception { + void shouldReturn401WhenNotAuthenticated() throws Exception { mockMvc.perform(post("/api/payment/{orderId}/pay", "c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11")) - .andExpect(status().isForbidden()); + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.message").exists()); } @Test diff --git a/frontend/e2e/auth-guards.spec.ts b/frontend/e2e/auth-guards.spec.ts index f0e7e4c..116c632 100644 --- a/frontend/e2e/auth-guards.spec.ts +++ b/frontend/e2e/auth-guards.spec.ts @@ -70,6 +70,13 @@ test.describe('Auth guards', () => { }) test('allows admin user to access /admin', async ({ page }) => { + await page.route('**/api/admin/orders', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: '[]', + }), + ) const jwt = makeJwt({ role: 'admin' }) await page.goto('/') await page.evaluate((token) => localStorage.setItem('auth_token', token), jwt) diff --git a/frontend/e2e/expired-token.spec.ts b/frontend/e2e/expired-token.spec.ts new file mode 100644 index 0000000..a1b6fbc --- /dev/null +++ b/frontend/e2e/expired-token.spec.ts @@ -0,0 +1,55 @@ +import { test, expect } from '@playwright/test' + +test.describe('Expired token logout', () => { + test('router guard redirects expired token to login and logs out', async ({ + page, + }) => { + const past = Math.floor(Date.now() / 1000) - 3600 + const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user', exp: past }) + + await page.goto('/') + await page.evaluate((token) => localStorage.setItem('auth_token', token), jwt) + + await page.goto('/orders') + + await expect(page).toHaveURL(/\/logga-in\?redirect=\/orders/) + await expect(page.getByRole('heading', { name: 'Logga in' })).toBeVisible() + + const header = page.locator('header') + await expect(header.getByRole('link', { name: 'Logga in' })).toBeVisible() + await expect( + header.getByRole('button', { name: 'Logga ut' }), + ).not.toBeVisible() + + const stored = await page.evaluate(() => localStorage.getItem('auth_token')) + expect(stored).toBeNull() + }) + + test('API 401 logs out and redirects when guard accepts token but backend rejects it', async ({ + page, + }) => { + const future = Math.floor(Date.now() / 1000) + 3600 + const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user', exp: future }) + + await page.goto('/') + await page.evaluate((token) => localStorage.setItem('auth_token', token), jwt) + + await page.goto('/orders') + await page.waitForURL(/\/logga-in\?redirect=\/orders/) + + await expect(page.getByRole('heading', { name: 'Logga in' })).toBeVisible() + + const header = page.locator('header') + await expect(header.getByRole('button', { name: 'Logga ut' })).not.toBeVisible() + + const stored = await page.evaluate(() => localStorage.getItem('auth_token')) + expect(stored).toBeNull() + }) +}) + +function makeJwt(payload: Record): string { + const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' })) + const body = btoa(JSON.stringify(payload)) + const signature = 'test-sig' + return `${header}.${body}.${signature}` +} diff --git a/frontend/e2e/header-auth.spec.ts b/frontend/e2e/header-auth.spec.ts index a8f670a..18d8797 100644 --- a/frontend/e2e/header-auth.spec.ts +++ b/frontend/e2e/header-auth.spec.ts @@ -100,6 +100,13 @@ test.describe('Header auth state', () => { }) test('logout redirects to home page', async ({ page }) => { + await page.route('**/api/orders', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: '[]', + }), + ) const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user' }) await page.goto('/orders') await page.evaluate( diff --git a/frontend/src/__tests__/OrdersPage.spec.ts b/frontend/src/__tests__/OrdersPage.spec.ts index 32e8f94..a7d9476 100644 --- a/frontend/src/__tests__/OrdersPage.spec.ts +++ b/frontend/src/__tests__/OrdersPage.spec.ts @@ -4,6 +4,26 @@ import { createRouter, createMemoryHistory } from 'vue-router' import { createPinia } from 'pinia' import OrdersPage from '@/pages/OrdersPage.vue' +const sessionMocks = vi.hoisted(() => { + const mockLogout = vi.fn() + const mockPush = vi.fn() + return { + mockLogout, + mockPush, + mockAuth: { isAuthenticated: true, logout: mockLogout }, + } +}) + +vi.mock('@/stores/authStore', () => ({ + useAuthStore: () => sessionMocks.mockAuth, +})) +vi.mock('@/router', () => ({ + default: { + currentRoute: { value: { fullPath: '/orders' } }, + push: sessionMocks.mockPush, + }, +})) + function mockFetchResponse(status: number, body: unknown) { return Promise.resolve({ ok: status >= 200 && status < 300, @@ -376,3 +396,34 @@ describe('OrdersPage', () => { expect(wrapper.find('.orders__preview-toggle').exists()).toBe(false) }) }) + +describe('OrdersPage — expired session (401)', () => { + beforeEach(() => { + localStorage.clear() + globalThis.fetch = vi.fn() + sessionMocks.mockLogout.mockClear() + sessionMocks.mockPush.mockClear() + sessionMocks.mockAuth.isAuthenticated = true + }) + + it('does not show generic error and triggers global logout/redirect on 401', async () => { + vi.mocked(globalThis.fetch).mockImplementation((url) => { + const urlStr = String(url) + if (urlStr.includes('/payment/swish-info')) { + return mockFetchResponse(200, { number: '123', amount: 49 }) + } + return mockFetchResponse(401, { message: 'Din session har löpt ut.' }) + }) + localStorage.setItem('auth_token', 'expired-token') + + const { wrapper } = mountPage() + await new Promise((r) => setTimeout(r, 50)) + + expect(wrapper.text()).not.toContain('Kunde inte hämta beställningar') + expect(sessionMocks.mockLogout).toHaveBeenCalledTimes(1) + expect(sessionMocks.mockPush).toHaveBeenCalledWith({ + name: 'login', + query: { redirect: '/orders' }, + }) + }) +}) diff --git a/frontend/src/__tests__/Router.spec.ts b/frontend/src/__tests__/Router.spec.ts index 37115eb..c007021 100644 --- a/frontend/src/__tests__/Router.spec.ts +++ b/frontend/src/__tests__/Router.spec.ts @@ -214,6 +214,64 @@ describe('Router guards', () => { }) }) +describe('Router guards — expired tokens', () => { + beforeEach(() => { + setActivePinia(createPinia()) + localStorage.clear() + }) + + it('redirects expired-token user from /orders to /logga-in with redirect query', async () => { + const past = Math.floor(Date.now() / 1000) - 3600 + localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: past })) + + await router.push('/orders') + await router.isReady() + + expect(router.currentRoute.value.name).toBe('login') + expect(router.currentRoute.value.query.redirect).toBe('/orders') + }) + + it('clears the expired token from localStorage on redirect', async () => { + const past = Math.floor(Date.now() / 1000) - 3600 + localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: past })) + + await router.push('/orders') + await router.isReady() + + expect(localStorage.getItem('auth_token')).toBeNull() + }) + + it('allows access with a token whose exp is in the future', async () => { + const future = Math.floor(Date.now() / 1000) + 3600 + localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: future })) + + await router.push('/orders') + await router.isReady() + + expect(router.currentRoute.value.name).toBe('orders') + }) + + it('lets expired-token user open /logga-in instead of bouncing to home', async () => { + const past = Math.floor(Date.now() / 1000) - 3600 + localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: past })) + + await router.push('/logga-in') + await router.isReady() + + expect(router.currentRoute.value.name).toBe('login') + }) + + it('lets expired-token user open /registrera instead of bouncing to home', async () => { + const past = Math.floor(Date.now() / 1000) - 3600 + localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: past })) + + await router.push('/registrera') + await router.isReady() + + expect(router.currentRoute.value.name).toBe('register') + }) +}) + function makeJwt(payload: Record): string { const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' })) const body = btoa(JSON.stringify(payload)) diff --git a/frontend/src/__tests__/authStore.spec.ts b/frontend/src/__tests__/authStore.spec.ts index 00ac019..9da2039 100644 --- a/frontend/src/__tests__/authStore.spec.ts +++ b/frontend/src/__tests__/authStore.spec.ts @@ -212,6 +212,52 @@ describe('authStore', () => { }) }) +describe('authStore.isTokenExpired', () => { + beforeEach(() => { + setActivePinia(createPinia()) + localStorage.clear() + }) + + it('returns true when there is no token', () => { + const store = useAuthStore() + expect(store.isTokenExpired()).toBe(true) + }) + + it('returns true for a token with an expired exp claim', () => { + const past = Math.floor(Date.now() / 1000) - 3600 + localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: past })) + const store = useAuthStore() + + expect(store.isTokenExpired()).toBe(true) + }) + + it('returns false for a token with a future exp claim', () => { + const future = Math.floor(Date.now() / 1000) + 3600 + localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: future })) + const store = useAuthStore() + + expect(store.isTokenExpired()).toBe(false) + }) + + it('returns false for a token without an exp claim', () => { + localStorage.setItem('auth_token', makeJwt({ role: 'user' })) + const store = useAuthStore() + + expect(store.isTokenExpired()).toBe(false) + }) + + it('returns true after logout clears the token', async () => { + const future = Math.floor(Date.now() / 1000) + 3600 + localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: future })) + const store = useAuthStore() + expect(store.isTokenExpired()).toBe(false) + + store.logout() + + expect(store.isTokenExpired()).toBe(true) + }) +}) + function makeJwt(payload: Record): string { const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' })) const body = btoa(JSON.stringify(payload)) diff --git a/frontend/src/__tests__/client.spec.ts b/frontend/src/__tests__/client.spec.ts new file mode 100644 index 0000000..eeabbe9 --- /dev/null +++ b/frontend/src/__tests__/client.spec.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' + +const mocks = vi.hoisted(() => { + const mockLogout = vi.fn() + const mockPush = vi.fn() + return { + mockLogout, + mockPush, + mockAuth: { isAuthenticated: true, logout: mockLogout }, + } +}) + +vi.mock('@/stores/authStore', () => ({ + useAuthStore: () => mocks.mockAuth, +})) +vi.mock('@/router', () => ({ + default: { + currentRoute: { value: { fullPath: '/orders' } }, + push: mocks.mockPush, + }, +})) + +import { request, ApiError, isSessionExpired, isForbidden } from '@/api/client' + +function mockFetchResponse(status: number, body: unknown) { + return Promise.resolve({ + ok: status >= 200 && status < 300, + status, + json: () => Promise.resolve(body), + }) +} + +describe('api client', () => { + beforeEach(() => { + setActivePinia(createPinia()) + localStorage.clear() + globalThis.fetch = vi.fn() + mocks.mockLogout.mockClear() + mocks.mockPush.mockClear() + mocks.mockAuth.isAuthenticated = true + }) + + it('logs out and redirects to login on 401 from a protected endpoint', async () => { + vi.mocked(globalThis.fetch).mockResolvedValue( + mockFetchResponse(401, { message: 'Din session har löpt ut.' }), + ) + localStorage.setItem('auth_token', 'expired-token') + + await expect(request('/orders')).rejects.toThrow('Din session har löpt ut.') + + expect(mocks.mockLogout).toHaveBeenCalledTimes(1) + expect(mocks.mockPush).toHaveBeenCalledWith({ + name: 'login', + query: { redirect: '/orders' }, + }) + }) + + it('still throws ApiError with 401 status after handling expired session', async () => { + vi.mocked(globalThis.fetch).mockResolvedValue( + mockFetchResponse(401, { message: 'Din session har löpt ut.' }), + ) + + try { + await request('/orders') + throw new Error('should have thrown') + } catch (err) { + expect(err).toBeInstanceOf(ApiError) + expect((err as ApiError).status).toBe(401) + } + }) + + it('does not log out on 401 from an auth endpoint (wrong credentials)', async () => { + vi.mocked(globalThis.fetch).mockResolvedValue( + mockFetchResponse(401, { message: 'Felaktig e-post eller lösenord' }), + ) + + await expect(request('/auth/login', { method: 'POST' })).rejects.toThrow( + 'Felaktig e-post eller lösenord', + ) + + expect(mocks.mockLogout).not.toHaveBeenCalled() + expect(mocks.mockPush).not.toHaveBeenCalled() + }) + + it('does not log out on 403 (forbidden is not session expiry)', async () => { + vi.mocked(globalThis.fetch).mockResolvedValue( + mockFetchResponse(403, { message: 'Du har inte behörighet' }), + ) + localStorage.setItem('auth_token', 'valid-token') + + await expect(request('/admin/orders')).rejects.toThrow( + 'Du har inte behörighet', + ) + + expect(mocks.mockLogout).not.toHaveBeenCalled() + expect(mocks.mockPush).not.toHaveBeenCalled() + }) + + it('does not redirect when there is no token on 401', async () => { + mocks.mockAuth.isAuthenticated = false + vi.mocked(globalThis.fetch).mockResolvedValue( + mockFetchResponse(401, { message: 'Din session har löpt ut.' }), + ) + + await expect(request('/orders')).rejects.toThrow() + + expect(mocks.mockLogout).not.toHaveBeenCalled() + expect(mocks.mockPush).not.toHaveBeenCalled() + }) + + it('isSessionExpired returns true only for a 401 ApiError', () => { + expect(isSessionExpired(new ApiError(401, 'x'))).toBe(true) + expect(isSessionExpired(new ApiError(403, 'x'))).toBe(false) + expect(isSessionExpired(new ApiError(500, 'x'))).toBe(false) + expect(isSessionExpired(new Error('x'))).toBe(false) + expect(isSessionExpired(null)).toBe(false) + }) + + it('isForbidden returns true only for a 403 ApiError', () => { + expect(isForbidden(new ApiError(403, 'x'))).toBe(true) + expect(isForbidden(new ApiError(401, 'x'))).toBe(false) + expect(isForbidden(new Error('x'))).toBe(false) + }) +}) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index bf9197a..be32407 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,3 +1,6 @@ +import { useAuthStore } from '@/stores/authStore' +import router from '@/router' + const API_BASE = import.meta.env.VITE_API_URL || '/api' export class ApiError extends Error { @@ -10,10 +13,27 @@ export class ApiError extends Error { } } +export function isSessionExpired(err: unknown): boolean { + return err instanceof ApiError && err.status === 401 +} + +export function isForbidden(err: unknown): boolean { + return err instanceof ApiError && err.status === 403 +} + function getToken(): string | null { return localStorage.getItem('auth_token') } +function handleExpiredSession(): void { + const auth = useAuthStore() + if (auth.isAuthenticated) { + auth.logout() + const redirect = router.currentRoute.value.fullPath + router.push({ name: 'login', query: { redirect } }) + } +} + export async function request( url: string, options: RequestInit = {}, @@ -34,6 +54,9 @@ export async function request( }) if (!response.ok) { + if (response.status === 401 && !url.startsWith('/auth/')) { + handleExpiredSession() + } const body = await response.json().catch(() => ({})) throw new ApiError(response.status, body.message || 'Något gick fel') } diff --git a/frontend/src/composables/useAdminOrderActions.ts b/frontend/src/composables/useAdminOrderActions.ts index 15a5e5f..05ade66 100644 --- a/frontend/src/composables/useAdminOrderActions.ts +++ b/frontend/src/composables/useAdminOrderActions.ts @@ -1,5 +1,5 @@ import { ref, reactive, type Ref } from 'vue' -import { ApiError } from '@/api/client' +import { ApiError, isSessionExpired } from '@/api/client' import { updateOrderStatus, registerShipment, @@ -69,10 +69,12 @@ export function useAdminOrderActions( replaceOrder(updated) } catch (err) { order.status = previousStatus - statusError.value = - err instanceof ApiError && err.message - ? err.message - : 'Kunde inte uppdatera status. Försök igen.' + if (!isSessionExpired(err)) { + statusError.value = + err instanceof ApiError && err.message + ? err.message + : 'Kunde inte uppdatera status. Försök igen.' + } } } @@ -100,11 +102,13 @@ export function useAdminOrderActions( ) replaceOrder(updated) trackingInputValues[orderId] = updated.trackingId ?? trackingInput - } catch { + } catch (err) { order.status = previousStatus order.trackingId = previousTrackingId - trackingError.value = - 'Kunde inte registrera utskick. Kontrollera spårnings-ID och försök igen.' + if (!isSessionExpired(err)) { + trackingError.value = + 'Kunde inte registrera utskick. Kontrollera spårnings-ID och försök igen.' + } } finally { registeringId.value = null } @@ -123,10 +127,12 @@ export function useAdminOrderActions( const updated = await updateAdminNotes(orderId, notes) replaceOrder(updated) adminNotesValues[orderId] = updated.adminNotes ?? '' - } catch { + } catch (err) { order.adminNotes = previousNotes adminNotesValues[orderId] = previousNotes ?? '' - notesError.value = 'Kunde inte spara anteckningar. Försök igen.' + if (!isSessionExpired(err)) { + notesError.value = 'Kunde inte spara anteckningar. Försök igen.' + } } finally { savingNotesId.value = null } diff --git a/frontend/src/composables/useAdminOrders.ts b/frontend/src/composables/useAdminOrders.ts index 4ef0be1..ce1f0b6 100644 --- a/frontend/src/composables/useAdminOrders.ts +++ b/frontend/src/composables/useAdminOrders.ts @@ -1,5 +1,6 @@ import { ref, computed } from 'vue' import { fetchAllOrders, type AdminOrder } from '@/api/admin' +import { isSessionExpired } from '@/api/client' import { PAID_GROUP_STATUSES } from '@/constants/orderStatus' export type AdminOrderFilter = @@ -61,8 +62,10 @@ export function useAdminOrders() { error.value = '' try { orders.value = await fetchAllOrders() - } catch { - error.value = 'Kunde inte hämta beställningar. Försök igen senare.' + } catch (err) { + if (!isSessionExpired(err)) { + error.value = 'Kunde inte hämta beställningar. Försök igen senare.' + } } finally { loading.value = false } diff --git a/frontend/src/pages/ComposePage.vue b/frontend/src/pages/ComposePage.vue index bb9827f..932b88e 100644 --- a/frontend/src/pages/ComposePage.vue +++ b/frontend/src/pages/ComposePage.vue @@ -2,6 +2,7 @@ import { ref, computed } from 'vue' import { useRouter, useRoute } from 'vue-router' import { createOrder } from '@/api/orders' +import { isSessionExpired } from '@/api/client' import { type LetterTemplate } from '@/data/templates' import TemplatePicker from '@/components/TemplatePicker.vue' import { RouterLink } from 'vue-router' @@ -41,8 +42,10 @@ async function handleSubmit() { params: { orderId: order.id }, query: { plate: plate.value }, }) - } catch { - errorMessage.value = 'Kunde inte skapa beställningen. Försök igen senare.' + } catch (err) { + if (!isSessionExpired(err)) { + errorMessage.value = 'Kunde inte skapa beställningen. Försök igen senare.' + } } finally { submitting.value = false } diff --git a/frontend/src/pages/EditOrderPage.vue b/frontend/src/pages/EditOrderPage.vue index 3786f8c..d9efac0 100644 --- a/frontend/src/pages/EditOrderPage.vue +++ b/frontend/src/pages/EditOrderPage.vue @@ -2,6 +2,7 @@ import { ref, computed, onMounted } from 'vue' import { useRouter, useRoute, RouterLink } from 'vue-router' import { fetchOrder, updateOrder, type Order } from '@/api/orders' +import { isSessionExpired } from '@/api/client' import { type LetterTemplate } from '@/data/templates' import TemplatePicker from '@/components/TemplatePicker.vue' @@ -44,8 +45,10 @@ async function loadOrder() { if (fetched.status === 'pending_payment') { letterText.value = fetched.letterText } - } catch { - loadError.value = 'Kunde inte hämta beställningen. Försök igen senare.' + } catch (err) { + if (!isSessionExpired(err)) { + loadError.value = 'Kunde inte hämta beställningen. Försök igen senare.' + } } finally { loading.value = false } @@ -64,8 +67,10 @@ async function handleSubmit() { params: { orderId: order.value.id }, query: { plate: order.value.plate }, }) - } catch { - errorMessage.value = 'Kunde inte spara ändringarna. Försök igen senare.' + } catch (err) { + if (!isSessionExpired(err)) { + errorMessage.value = 'Kunde inte spara ändringarna. Försök igen senare.' + } } finally { submitting.value = false } diff --git a/frontend/src/pages/OrdersPage.vue b/frontend/src/pages/OrdersPage.vue index b0aa0a3..a82285b 100644 --- a/frontend/src/pages/OrdersPage.vue +++ b/frontend/src/pages/OrdersPage.vue @@ -2,6 +2,7 @@ import { ref, computed, onMounted } from 'vue' import { fetchOrders, cancelOrder, type Order } from '@/api/orders' import { fetchSwishInfo } from '@/api/payment' +import { isSessionExpired } from '@/api/client' import { RouterLink } from 'vue-router' import { ORDER_STATUS_BADGE, @@ -65,8 +66,10 @@ async function loadOrders() { ]) orders.value = fetchedOrders orderAmount.value = swishInfo.amount - } catch { - error.value = 'Kunde inte hämta beställningar. Försök igen senare.' + } catch (err) { + if (!isSessionExpired(err)) { + error.value = 'Kunde inte hämta beställningar. Försök igen senare.' + } } finally { loading.value = false } @@ -87,8 +90,11 @@ async function handleCancel(order: Order) { try { const updated = await cancelOrder(order.id) orders.value = orders.value.map((o) => (o.id === updated.id ? updated : o)) - } catch { - actionError.value = 'Kunde inte avbryta beställningen. Försök igen senare.' + } catch (err) { + if (!isSessionExpired(err)) { + actionError.value = + 'Kunde inte avbryta beställningen. Försök igen senare.' + } } finally { cancellingId.value = null } diff --git a/frontend/src/pages/PaymentRedirect.vue b/frontend/src/pages/PaymentRedirect.vue index c6746ea..fd05de0 100644 --- a/frontend/src/pages/PaymentRedirect.vue +++ b/frontend/src/pages/PaymentRedirect.vue @@ -2,6 +2,7 @@ import { ref, onMounted } from 'vue' import { useRouter, useRoute } from 'vue-router' import { payOrder, fetchSwishInfo } from '@/api/payment' +import { isSessionExpired } from '@/api/client' const router = useRouter() const route = useRoute() @@ -38,8 +39,10 @@ async function confirmPayment() { try { await payOrder(orderId) await router.push({ name: 'orders' }) - } catch { - error.value = 'Kunde inte bekräfta betalningen. Försök igen.' + } catch (err) { + if (!isSessionExpired(err)) { + error.value = 'Kunde inte bekräfta betalningen. Försök igen.' + } } finally { paying.value = false } diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 68553fc..7e63f11 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -148,9 +148,11 @@ router.beforeEach((to) => { if (!getActivePinia()) return const auth = useAuthStore() + const authenticated = auth.isAuthenticated && !auth.isTokenExpired() - if (to.meta.guestOnly && auth.isAuthenticated) return { name: 'home' } - if (to.meta.requiresAuth && !auth.isAuthenticated) { + if (to.meta.guestOnly && authenticated) return { name: 'home' } + if (to.meta.requiresAuth && !authenticated) { + if (auth.isAuthenticated) auth.logout() return { name: 'login', query: { redirect: to.fullPath } } } if (to.meta.requiresAdmin && !auth.isAdmin) return { name: 'home' } diff --git a/frontend/src/stores/authStore.ts b/frontend/src/stores/authStore.ts index 274a61d..3acfc8f 100644 --- a/frontend/src/stores/authStore.ts +++ b/frontend/src/stores/authStore.ts @@ -1,7 +1,7 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' import { register, login, changeEmail, confirmEmailChange } from '@/api/auth' -import { parseJwtPayload } from '@/utils/jwt' +import { parseJwtPayload, isTokenExpired as isJwtExpired } from '@/utils/jwt' export const useAuthStore = defineStore('auth', () => { const token = ref(localStorage.getItem('auth_token')) @@ -21,6 +21,10 @@ export const useAuthStore = defineStore('auth', () => { return payload.role ?? null } + function isTokenExpired(): boolean { + return isJwtExpired(token.value) + } + function setToken(newToken: string) { token.value = newToken role.value = extractRole(newToken) @@ -69,6 +73,7 @@ export const useAuthStore = defineStore('auth', () => { email, isAuthenticated, isAdmin, + isTokenExpired, registerUser, loginUser, changeUserEmail, diff --git a/frontend/src/utils/jwt.ts b/frontend/src/utils/jwt.ts index 2632fa4..4a4bab7 100644 --- a/frontend/src/utils/jwt.ts +++ b/frontend/src/utils/jwt.ts @@ -20,3 +20,10 @@ export function parseJwtPayload(token: string): JwtPayload { return {} } } + +export function isTokenExpired(token: string | null): boolean { + if (!token) return true + const payload = parseJwtPayload(token) + if (payload.exp === undefined || payload.exp === null) return false + return payload.exp < Math.floor(Date.now() / 1000) +}