2026-01-25 02:19:49 +01:00
|
|
|
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';
|
2026-01-26 09:36:25 +01:00
|
|
|
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
|
2026-01-25 02:19:49 +01:00
|
|
|
|
|
|
|
|
// 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';
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-26 09:36:25 +01:00
|
|
|
/**
|
|
|
|
|
* 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);
|
2026-01-26 09:55:37 +01:00
|
|
|
// If table doesn't exist or other DB error, assume setup is needed
|
|
|
|
|
// Common errors: relation "members" does not exist, permission denied
|
|
|
|
|
if (error.message?.includes('does not exist') ||
|
|
|
|
|
error.message?.includes('relation') ||
|
|
|
|
|
error.code === '42P01' || // undefined_table
|
|
|
|
|
error.code === 'PGRST204') { // no rows (table might not exist)
|
|
|
|
|
setupCheckCache = { needsSetup: true, checkedAt: now };
|
|
|
|
|
throw redirect(303, '/setup');
|
|
|
|
|
}
|
|
|
|
|
// For other errors, fail open to avoid blocking the app
|
2026-01-26 09:36:25 +01:00
|
|
|
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);
|
2026-01-26 09:55:37 +01:00
|
|
|
// On unexpected errors, redirect to setup as a safe default
|
|
|
|
|
throw redirect(303, '/setup');
|
2026-01-26 09:36:25 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return resolve(event);
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-25 02:19:49 +01:00
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
|
2026-01-26 09:36:25 +01:00
|
|
|
// Setup page is handled by setupCheckHandle
|
|
|
|
|
if (path === '/setup') {
|
|
|
|
|
return resolve(event);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-25 02:19:49 +01:00
|
|
|
// 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);
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-26 09:36:25 +01:00
|
|
|
export const handle: Handle = sequence(supabaseHandle, setupCheckHandle, authorizationHandle);
|