From 401a1f359f1d9d0c8a8f73cac8a1556eb3529d7c Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 20 Mar 2026 23:03:42 +0100 Subject: [PATCH] feat(frontend): replace logout button with user avatar dropdown in nav Show user initials (e.g. MM) in a circular button when name is set, or a fallback person icon. Clicking opens a dropdown with links to /profile and a logout form. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/+layout.svelte | 99 +++++++++++++++++++---- frontend/src/routes/layout.svelte.spec.ts | 86 ++++++++++++++++++++ 2 files changed, 169 insertions(+), 16 deletions(-) create mode 100644 frontend/src/routes/layout.svelte.spec.ts diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 0734b8f5..3dd6aea5 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -6,14 +6,14 @@ import { onMount } from 'svelte'; import { m } from '$lib/paraglide/messages.js'; import { setLocale, getLocale } from '$lib/paraglide/runtime'; -let { children } = $props(); +let { children, data } = $props(); const locales = ['DE', 'EN', 'ES'] as const; const localeMap = { DE: 'de', EN: 'en', ES: 'es' } as const; const activeLocale = $derived(getLocale().toUpperCase()); const isAdmin = $derived( - page.data.user?.groups.some((g: { permissions: string[] }) => g.permissions.includes('ADMIN')) + data?.user?.groups?.some((g: { permissions: string[] }) => g.permissions.includes('ADMIN')) ); // Set after client-side hydration completes. Used by E2E tests to know the @@ -22,6 +22,27 @@ let hydrated = $state(false); onMount(() => { hydrated = true; }); + +let userMenuOpen = $state(false); + +const userInitials = $derived.by(() => { + const first = data?.user?.firstName?.[0]; + const last = data?.user?.lastName?.[0]; + if (first && last) return (first + last).toUpperCase(); + return null; +}); + +function clickOutside(node: HTMLElement) { + const handleClick = (event: MouseEvent) => { + if (node && !node.contains(event.target as Node) && !event.defaultPrevented) { + userMenuOpen = false; + } + }; + document.addEventListener('click', handleClick, true); + return () => { + document.removeEventListener('click', handleClick, true); + }; +}
@@ -103,20 +124,66 @@ onMount(() => { {/each}
-
- -
+ + +
{ if (e.key === 'Escape') userMenuOpen = false; }} + role="none" + > + {#if userInitials} + + {:else} + + {/if} + + {#if userMenuOpen} + + {/if} +
diff --git a/frontend/src/routes/layout.svelte.spec.ts b/frontend/src/routes/layout.svelte.spec.ts new file mode 100644 index 00000000..add63655 --- /dev/null +++ b/frontend/src/routes/layout.svelte.spec.ts @@ -0,0 +1,86 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page, userEvent } from 'vitest/browser'; +import { createRawSnippet } from 'svelte'; + +afterEach(cleanup); + +const emptySnippet = createRawSnippet(() => ({ render: () => '' })); +import Layout from './+layout.svelte'; + +const tick = () => new Promise((r) => setTimeout(r, 0)); + +// Minimal data required by the layout +const makeData = (overrides = {}) => ({ + user: { + id: '1', + username: 'max', + firstName: 'Max', + lastName: 'Müller', + groups: [], + enabled: true, + createdAt: '' + }, + canWrite: true, + ...overrides +}); + +// ─── User avatar ────────────────────────────────────────────────────────────── + +describe('Layout – user avatar button', () => { + it('shows user initials when first and last name are set', async () => { + render(Layout, { data: makeData(), children: emptySnippet }); + await expect.element(page.getByRole('button', { name: /MM/ })).toBeInTheDocument(); + }); + + it('shows fallback icon button when names are not set', async () => { + render(Layout, { + data: makeData({ + user: { id: '1', username: 'x', groups: [], enabled: true, createdAt: '' } + }), + children: emptySnippet + }); + // Button should still exist (with aria-label for accessibility) + await expect.element(page.getByRole('button', { name: /Profil/i })).toBeInTheDocument(); + }); +}); + +// ─── Dropdown ───────────────────────────────────────────────────────────────── + +describe('Layout – user dropdown', () => { + it('dropdown is hidden initially', async () => { + render(Layout, { data: makeData(), children: emptySnippet }); + await tick(); + await expect.element(page.getByRole('link', { name: /Profil/i })).not.toBeInTheDocument(); + }); + + it('opens dropdown on button click', async () => { + render(Layout, { data: makeData(), children: emptySnippet }); + await page.getByRole('button', { name: /MM/ }).click(); + await expect.element(page.getByRole('link', { name: /Profil/i })).toBeInTheDocument(); + }); + + it('profile link points to /profile', async () => { + render(Layout, { data: makeData(), children: emptySnippet }); + await page.getByRole('button', { name: /MM/ }).click(); + await expect + .element(page.getByRole('link', { name: /Profil/i })) + .toHaveAttribute('href', '/profile'); + }); + + it('logout button is in the dropdown', async () => { + render(Layout, { data: makeData(), children: emptySnippet }); + await page.getByRole('button', { name: /MM/ }).click(); + await expect.element(page.getByRole('button', { name: /Abmelden/i })).toBeInTheDocument(); + }); + + it('closes dropdown when Escape is pressed', async () => { + render(Layout, { data: makeData(), children: emptySnippet }); + const btn = page.getByRole('button', { name: /MM/ }); + await btn.click(); + await expect.element(page.getByRole('link', { name: /Profil/i })).toBeInTheDocument(); + await userEvent.keyboard('{Escape}'); + await tick(); + await expect.element(page.getByRole('link', { name: /Profil/i })).not.toBeInTheDocument(); + }); +});