diff --git a/.changeset/afraid-maps-speak.md b/.changeset/afraid-maps-speak.md new file mode 100644 index 00000000000..65a64299e09 --- /dev/null +++ b/.changeset/afraid-maps-speak.md @@ -0,0 +1,5 @@ +--- +'@aws-amplify/ui-react': patch +--- + +fix: Swapped save and cancel buttons. diff --git a/.changeset/bright-worms-explain.md b/.changeset/bright-worms-explain.md new file mode 100644 index 00000000000..e8d4e62d7f7 --- /dev/null +++ b/.changeset/bright-worms-explain.md @@ -0,0 +1,10 @@ +--- +"@aws-amplify/ui-react-core": patch +"@aws-amplify/ui-react-native": patch +"@aws-amplify/ui-react": patch +"@aws-amplify/ui": patch +"@aws-amplify/ui-vue": patch +"@aws-amplify/ui-angular": patch +--- + +fix(authenticator): migrate totpSecretCode generation to state machine diff --git a/.changeset/config.json b/.changeset/config.json index da31028890b..de2231d05ab 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -10,12 +10,13 @@ "baseBranch": "main", "updateInternalDependencies": "patch", "ignore": [ + "amplify-ui-angular-mono", + "angular-example", "docs", + "e2e", "environments", - "angular-example", "next-example", - "vue-example", - "amplify-ui-angular-mono", - "e2e" + "react-native-example", + "vue-example" ] } diff --git a/.changeset/purple-hotels-tease.md b/.changeset/purple-hotels-tease.md new file mode 100644 index 00000000000..9a3bd9dd7d6 --- /dev/null +++ b/.changeset/purple-hotels-tease.md @@ -0,0 +1,5 @@ +--- +'@aws-amplify/ui-react': patch +--- + +fix: Updated error text for max file count to be more explicit. diff --git a/.changeset/thirty-kangaroos-develop.md b/.changeset/thirty-kangaroos-develop.md new file mode 100644 index 00000000000..267b7d118c2 --- /dev/null +++ b/.changeset/thirty-kangaroos-develop.md @@ -0,0 +1,5 @@ +--- +'@aws-amplify/ui-react': patch +--- + +fix: Swap the upload button with the clear all button. diff --git a/examples/react-native/README.md b/examples/react-native/README.md index 19c04e8e48a..5d897cdaf25 100644 --- a/examples/react-native/README.md +++ b/examples/react-native/README.md @@ -30,13 +30,13 @@ From the monorepo root run the following commands: yarn && yarn build ``` -1. Install CocoaPod dependencies: +2. Install CocoaPod dependencies: ```bash yarn react-native-example ios:pod-install ``` -1. Build and install the Example App: +3. Build and install the Example App: ```bash yarn react-native-example ios @@ -50,7 +50,7 @@ yarn react-native-example ios yarn && yarn build ``` -1. Build and install the Example App: +2. Build and install the Example App: ```bash yarn react-native-example android @@ -70,19 +70,19 @@ All of the below commands should be run from the monorepo root. yarn react-native dev ``` -1. To optionally develop against `@aws-amplify/ui`: +2. To optionally develop against `@aws-amplify/ui`: ```bash -yarn ui dev +yarn ui build ``` -1. Run: +3. Run: ```bash yarn react-native-example dev ``` -1. Open the app on the iOS simulator or Android emulator. +4. Open the app on the iOS simulator or Android emulator. ## Storybook diff --git a/packages/angular/projects/ui-angular/src/lib/components/authenticator/components/setup-totp/setup-totp.component.html b/packages/angular/projects/ui-angular/src/lib/components/authenticator/components/setup-totp/setup-totp.component.html index 4a7c8f7a8cf..b2e16fa3f30 100644 --- a/packages/angular/projects/ui-angular/src/lib/components/authenticator/components/setup-totp/setup-totp.component.html +++ b/packages/angular/projects/ui-angular/src/lib/components/authenticator/components/setup-totp/setup-totp.component.html @@ -17,7 +17,7 @@

{{ this.headerText }}

height="228" />
-
{{ secretKey }}
+
{{ totpSecretCode }}
{{ copyTextLabel }}
{ let fixture: ComponentFixture; - let component: SetupTotpComponent; beforeEach(async () => { jest.resetAllMocks(); @@ -44,6 +34,7 @@ describe('SetupTotpComponent', () => { submitForm: jest.fn(), context: jest.fn().mockReturnValue({}), slotContext: jest.fn().mockReturnValue({}), + totpSecretCode: 'Keep it quiet!', }; await TestBed.configureTestingModule({ @@ -61,27 +52,9 @@ describe('SetupTotpComponent', () => { }).compileComponents(); fixture = TestBed.createComponent(SetupTotpComponent); - component = fixture.componentInstance; }); + it('successfully mounts', () => { expect(fixture).toBeTruthy(); }); - - it('validate generateQR Code generates correct code', async () => { - setupTOTPSpy.mockResolvedValue(SECRET_KEY); - const defaultTotpCode = getTotpCodeURL( - DEFAULT_TOTP_ISSUER, - mockUser.username, - SECRET_KEY - ); - await fixture.detectChanges(); - - expect(setupTOTPSpy).toHaveBeenCalledTimes(1); - expect(setupTOTPSpy).toHaveBeenCalledWith(mockUser); - - await fixture.detectChanges(); - - expect(toDataURLSpy).toHaveBeenCalledTimes(1); - expect(toDataURLSpy).toHaveBeenCalledWith(defaultTotpCode); - }); }); diff --git a/packages/angular/projects/ui-angular/src/lib/components/authenticator/components/setup-totp/setup-totp.component.ts b/packages/angular/projects/ui-angular/src/lib/components/authenticator/components/setup-totp/setup-totp.component.ts index 0ac74d06415..204938514d8 100644 --- a/packages/angular/projects/ui-angular/src/lib/components/authenticator/components/setup-totp/setup-totp.component.ts +++ b/packages/angular/projects/ui-angular/src/lib/components/authenticator/components/setup-totp/setup-totp.component.ts @@ -1,6 +1,6 @@ import { Component, HostBinding, OnInit } from '@angular/core'; import QRCode from 'qrcode'; -import { Auth, Logger } from 'aws-amplify'; +import { Logger } from 'aws-amplify'; import { FormFieldsArray, getActorContext, @@ -29,7 +29,7 @@ export class SetupTotpComponent implements OnInit { @HostBinding('attr.data-amplify-authenticator-setup-totp') dataAttr = ''; public headerText = getSetupTOTPText(); public qrCodeSource = ''; - public secretKey = ''; + public totpSecretCode = ''; public copyTextLabel = getCopyText(); // translated texts @@ -48,15 +48,19 @@ export class SetupTotpComponent implements OnInit { } async generateQRCode() { - // TODO: This should be handled in core. - const state = this.authenticator.authState; - const actorContext = getActorContext(state) as SignInContext; - const { user, formFields } = actorContext; + const { authState: state, totpSecretCode, user } = this.authenticator; + const { formFields } = getActorContext(state) as SignInContext; const { totpIssuer = 'AWSCognito', totpUsername = user?.username } = formFields?.setupTOTP?.QR ?? {}; + + this.totpSecretCode = totpSecretCode; + try { - this.secretKey = await Auth.setupTOTP(user); - const totpCode = getTotpCodeURL(totpIssuer, totpUsername, this.secretKey); + const totpCode = getTotpCodeURL( + totpIssuer, + totpUsername, + this.totpSecretCode + ); logger.info('totp code was generated:', totpCode); this.qrCodeSource = await QRCode.toDataURL(totpCode); @@ -77,7 +81,7 @@ export class SetupTotpComponent implements OnInit { } copyText(): void { - navigator.clipboard.writeText(this.secretKey); + navigator.clipboard.writeText(this.totpSecretCode); this.copyTextLabel = getCopiedText(); } } diff --git a/packages/angular/projects/ui-angular/src/lib/services/authenticator.service.ts b/packages/angular/projects/ui-angular/src/lib/services/authenticator.service.ts index 30f9dfa94a5..da7c66e0584 100644 --- a/packages/angular/projects/ui-angular/src/lib/services/authenticator.service.ts +++ b/packages/angular/projects/ui-angular/src/lib/services/authenticator.service.ts @@ -83,6 +83,10 @@ export class AuthenticatorService implements OnDestroy { return this._facade?.codeDeliveryDetails; } + public get totpSecretCode() { + return this._facade?.totpSecretCode; + } + /** * Service facades */ diff --git a/packages/react-core/src/Authenticator/hooks/types.ts b/packages/react-core/src/Authenticator/hooks/types.ts index 705c00a8566..fc34e45a5dd 100644 --- a/packages/react-core/src/Authenticator/hooks/types.ts +++ b/packages/react-core/src/Authenticator/hooks/types.ts @@ -6,6 +6,8 @@ import { LegacyFormFieldOptions, } from '@aws-amplify/ui'; +import { UseAuthenticator } from './useAuthenticator'; + export type AuthenticatorRouteComponentKey = | 'confirmResetPassword' | 'confirmSignIn' @@ -31,8 +33,6 @@ export type AuthenticatorMachineContextKey = keyof AuthenticatorMachineContext; export type AuthenticatorRouteComponentName = Capitalize; -export type GetTotpSecretCode = () => Promise; - interface HeaderProps { children?: React.ReactNode; } @@ -42,8 +42,8 @@ interface FooterProps { } type FormFieldsProps = { - isPending: AuthenticatorMachineContext['isPending']; - validationErrors?: AuthenticatorMachineContext['validationErrors']; + isPending: UseAuthenticator['isPending']; + validationErrors?: UseAuthenticator['validationErrors']; }; export type FooterComponent = React.ComponentType< @@ -70,74 +70,74 @@ export interface ComponentSlots { * Common component prop types used for both RWA and RNA implementations */ export type CommonRouteProps = { - error?: AuthenticatorMachineContext['error']; - isPending: AuthenticatorMachineContext['isPending']; - handleBlur: AuthenticatorMachineContext['updateBlur']; - handleChange: AuthenticatorMachineContext['updateForm']; - handleSubmit: AuthenticatorMachineContext['submitForm']; + error?: UseAuthenticator['error']; + isPending: UseAuthenticator['isPending']; + handleBlur: UseAuthenticator['updateBlur']; + handleChange: UseAuthenticator['updateForm']; + handleSubmit: UseAuthenticator['submitForm']; }; /** * Base Route component props */ export type ConfirmResetPasswordBaseProps = { - resendCode: AuthenticatorMachineContext['resendCode']; - validationErrors?: AuthenticatorMachineContext['validationErrors']; + resendCode: UseAuthenticator['resendCode']; + validationErrors?: UseAuthenticator['validationErrors']; } & CommonRouteProps & ComponentSlots; export type ConfirmSignInBaseProps = { challengeName: AuthChallengeName; - toSignIn: AuthenticatorMachineContext['toSignIn']; + toSignIn: UseAuthenticator['toSignIn']; } & CommonRouteProps & ComponentSlots; export type ConfirmSignUpBaseProps = { - codeDeliveryDetails: AuthenticatorMachineContext['codeDeliveryDetails']; - resendCode: AuthenticatorMachineContext['resendCode']; + codeDeliveryDetails: UseAuthenticator['codeDeliveryDetails']; + resendCode: UseAuthenticator['resendCode']; } & CommonRouteProps & ComponentSlots; export type ConfirmVerifyUserProps = { - skipVerification: AuthenticatorMachineContext['skipVerification']; + skipVerification: UseAuthenticator['skipVerification']; } & CommonRouteProps & ComponentSlots; export type ForceResetPasswordBaseProps = { - toSignIn: AuthenticatorMachineContext['toSignIn']; - validationErrors?: AuthenticatorMachineContext['validationErrors']; + toSignIn: UseAuthenticator['toSignIn']; + validationErrors?: UseAuthenticator['validationErrors']; } & CommonRouteProps & ComponentSlots; export type ResetPasswordBaseProps = { - toSignIn: AuthenticatorMachineContext['toSignIn']; + toSignIn: UseAuthenticator['toSignIn']; } & CommonRouteProps & ComponentSlots; export type SetupTOTPBaseProps = { - getTotpSecretCode: GetTotpSecretCode; - toSignIn: AuthenticatorMachineContext['toSignIn']; + toSignIn: UseAuthenticator['toSignIn']; + totpSecretCode: UseAuthenticator['totpSecretCode']; } & CommonRouteProps & ComponentSlots; export type SignInBaseProps = { hideSignUp?: boolean; - toFederatedSignIn: AuthenticatorMachineContext['toFederatedSignIn']; - toResetPassword: AuthenticatorMachineContext['toResetPassword']; - toSignUp: AuthenticatorMachineContext['toSignUp']; + toFederatedSignIn: UseAuthenticator['toFederatedSignIn']; + toResetPassword: UseAuthenticator['toResetPassword']; + toSignUp: UseAuthenticator['toSignUp']; } & CommonRouteProps & ComponentSlots; export type SignUpBaseProps = { hideSignIn?: boolean; - toFederatedSignIn: AuthenticatorMachineContext['toFederatedSignIn']; - toSignIn: AuthenticatorMachineContext['toSignIn']; - validationErrors?: AuthenticatorMachineContext['validationErrors']; + toFederatedSignIn: UseAuthenticator['toFederatedSignIn']; + toSignIn: UseAuthenticator['toSignIn']; + validationErrors?: UseAuthenticator['validationErrors']; } & CommonRouteProps & ComponentSlots; export type VerifyUserProps = { - skipVerification: AuthenticatorMachineContext['skipVerification']; + skipVerification: UseAuthenticator['skipVerification']; } & CommonRouteProps & ComponentSlots; diff --git a/packages/react-core/src/Authenticator/hooks/useAuthenticator/__mock__/useAuthenticator.ts b/packages/react-core/src/Authenticator/hooks/useAuthenticator/__mock__/useAuthenticator.ts index 6a06b23f7f2..e0eba24ebd4 100644 --- a/packages/react-core/src/Authenticator/hooks/useAuthenticator/__mock__/useAuthenticator.ts +++ b/packages/react-core/src/Authenticator/hooks/useAuthenticator/__mock__/useAuthenticator.ts @@ -14,6 +14,7 @@ const getTotpSecretCode = jest.fn(); const hasValidationErrors = false; const initializeMachine = jest.fn(); const isPending = false; +const QRFields = null; const resendCode = jest.fn(); const route = 'idle'; const skipVerification = jest.fn(); @@ -24,6 +25,7 @@ const toFederatedSignIn = jest.fn(); const toResetPassword = jest.fn(); const toSignIn = jest.fn(); const toSignUp = jest.fn(); +const totpSecretCode = null; const unverifiedContactMethods = {}; const updateBlur = jest.fn(); const updateForm = jest.fn(); @@ -53,6 +55,7 @@ export const mockMachineContext: AuthenticatorMachineContext = { socialProviders, toFederatedSignIn, toResetPassword, + totpSecretCode, unverifiedContactMethods, validationErrors, }; @@ -61,4 +64,5 @@ export const mockUseAuthenticatorOutput: UseAuthenticator = { ...mockMachineContext, fields, getTotpSecretCode, -} as unknown as UseAuthenticator; + QRFields, +}; diff --git a/packages/react-core/src/Authenticator/hooks/useAuthenticator/__tests__/__snapshots__/useAuthenticator.spec.tsx.snap b/packages/react-core/src/Authenticator/hooks/useAuthenticator/__tests__/__snapshots__/useAuthenticator.spec.tsx.snap index f080314f537..30c7dc0bd57 100644 --- a/packages/react-core/src/Authenticator/hooks/useAuthenticator/__tests__/__snapshots__/useAuthenticator.spec.tsx.snap +++ b/packages/react-core/src/Authenticator/hooks/useAuthenticator/__tests__/__snapshots__/useAuthenticator.spec.tsx.snap @@ -2,30 +2,31 @@ exports[`useAuthenticator returns the expected values 1`] = ` Object { - "QRFields": undefined, + "QRFields": null, "authStatus": "authenticated", "codeDeliveryDetails": Object {}, "error": undefined, "fields": undefined, "getTotpSecretCode": undefined, "hasValidationErrors": false, - "initializeMachine": [Function], + "initializeMachine": [MockFunction], "isPending": false, - "resendCode": [Function], + "resendCode": [MockFunction], "route": "idle", - "signOut": [Function], - "skipVerification": [Function], + "signOut": [MockFunction], + "skipVerification": [MockFunction], "socialProviders": Array [], - "submitForm": [Function], - "toFederatedSignIn": [Function], - "toResetPassword": [Function], - "toSignIn": [Function], - "toSignUp": [Function], + "submitForm": [MockFunction], + "toFederatedSignIn": [MockFunction], + "toResetPassword": [MockFunction], + "toSignIn": [MockFunction], + "toSignUp": [MockFunction], + "totpSecretCode": null, "unverifiedContactMethods": Object { "email": "test#example.com", }, - "updateBlur": [Function], - "updateForm": [Function], + "updateBlur": [MockFunction], + "updateForm": [MockFunction], "user": Object {}, "validationErrors": undefined, } diff --git a/packages/react-core/src/Authenticator/hooks/useAuthenticator/__tests__/useAuthenticator.spec.tsx b/packages/react-core/src/Authenticator/hooks/useAuthenticator/__tests__/useAuthenticator.spec.tsx index a80f344d65a..78e94a73fd0 100644 --- a/packages/react-core/src/Authenticator/hooks/useAuthenticator/__tests__/useAuthenticator.spec.tsx +++ b/packages/react-core/src/Authenticator/hooks/useAuthenticator/__tests__/useAuthenticator.spec.tsx @@ -20,17 +20,18 @@ const mockServiceFacade: AuthenticatorServiceFacade = { user: {} as UseAuthenticator['user'], validationErrors: undefined as unknown as UseAuthenticator['validationErrors'], - initializeMachine: jest.fn, - resendCode: jest.fn, - signOut: jest.fn, - submitForm: jest.fn, - updateForm: jest.fn, - updateBlur: jest.fn, - toFederatedSignIn: jest.fn, - toResetPassword: jest.fn, - toSignIn: jest.fn, - toSignUp: jest.fn, - skipVerification: jest.fn, + totpSecretCode: null, + initializeMachine: jest.fn(), + resendCode: jest.fn(), + signOut: jest.fn(), + submitForm: jest.fn(), + updateForm: jest.fn(), + updateBlur: jest.fn(), + toFederatedSignIn: jest.fn(), + toResetPassword: jest.fn(), + toSignIn: jest.fn(), + toSignUp: jest.fn(), + skipVerification: jest.fn(), }; const getServiceFacadeSpy = jest @@ -50,6 +51,7 @@ jest.mock('aws-amplify'); jest.mock('../utils'); const getComparatorSpy = jest.spyOn(utils, 'getComparator'); +const getQRFieldsSpy = jest.spyOn(utils, 'getQRFields'); const Wrapper = ({ children }: { children?: React.ReactNode }) => ( {children} @@ -90,4 +92,33 @@ describe('useAuthenticator', () => { expect(getComparatorSpy).not.toBeCalled(); }); + + it('calls getQRFields only for the setupTOTP route', () => { + getServiceFacadeSpy.mockReturnValueOnce({ + ...mockServiceFacade, + route: 'signIn', + }); + + const { rerender } = renderHook(useAuthenticator, { wrapper: Wrapper }); + + expect(getQRFieldsSpy).toHaveBeenCalledTimes(0); + + getServiceFacadeSpy.mockReturnValueOnce({ + ...mockServiceFacade, + route: 'setupTOTP', + }); + + rerender(); + + expect(getQRFieldsSpy).toHaveBeenCalledTimes(1); + + getServiceFacadeSpy.mockReturnValueOnce({ + ...mockServiceFacade, + route: 'signOut', + }); + + rerender(); + + expect(getQRFieldsSpy).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/react-core/src/Authenticator/hooks/useAuthenticator/__tests__/utils.spec.tsx b/packages/react-core/src/Authenticator/hooks/useAuthenticator/__tests__/utils.spec.tsx index 05cf5f7058b..e040a3f7bbc 100644 --- a/packages/react-core/src/Authenticator/hooks/useAuthenticator/__tests__/utils.spec.tsx +++ b/packages/react-core/src/Authenticator/hooks/useAuthenticator/__tests__/utils.spec.tsx @@ -176,10 +176,19 @@ describe('getMachineFields', () => { const QRFields = getQRFields(state); expect(QRFields).toEqual({ totpIssuer, totpUsername }); }); - it('returns empty object if QR field is not present', () => { + + it('returns an empty object if no QRfields are present', () => { getActorContextSpy.mockReturnValue({}); const QRFields = getQRFields(state); expect(QRFields).toEqual({}); }); + + it('returns an empty object when getActorContext returns undefined', () => { + getActorContextSpy.mockReturnValue( + undefined as unknown as AuthActorContext + ); + const QRFields = getQRFields(state); + expect(QRFields).toEqual({}); + }); }); }); diff --git a/packages/react-core/src/Authenticator/hooks/useAuthenticator/types.ts b/packages/react-core/src/Authenticator/hooks/useAuthenticator/types.ts index e5f5fb9988a..75fcb798b9b 100644 --- a/packages/react-core/src/Authenticator/hooks/useAuthenticator/types.ts +++ b/packages/react-core/src/Authenticator/hooks/useAuthenticator/types.ts @@ -35,12 +35,14 @@ export type UseAuthenticatorSelector = ( ) => AuthenticatorMachineContext[AuthenticatorMachineContextKey][]; export interface UseAuthenticator extends AuthenticatorServiceFacade { - getTotpSecretCode: () => Promise; + /** @deprecated For internal use only */ + fields: AuthenticatorLegacyFields; /** @deprecated For internal use only */ - QRFields: { totpIssuer?: string; totpUsername?: string }; + getTotpSecretCode: () => Promise; + /** @deprecated For internal use only */ - fields: AuthenticatorLegacyFields; + QRFields: { totpIssuer?: string; totpUsername?: string } | null; } export type Comparator = ( diff --git a/packages/react-core/src/Authenticator/hooks/useAuthenticator/useAuthenticator.tsx b/packages/react-core/src/Authenticator/hooks/useAuthenticator/useAuthenticator.ts similarity index 75% rename from packages/react-core/src/Authenticator/hooks/useAuthenticator/useAuthenticator.tsx rename to packages/react-core/src/Authenticator/hooks/useAuthenticator/useAuthenticator.ts index 31880a4b482..837b007ddf3 100644 --- a/packages/react-core/src/Authenticator/hooks/useAuthenticator/useAuthenticator.tsx +++ b/packages/react-core/src/Authenticator/hooks/useAuthenticator/useAuthenticator.ts @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback } from 'react'; import { useSelector } from '@xstate/react'; import { AuthMachineState, getServiceFacade } from '@aws-amplify/ui'; @@ -38,38 +38,32 @@ export default function useAuthenticator( const facade = useSelector(service, xstateSelector, comparator); - const { route, unverifiedContactMethods, user, ...rest } = facade; + const { route, totpSecretCode, unverifiedContactMethods, user, ...rest } = + facade; // do not memoize output. `service.getSnapshot` reference remains stable preventing // `fields` from updating with current form state on value changes - const serviceSnapshot = service.getSnapshot(); + const serviceSnapshot = service.getSnapshot() as AuthMachineState; // legacy `QRFields` values only used for SetupTOTP page to retrieve issuer information, will be removed in future - const QRFields = useMemo( - () => getQRFields(serviceSnapshot as AuthMachineState), - [serviceSnapshot] - ); + const QRFields = route === 'setupTOTP' ? getQRFields(serviceSnapshot) : null; // legacy `formFields` values required until form state is removed from state machine - const fields = useMemo( - () => - getMachineFields( - route, - serviceSnapshot as AuthMachineState, - unverifiedContactMethods - ), - [route, serviceSnapshot, unverifiedContactMethods] + const fields = getMachineFields( + route, + serviceSnapshot, + unverifiedContactMethods ); return { ...rest, - getTotpSecretCode: getTotpSecretCodeCallback(user), route, + totpSecretCode, unverifiedContactMethods, user, /** @deprecated For internal use only */ fields, - /** @deprecated For internal use only */ + getTotpSecretCode: getTotpSecretCodeCallback(user), QRFields, }; } diff --git a/packages/react-core/src/Authenticator/hooks/useAuthenticator/utils.ts b/packages/react-core/src/Authenticator/hooks/useAuthenticator/utils.ts index 06b4e2bb5bb..8197689541b 100644 --- a/packages/react-core/src/Authenticator/hooks/useAuthenticator/utils.ts +++ b/packages/react-core/src/Authenticator/hooks/useAuthenticator/utils.ts @@ -55,13 +55,9 @@ export const getComparator = export const getQRFields = ( state: AuthMachineState -): { totpIssuer?: string; totpUsername?: string } => { - const fields = getActorContext(state); - - const QR = fields?.formFields?.setupTOTP?.QR ?? {}; - - return { ...QR }; -}; +): { totpIssuer?: string; totpUsername?: string } => ({ + ...getActorContext(state)?.formFields?.setupTOTP?.QR, +}); export const getTotpSecretCodeCallback = (user: AmplifyUser) => async function getTotpSecretCode(): Promise { diff --git a/packages/react-core/src/Authenticator/hooks/useAuthenticatorRoute/__tests__/__snapshots__/useAuthenticatorRoute.spec.ts.snap b/packages/react-core/src/Authenticator/hooks/useAuthenticatorRoute/__tests__/__snapshots__/useAuthenticatorRoute.spec.ts.snap index bf3f340b7b7..8ac9f346793 100644 --- a/packages/react-core/src/Authenticator/hooks/useAuthenticatorRoute/__tests__/__snapshots__/useAuthenticatorRoute.spec.ts.snap +++ b/packages/react-core/src/Authenticator/hooks/useAuthenticatorRoute/__tests__/__snapshots__/useAuthenticatorRoute.spec.ts.snap @@ -114,12 +114,12 @@ Object { "FormFields": [Function], "Header": [Function], "error": "error", - "getTotpSecretCode": [MockFunction], "handleBlur": [MockFunction], "handleChange": [MockFunction], "handleSubmit": [MockFunction], "isPending": false, "toSignIn": [MockFunction], + "totpSecretCode": null, }, } `; diff --git a/packages/react-core/src/Authenticator/hooks/useAuthenticatorRoute/__tests__/utils.spec.ts b/packages/react-core/src/Authenticator/hooks/useAuthenticatorRoute/__tests__/utils.spec.ts index 418345e86c4..20fd9033e38 100644 --- a/packages/react-core/src/Authenticator/hooks/useAuthenticatorRoute/__tests__/utils.spec.ts +++ b/packages/react-core/src/Authenticator/hooks/useAuthenticatorRoute/__tests__/utils.spec.ts @@ -37,7 +37,6 @@ type PropsResolver = ( const { codeDeliveryDetails, error, - getTotpSecretCode, isPending, resendCode, route, @@ -47,6 +46,7 @@ const { toResetPassword, toSignIn, toSignUp, + totpSecretCode, updateBlur, updateForm, user, @@ -96,7 +96,7 @@ describe('getRouteMachineSelector', () => { ], ], ['signUp', [...commonSelectorProps, toSignIn, validationErrors, route]], - ['setupTOTP', [...commonSelectorProps, toSignIn, route]], + ['setupTOTP', [...commonSelectorProps, toSignIn, totpSecretCode, route]], ['verifyUser', [...commonSelectorProps, skipVerification, route]], ])('returns the expected route selector for %s', (route, expected) => { const selector = getRouteMachineSelector(route as AuthenticatorRoute); @@ -133,7 +133,7 @@ describe('props resolver functions', () => { resolveResetPasswordRoute, { error, isPending, toSignIn }, ], - ['SetupTOTP', resolveSetupTOTPRoute, { getTotpSecretCode, toSignIn }], + ['SetupTOTP', resolveSetupTOTPRoute, { toSignIn, totpSecretCode }], [ 'SignIn', resolveSignInRoute, diff --git a/packages/react-core/src/Authenticator/hooks/useAuthenticatorRoute/constants.ts b/packages/react-core/src/Authenticator/hooks/useAuthenticatorRoute/constants.ts index dbb49849d40..0a267a0cc41 100644 --- a/packages/react-core/src/Authenticator/hooks/useAuthenticatorRoute/constants.ts +++ b/packages/react-core/src/Authenticator/hooks/useAuthenticatorRoute/constants.ts @@ -77,6 +77,7 @@ const SIGN_UP_MACHINE_KEYS: SignUpMachineKey[] = [ const SETUP_TOTP_MACHINE_KEYS: SetupTOTPMachineKey[] = [ ...COMMON_ROUTE_MACHINE_KEYS, 'toSignIn', + 'totpSecretCode', ]; const VERIFY_USER_MACHINE_KEYS: VerifyUserMachineKey[] = [ ...COMMON_ROUTE_MACHINE_KEYS, diff --git a/packages/react-core/src/Authenticator/hooks/useAuthenticatorRoute/utils.ts b/packages/react-core/src/Authenticator/hooks/useAuthenticatorRoute/utils.ts index 629ffea5bf6..f79f89eb2bd 100644 --- a/packages/react-core/src/Authenticator/hooks/useAuthenticatorRoute/utils.ts +++ b/packages/react-core/src/Authenticator/hooks/useAuthenticatorRoute/utils.ts @@ -146,14 +146,13 @@ export function resolveResetPasswordRoute( export function resolveSetupTOTPRoute( Component: Defaults['SetupTOTP'], - { getTotpSecretCode, ...props }: UseAuthenticator + props: UseAuthenticator ): UseAuthenticatorRoute<'SetupTOTP', FieldType> { return { Component, props: { ...Component, ...getConvertedMachineProps('setupTOTP', props), - getTotpSecretCode, }, }; } diff --git a/packages/react-native/src/Authenticator/Defaults/SetupTOTP/SetupTOTP.tsx b/packages/react-native/src/Authenticator/Defaults/SetupTOTP/SetupTOTP.tsx index 2d1337a4528..8c96b584b5c 100644 --- a/packages/react-native/src/Authenticator/Defaults/SetupTOTP/SetupTOTP.tsx +++ b/packages/react-native/src/Authenticator/Defaults/SetupTOTP/SetupTOTP.tsx @@ -1,6 +1,5 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useMemo } from 'react'; -import { Logger } from 'aws-amplify'; import { authenticatorTextUtil } from '@aws-amplify/ui'; import { Label } from '../../../primitives'; @@ -18,8 +17,6 @@ import { styles } from './styles'; const COMPONENT_NAME = 'SetupTOTP'; -const logger = new Logger('Authenticator'); - const { getBackToSignInText, getConfirmingText, @@ -30,12 +27,12 @@ const { const SetupTOTP: DefaultSetupTOTPComponent = ({ fields, - getTotpSecretCode, handleBlur, handleChange, handleSubmit, isPending, toSignIn, + totpSecretCode, ...rest }) => { const { fields: fieldsWithHandlers, handleFormSubmit } = useFieldValues({ @@ -46,37 +43,20 @@ const SetupTOTP: DefaultSetupTOTPComponent = ({ handleSubmit, }); - const [secretKey, setSecretKey] = useState(null); - - const getSecretKey = useCallback(async () => { - try { - const newSecretKey = await getTotpSecretCode(); - setSecretKey(newSecretKey); - } catch (error) { - logger.error(error); - } - }, [getTotpSecretCode]); - - useEffect(() => { - if (!secretKey) { - getSecretKey(); - } - }, [getSecretKey, secretKey]); - const headerText = getSetupTOTPText(); const primaryButtonText = isPending ? getConfirmingText() : getConfirmText(); const secondaryButtonText = getBackToSignInText(); - const body = secretKey ? ( + const body = ( <> - ) : null; + ); const buttons = useMemo( () => ({ diff --git a/packages/react-native/src/Authenticator/Defaults/SetupTOTP/__tests__/SetupTOTP.spec.tsx b/packages/react-native/src/Authenticator/Defaults/SetupTOTP/__tests__/SetupTOTP.spec.tsx index 854717a9f53..fc942b3240f 100644 --- a/packages/react-native/src/Authenticator/Defaults/SetupTOTP/__tests__/SetupTOTP.spec.tsx +++ b/packages/react-native/src/Authenticator/Defaults/SetupTOTP/__tests__/SetupTOTP.spec.tsx @@ -1,13 +1,8 @@ import React from 'react'; import { fireEvent, render, waitFor } from '@testing-library/react-native'; import { authenticatorTextUtil } from '@aws-amplify/ui'; -import { Logger } from 'aws-amplify'; import { SetupTOTP } from '..'; -import { GetTotpSecretCode } from '@aws-amplify/ui-react-core/dist/types/Authenticator/hooks'; - -// use empty mockImplementation to turn off console output -const errorSpy = jest.spyOn(Logger.prototype, 'error').mockImplementation(); const code = { name: 'code', @@ -24,12 +19,12 @@ const props = { Footer: SetupTOTP.Footer, FormFields: SetupTOTP.FormFields, Header: SetupTOTP.Header, - getTotpSecretCode: jest.fn() as unknown as GetTotpSecretCode, handleBlur: jest.fn(), handleChange: jest.fn(), handleSubmit: jest.fn(), isPending: false, toSignIn, + totpSecretCode: "Let's keep it hush hush", }; const { @@ -37,54 +32,45 @@ const { getConfirmingText, getConfirmText, getSetupTOTPText, - getSetupTOTPInstructionsText, } = authenticatorTextUtil; -const SECRET_KEY = 'secretKey'; -const mockGetTotpSecretCode = jest.fn().mockResolvedValue(SECRET_KEY); - describe('SetupTOTP', () => { beforeEach(() => { jest.clearAllMocks(); }); - it('renders as expected', async () => { + it('renders as expected', () => { const { toJSON, getAllByRole, getByText } = render( ); - await waitFor(() => { - expect(toJSON()).toMatchSnapshot(); - expect(getAllByRole('header')).toBeDefined(); - expect(getByText(getSetupTOTPText())).toBeDefined(); - expect(getByText(getConfirmText())).toBeDefined(); - expect(getAllByRole('text')).toHaveLength(fields.length); - }); + expect(toJSON()).toMatchSnapshot(); + + expect(getAllByRole('header')).toBeDefined(); + expect(getByText(getSetupTOTPText())).toBeDefined(); + expect(getByText(getConfirmText())).toBeDefined(); }); - it('renders an error message', async () => { + it('renders an error message', () => { const errorMessage = 'Test error message'; const { toJSON, getByText } = render( ); - await waitFor(() => { - expect(toJSON()).toMatchSnapshot(); - expect(getByText(errorMessage)).toBeDefined(); - }); + + expect(toJSON()).toMatchSnapshot(); + expect(getByText(errorMessage)).toBeDefined(); }); - it('calls toSignIn an secondary button press', async () => { - const errorMessage = 'Test error message'; - const { getByText } = render(); - await waitFor(() => { - const secondaryButton = getByText(getBackToSignInText()); + it('calls toSignIn an secondary button press', () => { + const { getByText } = render(); - expect(secondaryButton).toBeDefined(); + const secondaryButton = getByText(getBackToSignInText()); - fireEvent(secondaryButton, 'press'); + expect(secondaryButton).toBeDefined(); - expect(toSignIn).toHaveBeenCalledTimes(1); - }); + fireEvent(secondaryButton, 'press'); + + expect(toSignIn).toHaveBeenCalledTimes(1); }); it('shows the correct submit button based on isPending', async () => { @@ -95,37 +81,4 @@ describe('SetupTOTP', () => { expect(queryByText(getConfirmText())).toBe(null); }); }); - - it('handles secret code generation as expected', async () => { - const { getByText, queryByText, rerender } = render( - - ); - await waitFor(async () => { - expect(mockGetTotpSecretCode).toHaveBeenCalledTimes(1); - expect(getByText(getSetupTOTPInstructionsText())).toBeDefined(); - expect(queryByText(SECRET_KEY)).toBeDefined(); - expect(getByText(SECRET_KEY).props.selectable).toBe(true); - expect(errorSpy).not.toHaveBeenCalled(); - - rerender( - - ); - await waitFor(() => { - expect(mockGetTotpSecretCode).toHaveBeenCalledTimes(1); - expect(errorSpy).not.toHaveBeenCalled(); - }); - }); - }); - - it('handles secret code generation errors as expected', async () => { - mockGetTotpSecretCode.mockImplementationOnce(() => { - throw new Error('Mock Error'); - }); - - render(); - await waitFor(() => { - expect(mockGetTotpSecretCode).toHaveBeenCalledTimes(1); - expect(errorSpy).toHaveBeenCalledTimes(1); - }); - }); }); diff --git a/packages/react-native/src/Authenticator/Defaults/SetupTOTP/__tests__/__snapshots__/SetupTOTP.spec.tsx.snap b/packages/react-native/src/Authenticator/Defaults/SetupTOTP/__tests__/__snapshots__/SetupTOTP.spec.tsx.snap index 484ec7e67c9..c39e5e99e6f 100644 --- a/packages/react-native/src/Authenticator/Defaults/SetupTOTP/__tests__/__snapshots__/SetupTOTP.spec.tsx.snap +++ b/packages/react-native/src/Authenticator/Defaults/SetupTOTP/__tests__/__snapshots__/SetupTOTP.spec.tsx.snap @@ -23,6 +23,51 @@ Array [ > Setup TOTP , + + Copy and paste the secret key below into an authenticator app and then enter the code in the text field below. + , + + Let's keep it hush hush + , Setup TOTP , + + Copy and paste the secret key below into an authenticator app and then enter the code in the text field below. + , + + Let's keep it hush hush + , { - const { getTotpSecretCode, isPending, user, QRFields } = useAuthenticator( - (context) => [context.isPending] + const { totpSecretCode, isPending, user, QRFields } = useAuthenticator( + (context) => [context.isPending, context.totpSecretCode] ); const { handleChange, handleSubmit } = useFormHandlers(); @@ -40,15 +40,13 @@ export const SetupTOTP = ({ const [isLoading, setIsLoading] = React.useState(true); const [qrCode, setQrCode] = React.useState(); const [copyTextLabel, setCopyTextLabel] = React.useState('COPY'); - const [secretKey, setSecretKey] = React.useState(''); + const { totpIssuer = 'AWSCognito', totpUsername = user?.username } = (QRFields as LegacyQRFields) ?? {}; const generateQRCode = React.useCallback(async (): Promise => { try { - const newSecretKey = await getTotpSecretCode(); - setSecretKey(newSecretKey); - const totpCode = getTotpCodeURL(totpIssuer, totpUsername, newSecretKey); + const totpCode = getTotpCodeURL(totpIssuer, totpUsername, totpSecretCode); const qrCodeImageSource = await QRCode.toDataURL(totpCode); setQrCode(qrCodeImageSource); @@ -57,7 +55,7 @@ export const SetupTOTP = ({ } finally { setIsLoading(false); } - }, [getTotpSecretCode, totpIssuer, totpUsername]); + }, [totpIssuer, totpUsername, totpSecretCode]); React.useEffect(() => { if (!qrCode) { @@ -66,7 +64,7 @@ export const SetupTOTP = ({ }, [generateQRCode, qrCode]); const copyText = (): void => { - navigator.clipboard.writeText(secretKey); + navigator.clipboard.writeText(totpSecretCode); setCopyTextLabel(getCopiedText()); }; @@ -96,7 +94,7 @@ export const SetupTOTP = ({ /> )} -
{secretKey}
+
{totpSecretCode}
{copyTextLabel}
({ jest.mock('../../shared/FormFields', () => ({ FormFields: () => null })); const DEFAULT_TOTP_ISSUER = 'AWSCognito'; -const SECRET_KEY = 'secretKey'; +const SECRET_KEY = "Don't tell anyone"; -const mockUser = { username: 'username' }; +const user = { username: 'username' }; const getTotpCodeURLSpy = jest.spyOn(UI, 'getTotpCodeURL'); describe('SetupTOTP', () => { - let mockGetTotpSecretCode: jest.Mock; beforeEach(() => { jest.clearAllMocks(); - mockGetTotpSecretCode = jest.fn().mockResolvedValue(SECRET_KEY); - (useAuthenticator as jest.Mock).mockReturnValue({ isPending: false, - user: mockUser, - getTotpSecretCode: mockGetTotpSecretCode, - }); + user, + totpSecretCode: SECRET_KEY, + } as UseAuthenticator); }); it('handles an undefined value when looking up QR field values', async () => { @@ -55,7 +52,7 @@ describe('SetupTOTP', () => { expect(getTotpCodeURLSpy).toHaveBeenCalledTimes(1); expect(getTotpCodeURLSpy).toHaveBeenCalledWith( DEFAULT_TOTP_ISSUER, - mockUser.username, + user.username, SECRET_KEY ); }); @@ -70,9 +67,9 @@ describe('SetupTOTP', () => { totpIssuer: customTotpIssuer, totpUsername: customTotpUsername, }, - getTotpSecretCode: mockGetTotpSecretCode, - user: mockUser, - }); + totpSecretCode: SECRET_KEY, + user, + } as UseAuthenticator); await act(async () => { render(); diff --git a/packages/react/src/components/Storage/FileUploader/FileUploader.tsx b/packages/react/src/components/Storage/FileUploader/FileUploader.tsx index 8ec957fbe83..2b0f237681b 100644 --- a/packages/react/src/components/Storage/FileUploader/FileUploader.tsx +++ b/packages/react/src/components/Storage/FileUploader/FileUploader.tsx @@ -379,6 +379,7 @@ export function FileUploader({ isLoading={isLoading} isSuccessful={isSuccessful} hasMaxFilesError={hasMaxFilesError} + maxFiles={maxFiles} onClear={onClear} onFileClick={onFileClick} aggregatePercentage={aggregatePercentage} diff --git a/packages/react/src/components/Storage/FileUploader/UploadPreviewer/__tests__/UploadPreviewer.test.tsx b/packages/react/src/components/Storage/FileUploader/UploadPreviewer/__tests__/UploadPreviewer.test.tsx index f338e6bda0f..105c0838b2a 100644 --- a/packages/react/src/components/Storage/FileUploader/UploadPreviewer/__tests__/UploadPreviewer.test.tsx +++ b/packages/react/src/components/Storage/FileUploader/UploadPreviewer/__tests__/UploadPreviewer.test.tsx @@ -35,6 +35,7 @@ const commonProps = { isLoading: false, isSuccessful: false, hasMaxFilesError: false, + maxFiles: 10, onClear: () => null, onFileClick: () => null, }; @@ -104,11 +105,7 @@ describe('UploadPreviewer', () => { }); it('shows when loading an uploading with percentage', async () => { render( - + ); expect(await screen.findByText(/Uploading: 23%/)).toBeVisible(); diff --git a/packages/react/src/components/Storage/FileUploader/UploadPreviewer/__tests__/__snapshots__/UploadPreviewer.test.tsx.snap b/packages/react/src/components/Storage/FileUploader/UploadPreviewer/__tests__/__snapshots__/UploadPreviewer.test.tsx.snap index 11c2a92d787..19d7515b1c1 100644 --- a/packages/react/src/components/Storage/FileUploader/UploadPreviewer/__tests__/__snapshots__/UploadPreviewer.test.tsx.snap +++ b/packages/react/src/components/Storage/FileUploader/UploadPreviewer/__tests__/__snapshots__/UploadPreviewer.test.tsx.snap @@ -22,22 +22,22 @@ exports[`UploadPreviewer renders as expected 1`] = ` class="amplify-fileuploader__previewer__footer__actions" >
diff --git a/packages/react/src/components/Storage/FileUploader/UploadPreviewer/index.tsx b/packages/react/src/components/Storage/FileUploader/UploadPreviewer/index.tsx index 0b5a8a7eaff..8d7c11ce9d4 100644 --- a/packages/react/src/components/Storage/FileUploader/UploadPreviewer/index.tsx +++ b/packages/react/src/components/Storage/FileUploader/UploadPreviewer/index.tsx @@ -18,10 +18,11 @@ export function UploadPreviewer({ isLoading, isSuccessful, hasMaxFilesError, + maxFiles, onClear, onFileClick, }: PreviewerProps): JSX.Element { - const headingMaxFiles = translate('Over Max files'); + const headingMaxFiles = `${translate('Cannot choose more than')} ${maxFiles}`; const getUploadedFilesLength = () => fileStatuses.filter((file) => file?.fileState === 'success').length; @@ -78,6 +79,9 @@ export function UploadPreviewer({ {!isLoading && !isSuccessful && ( <> + - - )} {isSuccessful && ( diff --git a/packages/react/src/components/Storage/FileUploader/UploadTracker/index.tsx b/packages/react/src/components/Storage/FileUploader/UploadTracker/index.tsx index 15eb24a7fb6..5aa3fd96ba5 100644 --- a/packages/react/src/components/Storage/FileUploader/UploadTracker/index.tsx +++ b/packages/react/src/components/Storage/FileUploader/UploadTracker/index.tsx @@ -79,21 +79,21 @@ export function UploadTracker({ <> ); diff --git a/packages/react/src/components/Storage/FileUploader/__tests__/FileUploader.test.tsx b/packages/react/src/components/Storage/FileUploader/__tests__/FileUploader.test.tsx index 3a9692ebe0a..eeb3e8d18bb 100644 --- a/packages/react/src/components/Storage/FileUploader/__tests__/FileUploader.test.tsx +++ b/packages/react/src/components/Storage/FileUploader/__tests__/FileUploader.test.tsx @@ -361,7 +361,7 @@ describe('File Uploader', () => { render(); // click pencel icon - const button = screen.getAllByRole('button')[1]; + const button = screen.getAllByRole('button')[0]; fireEvent.click(button); // input file name box const input = screen.getByLabelText('file name'); @@ -391,7 +391,7 @@ describe('File Uploader', () => { render(); // click pencel icon - const button = screen.getAllByRole('button')[1]; + const button = screen.getAllByRole('button')[0]; fireEvent.click(button); // input file name box const input = screen.getByLabelText('file name'); @@ -602,7 +602,7 @@ describe('File Uploader', () => { render(); - const errorText = await screen.findByText(/Over Max files/); + const errorText = await screen.findByText(/Cannot choose more than 1/); expect(errorText).toBeVisible(); }); diff --git a/packages/react/src/components/Storage/FileUploader/types.ts b/packages/react/src/components/Storage/FileUploader/types.ts index 54ea670c5bd..1b28d8e9402 100644 --- a/packages/react/src/components/Storage/FileUploader/types.ts +++ b/packages/react/src/components/Storage/FileUploader/types.ts @@ -39,6 +39,7 @@ export interface PreviewerProps { isLoading: boolean; isSuccessful: boolean; hasMaxFilesError: boolean; + maxFiles: number; onClear: () => void; onFileClick: () => void; } diff --git a/packages/ui/src/helpers/authenticator/facade.ts b/packages/ui/src/helpers/authenticator/facade.ts index 53283f457c4..ef57b96eafd 100644 --- a/packages/ui/src/helpers/authenticator/facade.ts +++ b/packages/ui/src/helpers/authenticator/facade.ts @@ -49,6 +49,7 @@ interface AuthenticatorServiceContextFacade { isPending: boolean; route: AuthenticatorRoute; socialProviders: SocialProvider[]; + totpSecretCode: string | null; unverifiedContactMethods: UnverifiedContactMethods; user: AmplifyUser; validationErrors: AuthenticatorValidationErrors; @@ -124,6 +125,7 @@ export const getServiceContextFacade = ( remoteError: error, unverifiedContactMethods, validationError: validationErrors, + totpSecretCode = null, } = actorContext; const { socialProviders } = state.context?.config ?? {}; @@ -153,7 +155,8 @@ export const getServiceContextFacade = ( return 'confirmSignUp'; case actorState?.matches('confirmSignIn'): return 'confirmSignIn'; - case actorState?.matches('setupTOTP'): + case actorState?.matches('setupTOTP.edit'): + case actorState?.matches('setupTOTP.submit'): return 'setupTOTP'; case actorState?.matches('signIn'): return 'signIn'; @@ -169,6 +172,7 @@ export const getServiceContextFacade = ( return 'verifyUser'; case actorState?.matches('confirmVerifyUser'): return 'confirmVerifyUser'; + case actorState?.matches('setupTOTP.getTotpSecretCode'): case state.matches('signIn.runActor'): /** * This route is needed for autoSignIn to capture both the @@ -207,6 +211,7 @@ export const getServiceContextFacade = ( isPending, route, socialProviders, + totpSecretCode, unverifiedContactMethods, user, validationErrors, diff --git a/packages/ui/src/helpers/authenticator/utils.ts b/packages/ui/src/helpers/authenticator/utils.ts index 7fcf24a78e9..db53f877908 100644 --- a/packages/ui/src/helpers/authenticator/utils.ts +++ b/packages/ui/src/helpers/authenticator/utils.ts @@ -98,13 +98,14 @@ export const defaultAuthHubHandler: AuthMachineHubHandler = async ( } } break; - case 'autoSignIn_failure': + case 'autoSignIn_failure': { await waitForAutoSignInState(service); const currentActorState = getActorState(service.getSnapshot()); if (currentActorState?.matches('autoSignIn')) { send({ type: 'AUTO_SIGN_IN_FAILURE', data: data.payload.data }); } break; + } case 'signOut': case 'tokenRefresh_failure': if (state.matches('authenticated.idle')) { @@ -133,6 +134,7 @@ const getHubEventHandler = */ export const listenToAuthHub = ( service: AuthInterpreter, + // angular passes its own `handler` param handler: AuthMachineHubHandler = defaultAuthHubHandler ) => { return Hub.listen( diff --git a/packages/ui/src/machines/authenticator/actions.ts b/packages/ui/src/machines/authenticator/actions.ts index 0063125bd57..87526de7ed0 100644 --- a/packages/ui/src/machines/authenticator/actions.ts +++ b/packages/ui/src/machines/authenticator/actions.ts @@ -57,6 +57,10 @@ export const clearValidationError = assign({ validationError: (_) => ({}) }); /** * "set" actions */ +export const setTotpSecretCode = assign({ + totpSecretCode: (_, event: AuthEvent) => event.data, +}); + export const setChallengeName = assign({ challengeName: (_, event: AuthEvent) => event.data?.challengeName, }); diff --git a/packages/ui/src/machines/authenticator/actors/signIn.ts b/packages/ui/src/machines/authenticator/actors/signIn.ts index b2bffbfaab0..3687ebb64b5 100644 --- a/packages/ui/src/machines/authenticator/actors/signIn.ts +++ b/packages/ui/src/machines/authenticator/actors/signIn.ts @@ -29,6 +29,7 @@ import { setFieldErrors, setRemoteError, setRequiredAttributes, + setTotpSecretCode, setUnverifiedContactMethods, setUser, setUsernameAuthAttributes, @@ -372,9 +373,22 @@ export function signInActor({ services }: SignInMachineOptions) { }, }, setupTOTP: { - initial: 'edit', + initial: 'getTotpSecretCode', exit: ['clearFormValues', 'clearError', 'clearTouched'], states: { + getTotpSecretCode: { + invoke: { + src: 'getTotpSecretCode', + onDone: { + target: 'edit', + actions: 'setTotpSecretCode', + }, + onError: { + target: 'edit', + actions: 'setRemoteError', + }, + }, + }, edit: { entry: 'sendUpdate', on: { @@ -498,6 +512,7 @@ export function signInActor({ services }: SignInMachineOptions) { setCredentials, setFieldErrors, setRemoteError, + setTotpSecretCode, setUnverifiedContactMethods, setUser, setUsernameAuthAttributes, @@ -606,6 +621,10 @@ export function signInActor({ services }: SignInMachineOptions) { return Promise.reject(err); } }, + async getTotpSecretCode(context) { + const { user } = context; + return Auth.setupTOTP(user); + }, async verifyTotpToken(context) { const { formValues, user } = context; const { confirmation_code } = formValues; diff --git a/packages/ui/src/types/authenticator/stateMachine/context.ts b/packages/ui/src/types/authenticator/stateMachine/context.ts index 70cf3d2e61f..29303e25704 100644 --- a/packages/ui/src/types/authenticator/stateMachine/context.ts +++ b/packages/ui/src/types/authenticator/stateMachine/context.ts @@ -76,6 +76,8 @@ interface BaseFormContext { codeDeliveryDetails?: CodeDeliveryDetails; /** Default country code for all phone number fields. */ country_code?: string; // TODO: this one should be customizable as well + /** TOTP secret code */ + totpSecretCode?: string; } // Actor context types diff --git a/packages/vue/.eslintrc.cjs b/packages/vue/.eslintrc.cjs index 23399828ce8..c69d73abf81 100644 --- a/packages/vue/.eslintrc.cjs +++ b/packages/vue/.eslintrc.cjs @@ -7,7 +7,6 @@ module.exports = { 'plugin:vue/vue3-essential', 'eslint:recommended', '@vue/eslint-config-typescript/recommended', - '@vue/eslint-config-prettier', ], parserOptions: { ecmaVersion: 2020, diff --git a/packages/vue/src/components/authenticator.vue b/packages/vue/src/components/authenticator.vue index 3eb70f739d9..95e7c76c2bf 100644 --- a/packages/vue/src/components/authenticator.vue +++ b/packages/vue/src/components/authenticator.vue @@ -427,7 +427,10 @@ const hasRouteComponent = computed(() => { diff --git a/packages/vue/src/components/setup-totp.vue b/packages/vue/src/components/setup-totp.vue index 71e83f72776..f7e97fb4157 100644 --- a/packages/vue/src/components/setup-totp.vue +++ b/packages/vue/src/components/setup-totp.vue @@ -3,18 +3,21 @@ import { onMounted, reactive, computed, ComputedRef, useAttrs, ref } from 'vue'; import { createSharedComposable } from '@vueuse/core'; import QRCode from 'qrcode'; -import { Auth, Logger } from 'aws-amplify'; +import { Logger } from 'aws-amplify'; import { authenticatorTextUtil, getActorState, getFormDataFromEvent, SignInState, translate, + getTotpCodeURL, } from '@aws-amplify/ui'; import { useAuth, useAuthenticator } from '../composables/useAuth'; import BaseFormFields from './primitives/base-form-fields.vue'; +const logger = new Logger('SetupTOTP-logger'); + const useAuthShared = createSharedComposable(useAuthenticator); const props = useAuthShared(); @@ -26,46 +29,45 @@ const { value: { context }, } = state; -const formOverrides = context?.config?.formFields?.setupTOTP; -const QROR = formOverrides?.['QR']; - const actorState = computed(() => getActorState(state.value) ) as ComputedRef; +const { totpSecretCode, user } = actorState.value.context; + +const formOverrides = context?.config?.formFields?.setupTOTP; +const { totpIssuer = 'AWSCognito', totpUsername = user?.username } = + formOverrides?.['QR'] ?? {}; + +const totpCodeURL = + typeof totpSecretCode === 'string' && typeof totpUsername === 'string' + ? getTotpCodeURL(totpIssuer, totpUsername, totpSecretCode) + : null; -let qrCode = reactive({ +const qrCode = reactive({ qrCodeImageSource: '', isLoading: true, }); -let secretKey = ref(''); // Text Util const { getCopyText, getCopiedText, getBackToSignInText, getConfirmText } = authenticatorTextUtil; -let copyTextLabel = ref(getCopyText()); +const copyTextLabel = ref(getCopyText()); function copyText() { - navigator.clipboard.writeText(secretKey.value); + if (typeof totpSecretCode === 'string') { + navigator.clipboard.writeText(totpSecretCode); + } copyTextLabel.value = getCopiedText(); } // lifecycle hooks - onMounted(async () => { - const logger = new Logger('SetupTOTP-logger'); - const { user } = actorState.value.context; - if (!user) { + if (!user || !totpCodeURL) { return; } try { - secretKey.value = await Auth.setupTOTP(user); - const issuer = QROR?.totpIssuer ?? 'AWSCognito'; - const username = QROR?.totpUsername ?? user.username; - const totpCode = encodeURI( - `otpauth://totp/${issuer}:${username}?secret=${secretKey.value}&issuer=${issuer}` - ); - qrCode.qrCodeImageSource = await QRCode.toDataURL(totpCode); + qrCode.qrCodeImageSource = await QRCode.toDataURL(totpCodeURL); } catch (error) { logger.error(error); } finally { @@ -80,11 +82,7 @@ const confirmText = computed(() => getConfirmText()); // Methods const onInput = (e: Event): void => { const { name, value } = e.target as HTMLInputElement; - send({ - type: 'CHANGE', - //@ts-ignore - data: { name, value }, - }); + send({ type: 'CHANGE', data: { name, value } }); }; const onSetupTOTPSubmit = (e: Event): void => { @@ -103,9 +101,7 @@ const onBackToSignInClicked = (): void => { if (attrs?.onBackToSignInClicked) { emit('backToSignInClicked'); } else { - send({ - type: 'SIGN_IN', - }); + send({ type: 'SIGN_IN' }); } }; @@ -122,18 +118,18 @@ const onBackToSignInClicked = (): void => { class="amplify-flex amplify-authenticator__column" :disabled="actorState.matches('confirmSignIn.pending')" > - - + + + {{ translate(actorState.context.remoteError) }} + + + {{ confirmText }} + + + {{ backSignInText }} + + + +