diff --git a/jest.config.base.js b/jest.config.base.js index b222682fd40f..a9a7339ee322 100644 --- a/jest.config.base.js +++ b/jest.config.base.js @@ -15,7 +15,7 @@ module.exports = { coverageDirectory: './coverage/', testRegex: '(/__tests__/.*|(\\.)(test|spec))\\.(js|jsx|tsx|ts)?$', // This is needed to transform es modules imported from node_modules of the target component. - transformIgnorePatterns: ['/node_modules/(?!@enykeev/react-virtualized).+\\.js$'], + transformIgnorePatterns: ['/node_modules/(?!(@enykeev/react-virtualized|@simplewebauthn/browser)).+\\.js$'], setupFiles: ['/../../jest.setup.js'], setupFilesAfterEnv: ['/../../setupTests.js'], testPathIgnorePatterns: ['/integration-tests/', '/component-tests/'], diff --git a/jest.config.js b/jest.config.js index e1d6b9381fd1..da5d1f35c404 100644 --- a/jest.config.js +++ b/jest.config.js @@ -17,6 +17,6 @@ module.exports = { '^.+\\.(ts|tsx)?$': 'ts-jest', }, testRegex: '(/__tests__/.*|(\\.)(test|spec))\\.(js|jsx|tsx|ts)?$', - transformIgnorePatterns: ['/node_modules/(?!@enykeev/react-virtualized).+\\.js$'], + transformIgnorePatterns: ['/node_modules/(?!(@enykeev/react-virtualized|@simplewebauthn/browser)).+\\.js$'], testPathIgnorePatterns: ['/integration-tests/'], }; diff --git a/package-lock.json b/package-lock.json index 67c11fb211f9..6f813b1adac4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,8 @@ "@deriv/ui": "^0.6.0", "@livechat/customer-sdk": "^2.0.4", "@sendbird/chat": "^4.9.7", + "@simplewebauthn/browser": "^8.3.4", + "@simplewebauthn/typescript-types": "^8.3.4", "@storybook/addon-actions": "^6.5.10", "@storybook/addon-docs": "^6.5.10", "@storybook/addon-essentials": "^6.5.10", @@ -10648,6 +10650,19 @@ } } }, + "node_modules/@simplewebauthn/browser": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-8.3.4.tgz", + "integrity": "sha512-rO0hZ0ESD28bZl6Qe8k7RUuYvDLbsS6oPezkMMTtZ5vC80U07j4qBELKBzojDD6BsdL3dIJ9SExVp8E7pqQ5fA==", + "dependencies": { + "@simplewebauthn/typescript-types": "^8.3.4" + } + }, + "node_modules/@simplewebauthn/typescript-types": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/@simplewebauthn/typescript-types/-/typescript-types-8.3.4.tgz", + "integrity": "sha512-38xtca0OqfRVNloKBrFB5LEM6PN5vzFbJG6rAutPVrtGHFYxPdiV3btYWq0eAZAZmP+dqFPYJxJWeJrGfmYHng==" + }, "node_modules/@sinclair/typebox": { "version": "0.24.51", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.51.tgz", diff --git a/packages/account/src/App.tsx b/packages/account/src/App.tsx index d534f1951ea1..a914d212006a 100644 --- a/packages/account/src/App.tsx +++ b/packages/account/src/App.tsx @@ -1,6 +1,7 @@ import React from 'react'; import Routes from './Containers/routes'; import ResetTradingPassword from './Containers/reset-trading-password'; +import { NetworkStatusToastErrorPopup } from './Containers/toast-popup'; import { APIProvider } from '@deriv/api'; import { StoreProvider } from '@deriv/stores'; import { TCoreStores } from '@deriv/stores/types'; @@ -21,6 +22,7 @@ const App = ({ passthrough }: TAppProps) => { return ( + {Notifications && } diff --git a/packages/account/src/Constants/routes-config.ts b/packages/account/src/Constants/routes-config.ts index c23cad81698b..74b7376fc5da 100644 --- a/packages/account/src/Constants/routes-config.ts +++ b/packages/account/src/Constants/routes-config.ts @@ -4,6 +4,7 @@ import { localize } from '@deriv/translations'; import { AccountLimits, Passwords, + Passkeys, PersonalDetails, TradingAssessment, FinancialAssessment, @@ -111,12 +112,18 @@ const initRoutesConfig = () => [ { getTitle: () => localize('Security and safety'), icon: 'IcSecurity', + id: 'security_routes', subroutes: [ { path: routes.passwords, component: Passwords, getTitle: () => localize('Email and passwords'), }, + { + path: routes.passkeys, + component: Passkeys, + getTitle: () => localize('Passkeys'), + }, { path: routes.self_exclusion, component: SelfExclusion, diff --git a/packages/account/src/Containers/Account/account.tsx b/packages/account/src/Containers/Account/account.tsx index 2b5c78068864..e74f7e491a86 100644 --- a/packages/account/src/Containers/Account/account.tsx +++ b/packages/account/src/Containers/Account/account.tsx @@ -1,12 +1,18 @@ import React from 'react'; import { RouteComponentProps, withRouter } from 'react-router-dom'; import { FadeWrapper, Loading } from '@deriv/components'; -import { matchRoute, routes as shared_routes } from '@deriv/shared'; +import { + deepCopy, + flatten, + matchRoute, + removeExactRouteFromRoutes, + routes as shared_routes, + TRoute as TSharedRoute, +} from '@deriv/shared'; import { observer, useStore } from '@deriv/stores'; import PageOverlayWrapper from './page-overlay-wrapper'; import { TRoute } from '../../Types'; import 'Styles/account.scss'; -import { flatten } from 'Helpers/utils'; type TAccountProps = RouteComponentProps & { routes: Array; @@ -30,17 +36,31 @@ const Account = observer(({ history, location, routes }: TAccountProps) => { landing_company_shortcode, should_allow_authentication, should_allow_poinc_authentication, + is_passkey_supported, } = client; - const { toggleAccountSettings, is_account_settings_visible } = ui; + const { toggleAccountSettings, is_account_settings_visible, is_mobile, is_desktop } = ui; + + const [available_routes, setAvailableRoutes] = React.useState(routes); + + React.useEffect(() => { + const should_remove_passkeys_route = is_desktop || (is_mobile && !is_passkey_supported); + if (should_remove_passkeys_route) { + const desktop_routes = removeExactRouteFromRoutes(deepCopy(routes) as TSharedRoute[], 'passkeys'); + setAvailableRoutes(desktop_routes as TRoute[]); + } else { + setAvailableRoutes(routes); + } + }, [routes, is_desktop, is_mobile, is_passkey_supported]); + // subroutes of a route is structured as an array of arrays - const subroutes = flatten(routes.map(i => i.subroutes)); + const subroutes = flatten(available_routes.map(i => i.subroutes)); const selected_content = subroutes.find(r => matchRoute(r, location.pathname)); React.useEffect(() => { toggleAccountSettings(true); }, [toggleAccountSettings]); - routes.forEach(menu_item => { + available_routes.forEach(menu_item => { if (menu_item?.subroutes?.length) { menu_item.subroutes.forEach(route => { if (route.path === shared_routes.financial_assessment) { @@ -81,7 +101,7 @@ const Account = observer(({ history, location, routes }: TAccountProps) => { keyname='account-page-wrapper' >
- +
); diff --git a/packages/account/src/Containers/toast-popup.tsx b/packages/account/src/Containers/toast-popup.tsx new file mode 100644 index 000000000000..18efb3308a09 --- /dev/null +++ b/packages/account/src/Containers/toast-popup.tsx @@ -0,0 +1,79 @@ +import classNames from 'classnames'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import { MobileWrapper, Toast } from '@deriv/components'; +import { observer, useStore } from '@deriv/stores'; + +type TToastPopUp = { + portal_id?: string; + className: string; +} & React.ComponentProps; + +type TNetworkStatusToastError = { + status: string; + portal_id: string; + message: string; +}; + +export const ToastPopup = ({ + portal_id = 'popup_root', + children, + className, + ...props +}: React.PropsWithChildren) => { + const new_portal_id = document.getElementById(portal_id); + if (!new_portal_id) return null; + return ReactDOM.createPortal( + + {children} + , + new_portal_id + ); +}; + +/** + * Network status Toast components + */ +const NetworkStatusToastError = ({ status, portal_id, message }: TNetworkStatusToastError) => { + const [is_open, setIsOpen] = React.useState(false); + const new_portal_id = document.getElementById(portal_id); + + if (!new_portal_id || !message) return null; + + if (!is_open && status !== 'online') { + setIsOpen(true); // open if status === 'blinker' or 'offline' + } else if (is_open && status === 'online') { + setTimeout(() => { + setIsOpen(false); + }, 1500); + } + + return ReactDOM.createPortal( + + + {message} + + , + new_portal_id + ); +}; + +export const NetworkStatusToastErrorPopup = observer(() => { + const { + common: { network_status }, + } = useStore(); + return ( + + ); +}); diff --git a/packages/account/src/Helpers/utils.tsx b/packages/account/src/Helpers/utils.tsx index 488cef17c3dd..3fb76cbf61f5 100644 --- a/packages/account/src/Helpers/utils.tsx +++ b/packages/account/src/Helpers/utils.tsx @@ -199,9 +199,6 @@ export const isDocumentNumberValid = (document_number: string, document_type: Fo export const shouldHideHelperImage = (document_id: string) => document_id === IDV_NOT_APPLICABLE_OPTION.id; -// @ts-expect-error as the generic is a Array -export const flatten = >(arr: T) => [].concat(...arr); - export const isServerError = (error: unknown): error is TServerError => typeof error === 'object' && error !== null && 'code' in error; diff --git a/packages/account/src/Sections/Security/Passkeys/__tests__/passkeys.spec.tsx b/packages/account/src/Sections/Security/Passkeys/__tests__/passkeys.spec.tsx new file mode 100644 index 000000000000..8b31eaa8e617 --- /dev/null +++ b/packages/account/src/Sections/Security/Passkeys/__tests__/passkeys.spec.tsx @@ -0,0 +1,232 @@ +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { APIProvider } from '@deriv/api'; +import { useGetPasskeysList, useRegisterPasskey } from '@deriv/hooks'; +import { mockStore, StoreProvider } from '@deriv/stores'; +import Passkeys from '../passkeys'; +import PasskeysList from '../components/passkeys-list'; + +const passkey_name_1 = 'Test Passkey 1'; +const passkey_name_2 = 'Test Passkey 2'; + +export const mock_passkeys_list: React.ComponentProps['passkeys_list'] = [ + { + id: 1, + name: passkey_name_1, + last_used: 1633024800000, + created_at: 1633024800000, + stored_on: '', + icon: 'Test Icon 1', + passkey_id: 'mock-id-1', + }, + { + id: 2, + name: passkey_name_2, + last_used: 1633124800000, + created_at: 1634024800000, + stored_on: '', + icon: 'Test Icon 2', + passkey_id: 'mock-id-2', + }, +]; + +jest.mock('@deriv/hooks', () => ({ + ...jest.requireActual('@deriv/hooks'), + useGetPasskeysList: jest.fn(() => ({})), + useRegisterPasskey: jest.fn(() => ({})), +})); + +jest.mock('@deriv/components', () => ({ + ...jest.requireActual('@deriv/components'), + Loading: () =>
MockLoading
, +})); + +describe('Passkeys', () => { + const mock_store = mockStore({ + ui: { is_mobile: true }, + client: { is_passkey_supported: true }, + common: { network_status: { class: 'online' } }, + }); + const create_passkey = 'Create passkey'; + + let modal_root_el: HTMLElement; + beforeAll(() => { + modal_root_el = document.createElement('div'); + modal_root_el.setAttribute('id', 'modal_root'); + document.body.appendChild(modal_root_el); + }); + + afterAll(() => { + document.body.removeChild(modal_root_el); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + + const RenderWrapper = ({ children }: React.PropsWithChildren) => ( + + + {children} + + + ); + + const mockCreatePasskey = jest.fn(); + const mockStartPasskeyRegistration = jest.fn(); + const mockClearPasskeyRegistrationError = jest.fn(); + const mockReloadPasskeysList = jest.fn(); + + it('renders existed passkeys correctly and triggers new passkey creation', async () => { + (useGetPasskeysList as jest.Mock).mockReturnValue({ + passkeys_list: mock_passkeys_list, + }); + (useRegisterPasskey as jest.Mock).mockReturnValue({ + startPasskeyRegistration: mockStartPasskeyRegistration, + }); + + render( + + + + ); + expect(screen.getByText(passkey_name_1)).toBeInTheDocument(); + expect(screen.getByText(passkey_name_2)).toBeInTheDocument(); + + const create_passkey_button = screen.getByRole('button', { name: create_passkey }); + userEvent.click(create_passkey_button); + expect(mockStartPasskeyRegistration).toBeCalledTimes(1); + }); + it("renders 'Experience safer logins' page when no passkey created, trigger 'Learn more' screen, trigger passkey creation", () => { + (useGetPasskeysList as jest.Mock).mockReturnValue({ + passkeys_list: [], + }); + (useRegisterPasskey as jest.Mock).mockReturnValue({ + startPasskeyRegistration: mockStartPasskeyRegistration, + }); + + render( + + + + ); + + expect(screen.getByText('Experience safer logins')).toBeInTheDocument(); + const learn_more_button = screen.getByRole('button', { name: 'Learn more' }); + userEvent.click(learn_more_button); + expect(screen.getByText('Effortless login with passkeys')).toBeInTheDocument(); + expect(screen.getByText('Tips:')).toBeInTheDocument(); + const create_passkey_button = screen.getByRole('button', { name: create_passkey }); + userEvent.click(create_passkey_button); + expect(mockStartPasskeyRegistration).toBeCalledTimes(1); + }); + it('renders success screen when new passkeys created', () => { + (useRegisterPasskey as jest.Mock).mockReturnValue({ + is_passkey_registered: true, + }); + + render( + + + + ); + + expect(screen.getByText('Success!')).toBeInTheDocument(); + }); + it("doesn't render existed passkeys for desktop", () => { + (useGetPasskeysList as jest.Mock).mockReturnValue({ + passkeys_list: mock_passkeys_list, + }); + + mock_store.ui.is_mobile = false; + render( + + + + ); + + expect(screen.queryByText(passkey_name_1)).not.toBeInTheDocument(); + expect(screen.queryByText(passkey_name_2)).not.toBeInTheDocument(); + + mock_store.ui.is_mobile = true; + }); + it('renders loader if passkeys list is loading', () => { + (useGetPasskeysList as jest.Mock).mockReturnValue({ + is_passkeys_list_loading: true, + }); + + mock_store.client.is_passkey_supported = false; + + render( + + + + ); + + expect(screen.getByText('MockLoading')).toBeInTheDocument(); + }); + 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({ + createPasskey: mockCreatePasskey, + is_passkey_registration_started: true, + }); + + render( + + + + ); + + const continue_button = screen.getByRole('button', { name: /continue/i }); + userEvent.click(continue_button); + expect(mockCreatePasskey).toBeCalledTimes(1); + }); + it('renders passkeys registration error modal and triggers closing', async () => { + (useRegisterPasskey as jest.Mock).mockReturnValue({ + passkey_registration_error: { message: 'registration test error' }, + clearPasskeyRegistrationError: mockClearPasskeyRegistrationError, + }); + + render( + + + + ); + + const try_again_button = screen.getByRole('button', { name: /try again/i }); + expect(screen.getByText('registration test error')).toBeInTheDocument(); + userEvent.click(try_again_button); + await waitFor(() => { + expect(mockClearPasskeyRegistrationError).toBeCalledTimes(1); + }); + }); + it('renders passkeys list error modal and triggers closing', async () => { + (useRegisterPasskey as jest.Mock).mockReturnValue({ + passkey_registration_error: null, + }); + + (useGetPasskeysList as jest.Mock).mockReturnValue({ + passkeys_list_error: { message: 'list test error' }, + reloadPasskeysList: mockReloadPasskeysList, + }); + + render( + + + + ); + + const try_again_button = screen.getByRole('button', { name: /try again/i }); + expect(screen.getByText('list test error')).toBeInTheDocument(); + userEvent.click(try_again_button); + await waitFor(() => { + expect(mockReloadPasskeysList).toBeCalledTimes(1); + }); + }); +}); diff --git a/packages/account/src/Sections/Security/Passkeys/components/__tests__/description-container.spec.tsx b/packages/account/src/Sections/Security/Passkeys/components/__tests__/description-container.spec.tsx new file mode 100644 index 000000000000..afc3abc5032c --- /dev/null +++ b/packages/account/src/Sections/Security/Passkeys/components/__tests__/description-container.spec.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { screen, render } from '@testing-library/react'; +import { DescriptionContainer } from '../description-container'; + +describe('DescriptionContainer', () => { + it('renders the descriptions correctly', () => { + const description_data = [ + { + question: 'What are passkeys?', + description: + 'Passkeys are a security measure that lets you log in the same way you unlock your device: with a fingerprint, a face scan, or a screen lock PIN.', + }, + { + question: 'Why passkeys?', + description: + 'Passkeys are an added layer of security that protects your account against unauthorised access and phishing attacks.', + }, + { + question: 'How to create a passkey?', + description: + 'Go to ‘Account Settings’ on Deriv to set up your passkey. Each device can only save one passkey; however, iOS users may still see the "Create passkey" button due to iOS’s ability to save passkeys on other devices.', + }, + { + question: 'Where are passkeys saved?', + description: + 'Passkeys are saved in your Google password manager for Android devices and in iCloud keychain on iOS devices to help you sign in on other devices.', + }, + { + question: 'What happens if my Deriv account email is changed?', + description: + 'Even if you change your email address, you can still continue to log in to your Deriv account with the same passkey.', + }, + ]; + + render(); + + description_data.forEach(({ question, description }) => { + expect(screen.getByText(question)).toBeInTheDocument(); + expect(screen.getByText(description)).toBeInTheDocument(); + }); + }); +}); 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 new file mode 100644 index 000000000000..7e7bc34fa902 --- /dev/null +++ b/packages/account/src/Sections/Security/Passkeys/components/__tests__/passkey-card.spec.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { screen, render } from '@testing-library/react'; +import PasskeyCard from '../passkey-card'; + +describe('PasskeyCard', () => { + it('renders the passkey card correctly', () => { + const mock_card: React.ComponentProps = { + id: 1, + name: 'Test Passkey', + last_used: 1633024800, + created_at: 1633024800, + stored_on: 'Device', + icon: 'IcPasskey', + passkey_id: 'mock-id', + }; + + render(); + + expect(screen.getByText(/test passkey/i)).toBeInTheDocument(); + expect(screen.getByText(/stored on/i)).toBeInTheDocument(); + expect(screen.getByText(/last used/i)).toBeInTheDocument(); + }); +}); diff --git a/packages/account/src/Sections/Security/Passkeys/components/__tests__/passkey-modal.spec.tsx b/packages/account/src/Sections/Security/Passkeys/components/__tests__/passkey-modal.spec.tsx new file mode 100644 index 000000000000..35d03d1c6b7d --- /dev/null +++ b/packages/account/src/Sections/Security/Passkeys/components/__tests__/passkey-modal.spec.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import PasskeyModal from '../passkey-modal'; + +describe('PasskeyModal', () => { + let modal_root_el: HTMLElement; + beforeAll(() => { + modal_root_el = document.createElement('div'); + modal_root_el.setAttribute('id', 'modal_root'); + document.body.appendChild(modal_root_el); + }); + + afterAll(() => { + document.body.removeChild(modal_root_el); + }); + + const test_header = 'Test Header'; + const test_description = 'Test Description'; + const test_button_text = 'Test Button'; + + it('renders the modal correctly and responds to user interaction', () => { + const handleClick = jest.fn(); + render( + {test_header}} + description={{test_description}} + button_text={{test_button_text}} + onButtonClick={handleClick} + /> + ); + + const close_icon = screen.getByTestId('dt_modal_close_icon'); + expect(screen.getByText(test_header)).toBeInTheDocument(); + expect(screen.getByText(test_description)).toBeInTheDocument(); + expect(screen.getByText(test_button_text)).toBeInTheDocument(); + expect(close_icon).toBeInTheDocument(); + + userEvent.click(screen.getByText('Test Button')); + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it('renders the modal without header and close icon', () => { + const handleClick = jest.fn(); + render( + {test_description}} + button_text={{test_button_text}} + onButtonClick={handleClick} + /> + ); + + const close_icon = screen.queryByTestId('dt_modal_close_icon'); + expect(screen.queryByText(test_header)).not.toBeInTheDocument(); + expect(screen.getByText(test_description)).toBeInTheDocument(); + expect(screen.getByText(test_button_text)).toBeInTheDocument(); + expect(close_icon).not.toBeInTheDocument(); + + userEvent.click(screen.getByText(test_button_text)); + expect(handleClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/account/src/Sections/Security/Passkeys/components/__tests__/passkeys-footer-buttons.spec.tsx b/packages/account/src/Sections/Security/Passkeys/components/__tests__/passkeys-footer-buttons.spec.tsx new file mode 100644 index 000000000000..6b518caff832 --- /dev/null +++ b/packages/account/src/Sections/Security/Passkeys/components/__tests__/passkeys-footer-buttons.spec.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import PasskeysFooterButtons from '../passkeys-footer-buttons'; + +describe('PasskeysFooterButtons', () => { + const next = 'Next'; + const back = 'Back'; + + const mockOnButtonClick = jest.fn(); + const mockOnBackButtonClick = jest.fn(); + + it('calls the correct functions when the buttons are clicked', () => { + render( + {next}} + onPrimaryButtonClick={mockOnButtonClick} + secondary_button_text={{back}} + onSecondaryButtonClick={mockOnBackButtonClick} + /> + ); + + userEvent.click(screen.getByRole('button', { name: next })); + expect(mockOnButtonClick).toHaveBeenCalled(); + + userEvent.click(screen.getByRole('button', { name: back })); + expect(mockOnBackButtonClick).toHaveBeenCalled(); + }); + + it('does not render the back button if back_button_text is not provided', () => { + render( + {next}} onPrimaryButtonClick={mockOnButtonClick} /> + ); + + expect(screen.queryByText(back)).not.toBeInTheDocument(); + }); +}); diff --git a/packages/account/src/Sections/Security/Passkeys/components/__tests__/passkeys-list.spec.tsx b/packages/account/src/Sections/Security/Passkeys/components/__tests__/passkeys-list.spec.tsx new file mode 100644 index 000000000000..087e15220151 --- /dev/null +++ b/packages/account/src/Sections/Security/Passkeys/components/__tests__/passkeys-list.spec.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { screen, render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import PasskeysList from '../passkeys-list'; +import { mock_passkeys_list } from '../../__tests__/passkeys.spec'; + +describe('PasskeysList', () => { + it('renders the passkeys and calls the correct function when the button is clicked', () => { + const mockOnPrimaryButtonClick = jest.fn(); + const mockOnSecondaryButtonClick = jest.fn(); + + render( + + ); + + mock_passkeys_list.forEach(passkey => { + expect(screen.getByText(passkey.name)).toBeInTheDocument(); + }); + + userEvent.click(screen.getByRole('button', { name: /create passkey/i })); + userEvent.click(screen.getByRole('button', { name: /learn more/i })); + expect(mockOnPrimaryButtonClick).toHaveBeenCalled(); + expect(mockOnSecondaryButtonClick).toHaveBeenCalled(); + }); +}); 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 new file mode 100644 index 000000000000..964fd3c6722d --- /dev/null +++ b/packages/account/src/Sections/Security/Passkeys/components/__tests__/passkeys-status-container.spec.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { getStatusContent, PASSKEY_STATUS_CODES } from '../../passkeys-configs'; +import PasskeysStatusContainer from '../passkeys-status-container'; + +describe('PasskeysStatusContainer', () => { + const createPasskeyMock = jest.fn(); + const setPasskeyStatusMock = jest.fn(); + + afterEach(() => { + jest.clearAllMocks(); + }); + + // TODO: add more checks for renaming and verifying flows + it('renders correctly for each status code', () => { + Object.values(PASSKEY_STATUS_CODES).forEach(status => { + const { unmount, container } = render( + + ); + if (status) { + const content = getStatusContent(status); + expect(screen.getByText(content.title.props.i18n_default_text)).toBeInTheDocument(); + const primary_button = screen.getByRole('button', { + name: content.primary_button_text.props.i18n_default_text, + }); + const secondary_button = screen.getByRole('button', { + name: content?.secondary_button_text?.props.i18n_default_text, + }); + + expect(primary_button).toBeInTheDocument(); + expect(secondary_button).toBeInTheDocument(); + + if (status === PASSKEY_STATUS_CODES.LEARN_MORE || status === PASSKEY_STATUS_CODES.NO_PASSKEY) { + userEvent.click(primary_button); + userEvent.click(secondary_button); + expect(createPasskeyMock).toHaveBeenCalled(); + expect(setPasskeyStatusMock).toHaveBeenCalled(); + } + + if (status === PASSKEY_STATUS_CODES.CREATED || status === PASSKEY_STATUS_CODES.REMOVED) { + userEvent.click(primary_button); + expect(setPasskeyStatusMock).toHaveBeenCalledWith(PASSKEY_STATUS_CODES.NONE); + } + + if (status === PASSKEY_STATUS_CODES.NO_PASSKEY) { + userEvent.click(secondary_button); + expect(setPasskeyStatusMock).toHaveBeenCalledWith(PASSKEY_STATUS_CODES.LEARN_MORE); + } + + if (status === PASSKEY_STATUS_CODES.RENAMING) { + userEvent.click(secondary_button); + expect(setPasskeyStatusMock).toHaveBeenCalledWith(PASSKEY_STATUS_CODES.NONE); + } + + unmount(); + } else { + expect(container).toBeEmptyDOMElement(); + } + }); + }); +}); diff --git a/packages/account/src/Sections/Security/Passkeys/components/__tests__/passkeys-status.spec.tsx b/packages/account/src/Sections/Security/Passkeys/components/__tests__/passkeys-status.spec.tsx new file mode 100644 index 000000000000..a994391db516 --- /dev/null +++ b/packages/account/src/Sections/Security/Passkeys/components/__tests__/passkeys-status.spec.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { screen, render } from '@testing-library/react'; +import PasskeysStatus from '../passkeys-status'; + +describe('PasskeysStatus', () => { + const title = 'Test Title'; + const description = 'Test Description'; + const child = 'Test Child'; + + it('renders the title and description correctly', () => { + render( + {title}} description={{description}} icon='IcPasskey' /> + ); + + expect(screen.getByText(title)).toBeInTheDocument(); + expect(screen.getByText(description)).toBeInTheDocument(); + }); + + it('does not render the description if it is not provided', () => { + render({title}} icon='IcPasskey' />); + + expect(screen.queryByText(description)).not.toBeInTheDocument(); + }); + + it('renders the children correctly', () => { + render( + {title}} icon='IcPasskey'> + {child} + + ); + + expect(screen.getByText(child)).toBeInTheDocument(); + }); +}); diff --git a/packages/account/src/Sections/Security/Passkeys/components/__tests__/tips-block.spec.tsx b/packages/account/src/Sections/Security/Passkeys/components/__tests__/tips-block.spec.tsx new file mode 100644 index 000000000000..ba0494196192 --- /dev/null +++ b/packages/account/src/Sections/Security/Passkeys/components/__tests__/tips-block.spec.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { screen, render } from '@testing-library/react'; +import { TipsBlock } from '../tips-block'; + +describe('TipsBlock', () => { + const enable_screen_lock_tip = 'Enable screen lock on your device.'; + const google_sign_in_tip = 'Sign in to your Google or iCloud account.'; + const bluetooth_tip = 'Enable Bluetooth.'; + + it('renders the tips correctly', () => { + render(); + + expect(screen.getByText('Tips:')).toBeInTheDocument(); + expect(screen.getByText('Before using passkey:')).toBeInTheDocument(); + expect(screen.getByText(enable_screen_lock_tip)).toBeInTheDocument(); + expect(screen.getByText(google_sign_in_tip)).toBeInTheDocument(); + expect(screen.getByText(bluetooth_tip)).toBeInTheDocument(); + }); +}); diff --git a/packages/account/src/Sections/Security/Passkeys/components/description-container.tsx b/packages/account/src/Sections/Security/Passkeys/components/description-container.tsx new file mode 100644 index 000000000000..97586332934a --- /dev/null +++ b/packages/account/src/Sections/Security/Passkeys/components/description-container.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { Text } from '@deriv/components'; +import { Localize } from '@deriv/translations'; + +const getPasskeysDescriptions = () => + [ + { + id: 1, + question: , + description: ( + + ), + }, + { + id: 2, + question: , + description: ( + + ), + }, + { + id: 3, + question: , + description: ( + + ), + }, + { + id: 4, + question: , + description: ( + + ), + }, + { + id: 5, + question: , + description: ( + + ), + }, + ] as const; + +export const DescriptionContainer = () => { + const passkeys_descriptions = getPasskeysDescriptions(); + return ( +
+ {passkeys_descriptions.map(({ id, question, description }) => ( +
+ + {question} + + {description} +
+ ))} +
+ ); +}; diff --git a/packages/account/src/Sections/Security/Passkeys/components/passkey-card.tsx b/packages/account/src/Sections/Security/Passkeys/components/passkey-card.tsx new file mode 100644 index 000000000000..c75370010fc5 --- /dev/null +++ b/packages/account/src/Sections/Security/Passkeys/components/passkey-card.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { Icon, Text } from '@deriv/components'; +import { getLongDate } from '@deriv/shared'; +import { Localize } from '@deriv/translations'; + +// TODO: remove here types and grab from API after implementation +type TPasskeyCard = { + id?: number; + name: string; + last_used: number; + created_at?: number; + stored_on?: string; + passkey_id?: string; + icon?: string; +}; + +const PasskeyCard = ({ name, last_used, stored_on, icon }: TPasskeyCard) => { + // TODO: add revoke and rename flow as the next step. 'IcContextMenu' is supposed to be used here + + return ( +
+ +
+ + {name} + + {stored_on && ( +
+ + {stored_on} + +
+ )} +
+ + {' '} + {last_used ? getLongDate(last_used) : } + +
+ {icon && } +
+
+ ); +}; + +export default PasskeyCard; diff --git a/packages/account/src/Sections/Security/Passkeys/components/passkey-modal.tsx b/packages/account/src/Sections/Security/Passkeys/components/passkey-modal.tsx new file mode 100644 index 000000000000..fd367b2316ef --- /dev/null +++ b/packages/account/src/Sections/Security/Passkeys/components/passkey-modal.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { Button, Modal } from '@deriv/components'; + +type TPasskeyModal = { + header?: React.ReactElement; + description?: React.ReactElement | string; + button_text?: React.ReactElement; + onButtonClick: React.MouseEventHandler; + is_modal_open: boolean; + className?: string; + transition_timeout?: number; + has_close_icon?: boolean; + toggleModal?: () => void; +}; + +const PasskeyModal = ({ + is_modal_open, + header, + description, + button_text, + onButtonClick, + className, + transition_timeout, + has_close_icon, + toggleModal, +}: TPasskeyModal) => ( + + {description} + + {button_text && ( + + )} + + +); + +export default PasskeyModal; diff --git a/packages/account/src/Sections/Security/Passkeys/components/passkeys-footer-buttons.tsx b/packages/account/src/Sections/Security/Passkeys/components/passkeys-footer-buttons.tsx new file mode 100644 index 000000000000..4cc60aa8e264 --- /dev/null +++ b/packages/account/src/Sections/Security/Passkeys/components/passkeys-footer-buttons.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { Button } from '@deriv/components'; +import FormFooter from '../../../../Components/form-footer'; + +type TPasskeysFooterButtons = { + primary_button_text: React.ReactElement; + onPrimaryButtonClick: React.MouseEventHandler; + secondary_button_text?: React.ReactElement; + onSecondaryButtonClick?: React.MouseEventHandler; +}; + +const PasskeysFooterButtons = ({ + primary_button_text, + onPrimaryButtonClick, + secondary_button_text, + onSecondaryButtonClick, +}: TPasskeysFooterButtons) => ( + + {secondary_button_text && ( + + )} + + +); + +export default PasskeysFooterButtons; diff --git a/packages/account/src/Sections/Security/Passkeys/components/passkeys-list.tsx b/packages/account/src/Sections/Security/Passkeys/components/passkeys-list.tsx new file mode 100644 index 000000000000..0debf066d283 --- /dev/null +++ b/packages/account/src/Sections/Security/Passkeys/components/passkeys-list.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { Localize } from '@deriv/translations'; +import FormBody from '../../../../Components/form-body'; +import PasskeyCard from './passkey-card'; +import PasskeysFooterButtons from 'Sections/Security/Passkeys/components/passkeys-footer-buttons'; + +type TPasskeysList = { + passkeys_list: React.ComponentProps[]; + onPrimaryButtonClick: React.MouseEventHandler; + onSecondaryButtonClick: React.MouseEventHandler; +}; + +const PasskeysList = ({ passkeys_list, onPrimaryButtonClick, onSecondaryButtonClick }: TPasskeysList) => ( + + + {passkeys_list.map(passkey => ( + + ))} + + } + secondary_button_text={} + /> + +); + +export default PasskeysList; 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 new file mode 100644 index 000000000000..14f88de0a654 --- /dev/null +++ b/packages/account/src/Sections/Security/Passkeys/components/passkeys-status-container.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { Icon } from '@deriv/components'; +import { getStatusContent, PASSKEY_STATUS_CODES, TPasskeysStatus } from 'Sections/Security/Passkeys/passkeys-configs'; +import PasskeysFooterButtons from './passkeys-footer-buttons'; +import PasskeysStatus from './passkeys-status'; + +type TPasskeysStatusContainer = { + createPasskey: () => void; + passkey_status: TPasskeysStatus; + setPasskeyStatus: (status: TPasskeysStatus) => void; +}; + +const PasskeysStatusContainer = ({ createPasskey, passkey_status, setPasskeyStatus }: TPasskeysStatusContainer) => { + const prev_passkey_status = React.useRef(PASSKEY_STATUS_CODES.NONE); + + if (passkey_status === PASSKEY_STATUS_CODES.NONE) return null; + + const onPrimaryButtonClick = () => { + if (passkey_status === PASSKEY_STATUS_CODES.CREATED || passkey_status === PASSKEY_STATUS_CODES.REMOVED) { + // set status to 'NONE' means 'continue' button is clicked + setPasskeyStatus(PASSKEY_STATUS_CODES.NONE); + return; + } + // if (passkey_status === PASSKEY_STATUS_CODES.RENAMING) { + // // TODO: implement renaming flow & add 'Save changes' action for onPrimaryButtonClick + // return; + // } + // if (passkey_status === PASSKEY_STATUS_CODES.VERIFYING) { + // // TODO: implement verifying flow and onPrimaryButtonClick action (send email) + // return; + // } + createPasskey(); + }; + + const onSecondaryButtonClick = () => { + if (passkey_status === PASSKEY_STATUS_CODES.LEARN_MORE) { + setPasskeyStatus(prev_passkey_status.current); + return; + } + // if (passkey_status === PASSKEY_STATUS_CODES.RENAMING) { + // setPasskeyStatus(PASSKEY_STATUS_CODES.NONE); + // return; + // } + prev_passkey_status.current = passkey_status; + setPasskeyStatus(PASSKEY_STATUS_CODES.LEARN_MORE); + }; + + const content = getStatusContent(passkey_status); + const is_learn_more_opened = passkey_status === PASSKEY_STATUS_CODES.LEARN_MORE; + + return ( +
+ {is_learn_more_opened && ( + + )} + + + +
+ ); +}; + +export default PasskeysStatusContainer; diff --git a/packages/account/src/Sections/Security/Passkeys/components/passkeys-status.tsx b/packages/account/src/Sections/Security/Passkeys/components/passkeys-status.tsx new file mode 100644 index 000000000000..5be143235f0e --- /dev/null +++ b/packages/account/src/Sections/Security/Passkeys/components/passkeys-status.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import classNames from 'classnames'; +import { Icon, Text } from '@deriv/components'; +import FormBody from '../../../../Components/form-body'; + +type TPasskeysStatus = { + title: React.ReactElement; + description?: React.ReactNode; + icon: string; + className?: string; +}; + +const PasskeysStatus = ({ + title, + description, + icon, + children, + className, +}: React.PropsWithChildren) => { + return ( + + + + + {title} + + {description && ( + + {description} + + )} + + {children} + + ); +}; + +export default PasskeysStatus; diff --git a/packages/account/src/Sections/Security/Passkeys/components/tips-block.tsx b/packages/account/src/Sections/Security/Passkeys/components/tips-block.tsx new file mode 100644 index 000000000000..1aba7cf500af --- /dev/null +++ b/packages/account/src/Sections/Security/Passkeys/components/tips-block.tsx @@ -0,0 +1,43 @@ +import { Icon, Text } from '@deriv/components'; +import { Localize } from '@deriv/translations'; +import React from 'react'; + +const getPasskeysTips = () => + [ + { + id: 1, + description: , + }, + { + id: 2, + description: , + }, + { + id: 3, + description: , + }, + ] as const; + +export const TipsBlock = () => { + const tips = getPasskeysTips(); + return ( +
+ +
+ + + + + + + {tips.map(({ id, description }) => ( +
  • + + {description} + +
  • + ))} +
    +
    + ); +}; diff --git a/packages/account/src/Sections/Security/Passkeys/index.ts b/packages/account/src/Sections/Security/Passkeys/index.ts new file mode 100644 index 000000000000..4d579080922e --- /dev/null +++ b/packages/account/src/Sections/Security/Passkeys/index.ts @@ -0,0 +1,3 @@ +import Passkeys from './passkeys'; + +export default Passkeys; diff --git a/packages/account/src/Sections/Security/Passkeys/passkeys-configs.tsx b/packages/account/src/Sections/Security/Passkeys/passkeys-configs.tsx new file mode 100644 index 000000000000..1053f45fbc24 --- /dev/null +++ b/packages/account/src/Sections/Security/Passkeys/passkeys-configs.tsx @@ -0,0 +1,133 @@ +import React from 'react'; +import { Text } from '@deriv/components'; +import { Localize } from '@deriv/translations'; +import { mobileOSDetect } from '@deriv/shared'; +import { DescriptionContainer } from './components/description-container'; +import { TipsBlock } from './components/tips-block'; +import { TServerError } from '../../../Types/common.type'; + +export const PASSKEY_STATUS_CODES = { + CREATED: 'created', + LEARN_MORE: 'learn_more', + NONE: '', + NO_PASSKEY: 'no_passkey', + REMOVED: 'removed', + RENAMING: 'renaming', + VERIFYING: 'verifying', +} as const; + +export type TPasskeysStatus = typeof PASSKEY_STATUS_CODES[keyof typeof PASSKEY_STATUS_CODES]; + +export const getStatusContent = (status: Exclude) => { + const learn_more_button_text = ; + const create_passkey_button_text = ; + const continue_button_text = ; + + const getPasskeysRemovedDescription = () => { + const os_type = mobileOSDetect(); + + switch (os_type) { + case 'Android': + return ( + + ); + case 'iOS': + return ( + + ); + default: + return ( + + ); + } + }; + + const titles = { + created: , + learn_more: , + no_passkey: , + removed: , + renaming: , + verifying: , + }; + const descriptions = { + created: ( + ]} + /> + ), + learn_more: ( + + + + + ), + no_passkey: ( + , ]} + /> + ), + removed: getPasskeysRemovedDescription(), + renaming: '', + verifying: ( + + ), + }; + const icons = { + created: 'IcSuccessPasskey', + learn_more: 'IcInfoPasskey', + no_passkey: 'IcAddPasskey', + removed: 'IcSuccessPasskey', + renaming: 'IcEditPasskey', + verifying: 'IcVerifyPasskey', + }; + const button_texts = { + created: continue_button_text, + learn_more: create_passkey_button_text, + no_passkey: create_passkey_button_text, + removed: continue_button_text, + renaming: , + verifying: , + }; + const back_button_texts = { + created: undefined, + learn_more: undefined, + no_passkey: learn_more_button_text, + removed: undefined, + renaming: , + verifying: undefined, + }; + + return { + title: titles[status], + description: descriptions[status], + icon: icons[status], + primary_button_text: button_texts[status], + secondary_button_text: back_button_texts[status], + }; +}; + +type TGetModalContent = { error: TServerError | null; is_passkey_registration_started: boolean }; + +export const getModalContent = ({ error, is_passkey_registration_started }: TGetModalContent) => { + if (is_passkey_registration_started) { + return { + description: ( + + ), + button_text: , + header: ( + + + + ), + }; + } + + return { + description: error?.message ?? '', + button_text: error ? : undefined, + }; +}; diff --git a/packages/account/src/Sections/Security/Passkeys/passkeys.scss b/packages/account/src/Sections/Security/Passkeys/passkeys.scss new file mode 100644 index 000000000000..3d8225ca91bf --- /dev/null +++ b/packages/account/src/Sections/Security/Passkeys/passkeys.scss @@ -0,0 +1,136 @@ +@mixin flex-column { + display: flex; + flex-direction: column; +} + +.passkeys { + width: 100%; + margin-inline: auto; + + &-status { + &__wrapper { + height: 100%; + @include flex-column; + justify-content: center; + align-items: center; + margin-block: 1.6rem; + + &--expanded { + display: block; + + .dc-icon { + display: block; + margin-inline: auto; + } + + .passkeys-status__title { + margin-bottom: 0; + } + } + } + + &__title { + margin: 2.4rem auto 0.8rem; + } + + &__description { + &-back-button { + margin: 2.4rem 0 0 2.4rem; + } + + &-card { + @include flex-column; + gap: 0.8rem; + border-bottom: 1px solid var(--general-hover); + padding: 1.6rem 0; + } + + &-container { + @include flex-column; + } + + &-tips { + &-wrapper { + display: flex; + padding: 1.6rem; + margin-top: 1.6rem; + border-radius: 2 * $BORDER_RADIUS; + border: 1px solid var(--border-normal); + gap: 0.8rem; + + svg { + flex-shrink: 0; + } + + .dc-icon { + margin: initial; + } + } + + &-container { + @include flex-column; + align-items: flex-start; + } + } + } + + &__footer { + flex-direction: row; + gap: 0.8rem; + padding: 1.6rem; + + .dc-btn { + flex: 1; + } + } + } + + .dc-btn { + height: 4rem; + } + + &-card { + &__wrapper { + display: flex; + gap: 0.8rem; + margin-block: 1.6rem; + padding-bottom: 1.6rem; + border-bottom: 1px solid var(--general-hover); + + & > .dc-icon:first-child { + margin-top: 0.4rem; + } + + & > .dc-icon:last-child { + margin-left: auto; + } + + &:last-child { + border-bottom: none; + } + } + + &__passkey-type-icon { + margin-top: 0.4rem; + } + } +} + +.dc-modal__container_passkeys-modal { + min-width: 32.8rem; + + .dc-modal-header { + &__close, + &__section { + margin: 1.6rem 1.6rem 0; + padding: 0; + width: initial; + height: initial; + line-height: initial; + } + } + + .dc-modal-body { + padding: 1.6rem 1.6rem 0; + } +} diff --git a/packages/account/src/Sections/Security/Passkeys/passkeys.tsx b/packages/account/src/Sections/Security/Passkeys/passkeys.tsx new file mode 100644 index 000000000000..7ba993df497e --- /dev/null +++ b/packages/account/src/Sections/Security/Passkeys/passkeys.tsx @@ -0,0 +1,123 @@ +import React from 'react'; +import { Redirect } from 'react-router-dom'; +import { Loading } from '@deriv/components'; +import { useGetPasskeysList, useRegisterPasskey } from '@deriv/hooks'; +import { routes } from '@deriv/shared'; +import { observer, useStore } from '@deriv/stores'; +import PasskeysStatusContainer from './components/passkeys-status-container'; +import PasskeysList from './components/passkeys-list'; +import PasskeyModal from './components/passkey-modal'; +import { getModalContent, PASSKEY_STATUS_CODES, TPasskeysStatus } from './passkeys-configs'; +import './passkeys.scss'; + +const Passkeys = observer(() => { + const { ui, client, common } = useStore(); + const { is_mobile } = ui; + const { is_passkey_supported } = client; + let timeout: ReturnType; + + const [passkey_status, setPasskeyStatus] = React.useState(PASSKEY_STATUS_CODES.NONE); + const [is_modal_open, setIsModalOpen] = React.useState(false); + const { passkeys_list, is_passkeys_list_loading, passkeys_list_error, reloadPasskeysList } = useGetPasskeysList(); + const { + cancelPasskeyRegistration, + createPasskey, + clearPasskeyRegistrationError, + startPasskeyRegistration, + is_passkey_registration_started, + is_passkey_registered, + passkey_registration_error, + } = useRegisterPasskey(); + + const should_show_passkeys = is_passkey_supported && is_mobile; + const error = passkeys_list_error || passkey_registration_error; + const modal_content = getModalContent({ + error, + is_passkey_registration_started, + }); + + React.useEffect(() => { + if (!passkeys_list?.length && !is_passkey_registered) { + setPasskeyStatus(PASSKEY_STATUS_CODES.NO_PASSKEY); + } else if (is_passkey_registered) { + setPasskeyStatus(PASSKEY_STATUS_CODES.CREATED); + } else { + setPasskeyStatus(PASSKEY_STATUS_CODES.NONE); + } + }, [is_passkey_registered, passkeys_list]); + + React.useEffect(() => { + if (!!error || is_passkey_registration_started) { + setIsModalOpen(true); + } + return () => { + clearTimeout(timeout); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [error, is_passkey_registration_started]); + + if (is_passkeys_list_loading || common.network_status.class !== 'online') { + return ; + } + if (!should_show_passkeys) { + return ; + } + + // to avoid flickering with blank Modal we need first close it, then clear content + const onCloseModal = (delayed_action: () => void) => { + setIsModalOpen(false); + if (timeout) clearTimeout(timeout); + timeout = setTimeout(delayed_action, 300); + }; + + const onCloseError = () => { + if (passkeys_list_error) { + reloadPasskeysList(); + } + if (passkey_registration_error) { + clearPasskeyRegistrationError(); + } + }; + const onModalButtonClick = () => { + if (error) { + onCloseModal(onCloseError); + } else { + createPasskey(); + setIsModalOpen(false); + } + }; + + const onCloseRegistration = () => { + onCloseModal(cancelPasskeyRegistration); + }; + + return ( +
    + {passkey_status ? ( + + ) : ( + setPasskeyStatus(PASSKEY_STATUS_CODES.LEARN_MORE)} + /> + )} + +
    + ); +}); + +export default Passkeys; diff --git a/packages/account/src/Sections/index.js b/packages/account/src/Sections/index.js index 387a583b9361..00c9d91d68a6 100644 --- a/packages/account/src/Sections/index.js +++ b/packages/account/src/Sections/index.js @@ -1,3 +1,4 @@ +import Passkeys from 'Sections/Security/Passkeys'; import Passwords from 'Sections/Security/Passwords'; import AccountLimits from 'Sections/Security/AccountLimits'; import PersonalDetails from 'Sections/Profile/PersonalDetails'; @@ -20,6 +21,7 @@ import LanguageSettings from 'Sections/Profile/LanguageSettings'; export { AccountLimits, + Passkeys, Passwords, PersonalDetails, TradingAssessment, diff --git a/packages/account/src/Types/common.type.ts b/packages/account/src/Types/common.type.ts index c60540ccaf55..360a8ca2fc7a 100644 --- a/packages/account/src/Types/common.type.ts +++ b/packages/account/src/Types/common.type.ts @@ -139,8 +139,9 @@ export type TIDVFormValues = { export type TPlatforms = typeof Platforms[keyof typeof Platforms]; export type TServerError = { - code: string; + code?: string; message: string; + name?: string; details?: { [key: string]: string }; fields?: string[]; }; diff --git a/packages/api/types.ts b/packages/api/types.ts index a50d4764b0a4..2d9ec5efea31 100644 --- a/packages/api/types.ts +++ b/packages/api/types.ts @@ -2144,6 +2144,95 @@ type TPrivateSocketEndpoints = { }; }; +// TODO: remove these mock passkeys types after implementing them inside api-types +type PasskeysListRequest = { + passkeys_list: 1; + req_id?: number; +}; +type PasskeysListResponse = { + passkeys_list?: { + id: number; + name: string; + last_used: number; + created_at: number; + stored_on?: string; + passkey_id: string; + }[]; + echo_req: { + [k: string]: unknown; + }; + msg_type: 'passkeys_list'; + req_id?: number; + [k: string]: unknown; +}; +type PasskeysRegisterOptionsRequest = { + passkeys_register_options: 1; + req_id?: number; +}; +type PasskeysRegisterOptionsResponse = { + passkeys_register_options?: { + publicKey: { + challenge: string; + rp: { + name: string; + id: string; + }; + user: Record<'id' | 'name' | 'displayName', string>; + pubKeyCredParams: PublicKeyCredentialParameters[]; + timeout: number; + attestation: AttestationConveyancePreference; + excludeCredentials: []; + authenticatorSelection: { + residentKey: ResidentKeyRequirement; + userVerification: UserVerificationRequirement; + authenticatorAttachment?: AuthenticatorAttachment; + requireResidentKey?: boolean; + }; + extensions: Record; + }; + }; + echo_req: { + [k: string]: unknown; + }; + msg_type: 'passkeys_list'; + req_id?: number; + [k: string]: unknown; +}; +type PasskeyRegisterRequest = { + passkeys_register: 1; + name?: string; + publicKeyCredential: { + type: string; + id: string; + rawId: string; + authenticatorAttachment?: string; + response: { + attestationObject: string; + clientDataJSON: string; + transports?: string[]; + authenticatorData?: string; + }; + clientExtensionResults: AuthenticationExtensionsClientOutputs; + }; + req_id?: number; +}; +type PasskeyRegisterResponse = { + passkeys_register: { + id: number; + name: string; + last_used: number; + created_at: number; + stored_on: string; + passkey_id: string; + }; + echo_req: { + [k: string]: unknown; + }; + msg_type: 'passkeys_list'; + req_id?: number; + [k: string]: unknown; +}; + type TSocketEndpoints = { active_symbols: { request: ActiveSymbolsRequest; @@ -2437,6 +2526,18 @@ type TSocketEndpoints = { request: P2PPingRequest; response: P2PPingResponse; }; + passkeys_list: { + request: PasskeysListRequest; + response: PasskeysListResponse; + }; + passkeys_register_options: { + request: PasskeysRegisterOptionsRequest; + response: PasskeysRegisterOptionsResponse; + }; + passkeys_register: { + request: PasskeyRegisterRequest; + response: PasskeyRegisterResponse; + }; payment_methods: { request: PaymentMethodsRequest; response: PaymentMethodsResponse; diff --git a/packages/components/src/components/icon/common/ic-add-passkey.svg b/packages/components/src/components/icon/common/ic-add-passkey.svg new file mode 100644 index 000000000000..6293eaf33be7 --- /dev/null +++ b/packages/components/src/components/icon/common/ic-add-passkey.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/components/src/components/icon/common/ic-bulb.svg b/packages/components/src/components/icon/common/ic-bulb.svg new file mode 100644 index 000000000000..6af0f8efe474 --- /dev/null +++ b/packages/components/src/components/icon/common/ic-bulb.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/components/src/components/icon/common/ic-edit-passkey.svg b/packages/components/src/components/icon/common/ic-edit-passkey.svg new file mode 100644 index 000000000000..b63adb0d8844 --- /dev/null +++ b/packages/components/src/components/icon/common/ic-edit-passkey.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/components/src/components/icon/common/ic-faceid.svg b/packages/components/src/components/icon/common/ic-faceid.svg new file mode 100644 index 000000000000..a7311b7d9271 --- /dev/null +++ b/packages/components/src/components/icon/common/ic-faceid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/components/src/components/icon/common/ic-fingerprint-bold.svg b/packages/components/src/components/icon/common/ic-fingerprint-bold.svg new file mode 100644 index 000000000000..3224b2703cdc --- /dev/null +++ b/packages/components/src/components/icon/common/ic-fingerprint-bold.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/components/src/components/icon/common/ic-fingerprint.svg b/packages/components/src/components/icon/common/ic-fingerprint.svg new file mode 100644 index 000000000000..56df6906e785 --- /dev/null +++ b/packages/components/src/components/icon/common/ic-fingerprint.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/components/src/components/icon/common/ic-info-passkey.svg b/packages/components/src/components/icon/common/ic-info-passkey.svg new file mode 100644 index 000000000000..45f10258f03f --- /dev/null +++ b/packages/components/src/components/icon/common/ic-info-passkey.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/components/src/components/icon/common/ic-lock-bold.svg b/packages/components/src/components/icon/common/ic-lock-bold.svg new file mode 100644 index 000000000000..0cac38ebb92d --- /dev/null +++ b/packages/components/src/components/icon/common/ic-lock-bold.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/components/src/components/icon/common/ic-mobile-device.svg b/packages/components/src/components/icon/common/ic-mobile-device.svg new file mode 100644 index 000000000000..252c3db0b40a --- /dev/null +++ b/packages/components/src/components/icon/common/ic-mobile-device.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/components/src/components/icon/common/ic-passcode.svg b/packages/components/src/components/icon/common/ic-passcode.svg new file mode 100644 index 000000000000..ed15330e1f89 --- /dev/null +++ b/packages/components/src/components/icon/common/ic-passcode.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/components/src/components/icon/common/ic-passkey.svg b/packages/components/src/components/icon/common/ic-passkey.svg new file mode 100644 index 000000000000..25ab6c8f40dd --- /dev/null +++ b/packages/components/src/components/icon/common/ic-passkey.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/components/src/components/icon/common/ic-pattern.svg b/packages/components/src/components/icon/common/ic-pattern.svg new file mode 100644 index 000000000000..7a79d3cfbe18 --- /dev/null +++ b/packages/components/src/components/icon/common/ic-pattern.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/components/src/components/icon/common/ic-success-passkey.svg b/packages/components/src/components/icon/common/ic-success-passkey.svg new file mode 100644 index 000000000000..b9ae018cc122 --- /dev/null +++ b/packages/components/src/components/icon/common/ic-success-passkey.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/components/src/components/icon/common/ic-verify-passkey.svg b/packages/components/src/components/icon/common/ic-verify-passkey.svg new file mode 100644 index 000000000000..f1ac17603ceb --- /dev/null +++ b/packages/components/src/components/icon/common/ic-verify-passkey.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/components/src/components/icon/icons.js b/packages/components/src/components/icon/icons.js index 9427d6a25024..377f33f480ca 100644 --- a/packages/components/src/components/icon/icons.js +++ b/packages/components/src/components/icon/icons.js @@ -253,6 +253,7 @@ import './common/ic-add-account.svg'; import './common/ic-add-bold.svg'; import './common/ic-add-circle.svg'; import './common/ic-add-outline.svg'; +import './common/ic-add-passkey.svg'; import './common/ic-add-rounded.svg'; import './common/ic-add.svg'; import './common/ic-adjustment.svg'; @@ -298,6 +299,7 @@ import './common/ic-bot-builder-tab-icon.svg'; import './common/ic-bot-builder.svg'; import './common/ic-bot-stop.svg'; import './common/ic-box.svg'; +import './common/ic-bulb.svg'; import './common/ic-button-back.svg'; import './common/ic-calendar-datefrom.svg'; import './common/ic-calendar-dateto.svg'; @@ -379,6 +381,7 @@ import './common/ic-dp2p.svg'; import './common/ic-driving-licence-front.svg'; import './common/ic-driving-license-dashboard.svg'; import './common/ic-driving-license.svg'; +import './common/ic-edit-passkey.svg'; import './common/ic-edit.svg'; import './common/ic-email-changed.svg'; import './common/ic-email-firewall.svg'; @@ -402,7 +405,10 @@ import './common/ic-empty-star.svg'; import './common/ic-ewallet.svg'; import './common/ic-eye.svg'; import './common/ic-facebook.svg'; +import './common/ic-faceid.svg'; import './common/ic-filter.svg'; +import './common/ic-fingerprint-bold.svg'; +import './common/ic-fingerprint.svg'; import './common/ic-folder-open-filled.svg'; import './common/ic-folder-open.svg'; import './common/ic-full-screen-restore.svg'; @@ -430,6 +436,7 @@ import './common/ic-identity-document-verification.svg'; import './common/ic-image.svg'; import './common/ic-info-blue.svg'; import './common/ic-info-outline.svg'; +import './common/ic-info-passkey.svg'; import './common/ic-info.svg'; import './common/ic-ins-outs.svg'; import './common/ic-installation-apple.svg'; @@ -449,6 +456,7 @@ import './common/ic-linux-logo.svg'; import './common/ic-linux.svg'; import './common/ic-live-chat.svg'; import './common/ic-local.svg'; +import './common/ic-lock-bold.svg'; import './common/ic-lock.svg'; import './common/ic-logout.svg'; import './common/ic-long-arrow-down.svg'; @@ -467,6 +475,7 @@ import './common/ic-message-seen.svg'; import './common/ic-minus-bold.svg'; import './common/ic-minus-rounded.svg'; import './common/ic-minus.svg'; +import './common/ic-mobile-device.svg'; import './common/ic-mobile-outline.svg'; import './common/ic-mobile.svg'; import './common/ic-money-transfer.svg'; @@ -490,11 +499,14 @@ import './common/ic-online-naira.svg'; import './common/ic-open-positions.svg'; import './common/ic-other-payment-method.svg'; import './common/ic-pa.svg'; +import './common/ic-passcode.svg'; +import './common/ic-passkey.svg'; import './common/ic-passport-dashboard.svg'; import './common/ic-passport.svg'; import './common/ic-password-eye-hide.svg'; import './common/ic-password-eye-visible.svg'; import './common/ic-password-updated.svg'; +import './common/ic-pattern.svg'; import './common/ic-pause-outline.svg'; import './common/ic-pause.svg'; import './common/ic-payment-agent.svg'; @@ -591,6 +603,7 @@ import './common/ic-statement.svg'; import './common/ic-sticpay-dark.svg'; import './common/ic-sticpay-light.svg'; import './common/ic-stop.svg'; +import './common/ic-success-passkey.svg'; import './common/ic-success-reset-trading-password.svg'; import './common/ic-success.svg'; import './common/ic-telegram.svg'; @@ -622,6 +635,7 @@ import './common/ic-verification-status-red.svg'; import './common/ic-verification-status-yellow.svg'; import './common/ic-verification-success.svg'; import './common/ic-verification.svg'; +import './common/ic-verify-passkey.svg'; import './common/ic-visa-dark.svg'; import './common/ic-visa-light.svg'; import './common/ic-warning.svg'; diff --git a/packages/components/stories/icon/icons.js b/packages/components/stories/icon/icons.js index 89ceb765c22a..20d9cc36f2d3 100644 --- a/packages/components/stories/icon/icons.js +++ b/packages/components/stories/icon/icons.js @@ -262,6 +262,7 @@ export const icons = 'IcAddBold', 'IcAddCircle', 'IcAddOutline', + 'IcAddPasskey', 'IcAddRounded', 'IcAdd', 'IcAdjustment', @@ -307,6 +308,7 @@ export const icons = 'IcBotBuilder', 'IcBotStop', 'IcBox', + 'IcBulb', 'IcButtonBack', 'IcCalendarDatefrom', 'IcCalendarDateto', @@ -388,6 +390,7 @@ export const icons = 'IcDrivingLicenceFront', 'IcDrivingLicenseDashboard', 'IcDrivingLicense', + 'IcEditPasskey', 'IcEdit', 'IcEmailChanged', 'IcEmailFirewall', @@ -411,7 +414,10 @@ export const icons = 'IcEwallet', 'IcEye', 'IcFacebook', + 'IcFaceid', 'IcFilter', + 'IcFingerprintBold', + 'IcFingerprint', 'IcFolderOpenFilled', 'IcFolderOpen', 'IcFullScreenRestore', @@ -439,6 +445,7 @@ export const icons = 'IcImage', 'IcInfoBlue', 'IcInfoOutline', + 'IcInfoPasskey', 'IcInfo', 'IcInsOuts', 'IcInstallationApple', @@ -458,6 +465,7 @@ export const icons = 'IcLinux', 'IcLiveChat', 'IcLocal', + 'IcLockBold', 'IcLock', 'IcLogout', 'IcLongArrowDown', @@ -476,6 +484,7 @@ export const icons = 'IcMinusBold', 'IcMinusRounded', 'IcMinus', + 'IcMobileDevice', 'IcMobileOutline', 'IcMobile', 'IcMoneyTransfer', @@ -499,11 +508,14 @@ export const icons = 'IcOpenPositions', 'IcOtherPaymentMethod', 'IcPa', + 'IcPasscode', + 'IcPasskey', 'IcPassportDashboard', 'IcPassport', 'IcPasswordEyeHide', 'IcPasswordEyeVisible', 'IcPasswordUpdated', + 'IcPattern', 'IcPauseOutline', 'IcPause', 'IcPaymentAgent', @@ -600,6 +612,7 @@ export const icons = 'IcSticpayDark', 'IcSticpayLight', 'IcStop', + 'IcSuccessPasskey', 'IcSuccessResetTradingPassword', 'IcSuccess', 'IcTelegram', @@ -631,6 +644,7 @@ export const icons = 'IcVerificationStatusYellow', 'IcVerificationSuccess', 'IcVerification', + 'IcVerifyPasskey', 'IcVisaDark', 'IcVisaLight', 'IcWarning', diff --git a/packages/core/package.json b/packages/core/package.json index fbf5ed0baaa7..72201770c26c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -151,6 +151,8 @@ "react-tiny-popover": "^7.0.1", "react-transition-group": "4.4.2", "react-window": "^1.8.5", + "@simplewebauthn/browser": "^8.3.4", + "@simplewebauthn/typescript-types": "^8.3.4", "usehooks-ts": "^2.7.0" } } diff --git a/packages/core/src/App/Components/Layout/Header/menu-link.tsx b/packages/core/src/App/Components/Layout/Header/menu-link.tsx index 83c37f0c81db..570024cb85c8 100644 --- a/packages/core/src/App/Components/Layout/Header/menu-link.tsx +++ b/packages/core/src/App/Components/Layout/Header/menu-link.tsx @@ -17,7 +17,7 @@ type TMenuLink = { link_to: string; onClickLink: () => void; suffix_icon: string; - text: string; + text: React.ReactNode; }; const MenuLink = observer( diff --git a/packages/core/src/App/Components/Layout/Header/toggle-menu-drawer.jsx b/packages/core/src/App/Components/Layout/Header/toggle-menu-drawer.jsx index c344334c93ee..dd0b72b6a6e0 100644 --- a/packages/core/src/App/Components/Layout/Header/toggle-menu-drawer.jsx +++ b/packages/core/src/App/Components/Layout/Header/toggle-menu-drawer.jsx @@ -1,17 +1,18 @@ -import classNames from 'classnames'; import React from 'react'; -import { useLocation, useHistory } from 'react-router-dom'; +import { useHistory, useLocation } from 'react-router-dom'; +import classNames from 'classnames'; +import { useRemoteConfig } from '@deriv/api'; import { Div100vhContainer, Icon, MobileDrawer, ToggleSwitch } from '@deriv/components'; import { - useOnrampVisible, useAccountTransferVisible, + useAuthorize, + useFeatureFlags, useIsP2PEnabled, + useOnrampVisible, usePaymentAgentTransferVisible, - useFeatureFlags, useP2PSettings, - useAuthorize, } from '@deriv/hooks'; -import { routes, PlatformContext, getStaticUrl, whatsapp_url } from '@deriv/shared'; +import { deepCopy, getStaticUrl, removeExactRouteFromRoutes, routes, whatsapp_url } from '@deriv/shared'; import { observer, useStore } from '@deriv/stores'; import { localize } from '@deriv/translations'; import NetworkStatus from 'App/Components/Layout/Footer'; @@ -19,10 +20,9 @@ import ServerTime from 'App/Containers/server-time.jsx'; import getRoutesConfig from 'App/Constants/routes-config'; import LiveChat from 'App/Components/Elements/LiveChat'; import useLiveChat from 'App/Components/Elements/LiveChat/use-livechat.ts'; -import PlatformSwitcher from './platform-switcher'; +import { MenuTitle, MobileLanguageMenu } from './Components/ToggleMenu'; import MenuLink from './menu-link'; -import { MobileLanguageMenu, MenuTitle } from './Components/ToggleMenu'; -import { useRemoteConfig } from '@deriv/api'; +import PlatformSwitcher from './platform-switcher'; const ToggleMenuDrawer = observer(({ platform_config }) => { const { common, ui, client, traders_hub, modules } = useStore(); @@ -30,6 +30,7 @@ const ToggleMenuDrawer = observer(({ platform_config }) => { const { disableApp, enableApp, + is_mobile, is_mobile_language_menu_open, is_dark_mode_on: is_dark_mode, setDarkMode: toggleTheme, @@ -48,6 +49,7 @@ const ToggleMenuDrawer = observer(({ platform_config }) => { is_landing_company_loaded, is_proof_of_ownership_enabled, is_eu, + is_passkey_supported, } = client; const { cashier } = modules; const { payment_agent } = cashier; @@ -59,13 +61,20 @@ const ToggleMenuDrawer = observer(({ platform_config }) => { const { data: is_payment_agent_transfer_visible } = usePaymentAgentTransferVisible(); const { is_p2p_enabled } = useIsP2PEnabled(); + const { pathname: route } = useLocation(); + + const is_trading_hub_category = + route.startsWith(routes.traders_hub) || route.startsWith(routes.cashier) || route.startsWith(routes.account); + + const { data } = useRemoteConfig(); + const { cs_chat_livechat, cs_chat_whatsapp } = data; + const liveChat = useLiveChat(false, loginid); const [is_open, setIsOpen] = React.useState(false); const [transitionExit, setTransitionExit] = React.useState(false); const [primary_routes_config, setPrimaryRoutesConfig] = React.useState([]); const [is_submenu_expanded, expandSubMenu] = React.useState(false); - const { is_appstore } = React.useContext(PlatformContext); const timeout = React.useRef(); const history = useHistory(); const { is_next_wallet_enabled } = useFeatureFlags(); @@ -83,21 +92,17 @@ const ToggleMenuDrawer = observer(({ platform_config }) => { React.useEffect(() => { const processRoutes = () => { - const routes_config = getRoutesConfig({ is_appstore }); + let routes_config = getRoutesConfig({}); + + const should_remove_passkeys_route = !is_mobile || !is_passkey_supported; + if (should_remove_passkeys_route) { + routes_config = removeExactRouteFromRoutes(deepCopy(routes_config), 'passkeys'); + } let primary_routes = []; const location = window.location.pathname; - if (is_appstore) { - primary_routes = [ - routes.my_apps, - routes.explore, - routes.wallets, - routes.platforms, - routes.trade_types, - routes.markets, - ]; - } else if (location === routes.traders_hub || is_trading_hub_category) { + if (location === routes.traders_hub || is_trading_hub_category) { primary_routes = [routes.account, routes.cashier]; } else if (location === routes.wallets || is_next_wallet_enabled) { primary_routes = [routes.reports, routes.account]; @@ -113,11 +118,12 @@ const ToggleMenuDrawer = observer(({ platform_config }) => { return () => clearTimeout(timeout.current); }, [ - is_appstore, account_status, should_allow_authentication, is_trading_hub_category, is_next_wallet_enabled, + is_mobile, + is_passkey_supported, is_p2p_enabled, ]); @@ -254,24 +260,13 @@ const ToggleMenuDrawer = observer(({ platform_config }) => { ); }; - const { pathname: route } = useLocation(); - const { data } = useRemoteConfig(); - const { cs_chat_livechat, cs_chat_whatsapp } = data; - const is_trading_hub_category = - route.startsWith(routes.traders_hub) || route.startsWith(routes.cashier) || route.startsWith(routes.account); - return ( - + { >
    - {is_appstore && ( - - {primary_routes_config.map((route_config, idx) => - getRoutesWithSubMenu(route_config, idx) - )} - - )} {!is_trading_hub_category && ( { component: Account, getTitle: () => localize('Email and passwords'), }, + { + path: routes.passkeys, + component: Account, + getTitle: () => ( + <> + {localize('Passkeys')} + {localize('NEW')}! + + ), + }, { path: routes.self_exclusion, component: Account, @@ -443,7 +453,7 @@ const lazyLoadComplaintsPolicy = makeLazyLoader( // Order matters // TODO: search tag: test-route-parent-info -> Enable test for getting route parent info when there are nested routes -const initRoutesConfig = ({ is_appstore, is_eu_country }) => [ +const initRoutesConfig = ({ is_eu_country }) => [ { path: routes.index, component: RouterRedirect, getTitle: () => '', to: routes.root }, { path: routes.endpoint, component: Endpoint, getTitle: () => 'Endpoint' }, // doesn't need localization as it's for internal use { path: routes.redirect, component: Redirect, getTitle: () => localize('Redirect') }, @@ -454,7 +464,7 @@ const initRoutesConfig = ({ is_appstore, is_eu_country }) => [ icon_component: 'IcComplaintsPolicy', is_authenticated: true, }, - ...getModules({ is_appstore, is_eu_country }), + ...getModules({ is_eu_country }), ]; let routesConfig; @@ -463,9 +473,9 @@ let routesConfig; const route_default = { component: Page404, getTitle: () => localize('Error 404') }; // is_deriv_crypto = true as default to prevent route ui blinking -const getRoutesConfig = ({ is_appstore = true, is_eu_country }) => { +const getRoutesConfig = ({ is_eu_country }) => { if (!routesConfig) { - routesConfig = initRoutesConfig({ is_appstore, is_eu_country }); + routesConfig = initRoutesConfig({ is_eu_country }); routesConfig.push(route_default); } return routesConfig; diff --git a/packages/core/src/App/Containers/EffortlessLoginModal/__test__/effortless-login-modal.spec.tsx b/packages/core/src/App/Containers/EffortlessLoginModal/__test__/effortless-login-modal.spec.tsx new file mode 100644 index 000000000000..bc1c0c2234d9 --- /dev/null +++ b/packages/core/src/App/Containers/EffortlessLoginModal/__test__/effortless-login-modal.spec.tsx @@ -0,0 +1,149 @@ +import React from 'react'; +import { Router } from 'react-router-dom'; +import { createBrowserHistory } from 'history'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { routes } from '@deriv/shared'; +import { StoreProvider, mockStore } from '@deriv/stores'; +import EffortlessLoginModal from '../effortless-login-modal'; + +describe('EffortlessLoginModal', () => { + let modal_root_el: HTMLDivElement, mock_store: ReturnType; + + beforeEach(() => { + mock_store = mockStore({ + client: { + setShouldShowEffortlessLoginModal: jest.fn(), + }, + }); + }); + + beforeAll(() => { + modal_root_el = document.createElement('div'); + modal_root_el.setAttribute('id', 'effortless_modal_root'); + document.body.appendChild(modal_root_el); + Object.defineProperty(window, 'localStorage', { + value: { + getItem: jest.fn(), + setItem: jest.fn(), + }, + }); + }); + + afterAll(() => { + document.body.removeChild(modal_root_el); + }); + + const title = 'Effortless login with passkeys'; + const tips = [ + 'No need to remember a password', + 'Sync across devices', + 'Enhanced security with biometrics or screen lock', + ]; + const learn_more = /learn more about passkeys/i; + const descriptions = [ + 'What are passkeys?', + 'Why passkeys?', + 'How to create a passkey?', + 'Where are passkeys saved?', + 'What happens if my Deriv account email is changed?', + ]; + const tips_title = 'Tips:'; + + const mainScreenCheck = () => { + expect(screen.getByText(title)).toBeInTheDocument(); + tips.forEach(tip => { + expect(screen.getByText(tip)).toBeInTheDocument(); + }); + descriptions.forEach(description => { + expect(screen.queryByText(description)).not.toBeInTheDocument(); + }); + + expect(screen.getByText(learn_more)).toBeInTheDocument(); + expect(screen.queryByText(tips_title)).not.toBeInTheDocument(); + }; + + const learnMoreScreenCheck = () => { + expect(screen.getByText(title)).toBeInTheDocument(); + tips.forEach(tip => { + expect(screen.queryByText(tip)).not.toBeInTheDocument(); + }); + descriptions.forEach(description => { + expect(screen.getByText(description)).toBeInTheDocument(); + }); + expect(screen.queryByText(learn_more)).not.toBeInTheDocument(); + expect(screen.getByText(tips_title)).toBeInTheDocument(); + }; + + const componentRender = () => { + const history = createBrowserHistory(); + + render( + + + + + + ); + + return { history_object: history }; + }; + + it('should render EffortlessLoginModal and show "learn more" page', () => { + componentRender(); + + mainScreenCheck(); + const learn_more_link = screen.getByText(/here/i); + expect(learn_more_link).toBeInTheDocument(); + userEvent.click(learn_more_link); + learnMoreScreenCheck(); + expect(learn_more_link).not.toBeInTheDocument(); + const back_button = screen.getByTestId('effortless_login_modal__back-button'); + expect(back_button).toBeInTheDocument(); + userEvent.click(back_button); + mainScreenCheck(); + }); + + it('should leave EffortlessLoginModal', () => { + const { history_object } = componentRender(); + + mainScreenCheck(); + const maybe_later_link = screen.getByText(/maybe later/i); + expect(maybe_later_link).toBeInTheDocument(); + userEvent.click(maybe_later_link); + expect(history_object.location.pathname).toBe(routes.traders_hub); + expect(mock_store.client.setShouldShowEffortlessLoginModal).toHaveBeenCalled(); + expect(localStorage.setItem).toHaveBeenCalled(); + }); + + it('should leave EffortlessLoginModal from "learn more" screen', () => { + const { history_object } = componentRender(); + + mainScreenCheck(); + const learn_more_link = screen.getByText(/here/i); + expect(learn_more_link).toBeInTheDocument(); + userEvent.click(learn_more_link); + learnMoreScreenCheck(); + const get_started_button = screen.getByRole('button', { name: /get started/i }); + expect(get_started_button).toBeInTheDocument(); + userEvent.click(get_started_button); + expect(history_object.location.pathname).toBe(routes.passkeys); + expect(mock_store.client.setShouldShowEffortlessLoginModal).toHaveBeenCalled(); + expect(localStorage.setItem).toHaveBeenCalled(); + }); + + it('should not render EffortlessLoginModal if there is no portal', () => { + modal_root_el.setAttribute('id', ''); + + componentRender(); + + expect(screen.queryByText(title)).not.toBeInTheDocument(); + expect(screen.queryByText(learn_more)).not.toBeInTheDocument(); + tips.forEach(tip => { + expect(screen.queryByText(tip)).not.toBeInTheDocument(); + }); + descriptions.forEach(description => { + expect(screen.queryByText(description)).not.toBeInTheDocument(); + }); + }); +}); diff --git a/packages/core/src/App/Containers/EffortlessLoginModal/effortless-login-description.tsx b/packages/core/src/App/Containers/EffortlessLoginModal/effortless-login-description.tsx new file mode 100644 index 000000000000..3b789d7b4b3d --- /dev/null +++ b/packages/core/src/App/Containers/EffortlessLoginModal/effortless-login-description.tsx @@ -0,0 +1,98 @@ +import React from 'react'; +import { Icon, Text } from '@deriv/components'; +import { Localize } from '@deriv/translations'; + +const getPasskeysDescription = () => + [ + { + id: 1, + question: , + description: ( + + ), + }, + { + id: 2, + question: , + description: ( + + ), + }, + { + id: 3, + question: , + description: ( + + ), + }, + { + id: 4, + question: , + description: ( + + ), + }, + { + id: 5, + question: , + description: ( + + ), + }, + ] as const; + +const getPasskeysTips = () => + [ + { + id: 1, + description: , + }, + { + id: 2, + description: , + }, + { + id: 3, + description: , + }, + ] as const; + +export const EffortlessLoginDescription = () => { + const passkeys_descriptions = getPasskeysDescription(); + const tips = getPasskeysTips(); + + return ( + +
    + {passkeys_descriptions.map(({ id, question, description }) => ( +
    + + {question} + + + {description} + +
    + ))} +
    +
    + +
    + + + + + + + {tips.map(({ id, description }) => ( +
  • + + {description} + +
  • + ))} +
    +
    +
    + ); +}; diff --git a/packages/core/src/App/Containers/EffortlessLoginModal/effortless-login-modal.scss b/packages/core/src/App/Containers/EffortlessLoginModal/effortless-login-modal.scss new file mode 100644 index 000000000000..2c8cc8a142f0 --- /dev/null +++ b/packages/core/src/App/Containers/EffortlessLoginModal/effortless-login-modal.scss @@ -0,0 +1,93 @@ +.effortless-login-modal { + background: var(--general-main-1); + height: 100%; + width: 100%; + + &__header { + padding: 1.2rem 2.4rem; + border-bottom: 1px solid var(--border-disabled); + } + + &__back-button { + margin: 1.6rem; + } + + &__wrapper { + display: block; + margin-top: 1.6rem; + + .dc-icon { + display: block; + margin-inline: auto; + } + + .effortless-login-modal { + &__title { + margin: 2.4rem auto 1.6rem; + } + + &__overlay-tip { + padding-block: 1.6rem; + + &:not(:last-child) { + display: flex; + border-bottom: 1px solid var(--border-disabled); + gap: 0.8rem; + } + + .dc-icon { + margin-inline: unset; + } + } + + .dc-icon { + display: block; + margin-inline: auto; + } + + &__description { + &-container { + display: flex; + flex-direction: column; + + .effortless-login-modal__description-card { + display: flex; + flex-direction: column; + gap: 0.8rem; + border-bottom: 1px solid var(--general-hover); + padding: 1.6rem 0; + } + } + + &-tips { + &-wrapper { + display: flex; + padding: 1.6rem; + margin-top: 1.6rem; + border-radius: 2 * $BORDER_RADIUS; + border: 1px solid var(--border-normal); + gap: 0.8rem; + + svg { + flex-shrink: 0; + } + + .dc-icon { + margin: initial; + } + } + + &-container { + display: flex; + flex-direction: column; + align-items: flex-start; + + li::marker { + color: var(--text-general); + } + } + } + } + } + } +} diff --git a/packages/core/src/App/Containers/EffortlessLoginModal/effortless-login-modal.tsx b/packages/core/src/App/Containers/EffortlessLoginModal/effortless-login-modal.tsx new file mode 100644 index 000000000000..72627ac61039 --- /dev/null +++ b/packages/core/src/App/Containers/EffortlessLoginModal/effortless-login-modal.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import { useHistory } from 'react-router'; +import ReactDOM from 'react-dom'; +import FormFooter from '@deriv/account/src/Components/form-footer'; +import FormBody from '@deriv/account/src/Components/form-body'; +import { Button, Icon, Text } from '@deriv/components'; +import { routes } from '@deriv/shared'; +import { observer, useStore } from '@deriv/stores'; +import { Localize } from '@deriv/translations'; +import { EffortLessLoginTips } from './effortless-login-tips'; +import { EffortlessLoginDescription } from './effortless-login-description'; +import './effortless-login-modal.scss'; + +const EffortlessLoginModal = observer(() => { + const [is_learn_more_opened, setIsLearnMoreOpened] = React.useState(false); + const portal_element = document.getElementById('effortless_modal_root'); + const history = useHistory(); + const { client } = useStore(); + const { setShouldShowEffortlessLoginModal } = client; + + const onClickHandler = (route: string) => { + localStorage.setItem('show_effortless_login_modal', JSON.stringify(false)); + history.push(route); + setShouldShowEffortlessLoginModal(false); + }; + + if (!portal_element) return null; + return ReactDOM.createPortal( +
    + {is_learn_more_opened ? ( + setIsLearnMoreOpened(false)} + className='effortless-login-modal__back-button' + /> + ) : ( + onClickHandler(routes.traders_hub)} + > + + + )} + + + + + + + {is_learn_more_opened ? ( + + ) : ( + setIsLearnMoreOpened(true)} /> + )} + + + + +
    , + portal_element + ); +}); + +export default EffortlessLoginModal; diff --git a/packages/core/src/App/Containers/EffortlessLoginModal/effortless-login-tips.tsx b/packages/core/src/App/Containers/EffortlessLoginModal/effortless-login-tips.tsx new file mode 100644 index 000000000000..71b6fb5cfb23 --- /dev/null +++ b/packages/core/src/App/Containers/EffortlessLoginModal/effortless-login-tips.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { Icon, Text } from '@deriv/components'; +import { Localize } from '@deriv/translations'; + +const getEffortLessLoginTips = () => + [ + { + id: 1, + icon: 'IcFingerprintBold', + description: , + }, + { + id: 2, + icon: 'IcMobileDevice', + + description: , + }, + { + id: 3, + icon: 'IcLockBold', + description: , + }, + ] as const; + +export const EffortLessLoginTips = ({ onLearnMoreClick }: { onLearnMoreClick?: () => void }) => { + const tips = getEffortLessLoginTips(); + + return ( +
    + {tips.map(({ id, icon, description }) => ( +
    + + + {description} + +
    + ))} + + + ]} + /> + +
    + ); +}; diff --git a/packages/core/src/App/Containers/EffortlessLoginModal/index.ts b/packages/core/src/App/Containers/EffortlessLoginModal/index.ts new file mode 100644 index 000000000000..3f53b29808e5 --- /dev/null +++ b/packages/core/src/App/Containers/EffortlessLoginModal/index.ts @@ -0,0 +1,3 @@ +import EffortlessLoginModal from './effortless-login-modal'; + +export default EffortlessLoginModal; diff --git a/packages/core/src/App/Containers/Modals/app-modals.jsx b/packages/core/src/App/Containers/Modals/app-modals.jsx index 520551302eae..a1b099971f99 100644 --- a/packages/core/src/App/Containers/Modals/app-modals.jsx +++ b/packages/core/src/App/Containers/Modals/app-modals.jsx @@ -15,6 +15,7 @@ import MT5Notification from './mt5-notification'; import NeedRealAccountForCashierModal from './need-real-account-for-cashier-modal'; import ReadyToDepositModal from './ready-to-deposit-modal'; import RiskAcceptTestWarningModal from './risk-accept-test-warning-modal'; +import EffortlessLoginModal from '../EffortlessLoginModal'; const TradingAssessmentExistingUser = React.lazy(() => moduleLoader(() => @@ -89,8 +90,9 @@ const AppModals = observer(() => { landing_company_shortcode: active_account_landing_company, is_trading_experience_incomplete, mt5_login_list, + should_show_effortless_login_modal, } = client; - const { content_flag } = traders_hub; + const { content_flag, is_tour_open } = traders_hub; const { is_account_needed_modal_on, is_closing_create_real_account_modal, @@ -192,6 +194,11 @@ const AppModals = observer(() => { } else if (isUrlUnavailableModalVisible) { ComponentToLoad = ; } + + if (should_show_effortless_login_modal && !is_tour_open) { + ComponentToLoad = ; + } + if (is_ready_to_deposit_modal_visible) { ComponentToLoad = ; } diff --git a/packages/core/src/Stores/client-store.js b/packages/core/src/Stores/client-store.js index d05d6ca0fe9a..c5446bfa975c 100644 --- a/packages/core/src/Stores/client-store.js +++ b/packages/core/src/Stores/client-store.js @@ -1,6 +1,7 @@ import Cookies from 'js-cookie'; import { action, computed, makeObservable, observable, reaction, runInAction, toJS, when } from 'mobx'; import moment from 'moment'; +import { browserSupportsWebAuthn } from '@simplewebauthn/browser'; import { CFD_PLATFORMS, @@ -152,6 +153,9 @@ export default class ClientStore extends BaseStore { real_account_signup_form_data = []; real_account_signup_form_step = 0; + is_passkey_supported = false; + should_show_effortless_login_modal = false; + constructor(root_store) { const local_storage_properties = ['device_data']; super({ root_store, local_storage_properties, store_name }); @@ -219,6 +223,8 @@ export default class ClientStore extends BaseStore { is_p2p_enabled: observable, real_account_signup_form_data: observable, real_account_signup_form_step: observable, + is_passkey_supported: observable, + should_show_effortless_login_modal: observable, balance: computed, account_open_date: computed, is_svg: computed, @@ -393,6 +399,9 @@ export default class ClientStore extends BaseStore { setIsP2PEnabled: action.bound, setRealAccountSignupFormData: action.bound, setRealAccountSignupFormStep: action.bound, + setIsPasskeySupported: action.bound, + setShouldShowEffortlessLoginModal: action.bound, + fetchShouldShowEffortlessLoginModal: action.bound, }); reaction( @@ -1642,6 +1651,8 @@ export default class ClientStore extends BaseStore { await this.fetchResidenceList(); await this.getTwoFAStatus(); + await this.setIsPasskeySupported(); + await this.fetchShouldShowEffortlessLoginModal(); if (this.account_settings && !this.account_settings.residence) { this.root_store.ui.toggleSetResidenceModal(true); } @@ -2004,6 +2015,7 @@ export default class ClientStore extends BaseStore { this.landing_companies = {}; localStorage.removeItem('readScamMessage'); localStorage.removeItem('isNewAccount'); + localStorage.removeItem('show_effortless_login_modal'); LocalStore.set('marked_notifications', JSON.stringify([])); localStorage.setItem('active_loginid', this.loginid); localStorage.setItem('active_user_id', this.user_id); @@ -2635,4 +2647,39 @@ export default class ClientStore extends BaseStore { setRealAccountSignupFormStep(step) { this.real_account_signup_form_step = step; } + + async setIsPasskeySupported() { + try { + // TODO: replace later "Analytics?.isFeatureOn()" to "Analytics?.getFeatureValue()" + const is_passkeys_enabled = Analytics?.isFeatureOn('web_passkeys'); + const is_passkeys_enabled_on_be = Analytics?.isFeatureOn('service_passkeys'); + // "browserSupportsWebAuthn" does not consider, if platform authenticator is available (unlike "platformAuthenticatorIsAvailable()") + const is_supported_by_browser = await browserSupportsWebAuthn(); + this.is_passkey_supported = is_passkeys_enabled && is_supported_by_browser && is_passkeys_enabled_on_be; + } catch (e) { + //error handling needed + } + } + + setShouldShowEffortlessLoginModal(should_show_effortless_login_modal = true) { + this.should_show_effortless_login_modal = should_show_effortless_login_modal; + } + async fetchShouldShowEffortlessLoginModal() { + const stored_value = localStorage.getItem('show_effortless_login_modal'); + const show_effortless_login_modal = stored_value === null || JSON.parse(stored_value) === true; + if (show_effortless_login_modal) { + localStorage.setItem('show_effortless_login_modal', JSON.stringify(true)); + } + + const data = await WS.send({ passkeys_list: 1 }); + + const should_show_effortless_login_modal = + this.root_store.ui.is_mobile && + !data?.passkeys_list.length && + this.is_passkey_supported && + show_effortless_login_modal && + this.is_logged_in; + + this.setShouldShowEffortlessLoginModal(should_show_effortless_login_modal); + } } diff --git a/packages/core/src/index.html b/packages/core/src/index.html index e06deed4412f..1cfaf84dcedc 100644 --- a/packages/core/src/index.html +++ b/packages/core/src/index.html @@ -245,6 +245,7 @@
    + diff --git a/packages/core/src/public/.well-known/apple-app-site-association b/packages/core/src/public/.well-known/apple-app-site-association index d9fcf403fb5c..8dac312fd9c2 100644 --- a/packages/core/src/public/.well-known/apple-app-site-association +++ b/packages/core/src/public/.well-known/apple-app-site-association @@ -1,50 +1,69 @@ { + "appclips": { + "apps": [] + }, "applinks": { "details": [ { "appID": "36S5Q8S4V5.com.deriv.app", "paths": [ - "/redirect/derivgo" + "/redirect" ] }, { "appID": "36S5Q8S4V5.com.deriv.app.dev", "paths": [ - "/redirect/derivgo" + "/redirect" ] }, { "appID": "36S5Q8S4V5.com.deriv.app.staging", "paths": [ - "/redirect/derivgo" + "/redirect" ] }, { "appID": "36S5Q8S4V5.com.deriv.dp2p", "paths": [ - "/cashier/p2p", - "/cashier/p2p/advertiser", - "/redirect/p2p" + "/cashier/p2p" + ] + }, + { + "appID": "36S5Q8S4V5.com.deriv.sample", + "paths": [ + "/redirect" ] }, { "appID": "36S5Q8S4V5.com.deriv.blanc", "paths": [ - "/redirect/dblanc" + "/redirect" ] }, { "appID": "36S5Q8S4V5.com.deriv.blanc.dev", "paths": [ - "/redirect/dblanc" + "/redirect" ] }, { "appID": "36S5Q8S4V5.com.deriv.blanc.stg", "paths": [ - "/redirect/dblanc" + "/redirect" ] } ] + }, + "webcredentials": { + "apps": [ + "36S5Q8S4V5.com.deriv.app", + "36S5Q8S4V5.com.deriv.app.dev", + "36S5Q8S4V5.com.deriv.app.staging", + "36S5Q8S4V5.com.deriv.dp2p", + "36S5Q8S4V5.com.deriv.blanc", + "36S5Q8S4V5.com.deriv.blanc.dev", + "36S5Q8S4V5.com.deriv.blanc.stg", + "36S5Q8S4V5.com.deriv.sample" + ] } -} +} \ No newline at end of file diff --git a/packages/core/src/public/.well-known/assetslinks.json b/packages/core/src/public/.well-known/assetslinks.json index 0b97dcfb83c9..8006ccc07913 100644 --- a/packages/core/src/public/.well-known/assetslinks.json +++ b/packages/core/src/public/.well-known/assetslinks.json @@ -1,71 +1,116 @@ -[{ - "relation": ["delegate_permission/common.handle_all_urls"], - "target" : { - "namespace": "android_app", - "package_name": "com.deriv.app", - "sha256_cert_fingerprints": [ - "9B:BD:04:CB:27:B5:FE:59:30:0E:3C:5C:F6:AC:79:52:F3:36:04:7A:FD:A4:59:82:CC:C8:90:3C:74:0E:22:0C", - "60:89:73:84:71:F6:55:AA:2A:0E:23:94:60:84:75:12:A7:86:E1:AE:85:E2:5C:8C:3C:89:5B:51:EA:DB:75:D0" - ] - } -}, { - "relation": ["delegate_permission/common.handle_all_urls"], - "target" : { - "namespace": "android_app", - "package_name": "com.deriv.app.dev", - "sha256_cert_fingerprints": [ - "9B:BD:04:CB:27:B5:FE:59:30:0E:3C:5C:F6:AC:79:52:F3:36:04:7A:FD:A4:59:82:CC:C8:90:3C:74:0E:22:0C", - "60:89:73:84:71:F6:55:AA:2A:0E:23:94:60:84:75:12:A7:86:E1:AE:85:E2:5C:8C:3C:89:5B:51:EA:DB:75:D0" - ] - } -}, { - "relation": ["delegate_permission/common.handle_all_urls"], - "target" : { - "namespace": "android_app", - "package_name": "com.deriv.app.staging", - "sha256_cert_fingerprints": [ - "9B:BD:04:CB:27:B5:FE:59:30:0E:3C:5C:F6:AC:79:52:F3:36:04:7A:FD:A4:59:82:CC:C8:90:3C:74:0E:22:0C", - "60:89:73:84:71:F6:55:AA:2A:0E:23:94:60:84:75:12:A7:86:E1:AE:85:E2:5C:8C:3C:89:5B:51:EA:DB:75:D0" - ] - } -}, { - "relation": ["delegate_permission/common.handle_all_urls"], - "target" : { - "namespace": "android_app", - "package_name": "com.deriv.dp2p", - "sha256_cert_fingerprints": [ - "58:A3:D1:77:A0:5A:BE:73:76:B8:90:29:1B:D3:BD:E9:DF:ab:FF:3B:52:B4:15:6E:FA:A9:68:91:3F:6D:DE:78", - "E7:45:0A:69:4F:0D:84:43:A8:E1:E0:3D:9D:00:E2:E5:8F:84:9F:81:B1:8F:94:A6:F9:0E:6A:D9:F6:52:13:EB" - ] - } -}, { - "relation": ["delegate_permission/common.handle_all_urls"], - "target": { +[ + { + "relation": [ + "delegate_permission/common.handle_all_urls", + "delegate_permission/common.get_login_creds" + ], + "target": { + "namespace": "android_app", + "package_name": "com.deriv.app", + "sha256_cert_fingerprints": [ + "9B:BD:04:CB:27:B5:FE:59:30:0E:3C:5C:F6:AC:79:52:F3:36:04:7A:FD:A4:59:82:CC:C8:90:3C:74:0E:22:0C", + "60:89:73:84:71:F6:55:AA:2A:0E:23:94:60:84:75:12:A7:86:E1:AE:85:E2:5C:8C:3C:89:5B:51:EA:DB:75:D0", + "1E:FD:D1:99:FE:B1:CA:45:F9:7D:AC:65:53:E3:2C:B1:62:3F:EF:BD:3A:DD:60:0D:8E:D2:CE:4A:39:E5:9F:DC" + ] + } + }, + { + "relation": [ + "delegate_permission/common.handle_all_urls", + "delegate_permission/common.get_login_creds" + ], + "target": { + "namespace": "android_app", + "package_name": "com.deriv.app.dev", + "sha256_cert_fingerprints": [ + "9B:BD:04:CB:27:B5:FE:59:30:0E:3C:5C:F6:AC:79:52:F3:36:04:7A:FD:A4:59:82:CC:C8:90:3C:74:0E:22:0C", + "60:89:73:84:71:F6:55:AA:2A:0E:23:94:60:84:75:12:A7:86:E1:AE:85:E2:5C:8C:3C:89:5B:51:EA:DB:75:D0", + "1E:FD:D1:99:FE:B1:CA:45:F9:7D:AC:65:53:E3:2C:B1:62:3F:EF:BD:3A:DD:60:0D:8E:D2:CE:4A:39:E5:9F:DC" + ] + } + }, + { + "relation": [ + "delegate_permission/common.handle_all_urls", + "delegate_permission/common.get_login_creds" + ], + "target": { + "namespace": "android_app", + "package_name": "com.deriv.app.staging", + "sha256_cert_fingerprints": [ + "9B:BD:04:CB:27:B5:FE:59:30:0E:3C:5C:F6:AC:79:52:F3:36:04:7A:FD:A4:59:82:CC:C8:90:3C:74:0E:22:0C", + "60:89:73:84:71:F6:55:AA:2A:0E:23:94:60:84:75:12:A7:86:E1:AE:85:E2:5C:8C:3C:89:5B:51:EA:DB:75:D0", + "1E:FD:D1:99:FE:B1:CA:45:F9:7D:AC:65:53:E3:2C:B1:62:3F:EF:BD:3A:DD:60:0D:8E:D2:CE:4A:39:E5:9F:DC" + ] + } + }, + { + "relation": [ + "delegate_permission/common.handle_all_urls", + "delegate_permission/common.get_login_creds" + ], + "target": { + "namespace": "android_app", + "package_name": "com.deriv.dp2p", + "sha256_cert_fingerprints": [ + "58:A3:D1:77:A0:5A:BE:73:76:B8:90:29:1B:D3:BD:E9:DF:ab:FF:3B:52:B4:15:6E:FA:A9:68:91:3F:6D:DE:78", + "E7:45:0A:69:4F:0D:84:43:A8:E1:E0:3D:9D:00:E2:E5:8F:84:9F:81:B1:8F:94:A6:F9:0E:6A:D9:F6:52:13:EB" + ] + } + }, + { + "relation": [ + "delegate_permission/common.handle_all_urls", + "delegate_permission/common.get_login_creds" + ], + "target": { "namespace": "android_app", "package_name": "com.deriv.blanc", "sha256_cert_fingerprints": [ - "25:F5:1D:C4:37:2C:14:59:9E:91:0A:FA:F8:8B:28:F9:D3:E9:E8:E2:81:CD:B5:58:AF:0E:2D:C2:E4:CF:F9:EB", - "C7:0D:02:85:4B:B8:49:DE:CA:72:3E:E1:A3:0B:8A:47:31:0F:79:5C:34:1B:84:CE:92:73:29:73:DB:66:85:8D" + "25:F5:1D:C4:37:2C:14:59:9E:91:0A:FA:F8:8B:28:F9:D3:E9:E8:E2:81:cd:B5:58:AF:0E:2D:C2:E4:CF:F9:EB", + "C7:0D:02:85:4B:B8:49:de:CA:72:3E:E1:A3:0B:8A:47:31:0F:79:5C:34:1B:84:CE:92:73:29:73:DB:66:85:8D" ] - } -}, { - "relation": ["delegate_permission/common.handle_all_urls"], - "target": { + } + }, + { + "relation": [ + "delegate_permission/common.handle_all_urls", + "delegate_permission/common.get_login_creds" + ], + "target": { "namespace": "android_app", "package_name": "com.deriv.blanc.dev", "sha256_cert_fingerprints": [ - "25:F5:1D:C4:37:2C:14:59:9E:91:0A:FA:F8:8B:28:F9:D3:E9:E8:E2:81:CD:B5:58:AF:0E:2D:C2:E4:CF:F9:EB", - "C7:0D:02:85:4B:B8:49:DE:CA:72:3E:E1:A3:0B:8A:47:31:0F:79:5C:34:1B:84:CE:92:73:29:73:DB:66:85:8D" + "25:F5:1D:C4:37:2C:14:59:9E:91:0A:FA:F8:8B:28:F9:D3:E9:E8:E2:81:cd:B5:58:AF:0E:2D:C2:E4:CF:F9:EB", + "C7:0D:02:85:4B:B8:49:de:CA:72:3E:E1:A3:0B:8A:47:31:0F:79:5C:34:1B:84:CE:92:73:29:73:DB:66:85:8D" ] - } -}, { - "relation": ["delegate_permission/common.handle_all_urls"], - "target": { + } + }, + { + "relation": [ + "delegate_permission/common.handle_all_urls", + "delegate_permission/common.get_login_creds" + ], + "target": { "namespace": "android_app", "package_name": "com.deriv.blanc.stg", "sha256_cert_fingerprints": [ - "25:F5:1D:C4:37:2C:14:59:9E:91:0A:FA:F8:8B:28:F9:D3:E9:E8:E2:81:CD:B5:58:AF:0E:2D:C2:E4:CF:F9:EB", - "C7:0D:02:85:4B:B8:49:DE:CA:72:3E:E1:A3:0B:8A:47:31:0F:79:5C:34:1B:84:CE:92:73:29:73:DB:66:85:8D" + "25:F5:1D:C4:37:2C:14:59:9E:91:0A:FA:F8:8B:28:F9:D3:E9:E8:E2:81:cd:B5:58:AF:0E:2D:C2:E4:CF:F9:EB", + "C7:0D:02:85:4B:B8:49:de:CA:72:3E:E1:A3:0B:8A:47:31:0F:79:5C:34:1B:84:CE:92:73:29:73:DB:66:85:8D" + ] + } + }, + { + "relation": [ + "delegate_permission/common.handle_all_urls", + "delegate_permission/common.get_login_creds" + ], + "target": { + "namespace": "android_app", + "package_name": "com.example.passkeys_poc", + "sha256_cert_fingerprints": [ + "71:52:29:88:59:88:47:10:1A:F6:82:F9:B6:5B:BE:E9:D4:3F:7F:B7:FB:33:E1:9D:D7:5C:22:5E:33:17:2D:4F" ] + } } -}] +] \ No newline at end of file diff --git a/packages/hooks/package.json b/packages/hooks/package.json index 1538ca747a63..44de881d87db 100644 --- a/packages/hooks/package.json +++ b/packages/hooks/package.json @@ -10,6 +10,8 @@ "@deriv/stores": "^1.0.0", "@deriv/utils": "^1.0.0", "@deriv/shared": "^1.0.0", + "@simplewebauthn/browser": "^8.3.4", + "@simplewebauthn/typescript-types": "^8.3.4", "react": "^17.0.2" }, "devDependencies": { diff --git a/packages/hooks/src/__tests__/useGetPasskeysList.spec.tsx b/packages/hooks/src/__tests__/useGetPasskeysList.spec.tsx new file mode 100644 index 000000000000..764ed33192f2 --- /dev/null +++ b/packages/hooks/src/__tests__/useGetPasskeysList.spec.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { renderHook } from '@testing-library/react-hooks'; +import { useQuery } from '@deriv/api'; +import useGetPasskeysList from '../useGetPasskeysList'; +import { mockStore, StoreProvider } from '@deriv/stores'; + +jest.mock('@deriv/api', () => ({ + ...jest.requireActual('@deriv/api'), + useQuery: jest.fn(), +})); + +describe('useGetPasskeysList', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const mock = mockStore({ + client: { is_passkey_supported: true, is_logged_in: true }, + }); + + 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: [] } }); + + const { result } = renderHook(() => useGetPasskeysList(), { wrapper }); + + expect(useQuery).toHaveBeenCalledWith('passkeys_list', { options: { enabled: true } }); + expect(result.current.passkeys_list).toEqual([]); + }); + it('calls useQuery with enabled set to false when is_logged_in is false', () => { + (useQuery as jest.Mock).mockReturnValue({ data: { passkeys_list: undefined } }); + mock.client.is_logged_in = false; + + const { result } = renderHook(() => useGetPasskeysList(), { wrapper }); + + expect(useQuery).toHaveBeenCalledWith('passkeys_list', { options: { enabled: false } }); + expect(result.current.passkeys_list).toEqual(undefined); + }); + 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; + mock.client.is_logged_in = true; + + const { result } = renderHook(() => useGetPasskeysList(), { wrapper }); + + expect(useQuery).toHaveBeenCalledWith('passkeys_list', { options: { enabled: false } }); + expect(result.current.passkeys_list).toEqual(undefined); + }); +}); diff --git a/packages/hooks/src/__tests__/useRegisterPasskey.spec.tsx b/packages/hooks/src/__tests__/useRegisterPasskey.spec.tsx new file mode 100644 index 000000000000..c94d83bd9269 --- /dev/null +++ b/packages/hooks/src/__tests__/useRegisterPasskey.spec.tsx @@ -0,0 +1,155 @@ +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 useRegisterPasskey from '../useRegisterPasskey'; +import { startRegistration } from '@simplewebauthn/browser'; + +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: { + send: jest.fn(), + }, +})); + +describe('useRegisterPasskey', () => { + const wrapper = ({ children }: { children: JSX.Element }) => {children}; + + const ws_error = { message: 'Test connection error' }; + const authenticator_error = { message: 'Test authenticator error' }; + + beforeEach(() => { + (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 }); + + expect(result.current.is_passkey_registered).toBe(false); + + await act(async () => { + await result.current.startPasskeyRegistration(); + }); + + expect(WS.send).toHaveBeenCalledWith({ passkeys_register_options: 1 }); + + (WS.send as jest.Mock).mockResolvedValue({ passkeys_register: { properties: { name: 'test passkey name' } } }); + + expect(result.current.is_passkey_registration_started).toBe(true); + + await act(async () => { + await result.current.createPasskey(); + }); + + expect(WS.send).toHaveBeenCalledWith({ + passkeys_register: 1, + publicKeyCredential: 'authenticator_response', + }); + + expect(mockInvalidate).toHaveBeenCalled(); + expect(result.current.is_passkey_registered).toBe(true); + }); + + it('should start passkey registration and cancel', async () => { + const { result } = renderHook(() => useRegisterPasskey(), { wrapper }); + + expect(result.current.is_passkey_registered).toBe(false); + + await act(async () => { + await result.current.startPasskeyRegistration(); + }); + + expect(WS.send).toHaveBeenCalledWith({ passkeys_register_options: 1 }); + + (WS.send as jest.Mock).mockResolvedValue({ passkeys_register: { properties: { name: 'test passkey name' } } }); + + expect(result.current.is_passkey_registration_started).toBe(true); + + await act(async () => { + result.current.cancelPasskeyRegistration(); + }); + + expect(result.current.is_passkey_registration_started).toBe(false); + }); + + it('should handle passkey registration error', async () => { + (WS.send as jest.Mock).mockRejectedValue(ws_error); + + const { result } = renderHook(() => useRegisterPasskey(), { 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); + }); + + it('should handle passkey creation error', async () => { + const { result } = renderHook(() => useRegisterPasskey(), { wrapper }); + + await act(async () => { + await result.current.startPasskeyRegistration(); + }); + + expect(WS.send).toHaveBeenCalledWith({ passkeys_register_options: 1 }); + + expect(result.current.passkey_registration_error).toBe(null); + + (WS.send as jest.Mock).mockRejectedValue(ws_error); + + await act(async () => { + await result.current.createPasskey(); + }); + + expect(result.current.passkey_registration_error).toBe(ws_error); + + await act(async () => { + result.current.clearPasskeyRegistrationError(); + }); + + expect(result.current.passkey_registration_error).toBe(null); + }); + + it('should handle passkey creation authenticator error', async () => { + const { result } = renderHook(() => useRegisterPasskey(), { wrapper }); + + await act(async () => { + await result.current.startPasskeyRegistration(); + }); + + expect(WS.send).toHaveBeenCalledWith({ passkeys_register_options: 1 }); + + expect(result.current.passkey_registration_error).toBe(null); + + (startRegistration as jest.Mock).mockRejectedValue(authenticator_error); + + await act(async () => { + await result.current.createPasskey(); + }); + + expect(result.current.passkey_registration_error).toBe(authenticator_error); + + await act(async () => { + result.current.clearPasskeyRegistrationError(); + }); + + expect(result.current.passkey_registration_error).toBe(null); + }); +}); diff --git a/packages/hooks/src/index.ts b/packages/hooks/src/index.ts index cbff125d1aec..b789529cd7aa 100644 --- a/packages/hooks/src/index.ts +++ b/packages/hooks/src/index.ts @@ -23,6 +23,7 @@ export { default as useFeatureFlags } from './useFeatureFlags'; export { default as useFiatAccountList } from './useFiatAccountList'; export { default as useFileUploader } from './useFileUploader'; export { default as useGetMFAccountStatus } from './useGetMFAccountStatus'; +export { default as useGetPasskeysList } from './useGetPasskeysList'; export { default as useHasActiveRealAccount } from './useHasActiveRealAccount'; export { default as useHasCryptoCurrency } from './useHasCryptoCurrency'; export { default as useHasFiatCurrency } from './useHasFiatCurrency'; @@ -65,6 +66,7 @@ export { default as usePlatformAccounts } from './usePlatformAccounts'; 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 useServiceToken } from './useServiceToken'; export { default as useStatesList } from './useStatesList'; export { default as useStoreLinkedWalletsAccounts } from './useStoreLinkedWalletsAccounts'; diff --git a/packages/hooks/src/useGetPasskeysList.ts b/packages/hooks/src/useGetPasskeysList.ts new file mode 100644 index 000000000000..8091fb50fd95 --- /dev/null +++ b/packages/hooks/src/useGetPasskeysList.ts @@ -0,0 +1,23 @@ +import { useQuery } from '@deriv/api'; +import { useStore } from '@deriv/stores'; + +const useGetPasskeysList = () => { + const { client, ui } = useStore(); + const { is_passkey_supported, is_logged_in } = client; + + const { data, error, isLoading, refetch, ...rest } = useQuery('passkeys_list', { + options: { + enabled: is_passkey_supported && is_logged_in, + }, + }); + + return { + passkeys_list: data?.passkeys_list, + passkeys_list_error: error?.error ?? null, + reloadPasskeysList: refetch, + is_passkeys_list_loading: isLoading, + ...rest, + }; +}; + +export default useGetPasskeysList; diff --git a/packages/hooks/src/useRegisterPasskey.ts b/packages/hooks/src/useRegisterPasskey.ts new file mode 100644 index 000000000000..3410960a8726 --- /dev/null +++ b/packages/hooks/src/useRegisterPasskey.ts @@ -0,0 +1,70 @@ +import React from 'react'; +import { startRegistration } from '@simplewebauthn/browser'; +import { PublicKeyCredentialCreationOptionsJSON } from '@simplewebauthn/typescript-types'; +import { useInvalidateQuery } from '@deriv/api'; +import { WS } from '@deriv/shared'; + +type TError = { code?: string; name?: string; message: string }; + +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_registration_started, setIsPasskeyRegistrationStarted] = React.useState(false); + 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 cancelPasskeyRegistration = () => setIsPasskeyRegistrationStarted(false); + + const startPasskeyRegistration = async () => { + try { + setIsPasskeyRegistrationStarted(true); + const passkeys_register_options_response = await WS.send({ passkeys_register_options: 1 }); + const public_key = passkeys_register_options_response?.passkeys_register_options?.publicKey; + setPublicKey(public_key); + } catch (e) { + setIsPasskeyRegistrationStarted(false); + setPasskeyRegistrationError(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); + } + } + } catch (e) { + if (!excluded_error_names.some(name => name === (e as TError).name)) { + setPasskeyRegistrationError(e as TError); + } + } finally { + setIsPasskeyRegistrationStarted(false); + setPublicKey(null); + } + }; + + return { + cancelPasskeyRegistration, + clearPasskeyRegistrationError, + createPasskey, + is_passkey_registration_started, + is_passkey_registered, + passkey_registration_error, + startPasskeyRegistration, + }; +}; + +export default useRegisterPasskey; diff --git a/packages/p2p/jest.config.js b/packages/p2p/jest.config.js index 42449c34a5a4..c49733a3d7d4 100644 --- a/packages/p2p/jest.config.js +++ b/packages/p2p/jest.config.js @@ -32,5 +32,5 @@ module.exports = { '/coverage/lcov-report', '/dist', ], - transformIgnorePatterns: ['/node_modules/(?!@sendbird/chat).+\\.js$'], + transformIgnorePatterns: ['/node_modules/(?!(@sendbird/chat|@simplewebauthn/browser)).+\\.js$'], }; diff --git a/packages/shared/src/utils/array/array.ts b/packages/shared/src/utils/array/array.ts index 15b2faf7a2dc..d1beef799052 100644 --- a/packages/shared/src/utils/array/array.ts +++ b/packages/shared/src/utils/array/array.ts @@ -10,3 +10,6 @@ export const shuffleArray = (array: T[]): T[] => { } return array; }; + +// @ts-expect-error as the generic is a Array +export const flatten = >(arr: T) => [].concat(...arr); diff --git a/packages/shared/src/utils/date/date-time.ts b/packages/shared/src/utils/date/date-time.ts index 09d006e18743..9098805db8ad 100644 --- a/packages/shared/src/utils/date/date-time.ts +++ b/packages/shared/src/utils/date/date-time.ts @@ -1,4 +1,4 @@ -import { localize } from '@deriv/translations'; +import { getLanguage, localize } from '@deriv/translations'; import moment from 'moment'; type TExtendedMoment = typeof moment & { @@ -51,6 +51,12 @@ export const toMoment = (value?: moment.MomentInput): moment.Moment => { }; export const toLocalFormat = (time: moment.MomentInput) => moment.utc(time).local().format('YYYY-MM-DD HH:mm:ss Z'); +export const getLongDate = (time: number): string => { + moment.locale(getLanguage().toLowerCase()); + //need to divide to 1000 as timestamp coming from BE is in ms + return moment.unix(time / 1000).format('MMMM Do, YYYY'); +}; + /** * Set specified time on moment object * @param {moment} moment_obj the moment to set the time on diff --git a/packages/shared/src/utils/object/__tests__/object.spec.ts b/packages/shared/src/utils/object/__tests__/object.spec.ts index 1ae50e0445a6..d9b676287c2d 100644 --- a/packages/shared/src/utils/object/__tests__/object.spec.ts +++ b/packages/shared/src/utils/object/__tests__/object.spec.ts @@ -215,4 +215,46 @@ describe('Utility', () => { }); }); }); + describe('deepCopy', () => { + it('should create a deep copy of an array', () => { + const originalArray = [1, 2, [3, 4], { a: 5 }]; + const copiedArray = Utility.deepCopy(originalArray); + + // Check if the values are equal + expect(copiedArray).toEqual(originalArray); + + // Check if the reference is different + expect(copiedArray).not.toBe(originalArray); + + // Check if nested arrays/objects are also deep-copied + expect(copiedArray[2]).not.toBe(originalArray[2]); + expect(copiedArray[3]).not.toBe(originalArray[3]); + }); + + it('should create a deep copy of an object', () => { + const originalObject = { a: 1, b: { c: 2 }, d: [3, 4] }; + const copiedObject = Utility.deepCopy(originalObject); + + // Check if the values are equal + expect(copiedObject).toEqual(originalObject); + + // Check if the reference is different + expect(copiedObject).not.toBe(originalObject); + + // Check if nested arrays/objects are also deep-copied + expect(copiedObject.b).not.toBe(originalObject.b); + expect(copiedObject.d).not.toBe(originalObject.d); + }); + + it('should return primitive values unchanged', () => { + const primitiveValue = 42; + const copiedValue = Utility.deepCopy(primitiveValue); + + // Check if the values are equal + expect(copiedValue).toEqual(primitiveValue); + + // Check if the reference is the same for primitive values + expect(copiedValue).toBe(primitiveValue); + }); + }); }); diff --git a/packages/shared/src/utils/object/object.ts b/packages/shared/src/utils/object/object.ts index 9ff8296f1e4b..486d91b71d37 100644 --- a/packages/shared/src/utils/object/object.ts +++ b/packages/shared/src/utils/object/object.ts @@ -119,3 +119,25 @@ export const deepFreeze = (obj: any) => { }); return Object.freeze(obj); }; + +export const deepCopy = (value: any) => { + if (Array.isArray(value)) { + const count = value.length; + const arr = new Array(count); + for (let i = 0; i < count; i++) { + arr[i] = deepCopy(value[i]); + } + + return arr; + } else if (typeof value === 'object') { + const obj: { [key: string]: any } = {}; + for (let prop in value) { + obj[prop] = deepCopy(value[prop]); + } + + return obj; + } else { + // Primitive value + return value; + } +}; diff --git a/packages/shared/src/utils/route/route.ts b/packages/shared/src/utils/route/route.ts deleted file mode 100644 index de927db7d182..000000000000 --- a/packages/shared/src/utils/route/route.ts +++ /dev/null @@ -1,34 +0,0 @@ -// Checks if pathname matches route. (Works even with query string /?) - -// TODO: Add test cases for this -export type TRoute = { - component?: React.ElementType | null; - default?: boolean; - exact?: boolean; - getTitle?: () => string; - icon_component?: string; - id?: string; - is_authenticated?: boolean; - is_invisible?: boolean; - path?: string; - to?: string; -}; - -type TGetSelectedRoute = { - routes: TRoute[]; - pathname: string; -}; - -// @ts-expect-error as this is a utility function with dynamic types -export const matchRoute = (route: T, pathname: string) => new RegExp(`^${route?.path}(/.*)?$`).test(pathname); - -export const getSelectedRoute = ({ routes, pathname }: TGetSelectedRoute) => { - const matching_route = routes.find(route => matchRoute(route, pathname)); - if (!matching_route) { - return routes.find(route => route.default) || routes[0] || null; - } - return matching_route; -}; - -export const isRouteVisible = (route: TRoute, is_logged_in: boolean) => - !(route && route.is_authenticated && !is_logged_in); diff --git a/packages/shared/src/utils/route/route.tsx b/packages/shared/src/utils/route/route.tsx new file mode 100644 index 000000000000..9d7272d0f095 --- /dev/null +++ b/packages/shared/src/utils/route/route.tsx @@ -0,0 +1,57 @@ +// Checks if pathname matches route. (Works even with query string /?) + +// TODO: Add test cases for this +import React from 'react'; +import { Redirect } from 'react-router-dom'; +import { routes } from '../routes'; + +export type TRoute = Partial<{ + component: React.ElementType | null | ((routes?: TRoute[]) => JSX.Element) | typeof Redirect; + default: boolean; + exact: boolean; + getTitle: () => string; + icon_component: string; + id: string; + is_authenticated: boolean; + is_invisible: boolean; + path: string; + to: string; + icon: string; + is_disabled: boolean; + subroutes: TRoute[]; + routes: TRoute[]; +}>; + +type TGetSelectedRoute = { + routes: TRoute[]; + pathname: string; +}; + +// @ts-expect-error as this is a utility function with dynamic types +export const matchRoute = (route: T, pathname: string) => new RegExp(`^${route?.path}(/.*)?$`).test(pathname); + +export const getSelectedRoute = ({ routes, pathname }: TGetSelectedRoute) => { + const matching_route = routes.find(route => matchRoute(route, pathname)); + if (!matching_route) { + return routes.find(route => route.default) || routes[0] || null; + } + return matching_route; +}; + +export const isRouteVisible = (route: TRoute, is_logged_in: boolean) => + !(route && route.is_authenticated && !is_logged_in); + +export const removeExactRouteFromRoutes = (routes_array: TRoute[], route_to_remove: keyof typeof routes) => { + return routes_array.filter((route: TRoute) => { + if (route.path === routes[route_to_remove]) { + return false; + } + if (route.routes) { + route.routes = removeExactRouteFromRoutes(route.routes, route_to_remove); + } + if (route.subroutes) { + route.subroutes = removeExactRouteFromRoutes(route.subroutes, route_to_remove); + } + return true; + }); +}; diff --git a/packages/shared/src/utils/routes/routes.ts b/packages/shared/src/utils/routes/routes.ts index bf85d108e996..cbedeee0aba5 100644 --- a/packages/shared/src/utils/routes/routes.ts +++ b/packages/shared/src/utils/routes/routes.ts @@ -12,6 +12,7 @@ export const routes = { proof_of_ownership: '/account/proof-of-ownership', proof_of_income: '/account/proof-of-income', passwords: '/account/passwords', + passkeys: '/account/passkeys', closing_account: '/account/closing-account', deactivate_account: '/account/deactivate-account', // TODO: Remove once mobile team has changed this link account_closed: '/account-closed', diff --git a/packages/stores/src/mockStore.ts b/packages/stores/src/mockStore.ts index e7f5361026fc..6d7416dd005b 100644 --- a/packages/stores/src/mockStore.ts +++ b/packages/stores/src/mockStore.ts @@ -301,6 +301,11 @@ const mock = (): TStores & { is_mock: boolean } => { real_account_signup_form_step: 0, setRealAccountSignupFormData: jest.fn(), setRealAccountSignupFormStep: jest.fn(), + is_passkey_supported: false, + should_show_effortless_login_modal: false, + setIsPasskeySupported: jest.fn(), + setShouldShowEffortlessLoginModal: jest.fn(), + fetchShouldShowEffortlessLoginModal: jest.fn(), }, common: { error: common_store_error, diff --git a/packages/stores/types.ts b/packages/stores/types.ts index 23a7b59b8b90..29d95bc4b2d3 100644 --- a/packages/stores/types.ts +++ b/packages/stores/types.ts @@ -41,6 +41,7 @@ type TRoutes = | '/account/proof-of-ownership' | '/account/proof-of-income' | '/account/passwords' + | '/account/passkeys' | '/account/closing-account' | '/account/deactivate-account' | '/account-closed' @@ -606,6 +607,11 @@ type TClientStore = { real_account_signup_form_step: number; setRealAccountSignupFormData: (data: Array>) => void; setRealAccountSignupFormStep: (step: number) => void; + is_passkey_supported: boolean; + setIsPasskeySupported: (value: boolean) => void; + should_show_effortless_login_modal: boolean; + setShouldShowEffortlessLoginModal: (value: boolean) => void; + fetchShouldShowEffortlessLoginModal: () => void; }; type TCommonStoreError = {