diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index e312be4794e5..54557925f7a5 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" }, @@ -1172,6 +1182,10 @@ "connectedWith": { "message": "Connected with" }, + "connectedWithAccount": { + "message": "Connected with $1", + "description": "$1 represents account name" + }, "connecting": { "message": "Connecting" }, @@ -1199,6 +1213,9 @@ "connectingToSepolia": { "message": "Connecting to Sepolia test network" }, + "connectionDescription": { + "message": "This site wants to" + }, "connectionFailed": { "message": "Connection failed" }, @@ -1591,6 +1608,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" }, @@ -1602,6 +1623,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" }, @@ -1766,6 +1791,9 @@ "editPermission": { "message": "Edit permission" }, + "editPermissions": { + "message": "Edit permissions" + }, "editSpeedUpEditGasFeeModalTitle": { "message": "Edit speed up gas fee" }, @@ -2904,6 +2932,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." }, @@ -3086,6 +3122,10 @@ "networkOptions": { "message": "Network options" }, + "networkPermissionToast": { + "message": "Network permissions updated for $1", + "description": "$1 represents connected dapp" + }, "networkProvider": { "message": "Network provider" }, @@ -3124,6 +3164,9 @@ "networks": { "message": "Networks" }, + "networksSmallCase": { + "message": "networks" + }, "nevermind": { "message": "Nevermind" }, @@ -4031,6 +4074,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." }, @@ -4400,6 +4446,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 577184ed8ede..49eaa2d7a8c8 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, @@ -2844,7 +2845,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); @@ -2853,6 +2854,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 () => { @@ -3217,6 +3252,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, @@ -5665,7 +5707,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( @@ -5680,10 +5727,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,