-
Notifications
You must be signed in to change notification settings - Fork 20
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Email magic links and SMS OTP MFA example (#164)
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
1 parent
6b660e5
commit d7321ee
Showing
17 changed files
with
953 additions
and
2,432 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}>×</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'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; |
Oops, something went wrong.