Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 52 additions & 13 deletions frontend/src/pages/Register.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,37 @@ 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;

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);
Expand All @@ -19,6 +44,7 @@ export default function Register() {
const oauthError = localStorage.getItem('oauth_login_error');
if (oauthError) {
setError(oauthError);
setErrorKind(null);
localStorage.removeItem('oauth_login_error');
}
}, []);
Expand All @@ -39,21 +65,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({
Expand All @@ -69,14 +99,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);
}
Expand All @@ -91,6 +123,7 @@ export default function Register() {
const handleGoogleSignup = async () => {
setLoading(true);
setError(null);
setErrorKind(null);

try {
// Store that OAuth flow started from register page
Expand Down Expand Up @@ -140,7 +173,7 @@ export default function Register() {
<ol className="text-white/60 text-sm space-y-2 ml-4 list-decimal">
<li>Check your inbox (and spam folder)</li>
<li>Click the confirmation link in the email</li>
<li>You'll be redirected to login</li>
<li>You'll be signed in and taken to your dashboard</li>
</ol>
</div>
<p className="text-white/50 text-sm mb-6">
Expand Down Expand Up @@ -201,10 +234,16 @@ export default function Register() {
<AlertCircle size={20} className="flex-shrink-0 mt-0.5" />
<div className="flex-1">
<span>{error}</span>
{(error.includes('already in use') || error.includes('already exists') || error.includes('already has an account')) && (
<div className="mt-2">
<Link to="/login" className="text-primary-500 hover:text-primary-400 font-medium underline">
Go to Login
{errorKind === 'duplicate' && (
<div className="mt-2 flex gap-4">
<Link
to={`/login?email=${encodeURIComponent(email)}`}
className="text-primary-500 hover:text-primary-400 font-medium underline"
>
Log in
</Link>
<Link to="/forgot-password" className="text-primary-500 hover:text-primary-400 font-medium underline">
Reset password
</Link>
</div>
)}
Expand Down
118 changes: 114 additions & 4 deletions frontend/src/pages/__tests__/Register.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down Expand Up @@ -42,6 +42,7 @@ describe('Register', () => {

afterEach(() => {
cleanup();
vi.restoreAllMocks();
});

it('renders registration form', () => {
Expand Down Expand Up @@ -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: {
Expand All @@ -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,
Expand Down
Loading