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
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:
parent
f9364d2176
commit
439d70c7e4
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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')}`);
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue