From 439d70c7e46e50b734181bc0e7799eae5d47ed5d Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 10 Feb 2026 19:13:44 +0100 Subject: [PATCH] Fix invite flow, dashboard 500, and RLS policy errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix auth verify handler to read token_hash (GoTrue param name) instead of token, and verify OTP server-side before redirecting - Fix reset-password page to handle both token_hash and pre-existing session from verify handler - Fix intermittent dashboard 500 by adding error handling and retry to members_with_dues query in safeGetSession - Fix RLS policies using members.user_id (nonexistent) → members.id for cron_execution_logs and bulk_emails tables Co-Authored-By: Claude Opus 4.6 --- deploy/post-deploy.sql | 4 +- docker/migrate/post-deploy.sql | 4 +- src/hooks.server.ts | 13 +- .../auth/reset-password/+page.server.ts | 37 ++--- src/routes/auth/verify/+server.ts | 143 ++++++++++++------ 5 files changed, 133 insertions(+), 68 deletions(-) diff --git a/deploy/post-deploy.sql b/deploy/post-deploy.sql index 05924f4..d456bcb 100644 --- a/deploy/post-deploy.sql +++ b/deploy/post-deploy.sql @@ -382,7 +382,7 @@ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polrelid = 'public.cron_execution_logs'::regclass AND polname = 'Admins can read cron logs') THEN CREATE POLICY "Admins can read cron logs" ON public.cron_execution_logs FOR SELECT TO authenticated - USING (EXISTS (SELECT 1 FROM public.members WHERE members.user_id = auth.uid() AND members.role = 'admin')); + USING (EXISTS (SELECT 1 FROM public.members WHERE members.id = auth.uid() AND members.role = 'admin')); END IF; IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polrelid = 'public.cron_execution_logs'::regclass AND polname = 'Service role can manage cron logs') THEN CREATE POLICY "Service role can manage cron logs" @@ -417,7 +417,7 @@ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polrelid = 'public.bulk_emails'::regclass AND polname = 'Admins can manage bulk emails') THEN CREATE POLICY "Admins can manage bulk emails" ON public.bulk_emails FOR ALL TO authenticated - USING (EXISTS (SELECT 1 FROM public.members WHERE members.user_id = auth.uid() AND members.role = 'admin')); + USING (EXISTS (SELECT 1 FROM public.members WHERE members.id = auth.uid() AND members.role = 'admin')); END IF; IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polrelid = 'public.bulk_emails'::regclass AND polname = 'Service role full access to bulk emails') THEN CREATE POLICY "Service role full access to bulk emails" diff --git a/docker/migrate/post-deploy.sql b/docker/migrate/post-deploy.sql index 05924f4..d456bcb 100644 --- a/docker/migrate/post-deploy.sql +++ b/docker/migrate/post-deploy.sql @@ -382,7 +382,7 @@ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polrelid = 'public.cron_execution_logs'::regclass AND polname = 'Admins can read cron logs') THEN CREATE POLICY "Admins can read cron logs" ON public.cron_execution_logs FOR SELECT TO authenticated - USING (EXISTS (SELECT 1 FROM public.members WHERE members.user_id = auth.uid() AND members.role = 'admin')); + USING (EXISTS (SELECT 1 FROM public.members WHERE members.id = auth.uid() AND members.role = 'admin')); END IF; IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polrelid = 'public.cron_execution_logs'::regclass AND polname = 'Service role can manage cron logs') THEN CREATE POLICY "Service role can manage cron logs" @@ -417,7 +417,7 @@ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polrelid = 'public.bulk_emails'::regclass AND polname = 'Admins can manage bulk emails') THEN CREATE POLICY "Admins can manage bulk emails" ON public.bulk_emails FOR ALL TO authenticated - USING (EXISTS (SELECT 1 FROM public.members WHERE members.user_id = auth.uid() AND members.role = 'admin')); + USING (EXISTS (SELECT 1 FROM public.members WHERE members.id = auth.uid() AND members.role = 'admin')); END IF; IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polrelid = 'public.bulk_emails'::regclass AND polname = 'Service role full access to bulk emails') THEN CREATE POLICY "Service role full access to bulk emails" diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 3726704..e4189d4 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -67,12 +67,23 @@ const supabaseHandle: Handle = async ({ event, resolve }) => { } // Fetch member profile with dues status - const { data: member } = await event.locals.supabase + const { data: member, error: memberError } = await event.locals.supabase .from('members_with_dues') .select('*') .eq('id', user.id) .single(); + // If query failed with a transient error (not "no rows"), retry once + if (memberError && memberError.code !== 'PGRST116') { + console.warn('Member profile query error, retrying:', memberError.message); + const { data: memberRetry } = await event.locals.supabase + .from('members_with_dues') + .select('*') + .eq('id', user.id) + .single(); + return { session, user, member: memberRetry }; + } + return { session, user, member }; }; diff --git a/src/routes/auth/reset-password/+page.server.ts b/src/routes/auth/reset-password/+page.server.ts index 9c90f85..62fd49b 100644 --- a/src/routes/auth/reset-password/+page.server.ts +++ b/src/routes/auth/reset-password/+page.server.ts @@ -2,8 +2,6 @@ import { fail, redirect } from '@sveltejs/kit'; import type { Actions, PageServerLoad } from './$types'; export const load: PageServerLoad = async ({ url, locals }) => { - // Check for token in URL (from email link via /auth/verify redirect) - const token = url.searchParams.get('token'); const type = url.searchParams.get('type'); const error = url.searchParams.get('error'); @@ -12,20 +10,18 @@ export const load: PageServerLoad = async ({ url, locals }) => { return { error }; } - // If there's a token, we need to verify it to establish a session - if (token) { + // Handle token_hash if passed directly (backwards compat / fallback) + const token_hash = url.searchParams.get('token_hash') || url.searchParams.get('token'); + if (token_hash) { try { - // For recovery/invite tokens, verify the OTP const otpType = type === 'invite' ? 'invite' : 'recovery'; - const { data, error: verifyError } = await locals.supabase.auth.verifyOtp({ - token_hash: token, + token_hash, type: otpType }); if (verifyError) { console.error('Token verification error:', verifyError); - // Token invalid or expired throw redirect( 303, `/forgot-password?error=${encodeURIComponent(verifyError.message || 'Invalid or expired reset link. Please request a new one.')}` @@ -33,32 +29,39 @@ export const load: PageServerLoad = async ({ url, locals }) => { } if (data.session) { - // Session established - user can now reset password return { isInvite: type === 'invite', email: data.user?.email }; } } catch (e) { - // Check if it's a redirect (which is expected) - if (e && typeof e === 'object' && 'status' in e) { - throw e; - } + if (e && typeof e === 'object' && 'status' in e) throw e; console.error('Verification error:', e); throw redirect(303, '/forgot-password?error=expired'); } } - // No token - check if user has an existing session (from successful verification) - const { session } = await locals.safeGetSession(); + // No token - check if user has an existing session (set by /auth/verify) + const { + data: { session } + } = await locals.supabase.auth.getSession(); if (!session) { - // No session and no token - invalid access + throw redirect(303, '/forgot-password?error=expired'); + } + + // Validate session + const { + data: { user } + } = await locals.supabase.auth.getUser(); + + if (!user) { throw redirect(303, '/forgot-password?error=expired'); } return { - email: session.user?.email + isInvite: type === 'invite', + email: user.email }; }; diff --git a/src/routes/auth/verify/+server.ts b/src/routes/auth/verify/+server.ts index 40200cd..c60ab41 100644 --- a/src/routes/auth/verify/+server.ts +++ b/src/routes/auth/verify/+server.ts @@ -5,62 +5,113 @@ import type { RequestHandler } from './$types'; * Auth verify handler for email links from Supabase/GoTrue * This handles invite, recovery, confirmation, and email change tokens * - * Flow: - * 1. User clicks link in email (e.g., password reset) - * 2. Link goes to /auth/verify?token=...&type=recovery&redirect_to=... - * 3. This handler extracts parameters and redirects to the appropriate SvelteKit page + * GoTrue sends links with token_hash (not token) as the query parameter. + * We verify the OTP here to establish a session, then redirect to the appropriate page. */ export const GET: RequestHandler = async ({ url, locals }) => { - const token = url.searchParams.get('token'); + // GoTrue uses 'token_hash' param; support 'token' for backwards compat + const token_hash = url.searchParams.get('token_hash') || url.searchParams.get('token'); const type = url.searchParams.get('type'); - const redirectTo = url.searchParams.get('redirect_to'); + const redirectTo = url.searchParams.get('redirect_to') || url.searchParams.get('next'); - console.log('Auth verify handler:', { token: token?.substring(0, 20) + '...', type, redirectTo }); + console.log('Auth verify handler:', { token_hash: token_hash ? token_hash.substring(0, 20) + '...' : null, type, redirectTo }); - // Handle different verification types + if (!token_hash) { + console.error('Auth verify: no token_hash or token in URL params'); + throw redirect(303, `/login?error=${encodeURIComponent('Invalid verification link. Please request a new one.')}`); + } + + // Handle recovery (password reset) if (type === 'recovery' || type === 'rec') { - // Password reset - redirect to reset password page with token - const resetUrl = new URL('/auth/reset-password', url.origin); - if (token) resetUrl.searchParams.set('token', token); - if (type) resetUrl.searchParams.set('type', type); - throw redirect(303, resetUrl.toString()); - } + try { + const { error } = await locals.supabase.auth.verifyOtp({ + token_hash, + type: 'recovery' + }); - if (type === 'invite' || type === 'inv') { - // Member invitation - redirect to set password page - const resetUrl = new URL('/auth/reset-password', url.origin); - if (token) resetUrl.searchParams.set('token', token); - resetUrl.searchParams.set('type', 'invite'); - throw redirect(303, resetUrl.toString()); - } - - if (type === 'signup' || type === 'confirmation' || type === 'email_change') { - // Email confirmation - try to verify directly then redirect - if (token) { - try { - const { error } = await locals.supabase.auth.verifyOtp({ - token_hash: token, - type: type === 'email_change' ? 'email_change' : 'signup' - }); - - if (error) { - console.error('Email verification error:', error); - throw redirect(303, `/login?error=${encodeURIComponent(error.message)}`); - } - - // Success - redirect to dashboard - throw redirect(303, redirectTo || '/dashboard'); - } catch (e) { - if (e && typeof e === 'object' && 'status' in e) { - // This is a redirect, rethrow it - throw e; - } - console.error('Verification error:', e); - throw redirect(303, `/login?error=${encodeURIComponent('Verification failed. Please try again.')}`); + if (error) { + console.error('Recovery token verification error:', error); + throw redirect(303, `/forgot-password?error=${encodeURIComponent(error.message || 'Invalid or expired reset link. Please request a new one.')}`); } + + // Session established via cookies, redirect to reset password page + throw redirect(303, '/auth/reset-password'); + } catch (e) { + if (e && typeof e === 'object' && 'status' in e) throw e; + console.error('Recovery verification error:', e); + throw redirect(303, '/forgot-password?error=expired'); + } + } + + // Handle invite (new member setting password) + if (type === 'invite' || type === 'inv') { + try { + const { error } = await locals.supabase.auth.verifyOtp({ + token_hash, + type: 'invite' + }); + + if (error) { + console.error('Invite token verification error:', error); + // Invite tokens use 'recovery' type internally in some GoTrue versions + // Try recovery as fallback + const { error: fallbackError } = await locals.supabase.auth.verifyOtp({ + token_hash, + type: 'recovery' + }); + + if (fallbackError) { + console.error('Invite fallback verification error:', fallbackError); + throw redirect(303, `/login?error=${encodeURIComponent('Invalid or expired invitation link. Please contact an administrator.')}`); + } + } + + // Session established, redirect to set password page + throw redirect(303, '/auth/reset-password?type=invite'); + } catch (e) { + if (e && typeof e === 'object' && 'status' in e) throw e; + console.error('Invite verification error:', e); + throw redirect(303, `/login?error=${encodeURIComponent('Verification failed. Please contact an administrator.')}`); + } + } + + // Handle signup/confirmation/email_change + if (type === 'signup' || type === 'confirmation' || type === 'email_change') { + try { + const { error } = await locals.supabase.auth.verifyOtp({ + token_hash, + type: type === 'email_change' ? 'email_change' : 'signup' + }); + + if (error) { + console.error('Email verification error:', error); + throw redirect(303, `/login?error=${encodeURIComponent(error.message)}`); + } + + // Success - redirect to dashboard or specified URL + throw redirect(303, redirectTo || '/dashboard'); + } catch (e) { + if (e && typeof e === 'object' && 'status' in e) throw e; + console.error('Verification error:', e); + throw redirect(303, `/login?error=${encodeURIComponent('Verification failed. Please try again.')}`); + } + } + + // Unknown type - try recovery as default (most common case) + if (token_hash) { + try { + const { error } = await locals.supabase.auth.verifyOtp({ + token_hash, + type: 'recovery' + }); + + if (!error) { + throw redirect(303, '/auth/reset-password'); + } + } catch (e) { + if (e && typeof e === 'object' && 'status' in e) throw e; } } - // Default: redirect to login with error throw redirect(303, `/login?error=${encodeURIComponent('Invalid verification link')}`); };