From f51967bcbd90b27b149140a672bfec1bfec06650 Mon Sep 17 00:00:00 2001 From: KrishP147 Date: Wed, 23 Sep 2026 22:31:25 -0400 Subject: [PATCH 1/2] fix(signup): detect existing accounts before showing success Add isExistingAccount() helper: confirmed dupes via empty identities[], unconfirmed dupes via created_at older than 5min. Unify duplicate-email copy w/ login+reset-password links; fix success copy to match dashboard redirect. Co-Authored-By: Claude Sonnet 5 --- frontend/src/pages/Register.jsx | 64 ++++++++-- .../src/pages/__tests__/Register.test.jsx | 118 +++++++++++++++++- 2 files changed, 165 insertions(+), 17 deletions(-) diff --git a/frontend/src/pages/Register.jsx b/frontend/src/pages/Register.jsx index 3e5610e..1b8eb6e 100644 --- a/frontend/src/pages/Register.jsx +++ b/frontend/src/pages/Register.jsx @@ -4,12 +4,36 @@ import { Link } from 'react-router-dom'; import { motion as Motion } from 'motion/react'; import { Mail, Lock, User, ArrowRight, AlertCircle, CheckCircle2, Eye, EyeOff } from 'lucide-react'; +// Generous 5 min: a false "already exists" for a genuinely new user is worse +// than missing a duplicate. +export const EXISTING_ACCOUNT_AGE_MS = 300_000; + +export const DUPLICATE_ACCOUNT_MESSAGE = + "An account with this email already exists — log in or reset your password."; + +// Supabase returns identities: [] when the email is already registered (confirmed +// account). For an unconfirmed duplicate, identities come back non-empty but the +// account's created_at is old (Supabase re-signup doesn't reset it). A missing or +// unparseable created_at, or missing identities, is treated as a fresh account. +export function isExistingAccount(user, now = Date.now()) { + if (!user) return false; + if (user.identities?.length === 0) return true; + if (user.created_at) { + const createdAt = Date.parse(user.created_at); + if (!Number.isNaN(createdAt) && now - createdAt > EXISTING_ACCOUNT_AGE_MS) { + return true; + } + } + return false; +} + export default function Register() { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [errorKind, setErrorKind] = useState(null); const [success, setSuccess] = useState(false); const [showPassword, setShowPassword] = useState(false); const [showConfirmPassword, setShowConfirmPassword] = useState(false); @@ -19,6 +43,7 @@ export default function Register() { const oauthError = localStorage.getItem('oauth_login_error'); if (oauthError) { setError(oauthError); + setErrorKind(null); localStorage.removeItem('oauth_login_error'); } }, []); @@ -39,21 +64,25 @@ export default function Register() { if (!email.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) { setError('Please enter a valid email address'); + setErrorKind(null); return; } - + if (!isPasswordValid) { setError('Please meet all password requirements'); + setErrorKind(null); return; } - + if (password !== confirmPassword) { setError('Passwords do not match'); + setErrorKind(null); return; } - + setLoading(true); setError(null); + setErrorKind(null); try { const { data, error } = await supabase.auth.signUp({ @@ -69,14 +98,16 @@ export default function Register() { if (error.message.toLowerCase().includes('already registered') || error.message.toLowerCase().includes('user already exists') || error.message.toLowerCase().includes('email already in use')) { - setError('This email is already in use. Please log in instead or use a different email.'); + setError(DUPLICATE_ACCOUNT_MESSAGE); + setErrorKind('duplicate'); } else { setError(error.message); } - } else if (data?.user?.identities?.length === 0) { - // Supabase returns empty identities array if email already exists - // This happens when the email is already registered (via email or OAuth) - setError('This email is already in use. Please log in instead or use a different email.'); + } else if (isExistingAccount(data?.user)) { + // Confirmed duplicate: Supabase returns an empty identities array. + // Unconfirmed duplicate: identities come back, but created_at is old. + setError(DUPLICATE_ACCOUNT_MESSAGE); + setErrorKind('duplicate'); } else { setSuccess(true); } @@ -91,6 +122,7 @@ export default function Register() { const handleGoogleSignup = async () => { setLoading(true); setError(null); + setErrorKind(null); try { // Store that OAuth flow started from register page @@ -140,7 +172,7 @@ export default function Register() {
  1. Check your inbox (and spam folder)
  2. Click the confirmation link in the email
  3. -
  4. You'll be redirected to login
  5. +
  6. You'll be signed in and taken to your dashboard

@@ -201,10 +233,16 @@ export default function Register() {

{error} - {(error.includes('already in use') || error.includes('already exists') || error.includes('already has an account')) && ( -
- - Go to Login + {errorKind === 'duplicate' && ( +
+ + Log in + + + Reset password
)} diff --git a/frontend/src/pages/__tests__/Register.test.jsx b/frontend/src/pages/__tests__/Register.test.jsx index aff8573..e2ebf26 100644 --- a/frontend/src/pages/__tests__/Register.test.jsx +++ b/frontend/src/pages/__tests__/Register.test.jsx @@ -4,7 +4,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, fireEvent, waitFor, cleanup, act } from '@testing-library/react'; import { BrowserRouter } from 'react-router-dom'; -import Register from '../Register'; +import Register, { isExistingAccount, EXISTING_ACCOUNT_AGE_MS } from '../Register'; // Mock Supabase vi.mock('../../supabaseClient', () => ({ @@ -42,6 +42,7 @@ describe('Register', () => { afterEach(() => { cleanup(); + vi.restoreAllMocks(); }); it('renders registration form', () => { @@ -313,7 +314,7 @@ describe('Register', () => { }); }); - it('detects existing user by empty identities array', async () => { + it('detects existing user by empty identities array (confirmed duplicate)', async () => { const { supabase } = await import('../../supabaseClient'); supabase.auth.signUp.mockResolvedValue({ data: { @@ -339,11 +340,120 @@ describe('Register', () => { }); await waitFor(() => { - expect(screen.getByText(/email is already in use/i)).toBeInTheDocument(); - expect(screen.getByText(/go to login/i)).toBeInTheDocument(); + expect(screen.getByText(/account with this email already exists/i)).toBeInTheDocument(); + const loginLink = screen.getByRole('link', { name: /^log in$/i }); + expect(loginLink).toHaveAttribute('href', `/login?email=${encodeURIComponent('existing@example.com')}`); + const resetLink = screen.getByRole('link', { name: /^reset password$/i }); + expect(resetLink).toHaveAttribute('href', '/forgot-password'); }, { timeout: 3000 }); }); + it('detects unconfirmed duplicate account by old created_at', async () => { + const { supabase } = await import('../../supabaseClient'); + const fixedNow = new Date('2026-09-23T00:00:00.000Z').getTime(); + vi.spyOn(Date, 'now').mockReturnValue(fixedNow); + const oldCreatedAt = new Date(fixedNow - (EXISTING_ACCOUNT_AGE_MS + 60_000)).toISOString(); + + supabase.auth.signUp.mockResolvedValue({ + data: { + user: { + id: '123', + email: 'unconfirmed@example.com', + identities: [{ provider: 'email' }], + created_at: oldCreatedAt + }, + session: null + }, + error: null + }); + + renderRegister(); + + const emailInput = screen.getByPlaceholderText(/you@example.com/i); + const passwordInputs = screen.getAllByPlaceholderText(/password/i); + const passwordInput = passwordInputs.find(input => input.placeholder.includes('Create')); + const confirmInput = passwordInputs.find(input => input.placeholder.includes('Confirm')); + const submitButton = screen.getByRole('button', { name: /create account/i }); + + await act(async () => { + fireEvent.change(emailInput, { target: { value: 'unconfirmed@example.com' } }); + fireEvent.change(passwordInput, { target: { value: 'ValidPass123!' } }); + fireEvent.change(confirmInput, { target: { value: 'ValidPass123!' } }); + fireEvent.click(submitButton); + }); + + await waitFor(() => { + expect(screen.getByText(/account with this email already exists/i)).toBeInTheDocument(); + expect(screen.queryByText(/check your email/i)).not.toBeInTheDocument(); + }, { timeout: 3000 }); + }); + + it('reaches success screen for a fresh signup with created_at = now', async () => { + const { supabase } = await import('../../supabaseClient'); + const fixedNow = new Date('2026-09-23T00:00:00.000Z').getTime(); + vi.spyOn(Date, 'now').mockReturnValue(fixedNow); + + supabase.auth.signUp.mockResolvedValue({ + data: { + user: { + id: '123', + email: 'brandnew@example.com', + identities: [{ provider: 'email' }], + created_at: new Date(fixedNow).toISOString() + }, + session: null + }, + error: null + }); + + renderRegister(); + + const emailInput = screen.getByPlaceholderText(/you@example.com/i); + const passwordInputs = screen.getAllByPlaceholderText(/password/i); + const passwordInput = passwordInputs.find(input => input.placeholder.includes('Create')); + const confirmInput = passwordInputs.find(input => input.placeholder.includes('Confirm')); + const submitButton = screen.getByRole('button', { name: /create account/i }); + + await act(async () => { + fireEvent.change(emailInput, { target: { value: 'brandnew@example.com' } }); + fireEvent.change(passwordInput, { target: { value: 'ValidPass123!' } }); + fireEvent.change(confirmInput, { target: { value: 'ValidPass123!' } }); + fireEvent.click(submitButton); + }); + + await waitFor(() => { + expect(screen.getByText(/check your email/i)).toBeInTheDocument(); + expect(screen.getByText(/signed in and taken to your dashboard/i)).toBeInTheDocument(); + }, { timeout: 3000 }); + }); + + describe('isExistingAccount', () => { + it('returns false for undefined user', () => { + expect(isExistingAccount(undefined)).toBe(false); + }); + + it('returns true when identities is an empty array', () => { + expect(isExistingAccount({ identities: [] })).toBe(true); + }); + + it('returns false when created_at is missing or unparseable', () => { + expect(isExistingAccount({ identities: [{ provider: 'email' }] })).toBe(false); + expect(isExistingAccount({ identities: [{ provider: 'email' }], created_at: 'not-a-date' })).toBe(false); + }); + + it('returns true when created_at is older than the threshold', () => { + const now = 1_000_000_000; + const old = new Date(now - EXISTING_ACCOUNT_AGE_MS - 1).toISOString(); + expect(isExistingAccount({ identities: [{ provider: 'email' }], created_at: old }, now)).toBe(true); + }); + + it('returns false when created_at is within the threshold', () => { + const now = 1_000_000_000; + const recent = new Date(now - 1000).toISOString(); + expect(isExistingAccount({ identities: [{ provider: 'email' }], created_at: recent }, now)).toBe(false); + }); + }); + it('validates email format before submission', async () => { // This test verifies that the email validation logic exists // Browser HTML5 validation typically handles malformed emails, From 92a87bc38d8f500d8421a173e37dddbf9452c3f3 Mon Sep 17 00:00:00 2001 From: KrishP147 Date: Wed, 23 Sep 2026 22:36:44 -0400 Subject: [PATCH 2/2] fix(signup): silence react-refresh lint on Register.jsx helper exports isExistingAccount/consts are non-component exports needed by tests; matches disable pattern already used in FastingContext/GoalsContext. Co-Authored-By: Claude Sonnet 5 --- frontend/src/pages/Register.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/pages/Register.jsx b/frontend/src/pages/Register.jsx index 1b8eb6e..80bcbcd 100644 --- a/frontend/src/pages/Register.jsx +++ b/frontend/src/pages/Register.jsx @@ -4,6 +4,7 @@ import { Link } from 'react-router-dom'; import { motion as Motion } from 'motion/react'; import { Mail, Lock, User, ArrowRight, AlertCircle, CheckCircle2, Eye, EyeOff } from 'lucide-react'; +/* eslint-disable react-refresh/only-export-components -- test-only helper exports */ // Generous 5 min: a false "already exists" for a genuinely new user is worse // than missing a duplicate. export const EXISTING_ACCOUNT_AGE_MS = 300_000;