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 { describe, it, expect } from 'vitest'
|
||||||
import { mount } from '@vue/test-utils'
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
import App from '@/App.vue'
|
import App from '@/App.vue'
|
||||||
import AppHeader from '@/components/AppHeader.vue'
|
import AppHeader from '@/components/AppHeader.vue'
|
||||||
import AppFooter from '@/components/AppFooter.vue'
|
import AppFooter from '@/components/AppFooter.vue'
|
||||||
|
|
@ -7,11 +8,12 @@ import router from '@/router'
|
||||||
|
|
||||||
describe('App', () => {
|
describe('App', () => {
|
||||||
it('renders AppHeader and AppFooter', async () => {
|
it('renders AppHeader and AppFooter', async () => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
router.push('/')
|
router.push('/')
|
||||||
await router.isReady()
|
await router.isReady()
|
||||||
const wrapper = mount(App, {
|
const wrapper = mount(App, {
|
||||||
global: {
|
global: {
|
||||||
plugins: [router],
|
plugins: [router, createPinia()],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
expect(wrapper.findComponent(AppHeader).exists()).toBe(true)
|
expect(wrapper.findComponent(AppHeader).exists()).toBe(true)
|
||||||
|
|
@ -19,11 +21,12 @@ describe('App', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders RouterView with HomePage content', async () => {
|
it('renders RouterView with HomePage content', async () => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
router.push('/')
|
router.push('/')
|
||||||
await router.isReady()
|
await router.isReady()
|
||||||
const wrapper = mount(App, {
|
const wrapper = mount(App, {
|
||||||
global: {
|
global: {
|
||||||
plugins: [router],
|
plugins: [router, createPinia()],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
expect(wrapper.text()).toContain('Skicka ett brev till en fordonsägare')
|
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 { mount } from '@vue/test-utils'
|
||||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||||
|
import { setActivePinia, createPinia } from 'pinia'
|
||||||
import AppHeader from '@/components/AppHeader.vue'
|
import AppHeader from '@/components/AppHeader.vue'
|
||||||
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
|
|
||||||
function createTestRouter() {
|
function createTestRouter() {
|
||||||
return createRouter({
|
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', () => {
|
describe('AppHeader', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
localStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
it('renders the logo text', () => {
|
it('renders the logo text', () => {
|
||||||
const router = createTestRouter()
|
const router = createTestRouter()
|
||||||
const wrapper = mount(AppHeader, {
|
const wrapper = mount(AppHeader, {
|
||||||
global: { plugins: [router] },
|
global: { plugins: [router, createPinia()] },
|
||||||
})
|
})
|
||||||
expect(wrapper.text()).toContain('BilHälsning')
|
expect(wrapper.text()).toContain('BilHälsning')
|
||||||
})
|
})
|
||||||
|
|
@ -34,34 +48,107 @@ describe('AppHeader', () => {
|
||||||
it('has a link to home', () => {
|
it('has a link to home', () => {
|
||||||
const router = createTestRouter()
|
const router = createTestRouter()
|
||||||
const wrapper = mount(AppHeader, {
|
const wrapper = mount(AppHeader, {
|
||||||
global: { plugins: [router] },
|
global: { plugins: [router, createPinia()] },
|
||||||
})
|
})
|
||||||
const links = wrapper.findAll('a')
|
const links = wrapper.findAll('a')
|
||||||
const homeLink = links.find((a) => a.attributes('href') === '/')
|
const homeLink = links.find((a) => a.attributes('href') === '/')
|
||||||
expect(homeLink).toBeTruthy()
|
expect(homeLink).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('has a link to register', () => {
|
describe('when not authenticated', () => {
|
||||||
const router = createTestRouter()
|
it('shows login link', () => {
|
||||||
const wrapper = mount(AppHeader, {
|
const router = createTestRouter()
|
||||||
global: { plugins: [router] },
|
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', () => {
|
describe('when authenticated', () => {
|
||||||
const router = createTestRouter()
|
function mountAuthenticated() {
|
||||||
const wrapper = mount(AppHeader, {
|
const jwt = makeJwt({ sub: 'test@bilhalsning.se', role: 'user' })
|
||||||
global: { plugins: [router] },
|
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.role).toBe('admin')
|
||||||
expect(store.isAdmin).toBe(true)
|
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 {
|
function makeJwt(payload: Record<string, unknown>): string {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { RouterLink } from 'vue-router'
|
import { RouterLink } from 'vue-router'
|
||||||
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -7,10 +10,20 @@ import { RouterLink } from 'vue-router'
|
||||||
<RouterLink to="/" class="app-header__logo">BilHälsning</RouterLink>
|
<RouterLink to="/" class="app-header__logo">BilHälsning</RouterLink>
|
||||||
<nav class="app-header__nav">
|
<nav class="app-header__nav">
|
||||||
<RouterLink to="/" class="app-header__link">Hem</RouterLink>
|
<RouterLink to="/" class="app-header__link">Hem</RouterLink>
|
||||||
<RouterLink to="/logga-in" class="app-header__link">Logga in</RouterLink>
|
<template v-if="!auth.isAuthenticated">
|
||||||
<RouterLink to="/registrera" class="app-header__link"
|
<RouterLink to="/logga-in" class="app-header__link"
|
||||||
>Registrera</RouterLink
|
>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>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -46,4 +59,22 @@ import { RouterLink } from 'vue-router'
|
||||||
.app-header__link:hover {
|
.app-header__link:hover {
|
||||||
color: #1a202c;
|
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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,11 @@ export const useAuthStore = defineStore('auth', () => {
|
||||||
|
|
||||||
const isAuthenticated = computed(() => token.value !== null)
|
const isAuthenticated = computed(() => token.value !== null)
|
||||||
const isAdmin = computed(() => role.value === 'admin')
|
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 {
|
function extractRole(jwt: string | null): string | null {
|
||||||
if (!jwt) return null
|
if (!jwt) return null
|
||||||
|
|
@ -42,5 +47,5 @@ export const useAuthStore = defineStore('auth', () => {
|
||||||
clearToken()
|
clearToken()
|
||||||
}
|
}
|
||||||
|
|
||||||
return { token, role, isAuthenticated, isAdmin, registerUser, loginUser, logout }
|
return { token, role, email, isAuthenticated, isAdmin, registerUser, loginUser, logout }
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue