From 8d07bb7ab1356737f50eda8793de0731f6cd61d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20M=C3=B6rling?= Date: Thu, 14 May 2026 12:39:17 +0200 Subject: [PATCH] feat: add Vue Router auth guards with admin role support Implement client-side route protection with role-based access control. The auth store now extracts the role claim from JWT tokens and exposes isAdmin. Router guards enforce three levels of access: guestOnly (redirect authenticated users), requiresAuth (redirect unauthenticated to login with redirect param), and requiresAdmin (redirect non-admin users to home). Changes: - utils/jwt.ts: JWT payload parser using base64url decode (new file) - authStore: add role ref, isAdmin computed, extractRole from JWT payload - router: add route metadata (requiresAuth, requiresAdmin, guestOnly) and beforeEach guard with getActivePinia() safety for test environments - OrdersPage.vue, AdminPage.vue: placeholder pages (new files) - LoginPage.vue, RegisterPage.vue: use route.query.redirect after auth - Router.spec.ts: 14 tests covering all guard scenarios - authStore.spec.ts: tests for role extraction, isAdmin, role persistence - LoginPage.spec.ts: test for redirect query param after login - auth-guards.spec.ts: 7 Playwright E2E tests for guard behavior - login.spec.ts: fix seed user credentials (test@bilhalsning.se) --- frontend/e2e/auth-guards.spec.ts | 73 +++++++++++++++++ frontend/e2e/login.spec.ts | 4 +- frontend/src/__tests__/LoginPage.spec.ts | 22 +++++ frontend/src/__tests__/Router.spec.ts | 100 ++++++++++++++++++++++- frontend/src/__tests__/authStore.spec.ts | 57 +++++++++++++ frontend/src/pages/AdminPage.vue | 29 +++++++ frontend/src/pages/LoginPage.vue | 6 +- frontend/src/pages/OrdersPage.vue | 29 +++++++ frontend/src/pages/RegisterPage.vue | 6 +- frontend/src/router/index.ts | 31 +++++++ frontend/src/stores/authStore.ts | 13 ++- frontend/src/utils/jwt.ts | 22 +++++ 12 files changed, 384 insertions(+), 8 deletions(-) create mode 100644 frontend/e2e/auth-guards.spec.ts create mode 100644 frontend/src/pages/AdminPage.vue create mode 100644 frontend/src/pages/OrdersPage.vue create mode 100644 frontend/src/utils/jwt.ts diff --git a/frontend/e2e/auth-guards.spec.ts b/frontend/e2e/auth-guards.spec.ts new file mode 100644 index 0000000..b36149f --- /dev/null +++ b/frontend/e2e/auth-guards.spec.ts @@ -0,0 +1,73 @@ +import { test, expect } from '@playwright/test' + +test.describe('Auth guards', () => { + test('redirects unauthenticated user from /compose to /logga-in', async ({ + page, + }) => { + await page.goto('/compose') + await expect(page).toHaveURL(/\/logga-in\?redirect=\/compose/) + await expect(page.getByRole('heading', { name: 'Logga in' })).toBeVisible() + }) + + test('redirects unauthenticated user from /orders to /logga-in', async ({ + page, + }) => { + await page.goto('/orders') + await expect(page).toHaveURL(/\/logga-in\?redirect=\/orders/) + await expect(page.getByRole('heading', { name: 'Logga in' })).toBeVisible() + }) + + test('redirects unauthenticated user from /admin to /logga-in', async ({ + page, + }) => { + await page.goto('/admin') + await expect(page).toHaveURL(/\/logga-in\?redirect=\/admin/) + await expect(page.getByRole('heading', { name: 'Logga in' })).toBeVisible() + }) + + test('redirects authenticated user from /logga-in to home', async ({ + page, + }) => { + const jwt = makeJwt({ role: 'user' }) + await page.goto('/') + await page.evaluate((token) => localStorage.setItem('auth_token', token), jwt) + await page.goto('/logga-in') + await expect(page).toHaveURL('/') + }) + + test('redirects authenticated user from /registrera to home', async ({ + page, + }) => { + const jwt = makeJwt({ role: 'user' }) + await page.goto('/') + await page.evaluate((token) => localStorage.setItem('auth_token', token), jwt) + await page.goto('/registrera') + await expect(page).toHaveURL('/') + }) + + test('redirects non-admin user from /admin to home', async ({ page }) => { + const jwt = makeJwt({ role: 'user' }) + await page.goto('/') + await page.evaluate((token) => localStorage.setItem('auth_token', token), jwt) + await page.goto('/admin') + await expect(page).toHaveURL('/') + }) + + test('allows admin user to access /admin', async ({ page }) => { + const jwt = makeJwt({ role: 'admin' }) + await page.goto('/') + await page.evaluate((token) => localStorage.setItem('auth_token', token), jwt) + await page.goto('/admin') + await expect(page).toHaveURL('/admin') + await expect( + page.getByRole('heading', { name: 'Administration' }), + ).toBeVisible() + }) +}) + +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/login.spec.ts b/frontend/e2e/login.spec.ts index 2d5630f..6eb159f 100644 --- a/frontend/e2e/login.spec.ts +++ b/frontend/e2e/login.spec.ts @@ -17,8 +17,8 @@ test.describe('Login page', () => { test('redirects to home after successful login', async ({ page }) => { await page.goto('/logga-in') - await page.getByLabel('E-postadress').fill('test@example.com') - await page.getByLabel('Lösenord').fill('password123') + await page.getByLabel('E-postadress').fill('test@bilhalsning.se') + await page.getByLabel('Lösenord').fill('test1234') await page.getByRole('button', { name: 'Logga in' }).click() await expect(page).toHaveURL('/') diff --git a/frontend/src/__tests__/LoginPage.spec.ts b/frontend/src/__tests__/LoginPage.spec.ts index a6df103..4d8de8a 100644 --- a/frontend/src/__tests__/LoginPage.spec.ts +++ b/frontend/src/__tests__/LoginPage.spec.ts @@ -23,6 +23,11 @@ function createTestRouter() { name: 'register', component: { template: '
Register
' }, }, + { + path: '/compose', + name: 'compose', + component: { template: '
Compose
' }, + }, ], }) } @@ -145,4 +150,21 @@ describe('LoginPage', () => { const { wrapper } = mountPage() expect(wrapper.text()).toContain('Har du inget konto?') }) + + it('redirects to query param after login', async () => { + const router = createTestRouter() + await router.push({ path: '/logga-in', query: { redirect: '/compose' } }) + const pinia = createPinia() + const wrapper = mount(LoginPage, { + global: { plugins: [router, pinia] }, + }) + + await wrapper.find('#email').setValue('test@example.com') + await wrapper.find('#password').setValue('password123') + await wrapper.find('form').trigger('submit.prevent') + + await new Promise((resolve) => setTimeout(resolve, 50)) + + expect(router.currentRoute.value.fullPath).toBe('/compose') + }) }) diff --git a/frontend/src/__tests__/Router.spec.ts b/frontend/src/__tests__/Router.spec.ts index ab9456e..fe20f92 100644 --- a/frontend/src/__tests__/Router.spec.ts +++ b/frontend/src/__tests__/Router.spec.ts @@ -1,7 +1,14 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, beforeEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' import router from '@/router' +import { useAuthStore } from '@/stores/authStore' describe('Router', () => { + beforeEach(() => { + setActivePinia(createPinia()) + localStorage.clear() + }) + it('resolves / to HomePage', async () => { await router.push('/') await router.isReady() @@ -20,9 +27,100 @@ describe('Router', () => { expect(router.currentRoute.value.name).toBe('login') }) + it('resolves /orders to OrdersPage', async () => { + localStorage.setItem('auth_token', makeJwt({ role: 'user' })) + await router.push('/orders') + await router.isReady() + expect(router.currentRoute.value.name).toBe('orders') + }) + + it('resolves /admin to AdminPage for admin user', async () => { + localStorage.setItem('auth_token', makeJwt({ role: 'admin' })) + await router.push('/admin') + await router.isReady() + expect(router.currentRoute.value.name).toBe('admin') + }) + it('does not crash on unknown route', async () => { await router.push('/nonexistent') await router.isReady() expect(router.currentRoute.value.matched.length).toBe(0) }) }) + +describe('Router guards', () => { + beforeEach(() => { + setActivePinia(createPinia()) + localStorage.clear() + }) + + it('redirects unauthenticated user from /compose to /logga-in', async () => { + await router.push('/compose') + await router.isReady() + expect(router.currentRoute.value.name).toBe('login') + expect(router.currentRoute.value.query.redirect).toBe('/compose') + }) + + it('redirects unauthenticated user from /orders to /logga-in', async () => { + await router.push('/orders') + await router.isReady() + expect(router.currentRoute.value.name).toBe('login') + expect(router.currentRoute.value.query.redirect).toBe('/orders') + }) + + it('redirects unauthenticated user from /admin to /logga-in', async () => { + await router.push('/admin') + await router.isReady() + expect(router.currentRoute.value.name).toBe('login') + expect(router.currentRoute.value.query.redirect).toBe('/admin') + }) + + it('allows authenticated user to access /compose', async () => { + localStorage.setItem('auth_token', makeJwt({ role: 'user' })) + await router.push('/compose') + await router.isReady() + expect(router.currentRoute.value.name).toBe('compose') + }) + + it('allows authenticated user to access /orders', async () => { + localStorage.setItem('auth_token', makeJwt({ role: 'user' })) + await router.push('/orders') + await router.isReady() + expect(router.currentRoute.value.name).toBe('orders') + }) + + it('redirects authenticated user from /logga-in to home', async () => { + localStorage.setItem('auth_token', makeJwt({ role: 'user' })) + await router.push('/logga-in') + await router.isReady() + expect(router.currentRoute.value.name).toBe('home') + }) + + it('redirects authenticated user from /registrera to home', async () => { + localStorage.setItem('auth_token', makeJwt({ role: 'user' })) + await router.push('/registrera') + await router.isReady() + expect(router.currentRoute.value.name).toBe('home') + }) + + it('redirects non-admin user from /admin to home', async () => { + localStorage.setItem('auth_token', makeJwt({ role: 'user' })) + await router.push('/admin') + await router.isReady() + expect(router.currentRoute.value.name).toBe('home') + }) + + it('allows admin user to access /admin', async () => { + localStorage.setItem('auth_token', makeJwt({ role: 'admin' })) + await router.push('/admin') + await router.isReady() + expect(router.currentRoute.value.name).toBe('admin') + }) +}) + +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/src/__tests__/authStore.spec.ts b/frontend/src/__tests__/authStore.spec.ts index 6105ef2..b54f2b5 100644 --- a/frontend/src/__tests__/authStore.spec.ts +++ b/frontend/src/__tests__/authStore.spec.ts @@ -129,4 +129,61 @@ describe('authStore', () => { const registerCall = calls.find((call) => call[0] === '/api/auth/register') expect(registerCall).toBeUndefined() }) + + it('extracts role from JWT token', async () => { + const jwt = makeJwt({ role: 'admin' }) + vi.mocked(globalThis.fetch).mockResolvedValue( + mockFetchResponse(200, { token: jwt }), + ) + const store = useAuthStore() + + await store.loginUser('admin@example.com', 'password123') + + expect(store.role).toBe('admin') + expect(store.isAdmin).toBe(true) + }) + + it('defaults to null role for user role', async () => { + const jwt = makeJwt({ role: 'user' }) + vi.mocked(globalThis.fetch).mockResolvedValue( + mockFetchResponse(200, { token: jwt }), + ) + const store = useAuthStore() + + await store.loginUser('user@example.com', 'password123') + + expect(store.role).toBe('user') + expect(store.isAdmin).toBe(false) + }) + + it('clears role on logout', async () => { + const jwt = makeJwt({ role: 'admin' }) + vi.mocked(globalThis.fetch).mockResolvedValue( + mockFetchResponse(200, { token: jwt }), + ) + const store = useAuthStore() + + await store.loginUser('admin@example.com', 'password123') + expect(store.isAdmin).toBe(true) + + store.logout() + expect(store.role).toBeNull() + expect(store.isAdmin).toBe(false) + }) + + it('restores role from localStorage on init', () => { + const jwt = makeJwt({ role: 'admin' }) + localStorage.setItem('auth_token', jwt) + const store = useAuthStore() + + expect(store.role).toBe('admin') + expect(store.isAdmin).toBe(true) + }) }) + +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/src/pages/AdminPage.vue b/frontend/src/pages/AdminPage.vue new file mode 100644 index 0000000..1247849 --- /dev/null +++ b/frontend/src/pages/AdminPage.vue @@ -0,0 +1,29 @@ + + + + + diff --git a/frontend/src/pages/LoginPage.vue b/frontend/src/pages/LoginPage.vue index 1b3f310..7c5dd7c 100644 --- a/frontend/src/pages/LoginPage.vue +++ b/frontend/src/pages/LoginPage.vue @@ -1,9 +1,10 @@ + + + + diff --git a/frontend/src/pages/RegisterPage.vue b/frontend/src/pages/RegisterPage.vue index 937b336..27e40b4 100644 --- a/frontend/src/pages/RegisterPage.vue +++ b/frontend/src/pages/RegisterPage.vue @@ -1,10 +1,11 @@