diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index af8da39..df40fa8 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,6 +1,7 @@ import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; import { useState, useEffect } from 'react'; import { supabase } from './supabaseClient'; +import { resolveOAuthAction } from './utils/authHelpers'; import { GoalsProvider } from './contexts/GoalsContext'; import { FastingProvider } from './contexts/FastingContext'; import Landing from './pages/Landing'; @@ -88,27 +89,12 @@ function App() { // Keep loading state active during OAuth checks setCheckingOAuth(true); - // User clicked "Continue with Google" on register page - // Use Supabase's identity metadata to determine if this is truly a new account - // When OAuth creates a new account, the user object is created fresh - // When OAuth logs into existing account, the user object already existed + // User clicked "Continue with Google" on register page. + // Decide new-vs-existing from server timestamps only (see + // authHelpers.js) - no client clock, no user_profile lookup. + const action = resolveOAuthAction({ origin: 'register', user: session.user, hasProfile: null }); - console.log('OAuth signup attempt'); - console.log('User created_at:', session.user.created_at); - console.log('User email confirmed:', session.user.email_confirmed_at); - console.log('User identities:', session.user.identities); - - // Check if user was created very recently (within last 3 seconds) - const createdAt = new Date(session.user.created_at); - const now = new Date(); - const accountAge = now - createdAt; - - console.log('Account age (ms):', accountAge); - console.log('Account age (seconds):', accountAge / 1000); - - // If account is more than 3 seconds old, it's an existing account trying to sign up - if (accountAge > 3000) { - console.log('Blocking OAuth signup - account exists (age > 3s)'); + if (action === 'block_existing') { await supabase.auth.signOut(); localStorage.removeItem('oauth_flow_origin'); localStorage.removeItem('oauth_account_check'); @@ -118,8 +104,7 @@ function App() { return; } - // Account is brand new (< 3 seconds old) - allow signup - console.log('New OAuth signup - allowing (account age < 3s)'); + // New account, or account age unknown - allow signup localStorage.removeItem('oauth_flow_origin'); localStorage.removeItem('oauth_account_check'); setCheckingOAuth(false); @@ -137,30 +122,35 @@ function App() { .eq('user_id', session.user.id) .maybeSingle(); - const hasProfile = profileData !== null && !profileError; - - console.log('OAuth login - Profile exists:', hasProfile); - - if (!hasProfile) { - // No profile found - brand new account created during login attempt - // DO NOT set session - this prevents dashboard from showing - console.log('New account created during login - blocking'); - await supabase.auth.signOut(); + if (profileError) { + // Profile state unknown (query failed) - fail open to + // dashboard rather than treating it as a missing row. localStorage.removeItem('oauth_flow_origin'); localStorage.removeItem('oauth_account_check'); - localStorage.setItem('oauth_login_error', 'No account found with this Google account. Please signup first.'); setCheckingOAuth(false); - window.location.replace('/login'); - return; // Exit without setting session - } else { - // Existing account - allow login - console.log('Existing OAuth user logging in - allowing'); - localStorage.removeItem('oauth_flow_origin'); - localStorage.removeItem('oauth_account_check'); + setSession(session); + return; + } + + const hasProfile = profileData !== null; + const action = resolveOAuthAction({ origin: 'login', user: session.user, hasProfile }); + + localStorage.removeItem('oauth_flow_origin'); + localStorage.removeItem('oauth_account_check'); + + if (action === 'allow_to_profile') { + // No profile row - never sign out over this; let them + // finish setting up their profile instead of locking out. + window.history.replaceState(null, '', '/profile'); setCheckingOAuth(false); setSession(session); return; } + + // Existing account with a profile - allow login + setCheckingOAuth(false); + setSession(session); + return; } catch (err) { console.error('Error checking profile:', err); // On error, default to allowing (fail open for better UX) diff --git a/frontend/src/utils/__tests__/authHelpers.test.js b/frontend/src/utils/__tests__/authHelpers.test.js new file mode 100644 index 0000000..174b15f --- /dev/null +++ b/frontend/src/utils/__tests__/authHelpers.test.js @@ -0,0 +1,133 @@ +import { describe, it, expect } from 'vitest'; +import { isNewOAuthAccount, resolveOAuthAction } from '../authHelpers'; + +describe('isNewOAuthAccount', () => { + it('returns true when created_at and last_sign_in_at are close (2s apart)', () => { + const user = { + created_at: '2024-01-01T00:00:00.000Z', + last_sign_in_at: '2024-01-01T00:00:02.000Z', + }; + expect(isNewOAuthAccount(user)).toBe(true); + }); + + it('returns false when account is months old and last_sign_in_at is fresh', () => { + const user = { + created_at: '2023-01-01T00:00:00.000Z', + last_sign_in_at: '2024-06-01T00:00:00.000Z', + }; + expect(isNewOAuthAccount(user)).toBe(false); + }); + + it('returns null when created_at is missing', () => { + const user = { last_sign_in_at: '2024-01-01T00:00:00.000Z' }; + expect(isNewOAuthAccount(user)).toBeNull(); + }); + + it('returns null when last_sign_in_at is missing', () => { + const user = { created_at: '2024-01-01T00:00:00.000Z' }; + expect(isNewOAuthAccount(user)).toBeNull(); + }); + + it('returns null when created_at is invalid/unparseable', () => { + const user = { + created_at: 'not-a-date', + last_sign_in_at: '2024-01-01T00:00:00.000Z', + }; + expect(isNewOAuthAccount(user)).toBeNull(); + }); + + it('returns null when last_sign_in_at is invalid/unparseable', () => { + const user = { + created_at: '2024-01-01T00:00:00.000Z', + last_sign_in_at: 'not-a-date', + }; + expect(isNewOAuthAccount(user)).toBeNull(); + }); + + it('returns null when user is null', () => { + expect(isNewOAuthAccount(null)).toBeNull(); + }); + + it('returns null when user is undefined', () => { + expect(isNewOAuthAccount(undefined)).toBeNull(); + }); + + it('is true exactly at the threshold boundary (default 30000ms)', () => { + const user = { + created_at: '2024-01-01T00:00:00.000Z', + last_sign_in_at: '2024-01-01T00:00:30.000Z', + }; + expect(isNewOAuthAccount(user)).toBe(true); + }); + + it('is false just past the threshold boundary (default 30000ms)', () => { + const user = { + created_at: '2024-01-01T00:00:00.000Z', + last_sign_in_at: '2024-01-01T00:00:30.001Z', + }; + expect(isNewOAuthAccount(user)).toBe(false); + }); + + it('respects a custom threshold', () => { + const user = { + created_at: '2024-01-01T00:00:00.000Z', + last_sign_in_at: '2024-01-01T00:00:05.000Z', + }; + expect(isNewOAuthAccount(user, 10000)).toBe(true); + expect(isNewOAuthAccount(user, 1000)).toBe(false); + }); + + it('handles last_sign_in_at before created_at (abs diff)', () => { + const user = { + created_at: '2024-01-01T00:00:10.000Z', + last_sign_in_at: '2024-01-01T00:00:00.000Z', + }; + expect(isNewOAuthAccount(user)).toBe(true); + }); +}); + +describe('resolveOAuthAction', () => { + const newUser = { + created_at: '2024-01-01T00:00:00.000Z', + last_sign_in_at: '2024-01-01T00:00:02.000Z', + }; + const existingUser = { + created_at: '2023-01-01T00:00:00.000Z', + last_sign_in_at: '2024-06-01T00:00:00.000Z', + }; + const unknownUser = { created_at: 'bad', last_sign_in_at: 'bad' }; + + const table = [ + // origin, user, hasProfile, expected + ['register', newUser, true, 'allow'], + ['register', newUser, false, 'allow'], + ['register', existingUser, true, 'block_existing'], + ['register', existingUser, false, 'block_existing'], + ['register', unknownUser, true, 'allow'], + ['register', unknownUser, false, 'allow'], + ['register', null, true, 'allow'], + ['register', null, false, 'allow'], + + ['login', newUser, true, 'allow'], + ['login', newUser, false, 'allow_to_profile'], + ['login', existingUser, true, 'allow'], + ['login', existingUser, false, 'allow_to_profile'], + ['login', unknownUser, true, 'allow'], + ['login', unknownUser, false, 'allow_to_profile'], + ['login', null, true, 'allow'], + ['login', null, false, 'allow_to_profile'], + ]; + + it.each(table)( + 'origin=%s user=%o hasProfile=%s -> %s', + (origin, user, hasProfile, expected) => { + expect(resolveOAuthAction({ origin, user, hasProfile })).toBe(expected); + } + ); + + it('falls through to allow for an unrecognized origin', () => { + expect( + resolveOAuthAction({ origin: 'something-else', user: newUser, hasProfile: false }) + ).toBe('allow'); + }); +}); diff --git a/frontend/src/utils/authHelpers.js b/frontend/src/utils/authHelpers.js new file mode 100644 index 0000000..5c4be2e --- /dev/null +++ b/frontend/src/utils/authHelpers.js @@ -0,0 +1,46 @@ +// Pure helpers for deciding OAuth (Google) sign-in/sign-up behavior. +// No client-side clocks (new Date()/Date.now()) - decisions are based only +// on server-provided fields (user.created_at, user.last_sign_in_at) so the +// result can't drift due to network latency or client clock skew. + +/** + * Decide whether a Supabase auth user looks like a brand-new account, based + * only on server-provided timestamps. + * + * @param {{ created_at?: string, last_sign_in_at?: string } | null | undefined} user + * @param {number} thresholdMs + * @returns {boolean|null} true if new, false if existing, null if unknown + */ +export function isNewOAuthAccount(user, thresholdMs = 30000) { + if (!user) return null; + + const createdAt = Date.parse(user.created_at); + const lastSignInAt = Date.parse(user.last_sign_in_at); + + if (Number.isNaN(createdAt) || Number.isNaN(lastSignInAt)) return null; + + const diff = Math.abs(lastSignInAt - createdAt); + return diff <= thresholdMs; +} + +/** + * Decide what action to take for a Google OAuth sign-in, based on where the + * flow originated and server-known account state. + * + * @param {{ origin: 'register'|'login'|string, user: object|null|undefined, hasProfile: boolean }} params + * @returns {'allow'|'block_existing'|'allow_to_profile'} + */ +export function resolveOAuthAction({ origin, user, hasProfile }) { + if (origin === 'register') { + const isNew = isNewOAuthAccount(user); + if (isNew === false) return 'block_existing'; + return 'allow'; + } + + if (origin === 'login') { + if (!hasProfile) return 'allow_to_profile'; + return 'allow'; + } + + return 'allow'; +}