From 7b4eb5b22a8aaacc4db63d9a47f2adb321841aee Mon Sep 17 00:00:00 2001 From: Nidhi Kumari Date: Thu, 26 Sep 2024 16:37:59 +0100 Subject: [PATCH] feat: Editing flow (#26635) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR is to add editing flow while switching networks via dapp This PR also includes the connections flow which includes editing ## **Related issues** Fixes: 1. https://github.com/MetaMask/MetaMask-planning/issues/2683 2. https://github.com/MetaMask/MetaMask-planning/issues/2684 3. https://github.com/MetaMask/MetaMask-planning/issues/2697 4. https://github.com/MetaMask/MetaMask-planning/issues/2698 5. https://github.com/MetaMask/MetaMask-planning/issues/2663 ## **Manual testing steps** 1. run the extension with CHAIN_PERMISSIONS flag 2. Connect to a dapp 3. Switch network via metamask 6. toast should show up 7. Then click on edit permissions, it should route to new permissions screen 8. We have edit networks and edit accounts modal there ## **Screenshots/Recordings** ### **Before** NA ### **After** https://github.com/user-attachments/assets/a9dbaecf-c96b-485c-b639-f50cbe30317c https://github.com/user-attachments/assets/8638bb1f-3739-4a2b-84ae-dca24f8abe6d ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-extension/blob/develop/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --------- Co-authored-by: Alex Co-authored-by: Jiexi Luan --- app/_locales/en/messages.json | 53 ++++ .../controllers/permissions/background-api.js | 71 ++++- .../permissions/background-api.test.js | 209 ++++++++++++- .../controllers/permissions/selectors.js | 77 +++-- .../controllers/permissions/selectors.test.js | 105 ++++++- .../controllers/permissions/specifications.js | 10 +- .../handlers/add-ethereum-chain.js | 4 + .../handlers/add-ethereum-chain.test.js | 29 +- .../handlers/ethereum-chain-utils.js | 8 +- app/scripts/metamask-controller.js | 78 ++++- shared/constants/permissions.ts | 4 + test/data/mock-send-state.json | 1 + test/data/mock-state.json | 1 + .../permission-cell/permission-cell-status.js | 9 +- .../permission-page-container.component.js | 32 +- .../app-header-unlocked-content.tsx | 11 +- .../connect-accounts-modal-list.tsx | 6 +- .../connect-accounts-modal.tsx | 21 +- .../disconnect-all-modal.tsx | 10 +- .../edit-accounts-modal.stories.tsx | 41 +++ .../edit-accounts-modal.test.tsx | 55 +++- .../edit-accounts-modal.tsx | 239 +++++++++++---- .../edit-networks-modal.test.js.snap | 3 + .../edit-networks-modal.js | 251 ++++++++++++---- .../edit-networks-modal.stories.js | 55 +++- .../edit-networks-modal.test.js | 92 ++++++ .../network-list-menu/network-list-menu.tsx | 21 +- ui/components/multichain/pages/index.js | 1 + .../permissions-page/connection-list-item.js | 85 ++++-- .../connection-list-tooltip.js | 4 +- .../permissions-page/permissions-page.js | 7 +- .../review-permissions-page.test.tsx.snap | 81 +++++ .../pages/review-permissions-page/index.js | 2 + .../review-permission.types.tsx | 36 +++ .../review-permissions-page.stories.tsx | 10 + .../review-permissions-page.test.tsx | 34 +++ .../review-permissions-page.tsx | 281 ++++++++++++++++++ ...ite-cell-connection-list-item.test.js.snap | 46 +++ .../site-cell-tooltip.test.js.snap | 241 +++++++++++++++ .../site-cell-connection-list-item.js | 131 ++++++++ .../site-cell-connection-list-item.test.js | 39 +++ .../site-cell/site-cell-tooltip.js | 190 ++++++++++++ .../site-cell/site-cell-tooltip.test.js | 221 ++++++++++++++ .../site-cell/site-cell.stories.tsx | 95 ++++++ .../site-cell/site-cell.tsx | 126 ++++++++ .../permissions-header/permissions-header.tsx | 83 ++++++ ui/components/ui/account-list/account-list.js | 11 +- ui/ducks/app/app.ts | 14 + ui/helpers/constants/routes.ts | 2 + .../__snapshots__/connect-page.test.tsx.snap | 221 ++++++++++++++ .../connect-page/connect-page.test.tsx | 77 +++++ .../connect-page/connect-page.tsx | 147 +++++++++ .../permissions-connect.component.js | 78 +++-- ui/pages/routes/routes.component.js | 43 ++- ui/pages/routes/routes.container.js | 6 + ui/selectors/permissions.js | 51 ++++ ui/selectors/selectors.js | 4 + ui/store/actionConstants.ts | 8 + ui/store/actions.test.js | 128 ++++++++ ui/store/actions.ts | 146 ++++++++- 60 files changed, 3832 insertions(+), 313 deletions(-) create mode 100644 ui/components/multichain/edit-networks-modal/__snapshots__/edit-networks-modal.test.js.snap create mode 100644 ui/components/multichain/edit-networks-modal/edit-networks-modal.test.js create mode 100644 ui/components/multichain/pages/review-permissions-page/__snapshots__/review-permissions-page.test.tsx.snap create mode 100644 ui/components/multichain/pages/review-permissions-page/index.js create mode 100644 ui/components/multichain/pages/review-permissions-page/review-permission.types.tsx create mode 100644 ui/components/multichain/pages/review-permissions-page/review-permissions-page.stories.tsx create mode 100644 ui/components/multichain/pages/review-permissions-page/review-permissions-page.test.tsx create mode 100644 ui/components/multichain/pages/review-permissions-page/review-permissions-page.tsx create mode 100644 ui/components/multichain/pages/review-permissions-page/site-cell/__snapshots__/site-cell-connection-list-item.test.js.snap create mode 100644 ui/components/multichain/pages/review-permissions-page/site-cell/__snapshots__/site-cell-tooltip.test.js.snap create mode 100644 ui/components/multichain/pages/review-permissions-page/site-cell/site-cell-connection-list-item.js create mode 100644 ui/components/multichain/pages/review-permissions-page/site-cell/site-cell-connection-list-item.test.js create mode 100644 ui/components/multichain/pages/review-permissions-page/site-cell/site-cell-tooltip.js create mode 100644 ui/components/multichain/pages/review-permissions-page/site-cell/site-cell-tooltip.test.js create mode 100644 ui/components/multichain/pages/review-permissions-page/site-cell/site-cell.stories.tsx create mode 100644 ui/components/multichain/pages/review-permissions-page/site-cell/site-cell.tsx create mode 100644 ui/components/multichain/permissions-header/permissions-header.tsx create mode 100644 ui/pages/permissions-connect/connect-page/__snapshots__/connect-page.test.tsx.snap create mode 100644 ui/pages/permissions-connect/connect-page/connect-page.test.tsx create mode 100644 ui/pages/permissions-connect/connect-page/connect-page.tsx diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index e39fe955e37a..b42895983048 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -162,6 +162,10 @@ "accountOptions": { "message": "Account options" }, + "accountPermissionToast": { + "message": "Account permissions updated for $1", + "description": "$1 represents connected dapp" + }, "accountSelectionRequired": { "message": "You need to select an account!" }, @@ -174,6 +178,12 @@ "accountsConnected": { "message": "Accounts connected" }, + "accountsPermissionsTitle": { + "message": "See your accounts and suggest transactions" + }, + "accountsSmallCase": { + "message": "accounts" + }, "active": { "message": "Active" }, @@ -1175,6 +1185,10 @@ "connectedWith": { "message": "Connected with" }, + "connectedWithAccount": { + "message": "Connected with $1", + "description": "$1 represents account name" + }, "connecting": { "message": "Connecting" }, @@ -1202,6 +1216,9 @@ "connectingToSepolia": { "message": "Connecting to Sepolia test network" }, + "connectionDescription": { + "message": "This site wants to" + }, "connectionFailed": { "message": "Connection failed" }, @@ -1594,6 +1611,10 @@ "disconnectAllAccountsText": { "message": "accounts" }, + "disconnectAllDescription": { + "message": "If you disconnect from $1, you’ll need to reconnect your accounts and networks to use this site again.", + "description": "$1 represents the website hostname" + }, "disconnectAllSnapsText": { "message": "Snaps" }, @@ -1605,6 +1626,10 @@ "message": "Disconnect all $1", "description": "$1 will map to `disconnectAllAccountsText` or `disconnectAllSnapsText`" }, + "disconnectMessage": { + "message": "This will disconnect you from $1", + "description": "$1 is the name of the dapp" + }, "disconnectPrompt": { "message": "Disconnect $1" }, @@ -1769,6 +1794,9 @@ "editPermission": { "message": "Edit permission" }, + "editPermissions": { + "message": "Edit permissions" + }, "editSpeedUpEditGasFeeModalTitle": { "message": "Edit speed up gas fee" }, @@ -2907,6 +2935,14 @@ "more": { "message": "more" }, + "moreAccounts": { + "message": "+ $1 more accounts", + "description": "$1 is the number of accounts" + }, + "moreNetworks": { + "message": "+ $1 more networks", + "description": "$1 is the number of networks" + }, "multichainAddEthereumChainConfirmationDescription": { "message": "You're adding this network to MetaMask and giving this site permission to use it." }, @@ -3089,6 +3125,10 @@ "networkOptions": { "message": "Network options" }, + "networkPermissionToast": { + "message": "Network permissions updated for $1", + "description": "$1 represents connected dapp" + }, "networkProvider": { "message": "Network provider" }, @@ -3127,6 +3167,9 @@ "networks": { "message": "Networks" }, + "networksSmallCase": { + "message": "networks" + }, "nevermind": { "message": "Nevermind" }, @@ -4034,6 +4077,9 @@ "permitSimulationDetailInfo": { "message": "You're giving the spender permission to spend this many tokens from your account." }, + "permittedChainToastUpdate": { + "message": "$1 has been given access to $2." + }, "personalAddressDetected": { "message": "Personal address detected. Input the token contract address." }, @@ -4403,6 +4449,13 @@ "requestNotVerifiedError": { "message": "Because of an error, this request was not verified by the security provider. Proceed with caution." }, + "requestingFor": { + "message": "Requesting for" + }, + "requestingForAccount": { + "message": "Requesting for $1", + "description": "Name of Account" + }, "requestsAwaitingAcknowledgement": { "message": "requests waiting to be acknowledged" }, diff --git a/app/scripts/controllers/permissions/background-api.js b/app/scripts/controllers/permissions/background-api.js index d3a29f129379..b778ff42385d 100644 --- a/app/scripts/controllers/permissions/background-api.js +++ b/app/scripts/controllers/permissions/background-api.js @@ -3,13 +3,10 @@ import { CaveatTypes, RestrictedMethods, } from '../../../../shared/constants/permissions'; -import { CaveatFactories } from './specifications'; +import { CaveatFactories, PermissionNames } from './specifications'; export function getPermissionBackgroundApiMethods(permissionController) { - const addMoreAccounts = (origin, accountOrAccounts) => { - const accounts = Array.isArray(accountOrAccounts) - ? accountOrAccounts - : [accountOrAccounts]; + const addMoreAccounts = (origin, accounts) => { const caveat = CaveatFactories.restrictReturnedAccounts(accounts); permissionController.grantPermissionsIncremental({ @@ -20,11 +17,21 @@ export function getPermissionBackgroundApiMethods(permissionController) { }); }; - return { - addPermittedAccount: (origin, account) => addMoreAccounts(origin, account), + const addMoreChains = (origin, chainIds) => { + const caveat = CaveatFactories.restrictNetworkSwitching(chainIds); + + permissionController.grantPermissionsIncremental({ + subject: { origin }, + approvedPermissions: { + [PermissionNames.permittedChains]: { caveats: [caveat] }, + }, + }); + }; - // To add more than one account when already connected to the dapp - addMorePermittedAccounts: (origin, accounts) => + return { + addPermittedAccount: (origin, account) => + addMoreAccounts(origin, [account]), + addPermittedAccounts: (origin, accounts) => addMoreAccounts(origin, accounts), removePermittedAccount: (origin, account) => { @@ -57,6 +64,52 @@ export function getPermissionBackgroundApiMethods(permissionController) { } }, + addPermittedChain: (origin, chainId) => addMoreChains(origin, [chainId]), + addPermittedChains: (origin, chainIds) => addMoreChains(origin, chainIds), + + removePermittedChain: (origin, chainId) => { + const { value: existingChains } = permissionController.getCaveat( + origin, + PermissionNames.permittedChains, + CaveatTypes.restrictNetworkSwitching, + ); + + const remainingChains = existingChains.filter( + (existingChain) => existingChain !== chainId, + ); + + if (remainingChains.length === existingChains.length) { + return; + } + + if (remainingChains.length === 0) { + permissionController.revokePermission( + origin, + PermissionNames.permittedChains, + ); + } else { + permissionController.updateCaveat( + origin, + PermissionNames.permittedChains, + CaveatTypes.restrictNetworkSwitching, + remainingChains, + ); + } + }, + + requestAccountsAndChainPermissionsWithId: async (origin) => { + const id = nanoid(); + permissionController.requestPermissions( + { origin }, + { + [PermissionNames.eth_accounts]: {}, + [PermissionNames.permittedChains]: {}, + }, + { id }, + ); + return id; + }, + requestAccountsPermissionWithId: async (origin) => { const id = nanoid(); permissionController.requestPermissions( diff --git a/app/scripts/controllers/permissions/background-api.test.js b/app/scripts/controllers/permissions/background-api.test.js index b6ba493ba7df..2a050b29a00e 100644 --- a/app/scripts/controllers/permissions/background-api.test.js +++ b/app/scripts/controllers/permissions/background-api.test.js @@ -3,15 +3,21 @@ import { RestrictedMethods, } from '../../../../shared/constants/permissions'; import { getPermissionBackgroundApiMethods } from './background-api'; -import { CaveatFactories } from './specifications'; +import { CaveatFactories, PermissionNames } from './specifications'; describe('permission background API methods', () => { - const getApprovedPermissions = (accounts) => ({ + const getEthAccountsPermissions = (accounts) => ({ [RestrictedMethods.eth_accounts]: { caveats: [CaveatFactories.restrictReturnedAccounts(accounts)], }, }); + const getPermittedChainsPermissions = (chainIds) => ({ + [PermissionNames.permittedChains]: { + caveats: [CaveatFactories.restrictNetworkSwitching(chainIds)], + }, + }); + describe('addPermittedAccount', () => { it('calls grantPermissionsIncremental with expected parameters', () => { const permissionController = { @@ -29,12 +35,12 @@ describe('permission background API methods', () => { permissionController.grantPermissionsIncremental, ).toHaveBeenCalledWith({ subject: { origin: 'foo.com' }, - approvedPermissions: getApprovedPermissions(['0x1']), + approvedPermissions: getEthAccountsPermissions(['0x1']), }); }); }); - describe('addMorePermittedAccounts', () => { + describe('addPermittedAccounts', () => { it('calls grantPermissionsIncremental with expected parameters for single account', () => { const permissionController = { grantPermissionsIncremental: jest.fn(), @@ -42,7 +48,7 @@ describe('permission background API methods', () => { getPermissionBackgroundApiMethods( permissionController, - ).addMorePermittedAccounts('foo.com', ['0x1']); + ).addPermittedAccounts('foo.com', ['0x1']); expect( permissionController.grantPermissionsIncremental, @@ -51,7 +57,7 @@ describe('permission background API methods', () => { permissionController.grantPermissionsIncremental, ).toHaveBeenCalledWith({ subject: { origin: 'foo.com' }, - approvedPermissions: getApprovedPermissions(['0x1']), + approvedPermissions: getEthAccountsPermissions(['0x1']), }); }); @@ -62,7 +68,7 @@ describe('permission background API methods', () => { getPermissionBackgroundApiMethods( permissionController, - ).addMorePermittedAccounts('foo.com', ['0x1', '0x2']); + ).addPermittedAccounts('foo.com', ['0x1', '0x2']); expect( permissionController.grantPermissionsIncremental, @@ -71,7 +77,7 @@ describe('permission background API methods', () => { permissionController.grantPermissionsIncremental, ).toHaveBeenCalledWith({ subject: { origin: 'foo.com' }, - approvedPermissions: getApprovedPermissions(['0x1', '0x2']), + approvedPermissions: getEthAccountsPermissions(['0x1', '0x2']), }); }); }); @@ -194,4 +200,191 @@ describe('permission background API methods', () => { ); }); }); + + describe('requestAccountsAndChainPermissionsWithId', () => { + it('request eth_accounts and permittedChains permissions and returns the request id', async () => { + const permissionController = { + requestPermissions: jest + .fn() + .mockImplementationOnce(async (_, __, { id }) => { + return [null, { id }]; + }), + }; + + const id = await getPermissionBackgroundApiMethods( + permissionController, + ).requestAccountsAndChainPermissionsWithId('foo.com'); + + expect(permissionController.requestPermissions).toHaveBeenCalledTimes(1); + expect(permissionController.requestPermissions).toHaveBeenCalledWith( + { origin: 'foo.com' }, + { + [PermissionNames.eth_accounts]: {}, + [PermissionNames.permittedChains]: {}, + }, + { id: expect.any(String) }, + ); + + expect(id.length > 0).toBe(true); + expect(id).toStrictEqual( + permissionController.requestPermissions.mock.calls[0][2].id, + ); + }); + }); + + describe('addPermittedChain', () => { + it('calls grantPermissionsIncremental with expected parameters', () => { + const permissionController = { + grantPermissionsIncremental: jest.fn(), + }; + + getPermissionBackgroundApiMethods(permissionController).addPermittedChain( + 'foo.com', + '0x1', + ); + + expect( + permissionController.grantPermissionsIncremental, + ).toHaveBeenCalledTimes(1); + expect( + permissionController.grantPermissionsIncremental, + ).toHaveBeenCalledWith({ + subject: { origin: 'foo.com' }, + approvedPermissions: getPermittedChainsPermissions(['0x1']), + }); + }); + }); + + describe('addPermittedChains', () => { + it('calls grantPermissionsIncremental with expected parameters for single chain', () => { + const permissionController = { + grantPermissionsIncremental: jest.fn(), + }; + + getPermissionBackgroundApiMethods( + permissionController, + ).addPermittedChains('foo.com', ['0x1']); + + expect( + permissionController.grantPermissionsIncremental, + ).toHaveBeenCalledTimes(1); + expect( + permissionController.grantPermissionsIncremental, + ).toHaveBeenCalledWith({ + subject: { origin: 'foo.com' }, + approvedPermissions: getPermittedChainsPermissions(['0x1']), + }); + }); + + it('calls grantPermissionsIncremental with expected parameters with multiple chains', () => { + const permissionController = { + grantPermissionsIncremental: jest.fn(), + }; + + getPermissionBackgroundApiMethods( + permissionController, + ).addPermittedChains('foo.com', ['0x1', '0x2']); + + expect( + permissionController.grantPermissionsIncremental, + ).toHaveBeenCalledTimes(1); + expect( + permissionController.grantPermissionsIncremental, + ).toHaveBeenCalledWith({ + subject: { origin: 'foo.com' }, + approvedPermissions: getPermittedChainsPermissions(['0x1', '0x2']), + }); + }); + }); + + describe('removePermittedChain', () => { + it('removes a permitted chain', () => { + const permissionController = { + getCaveat: jest.fn().mockImplementationOnce(() => { + return { + type: CaveatTypes.restrictNetworkSwitching, + value: ['0x1', '0x2'], + }; + }), + revokePermission: jest.fn(), + updateCaveat: jest.fn(), + }; + + getPermissionBackgroundApiMethods( + permissionController, + ).removePermittedChain('foo.com', '0x2'); + + expect(permissionController.getCaveat).toHaveBeenCalledTimes(1); + expect(permissionController.getCaveat).toHaveBeenCalledWith( + 'foo.com', + PermissionNames.permittedChains, + CaveatTypes.restrictNetworkSwitching, + ); + + expect(permissionController.revokePermission).not.toHaveBeenCalled(); + + expect(permissionController.updateCaveat).toHaveBeenCalledTimes(1); + expect(permissionController.updateCaveat).toHaveBeenCalledWith( + 'foo.com', + PermissionNames.permittedChains, + CaveatTypes.restrictNetworkSwitching, + ['0x1'], + ); + }); + + it('revokes the permittedChains permission if the removed chain is the only permitted chain', () => { + const permissionController = { + getCaveat: jest.fn().mockImplementationOnce(() => { + return { + type: CaveatTypes.restrictNetworkSwitching, + value: ['0x1'], + }; + }), + revokePermission: jest.fn(), + updateCaveat: jest.fn(), + }; + + getPermissionBackgroundApiMethods( + permissionController, + ).removePermittedChain('foo.com', '0x1'); + + expect(permissionController.getCaveat).toHaveBeenCalledTimes(1); + expect(permissionController.getCaveat).toHaveBeenCalledWith( + 'foo.com', + PermissionNames.permittedChains, + CaveatTypes.restrictNetworkSwitching, + ); + + expect(permissionController.revokePermission).toHaveBeenCalledTimes(1); + expect(permissionController.revokePermission).toHaveBeenCalledWith( + 'foo.com', + PermissionNames.permittedChains, + ); + + expect(permissionController.updateCaveat).not.toHaveBeenCalled(); + }); + + it('does not call permissionController.updateCaveat if the specified chain is not permitted', () => { + const permissionController = { + getCaveat: jest.fn().mockImplementationOnce(() => { + return { type: CaveatTypes.restrictNetworkSwitching, value: ['0x1'] }; + }), + revokePermission: jest.fn(), + updateCaveat: jest.fn(), + }; + + getPermissionBackgroundApiMethods( + permissionController, + ).removePermittedChain('foo.com', '0x2'); + expect(permissionController.getCaveat).toHaveBeenCalledTimes(1); + expect(permissionController.getCaveat).toHaveBeenCalledWith( + 'foo.com', + PermissionNames.permittedChains, + CaveatTypes.restrictNetworkSwitching, + ); + + expect(permissionController.revokePermission).not.toHaveBeenCalled(); + expect(permissionController.updateCaveat).not.toHaveBeenCalled(); + }); + }); }); diff --git a/app/scripts/controllers/permissions/selectors.js b/app/scripts/controllers/permissions/selectors.js index 1a7fa115dd48..76e638d25b54 100644 --- a/app/scripts/controllers/permissions/selectors.js +++ b/app/scripts/controllers/permissions/selectors.js @@ -1,5 +1,6 @@ import { createSelector } from 'reselect'; import { CaveatTypes } from '../../../../shared/constants/permissions'; +import { PermissionNames } from './specifications'; /** * This file contains selectors for PermissionController selector event @@ -40,47 +41,71 @@ export const getPermittedAccountsByOrigin = createSelector( ); /** - * Given the current and previous exposed accounts for each PermissionController - * subject, returns a new map containing all accounts that have changed. - * The values of each map must be immutable values directly from the - * PermissionController state, or an empty array instantiated in this - * function. + * Get the permitted chains for each subject, keyed by origin. + * The values of the returned map are immutable values from the + * PermissionController state. + * + * @returns {Map} The current origin:chainIds[] map. + */ +export const getPermittedChainsByOrigin = createSelector( + getSubjects, + (subjects) => { + return Object.values(subjects).reduce((originToChainsMap, subject) => { + const caveats = + subject.permissions?.[PermissionNames.permittedChains]?.caveats || []; + + const caveat = caveats.find( + ({ type }) => type === CaveatTypes.restrictNetworkSwitching, + ); + + if (caveat) { + originToChainsMap.set(subject.origin, caveat.value); + } + return originToChainsMap; + }, new Map()); + }, +); + +/** + * Returns a map containing key/value pairs for those that have been + * added, changed, or removed between two string:string[] maps * - * @param {Map} newAccountsMap - The new origin:accounts[] map. - * @param {Map} [previousAccountsMap] - The previous origin:accounts[] map. - * @returns {Map} The origin:accounts[] map of changed accounts. + * @param {Map} currentMap - The new string:string[] map. + * @param {Map} previousMap - The previous string:string[] map. + * @returns {Map} The string:string[] map of changed key/values. */ -export const getChangedAccounts = (newAccountsMap, previousAccountsMap) => { - if (previousAccountsMap === undefined) { - return newAccountsMap; +export const diffMap = (currentMap, previousMap) => { + if (previousMap === undefined) { + return currentMap; } - const changedAccounts = new Map(); - if (newAccountsMap === previousAccountsMap) { - return changedAccounts; + const changedMap = new Map(); + if (currentMap === previousMap) { + return changedMap; } - const newOrigins = new Set([...newAccountsMap.keys()]); + const newKeys = new Set([...currentMap.keys()]); - for (const origin of previousAccountsMap.keys()) { - const newAccounts = newAccountsMap.get(origin) ?? []; + for (const key of previousMap.keys()) { + const currentValue = currentMap.get(key) ?? []; + const previousValue = previousMap.get(key); // The values of these maps are references to immutable values, which is why // a strict equality check is enough for diffing. The values are either from // PermissionController state, or an empty array initialized in the previous - // call to this function. `newAccountsMap` will never contain any empty + // call to this function. `currentMap` will never contain any empty // arrays. - if (previousAccountsMap.get(origin) !== newAccounts) { - changedAccounts.set(origin, newAccounts); + if (currentValue !== previousValue) { + changedMap.set(key, currentValue); } - newOrigins.delete(origin); + newKeys.delete(key); } - // By now, newOrigins is either empty or contains some number of previously - // unencountered origins, and all of their accounts have "changed". - for (const origin of newOrigins.keys()) { - changedAccounts.set(origin, newAccountsMap.get(origin)); + // By now, newKeys is either empty or contains some number of previously + // unencountered origins, and all of their origins have "changed". + for (const origin of newKeys.keys()) { + changedMap.set(origin, currentMap.get(origin)); } - return changedAccounts; + return changedMap; }; diff --git a/app/scripts/controllers/permissions/selectors.test.js b/app/scripts/controllers/permissions/selectors.test.js index a32eabf7738e..41264d405ab2 100644 --- a/app/scripts/controllers/permissions/selectors.test.js +++ b/app/scripts/controllers/permissions/selectors.test.js @@ -1,21 +1,25 @@ import { cloneDeep } from 'lodash'; -import { getChangedAccounts, getPermittedAccountsByOrigin } from './selectors'; +import { CaveatTypes } from '../../../../shared/constants/permissions'; +import { + diffMap, + getPermittedAccountsByOrigin, + getPermittedChainsByOrigin, +} from './selectors'; +import { PermissionNames } from './specifications'; describe('PermissionController selectors', () => { - describe('getChangedAccounts', () => { + describe('diffMap', () => { it('returns the new value if the previous value is undefined', () => { const newAccounts = new Map([['foo.bar', ['0x1']]]); - expect(getChangedAccounts(newAccounts)).toBe(newAccounts); + expect(diffMap(newAccounts)).toBe(newAccounts); }); it('returns an empty map if the new and previous values are the same', () => { const newAccounts = new Map([['foo.bar', ['0x1']]]); - expect(getChangedAccounts(newAccounts, newAccounts)).toStrictEqual( - new Map(), - ); + expect(diffMap(newAccounts, newAccounts)).toStrictEqual(new Map()); }); - it('returns a new map of the changed accounts if the new and previous values differ', () => { + it('returns a new map of the changed key/value pairs if the new and previous maps differ', () => { // We set this on the new and previous value under the key 'foo.bar' to // check that identical values are excluded. const identicalValue = ['0x1']; @@ -32,7 +36,7 @@ describe('PermissionController selectors', () => { ]); newAccounts.set('foo.bar', identicalValue); - expect(getChangedAccounts(newAccounts, previousAccounts)).toStrictEqual( + expect(diffMap(newAccounts, previousAccounts)).toStrictEqual( new Map([ ['bar.baz', ['0x1', '0x2']], ['fizz.buzz', []], @@ -113,4 +117,89 @@ describe('PermissionController selectors', () => { expect(selected2).toBe(getPermittedAccountsByOrigin(state2)); }); }); + + describe('getPermittedChainsByOrigin', () => { + it('memoizes and gets permitted chains by origin', () => { + const state1 = { + subjects: { + 'foo.bar': { + origin: 'foo.bar', + permissions: { + [PermissionNames.permittedChains]: { + caveats: [ + { + type: CaveatTypes.restrictNetworkSwitching, + value: ['0x1'], + }, + ], + }, + }, + }, + 'bar.baz': { + origin: 'bar.baz', + permissions: { + [PermissionNames.permittedChains]: { + caveats: [ + { + type: CaveatTypes.restrictNetworkSwitching, + value: ['0x2'], + }, + ], + }, + }, + }, + 'baz.bizz': { + origin: 'baz.fizz', + permissions: { + [PermissionNames.permittedChains]: { + caveats: [ + { + type: CaveatTypes.restrictNetworkSwitching, + value: ['0x1', '0x2'], + }, + ], + }, + }, + }, + 'no.accounts': { + // we shouldn't see this in the result + permissions: { + foobar: {}, + }, + }, + }, + }; + + const expected1 = new Map([ + ['foo.bar', ['0x1']], + ['bar.baz', ['0x2']], + ['baz.fizz', ['0x1', '0x2']], + ]); + + const selected1 = getPermittedChainsByOrigin(state1); + + expect(selected1).toStrictEqual(expected1); + // The selector should return the memoized value if state.subjects is + // the same object + expect(selected1).toBe(getPermittedChainsByOrigin(state1)); + + // If we mutate the state, the selector return value should be different + // from the first. + const state2 = cloneDeep(state1); + delete state2.subjects['foo.bar']; + + const expected2 = new Map([ + ['bar.baz', ['0x2']], + ['baz.fizz', ['0x1', '0x2']], + ]); + + const selected2 = getPermittedChainsByOrigin(state2); + + expect(selected2).toStrictEqual(expected2); + expect(selected2).not.toBe(selected1); + // Since we didn't mutate the state at this point, the value should once + // again be the memoized. + expect(selected2).toBe(getPermittedChainsByOrigin(state2)); + }); + }); }); diff --git a/app/scripts/controllers/permissions/specifications.js b/app/scripts/controllers/permissions/specifications.js index 2d25ab16b1e4..8a40082d4d80 100644 --- a/app/scripts/controllers/permissions/specifications.js +++ b/app/scripts/controllers/permissions/specifications.js @@ -1,7 +1,6 @@ import { constructPermission, PermissionType, - SubjectType, } from '@metamask/permission-controller'; import { caveatSpecifications as snapsCaveatsSpecifications, @@ -10,6 +9,7 @@ import { import { isValidHexAddress } from '@metamask/utils'; import { CaveatTypes, + EndowmentTypes, RestrictedMethods, } from '../../../../shared/constants/permissions'; @@ -25,7 +25,7 @@ import { */ export const PermissionNames = Object.freeze({ ...RestrictedMethods, - permittedChains: 'endowment:permitted-chains', + ...EndowmentTypes, }); /** @@ -209,9 +209,13 @@ export const getPermissionSpecifications = ({ permissionType: PermissionType.Endowment, targetName: PermissionNames.permittedChains, allowedCaveats: [CaveatTypes.restrictNetworkSwitching], - subjectTypes: [SubjectType.Website], factory: (permissionOptions, requestData) => { + if (requestData === undefined) { + return constructPermission({ + ...permissionOptions, + }); + } if (!requestData.approvedChainIds) { throw new Error( `${PermissionNames.permittedChains}: No approved networks specified.`, diff --git a/app/scripts/lib/rpc-method-middleware/handlers/add-ethereum-chain.js b/app/scripts/lib/rpc-method-middleware/handlers/add-ethereum-chain.js index adf596824bdd..e224cb4a2b38 100644 --- a/app/scripts/lib/rpc-method-middleware/handlers/add-ethereum-chain.js +++ b/app/scripts/lib/rpc-method-middleware/handlers/add-ethereum-chain.js @@ -24,6 +24,7 @@ const addEthereumChain = { getCaveat: true, requestPermittedChainsPermission: true, getChainPermissionsFeatureFlag: true, + grantPermittedChainsPermissionIncremental: true, }, }; @@ -46,6 +47,7 @@ async function addEthereumChainHandler( getCaveat, requestPermittedChainsPermission, getChainPermissionsFeatureFlag, + grantPermittedChainsPermissionIncremental, }, ) { let validParams; @@ -210,12 +212,14 @@ async function addEthereumChainHandler( networkClientId, approvalFlowId, { + isAddFlow: true, getChainPermissionsFeatureFlag, setActiveNetwork, requestUserApproval, getCaveat, requestPermittedChainsPermission, endApprovalFlow, + grantPermittedChainsPermissionIncremental, }, ); } else if (approvalFlowId) { diff --git a/app/scripts/lib/rpc-method-middleware/handlers/add-ethereum-chain.test.js b/app/scripts/lib/rpc-method-middleware/handlers/add-ethereum-chain.test.js index d04037949e87..f6be2deb6f08 100644 --- a/app/scripts/lib/rpc-method-middleware/handlers/add-ethereum-chain.test.js +++ b/app/scripts/lib/rpc-method-middleware/handlers/add-ethereum-chain.test.js @@ -70,6 +70,7 @@ describe('addEthereumChainHandler', () => { setActiveNetwork: jest.fn(), requestUserApproval: jest.fn().mockResolvedValue(123), requestPermittedChainsPermission: jest.fn(), + grantPermittedChainsPermissionIncremental: jest.fn(), getCaveat: jest.fn().mockReturnValue({ value: permissionedChainIds }), startApprovalFlow: () => ({ id: 'approvalFlowId' }), endApprovalFlow: jest.fn(), @@ -411,10 +412,12 @@ describe('addEthereumChainHandler', () => { ); expect(mocks.addNetwork).toHaveBeenCalledWith(nonInfuraConfiguration); - expect(mocks.requestPermittedChainsPermission).toHaveBeenCalledTimes(1); - expect(mocks.requestPermittedChainsPermission).toHaveBeenCalledWith([ - createMockNonInfuraConfiguration().chainId, - ]); + expect( + mocks.grantPermittedChainsPermissionIncremental, + ).toHaveBeenCalledTimes(1); + expect( + mocks.grantPermittedChainsPermissionIncremental, + ).toHaveBeenCalledWith([createMockNonInfuraConfiguration().chainId]); expect(mocks.setActiveNetwork).toHaveBeenCalledTimes(1); expect(mocks.setActiveNetwork).toHaveBeenCalledWith(123); }); @@ -497,12 +500,12 @@ describe('addEthereumChainHandler', () => { ); expect(mocks.updateNetwork).toHaveBeenCalledTimes(1); - expect(mocks.requestPermittedChainsPermission).toHaveBeenCalledTimes( - 1, - ); - expect(mocks.requestPermittedChainsPermission).toHaveBeenCalledWith([ - NON_INFURA_CHAIN_ID, - ]); + expect( + mocks.grantPermittedChainsPermissionIncremental, + ).toHaveBeenCalledTimes(1); + expect( + mocks.grantPermittedChainsPermissionIncremental, + ).toHaveBeenCalledWith([NON_INFURA_CHAIN_ID]); expect(mocks.setActiveNetwork).toHaveBeenCalledTimes(1); }); }); @@ -607,7 +610,7 @@ describe('addEthereumChainHandler', () => { getCurrentChainIdForDomain: jest .fn() .mockReturnValue(CHAIN_IDS.SEPOLIA), - requestPermittedChainsPermission: jest + grantPermittedChainsPermissionIncremental: jest .fn() .mockRejectedValue(mockError), }, @@ -636,7 +639,9 @@ describe('addEthereumChainHandler', () => { mocks, ); - expect(mocks.requestPermittedChainsPermission).toHaveBeenCalledTimes(1); + expect( + mocks.grantPermittedChainsPermissionIncremental, + ).toHaveBeenCalledTimes(1); expect(mockEnd).toHaveBeenCalledWith(mockError); expect(mocks.setActiveNetwork).not.toHaveBeenCalled(); }); diff --git a/app/scripts/lib/rpc-method-middleware/handlers/ethereum-chain-utils.js b/app/scripts/lib/rpc-method-middleware/handlers/ethereum-chain-utils.js index 89415d471468..57d14eb6e6b8 100644 --- a/app/scripts/lib/rpc-method-middleware/handlers/ethereum-chain-utils.js +++ b/app/scripts/lib/rpc-method-middleware/handlers/ethereum-chain-utils.js @@ -162,12 +162,14 @@ export async function switchChain( networkClientId, approvalFlowId, { + isAddFlow, getChainPermissionsFeatureFlag, setActiveNetwork, endApprovalFlow, requestUserApproval, getCaveat, requestPermittedChainsPermission, + grantPermittedChainsPermissionIncremental, }, ) { try { @@ -182,7 +184,11 @@ export async function switchChain( permissionedChainIds === undefined || !permissionedChainIds.includes(chainId) ) { - await requestPermittedChainsPermission([chainId]); + if (isAddFlow) { + await grantPermittedChainsPermissionIncremental([chainId]); + } else { + await requestPermittedChainsPermission([chainId]); + } } } else { await requestUserApproval({ diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index 218704b98620..30112ee61a3c 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -312,10 +312,11 @@ import { CaveatFactories, CaveatMutatorFactories, getCaveatSpecifications, - getChangedAccounts, + diffMap, getPermissionBackgroundApiMethods, getPermissionSpecifications, getPermittedAccountsByOrigin, + getPermittedChainsByOrigin, NOTIFICATION_NAMES, PermissionNames, unrestrictedMethods, @@ -2853,7 +2854,7 @@ export default class MetamaskController extends EventEmitter { this.controllerMessenger.subscribe( `${this.permissionController.name}:stateChange`, async (currentValue, previousValue) => { - const changedAccounts = getChangedAccounts(currentValue, previousValue); + const changedAccounts = diffMap(currentValue, previousValue); for (const [origin, accounts] of changedAccounts.entries()) { this._notifyAccountsChange(origin, accounts); @@ -2862,6 +2863,40 @@ export default class MetamaskController extends EventEmitter { getPermittedAccountsByOrigin, ); + this.controllerMessenger.subscribe( + `${this.permissionController.name}:stateChange`, + async (currentValue, previousValue) => { + const changedChains = diffMap(currentValue, previousValue); + + // This operates under the assumption that there will be at maximum + // one origin permittedChains value change per event handler call + for (const [origin, chains] of changedChains.entries()) { + const currentNetworkClientIdForOrigin = + this.selectedNetworkController.getNetworkClientIdForDomain(origin); + const { chainId: currentChainIdForOrigin } = + this.networkController.getNetworkConfigurationByNetworkClientId( + currentNetworkClientIdForOrigin, + ); + // if(chains.length === 0) { + // TODO: This particular case should also occur at the same time + // that eth_accounts is revoked. When eth_accounts is revoked, + // the networkClientId for that origin should be reset to track + // the globally selected network. + // } + if (chains.length > 0 && !chains.includes(currentChainIdForOrigin)) { + const networkClientId = + this.networkController.findNetworkClientIdByChainId(chains[0]); + this.selectedNetworkController.setNetworkClientIdForDomain( + origin, + networkClientId, + ); + this.networkController.setActiveNetwork(networkClientId); + } + } + }, + getPermittedChainsByOrigin, + ); + this.controllerMessenger.subscribe( 'NetworkController:networkDidChange', async () => { @@ -3226,6 +3261,13 @@ export default class MetamaskController extends EventEmitter { getProviderConfig({ metamask: this.networkController.state, }), + grantPermissionsIncremental: + this.permissionController.grantPermissionsIncremental.bind( + this.permissionController, + ), + grantPermissions: this.permissionController.grantPermissions.bind( + this.permissionController, + ), setSecurityAlertsEnabled: preferencesController.setSecurityAlertsEnabled.bind( preferencesController, @@ -5674,7 +5716,12 @@ export default class MetamaskController extends EventEmitter { this.permissionController.requestPermissions.bind( this.permissionController, { origin }, - { eth_accounts: {} }, + { + eth_accounts: {}, + ...(process.env.CHAIN_PERMISSIONS && { + [PermissionNames.permittedChains]: {}, + }), + }, ), requestPermittedChainsPermission: (chainIds) => this.permissionController.requestPermissionsIncremental( @@ -5689,10 +5736,29 @@ export default class MetamaskController extends EventEmitter { }, }, ), - requestPermissionsForOrigin: - this.permissionController.requestPermissions.bind( - this.permissionController, + grantPermittedChainsPermissionIncremental: (chainIds) => + this.permissionController.grantPermissionsIncremental({ + subject: { origin }, + approvedPermissions: { + [PermissionNames.permittedChains]: { + caveats: [ + CaveatFactories[CaveatTypes.restrictNetworkSwitching]( + chainIds, + ), + ], + }, + }, + }), + requestPermissionsForOrigin: (requestedPermissions) => + this.permissionController.requestPermissions( { origin }, + { + ...(process.env.CHAIN_PERMISSIONS && + requestedPermissions[RestrictedMethods.eth_accounts] && { + [PermissionNames.permittedChains]: {}, + }), + ...requestedPermissions, + }, ), revokePermissionsForOrigin: (permissionKeys) => { try { diff --git a/shared/constants/permissions.ts b/shared/constants/permissions.ts index 0829d772e854..efcf5bd46872 100644 --- a/shared/constants/permissions.ts +++ b/shared/constants/permissions.ts @@ -3,6 +3,10 @@ export const CaveatTypes = Object.freeze({ restrictNetworkSwitching: 'restrictNetworkSwitching' as const, }); +export const EndowmentTypes = Object.freeze({ + permittedChains: 'endowment:permitted-chains', +}); + export const RestrictedEthMethods = Object.freeze({ eth_accounts: 'eth_accounts', }); diff --git a/test/data/mock-send-state.json b/test/data/mock-send-state.json index 59d24a1f5f54..6629d8c6ac67 100644 --- a/test/data/mock-send-state.json +++ b/test/data/mock-send-state.json @@ -12,6 +12,7 @@ "appState": { "networkDropdownOpen": false, "importNftsModal": { "open": false }, + "showPermittedNetworkToastOpen": false, "gasIsLoading": false, "isLoading": false, "importTokensModalOpen": false, diff --git a/test/data/mock-state.json b/test/data/mock-state.json index 5a86bbb4970b..4b6a2f506215 100644 --- a/test/data/mock-state.json +++ b/test/data/mock-state.json @@ -14,6 +14,7 @@ "importNftsModal": { "open": false }, + "showPermittedNetworkToastOpen": false, "gasIsLoading": false, "isLoading": false, "modal": { diff --git a/ui/components/app/permission-cell/permission-cell-status.js b/ui/components/app/permission-cell/permission-cell-status.js index 7f03a93a3584..5b0cf8f25b56 100644 --- a/ui/components/app/permission-cell/permission-cell-status.js +++ b/ui/components/app/permission-cell/permission-cell-status.js @@ -25,6 +25,7 @@ import { AvatarGroup } from '../../multichain'; import { AvatarType } from '../../multichain/avatar-group/avatar-group.types'; import { useI18nContext } from '../../../hooks/useI18nContext'; import { formatDate } from '../../../helpers/utils/util'; +import { CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP } from '../../../../shared/constants/network'; /** * Renders status of the given permission. Used by PermissionCell component. @@ -57,7 +58,7 @@ export const PermissionCellStatus = ({ {networks?.map((network, index) => ( - {network.avatarName} + {network.name} ))} diff --git a/ui/components/app/permission-page-container/permission-page-container.component.js b/ui/components/app/permission-page-container/permission-page-container.component.js index f2a04e7616ca..da7719f6d4dc 100644 --- a/ui/components/app/permission-page-container/permission-page-container.component.js +++ b/ui/components/app/permission-page-container/permission-page-container.component.js @@ -8,10 +8,10 @@ import { SubjectType } from '@metamask/permission-controller'; import { MetaMetricsEventCategory } from '../../../../shared/constants/metametrics'; import { PageContainerFooter } from '../../ui/page-container'; import PermissionsConnectFooter from '../permissions-connect-footer'; -import { RestrictedMethods } from '../../../../shared/constants/permissions'; -// TODO: Remove restricted import -// eslint-disable-next-line import/no-restricted-paths -import { PermissionNames } from '../../../../app/scripts/controllers/permissions'; +import { + CaveatTypes, + RestrictedMethods, +} from '../../../../shared/constants/permissions'; import SnapPrivacyWarning from '../snaps/snap-privacy-warning'; import { getDedupedSnaps } from '../../../helpers/utils/util'; @@ -22,6 +22,8 @@ import { FlexDirection, } from '../../../helpers/constants/design-system'; import { Box } from '../../component-library'; +// eslint-disable-next-line import/no-restricted-paths +import { PermissionNames } from '../../../../app/scripts/controllers/permissions'; import { PermissionPageContainerContent } from '.'; export default class PermissionPageContainer extends Component { @@ -140,18 +142,22 @@ export default class PermissionPageContainer extends Component { selectedAccounts, } = this.props; + const approvedAccounts = selectedAccounts.map( + (selectedAccount) => selectedAccount.address, + ); + + const permittedChainsPermission = + _request.permissions[PermissionNames.permittedChains]; + const approvedChainIds = permittedChainsPermission?.caveats.find( + (caveat) => caveat.type === CaveatTypes.restrictNetworkSwitching, + )?.value; + const request = { ..._request, permissions: { ..._request.permissions }, - ...(_request.permissions.eth_accounts && { - approvedAccounts: selectedAccounts.map( - (selectedAccount) => selectedAccount.address, - ), - }), - ...(_request.permissions.permittedChains && { - approvedChainIds: _request.permissions?.permittedChains?.caveats.find( - (caveat) => caveat.type === 'restrictNetworkSwitching', - )?.value, + ...(_request.permissions.eth_accounts && { approvedAccounts }), + ...(_request.permissions[PermissionNames.permittedChains] && { + approvedChainIds, }), }; diff --git a/ui/components/multichain/app-header/app-header-unlocked-content.tsx b/ui/components/multichain/app-header/app-header-unlocked-content.tsx index b6baf423c345..57e0c2f2c5fc 100644 --- a/ui/components/multichain/app-header/app-header-unlocked-content.tsx +++ b/ui/components/multichain/app-header/app-header-unlocked-content.tsx @@ -55,7 +55,10 @@ import { MetaMetricsContext } from '../../../contexts/metametrics'; import { useCopyToClipboard } from '../../../hooks/useCopyToClipboard'; import { MINUTE } from '../../../../shared/constants/time'; import { NotificationsTagCounter } from '../notifications-tag-counter'; -import { CONNECTIONS } from '../../../helpers/constants/routes'; +import { + CONNECTIONS, + REVIEW_PERMISSIONS, +} from '../../../helpers/constants/routes'; import { MultichainNetwork } from '../../../selectors/multichain'; type AppHeaderUnlockedContentProps = { @@ -119,7 +122,11 @@ export const AppHeaderUnlockedContent = ({ }; const handleConnectionsRoute = () => { - history.push(`${CONNECTIONS}/${encodeURIComponent(origin)}`); + if (process.env.CHAIN_PERMISSIONS) { + history.push(`${REVIEW_PERMISSIONS}/${encodeURIComponent(origin)}`); + } else { + history.push(`${CONNECTIONS}/${encodeURIComponent(origin)}`); + } }; return ( diff --git a/ui/components/multichain/connect-accounts-modal/connect-accounts-modal-list.tsx b/ui/components/multichain/connect-accounts-modal/connect-accounts-modal-list.tsx index ff08717322b4..bda1a169250e 100644 --- a/ui/components/multichain/connect-accounts-modal/connect-accounts-modal-list.tsx +++ b/ui/components/multichain/connect-accounts-modal/connect-accounts-modal-list.tsx @@ -25,7 +25,7 @@ import { } from '../../../helpers/constants/design-system'; import Tooltip from '../../ui/tooltip/tooltip'; import { getURLHost } from '../../../helpers/utils/util'; -import { addMorePermittedAccounts } from '../../../store/actions'; +import { addPermittedAccounts } from '../../../store/actions'; import { ConnectAccountsListProps } from './connect-account-modal.types'; export const ConnectAccountsModalList: React.FC = ({ @@ -106,9 +106,7 @@ export const ConnectAccountsModalList: React.FC = ({ { - dispatch( - addMorePermittedAccounts(activeTabOrigin, selectedAccounts), - ); + dispatch(addPermittedAccounts(activeTabOrigin, selectedAccounts)); onClose(); onAccountsUpdate(); }} diff --git a/ui/components/multichain/connect-accounts-modal/connect-accounts-modal.tsx b/ui/components/multichain/connect-accounts-modal/connect-accounts-modal.tsx index a511e792e77b..457e15b0141d 100644 --- a/ui/components/multichain/connect-accounts-modal/connect-accounts-modal.tsx +++ b/ui/components/multichain/connect-accounts-modal/connect-accounts-modal.tsx @@ -39,6 +39,10 @@ export const ConnectAccountsModal = ({ setSelectedAccounts(newSelectedAccounts); }; + const deselectAll = () => { + setSelectedAccounts([]); + }; + const selectAll = () => { const newSelectedAccounts = accounts.map( (account: { address: string }) => account.address, @@ -46,22 +50,13 @@ export const ConnectAccountsModal = ({ setSelectedAccounts(newSelectedAccounts); }; - const deselectAll = () => { - setSelectedAccounts([]); - }; - const allAreSelected = () => { return accounts.length === selectedAccounts.length; }; - let checked = false; - let isIndeterminate = false; - if (allAreSelected()) { - checked = true; - isIndeterminate = false; - } else if (selectedAccounts.length > 0 && !allAreSelected()) { - checked = false; - isIndeterminate = true; - } + + const checked = allAreSelected(); + const isIndeterminate = !checked && selectedAccounts.length > 0; + return ( - {t('disconnectAllTitle', [t(type)])} + {process.env.CHAIN_PERMISSIONS + ? t('disconnect') + : t('disconnectAllTitle', [t(type)])} - {t('disconnectAllText', [t(type), hostname])} + {process.env.CHAIN_PERMISSIONS ? ( + {t('disconnectAllDescription', [hostname])} + ) : ( + {t('disconnectAllText', [t(type), hostname])} + )} + +
+

+

+ + +
+

+
+ +
+
+

+ MetaMask isn’t connected to this site +

+

+ Select an account you want to use on this site to continue. +

+
+
+ + + + +`; diff --git a/ui/components/multichain/pages/review-permissions-page/index.js b/ui/components/multichain/pages/review-permissions-page/index.js new file mode 100644 index 000000000000..e2da178368f1 --- /dev/null +++ b/ui/components/multichain/pages/review-permissions-page/index.js @@ -0,0 +1,2 @@ +export { ReviewPermissions } from './review-permissions-page'; +export { SiteCell } from './site-cell/site-cell'; diff --git a/ui/components/multichain/pages/review-permissions-page/review-permission.types.tsx b/ui/components/multichain/pages/review-permissions-page/review-permission.types.tsx new file mode 100644 index 000000000000..6111dd8d946f --- /dev/null +++ b/ui/components/multichain/pages/review-permissions-page/review-permission.types.tsx @@ -0,0 +1,36 @@ +import { type InternalAccount } from '@metamask/keyring-api'; + +// Define ConnectedSite interface +export type ConnectedSite = { + iconUrl: string; + name: string; + origin: string; + subjectType: string; + extensionId: string | null; + // Add other properties as needed +}; + +// Define ConnectedSites interface +export type ConnectedSites = { + [address: string]: ConnectedSite[]; // Index signature +}; + +// Define KeyringType interface +export type KeyringType = { + type: string; +}; + +// Define AccountType interface +export type AccountType = InternalAccount & { + name: string; + balance: string; + keyring: KeyringType; + label: string; +}; + +export type Subject = { + permissions: { parentCapability: string }[]; +}; +export type SubjectsType = { + [key: string]: Subject; +}; diff --git a/ui/components/multichain/pages/review-permissions-page/review-permissions-page.stories.tsx b/ui/components/multichain/pages/review-permissions-page/review-permissions-page.stories.tsx new file mode 100644 index 000000000000..b2da4553ce50 --- /dev/null +++ b/ui/components/multichain/pages/review-permissions-page/review-permissions-page.stories.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import { ReviewPermissions } from '.'; + +export default { + title: 'Components/Multichain/ReviewPermissions', +}; + +export const DefaultStory = () => ; + +DefaultStory.storyName = 'Default'; diff --git a/ui/components/multichain/pages/review-permissions-page/review-permissions-page.test.tsx b/ui/components/multichain/pages/review-permissions-page/review-permissions-page.test.tsx new file mode 100644 index 000000000000..b644c16b6440 --- /dev/null +++ b/ui/components/multichain/pages/review-permissions-page/review-permissions-page.test.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { renderWithProvider } from '../../../../../test/jest/rendering'; +import mockState from '../../../../../test/data/mock-state.json'; +import configureStore from '../../../../store/store'; +import { ReviewPermissions } from '.'; + +const render = (state = {}) => { + const store = configureStore({ + ...mockState, + metamask: { + ...mockState.metamask, + ...state, + permissionHistory: { + 'https://test.dapp': { + eth_accounts: { + accounts: { + '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc': 1709225290848, + }, + }, + }, + }, + }, + activeTab: { + origin: 'https://test.dapp', + }, + }); + return renderWithProvider(, store); +}; +describe('ReviewPermissions', () => { + it('should render correctly', () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/ui/components/multichain/pages/review-permissions-page/review-permissions-page.tsx b/ui/components/multichain/pages/review-permissions-page/review-permissions-page.tsx new file mode 100644 index 000000000000..303d9dc2df4a --- /dev/null +++ b/ui/components/multichain/pages/review-permissions-page/review-permissions-page.tsx @@ -0,0 +1,281 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useHistory, useParams } from 'react-router-dom'; +import { NonEmptyArray } from '@metamask/utils'; +import { InternalAccount, isEvmAccountType } from '@metamask/keyring-api'; +import { NetworkConfiguration } from '@metamask/network-controller'; +import { + BlockSize, + Display, + FlexDirection, +} from '../../../../helpers/constants/design-system'; +import { getURLHost } from '../../../../helpers/utils/util'; +import { useI18nContext } from '../../../../hooks/useI18nContext'; +import { + getConnectedSitesList, + getInternalAccounts, + getNetworkConfigurationsByChainId, + getPermissionSubjects, + getPermittedAccountsForSelectedTab, + getPermittedChainsForSelectedTab, + getShowPermittedNetworkToastOpen, + getUpdatedAndSortedAccounts, +} from '../../../../selectors'; +import { + addPermittedAccounts, + addPermittedChains, + hidePermittedNetworkToast, + removePermissionsFor, + removePermittedAccount, + removePermittedChain, + requestAccountsAndChainPermissionsWithId, +} from '../../../../store/actions'; +import { + AvatarFavicon, + AvatarFaviconSize, + Box, + Button, + ButtonPrimary, + ButtonPrimarySize, + ButtonSize, + ButtonVariant, + IconName, +} from '../../../component-library'; +import { ToastContainer, Toast } from '../..'; +import { NoConnectionContent } from '../connections/components/no-connection'; +import { Content, Footer, Page } from '../page'; +import { SubjectsType } from '../connections/components/connections.types'; +import { CONNECT_ROUTE } from '../../../../helpers/constants/routes'; +import { + DisconnectAllModal, + DisconnectType, +} from '../../disconnect-all-modal/disconnect-all-modal'; +import { PermissionsHeader } from '../../permissions-header/permissions-header'; +import { mergeAccounts } from '../../account-list-menu/account-list-menu'; +import { MergedInternalAccount } from '../../../../selectors/selectors.types'; +import { TEST_CHAINS } from '../../../../../shared/constants/network'; +import { SiteCell } from '.'; + +export const ReviewPermissions = () => { + const t = useI18nContext(); + const dispatch = useDispatch(); + const history = useHistory(); + const urlParams: { origin: string } = useParams(); + const securedOrigin = decodeURIComponent(urlParams.origin); + const [showAccountToast, setShowAccountToast] = useState(false); + const [showNetworkToast, setShowNetworkToast] = useState(false); + const [showDisconnectAllModal, setShowDisconnectAllModal] = useState(false); + const activeTabOrigin: string = securedOrigin; + + const showPermittedNetworkToastOpen = useSelector( + getShowPermittedNetworkToastOpen, + ); + + useEffect(() => { + if (showPermittedNetworkToastOpen) { + setShowNetworkToast(showPermittedNetworkToastOpen); + dispatch(hidePermittedNetworkToast()); + } + }, [showPermittedNetworkToastOpen]); + + const requestAccountsAndChainPermissions = async () => { + const requestId = await dispatch( + requestAccountsAndChainPermissionsWithId(activeTabOrigin), + ); + history.push(`${CONNECT_ROUTE}/${requestId}`); + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const subjectMetadata: { [key: string]: any } = useSelector( + getConnectedSitesList, + ); + const connectedSubjectsMetadata = subjectMetadata[activeTabOrigin]; + const subjects = useSelector(getPermissionSubjects); + + const disconnectAllPermissions = () => { + const subject = (subjects as SubjectsType)[activeTabOrigin]; + + if (subject) { + const permissionMethodNames = Object.values(subject.permissions).map( + ({ parentCapability }: { parentCapability: string }) => + parentCapability, + ) as string[]; + if (permissionMethodNames.length > 0) { + const permissionsRecord = { + [activeTabOrigin]: permissionMethodNames as NonEmptyArray, + }; + + dispatch(removePermissionsFor(permissionsRecord)); + } + } + dispatch(hidePermittedNetworkToast()); + }; + + const networkConfigurations = useSelector(getNetworkConfigurationsByChainId); + const [nonTestNetworks, testNetworks] = useMemo( + () => + Object.entries(networkConfigurations).reduce( + ([nonTestNetworksList, testNetworksList], [chainId, network]) => { + const isTest = (TEST_CHAINS as string[]).includes(chainId); + (isTest ? testNetworksList : nonTestNetworksList).push(network); + return [nonTestNetworksList, testNetworksList]; + }, + [[] as NetworkConfiguration[], [] as NetworkConfiguration[]], + ), + [networkConfigurations], + ); + const connectedChainIds = useSelector((state) => + getPermittedChainsForSelectedTab(state, activeTabOrigin), + ) as string[]; + + const handleSelectChainIds = async (chainIds: string[]) => { + if (chainIds.length === 0) { + setShowDisconnectAllModal(true); + return; + } + + dispatch(addPermittedChains(activeTabOrigin, chainIds)); + + connectedChainIds.forEach((chainId: string) => { + if (!chainIds.includes(chainId)) { + dispatch(removePermittedChain(activeTabOrigin, chainId)); + } + }); + + setShowNetworkToast(true); + }; + + const accounts = useSelector(getUpdatedAndSortedAccounts); + const internalAccounts = useSelector(getInternalAccounts); + const mergedAccounts: MergedInternalAccount[] = useMemo(() => { + return mergeAccounts(accounts, internalAccounts).filter( + (account: InternalAccount) => isEvmAccountType(account.type), + ); + }, [accounts, internalAccounts]); + + const connectedAccountAddresses = useSelector((state) => + getPermittedAccountsForSelectedTab(state, activeTabOrigin), + ) as string[]; + + const handleSelectAccountAddresses = (addresses: string[]) => { + if (addresses.length === 0) { + setShowDisconnectAllModal(true); + return; + } + + dispatch(addPermittedAccounts(activeTabOrigin, addresses)); + + connectedAccountAddresses.forEach((address: string) => { + if (!addresses.includes(address)) { + dispatch(removePermittedAccount(activeTabOrigin, address)); + } + }); + + setShowAccountToast(true); + }; + + const hostName = getURLHost(securedOrigin); + + return ( + + <> + + + {connectedAccountAddresses.length > 0 ? ( + + ) : ( + + )} + {showDisconnectAllModal ? ( + setShowDisconnectAllModal(false)} + onClick={() => { + disconnectAllPermissions(); + setShowDisconnectAllModal(false); + }} + /> + ) : null} + +
+ <> + {connectedAccountAddresses.length > 0 ? ( + + {showAccountToast ? ( + + setShowAccountToast(false)} + startAdornment={ + + } + /> + + ) : null} + {showNetworkToast ? ( + + setShowNetworkToast(false)} + startAdornment={ + + } + /> + + ) : null} + + + ) : ( + + {t('connectAccounts')} + + )} + +
+ +
+ ); +}; diff --git a/ui/components/multichain/pages/review-permissions-page/site-cell/__snapshots__/site-cell-connection-list-item.test.js.snap b/ui/components/multichain/pages/review-permissions-page/site-cell/__snapshots__/site-cell-connection-list-item.test.js.snap new file mode 100644 index 000000000000..5dc31c8e210a --- /dev/null +++ b/ui/components/multichain/pages/review-permissions-page/site-cell/__snapshots__/site-cell-connection-list-item.test.js.snap @@ -0,0 +1,46 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SiteCellConnectionListItem renders correctly with required props 1`] = ` +
+
+
+ +
+
+

+ Title +

+
+ + Unconnected Message + +
+ Content +
+
+
+ +
+
+`; diff --git a/ui/components/multichain/pages/review-permissions-page/site-cell/__snapshots__/site-cell-tooltip.test.js.snap b/ui/components/multichain/pages/review-permissions-page/site-cell/__snapshots__/site-cell-tooltip.test.js.snap new file mode 100644 index 000000000000..bafd3fea4948 --- /dev/null +++ b/ui/components/multichain/pages/review-permissions-page/site-cell/__snapshots__/site-cell-tooltip.test.js.snap @@ -0,0 +1,241 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SiteCellTooltip should render correctly 1`] = ` +
+
Alerts"" + data-tooltipped="" + style="display: inline;" + > +
+
+
+ +
+
+ +
+
+ +
+
+
+
+
+
+
+ Polygon logo +
+
+
+
+ Binance Smart Chain logo +
+
+
+
+ zkSync Era Mainnet logo +
+
+
+
+ Ethereum Mainnet logo +
+
+
+
+

+ +1 +

+
+
+
+
+`; diff --git a/ui/components/multichain/pages/review-permissions-page/site-cell/site-cell-connection-list-item.js b/ui/components/multichain/pages/review-permissions-page/site-cell/site-cell-connection-list-item.js new file mode 100644 index 000000000000..85e50b0b0fed --- /dev/null +++ b/ui/components/multichain/pages/review-permissions-page/site-cell/site-cell-connection-list-item.js @@ -0,0 +1,131 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + AlignItems, + BackgroundColor, + BlockSize, + Display, + FlexDirection, + IconColor, + TextAlign, + TextColor, + TextVariant, +} from '../../../../../helpers/constants/design-system'; +import { + AvatarIcon, + AvatarIconSize, + Box, + ButtonIcon, + ButtonIconSize, + ButtonLink, + IconName, + Text, +} from '../../../../component-library'; +import { useI18nContext } from '../../../../../hooks/useI18nContext'; + +export const SiteCellConnectionListItem = ({ + title, + iconName, + connectedMessage, + unconnectedMessage, + isConnectFlow, + onClick, + content, +}) => { + const t = useI18nContext(); + + return ( + + + + + {title} + + + + {isConnectFlow ? unconnectedMessage : connectedMessage} + + {content} + + + {isConnectFlow ? ( + onClick()}>{t('edit')} + ) : ( + onClick()} + size={ButtonIconSize.Sm} + /> + )} + + ); +}; +SiteCellConnectionListItem.propTypes = { + /** + * Title that should be displayed in the connection list item + */ + title: PropTypes.string, + + /** + * The name of the icon that should be passed to the AvatarIcon component + */ + iconName: PropTypes.string, + + /** + * The message that should be displayed when there are connected accounts + */ + connectedMessage: PropTypes.string, + + /** + * The message that should be displayed when there are no connected accounts + */ + unconnectedMessage: PropTypes.string, + + /** + * If the component should show context related to adding a connection or editing one + */ + isConnectFlow: PropTypes.bool, + + /** + * Handler called when the edit button is clicked + */ + onClick: PropTypes.func, + + /** + * Components to display in the connection list item + */ + content: PropTypes.node, +}; diff --git a/ui/components/multichain/pages/review-permissions-page/site-cell/site-cell-connection-list-item.test.js b/ui/components/multichain/pages/review-permissions-page/site-cell/site-cell-connection-list-item.test.js new file mode 100644 index 000000000000..613f07f348f3 --- /dev/null +++ b/ui/components/multichain/pages/review-permissions-page/site-cell/site-cell-connection-list-item.test.js @@ -0,0 +1,39 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { IconName } from '../../../../component-library'; +import { SiteCellConnectionListItem } from './site-cell-connection-list-item'; + +describe('SiteCellConnectionListItem', () => { + let getByTestId, container, getByText; + + const renderComponent = () => { + const rendered = render( + null} + content={
Content
} + />, + ); + getByTestId = rendered.getByTestId; + container = rendered.container; + getByText = rendered.getByText; + }; + + beforeEach(() => { + renderComponent(); + }); + + it('renders correctly with required props', () => { + expect(container).toMatchSnapshot(); + const siteCell = getByTestId('site-cell-connection-list-item'); + expect(siteCell).toBeDefined(); + }); + + it('returns wallet icon correctly', () => { + expect(getByText('Title')).toBeDefined(); + }); +}); diff --git a/ui/components/multichain/pages/review-permissions-page/site-cell/site-cell-tooltip.js b/ui/components/multichain/pages/review-permissions-page/site-cell/site-cell-tooltip.js new file mode 100644 index 000000000000..2e4eef35d594 --- /dev/null +++ b/ui/components/multichain/pages/review-permissions-page/site-cell/site-cell-tooltip.js @@ -0,0 +1,190 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Tooltip } from 'react-tippy'; +import { useSelector } from 'react-redux'; +import { + AlignItems, + BackgroundColor, + BorderStyle, + Display, + FlexDirection, + TextAlign, + TextColor, + TextVariant, +} from '../../../../../helpers/constants/design-system'; +import { AvatarType } from '../../../avatar-group/avatar-group.types'; +import { AvatarGroup } from '../../..'; +import { + AvatarAccount, + AvatarAccountSize, + AvatarAccountVariant, + AvatarNetwork, + AvatarNetworkSize, + Box, + Text, +} from '../../../../component-library'; +import { getUseBlockie } from '../../../../../selectors'; +import { useI18nContext } from '../../../../../hooks/useI18nContext'; +import { CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP } from '../../../../../../shared/constants/network'; + +export const SiteCellTooltip = ({ accounts, networks }) => { + const t = useI18nContext(); + const AVATAR_GROUP_LIMIT = 4; + const TOOLTIP_LIMIT = 4; + const useBlockie = useSelector(getUseBlockie); + const avatarAccountVariant = useBlockie + ? AvatarAccountVariant.Blockies + : AvatarAccountVariant.Jazzicon; + + const avatarAccountsData = accounts?.map((account) => ({ + avatarValue: account.address, + })); + + const avatarNetworksData = networks?.map((network) => ({ + avatarValue: CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP[network.chainId], + symbol: network.name, + })); + + return ( + + + {accounts?.slice(0, TOOLTIP_LIMIT).map((acc) => { + return ( + + + + {acc.label || acc.metadata.name} + + + ); + })} + {networks?.slice(0, TOOLTIP_LIMIT).map((network) => { + return ( + + + + {network.name} + + + ); + })} + {accounts?.length > TOOLTIP_LIMIT || + networks?.length > TOOLTIP_LIMIT ? ( + + + {accounts?.length > 0 + ? t('moreAccounts', [accounts?.length - TOOLTIP_LIMIT]) + : t('moreNetworks', [networks.length - TOOLTIP_LIMIT])} + + + ) : null} + +
+ } + arrow + offset={0} + delay={50} + duration={0} + size="small" + title={t('alertDisableTooltip')} + trigger="mouseenter focus" + theme="dark" + tag="div" + > + {accounts?.length > 0 && ( + + )} + {networks?.length > 0 && ( + + )} + + ); +}; +SiteCellTooltip.propTypes = { + /** + * An array of account objects to be displayed in the tooltip. + * Each object should contain `address`, `label`, and `metadata.name`. + */ + accounts: PropTypes.arrayOf( + PropTypes.shape({ + address: PropTypes.string, // The unique address of the account. + label: PropTypes.string, // Optional label for the account. + metadata: PropTypes.shape({ + name: PropTypes.string, // Account's name from metadata. + }), + }), + ), + + /** + * An array of network objects to display in the tooltip. + */ + networks: PropTypes.arrayOf( + PropTypes.shape({ + chainId: PropTypes.string, // The unique chain ID of the network. + name: PropTypes.string, // The network's name. + }), + ), +}; diff --git a/ui/components/multichain/pages/review-permissions-page/site-cell/site-cell-tooltip.test.js b/ui/components/multichain/pages/review-permissions-page/site-cell/site-cell-tooltip.test.js new file mode 100644 index 000000000000..568e077ad0ed --- /dev/null +++ b/ui/components/multichain/pages/review-permissions-page/site-cell/site-cell-tooltip.test.js @@ -0,0 +1,221 @@ +import React from 'react'; +import { renderWithProvider } from '../../../../../../test/jest'; +import configureStore from '../../../../../store/store'; +import mockState from '../../../../../../test/data/mock-state.json'; +import { SiteCellTooltip } from './site-cell-tooltip'; + +describe('SiteCellTooltip', () => { + const store = configureStore({ + metamask: { + ...mockState.metamask, + }, + }); + const props = { + accounts: [ + { + id: 'e4a2f136-282d-4f06-8149-2e74e704a3fc', + address: '0x4dd158e8b382ba1649bda883a909037e1298552c', + options: {}, + methods: [ + 'personal_sign', + 'eth_sign', + 'eth_signTransaction', + 'eth_signTypedData_v1', + 'eth_signTypedData_v3', + 'eth_signTypedData_v4', + ], + type: 'eip155:eoa', + metadata: { + name: 'Account 4', + nameLastUpdatedAt: 1727088231912, + importTime: 1727088231225, + lastSelected: 1727088231278, + keyring: { + type: 'HD Key Tree', + }, + }, + balance: '0x00', + pinned: false, + hidden: false, + active: false, + keyring: { + type: 'HD Key Tree', + }, + label: null, + }, + { + id: '96bb1385-2807-479a-a00e-af63e74119cd', + address: '0x86771cd233a04c004ceebc3c1ad402fe8a37ff32', + options: {}, + methods: [ + 'personal_sign', + 'eth_sign', + 'eth_signTransaction', + 'eth_signTypedData_v1', + 'eth_signTypedData_v3', + 'eth_signTypedData_v4', + ], + type: 'eip155:eoa', + metadata: { + name: 'Account 5', + nameLastUpdatedAt: 1727099031302, + importTime: 1727099031101, + lastSelected: 1727099031109, + keyring: { + type: 'HD Key Tree', + }, + }, + balance: '0x00', + pinned: false, + hidden: false, + active: false, + keyring: { + type: 'HD Key Tree', + }, + label: null, + }, + { + id: '390013ea-34d9-4c58-a2d5-d98cd797aab8', + address: '0xf0b4efe81d9f277d05a9afeacbf076d86d9c041b', + options: {}, + methods: [ + 'personal_sign', + 'eth_sign', + 'eth_signTransaction', + 'eth_signTypedData_v1', + 'eth_signTypedData_v3', + 'eth_signTypedData_v4', + ], + type: 'eip155:eoa', + metadata: { + name: 'Account 6', + importTime: 1727180391924, + keyring: { + type: 'HD Key Tree', + }, + lastSelected: 1727180391971, + nameLastUpdatedAt: 1727180392652, + }, + balance: '0x00', + pinned: false, + hidden: false, + active: false, + keyring: { + type: 'HD Key Tree', + }, + label: null, + }, + ], + networks: [ + { + blockExplorerUrls: ['https://etherscan.io'], + chainId: '0x1', + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + rpcEndpoints: [ + { + networkClientId: 'mainnet', + type: 'infura', + url: 'https://mainnet.infura.io/v3/{infuraProjectId}', + }, + ], + }, + { + blockExplorerUrls: ['https://era.zksync.network/'], + chainId: '0x144', + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + name: 'zkSync Era Mainnet', + nativeCurrency: 'ETH', + rpcEndpoints: [ + { + name: 'ZKsync Era', + networkClientId: '9ceaf9eb-0aa2-4bd4-bf98-b390b91714d5', + type: 'custom', + url: 'https://mainnet.era.zksync.io', + }, + ], + }, + { + blockExplorerUrls: ['https://bscscan.com'], + chainId: '0x38', + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + name: 'Binance Smart Chain', + nativeCurrency: 'BNB', + rpcEndpoints: [ + { + name: 'BNB Smart Chain', + networkClientId: 'f1b61a9b-2238-4344-af5e-36d20f76de10', + type: 'custom', + url: 'https://bsc-dataseed.binance.org/', + }, + ], + }, + { + blockExplorerUrls: ['https://polygonscan.com/'], + chainId: '0x89', + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + name: 'Polygon', + nativeCurrency: 'POL', + rpcEndpoints: [ + { + name: 'Polygon Mainnet', + networkClientId: 'cf19f0de-8a83-468c-ad97-49b855a2ca9e', + type: 'custom', + url: 'https://polygon-mainnet.infura.io/v3/{infuraProjectId}', + }, + ], + }, + { + blockExplorerUrls: ['https://lineascan.build'], + chainId: '0xe708', + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + name: 'Linea Mainnet', + nativeCurrency: 'ETH', + rpcEndpoints: [ + { + networkClientId: 'linea-mainnet', + type: 'infura', + url: 'https://linea-mainnet.infura.io/v3/{infuraProjectId}', + }, + ], + }, + ], + }; + + it('should render correctly', () => { + const { container } = renderWithProvider( + , + store, + ); + + expect(container).toMatchSnapshot(); + }); + + it('should render Avatar Account correctly', () => { + const { container } = renderWithProvider( + , + store, + ); + + expect( + container.getElementsByClassName('mm-avatar-account__jazzicon'), + ).toBeDefined(); + }); + + it('should render Avatar Networks correctly', () => { + const { container } = renderWithProvider( + , + store, + ); + + expect( + container.getElementsByClassName('multichain-avatar-group'), + ).toBeDefined(); + }); +}); diff --git a/ui/components/multichain/pages/review-permissions-page/site-cell/site-cell.stories.tsx b/ui/components/multichain/pages/review-permissions-page/site-cell/site-cell.stories.tsx new file mode 100644 index 000000000000..7ca949ff9c02 --- /dev/null +++ b/ui/components/multichain/pages/review-permissions-page/site-cell/site-cell.stories.tsx @@ -0,0 +1,95 @@ +import React from 'react'; +import { SiteCell } from './site-cell'; + +export default { + title: 'Components/Multichain/SiteCell', + component: SiteCell, + argTypes: { + accounts: { control: 'array' }, + nonTestNetworks: { control: 'array' }, + testNetworks: { control: 'array' }, + }, + args: { + accounts: [ + { + id: '689821df-0e8f-4093-bbbb-b95cf0fa79cb', + address: '0x860092756917d3e069926ba130099375eeeb9440', + options: {}, + methods: [ + 'personal_sign', + 'eth_sign', + 'eth_signTransaction', + 'eth_signTypedData_v1', + 'eth_signTypedData_v3', + 'eth_signTypedData_v4', + ], + type: 'eip155:eoa', + metadata: { + name: 'Account 1', + importTime: 1726046726882, + keyring: { + type: 'HD Key Tree', + }, + lastSelected: 1726046726882, + }, + balance: '0x00', + }, + ], + selectedAccountAddresses: ['0x860092756917d3e069926ba130099375eeeb9440'], + selectedChainIds: ['0x1', '0xe708', '0x144', '0x89', '0x38'], + activeTabOrigin: 'https://app.uniswap.org', + nonTestNetworks: [ + { + chainId: '0x1', + rpcEndpoints: [ + { + networkClientId: 'mainnet', + url: 'https://mainnet.infura.io/v3/{infuraProjectId}', + type: 'infura', + }, + ], + defaultRpcEndpointIndex: 0, + blockExplorerUrls: ['https://etherscan.io'], + defaultBlockExplorerUrlIndex: 0, + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + }, + ], + testNetworks: [ + { + chainId: '0xaa36a7', + rpcEndpoints: [ + { + networkClientId: 'sepolia', + url: 'https://sepolia.infura.io/v3/{infuraProjectId}', + type: 'infura', + }, + ], + defaultRpcEndpointIndex: 0, + blockExplorerUrls: ['https://sepolia.etherscan.io'], + defaultBlockExplorerUrlIndex: 0, + name: 'Sepolia', + nativeCurrency: 'SepoliaETH', + }, + { + chainId: '0xe705', + rpcEndpoints: [ + { + networkClientId: 'linea-sepolia', + url: 'https://linea-sepolia.infura.io/v3/{infuraProjectId}', + type: 'infura', + }, + ], + defaultRpcEndpointIndex: 0, + blockExplorerUrls: ['https://sepolia.lineascan.build'], + defaultBlockExplorerUrlIndex: 0, + name: 'Linea Sepolia', + nativeCurrency: 'LineaETH', + }, + ], + }, +}; + +export const DefaultStory = (args) => ; + +DefaultStory.storyName = 'Default'; diff --git a/ui/components/multichain/pages/review-permissions-page/site-cell/site-cell.tsx b/ui/components/multichain/pages/review-permissions-page/site-cell/site-cell.tsx new file mode 100644 index 000000000000..2ed1fce8fddd --- /dev/null +++ b/ui/components/multichain/pages/review-permissions-page/site-cell/site-cell.tsx @@ -0,0 +1,126 @@ +import React, { useState } from 'react'; +import { Hex } from '@metamask/utils'; +import { BorderColor } from '../../../../../helpers/constants/design-system'; +import { useI18nContext } from '../../../../../hooks/useI18nContext'; +import { + AvatarAccount, + AvatarAccountSize, + IconName, +} from '../../../../component-library'; +import { EditAccountsModal, EditNetworksModal } from '../../..'; +import { MergedInternalAccount } from '../../../../../selectors/selectors.types'; +import { SiteCellTooltip } from './site-cell-tooltip'; +import { SiteCellConnectionListItem } from './site-cell-connection-list-item'; + +// Define types for networks, accounts, and other props +type Network = { + name: string; + chainId: string; +}; + +type SiteCellProps = { + nonTestNetworks: Network[]; + testNetworks: Network[]; + accounts: MergedInternalAccount[]; + onSelectAccountAddresses: (addresses: string[]) => void; + onSelectChainIds: (chainIds: Hex[]) => void; + selectedAccountAddresses: string[]; + selectedChainIds: string[]; + activeTabOrigin: string; + isConnectFlow?: boolean; +}; + +export const SiteCell: React.FC = ({ + nonTestNetworks, + testNetworks, + accounts, + onSelectAccountAddresses, + onSelectChainIds, + selectedAccountAddresses, + selectedChainIds, + activeTabOrigin, + isConnectFlow, +}) => { + const t = useI18nContext(); + + const allNetworks = [...nonTestNetworks, ...testNetworks]; + + const [showEditAccountsModal, setShowEditAccountsModal] = useState(false); + const [showEditNetworksModal, setShowEditNetworksModal] = useState(false); + + const selectedAccounts = accounts.filter(({ address }) => + selectedAccountAddresses.includes(address), + ); + const selectedNetworks = allNetworks.filter(({ chainId }) => + selectedChainIds.includes(chainId), + ); + + // Determine the messages for connected and not connected states + const accountMessageConnectedState = + selectedAccounts.length === 1 + ? t('connectedWithAccount', [ + selectedAccounts[0].label || selectedAccounts[0].metadata.name, + ]) + : t('connectedWith'); + const accountMessageNotConnectedState = + selectedAccounts.length === 1 + ? t('requestingForAccount', [ + selectedAccounts[0].label || selectedAccounts[0].metadata.name, + ]) + : t('requestingFor'); + + return ( + <> + setShowEditAccountsModal(true)} + content={ + // Why this difference? + selectedAccounts.length === 1 ? ( + + ) : ( + + ) + } + /> + setShowEditNetworksModal(true)} + content={} + /> + + {showEditAccountsModal && ( + setShowEditAccountsModal(false)} + onSubmit={onSelectAccountAddresses} + /> + )} + + {showEditNetworksModal && ( + setShowEditNetworksModal(false)} + onSubmit={onSelectChainIds} + /> + )} + + ); +}; diff --git a/ui/components/multichain/permissions-header/permissions-header.tsx b/ui/components/multichain/permissions-header/permissions-header.tsx new file mode 100644 index 000000000000..9ee7bec7a52c --- /dev/null +++ b/ui/components/multichain/permissions-header/permissions-header.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import { useHistory } from 'react-router-dom'; +import { + AlignItems, + BackgroundColor, + Display, + IconColor, + JustifyContent, + TextAlign, + TextVariant, +} from '../../../helpers/constants/design-system'; +import { + AvatarFavicon, + AvatarFaviconSize, + Box, + ButtonIcon, + ButtonIconSize, + Icon, + IconName, + IconSize, + Text, +} from '../../component-library'; +import { Header } from '../pages/page'; +import { getURLHost } from '../../../helpers/utils/util'; +import { useI18nContext } from '../../../hooks/useI18nContext'; + +export const PermissionsHeader = ({ + securedOrigin, + connectedSubjectsMetadata, +}: { + securedOrigin: string; + connectedSubjectsMetadata?: { name: string; iconUrl: string }; +}) => { + const t = useI18nContext(); + const history = useHistory(); + + return ( +
(history as any).goBack()} + size={ButtonIconSize.Sm} + /> + } + > + + {connectedSubjectsMetadata?.iconUrl ? ( + + ) : ( + + )} + + {getURLHost(securedOrigin)} + + +
+ ); +}; diff --git a/ui/components/ui/account-list/account-list.js b/ui/components/ui/account-list/account-list.js index 18fc35b2c6ce..13afac6c08f2 100644 --- a/ui/components/ui/account-list/account-list.js +++ b/ui/components/ui/account-list/account-list.js @@ -56,15 +56,8 @@ const AccountList = ({ }; const Header = () => { - let checked = false; - let isIndeterminate = false; - if (allAreSelected()) { - checked = true; - } else if (selectedAccounts.size === 0) { - checked = false; - } else { - isIndeterminate = true; - } + const checked = allAreSelected(); + const isIndeterminate = !checked && selectedAccounts.size !== 0; return (
+
+
+
+
+

+

+ Connect with MetaMask +

+

+ This site wants to + : +

+

+
+
+
+
+
+ +
+
+

+ See your accounts and suggest transactions +

+
+ + Requesting for Test Account + + +
+
+ +
+
+
+ +
+
+

+ Use your enabled networks +

+
+ + Requesting for + +
Alerts"" + data-tooltipped="" + style="display: inline;" + > +
+
+
+
+ G +
+
+
+
+ Custom Mainnet RPC logo +
+
+
+
+
+
+
+ +
+
+ +
+
+
+`; diff --git a/ui/pages/permissions-connect/connect-page/connect-page.test.tsx b/ui/pages/permissions-connect/connect-page/connect-page.test.tsx new file mode 100644 index 000000000000..9440d5031334 --- /dev/null +++ b/ui/pages/permissions-connect/connect-page/connect-page.test.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { renderWithProvider } from '../../../../test/jest/rendering'; +import mockState from '../../../../test/data/mock-state.json'; +import configureStore from '../../../store/store'; +import { ConnectPage, ConnectPageRequest } from './connect-page'; + +const render = ( + props: { + request: ConnectPageRequest; + permissionsRequestId: string; + rejectPermissionsRequest: (id: string) => void; + approveConnection: (request: ConnectPageRequest) => void; + activeTabOrigin: string; + } = { + request: { + id: '1', + origin: 'https://test.dapp', + }, + permissionsRequestId: '1', + rejectPermissionsRequest: jest.fn(), + approveConnection: jest.fn(), + activeTabOrigin: 'https://test.dapp', + }, + state = {}, +) => { + const store = configureStore({ + ...mockState, + metamask: { + ...mockState.metamask, + ...state, + permissionHistory: { + 'https://test.dapp': { + eth_accounts: { + accounts: { + '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc': 1709225290848, + }, + }, + }, + }, + }, + activeTab: { + origin: 'https://test.dapp', + }, + }); + return renderWithProvider(, store); +}; +describe('ConnectPage', () => { + it('should render correctly', () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it('should render title correctly', () => { + const { getByText } = render(); + expect(getByText('Connect with MetaMask')).toBeDefined(); + }); + + it('should render account connectionListItem', () => { + const { getByText } = render(); + expect( + getByText('See your accounts and suggest transactions'), + ).toBeDefined(); + }); + + it('should render network connectionListItem', () => { + const { getByText } = render(); + expect(getByText('Use your enabled networks')).toBeDefined(); + }); + + it('should render confirm and cancel button', () => { + const { getByText } = render(); + const confirmButton = getByText('Confirm'); + const cancelButton = getByText('Cancel'); + expect(confirmButton).toBeDefined(); + expect(cancelButton).toBeDefined(); + }); +}); diff --git a/ui/pages/permissions-connect/connect-page/connect-page.tsx b/ui/pages/permissions-connect/connect-page/connect-page.tsx new file mode 100644 index 000000000000..f332ba6cc07e --- /dev/null +++ b/ui/pages/permissions-connect/connect-page/connect-page.tsx @@ -0,0 +1,147 @@ +import React, { useMemo, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { InternalAccount, isEvmAccountType } from '@metamask/keyring-api'; +import { NetworkConfiguration } from '@metamask/network-controller'; +import { useI18nContext } from '../../../hooks/useI18nContext'; +import { + getInternalAccounts, + getNetworkConfigurationsByChainId, + getSelectedInternalAccount, + getUpdatedAndSortedAccounts, +} from '../../../selectors'; +import { + Box, + Button, + ButtonSize, + ButtonVariant, + Text, +} from '../../../components/component-library'; +import { + Content, + Footer, + Header, + Page, +} from '../../../components/multichain/pages/page'; +import { SiteCell } from '../../../components/multichain/pages/review-permissions-page'; +import { + BlockSize, + Display, + TextVariant, +} from '../../../helpers/constants/design-system'; +import { MergedInternalAccount } from '../../../selectors/selectors.types'; +import { mergeAccounts } from '../../../components/multichain/account-list-menu/account-list-menu'; +import { TEST_CHAINS } from '../../../../shared/constants/network'; + +export type ConnectPageRequest = { + id: string; + origin: string; +}; + +type ConnectPageProps = { + request: ConnectPageRequest; + permissionsRequestId: string; + rejectPermissionsRequest: (id: string) => void; + approveConnection: (request: ConnectPageRequest) => void; + activeTabOrigin: string; +}; + +export const ConnectPage: React.FC = ({ + request, + permissionsRequestId, + rejectPermissionsRequest, + approveConnection, + activeTabOrigin, +}) => { + const t = useI18nContext(); + + const networkConfigurations = useSelector(getNetworkConfigurationsByChainId); + const [nonTestNetworks, testNetworks] = useMemo( + () => + Object.entries(networkConfigurations).reduce( + ([nonTestNetworksList, testNetworksList], [chainId, network]) => { + const isTest = (TEST_CHAINS as string[]).includes(chainId); + (isTest ? testNetworksList : nonTestNetworksList).push(network); + return [nonTestNetworksList, testNetworksList]; + }, + [[] as NetworkConfiguration[], [] as NetworkConfiguration[]], + ), + [networkConfigurations], + ); + const defaultSelectedChainIds = nonTestNetworks.map(({ chainId }) => chainId); + const [selectedChainIds, setSelectedChainIds] = useState( + defaultSelectedChainIds, + ); + + const accounts = useSelector(getUpdatedAndSortedAccounts); + const internalAccounts = useSelector(getInternalAccounts); + const mergedAccounts: MergedInternalAccount[] = useMemo(() => { + return mergeAccounts(accounts, internalAccounts).filter( + (account: InternalAccount) => isEvmAccountType(account.type), + ); + }, [accounts, internalAccounts]); + + const currentAccount = useSelector(getSelectedInternalAccount); + const defaultAccountsAddresses = [currentAccount?.address]; + const [selectedAccountAddresses, setSelectedAccountAddresses] = useState( + defaultAccountsAddresses, + ); + + const onConfirm = () => { + const _request = { + ...request, + approvedAccounts: selectedAccountAddresses, + approvedChainIds: selectedChainIds, + }; + approveConnection(_request); + }; + + return ( + +
+ {t('connectWithMetaMask')} + {t('connectionDescription')}: +
+ + + +
+ + + + +
+
+ ); +}; diff --git a/ui/pages/permissions-connect/permissions-connect.component.js b/ui/pages/permissions-connect/permissions-connect.component.js index e5adf45a43fe..403c431330b1 100644 --- a/ui/pages/permissions-connect/permissions-connect.component.js +++ b/ui/pages/permissions-connect/permissions-connect.component.js @@ -25,6 +25,7 @@ import SnapsConnect from './snaps/snaps-connect'; import SnapInstall from './snaps/snap-install'; import SnapUpdate from './snaps/snap-update'; import SnapResult from './snaps/snap-result'; +import { ConnectPage } from './connect-page/connect-page'; const APPROVE_TIMEOUT = MILLISECOND * 1200; @@ -147,6 +148,9 @@ export default class PermissionConnect extends Component { history.replace(DEFAULT_ROUTE); return; } + if (process.env.CHAIN_PERMISSIONS) { + history.replace(confirmPermissionPath); + } // if this is an incremental permission request for permitted chains, skip the account selection if ( permissionsRequest?.diff?.permissionDiffMap?.[ @@ -155,7 +159,6 @@ export default class PermissionConnect extends Component { ) { history.replace(confirmPermissionPath); } - if (history.location.pathname === connectPath && !isRequestingAccounts) { switch (requestType) { case 'wallet_installSnap': @@ -292,9 +295,14 @@ export default class PermissionConnect extends Component { ); } + approveConnection = (...args) => { + const { approvePermissionsRequest } = this.props; + approvePermissionsRequest(...args); + this.redirect(true); + }; + render() { const { - approvePermissionsRequest, accounts, showNewAccountModal, newAccountNumber, @@ -314,6 +322,7 @@ export default class PermissionConnect extends Component { approvePendingApproval, rejectPendingApproval, setSnapsInstallPrivacyWarningShownStatus, + approvePermissionsRequest, } = this.props; const { selectedAccountAddresses, @@ -357,30 +366,42 @@ export default class PermissionConnect extends Component { ( - { - approvePermissionsRequest(...args); - this.redirect(true); - }} - rejectPermissionsRequest={(requestId) => - this.cancelPermissionsRequest(requestId) - } - selectedAccounts={accounts.filter((account) => - selectedAccountAddresses.has(account.address), - )} - targetSubjectMetadata={targetSubjectMetadata} - history={this.props.history} - connectPath={connectPath} - snapsInstallPrivacyWarningShown={ - snapsInstallPrivacyWarningShown - } - setSnapsInstallPrivacyWarningShownStatus={ - setSnapsInstallPrivacyWarningShownStatus - } - /> - )} + render={() => + process.env.CHAIN_PERMISSIONS && !permissionsRequest?.diff ? ( + + this.cancelPermissionsRequest(requestId) + } + activeTabOrigin={this.state.origin} + request={permissionsRequest} + permissionsRequestId={permissionsRequestId} + approveConnection={this.approveConnection} + /> + ) : ( + { + approvePermissionsRequest(...args); + this.redirect(true); + }} + rejectPermissionsRequest={(requestId) => + this.cancelPermissionsRequest(requestId) + } + selectedAccounts={accounts.filter((account) => + selectedAccountAddresses.has(account.address), + )} + targetSubjectMetadata={targetSubjectMetadata} + history={this.props.history} + connectPath={connectPath} + snapsInstallPrivacyWarningShown={ + snapsInstallPrivacyWarningShown + } + setSnapsInstallPrivacyWarningShownStatus={ + setSnapsInstallPrivacyWarningShownStatus + } + /> + ) + } /> ( { - approvePermissionsRequest(...args); - this.redirect(true); - }} + approveConnection={this.approveConnection} rejectConnection={(requestId) => this.cancelPermissionsRequest(requestId) } diff --git a/ui/pages/routes/routes.component.js b/ui/pages/routes/routes.component.js index 5c91d49c5266..a02ecfa32ef9 100644 --- a/ui/pages/routes/routes.component.js +++ b/ui/pages/routes/routes.component.js @@ -11,6 +11,7 @@ import Home from '../home'; import { PermissionsPage, Connections, + ReviewPermissions, } from '../../components/multichain/pages'; import Settings from '../settings'; import Authenticated from '../../helpers/higher-order-components/authenticated'; @@ -77,6 +78,7 @@ import { TOKEN_DETAILS, CONNECTIONS, PERMISSIONS, + REVIEW_PERMISSIONS, ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) INSTITUTIONAL_FEATURES_DONE_ROUTE, CUSTODY_ACCOUNT_DONE_ROUTE, @@ -189,6 +191,8 @@ export default class Routes extends Component { accountDetailsAddress: PropTypes.string, isImportNftsModalOpen: PropTypes.bool.isRequired, hideImportNftsModal: PropTypes.func.isRequired, + isPermittedNetworkToastOpen: PropTypes.bool.isRequired, + hidePermittedNetworkToast: PropTypes.func.isRequired, isIpfsModalOpen: PropTypes.bool.isRequired, isBasicConfigurationModalOpen: PropTypes.bool.isRequired, hideIpfsModal: PropTypes.func.isRequired, @@ -199,6 +203,7 @@ export default class Routes extends Component { addPermittedAccount: PropTypes.func.isRequired, switchedNetworkDetails: PropTypes.object, useNftDetection: PropTypes.bool, + currentNetwork: PropTypes.object, showNftEnablementToast: PropTypes.bool, setHideNftEnablementToast: PropTypes.func.isRequired, clearSwitchedNetworkDetails: PropTypes.func.isRequired, @@ -439,6 +444,11 @@ export default class Routes extends Component { component={Connections} /> + ); @@ -635,14 +645,16 @@ export default class Routes extends Component { useNftDetection, showNftEnablementToast, setHideNftEnablementToast, + isPermittedNetworkToastOpen, + currentNetwork, } = this.props; const showAutoNetworkSwitchToast = this.getShowAutoNetworkSwitchTest(); const isPrivacyToastRecent = this.getIsPrivacyToastRecent(); const isPrivacyToastNotShown = !newPrivacyPolicyToastShownDate; const isEvmAccount = isEvmAccountType(account?.type); - const autoHideToastDelay = 5 * SECOND; + const safeEncodedHost = encodeURIComponent(activeTabOrigin); const onAutoHideToast = () => { setHideNftEnablementToast(false); @@ -735,7 +747,7 @@ export default class Routes extends Component { } @@ -761,6 +773,32 @@ export default class Routes extends Component { onAutoHideToast={onAutoHideToast} /> ) : null} + + {process.env.CHAIN_PERMISSIONS && isPermittedNetworkToastOpen ? ( + + } + text={this.context.t('permittedChainToastUpdate', [ + getURLHost(activeTabOrigin), + currentNetwork?.nickname, + ])} + actionText={this.context.t('editPermissions')} + onActionClick={() => { + this.props.hidePermittedNetworkToast(); + this.props.history.push( + `${REVIEW_PERMISSIONS}/${safeEncodedHost}`, + ); + }} + onClose={() => this.props.hidePermittedNetworkToast()} + /> + ) : null} ); } @@ -929,6 +967,7 @@ export default class Routes extends Component { {isImportNftsModalOpen ? ( hideImportNftsModal()} /> ) : null} + {isIpfsModalOpen ? ( hideIpfsModal()} /> ) : null} diff --git a/ui/pages/routes/routes.container.js b/ui/pages/routes/routes.container.js index 856aa8b53ade..419daf561778 100644 --- a/ui/pages/routes/routes.container.js +++ b/ui/pages/routes/routes.container.js @@ -28,6 +28,7 @@ import { getUseRequestQueue, getUseNftDetection, getNftDetectionEnablementToast, + getCurrentNetwork, } from '../../selectors'; import { getSmartTransactionsOptInStatus } from '../../../shared/modules/selectors'; import { @@ -52,6 +53,7 @@ import { hideKeyringRemovalResultModal, ///: END:ONLY_INCLUDE_IF setEditedNetwork, + hidePermittedNetworkToast, } from '../../store/actions'; import { pageChanged } from '../../ducks/history/history'; import { prepareToLeaveSwaps } from '../../ducks/swaps/swaps'; @@ -77,6 +79,7 @@ function mapStateToProps(state) { const account = getSelectedAccount(state); const activeTabOrigin = activeTab?.origin; const connectedAccounts = getPermittedAccountsForCurrentTab(state); + const currentNetwork = getCurrentNetwork(state); const showConnectAccountToast = Boolean( allowShowAccountSetting && account && @@ -129,10 +132,12 @@ function mapStateToProps(state) { accountDetailsAddress: state.appState.accountDetailsAddress, isImportNftsModalOpen: state.appState.importNftsModal.open, isIpfsModalOpen: state.appState.showIpfsModalOpen, + isPermittedNetworkToastOpen: state.appState.showPermittedNetworkToastOpen, switchedNetworkDetails, useNftDetection, showNftEnablementToast, networkToAutomaticallySwitchTo, + currentNetwork, totalUnapprovedConfirmationCount: getNumberOfAllUnapprovedTransactionsAndMessages(state), neverShowSwitchedNetworkMessage: getNeverShowSwitchedNetworkMessage(state), @@ -160,6 +165,7 @@ function mapDispatchToProps(dispatch) { toggleNetworkMenu: () => dispatch(toggleNetworkMenu()), hideImportNftsModal: () => dispatch(hideImportNftsModal()), hideIpfsModal: () => dispatch(hideIpfsModal()), + hidePermittedNetworkToast: () => dispatch(hidePermittedNetworkToast()), hideImportTokensModal: () => dispatch(hideImportTokensModal()), hideDeprecatedNetworkModal: () => dispatch(hideDeprecatedNetworkModal()), addPermittedAccount: (activeTabOrigin, address) => diff --git a/ui/selectors/permissions.js b/ui/selectors/permissions.js index 65f2acf37c4b..fb32d41c9b17 100644 --- a/ui/selectors/permissions.js +++ b/ui/selectors/permissions.js @@ -2,6 +2,8 @@ import { ApprovalType } from '@metamask/controller-utils'; import { WALLET_SNAP_PERMISSION_KEY } from '@metamask/snaps-rpc-methods'; import { isEvmAccountType } from '@metamask/keyring-api'; import { CaveatTypes } from '../../shared/constants/permissions'; +// eslint-disable-next-line import/no-restricted-paths +import { PermissionNames } from '../../app/scripts/controllers/permissions'; import { getApprovalRequestsByType } from './approvals'; import { createDeepEqualSelector } from './util'; import { @@ -60,6 +62,12 @@ export function getPermittedAccounts(state, origin) { ); } +export function getPermittedChains(state, origin) { + return getChainsFromPermission( + getChainsPermissionFromSubject(subjectSelector(state, origin)), + ); +} + /** * Selects the permitted accounts from the eth_accounts permission for the * origin of the current tab. @@ -75,6 +83,14 @@ export function getPermittedAccountsForSelectedTab(state, activeTab) { return getPermittedAccounts(state, activeTab); } +export function getPermittedChainsForCurrentTab(state) { + return getPermittedAccounts(state, getOriginOfCurrentTab(state)); +} + +export function getPermittedChainsForSelectedTab(state, activeTab) { + return getPermittedChains(state, activeTab); +} + /** * Returns a map of permitted accounts by origin for all origins. * @@ -92,6 +108,17 @@ export function getPermittedAccountsByOrigin(state) { }, {}); } +export function getPermittedChainsByOrigin(state) { + const subjects = getPermissionSubjects(state); + return Object.keys(subjects).reduce((acc, subjectKey) => { + const chains = getChainsFromSubject(subjects[subjectKey]); + if (chains.length > 0) { + acc[subjectKey] = chains; + } + return acc; + }, {}); +} + export function getSubjectMetadata(state) { return state.metamask.subjectMetadata; } @@ -256,6 +283,14 @@ function getAccountsPermissionFromSubject(subject = {}) { return subject.permissions?.eth_accounts || {}; } +function getChainsFromSubject(subject) { + return getChainsFromPermission(getChainsPermissionFromSubject(subject)); +} + +function getChainsPermissionFromSubject(subject = {}) { + return subject.permissions?.[PermissionNames.permittedChains] || {}; +} + function getAccountsFromPermission(accountsPermission) { const accountsCaveat = getAccountsCaveatFromPermission(accountsPermission); return accountsCaveat && Array.isArray(accountsCaveat.value) @@ -263,6 +298,22 @@ function getAccountsFromPermission(accountsPermission) { : []; } +function getChainsFromPermission(chainsPermission) { + const chainsCaveat = getChainsCaveatFromPermission(chainsPermission); + return chainsCaveat && Array.isArray(chainsCaveat.value) + ? chainsCaveat.value + : []; +} + +function getChainsCaveatFromPermission(chainsPermission = {}) { + return ( + Array.isArray(chainsPermission.caveats) && + chainsPermission.caveats.find( + (caveat) => caveat.type === CaveatTypes.restrictNetworkSwitching, + ) + ); +} + function getAccountsCaveatFromPermission(accountsPermission = {}) { return ( Array.isArray(accountsPermission.caveats) && diff --git a/ui/selectors/selectors.js b/ui/selectors/selectors.js index bdc1547b7246..31c262df62c4 100644 --- a/ui/selectors/selectors.js +++ b/ui/selectors/selectors.js @@ -1307,6 +1307,10 @@ export function getShowWhatsNewPopup(state) { return state.appState.showWhatsNewPopup; } +export function getShowPermittedNetworkToastOpen(state) { + return state.appState.showPermittedNetworkToastOpen; +} + /** * Returns a memoized selector that gets the internal accounts from the Redux store. * diff --git a/ui/store/actionConstants.ts b/ui/store/actionConstants.ts index a54a5a220be8..074568cfbf1d 100644 --- a/ui/store/actionConstants.ts +++ b/ui/store/actionConstants.ts @@ -14,6 +14,10 @@ export const NETWORK_DROPDOWN_CLOSE = 'UI_NETWORK_DROPDOWN_CLOSE'; export const IMPORT_NFTS_MODAL_OPEN = 'UI_IMPORT_NFTS_MODAL_OPEN'; export const IMPORT_NFTS_MODAL_CLOSE = 'UI_IMPORT_NFTS_MODAL_CLOSE'; export const SHOW_IPFS_MODAL_OPEN = 'UI_IPFS_MODAL_OPEN'; +export const SHOW_PERMITTED_NETWORK_TOAST_OPEN = + 'UI_PERMITTED_NETWORK_TOAST_OPEN'; +export const SHOW_PERMITTED_NETWORK_TOAST_CLOSE = + 'UI_PERMITTED_NETWORK_TOAST_CLOSE'; export const SHOW_IPFS_MODAL_CLOSE = 'UI_IPFS_MODAL_CLOSE'; export const IMPORT_TOKENS_POPOVER_OPEN = 'UI_IMPORT_TOKENS_POPOVER_OPEN'; export const IMPORT_TOKENS_POPOVER_CLOSE = 'UI_IMPORT_TOKENS_POPOVER_CLOSE'; @@ -78,6 +82,10 @@ export const SHOW_NFT_DETECTION_ENABLEMENT_TOAST = export const TOGGLE_ACCOUNT_MENU = 'TOGGLE_ACCOUNT_MENU'; export const TOGGLE_NETWORK_MENU = 'TOGGLE_NETWORK_MENU'; +export const SET_SELECTED_ACCOUNTS_FOR_DAPP_CONNECTIONS = + 'SET_SELECTED_ACCOUNTS_FOR_DAPP_CONNECTIONS'; +export const SET_SELECTED_NETWORKS_FOR_DAPP_CONNECTIONS = + 'SET_SELECTED_NETWORKS_FOR_DAPP_CONNECTIONS'; // deprecated network modal export const DEPRECATED_NETWORK_POPOVER_OPEN = diff --git a/ui/store/actions.test.js b/ui/store/actions.test.js index 68c887c82a82..a136287f039c 100644 --- a/ui/store/actions.test.js +++ b/ui/store/actions.test.js @@ -17,6 +17,10 @@ import { MetaMetricsNetworkEventSource } from '../../shared/constants/metametric import { ETH_EOA_METHODS } from '../../shared/constants/eth-methods'; import { mockNetworkState } from '../../test/stub/networks'; import { CHAIN_IDS } from '../../shared/constants/network'; +import { + CaveatTypes, + EndowmentTypes, +} from '../../shared/constants/permissions'; import * as actions from './actions'; import * as actionConstants from './actionConstants'; import { setBackgroundConnection } from './background-connection'; @@ -77,6 +81,10 @@ describe('Actions', () => { background.abortTransactionSigning = sinon.stub(); background.toggleExternalServices = sinon.stub(); background.getStatePatches = sinon.stub().callsFake((cb) => cb(null, [])); + background.removePermittedChain = sinon.stub(); + background.requestAccountsAndChainPermissionsWithId = sinon.stub(); + background.grantPermissions = sinon.stub(); + background.grantPermissionsIncremental = sinon.stub(); }); describe('#tryUnlockMetamask', () => { @@ -2530,4 +2538,124 @@ describe('Actions', () => { ); }); }); + + describe('removePermittedChain', () => { + afterEach(() => { + sinon.restore(); + }); + + it('calls removePermittedChain in the background', async () => { + const store = mockStore(); + + background.removePermittedChain.callsFake((_, __, cb) => cb()); + setBackgroundConnection(background); + + await store.dispatch(actions.removePermittedChain('test.com', '0x1')); + + expect( + background.removePermittedChain.calledWith( + 'test.com', + '0x1', + sinon.match.func, + ), + ).toBe(true); + expect(store.getActions()).toStrictEqual([]); + }); + }); + + describe('requestAccountsAndChainPermissionsWithId', () => { + afterEach(() => { + sinon.restore(); + }); + + it('calls requestAccountsAndChainPermissionsWithId in the background', async () => { + const store = mockStore(); + + background.requestAccountsAndChainPermissionsWithId.callsFake((_, cb) => + cb(), + ); + setBackgroundConnection(background); + + await store.dispatch( + actions.requestAccountsAndChainPermissionsWithId('test.com'), + ); + + expect( + background.requestAccountsAndChainPermissionsWithId.calledWith( + 'test.com', + sinon.match.func, + ), + ).toBe(true); + expect(store.getActions()).toStrictEqual([]); + }); + }); + + describe('grantPermittedChain', () => { + afterEach(() => { + sinon.restore(); + }); + + it('calls grantPermissionsIncremental in the background', async () => { + const store = mockStore(); + + background.grantPermissionsIncremental.callsFake((_, cb) => cb()); + setBackgroundConnection(background); + + await actions.grantPermittedChain('test.com', '0x1'); + expect( + background.grantPermissionsIncremental.calledWith( + { + subject: { origin: 'test.com' }, + approvedPermissions: { + [EndowmentTypes.permittedChains]: { + caveats: [ + { + type: CaveatTypes.restrictNetworkSwitching, + value: ['0x1'], + }, + ], + }, + }, + }, + sinon.match.func, + ), + ).toBe(true); + expect(store.getActions()).toStrictEqual([]); + }); + }); + + describe('grantPermittedChains', () => { + afterEach(() => { + sinon.restore(); + }); + + it('calls grantPermissions in the background', async () => { + const store = mockStore(); + + background.grantPermissions.callsFake((_, cb) => cb()); + setBackgroundConnection(background); + + await actions.grantPermittedChains('test.com', ['0x1', '0x2']); + expect( + background.grantPermissions.calledWith( + { + subject: { origin: 'test.com' }, + approvedPermissions: { + [EndowmentTypes.permittedChains]: { + caveats: [ + { + type: CaveatTypes.restrictNetworkSwitching, + value: ['0x1', '0x2'], + }, + ], + }, + }, + }, + sinon.match.func, + ), + ).toBe(true); + + expect(store.getActions()).toStrictEqual([]); + }); + }); }); diff --git a/ui/store/actions.ts b/ui/store/actions.ts index db23a2e5e7a2..c4bed2665a6b 100644 --- a/ui/store/actions.ts +++ b/ui/store/actions.ts @@ -119,6 +119,10 @@ import { getMethodDataAsync } from '../../shared/lib/four-byte'; import { DecodedTransactionDataResponse } from '../../shared/types/transaction-decode'; import { LastInteractedConfirmationInfo } from '../pages/confirmations/types/confirm'; import { EndTraceRequest } from '../../shared/lib/trace'; +import { + CaveatTypes, + EndowmentTypes, +} from '../../shared/constants/permissions'; import * as actionConstants from './actionConstants'; ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) import { updateCustodyState } from './institutional/institution-actions'; @@ -1748,8 +1752,8 @@ export function setSelectedAccount( export function addPermittedAccount( origin: string, - address: [], -): ThunkAction { + address: string, +): ThunkAction, MetaMaskReduxState, unknown, AnyAction> { return async (dispatch: MetaMaskReduxDispatch) => { await new Promise((resolve, reject) => { callBackgroundMethod( @@ -1767,14 +1771,14 @@ export function addPermittedAccount( await forceUpdateMetamaskState(dispatch); }; } -export function addMorePermittedAccounts( +export function addPermittedAccounts( origin: string, address: string[], -): ThunkAction { +): ThunkAction, MetaMaskReduxState, unknown, AnyAction> { return async (dispatch: MetaMaskReduxDispatch) => { await new Promise((resolve, reject) => { callBackgroundMethod( - 'addMorePermittedAccounts', + 'addPermittedAccounts', [origin, address], (error) => { if (error) { @@ -1792,7 +1796,7 @@ export function addMorePermittedAccounts( export function removePermittedAccount( origin: string, address: string, -): ThunkAction { +): ThunkAction, MetaMaskReduxState, unknown, AnyAction> { return async (dispatch: MetaMaskReduxDispatch) => { await new Promise((resolve, reject) => { callBackgroundMethod( @@ -1811,6 +1815,67 @@ export function removePermittedAccount( }; } +export function addPermittedChain( + origin: string, + chainId: string, +): ThunkAction, MetaMaskReduxState, unknown, AnyAction> { + return async (dispatch: MetaMaskReduxDispatch) => { + await new Promise((resolve, reject) => { + callBackgroundMethod('addPermittedChain', [origin, chainId], (error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + await forceUpdateMetamaskState(dispatch); + }; +} +export function addPermittedChains( + origin: string, + chainIds: string[], +): ThunkAction, MetaMaskReduxState, unknown, AnyAction> { + return async (dispatch: MetaMaskReduxDispatch) => { + await new Promise((resolve, reject) => { + callBackgroundMethod( + 'addPermittedChains', + [origin, chainIds], + (error) => { + if (error) { + reject(error); + return; + } + resolve(); + }, + ); + }); + await forceUpdateMetamaskState(dispatch); + }; +} + +export function removePermittedChain( + origin: string, + chainId: string, +): ThunkAction, MetaMaskReduxState, unknown, AnyAction> { + return async (dispatch: MetaMaskReduxDispatch) => { + await new Promise((resolve, reject) => { + callBackgroundMethod( + 'removePermittedChain', + [origin, chainId], + (error) => { + if (error) { + reject(error); + return; + } + resolve(); + }, + ); + }); + await forceUpdateMetamaskState(dispatch); + }; +} + export function showAccountsPage() { return { type: actionConstants.SHOW_ACCOUNTS_PAGE, @@ -2552,6 +2617,18 @@ export function hideImportNftsModal(): Action { }; } +export function hidePermittedNetworkToast(): Action { + return { + type: actionConstants.SHOW_PERMITTED_NETWORK_TOAST_CLOSE, + }; +} + +export function showPermittedNetworkToast(): Action { + return { + type: actionConstants.SHOW_PERMITTED_NETWORK_TOAST_OPEN, + }; +} + // TODO: Replace `any` with type // eslint-disable-next-line @typescript-eslint/no-explicit-any export function setConfirmationExchangeRates(value: Record) { @@ -3143,7 +3220,7 @@ export function toggleNetworkMenu(payload?: { }; } -export function setAccountDetailsAddress(address: string) { +export function setAccountDetailsAddress(address: string[]) { return { type: actionConstants.SET_ACCOUNT_DETAILS_ADDRESS, payload: address, @@ -3800,6 +3877,19 @@ export function requestAccountsPermissionWithId( }; } +export function requestAccountsAndChainPermissionsWithId( + origin: string, +): ThunkAction, MetaMaskReduxState, unknown, AnyAction> { + return async (dispatch: MetaMaskReduxDispatch) => { + const id = await submitRequestToBackground( + 'requestAccountsAndChainPermissionsWithId', + [origin], + ); + await forceUpdateMetamaskState(dispatch); + return id; + }; +} + /** * Approves the permissions request. * @@ -5557,6 +5647,48 @@ export async function getNextAvailableAccountName( ); } +export async function grantPermittedChain( + selectedTabOrigin: string, + chainId?: string, +): Promise { + return await submitRequestToBackground('grantPermissionsIncremental', [ + { + subject: { origin: selectedTabOrigin }, + approvedPermissions: { + [EndowmentTypes.permittedChains]: { + caveats: [ + { + type: CaveatTypes.restrictNetworkSwitching, + value: [chainId], + }, + ], + }, + }, + }, + ]); +} + +export async function grantPermittedChains( + selectedTabOrigin: string, + chainIds: string[], +): Promise { + return await submitRequestToBackground('grantPermissions', [ + { + subject: { origin: selectedTabOrigin }, + approvedPermissions: { + [EndowmentTypes.permittedChains]: { + caveats: [ + { + type: CaveatTypes.restrictNetworkSwitching, + value: chainIds, + }, + ], + }, + }, + }, + ]); +} + export async function decodeTransactionData({ transactionData, contractAddress,