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();
+ });
+});