diff --git a/src/client/createSeamlessAuthClient.ts b/src/client/createSeamlessAuthClient.ts index 5b896c3..8f8c55f 100644 --- a/src/client/createSeamlessAuthClient.ts +++ b/src/client/createSeamlessAuthClient.ts @@ -35,6 +35,14 @@ export interface LoginInput { passkeyAvailable: boolean; } +export type LoginMethod = 'passkey' | 'magic_link' | 'email_otp' | 'phone_otp'; + +export interface LoginStartResult { + message?: string; + identifierType?: 'email' | 'phone'; + loginMethods?: LoginMethod[]; +} + export interface RegisterInput { email: string; phone: string; @@ -109,8 +117,12 @@ export interface SeamlessAuthClient { register: (input: RegisterInput) => Promise; requestPhoneOtp: () => Promise; verifyPhoneOtp: (verificationToken: string) => Promise; + requestLoginPhoneOtp: () => Promise; + verifyLoginPhoneOtp: (verificationToken: string) => Promise; requestEmailOtp: () => Promise; verifyEmailOtp: (verificationToken: string) => Promise; + requestLoginEmailOtp: () => Promise; + verifyLoginEmailOtp: (verificationToken: string) => Promise; requestMagicLink: () => Promise; checkMagicLink: () => Promise; verifyMagicLink: (token: string) => Promise; @@ -300,6 +312,23 @@ export const createSeamlessAuthClient = ( credentials: 'include', }), + requestLoginPhoneOtp: () => + fetchWithAuth(`/otp/generate-login-phone-otp`, { + method: 'GET', + }), + + verifyLoginPhoneOtp: verificationToken => + fetchWithAuth(`/otp/verify-login-phone-otp`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + verificationToken, + }), + credentials: 'include', + }), + requestEmailOtp: () => fetchWithAuth(`/otp/generate-email-otp`, { method: 'GET', @@ -316,6 +345,22 @@ export const createSeamlessAuthClient = ( credentials: 'include', }), + requestLoginEmailOtp: () => + fetchWithAuth(`/otp/generate-login-email-otp`, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }), + + verifyLoginEmailOtp: verificationToken => + fetchWithAuth(`/otp/verify-login-email-otp`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + verificationToken, + }), + credentials: 'include', + }), + requestMagicLink: () => fetchWithAuth(`/magic-link`, { method: 'GET', diff --git a/src/components/AuthFallbackOptions.tsx b/src/components/AuthFallbackOptions.tsx index e56f59e..2bb7eb9 100644 --- a/src/components/AuthFallbackOptions.tsx +++ b/src/components/AuthFallbackOptions.tsx @@ -6,28 +6,43 @@ import React from 'react'; import { isValidEmail, isValidPhoneNumber } from '../utils'; +import type { LoginMethod } from '@/client/createSeamlessAuthClient'; import styles from '../styles/login.module.css'; interface AuthFallbackOptionsProps { identifier: string; onMagicLink: () => void; + onEmailOtp?: () => void; onPhoneOtp: () => void; - onPasskeyRetry: () => void; + onPasskeyRetry?: () => void; + loginMethods?: LoginMethod[]; } const AuthFallbackOptions: React.FC = ({ identifier, onMagicLink, + onEmailOtp, onPhoneOtp, onPasskeyRetry, + loginMethods, }) => { - const showMagicLink = isValidEmail(identifier); - const showPhoneOtp = isValidPhoneNumber(identifier); + const allowedMethods = new Set( + loginMethods ?? ['passkey', 'magic_link', 'phone_otp'] + ); + const showMagicLink = allowedMethods.has('magic_link') && isValidEmail(identifier); + const showEmailOtp = + allowedMethods.has('email_otp') && Boolean(onEmailOtp) && isValidEmail(identifier); + const showPhoneOtp = allowedMethods.has('phone_otp') && isValidPhoneNumber(identifier); + const showPasskeyRetry = allowedMethods.has('passkey') && Boolean(onPasskeyRetry); + + if (!showMagicLink && !showEmailOtp && !showPhoneOtp && !showPasskeyRetry) { + return null; + } return (
-
Trouble with passkey login?
+
Choose a sign-in method

Choose another secure sign-in method.

@@ -45,6 +60,19 @@ const AuthFallbackOptions: React.FC = ({ )} + {showEmailOtp && ( + + )} + {showPhoneOtp && (
- + {showPasskeyRetry && ( + + )} ); }; diff --git a/src/index.ts b/src/index.ts index e6806ab..da01401 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,8 @@ import { createSeamlessAuthClient, CurrentUserResult, LoginInput, + LoginMethod, + LoginStartResult, PasskeyLoginResult, PasskeyLoginWithPrfResult, PasskeyMetadata, @@ -52,6 +54,8 @@ export type { Credential, CurrentUserResult, LoginInput, + LoginMethod, + LoginStartResult, PasskeyLoginWithPrfResult, PasskeyLoginResult, PasskeyMetadata, diff --git a/src/views/EmailRegistration.tsx b/src/views/EmailRegistration.tsx index e31ee8c..7c74067 100644 --- a/src/views/EmailRegistration.tsx +++ b/src/views/EmailRegistration.tsx @@ -8,16 +8,19 @@ import { useAuth } from '@/AuthProvider'; import React, { useEffect, useState } from 'react'; import { useAuthClient } from '@/hooks/useAuthClient'; import { usePasskeySupport } from '@/hooks/usePasskeySupport'; -import { useNavigate } from 'react-router-dom'; +import { useLocation, useNavigate } from 'react-router-dom'; import styles from '@/styles/verifyOTP.module.css'; import OtpInput from '@/components/OtpInput'; const EmailRegistration: React.FC = () => { const navigate = useNavigate(); + const location = useLocation(); const { refreshSession } = useAuth(); const authClient = useAuthClient(); const { passkeySupported } = usePasskeySupport(); + const isLoginFlow = + (location.state as { flow?: string } | null)?.flow === 'login'; const [loading, setLoading] = useState(false); const [emailOtp, setEmailOtp] = useState(''); @@ -29,7 +32,9 @@ const EmailRegistration: React.FC = () => { setError(''); setResendMsg(''); - const response = await authClient.requestEmailOtp(); + const response = isLoginFlow + ? await authClient.requestLoginEmailOtp() + : await authClient.requestEmailOtp(); if (!response.ok) { setError( @@ -53,13 +58,21 @@ const EmailRegistration: React.FC = () => { setLoading(true); try { - const response = await authClient.verifyEmailOtp(emailOtp); + const response = isLoginFlow + ? await authClient.verifyLoginEmailOtp(emailOtp) + : await authClient.verifyEmailOtp(emailOtp); if (!response.ok) { setError('Verification failed.'); return; } + if (isLoginFlow) { + await refreshSession(); + navigate('/'); + return; + } + if (passkeySupported) { navigate('/registerPasskey'); } else { diff --git a/src/views/Login.tsx b/src/views/Login.tsx index 5f5fc2a..ddfcc87 100644 --- a/src/views/Login.tsx +++ b/src/views/Login.tsx @@ -13,6 +13,23 @@ import { useNavigate } from 'react-router-dom'; import styles from '@/styles/login.module.css'; import { isValidEmail, isValidPhoneNumber } from '../utils'; import AuthFallbackOptions from '@/components/AuthFallbackOptions'; +import type { LoginMethod, LoginStartResult } from '@/client/createSeamlessAuthClient'; + +const DEFAULT_LOGIN_METHODS: LoginMethod[] = ['passkey', 'magic_link', 'phone_otp']; + +const parseLoginStartResult = async ( + response: Response | null +): Promise => { + if (!response || typeof response.json !== 'function') { + return null; + } + + try { + return (await response.json()) as LoginStartResult; + } catch { + return null; + } +}; const Login: React.FC = () => { const navigate = useNavigate(); @@ -28,6 +45,8 @@ const Login: React.FC = () => { const [emailError, setEmailError] = useState(''); const [identifierError, setIdentifierError] = useState(''); const [showFallbackOptions, setShowFallbackOptions] = useState(false); + const [loginMethods, setLoginMethods] = + useState(DEFAULT_LOGIN_METHODS); const [bootstrapToken, setBootstrapToken] = useState(null); useEffect(() => { @@ -112,20 +131,36 @@ const Login: React.FC = () => { const sendPhoneOtp = async () => { try { - const response = await authClient.requestPhoneOtp(); + const response = await authClient.requestLoginPhoneOtp(); if (!response.ok) { setFormErrors('Failed to send OTP.'); return; } - navigate('/verifyPhoneOTP'); + navigate('/verifyPhoneOTP', { state: { flow: 'login' } }); } catch (err) { console.error(err); setFormErrors('Failed to send OTP.'); } }; + const sendEmailOtp = async () => { + try { + const response = await authClient.requestLoginEmailOtp(); + + if (!response.ok) { + setFormErrors('Failed to send email code.'); + return; + } + + navigate('/verifyEmailOTP', { state: { flow: 'login' } }); + } catch (err) { + console.error(err); + setFormErrors('Failed to send email code.'); + } + }; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setFormErrors(''); @@ -134,8 +169,17 @@ const Login: React.FC = () => { try { if (mode === 'login') { const loginRes = await login(identifier, passkeySupported); - - if (loginRes?.ok && passkeySupported) { + const loginStart = await parseLoginStartResult(loginRes); + const availableMethods = loginStart?.loginMethods?.length + ? loginStart.loginMethods + : DEFAULT_LOGIN_METHODS; + setLoginMethods(availableMethods); + + if ( + loginRes?.ok && + passkeySupported && + availableMethods.includes('passkey') + ) { const passkeyResult = await handlePasskeyLogin(); if (passkeyResult) { navigate('/'); @@ -207,8 +251,10 @@ const Login: React.FC = () => { )} diff --git a/src/views/PhoneRegistration.tsx b/src/views/PhoneRegistration.tsx index 228138d..8eb64a5 100644 --- a/src/views/PhoneRegistration.tsx +++ b/src/views/PhoneRegistration.tsx @@ -5,14 +5,19 @@ */ import React, { useEffect, useState } from 'react'; +import { useAuth } from '@/AuthProvider'; import { useAuthClient } from '@/hooks/useAuthClient'; -import { useNavigate } from 'react-router-dom'; +import { useLocation, useNavigate } from 'react-router-dom'; import styles from '@/styles/verifyOTP.module.css'; import OtpInput from '@/components/OtpInput'; const PhoneRegistration: React.FC = () => { const navigate = useNavigate(); + const location = useLocation(); + const { refreshSession } = useAuth(); + const isLoginFlow = + (location.state as { flow?: string } | null)?.flow === 'login'; const [phoneOtp, setPhoneOtp] = useState(''); const [phoneVerified, setPhoneVerified] = useState(null); @@ -46,11 +51,19 @@ const PhoneRegistration: React.FC = () => { setLoading(true); try { if (!phoneVerified) { - const response = await authClient.verifyPhoneOtp(phoneOtp); + const response = isLoginFlow + ? await authClient.verifyLoginPhoneOtp(phoneOtp) + : await authClient.verifyPhoneOtp(phoneOtp); if (!response.ok) { setError('Verification failed.'); } else { + if (isLoginFlow) { + await refreshSession(); + navigate('/'); + return; + } + setPhoneVerified(true); } } @@ -64,7 +77,9 @@ const PhoneRegistration: React.FC = () => { const onResendPhone = async () => { setError(''); - const response = await authClient.requestPhoneOtp(); + const response = isLoginFlow + ? await authClient.requestLoginPhoneOtp() + : await authClient.requestPhoneOtp(); const data = await response.json(); @@ -123,10 +138,10 @@ const PhoneRegistration: React.FC = () => { navigate('/verifyEmailOTP'); } }; - if (phoneVerified) { + if (phoneVerified && !isLoginFlow) { nextStep(); } - }, [phoneVerified, navigate, authClient]); + }, [phoneVerified, isLoginFlow, navigate, authClient]); return (
diff --git a/tests/AuthFallbackOptions.test.tsx b/tests/AuthFallbackOptions.test.tsx index 36f38e3..4ff66ab 100644 --- a/tests/AuthFallbackOptions.test.tsx +++ b/tests/AuthFallbackOptions.test.tsx @@ -16,6 +16,7 @@ jest.mock('@/utils', () => ({ describe('AuthFallbackOptions', () => { const magicLinkHandler = jest.fn(); + const emailOtpHandler = jest.fn(); const phoneOtpHandler = jest.fn(); const passkeyHandler = jest.fn(); @@ -31,6 +32,7 @@ describe('AuthFallbackOptions', () => { @@ -51,6 +53,7 @@ describe('AuthFallbackOptions', () => { @@ -73,6 +76,7 @@ describe('AuthFallbackOptions', () => { @@ -91,6 +95,7 @@ describe('AuthFallbackOptions', () => { @@ -101,6 +106,51 @@ describe('AuthFallbackOptions', () => { expect(phoneOtpHandler).toHaveBeenCalledTimes(1); }); + test('renders and calls email OTP when enabled for an email identifier', () => { + (isValidEmail as jest.Mock).mockReturnValue(true); + (isValidPhoneNumber as jest.Mock).mockReturnValue(false); + + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: /email code/i })); + + expect(emailOtpHandler).toHaveBeenCalledTimes(1); + expect( + screen.queryByRole('button', { name: /email magic link/i }) + ).not.toBeInTheDocument(); + }); + + test('filters fallback options using configured login methods', () => { + (isValidEmail as jest.Mock).mockReturnValue(true); + (isValidPhoneNumber as jest.Mock).mockReturnValue(false); + + render( + + ); + + expect(screen.getByRole('button', { name: /email magic link/i })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /email code/i })).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /try passkey anyway/i }) + ).not.toBeInTheDocument(); + }); + test('always renders passkey retry option', () => { (isValidEmail as jest.Mock).mockReturnValue(false); (isValidPhoneNumber as jest.Mock).mockReturnValue(false); @@ -109,6 +159,7 @@ describe('AuthFallbackOptions', () => { @@ -127,6 +178,7 @@ describe('AuthFallbackOptions', () => { diff --git a/tests/EmailRegistration.test.tsx b/tests/EmailRegistration.test.tsx index 2aab7f0..09d1446 100644 --- a/tests/EmailRegistration.test.tsx +++ b/tests/EmailRegistration.test.tsx @@ -11,13 +11,14 @@ import { useAuth } from '@/AuthProvider'; import { useAuthClient } from '@/hooks/useAuthClient'; import { usePasskeySupport } from '@/hooks/usePasskeySupport'; -import { useNavigate } from 'react-router-dom'; +import { useLocation, useNavigate } from 'react-router-dom'; jest.mock('@/AuthProvider'); jest.mock('@/hooks/useAuthClient'); jest.mock('@/hooks/usePasskeySupport'); jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), + useLocation: jest.fn(), useNavigate: jest.fn(), })); @@ -35,12 +36,15 @@ describe('EmailRegistration', () => { const mockAuthClient = { requestEmailOtp: jest.fn(), verifyEmailOtp: jest.fn(), + requestLoginEmailOtp: jest.fn(), + verifyLoginEmailOtp: jest.fn(), }; beforeEach(() => { jest.useFakeTimers(); (useNavigate as jest.Mock).mockReturnValue(navigate); + (useLocation as jest.Mock).mockReturnValue({ state: null }); (useAuth as jest.Mock).mockReturnValue({ refreshSession, @@ -54,6 +58,8 @@ describe('EmailRegistration', () => { mockAuthClient.requestEmailOtp.mockResolvedValue({ ok: true }); mockAuthClient.verifyEmailOtp.mockResolvedValue({ ok: true }); + mockAuthClient.requestLoginEmailOtp.mockResolvedValue({ ok: true }); + mockAuthClient.verifyLoginEmailOtp.mockResolvedValue({ ok: true }); }); afterEach(() => { @@ -159,6 +165,39 @@ describe('EmailRegistration', () => { expect(navigate).toHaveBeenCalledWith('/'); }); + test('login flow verifies email OTP and refreshes the session', async () => { + (useLocation as jest.Mock).mockReturnValue({ state: { flow: 'login' } }); + mockAuthClient.verifyLoginEmailOtp.mockResolvedValue({ ok: true }); + + render(); + + fireEvent.change(screen.getByTestId('otp-input'), { + target: { value: 'ABCDEF' }, + }); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /verify & continue/i })); + }); + + expect(mockAuthClient.verifyLoginEmailOtp).toHaveBeenCalledWith('ABCDEF'); + expect(refreshSession).toHaveBeenCalled(); + expect(navigate).toHaveBeenCalledWith('/'); + expect(navigate).not.toHaveBeenCalledWith('/registerPasskey'); + }); + + test('login flow resend uses the login email OTP endpoint', async () => { + (useLocation as jest.Mock).mockReturnValue({ state: { flow: 'login' } }); + + render(); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /resend code to email/i })); + }); + + expect(mockAuthClient.requestLoginEmailOtp).toHaveBeenCalled(); + expect(mockAuthClient.requestEmailOtp).not.toHaveBeenCalled(); + }); + test('back to login navigates to login page', () => { render(); diff --git a/tests/PhoneRegistration.test.tsx b/tests/PhoneRegistration.test.tsx index 6dc67c2..a9aa91b 100644 --- a/tests/PhoneRegistration.test.tsx +++ b/tests/PhoneRegistration.test.tsx @@ -7,12 +7,15 @@ import { render, screen, fireEvent, act } from '@testing-library/react'; import PhoneRegistration from '@/views/PhoneRegistration'; +import { useAuth } from '@/AuthProvider'; import { useAuthClient } from '@/hooks/useAuthClient'; -import { useNavigate } from 'react-router-dom'; +import { useLocation, useNavigate } from 'react-router-dom'; +jest.mock('@/AuthProvider'); jest.mock('@/hooks/useAuthClient'); jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), + useLocation: jest.fn(), useNavigate: jest.fn(), })); @@ -26,23 +29,33 @@ jest.mock('@/components/OtpInput', () => (props: any) => ( describe('PhoneRegistration', () => { const navigate = jest.fn(); + const refreshSession = jest.fn(); const mockAuthClient = { verifyPhoneOtp: jest.fn(), requestPhoneOtp: jest.fn(), requestEmailOtp: jest.fn(), + verifyLoginPhoneOtp: jest.fn(), + requestLoginPhoneOtp: jest.fn(), }; beforeEach(() => { jest.useFakeTimers(); (useNavigate as jest.Mock).mockReturnValue(navigate); + (useLocation as jest.Mock).mockReturnValue({ state: null }); + (useAuth as jest.Mock).mockReturnValue({ refreshSession }); (useAuthClient as jest.Mock).mockReturnValue(mockAuthClient); mockAuthClient.verifyPhoneOtp.mockResolvedValue({ ok: true }); + mockAuthClient.verifyLoginPhoneOtp.mockResolvedValue({ ok: true }); mockAuthClient.requestPhoneOtp.mockResolvedValue({ ok: true, json: async () => ({}), }); + mockAuthClient.requestLoginPhoneOtp.mockResolvedValue({ + ok: true, + json: async () => ({}), + }); mockAuthClient.requestEmailOtp.mockResolvedValue({ ok: true }); }); @@ -142,6 +155,25 @@ describe('PhoneRegistration', () => { expect(navigate).toHaveBeenCalledWith('/verifyEmailOTP'); }); + test('login flow verifies phone OTP and refreshes the session', async () => { + (useLocation as jest.Mock).mockReturnValue({ state: { flow: 'login' } }); + + render(); + + fireEvent.change(screen.getByTestId('otp-input'), { + target: { value: '123456' }, + }); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /verify & continue/i })); + }); + + expect(mockAuthClient.verifyLoginPhoneOtp).toHaveBeenCalledWith('123456'); + expect(refreshSession).toHaveBeenCalled(); + expect(navigate).toHaveBeenCalledWith('/'); + expect(mockAuthClient.requestEmailOtp).not.toHaveBeenCalled(); + }); + test('back to login navigates correctly', () => { render(); diff --git a/tests/createSeamlessAuthClient.test.ts b/tests/createSeamlessAuthClient.test.ts index 975605a..82aee96 100644 --- a/tests/createSeamlessAuthClient.test.ts +++ b/tests/createSeamlessAuthClient.test.ts @@ -66,6 +66,60 @@ describe('createSeamlessAuthClient', () => { }); }); + it('uses login-specific phone OTP endpoints', async () => { + const response = { ok: true }; + mockFetchWithAuth.mockResolvedValue(response); + + const client = createSeamlessAuthClient({ + apiHost: 'https://api.example.com', + mode: 'server', + }); + + await expect(client.requestLoginPhoneOtp()).resolves.toBe(response); + await expect(client.verifyLoginPhoneOtp('123456')).resolves.toBe(response); + + expect(mockFetchWithAuth).toHaveBeenNthCalledWith(1, '/otp/generate-login-phone-otp', { + method: 'GET', + }); + expect(mockFetchWithAuth).toHaveBeenNthCalledWith( + 2, + '/otp/verify-login-phone-otp', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ verificationToken: '123456' }), + credentials: 'include', + }) + ); + }); + + it('uses login-specific email OTP endpoints', async () => { + const response = { ok: true }; + mockFetchWithAuth.mockResolvedValue(response); + + const client = createSeamlessAuthClient({ + apiHost: 'https://api.example.com', + mode: 'server', + }); + + await expect(client.requestLoginEmailOtp()).resolves.toBe(response); + await expect(client.verifyLoginEmailOtp('ABCDEF')).resolves.toBe(response); + + expect(mockFetchWithAuth).toHaveBeenNthCalledWith( + 1, + '/otp/generate-login-email-otp', + expect.objectContaining({ method: 'GET' }) + ); + expect(mockFetchWithAuth).toHaveBeenNthCalledWith( + 2, + '/otp/verify-login-email-otp', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ verificationToken: 'ABCDEF' }), + credentials: 'include', + }) + ); + }); + it('returns a successful passkey login result when both WebAuthn steps succeed', async () => { mockFetchWithAuth .mockResolvedValueOnce({ diff --git a/tests/login.test.tsx b/tests/login.test.tsx index 352a225..dd78cad 100644 --- a/tests/login.test.tsx +++ b/tests/login.test.tsx @@ -37,8 +37,10 @@ jest.mock('@/components/phoneInput', () => (props: any) => ( jest.mock('@/components/AuthFallbackOptions', () => (props: any) => (
+ + {props.loginMethods?.join(',')}
)); @@ -48,6 +50,8 @@ describe('Login', () => { register: jest.fn(), requestMagicLink: jest.fn(), requestPhoneOtp: jest.fn(), + requestLoginPhoneOtp: jest.fn(), + requestLoginEmailOtp: jest.fn(), }; beforeEach(() => { @@ -76,6 +80,8 @@ describe('Login', () => { }); mockAuthClient.requestMagicLink.mockResolvedValue({ ok: true }); mockAuthClient.requestPhoneOtp.mockResolvedValue({ ok: true }); + mockAuthClient.requestLoginPhoneOtp.mockResolvedValue({ ok: true }); + mockAuthClient.requestLoginEmailOtp.mockResolvedValue({ ok: true }); jest.clearAllMocks(); }); @@ -103,7 +109,10 @@ describe('Login', () => { }); test('login triggers API request', async () => { - const mockLogin = jest.fn().mockResolvedValueOnce({ ok: true }); + const mockLogin = jest.fn().mockResolvedValueOnce({ + ok: true, + json: async () => ({ loginMethods: ['passkey', 'magic_link'] }), + }); const mockHandlePasskeyLogin = jest.fn().mockResolvedValue(false); (useAuth as jest.Mock).mockReturnValue({ apiHost: 'http://localhost', @@ -151,6 +160,34 @@ describe('Login', () => { expect(await screen.findByTestId('fallback-options')).toBeInTheDocument(); }); + test('passes login methods from login start response into fallback options', async () => { + const mockLogin = jest.fn().mockResolvedValueOnce({ + ok: true, + json: async () => ({ loginMethods: ['magic_link', 'email_otp'] }), + }); + (useAuth as jest.Mock).mockReturnValue({ + apiHost: 'http://localhost', + hasSignedInBefore: true, + mode: 'web', + login: mockLogin, + handlePasskeyLogin: jest.fn().mockResolvedValue(false), + }); + + render(); + + fireEvent.change(screen.getByPlaceholderText(/email or phone number/i), { + target: { value: 'test@example.com' }, + }); + + await act(async () => { + fireEvent.click(await screen.findByRole('button', { name: /login/i })); + }); + + expect(await screen.findByTestId('fallback-methods')).toHaveTextContent( + 'magic_link,email_otp' + ); + }); + test('magic link option navigates to magic link sent page', async () => { render(); @@ -197,8 +234,33 @@ describe('Login', () => { fireEvent.click(phoneOtp); }); - expect(mockAuthClient.requestPhoneOtp).toHaveBeenCalled(); - expect(navigate).toHaveBeenCalledWith('/verifyPhoneOTP'); + expect(mockAuthClient.requestLoginPhoneOtp).toHaveBeenCalled(); + expect(navigate).toHaveBeenCalledWith('/verifyPhoneOTP', { + state: { flow: 'login' }, + }); + }); + + test('email OTP option navigates to verify email for login flow', async () => { + render(); + + fireEvent.change(screen.getByPlaceholderText(/email or phone number/i), { + target: { value: 'test@example.com' }, + }); + + await act(async () => { + fireEvent.click(await screen.findByRole('button', { name: /login/i })); + }); + + const emailOtp = await screen.findByText('EmailOTP'); + + await act(async () => { + fireEvent.click(emailOtp); + }); + + expect(mockAuthClient.requestLoginEmailOtp).toHaveBeenCalled(); + expect(navigate).toHaveBeenCalledWith('/verifyEmailOTP', { + state: { flow: 'login' }, + }); }); test('register mode submits registration', async () => {