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:
parent
8a95483fb8
commit
8d07bb7ab1
12 changed files with 384 additions and 8 deletions
73
frontend/e2e/auth-guards.spec.ts
Normal file
73
frontend/e2e/auth-guards.spec.ts
Normal 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}`
|
||||
}
|
||||
|
|
@ -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('/')
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
}
|
||||
|
|
|
|||
29
frontend/src/pages/AdminPage.vue
Normal file
29
frontend/src/pages/AdminPage.vue
Normal 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>
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
29
frontend/src/pages/OrdersPage.vue
Normal file
29
frontend/src/pages/OrdersPage.vue
Normal 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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
22
frontend/src/utils/jwt.ts
Normal 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 {}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue