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 }) => {
|
test('redirects to home after successful login', async ({ page }) => {
|
||||||
await page.goto('/logga-in')
|
await page.goto('/logga-in')
|
||||||
await page.getByLabel('E-postadress').fill('test@example.com')
|
await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
|
||||||
await page.getByLabel('Lösenord').fill('password123')
|
await page.getByLabel('Lösenord').fill('test1234')
|
||||||
await page.getByRole('button', { name: 'Logga in' }).click()
|
await page.getByRole('button', { name: 'Logga in' }).click()
|
||||||
|
|
||||||
await expect(page).toHaveURL('/')
|
await expect(page).toHaveURL('/')
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,11 @@ function createTestRouter() {
|
||||||
name: 'register',
|
name: 'register',
|
||||||
component: { template: '<div>Register</div>' },
|
component: { template: '<div>Register</div>' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/compose',
|
||||||
|
name: 'compose',
|
||||||
|
component: { template: '<div>Compose</div>' },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -145,4 +150,21 @@ describe('LoginPage', () => {
|
||||||
const { wrapper } = mountPage()
|
const { wrapper } = mountPage()
|
||||||
expect(wrapper.text()).toContain('Har du inget konto?')
|
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 router from '@/router'
|
||||||
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
|
|
||||||
describe('Router', () => {
|
describe('Router', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
localStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
it('resolves / to HomePage', async () => {
|
it('resolves / to HomePage', async () => {
|
||||||
await router.push('/')
|
await router.push('/')
|
||||||
await router.isReady()
|
await router.isReady()
|
||||||
|
|
@ -20,9 +27,100 @@ describe('Router', () => {
|
||||||
expect(router.currentRoute.value.name).toBe('login')
|
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 () => {
|
it('does not crash on unknown route', async () => {
|
||||||
await router.push('/nonexistent')
|
await router.push('/nonexistent')
|
||||||
await router.isReady()
|
await router.isReady()
|
||||||
expect(router.currentRoute.value.matched.length).toBe(0)
|
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')
|
const registerCall = calls.find((call) => call[0] === '/api/auth/register')
|
||||||
expect(registerCall).toBeUndefined()
|
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">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { useRouter, RouterLink } from 'vue-router'
|
import { useRouter, useRoute, RouterLink } from 'vue-router'
|
||||||
import { useAuthStore } from '@/stores/authStore'
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
const email = ref('')
|
const email = ref('')
|
||||||
|
|
@ -23,7 +24,8 @@ async function handleSubmit() {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await authStore.loginUser(email.value, password.value)
|
await authStore.loginUser(email.value, password.value)
|
||||||
await router.push('/')
|
const redirect = route.query.redirect as string | undefined
|
||||||
|
await router.push(redirect || '/')
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage.value = 'Felaktig e-post eller lösenord'
|
errorMessage.value = 'Felaktig e-post eller lösenord'
|
||||||
} finally {
|
} 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">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { useRouter, RouterLink } from 'vue-router'
|
import { useRouter, useRoute, RouterLink } from 'vue-router'
|
||||||
import { useAuthStore } from '@/stores/authStore'
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
import { ApiError } from '@/api/client'
|
import { ApiError } from '@/api/client'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
const email = ref('')
|
const email = ref('')
|
||||||
|
|
@ -54,7 +55,8 @@ async function handleSubmit() {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await authStore.registerUser(email.value, password.value)
|
await authStore.registerUser(email.value, password.value)
|
||||||
await router.push('/')
|
const redirect = route.query.redirect as string | undefined
|
||||||
|
await router.push(redirect || '/')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
errorMessage.value = err.message
|
errorMessage.value = err.message
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,10 @@ import AboutPage from '@/pages/AboutPage.vue'
|
||||||
import ContactPage from '@/pages/ContactPage.vue'
|
import ContactPage from '@/pages/ContactPage.vue'
|
||||||
import RegisterPage from '@/pages/RegisterPage.vue'
|
import RegisterPage from '@/pages/RegisterPage.vue'
|
||||||
import LoginPage from '@/pages/LoginPage.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({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
|
|
@ -18,16 +22,31 @@ const router = createRouter({
|
||||||
path: '/compose',
|
path: '/compose',
|
||||||
name: 'compose',
|
name: 'compose',
|
||||||
component: ComposePage,
|
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',
|
path: '/registrera',
|
||||||
name: 'register',
|
name: 'register',
|
||||||
component: RegisterPage,
|
component: RegisterPage,
|
||||||
|
meta: { guestOnly: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/logga-in',
|
path: '/logga-in',
|
||||||
name: 'login',
|
name: 'login',
|
||||||
component: LoginPage,
|
component: LoginPage,
|
||||||
|
meta: { guestOnly: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/om',
|
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
|
export default router
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,30 @@
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { register, login } from '@/api/auth'
|
import { register, login } from '@/api/auth'
|
||||||
|
import { parseJwtPayload } from '@/utils/jwt'
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
const token = ref<string | null>(localStorage.getItem('auth_token'))
|
const token = ref<string | null>(localStorage.getItem('auth_token'))
|
||||||
|
const role = ref<string | null>(extractRole(token.value))
|
||||||
|
|
||||||
const isAuthenticated = computed(() => token.value !== null)
|
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) {
|
function setToken(newToken: string) {
|
||||||
token.value = newToken
|
token.value = newToken
|
||||||
|
role.value = extractRole(newToken)
|
||||||
localStorage.setItem('auth_token', newToken)
|
localStorage.setItem('auth_token', newToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearToken() {
|
function clearToken() {
|
||||||
token.value = null
|
token.value = null
|
||||||
|
role.value = null
|
||||||
localStorage.removeItem('auth_token')
|
localStorage.removeItem('auth_token')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -31,5 +42,5 @@ export const useAuthStore = defineStore('auth', () => {
|
||||||
clearToken()
|
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