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 })}
+
+
+
+ ) : (
+
+ )}
+
+
+ )
+}
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'
+ }
+}