Skip to content

Commit

Permalink
feat(account-settings): Add back SetupTOTP (#3149)
Browse files Browse the repository at this point in the history
* Revert "Revert "feat(account-settings): Add ConfigureTOTP component" (#3083)"

This reverts commit d839d84.

* Rename ConfigureTOTP -> SetupTOTP

* Update Example

* Update snapshot

* Reorder returns

* Update tests

* Add classname

* Update packages/react/src/components/AccountSettings/SetupTOTP/types.ts

Co-authored-by: Caleb Pollman <cpollman@amazon.com>

* Move ComponentClassName to types

* Rename default components

* Fix test wordings

* Comment cleanup

* confirmationCode state cleanup

* Update tests with new pathchange

* Make label required

* Update packages/react/src/components/AccountSettings/SetupTOTP/SetupTOTP.tsx

Co-authored-by: Caleb Pollman <cpollman@amazon.com>

* Revert nullish coalescing

* remove ref from useEffect

* ConfirmationCode -> ConfirmationCodeField

Co-authored-by: Caleb Pollman <cpollman@amazon.com>
  • Loading branch information
wlee221 and calebpollman committed Dec 16, 2022
1 parent d418fc8 commit 159cfd6
Show file tree
Hide file tree
Showing 17 changed files with 513 additions and 11 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import awsExports from '@environments/auth/auth-with-optional-totp-and-sms-mfa/src/aws-exports';
export default awsExports;
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import React from 'react';

import { Amplify } from 'aws-amplify';

import {
AccountSettings,
Alert,
Authenticator,
Button,
Card,
Flex,
Heading,
} from '@aws-amplify/ui-react';
import '@aws-amplify/ui-react/styles.css';

import awsExports from './aws-exports';
Amplify.configure(awsExports);

export default function App() {
const [isSuccessful, setIsSuccessful] = React.useState(false);
return (
<Authenticator>
{({ signOut }) => (
<Card width="800px">
<Flex direction="column">
<Card variation="outlined">
<Flex direction="column">
<Heading>Setup MFA:</Heading>
<AccountSettings.SetupTOTP
onSuccess={() => {
setIsSuccessful(true);
}}
/>
{isSuccessful ? (
<Alert variation="success" isDismissible>
TOTP has been set up successfully
</Alert>
) : null}
</Flex>
</Card>
<Button onClick={signOut}>Sign Out</Button>
</Flex>
</Card>
)}
</Authenticator>
);
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ChangePassword } from './ChangePassword';
import { DeleteUser } from './DeleteUser';
import { SetupTOTP } from './SetupTOTP';

export default { ChangePassword, DeleteUser };
export default { ChangePassword, DeleteUser, SetupTOTP };
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,12 @@ import {

import { useAuth } from '../../../internal';
import { View, Flex } from '../../../primitives';
import { ComponentClassName } from '../constants';
import { FormValues, BlurredFields, ValidationError } from '../types';
import {
ComponentClassName,
FormValues,
BlurredFields,
ValidationError,
} from '../types';
import { ChangePasswordProps, ValidateParams } from './types';
import DEFAULTS from './defaults';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react';

import * as UIModule from '@aws-amplify/ui';

import ChangePassword from '../ChangePassword';
import { Button } from '../../../../primitives';
import ChangePassword from '../ChangePassword';
import { ComponentClassName } from '../../types';
import { ChangePasswordComponents } from '../types';
import { ComponentClassName } from '../../constants';

const components: ChangePasswordComponents = {
CurrentPasswordField: (props) => (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { deleteUser, translate, getLogger } from '@aws-amplify/ui';

import { useAuth } from '../../../internal';
import { Flex } from '../../../primitives';
import { ComponentClassName } from '../constants';
import { ComponentClassName } from '../types';
import DEFAULTS from './defaults';
import { DeleteUserProps, DeleteUserState } from './types';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import * as UIModule from '@aws-amplify/ui';

import { Button, Flex, Heading, Text } from '../../../../primitives';
import { ComponentClassName } from '../../constants';
import { ComponentClassName } from '../../types';
import { DeleteUserComponents } from '../types';
import DeleteUser from '../DeleteUser';

Expand Down
177 changes: 177 additions & 0 deletions packages/react/src/components/AccountSettings/SetupTOTP/SetupTOTP.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import React from 'react';
import QRCode from 'qrcode';

import {
AmplifyUser,
getLogger,
getTotpCodeURL,
setupTOTP,
translate,
verifyTOTPToken,
} from '@aws-amplify/ui';

import { useAuth } from '../../../internal';
import { View, Flex } from '../../../primitives';
import { ComponentClassName } from '../types';
import {
ConfirmationCodeField,
CopyButton,
ErrorMessage,
QRCodeImage,
SubmitButton,
} from './defaults';
import { ConfigureTOTPProps, TotpSecret, VerifyTotpStatus } from './types';
import { QR_CODE_DIMENSIONS } from './constants';

const logger = getLogger('Auth');

function SetupTOTP({
totpIssuer = 'AWSCognito',
totpUsername,
onSuccess,
onError,
}: ConfigureTOTPProps): JSX.Element | null {
const [confirmationCode, setConfirmationCode] = React.useState<string>('');
const [totpSecret, setTotpSecret] = React.useState<TotpSecret>(null);
const [verifyTotpStatus, setVerifyTotpStatus] =
React.useState<VerifyTotpStatus>({
isVerifying: false,
errorMessage: null,
});

const { user, isLoading } = useAuth();

const generateQRCode = React.useCallback(
async (currentUser: AmplifyUser): Promise<void> => {
try {
const secretKey = await setupTOTP(currentUser);
const username = totpUsername ?? currentUser?.username;
const totpCode = getTotpCodeURL(totpIssuer, username, secretKey);
const qrCode = await QRCode.toDataURL(totpCode);

setTotpSecret({ secretKey, qrCode });
} catch (e) {
logger.error(e);
}
},
[totpIssuer, totpUsername]
);

React.useEffect(() => {
if (user && !totpSecret) {
generateQRCode(user);
}
}, [generateQRCode, totpSecret, user]);

// translations
const confirmText = translate('Confirm');
const copyCodeText = translate('Copy Secret Code');

// event handlers
const handleChange = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
event.preventDefault();
const { value } = event.target;
setConfirmationCode(value);
},
[]
);

const runVerifyTotpToken = React.useCallback(
async ({ user, code }: { user: AmplifyUser; code: string }) => {
setVerifyTotpStatus({ isVerifying: true, errorMessage: null });

try {
await verifyTOTPToken({ user, code });

setVerifyTotpStatus({ isVerifying: false, errorMessage: null });

onSuccess?.();
} catch (e) {
const error = e as Error;

setVerifyTotpStatus({
isVerifying: false,
errorMessage: error.message,
});

onError?.(error);
}
},
[onError, onSuccess]
);

const handleSubmit = React.useCallback(
(event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
runVerifyTotpToken({ user, code: confirmationCode });
},
[user, confirmationCode, runVerifyTotpToken]
);

const handleCopy = React.useCallback(() => {
navigator.clipboard.writeText(totpSecret.secretKey);
}, [totpSecret]);

// return null if Auth.getCurrentAuthenticatedUser is still in progress
if (isLoading) {
return null;
}

// return null if user isn't authenticated in the first place
if (!user) {
logger.warn('<SetupTOTP /> requires user to be authenticated.');
return null;
}

const { isVerifying, errorMessage } = verifyTotpStatus;

return (
<View
as="form"
className={ComponentClassName.SetupTOTP}
onSubmit={handleSubmit}
>
<Flex direction="column" alignItems="center">
{totpSecret?.qrCode ? (
<QRCodeImage
src={totpSecret?.qrCode}
alt="qr code"
{...QR_CODE_DIMENSIONS}
/>
) : null}
<CopyButton
isDisabled={isVerifying}
alignSelf="stretch"
onClick={handleCopy}
>
{copyCodeText}
</CopyButton>
<ConfirmationCodeField
alignSelf="stretch"
isRequired
label="Confirmation Code"
name="code"
onChange={handleChange}
placeholder="Code"
value={confirmationCode}
isDisabled={isVerifying}
/>

<SubmitButton
type="submit"
variation="primary"
isDisabled={isVerifying || confirmationCode.length === 0}
isFullWidth
>
{confirmText}
</SubmitButton>
{errorMessage ? (
<ErrorMessage width="100%">{errorMessage}</ErrorMessage>
) : null}
</Flex>
</View>
);
}

export default SetupTOTP;
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import React from 'react';
import {
render,
screen,
fireEvent,
waitFor,
act,
} from '@testing-library/react';

import * as UIModule from '@aws-amplify/ui';

import SetupTOTP from '../SetupTOTP';

const user = { username: 'testuser' } as unknown as UIModule.AmplifyUser;
jest.mock('../../../../internal', () => ({
useAuth: () => ({
user,
isLoading: false,
}),
}));

const setupTOTPSpy = jest.spyOn(UIModule, 'setupTOTP');
const verifyTOTPToken = jest.spyOn(UIModule, 'verifyTOTPToken');

describe('SetupTOTP', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('renders as expected', () => {
setupTOTPSpy.mockResolvedValue('secretcode');
const { container } = render(<SetupTOTP />);
expect(container).toMatchSnapshot();
});

it('calls setupTOTP with expected arguments', async () => {
setupTOTPSpy.mockResolvedValue('secretcode');

render(<SetupTOTP />);

await screen.findByAltText('qr code');
expect(setupTOTPSpy).toHaveBeenCalledWith(user);
});

it('calls onSuccess on successful totp verification', async () => {
setupTOTPSpy.mockResolvedValue('secretcode');
verifyTOTPToken.mockResolvedValue();

const onSuccess = jest.fn();

await act(async () => {
render(<SetupTOTP onSuccess={onSuccess} />);
});

const submitButton = await screen.findByRole('button', {
name: 'Confirm',
});
fireEvent.submit(submitButton);

// submit handling is async, wait for onSuccess to be called
// https://testing-library.com/docs/dom-testing-library/api-async/#waitfor
await waitFor(() => expect(onSuccess).toBeCalledTimes(1));
});

it('calls onError after unsuccessful submit', async () => {
setupTOTPSpy.mockResolvedValue('secretCode');
verifyTOTPToken.mockRejectedValue(new Error('Mock Error'));

const onError = jest.fn();
await act(async () => {
render(<SetupTOTP onError={onError} />);
});
const submitButton = await screen.findByRole('button', {
name: 'Confirm',
});

fireEvent.submit(submitButton);

// submit handling is async, wait for onError to be called
await waitFor(() => expect(onError).toBeCalledTimes(1));
});

it('displays an error message on unsuccessful submit', async () => {
setupTOTPSpy.mockResolvedValue('secretCode');
verifyTOTPToken.mockRejectedValue(new Error('Mock Error'));

const onError = jest.fn();

await act(async () => {
render(<SetupTOTP onError={onError} />);
});

const submitButton = await screen.findByRole('button', {
name: 'Confirm',
});

fireEvent.submit(submitButton);

// submit handling is async, wait for error to be displayed
expect(await screen.findByText('Mock Error')).toBeDefined();
});
});
Loading

0 comments on commit 159cfd6

Please sign in to comment.