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
11 changes: 11 additions & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
11 changes: 11 additions & 0 deletions messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
188 changes: 188 additions & 0 deletions src/components/EmailAuthModal.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(11, 43, 35, 0.65)',
backdropFilter: 'blur(6px)',
WebkitBackdropFilter: 'blur(6px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 9999,
padding: 20,
}}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="email-auth-title"
style={{
background: 'var(--surface)',
border: '1px solid var(--ink-12)',
borderRadius: 'var(--radius-modal)',
boxShadow: 'var(--shadow-lg)',
maxWidth: 460,
width: '100%',
padding: 28,
animation: 'hb-rise 200ms var(--ease-out) forwards',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<h3
id="email-auth-title"
style={{
fontFamily: 'var(--font-display)',
fontSize: 'var(--type-h3-sm)',
fontWeight: 700,
color: 'var(--ink)',
margin: 0,
}}
>
{t('modalTitle')}
</h3>
<button
onClick={onClose}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
fontSize: 18,
color: 'var(--ink-40)',
}}
aria-label="Close"
>
</button>
</div>

{submitted ? (
<div style={{ textAlign: 'center', padding: '16px 0' }}>
<p style={{ fontFamily: 'var(--font-body)', fontSize: 'var(--type-body)', color: 'var(--ink)' }}>
{t('linkSentMessage', { email })}
</p>
<Button variant="secondary" size="md" onClick={onClose} style={{ marginTop: 16 }}>
{t('close')}
</Button>
</div>
) : (
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<label
htmlFor="auth-email-input"
style={{
fontFamily: 'var(--font-body)',
fontSize: 'var(--type-small)',
color: 'var(--ink)',
fontWeight: 600,
}}
>
{t('emailInputLabel')}
</label>
<input
id="auth-email-input"
type="email"
value={email}
onChange={(e) => {
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 && (
<SocialAccountConflictWarning
provider={detection.existingProvider}
email={email}
onContinueWithProvider={handleSocialSelect}
onProceedWithEmail={() => setBypassWarning(true)}
/>
)}

<Button
type="submit"
variant="primary"
size="lg"
disabled={showConflict || !email}
style={{ marginTop: 8 }}
>
{t('submitCta')}
</Button>
</form>
)}
</div>
</div>
)
}
54 changes: 54 additions & 0 deletions src/components/SocialAccountConflictWarning.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<SocialAccountConflictWarning
provider="google"
email="alex.doe@gmail.com"
onContinueWithProvider={vi.fn()}
/>,
)

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(
<SocialAccountConflictWarning
provider="google"
email="alex.doe@gmail.com"
onContinueWithProvider={onContinue}
/>,
)

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(
<SocialAccountConflictWarning
provider="apple"
email="user@icloud.com"
onContinueWithProvider={vi.fn()}
onProceedWithEmail={onProceed}
/>,
)

const linkAnywayBtn = screen.getByRole('button', { name: 'Link this email to my account' })
expect(linkAnywayBtn).toBeInTheDocument()

fireEvent.click(linkAnywayBtn)
expect(onProceed).toHaveBeenCalledTimes(1)
})
})
Loading
Loading