diff --git a/jest.setup.ts b/jest.setup.ts index 81c621c..8aec8f0 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -3,3 +3,14 @@ import { TextEncoder, TextDecoder } from 'util'; (global as any).TextEncoder = TextEncoder; (global as any).TextDecoder = TextDecoder; + +(global as any).BroadcastChannel = class { + onmessage = null; + postMessage() {} + close() {} +}; + +beforeAll(() => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, 'warn').mockImplementation(() => {}); +}); diff --git a/src/AuthProvider.tsx b/src/AuthProvider.tsx index 32aa77f..651bce6 100644 --- a/src/AuthProvider.tsx +++ b/src/AuthProvider.tsx @@ -9,7 +9,7 @@ import React, { } from 'react'; import { AuthMode, createFetchWithAuth } from './fetchWithAuth'; -import LoadingSpinner from './LoadingSpinner'; +import LoadingSpinner from './components/LoadingSpinner'; import { usePreviousSignIn } from './hooks/usePreviousSignIn'; import { AuthenticatorTransportFuture, diff --git a/src/AuthRoutes.tsx b/src/AuthRoutes.tsx index 0408eb1..e1c0506 100644 --- a/src/AuthRoutes.tsx +++ b/src/AuthRoutes.tsx @@ -1,18 +1,22 @@ import { Navigate, Route, Routes } from 'react-router-dom'; -import Login from '@/Login'; -import MfaLogin from '@/MfaLogin'; -import PassKeyLogin from '@/PassKeyLogin'; -import RegisterPasskey from '@/RegisterPassKey'; -import VerifyOTP from '@/VerifyOTP'; +import Login from '@/views/Login'; +import PassKeyLogin from '@/views/PassKeyLogin'; +import PasskeyRegistration from '@/views/PassKeyRegistration'; +import PhoneRegistration from '@/views/PhoneRegistration'; +import EmailRegistration from '@/views/EmailRegistration'; +import VerifyMagicLink from '@/views/VerifyMagicLink'; +import MagicLinkSent from './components/MagicLinkSent'; export const AuthRoutes = () => ( } /> } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> } /> ); diff --git a/src/MfaLogin.tsx b/src/MfaLogin.tsx deleted file mode 100644 index fd2d4ad..0000000 --- a/src/MfaLogin.tsx +++ /dev/null @@ -1,174 +0,0 @@ -import { useAuth } from '@/AuthProvider'; -import { useInternalAuth } from '@/context/InternalAuthContext'; -import React, { useEffect, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; - -import styles from './styles/mfaLogin.module.css'; -import { createFetchWithAuth } from './fetchWithAuth'; - -const MfaLogin: React.FC = () => { - const { apiHost, mode } = useAuth(); - const { validateToken } = useInternalAuth(); - const navigate = useNavigate(); - const [OTP, setOTP] = useState(''); - const [error, setError] = useState(''); - const [loading, setLoading] = useState(false); - const [resendMsg, setResendMsg] = useState(''); - const [timeLeft, setTimeLeft] = useState(300); - const [selectedMethod, setSelectedMethod] = useState(null); - - const maskedPhone = '****1234'; - const maskedEmail = 'j***@example.com'; - - const fetchWithAuth = createFetchWithAuth({ - authMode: mode, - authHost: apiHost, - }); - - const formatTime = (seconds: number) => { - const m = Math.floor(seconds / 60) - .toString() - .padStart(2, '0'); - const s = (seconds % 60).toString().padStart(2, '0'); - return `${m}:${s}`; - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setError(''); - setLoading(true); - - try { - await verifyOTP(); - } catch { - setError('Unexpected error occurred.'); - } finally { - setLoading(false); - } - }; - - const verifyOTP = async () => { - const endpoint = - selectedMethod === 'phone' - ? 'otp/verify-login-phone-otp' - : 'otp/verify-login-email-otp'; - - try { - const response = await fetchWithAuth(`/${endpoint}`, { - method: 'POST', - body: JSON.stringify({ verificationToken: OTP }), - }); - - if (!response.ok) { - setError('Verification failed.'); - return; - } - - await validateToken(); - navigate('/'); - } catch (err) { - console.error('Error verifying OTP', err); - setError('Verification failed.'); - } - }; - - const sendOTP = async (target: string) => { - setError(''); - const endpoint = - target === 'phone' - ? 'otp/generate-login-phone-otp' - : 'otp/generate-login-email-otp'; - - try { - const response = await fetchWithAuth(`/${endpoint}`, { - method: 'GET', - }); - - if (!response.ok) { - setError( - `Failed to send ${target} code. If this persists, refresh the page and try again.` - ); - } else { - setResendMsg( - `Verification ${target === 'phone' ? 'SMS' : 'email'} has been sent.` - ); - } - } catch { - setError( - `Failed to send ${target} code. If this persists, refresh the page and try again.` - ); - } - }; - - useEffect(() => { - const interval = setInterval(() => { - setTimeLeft(prev => (prev > 0 ? prev - 1 : 0)); - }, 1000); - return () => clearInterval(interval); - }, []); - - return ( -
-
-

Multi-factor Authentication

- - {resendMsg &&

{resendMsg}

} - -
- - - -
- - {selectedMethod && ( - <> - {error &&

{error}

} - - setOTP(e.target.value)} - /> - - - )} -
-
- ); -}; - -export default MfaLogin; diff --git a/src/components/AuthFallbackOptions.tsx b/src/components/AuthFallbackOptions.tsx new file mode 100644 index 0000000..384ddb5 --- /dev/null +++ b/src/components/AuthFallbackOptions.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { isValidEmail, isValidPhoneNumber } from '../utils'; + +import styles from '../styles/login.module.css'; + +interface AuthFallbackOptionsProps { + identifier: string; + onMagicLink: () => void; + onPhoneOtp: () => void; + onPasskeyRetry: () => void; +} + +const AuthFallbackOptions: React.FC = ({ + identifier, + onMagicLink, + onPhoneOtp, + onPasskeyRetry, +}) => { + const showMagicLink = isValidEmail(identifier); + const showPhoneOtp = isValidPhoneNumber(identifier); + + return ( +
+
Passkeys unavailable on this device
+ +

Choose another secure sign-in method.

+ +
+ {showMagicLink && ( + + )} + + {showPhoneOtp && ( + + )} +
+ + +
+ ); +}; + +export default AuthFallbackOptions; diff --git a/src/LoadingSpinner.tsx b/src/components/LoadingSpinner.tsx similarity index 100% rename from src/LoadingSpinner.tsx rename to src/components/LoadingSpinner.tsx diff --git a/src/components/MagicLinkSent.tsx b/src/components/MagicLinkSent.tsx new file mode 100644 index 0000000..1f13c35 --- /dev/null +++ b/src/components/MagicLinkSent.tsx @@ -0,0 +1,145 @@ +import React, { useEffect, useState } from 'react'; +import { useAuth } from '@/AuthProvider'; +import { useInternalAuth } from '@/context/InternalAuthContext'; +import { useNavigate, useLocation } from 'react-router-dom'; + +import styles from '@/styles/magiclink.module.css'; +import { createFetchWithAuth } from '@/fetchWithAuth'; + +const MagicLinkSent: React.FC = () => { + const { apiHost, mode: authMode } = useAuth(); + const { validateToken } = useInternalAuth(); + const navigate = useNavigate(); + const location = useLocation(); + + const fetchWithAuth = createFetchWithAuth({ + authMode, + authHost: apiHost, + }); + + const identifier = location.state?.identifier; + + const [cooldown, setCooldown] = useState(30); + + useEffect(() => { + const timer = setInterval(() => { + setCooldown(c => (c > 0 ? c - 1 : 0)); + }, 1000); + + return () => clearInterval(timer); + }, []); + + const resend = async () => { + if (cooldown > 0) return; + + await fetchWithAuth(`/magic-link`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + setCooldown(30); + }; + + useEffect(() => { + const channel = new BroadcastChannel('seamless-auth'); + + channel.onmessage = async event => { + if (event.data?.type === 'MAGIC_LINK_AUTH_SUCCESS') { + const response = await fetchWithAuth(`/magic-link/check`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + if (response.status === 200) { + await validateToken(); + navigate('/'); + } + } + }; + + return () => { + channel.close(); + }; + }, [fetchWithAuth, navigate, validateToken]); + + useEffect(() => { + const interval = setInterval(async () => { + try { + const response = await fetchWithAuth(`/magic-link/check`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + if (response.status === 200) { + await validateToken(); + navigate('/'); + } + } catch { + /* ignore */ + } + }, 5000); + + return () => clearInterval(interval); + }, [apiHost, validateToken, navigate]); + + return ( +
+
+
+
+ + + + + +
+ +

Check your email

+ +

+ If an account exists for this address, we sent a secure sign-in link. +

+ +
{identifier}
+ +

+ Open the email and click the link to finish signing in. +

+ +

+ Didn’t receive anything? Check your spam folder or try creating a new account. +

+ +
+ + + {cooldown > 0 && ( +
Available in {cooldown}s
+ )} + + +
+
+
+ ); +}; + +export default MagicLinkSent; diff --git a/src/components/OtpInput.tsx b/src/components/OtpInput.tsx new file mode 100644 index 0000000..038c41d --- /dev/null +++ b/src/components/OtpInput.tsx @@ -0,0 +1,63 @@ +import React, { useRef } from 'react'; +import styles from '@/styles/otpInput.module.css'; + +interface Props { + length?: number; + value: string; + onChange: (value: string) => void; +} + +const OtpInput: React.FC = ({ length = 6, value, onChange }) => { + const inputs = useRef>([]); + + const values = value.split('').concat(Array(length).fill('')).slice(0, length); + + const handleChange = (index: number, char: string) => { + if (!/^\d?$/.test(char)) return; + + const newValue = value.substring(0, index) + char + value.substring(index + 1); + + onChange(newValue.trim()); + + if (char && inputs.current[index + 1]) { + inputs.current[index + 1]?.focus(); + } + }; + + const handleKeyDown = (index: number, e: React.KeyboardEvent) => { + if (e.key === 'Backspace' && !values[index] && inputs.current[index - 1]) { + inputs.current[index - 1]?.focus(); + } + }; + + const handlePaste = (e: React.ClipboardEvent) => { + const pasted = e.clipboardData.getData('text').replace(/\D/g, ''); + + if (pasted.length === length) { + onChange(pasted); + inputs.current[length - 1]?.focus(); + } + + e.preventDefault(); + }; + + return ( +
+ {values.map((digit, i) => ( + (inputs.current[i] = el)} + type="text" + inputMode="numeric" + maxLength={1} + value={digit || ''} + className={styles.otpInput} + onChange={e => handleChange(i, e.target.value)} + onKeyDown={e => handleKeyDown(i, e)} + /> + ))} +
+ ); +}; + +export default OtpInput; diff --git a/src/TermsModal.tsx b/src/components/TermsModal.tsx similarity index 98% rename from src/TermsModal.tsx rename to src/components/TermsModal.tsx index ef4127c..c992a9d 100644 --- a/src/TermsModal.tsx +++ b/src/components/TermsModal.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useRef } from 'react'; -import styles from './styles/termsModal.module.css'; +import styles from '@/styles/termsModal.module.css'; interface TermsModalProps { isOpen: boolean; diff --git a/src/components/phoneInput.tsx b/src/components/phoneInput.tsx index 6909f3d..815a2a0 100644 --- a/src/components/phoneInput.tsx +++ b/src/components/phoneInput.tsx @@ -2,7 +2,7 @@ import { AsYouType, parsePhoneNumberFromString } from 'libphonenumber-js'; import { useEffect, useState } from 'react'; import styles from '../styles/login.module.css'; -import TermsModal from '../TermsModal'; +import TermsModal from './TermsModal'; const countries = [ { code: 'US', name: 'United States', dialCode: '+1', flag: '🇺🇸' }, diff --git a/src/styles/login.module.css b/src/styles/login.module.css index e1fe70c..930c9fd 100644 --- a/src/styles/login.module.css +++ b/src/styles/login.module.css @@ -119,9 +119,84 @@ border: 1px solid #d1d5db; } -/* Optional: responsive adjustment */ @media (max-width: 500px) { .phoneRow { grid-template-columns: 1fr; } } + +/* Fallback container */ + +.fallbackCard { + margin-top: 18px; + padding: 18px; + border-radius: 12px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + display: flex; + flex-direction: column; + gap: 14px; +} + +.fallbackHeader { + font-size: 0.95rem; + font-weight: 600; + color: #f3f4f6; +} + +.fallbackDescription { + font-size: 0.85rem; + color: #9ca3af; + line-height: 1.4; +} + +.fallbackActions { + display: flex; + flex-direction: column; + gap: 10px; +} + +.fallbackActionButton { + display: flex; + flex-direction: column; + text-align: left; + + padding: 12px 14px; + border-radius: 8px; + + background: rgba(0, 0, 0, 0.25); + border: 1px solid rgba(255, 255, 255, 0.08); + + cursor: pointer; + transition: all 0.15s ease; +} + +.fallbackActionButton:hover { + background: rgba(255, 255, 255, 0.06); + border-color: rgba(255, 255, 255, 0.15); +} + +.actionTitle { + font-size: 0.9rem; + font-weight: 500; + color: #f9fafb; +} + +.actionSubtext { + font-size: 0.75rem; + color: #9ca3af; + margin-top: 2px; +} + +.linkButton { + margin-top: 6px; + background: none; + border: none; + color: #60a5fa; + font-size: 0.8rem; + cursor: pointer; +} + +.linkButton:hover { + text-decoration: underline; +} diff --git a/src/styles/magiclink.module.css b/src/styles/magiclink.module.css new file mode 100644 index 0000000..98c62da --- /dev/null +++ b/src/styles/magiclink.module.css @@ -0,0 +1,133 @@ +.container { + display: flex; + align-items: center; + justify-content: center; + color: white; +} + +.card { + background-color: #1f2937; + padding: 2.5rem 2rem; + border-radius: 0.75rem; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.25); + width: 100%; + max-width: 420px; + text-align: center; +} + +/* ICON */ + +.iconWrapper { + position: relative; + margin-bottom: 1.25rem; + display: flex; + justify-content: center; +} + +.mailIcon { + width: 38px; + height: 38px; + color: #a5b4fc; + z-index: 2; +} + +.iconRing { + position: absolute; + width: 72px; + height: 72px; + border-radius: 50%; + background: rgba(99, 102, 241, 0.15); + animation: pulse 2s infinite; +} + +@keyframes pulse { + 0% { + transform: scale(0.6); + opacity: 0.8; + } + 70% { + transform: scale(1.4); + opacity: 0; + } + 100% { + opacity: 0; + } +} + +/* TEXT */ + +.heading { + font-size: 1.55rem; + font-weight: 600; + margin-bottom: 0.75rem; +} + +.message { + color: #d1d5db; + font-size: 0.95rem; + margin-bottom: 1rem; + line-height: 1.4; +} + +.identifier { + font-weight: 600; + font-size: 1rem; + background: rgba(255, 255, 255, 0.05); + padding: 0.45rem 0.6rem; + border-radius: 6px; + margin-bottom: 1rem; + display: inline-block; +} + +.helperText { + color: #9ca3af; + font-size: 0.85rem; + margin-bottom: 0.6rem; + line-height: 1.4; +} + +/* ACTIONS */ + +.actions { + margin-top: 1.6rem; + display: flex; + flex-direction: column; + gap: 0.6rem; +} + +.secondaryButton { + padding: 0.65rem; + border-radius: 8px; + border: 1px solid #374151; + background: #111827; + color: white; + cursor: pointer; + font-weight: 500; +} + +.secondaryButton:hover:not(:disabled) { + background: #1f2937; +} + +.secondaryButton:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.cooldown { + font-size: 0.8rem; + color: #9ca3af; +} + +.linkButton { + margin-top: 0.4rem; + background: none; + border: none; + color: #a5b4fc; + font-size: 0.85rem; + cursor: pointer; +} + +.linkButton:hover { + text-decoration: underline; +} diff --git a/src/styles/otpInput.module.css b/src/styles/otpInput.module.css new file mode 100644 index 0000000..5d29dbe --- /dev/null +++ b/src/styles/otpInput.module.css @@ -0,0 +1,26 @@ +.otpContainer { + display: flex; + justify-content: center; + gap: 8px; + margin-top: 8px; +} + +.otpInput { + width: 42px; + height: 48px; + text-align: center; + font-size: 1.2rem; + border-radius: 8px; + border: 1px solid #4b5563; + background: #374151; + color: white; + transition: + border-color 0.15s ease, + box-shadow 0.15s ease; +} + +.otpInput:focus { + outline: none; + border-color: #60a5fa; + box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.25); +} diff --git a/src/styles/verifyMagiclink.module.css b/src/styles/verifyMagiclink.module.css new file mode 100644 index 0000000..b97a7b4 --- /dev/null +++ b/src/styles/verifyMagiclink.module.css @@ -0,0 +1,83 @@ +.container { + color: white; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; +} + +.card { + background-color: #1f2937; + width: 100%; + max-width: 28rem; + padding: 1.5rem; + border-radius: 0.5rem; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.3); +} + +.title { + font-size: 1.5rem; + font-weight: bold; + text-align: center; + margin-bottom: 1rem; + color: white; +} + +.verificationContent { + display: flex; + flex-direction: column; + align-items: center; + gap: 14px; + margin: 20px 0; +} + +.spinner { + width: 28px; + height: 28px; + border-radius: 50%; + border: 3px solid rgba(255, 255, 255, 0.15); + border-top-color: #60a5fa; + animation: spin 0.9s linear infinite; +} + +.successIcon { + width: 36px; + height: 36px; + border-radius: 50%; + background: rgba(16, 185, 129, 0.15); + display: flex; + align-items: center; + justify-content: center; +} + +.checkIcon { + width: 22px; + height: 22px; + color: #34d399; + animation: checkPop 0.25s ease-out; +} + +.successText { + color: #34d399; + font-size: 0.9rem; +} + +@keyframes checkPop { + 0% { + transform: scale(0.6); + opacity: 0; + } + 80% { + transform: scale(1.15); + } + 100% { + transform: scale(1); + opacity: 1; + } +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} diff --git a/src/views/EmailRegistration.tsx b/src/views/EmailRegistration.tsx new file mode 100644 index 0000000..ba04668 --- /dev/null +++ b/src/views/EmailRegistration.tsx @@ -0,0 +1,166 @@ +import { useAuth } from '@/AuthProvider'; +import React, { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import styles from '@/styles/verifyOTP.module.css'; +import { createFetchWithAuth } from '@/fetchWithAuth'; +import { isPasskeySupported } from '@/utils'; +import { useInternalAuth } from '@/context/InternalAuthContext'; + +const EmailRegistration: React.FC = () => { + const navigate = useNavigate(); + const { apiHost, mode } = useAuth(); + const { validateToken } = useInternalAuth(); + + const [loading, setLoading] = useState(false); + const [emailOtp, setEmailOtp] = useState(''); + const [emailTimeLeft, setEmailTimeLeft] = useState(300); + const [error, setError] = useState(''); + const [resendMsg, setResendMsg] = useState(''); + const [passkeyAvailable, setPasskeyAvailable] = useState(false); + + const fetchWithAuth = createFetchWithAuth({ + authMode: mode, + authHost: apiHost, + }); + + const onResendEmail = async () => { + setError(''); + setResendMsg(''); + + const response = await fetchWithAuth(`/otp/generate-email-otp`, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }); + + if (!response.ok) { + setError( + 'Failed to send Email code. If this persists, refresh the page and try again.' + ); + return; + } + + setResendMsg('Verification email has been resent.'); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + + if (emailOtp.length !== 6) { + setError('Please enter a valid code.'); + return; + } + + setLoading(true); + + try { + const response = await fetchWithAuth(`/otp/verify-email-otp`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + verificationToken: emailOtp, + }), + credentials: 'include', + }); + + if (!response.ok) { + setError('Verification failed.'); + return; + } + + if (passkeyAvailable) { + navigate('/registerPasskey'); + } else { + await validateToken(); + navigate('/'); + } + } catch (err) { + console.error(err); + setError('Verification failed.'); + } finally { + setLoading(false); + } + }; + + const formatTime = (seconds: number) => { + const m = Math.floor(seconds / 60) + .toString() + .padStart(2, '0'); + const s = (seconds % 60).toString().padStart(2, '0'); + return `${m}:${s}`; + }; + + useEffect(() => { + const interval = setInterval(() => { + setEmailTimeLeft(prev => (prev > 0 ? prev - 1 : 0)); + }, 1000); + + return () => clearInterval(interval); + }, []); + + useEffect(() => { + async function checkSupport() { + const supported = await isPasskeySupported(); + setPasskeyAvailable(supported); + } + + checkSupport(); + }, []); + + return ( +
+
+

Verify Your Email

+ +

+ We sent you a verification email. Enter the code below. +

+ + {error &&

{error}

} + {resendMsg &&

{resendMsg}

} + +
+
+ + + setEmailOtp(e.target.value)} + className={styles.input} + required + /> + + +
+ + + + +
+
+
+ ); +}; + +export default EmailRegistration; diff --git a/src/Login.tsx b/src/views/Login.tsx similarity index 52% rename from src/Login.tsx rename to src/views/Login.tsx index bff496a..489dad1 100644 --- a/src/Login.tsx +++ b/src/views/Login.tsx @@ -5,9 +5,10 @@ import { useInternalAuth } from '@/context/InternalAuthContext'; import React, { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import styles from './styles/login.module.css'; -import { isPasskeySupported, isValidEmail, isValidPhoneNumber } from './utils'; -import { createFetchWithAuth } from './fetchWithAuth'; +import styles from '@/styles/login.module.css'; +import { isPasskeySupported, isValidEmail, isValidPhoneNumber } from '../utils'; +import { createFetchWithAuth } from '@/fetchWithAuth'; +import AuthFallbackOptions from '@/components/AuthFallbackOptions'; const Login: React.FC = () => { const navigate = useNavigate(); @@ -22,6 +23,7 @@ const Login: React.FC = () => { const [emailError, setEmailError] = useState(''); const [identifierError, setIdentifierError] = useState(''); const [passkeyAvailable, setPasskeyAvailable] = useState(false); + const [showFallbackOptions, setShowFallbackOptions] = useState(false); const fetchWithAuth = createFetchWithAuth({ authMode, @@ -103,23 +105,26 @@ const Login: React.FC = () => { const login = async () => { setFormErrors(''); - try { - const response = await fetchWithAuth(`/login`, { - method: 'POST', - body: JSON.stringify({ identifier, passkeyAvailable }), - }); + const response = await fetchWithAuth(`/login`, { + method: 'POST', + body: JSON.stringify({ identifier, passkeyAvailable }), + }); - if (!response.ok) { - setFormErrors('Failed to send login link. Please try again.'); - return; - } + if (!response.ok) { + setFormErrors('Failed to send login link. Please try again.'); + return; + } + + if (!passkeyAvailable) { + setShowFallbackOptions(true); + return; + } + try { await handlePasskeyLogin(); } catch (err) { - console.error('Unexpected login error', err); - setFormErrors( - 'An unexpected error occured. Try again. If the problem persists, try resetting your password' - ); + console.error('Passkey login failed', err); + setShowFallbackOptions(true); } }; @@ -140,7 +145,7 @@ const Login: React.FC = () => { const data = await response.json(); if (data.message === 'Success') { - navigate('/verifyOTP'); + navigate('/verifyPhoneOTP'); } setFormErrors( 'An unexpected error occured. Try again. If the problem persists, try resetting your password' @@ -153,6 +158,43 @@ const Login: React.FC = () => { } }; + const sendMagicLink = async () => { + try { + const response = await fetchWithAuth(`/magic-link`, { + method: 'GET', + }); + + if (!response.ok) { + setFormErrors('Failed to send magic link.'); + return; + } + + navigate('/magic-link-sent'); + } catch (err) { + console.error(err); + setFormErrors('Failed to send magic link.'); + } + }; + + const sendPhoneOtp = async () => { + try { + const response = await fetchWithAuth(`/login/phone-otp`, { + method: 'POST', + body: JSON.stringify({ identifier }), + }); + + if (!response.ok) { + setFormErrors('Failed to send OTP.'); + return; + } + + navigate('/verifyPhoneOTP'); + } catch (err) { + console.error(err); + setFormErrors('Failed to send OTP.'); + } + }; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -167,96 +209,94 @@ const Login: React.FC = () => { {mode === 'login' ? 'Sign In' : 'Create Account'} - {!passkeyAvailable ? ( -

- ❌ This device doesn't support passkey login. You must provide or register a - passkey. -

- ) : ( - <> -
- {mode === 'login' && ( + <> + + {mode === 'login' && ( +
+ + { + if (identifier) { + const isValid = + isValidEmail(identifier) || isValidPhoneNumber(identifier); + setIdentifierError( + isValid ? '' : 'Please enter a valid email or phone number' + ); + } + }} + required + /> +

+ Phone numbers must include a country code e.g. +1 +

+ {showFallbackOptions && ( + + )} + + {identifierError &&

{identifierError}

} +
+ )} + {mode === 'register' && ( + <>
-
- )} - - {mode === 'register' && ( - <> -
- - { - if (email) { - const isValid = isValidEmail(email); - setEmailError(isValid ? '' : 'Please enter a valid email'); - } - }} - required - /> - {emailError &&

{emailError}

} -
- - - - )} - - - - {formErrors &&

{formErrors}

} - - - - - )} + + + + )} + + {formErrors &&

{formErrors}

} + + + ); diff --git a/src/PassKeyLogin.tsx b/src/views/PassKeyLogin.tsx similarity index 94% rename from src/PassKeyLogin.tsx rename to src/views/PassKeyLogin.tsx index bdf1e51..6655534 100644 --- a/src/PassKeyLogin.tsx +++ b/src/views/PassKeyLogin.tsx @@ -4,8 +4,8 @@ import { useInternalAuth } from '@/context/InternalAuthContext'; import React from 'react'; import { useNavigate } from 'react-router-dom'; -import styles from './styles/passKeyLogin.module.css'; -import { createFetchWithAuth } from './fetchWithAuth'; +import styles from '@/styles/passKeyLogin.module.css'; +import { createFetchWithAuth } from '../fetchWithAuth'; const PassKeyLogin: React.FC = () => { const navigate = useNavigate(); diff --git a/src/RegisterPassKey.tsx b/src/views/PassKeyRegistration.tsx similarity index 94% rename from src/RegisterPassKey.tsx rename to src/views/PassKeyRegistration.tsx index 62f8252..483d4aa 100644 --- a/src/RegisterPassKey.tsx +++ b/src/views/PassKeyRegistration.tsx @@ -9,11 +9,11 @@ import React, { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import styles from '@/styles/registerPasskey.module.css'; -import { isPasskeySupported, parseUserAgent } from './utils'; -import { createFetchWithAuth } from './fetchWithAuth'; -import DeviceNameModal from './components/DeviceNameModal'; +import { isPasskeySupported, parseUserAgent } from '@/utils'; +import { createFetchWithAuth } from '@/fetchWithAuth'; +import DeviceNameModal from '@/components/DeviceNameModal'; -const RegisterPasskey: React.FC = () => { +const PasskeyRegistration: React.FC = () => { const { apiHost, mode } = useAuth(); const { validateToken } = useInternalAuth(); const navigate = useNavigate(); @@ -177,4 +177,4 @@ const RegisterPasskey: React.FC = () => { ); }; -export default RegisterPasskey; +export default PasskeyRegistration; diff --git a/src/VerifyOTP.tsx b/src/views/PhoneRegistration.tsx similarity index 51% rename from src/VerifyOTP.tsx rename to src/views/PhoneRegistration.tsx index b4387db..1df048a 100644 --- a/src/VerifyOTP.tsx +++ b/src/views/PhoneRegistration.tsx @@ -2,21 +2,19 @@ import { useAuth } from '@/AuthProvider'; import React, { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import styles from './styles/verifyOTP.module.css'; -import { createFetchWithAuth } from './fetchWithAuth'; +import styles from '@/styles/verifyOTP.module.css'; +import { createFetchWithAuth } from '../fetchWithAuth'; +import OtpInput from '@/components/OtpInput'; -const VerifyOTP: React.FC = () => { +const PhoneRegistration: React.FC = () => { const navigate = useNavigate(); const { apiHost, mode } = useAuth(); - const [emailOtp, setEmailOtp] = useState(''); const [phoneOtp, setPhoneOtp] = useState(''); const [phoneVerified, setPhoneVerified] = useState(null); - const [emailVerified, setEmailVerified] = useState(null); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); const [resendMsg, setResendMsg] = useState(''); - const [emailTimeLeft, setEmailTimeLeft] = useState(300); const [phoneTimeLeft, setPhoneTimeLeft] = useState(300); const fetchWithAuth = createFetchWithAuth({ @@ -28,11 +26,6 @@ const VerifyOTP: React.FC = () => { e.preventDefault(); setError(''); - if (emailOtp.length !== 6) { - setError('Please enter a valid code.'); - return; - } - if (phoneOtp.length !== 6) { setError('Please enter a valid code.'); return; @@ -40,7 +33,6 @@ const VerifyOTP: React.FC = () => { setLoading(true); try { - verifyEmailOTP(); verifyPhoneOTP(); } catch { setError('Unexpected error occurred.'); @@ -78,60 +70,6 @@ const VerifyOTP: React.FC = () => { setLoading(false); }; - const verifyEmailOTP = async () => { - setLoading(true); - try { - if (!emailVerified) { - const response = await fetchWithAuth(`/otp/verify-email-otp`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - verificationToken: emailOtp, - }), - credentials: 'include', - }); - - if (!response.ok) { - setError('Verification failed.'); - } else { - setEmailVerified(true); - } - } - } catch (error: unknown) { - console.error(error); - setError('Verification failed.'); - } - - setLoading(false); - }; - - const onResendEmail = async () => { - setError(''); - const response = await fetchWithAuth(`/otp/generate-email-otp`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }); - - const data = await response.json(); - - if (!response.ok) { - setError( - 'Failed to send Email code. If this persists, refresh the page and try again.' - ); - return; - } else { - setResendMsg('Verification email has been resent.'); - if (data.token) { - // Set a new token - localStorage.setItem('token', data.token); - } - } - }; - const onResendPhone = async () => { setError(''); const response = await fetchWithAuth(`/otp/generate-phone-otp`, { @@ -157,15 +95,10 @@ const VerifyOTP: React.FC = () => { } }; - const handleResend = (type: 'email' | 'phone') => { + const handleResend = async () => { setResendMsg(''); - if (type === 'email') { - onResendEmail(); - setResendMsg('Verification email has been resent.'); - } else { - onResendPhone(); - setResendMsg('Verification SMS has been resent.'); - } + await onResendPhone(); + setResendMsg('Verification SMS has been resent.'); }; const getStatusIcon = (verified: boolean | null) => { @@ -182,14 +115,6 @@ const VerifyOTP: React.FC = () => { return `${m}:${s}`; }; - useEffect(() => { - const interval = setInterval(() => { - setEmailTimeLeft(prev => (prev > 0 ? prev - 1 : 0)); - }, 1000); - - return () => clearInterval(interval); - }, []); - useEffect(() => { const interval = setInterval(() => { setPhoneTimeLeft(prev => (prev > 0 ? prev - 1 : 0)); @@ -199,52 +124,38 @@ const VerifyOTP: React.FC = () => { }, []); useEffect(() => { - if (emailVerified && phoneVerified) { - navigate('/registerPasskey'); + const nextStep = async () => { + const response = await fetchWithAuth(`/otp/generate-email-otp`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + setError( + 'Failed to send Email code. If this persists, refresh the page and try registering again.' + ); + return; + } else { + navigate('/verifyEmailOTP'); + } + }; + if (phoneVerified) { + nextStep(); } - }, [emailVerified, phoneVerified, navigate]); + }, [phoneVerified, navigate, fetchWithAuth]); return (
-

Verify Your Contact Info

-

- Enter the codes sent to your email and phone number. -

+

Verify Your Phone Number

+

Enter the code sent to your phone number.

{error &&

{error}

} {resendMsg &&

{resendMsg}

}
-
- - { - setEmailOtp(e.target.value); - setEmailVerified(null); - }} - className={styles.input} - required - /> - -
-
- { - setPhoneOtp(e.target.value); - setPhoneVerified(null); - }} - className={styles.input} - required - /> + + + +
+)); -describe('Login Component', () => { - it('renders register mode by default', async () => { - await act(async () => { - render(); - }); +describe('Login', () => { + const navigate = jest.fn(); + const validateToken = jest.fn(); + const mockFetch = jest.fn(); - expect(await screen.findByText('Create Account')).toBeInTheDocument(); - }); + beforeEach(() => { + (useNavigate as jest.Mock).mockReturnValue(navigate); - it('toggles between register and login modes', async () => { - await act(async () => { - render(); + (useAuth as jest.Mock).mockReturnValue({ + apiHost: 'http://localhost', + hasSignedInBefore: true, + mode: 'web', }); - const toggleButton = screen.getByRole('button', { name: /Already have an account/i }); - fireEvent.click(toggleButton); - expect(await screen.findByText('Sign In')).toBeInTheDocument(); - }); - it('submits register form successfully', async () => { - (global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: async () => ({ message: 'Success' }), + (useInternalAuth as jest.Mock).mockReturnValue({ + validateToken, }); - await act(async () => { - render(); - }); + (createFetchWithAuth as jest.Mock).mockReturnValue(mockFetch); + + (isPasskeySupported as jest.Mock).mockResolvedValue(false); - fireEvent.change(screen.getByLabelText(/Email Address/i), { - target: { value: 'user@example.com' }, + (isValidEmail as jest.Mock).mockReturnValue(true); + (isValidPhoneNumber as jest.Mock).mockReturnValue(false); + + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ message: 'Success', mfaLogin: false }), }); - fireEvent.change(screen.getByTestId('phone-input'), { - target: { value: '+15555555555' }, + + (startAuthentication as jest.Mock).mockResolvedValue({}); + + jest.clearAllMocks(); + }); + + test('renders login form when user has signed in before', async () => { + render(); + + expect(await screen.findByText(/sign in/i)).toBeInTheDocument(); + }); + + test('shows validation error on invalid identifier', async () => { + (isValidEmail as jest.Mock).mockReturnValue(false); + (isValidPhoneNumber as jest.Mock).mockReturnValue(false); + + render(); + + const input = screen.getByPlaceholderText(/email or phone number/i); + + fireEvent.change(input, { target: { value: 'invalid' } }); + fireEvent.blur(input); + + expect( + await screen.findByText(/please enter a valid email or phone number/i) + ).toBeInTheDocument(); + }); + + test('login triggers API request', async () => { + mockFetch.mockResolvedValue({ ok: true }); + + render(); + + const input = screen.getByPlaceholderText(/email or phone number/i); + + fireEvent.change(input, { + target: { value: 'test@example.com' }, }); - const submitButton = screen.getByRole('button', { name: /Register/i }); + const loginButton = await screen.findByRole('button', { name: /login/i }); + + await waitFor(() => { + expect(loginButton).toBeEnabled(); + }); await act(async () => { - fireEvent.click(submitButton); + fireEvent.click(loginButton); }); - await waitFor(() => { - expect(global.fetch).toHaveBeenCalledWith( - 'https://api.example.com/registration/register', - expect.objectContaining({ - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - email: 'user@example.com', - phone: '+15555555555', - }), - }) - ); - }); - - expect(mockNavigate).toHaveBeenCalledWith('/verifyOTP'); + expect(mockFetch).toHaveBeenCalledWith( + '/login', + expect.objectContaining({ method: 'POST' }) + ); }); - it('shows error when registration fails', async () => { - (global.fetch as jest.Mock).mockResolvedValueOnce({ ok: false }); + test('fallback options appear if passkeys unavailable', async () => { + (isPasskeySupported as jest.Mock).mockResolvedValue(false); - await act(async () => { - render(); + render(); + + const input = screen.getByPlaceholderText(/email or phone number/i); + + fireEvent.change(input, { target: { value: 'test@example.com' } }); + + const loginButton = await screen.findByRole('button', { name: /login/i }); + + await waitFor(() => { + expect(loginButton).toBeEnabled(); }); - fireEvent.change(screen.getByLabelText(/Email Address/i), { - target: { value: 'user@example.com' }, + fireEvent.click(loginButton); + + expect(await screen.findByTestId('fallback-options')).toBeInTheDocument(); + }); + + test('magic link option navigates to magic link sent page', async () => { + (isPasskeySupported as jest.Mock).mockResolvedValue(false); + + render(); + + fireEvent.change(screen.getByPlaceholderText(/email or phone number/i), { + target: { value: 'test@example.com' }, }); - fireEvent.change(screen.getByTestId('phone-input'), { - target: { value: '+15555555555' }, + + const loginButton = await screen.findByRole('button', { name: /login/i }); + + await act(async () => { + fireEvent.click(loginButton); }); - fireEvent.click(screen.getByRole('button', { name: /Register/i })); + const magicLink = await screen.findByText('MagicLink'); - await waitFor(() => { - expect( - screen.getByText( - /An unexpected error occured. Try again. If the problem persists, try resetting your password/i - ) - ).toBeInTheDocument(); + await act(async () => { + fireEvent.click(magicLink); }); + + expect(navigate).toHaveBeenCalledWith('/magic-link-sent'); }); - it('handles login flow and successful passkey verification', async () => { - (global.fetch as jest.Mock) - // login API call - .mockResolvedValueOnce({ ok: true }) - // generate-authentication-options - .mockResolvedValueOnce({ ok: true, json: async () => ({}) }) - // verify-authentication - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ message: 'Success', token: 'abc' }), - }); + test('phone OTP option navigates to verify phone', async () => { + (isValidEmail as jest.Mock).mockReturnValue(false); + (isValidPhoneNumber as jest.Mock).mockReturnValue(true); + (isPasskeySupported as jest.Mock).mockResolvedValue(false); - mockStartAuthentication.mockResolvedValueOnce({ credential: '123' }); + render(); - await act(async () => { - render(); + fireEvent.change(screen.getByPlaceholderText(/email or phone number/i), { + target: { value: '+15555555555' }, }); - // Switch to login mode - fireEvent.click(screen.getByRole('button', { name: /Already have an account/i })); + const loginButton = await screen.findByRole('button', { name: /login/i }); - fireEvent.change(screen.getByLabelText(/Email Address \/ Phone Number/i), { - target: { value: 'user@example.com' }, + await act(async () => { + fireEvent.click(loginButton); }); - fireEvent.click(screen.getByRole('button', { name: /Login/i })); + const phoneOtp = await screen.findByText('PhoneOTP'); - await waitFor(() => { - expect(global.fetch).toHaveBeenCalledWith( - 'https://api.example.com/login', - expect.objectContaining({ method: 'POST' }) - ); + await act(async () => { + fireEvent.click(phoneOtp); }); - expect(mockValidateToken).toHaveBeenCalled(); - expect(mockNavigate).toHaveBeenCalledWith('/'); + expect(navigate).toHaveBeenCalledWith('/verifyPhoneOTP'); }); - it('shows error when login fails', async () => { - (global.fetch as jest.Mock).mockResolvedValueOnce({ ok: false }); + test('register mode submits registration', async () => { + (isValidEmail as jest.Mock).mockReturnValue(true); + (isValidPhoneNumber as jest.Mock).mockReturnValue(true); - await act(async () => { - render(); + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ message: 'Success' }), + }); + + render(); + + fireEvent.click(screen.getByText(/don't have an account/i)); + + fireEvent.change(screen.getByLabelText(/email address/i), { + target: { value: 'test@example.com' }, }); - fireEvent.click(screen.getByRole('button', { name: /Already have an account/i })); - fireEvent.change(screen.getByLabelText(/Email Address \/ Phone Number/i), { - target: { value: 'user@example.com' }, + fireEvent.change(screen.getByTestId('phone-input'), { + target: { value: '+15555555555' }, }); - fireEvent.click(screen.getByRole('button', { name: /Login/i })); + const registerButton = await screen.findByRole('button', { + name: /register/i, + }); - await waitFor(() => - expect( - screen.getByText( - /An unexpected error occured. Try again. If the problem persists, try resetting your password/i - ) - ).toBeInTheDocument() - ); + await waitFor(() => { + expect(registerButton).toBeEnabled(); + }); + + await act(async () => { + fireEvent.click(registerButton); + }); + + expect(navigate).toHaveBeenCalledWith('/verifyPhoneOTP'); }); });