Fix invite flow, dashboard 500, and RLS policy errors
Build and Push Docker Images / build-portal (push) Successful in 1m49s Details
Build and Push Docker Images / build-infra (docker/db, monacousa-db) (push) Successful in 1m4s Details
Build and Push Docker Images / build-infra (docker/kong, monacousa-kong) (push) Successful in 24s Details
Build and Push Docker Images / build-infra (docker/migrate, monacousa-migrate) (push) Successful in 1m3s Details

- 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 <noreply@anthropic.com>
This commit is contained in:
Matt 2026-02-10 19:13:44 +01:00
parent f9364d2176
commit 439d70c7e4
5 changed files with 133 additions and 68 deletions

View File

@ -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 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" CREATE POLICY "Admins can read cron logs"
ON public.cron_execution_logs FOR SELECT TO authenticated 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; 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 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" 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 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" CREATE POLICY "Admins can manage bulk emails"
ON public.bulk_emails FOR ALL TO authenticated 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; 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 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" CREATE POLICY "Service role full access to bulk emails"

View File

@ -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 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" CREATE POLICY "Admins can read cron logs"
ON public.cron_execution_logs FOR SELECT TO authenticated 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; 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 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" 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 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" CREATE POLICY "Admins can manage bulk emails"
ON public.bulk_emails FOR ALL TO authenticated 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; 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 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" CREATE POLICY "Service role full access to bulk emails"

View File

@ -67,12 +67,23 @@ const supabaseHandle: Handle = async ({ event, resolve }) => {
} }
// Fetch member profile with dues status // 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') .from('members_with_dues')
.select('*') .select('*')
.eq('id', user.id) .eq('id', user.id)
.single(); .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 }; return { session, user, member };
}; };

View File

@ -2,8 +2,6 @@ import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ url, locals }) => { 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 type = url.searchParams.get('type');
const error = url.searchParams.get('error'); const error = url.searchParams.get('error');
@ -12,20 +10,18 @@ export const load: PageServerLoad = async ({ url, locals }) => {
return { error }; return { error };
} }
// If there's a token, we need to verify it to establish a session // Handle token_hash if passed directly (backwards compat / fallback)
if (token) { const token_hash = url.searchParams.get('token_hash') || url.searchParams.get('token');
if (token_hash) {
try { try {
// For recovery/invite tokens, verify the OTP
const otpType = type === 'invite' ? 'invite' : 'recovery'; const otpType = type === 'invite' ? 'invite' : 'recovery';
const { data, error: verifyError } = await locals.supabase.auth.verifyOtp({ const { data, error: verifyError } = await locals.supabase.auth.verifyOtp({
token_hash: token, token_hash,
type: otpType type: otpType
}); });
if (verifyError) { if (verifyError) {
console.error('Token verification error:', verifyError); console.error('Token verification error:', verifyError);
// Token invalid or expired
throw redirect( throw redirect(
303, 303,
`/forgot-password?error=${encodeURIComponent(verifyError.message || 'Invalid or expired reset link. Please request a new one.')}` `/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) { if (data.session) {
// Session established - user can now reset password
return { return {
isInvite: type === 'invite', isInvite: type === 'invite',
email: data.user?.email email: data.user?.email
}; };
} }
} catch (e) { } 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); console.error('Verification error:', e);
throw redirect(303, '/forgot-password?error=expired'); throw redirect(303, '/forgot-password?error=expired');
} }
} }
// No token - check if user has an existing session (from successful verification) // No token - check if user has an existing session (set by /auth/verify)
const { session } = await locals.safeGetSession(); const {
data: { session }
} = await locals.supabase.auth.getSession();
if (!session) { 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'); throw redirect(303, '/forgot-password?error=expired');
} }
return { return {
email: session.user?.email isInvite: type === 'invite',
email: user.email
}; };
}; };

View File

@ -5,41 +5,81 @@ import type { RequestHandler } from './$types';
* Auth verify handler for email links from Supabase/GoTrue * Auth verify handler for email links from Supabase/GoTrue
* This handles invite, recovery, confirmation, and email change tokens * This handles invite, recovery, confirmation, and email change tokens
* *
* Flow: * GoTrue sends links with token_hash (not token) as the query parameter.
* 1. User clicks link in email (e.g., password reset) * We verify the OTP here to establish a session, then redirect to the appropriate page.
* 2. Link goes to /auth/verify?token=...&type=recovery&redirect_to=...
* 3. This handler extracts parameters and redirects to the appropriate SvelteKit page
*/ */
export const GET: RequestHandler = async ({ url, locals }) => { 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 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') { 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());
}
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 { try {
const { error } = await locals.supabase.auth.verifyOtp({ const { error } = await locals.supabase.auth.verifyOtp({
token_hash: token, token_hash,
type: 'recovery'
});
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' type: type === 'email_change' ? 'email_change' : 'signup'
}); });
@ -48,19 +88,30 @@ export const GET: RequestHandler = async ({ url, locals }) => {
throw redirect(303, `/login?error=${encodeURIComponent(error.message)}`); throw redirect(303, `/login?error=${encodeURIComponent(error.message)}`);
} }
// Success - redirect to dashboard // Success - redirect to dashboard or specified URL
throw redirect(303, redirectTo || '/dashboard'); throw redirect(303, redirectTo || '/dashboard');
} catch (e) { } catch (e) {
if (e && typeof e === 'object' && 'status' in e) { if (e && typeof e === 'object' && 'status' in e) throw e;
// This is a redirect, rethrow it
throw e;
}
console.error('Verification error:', e); console.error('Verification error:', e);
throw redirect(303, `/login?error=${encodeURIComponent('Verification failed. Please try again.')}`); 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')}`); throw redirect(303, `/login?error=${encodeURIComponent('Invalid verification link')}`);
}; };