diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 82873ab64c6f..64fe6aa05f2a 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -103,6 +103,9 @@ "SIWEWarningTitle": { "message": "Are you sure?" }, + "ShowMore": { + "message": "Show more" + }, "about": { "message": "About" }, @@ -348,6 +351,9 @@ "amount": { "message": "Amount" }, + "apiUrl": { + "message": "API URL" + }, "appDescription": { "message": "An Ethereum Wallet in your Browser", "description": "The description of the application" @@ -908,6 +914,12 @@ "curveMediumGasEstimate": { "message": "Market gas estimate graph" }, + "custodian": { + "message": "Custodian" + }, + "custodianAccount": { + "message": "Custodian account" + }, "custom": { "message": "Advanced" }, @@ -2118,6 +2130,9 @@ "missingToken": { "message": "Don't see your token?" }, + "mmiAddToken": { + "message": "The page at $1 would like to authorise the following custodian token 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." }, @@ -2169,6 +2184,9 @@ "networkIsBusy": { "message": "Network is busy. Gas prices are high and estimates are less accurate." }, + "networkMenuHeading": { + "message": "Select a network" + }, "networkName": { "message": "Network name" }, @@ -2309,6 +2327,9 @@ "nfts": { "message": "NFTs" }, + "nftsPreviouslyOwned": { + "message": "Previously Owned" + }, "nickname": { "message": "Nickname" }, @@ -3735,6 +3756,9 @@ "statusNotConnected": { "message": "Not connected" }, + "statusNotConnectedAccount": { + "message": "No accounts connected" + }, "step1LatticeWallet": { "message": "Connect your Lattice1" }, @@ -4333,6 +4357,12 @@ "tooltipApproveButton": { "message": "I understand" }, + "tooltipSatusConnected": { + "message": "connected" + }, + "tooltipSatusNotConnected": { + "message": "not connected" + }, "total": { "message": "Total" }, diff --git a/app/scripts/background.js b/app/scripts/background.js index 956487f89487..2fdca5a161a0 100644 --- a/app/scripts/background.js +++ b/app/scripts/background.js @@ -556,6 +556,10 @@ export function setupController(initState, initLangCode, overrides) { if (message.name === WORKER_KEEP_ALIVE_MESSAGE) { // To test un-comment this line and wait for 1 minute. An error should be shown on MetaMask UI. remotePort.postMessage({ name: ACK_KEEP_ALIVE_MESSAGE }); + + controller.appStateController.setServiceWorkerLastActiveTime( + Date.now(), + ); } }); } diff --git a/app/scripts/controllers/app-state.js b/app/scripts/controllers/app-state.js index 7d1c83a4e6e8..f7669e055bcd 100644 --- a/app/scripts/controllers/app-state.js +++ b/app/scripts/controllers/app-state.js @@ -51,6 +51,7 @@ export default class AppStateController extends EventEmitter { '0x5': true, '0x539': true, }, + serviceWorkerLastActiveTime: 0, }); this.timer = null; @@ -362,4 +363,10 @@ export default class AppStateController extends EventEmitter { getCurrentPopupId() { return this.store.getState().currentPopupId; } + + setServiceWorkerLastActiveTime(serviceWorkerLastActiveTime) { + this.store.updateState({ + serviceWorkerLastActiveTime, + }); + } } diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index d4fff36bb4f3..93670073da94 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -121,6 +121,7 @@ import { isMain, isFlask } from '../../shared/constants/environment'; // eslint-disable-next-line import/order import { DesktopController } from '@metamask/desktop/dist/controllers/desktop'; ///: END:ONLY_INCLUDE_IN +import { ACTION_QUEUE_METRICS_E2E_TEST } from '../../shared/constants/test-flags'; import { onMessageReceived, checkForMultipleVersionsRunning, @@ -695,6 +696,7 @@ export default class MetamaskController extends EventEmitter { this.keyringController.memStore.subscribe((state) => this._onKeyringControllerUpdate(state), ); + this.keyringController.on('unlock', () => this._onUnlock()); this.keyringController.on('lock', () => this._onLock()); @@ -1190,6 +1192,25 @@ export default class MetamaskController extends EventEmitter { }, ); + if (isManifestV3 && globalThis.isFirstTimeProfileLoaded === false) { + const { serviceWorkerLastActiveTime } = + this.appStateController.store.getState(); + const metametricsPayload = { + category: EVENT.SOURCE.SERVICE_WORKERS, + event: EVENT_NAMES.SERVICE_WORKER_RESTARTED, + properties: { + service_worker_restarted_time: + Date.now() - serviceWorkerLastActiveTime, + }, + }; + + try { + this.metaMetricsController.trackEvent(metametricsPayload); + } catch (e) { + log.warn('Failed to track service worker restart metric:', e); + } + } + this.metamaskMiddleware = createMetamaskMiddleware({ static: { eth_syncing: false, @@ -2627,7 +2648,7 @@ export default class MetamaskController extends EventEmitter { try { // Automatic login via config password const password = process.env.CONF?.PASSWORD; - if (password) { + if (password && !process.env.IN_TEST) { await this.submitPassword(password); } // Automatic login via storage encryption key @@ -2960,6 +2981,13 @@ export default class MetamaskController extends EventEmitter { * @returns {} keyState */ async addNewAccount(accountCount) { + const isActionMetricsQueueE2ETest = + this.appStateController.store.getState()[ACTION_QUEUE_METRICS_E2E_TEST]; + + if (process.env.IN_TEST && isActionMetricsQueueE2ETest) { + await new Promise((resolve) => setTimeout(resolve, 5_000)); + } + const [primaryKeyring] = this.keyringController.getKeyringsByType( KeyringType.hdKeyTree, ); @@ -3581,7 +3609,11 @@ export default class MetamaskController extends EventEmitter { phishingStream.on( 'data', createMetaRPCHandler( - { safelistPhishingDomain: this.safelistPhishingDomain.bind(this) }, + { + safelistPhishingDomain: this.safelistPhishingDomain.bind(this), + backToSafetyPhishingWarning: + this.backToSafetyPhishingWarning.bind(this), + }, phishingStream, ), ); @@ -4342,6 +4374,11 @@ export default class MetamaskController extends EventEmitter { return this.phishingController.bypass(hostname); } + async backToSafetyPhishingWarning() { + const extensionURL = this.platform.getExtensionURL(); + await this.platform.switchToAnotherURL(undefined, extensionURL); + } + /** * Locks MetaMask */ diff --git a/app/scripts/platforms/extension.js b/app/scripts/platforms/extension.js index 098c69efdbd9..1ac0c853e020 100644 --- a/app/scripts/platforms/extension.js +++ b/app/scripts/platforms/extension.js @@ -77,11 +77,7 @@ export default class ExtensionPlatform { return version; } - openExtensionInBrowser( - route = null, - queryString = null, - keepWindowOpen = false, - ) { + getExtensionURL(route = null, queryString = null) { let extensionURL = browser.runtime.getURL('home.html'); if (route) { @@ -92,7 +88,22 @@ export default class ExtensionPlatform { extensionURL += `?${queryString}`; } + return extensionURL; + } + + openExtensionInBrowser( + route = null, + queryString = null, + keepWindowOpen = false, + ) { + const extensionURL = this.getExtensionURL( + route, + queryString, + keepWindowOpen, + ); + this.openTab({ url: extensionURL }); + if ( getEnvironmentType() !== ENVIRONMENT_TYPE_BACKGROUND && !keepWindowOpen @@ -153,6 +164,10 @@ export default class ExtensionPlatform { return tab; } + async switchToAnotherURL(tabId, url) { + await browser.tabs.update(tabId, { url }); + } + async closeTab(tabId) { await browser.tabs.remove(tabId); } diff --git a/app/scripts/platforms/extension.test.js b/app/scripts/platforms/extension.test.js index 1b2067d783ac..27cc0e8b9052 100644 --- a/app/scripts/platforms/extension.test.js +++ b/app/scripts/platforms/extension.test.js @@ -1,10 +1,14 @@ import browser from 'webextension-polyfill'; import ExtensionPlatform from './extension'; +const TEST_URL = + 'chrome-extension://jjlgkphpeekojaidfeknpknnimdbleaf/home.html'; + jest.mock('webextension-polyfill', () => { return { runtime: { getManifest: jest.fn(), + getURL: jest.fn(), }, }; }); @@ -91,4 +95,30 @@ describe('extension platform', () => { ); }); }); + + describe('getExtensionURL', () => { + let extensionPlatform; + beforeEach(() => { + browser.runtime.getURL.mockReturnValue(TEST_URL); + extensionPlatform = new ExtensionPlatform(); + }); + + it('should return URL itself if no route or queryString is provided', () => { + expect(extensionPlatform.getExtensionURL()).toStrictEqual(TEST_URL); + }); + + it('should return URL with route when provided', () => { + const TEST_ROUTE = 'test-route'; + expect(extensionPlatform.getExtensionURL(TEST_ROUTE)).toStrictEqual( + `${TEST_URL}#${TEST_ROUTE}`, + ); + }); + + it('should return URL with queryString when provided', () => { + const QUERY_STRING = 'name=ferret'; + expect( + extensionPlatform.getExtensionURL(null, QUERY_STRING), + ).toStrictEqual(`${TEST_URL}?${QUERY_STRING}`); + }); + }); }); diff --git a/development/build/utils.js b/development/build/utils.js index b0df586b1931..ce88d1f0929b 100644 --- a/development/build/utils.js +++ b/development/build/utils.js @@ -58,7 +58,12 @@ function getBrowserVersionMap(platforms, version) { if (!String(buildVersion).match(/^\d+$/u)) { throw new Error(`Invalid prerelease build version: '${buildVersion}'`); } else if ( - ![BuildType.beta, BuildType.flask, BuildType.desktop].includes(buildType) + ![ + BuildType.beta, + BuildType.flask, + BuildType.desktop, + BuildType.mmi, + ].includes(buildType) ) { throw new Error(`Invalid prerelease build type: ${buildType}`); } diff --git a/package.json b/package.json index b0cdf96a7fad..b6849a24dca5 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "build:test": "SEGMENT_HOST='https://api.segment.io' SEGMENT_WRITE_KEY='FAKE' SENTRY_DSN_DEV=https://fake@sentry.io/0000000 PORTFOLIO_URL=http://127.0.0.1:8080 yarn build test", "build:test:flask": "yarn build test --build-type flask", "build:test:mv3": "ENABLE_MV3=true SEGMENT_HOST='https://api.segment.io' SEGMENT_WRITE_KEY='FAKE' SENTRY_DSN_DEV=https://fake@sentry.io/0000000 PORTFOLIO_URL=http://127.0.0.1:8080 yarn build test", + "build:test:dev:mv3": "ENABLE_MV3=true SEGMENT_HOST='https://api.segment.io' SEGMENT_WRITE_KEY='FAKE' SENTRY_DSN_DEV=https://fake@sentry.io/0000000 PORTFOLIO_URL=http://127.0.0.1:8080 yarn build:dev testDev --apply-lavamoat=false", "test": "yarn lint && yarn test:unit && yarn test:unit:jest", "dapp": "node development/static-server.js node_modules/@metamask/test-dapp/dist --port 8080", "dapp-chain": "GANACHE_ARGS='-b 2' concurrently -k -n ganache,dapp -p '[{time}][{name}]' 'yarn ganache:start' 'sleep 5 && yarn dapp'", @@ -32,6 +33,7 @@ "test:unit:mocha": "node ./test/run-unit-tests.js --mocha", "test:e2e:chrome": "SELENIUM_BROWSER=chrome node test/e2e/run-all.js", "test:e2e:chrome:snaps": "SELENIUM_BROWSER=chrome node test/e2e/run-all.js --snaps", + "test:e2e:chrome:mv3": "SELENIUM_BROWSER=chrome node test/e2e/run-all.js --mv3", "test:e2e:firefox": "SELENIUM_BROWSER=firefox node test/e2e/run-all.js", "test:e2e:firefox:snaps": "SELENIUM_BROWSER=firefox node test/e2e/run-all.js --snaps", "test:e2e:single": "node test/e2e/run-e2e-test.js", @@ -375,7 +377,7 @@ "@metamask/eslint-config-nodejs": "^9.0.0", "@metamask/eslint-config-typescript": "^9.0.1", "@metamask/forwarder": "^1.1.0", - "@metamask/phishing-warning": "^2.0.1", + "@metamask/phishing-warning": "^2.1.0", "@metamask/test-dapp": "^5.6.0", "@sentry/cli": "^1.58.0", "@storybook/addon-a11y": "^6.5.13", diff --git a/shared/constants/metametrics.js b/shared/constants/metametrics.js index de3244c42af2..1eeb0a314b3b 100644 --- a/shared/constants/metametrics.js +++ b/shared/constants/metametrics.js @@ -374,6 +374,7 @@ export const EVENT_NAMES = { ONBOARDING_WALLET_IMPORT_ATTEMPTED: 'Wallet Import Attempted', ONBOARDING_WALLET_VIDEO_PLAY: 'SRP Intro Video Played', ONBOARDING_TWITTER_CLICK: 'External Link Clicked', + SERVICE_WORKER_RESTARTED: 'Service Worker Restarted', }; export const EVENT = { @@ -447,6 +448,7 @@ export const EVENT = { DAPP: 'dapp', USER: 'user', }, + SERVICE_WORKERS: 'service_workers', }, LOCATION: { TOKEN_DETAILS: 'token_details', diff --git a/shared/constants/test-flags.js b/shared/constants/test-flags.js new file mode 100644 index 000000000000..a5018d6be90d --- /dev/null +++ b/shared/constants/test-flags.js @@ -0,0 +1 @@ +export const ACTION_QUEUE_METRICS_E2E_TEST = 'action_queue_metrics_e2e_test'; diff --git a/shared/modules/fetch-with-timeout.test.js b/shared/modules/fetch-with-timeout.test.ts similarity index 100% rename from shared/modules/fetch-with-timeout.test.js rename to shared/modules/fetch-with-timeout.test.ts diff --git a/shared/modules/fetch-with-timeout.js b/shared/modules/fetch-with-timeout.ts similarity index 51% rename from shared/modules/fetch-with-timeout.js rename to shared/modules/fetch-with-timeout.ts index 791470d77cdc..f5c6d5384012 100644 --- a/shared/modules/fetch-with-timeout.js +++ b/shared/modules/fetch-with-timeout.ts @@ -1,12 +1,25 @@ import { memoize } from 'lodash'; import { SECOND } from '../constants/time'; +/** + * Returns a function that can be used to make an HTTP request but timing out + * automatically after a desired amount of time. + * + * @param timeout - The number of milliseconds to wait until the request times + * out. + * @returns A function that, when called, returns a promise that either resolves + * to the HTTP response object or is rejected if a network error is encountered + * or the request times out. + */ const getFetchWithTimeout = memoize((timeout = SECOND * 30) => { if (!Number.isInteger(timeout) || timeout < 1) { throw new Error('Must specify positive integer timeout.'); } - return async function _fetch(url, opts) { + return async function fetchWithTimeout( + url: RequestInfo, + opts?: RequestInit, + ): Promise { const abortController = new window.AbortController(); const { signal } = abortController; const f = window.fetch(url, { @@ -17,12 +30,9 @@ const getFetchWithTimeout = memoize((timeout = SECOND * 30) => { const timer = setTimeout(() => abortController.abort(), timeout); try { - const res = await f; + return await f; + } finally { clearTimeout(timer); - return res; - } catch (e) { - clearTimeout(timer); - throw e; } }; }); diff --git a/shared/modules/network.utils.test.ts b/shared/modules/network.utils.test.ts new file mode 100644 index 000000000000..ee4ef3f8399e --- /dev/null +++ b/shared/modules/network.utils.test.ts @@ -0,0 +1,86 @@ +import { MAX_SAFE_CHAIN_ID } from '../constants/network'; +import { + isSafeChainId, + isPrefixedFormattedHexString, + isTokenDetectionEnabledForNetwork, +} from './network.utils'; + +describe('network utils', () => { + describe('isSafeChainId', () => { + it('returns true given an integer greater than 0 and less than or equal to the max safe chain ID', () => { + expect(isSafeChainId(3)).toBe(true); + }); + + it('returns true given the max safe chain ID', () => { + expect(isSafeChainId(MAX_SAFE_CHAIN_ID)).toBe(true); + }); + + it('returns false given something other than an integer', () => { + expect(isSafeChainId('not-an-integer')).toBe(false); + }); + + it('returns false given a negative integer', () => { + expect(isSafeChainId(-1)).toBe(false); + }); + + it('returns false given an integer greater than the max safe chain ID', () => { + expect(isSafeChainId(MAX_SAFE_CHAIN_ID + 1)).toBe(false); + }); + }); + + describe('isPrefixedFormattedHexString', () => { + it('returns true given a string that matches a hex number formatted as a "0x"-prefixed, non-zero, non-zero-padded string', () => { + expect(isPrefixedFormattedHexString('0x1')).toBe(true); + expect(isPrefixedFormattedHexString('0xa')).toBe(true); + expect(isPrefixedFormattedHexString('0xabc123')).toBe(true); + }); + + it('returns true given a "0x"-prefixed hex string that contains uppercase characters', () => { + expect(isPrefixedFormattedHexString('0XABC123')).toBe(true); + }); + + it('returns false given a "0x"-prefixed hex string that evaluates to zero', () => { + expect(isPrefixedFormattedHexString('0x0')).toBe(false); + }); + + it('returns false given a "0x"-prefixed hex string that does not evaluate to zero but is zero-padded', () => { + expect(isPrefixedFormattedHexString('0x01')).toBe(false); + }); + + it('returns false given a hex number that is simply a string but not "0x"-prefixed', () => { + expect(isPrefixedFormattedHexString('abc123')).toBe(false); + }); + + it('returns false if given something other than a string', () => { + expect(isPrefixedFormattedHexString({ something: 'else' })).toBe(false); + }); + }); + + describe('isTokenDetectionEnabledForNetwork', () => { + it('returns true given the chain ID for Mainnet', () => { + expect(isTokenDetectionEnabledForNetwork('0x1')).toBe(true); + }); + + it('returns true given the chain ID for BSC', () => { + expect(isTokenDetectionEnabledForNetwork('0x38')).toBe(true); + }); + + it('returns true given the chain ID for Polygon', () => { + expect(isTokenDetectionEnabledForNetwork('0x89')).toBe(true); + }); + + it('returns true given the chain ID for Avalanche', () => { + expect(isTokenDetectionEnabledForNetwork('0xa86a')).toBe(true); + }); + + it('returns false given a string that is not the chain ID for Mainnet, BSC, Polygon, or Avalanche', () => { + expect(isTokenDetectionEnabledForNetwork('some other chain ID')).toBe( + false, + ); + }); + + it('returns false given undefined', () => { + expect(isTokenDetectionEnabledForNetwork(undefined)).toBe(false); + }); + }); +}); diff --git a/shared/modules/network.utils.js b/shared/modules/network.utils.ts similarity index 52% rename from shared/modules/network.utils.js rename to shared/modules/network.utils.ts index 05723b3ac141..13fca471d819 100644 --- a/shared/modules/network.utils.js +++ b/shared/modules/network.utils.ts @@ -5,24 +5,22 @@ import { CHAIN_IDS, MAX_SAFE_CHAIN_ID } from '../constants/network'; * Because some cryptographic libraries we use expect the chain ID to be a * number primitive, it must not exceed a certain size. * - * @param {number} chainId - The chain ID to check for safety. - * @returns {boolean} Whether the given chain ID is safe. + * @param chainId - The chain ID to check for safety. + * @returns Whether the given chain ID is safe. */ -export function isSafeChainId(chainId) { - return ( - Number.isSafeInteger(chainId) && chainId > 0 && chainId <= MAX_SAFE_CHAIN_ID - ); +export function isSafeChainId(chainId: unknown): boolean { + return isSafeInteger(chainId) && chainId > 0 && chainId <= MAX_SAFE_CHAIN_ID; } /** * Checks whether the given value is a 0x-prefixed, non-zero, non-zero-padded, * hexadecimal string. * - * @param {any} value - The value to check. - * @returns {boolean} True if the value is a correctly formatted hex string, + * @param value - The value to check. + * @returns True if the value is a correctly formatted hex string, * false otherwise. */ -export function isPrefixedFormattedHexString(value) { +export function isPrefixedFormattedHexString(value: unknown) { if (typeof value !== 'string') { return false; } @@ -35,7 +33,7 @@ export function isPrefixedFormattedHexString(value) { * @param chainId - ChainID of network * @returns Whether the current network supports token detection */ -export function isTokenDetectionEnabledForNetwork(chainId) { +export function isTokenDetectionEnabledForNetwork(chainId: string | undefined) { switch (chainId) { case CHAIN_IDS.MAINNET: case CHAIN_IDS.BSC: @@ -46,3 +44,14 @@ export function isTokenDetectionEnabledForNetwork(chainId) { return false; } } + +/** + * Like {@link Number.isSafeInteger}, but types the input as a `number` if it is + * indeed a safe integer. + * + * @param value - The value to check. + * @returns True if the value is a safe integer, false otherwise. + */ +function isSafeInteger(value: unknown): value is number { + return Number.isSafeInteger(value); +} diff --git a/test/e2e/fixture-builder.js b/test/e2e/fixture-builder.js index 81b4d63852e7..5e3695a05e53 100644 --- a/test/e2e/fixture-builder.js +++ b/test/e2e/fixture-builder.js @@ -4,6 +4,9 @@ const { } = require('@metamask/snaps-utils'); const { merge } = require('lodash'); const { CHAIN_IDS } = require('../../shared/constants/network'); +const { + ACTION_QUEUE_METRICS_E2E_TEST, +} = require('../../shared/constants/test-flags'); const { SMART_CONTRACTS } = require('./seeder/smart-contracts'); function defaultFixture() { @@ -309,6 +312,7 @@ function onboardingFixture() { [CHAIN_IDS.GOERLI]: true, [CHAIN_IDS.LOCALHOST]: true, }, + [ACTION_QUEUE_METRICS_E2E_TEST]: false, }, NetworkController: { networkId: '1337', diff --git a/test/e2e/mv3/dapp-interactions.spec.js b/test/e2e/mv3/dapp-interactions.spec.js index 8020047cf5c6..294182cc9390 100644 --- a/test/e2e/mv3/dapp-interactions.spec.js +++ b/test/e2e/mv3/dapp-interactions.spec.js @@ -12,6 +12,7 @@ describe('MV3 - Dapp interactions', function () { balance: convertToHexValue(25000000000000000000), }, ], + concurrent: { port: 8546, chainId: 1338 }, }; it('should continue to support dapp interactions after service worker re-start', async function () { await withFixtures( diff --git a/test/e2e/nft/import-erc1155.spec.js b/test/e2e/nft/import-erc1155.spec.js index 9be71fd05325..95992d770438 100644 --- a/test/e2e/nft/import-erc1155.spec.js +++ b/test/e2e/nft/import-erc1155.spec.js @@ -51,7 +51,7 @@ describe('Import ERC1155 NFT', function () { // Check the imported ERC1155 and its image are displayed in the ERC1155 tab const importedERC1155 = await driver.waitForSelector({ css: 'h5', - text: 'Rocks', + text: 'Unnamed collection', }); assert.equal(await importedERC1155.isDisplayed(), true); diff --git a/test/e2e/tests/phishing-detection.spec.js b/test/e2e/tests/phishing-detection.spec.js index cc30a5ca0342..c82645e1f85f 100644 --- a/test/e2e/tests/phishing-detection.spec.js +++ b/test/e2e/tests/phishing-detection.spec.js @@ -289,4 +289,46 @@ describe('Phishing Detection', function () { }, ); }); + + it('should open a new extension expanded view when clicking back to safety button', async function () { + await withFixtures( + { + fixtures: new FixtureBuilder().build(), + ganacheOptions, + title: this.test.title, + testSpecificMock: mockPhishingDetection, + dapp: true, + dappPaths: ['mock-page-with-disallowed-iframe'], + dappOptions: { + numberOfDapps: 2, + }, + failOnConsoleError: false, + }, + async ({ driver }) => { + await driver.navigate(); + await driver.fill('#password', 'correct horse battery staple'); + await driver.press('#password', driver.Key.ENTER); + await driver.openNewPage( + `http://localhost:8080?extensionUrl=${driver.extensionUrl}`, + ); + + const iframe = await driver.findElement('iframe'); + + await driver.switchToFrame(iframe); + await driver.clickElement({ + text: 'Open this warning in a new tab', + }); + await driver.switchToWindowWithTitle('MetaMask Phishing Detection'); + await driver.clickElement({ + text: 'Back to safety', + }); + + // Ensure we're redirected to wallet home page + const homePage = await driver.findElement('.home__main-view'); + const homePageDisplayed = await homePage.isDisplayed(); + + assert.equal(homePageDisplayed, true); + }, + ); + }); }); diff --git a/test/e2e/webdriver/driver.js b/test/e2e/webdriver/driver.js index 09bdccb1bb1a..2b2fcb3860f1 100644 --- a/test/e2e/webdriver/driver.js +++ b/test/e2e/webdriver/driver.js @@ -428,10 +428,6 @@ class Driver { const artifactDir = `./test-artifacts/${this.browser}/${title}`; const filepathBase = `${artifactDir}/test-failure`; await fs.mkdir(artifactDir, { recursive: true }); - const isPageError = await this.isElementPresent('.error-page__details'); - if (isPageError) { - await this.clickElement('.error-page__details'); - } const screenshot = await this.driver.takeScreenshot(); await fs.writeFile(`${filepathBase}-screenshot.png`, screenshot, { encoding: 'base64', @@ -482,20 +478,22 @@ class Driver { // 4Byte 'Failed to load resource: the server responded with a status of 502 (Bad Gateway)', ]; + const { errors } = this; const cdpConnection = await this.driver.createCDPConnection('page'); await this.driver.onLogEvent(cdpConnection, (event) => { if (event.type === 'error') { - const eventDescription = event.args.filter( + const eventDescriptions = event.args.filter( (err) => err.description !== undefined, ); - const [{ description }] = eventDescription; + + const [eventDescription] = eventDescriptions; const ignore = ignoredErrorMessages.some((message) => - description.includes(message), + eventDescription?.description.includes(message), ); if (!ignore) { - errors.push(description); - logBrowserError(failOnConsoleError, description); + errors.push(eventDescription?.description); + logBrowserError(failOnConsoleError, eventDescription?.description); } } }); diff --git a/ui/components/app/connected-status-indicator/connected-status-indicator.js b/ui/components/app/connected-status-indicator/connected-status-indicator.js index 1abca94f3aca..55a268510276 100644 --- a/ui/components/app/connected-status-indicator/connected-status-indicator.js +++ b/ui/components/app/connected-status-indicator/connected-status-indicator.js @@ -8,13 +8,17 @@ import { STATUS_NOT_CONNECTED, } from '../../../helpers/constants/connected-sites'; import ColorIndicator from '../../ui/color-indicator'; -import { Color } from '../../../helpers/constants/design-system'; +import { + BackgroundColor, + Color, +} from '../../../helpers/constants/design-system'; import { useI18nContext } from '../../../hooks/useI18nContext'; import { getAddressConnectedSubjectMap, getOriginOfCurrentTab, getSelectedAddress, } from '../../../selectors'; +import { MultichainConnectedSiteMenu } from '../../multichain'; export default function ConnectedStatusIndicator({ onClick }) { const t = useI18nContext(); @@ -38,23 +42,39 @@ export default function ConnectedStatusIndicator({ onClick }) { let indicatorType = ColorIndicator.TYPES.OUTLINE; let indicatorColor = Color.iconDefault; + let globalMenuColor = Color.iconAlternative; if (status === STATUS_CONNECTED) { indicatorColor = Color.successDefault; indicatorType = ColorIndicator.TYPES.PARTIAL; + globalMenuColor = Color.successDefault; } else if (status === STATUS_CONNECTED_TO_ANOTHER_ACCOUNT) { indicatorColor = Color.errorDefault; + globalMenuColor = BackgroundColor.backgroundDefault; } const text = status === STATUS_CONNECTED ? t('statusConnected') - : t('statusNotConnected'); - + : t('statusNotConnected'); // TODO: Remove text since we only need the tooltip text for new permission icon + const tooltipText = + status === STATUS_CONNECTED + ? t('tooltipSatusConnected') + : t('tooltipSatusNotConnected'); return ( ); } diff --git a/ui/components/app/menu-bar/menu-bar.js b/ui/components/app/menu-bar/menu-bar.js index bf80cad5da6e..3ddc301af563 100644 --- a/ui/components/app/menu-bar/menu-bar.js +++ b/ui/components/app/menu-bar/menu-bar.js @@ -30,7 +30,7 @@ export default function MenuBar() { return (
- {showStatus ? ( + {showStatus ? ( // TODO: Move the connection status menu icon to the correct position in header once we implement the new header history.push(CONNECTED_ACCOUNTS_ROUTE)} /> diff --git a/ui/components/institutional/jwt-url-form/__snapshots__/jwt-url-form.test.js.snap b/ui/components/institutional/jwt-url-form/__snapshots__/jwt-url-form.test.js.snap index 6c15c0327d35..cdf419d8938e 100644 --- a/ui/components/institutional/jwt-url-form/__snapshots__/jwt-url-form.test.js.snap +++ b/ui/components/institutional/jwt-url-form/__snapshots__/jwt-url-form.test.js.snap @@ -3,16 +3,16 @@ exports[`JwtUrlForm shows JWT text area when no jwt token exists 1`] = `

input text

@@ -25,10 +25,10 @@ exports[`JwtUrlForm shows JWT text area when no jwt token exists 1`] = `

{ const showJwtDropdown = props.jwtList.length >= 1; return ( - + {showJwtDropdown && ( { )} {showJwtDropdown && !showAddNewToken && ( {t('or')} +
+ +
+
+`; diff --git a/ui/components/multichain/network-list-item/index.js b/ui/components/multichain/network-list-item/index.js new file mode 100644 index 000000000000..7fc23e245348 --- /dev/null +++ b/ui/components/multichain/network-list-item/index.js @@ -0,0 +1 @@ +export { NetworkListItem } from './network-list-item'; diff --git a/ui/components/multichain/network-list-item/index.scss b/ui/components/multichain/network-list-item/index.scss new file mode 100644 index 000000000000..a7a1e6aa158e --- /dev/null +++ b/ui/components/multichain/network-list-item/index.scss @@ -0,0 +1,51 @@ +.multichain-network-list-item { + position: relative; + cursor: pointer; + + &:not(.multichain-network-list-item--selected) { + &:hover, + &:focus-within { + background: var(--color-background-default-hover); + } + } + + a:hover, + a:focus { + color: inherit; + } + + &:hover, + &:focus, + &:focus-within { + .multichain-network-list-item__delete { + visibility: visible; + } + } + + &__network-name { + width: 100%; + flex: 1; + overflow: hidden; + text-align: start; + + button:hover { + opacity: 1; + } + } + + &__tooltip { + display: inline; + } + + &__selected-indicator { + width: 4px; + height: calc(100% - 8px); + position: absolute; + top: 4px; + left: 4px; + } + + &__delete { + visibility: hidden; + } +} diff --git a/ui/components/multichain/network-list-item/network-list-item.js b/ui/components/multichain/network-list-item/network-list-item.js new file mode 100644 index 000000000000..02f030e83847 --- /dev/null +++ b/ui/components/multichain/network-list-item/network-list-item.js @@ -0,0 +1,108 @@ +import React from 'react'; +import classnames from 'classnames'; +import PropTypes from 'prop-types'; +import Box from '../../ui/box/box'; +import { + AlignItems, + IconColor, + BorderRadius, + Color, + Size, + JustifyContent, + TextColor, + BLOCK_SIZES, +} from '../../../helpers/constants/design-system'; +import { + AvatarNetwork, + ButtonIcon, + ButtonLink, + ICON_NAMES, +} from '../../component-library'; +import { useI18nContext } from '../../../hooks/useI18nContext'; +import Tooltip from '../../ui/tooltip/tooltip'; + +const MAXIMUM_CHARACTERS_WITHOUT_TOOLTIP = 17; + +export const NetworkListItem = ({ + name, + iconSrc, + selected = false, + onClick, + onDeleteClick, +}) => { + const t = useI18nContext(); + return ( + + {selected && ( + + )} + + + + {name.length > MAXIMUM_CHARACTERS_WITHOUT_TOOLTIP ? ( + + {name} + + ) : ( + name + )} + + + {onDeleteClick ? ( + { + e.stopPropagation(); + onDeleteClick(); + }} + /> + ) : null} + + ); +}; + +NetworkListItem.propTypes = { + /** + * The name of the network + */ + name: PropTypes.string.isRequired, + /** + * Path to the Icon image + */ + iconSrc: PropTypes.string, + /** + * Represents if the network item is selected + */ + selected: PropTypes.bool, + /** + * Executes when the item is clicked + */ + onClick: PropTypes.func.isRequired, + /** + * Executes when the delete icon is clicked + */ + onDeleteClick: PropTypes.func, +}; diff --git a/ui/components/multichain/network-list-item/network-list-item.stories.js b/ui/components/multichain/network-list-item/network-list-item.stories.js new file mode 100644 index 000000000000..51b15f9823cc --- /dev/null +++ b/ui/components/multichain/network-list-item/network-list-item.stories.js @@ -0,0 +1,67 @@ +import React from 'react'; +import { NetworkListItem } from '.'; + +export default { + title: 'Components/Multichain/NetworkListItem', + component: NetworkListItem, + argTypes: { + name: { + control: 'text', + }, + selected: { + control: 'boolean', + }, + onClick: { + action: 'onClick', + }, + onDeleteClick: { + action: 'onDeleteClick', + }, + iconSrc: { + action: 'text', + }, + }, + args: { + name: 'Ethereum', + iconSrc: '', + selected: false, + }, +}; + +export const DefaultStory = (args) => ( +
+ +
+); + +export const IconStory = (args) => ( +
+ +
+); +IconStory.args = { iconSrc: './images/matic-token.png', name: 'Polygon' }; + +export const SelectedStory = (args) => ( +
+ +
+); +SelectedStory.args = { selected: true }; + +export const ChaosStory = (args) => ( +
+ +
+); +ChaosStory.args = { + name: 'This is a super long network name that should be ellipsized', + selected: true, +}; diff --git a/ui/components/multichain/network-list-item/network-list-item.test.js b/ui/components/multichain/network-list-item/network-list-item.test.js new file mode 100644 index 000000000000..4c4215d02b10 --- /dev/null +++ b/ui/components/multichain/network-list-item/network-list-item.test.js @@ -0,0 +1,81 @@ +/* eslint-disable jest/require-top-level-describe */ +import React from 'react'; +import { fireEvent, render } from '@testing-library/react'; +import { + MATIC_TOKEN_IMAGE_URL, + POLYGON_DISPLAY_NAME, +} from '../../../../shared/constants/network'; +import { NetworkListItem } from '.'; + +const DEFAULT_PROPS = { + name: POLYGON_DISPLAY_NAME, + iconSrc: MATIC_TOKEN_IMAGE_URL, + selected: false, + onClick: () => undefined, + onDeleteClick: () => undefined, +}; + +describe('NetworkListItem', () => { + it('renders properly', () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it('does not render the delete icon when no onDeleteClick is clicked', () => { + const { container } = render( + , + ); + expect( + container.querySelector('.multichain-network-list-item__delete'), + ).toBeNull(); + }); + + it('shows as selected when selected', () => { + const { container } = render( + , + ); + expect( + container.querySelector( + '.multichain-network-list-item__selected-indicator', + ), + ).toBeInTheDocument(); + }); + + it('renders a tooltip when the network name is very long', () => { + const { container } = render( + , + ); + expect( + container.querySelector('.multichain-network-list-item__tooltip'), + ).toBeInTheDocument(); + }); + + it('executes onClick when the item is clicked', () => { + const onClick = jest.fn(); + const { container } = render( + , + ); + fireEvent.click(container.querySelector('.multichain-network-list-item')); + expect(onClick).toHaveBeenCalled(); + }); + + it('executes onDeleteClick when the delete button is clicked', () => { + const onDeleteClick = jest.fn(); + const onClick = jest.fn(); + const { container } = render( + , + ); + fireEvent.click( + container.querySelector('.multichain-network-list-item__delete'), + ); + expect(onDeleteClick).toHaveBeenCalledTimes(1); + expect(onClick).toHaveBeenCalledTimes(0); + }); +}); diff --git a/ui/components/multichain/network-list-menu/index.js b/ui/components/multichain/network-list-menu/index.js new file mode 100644 index 000000000000..6e11c426a24d --- /dev/null +++ b/ui/components/multichain/network-list-menu/index.js @@ -0,0 +1 @@ +export { NetworkListMenu } from './network-list-menu'; diff --git a/ui/components/multichain/network-list-menu/index.scss b/ui/components/multichain/network-list-menu/index.scss new file mode 100644 index 000000000000..fbd2ed7ba617 --- /dev/null +++ b/ui/components/multichain/network-list-menu/index.scss @@ -0,0 +1,4 @@ +.multichain-network-list-menu { + max-height: 200px; + overflow: auto; +} diff --git a/ui/components/multichain/network-list-menu/network-list-menu.js b/ui/components/multichain/network-list-menu/network-list-menu.js new file mode 100644 index 000000000000..0cf1818ca69c --- /dev/null +++ b/ui/components/multichain/network-list-menu/network-list-menu.js @@ -0,0 +1,122 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { useDispatch, useSelector } from 'react-redux'; +import { useHistory } from 'react-router-dom'; +import { useI18nContext } from '../../../hooks/useI18nContext'; +import Popover from '../../ui/popover/popover.component'; +import { NetworkListItem } from '../network-list-item'; +import { + setActiveNetwork, + showModal, + setShowTestNetworks, + setProviderType, +} from '../../../store/actions'; +import { CHAIN_IDS, TEST_CHAINS } from '../../../../shared/constants/network'; +import { + getShowTestNetworks, + getAllNetworks, + getCurrentChainId, +} from '../../../selectors'; +import Box from '../../ui/box/box'; +import ToggleButton from '../../ui/toggle-button'; +import { + DISPLAY, + JustifyContent, +} from '../../../helpers/constants/design-system'; +import { Button, BUTTON_TYPES, Text } from '../../component-library'; +import { ADD_POPULAR_CUSTOM_NETWORK } from '../../../helpers/constants/routes'; +import { getEnvironmentType } from '../../../../app/scripts/lib/util'; +import { ENVIRONMENT_TYPE_FULLSCREEN } from '../../../../shared/constants/app'; + +const UNREMOVABLE_CHAIN_IDS = [CHAIN_IDS.MAINNET, ...TEST_CHAINS]; + +export const NetworkListMenu = ({ closeMenu }) => { + const t = useI18nContext(); + const networks = useSelector(getAllNetworks); + const showTestNetworks = useSelector(getShowTestNetworks); + const currentChainId = useSelector(getCurrentChainId); + const dispatch = useDispatch(); + const history = useHistory(); + + const environmentType = getEnvironmentType(); + const isFullScreen = environmentType === ENVIRONMENT_TYPE_FULLSCREEN; + + return ( + + <> + + {networks.map((network) => { + const isCurrentNetwork = currentChainId === network.chainId; + const canDeleteNetwork = + !isCurrentNetwork && + !UNREMOVABLE_CHAIN_IDS.includes(network.chainId); + + return ( + { + if (network.providerType) { + dispatch(setProviderType(network.providerType)); + } else { + dispatch(setActiveNetwork(network.id)); + } + closeMenu(); + }} + onDeleteClick={ + canDeleteNetwork + ? () => { + dispatch( + showModal({ + name: 'CONFIRM_DELETE_NETWORK', + target: network.id || network.chainId, + onConfirm: () => undefined, + }), + ); + closeMenu(); + } + : null + } + /> + ); + })} + + + {t('showTestnetNetworks')} + dispatch(setShowTestNetworks(!value))} + /> + + + + + + + ); +}; + +NetworkListMenu.propTypes = { + /** + * Executes when the menu should be closed + */ + closeMenu: PropTypes.func.isRequired, +}; diff --git a/ui/components/multichain/network-list-menu/network-list-menu.stories.js b/ui/components/multichain/network-list-menu/network-list-menu.stories.js new file mode 100644 index 000000000000..0629cd8e670e --- /dev/null +++ b/ui/components/multichain/network-list-menu/network-list-menu.stories.js @@ -0,0 +1,58 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import testData from '../../../../.storybook/test-data'; +import configureStore from '../../../store/store'; +import { + OPTIMISM_DISPLAY_NAME, + CHAIN_IDS, + OPTIMISM_TOKEN_IMAGE_URL, + BSC_DISPLAY_NAME, + BNB_TOKEN_IMAGE_URL, +} from '../../../../shared/constants/network'; +import { NetworkListMenu } from '.'; + +const customNetworkStore = configureStore({ + ...testData, + metamask: { + ...testData.metamask, + preferences: { + showTestNetworks: true, + }, + networkConfigurations: { + ...testData.metamask.networkConfigurations, + ...{ + 'test-networkConfigurationId-3': { + rpcUrl: 'https://testrpc.com', + chainId: CHAIN_IDS.OPTIMISM, + nickname: OPTIMISM_DISPLAY_NAME, + rpcPrefs: { imageUrl: OPTIMISM_TOKEN_IMAGE_URL }, + }, + 'test-networkConfigurationId-4': { + rpcUrl: 'https://testrpc.com', + chainId: CHAIN_IDS.BSC, + nickname: BSC_DISPLAY_NAME, + rpcPrefs: { imageUrl: BNB_TOKEN_IMAGE_URL }, + }, + }, + }, + }, +}); + +export default { + title: 'Components/Multichain/NetworkListMenu', + component: NetworkListMenu, + argTypes: { + closeMenu: { + action: 'closeMenu', + }, + }, +}; + +export const DefaultStory = (args) => ; +DefaultStory.decorators = [ + (Story) => ( + + + + ), +]; diff --git a/ui/components/multichain/network-list-menu/network-list-menu.test.js b/ui/components/multichain/network-list-menu/network-list-menu.test.js new file mode 100644 index 000000000000..e87876f39940 --- /dev/null +++ b/ui/components/multichain/network-list-menu/network-list-menu.test.js @@ -0,0 +1,61 @@ +/* eslint-disable jest/require-top-level-describe */ +import React from 'react'; +import { fireEvent, renderWithProvider } from '../../../../test/jest'; +import configureStore from '../../../store/store'; +import mockState from '../../../../test/data/mock-state.json'; +import { + MAINNET_DISPLAY_NAME, + SEPOLIA_DISPLAY_NAME, +} from '../../../../shared/constants/network'; +import { NetworkListMenu } from '.'; + +const mockSetShowTestNetworks = jest.fn(); +const mockSetProviderType = jest.fn(); +jest.mock('../../../store/actions.ts', () => ({ + setShowTestNetworks: () => mockSetShowTestNetworks, + setProviderType: () => mockSetProviderType, +})); + +const render = (showTestNetworks = false) => { + const store = configureStore({ + metamask: { + ...mockState.metamask, + preferences: { + showTestNetworks, + }, + }, + }); + return renderWithProvider(, store); +}; + +describe('NetworkListMenu', () => { + it('displays important controls', () => { + const { getByText } = render(); + + expect(getByText('Add network')).toBeInTheDocument(); + expect(getByText('Show test networks')).toBeInTheDocument(); + }); + + it('renders mainnet item', () => { + const { getByText } = render(); + expect(getByText(MAINNET_DISPLAY_NAME)).toBeInTheDocument(); + }); + + it('renders test networks when it should', () => { + const { getByText } = render(true); + expect(getByText(SEPOLIA_DISPLAY_NAME)).toBeInTheDocument(); + }); + + it('toggles showTestNetworks when toggle is clicked', () => { + const { queryAllByRole } = render(); + const [testNetworkToggle] = queryAllByRole('checkbox'); + fireEvent.click(testNetworkToggle); + expect(mockSetShowTestNetworks).toHaveBeenCalled(); + }); + + it('switches networks when an item is clicked', () => { + const { getByText } = render(); + fireEvent.click(getByText(MAINNET_DISPLAY_NAME)); + expect(mockSetProviderType).toHaveBeenCalled(); + }); +}); diff --git a/ui/helpers/constants/common.ts b/ui/helpers/constants/common.ts index da6e58a345a9..62e8077b8c38 100644 --- a/ui/helpers/constants/common.ts +++ b/ui/helpers/constants/common.ts @@ -12,8 +12,8 @@ _supportRequestLink = ///: BEGIN:ONLY_INCLUDE_IN(mmi) _supportRequestLink = 'https://mmi-support.zendesk.com/hc/en-us/requests/new'; -const _mmiWebSite = 'https://metamask.io/institutions/'; -export const MMI_WEB_SITE = _mmiWebSite; +export const SUPPORT_LINK = 'https://mmi-support.zendesk.com/hc/en-us'; +export const MMI_WEB_SITE = 'https://metamask.io/institutions/'; ///: END:ONLY_INCLUDE_IN export const SUPPORT_REQUEST_LINK = _supportRequestLink; diff --git a/ui/helpers/constants/routes.ts b/ui/helpers/constants/routes.ts index 8b206ed6d857..f448f07bc2b0 100644 --- a/ui/helpers/constants/routes.ts +++ b/ui/helpers/constants/routes.ts @@ -20,6 +20,9 @@ const CONTACT_LIST_ROUTE = '/settings/contact-list'; const CONTACT_EDIT_ROUTE = '/settings/contact-list/edit-contact'; const CONTACT_ADD_ROUTE = '/settings/contact-list/add-contact'; const CONTACT_VIEW_ROUTE = '/settings/contact-list/view-contact'; +///: BEGIN:ONLY_INCLUDE_IN(mmi) +const CUSTODY_ACCOUNT_ROUTE = '/new-account/custody'; +///: END:ONLY_INCLUDE_IN const REVEAL_SEED_ROUTE = '/seed'; const MOBILE_SYNC_ROUTE = '/mobile-sync'; const RESTORE_VAULT_ROUTE = '/restore-vault'; @@ -203,6 +206,9 @@ export { CONTACT_EDIT_ROUTE, CONTACT_ADD_ROUTE, CONTACT_VIEW_ROUTE, + ///: BEGIN:ONLY_INCLUDE_IN(mmi) + CUSTODY_ACCOUNT_ROUTE, + ///: END:ONLY_INCLUDE_IN NETWORKS_ROUTE, NETWORKS_FORM_ROUTE, ADD_NETWORK_ROUTE, diff --git a/ui/hooks/useNftsCollections.js b/ui/hooks/useNftsCollections.js index d6b7251b730c..17d639a15287 100644 --- a/ui/hooks/useNftsCollections.js +++ b/ui/hooks/useNftsCollections.js @@ -4,11 +4,16 @@ import { isEqual } from 'lodash'; import { getNfts, getNftContracts } from '../ducks/metamask/metamask'; import { getCurrentChainId, getSelectedAddress } from '../selectors'; import { usePrevious } from './usePrevious'; +import { useI18nContext } from './useI18nContext'; export function useNftsCollections() { + const t = useI18nContext(); + const previouslyOwnedText = t('nftsPreviouslyOwned'); + const unknownCollectionText = t('unknownCollection'); + const [collections, setCollections] = useState({}); const [previouslyOwnedCollection, setPreviouslyOwnedCollection] = useState({ - collectionName: 'Previously Owned', + collectionName: previouslyOwnedText, nfts: [], }); const nfts = useSelector(getNfts); @@ -19,6 +24,7 @@ export function useNftsCollections() { const prevNfts = usePrevious(nfts); const prevChainId = usePrevious(chainId); const prevSelectedAddress = usePrevious(selectedAddress); + useEffect(() => { const getCollections = () => { setNftsLoading(true); @@ -27,7 +33,7 @@ export function useNftsCollections() { } const newCollections = {}; const newPreviouslyOwnedCollections = { - collectionName: 'Previously Owned', + collectionName: previouslyOwnedText, nfts: [], }; @@ -41,7 +47,7 @@ export function useNftsCollections() { ({ address }) => address === nft.address, ); newCollections[nft.address] = { - collectionName: collectionContract?.name || nft.name, + collectionName: collectionContract?.name || unknownCollectionText, collectionImage: collectionContract?.logo || nft.image, nfts: [nft], }; diff --git a/ui/pages/institutional/confirm-add-custodian-token/confirm-add-custodian-token.js b/ui/pages/institutional/confirm-add-custodian-token/confirm-add-custodian-token.js new file mode 100644 index 000000000000..3046ab54d8f3 --- /dev/null +++ b/ui/pages/institutional/confirm-add-custodian-token/confirm-add-custodian-token.js @@ -0,0 +1,268 @@ +import React, { useContext, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useHistory } from 'react-router-dom'; +import PulseLoader from '../../../components/ui/pulse-loader'; +import { CUSTODY_ACCOUNT_ROUTE } from '../../../helpers/constants/routes'; +import { + AlignItems, + DISPLAY, + TextColor, + TEXT_ALIGN, + FLEX_DIRECTION, +} from '../../../helpers/constants/design-system'; +import { BUILT_IN_NETWORKS } from '../../../../shared/constants/network'; +import { I18nContext } from '../../../contexts/i18n'; +import { MetaMetricsContext } from '../../../contexts/metametrics'; +import { getMostRecentOverviewPage } from '../../../ducks/history/history'; +import { setProviderType } from '../../../store/actions'; +import { mmiActionsFactory } from '../../../store/institutional/institution-background'; +import { + Label, + Text, + ButtonLink, + Button, + BUTTON_SIZES, + BUTTON_TYPES, +} from '../../../components/component-library'; +import Box from '../../../components/ui/box'; + +const ConfirmAddCustodianToken = () => { + const t = useContext(I18nContext); + const dispatch = useDispatch(); + const history = useHistory(); + const trackEvent = useContext(MetaMetricsContext); + const mmiActions = mmiActionsFactory(); + + const mostRecentOverviewPage = useSelector(getMostRecentOverviewPage); + const connectRequests = useSelector( + (state) => state.metamask.institutionalFeatures?.connectRequests, + ); + const complianceActivated = useSelector((state) => + Boolean(state.metamask.institutionalFeatures?.complianceProjectId), + ); + const [showMore, setShowMore] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [connectError, setConnectError] = useState(''); + + const handleConnectError = (e) => { + let errorMessage = e.message; + + if (!errorMessage) { + errorMessage = 'Connection error'; + } + + setConnectError(errorMessage); + setIsLoading(false); + }; + + const renderSelectedToken = () => { + const connectRequest = connectRequests ? connectRequests[0] : undefined; + + return ( + + + {showMore && connectRequest?.token + ? connectRequest?.token + : `...${connectRequest?.token.slice(-9)}`} + + {!showMore && ( + + { + setShowMore(true); + }} + > + {t('ShowMore')} + + + )} + + ); + }; + + const connectRequest = connectRequests ? connectRequests[0] : undefined; + + if (!connectRequest) { + history.push(mostRecentOverviewPage); + return null; + } + + trackEvent({ + category: 'MMI', + event: 'Custodian onboarding', + properties: { + actions: 'Custodian RPC request', + custodian: connectRequest.custodian, + apiUrl: connectRequest.apiUrl, + }, + }); + + let custodianLabel = ''; + + if ( + connectRequest.labels && + connectRequest.labels.some((label) => label.key === 'service') + ) { + custodianLabel = connectRequest.labels.find( + (label) => label.key === 'service', + ).value; + } + + return ( + + + {t('custodianAccount')} + + {t('mmiAddToken', [connectRequest.origin])} + + + + {custodianLabel && ( + <> + + {t('custodian')} + + + + )} + + + {t('token')} + + + {renderSelectedToken()} + + {connectRequest.apiUrl && ( + + + {t('apiUrl')} + + + {connectRequest.apiUrl} + + + )} + + + {!complianceActivated && ( + + + {connectError} + + + )} + + + {isLoading ? ( + + ) : ( + + + + + )} + + + ); +}; + +export default ConfirmAddCustodianToken; diff --git a/ui/pages/institutional/confirm-add-custodian-token/confirm-add-custodian-token.stories.js b/ui/pages/institutional/confirm-add-custodian-token/confirm-add-custodian-token.stories.js new file mode 100644 index 000000000000..01055b785d8d --- /dev/null +++ b/ui/pages/institutional/confirm-add-custodian-token/confirm-add-custodian-token.stories.js @@ -0,0 +1,43 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import configureStore from '../../../store/store'; +import testData from '../../../../.storybook/test-data'; +import ConfirmAddCustodianToken from '.'; + +const customData = { + ...testData, + metamask: { + ...testData.metamask, + institutionalFeatures: { + complianceProjectId: '', + connectRequests: [ + { + labels: [ + { + key: 'service', + value: 'test', + }, + ], + origin: 'origin', + token: 'awesomeTestToken', + feature: 'custodian', + service: 'Saturn', + apiUrl: 'https://www.apiurl.net/v1', + chainId: 1, + }, + ], + }, + }, +}; + +const store = configureStore(customData); + +export default { + title: 'Pages/Institutional/ConfirmAddCustodianToken', + decorators: [(story) => {story()}], + component: ConfirmAddCustodianToken, +}; + +export const DefaultStory = () => ; + +DefaultStory.storyName = 'ConfirmAddCustodianToken'; diff --git a/ui/pages/institutional/confirm-add-custodian-token/confirm-add-custodian-token.test.js b/ui/pages/institutional/confirm-add-custodian-token/confirm-add-custodian-token.test.js new file mode 100644 index 000000000000..37660c0da7f9 --- /dev/null +++ b/ui/pages/institutional/confirm-add-custodian-token/confirm-add-custodian-token.test.js @@ -0,0 +1,126 @@ +import React from 'react'; +import { screen, fireEvent } from '@testing-library/react'; +import configureMockStore from 'redux-mock-store'; +import { renderWithProvider } from '../../../../test/lib/render-helpers'; +import ConfirmAddCustodianToken from './confirm-add-custodian-token'; + +describe('Confirm Add Custodian Token', () => { + const mockStore = { + metamask: { + provider: { + type: 'test', + }, + preferences: { + useNativeCurrencyAsPrimaryCurrency: true, + }, + institutionalFeatures: { + complianceProjectId: '', + connectRequests: [ + { + labels: [ + { + key: 'service', + value: 'test', + }, + ], + origin: 'origin', + token: 'testToken', + feature: 'custodian', + service: 'Jupiter', + apiUrl: 'https://', + chainId: 1, + }, + ], + }, + }, + history: { + push: '/', + mostRecentOverviewPage: '/', + }, + }; + + const store = configureMockStore()(mockStore); + + it('opens confirm add custodian token with correct token', () => { + renderWithProvider(, store); + + const tokenContainer = screen.getByText('...testToken'); + expect(tokenContainer).toBeInTheDocument(); + }); + + it('shows the custodian on cancel click', () => { + renderWithProvider(, store); + + const cancelButton = screen.getByTestId('cancel-btn'); + + fireEvent.click(cancelButton); + + expect(screen.getByText('Custodian')).toBeInTheDocument(); + }); + + it('tries to connect to custodian with empty token', async () => { + const customMockedStore = { + metamask: { + provider: { + type: 'test', + }, + preferences: { + useNativeCurrencyAsPrimaryCurrency: true, + }, + institutionalFeatures: { + complianceProjectId: '', + connectRequests: [ + { + labels: [ + { + key: 'service', + value: 'test', + }, + ], + origin: 'origin', + token: '', + feature: 'custodian', + service: 'Jupiter', + apiUrl: 'https://', + chainId: 1, + }, + ], + }, + }, + history: { + push: '/', + mostRecentOverviewPage: '/', + }, + }; + + const customStore = configureMockStore()(customMockedStore); + + renderWithProvider(, customStore); + + const confirmButton = screen.getByTestId('confirm-btn'); + fireEvent.click(confirmButton); + + const errorMessage = screen.getByTestId('connect-custodian-token-error'); + + expect(errorMessage).toBeVisible(); + }); + + it('clicks the confirm button and shows the test value', async () => { + renderWithProvider(, store); + + const confirmButton = screen.getByTestId('confirm-btn'); + fireEvent.click(confirmButton); + + expect(screen.getByText('test')).toBeInTheDocument(); + }); + + it('shows the error area', () => { + renderWithProvider(, store); + + const confirmButton = screen.getByTestId('confirm-btn'); + + fireEvent.click(confirmButton); + + expect(screen.getByTestId('error-message')).toBeVisible(); + }); +}); diff --git a/ui/pages/institutional/confirm-add-custodian-token/index.js b/ui/pages/institutional/confirm-add-custodian-token/index.js new file mode 100644 index 000000000000..39b01e55ee6c --- /dev/null +++ b/ui/pages/institutional/confirm-add-custodian-token/index.js @@ -0,0 +1 @@ +export { default } from './confirm-add-custodian-token'; diff --git a/ui/pages/institutional/confirm-add-custodian-token/index.scss b/ui/pages/institutional/confirm-add-custodian-token/index.scss new file mode 100644 index 000000000000..17845c049631 --- /dev/null +++ b/ui/pages/institutional/confirm-add-custodian-token/index.scss @@ -0,0 +1,8 @@ +.add_custodian_token_confirm { + &__token, + &__url { + height: auto; + overflow-wrap: break-word; + opacity: 0.8; + } +} diff --git a/ui/pages/settings/info-tab/info-tab.component.js b/ui/pages/settings/info-tab/info-tab.component.js index 5c6e4b66ecc4..8fef74d1210a 100644 --- a/ui/pages/settings/info-tab/info-tab.component.js +++ b/ui/pages/settings/info-tab/info-tab.component.js @@ -4,7 +4,12 @@ import PropTypes from 'prop-types'; import Button from '../../../components/ui/button'; import { Tag } from '../../../components/component-library'; -import { SUPPORT_REQUEST_LINK } from '../../../helpers/constants/common'; +import { + SUPPORT_REQUEST_LINK, + ///: BEGIN:ONLY_INCLUDE_IN(mmi) + MMI_WEB_SITE, + ///: END:ONLY_INCLUDE_IN +} from '../../../helpers/constants/common'; import { isBeta } from '../../../helpers/utils/build-types'; import { getNumberOfSettingsInSection, @@ -47,6 +52,17 @@ export default class InfoTab extends PureComponent { renderInfoLinks() { const { t } = this.context; + let privacyUrl, siteUrl; + + ///: BEGIN:ONLY_INCLUDE_IN(mmi) + privacyUrl = 'https://consensys.net/codefi/about/privacy-policy/'; + siteUrl = MMI_WEB_SITE; + ///: END:ONLY_INCLUDE_IN + + ///: BEGIN:ONLY_INCLUDE_IN(main,beta,flask) + privacyUrl = 'https://metamask.io/privacy.html'; + siteUrl = 'https://metamask.io/'; + ///: END:ONLY_INCLUDE_IN return (
@@ -56,7 +72,7 @@ export default class InfoTab extends PureComponent {