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;