From 97eb8c65ef0ee88f4437af5f77f1776f92cd3d5f Mon Sep 17 00:00:00 2001 From: yauheni-deriv <103182683+yauheni-deriv@users.noreply.github.com> Date: Wed, 18 Sep 2024 12:32:09 +0300 Subject: [PATCH] [UPM-1474]/evgeniy/passkey remove without verification (#16764) * chore: [UPM-1474]/evgeniy/passkey remove without verification * fix: import and typo * fix: error modal overlapping, no paskey page show when no passkey after removing * chore: test cases * refactor: remove usequery from getpasskeys * fix: failing test * fix: failing tests * fix: test * fix: wrong condition to save empty passkey array --- .../network-status-toast-popup.scss | 2 +- .../account/src/Constants/routes-config.tsx | 6 +- .../Passkeys/__tests__/passkeys.spec.tsx | 106 +++++++----- .../__tests__/passkey-card.spec.tsx | 2 +- .../__tests__/passkey-removed.spec.tsx | 49 ++++++ .../__tests__/passkey-rename.spec.tsx | 2 +- .../passkeys-status-container.spec.tsx | 2 +- .../components/description-container.tsx | 3 +- .../Passkeys/components/no-passkeys.tsx | 3 +- .../Passkeys/components/passkey-card.tsx | 34 ++-- .../Passkeys/components/passkey-created.tsx | 3 +- .../components/passkey-error-modal.tsx | 7 +- .../components/passkey-reminder-modal.tsx | 6 +- .../passkey-remove-confirmation-modal.tsx | 48 ++++++ .../Passkeys/components/passkey-removed.tsx | 27 +++ .../Passkeys/components/passkey-rename.tsx | 8 +- .../components/passkeys-learn-more.tsx | 8 +- .../Passkeys/components/passkeys-list.tsx | 3 +- .../components/passkeys-status-container.tsx | 5 +- .../Passkeys/components/tips-block.tsx | 3 +- .../Security/Passkeys/passkeys-configs.tsx | 14 +- .../Sections/Security/Passkeys/passkeys.tsx | 158 +++++++++++------- packages/account/src/Sections/index.js | 2 - packages/core/src/Stores/client-store.js | 12 +- .../src/__tests__/useGetPasskeysList.spec.tsx | 101 +++++++---- .../src/__tests__/useRegisterPasskey.spec.tsx | 47 ++---- .../src/__tests__/useRemovePasskey.spec.tsx | 59 +++++++ .../src/__tests__/useRenamePasskey.spec.tsx | 25 +-- packages/hooks/src/index.ts | 1 + packages/hooks/src/useGetPasskeysList.ts | 51 +++--- packages/hooks/src/useRegisterPasskey.ts | 42 +---- packages/hooks/src/useRemovePasskey.ts | 32 ++++ packages/hooks/src/useRenamePasskey.ts | 31 +--- packages/stores/src/mockStore.ts | 2 + packages/stores/types.ts | 10 ++ 35 files changed, 582 insertions(+), 332 deletions(-) create mode 100644 packages/account/src/Sections/Security/Passkeys/components/__tests__/passkey-removed.spec.tsx create mode 100644 packages/account/src/Sections/Security/Passkeys/components/passkey-remove-confirmation-modal.tsx create mode 100644 packages/account/src/Sections/Security/Passkeys/components/passkey-removed.tsx create mode 100644 packages/hooks/src/__tests__/useRemovePasskey.spec.tsx create mode 100644 packages/hooks/src/useRemovePasskey.ts diff --git a/packages/account/src/Components/network-status-toast-popup/network-status-toast-popup.scss b/packages/account/src/Components/network-status-toast-popup/network-status-toast-popup.scss index 0a9a8ce79d98..ff5daadf94cb 100644 --- a/packages/account/src/Components/network-status-toast-popup/network-status-toast-popup.scss +++ b/packages/account/src/Components/network-status-toast-popup/network-status-toast-popup.scss @@ -1,6 +1,6 @@ .network-status { &__container { - .snackbar { + .quill-snackbar { //center fixed element with dynamic width left: 50%; transform: translateX(-50%); diff --git a/packages/account/src/Constants/routes-config.tsx b/packages/account/src/Constants/routes-config.tsx index f73671e9dd84..cae933c6c535 100644 --- a/packages/account/src/Constants/routes-config.tsx +++ b/packages/account/src/Constants/routes-config.tsx @@ -4,7 +4,6 @@ import { routes, moduleLoader, makeLazyLoader } from '@deriv/shared'; import { localize } from '@deriv/translations'; import { - Passkeys, PersonalDetails, ProofOfIdentity, ProofOfAddress, @@ -27,6 +26,11 @@ const Passwords = makeLazyLoader( () => )(); +const Passkeys = makeLazyLoader( + () => moduleLoader(() => import('../Sections/Security/Passkeys')), + () => +)(); + const AccountLimits = makeLazyLoader( () => moduleLoader(() => import('../Sections/Security/AccountLimits')), () => diff --git a/packages/account/src/Sections/Security/Passkeys/__tests__/passkeys.spec.tsx b/packages/account/src/Sections/Security/Passkeys/__tests__/passkeys.spec.tsx index bde4ce705c08..c63d9d06ca5e 100644 --- a/packages/account/src/Sections/Security/Passkeys/__tests__/passkeys.spec.tsx +++ b/packages/account/src/Sections/Security/Passkeys/__tests__/passkeys.spec.tsx @@ -4,7 +4,7 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { Analytics } from '@deriv-com/analytics'; import { APIProvider } from '@deriv/api'; -import { useGetPasskeysList, useRegisterPasskey } from '@deriv/hooks'; +import { useGetPasskeysList, useRegisterPasskey, useRenamePasskey, useRemovePasskey } from '@deriv/hooks'; import { useDevice } from '@deriv-com/ui'; import { routes } from '@deriv/shared'; import { mockStore, StoreProvider } from '@deriv/stores'; @@ -48,6 +48,8 @@ jest.mock('@deriv/hooks', () => ({ ...jest.requireActual('@deriv/hooks'), useGetPasskeysList: jest.fn(() => ({})), useRegisterPasskey: jest.fn(() => ({})), + useRenamePasskey: jest.fn(() => ({})), + useRemovePasskey: jest.fn(() => ({})), })); jest.mock('@deriv/components', () => ({ @@ -119,7 +121,8 @@ describe('Passkeys', () => { const mockCreatePasskey = jest.fn(); const mockStartPasskeyRegistration = jest.fn(); - const mockClearPasskeyRegistrationError = jest.fn(); + const mockRenamePasskey = jest.fn(); + const mockRemovePasskey = jest.fn(); const mockReloadPasskeysList = jest.fn(); it("doesn't render existed passkeys for desktop and tablet", () => { @@ -207,81 +210,92 @@ describe('Passkeys', () => { ); }); - it('renders success screen when new passkeys created and open "add more passkeys" ', () => { + it('renders passkeys creation modal and triggers new passkey creation', async () => { + (useGetPasskeysList as jest.Mock).mockReturnValue({ + is_passkeys_list_loading: false, + }); + mock_store.client.is_passkey_supported = true; + (useRegisterPasskey as jest.Mock).mockReturnValue({ - is_passkey_registered: true, + createPasskey: mockCreatePasskey, + startPasskeyRegistration: mockStartPasskeyRegistration, }); renderComponent(); - expect(screen.getByText('Success!')).toBeInTheDocument(); + userEvent.click(screen.getByRole('button', { name: create_passkey })); + expect(screen.getByText('Just a reminder')).toBeInTheDocument(); + expect(screen.getByText('Enable screen lock on your device.')).toBeInTheDocument(); + expect(screen.getByText('Enable bluetooth.')).toBeInTheDocument(); + expect(screen.getByText('Sign in to your Google or iCloud account.')).toBeInTheDocument(); + + userEvent.click(screen.getByRole('button', { name: continue_button })); + expect(mockCreatePasskey).toBeCalledTimes(1); expect(Analytics.trackEvent).toHaveBeenCalledWith( tracking_event, - getAnalyticsParams('create_passkey_finished') + getAnalyticsParams('create_passkey_reminder_passed') ); - const add_more_passkeys_button = screen.getByRole('button', { name: 'Add more passkeys' }); - userEvent.click(add_more_passkeys_button); - expect(Analytics.trackEvent).toHaveBeenCalledWith(tracking_event, getAnalyticsParams('add_more_passkeys')); - - const create_passkey_button = screen.getByRole('button', { name: create_passkey }); - expect(create_passkey_button).toBeInTheDocument(); - expect(screen.queryByText('Success!')).not.toBeInTheDocument(); }); - it('renders success screen when new passkeys created and open tradershub ', () => { - (useRegisterPasskey as jest.Mock).mockReturnValue({ - is_passkey_registered: true, + it('renders passkeys and triggers rename passkey', async () => { + (useGetPasskeysList as jest.Mock).mockReturnValue({ + is_passkeys_list_loading: false, + passkeys_list: mock_passkeys_list, + }); + mock_store.client.is_passkey_supported = true; + + (useRenamePasskey as jest.Mock).mockReturnValue({ + renamePasskey: mockRenamePasskey, }); renderComponent(); - expect(screen.getByText('Success!')).toBeInTheDocument(); - expect(Analytics.trackEvent).toHaveBeenCalledWith( - tracking_event, - getAnalyticsParams('create_passkey_finished') - ); + expect(screen.queryByText('Edit passkey')).not.toBeInTheDocument(); - const continue_trading_button = screen.getByRole('button', { name: 'Continue trading' }); - userEvent.click(continue_trading_button); - expect(Analytics.trackEvent).toHaveBeenCalledWith( - tracking_event, - getAnalyticsParams('create_passkey_continue_trading') - ); - expect(mockHistoryPush).toHaveBeenCalledWith(routes.traders_hub); + userEvent.click(screen.getAllByTestId('dt_dropdown_display')[0]); + userEvent.click(screen.getByText('Rename')); + + expect(Analytics.trackEvent).toHaveBeenCalledWith(tracking_event, getAnalyticsParams('passkey_rename_started')); + expect(screen.getByText('Edit passkey')).toBeInTheDocument(); + + const input: HTMLInputElement = screen.getByRole('textbox'); + userEvent.clear(input); + userEvent.type(input, 'new passkey name'); + userEvent.click(screen.getByRole('button', { name: /save changes/i })); + + await waitFor(() => { + expect(mockRenamePasskey).toHaveBeenCalledTimes(1); + }); }); - it('renders passkeys creation modal and triggers new passkey creation', async () => { + it('renders passkeys and triggers remove passkey', async () => { (useGetPasskeysList as jest.Mock).mockReturnValue({ is_passkeys_list_loading: false, + passkeys_list: mock_passkeys_list, }); mock_store.client.is_passkey_supported = true; - (useRegisterPasskey as jest.Mock).mockReturnValue({ - createPasskey: mockCreatePasskey, - is_passkey_registration_started: true, - startPasskeyRegistration: mockStartPasskeyRegistration, + (useRemovePasskey as jest.Mock).mockReturnValue({ + removePasskey: mockRemovePasskey, }); renderComponent(); - userEvent.click(screen.getByRole('button', { name: create_passkey })); - expect(screen.getByText('Just a reminder')).toBeInTheDocument(); - expect(screen.getByText('Enable screen lock on your device.')).toBeInTheDocument(); - expect(screen.getByText('Enable bluetooth.')).toBeInTheDocument(); - expect(screen.getByText('Sign in to your Google or iCloud account.')).toBeInTheDocument(); + expect(screen.queryByText('Edit passkey')).not.toBeInTheDocument(); - userEvent.click(screen.getByRole('button', { name: continue_button })); - expect(mockCreatePasskey).toBeCalledTimes(1); - expect(Analytics.trackEvent).toHaveBeenCalledWith( - tracking_event, - getAnalyticsParams('create_passkey_reminder_passed') - ); + userEvent.click(screen.getAllByTestId('dt_dropdown_display')[0]); + userEvent.click(screen.getByText('Remove')); + + expect(Analytics.trackEvent).toHaveBeenCalledWith(tracking_event, getAnalyticsParams('passkey_remove_started')); + expect(screen.getByText('Are you sure you want to remove this passkey?')).toBeInTheDocument(); + + userEvent.click(screen.getByRole('button', { name: /remove/i })); + expect(mockRemovePasskey).toHaveBeenCalledTimes(1); }); it('renders passkeys registration error modal and triggers closing', async () => { (useRegisterPasskey as jest.Mock).mockReturnValue({ passkey_registration_error: { message: 'error' }, - clearPasskeyRegistrationError: mockClearPasskeyRegistrationError, startPasskeyRegistration: mockStartPasskeyRegistration, createPasskey: mockCreatePasskey, }); @@ -297,8 +311,8 @@ describe('Passkeys', () => { }); userEvent.click(screen.getByRole('button', { name: ok_button })); + await waitFor(() => { - expect(mockClearPasskeyRegistrationError).toBeCalledTimes(1); expect(mockHistoryPush).toHaveBeenCalledWith(routes.traders_hub); }); }); diff --git a/packages/account/src/Sections/Security/Passkeys/components/__tests__/passkey-card.spec.tsx b/packages/account/src/Sections/Security/Passkeys/components/__tests__/passkey-card.spec.tsx index c0e635c64aff..a8e159b1bb3c 100644 --- a/packages/account/src/Sections/Security/Passkeys/components/__tests__/passkey-card.spec.tsx +++ b/packages/account/src/Sections/Security/Passkeys/components/__tests__/passkey-card.spec.tsx @@ -30,7 +30,7 @@ const mock_passkeys_list: React.ComponentProps['passkeys_li jest.mock('@deriv/shared', () => ({ ...jest.requireActual('@deriv/shared'), - getOSNameWithUAParser: () => 'test OS', + getOSNameWithUAParser: jest.fn(() => 'test OS'), })); describe('PasskeyCard', () => { diff --git a/packages/account/src/Sections/Security/Passkeys/components/__tests__/passkey-removed.spec.tsx b/packages/account/src/Sections/Security/Passkeys/components/__tests__/passkey-removed.spec.tsx new file mode 100644 index 000000000000..ebce409b3df4 --- /dev/null +++ b/packages/account/src/Sections/Security/Passkeys/components/__tests__/passkey-removed.spec.tsx @@ -0,0 +1,49 @@ +import userEvent from '@testing-library/user-event'; +import { render, screen } from '@testing-library/react'; +import { PasskeyRemoved } from '../passkey-removed'; +import { getOSNameWithUAParser } from '@deriv/shared'; + +jest.mock('@deriv/shared', () => ({ + ...jest.requireActual('@deriv/shared'), + getOSNameWithUAParser: jest.fn(() => 'test OS'), +})); + +describe('PasskeyRemoved', () => { + it('renders correctly for iOS', () => { + (getOSNameWithUAParser as jest.Mock).mockReturnValue('iOS'); + + const mockOnPrimaryButtonClick = jest.fn(); + + render(); + + expect(screen.getByText('Passkey successfully removed')).toBeInTheDocument(); + expect( + screen.getByText( + 'Your passkey is successfully removed. To avoid sign-in prompts, also remove the passkey from your iCloud keychain.' + ) + ).toBeInTheDocument(); + expect(screen.getByText('Continue')).toBeInTheDocument(); + + userEvent.click(screen.getByText('Continue')); + expect(mockOnPrimaryButtonClick).toHaveBeenCalled(); + }); + + it('renders correctly for other OS', () => { + (getOSNameWithUAParser as jest.Mock).mockReturnValue('Windows'); + + const mockOnPrimaryButtonClick = jest.fn(); + + render(); + + expect(screen.getByText('Passkey successfully removed')).toBeInTheDocument(); + expect( + screen.getByText( + 'Your passkey is successfully removed. To avoid sign-in prompts, also remove the passkey from your Google password manager.' + ) + ).toBeInTheDocument(); + expect(screen.getByText('Continue')).toBeInTheDocument(); + + userEvent.click(screen.getByText('Continue')); + expect(mockOnPrimaryButtonClick).toHaveBeenCalled(); + }); +}); diff --git a/packages/account/src/Sections/Security/Passkeys/components/__tests__/passkey-rename.spec.tsx b/packages/account/src/Sections/Security/Passkeys/components/__tests__/passkey-rename.spec.tsx index 005ad67c21fb..416b7855e886 100644 --- a/packages/account/src/Sections/Security/Passkeys/components/__tests__/passkey-rename.spec.tsx +++ b/packages/account/src/Sections/Security/Passkeys/components/__tests__/passkey-rename.spec.tsx @@ -9,7 +9,7 @@ describe('PasskeyRename', () => { const new_passkey_name = 'new passkey name'; const mock_current_managed_passkey: React.ComponentProps< typeof PasskeysStatusContainer - >['current_managed_passkey'] = { id: 777, name: init_passkey_name }; + >['current_managed_passkey'] = { id: 777, name: init_passkey_name, passkey_id: 'test_passkey_id' }; const validation_error = 'Only 3-30 characters allowed.'; const mockOnPrimaryButtonClick = jest.fn(); const mockOnSecondaryButtonClick = jest.fn(); diff --git a/packages/account/src/Sections/Security/Passkeys/components/__tests__/passkeys-status-container.spec.tsx b/packages/account/src/Sections/Security/Passkeys/components/__tests__/passkeys-status-container.spec.tsx index f8021656efb9..792b35ff0f45 100644 --- a/packages/account/src/Sections/Security/Passkeys/components/__tests__/passkeys-status-container.spec.tsx +++ b/packages/account/src/Sections/Security/Passkeys/components/__tests__/passkeys-status-container.spec.tsx @@ -25,7 +25,7 @@ describe('PasskeysStatusContainer', () => { const mockOnSecondaryButtonClick = jest.fn(); const mock_current_managed_passkey: React.ComponentProps< typeof PasskeysStatusContainer - >['current_managed_passkey'] = { id: 777, name: 'test passkey name' }; + >['current_managed_passkey'] = { id: 777, name: 'test passkey name', passkey_id: 'test_passkey_id' }; const renderComponent = (passkey_status: TPasskeysStatus) => { render( diff --git a/packages/account/src/Sections/Security/Passkeys/components/description-container.tsx b/packages/account/src/Sections/Security/Passkeys/components/description-container.tsx index 9ed3710cdfd6..0c5bc0aaa6f7 100644 --- a/packages/account/src/Sections/Security/Passkeys/components/description-container.tsx +++ b/packages/account/src/Sections/Security/Passkeys/components/description-container.tsx @@ -1,6 +1,5 @@ -import React from 'react'; import { Text } from '@deriv/components'; -import { Localize } from '@deriv/translations'; +import { Localize } from '@deriv-com/translations'; const getPasskeysDescriptions = () => [ diff --git a/packages/account/src/Sections/Security/Passkeys/components/no-passkeys.tsx b/packages/account/src/Sections/Security/Passkeys/components/no-passkeys.tsx index 059342c9529a..4328365206e8 100644 --- a/packages/account/src/Sections/Security/Passkeys/components/no-passkeys.tsx +++ b/packages/account/src/Sections/Security/Passkeys/components/no-passkeys.tsx @@ -1,5 +1,4 @@ -import React from 'react'; -import { Localize } from '@deriv/translations'; +import { Localize } from '@deriv-com/translations'; import { DerivLightIcAddPasskeyIcon } from '@deriv/quill-icons'; import { PasskeysStatusLayout, TPasskeysButtonOnClicks } from './passkeys-status-layout'; diff --git a/packages/account/src/Sections/Security/Passkeys/components/passkey-card.tsx b/packages/account/src/Sections/Security/Passkeys/components/passkey-card.tsx index bb57597557c8..228eeff77594 100644 --- a/packages/account/src/Sections/Security/Passkeys/components/passkey-card.tsx +++ b/packages/account/src/Sections/Security/Passkeys/components/passkey-card.tsx @@ -1,24 +1,23 @@ -import React from 'react'; import { Dropdown, Icon, Text } from '@deriv/components'; import { getLongDate } from '@deriv/shared'; -import { localize, Localize } from '@deriv/translations'; +import { useTranslations, Localize } from '@deriv-com/translations'; import { TOnPasskeyMenuClick, TPasskey } from '../passkeys'; import { PASSKEY_STATUS_CODES, passkeysMenuActionEventTrack } from '../passkeys-configs'; type TPasskeyCard = TPasskey & { onPasskeyMenuClick: TOnPasskeyMenuClick }; -export const PasskeyCard = ({ name, last_used, stored_on, id, icon, onPasskeyMenuClick }: TPasskeyCard) => { +export const PasskeyCard = ({ name, last_used, stored_on, id, icon, passkey_id, onPasskeyMenuClick }: TPasskeyCard) => { + const { localize } = useTranslations(); + + const current_passkey_data = { id, name, passkey_id }; + const handleManagePasskey = (event: { target: { value: string } }) => { if (event.target.value === 'rename') { - onPasskeyMenuClick(PASSKEY_STATUS_CODES.RENAMING, { - id, - name, - }); - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - passkeysMenuActionEventTrack('passkey_rename_open'); - } else if (event.target.value === 'revoke') { - // TODO: add action for revoke passkey + onPasskeyMenuClick(PASSKEY_STATUS_CODES.RENAMING, current_passkey_data); + passkeysMenuActionEventTrack('passkey_rename_started'); + } else if (event.target.value === 'remove') { + onPasskeyMenuClick(PASSKEY_STATUS_CODES.REMOVING, current_passkey_data); + passkeysMenuActionEventTrack('passkey_remove_started'); } }; @@ -45,6 +44,7 @@ export const PasskeyCard = ({ name, last_used, stored_on, id, icon, onPasskeyMen {icon && } { - const isNotSupportedError = (error: TServerError) => error?.name === 'NotSupportedError'; - const error_message_header = ( {isNotSupportedError(error as TServerError) ? ( diff --git a/packages/account/src/Sections/Security/Passkeys/components/passkey-reminder-modal.tsx b/packages/account/src/Sections/Security/Passkeys/components/passkey-reminder-modal.tsx index 89dad55405a5..a3b43bb8b46f 100644 --- a/packages/account/src/Sections/Security/Passkeys/components/passkey-reminder-modal.tsx +++ b/packages/account/src/Sections/Security/Passkeys/components/passkey-reminder-modal.tsx @@ -1,9 +1,9 @@ -import React from 'react'; +import { MouseEventHandler } from 'react'; import { Button, Modal, Text } from '@deriv/components'; -import { Localize } from '@deriv/translations'; +import { Localize } from '@deriv-com/translations'; type TPasskeyReminderModal = { - onButtonClick: React.MouseEventHandler; + onButtonClick: MouseEventHandler; is_modal_open: boolean; toggleModal?: () => void; }; diff --git a/packages/account/src/Sections/Security/Passkeys/components/passkey-remove-confirmation-modal.tsx b/packages/account/src/Sections/Security/Passkeys/components/passkey-remove-confirmation-modal.tsx new file mode 100644 index 000000000000..92516316c8f1 --- /dev/null +++ b/packages/account/src/Sections/Security/Passkeys/components/passkey-remove-confirmation-modal.tsx @@ -0,0 +1,48 @@ +import { MouseEventHandler } from 'react'; +import { Button, Modal, Text } from '@deriv/components'; +import { Localize } from '@deriv-com/translations'; + +type TPasskeyRemoveConfirmationrModal = { + onPrimaryButtonClick: MouseEventHandler; + onSecondaryButtonClick: MouseEventHandler; + is_modal_open: boolean; + toggleModal?: () => void; +}; + +export const PasskeyRemoveConfirmationModal = ({ + is_modal_open, + onPrimaryButtonClick, + onSecondaryButtonClick, + toggleModal, +}: TPasskeyRemoveConfirmationrModal) => { + const header = ( + + + + ); + + return ( + + + + + + + + + + + + ); +}; diff --git a/packages/account/src/Sections/Security/Passkeys/components/passkey-removed.tsx b/packages/account/src/Sections/Security/Passkeys/components/passkey-removed.tsx new file mode 100644 index 000000000000..0b1b8a73113c --- /dev/null +++ b/packages/account/src/Sections/Security/Passkeys/components/passkey-removed.tsx @@ -0,0 +1,27 @@ +import { Localize } from '@deriv-com/translations'; +import { getOSNameWithUAParser } from '@deriv/shared'; +import { DerivLightIcSuccessPasskeyIcon } from '@deriv/quill-icons'; +import { PasskeysStatusLayout, TPasskeysButtonOnClicks } from './passkeys-status-layout'; + +const getPasskeysRemovedDescription = (os: ReturnType) => { + if (os === 'iOS' || os === 'Mac OS') { + return ( + + ); + } + return ( + + ); +}; + +export const PasskeyRemoved = ({ onPrimaryButtonClick }: TPasskeysButtonOnClicks) => ( +
+ } + title={} + onPrimaryButtonClick={onPrimaryButtonClick} + primary_button_text={} + /> +
+); diff --git a/packages/account/src/Sections/Security/Passkeys/components/passkey-rename.tsx b/packages/account/src/Sections/Security/Passkeys/components/passkey-rename.tsx index 09763b72a500..211e126fbd44 100644 --- a/packages/account/src/Sections/Security/Passkeys/components/passkey-rename.tsx +++ b/packages/account/src/Sections/Security/Passkeys/components/passkey-rename.tsx @@ -1,8 +1,7 @@ -import React from 'react'; import { Form, Formik } from 'formik'; -import { localize, Localize } from '@deriv/translations'; +import { Localize, useTranslations } from '@deriv-com/translations'; import { DerivLightIcEditPasskeyIcon } from '@deriv/quill-icons'; -import { FormInputField } from 'Components/forms/form-fields'; +import { FormInputField } from '../../../../Components/forms/form-fields'; import { TCurrentManagedPasskey } from '../passkeys'; import { getPasskeyRenameValidationSchema } from '../passkeys-configs'; import { PasskeysStatusLayout, TPasskeysButtonOnClicks } from './passkeys-status-layout'; @@ -16,6 +15,8 @@ export const PasskeyRename = ({ onSecondaryButtonClick, current_managed_passkey, }: TPasskeyRename) => { + const { localize } = useTranslations(); + const form_initial_values: TInitialValues = { passkey_name: current_managed_passkey.name, }; @@ -29,6 +30,7 @@ export const PasskeyRename = ({ initialValues={form_initial_values} onSubmit={onSubmitValues} validationSchema={getPasskeyRenameValidationSchema()} + noValidate > {({ dirty, isValid }) => (
diff --git a/packages/account/src/Sections/Security/Passkeys/components/passkeys-learn-more.tsx b/packages/account/src/Sections/Security/Passkeys/components/passkeys-learn-more.tsx index e5adb60de976..fe228bf6eeac 100644 --- a/packages/account/src/Sections/Security/Passkeys/components/passkeys-learn-more.tsx +++ b/packages/account/src/Sections/Security/Passkeys/components/passkeys-learn-more.tsx @@ -1,6 +1,6 @@ -import React from 'react'; +import { Fragment } from 'react'; import { Icon } from '@deriv/components'; -import { Localize } from '@deriv/translations'; +import { Localize } from '@deriv-com/translations'; import { DerivLightIcInfoPasskeyIcon } from '@deriv/quill-icons'; import { DescriptionContainer } from './description-container'; import { PasskeysStatusLayout, TPasskeysButtonOnClicks } from './passkeys-status-layout'; @@ -17,10 +17,10 @@ export const PasskeysLearnMore = ({ onPrimaryButtonClick, onSecondaryButtonClick + - + } icon={} title={} diff --git a/packages/account/src/Sections/Security/Passkeys/components/passkeys-list.tsx b/packages/account/src/Sections/Security/Passkeys/components/passkeys-list.tsx index 3ec6ba332db9..08e7f13c9951 100644 --- a/packages/account/src/Sections/Security/Passkeys/components/passkeys-list.tsx +++ b/packages/account/src/Sections/Security/Passkeys/components/passkeys-list.tsx @@ -1,5 +1,4 @@ -import React from 'react'; -import { Localize } from '@deriv/translations'; +import { Localize } from '@deriv-com/translations'; import { TOnPasskeyMenuClick, TPasskey } from '../passkeys'; import { PasskeyCard } from './passkey-card'; import { PasskeysStatusLayout, TPasskeysButtonOnClicks } from './passkeys-status-layout'; diff --git a/packages/account/src/Sections/Security/Passkeys/components/passkeys-status-container.tsx b/packages/account/src/Sections/Security/Passkeys/components/passkeys-status-container.tsx index 56f547be285d..0a10f2025d5b 100644 --- a/packages/account/src/Sections/Security/Passkeys/components/passkeys-status-container.tsx +++ b/packages/account/src/Sections/Security/Passkeys/components/passkeys-status-container.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import { observer } from '@deriv/stores'; import { NoPasskeys } from './no-passkeys'; import { TCurrentManagedPasskey, TOnPasskeyMenuClick, TPasskey } from '../passkeys'; @@ -7,6 +6,7 @@ import { PASSKEY_STATUS_CODES, TPasskeysStatus } from '../passkeys-configs'; import { PasskeysLearnMore } from './passkeys-learn-more'; import { PasskeysList } from './passkeys-list'; import { PasskeyRename } from './passkey-rename'; +import { PasskeyRemoved } from './passkey-removed'; import { TPasskeysButtonOnClicks } from './passkeys-status-layout'; type TPasskeysStatusContainer = { @@ -55,7 +55,8 @@ export const PasskeysStatusContainer = observer( onSecondaryButtonClick={onSecondaryButtonClick} /> ); - + case PASSKEY_STATUS_CODES.REMOVED: + return ; default: return ( [ diff --git a/packages/account/src/Sections/Security/Passkeys/passkeys-configs.tsx b/packages/account/src/Sections/Security/Passkeys/passkeys-configs.tsx index d51491e58b07..8606d168d408 100644 --- a/packages/account/src/Sections/Security/Passkeys/passkeys-configs.tsx +++ b/packages/account/src/Sections/Security/Passkeys/passkeys-configs.tsx @@ -1,8 +1,8 @@ -import React from 'react'; +import { MutableRefObject } from 'react'; import * as Yup from 'yup'; import { TSocketError } from '@deriv/api/types'; import { getOSNameWithUAParser } from '@deriv/shared'; -import { localize } from '@deriv/translations'; +import { localize } from '@deriv-com/translations'; import { Analytics, TEvents } from '@deriv-com/analytics'; import { TServerError } from '../../../Types'; @@ -12,6 +12,7 @@ export const PASSKEY_STATUS_CODES = { LIST: '', NO_PASSKEY: 'no_passkey', REMOVED: 'removed', + REMOVING: 'removing', RENAMING: 'renaming', VERIFYING: 'verifying', } as const; @@ -33,10 +34,17 @@ export const getPasskeyRenameValidationSchema = () => .matches(/^[A-Za-z0-9][A-Za-z0-9\s-]*$/, localize('Only letters, numbers, space, and hyphen are allowed.')), }); -export const clearTimeOut = (timeout_ref: React.MutableRefObject) => { +export const clearRefTimeOut = (timeout_ref: MutableRefObject) => { if (timeout_ref.current) clearTimeout(timeout_ref.current); }; +export const isNotExistedPasskey = (error: TServerError) => error?.code === 'UserNotFound'; +export const isNotSupportedError = (error: TServerError) => error?.name === 'NotSupportedError'; + +// the errors are connected with terminating the registration process or setting up the unlock method from user side +export const excluded_error_names = ['NotAllowedError', 'AbortError', 'NotReadableError', 'UnknownError']; +export const excluded_error_codes = ['ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED']; + export const passkeysMenuActionEventTrack = ( action: TEvents['ce_passkey_account_settings_form']['action'], additional_data: { error_message?: string; subform_name?: string } = {} diff --git a/packages/account/src/Sections/Security/Passkeys/passkeys.tsx b/packages/account/src/Sections/Security/Passkeys/passkeys.tsx index f9d6163f0b71..656e37779aae 100644 --- a/packages/account/src/Sections/Security/Passkeys/passkeys.tsx +++ b/packages/account/src/Sections/Security/Passkeys/passkeys.tsx @@ -1,15 +1,25 @@ import { Fragment, useEffect, useRef, useState } from 'react'; import { Redirect, useHistory } from 'react-router-dom'; import { InlineMessage, Loading } from '@deriv/components'; -import { useGetPasskeysList, useRegisterPasskey, useRenamePasskey } from '@deriv/hooks'; +import { useGetPasskeysList, useRegisterPasskey, useRemovePasskey, useRenamePasskey } from '@deriv/hooks'; import { routes } from '@deriv/shared'; import { useDevice } from '@deriv-com/ui'; import { observer, useStore } from '@deriv/stores'; -import { Localize } from '@deriv/translations'; +import { Localize } from '@deriv-com/translations'; import { PasskeyErrorModal } from './components/passkey-error-modal'; import { PasskeyReminderModal } from './components/passkey-reminder-modal'; +import { PasskeyRemoveConfirmationModal } from './components/passkey-remove-confirmation-modal'; import { PasskeysStatusContainer } from './components/passkeys-status-container'; -import { clearTimeOut, PASSKEY_STATUS_CODES, passkeysMenuActionEventTrack, TPasskeysStatus } from './passkeys-configs'; +import { + clearRefTimeOut, + excluded_error_codes, + excluded_error_names, + isNotExistedPasskey, + PASSKEY_STATUS_CODES, + passkeysMenuActionEventTrack, + TPasskeysStatus, +} from './passkeys-configs'; +import { TServerError } from '../../../Types'; import './passkeys.scss'; export type TPasskey = { @@ -18,7 +28,7 @@ export type TPasskey = { last_used: number; created_at?: number; stored_on?: string; - passkey_id?: string; + passkey_id: string; icon?: string; }; export type TOnPasskeyMenuClick = ( @@ -27,102 +37,116 @@ export type TOnPasskeyMenuClick = ( ) => void; export type TCurrentManagedPasskey = { id: TPasskey['id']; + passkey_id: TPasskey['passkey_id']; name: TPasskey['name']; }; const Passkeys = observer(() => { const { client, common, notifications } = useStore(); - const { is_passkey_supported, setShouldShowPasskeyNotification, setPasskeysStatusToCookie } = client; const { isMobile } = useDevice(); + const { is_passkey_supported, setShouldShowPasskeyNotification, setPasskeysStatusToCookie } = client; const { removeNotificationByKey } = notifications; const is_network_on = common.network_status.class === 'online'; const error_modal_timeout = useRef | null>(null); const snackbar_timeout = useRef | null>(null); - const prev_passkey_status = useRef(PASSKEY_STATUS_CODES.LIST); const history = useHistory(); - const { passkeys_list, is_passkeys_list_loading, passkeys_list_error } = useGetPasskeysList(); - const { - createPasskey, - clearPasskeyRegistrationError, - startPasskeyRegistration, - is_passkey_registered, - passkey_registration_error, - } = useRegisterPasskey(); - const { is_passkey_renamed, passkey_renaming_error, renamePasskey } = useRenamePasskey(); - const [passkey_status, setPasskeyStatus] = useState(PASSKEY_STATUS_CODES.LIST); const [is_reminder_modal_open, setIsReminderModalOpen] = useState(false); const [is_error_modal_open, setIsErrorModalOpen] = useState(false); const [is_snackbar_open, setIsSnackbarOpen] = useState(false); const [current_managed_passkey, setCurrentManagedPasskey] = useState({ id: 0, + passkey_id: '', name: '', }); + const onSuccessPasskeyRegister = () => { + setShouldShowPasskeyNotification(false); + removeNotificationByKey({ key: 'enable_passkey' }); + refetchPasskeysList(); + passkeysMenuActionEventTrack('create_passkey_finished'); + setPasskeyStatus(PASSKEY_STATUS_CODES.CREATED); + setPasskeysStatusToCookie('available'); + }; + + const onSuccessPasskeyRemove = () => { + refetchPasskeysList(); + setPasskeyStatus(PASSKEY_STATUS_CODES.REMOVED); + passkeysMenuActionEventTrack('passkey_remove_success'); + }; + + const onSuccessPasskeyRename = () => { + refetchPasskeysList(); + setPasskeyStatus(PASSKEY_STATUS_CODES.LIST); + setIsSnackbarOpen(true); + passkeysMenuActionEventTrack('passkey_rename_success'); + clearRefTimeOut(snackbar_timeout); + snackbar_timeout.current = setTimeout(() => { + setIsSnackbarOpen(false); + }, 5000); + }; + + const { passkeys_list, is_passkeys_list_loading, passkeys_list_error, refetchPasskeysList } = useGetPasskeysList(); + const { passkey_removing_error, removePasskey } = useRemovePasskey({ onSuccess: onSuccessPasskeyRemove }); + const { passkey_renaming_error, renamePasskey } = useRenamePasskey({ onSuccess: onSuccessPasskeyRename }); + const { createPasskey, startPasskeyRegistration, passkey_registration_error } = useRegisterPasskey({ + onSuccess: onSuccessPasskeyRegister, + }); + const should_show_passkeys = is_passkey_supported && isMobile; - const error = passkeys_list_error || passkey_registration_error || passkey_renaming_error; + const error = passkeys_list_error || passkey_registration_error || passkey_renaming_error || passkey_removing_error; useEffect(() => { - if (is_passkeys_list_loading || passkey_status === PASSKEY_STATUS_CODES.CREATED) return; + const should_not_render_main_page = + is_passkeys_list_loading || + passkey_status === PASSKEY_STATUS_CODES.CREATED || + passkey_status === PASSKEY_STATUS_CODES.REMOVED; + + if (should_not_render_main_page) return; + if (!passkeys_list?.length) { setPasskeyStatus(PASSKEY_STATUS_CODES.NO_PASSKEY); + setPasskeysStatusToCookie('not_available'); } else { setPasskeyStatus(PASSKEY_STATUS_CODES.LIST); } + return () => clearRefTimeOut(snackbar_timeout); // eslint-disable-next-line react-hooks/exhaustive-deps }, [is_passkeys_list_loading, passkeys_list?.length]); useEffect(() => { - if (is_passkey_renamed) { - setPasskeyStatus(PASSKEY_STATUS_CODES.LIST); - setIsSnackbarOpen(true); - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - passkeysMenuActionEventTrack('passkey_rename_success'); - clearTimeOut(snackbar_timeout); - snackbar_timeout.current = setTimeout(() => { - setIsSnackbarOpen(false); - }, 5000); - } - return () => { - clearTimeOut(snackbar_timeout); - }; - }, [is_passkey_renamed]); + if (error) { + passkeysMenuActionEventTrack('error', { error_message: (error as TServerError)?.message }); - useEffect(() => { - if (is_passkey_registered) { - setShouldShowPasskeyNotification(false); - removeNotificationByKey({ key: 'enable_passkey' }); - passkeysMenuActionEventTrack('create_passkey_finished'); - setPasskeyStatus(PASSKEY_STATUS_CODES.CREATED); - setPasskeysStatusToCookie('available'); - } - }, [is_passkey_registered, setPasskeysStatusToCookie]); + const should_hide_error = + excluded_error_names.some(name => name === (error as TServerError).name) || + excluded_error_codes.some(code => code === (error as TServerError).code); + + if (should_hide_error) return; + + if (passkey_status === PASSKEY_STATUS_CODES.REMOVING) { + setPasskeyStatus(passkeys_list?.length ? PASSKEY_STATUS_CODES.LIST : PASSKEY_STATUS_CODES.NO_PASSKEY); + } - useEffect(() => { - if (error) { is_reminder_modal_open && setIsReminderModalOpen(false); - clearTimeOut(error_modal_timeout); + clearRefTimeOut(error_modal_timeout); error_modal_timeout.current = setTimeout(() => setIsErrorModalOpen(true), 500); } - return () => clearTimeOut(error_modal_timeout); + return () => clearRefTimeOut(error_modal_timeout); }, [error, is_reminder_modal_open]); - if (should_show_passkeys && (is_passkeys_list_loading || !is_network_on)) { - return ; - } - if (!should_show_passkeys) { return ; } + if ((is_passkeys_list_loading && passkey_status === PASSKEY_STATUS_CODES.LIST) || !is_network_on) { + return ; + } + const onCloseErrorModal = () => { - if (passkey_registration_error) { - clearPasskeyRegistrationError(); - } history.push(routes.traders_hub); }; @@ -139,6 +163,9 @@ const Passkeys = observer(() => { }; const onPasskeyMenuClick = (passkey_managing_status: TPasskeysStatus, passkey_data: TCurrentManagedPasskey) => { + if (passkey_managing_status !== PASSKEY_STATUS_CODES.LIST && is_snackbar_open) { + setIsSnackbarOpen(false); + } setCurrentManagedPasskey(passkey_data); setPasskeyStatus(passkey_managing_status); }; @@ -162,8 +189,12 @@ const Passkeys = observer(() => { renamePasskey(current_managed_passkey.id, passkey_data?.name ?? current_managed_passkey.name); } if (passkey_status === PASSKEY_STATUS_CODES.REMOVED) { - // TODO: add the logic for revoking and tracking events + setPasskeyStatus(passkeys_list?.length ? PASSKEY_STATUS_CODES.LIST : PASSKEY_STATUS_CODES.NO_PASSKEY); } + // next condition is for future additional verification screen + // if (passkey_status === PASSKEY_STATUS_CODES.REMOVING) { + // removePasskey(current_managed_passkey?.id); + // } }; const onSecondaryButtonClick = () => { @@ -171,21 +202,18 @@ const Passkeys = observer(() => { passkeysMenuActionEventTrack('info_open'); setPasskeyStatus(PASSKEY_STATUS_CODES.LEARN_MORE); } - if (passkey_status === PASSKEY_STATUS_CODES.LEARN_MORE) { + if (passkey_status === PASSKEY_STATUS_CODES.LEARN_MORE || passkey_status === PASSKEY_STATUS_CODES.REMOVING) { passkeysMenuActionEventTrack('info_back'); - setPasskeyStatus(prev_passkey_status.current); + setPasskeyStatus(passkeys_list?.length ? PASSKEY_STATUS_CODES.LIST : PASSKEY_STATUS_CODES.NO_PASSKEY); } if (passkey_status === PASSKEY_STATUS_CODES.CREATED) { passkeysMenuActionEventTrack('add_more_passkeys'); setPasskeyStatus(PASSKEY_STATUS_CODES.LIST); } if (passkey_status === PASSKEY_STATUS_CODES.RENAMING) { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore passkeysMenuActionEventTrack('passkey_rename_back'); setPasskeyStatus(PASSKEY_STATUS_CODES.LIST); } - prev_passkey_status.current = passkey_status; }; return ( @@ -213,6 +241,18 @@ const Passkeys = observer(() => { toggleModal={onCloseReminderModal} /> + {/* TODO: Remove confirmation modal, when verification page is implemented*/} + { + setPasskeyStatus( + passkeys_list?.length ? PASSKEY_STATUS_CODES.LIST : PASSKEY_STATUS_CODES.NO_PASSKEY + ); + }} + onPrimaryButtonClick={() => { + removePasskey(current_managed_passkey?.id); + }} + /> ); }); diff --git a/packages/account/src/Sections/index.js b/packages/account/src/Sections/index.js index 9e8385c89cf3..b21e5e13818d 100644 --- a/packages/account/src/Sections/index.js +++ b/packages/account/src/Sections/index.js @@ -1,4 +1,3 @@ -import Passkeys from 'Sections/Security/Passkeys'; import PersonalDetails from 'Sections/Profile/PersonalDetails'; import { ProofOfIdentityContainer, ProofOfIdentity } from 'Sections/Verification/ProofOfIdentity'; import ProofOfAddress from 'Sections/Verification/ProofOfAddress'; @@ -8,7 +7,6 @@ import Account from 'Containers/Account/account'; import DeactivateAccount from 'Sections/Security/DeactivateAccount'; // TODO: Remove once mobile team has changed this link export { - Passkeys, PersonalDetails, ProofOfIdentityContainer, ProofOfIdentity, diff --git a/packages/core/src/Stores/client-store.js b/packages/core/src/Stores/client-store.js index 5ee7fbb368e9..bbeae69d4774 100644 --- a/packages/core/src/Stores/client-store.js +++ b/packages/core/src/Stores/client-store.js @@ -162,6 +162,7 @@ export default class ClientStore extends BaseStore { is_passkey_supported = false; should_show_passkey_notification = false; + passkeys_list = []; subscriptions = {}; exchange_rates = {}; @@ -239,6 +240,7 @@ export default class ClientStore extends BaseStore { wallet_migration_state: observable, is_wallet_migration_request_is_in_progress: observable, is_passkey_supported: observable, + passkeys_list: observable, should_show_passkey_notification: observable, balance: computed, account_open_date: computed, @@ -410,6 +412,7 @@ export default class ClientStore extends BaseStore { setIsPasskeySupported: action.bound, setPasskeysStatusToCookie: action.bound, fetchShouldShowPasskeyNotification: action.bound, + fetchPasskeysList: action.bound, setShouldShowPasskeyNotification: action.bound, getExchangeRate: action.bound, subscribeToExchangeRate: action.bound, @@ -2757,11 +2760,16 @@ export default class ClientStore extends BaseStore { }); } + async fetchPasskeysList() { + const data = await WS.authorized.send({ passkeys_list: 1 }); + this.passkeys_list = data?.passkeys_list; + } + async fetchShouldShowPasskeyNotification() { if (this.root_store.ui?.is_mobile) { try { - const data = await WS.authorized.send({ passkeys_list: 1 }); - const is_passkeys_empty = data?.passkeys_list?.length === 0; + await this.fetchPasskeysList(); + const is_passkeys_empty = this.passkeys_list.length === 0; if (!is_passkeys_empty) { this.setPasskeysStatusToCookie('available'); } diff --git a/packages/hooks/src/__tests__/useGetPasskeysList.spec.tsx b/packages/hooks/src/__tests__/useGetPasskeysList.spec.tsx index e72361c3818c..879e696682e0 100644 --- a/packages/hooks/src/__tests__/useGetPasskeysList.spec.tsx +++ b/packages/hooks/src/__tests__/useGetPasskeysList.spec.tsx @@ -1,55 +1,92 @@ import React from 'react'; -import { renderHook } from '@testing-library/react-hooks'; -import { useQuery } from '@deriv/api'; +import { renderHook, act } from '@testing-library/react-hooks'; +import { waitFor } from '@testing-library/react'; import { mockStore, StoreProvider } from '@deriv/stores'; import useGetPasskeysList from '../useGetPasskeysList'; -import useAuthorize from '../useAuthorize'; - -jest.mock('@deriv/api', () => ({ - ...jest.requireActual('@deriv/api'), - useQuery: jest.fn(), -})); - -jest.mock('../useAuthorize', () => jest.fn(() => ({ isSuccess: true }))); describe('useGetPasskeysList', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); + const mockFetchPasskeysList = jest.fn(); + const mock_passkeys_list = [ + { + id: 1, + name: 'Test Passkey 1', + last_used: 1633024800000, + created_at: 1633024800000, + stored_on: 'Test device 1', + icon: 'Test Icon 1', + passkey_id: 'mock-id-1', + }, + { + id: 2, + name: 'Test Passkey 2', + last_used: 1633124800000, + created_at: 1634024800000, + stored_on: 'Test device 2', + icon: 'Test Icon 2', + passkey_id: 'mock-id-2', + }, + ]; const mock = mockStore({ - client: { is_passkey_supported: true }, - common: { network_status: { class: 'online' } }, + client: { + passkeys_list: mock_passkeys_list, + fetchPasskeysList: mockFetchPasskeysList, + }, }); const wrapper = ({ children }: { children: JSX.Element }) => {children}; - it('calls useQuery when is_logged_in and is_passkey_supported are true', () => { - (useQuery as jest.Mock).mockReturnValue({ data: { passkeys_list: [] } }); + it('should return the initial state correctly', () => { + const { result } = renderHook(() => useGetPasskeysList(), { + wrapper, + }); + + expect(result.current.passkeys_list).toEqual(mock_passkeys_list); + expect(result.current.passkeys_list_error).toBeNull(); + expect(result.current.is_passkeys_list_loading).toBeFalsy(); + }); + + it('should set loading state to true when refetching', async () => { + mockFetchPasskeysList.mockResolvedValueOnce(undefined); + const { result } = renderHook(() => useGetPasskeysList(), { + wrapper, + }); - const { result } = renderHook(() => useGetPasskeysList(), { wrapper }); + act(() => { + result.current.refetchPasskeysList(); + }); - expect(useQuery).toHaveBeenCalledWith('passkeys_list', { options: { enabled: true, retry: 0 } }); - expect(result.current.passkeys_list).toEqual([]); + await waitFor(() => { + expect(result.current.is_passkeys_list_loading).toBeTruthy(); + }); }); - it('calls useQuery with enabled set to false when is_logged_in is false', () => { - (useQuery as jest.Mock).mockReturnValue({ data: { passkeys_list: undefined } }); - (useAuthorize as jest.Mock).mockReturnValueOnce({ isSuccess: false }); + it('should handle successful fetch correctly', async () => { + mockFetchPasskeysList.mockResolvedValueOnce(undefined); + const { result } = renderHook(() => useGetPasskeysList(), { + wrapper, + }); - const { result } = renderHook(() => useGetPasskeysList(), { wrapper }); + await act(async () => { + await result.current.refetchPasskeysList(); + }); - expect(useQuery).toHaveBeenCalledWith('passkeys_list', { options: { enabled: false, retry: 0 } }); - expect(result.current.passkeys_list).toEqual(undefined); + expect(result.current.is_passkeys_list_loading).toBeFalsy(); + expect(result.current.passkeys_list_error).toBeNull(); }); - it('calls useQuery with enabled set to false when is_passkey_supported is false', () => { - (useQuery as jest.Mock).mockReturnValue({ data: { passkeys_list: undefined } }); - mock.client.is_passkey_supported = false; + it('should handle fetch errors correctly', async () => { + const mockError = { message: 'Fetch failed' }; + mockFetchPasskeysList.mockRejectedValueOnce(mockError); + const { result } = renderHook(() => useGetPasskeysList(), { + wrapper, + }); - const { result } = renderHook(() => useGetPasskeysList(), { wrapper }); + await act(async () => { + await result.current.refetchPasskeysList(); + }); - expect(useQuery).toHaveBeenCalledWith('passkeys_list', { options: { enabled: false, retry: 0 } }); - expect(result.current.passkeys_list).toEqual(undefined); + expect(result.current.is_passkeys_list_loading).toBeFalsy(); + expect(result.current.passkeys_list_error).toEqual(mockError); }); }); diff --git a/packages/hooks/src/__tests__/useRegisterPasskey.spec.tsx b/packages/hooks/src/__tests__/useRegisterPasskey.spec.tsx index b7ced358d67b..942ed5bcab8f 100644 --- a/packages/hooks/src/__tests__/useRegisterPasskey.spec.tsx +++ b/packages/hooks/src/__tests__/useRegisterPasskey.spec.tsx @@ -9,11 +9,6 @@ jest.mock('@simplewebauthn/browser', () => ({ ...jest.requireActual('@simplewebauthn/browser'), startRegistration: jest.fn(() => Promise.resolve('authenticator_response')), })); -const mockInvalidate = jest.fn(); -jest.mock('@deriv/api', () => ({ - ...jest.requireActual('@deriv/api'), - useInvalidateQuery: jest.fn(() => mockInvalidate), -})); jest.mock('@deriv/shared', () => ({ ...jest.requireActual('@deriv/shared'), WS: { @@ -26,17 +21,19 @@ describe('useRegisterPasskey', () => { const ws_error = { message: 'Test connection error' }; const authenticator_error = { message: 'Test authenticator error' }; + const mockOnSuccess = jest.fn(); beforeEach(() => { + jest.clearAllMocks(); (WS.send as jest.Mock).mockResolvedValue({ passkeys_register_options: { publicKey: { name: 'test publicKey' } }, }); }); it('should start passkey registration and create passkey', async () => { - const { result } = renderHook(() => useRegisterPasskey(), { wrapper }); + const { result } = renderHook(() => useRegisterPasskey({ onSuccess: mockOnSuccess }), { wrapper }); - expect(result.current.is_passkey_registered).toBe(false); + expect(mockOnSuccess).not.toHaveBeenCalled(); await act(async () => { await result.current.startPasskeyRegistration(); @@ -55,30 +52,24 @@ describe('useRegisterPasskey', () => { publicKeyCredential: 'authenticator_response', }); - expect(mockInvalidate).toHaveBeenCalled(); - expect(result.current.is_passkey_registered).toBe(true); + expect(mockOnSuccess).toHaveBeenCalledTimes(1); }); - it('should handle passkey registration error', async () => { + it('should throw passkey registration error', async () => { (WS.send as jest.Mock).mockRejectedValue(ws_error); - const { result } = renderHook(() => useRegisterPasskey(), { wrapper }); + const { result } = renderHook(() => useRegisterPasskey({ onSuccess: mockOnSuccess }), { wrapper }); await act(async () => { await result.current.startPasskeyRegistration(); }); expect(result.current.passkey_registration_error).toBe(ws_error); - - await act(async () => { - result.current.clearPasskeyRegistrationError(); - }); - - expect(result.current.passkey_registration_error).toBe(null); + expect(mockOnSuccess).not.toHaveBeenCalled(); }); - it('should handle passkey creation error', async () => { - const { result } = renderHook(() => useRegisterPasskey(), { wrapper }); + it('should throw passkey creation error', async () => { + const { result } = renderHook(() => useRegisterPasskey({ onSuccess: mockOnSuccess }), { wrapper }); await act(async () => { await result.current.startPasskeyRegistration(); @@ -95,16 +86,11 @@ describe('useRegisterPasskey', () => { }); expect(result.current.passkey_registration_error).toBe(ws_error); - - await act(async () => { - result.current.clearPasskeyRegistrationError(); - }); - - expect(result.current.passkey_registration_error).toBe(null); + expect(mockOnSuccess).not.toHaveBeenCalled(); }); - it('should handle passkey creation authenticator error', async () => { - const { result } = renderHook(() => useRegisterPasskey(), { wrapper }); + it('should throw passkey creation authenticator error', async () => { + const { result } = renderHook(() => useRegisterPasskey({ onSuccess: mockOnSuccess }), { wrapper }); await act(async () => { await result.current.startPasskeyRegistration(); @@ -121,11 +107,6 @@ describe('useRegisterPasskey', () => { }); expect(result.current.passkey_registration_error).toBe(authenticator_error); - - await act(async () => { - result.current.clearPasskeyRegistrationError(); - }); - - expect(result.current.passkey_registration_error).toBe(null); + expect(mockOnSuccess).not.toHaveBeenCalled(); }); }); diff --git a/packages/hooks/src/__tests__/useRemovePasskey.spec.tsx b/packages/hooks/src/__tests__/useRemovePasskey.spec.tsx new file mode 100644 index 000000000000..e4ff83cf22e4 --- /dev/null +++ b/packages/hooks/src/__tests__/useRemovePasskey.spec.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { renderHook, act } from '@testing-library/react-hooks'; +import APIProvider from '@deriv/api/src/APIProvider'; +import { WS } from '@deriv/shared'; +import useRemovePasskey from '../useRemovePasskey'; + +jest.mock('@deriv/shared', () => ({ + ...jest.requireActual('@deriv/shared'), + WS: { + send: jest.fn(() => ({ + passkeys_revoke: 1, + })), + }, +})); + +describe('useRemovePasskey', () => { + const ws_error = { message: 'Test error' }; + const mockOnSuccess = jest.fn(); + + const wrapper = ({ children }: { children: JSX.Element }) => {children}; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should remove passkey', async () => { + const { result } = renderHook(() => useRemovePasskey({ onSuccess: mockOnSuccess }), { + wrapper, + }); + + expect(mockOnSuccess).not.toHaveBeenCalled(); + + await act(async () => { + result.current.removePasskey(123); + }); + + expect(WS.send).toHaveBeenCalledWith({ + passkeys_revoke: 1, + id: 123, + }); + expect(mockOnSuccess).toHaveBeenCalled(); + }); + + it('should throw passkey removing error', async () => { + (WS.send as jest.Mock).mockRejectedValue(ws_error); + + const { result } = renderHook(() => useRemovePasskey({ onSuccess: mockOnSuccess }), { wrapper }); + + expect(mockOnSuccess).not.toHaveBeenCalled(); + + await act(async () => { + result.current.removePasskey(123); + }); + + expect(WS.send).toHaveBeenCalledWith({ passkeys_revoke: 1, id: 123 }); + expect(result.current.passkey_removing_error).toBe(ws_error); + expect(mockOnSuccess).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/hooks/src/__tests__/useRenamePasskey.spec.tsx b/packages/hooks/src/__tests__/useRenamePasskey.spec.tsx index f7593babc968..77d0f8c23fa8 100644 --- a/packages/hooks/src/__tests__/useRenamePasskey.spec.tsx +++ b/packages/hooks/src/__tests__/useRenamePasskey.spec.tsx @@ -4,11 +4,6 @@ import APIProvider from '@deriv/api/src/APIProvider'; import { WS } from '@deriv/shared'; import useRenamePasskey from '../useRenamePasskey'; -const mockInvalidate = jest.fn(); -jest.mock('@deriv/api', () => ({ - ...jest.requireActual('@deriv/api'), - useInvalidateQuery: jest.fn(() => mockInvalidate), -})); jest.mock('@deriv/shared', () => ({ ...jest.requireActual('@deriv/shared'), WS: { @@ -22,29 +17,35 @@ describe('useRenamePasskey', () => { id: 123, name: 'test name', }; + const mockOnSuccess = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); const wrapper = ({ children }: { children: JSX.Element }) => {children}; it('should rename passkey', async () => { (WS.send as jest.Mock).mockResolvedValue({ passkeys_rename: 1 }); - const { result } = renderHook(() => useRenamePasskey(), { wrapper }); + const { result } = renderHook(() => useRenamePasskey({ onSuccess: mockOnSuccess }), { wrapper }); - expect(result.current.is_passkey_renamed).toBe(false); + expect(mockOnSuccess).not.toHaveBeenCalled(); await act(async () => { await result.current.renamePasskey(test_passkey_data.id, test_passkey_data.name); }); expect(WS.send).toHaveBeenCalledWith({ passkeys_rename: 1, ...test_passkey_data }); - expect(mockInvalidate).toHaveBeenCalled(); - expect(result.current.is_passkey_renamed).toBe(true); + expect(mockOnSuccess).toHaveBeenCalled(); }); - it('should handle passkey registration error', async () => { + it('should throw passkey renaming error', async () => { (WS.send as jest.Mock).mockRejectedValue(ws_error); - const { result } = renderHook(() => useRenamePasskey(), { wrapper }); + const { result } = renderHook(() => useRenamePasskey({ onSuccess: mockOnSuccess }), { wrapper }); + + expect(mockOnSuccess).not.toHaveBeenCalled(); await act(async () => { await result.current.renamePasskey(test_passkey_data.id, test_passkey_data.name); @@ -52,6 +53,6 @@ describe('useRenamePasskey', () => { expect(WS.send).toHaveBeenCalledWith({ passkeys_rename: 1, ...test_passkey_data }); expect(result.current.passkey_renaming_error).toBe(ws_error); - expect(result.current.is_passkey_renamed).toBe(false); + expect(mockOnSuccess).not.toHaveBeenCalled(); }); }); diff --git a/packages/hooks/src/index.ts b/packages/hooks/src/index.ts index 2c3223da7320..f936ca864591 100644 --- a/packages/hooks/src/index.ts +++ b/packages/hooks/src/index.ts @@ -69,6 +69,7 @@ export { default as usePlatformDemoAccount } from './usePlatformDemoAccount'; export { default as usePlatformRealAccounts } from './usePlatformRealAccounts'; export { default as useRealSTPAccount } from './useRealSTPAccount'; export { default as useRegisterPasskey } from './useRegisterPasskey'; +export { default as useRemovePasskey } from './useRemovePasskey'; export { default as useRenamePasskey } from './useRenamePasskey'; export { default as useServiceToken } from './useServiceToken'; export { default as useStatesList } from './useStatesList'; diff --git a/packages/hooks/src/useGetPasskeysList.ts b/packages/hooks/src/useGetPasskeysList.ts index cf3303caf553..6ec96f2dc631 100644 --- a/packages/hooks/src/useGetPasskeysList.ts +++ b/packages/hooks/src/useGetPasskeysList.ts @@ -1,42 +1,31 @@ -import { useEffect } from 'react'; -import { Analytics } from '@deriv-com/analytics'; -import { useQuery } from '@deriv/api'; -import { getOSNameWithUAParser } from '@deriv/shared'; +import { useState } from 'react'; import { useStore } from '@deriv/stores'; -import useAuthorize from './useAuthorize'; + +type TError = { code?: string; name?: string; message: string }; const useGetPasskeysList = () => { - const { client, common } = useStore(); - const { isSuccess, isFetching: isAuthorizeFetching } = useAuthorize(); - const { is_passkey_supported } = client; - const { network_status } = common; + const { client } = useStore(); + const { passkeys_list, fetchPasskeysList } = client; - const { data, error, isLoading, isFetching, refetch, ...rest } = useQuery('passkeys_list', { - options: { - enabled: is_passkey_supported && isSuccess && !isAuthorizeFetching && network_status.class === 'online', - retry: 0, - }, - }); + const [is_passkeys_list_loading, setIsPasskeysListLoading] = useState(false); + const [passkeys_list_error, setPasskeysListError] = useState(null); - useEffect(() => { - if (error) { - Analytics.trackEvent('ce_passkey_account_settings_form', { - action: 'error', - form_name: 'ce_passkey_account_settings_form', - operating_system: getOSNameWithUAParser(), - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - error_message: error?.message, - }); + const refetchPasskeysList = async () => { + try { + setIsPasskeysListLoading(true); + await fetchPasskeysList(); + } catch (e) { + setPasskeysListError(e as TError); + } finally { + setIsPasskeysListLoading(false); } - }, [error]); + }; return { - passkeys_list: data?.passkeys_list, - passkeys_list_error: error ?? null, - reloadPasskeysList: refetch, - is_passkeys_list_loading: isLoading || isFetching, - ...rest, + passkeys_list, + passkeys_list_error, + is_passkeys_list_loading, + refetchPasskeysList, }; }; diff --git a/packages/hooks/src/useRegisterPasskey.ts b/packages/hooks/src/useRegisterPasskey.ts index b0046ee353be..86692bd8d24c 100644 --- a/packages/hooks/src/useRegisterPasskey.ts +++ b/packages/hooks/src/useRegisterPasskey.ts @@ -1,32 +1,13 @@ -import React from 'react'; +import { useState } from 'react'; import { startRegistration } from '@simplewebauthn/browser'; import { PublicKeyCredentialCreationOptionsJSON } from '@simplewebauthn/typescript-types'; -import { useInvalidateQuery } from '@deriv/api'; -import { mobileOSDetect, WS } from '@deriv/shared'; -import { Analytics } from '@deriv-com/analytics'; +import { WS } from '@deriv/shared'; type TError = { code?: string; name?: string; message: string }; -const passkeyErrorEventTrack = (error: TError) => { - Analytics.trackEvent('ce_passkey_account_settings_form', { - action: 'error', - form_name: 'ce_passkey_account_settings_form', - operating_system: mobileOSDetect(), - error_message: error?.message, - }); -}; - -const useRegisterPasskey = () => { - const invalidate = useInvalidateQuery(); - - // the errors are connected with terminating the registration process or setting up the unlock method from user side - const excluded_error_names = ['NotAllowedError', 'AbortError', 'NotReadableError', 'UnknownError']; - - const [is_passkey_registered, setIsPasskeyRegistered] = React.useState(false); - const [passkey_registration_error, setPasskeyRegistrationError] = React.useState(null); - const [public_key, setPublicKey] = React.useState(null); - - const clearPasskeyRegistrationError = () => setPasskeyRegistrationError(null); +const useRegisterPasskey = ({ onSuccess }: { onSuccess: () => void }) => { + const [passkey_registration_error, setPasskeyRegistrationError] = useState(null); + const [public_key, setPublicKey] = useState(null); const startPasskeyRegistration = async () => { try { @@ -35,41 +16,32 @@ const useRegisterPasskey = () => { setPublicKey(public_key); } catch (e) { setPasskeyRegistrationError(e as TError); - passkeyErrorEventTrack(e as TError); } }; const createPasskey = async () => { try { if (public_key) { - setIsPasskeyRegistered(false); const authenticator_response = await startRegistration(public_key); const passkeys_register_response = await WS.send({ passkeys_register: 1, publicKeyCredential: authenticator_response, }); if (passkeys_register_response?.passkeys_register?.properties?.name) { - invalidate('passkeys_list'); - setIsPasskeyRegistered(true); + onSuccess(); } else if (passkeys_register_response?.error) { setPasskeyRegistrationError(passkeys_register_response?.error); - passkeyErrorEventTrack(passkeys_register_response?.error); } } } catch (e) { - if (!excluded_error_names.some(name => name === (e as TError).name)) { - setPasskeyRegistrationError(e as TError); - passkeyErrorEventTrack(e as TError); - } + setPasskeyRegistrationError(e as TError); } finally { setPublicKey(null); } }; return { - clearPasskeyRegistrationError, createPasskey, - is_passkey_registered, passkey_registration_error, startPasskeyRegistration, }; diff --git a/packages/hooks/src/useRemovePasskey.ts b/packages/hooks/src/useRemovePasskey.ts new file mode 100644 index 000000000000..d5757d303b95 --- /dev/null +++ b/packages/hooks/src/useRemovePasskey.ts @@ -0,0 +1,32 @@ +import { useState } from 'react'; +import { WS } from '@deriv/shared'; + +type TError = { code?: string; name?: string; message: string }; + +const useRemovePasskey = ({ onSuccess }: { onSuccess: () => void }) => { + const [passkey_removing_error, setPasskeyRemovingError] = useState(null); + + const removePasskey = async (id: number) => { + try { + const passkeys_revoke_response = await WS.send({ + passkeys_revoke: 1, + id, + }); + + if (passkeys_revoke_response.passkeys_revoke) { + onSuccess(); + } else if (passkeys_revoke_response.error) { + setPasskeyRemovingError(passkeys_revoke_response.error); + } + } catch (e) { + setPasskeyRemovingError(e as TError); + } + }; + + return { + removePasskey, + passkey_removing_error, + }; +}; + +export default useRemovePasskey; diff --git a/packages/hooks/src/useRenamePasskey.ts b/packages/hooks/src/useRenamePasskey.ts index da25db1257e0..3c8405d95f39 100644 --- a/packages/hooks/src/useRenamePasskey.ts +++ b/packages/hooks/src/useRenamePasskey.ts @@ -1,49 +1,30 @@ -import React from 'react'; -import { useInvalidateQuery } from '@deriv/api'; -import { mobileOSDetect, WS } from '@deriv/shared'; -import { Analytics } from '@deriv-com/analytics'; +import { useState } from 'react'; +import { WS } from '@deriv/shared'; type TError = { code?: string; name?: string; message: string }; -const passkeyErrorEventTrack = (error: TError) => { - Analytics.trackEvent('ce_passkey_account_settings_form', { - action: 'error', - form_name: 'ce_passkey_account_settings_form', - operating_system: mobileOSDetect(), - error_message: error?.message, - }); -}; - -const useRenamePasskey = () => { - const invalidate = useInvalidateQuery(); - - const [is_passkey_renamed, setIsPasskeyRenamed] = React.useState(false); - const [passkey_renaming_error, setPasskeyRenamingError] = React.useState(null); +const useRenamePasskey = ({ onSuccess }: { onSuccess: () => void }) => { + const [passkey_renaming_error, setPasskeyRenamingError] = useState(null); - const renamePasskey = async (passkey_id: number, new_passkey_name: string) => { + const renamePasskey = async (passkey_id: number, new_passkey_name = '') => { try { - setIsPasskeyRenamed(false); const passkeys_rename_response = await WS.send({ passkeys_rename: 1, id: passkey_id, name: new_passkey_name, }); if (passkeys_rename_response.passkeys_rename) { - invalidate('passkeys_list'); - setIsPasskeyRenamed(true); + onSuccess(); } else if (passkeys_rename_response?.error) { setPasskeyRenamingError(passkeys_rename_response?.error); - passkeyErrorEventTrack(passkeys_rename_response?.error); } } catch (e) { setPasskeyRenamingError(e as TError); - passkeyErrorEventTrack(e as TError); } }; return { renamePasskey, - is_passkey_renamed, passkey_renaming_error, }; }; diff --git a/packages/stores/src/mockStore.ts b/packages/stores/src/mockStore.ts index 5b8c31b2c41b..2111ecc890c3 100644 --- a/packages/stores/src/mockStore.ts +++ b/packages/stores/src/mockStore.ts @@ -294,11 +294,13 @@ const mock = (): TStores & { is_mock: boolean } => { resetWalletMigration: jest.fn(), is_wallet_migration_request_is_in_progress: false, is_passkey_supported: false, + passkeys_list: [], should_show_passkey_notification: false, setIsPasskeySupported: jest.fn(), setPasskeysStatusToCookie: jest.fn(), setShouldShowPasskeyNotification: jest.fn(), fetchShouldShowPasskeyNotification: jest.fn(), + fetchPasskeysList: jest.fn(), exchange_rates: {}, getExchangeRate: jest.fn(), subscribeToExchangeRate: jest.fn(), diff --git a/packages/stores/types.ts b/packages/stores/types.ts index d44891002856..3164190f51e5 100644 --- a/packages/stores/types.ts +++ b/packages/stores/types.ts @@ -604,11 +604,21 @@ export type TClientStore = { resetWalletMigration: () => void; is_wallet_migration_request_is_in_progress: boolean; is_passkey_supported: boolean; + passkeys_list: Array<{ + id: number; + name: string; + last_used: number; + created_at?: number; + stored_on?: string; + passkey_id: string; + icon?: string; + }>; setIsPasskeySupported: (value: boolean) => void; setPasskeysStatusToCookie: (status: 'available' | 'not_available') => void; should_show_passkey_notification: boolean; setShouldShowPasskeyNotification: (value: boolean) => void; fetchShouldShowPasskeyNotification: () => void; + fetchPasskeysList: () => void; exchange_rates: Record>; getExchangeRate: (base_currency: string, target_currency: string) => number; subscribeToExchangeRate: (base_currency: string, target_currency: string) => Promise;