-
Notifications
You must be signed in to change notification settings - Fork 280
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(account-settings): Add back
SetupTOTP
(#3149)
* 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
1 parent
d418fc8
commit 159cfd6
Showing
17 changed files
with
513 additions
and
11 deletions.
There are no files selected for viewing
2 changes: 2 additions & 0 deletions
2
examples/next/pages/ui/components/account-settings/configure-totp/aws-exports.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
47 changes: 47 additions & 0 deletions
47
examples/next/pages/ui/components/account-settings/configure-totp/index.page.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
} |
3 changes: 2 additions & 1 deletion
3
packages/react/src/components/AccountSettings/AccountSettings.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
177 changes: 177 additions & 0 deletions
177
packages/react/src/components/AccountSettings/SetupTOTP/SetupTOTP.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
102 changes: 102 additions & 0 deletions
102
packages/react/src/components/AccountSettings/SetupTOTP/__tests__/SetupTOTP.test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
}); | ||
}); |
Oops, something went wrong.