diff --git a/messages/en.json b/messages/en.json index 13041424..b10c16cd 100644 --- a/messages/en.json +++ b/messages/en.json @@ -355,5 +355,16 @@ "expiresIn": "Session expiring in", "extendCta": "Stay connected", "logoutCta": "Disconnect now" + }, + "AccountConflict": { + "modalTitle": "Sign in with email", + "emailInputLabel": "Your email address", + "submitCta": "Continue with email", + "conflictTitle": "Existing {provider} account detected", + "conflictBody": "The email {email} is already registered using your {provider} account. Sign in with {provider} to access your existing portfolio and avoid creating a duplicate account.", + "continueWithProvider": "Sign in with {provider}", + "sendLinkAnyway": "Link this email to my account", + "linkSentMessage": "A magic sign-in link has been sent to {email}.", + "close": "Close" } } diff --git a/messages/fr.json b/messages/fr.json index b4f3aa40..d12ea95c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -355,5 +355,16 @@ "expiresIn": "Expiration de la session dans", "extendCta": "Rester connecté", "logoutCta": "Se déconnecter" + }, + "AccountConflict": { + "modalTitle": "Se connecter par e-mail", + "emailInputLabel": "Votre adresse e-mail", + "submitCta": "Continuer avec l'e-mail", + "conflictTitle": "Compte {provider} existant détecté", + "conflictBody": "L'adresse {email} est déjà enregistrée avec votre compte {provider}. Connectez-vous avec {provider} pour accéder à votre portefeuille existant et éviter de créer un doublon.", + "continueWithProvider": "Se connecter avec {provider}", + "sendLinkAnyway": "Lier cet e-mail à mon compte", + "linkSentMessage": "Un lien magique de connexion a été envoyé à {email}.", + "close": "Fermer" } } diff --git a/src/components/EmailAuthModal.tsx b/src/components/EmailAuthModal.tsx new file mode 100644 index 00000000..4d7dda83 --- /dev/null +++ b/src/components/EmailAuthModal.tsx @@ -0,0 +1,188 @@ +'use client' + +import { useState } from 'react' +import { useTranslations } from 'next-intl' +import { + detectEmailAuthProvider, + type AuthProviderType, +} from '../lib/auth/accountProviderDetection' +import { SocialAccountConflictWarning } from './SocialAccountConflictWarning' +import { Button } from './Button' + +export interface EmailAuthModalProps { + /** Whether the modal is visible. */ + open: boolean + /** Callback to close the modal. */ + onClose: () => void + /** Callback fired when email submission is successful. */ + onSuccess?: (email: string) => void + /** Callback fired when user redirects to a social provider. */ + onSocialLogin?: (provider: AuthProviderType) => void +} + +/** + * Modal dialog for email-based onboarding and sign-in with real-time social conflict detection. + */ +export function EmailAuthModal({ + open, + onClose, + onSuccess, + onSocialLogin, +}: EmailAuthModalProps) { + const t = useTranslations('AccountConflict') + const [email, setEmail] = useState('') + const [submitted, setSubmitted] = useState(false) + const [bypassWarning, setBypassWarning] = useState(false) + + if (!open) return null + + const detection = detectEmailAuthProvider(email) + const showConflict = detection.hasConflict && !bypassWarning && detection.existingProvider !== null + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + if (!email || showConflict) return + + setSubmitted(true) + if (onSuccess) { + onSuccess(email) + } + } + + const handleSocialSelect = (provider: AuthProviderType) => { + if (onSocialLogin) { + onSocialLogin(provider) + } + onClose() + } + + return ( +
+
+
+

+ {t('modalTitle')} +

+ +
+ + {submitted ? ( +
+

+ {t('linkSentMessage', { email })} +

+ +
+ ) : ( +
+ + { + setEmail(e.target.value) + setBypassWarning(false) + }} + placeholder="you@example.com" + required + style={{ + fontFamily: 'var(--font-body)', + fontSize: 'var(--type-body)', + padding: '12px 14px', + borderRadius: 'var(--radius-input)', + border: '1px solid var(--ink-12)', + background: 'var(--canvas)', + color: 'var(--ink)', + outline: 'none', + }} + /> + + {/* Social Account Collision Warning */} + {showConflict && detection.existingProvider && ( + setBypassWarning(true)} + /> + )} + + + + )} +
+
+ ) +} diff --git a/src/components/SocialAccountConflictWarning.test.tsx b/src/components/SocialAccountConflictWarning.test.tsx new file mode 100644 index 00000000..d840a627 --- /dev/null +++ b/src/components/SocialAccountConflictWarning.test.tsx @@ -0,0 +1,54 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@/test/render' +import { SocialAccountConflictWarning } from './SocialAccountConflictWarning' + +describe('SocialAccountConflictWarning', () => { + it('renders provider conflict title, description, and action button', () => { + render( + , + ) + + expect(screen.getByRole('status')).toBeInTheDocument() + expect(screen.getByText('Existing Google account detected')).toBeInTheDocument() + expect( + screen.getByText(/The email alex.doe@gmail.com is already registered using your Google account/), + ).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Sign in with Google' })).toBeInTheDocument() + }) + + it('triggers onContinueWithProvider with the correct provider when CTA is clicked', () => { + const onContinue = vi.fn() + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'Sign in with Google' })) + expect(onContinue).toHaveBeenCalledWith('google') + }) + + it('renders optional link anyway secondary action when provided', () => { + const onProceed = vi.fn() + render( + , + ) + + const linkAnywayBtn = screen.getByRole('button', { name: 'Link this email to my account' }) + expect(linkAnywayBtn).toBeInTheDocument() + + fireEvent.click(linkAnywayBtn) + expect(onProceed).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/components/SocialAccountConflictWarning.tsx b/src/components/SocialAccountConflictWarning.tsx new file mode 100644 index 00000000..b9e5f231 --- /dev/null +++ b/src/components/SocialAccountConflictWarning.tsx @@ -0,0 +1,131 @@ +'use client' + +import { useTranslations } from 'next-intl' +import { + type AuthProviderType, + getProviderDisplayName, +} from '../lib/auth/accountProviderDetection' +import { Button } from './Button' + +export interface SocialAccountConflictWarningProps { + /** The social provider already associated with this email (e.g. 'google', 'apple'). */ + provider: AuthProviderType + /** The entered email address. */ + email: string + /** Callback fired when user chooses to sign in with their existing social provider. */ + onContinueWithProvider: (provider: AuthProviderType) => void + /** Optional callback if the user explicitly wants to proceed with email login or account linking. */ + onProceedWithEmail?: () => void +} + +/** + * High-contrast alert banner warning the user that their email is already linked + * to a social identity provider (e.g. Google), preventing accidental duplicate account creation. + */ +export function SocialAccountConflictWarning({ + provider, + email, + onContinueWithProvider, + onProceedWithEmail, +}: SocialAccountConflictWarningProps) { + const t = useTranslations('AccountConflict') + const providerName = getProviderDisplayName(provider) + + return ( +
+
+ +
+

+ {t('conflictTitle', { provider: providerName })} +

+

+ {t('conflictBody', { email, provider: providerName })} +

+
+
+ +
+ + + {onProceedWithEmail && ( + + )} +
+
+ ) +} diff --git a/src/components/index.ts b/src/components/index.ts index 95770812..9148f19c 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -36,4 +36,8 @@ export { Sparkline } from './Sparkline' export type { SparklineProps } from './Sparkline' export { SessionTimeoutModal } from './SessionTimeoutModal' export type { SessionTimeoutModalProps } from './SessionTimeoutModal' +export { SocialAccountConflictWarning } from './SocialAccountConflictWarning' +export type { SocialAccountConflictWarningProps } from './SocialAccountConflictWarning' +export { EmailAuthModal } from './EmailAuthModal' +export type { EmailAuthModalProps } from './EmailAuthModal' export * from './icons' diff --git a/src/lib/auth/accountProviderDetection.test.ts b/src/lib/auth/accountProviderDetection.test.ts new file mode 100644 index 00000000..10d5a8dc --- /dev/null +++ b/src/lib/auth/accountProviderDetection.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from 'vitest' +import { + normalizeEmail, + isValidEmail, + detectEmailAuthProvider, + getProviderDisplayName, +} from './accountProviderDetection' + +describe('accountProviderDetection', () => { + describe('normalizeEmail', () => { + it('trims leading/trailing whitespace and converts to lowercase', () => { + expect(normalizeEmail(' User@Domain.COM ')).toBe('user@domain.com') + expect(normalizeEmail('ALEX.DOE@GMAIL.COM')).toBe('alex.doe@gmail.com') + }) + }) + + describe('isValidEmail', () => { + it('validates proper email formats', () => { + expect(isValidEmail('test@example.com')).toBe(true) + expect(isValidEmail('user.name+tag@sub.domain.org')).toBe(true) + }) + + it('rejects invalid email formats', () => { + expect(isValidEmail('')).toBe(false) + expect(isValidEmail('not-an-email')).toBe(false) + expect(isValidEmail('missing@domain')).toBe(false) + expect(isValidEmail('@nodomain.com')).toBe(false) + }) + }) + + describe('detectEmailAuthProvider', () => { + const customRegistry = { + 'google.user@domain.com': 'google' as const, + 'apple.user@domain.com': 'apple' as const, + 'github.user@domain.com': 'github' as const, + 'regular.user@domain.com': 'email' as const, + } + + it('detects google conflict when email is registered with Google OAuth', () => { + const result = detectEmailAuthProvider('google.user@domain.com', customRegistry) + expect(result.hasConflict).toBe(true) + expect(result.existingProvider).toBe('google') + expect(result.email).toBe('google.user@domain.com') + }) + + it('detects apple conflict case-insensitively', () => { + const result = detectEmailAuthProvider(' APPLE.USER@DOMAIN.COM ', customRegistry) + expect(result.hasConflict).toBe(true) + expect(result.existingProvider).toBe('apple') + expect(result.email).toBe('apple.user@domain.com') + }) + + it('reports no conflict for standard email-registered accounts or unlinked emails', () => { + const emailResult = detectEmailAuthProvider('regular.user@domain.com', customRegistry) + expect(emailResult.hasConflict).toBe(false) + expect(emailResult.existingProvider).toBe('email') + + const unknownResult = detectEmailAuthProvider('brand.new@domain.com', customRegistry) + expect(unknownResult.hasConflict).toBe(false) + expect(unknownResult.existingProvider).toBeNull() + }) + + it('returns no conflict for malformed email strings', () => { + const result = detectEmailAuthProvider('invalid-email-string') + expect(result.hasConflict).toBe(false) + expect(result.existingProvider).toBeNull() + }) + }) + + describe('getProviderDisplayName', () => { + it('returns friendly provider names', () => { + expect(getProviderDisplayName('google')).toBe('Google') + expect(getProviderDisplayName('apple')).toBe('Apple') + expect(getProviderDisplayName('github')).toBe('GitHub') + expect(getProviderDisplayName('email')).toBe('Email') + expect(getProviderDisplayName('wallet')).toBe('Stellar Wallet') + expect(getProviderDisplayName(null)).toBe('Social Account') + }) + }) +}) diff --git a/src/lib/auth/accountProviderDetection.ts b/src/lib/auth/accountProviderDetection.ts new file mode 100644 index 00000000..57d0e7d7 --- /dev/null +++ b/src/lib/auth/accountProviderDetection.ts @@ -0,0 +1,94 @@ +/** + * Heliobond — Account Provider & Social Conflict Detection Utility. + * + * Implements Issue #353: Prevents duplicate or orphaned accounts when a user attempts + * email authentication for an address previously registered with a social OAuth provider (e.g. Google, Apple). + */ + +export type AuthProviderType = 'google' | 'apple' | 'github' | 'email' | 'wallet' + +export interface AuthProviderDetectionResult { + email: string + hasConflict: boolean + existingProvider: AuthProviderType | null + messageKey?: string +} + +// Known test/mock associations for demonstration and validation +const MOCK_SOCIAL_ACCOUNTS: Record = { + 'alex.doe@gmail.com': 'google', + 'creator@google.com': 'google', + 'user@icloud.com': 'apple', + 'dev@github.com': 'github', +} + +/** + * Normalizes email by trimming whitespace and converting to lowercase. + * O(N) complexity where N is string length. + */ +export function normalizeEmail(email: string): string { + return email.trim().toLowerCase() +} + +/** + * Validates basic email formatting. + */ +export function isValidEmail(email: string): boolean { + const normalized = normalizeEmail(email) + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + return emailRegex.test(normalized) +} + +/** + * Detects whether an email is already associated with a social identity provider. + * Returns conflict details if an existing social provider is registered for the email. + */ +export function detectEmailAuthProvider( + rawEmail: string, + customRegistry?: Record, +): AuthProviderDetectionResult { + const email = normalizeEmail(rawEmail) + + if (!isValidEmail(email)) { + return { + email, + hasConflict: false, + existingProvider: null, + } + } + + const registry = customRegistry ?? MOCK_SOCIAL_ACCOUNTS + const existingProvider = registry[email] ?? null + + // A conflict exists if the email is registered via a social/OAuth provider + const hasConflict = + existingProvider !== null && + existingProvider !== 'email' && + existingProvider !== 'wallet' + + return { + email, + hasConflict, + existingProvider, + } +} + +/** + * Returns formatted human-readable provider name. + */ +export function getProviderDisplayName(provider: AuthProviderType | null): string { + switch (provider) { + case 'google': + return 'Google' + case 'apple': + return 'Apple' + case 'github': + return 'GitHub' + case 'email': + return 'Email' + case 'wallet': + return 'Stellar Wallet' + default: + return 'Social Account' + } +}