From ff6dc03a06cb2cc9e9330dee7178a49f73c0a94c Mon Sep 17 00:00:00 2001 From: Justin Reiners Date: Thu, 26 Mar 2026 12:00:30 -0500 Subject: [PATCH] feat: add toast state, server hooks (auth, guards, tracking, security headers) --- src/app.d.ts | 21 +++++++---- src/hooks.server.ts | 80 +++++++++++++++++++++++++++++++++++++++++ src/lib/toast.svelte.ts | 22 ++++++++++++ 3 files changed, 116 insertions(+), 7 deletions(-) create mode 100644 src/hooks.server.ts create mode 100644 src/lib/toast.svelte.ts diff --git a/src/app.d.ts b/src/app.d.ts index da08e6d..7811f18 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -1,12 +1,19 @@ -// See https://svelte.dev/docs/kit/types#app.d.ts -// for information about these interfaces +import type { OrgConfig } from '$lib/server/config'; + declare global { namespace App { - // interface Error {} - // interface Locals {} - // interface PageData {} - // interface PageState {} - // interface Platform {} + interface Locals { + user?: { + id: number; + email: string; + name: string; + role: string; + active: boolean; + mustChangePassword: boolean; + }; + sessionToken?: string; + orgConfig: OrgConfig; + } } } diff --git a/src/hooks.server.ts b/src/hooks.server.ts new file mode 100644 index 0000000..5153feb --- /dev/null +++ b/src/hooks.server.ts @@ -0,0 +1,80 @@ +import type { Handle } from '@sveltejs/kit'; +import { redirect } from '@sveltejs/kit'; +import { validateSession } from '$lib/server/auth'; +import { getOrgConfig } from '$lib/server/config'; +import { trackPageView } from '$lib/server/track'; + +export const handle: Handle = async ({ event, resolve }) => { + // Load org config (cached) + event.locals.orgConfig = await getOrgConfig(); + + // Session validation + const sessionToken = event.cookies.get('session'); + if (sessionToken) { + const result = await validateSession(sessionToken); + if (result) { + event.locals.user = result.user; + event.locals.sessionToken = sessionToken; + } else { + event.cookies.delete('session', { path: '/' }); + } + } + + // Force password change + if (event.locals.user?.mustChangePassword) { + const path = event.url.pathname; + if (path !== '/change-password' && path !== '/logout' && !path.startsWith('/api/')) { + throw redirect(302, '/change-password'); + } + } + + // Redirect to setup wizard if not complete + if (event.locals.user && !event.locals.orgConfig.setupComplete) { + const path = event.url.pathname; + if ( + path.startsWith('/admin') && + path !== '/admin' && + !path.startsWith('/setup') && + !path.startsWith('/api/') && + path !== '/logout' + ) { + throw redirect(302, '/setup'); + } + } + + // Auth guards + if (event.url.pathname.startsWith('/admin')) { + if (!event.locals.user) { + throw redirect(302, '/login'); + } + } + + if (event.url.pathname.startsWith('/foster')) { + if (!event.locals.user) { + throw redirect(302, '/login'); + } + } + + // Page view tracking (async, don't await) + const path = event.url.pathname; + if (!path.startsWith('/admin') && !path.startsWith('/api/') && !path.startsWith('/foster')) { + const ipAddress = event.request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() + || event.getClientAddress(); + trackPageView({ + path, + ipAddress, + userAgent: event.request.headers.get('user-agent') || undefined, + referrer: event.request.headers.get('referer') || undefined + }); + } + + // Resolve with security headers + const response = await resolve(event); + + response.headers.set('X-Frame-Options', 'SAMEORIGIN'); + response.headers.set('X-Content-Type-Options', 'nosniff'); + response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin'); + response.headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()'); + + return response; +}; diff --git a/src/lib/toast.svelte.ts b/src/lib/toast.svelte.ts new file mode 100644 index 0000000..f5a60df --- /dev/null +++ b/src/lib/toast.svelte.ts @@ -0,0 +1,22 @@ +interface Toast { + id: number; + message: string; + type: 'success' | 'error' | 'info'; +} + +let toasts = $state([]); +let nextId = 0; + +export function addToast(message: string, type: Toast['type'] = 'info'): void { + const id = nextId++; + toasts.push({ id, message, type }); + setTimeout(() => removeToast(id), 5000); +} + +export function removeToast(id: number): void { + toasts = toasts.filter((t) => t.id !== id); +} + +export function getToasts(): Toast[] { + return toasts; +}