From 6f2336874913158951d640d3403aabe3f4492c3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20M=C3=B6rling?= Date: Thu, 14 May 2026 13:11:11 +0200 Subject: [PATCH] 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 --- frontend/e2e/header-auth.spec.ts | 91 ++++++++++++++++ frontend/src/__tests__/App.spec.ts | 7 +- frontend/src/__tests__/AppHeader.spec.ts | 129 +++++++++++++++++++---- frontend/src/__tests__/authStore.spec.ts | 31 ++++++ frontend/src/components/AppHeader.vue | 39 ++++++- frontend/src/stores/authStore.ts | 7 +- 6 files changed, 276 insertions(+), 28 deletions(-) create mode 100644 frontend/e2e/header-auth.spec.ts diff --git a/frontend/e2e/header-auth.spec.ts b/frontend/e2e/header-auth.spec.ts new file mode 100644 index 0000000..fe41fed --- /dev/null +++ b/frontend/e2e/header-auth.spec.ts @@ -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 { + const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' })) + const body = btoa(JSON.stringify(payload)) + const signature = 'test-sig' + return `${header}.${body}.${signature}` +} diff --git a/frontend/src/__tests__/App.spec.ts b/frontend/src/__tests__/App.spec.ts index 4d39ba0..eb8cc3f 100644 --- a/frontend/src/__tests__/App.spec.ts +++ b/frontend/src/__tests__/App.spec.ts @@ -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') diff --git a/frontend/src/__tests__/AppHeader.spec.ts b/frontend/src/__tests__/AppHeader.spec.ts index be5616a..48618bd 100644 --- a/frontend/src/__tests__/AppHeader.spec.ts +++ b/frontend/src/__tests__/AppHeader.spec.ts @@ -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 { + 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') }) }) diff --git a/frontend/src/__tests__/authStore.spec.ts b/frontend/src/__tests__/authStore.spec.ts index b54f2b5..a8d152e 100644 --- a/frontend/src/__tests__/authStore.spec.ts +++ b/frontend/src/__tests__/authStore.spec.ts @@ -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 { diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue index ee10553..f5e5286 100644 --- a/frontend/src/components/AppHeader.vue +++ b/frontend/src/components/AppHeader.vue @@ -1,5 +1,8 @@ @@ -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; +} diff --git a/frontend/src/stores/authStore.ts b/frontend/src/stores/authStore.ts index 5a80efb..fe2c5b7 100644 --- a/frontend/src/stores/authStore.ts +++ b/frontend/src/stores/authStore.ts @@ -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 } })