From 4ffa4baa45a1d0dd7380a4f724dcf4eca6d13d0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cosmin=20P=C3=A2rvulescu?= Date: Wed, 8 Nov 2023 15:42:20 +0200 Subject: [PATCH] feat(passport): Inclusion of magic links for email OTPs (#2749) --- .../apps/$clientId/designer.otp.preview.tsx | 2 + .../authenticate/$clientId/email/verify.tsx | 21 ++- apps/passport/app/routes/connect/email/otp.ts | 2 + .../email-otp-validator/EmailOTPValidator.tsx | 132 +++++++++++------- .../src/jsonrpc/methods/generateEmailOTP.ts | 7 +- platform/email/emailTemplate.ts | 31 ++++ platform/email/src/emailFunctions.ts | 13 +- .../email/src/jsonrpc/methods/sendOTPEmail.ts | 12 +- 8 files changed, 168 insertions(+), 52 deletions(-) diff --git a/apps/console/app/routes/apps/$clientId/designer.otp.preview.tsx b/apps/console/app/routes/apps/$clientId/designer.otp.preview.tsx index b0d9564d91..2f6639f2f6 100644 --- a/apps/console/app/routes/apps/$clientId/designer.otp.preview.tsx +++ b/apps/console/app/routes/apps/$clientId/designer.otp.preview.tsx @@ -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, diff --git a/apps/passport/app/routes/authenticate/$clientId/email/verify.tsx b/apps/passport/app/routes/authenticate/$clientId/email/verify.tsx index a94a2379ee..9be96504e4 100644 --- a/apps/passport/app/routes/authenticate/$clientId/email/verify.tsx +++ b/apps/passport/app/routes/authenticate/$clientId/email/verify.tsx @@ -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') @@ -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, }) } ) @@ -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() @@ -198,6 +213,8 @@ export default () => { ) }} goBack={() => history.back()} + autoVerify={!isBot} + code={code} > {errorMessage ? ( Promise regenerationTimerSeconds?: number + + autoVerify?: boolean + code?: string } export default function EmailOTPValidator({ @@ -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() ) - const [fullCode, setFullCode] = useState('') + const [fullCode, setFullCode] = useState(code ?? '') const updateFullCode = useCallback(() => { const updatedCode = inputRefs.map((ir) => ir.current?.value).join('') setFullCode(updatedCode) @@ -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 ( <>
)} - - Please check your email - + {code ? ( + + Email Verification + + ) : ( + + Please check your email + + )}
@@ -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} @@ -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`} /> ))} @@ -211,44 +245,48 @@ export default function EmailOTPValidator({ )} -
- - Did not get the code? - - { - if (regenerationRequested) return - - setRegenerationRequested(true) - requestRegeneration(email) - setShowChildren(true) - }} - > - Click to send another - {regenerationRequested && ( -
- { - setRegenerationRequested(false) - setShowChildren(false) - }} - /> -
+ {(!code || invalid) && ( +
+ {!code && ( + + Did not get the code? + )} - -
+ { + if (regenerationRequested) return + + setRegenerationRequested(true) + requestRegeneration(email) + setShowChildren(true) + }} + > + Click to send another + {regenerationRequested && ( +
+ { + setRegenerationRequested(false) + setShowChildren(false) + }} + /> +
+ )} +
+
+ )} {children && showChildren &&
{children}
}
@@ -275,7 +313,7 @@ export default function EmailOTPValidator({ await requestVerification(email, fullCode, loadedState) }} > - Verify + {loading ? `Verifying...` : `Verify`} diff --git a/platform/account/src/jsonrpc/methods/generateEmailOTP.ts b/platform/account/src/jsonrpc/methods/generateEmailOTP.ts index ebf4c9f902..ed741f86ee 100644 --- a/platform/account/src/jsonrpc/methods/generateEmailOTP.ts +++ b/platform/account/src/jsonrpc/methods/generateEmailOTP.ts @@ -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(), }) @@ -26,7 +28,7 @@ export const generateEmailOTPMethod = async ({ input: GenerateEmailOTPParams ctx: Context }): Promise => { - 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) @@ -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 } diff --git a/platform/email/emailTemplate.ts b/platform/email/emailTemplate.ts index c078ddbe88..6dcc7ccecc 100644 --- a/platform/email/emailTemplate.ts +++ b/platform/email/emailTemplate.ts @@ -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; @@ -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; @@ -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%; @@ -235,12 +260,18 @@ const EmailTemplateBase = ( export const EmailTemplateOTP = ( passcode: string, + clientId: string, + email: string, + state: string, + passportURL: string, params: EmailTemplateParams ): EmailContent => { const content = `
Confirm Your Email Address

Please copy the code below into the email verification screen.

${passcode}
+

Or submit the code by clicking button below

+ Verify Email Address

Please note: the code will be valid for the next 10 minutes.

If you didn't request this email, there's nothing to worry about - you can safely ignore it. diff --git a/platform/email/src/emailFunctions.ts b/platform/email/src/emailFunctions.ts index 6fceab5231..8fa5cb93ed 100644 --- a/platform/email/src/emailFunctions.ts +++ b/platform/email/src/emailFunctions.ts @@ -183,11 +183,22 @@ const adjustEmailParams = (params?: Partial) => { /** OTP email content template with a `code` parameter */ export const getOTPEmailContent = ( passcode: string, + clientId: string, + state: string, + email: string, + passportURL: string, params?: Partial ): 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 */ diff --git a/platform/email/src/jsonrpc/methods/sendOTPEmail.ts b/platform/email/src/jsonrpc/methods/sendOTPEmail.ts index 52577b48c4..c3a74f9e75 100644 --- a/platform/email/src/jsonrpc/methods/sendOTPEmail.ts +++ b/platform/email/src/jsonrpc/methods/sendOTPEmail.ts @@ -12,7 +12,10 @@ export const sendOTPEmailMethodInput = z.object({ name: z.string(), emailAddress: z.string(), otpCode: z.string(), + state: z.string(), + clientId: z.string(), themeProps: EmailThemePropsSchema.optional(), + passportURL: z.string().url(), }) export type sendOTPEmailMethodParams = z.infer @@ -30,7 +33,14 @@ export const sendOTPMethod = async ({ input: sendOTPEmailMethodParams ctx: Context }): Promise => { - const otpEmailTemplate = getOTPEmailContent(input.otpCode, input.themeProps) + const otpEmailTemplate = getOTPEmailContent( + input.otpCode, + input.clientId, + input.state, + input.emailAddress, + input.passportURL, + input.themeProps + ) const { env, notification, customSender } = getEmailContent({ ctx, address: input.emailAddress,