Compare commits

...

12 Commits

Author SHA1 Message Date
682580e11d feat(nav): add hover state on inactive tablet and desktop nav items
Applies hover:bg-[var(--color-subtle)] to inactive nav links for
visual feedback on pointer devices.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 14:04:50 +02:00
5c066d33ef feat(nav): add emoji icons to all nav components
Renders emoji icons in MobileTabBar (stacked above label),
TabletNavBar (inline), and DesktopSidebar (16px, 20px column).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 14:03:53 +02:00
4bd020fa16 test(nav): add parameterized active-state tests for all routes
Proves active state logic generalizes beyond /planner by testing
all 4 mobile nav routes with writable page store.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 14:01:26 +02:00
bd8e901685 fix(nav): use segment-boundary route matching to prevent false positives
Extracts isActiveRoute() into shared nav module. Matches exact path
or path + '/' prefix, preventing /settings from matching /settings-advanced.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 14:00:18 +02:00
aeaca76534 fix(auth): handle users without household — fallback to 'Kein Haushalt'
Removes non-null assertions on householdId/householdName. Users who
haven't joined a household get a fallback name in the sidebar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 13:58:37 +02:00
32550377aa fix(auth): read JSESSIONID cookie to match Spring Security default
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 13:57:34 +02:00
92c7d8f92e feat(auth): preserve redirect URL when redirecting to /login
Appends ?redirect= with the original pathname so the login page
can redirect back after successful authentication.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 13:56:49 +02:00
cc74c0042a test(auth): add isPublicRoute boundary tests for sub-paths and trailing slash
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 13:55:48 +02:00
2bdb1010f8 fix(auth): bypass auth guard for static assets and favicon
Prevents redirect loop when backend is down — login page CSS/JS
would otherwise be redirected to /login.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 13:55:03 +02:00
d7f317587e refactor(public): add lang="ts" to public layout for consistency
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 13:53:56 +02:00
05bf66de56 refactor(test): replace require() with import() in $app/stores mocks
CJS require() is fragile in an ESM project. Use async import() instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 13:53:20 +02:00
db4b01ca77 refactor(config): document resolve.conditions safety for SSR builds
Verified: SvelteKit's plugin overrides resolve.conditions for SSR
builds. The global 'browser' condition only affects vitest and dev.
Build output confirmed correct with npm run build.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 13:52:23 +02:00
15 changed files with 173 additions and 41 deletions

View File

@@ -12,7 +12,7 @@ declare global {
rolle: 'planer' | 'mitglied'; rolle: 'planer' | 'mitglied';
}; };
haushalt?: { haushalt?: {
id: string; id: string | undefined;
name: string; name: string;
}; };
} }

View File

@@ -25,7 +25,10 @@ describe('auth guard (hooks.server.ts handle)', () => {
const event = { const event = {
url: new URL(`http://localhost${pathname}`), url: new URL(`http://localhost${pathname}`),
cookies: { cookies: {
get: vi.fn().mockReturnValue(cookie) get: vi.fn().mockImplementation((name: string) => {
if (name === 'JSESSIONID') return cookie;
return undefined;
})
}, },
locals: {} as any, locals: {} as any,
fetch: vi.fn() fetch: vi.fn()
@@ -39,15 +42,32 @@ describe('auth guard (hooks.server.ts handle)', () => {
expect(resolve).toHaveBeenCalledWith(event); expect(resolve).toHaveBeenCalledWith(event);
}); });
it('redirects unauthenticated requests on protected routes', async () => { it.each(['/login', '/login/', '/register', '/invite/abc123'])(
const { event, resolve } = createEvent('/planner'); 'allows public route %s without auth',
async (path) => {
const { event, resolve } = createEvent(path);
await handle({ event, resolve });
expect(resolve).toHaveBeenCalledWith(event);
}
);
it.each(['/_app/immutable/chunks/app.js', '/favicon.ico'])(
'allows static asset %s without auth',
async (path) => {
const { event, resolve } = createEvent(path);
await handle({ event, resolve });
expect(resolve).toHaveBeenCalledWith(event);
}
);
it('redirects unauthenticated requests to /login with redirect param', async () => {
const { event, resolve } = createEvent('/recipes/abc');
try { try {
await handle({ event, resolve }); await handle({ event, resolve });
// If using SvelteKit redirect, it throws
expect.unreachable(); expect.unreachable();
} catch (e: any) { } catch (e: any) {
expect(e.status).toBe(302); expect(e.status).toBe(302);
expect(e.location).toBe('/login'); expect(e.location).toBe('/login?redirect=%2Frecipes%2Fabc');
} }
}); });
@@ -81,7 +101,37 @@ describe('auth guard (hooks.server.ts handle)', () => {
expect(resolve).toHaveBeenCalledWith(event); expect(resolve).toHaveBeenCalledWith(event);
}); });
it('redirects to /login when session validation fails', async () => { it('handles user without household gracefully', async () => {
mockGet.mockResolvedValue({
data: {
data: {
id: '456',
displayName: 'Neu',
householdId: null,
householdName: null,
householdRole: null,
email: 'neu@example.com',
systemRole: 'user'
}
},
error: undefined
});
const { event, resolve } = createEvent('/planner', 'valid-session');
await handle({ event, resolve });
expect(event.locals.benutzer).toEqual({
id: '456',
name: 'Neu',
rolle: 'mitglied'
});
expect(event.locals.haushalt).toEqual({
id: undefined,
name: 'Kein Haushalt'
});
expect(resolve).toHaveBeenCalledWith(event);
});
it('redirects to /login with redirect param when session validation fails', async () => {
mockGet.mockResolvedValue({ data: undefined, error: { status: 401 } }); mockGet.mockResolvedValue({ data: undefined, error: { status: 401 } });
const { event, resolve } = createEvent('/planner', 'bad-session'); const { event, resolve } = createEvent('/planner', 'bad-session');
@@ -90,7 +140,7 @@ describe('auth guard (hooks.server.ts handle)', () => {
expect.unreachable(); expect.unreachable();
} catch (e: any) { } catch (e: any) {
expect(e.status).toBe(302); expect(e.status).toBe(302);
expect(e.location).toBe('/login'); expect(e.location).toBe('/login?redirect=%2Fplanner');
} }
}); });
}); });

View File

@@ -4,36 +4,46 @@ import { apiClient } from '$lib/server/api';
const PUBLIC_ROUTES = ['/login', '/register', '/invite']; const PUBLIC_ROUTES = ['/login', '/register', '/invite'];
const STATIC_PREFIXES = ['/_app/', '/favicon'];
function isPublicRoute(pathname: string): boolean { function isPublicRoute(pathname: string): boolean {
if (STATIC_PREFIXES.some((prefix) => pathname.startsWith(prefix))) {
return true;
}
return PUBLIC_ROUTES.some((route) => pathname === route || pathname.startsWith(route + '/')); return PUBLIC_ROUTES.some((route) => pathname === route || pathname.startsWith(route + '/'));
} }
function loginRedirect(pathname: string): never {
const target = '/login?redirect=' + encodeURIComponent(pathname);
redirect(302, target);
}
export const handle: Handle = async ({ event, resolve }) => { export const handle: Handle = async ({ event, resolve }) => {
if (isPublicRoute(event.url.pathname)) { if (isPublicRoute(event.url.pathname)) {
return resolve(event); return resolve(event);
} }
const sessionCookie = event.cookies.get('session'); const sessionCookie = event.cookies.get('JSESSIONID');
if (!sessionCookie) { if (!sessionCookie) {
redirect(302, '/login'); loginRedirect(event.url.pathname);
} }
const api = apiClient(event.fetch); const api = apiClient(event.fetch);
const { data, error } = await api.GET('/v1/auth/me'); const { data, error } = await api.GET('/v1/auth/me');
if (error || !data?.data) { if (error || !data?.data) {
redirect(302, '/login'); loginRedirect(event.url.pathname);
} }
const user = data.data; const user = data.data;
event.locals.benutzer = { event.locals.benutzer = {
id: user.id!, id: user.id!,
name: user.displayName!, name: user.displayName!,
rolle: user.householdRole as 'planer' | 'mitglied' rolle: (user.householdRole as 'planer' | 'mitglied') ?? 'mitglied'
}; };
event.locals.haushalt = { event.locals.haushalt = {
id: user.householdId!, id: user.householdId ?? undefined,
name: user.householdName! name: user.householdName ?? 'Kein Haushalt'
}; };
return resolve(event); return resolve(event);

View File

@@ -2,8 +2,8 @@ import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte'; import { render, screen } from '@testing-library/svelte';
import AppShell from './AppShell.svelte'; import AppShell from './AppShell.svelte';
vi.mock('$app/stores', () => { vi.mock('$app/stores', async () => {
const { readable } = require('svelte/store'); const { readable } = await import('svelte/store');
return { return {
page: readable({ url: new URL('http://localhost/planner') }) page: readable({ url: new URL('http://localhost/planner') })
}; };

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores'; import { page } from '$app/stores';
import { desktopNavSections } from './nav'; import { desktopNavSections, isActiveRoute } from './nav';
let { appName, householdName }: { appName: string; householdName: string } = $props(); let { appName, householdName }: { appName: string; householdName: string } = $props();
</script> </script>
@@ -24,14 +24,15 @@
{section.title} {section.title}
</p> </p>
{#each section.items as item (item.href)} {#each section.items as item (item.href)}
{@const active = $page.url.pathname.startsWith(item.href)} {@const active = isActiveRoute(item.href, $page.url.pathname)}
<a <a
href={item.href} href={item.href}
aria-current={active ? 'page' : undefined} aria-current={active ? 'page' : undefined}
class="px-3 py-[7px] text-[13px] font-[var(--font-sans)] rounded-[var(--radius-md)] flex items-center gap-2 {active class="px-3 py-[7px] text-[13px] font-[var(--font-sans)] rounded-[var(--radius-md)] flex items-center gap-2 {active
? 'bg-[var(--green-tint)] text-[var(--green-dark)] font-medium' ? 'bg-[var(--green-tint)] text-[var(--green-dark)] font-medium'
: ''}" : 'hover:bg-[var(--color-subtle)]'}"
> >
<span class="w-[20px] text-[16px] text-center">{item.icon}</span>
{item.label} {item.label}
</a> </a>
{/each} {/each}

View File

@@ -2,8 +2,8 @@ import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte'; import { render, screen } from '@testing-library/svelte';
import DesktopSidebar from './DesktopSidebar.svelte'; import DesktopSidebar from './DesktopSidebar.svelte';
vi.mock('$app/stores', () => { vi.mock('$app/stores', async () => {
const { readable } = require('svelte/store'); const { readable } = await import('svelte/store');
return { return {
page: readable({ url: new URL('http://localhost/planner') }) page: readable({ url: new URL('http://localhost/planner') })
}; };

View File

@@ -0,0 +1,37 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
const { pageStore } = vi.hoisted(() => {
const { writable } = require('svelte/store');
const pageStore = writable({ url: new URL('http://localhost/planner') });
return { pageStore };
});
vi.mock('$app/stores', () => ({
page: pageStore
}));
import MobileTabBar from './MobileTabBar.svelte';
describe('MobileTabBar active state per route', () => {
it.each([
['/planner', 'Planer'],
['/recipes', 'Rezepte'],
['/shopping', 'Einkauf'],
['/settings', 'Einstellungen']
])('on %s, %s is active and others are not', (route, expectedActiveLabel) => {
pageStore.set({ url: new URL(`http://localhost${route}`) });
const { unmount } = render(MobileTabBar);
const activeLink = screen.getByRole('link', { name: new RegExp(expectedActiveLabel) });
expect(activeLink).toHaveAttribute('aria-current', 'page');
const allLinks = screen.getAllByRole('link');
const inactiveLinks = allLinks.filter((link) => !link.textContent?.includes(expectedActiveLabel));
for (const link of inactiveLinks) {
expect(link).not.toHaveAttribute('aria-current');
}
unmount();
});
});

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores'; import { page } from '$app/stores';
import { mobileNavItems } from './nav'; import { mobileNavItems, isActiveRoute } from './nav';
</script> </script>
<nav <nav
@@ -8,7 +8,7 @@
class="fixed bottom-0 w-full flex justify-around bg-white border-t pb-[env(safe-area-inset-bottom,20px)] md:hidden" class="fixed bottom-0 w-full flex justify-around bg-white border-t pb-[env(safe-area-inset-bottom,20px)] md:hidden"
> >
{#each mobileNavItems as item (item.href)} {#each mobileNavItems as item (item.href)}
{@const active = $page.url.pathname.startsWith(item.href)} {@const active = isActiveRoute(item.href, $page.url.pathname)}
<a <a
href={item.href} href={item.href}
aria-current={active ? 'page' : undefined} aria-current={active ? 'page' : undefined}
@@ -16,6 +16,7 @@
? 'bg-[var(--green-tint)] text-[var(--green-dark)] font-medium' ? 'bg-[var(--green-tint)] text-[var(--green-dark)] font-medium'
: ''}" : ''}"
> >
<span class="text-[16px]">{item.icon}</span>
{item.label} {item.label}
</a> </a>
{/each} {/each}

View File

@@ -2,8 +2,8 @@ import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte'; import { render, screen } from '@testing-library/svelte';
import MobileTabBar from './MobileTabBar.svelte'; import MobileTabBar from './MobileTabBar.svelte';
vi.mock('$app/stores', () => { vi.mock('$app/stores', async () => {
const { readable } = require('svelte/store'); const { readable } = await import('svelte/store');
return { return {
page: readable({ url: new URL('http://localhost/planner') }) page: readable({ url: new URL('http://localhost/planner') })
}; };
@@ -39,6 +39,14 @@ describe('MobileTabBar', () => {
expect(plannerLink).toHaveAttribute('aria-current', 'page'); expect(plannerLink).toHaveAttribute('aria-current', 'page');
}); });
it('renders icons for each nav item', () => {
render(MobileTabBar);
expect(screen.getByText('📅')).toBeInTheDocument();
expect(screen.getByText('📖')).toBeInTheDocument();
expect(screen.getByText('🛒')).toBeInTheDocument();
expect(screen.getByText('⚙️')).toBeInTheDocument();
});
it('non-active items do not have aria-current', () => { it('non-active items do not have aria-current', () => {
render(MobileTabBar); render(MobileTabBar);
const recipesLink = screen.getByRole('link', { name: /rezepte/i }); const recipesLink = screen.getByRole('link', { name: /rezepte/i });

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores'; import { page } from '$app/stores';
import { mobileNavItems } from './nav'; import { mobileNavItems, isActiveRoute } from './nav';
</script> </script>
<nav <nav
@@ -8,14 +8,15 @@
class="hidden md:flex lg:hidden gap-2 items-center p-2" class="hidden md:flex lg:hidden gap-2 items-center p-2"
> >
{#each mobileNavItems as item (item.href)} {#each mobileNavItems as item (item.href)}
{@const active = $page.url.pathname.startsWith(item.href)} {@const active = isActiveRoute(item.href, $page.url.pathname)}
<a <a
href={item.href} href={item.href}
aria-current={active ? 'page' : undefined} aria-current={active ? 'page' : undefined}
class="px-4 py-2 rounded-[var(--radius-md)] text-[13px] font-[var(--font-sans)] {active class="px-4 py-2 rounded-[var(--radius-md)] text-[13px] font-[var(--font-sans)] {active
? 'bg-[var(--green-tint)] text-[var(--green-dark)] font-medium' ? 'bg-[var(--green-tint)] text-[var(--green-dark)] font-medium'
: ''}" : 'hover:bg-[var(--color-subtle)]'}"
> >
<span>{item.icon}</span>
{item.label} {item.label}
</a> </a>
{/each} {/each}

View File

@@ -2,8 +2,8 @@ import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte'; import { render, screen } from '@testing-library/svelte';
import TabletNavBar from './TabletNavBar.svelte'; import TabletNavBar from './TabletNavBar.svelte';
vi.mock('$app/stores', () => { vi.mock('$app/stores', async () => {
const { readable } = require('svelte/store'); const { readable } = await import('svelte/store');
return { return {
page: readable({ url: new URL('http://localhost/planner') }) page: readable({ url: new URL('http://localhost/planner') })
}; };

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { mobileNavItems, desktopNavSections } from './nav'; import { mobileNavItems, desktopNavSections, isActiveRoute } from './nav';
describe('nav config', () => { describe('nav config', () => {
describe('mobileNavItems', () => { describe('mobileNavItems', () => {
@@ -39,4 +39,22 @@ describe('nav config', () => {
expect(labels).toEqual(['Mitglieder', 'Einstellungen']); expect(labels).toEqual(['Mitglieder', 'Einstellungen']);
}); });
}); });
describe('isActiveRoute', () => {
it('matches exact route', () => {
expect(isActiveRoute('/planner', '/planner')).toBe(true);
});
it('matches sub-route', () => {
expect(isActiveRoute('/planner', '/planner/week')).toBe(true);
});
it('does not match route with similar prefix', () => {
expect(isActiveRoute('/settings', '/settings-advanced')).toBe(false);
});
it('does not match unrelated route', () => {
expect(isActiveRoute('/planner', '/recipes')).toBe(false);
});
});
}); });

View File

@@ -10,26 +10,30 @@ export interface NavSection {
} }
export const mobileNavItems: NavItem[] = [ export const mobileNavItems: NavItem[] = [
{ href: '/planner', label: 'Planer', icon: 'calendar' }, { href: '/planner', label: 'Planer', icon: '📅' },
{ href: '/recipes', label: 'Rezepte', icon: 'book' }, { href: '/recipes', label: 'Rezepte', icon: '📖' },
{ href: '/shopping', label: 'Einkauf', icon: 'shopping-cart' }, { href: '/shopping', label: 'Einkauf', icon: '🛒' },
{ href: '/settings', label: 'Einstellungen', icon: 'settings' } { href: '/settings', label: 'Einstellungen', icon: '⚙️' }
]; ];
export function isActiveRoute(href: string, pathname: string): boolean {
return pathname === href || pathname.startsWith(href + '/');
}
export const desktopNavSections: NavSection[] = [ export const desktopNavSections: NavSection[] = [
{ {
title: 'Plan', title: 'Plan',
items: [ items: [
{ href: '/planner', label: 'Planer', icon: 'calendar' }, { href: '/planner', label: 'Planer', icon: '📅' },
{ href: '/recipes', label: 'Rezepte', icon: 'book' }, { href: '/recipes', label: 'Rezepte', icon: '📖' },
{ href: '/shopping', label: 'Einkauf', icon: 'shopping-cart' } { href: '/shopping', label: 'Einkauf', icon: '🛒' }
] ]
}, },
{ {
title: 'Haushalt', title: 'Haushalt',
items: [ items: [
{ href: '/members', label: 'Mitglieder', icon: 'users' }, { href: '/members', label: 'Mitglieder', icon: '👥' },
{ href: '/settings', label: 'Einstellungen', icon: 'settings' } { href: '/settings', label: 'Einstellungen', icon: '⚙️' }
] ]
} }
]; ];

View File

@@ -1,4 +1,4 @@
<script> <script lang="ts">
let { children } = $props(); let { children } = $props();
</script> </script>

View File

@@ -10,6 +10,8 @@ export default defineConfig({
environment: 'jsdom', environment: 'jsdom',
setupFiles: ['src/test-setup.ts'] setupFiles: ['src/test-setup.ts']
}, },
// Required for vitest: resolves Svelte to client entry (not server).
// SvelteKit's plugin overrides this for SSR builds — verified safe.
resolve: { resolve: {
conditions: ['browser'] conditions: ['browser']
} }