Frontend: App shell, navigation, routing, and design tokens #32

Merged
marcel merged 19 commits from feat/issue-16-design-system into master 2026-04-02 14:14:17 +02:00
3 changed files with 161 additions and 0 deletions
Showing only changes of commit 7a17873046 - Show all commits

25
frontend/src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,25 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
interface Error {
meldung?: string;
}
interface Locals {
benutzer?: {
id: string;
name: string;
rolle: 'planer' | 'mitglied';
};
haushalt?: {
id: string;
name: string;
};
}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

View File

@@ -0,0 +1,96 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock $env/dynamic/private before importing anything
vi.mock('$env/dynamic/private', () => ({
env: { BACKEND_URL: 'http://localhost:8080' }
}));
// Mock the apiClient
const mockGet = vi.fn();
vi.mock('$lib/server/api', () => ({
apiClient: () => ({ GET: mockGet })
}));
describe('auth guard (hooks.server.ts handle)', () => {
let handle: any;
beforeEach(async () => {
mockGet.mockReset();
const mod = await import('./hooks.server');
handle = mod.handle;
});
function createEvent(pathname: string, cookie?: string) {
const resolve = vi.fn().mockResolvedValue(new Response('ok'));
const event = {
url: new URL(`http://localhost${pathname}`),
cookies: {
get: vi.fn().mockReturnValue(cookie)
},
locals: {} as any,
fetch: vi.fn()
};
return { event, resolve };
}
it('allows public routes without auth', async () => {
const { event, resolve } = createEvent('/login');
await handle({ event, resolve });
expect(resolve).toHaveBeenCalledWith(event);
});
it('redirects unauthenticated requests on protected routes', async () => {
const { event, resolve } = createEvent('/planner');
try {
await handle({ event, resolve });
// If using SvelteKit redirect, it throws
expect.unreachable();
} catch (e: any) {
expect(e.status).toBe(302);
expect(e.location).toBe('/login');
}
});
it('populates event.locals.benutzer on valid session', async () => {
mockGet.mockResolvedValue({
data: {
data: {
id: '123',
displayName: 'Max',
householdId: 'h1',
householdName: 'Familie Müller',
householdRole: 'planer',
email: 'max@example.com',
systemRole: 'user'
}
},
error: undefined
});
const { event, resolve } = createEvent('/planner', 'valid-session');
await handle({ event, resolve });
expect(event.locals.benutzer).toEqual({
id: '123',
name: 'Max',
rolle: 'planer'
});
expect(event.locals.haushalt).toEqual({
id: 'h1',
name: 'Familie Müller'
});
expect(resolve).toHaveBeenCalledWith(event);
});
it('redirects to /login when session validation fails', async () => {
mockGet.mockResolvedValue({ data: undefined, error: { status: 401 } });
const { event, resolve } = createEvent('/planner', 'bad-session');
try {
await handle({ event, resolve });
expect.unreachable();
} catch (e: any) {
expect(e.status).toBe(302);
expect(e.location).toBe('/login');
}
});
});

View File

@@ -0,0 +1,40 @@
import type { Handle } from '@sveltejs/kit';
import { redirect } from '@sveltejs/kit';
import { apiClient } from '$lib/server/api';
const PUBLIC_ROUTES = ['/login', '/register', '/invite'];
function isPublicRoute(pathname: string): boolean {
return PUBLIC_ROUTES.some((route) => pathname === route || pathname.startsWith(route + '/'));
}
export const handle: Handle = async ({ event, resolve }) => {
if (isPublicRoute(event.url.pathname)) {
return resolve(event);
}
const sessionCookie = event.cookies.get('session');
if (!sessionCookie) {
redirect(302, '/login');
}
const api = apiClient(event.fetch);
const { data, error } = await api.GET('/v1/auth/me');
if (error || !data?.data) {
redirect(302, '/login');
}
const user = data.data;
event.locals.benutzer = {
id: user.id!,
name: user.displayName!,
rolle: user.householdRole as 'planer' | 'mitglied'
};
event.locals.haushalt = {
id: user.householdId!,
name: user.householdName!
};
return resolve(event);
};