From d3327074f881a8bdafc0864d3622a99f84c9dbc3 Mon Sep 17 00:00:00 2001 From: Sergey Kambalin Date: Wed, 17 Jul 2024 13:00:05 +0600 Subject: [PATCH] feature: Magic Link authentication client support (#205) --- CHANGELOG.md | 1 + .../ui/src/components/Loading/Loading.tsx | 12 +++- .../ui/src/components/OtpInput/OtpInput.tsx | 8 ++- src/api/auth-api.service.ts | 41 ++++++++++-- src/components/Login/OtpPage.tsx | 24 ++++--- src/hooks/usePopupStore.ts | 12 ++-- src/routes/AuthorizationRouter.tsx | 2 + src/routes/Authorize/AuthorizeLink.tsx | 63 +++++++++++++++++++ src/routes/Authorize/AuthorizeOtp.tsx | 28 ++++++--- src/routes/Authorize/index.ts | 1 + .../AuthorizePopupStore.ts | 24 +++++-- .../createAuthLinkResource.ts | 31 +++++++++ 12 files changed, 210 insertions(+), 37 deletions(-) create mode 100644 src/routes/Authorize/AuthorizeLink.tsx create mode 100644 src/stores/AuthorizePopupStore/createAuthLinkResource.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e3c2d157..3d751b55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - Add support for externally provided email to login with - Make it possible to change permission title and description - Store wallet permission to connected application and don't ask on each login +- Add `Magic Link` authentication support ### v1.38.0 diff --git a/packages/ui/src/components/Loading/Loading.tsx b/packages/ui/src/components/Loading/Loading.tsx index 199f8781..9cd9230f 100644 --- a/packages/ui/src/components/Loading/Loading.tsx +++ b/packages/ui/src/components/Loading/Loading.tsx @@ -2,6 +2,7 @@ import { PropsWithChildren } from 'react'; import { styled, Box, CircularProgress, CircularProgressProps } from '@mui/material'; export type LoadingProps = PropsWithChildren & { + hideSpinner?: boolean; fullScreen?: boolean; }; @@ -27,11 +28,18 @@ const Fullscreen = styled('div')({ alignItems: 'center', }); -export const Loading = ({ sx, fullScreen = false, size = 60, children, ...props }: LoadingProps) => { +export const Loading = ({ + sx, + hideSpinner = false, + fullScreen = false, + size = 60, + children, + ...props +}: LoadingProps) => { const spinner = ( {children && {children}} - + {!hideSpinner && } ); diff --git a/packages/ui/src/components/OtpInput/OtpInput.tsx b/packages/ui/src/components/OtpInput/OtpInput.tsx index 7331582e..d4783dfa 100644 --- a/packages/ui/src/components/OtpInput/OtpInput.tsx +++ b/packages/ui/src/components/OtpInput/OtpInput.tsx @@ -1,8 +1,9 @@ -import { OTPInput, SlotProps } from 'input-otp'; +import { OTPInput as OTPField, SlotProps } from 'input-otp'; import { styled, Typography, Stack, Box } from '@cere-wallet/ui'; import { forwardRef } from 'react'; interface OtpProps { + value?: string; errorMessage?: string; onChange?: (code: string) => void; } @@ -40,13 +41,14 @@ const Slot = ({ char, isActive, error }: SlotProps & SlotInputProps) => ( ); -export const OtpInput = forwardRef(({ onChange, errorMessage }, ref) => { +export const OtpInput = forwardRef(({ value, onChange, errorMessage }, ref) => { const hasError = !!errorMessage; return ( - { - let result: AxiosResponse> | null = null; + public static async sendOtp(email: string): Promise { + let result: AxiosResponse> | null = null; + try { - result = await api.post>('/auth/otp/send', { email }); + result = await api.post('/auth/otp/send', { email }); } catch (err: any) { reportError(err); } - return result?.data?.code === 'SUCCESS'; + return result?.data?.data?.authLinkCode || null; } public static async getTokenByEmail(email: string, code: string): Promise { @@ -53,4 +58,28 @@ export class AuthApiService { } return result?.data.code === 'SUCCESS' ? result?.data.data.token : null; } + + public static async validateLink(email: string, authLinkCode: string, otp: string): Promise { + let result: AxiosResponse> | null = null; + + try { + result = await api.post>('/auth/otp/validate', { email, otp, authLinkCode }); + } catch (err: any) { + reportError(err); + } + + return result?.data?.code === 'SUCCESS'; + } + + public static async getTokenByLink(email: string, authLinkCode: string): Promise { + let result: AxiosResponse> | null = null; + + try { + result = await api.post('/auth/token-by-link', { email, authLinkCode }); + } catch (err: any) { + reportError(err); + } + + return result?.data?.data || null; + } } diff --git a/src/components/Login/OtpPage.tsx b/src/components/Login/OtpPage.tsx index 318f56f4..38f0d1a2 100644 --- a/src/components/Login/OtpPage.tsx +++ b/src/components/Login/OtpPage.tsx @@ -12,7 +12,7 @@ import { import { useEffect, useMemo, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import * as yup from 'yup'; -import { SubmitHandler, useForm } from 'react-hook-form'; +import { SubmitHandler, useForm, Controller } from 'react-hook-form'; import { yupResolver } from '@hookform/resolvers/yup'; import { reportError } from '~/reporting'; @@ -24,6 +24,8 @@ const TIME_LEFT = 60; // seconds before next otp request interface OtpProps { email?: string; + busy?: boolean; + code?: string; onRequestLogin: (idToken: string) => void | Promise; } @@ -33,7 +35,7 @@ const validationSchema = yup }) .required(); -export const OtpPage = ({ email, onRequestLogin }: OtpProps) => { +export const OtpPage = ({ email, onRequestLogin, busy = false, code }: OtpProps) => { const location = useLocation(); const navigate = useNavigate(); const [timeLeft, setTimeLeft] = useState(TIME_LEFT); @@ -43,7 +45,7 @@ export const OtpPage = ({ email, onRequestLogin }: OtpProps) => { const verifyScreenSettings = store?.whiteLabel?.verifyScreenSettings; const { - register, + control, handleSubmit, setError, getValues: getFormValues, @@ -57,6 +59,12 @@ export const OtpPage = ({ email, onRequestLogin }: OtpProps) => { }, }); + useEffect(() => { + if (code) { + setFormValue('code', code); + } + }, [setFormValue, code]); + const onSubmit: SubmitHandler = async () => { const value = getFormValues('code'); const token = await AuthApiService.getTokenByEmail(email!, value); @@ -149,10 +157,10 @@ export const OtpPage = ({ email, onRequestLogin }: OtpProps) => { Verification code - setFormValue('code', val)} - errorMessage={errors?.code?.message} + } /> {errors.root && ( @@ -161,7 +169,7 @@ export const OtpPage = ({ email, onRequestLogin }: OtpProps) => { )} - + {errors.root ? 'Retry' : 'Verify'} {timeLeft ? ( diff --git a/src/hooks/usePopupStore.ts b/src/hooks/usePopupStore.ts index 33bfe6c9..9fd4b323 100644 --- a/src/hooks/usePopupStore.ts +++ b/src/hooks/usePopupStore.ts @@ -8,11 +8,13 @@ export const usePopupStore = (storeFactory: (popupId: string, local: boolean) const context = useRouteElementContext(); const { search, state } = useLocation(); - const popupId = useMemo( - () => - context?.preopenInstanceId || state?.preopenInstanceId || new URLSearchParams(search).get('preopenInstanceId'), - [search, state, context], - ); + const popupId = useMemo(() => { + const params = new URLSearchParams(search); + + return ( + context?.preopenInstanceId || state?.preopenInstanceId || params.get('preopenInstanceId') || params.get('popupId') + ); + }, [search, state, context]); if (!popupId) { throw Error('No `preopenInstanceId` found in query'); diff --git a/src/routes/AuthorizationRouter.tsx b/src/routes/AuthorizationRouter.tsx index 96941a83..59a06aaa 100644 --- a/src/routes/AuthorizationRouter.tsx +++ b/src/routes/AuthorizationRouter.tsx @@ -10,6 +10,7 @@ import { AuthorizeOtp, AuthorizePermissions, AuthorizeComplete, + AuthorizeLink, } from './Authorize'; export const AuthorizationRouter = () => { @@ -39,6 +40,7 @@ export const AuthorizationRouter = () => { } /> } /> } /> + } /> ); diff --git a/src/routes/Authorize/AuthorizeLink.tsx b/src/routes/Authorize/AuthorizeLink.tsx new file mode 100644 index 00000000..b8a3ce8f --- /dev/null +++ b/src/routes/Authorize/AuthorizeLink.tsx @@ -0,0 +1,63 @@ +import { useEffect, useMemo, useState } from 'react'; +import { observer } from 'mobx-react-lite'; +import { useSearchParams } from 'react-router-dom'; +import { CheckCircleIcon, CancelIcon, Loading, Logo, Stack, Typography } from '@cere-wallet/ui'; +import { AuthApiService } from '~/api/auth-api.service'; + +type LinkState = [string, string, string, string]; + +const parseState = (state: string | null): LinkState | undefined => { + try { + return state && JSON.parse(atob(state)); + } catch (e) { + return undefined; + } +}; + +const AuthorizeLink = () => { + const [validationError, setError] = useState(false); + const [success, setSuccess] = useState(false); + const [query] = useSearchParams(); + const encodedState = query.get('state'); + const state = useMemo(() => parseState(encodedState), [encodedState]); + const [email, otp, linkCode, appName] = state || []; + const error = !state || validationError; + + useEffect(() => { + if (!email || !linkCode || !otp) { + return; + } + + AuthApiService.validateLink(email, linkCode, otp).then((success) => { + return success ? setSuccess(true) : setError(true); + }); + }, [email, linkCode, otp]); + + if (!error && !success) { + return ( + + + + ); + } + + const Icon = success ? CheckCircleIcon : CancelIcon; + const title = success ? 'Login Success!' : 'Login Error!'; + const message = success + ? `You can now close this window and continue using the ${appName || 'Cere Wallet'}.` + : 'The login link is invalid or expired. Please try again.'; + + return ( + + + + {title} + + + {message} + + + ); +}; + +export default observer(AuthorizeLink); diff --git a/src/routes/Authorize/AuthorizeOtp.tsx b/src/routes/Authorize/AuthorizeOtp.tsx index 222da6f7..025c21e1 100644 --- a/src/routes/Authorize/AuthorizeOtp.tsx +++ b/src/routes/Authorize/AuthorizeOtp.tsx @@ -1,5 +1,5 @@ import { observer } from 'mobx-react-lite'; -import { useCallback, useEffect } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { Stack, useIsMobile, useTheme, ArrowBackIosIcon } from '@cere-wallet/ui'; import { useLocation, useNavigate, useOutletContext } from 'react-router-dom'; @@ -16,6 +16,8 @@ const AuthorizeOtp = ({ sendOtp }: AuthorizeOtpProps) => { const isMobile = useIsMobile(); const location = useLocation(); const navigate = useNavigate(); + const [isBusy, setBusy] = useState(false); + const [autoOtp, setAutoOtp] = useState(); const store = useOutletContext(); const { whiteLabel } = useAppContextStore(); const { isGame } = useTheme(); @@ -25,14 +27,9 @@ const AuthorizeOtp = ({ sendOtp }: AuthorizeOtpProps) => { store.email = location.state?.email; } - useEffect(() => { - if (sendOtp) { - store.sendOtp(); - } - }, [sendOtp, store]); - const handleLoginRequest = useCallback( async (idToken: string) => { + setBusy(true); const { isNewUser } = await store.login(idToken); if ( @@ -48,10 +45,23 @@ const AuthorizeOtp = ({ sendOtp }: AuthorizeOtpProps) => { } await store.acceptSession(); + setBusy(false); }, [location, navigate, store, whiteLabel], ); + useEffect(() => { + if (sendOtp) { + store.sendOtp(); + } + + store.waitForAuthLinkToken(async ({ token, code }) => { + setAutoOtp(code); + + await handleLoginRequest(token); + }); + }, [handleLoginRequest, sendOtp, store]); + if (isMobile) { return ( { {hasBackButton && navigate(-1)} />} - + ); @@ -83,7 +93,7 @@ const AuthorizeOtp = ({ sendOtp }: AuthorizeOtpProps) => { - + diff --git a/src/routes/Authorize/index.ts b/src/routes/Authorize/index.ts index e5598dc9..77856a35 100644 --- a/src/routes/Authorize/index.ts +++ b/src/routes/Authorize/index.ts @@ -6,3 +6,4 @@ export { default as AuthorizeIntro } from './AuthorizeIntro'; export { default as AuthorizeLogin } from './AuthorizeLogin'; export { default as AuthorizeOtp } from './AuthorizeOtp'; export { default as AuthorizeComplete } from './AuthorizeComplete'; +export { default as AuthorizeLink } from './AuthorizeLink'; diff --git a/src/stores/AuthorizePopupStore/AuthorizePopupStore.ts b/src/stores/AuthorizePopupStore/AuthorizePopupStore.ts index 97b0b2a3..0060919c 100644 --- a/src/stores/AuthorizePopupStore/AuthorizePopupStore.ts +++ b/src/stores/AuthorizePopupStore/AuthorizePopupStore.ts @@ -10,6 +10,7 @@ import { createSharedPopupState } from '../sharedState'; import { createRedirectUrl } from './createRedirectUrl'; import { Wallet } from '../types'; import { AuthApiService } from '~/api/auth-api.service'; +import { createAuthLinkResource, AuthLinkResource, AuthLinkResourcePayload } from './createAuthLinkResource'; type AuthenticationResult = { sessionId: string; @@ -47,6 +48,7 @@ export class AuthorizePopupStore { private mfaCheckPromise?: Promise; private selectedPermissions: PermissionRequest = {}; private appPermissions: PermissionRequest = {}; + private authLinkResource?: AuthLinkResource; constructor(private wallet: Wallet, private options: AuthorizePopupStoreOptions) { makeAutoObservable(this); @@ -107,6 +109,8 @@ export class AuthorizePopupStore { } async login(idToken: string): Promise { + this.authLinkResource?.dispose(); + const isMfa = await this.mfaCheckPromise?.catch((error) => { reportError(error); @@ -172,6 +176,13 @@ export class AuthorizePopupStore { return new Promise(() => {}); } + waitForAuthLinkToken(callback: (payload: AuthLinkResourcePayload) => Promise) { + return reaction( + () => this.authLinkResource?.current(), + (payload) => payload && callback(payload), + ); + } + async sendOtp(email?: string) { const toEmail = email || this.email; @@ -179,12 +190,17 @@ export class AuthorizePopupStore { throw new Error('Email is required to send OTP'); } - const isSent = await AuthApiService.sendOtp(toEmail); + const authLinkCode = await AuthApiService.sendOtp(toEmail); + + if (authLinkCode) { + this.authLinkResource?.dispose(); - if (isSent) { - this.email = toEmail; + runInAction(() => { + this.email = toEmail; + this.authLinkResource = createAuthLinkResource(toEmail, authLinkCode); + }); } - return isSent; + return !!authLinkCode; } } diff --git a/src/stores/AuthorizePopupStore/createAuthLinkResource.ts b/src/stores/AuthorizePopupStore/createAuthLinkResource.ts new file mode 100644 index 00000000..172f1167 --- /dev/null +++ b/src/stores/AuthorizePopupStore/createAuthLinkResource.ts @@ -0,0 +1,31 @@ +import { fromResource, IResource } from 'mobx-utils'; +import { AuthApiService, TokenByLinkData } from '~/api/auth-api.service'; + +export type AuthLinkResourcePayload = TokenByLinkData; +export type AuthLinkResource = IResource; + +export const createAuthLinkResource = (email: string, linkCode: string): AuthLinkResource => { + let timeout: NodeJS.Timeout; + + const start = async (run: () => Promise) => { + if (await run()) { + return clearTimeout(timeout); + } + + timeout = setTimeout(() => start(run), 1000); + }; + + return fromResource( + (sink) => + start(async () => { + const data = await AuthApiService.getTokenByLink(email, linkCode); + + if (data) { + sink(data); + } + + return !!data; + }), + () => clearTimeout(timeout), + ); +};