Skip to content

Commit

Permalink
feat(passport): Inclusion of magic links for email OTPs (#2749)
Browse files Browse the repository at this point in the history
  • Loading branch information
Cosmin-Parvulescu authored Nov 8, 2023
1 parent 0a7bec6 commit 4ffa4ba
Show file tree
Hide file tree
Showing 8 changed files with 168 additions and 52 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ export const action: ActionFunction = getRollupReqFunctionErrorWrapper(

try {
await coreClient.account.generateEmailOTP.mutate({
passportURL: context.env.PASSPORT_URL,
clientId,
email,
themeProps: {
privacyURL: appProps.privacyURL as string,
Expand Down
21 changes: 19 additions & 2 deletions apps/passport/app/routes/authenticate/$clientId/email/verify.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,21 @@ import {
IdentityGroupURN,
IdentityGroupURNSpace,
} from '@proofzero/urns/identity-group'
import { AccountURNSpace } from '@proofzero/urns/account'

export const loader: LoaderFunction = getRollupReqFunctionErrorWrapper(
async ({ request, params }) => {
const cfReq = request as {
cf?: {
botManagement: {
score: number
}
}
}
const isBot =
cfReq.cf &&
cfReq.cf.botManagement.score <= 89 &&
!['localhost', '127.0.0.1'].includes(new URL(request.url).hostname)

const qp = new URL(request.url).searchParams

const email = qp.get('email')
Expand All @@ -49,10 +60,14 @@ export const loader: LoaderFunction = getRollupReqFunctionErrorWrapper(
if (!state)
throw new BadRequestError({ message: 'No state included in request' })

const code = qp.get('code')

return json({
email,
initialState: state,
clientId: params.clientId,
code,
isBot,
})
}
)
Expand Down Expand Up @@ -137,7 +152,7 @@ export default () => {
prompt?: string
}>()

const { email, initialState } = useLoaderData()
const { email, initialState, code, isBot } = useLoaderData()
const ad = useActionData()
const submit = useSubmit()
const navigate = useNavigate()
Expand Down Expand Up @@ -198,6 +213,8 @@ export default () => {
)
}}
goBack={() => history.back()}
autoVerify={!isBot}
code={code}
>
{errorMessage ? (
<Text
Expand Down
2 changes: 2 additions & 0 deletions apps/passport/app/routes/connect/email/otp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ export const loader: LoaderFunction = getRollupReqFunctionErrorWrapper(
}

const state = await coreClient.account.generateEmailOTP.mutate({
passportURL: new URL(request.url).host,
clientId,
email,
themeProps,
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ type EmailOTPValidatorProps = {
) => Promise<void>

regenerationTimerSeconds?: number

autoVerify?: boolean
code?: string
}

export default function EmailOTPValidator({
Expand All @@ -39,13 +42,15 @@ export default function EmailOTPValidator({
requestRegeneration,
requestVerification,
regenerationTimerSeconds = 30,
autoVerify = false,
code,
}: EmailOTPValidatorProps) {
const inputLen = 6
const inputRefs = Array.from({ length: inputLen }, () =>
useRef<HTMLInputElement>()
)

const [fullCode, setFullCode] = useState('')
const [fullCode, setFullCode] = useState(code ?? '')
const updateFullCode = useCallback(() => {
const updatedCode = inputRefs.map((ir) => ir.current?.value).join('')
setFullCode(updatedCode)
Expand Down Expand Up @@ -99,6 +104,24 @@ export default function EmailOTPValidator({
}
}, [email, fullCode, loadedState, loading, isInvalid])

useEffect(() => {
if (code && email && loadedState) {
const codeArray = code.split('').slice(0, inputLen)
codeArray.forEach((value, index) => {
inputRefs[index].current.value = value
})

if (autoVerify) {
setShowInvalidMessage(false)

const asyncFn = async () => {
await requestVerification(email, code, loadedState)
}
asyncFn()
}
}
}, [email, code, loadedState])

return (
<>
<section
Expand All @@ -111,13 +134,23 @@ export default function EmailOTPValidator({
onClick={goBack}
/>
)}
<Text
size="xl"
weight="semibold"
className="text-[#2D333A] dark:text-white"
>
Please check your email
</Text>
{code ? (
<Text
size="xl"
weight="semibold"
className="text-[#2D333A] dark:text-white"
>
Email Verification
</Text>
) : (
<Text
size="xl"
weight="semibold"
className="text-[#2D333A] dark:text-white"
>
Please check your email
</Text>
)}
</section>

<section>
Expand All @@ -138,6 +171,7 @@ export default function EmailOTPValidator({
ref={ref}
id={`code_${i}`}
name={`code_${i}`}
readOnly={Boolean(code) && !invalid}
required
maxLength={1}
minLength={1}
Expand Down Expand Up @@ -196,7 +230,7 @@ export default function EmailOTPValidator({
}}
className={`flex text-base lg:text-2xl py-7 px-3.5 h-20 justify-center items-center text-gray-600 dark:text-white dark:bg-gray-800 border rounded-lg text-center ${
isInvalid ? 'border-red-500' : 'dark:border-gray-600'
}`}
} read-only:bg-gray-100`}
/>
))}
</div>
Expand All @@ -211,44 +245,48 @@ export default function EmailOTPValidator({
</Text>
)}

<div className="flex flex-col lg:flex-row space-x-1 justify-center items-center mt-4">
<Text type="span" size="sm" className="text-gray-500">
Did not get the code?
</Text>
<Text
type="span"
size="sm"
className={`${
regenerationRequested ? 'text-gray-300' : 'text-indigo-500'
} cursor-pointer relative`}
onClick={() => {
if (regenerationRequested) return

setRegenerationRequested(true)
requestRegeneration(email)
setShowChildren(true)
}}
>
Click to send another
{regenerationRequested && (
<div className="absolute right-[-20px] top-[2.5px]">
<CountdownCircleTimer
size={16}
strokeWidth={2}
isPlaying
duration={regenerationTimerSeconds}
rotation={'counterclockwise'}
colors={'#6366f1'}
isGrowing={true}
onComplete={() => {
setRegenerationRequested(false)
setShowChildren(false)
}}
/>
</div>
{(!code || invalid) && (
<div className="flex flex-col lg:flex-row space-x-1 justify-center items-center mt-4">
{!code && (
<Text type="span" size="sm" className="text-gray-500">
Did not get the code?
</Text>
)}
</Text>
</div>
<Text
type="span"
size="sm"
className={`${
regenerationRequested ? 'text-gray-300' : 'text-indigo-500'
} cursor-pointer relative`}
onClick={() => {
if (regenerationRequested) return

setRegenerationRequested(true)
requestRegeneration(email)
setShowChildren(true)
}}
>
Click to send another
{regenerationRequested && (
<div className="absolute right-[-20px] top-[2.5px]">
<CountdownCircleTimer
size={16}
strokeWidth={2}
isPlaying
duration={regenerationTimerSeconds}
rotation={'counterclockwise'}
colors={'#6366f1'}
isGrowing={true}
onComplete={() => {
setRegenerationRequested(false)
setShowChildren(false)
}}
/>
</div>
)}
</Text>
</div>
)}

{children && showChildren && <div className="my-3">{children}</div>}
</section>
Expand All @@ -275,7 +313,7 @@ export default function EmailOTPValidator({
await requestVerification(email, fullCode, loadedState)
}}
>
Verify
{loading ? `Verifying...` : `Verify`}
</Button>
</section>
</>
Expand Down
7 changes: 6 additions & 1 deletion platform/account/src/jsonrpc/methods/generateEmailOTP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { EmailThemePropsSchema } from '../../../../email/src/emailFunctions'

export const GenerateEmailOTPInput = z.object({
email: z.string(),
clientId: z.string(),
passportURL: z.string().url(),
themeProps: EmailThemePropsSchema.optional(),
preview: z.boolean().optional(),
})
Expand All @@ -26,7 +28,7 @@ export const generateEmailOTPMethod = async ({
input: GenerateEmailOTPParams
ctx: Context
}): Promise<string> => {
const { email, themeProps, preview } = input
const { email, themeProps, preview, clientId, passportURL } = input
const emailAccountNode = new EmailAccount(ctx.account as AccountNode, ctx.env)

const state = generateRandomString(EMAIL_VERIFICATION_OPTIONS.STATE_LENGTH)
Expand All @@ -38,10 +40,13 @@ export const generateEmailOTPMethod = async ({
)

await ctx.emailClient.sendOTP.mutate({
clientId,
state,
emailAddress: email,
name: email,
otpCode: code,
themeProps,
passportURL,
})
return state
}
31 changes: 31 additions & 0 deletions platform/email/emailTemplate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ export const darkModeStyles = `
#passcode {
background-color: #2D3748 !important;
}
.primary-button {
background: #6366f1 !important;
color: #ffffff !important;
}
.footer-links {
color: #E2E8F0 !important;
border-bottom-color: #E2E8F0 !important;
Expand All @@ -38,6 +42,10 @@ export const lightModeStyles = `
#passcode {
background-color: #f3f4f6 !important;
}
.primary-button {
background: #6366f1 !important;
color: #ffffff !important;
}
.footer-links {
color: #6b7280 !important;
border-bottom-color: #6b7280 !important;
Expand Down Expand Up @@ -123,6 +131,23 @@ const EmailTemplateBase = (
padding: 15px 0;
}
.primary-button {
margin-top: 20px;
margin-bottom: 20px;
display: block;
padding: 13px 25px;
justify-content: center;
align-items: center;
align-self: stretch;
border-radius: 6px;
font-size: 16px;
font-style: normal;
font-weight: 500;
line-height: 24px;
text-decoration: none;
text-align: center;
}
.divider {
border-bottom: 1px solid #e5e7eb;
width: 100%;
Expand Down Expand Up @@ -235,12 +260,18 @@ const EmailTemplateBase = (

export const EmailTemplateOTP = (
passcode: string,
clientId: string,
email: string,
state: string,
passportURL: string,
params: EmailTemplateParams
): EmailContent => {
const content = `
<div class="heading" style="font-size: 36px; font-weight: bold; line-height: 44px; margin-bottom: 16px;">Confirm Your Email Address</div>
<p style="font-size: 16px; font-weight: normal; line-height: 24px; margin-bottom: 16px;">Please copy the code below into the email verification screen.</p>
<div style="width: 100%; text-align: center; font-size: 46px; font-weight: bold; border-radius: 8px; margin-top: 20px; margin-bottom: 20px; padding: 15px 0; background-color: #f3f4f6;" id="passcode">${passcode}</div>
<p style="font-size: 16px; font-weight: normal; line-height: 24px; margin-bottom: 16px;">Or submit the code by clicking button below</p>
<a class="primary-button" style="margin-top: 20px; margin-bottom: 20px; padding: 13px 25px; justify-content: center; align-items: center; align-self: stretch; border-radius: 6px; background: #6366f1; color: #ffffff; font-size: 16px; font-style: normal; font-weight: 500; line-height: 24px; text-decoration: none; display: block; text-align: center;" href="${passportURL}/authenticate/${clientId}/email/verify?email=${email}&state=${state}&code=${passcode}">Verify Email Address</a>
<p style="font-size: 16px; font-weight: normal; line-height: 24px; margin-bottom: 16px;">Please note: the code will be valid for the next 10 minutes.</p>
<p style="font-size: 16px; font-weight: normal; line-height: 24px; margin-bottom: 16px;">
If you didn&apos;t request this email, there&apos;s nothing to worry about - you can safely ignore it.
Expand Down
13 changes: 12 additions & 1 deletion platform/email/src/emailFunctions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,11 +183,22 @@ const adjustEmailParams = (params?: Partial<EmailTemplateParams>) => {
/** OTP email content template with a `code` parameter */
export const getOTPEmailContent = (
passcode: string,
clientId: string,
state: string,
email: string,
passportURL: string,
params?: Partial<EmailTemplateParams>
): EmailContent => {
params = adjustEmailParams(params)

return EmailTemplateOTP(passcode, params as EmailTemplateParams)
return EmailTemplateOTP(
passcode,
clientId,
email,
state,
passportURL,
params as EmailTemplateParams
)
}

/** Subscription Cancellation email content template */
Expand Down
Loading

0 comments on commit 4ffa4ba

Please sign in to comment.