Skip to content

Commit

Permalink
feature: Magic Link authentication client support (#205)
Browse files Browse the repository at this point in the history
  • Loading branch information
skambalin authored Jul 17, 2024
1 parent 64991a2 commit d332707
Show file tree
Hide file tree
Showing 12 changed files with 210 additions and 37 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 10 additions & 2 deletions packages/ui/src/components/Loading/Loading.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { PropsWithChildren } from 'react';
import { styled, Box, CircularProgress, CircularProgressProps } from '@mui/material';

export type LoadingProps = PropsWithChildren<CircularProgressProps> & {
hideSpinner?: boolean;
fullScreen?: boolean;
};

Expand All @@ -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 = (
<Box sx={sx} position="relative" display="inline-block" width={size} height={size}>
{children && <Content>{children}</Content>}
<CircularProgress {...props} size={size} />
{!hideSpinner && <CircularProgress {...props} size={size} />}
</Box>
);

Expand Down
8 changes: 5 additions & 3 deletions packages/ui/src/components/OtpInput/OtpInput.tsx
Original file line number Diff line number Diff line change
@@ -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;
}
Expand Down Expand Up @@ -40,13 +41,14 @@ const Slot = ({ char, isActive, error }: SlotProps & SlotInputProps) => (
</SlotInput>
);

export const OtpInput = forwardRef<null, OtpProps>(({ onChange, errorMessage }, ref) => {
export const OtpInput = forwardRef<null, OtpProps>(({ value, onChange, errorMessage }, ref) => {
const hasError = !!errorMessage;

return (
<Box display="flex" flexDirection="column" alignItems="center">
<OTPInput
<OTPField
ref={ref}
value={value}
autoFocus
maxLength={6}
onChange={onChange}
Expand Down
41 changes: 35 additions & 6 deletions src/api/auth-api.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,29 @@ import { reportError } from '~/reporting';
import { WALLET_API } from '~/constants';
import { ApiResponse } from '~/api/interfaces';

interface TokenData {
export type TokenData = {
token: string;
}
};

export type TokenByLinkData = TokenData & {
code: string;
};

const api = axios.create({
baseURL: WALLET_API,
});

export class AuthApiService {
public static async sendOtp(email: string): Promise<boolean> {
let result: AxiosResponse<ApiResponse<null>> | null = null;
public static async sendOtp(email: string): Promise<string | null> {
let result: AxiosResponse<ApiResponse<{ authLinkCode: string }>> | null = null;

try {
result = await api.post<ApiResponse<null>>('/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<string | null> {
Expand Down Expand Up @@ -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<boolean> {
let result: AxiosResponse<ApiResponse<null>> | null = null;

try {
result = await api.post<ApiResponse<null>>('/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<TokenByLinkData | null> {
let result: AxiosResponse<ApiResponse<TokenByLinkData | null>> | null = null;

try {
result = await api.post('/auth/token-by-link', { email, authLinkCode });
} catch (err: any) {
reportError(err);
}

return result?.data?.data || null;
}
}
24 changes: 16 additions & 8 deletions src/components/Login/OtpPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<void>;
}

Expand All @@ -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<number>(TIME_LEFT);
Expand All @@ -43,7 +45,7 @@ export const OtpPage = ({ email, onRequestLogin }: OtpProps) => {
const verifyScreenSettings = store?.whiteLabel?.verifyScreenSettings;

const {
register,
control,
handleSubmit,
setError,
getValues: getFormValues,
Expand All @@ -57,6 +59,12 @@ export const OtpPage = ({ email, onRequestLogin }: OtpProps) => {
},
});

useEffect(() => {
if (code) {
setFormValue('code', code);
}
}, [setFormValue, code]);

const onSubmit: SubmitHandler<any> = async () => {
const value = getFormValues('code');
const token = await AuthApiService.getTokenByEmail(email!, value);
Expand Down Expand Up @@ -149,10 +157,10 @@ export const OtpPage = ({ email, onRequestLogin }: OtpProps) => {
<Typography variant="body2" color={isGame ? '#FFF' : 'text.secondary'} align={isGame ? 'center' : 'left'}>
Verification code
</Typography>
<OtpInput
{...register('code')}
onChange={(val) => setFormValue('code', val)}
errorMessage={errors?.code?.message}
<Controller
name="code"
control={control}
render={({ field }) => <OtpInput {...field} errorMessage={errors?.code?.message} />}
/>

{errors.root && (
Expand All @@ -161,7 +169,7 @@ export const OtpPage = ({ email, onRequestLogin }: OtpProps) => {
</Alert>
)}

<LoadingButton loading={isSubmitting} variant="contained" size="large" type="submit">
<LoadingButton loading={isSubmitting || busy} variant="contained" size="large" type="submit">
{errors.root ? 'Retry' : 'Verify'}
</LoadingButton>
{timeLeft ? (
Expand Down
12 changes: 7 additions & 5 deletions src/hooks/usePopupStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@ export const usePopupStore = <T>(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');
Expand Down
2 changes: 2 additions & 0 deletions src/routes/AuthorizationRouter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
AuthorizeOtp,
AuthorizePermissions,
AuthorizeComplete,
AuthorizeLink,
} from './Authorize';

export const AuthorizationRouter = () => {
Expand Down Expand Up @@ -39,6 +40,7 @@ export const AuthorizationRouter = () => {
<Route path="complete" element={<AuthorizeComplete />} />
<Route path="close" element={<AuthorizeClose />} />
<Route path="redirect" element={<AuthorizeRedirect />} />
<Route path="magic-link" element={<AuthorizeLink />} />
</Route>
</Routes>
);
Expand Down
63 changes: 63 additions & 0 deletions src/routes/Authorize/AuthorizeLink.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Loading fullScreen>
<Logo />
</Loading>
);
}

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 (
<Stack component={Typography} height="100vh" spacing={2} justifyContent="center" alignItems="center" paddingX={2}>
<Icon color={error ? 'error' : 'success'} sx={{ fontSize: 60 }} />
<Typography color="text.primary" variant="h3" noWrap>
{title}
</Typography>
<Typography textAlign="center" color="text.secondary">
{message}
</Typography>
</Stack>
);
};

export default observer(AuthorizeLink);
28 changes: 19 additions & 9 deletions src/routes/Authorize/AuthorizeOtp.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<string>();
const store = useOutletContext<AuthorizePopupStore>();
const { whiteLabel } = useAppContextStore();
const { isGame } = useTheme();
Expand All @@ -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 (
Expand All @@ -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 (
<Stack
Expand All @@ -65,7 +75,7 @@ const AuthorizeOtp = ({ sendOtp }: AuthorizeOtpProps) => {
{hasBackButton && <ArrowBackIosIcon onClick={() => navigate(-1)} />}

<Stack direction="column" textAlign="justify">
<OtpPage email={store.email} onRequestLogin={handleLoginRequest} />
<OtpPage code={autoOtp} busy={isBusy} email={store.email} onRequestLogin={handleLoginRequest} />
</Stack>
</Stack>
);
Expand All @@ -83,7 +93,7 @@ const AuthorizeOtp = ({ sendOtp }: AuthorizeOtpProps) => {

<Stack direction="row" justifyContent="center" alignItems="center" padding={2} height="100vh">
<Stack width={375}>
<OtpPage email={store.email} onRequestLogin={handleLoginRequest} />
<OtpPage code={autoOtp} busy={isBusy} email={store.email} onRequestLogin={handleLoginRequest} />
</Stack>
</Stack>
</Stack>
Expand Down
1 change: 1 addition & 0 deletions src/routes/Authorize/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Loading

0 comments on commit d332707

Please sign in to comment.