feat: show auth state in header with conditional nav links
Update AppHeader to reflect authentication state. When not authenticated, show Logga in and Registrera links. When authenticated, show the user's email address and a Logga ut button. Uses v-if/v-else with template blocks for clean conditional rendering without wrapper elements. Changes: - authStore: add email computed that extracts sub claim from JWT payload - AppHeader: conditional nav with v-if/v-else (guest vs authenticated) - AppHeader: add email display and logout button with styles - App.spec.ts: add Pinia to test setup (required by AppHeader now) - AppHeader.spec.ts: rewrite with tests for both auth states - authStore.spec.ts: add tests for email extraction and clearing - header-auth.spec.ts: 5 Playwright E2E tests for header auth state
This commit is contained in:
parent
0d7e672bc3
commit
6f23368749
6 changed files with 276 additions and 28 deletions
91
frontend/e2e/header-auth.spec.ts
Normal file
91
frontend/e2e/header-auth.spec.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Header auth state', () => {
|
||||
test('shows login and register links when not authenticated', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/')
|
||||
const header = page.locator('header')
|
||||
await expect(header.getByRole('link', { name: 'Logga in' })).toBeVisible()
|
||||
await expect(
|
||||
header.getByRole('link', { name: 'Registrera' }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('does not show logout button when not authenticated', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/')
|
||||
const header = page.locator('header')
|
||||
await expect(
|
||||
header.getByRole('button', { name: 'Logga ut' }),
|
||||
).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('shows email and logout when authenticated', async ({ page }) => {
|
||||
const jwt = makeJwt({ sub: 'test@bilhalsning.se', role: 'user' })
|
||||
await page.goto('/')
|
||||
await page.evaluate(
|
||||
(token) => localStorage.setItem('auth_token', token),
|
||||
jwt,
|
||||
)
|
||||
await page.goto('/')
|
||||
|
||||
const header = page.locator('header')
|
||||
await expect(header.getByText('test@bilhalsning.se')).toBeVisible()
|
||||
await expect(
|
||||
header.getByRole('button', { name: 'Logga ut' }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('hides login and register links when authenticated', async ({
|
||||
page,
|
||||
}) => {
|
||||
const jwt = makeJwt({ sub: 'test@bilhalsning.se', role: 'user' })
|
||||
await page.goto('/')
|
||||
await page.evaluate(
|
||||
(token) => localStorage.setItem('auth_token', token),
|
||||
jwt,
|
||||
)
|
||||
await page.goto('/')
|
||||
|
||||
const header = page.locator('header')
|
||||
await expect(
|
||||
header.getByRole('link', { name: 'Logga in' }),
|
||||
).not.toBeVisible()
|
||||
await expect(
|
||||
header.getByRole('link', { name: 'Registrera' }),
|
||||
).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('logout restores login and register links', async ({ page }) => {
|
||||
const jwt = makeJwt({ sub: 'test@bilhalsning.se', role: 'user' })
|
||||
await page.goto('/')
|
||||
await page.evaluate(
|
||||
(token) => localStorage.setItem('auth_token', token),
|
||||
jwt,
|
||||
)
|
||||
await page.goto('/')
|
||||
|
||||
const header = page.locator('header')
|
||||
await header.getByRole('button', { name: 'Logga ut' }).click()
|
||||
|
||||
await expect(
|
||||
header.getByRole('link', { name: 'Logga in' }),
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
header.getByRole('link', { name: 'Registrera' }),
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
header.getByRole('button', { name: 'Logga ut' }),
|
||||
).not.toBeVisible()
|
||||
await expect(header.getByText('test@bilhalsning.se')).not.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}`
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import App from '@/App.vue'
|
||||
import AppHeader from '@/components/AppHeader.vue'
|
||||
import AppFooter from '@/components/AppFooter.vue'
|
||||
|
|
@ -7,11 +8,12 @@ import router from '@/router'
|
|||
|
||||
describe('App', () => {
|
||||
it('renders AppHeader and AppFooter', async () => {
|
||||
setActivePinia(createPinia())
|
||||
router.push('/')
|
||||
await router.isReady()
|
||||
const wrapper = mount(App, {
|
||||
global: {
|
||||
plugins: [router],
|
||||
plugins: [router, createPinia()],
|
||||
},
|
||||
})
|
||||
expect(wrapper.findComponent(AppHeader).exists()).toBe(true)
|
||||
|
|
@ -19,11 +21,12 @@ describe('App', () => {
|
|||
})
|
||||
|
||||
it('renders RouterView with HomePage content', async () => {
|
||||
setActivePinia(createPinia())
|
||||
router.push('/')
|
||||
await router.isReady()
|
||||
const wrapper = mount(App, {
|
||||
global: {
|
||||
plugins: [router],
|
||||
plugins: [router, createPinia()],
|
||||
},
|
||||
})
|
||||
expect(wrapper.text()).toContain('Skicka ett brev till en fordonsägare')
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import AppHeader from '@/components/AppHeader.vue'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
function createTestRouter() {
|
||||
return createRouter({
|
||||
|
|
@ -22,11 +24,23 @@ function createTestRouter() {
|
|||
})
|
||||
}
|
||||
|
||||
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}`
|
||||
}
|
||||
|
||||
describe('AppHeader', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('renders the logo text', () => {
|
||||
const router = createTestRouter()
|
||||
const wrapper = mount(AppHeader, {
|
||||
global: { plugins: [router] },
|
||||
global: { plugins: [router, createPinia()] },
|
||||
})
|
||||
expect(wrapper.text()).toContain('BilHälsning')
|
||||
})
|
||||
|
|
@ -34,34 +48,107 @@ describe('AppHeader', () => {
|
|||
it('has a link to home', () => {
|
||||
const router = createTestRouter()
|
||||
const wrapper = mount(AppHeader, {
|
||||
global: { plugins: [router] },
|
||||
global: { plugins: [router, createPinia()] },
|
||||
})
|
||||
const links = wrapper.findAll('a')
|
||||
const homeLink = links.find((a) => a.attributes('href') === '/')
|
||||
expect(homeLink).toBeTruthy()
|
||||
})
|
||||
|
||||
it('has a link to register', () => {
|
||||
const router = createTestRouter()
|
||||
const wrapper = mount(AppHeader, {
|
||||
global: { plugins: [router] },
|
||||
describe('when not authenticated', () => {
|
||||
it('shows login link', () => {
|
||||
const router = createTestRouter()
|
||||
const wrapper = mount(AppHeader, {
|
||||
global: { plugins: [router, createPinia()] },
|
||||
})
|
||||
const links = wrapper.findAll('a')
|
||||
const loginLink = links.find(
|
||||
(a) => a.attributes('href') === '/logga-in',
|
||||
)
|
||||
expect(loginLink).toBeTruthy()
|
||||
expect(loginLink?.text()).toBe('Logga in')
|
||||
})
|
||||
|
||||
it('shows register link', () => {
|
||||
const router = createTestRouter()
|
||||
const wrapper = mount(AppHeader, {
|
||||
global: { plugins: [router, createPinia()] },
|
||||
})
|
||||
const links = wrapper.findAll('a')
|
||||
const registerLink = links.find(
|
||||
(a) => a.attributes('href') === '/registrera',
|
||||
)
|
||||
expect(registerLink).toBeTruthy()
|
||||
expect(registerLink?.text()).toBe('Registrera')
|
||||
})
|
||||
|
||||
it('does not show logout button', () => {
|
||||
const router = createTestRouter()
|
||||
const wrapper = mount(AppHeader, {
|
||||
global: { plugins: [router, createPinia()] },
|
||||
})
|
||||
expect(wrapper.find('button').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('does not show user email', () => {
|
||||
const router = createTestRouter()
|
||||
const wrapper = mount(AppHeader, {
|
||||
global: { plugins: [router, createPinia()] },
|
||||
})
|
||||
expect(wrapper.text()).not.toContain('@bilhalsning.se')
|
||||
})
|
||||
const links = wrapper.findAll('a')
|
||||
const registerLink = links.find(
|
||||
(a) => a.attributes('href') === '/registrera',
|
||||
)
|
||||
expect(registerLink).toBeTruthy()
|
||||
expect(registerLink?.text()).toBe('Registrera')
|
||||
})
|
||||
|
||||
it('has a link to login', () => {
|
||||
const router = createTestRouter()
|
||||
const wrapper = mount(AppHeader, {
|
||||
global: { plugins: [router] },
|
||||
describe('when authenticated', () => {
|
||||
function mountAuthenticated() {
|
||||
const jwt = makeJwt({ sub: 'test@bilhalsning.se', role: 'user' })
|
||||
localStorage.setItem('auth_token', jwt)
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
const router = createTestRouter()
|
||||
return mount(AppHeader, {
|
||||
global: { plugins: [router, pinia] },
|
||||
})
|
||||
}
|
||||
|
||||
it('shows user email', () => {
|
||||
const wrapper = mountAuthenticated()
|
||||
expect(wrapper.text()).toContain('test@bilhalsning.se')
|
||||
})
|
||||
|
||||
it('shows logout button', () => {
|
||||
const wrapper = mountAuthenticated()
|
||||
const logoutButton = wrapper.find('button')
|
||||
expect(logoutButton.exists()).toBe(true)
|
||||
expect(logoutButton.text()).toBe('Logga ut')
|
||||
})
|
||||
|
||||
it('does not show login link', () => {
|
||||
const wrapper = mountAuthenticated()
|
||||
const links = wrapper.findAll('a')
|
||||
const loginLink = links.find(
|
||||
(a) => a.attributes('href') === '/logga-in',
|
||||
)
|
||||
expect(loginLink).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not show register link', () => {
|
||||
const wrapper = mountAuthenticated()
|
||||
const links = wrapper.findAll('a')
|
||||
const registerLink = links.find(
|
||||
(a) => a.attributes('href') === '/registrera',
|
||||
)
|
||||
expect(registerLink).toBeUndefined()
|
||||
})
|
||||
|
||||
it('calls logout when clicking logout button', async () => {
|
||||
const wrapper = mountAuthenticated()
|
||||
const auth = useAuthStore()
|
||||
expect(auth.isAuthenticated).toBe(true)
|
||||
|
||||
await wrapper.find('button').trigger('click')
|
||||
|
||||
expect(auth.isAuthenticated).toBe(false)
|
||||
})
|
||||
const links = wrapper.findAll('a')
|
||||
const loginLink = links.find((a) => a.attributes('href') === '/logga-in')
|
||||
expect(loginLink).toBeTruthy()
|
||||
expect(loginLink?.text()).toBe('Logga in')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -179,6 +179,37 @@ describe('authStore', () => {
|
|||
expect(store.role).toBe('admin')
|
||||
expect(store.isAdmin).toBe(true)
|
||||
})
|
||||
|
||||
it('extracts email from JWT sub claim', async () => {
|
||||
const jwt = makeJwt({ sub: 'test@bilhalsning.se', role: 'user' })
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||
mockFetchResponse(200, { token: jwt }),
|
||||
)
|
||||
const store = useAuthStore()
|
||||
|
||||
await store.loginUser('test@bilhalsning.se', 'test1234')
|
||||
|
||||
expect(store.email).toBe('test@bilhalsning.se')
|
||||
})
|
||||
|
||||
it('returns null email when not authenticated', () => {
|
||||
const store = useAuthStore()
|
||||
expect(store.email).toBeNull()
|
||||
})
|
||||
|
||||
it('clears email on logout', async () => {
|
||||
const jwt = makeJwt({ sub: 'test@bilhalsning.se', role: 'user' })
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||
mockFetchResponse(200, { token: jwt }),
|
||||
)
|
||||
const store = useAuthStore()
|
||||
|
||||
await store.loginUser('test@bilhalsning.se', 'test1234')
|
||||
expect(store.email).toBe('test@bilhalsning.se')
|
||||
|
||||
store.logout()
|
||||
expect(store.email).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
function makeJwt(payload: Record<string, unknown>): string {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
<script setup lang="ts">
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
const auth = useAuthStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -7,10 +10,20 @@ import { RouterLink } from 'vue-router'
|
|||
<RouterLink to="/" class="app-header__logo">BilHälsning</RouterLink>
|
||||
<nav class="app-header__nav">
|
||||
<RouterLink to="/" class="app-header__link">Hem</RouterLink>
|
||||
<RouterLink to="/logga-in" class="app-header__link">Logga in</RouterLink>
|
||||
<RouterLink to="/registrera" class="app-header__link"
|
||||
>Registrera</RouterLink
|
||||
>
|
||||
<template v-if="!auth.isAuthenticated">
|
||||
<RouterLink to="/logga-in" class="app-header__link"
|
||||
>Logga in</RouterLink
|
||||
>
|
||||
<RouterLink to="/registrera" class="app-header__link"
|
||||
>Registrera</RouterLink
|
||||
>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="app-header__email">{{ auth.email }}</span>
|
||||
<button class="app-header__logout" @click="auth.logout()">
|
||||
Logga ut
|
||||
</button>
|
||||
</template>
|
||||
</nav>
|
||||
</header>
|
||||
</template>
|
||||
|
|
@ -46,4 +59,22 @@ import { RouterLink } from 'vue-router'
|
|||
.app-header__link:hover {
|
||||
color: #1a202c;
|
||||
}
|
||||
|
||||
.app-header__email {
|
||||
color: #4a5568;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.app-header__logout {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #4a5568;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.app-header__logout:hover {
|
||||
color: #1a202c;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,11 @@ export const useAuthStore = defineStore('auth', () => {
|
|||
|
||||
const isAuthenticated = computed(() => token.value !== null)
|
||||
const isAdmin = computed(() => role.value === 'admin')
|
||||
const email = computed(() => {
|
||||
if (!token.value) return null
|
||||
const payload = parseJwtPayload(token.value)
|
||||
return payload.sub ?? null
|
||||
})
|
||||
|
||||
function extractRole(jwt: string | null): string | null {
|
||||
if (!jwt) return null
|
||||
|
|
@ -42,5 +47,5 @@ export const useAuthStore = defineStore('auth', () => {
|
|||
clearToken()
|
||||
}
|
||||
|
||||
return { token, role, isAuthenticated, isAdmin, registerUser, loginUser, logout }
|
||||
return { token, role, email, isAuthenticated, isAdmin, registerUser, loginUser, logout }
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue