diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index fb45872eb..cd00fe2c9 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -654,6 +654,12 @@ "coingecko": { "message": "CoinGecko" }, + "complianceActivatedDesc": { + "message": "You can now use compliance in MetaMask Institutional. Receiving AML/CFT analysis within the confirmation screen on all the addresses you interact with." + }, + "complianceActivatedTitle": { + "message": "Your compliance feature is activated" + }, "complianceBlurb0": { "message": "DeFi raises AML/CFT risk for institutions, given the decentralised pools and pseudonymous counterparties." }, @@ -780,6 +786,9 @@ "connectingToSepolia": { "message": "Connecting to Sepolia test network" }, + "connectionError": { + "message": "Connection error" + }, "contactUs": { "message": "Contact us" }, @@ -1707,6 +1716,9 @@ "holdToRevealTitle": { "message": "Keep your SRP safe" }, + "id": { + "message": "Id" + }, "ignoreAll": { "message": "Ignore all" }, @@ -1812,6 +1824,9 @@ "install": { "message": "Install" }, + "institutionalFeatures": { + "message": "Institutional Features" + }, "insufficientBalance": { "message": "Insufficient balance." }, @@ -2141,6 +2156,9 @@ "mmiAddToken": { "message": "The page at $1 would like to authorise the following custodian token in MetaMask Institutional" }, + "mmiAuthenticate": { + "message": "The page at $1 would like to authorise the following project’s compliance settings in MetaMask Institutional" + }, "mobileSyncWarning": { "message": "The 'Sync with extension' feature is temporarily disabled. If you want to use your extension wallet on MetaMask mobile, then on your mobile app: go back to the wallet setup options and select the 'Import with Secret Recovery Phrase' option. Use your extension wallet's secret phrase to then import your wallet into mobile." }, @@ -3065,6 +3083,12 @@ "proceedWithTransaction": { "message": "I want to proceed anyway" }, + "projectIdInvalid": { + "message": "Provided Project ID is invalid" + }, + "projectName": { + "message": "Project Name" + }, "proposedApprovalLimit": { "message": "Proposed approval limit" }, diff --git a/ui/helpers/constants/routes.ts b/ui/helpers/constants/routes.ts index f448f07bc..6f045a369 100644 --- a/ui/helpers/constants/routes.ts +++ b/ui/helpers/constants/routes.ts @@ -32,6 +32,9 @@ const CONFIRM_ADD_SUGGESTED_TOKEN_ROUTE = '/confirm-add-suggested-token'; const NEW_ACCOUNT_ROUTE = '/new-account'; const IMPORT_ACCOUNT_ROUTE = '/new-account/import'; const CONNECT_HARDWARE_ROUTE = '/new-account/connect'; +///: BEGIN:ONLY_INCLUDE_IN(mmi) +const INSTITUTIONAL_FEATURES_DONE_ROUTE = '/institutional-features/done'; +///: END:ONLY_INCLUDE_IN const SEND_ROUTE = '/send'; const TOKEN_DETAILS = '/token-details'; const CONNECT_ROUTE = '/connect'; @@ -124,6 +127,9 @@ const PATH_NAME_MAP = { [NEW_ACCOUNT_ROUTE]: 'New Account Page', [IMPORT_ACCOUNT_ROUTE]: 'Import Account Page', [CONNECT_HARDWARE_ROUTE]: 'Connect Hardware Wallet Page', + ///: BEGIN:ONLY_INCLUDE_IN(mmi) + [INSTITUTIONAL_FEATURES_DONE_ROUTE]: 'Institutional features done', + ///: END:ONLY_INCLUDE_IN [SEND_ROUTE]: 'Send Page', [`${TOKEN_DETAILS}/:address`]: 'Token Details Page', [`${CONNECT_ROUTE}/:id`]: 'Connect To Site Confirmation Page', @@ -180,6 +186,9 @@ export { NEW_ACCOUNT_ROUTE, IMPORT_ACCOUNT_ROUTE, CONNECT_HARDWARE_ROUTE, + ///: BEGIN:ONLY_INCLUDE_IN(mmi) + INSTITUTIONAL_FEATURES_DONE_ROUTE, + ///: END:ONLY_INCLUDE_IN SEND_ROUTE, TOKEN_DETAILS, CONFIRM_TRANSACTION_ROUTE, diff --git a/ui/pages/institutional/confirm-add-institutional-feature/__snapshots__/confirm-add-institutional-feature.test.js.snap b/ui/pages/institutional/confirm-add-institutional-feature/__snapshots__/confirm-add-institutional-feature.test.js.snap new file mode 100644 index 000000000..2d68b5531 --- /dev/null +++ b/ui/pages/institutional/confirm-add-institutional-feature/__snapshots__/confirm-add-institutional-feature.test.js.snap @@ -0,0 +1,67 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Confirm Add Institutional Feature opens confirm institutional sucessfully 1`] = ` +
+
+
+

+ Institutional Features +

+

+ The page at origin would like to authorise the following project’s compliance settings in MetaMask Institutional +

+
+
+

+ Project Name +

+

+ projectName +

+

+ Id + : + projectId +

+
+ + +
+
+`; diff --git a/ui/pages/institutional/confirm-add-institutional-feature/confirm-add-institutional-feature.js b/ui/pages/institutional/confirm-add-institutional-feature/confirm-add-institutional-feature.js new file mode 100644 index 000000000..25a52fc16 --- /dev/null +++ b/ui/pages/institutional/confirm-add-institutional-feature/confirm-add-institutional-feature.js @@ -0,0 +1,208 @@ +import React, { useState, useContext, useEffect } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import PropTypes from 'prop-types'; +import { useI18nContext } from '../../../hooks/useI18nContext'; +import Button from '../../../components/ui/button'; +import PulseLoader from '../../../components/ui/pulse-loader'; +import { INSTITUTIONAL_FEATURES_DONE_ROUTE } from '../../../helpers/constants/routes'; +import { MetaMetricsContext } from '../../../contexts/metametrics'; +import { getMostRecentOverviewPage } from '../../../ducks/history/history'; +import { Text } from '../../../components/component-library'; +import { + TextColor, + TextVariant, + OVERFLOW_WRAP, + TEXT_ALIGN, +} from '../../../helpers/constants/design-system'; +import Box from '../../../components/ui/box'; +import { mmiActionsFactory } from '../../../store/institutional/institution-background'; + +export default function ConfirmAddInstitutionalFeature({ history }) { + const t = useI18nContext(); + const dispatch = useDispatch(); + const mmiActions = mmiActionsFactory(); + const [isLoading, setIsLoading] = useState(false); + const [connectError, setConnectError] = useState(''); + const mostRecentOverviewPage = useSelector(getMostRecentOverviewPage); + const connectRequests = useSelector( + (state) => state.metamask.institutionalFeatures?.connectRequests, + ); + + const trackEvent = useContext(MetaMetricsContext); + const connectRequest = connectRequests[0]; + + useEffect(() => { + if (!connectRequest) { + history.push(mostRecentOverviewPage); + } + }, [connectRequest, history, mostRecentOverviewPage]); + + if (!connectRequest) { + return null; + } + + const serviceLabel = connectRequest.labels.find( + (label) => label.key === 'service', + ); + + const sendEvent = ({ actions, service }) => { + trackEvent({ + category: 'MMI', + event: 'Institutional feature connection', + properties: { + actions, + service, + }, + }); + }; + + const handleConnectError = ({ message }) => { + let error = message; + if (message.startsWith('401')) { + error = t('projectIdInvalid'); + } + + if (!error) { + error = t('connectionError'); + } + + setIsLoading(false); + setConnectError(error); + sendEvent({ actions: 'Institutional feature RPC error' }); + }; + + const removeConnectInstitutionalFeature = ({ actions, service, push }) => { + dispatch( + mmiActions.removeConnectInstitutionalFeature({ + origin: connectRequest.origin, + projectId: connectRequest.token.projectId, + }), + ); + sendEvent({ actions, service }); + history.push(push); + }; + + const confirmAddInstitutionalFeature = async () => { + setIsLoading(true); + setConnectError(''); + + try { + await dispatch( + mmiActions.setComplianceAuthData({ + clientId: connectRequest.token.clientId, + projectId: connectRequest.token.projectId, + }), + ); + removeConnectInstitutionalFeature({ + actions: 'Institutional feature RPC confirm', + service: serviceLabel.value, + push: { + pathname: INSTITUTIONAL_FEATURES_DONE_ROUTE, + state: { + imgSrc: 'images/compliance-logo.png', + title: t('complianceActivatedTitle'), + description: t('complianceActivatedDesc'), + }, + }, + }); + } catch (e) { + handleConnectError(e); + } + }; + + sendEvent({ + actions: 'Institutional feature RPC request', + service: serviceLabel.value, + }); + + return ( + + + + {t('institutionalFeatures')} + + + {t('mmiAuthenticate', [connectRequest.origin, serviceLabel.value])} + + + + + {t('projectName')} + + + {connectRequest?.token?.projectName} + + + {t('id')}: {connectRequest?.token?.projectId} + + + {connectError && ( + + {connectError} + + )} + + + {isLoading ? ( +
+ +
+ ) : ( + + )} +
+
+ ); +} + +ConfirmAddInstitutionalFeature.propTypes = { + history: PropTypes.object, +}; diff --git a/ui/pages/institutional/confirm-add-institutional-feature/confirm-add-institutional-feature.stories.js b/ui/pages/institutional/confirm-add-institutional-feature/confirm-add-institutional-feature.stories.js new file mode 100644 index 000000000..07299393f --- /dev/null +++ b/ui/pages/institutional/confirm-add-institutional-feature/confirm-add-institutional-feature.stories.js @@ -0,0 +1,57 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import configureStore from '../../../store/store'; +import testData from '../../../../.storybook/test-data'; +import ConfirmAddInstitutionalFeature from '.'; + +const customData = { + ...testData, + metamask: { + provider: { + type: 'test', + }, + institutionalFeatures: { + complianceProjectId: '', + connectRequests: [ + { + labels: [ + { + key: 'service', + value: 'test', + }, + ], + origin: 'origin', + token: { + projectName: 'projectName', + projectId: 'projectId', + clientId: 'clientId', + }, + }, + ], + }, + preferences: { + useNativeCurrencyAsPrimaryCurrency: true, + }, + }, +}; + +const store = configureStore(customData); + +export default { + title: 'Pages/Institutional/ConfirmAddInstitutionalFeature', + decorators: [(story) => {story()}], + component: ConfirmAddInstitutionalFeature, + args: { + history: { + push: () => { + /**/ + }, + }, + }, +}; + +export const DefaultStory = (args) => ( + +); + +DefaultStory.storyName = 'ConfirmAddInstitutionalFeature'; diff --git a/ui/pages/institutional/confirm-add-institutional-feature/confirm-add-institutional-feature.test.js b/ui/pages/institutional/confirm-add-institutional-feature/confirm-add-institutional-feature.test.js new file mode 100644 index 000000000..141e27156 --- /dev/null +++ b/ui/pages/institutional/confirm-add-institutional-feature/confirm-add-institutional-feature.test.js @@ -0,0 +1,122 @@ +import React from 'react'; +import { fireEvent, screen } from '@testing-library/react'; +import configureMockStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; +import { renderWithProvider } from '../../../../test/lib/render-helpers'; +import mockState from '../../../../test/data/mock-state.json'; +import ConfirmAddInstitutionalFeature from '.'; + +const mockRemoveConnectInstitutionalFeature = jest + .fn() + .mockReturnValue({ type: 'TYPE' }); + +let mockSetComplianceAuthData = jest.fn().mockReturnValue({ type: 'TYPE' }); + +jest.mock('../../../store/institutional/institution-background', () => ({ + mmiActionsFactory: () => ({ + setComplianceAuthData: mockSetComplianceAuthData, + removeConnectInstitutionalFeature: mockRemoveConnectInstitutionalFeature, + }), +})); + +const connectRequests = [ + { + labels: [ + { + key: 'service', + value: 'test', + }, + ], + origin: 'origin', + token: { + projectName: 'projectName', + projectId: 'projectId', + clientId: 'clientId', + }, + }, +]; + +const props = { + history: { + push: jest.fn(), + }, +}; + +const render = ({ newState } = {}) => { + const state = { + ...mockState, + metamask: { + provider: { + type: 'test', + }, + institutionalFeatures: { + complianceProjectId: '', + connectRequests, + }, + preferences: { + useNativeCurrencyAsPrimaryCurrency: true, + }, + ...newState, + }, + }; + const middlewares = [thunk]; + const mockStore = configureMockStore(middlewares); + const store = mockStore(state); + + return renderWithProvider( + , + store, + ); +}; + +describe('Confirm Add Institutional Feature', function () { + it('opens confirm institutional sucessfully', () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + expect( + screen.getByText(`Id: ${connectRequests[0].token.projectId}`), + ).toBeInTheDocument(); + }); + + it('runs removeConnectInstitutionalFeature on cancel click', () => { + render(); + fireEvent.click(screen.queryByText('Cancel')); + expect(mockRemoveConnectInstitutionalFeature).toHaveBeenCalledTimes(1); + expect(mockRemoveConnectInstitutionalFeature).toHaveBeenCalledWith({ + origin: connectRequests[0].origin, + projectId: connectRequests[0].token.projectId, + }); + expect(props.history.push).toHaveBeenCalledTimes(1); + }); + + it('runs setComplianceAuthData on confirm click', () => { + render(); + fireEvent.click(screen.queryByText('Confirm')); + expect(mockSetComplianceAuthData).toHaveBeenCalledTimes(1); + expect(mockSetComplianceAuthData).toHaveBeenCalledWith({ + clientId: connectRequests[0].token.clientId, + projectId: connectRequests[0].token.projectId, + }); + }); + + it('handles error', () => { + mockSetComplianceAuthData = jest + .fn() + .mockReturnValue(new Error('Async error message')); + const { queryByTestId } = render(); + fireEvent.click(screen.queryByText('Confirm')); + expect(queryByTestId('connect-error-message')).toBeInTheDocument(); + }); + + it('does not render without connectRequest', () => { + const newState = { + institutionalFeatures: { + connectRequests: [], + }, + }; + const { queryByTestId } = render({ newState }); + expect( + queryByTestId('confirm-add-institutional-feature'), + ).not.toBeInTheDocument(); + }); +}); diff --git a/ui/pages/institutional/confirm-add-institutional-feature/index.js b/ui/pages/institutional/confirm-add-institutional-feature/index.js new file mode 100644 index 000000000..062300085 --- /dev/null +++ b/ui/pages/institutional/confirm-add-institutional-feature/index.js @@ -0,0 +1,3 @@ +import ConfirmAddInstitutionalFeature from './confirm-add-institutional-feature'; + +export default ConfirmAddInstitutionalFeature;