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)
This commit is contained in:
Joakim Mörling 2026-05-14 12:39:17 +02:00
parent 8a95483fb8
commit 8d07bb7ab1
12 changed files with 384 additions and 8 deletions

View file

@ -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, unknown>): string {
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
const body = btoa(JSON.stringify(payload))
const signature = 'test-sig'
return `${header}.${body}.${signature}`
}

View file

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

View file

@ -23,6 +23,11 @@ function createTestRouter() {
name: 'register',
component: { template: '<div>Register</div>' },
},
{
path: '/compose',
name: 'compose',
component: { template: '<div>Compose</div>' },
},
],
})
}
@ -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')
})
})

View file

@ -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, unknown>): string {
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
const body = btoa(JSON.stringify(payload))
const signature = 'test-sig'
return `${header}.${body}.${signature}`
}

View file

@ -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, unknown>): string {
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
const body = btoa(JSON.stringify(payload))
const signature = 'test-sig'
return `${header}.${body}.${signature}`
}

View file

@ -0,0 +1,29 @@
<script setup lang="ts">
</script>
<template>
<div class="admin">
<h1 class="admin__title">Administration</h1>
<p class="admin__subtitle">Hantera beställningar, mallar och användare.</p>
</div>
</template>
<style scoped>
.admin {
max-width: 48rem;
margin: 3rem auto 0;
padding: 0 1rem;
}
.admin__title {
margin: 0 0 0.25rem 0;
font-size: 1.5rem;
color: #1a202c;
}
.admin__subtitle {
margin: 0;
color: #718096;
font-size: 0.875rem;
}
</style>

View file

@ -1,9 +1,10 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter, RouterLink } from 'vue-router'
import { useRouter, useRoute, RouterLink } from 'vue-router'
import { useAuthStore } from '@/stores/authStore'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const email = ref('')
@ -23,7 +24,8 @@ async function handleSubmit() {
try {
await authStore.loginUser(email.value, password.value)
await router.push('/')
const redirect = route.query.redirect as string | undefined
await router.push(redirect || '/')
} catch {
errorMessage.value = 'Felaktig e-post eller lösenord'
} finally {

View file

@ -0,0 +1,29 @@
<script setup lang="ts">
</script>
<template>
<div class="orders">
<h1 class="orders__title">Mina beställningar</h1>
<p class="orders__subtitle">Här kan du se dina tidigare beställningar.</p>
</div>
</template>
<style scoped>
.orders {
max-width: 48rem;
margin: 3rem auto 0;
padding: 0 1rem;
}
.orders__title {
margin: 0 0 0.25rem 0;
font-size: 1.5rem;
color: #1a202c;
}
.orders__subtitle {
margin: 0;
color: #718096;
font-size: 0.875rem;
}
</style>

View file

@ -1,10 +1,11 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter, RouterLink } from 'vue-router'
import { useRouter, useRoute, RouterLink } from 'vue-router'
import { useAuthStore } from '@/stores/authStore'
import { ApiError } from '@/api/client'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const email = ref('')
@ -54,7 +55,8 @@ async function handleSubmit() {
try {
await authStore.registerUser(email.value, password.value)
await router.push('/')
const redirect = route.query.redirect as string | undefined
await router.push(redirect || '/')
} catch (err) {
if (err instanceof ApiError) {
errorMessage.value = err.message

View file

@ -5,6 +5,10 @@ import AboutPage from '@/pages/AboutPage.vue'
import ContactPage from '@/pages/ContactPage.vue'
import RegisterPage from '@/pages/RegisterPage.vue'
import LoginPage from '@/pages/LoginPage.vue'
import OrdersPage from '@/pages/OrdersPage.vue'
import AdminPage from '@/pages/AdminPage.vue'
import { useAuthStore } from '@/stores/authStore'
import { getActivePinia } from 'pinia'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
@ -18,16 +22,31 @@ const router = createRouter({
path: '/compose',
name: 'compose',
component: ComposePage,
meta: { requiresAuth: true },
},
{
path: '/orders',
name: 'orders',
component: OrdersPage,
meta: { requiresAuth: true },
},
{
path: '/admin',
name: 'admin',
component: AdminPage,
meta: { requiresAuth: true, requiresAdmin: true },
},
{
path: '/registrera',
name: 'register',
component: RegisterPage,
meta: { guestOnly: true },
},
{
path: '/logga-in',
name: 'login',
component: LoginPage,
meta: { guestOnly: true },
},
{
path: '/om',
@ -42,4 +61,16 @@ const router = createRouter({
],
})
router.beforeEach((to) => {
if (!getActivePinia()) return
const auth = useAuthStore()
if (to.meta.guestOnly && auth.isAuthenticated) return { name: 'home' }
if (to.meta.requiresAuth && !auth.isAuthenticated) {
return { name: 'login', query: { redirect: to.fullPath } }
}
if (to.meta.requiresAdmin && !auth.isAdmin) return { name: 'home' }
})
export default router

View file

@ -1,19 +1,30 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { register, login } from '@/api/auth'
import { parseJwtPayload } from '@/utils/jwt'
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(localStorage.getItem('auth_token'))
const role = ref<string | null>(extractRole(token.value))
const isAuthenticated = computed(() => token.value !== null)
const isAdmin = computed(() => role.value === 'admin')
function extractRole(jwt: string | null): string | null {
if (!jwt) return null
const payload = parseJwtPayload(jwt)
return payload.role ?? null
}
function setToken(newToken: string) {
token.value = newToken
role.value = extractRole(newToken)
localStorage.setItem('auth_token', newToken)
}
function clearToken() {
token.value = null
role.value = null
localStorage.removeItem('auth_token')
}
@ -31,5 +42,5 @@ export const useAuthStore = defineStore('auth', () => {
clearToken()
}
return { token, isAuthenticated, registerUser, loginUser, logout }
return { token, role, isAuthenticated, isAdmin, registerUser, loginUser, logout }
})

22
frontend/src/utils/jwt.ts Normal file
View file

@ -0,0 +1,22 @@
export interface JwtPayload {
sub?: string
role?: string
exp?: number
iat?: number
}
export function parseJwtPayload(token: string): JwtPayload {
try {
const base64Url = token.split('.')[1]
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/')
const jsonPayload = decodeURIComponent(
atob(base64)
.split('')
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
.join(''),
)
return JSON.parse(jsonPayload)
} catch {
return {}
}
}