From 159cfd6a6868b6c240c7797e09f4ceafddb3f5e2 Mon Sep 17 00:00:00 2001 From: William Lee <43682783+wlee221@users.noreply.github.com> Date: Thu, 15 Dec 2022 17:57:41 -0800 Subject: [PATCH] feat(account-settings): Add back `SetupTOTP` (#3149) * Revert "Revert "feat(account-settings): Add ConfigureTOTP component" (#3083)" This reverts commit d839d84428fc6fdef47838e550b4edef96d63fa8. * 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 * 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 * Revert nullish coalescing * remove ref from useEffect * ConfirmationCode -> ConfirmationCodeField Co-authored-by: Caleb Pollman --- .../configure-totp/aws-exports.js | 2 + .../configure-totp/index.page.tsx | 47 +++++ .../AccountSettings/AccountSettings.tsx | 3 +- .../ChangePassword/ChangePassword.tsx | 8 +- .../__tests__/ChangePassword.test.tsx | 4 +- .../AccountSettings/DeleteUser/DeleteUser.tsx | 2 +- .../DeleteUser/__tests__/DeleteUser.test.tsx | 2 +- .../AccountSettings/SetupTOTP/SetupTOTP.tsx | 177 ++++++++++++++++++ .../SetupTOTP/__tests__/SetupTOTP.test.tsx | 102 ++++++++++ .../__snapshots__/SetupTOTP.test.tsx.snap | 63 +++++++ .../AccountSettings/SetupTOTP/constants.ts | 5 + .../AccountSettings/SetupTOTP/defaults.tsx | 26 +++ .../AccountSettings/SetupTOTP/index.ts | 1 + .../AccountSettings/SetupTOTP/types.ts | 20 ++ .../components/AccountSettings/constants.ts | 4 - .../src/components/AccountSettings/types.ts | 24 +++ .../ui/src/helpers/accountSettings/utils.ts | 34 ++++ 17 files changed, 513 insertions(+), 11 deletions(-) create mode 100644 examples/next/pages/ui/components/account-settings/configure-totp/aws-exports.js create mode 100644 examples/next/pages/ui/components/account-settings/configure-totp/index.page.tsx create mode 100644 packages/react/src/components/AccountSettings/SetupTOTP/SetupTOTP.tsx create mode 100644 packages/react/src/components/AccountSettings/SetupTOTP/__tests__/SetupTOTP.test.tsx create mode 100644 packages/react/src/components/AccountSettings/SetupTOTP/__tests__/__snapshots__/SetupTOTP.test.tsx.snap create mode 100644 packages/react/src/components/AccountSettings/SetupTOTP/constants.ts create mode 100644 packages/react/src/components/AccountSettings/SetupTOTP/defaults.tsx create mode 100644 packages/react/src/components/AccountSettings/SetupTOTP/index.ts create mode 100644 packages/react/src/components/AccountSettings/SetupTOTP/types.ts delete mode 100644 packages/react/src/components/AccountSettings/constants.ts diff --git a/examples/next/pages/ui/components/account-settings/configure-totp/aws-exports.js b/examples/next/pages/ui/components/account-settings/configure-totp/aws-exports.js new file mode 100644 index 00000000000..ed7194f6bca --- /dev/null +++ b/examples/next/pages/ui/components/account-settings/configure-totp/aws-exports.js @@ -0,0 +1,2 @@ +import awsExports from '@environments/auth/auth-with-optional-totp-and-sms-mfa/src/aws-exports'; +export default awsExports; diff --git a/examples/next/pages/ui/components/account-settings/configure-totp/index.page.tsx b/examples/next/pages/ui/components/account-settings/configure-totp/index.page.tsx new file mode 100644 index 00000000000..84afd80ee9d --- /dev/null +++ b/examples/next/pages/ui/components/account-settings/configure-totp/index.page.tsx @@ -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 ( + + {({ signOut }) => ( + + + + + Setup MFA: + { + setIsSuccessful(true); + }} + /> + {isSuccessful ? ( + + TOTP has been set up successfully + + ) : null} + + + + + + )} + + ); +} diff --git a/packages/react/src/components/AccountSettings/AccountSettings.tsx b/packages/react/src/components/AccountSettings/AccountSettings.tsx index 970c6d6b020..854831f777d 100644 --- a/packages/react/src/components/AccountSettings/AccountSettings.tsx +++ b/packages/react/src/components/AccountSettings/AccountSettings.tsx @@ -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 }; diff --git a/packages/react/src/components/AccountSettings/ChangePassword/ChangePassword.tsx b/packages/react/src/components/AccountSettings/ChangePassword/ChangePassword.tsx index ea2d18c8342..2387bed06aa 100644 --- a/packages/react/src/components/AccountSettings/ChangePassword/ChangePassword.tsx +++ b/packages/react/src/components/AccountSettings/ChangePassword/ChangePassword.tsx @@ -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'; diff --git a/packages/react/src/components/AccountSettings/ChangePassword/__tests__/ChangePassword.test.tsx b/packages/react/src/components/AccountSettings/ChangePassword/__tests__/ChangePassword.test.tsx index bd13b0dd0bc..5701e85f014 100644 --- a/packages/react/src/components/AccountSettings/ChangePassword/__tests__/ChangePassword.test.tsx +++ b/packages/react/src/components/AccountSettings/ChangePassword/__tests__/ChangePassword.test.tsx @@ -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) => ( diff --git a/packages/react/src/components/AccountSettings/DeleteUser/DeleteUser.tsx b/packages/react/src/components/AccountSettings/DeleteUser/DeleteUser.tsx index 4db7481a28c..44419d9eadb 100644 --- a/packages/react/src/components/AccountSettings/DeleteUser/DeleteUser.tsx +++ b/packages/react/src/components/AccountSettings/DeleteUser/DeleteUser.tsx @@ -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'; diff --git a/packages/react/src/components/AccountSettings/DeleteUser/__tests__/DeleteUser.test.tsx b/packages/react/src/components/AccountSettings/DeleteUser/__tests__/DeleteUser.test.tsx index 0eef76ae9e7..1ac77d62740 100644 --- a/packages/react/src/components/AccountSettings/DeleteUser/__tests__/DeleteUser.test.tsx +++ b/packages/react/src/components/AccountSettings/DeleteUser/__tests__/DeleteUser.test.tsx @@ -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'; diff --git a/packages/react/src/components/AccountSettings/SetupTOTP/SetupTOTP.tsx b/packages/react/src/components/AccountSettings/SetupTOTP/SetupTOTP.tsx new file mode 100644 index 00000000000..a38658dca67 --- /dev/null +++ b/packages/react/src/components/AccountSettings/SetupTOTP/SetupTOTP.tsx @@ -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(''); + const [totpSecret, setTotpSecret] = React.useState(null); + const [verifyTotpStatus, setVerifyTotpStatus] = + React.useState({ + isVerifying: false, + errorMessage: null, + }); + + const { user, isLoading } = useAuth(); + + const generateQRCode = React.useCallback( + async (currentUser: AmplifyUser): Promise => { + 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) => { + 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) => { + 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(' requires user to be authenticated.'); + return null; + } + + const { isVerifying, errorMessage } = verifyTotpStatus; + + return ( + + + {totpSecret?.qrCode ? ( + + ) : null} + + {copyCodeText} + + + + + {confirmText} + + {errorMessage ? ( + {errorMessage} + ) : null} + + + ); +} + +export default SetupTOTP; diff --git a/packages/react/src/components/AccountSettings/SetupTOTP/__tests__/SetupTOTP.test.tsx b/packages/react/src/components/AccountSettings/SetupTOTP/__tests__/SetupTOTP.test.tsx new file mode 100644 index 00000000000..28568eab1ba --- /dev/null +++ b/packages/react/src/components/AccountSettings/SetupTOTP/__tests__/SetupTOTP.test.tsx @@ -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(); + expect(container).toMatchSnapshot(); + }); + + it('calls setupTOTP with expected arguments', async () => { + setupTOTPSpy.mockResolvedValue('secretcode'); + + render(); + + 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(); + }); + + 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(); + }); + 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(); + }); + + 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(); + }); +}); diff --git a/packages/react/src/components/AccountSettings/SetupTOTP/__tests__/__snapshots__/SetupTOTP.test.tsx.snap b/packages/react/src/components/AccountSettings/SetupTOTP/__tests__/__snapshots__/SetupTOTP.test.tsx.snap new file mode 100644 index 00000000000..b0392fcf5e5 --- /dev/null +++ b/packages/react/src/components/AccountSettings/SetupTOTP/__tests__/__snapshots__/SetupTOTP.test.tsx.snap @@ -0,0 +1,63 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SetupTOTP renders as expected 1`] = ` +
+
+
+ +
+ +
+
+ +
+
+
+ +
+
+
+`; diff --git a/packages/react/src/components/AccountSettings/SetupTOTP/constants.ts b/packages/react/src/components/AccountSettings/SetupTOTP/constants.ts new file mode 100644 index 00000000000..3557f271985 --- /dev/null +++ b/packages/react/src/components/AccountSettings/SetupTOTP/constants.ts @@ -0,0 +1,5 @@ +const QR_CODE_SIZE = '228px'; +export const QR_CODE_DIMENSIONS = { + height: QR_CODE_SIZE, + width: QR_CODE_SIZE, +}; diff --git a/packages/react/src/components/AccountSettings/SetupTOTP/defaults.tsx b/packages/react/src/components/AccountSettings/SetupTOTP/defaults.tsx new file mode 100644 index 00000000000..2f161a98e17 --- /dev/null +++ b/packages/react/src/components/AccountSettings/SetupTOTP/defaults.tsx @@ -0,0 +1,26 @@ +import React from 'react'; + +import { Alert, Button, Image, TextField } from '../../../primitives'; +import { + ButtonComponent, + ErrorMessageComponent, + ImageComponent, + SubmitButtonComponent, + TextFieldComponent, +} from '../types'; + +export const ConfirmationCodeField: TextFieldComponent = (props) => ( + +); + +export const QRCodeImage: ImageComponent = Image; + +export const CopyButton: ButtonComponent = Button; + +export const SubmitButton: SubmitButtonComponent = (props) => ( +