feat: add toast state, server hooks (auth, guards, tracking, security headers)

This commit is contained in:
2026-03-26 12:00:30 -05:00
parent 9dac6f9f8b
commit ff6dc03a06
3 changed files with 116 additions and 7 deletions
+14 -7
View File
@@ -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;
}
}
}
+80
View File
@@ -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;
};
+22
View File
@@ -0,0 +1,22 @@
interface Toast {
id: number;
message: string;
type: 'success' | 'error' | 'info';
}
let toasts = $state<Toast[]>([]);
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;
}