Skip to content

Commit

Permalink
Email magic links and SMS OTP MFA example (#164)
Browse files Browse the repository at this point in the history
New recipe featuring email magic links as a first factor of authentication and SMS OTPs as a step-up factor of authentication

---------

Co-authored-by: Basia Sudol 
Co-authored-by: Naina Amballa
  • Loading branch information
ashWeaver-Stytch authored May 17, 2024
1 parent 6b660e5 commit d7321ee
Show file tree
Hide file tree
Showing 17 changed files with 953 additions and 2,432 deletions.
109 changes: 109 additions & 0 deletions components/EmailSMS/LoginWithEmail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import React, { ChangeEventHandler, FormEventHandler, useState } from 'react';
import { sendEML } from '../../lib/emlUtils';

const STATUS = {
INIT: 0,
SENT: 1,
ERROR: 2,
};

const EML_REDIRECT = '/recipes/api-sms-mfa/magic-link-authenticate';

const LoginWithSMSMFA = () => {
const [emlSent, setEMLSent] = useState(STATUS.INIT);
const [email, setEmail] = useState('');
const [isDisabled, setIsDisabled] = useState(true);

const isValidEmail = (emailValue: string) => {
// Overly simple email address regex
const regex = /\S+@\S+\.\S+/;
return regex.test(emailValue);
};

const onEmailChange: ChangeEventHandler<HTMLInputElement> = (e) => {
setEmail(e.target.value);
setIsDisabled(!isValidEmail(e.target.value));
};

const onSubmit: FormEventHandler = async (e) => {
e.preventDefault();
// Disable button right away to prevent sending emails twice
if (isDisabled) {
return;
}
setIsDisabled(true);

if (isValidEmail(email)) {
const resp = await sendEML(email, EML_REDIRECT, EML_REDIRECT);
if (resp.status === 200) {
setEMLSent(STATUS.SENT);
} else {
setEMLSent(STATUS.ERROR);
}
}
};

const handleTryAgain = (e: any) => {
e.preventDefault();
e.stopPropagation();
setEMLSent(STATUS.INIT);
setEmail('');
};

return (
<>
{emlSent === STATUS.INIT && (
<div>
<h2>Sign up or log in</h2>
<p>
Make sure to add the appropriate Redirect URL in your{' '}
<a className="link" href="https://stytch.com/dashboard/redirect-urls" target="_blank" rel="noreferrer">
Stytch Dashboard
</a>
.
</p>
<form onSubmit={onSubmit}>
<input
style={styles.emailInput}
placeholder="[email protected]"
value={email}
onChange={onEmailChange}
type="email"
/>
<button className="full-width" disabled={isDisabled} id="button" type="submit">
Continue
</button>
</form>
</div>
)}
{emlSent === STATUS.SENT && (
<div>
<h2>Check your email</h2>
<p>{`An email was sent to ${email}`}</p>
<a className="link" onClick={handleTryAgain}>
Click here to try again.
</a>
</div>
)}
{emlSent === STATUS.ERROR && (
<div>
<h2>Something went wrong!</h2>
<p>{`Failed to send email to ${email}`}</p>
<a className="link" onClick={handleTryAgain}>
Click here to try again.
</a>
</div>
)}
</>
);
};

const styles: Record<string, React.CSSProperties> = {
emailInput: {
width: '100%',
fontSize: '18px',
marginBottom: '8px',
},
};

export default LoginWithSMSMFA;
166 changes: 166 additions & 0 deletions components/EmailSMS/SMSOTPButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import React, { useState } from 'react';
import { sendOTP, authOTP } from '../../lib/otpUtils';
import { useRouter } from 'next/router';

interface SMSOTPButtonProps {
phoneNumber: string;
}

function formatPhoneNumber(phoneNumber: string): string {
const cleaned = phoneNumber.replace(/\D/g, '').replace(/^1/, '');
const match = cleaned.match(/^(\d{3})(\d{3})(\d{4})$/);
if (match) {
return `(${match[1]}) ${match[2]}-${match[3]}`;
}
return phoneNumber;
}

function SMSOTPButton({ phoneNumber }: SMSOTPButtonProps) {
const router = useRouter();
const [openModal, setOpenModal] = useState(false); // State variable to control the modal visibility
const [otp, setOTP] = useState(''); // State variable to store the OTP input by the user
const [methodId, setMethodId] = useState(''); // State variable to store the method ID

const authenticate = async () => {
try {
const response = await sendOTP(phoneNumber);

// Check if response is empty
if (!response) {
console.error('Empty response received from sendOTP');
return;
}

const responseData = await response;
setMethodId(responseData.phone_id);

// Set state to open the modal
setOpenModal(true);

} catch (error) {
// Handle errors here, e.g., display an error message
console.error('Failed to send OTP:', error);
}
};

const handleModalClose = () => {
// Clear OTP input and close the modal
setOTP('');
setOpenModal(false);
};

const handleOTPSubmit = async () => {
try {
// Call the authOTP function with methodID and otp
console.log('METHOD', methodId);
await authOTP(methodId, otp);

// Redirect to profile page
router.push('./profile');
} catch (error) {
// Handle errors here, e.g., display an error message
console.error('Failed to authenticate OTP:', error);
}
};

const handleResend = (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
e.preventDefault();
authenticate();
};

return (
<div>
<button className="full-width" onClick={authenticate}>
Authenticate
</button>

{/* Modal for OTP input */}
{openModal && (
<div style={styles.modalOverlay}>
<div style={styles.modal}>
<span style={styles.close} onClick={handleModalClose}>&times;</span>
<h2>Enter Passcode</h2>
<p>
A 6-digit passcode was sent to you at <strong>{formatPhoneNumber(phoneNumber)}</strong>.
</p>
<input
style={styles.otpInput}
type="text"
value={otp}
onChange={(e) => setOTP(e.target.value)}
placeholder="Enter OTP"
/>
<p style={styles.smsDisclaimer}>
Didn&apos;t receive a code? <a style={styles.smsDisclaimer} href="#" onClick={handleResend}>Resend</a>
</p>
<button className="full-width" onClick={handleOTPSubmit}>Submit</button>
</div>
</div>
)}
</div>
);
}

const styles: Record<string, React.CSSProperties> = {
modalOverlay: {
position: 'fixed',
top: 0,
left: 0,
width: '100%',
height: '100%',
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
},
modal: {
position: 'relative',
backgroundColor: 'white',
padding: 20,
borderRadius: '0 3px 3px 0',
boxShadow: '0 0 10px rgba(0, 0, 0, 0.2)',
},
close: {
position: 'absolute',
top: 10,
right: 18,
cursor: 'pointer',
},
telInput: {
display: 'flex',
alignItems: 'center',
border: '1px solid #ccc',
borderRadius: '3px',
marginBottom: '10px',
},
flag: {
background: 'url("/stars-and-stripes.png") no-repeat scroll 8px 16px',
paddingLeft: 40,
borderRadius: 0,
width: 75,
border: 'none',
},
phoneNumber: {
border: 'none',
paddingLeft: 10,
fontSize: 18,
flexGrow: 1,
width: 'calc(100%)',
},
smsDisclaimer: {
color: '#5c727d',
fontSize: 14,
marginBottom: 16,
marginTop: 15,
},
otpInput: {
display: 'flex',
alignItems: 'center',
border: '1px solid #ccc',
borderRadius: '3px',
marginBottom: '10px',
width: '100%',
}
};

export default SMSOTPButton;
Loading

0 comments on commit d7321ee

Please sign in to comment.