135 lines
3.8 KiB
TypeScript
135 lines
3.8 KiB
TypeScript
|
|
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';
|
||
|
|
|
||
|
|
// 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';
|
||
|
|
}
|
||
|
|
});
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 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;
|
||
|
|
|
||
|
|
// 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, authorizationHandle);
|