Frontend: App shell, navigation, routing, and design tokens #32
25
frontend/src/app.d.ts
vendored
Normal file
25
frontend/src/app.d.ts
vendored
Normal 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 {};
|
||||
96
frontend/src/hooks.server.test.ts
Normal file
96
frontend/src/hooks.server.test.ts
Normal 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');
|
||||
}
|
||||
});
|
||||
});
|
||||
40
frontend/src/hooks.server.ts
Normal file
40
frontend/src/hooks.server.ts
Normal 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);
|
||||
};
|
||||
Reference in New Issue
Block a user