diff --git a/app/api/team/accept/route.ts b/app/api/team/accept/route.ts index e97016c5..5c4a137a 100644 --- a/app/api/team/accept/route.ts +++ b/app/api/team/accept/route.ts @@ -33,10 +33,9 @@ export async function GET(request: NextRequest) { const expired = new Date(companyInvite.expires_at) < new Date() - const { data: existingUsers } = await serviceClient.auth.admin.listUsers() - const alreadyHasAccount = existingUsers?.users?.some( - (u) => u.email?.toLowerCase() === companyInvite.email.toLowerCase() - ) ?? false + const { data: alreadyHasAccount } = await serviceClient.rpc('check_email_exists', { + email_to_check: companyInvite.email, + }) return NextResponse.json({ data: { diff --git a/app/invite/[token]/page.tsx b/app/invite/[token]/page.tsx index 3f667b74..00304b8c 100644 --- a/app/invite/[token]/page.tsx +++ b/app/invite/[token]/page.tsx @@ -135,7 +135,11 @@ export default function InvitePage() { await supabase.auth.signOut() // Keep the invite cookie alive so the next login/register picks it up. document.cookie = `gnubok-invite-token=${token}; path=/; max-age=3600; samesite=lax${secureCookieFlag}` - router.push('/login') + if (invite?.alreadyHasAccount) { + router.push('/login') + } else { + router.push(`/register?invite=${encodeURIComponent(token)}`) + } } if (isLoading) { @@ -209,8 +213,11 @@ export default function InvitePage() { - ) : invite?.alreadyHasAccount && isLoggedInAsInvitee ? ( + ) : isLoggedInAsInvitee ? ( // Already signed in as the invitee — one-click join. + // Prioritized over alreadyHasAccount to avoid the broken flow + // where a false-negative from the email check would send a + // logged-in user to /register, which middleware bounces to /.
@@ -245,7 +252,7 @@ export default function InvitePage() { )}
- ) : invite?.alreadyHasAccount && isLoggedInAsOther ? ( + ) : isLoggedInAsOther ? ( // Signed in as a different user — ask them to sign out first.
@@ -271,8 +278,7 @@ export default function InvitePage() {
) : invite?.alreadyHasAccount ? ( - // Not signed in yet — current behavior: bounce to /login with - // the invite cookie. + // Not signed in — email has an existing account, bounce to login.
@@ -297,6 +303,7 @@ export default function InvitePage() {
) : invite ? ( + // Not signed in, no existing account — register.
diff --git a/supabase/migrations/20260413140000_check_email_exists_rpc.sql b/supabase/migrations/20260413140000_check_email_exists_rpc.sql new file mode 100644 index 00000000..2dce642f --- /dev/null +++ b/supabase/migrations/20260413140000_check_email_exists_rpc.sql @@ -0,0 +1,19 @@ +-- Efficient email existence check for invite flow. +-- Replaces the previous approach of listing all auth users (which only +-- returned the first page and broke for instances with >50 users). +CREATE OR REPLACE FUNCTION public.check_email_exists(email_to_check text) +RETURNS boolean +LANGUAGE sql +SECURITY DEFINER +SET search_path = '' +AS $$ + SELECT EXISTS ( + SELECT 1 FROM auth.users WHERE lower(email) = lower(email_to_check) + ); +$$; + +-- Only callable by service role — prevents email enumeration via PostgREST. +-- Must revoke from PUBLIC first (PostgreSQL grants EXECUTE to PUBLIC by default), +-- then grant explicitly to service_role. +REVOKE EXECUTE ON FUNCTION public.check_email_exists(text) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.check_email_exists(text) TO service_role;