monacousa-portal/src/hooks.server.ts

195 lines
5.4 KiB
TypeScript
Raw Normal View History

import pkg from '@supabase/ssr';
const { createServerClient } = pkg;
import { type Handle, redirect } from '@sveltejs/kit';
import { sequence } from '@sveltejs/kit/hooks';
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
import { env } from '$env/dynamic/private';
import type { Database } from '$lib/types/database';
import { supabaseAdmin } from '$lib/server/supabase';
// Cache for setup check to avoid hitting DB on every request
let setupCheckCache: { needsSetup: boolean; checkedAt: number } | null = null;
const SETUP_CACHE_TTL = 60000; // 1 minute cache
// Use internal URL for server-side operations (Docker network), fallback to public URL
const SERVER_SUPABASE_URL = env.SUPABASE_INTERNAL_URL || PUBLIC_SUPABASE_URL;
/**
* Supabase authentication hook
* Sets up the Supabase client with cookie handling for SSR
*/
const supabaseHandle: Handle = async ({ event, resolve }) => {
event.locals.supabase = createServerClient<Database>(
SERVER_SUPABASE_URL,
PUBLIC_SUPABASE_ANON_KEY,
{
cookies: {
getAll: () => event.cookies.getAll(),
setAll: (cookiesToSet) => {
cookiesToSet.forEach(({ name, value, options }) => {
event.cookies.set(name, value, { ...options, path: '/' });
});
}
}
}
);
/**
* Safe session getter that validates the JWT
* Returns session, user, and member data
*/
event.locals.safeGetSession = async () => {
const {
data: { session }
} = await event.locals.supabase.auth.getSession();
if (!session) {
return { session: null, user: null, member: null };
}
// Validate the session by getting the user
const {
data: { user },
error: userError
} = await event.locals.supabase.auth.getUser();
if (userError || !user) {
return { session: null, user: null, member: null };
}
// Fetch member profile with dues status
const { data: member } = await event.locals.supabase
.from('members_with_dues')
.select('*')
.eq('id', user.id)
.single();
return { session, user, member };
};
return resolve(event, {
filterSerializedResponseHeaders(name) {
return name === 'content-range' || name === 'x-supabase-api-version';
}
});
};
/**
* Setup check hook
* Redirects to /setup if no users exist in the system
*/
const setupCheckHandle: Handle = async ({ event, resolve }) => {
const path = event.url.pathname;
// Always allow access to setup page and static assets
if (path === '/setup' || path.startsWith('/api/') || path.startsWith('/auth/')) {
return resolve(event);
}
// Check cache first
const now = Date.now();
if (setupCheckCache && (now - setupCheckCache.checkedAt) < SETUP_CACHE_TTL) {
if (setupCheckCache.needsSetup) {
throw redirect(303, '/setup');
}
return resolve(event);
}
// Check if any users exist
try {
const { count, error } = await supabaseAdmin
.from('members')
.select('*', { count: 'exact', head: true });
if (error) {
console.error('Error checking for existing users:', error);
// On error, continue without redirect (fail open)
return resolve(event);
}
const needsSetup = !count || count === 0;
setupCheckCache = { needsSetup, checkedAt: now };
if (needsSetup) {
throw redirect(303, '/setup');
}
} catch (err) {
// If it's a redirect, rethrow it
if (err && typeof err === 'object' && 'status' in err) {
throw err;
}
console.error('Error in setup check:', err);
}
return resolve(event);
};
/**
* Authorization hook
* Protects routes based on authentication and role requirements
*/
const authorizationHandle: Handle = async ({ event, resolve }) => {
const { session, member } = await event.locals.safeGetSession();
const path = event.url.pathname;
// Setup page is handled by setupCheckHandle
if (path === '/setup') {
return resolve(event);
}
// API routes handle their own authentication
if (path.startsWith('/api/')) {
return resolve(event);
}
// Auth callback routes should always be accessible
if (path.startsWith('/auth/')) {
return resolve(event);
}
// Logout route should always be accessible
if (path === '/logout') {
return resolve(event);
}
// Protected routes - require authentication
const protectedPrefixes = ['/dashboard', '/profile', '/payments', '/documents', '/board', '/admin'];
const isProtectedRoute = protectedPrefixes.some((prefix) => path.startsWith(prefix));
if (isProtectedRoute && !session) {
throw redirect(303, `/login?redirectTo=${encodeURIComponent(path)}`);
}
// Handle authenticated users without a member profile
// This can happen if member record creation failed or was deleted
if (isProtectedRoute && session && !member) {
console.error('Authenticated user has no member profile:', session.user?.id);
// Sign them out and redirect to login with an error
await event.locals.supabase.auth.signOut();
throw redirect(303, '/login?error=no_profile');
}
// Board routes - require board or admin role
if (path.startsWith('/board') && member) {
if (member.role !== 'board' && member.role !== 'admin') {
throw redirect(303, '/dashboard');
}
}
// Admin routes - require admin role
if (path.startsWith('/admin') && member) {
if (member.role !== 'admin') {
throw redirect(303, '/dashboard');
}
}
// Redirect authenticated users away from auth pages
if (session && (path === '/login' || path === '/signup')) {
throw redirect(303, '/dashboard');
}
return resolve(event);
};
export const handle: Handle = sequence(supabaseHandle, setupCheckHandle, authorizationHandle);