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
|
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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,62 +5,113 @@ 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
|
try {
|
||||||
const resetUrl = new URL('/auth/reset-password', url.origin);
|
const { error } = await locals.supabase.auth.verifyOtp({
|
||||||
if (token) resetUrl.searchParams.set('token', token);
|
token_hash,
|
||||||
if (type) resetUrl.searchParams.set('type', type);
|
type: 'recovery'
|
||||||
throw redirect(303, resetUrl.toString());
|
});
|
||||||
}
|
|
||||||
|
|
||||||
if (type === 'invite' || type === 'inv') {
|
if (error) {
|
||||||
// Member invitation - redirect to set password page
|
console.error('Recovery token verification error:', error);
|
||||||
const resetUrl = new URL('/auth/reset-password', url.origin);
|
throw redirect(303, `/forgot-password?error=${encodeURIComponent(error.message || 'Invalid or expired reset link. Please request a new one.')}`);
|
||||||
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.')}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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')}`);
|
throw redirect(303, `/login?error=${encodeURIComponent('Invalid verification link')}`);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue