Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(passport): Inclusion of magic links for email OTPs #2749

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading