diff --git a/.changeset/app-1003-02-data.md b/.changeset/app-1003-02-data.md new file mode 100644 index 0000000000..8ab0720bf7 --- /dev/null +++ b/.changeset/app-1003-02-data.md @@ -0,0 +1,5 @@ +--- +"@aragon/app": none +--- + +Internal stack layer — no user-facing changes. diff --git a/apps/app/src/backendApiMocks.ts b/apps/app/src/backendApiMocks.ts index b59494841b..1ce3b10fe0 100644 --- a/apps/app/src/backendApiMocks.ts +++ b/apps/app/src/backendApiMocks.ts @@ -1,4 +1,3 @@ -import { permissionsMocks } from './modules/settings/constants/permissionsMocks'; import type { IBackendApiMock } from './shared/types'; -export const backendApiMocks: IBackendApiMock[] = [...permissionsMocks]; +export const backendApiMocks: IBackendApiMock[] = []; diff --git a/apps/app/src/modules/settings/hooks/usePermissionsData/index.ts b/apps/app/src/modules/settings/hooks/usePermissionsData/index.ts new file mode 100644 index 0000000000..e344ad57da --- /dev/null +++ b/apps/app/src/modules/settings/hooks/usePermissionsData/index.ts @@ -0,0 +1,6 @@ +export { + type IPermissionsDataAccount, + type IUsePermissionsDataParams, + type IUsePermissionsDataResult, + usePermissionsData, +} from './usePermissionsData'; diff --git a/apps/app/src/modules/settings/hooks/usePermissionsData/usePermissionsData.test.ts b/apps/app/src/modules/settings/hooks/usePermissionsData/usePermissionsData.test.ts new file mode 100644 index 0000000000..de28004e9a --- /dev/null +++ b/apps/app/src/modules/settings/hooks/usePermissionsData/usePermissionsData.test.ts @@ -0,0 +1,245 @@ +import { act, renderHook } from '@testing-library/react'; +import * as daoService from '@/shared/api/daoService'; +import { type IDao, type IDaoPlugin, Network } from '@/shared/api/daoService'; +import * as featureFlagsProvider from '@/shared/components/featureFlagsProvider'; +import * as useDaoPluginsModule from '@/shared/hooks/useDaoPlugins'; +import { + generateDao, + generateDaoMetrics, + generateDaoPlugin, + generateFilterComponentPlugin, + generateReactQueryResultSuccess, +} from '@/shared/testUtils'; +import { usePermissionsData } from './usePermissionsData'; + +const linkedAccounts: IDao['linkedAccounts'] = [ + { + id: 'linked-1', + address: '0xLinkedAddress', + network: Network.POLYGON_MAINNET, + name: 'Linked Treasury', + description: '', + ens: null, + subdomain: null, + avatar: null, + metrics: generateDaoMetrics(), + links: [], + blockTimestamp: 0, + transactionHash: '', + }, +]; + +describe('usePermissionsData hook', () => { + const useDaoSpy = jest.spyOn(daoService, 'useDao'); + const useAllDaoPermissionsSpy = jest.spyOn( + daoService, + 'useAllDaoPermissions', + ); + const useDaoPluginsSpy = jest.spyOn(useDaoPluginsModule, 'useDaoPlugins'); + const useFeatureFlagsSpy = jest.spyOn( + featureFlagsProvider, + 'useFeatureFlags', + ); + + const setFeatureFlags = (enabled: Record) => { + useFeatureFlagsSpy.mockReturnValue({ + isEnabled: (key: string) => enabled[key] ?? false, + } as ReturnType); + }; + + const setDao = (dao?: Partial) => { + useDaoSpy.mockReturnValue( + generateReactQueryResultSuccess({ + data: generateDao(dao), + }) as ReturnType, + ); + }; + + beforeEach(() => { + setFeatureFlags({}); + setDao(); + useAllDaoPermissionsSpy.mockReturnValue({ + data: [], + isLoading: false, + error: null, + refetch: jest.fn(), + } as ReturnType); + useDaoPluginsSpy.mockReturnValue([]); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('re-queries permissions with the selected account params on switch', () => { + setFeatureFlags({ linkedAccount: true }); + setDao({ + id: 'main-dao', + address: '0xMainAddress', + network: Network.ETHEREUM_MAINNET, + name: 'Main DAO', + linkedAccounts, + }); + + const { result } = renderHook(() => + usePermissionsData({ daoId: 'main-dao' }), + ); + + expect(useAllDaoPermissionsSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ + urlParams: { + network: Network.ETHEREUM_MAINNET, + daoAddress: '0xMainAddress', + }, + }), + expect.anything(), + ); + + act(() => { + result.current.setSelectedAccountId('linked-1'); + }); + + expect(useAllDaoPermissionsSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ + urlParams: { + network: Network.POLYGON_MAINNET, + daoAddress: '0xLinkedAddress', + }, + }), + expect.anything(), + ); + }); + + // Guards the deleted permissionsMocks / preview system: the flag is inert and + // the hook always reads real backend dao data. + it('ignores the mocks flag and always uses backend dao data', () => { + setFeatureFlags({ useMocks: true }); + setDao({ + id: 'main-dao', + address: '0xMainAddress', + network: Network.ETHEREUM_MAINNET, + name: 'Main DAO', + }); + + const { result } = renderHook(() => + usePermissionsData({ daoId: 'main-dao' }), + ); + + expect(result.current.accounts).toEqual([ + expect.objectContaining({ + daoAddress: '0xMainAddress', + name: 'Main DAO', + }), + ]); + expect(result.current.dao?.name).toBe('Main DAO'); + }); + + it('loads subplugins for permission graph filtering', () => { + renderHook(() => usePermissionsData({ daoId: 'main-dao' })); + + expect(useDaoPluginsSpy).toHaveBeenCalledWith( + expect.objectContaining({ + daoId: 'main-dao', + includeLinkedAccounts: true, + includeSubPlugins: true, + }), + ); + }); + + it('only exposes plugin metadata for the selected account', () => { + setFeatureFlags({ linkedAccount: true }); + setDao({ + id: 'main-dao', + address: '0xMainAddress', + network: Network.ETHEREUM_MAINNET, + name: 'Main DAO', + linkedAccounts, + }); + const rootPlugin = generateFilterComponentPlugin({ + uniqueId: 'root-plugin', + meta: generateDaoPlugin({ + address: '0xRootPlugin', + daoAddress: '0xMainAddress', + }), + }); + const legacyRootPlugin = generateFilterComponentPlugin< + IDaoPlugin, + object + >({ + uniqueId: 'legacy-root-plugin', + meta: generateDaoPlugin({ + address: '0xLegacyRootPlugin', + daoAddress: undefined, + }), + }); + const linkedPlugin = generateFilterComponentPlugin({ + uniqueId: 'linked-plugin', + meta: generateDaoPlugin({ + address: '0xLinkedPlugin', + daoAddress: '0xLinkedAddress', + }), + }); + useDaoPluginsSpy.mockReturnValue([ + rootPlugin, + legacyRootPlugin, + linkedPlugin, + ]); + + const { result } = renderHook(() => + usePermissionsData({ daoId: 'main-dao' }), + ); + + expect( + result.current.daoPlugins?.map((plugin) => plugin.uniqueId), + ).toEqual(['root-plugin', 'legacy-root-plugin']); + + act(() => { + result.current.setSelectedAccountId('linked-1'); + }); + + expect( + result.current.daoPlugins?.map((plugin) => plugin.uniqueId), + ).toEqual(['linked-plugin']); + }); + + // FLT-5 guard: no stitching, no no-op memo — the endpoint rows pass through by identity. + it('preserves the permission endpoint rows without stitching or filtering', () => { + const rows = [ + { + permissionId: 'permission-id', + whoAddress: '0x1111111111111111111111111111111111111111', + whereAddress: '0x2222222222222222222222222222222222222222', + conditionAddress: + '0x0000000000000000000000000000000000000000000000000000000000000002', + }, + ]; + useAllDaoPermissionsSpy.mockReturnValue({ + data: rows, + isLoading: false, + error: null, + refetch: jest.fn(), + } as ReturnType); + + const { result } = renderHook(() => + usePermissionsData({ daoId: 'main-dao' }), + ); + + expect(result.current.rows).toBe(rows); + }); + + it('preserves the exact permission-query error', () => { + const error = new Error('permissions unavailable'); + useAllDaoPermissionsSpy.mockReturnValue({ + data: undefined, + isLoading: false, + error, + refetch: jest.fn(), + } as ReturnType); + + const { result } = renderHook(() => + usePermissionsData({ daoId: 'main-dao' }), + ); + + expect(result.current).toMatchObject({ error }); + }); +}); diff --git a/apps/app/src/modules/settings/hooks/usePermissionsData/usePermissionsData.ts b/apps/app/src/modules/settings/hooks/usePermissionsData/usePermissionsData.ts new file mode 100644 index 0000000000..b39f3db218 --- /dev/null +++ b/apps/app/src/modules/settings/hooks/usePermissionsData/usePermissionsData.ts @@ -0,0 +1,155 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { + type IDao, + type IDaoPermission, + type IDaoPlugin, + type Network, + useAllDaoPermissions, + useDao, +} from '@/shared/api/daoService'; +import { useFeatureFlags } from '@/shared/components/featureFlagsProvider'; +import type { IFilterComponentPlugin } from '@/shared/components/pluginFilterComponent'; +import { networkDefinitions } from '@/shared/constants/networkDefinitions'; +import { useDaoPlugins } from '@/shared/hooks/useDaoPlugins'; +import { ipfsUtils } from '@/shared/utils/ipfsUtils'; +import type { IPermissionAccountRef } from '../../utils/permissionEntityUtils'; + +export interface IPermissionsDataAccount { + id: string; + name: string; + network: Network; + daoAddress: string; + avatarSrc?: string; +} + +export interface IUsePermissionsDataParams { + daoId: string; +} + +export interface IUsePermissionsDataResult { + dao?: IDao; + accounts: IPermissionsDataAccount[]; + activeAccountId?: string; + setSelectedAccountId: (id: string) => void; + activeAccount?: IPermissionsDataAccount; + accountRefs: IPermissionAccountRef[]; + daoPlugins?: IFilterComponentPlugin[]; + rows: IDaoPermission[]; + chainId?: number; + isLoading: boolean; + error: unknown; +} + +export const usePermissionsData = ( + params: IUsePermissionsDataParams, +): IUsePermissionsDataResult => { + const { daoId } = params; + + const { isEnabled } = useFeatureFlags(); + + const { data: dao } = useDao({ urlParams: { id: daoId } }); + const daoPluginsData = useDaoPlugins({ + daoId, + includeSubPlugins: true, + includeLinkedAccounts: true, + }); + + const accounts = useMemo(() => { + if (dao == null) { + return []; + } + + const mainAccount: IPermissionsDataAccount = { + id: dao.id, + name: dao.name, + network: dao.network, + daoAddress: dao.address, + avatarSrc: ipfsUtils.cidToSrc(dao.avatar), + }; + + const linkedAccounts = dao.linkedAccounts ?? []; + const showLinkedAccounts = + isEnabled('linkedAccount') && linkedAccounts.length > 0; + + if (!showLinkedAccounts) { + return [mainAccount]; + } + + return [ + mainAccount, + ...linkedAccounts.map((account) => ({ + id: account.id, + name: account.name, + network: account.network, + daoAddress: account.address, + avatarSrc: ipfsUtils.cidToSrc(account.avatar), + })), + ]; + }, [dao, isEnabled]); + + const [selectedAccountId, setSelectedAccountId] = useState(); + const activeAccountId = selectedAccountId ?? accounts[0]?.id; + const activeAccount = + accounts.find((account) => account.id === activeAccountId) ?? + accounts[0]; + + const accountRefs = useMemo( + () => + accounts.map((account) => ({ + address: account.daoAddress, + name: account.name, + avatarSrc: account.avatarSrc, + })), + [accounts], + ); + + const { data, isLoading, error } = useAllDaoPermissions( + { + urlParams: { + network: activeAccount?.network as Network, + daoAddress: activeAccount?.daoAddress ?? '', + }, + }, + { enabled: activeAccount != null }, + ); + + const rows: IDaoPermission[] = data ?? []; + + const chainId = activeAccount + ? networkDefinitions[activeAccount.network].id + : undefined; + const daoPlugins = useMemo(() => { + if (daoPluginsData == null || dao == null || activeAccount == null) { + return daoPluginsData; + } + + const rootDaoAddress = dao.address.toLowerCase(); + const activeDaoAddress = activeAccount.daoAddress.toLowerCase(); + + return daoPluginsData.filter((plugin) => { + const pluginDaoAddress = plugin.meta.daoAddress?.toLowerCase(); + + if (pluginDaoAddress == null) { + return activeDaoAddress === rootDaoAddress; + } + + return pluginDaoAddress === activeDaoAddress; + }); + }, [activeAccount, dao, daoPluginsData]); + + return { + dao, + accounts, + activeAccountId, + setSelectedAccountId, + activeAccount, + accountRefs, + daoPlugins, + rows, + chainId, + isLoading, + error, + }; +}; diff --git a/apps/app/src/modules/settings/types/index.ts b/apps/app/src/modules/settings/types/index.ts index d6aa45df8e..8e50dbe6e7 100644 --- a/apps/app/src/modules/settings/types/index.ts +++ b/apps/app/src/modules/settings/types/index.ts @@ -2,6 +2,12 @@ export type { IBuildPreparePluginUpdateDataParams } from './buildPreparePluginUp export type { IDaoPolicyDetailsPageParams } from './daoPolicyDetailsPageParams'; export type { IDaoProcessDetailsPageParams } from './daoProcessDetailsPageParams'; export type { IGetUninstallHelpersParams } from './getUninstallHelpersParams'; +export type { + IPermissionGraph, + IPermissionGraphEdge, + IPermissionGraphNode, + PermissionNodeKind, +} from './permissionGraph'; export type { IConditionData, IPermissionRow } from './permissionRow'; export type { IPluginToFormDataParams } from './pluginToFormDataParams'; export type { IUseGovernanceSettingsParams } from './useGovernanceSettingsParams'; diff --git a/apps/app/src/modules/settings/types/permissionGraph.ts b/apps/app/src/modules/settings/types/permissionGraph.ts new file mode 100644 index 0000000000..77a67d5474 --- /dev/null +++ b/apps/app/src/modules/settings/types/permissionGraph.ts @@ -0,0 +1,33 @@ +import type { + IDaoPermission, + IPermissionEntityRef, +} from '@/shared/api/daoService'; + +export type PermissionNodeKind = 'dao' | 'linkedDao' | 'plugin' | 'actor'; + +export interface IPermissionGraphNode { + id: string; + kind: PermissionNodeKind; + label: string; + tag?: string; + layer?: IPermissionEntityRef['layer']; + status?: IPermissionEntityRef['status']; + brandId?: IPermissionEntityRef['brandId']; + avatarSrc?: string; + address: string; +} + +export interface IPermissionGraphEdge { + id: string; + source: string; + target: string; + permissionName: string; + permissionDisplayName: string; + conditionLabel?: string; + row: IDaoPermission; +} + +export interface IPermissionGraph { + nodes: IPermissionGraphNode[]; + edges: IPermissionGraphEdge[]; +} diff --git a/apps/app/src/modules/settings/utils/buildPermissionGraph/buildPermissionGraph.test.ts b/apps/app/src/modules/settings/utils/buildPermissionGraph/buildPermissionGraph.test.ts new file mode 100644 index 0000000000..ea419108a4 --- /dev/null +++ b/apps/app/src/modules/settings/utils/buildPermissionGraph/buildPermissionGraph.test.ts @@ -0,0 +1,505 @@ +import type { IDaoPermission, IDaoPlugin } from '@/shared/api/daoService'; +import type { IFilterComponentPlugin } from '@/shared/components/pluginFilterComponent'; +import { + generateDao, + generateDaoPermission, + generateFilterComponentPlugin, + generateLinkedAccount, +} from '@/shared/testUtils/generators'; +import { ALLOW_FLAG, ANY_ADDR } from '../../constants/permissionSentinels'; +import { buildPermissionGraph } from './buildPermissionGraph'; + +const ROOT_PERMISSION_ID = + '0x815fe80e4b37c8582a3b773d1d7071f983eacfd56b5965db654f3087c25ada33'; +const EXECUTE_PERMISSION_ID = + '0xbf04b4486c9663d805744005c3da000eda93de6e3308a4a7a812eb565327b78d'; +const CREATE_PROPOSAL_PERMISSION_ID = + '0x8c433a4cd6b51969eca37f974940894297b9fcf4b282a213fea5cd8f85289c90'; + +const daoAddress = '0x1F2e3D4C5b6A70819283746556473829100AbCdE'; +const pluginAddress = '0xA1b2C3d4E5F60718293A4b5C6d7E8f9001234567'; +const linkedDaoAddress = '0xdEAD000000000000000042069420694206942069'; +const conditionAddress = '0xC0Ffee254729296a45a3885639AC7E10F9d54979'; +const secondPluginAddress = '0xB1b2C3d4E5F60718293A4b5C6d7E8f9001234567'; +const multisigAddress = '0xC1b2C3d4E5F60718293A4b5C6d7E8f9001234567'; + +const daoPlugins = [ + generateFilterComponentPlugin({ + meta: { + address: pluginAddress, + name: 'Founders', + interfaceType: 'multisig', + } as IDaoPlugin, + }), +] satisfies IFilterComponentPlugin[]; + +const dao = generateDao({ + address: daoAddress, + name: 'Patito DAO', + avatar: 'https://patito.png', + linkedAccounts: [ + generateLinkedAccount({ + address: linkedDaoAddress, + name: 'Patito Developer DAO', + avatar: 'https://patito-dev.png', + }), + ], +}); + +const accountRefs = [ + { + address: daoAddress, + name: 'Patito DAO', + avatarSrc: 'https://patito.png', + }, + { + address: linkedDaoAddress, + name: 'Patito Developer DAO', + avatarSrc: 'https://patito-dev.png', + }, +]; + +const buildRow = (partial: Partial): IDaoPermission => + generateDaoPermission({ + permissionId: EXECUTE_PERMISSION_ID, + whoAddress: pluginAddress, + whereAddress: daoAddress, + conditionAddress: ALLOW_FLAG, + condition: undefined, + conditionEntity: undefined, + network: undefined, + who: undefined, + where: undefined, + ...partial, + }); + +// Shared fixture: a Safe process body granted create-proposal on a top-level +// plugin body, used both on its own and next to an open Anyone grant. +const buildSafeProposalCreatorRow = (): IDaoPermission => + buildRow({ + permissionId: CREATE_PROPOSAL_PERMISSION_ID, + whoAddress: multisigAddress, + who: { + address: multisigAddress, + brandId: 'safe', + label: 'Process internal', + layer: 'processInternal', + parentPluginAddress: pluginAddress, + }, + whereAddress: pluginAddress, + where: { + address: pluginAddress, + interfaceType: 'spp', + label: 'Core Governance', + layer: 'topLevelPlugin', + }, + }); + +describe('buildPermissionGraph', () => { + it('classifies DAO, linked DAO, plugin, and actor nodes', () => { + const graph = buildPermissionGraph({ + rows: [ + buildRow({ whoAddress: pluginAddress }), + buildRow({ + permissionId: ROOT_PERMISSION_ID, + whoAddress: ANY_ADDR, + whereAddress: linkedDaoAddress, + }), + ], + dao, + daoPlugins, + accountRefs, + }); + + expect(graph.nodes.find((node) => node.kind === 'dao')).toMatchObject({ + label: 'Patito DAO', + avatarSrc: 'https://patito.png', + }); + expect( + graph.nodes.find((node) => node.kind === 'linkedDao'), + ).toMatchObject({ + label: 'Patito Developer DAO', + avatarSrc: 'https://patito-dev.png', + }); + expect( + graph.nodes.find((node) => node.kind === 'plugin'), + ).toMatchObject({ label: 'Founders', tag: 'MULTISIG' }); + expect(graph.nodes.find((node) => node.kind === 'actor')).toMatchObject( + { label: 'Anyone' }, + ); + }); + + it.each([ + { + name: 'uses backend entity metadata without installed plugin lookup', + row: buildRow({ + whoAddress: pluginAddress, + who: { + address: pluginAddress, + interfaceType: 'spp', + label: 'Backend Process', + layer: 'topLevelPlugin', + status: 'installed', + }, + }), + expected: { + kind: 'plugin', + label: 'Backend Process', + tag: 'SPP', + layer: 'topLevelPlugin', + status: 'installed', + }, + }, + { + name: 'carries backend Safe brand metadata onto graph nodes', + row: buildRow({ + whoAddress: pluginAddress, + who: { + address: pluginAddress, + label: 'Process internal', + layer: 'processInternal', + brandId: 'safe', + }, + }), + expected: { kind: 'plugin', brandId: 'safe' }, + }, + ])('$name', ({ row, expected }) => { + const graph = buildPermissionGraph({ + rows: [row], + dao, + accountRefs, + }); + + expect( + graph.nodes.find((node) => node.id === pluginAddress.toLowerCase()), + ).toMatchObject(expected); + }); + + it('preserves every selected ordinary row and only omits condition endpoints', () => { + const ordinaryRows = [ + buildRow({}), + buildRow({ + permissionId: CREATE_PROPOSAL_PERMISSION_ID, + whoAddress: ANY_ADDR, + who: { + address: ANY_ADDR, + label: 'Anyone', + layer: 'unknown', + }, + whereAddress: pluginAddress, + where: { + address: pluginAddress, + label: 'Core Governance', + layer: 'topLevelPlugin', + }, + }), + buildRow({ + permissionId: ROOT_PERMISSION_ID, + whoAddress: secondPluginAddress, + who: { + address: secondPluginAddress, + label: 'Historical plugin', + layer: 'historicalPlugin', + status: 'uninstalled', + }, + }), + ]; + const conditionTargetRow = buildRow({ + permissionId: ROOT_PERMISSION_ID, + whereAddress: conditionAddress, + where: { + address: conditionAddress, + label: 'Condition contract', + layer: 'condition', + status: 'installed', + }, + }); + const conditionActorRow = buildRow({ + permissionId: ROOT_PERMISSION_ID, + whoAddress: conditionAddress, + who: { + address: conditionAddress, + label: 'Condition contract', + layer: 'condition', + status: 'installed', + }, + }); + + const graph = buildPermissionGraph({ + rows: [...ordinaryRows, conditionTargetRow, conditionActorRow], + dao, + daoPlugins, + accountRefs, + }); + + expect(graph.edges).toHaveLength(ordinaryRows.length); + graph.edges.forEach((edge, index) => { + expect(edge.row).toBe(ordinaryRows[index]); + }); + // Condition endpoints are dropped as nodes too, not only as edges. + expect( + graph.nodes.find( + (node) => node.id === conditionAddress.toLowerCase(), + ), + ).toBeUndefined(); + }); + + it.each([ + { + name: 'creates who-to-where edges with resolved permission and condition labels', + row: buildRow({ + conditionAddress, + condition: { conditionType: 'voting-power' }, + }), + expected: { + source: pluginAddress.toLowerCase(), + target: daoAddress.toLowerCase(), + permissionName: 'EXECUTE_PERMISSION', + permissionDisplayName: 'Execute', + conditionLabel: 'VotingPower', + }, + }, + { + name: 'omits condition labels for unconditional grants', + row: buildRow({ conditionAddress: ALLOW_FLAG }), + expected: { conditionLabel: undefined }, + }, + ])('$name', ({ row, expected }) => { + const graph = buildPermissionGraph({ + rows: [row], + dao, + daoPlugins, + accountRefs, + }); + + expect(graph.edges).toHaveLength(1); + expect(graph.edges[0]).toMatchObject({ ...expected, row }); + }); + + it('creates per-target proposal creator who nodes for open proposal grants', () => { + const rows = [ + buildRow({ + permissionId: CREATE_PROPOSAL_PERMISSION_ID, + whoAddress: ANY_ADDR, + whereAddress: pluginAddress, + where: { + address: pluginAddress, + interfaceType: 'spp', + label: 'Core Governance', + layer: 'topLevelPlugin', + }, + conditionAddress, + condition: { conditionType: 'unknown' }, + }), + buildRow({ + permissionId: CREATE_PROPOSAL_PERMISSION_ID, + whoAddress: ANY_ADDR, + whereAddress: secondPluginAddress, + where: { + address: secondPluginAddress, + interfaceType: 'spp', + label: 'Polling', + layer: 'topLevelPlugin', + }, + conditionAddress, + condition: { conditionType: 'unknown' }, + }), + ]; + + const graph = buildPermissionGraph({ + rows, + dao, + daoPlugins, + accountRefs, + }); + const creatorNodes = graph.nodes.filter((node) => + node.id.startsWith('governing-body-actor-'), + ); + + expect(creatorNodes).toHaveLength(2); + expect(creatorNodes.map((node) => node.label)).toEqual([ + 'Anyone', + 'Anyone', + ]); + expect(new Set(creatorNodes.map((node) => node.id)).size).toBe(2); + expect(graph.edges.map((edge) => edge.source)).toEqual( + creatorNodes.map((node) => node.id), + ); + expect(graph.edges.map((edge) => edge.target)).toEqual([ + pluginAddress.toLowerCase(), + secondPluginAddress.toLowerCase(), + ]); + expect(graph.edges.map((edge) => edge.permissionName)).toEqual([ + 'CREATE_PROPOSAL_PERMISSION', + 'CREATE_PROPOSAL_PERMISSION', + ]); + expect(graph.edges.map((edge) => edge.conditionLabel)).toEqual([ + 'Unrecognized condition', + 'Unrecognized condition', + ]); + }); + + it('labels multisig proposal creators as members of the multisig', () => { + const row = buildRow({ + permissionId: CREATE_PROPOSAL_PERMISSION_ID, + whoAddress: multisigAddress, + who: { + address: multisigAddress, + interfaceType: 'multisig', + label: 'Treasury Multisig', + layer: 'topLevelPlugin', + }, + whereAddress: pluginAddress, + where: { + address: pluginAddress, + interfaceType: 'spp', + label: 'Core Governance', + layer: 'topLevelPlugin', + }, + }); + + const graph = buildPermissionGraph({ + rows: [row], + dao, + daoPlugins, + accountRefs, + }); + + expect( + graph.nodes.find((node) => + node.id.startsWith('governing-body-actor-'), + ), + ).toMatchObject({ + kind: 'actor', + label: 'Members of Treasury Multisig', + address: multisigAddress, + }); + }); + + it('keeps Safe proposal creator labels and brand metadata', () => { + const graph = buildPermissionGraph({ + rows: [buildSafeProposalCreatorRow()], + dao, + daoPlugins, + accountRefs, + }); + + expect( + graph.nodes.find((node) => + node.id.startsWith('governing-body-actor-'), + ), + ).toMatchObject({ + kind: 'plugin', + label: 'Safe', + brandId: 'safe', + address: multisigAddress, + }); + }); + + it('keeps specific proposal creators alongside an open Anyone grant on the body', () => { + const rows = [ + buildRow({ + permissionId: CREATE_PROPOSAL_PERMISSION_ID, + whoAddress: ANY_ADDR, + who: { + address: ANY_ADDR, + label: 'Unknown address', + layer: 'unknown', + }, + whereAddress: pluginAddress, + where: { + address: pluginAddress, + interfaceType: 'spp', + label: 'Core Governance', + layer: 'topLevelPlugin', + }, + }), + buildSafeProposalCreatorRow(), + ]; + + const graph = buildPermissionGraph({ + rows, + dao, + daoPlugins, + accountRefs, + }); + const creators = graph.nodes.filter((node) => + node.id.startsWith('governing-body-actor-'), + ); + + // GRF-1 regression guard. + // No subsumption: the list and the graph show the same rows, so the + // Safe body's create-proposal eligibility stays visible next to the + // open Anyone grant on the same body. + expect(creators).toHaveLength(2); + expect(creators.map((node) => node.label)).toEqual(['Anyone', 'Safe']); + expect(graph.edges).toHaveLength(2); + }); + + it('styles concrete plugin proposal creators as their real body', () => { + const row = buildRow({ + permissionId: CREATE_PROPOSAL_PERMISSION_ID, + whoAddress: secondPluginAddress, + who: { + address: secondPluginAddress, + interfaceType: 'spp', + label: 'Polling', + layer: 'topLevelPlugin', + }, + whereAddress: pluginAddress, + where: { + address: pluginAddress, + interfaceType: 'spp', + label: 'Core Governance', + layer: 'topLevelPlugin', + }, + }); + + const graph = buildPermissionGraph({ + rows: [row], + dao, + daoPlugins, + accountRefs, + }); + + expect( + graph.nodes.find((node) => + node.id.startsWith('governing-body-actor-'), + ), + ).toMatchObject({ + kind: 'plugin', + label: 'Polling', + tag: 'SPP', + address: secondPluginAddress, + }); + }); + + it('keeps conditional permissions with the same endpoints distinct', () => { + const votingPowerCondition = + '0xC0Ffee254729296a45a3885639AC7E10F9d54979'; + const membershipCondition = + '0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF'; + const rows = [ + buildRow({ conditionAddress: votingPowerCondition }), + buildRow({ conditionAddress: membershipCondition }), + ]; + + const graph = buildPermissionGraph({ + rows, + dao, + daoPlugins, + accountRefs, + }); + + expect(graph.edges).toHaveLength(2); + expect(new Set(graph.edges.map((edge) => edge.id)).size).toBe(2); + expect(graph.edges.map((edge) => edge.source)).toEqual([ + pluginAddress.toLowerCase(), + pluginAddress.toLowerCase(), + ]); + expect(graph.edges.map((edge) => edge.target)).toEqual([ + daoAddress.toLowerCase(), + daoAddress.toLowerCase(), + ]); + }); +}); diff --git a/apps/app/src/modules/settings/utils/buildPermissionGraph/buildPermissionGraph.ts b/apps/app/src/modules/settings/utils/buildPermissionGraph/buildPermissionGraph.ts new file mode 100644 index 0000000000..df101fc484 --- /dev/null +++ b/apps/app/src/modules/settings/utils/buildPermissionGraph/buildPermissionGraph.ts @@ -0,0 +1,225 @@ +import { addressUtils } from '@aragon/gov-ui-kit'; +import type { + IDao, + IDaoPermission, + IDaoPlugin, + IPermissionEntityRef, +} from '@/shared/api/daoService'; +import { PermissionEntityExternalBrandId } from '@/shared/api/daoService'; +import type { IFilterComponentPlugin } from '@/shared/components/pluginFilterComponent'; +import { permissionNameUtils } from '@/shared/utils/permissionNameUtils'; +import { ALLOW_FLAG } from '../../constants/permissionSentinels'; +import type { + IPermissionGraph, + IPermissionGraphEdge, + IPermissionGraphNode, +} from '../../types'; +import { conditionTypeUtils, NO_CONDITION } from '../conditionTypeUtils'; +import { + type IPermissionAccountRef, + permissionEntityUtils, +} from '../permissionEntityUtils'; + +const GOVERNING_BODY_ACTOR_NODE_PREFIX = 'governing-body-actor'; + +/** + * Condition contracts are already conveyed as the `if …` label on the + * permission edge, so rendering them as their own graph node is pure + * duplication. Drop any permission whose actor or target *is* a condition + * contract from the graph (the condition annotation on real permissions is + * unaffected). `unknown` / unresolved `contract` endpoints stay because page + * selection hides only DAO-granted and true subplugin-touching rows. + */ +const isGraphExcludedEndpoint = (entity?: IPermissionEntityRef): boolean => + entity?.layer === 'condition'; + +export interface IBuildPermissionGraphParams { + rows: IDaoPermission[]; + dao: IDao; + daoPlugins?: IFilterComponentPlugin[]; + accountRefs?: IPermissionAccountRef[]; +} + +type IResolveNodeContext = Omit; + +const resolveNode = ( + address: string, + context: IResolveNodeContext, + enrichedEntity?: IPermissionEntityRef, +): IPermissionGraphNode => { + const { dao, daoPlugins, accountRefs } = context; + const id = address.toLowerCase(); + + if (addressUtils.isAddressEqual(address, dao.address)) { + const account = accountRefs?.find((item) => + addressUtils.isAddressEqual(item.address, address), + ); + + return { + id, + kind: 'dao', + label: account?.name ?? dao.name, + avatarSrc: account?.avatarSrc ?? dao.avatar ?? undefined, + address, + }; + } + + const linkedAccount = accountRefs?.find((item) => + addressUtils.isAddressEqual(item.address, address), + ); + + if (linkedAccount != null) { + return { + id, + kind: 'linkedDao', + label: linkedAccount.name, + avatarSrc: linkedAccount.avatarSrc, + address, + }; + } + + const entity = permissionEntityUtils.resolvePermissionEntity(address, { + daoPlugins, + accounts: accountRefs, + entity: enrichedEntity, + }); + + if (entity.type === 'plugin') { + return { + id, + kind: 'plugin', + label: entity.label, + tag: entity.tag, + layer: entity.layer, + status: entity.status, + brandId: entity.brandId, + address, + }; + } + + return { + id, + kind: 'actor', + label: entity.label, + layer: entity.layer, + status: entity.status, + brandId: entity.brandId, + address, + }; +}; + +interface IResolveEdgeOptions { + sourceId?: string; +} + +const getGoverningBodyActorNodeId = (row: IDaoPermission): string => + [GOVERNING_BODY_ACTOR_NODE_PREFIX, row.whoAddress, row.whereAddress] + .map((part) => part.toLowerCase()) + .join('-'); + +// A permission acting *on* a governance body (a top-level or historical OSx +// plugin). Its actor renders as a per-body node keyed by (who, where), so every +// permission the same actor holds on that body stacks into one node — and the +// ANY_ADDR quantifier never collapses across different bodies. +const targetsGovernanceBody = (row: IDaoPermission): boolean => + row.where?.layer === 'topLevelPlugin' || + row.where?.layer === 'historicalPlugin'; + +const resolveGoverningBodyActorNode = ( + row: IDaoPermission, + context: IResolveNodeContext, +): IPermissionGraphNode => { + const baseNode = resolveNode(row.whoAddress, context, row.who); + const id = getGoverningBodyActorNodeId(row); + const isMultisigMembers = + baseNode.brandId !== PermissionEntityExternalBrandId.SAFE && + row.who?.interfaceType?.toLowerCase() === 'multisig'; + + if (!isMultisigMembers) { + return { ...baseNode, id }; + } + + return { + id, + kind: 'actor', + label: `Members of ${baseNode.label}`, + layer: baseNode.layer, + status: baseNode.status, + brandId: baseNode.brandId, + address: row.whoAddress, + }; +}; + +const resolveEdge = ( + row: IDaoPermission, + options: IResolveEdgeOptions = {}, +): IPermissionGraphEdge => { + const { sourceId } = options; + const conditionAddress = row.conditionAddress ?? ALLOW_FLAG; + const conditionType = conditionTypeUtils.resolveConditionType( + conditionAddress, + row.condition, + ); + const conditionLabel = conditionTypeUtils.getConditionLabel(conditionType); + + const whoAddress = row.whoAddress.toLowerCase(); + const whereAddress = row.whereAddress.toLowerCase(); + const conditionNodeAddress = conditionAddress.toLowerCase(); + + return { + id: `${row.permissionId}-${whoAddress}-${whereAddress}-${conditionNodeAddress}`, + source: sourceId ?? whoAddress, + target: whereAddress, + permissionName: permissionNameUtils.getPermissionName(row.permissionId), + permissionDisplayName: permissionNameUtils.getPermissionDisplayName( + row.permissionId, + ), + conditionLabel: + conditionType === NO_CONDITION ? undefined : conditionLabel, + row, + }; +}; + +export const buildPermissionGraph = ( + params: IBuildPermissionGraphParams, +): IPermissionGraph => { + const { rows, ...context } = params; + const nodesById = new Map(); + + const ensureNode = ( + address: string, + entity?: IPermissionEntityRef, + ): void => { + const id = address.toLowerCase(); + + if (!nodesById.has(id)) { + nodesById.set(id, resolveNode(address, context, entity)); + } + }; + + const graphRows = rows.filter( + (row) => + !isGraphExcludedEndpoint(row.who) && + !isGraphExcludedEndpoint(row.where), + ); + + const edges: IPermissionGraphEdge[] = []; + + for (const row of graphRows) { + if (!targetsGovernanceBody(row)) { + ensureNode(row.whoAddress, row.who); + ensureNode(row.whereAddress, row.where); + edges.push(resolveEdge(row)); + continue; + } + + const actorNode = resolveGoverningBodyActorNode(row, context); + if (!nodesById.has(actorNode.id)) { + nodesById.set(actorNode.id, actorNode); + } + ensureNode(row.whereAddress, row.where); + edges.push(resolveEdge(row, { sourceId: actorNode.id })); + } + + return { nodes: [...nodesById.values()], edges }; +}; diff --git a/apps/app/src/modules/settings/utils/buildPermissionGraph/index.ts b/apps/app/src/modules/settings/utils/buildPermissionGraph/index.ts new file mode 100644 index 0000000000..bfc863f2c9 --- /dev/null +++ b/apps/app/src/modules/settings/utils/buildPermissionGraph/index.ts @@ -0,0 +1,4 @@ +export { + buildPermissionGraph, + type IBuildPermissionGraphParams, +} from './buildPermissionGraph'; diff --git a/apps/app/src/modules/settings/utils/conditionTypeUtils/conditionTypeUtils.test.ts b/apps/app/src/modules/settings/utils/conditionTypeUtils/conditionTypeUtils.test.ts index 44d12f0fb3..d90e30e219 100644 --- a/apps/app/src/modules/settings/utils/conditionTypeUtils/conditionTypeUtils.test.ts +++ b/apps/app/src/modules/settings/utils/conditionTypeUtils/conditionTypeUtils.test.ts @@ -61,9 +61,9 @@ describe('conditionType Utils', () => { expected: '-', }, { - description: 'maps "unknown" to the empty placeholder', + description: 'maps "unknown" to the unresolved placeholder', conditionType: 'unknown', - expected: '-', + expected: 'Unrecognized condition', }, { description: 'maps an empty string to the empty placeholder', diff --git a/apps/app/src/modules/settings/utils/conditionTypeUtils/conditionTypeUtils.ts b/apps/app/src/modules/settings/utils/conditionTypeUtils/conditionTypeUtils.ts index 89fc594a37..15e0a06c0f 100644 --- a/apps/app/src/modules/settings/utils/conditionTypeUtils/conditionTypeUtils.ts +++ b/apps/app/src/modules/settings/utils/conditionTypeUtils/conditionTypeUtils.ts @@ -1,25 +1,34 @@ +import type { + IDaoPermission, + IDaoPermissionCondition, +} from '@/shared/api/daoService'; import { stringUtils } from '@/shared/utils/stringUtils'; import { ALLOW_FLAG } from '../../constants/permissionSentinels'; -import type { IConditionData } from '../../types'; /** * Discriminator returned when a permission is granted unconditionally * (condition equals {@link ALLOW_FLAG}). */ -const NO_CONDITION = 'none'; +export const NO_CONDITION = 'none'; /** * Discriminator returned when the condition type cannot be resolved from the * payload (absent condition data or an empty/unrecognised `conditionType`). */ -const UNKNOWN_CONDITION = 'unknown'; +export const UNKNOWN_CONDITION = 'unknown'; /** * Placeholder rendered for conditions that have no human-readable label - * (unconditional grants and unresolvable condition types). + * (unconditional grants and empty condition types). */ const NO_LABEL = '-'; +/** + * Placeholder rendered when the permission references a condition address but + * the condition payload could not be resolved or recognised. + */ +const UNKNOWN_LABEL = 'Unrecognized condition'; + /** * Explicit display labels for the known condition types. Any other non-empty * type falls back to a Pascal-cased rendering of its discriminator. @@ -29,6 +38,18 @@ const CONDITION_LABELS: Record = { 'execute-selector': 'ExecuteSelector', }; +/** + * Bundled condition display data, so callers don't repeat the + * address/type/label/hasCondition/isUnrecognized computation. + */ +export interface IConditionDisplay { + address: string; + type: string; + label: string; + hasCondition: boolean; + isUnrecognized: boolean; +} + class ConditionTypeUtils { /** * Resolves the display condition type for a permission. @@ -46,7 +67,7 @@ class ConditionTypeUtils { */ resolveConditionType = ( conditionAddress: string, - conditionData?: IConditionData, + conditionData?: IDaoPermissionCondition, ): string => { if (conditionAddress.toLowerCase() === ALLOW_FLAG.toLowerCase()) { return NO_CONDITION; @@ -65,7 +86,8 @@ class ConditionTypeUtils { * Resolves a human-readable label for a condition type, used by the * collapsed permission row's CONDITION cell. * - * - `'none'` / `'unknown'` -> {@link NO_LABEL} (`'-'`). + * - `'none'` / empty -> {@link NO_LABEL} (`'-'`). + * - `'unknown'` -> {@link UNKNOWN_LABEL} (`'Unrecognized condition'`). * - a known type -> its explicit label (e.g. `'voting-power'` -> * `'VotingPower'`). * - any other non-empty type -> a Pascal-cased fallback (e.g. @@ -75,19 +97,45 @@ class ConditionTypeUtils { * @returns The display label for the condition type. */ getConditionLabel = (conditionType: string): string => { - if ( - conditionType === NO_CONDITION || - conditionType === UNKNOWN_CONDITION || - conditionType.length === 0 - ) { + if (conditionType === NO_CONDITION || conditionType.length === 0) { return NO_LABEL; } + if (conditionType === UNKNOWN_CONDITION) { + return UNKNOWN_LABEL; + } + return ( CONDITION_LABELS[conditionType] ?? stringUtils.toPascalCase(conditionType) ); }; + + /** + * Resolves the full display bundle for a permission row's condition in one + * call: the effective condition address, its type discriminator, its + * human-readable label, whether a real condition is attached (address is + * not {@link ALLOW_FLAG}), and whether the condition payload was + * unrecognised. Replaces a 5-line block duplicated across the list views + * and the graph edge panel. + * + * @param row The permission row (only `conditionAddress` / `condition`). + * @returns The resolved condition display data. + */ + resolveConditionDisplay = ( + row: Pick, + ): IConditionDisplay => { + const address = row.conditionAddress ?? ALLOW_FLAG; + const type = this.resolveConditionType(address, row.condition); + + return { + address, + type, + label: this.getConditionLabel(type), + hasCondition: address.toLowerCase() !== ALLOW_FLAG.toLowerCase(), + isUnrecognized: type === UNKNOWN_CONDITION, + }; + }; } export const conditionTypeUtils = new ConditionTypeUtils(); diff --git a/apps/app/src/modules/settings/utils/conditionTypeUtils/index.ts b/apps/app/src/modules/settings/utils/conditionTypeUtils/index.ts index 11ba8f604d..2840998fa7 100644 --- a/apps/app/src/modules/settings/utils/conditionTypeUtils/index.ts +++ b/apps/app/src/modules/settings/utils/conditionTypeUtils/index.ts @@ -1 +1,6 @@ -export { conditionTypeUtils } from './conditionTypeUtils'; +export { + conditionTypeUtils, + type IConditionDisplay, + NO_CONDITION, + UNKNOWN_CONDITION, +} from './conditionTypeUtils'; diff --git a/apps/app/src/modules/settings/utils/permissionEntityUtils/permissionEntityUtils.test.ts b/apps/app/src/modules/settings/utils/permissionEntityUtils/permissionEntityUtils.test.ts index 22b4614953..c1f9001031 100644 --- a/apps/app/src/modules/settings/utils/permissionEntityUtils/permissionEntityUtils.test.ts +++ b/apps/app/src/modules/settings/utils/permissionEntityUtils/permissionEntityUtils.test.ts @@ -1,5 +1,9 @@ import { addressUtils } from '@aragon/gov-ui-kit'; -import type { IDaoPlugin } from '@/shared/api/daoService'; +import { + type IDaoPlugin, + type IPermissionEntityRef, + PermissionEntityExternalBrandId, +} from '@/shared/api/daoService'; import type { IFilterComponentPlugin } from '@/shared/components/pluginFilterComponent'; import { ALLOW_FLAG, ANY_ADDR } from '../../constants/permissionSentinels'; import { @@ -8,6 +12,19 @@ import { permissionEntityUtils, } from './permissionEntityUtils'; +interface ISafeBrandCase { + name: string; + label: string; + layer: IPermissionEntityRef['layer']; +} + +interface IProcessBodyNameCase { + name: string; + label: string; + interfaceType: string; + expected: { label: string; tag: string | undefined; detailName: string }; +} + describe('permissionEntity Utils', () => { describe('resolvePermissionEntity', () => { const pluginAddress = '0x1234567890123456789012345678901234567890'; @@ -46,6 +63,9 @@ describe('permissionEntity Utils', () => { }, }, { + // VRB-2 [S6] guard: this case pins the `.toLowerCase()` calls in + // `resolvePermissionEntity` — the kit helper checksum-validates + // before case-folding, so dropping them breaks sentinel matching. description: 'resolves ANY_ADDR case-insensitively to "Anyone"', address: ANY_ADDR.toUpperCase(), expected: { @@ -66,10 +86,12 @@ describe('permissionEntity Utils', () => { }, }, { - description: 'resolves a matching plugin to name + type tag', + description: + 'resolves a matching plugin to name + type tag, with the metadata name and version as the detail name', address: pluginAddress, expected: { label: 'Multisig', + detailName: 'Multisig v1.2', isSentinel: false, tag: 'MULTISIG', type: 'plugin', @@ -87,10 +109,11 @@ describe('permissionEntity Utils', () => { }, { description: - 'falls back to a truncated address for unknown addresses', + 'falls back to the truncated address for unknown addresses', address: unknownAddress, expected: { label: addressUtils.truncateAddress(unknownAddress), + detailName: addressUtils.truncateAddress(unknownAddress), isSentinel: false, tag: undefined, type: 'address', @@ -107,16 +130,173 @@ describe('permissionEntity Utils', () => { expect(result.tag).toEqual(expected.tag); expect(result.isSentinel).toEqual(expected.isSentinel); expect(result.type).toEqual(expected.type); + if (expected.detailName != null) { + expect(result.detailName).toEqual(expected.detailName); + } expect(result.address).toEqual(address); }); - it('includes the plugin metadata name and version as the detail name', () => { + it('presents backend-unresolved addresses as their address, not a placeholder label', () => { + const result = permissionEntityUtils.resolvePermissionEntity( + unknownAddress, + { + entity: { + address: unknownAddress, + label: 'Unknown address', + layer: 'unknown', + status: 'unknown', + }, + }, + ); + + expect(result).toMatchObject({ + label: addressUtils.truncateAddress(unknownAddress), + type: 'address', + layer: 'unknown', + }); + }); + + it('prefers backend-enriched entity metadata over local fallbacks', () => { + const result = permissionEntityUtils.resolvePermissionEntity( + unknownAddress, + { + entity: { + address: unknownAddress, + interfaceType: 'tokenVoting', + label: 'Historical Token Voting', + layer: 'historicalPlugin', + status: 'uninstalled', + }, + }, + ); + + expect(result).toMatchObject({ + label: 'Historical Token Voting', + tag: 'TOKENVOTING', + type: 'plugin', + detailName: 'Historical Token Voting', + layer: 'historicalPlugin', + status: 'uninstalled', + }); + }); + + it('uses the local plugin formatter when a backend plugin label is only the raw interface type', () => { + const gaugeAddress = '0x8ab7f7b617b5248358ea9c9b728f3c2edbaa97a2'; const result = permissionEntityUtils.resolvePermissionEntity( - pluginAddress, - { daoPlugins }, + gaugeAddress, + { + daoPlugins: [ + { + id: 'gauge', + uniqueId: `${gaugeAddress}-gauge`, + label: 'gauge', + meta: { + address: gaugeAddress, + interfaceType: 'gauge', + subdomain: 'citrea-xctr-gauge-voter-0', + } as IDaoPlugin, + props: {}, + }, + ], + entity: { + address: gaugeAddress, + interfaceType: 'gauge', + label: 'gauge', + layer: 'topLevelPlugin', + status: 'installed', + }, + }, + ); + + expect(result).toMatchObject({ + label: 'Citrea Xctr Gauge Voter 0', + tag: 'GAUGE', + type: 'plugin', + detailName: 'Citrea Xctr Gauge Voter 0', + layer: 'topLevelPlugin', + }); + }); + + // LBL-1 guard: a recognized Safe brand is hoisted above the backend label, + // the interface type, and the layer fallbacks — in and out of processInternal. + it.each([ + { + name: 'identifies a recognized Safe before backend labels or interface types', + label: 'Treasury signers', + layer: 'processInternal', + }, + { + name: 'identifies a recognized Safe outside the process-internal layer', + label: 'Backend multisig label', + layer: 'externalActor', + }, + ])('$name', ({ label, layer }) => { + const result = permissionEntityUtils.resolvePermissionEntity( + unknownAddress, + { + entity: { + address: unknownAddress, + brandId: PermissionEntityExternalBrandId.SAFE, + interfaceType: 'multisig', + label, + layer, + parentPluginName: 'Core Governance', + }, + }, + ); + + expect(result).toMatchObject({ + brandId: PermissionEntityExternalBrandId.SAFE, + label: 'Safe', + tag: undefined, + type: 'plugin', + layer, + // The parent process name describes the body's container, not the + // address itself, so it must never become the Safe's detail line. + detailName: 'Safe', + }); + }); + + it.each([ + { + name: 'names internal process bodies by their interface type, not the generic layer label', + label: 'Process internal', + interfaceType: 'tokenVoting', + expected: { + label: 'Token Voting', + tag: undefined, + detailName: 'Core Governance', + }, + }, + { + name: 'renders internal bodies with the real name and type from the backend label', + label: 'Founders', + interfaceType: 'multisig', + expected: { + label: 'Founders', + tag: 'MULTISIG', + detailName: 'Core Governance', + }, + }, + ])('$name', ({ label, interfaceType, expected }) => { + const result = permissionEntityUtils.resolvePermissionEntity( + unknownAddress, + { + entity: { + address: unknownAddress, + label, + layer: 'processInternal', + interfaceType, + parentPluginName: 'Core Governance', + }, + }, ); - expect(result.detailName).toEqual('Multisig v1.2'); + expect(result).toMatchObject({ + ...expected, + type: 'plugin', + layer: 'processInternal', + }); }); }); }); diff --git a/apps/app/src/modules/settings/utils/permissionEntityUtils/permissionEntityUtils.ts b/apps/app/src/modules/settings/utils/permissionEntityUtils/permissionEntityUtils.ts index 149e1b84d6..6147460699 100644 --- a/apps/app/src/modules/settings/utils/permissionEntityUtils/permissionEntityUtils.ts +++ b/apps/app/src/modules/settings/utils/permissionEntityUtils/permissionEntityUtils.ts @@ -1,5 +1,9 @@ import { addressUtils } from '@aragon/gov-ui-kit'; -import type { IDaoPlugin } from '@/shared/api/daoService'; +import { + type IDaoPlugin, + type IPermissionEntityRef, + PermissionEntityExternalBrandId, +} from '@/shared/api/daoService'; import type { IFilterComponentPlugin } from '@/shared/components/pluginFilterComponent'; import { daoUtils } from '@/shared/utils/daoUtils'; import { ALLOW_FLAG, ANY_ADDR } from '../../constants/permissionSentinels'; @@ -36,10 +40,23 @@ export interface IPermissionEntity { * placeholder circle). */ type: PermissionEntityType; + /** + * Backend entity layer, when supplied by the permissions endpoint. + */ + layer?: IPermissionEntityRef['layer']; + /** + * Backend lifecycle status, when supplied by the permissions endpoint. + */ + status?: IPermissionEntityRef['status']; /** * Avatar source for DAO / linked-account entities. */ avatarSrc?: string; + /** + * External voting-body brand identity, mirrored from the backend permission + * entity enrichment. `safe` marks a Safe process body or external proposer. + */ + brandId?: IPermissionEntityRef['brandId']; /** * Secondary detail label shown under the address in the expanded row — the * DAO name, or the plugin metadata name and version (e.g. `Core v1.3`). @@ -70,6 +87,11 @@ interface IResolvePermissionEntityOptions { * DAO names and avatars. */ accounts?: IPermissionAccountRef[]; + /** + * Backend-enriched entity metadata returned with the permission row. When + * present, this is the source of truth for labels and layers. + */ + entity?: IPermissionEntityRef; } class PermissionEntityUtils { @@ -79,18 +101,19 @@ class PermissionEntityUtils { * Resolution order: * 1. {@link ANY_ADDR} sentinel -> "Anyone". * 2. {@link ALLOW_FLAG} sentinel -> "Any Address". - * 3. A matching installed DAO plugin -> the plugin name plus its interface + * 3. Backend-enriched permission entity metadata. + * 4. A matching installed DAO plugin -> the plugin name plus its interface * type as an uppercase tag (e.g. `MULTISIG`) and a `name vX.Y` detail. - * 4. A matching DAO / linked account -> the account name and avatar. - * 5. Otherwise -> the truncated address. + * 5. A matching DAO / linked account -> the account name and avatar. + * 6. Otherwise -> the truncated address. */ resolvePermissionEntity = ( address: string, options: IResolvePermissionEntityOptions = {}, ): IPermissionEntity => { - const { daoPlugins, accounts } = options; + const { daoPlugins, accounts, entity } = options; - if (this.isAddressEqual(address, ANY_ADDR)) { + if (addressUtils.isAddressEqual(address.toLowerCase(), ANY_ADDR)) { return { label: 'Anyone', address, @@ -99,7 +122,7 @@ class PermissionEntityUtils { }; } - if (this.isAddressEqual(address, ALLOW_FLAG)) { + if (addressUtils.isAddressEqual(address.toLowerCase(), ALLOW_FLAG)) { return { label: 'Any Address', address, @@ -108,8 +131,12 @@ class PermissionEntityUtils { }; } + if (entity != null) { + return this.resolveBackendEntity(address, entity, daoPlugins); + } + const matchedPlugin = daoPlugins?.find((plugin) => - this.isAddressEqual(plugin.meta.address, address), + addressUtils.isAddressEqual(plugin.meta.address, address), ); if (matchedPlugin != null) { @@ -130,7 +157,7 @@ class PermissionEntityUtils { } const matchedAccount = accounts?.find((account) => - this.isAddressEqual(account.address, address), + addressUtils.isAddressEqual(account.address, address), ); if (matchedAccount != null) { @@ -149,6 +176,126 @@ class PermissionEntityUtils { address, isSentinel: false, type: 'address', + detailName: addressUtils.truncateAddress(address), + }; + }; + + private resolveBackendEntity = ( + address: string, + entity: IPermissionEntityRef, + daoPlugins?: DaoPluginEntries, + ): IPermissionEntity => { + // The backend hardcodes a generic label for addresses it cannot resolve; + // treat it as "no name" so unknowns present as their address instead. + const backendLabel = + entity.label !== 'Unknown address' ? entity.label : undefined; + const label = + backendLabel ?? + (entity.layer === 'contract' + ? 'Unresolved contract' + : addressUtils.truncateAddress(address)); + const tag = entity.interfaceType?.toUpperCase(); + + if (entity.brandId === PermissionEntityExternalBrandId.SAFE) { + return { + label: 'Safe', + tag: undefined, + address, + isSentinel: false, + type: 'plugin', + // `parentPluginName` names the process this body sits in, not the + // address, so a Safe keeps its own identity as the detail line. + detailName: 'Safe', + layer: entity.layer, + status: entity.status, + brandId: entity.brandId, + }; + } + + if (entity.layer === 'dao') { + return { + label, + address, + isSentinel: false, + type: 'dao', + avatarSrc: entity.avatarSrc, + detailName: label, + layer: entity.layer, + status: entity.status, + brandId: entity.brandId, + }; + } + + if (entity.layer === 'processInternal') { + const bodyInterfaceType = entity.interfaceType; + const parsedType = + bodyInterfaceType != null + ? daoUtils.parsePluginInterfaceType(bodyInterfaceType) + : undefined; + // The backend hardcodes a generic label for bodies it cannot name. + // Treat that as "no name" and derive a title from the interface type + // (front-run until the backend resolves the body's real name). + const backendName = + entity.label != null && entity.label !== 'Process internal' + ? entity.label + : undefined; + const bodyName = backendName ?? parsedType ?? label; + + return { + label: bodyName, + // Show a type chip only when the title is a real name — otherwise + // the derived title already is the type and the chip is redundant. + tag: backendName ? bodyInterfaceType?.toUpperCase() : undefined, + address, + isSentinel: false, + type: 'plugin', + detailName: entity.parentPluginName ?? bodyName, + layer: entity.layer, + status: entity.status, + brandId: entity.brandId, + }; + } + + if ( + entity.layer === 'topLevelPlugin' || + entity.layer === 'historicalPlugin' + ) { + const matchedPlugin = daoPlugins?.find((plugin) => + addressUtils.isAddressEqual(plugin.meta.address, address), + ); + const backendLabelIsRawType = + entity.interfaceType != null && + entity.label === entity.interfaceType; + const pluginLabel = + backendLabelIsRawType && matchedPlugin != null + ? daoUtils.getPluginName(matchedPlugin.meta) + : label; + + return { + label: pluginLabel, + tag, + address, + isSentinel: false, + type: 'plugin', + detailName: + matchedPlugin != null && pluginLabel !== label + ? this.formatPluginDetail(matchedPlugin.meta) + : (entity.parentPluginName ?? pluginLabel), + layer: entity.layer, + status: entity.status, + brandId: entity.brandId, + }; + } + + return { + label, + address, + isSentinel: false, + type: 'address', + detailName: addressUtils.truncateAddress(address), + layer: entity.layer, + status: entity.status, + brandId: entity.brandId, }; }; @@ -162,9 +309,6 @@ class PermissionEntityUtils { return name; }; - - private isAddressEqual = (a?: string, b?: string): boolean => - a != null && b != null && a.toLowerCase() === b.toLowerCase(); } export const permissionEntityUtils = new PermissionEntityUtils(); diff --git a/apps/app/src/modules/settings/utils/permissionRowFilters/index.ts b/apps/app/src/modules/settings/utils/permissionRowFilters/index.ts new file mode 100644 index 0000000000..5838354010 --- /dev/null +++ b/apps/app/src/modules/settings/utils/permissionRowFilters/index.ts @@ -0,0 +1,8 @@ +export type { + IPermissionRowFilters, + IPermissionRowToggleAvailability, +} from './permissionRowFilters'; +export { + filterPermissionRows, + getPermissionRowToggleAvailability, +} from './permissionRowFilters'; diff --git a/apps/app/src/modules/settings/utils/permissionRowFilters/permissionRowFilters.test.ts b/apps/app/src/modules/settings/utils/permissionRowFilters/permissionRowFilters.test.ts new file mode 100644 index 0000000000..8fc67a5ed3 --- /dev/null +++ b/apps/app/src/modules/settings/utils/permissionRowFilters/permissionRowFilters.test.ts @@ -0,0 +1,554 @@ +import type { IDaoPermission, IDaoPlugin } from '@/shared/api/daoService'; +import type { IFilterComponentPlugin } from '@/shared/components/pluginFilterComponent'; +import { + generateDaoPermission, + generateFilterComponentPlugin, +} from '@/shared/testUtils/generators'; +import { ALLOW_FLAG, ANY_ADDR } from '../../constants/permissionSentinels'; +import { + filterPermissionRows, + getPermissionRowToggleAvailability, + type IPermissionRowFilters, +} from './permissionRowFilters'; + +const daoAddress = '0x1111111111111111111111111111111111111111'; +const pluginAddress = '0x2222222222222222222222222222222222222222'; +const subpluginAddress = '0x3333333333333333333333333333333333333333'; +const targetAddress = '0x4444444444444444444444444444444444444444'; +const parentPluginAddress = '0x5555555555555555555555555555555555555555'; +const unknownPermissionId = + '0x440d025ee487c9fe654894f3750aeb18132e334d52d7a9c0a3f6a5c77450a9b5'; +const createProposalPermissionId = + '0x8c433a4cd6b51969eca37f974940894297b9fcf4b282a213fea5cd8f85289c90'; + +const buildRow = (partial: Partial): IDaoPermission => ({ + ...generateDaoPermission({ + conditionAddress: '0x0000000000000000000000000000000000000002', + permissionId: 'permission-id', + whereAddress: targetAddress, + whoAddress: pluginAddress, + }), + ...partial, +}); + +const buildPlugin = ( + meta: Partial, +): IFilterComponentPlugin => + generateFilterComponentPlugin({ + meta: { + address: pluginAddress, + interfaceType: 'unknown', + release: '0', + build: '0', + isProcess: false, + isBody: false, + isSubPlugin: false, + settings: {}, + blockTimestamp: 0, + transactionHash: '0x0', + slug: 'plugin', + ...meta, + } as IDaoPlugin, + }); + +const subpluginWhere = { + address: subpluginAddress, + label: 'Subplugin process', + layer: 'processInternal', + parentPluginAddress, +} as const; + +const defaultFilters: IPermissionRowFilters = { + activeAccountAddress: daoAddress, + daoPlugins: [], + showDaoPermissions: true, + showSubpluginPermissions: false, +}; + +interface IFilterCase { + name: string; + rows: IDaoPermission[]; + daoPlugins?: IFilterComponentPlugin[]; + filters?: Partial; +} + +describe('filterPermissionRows', () => { + // The `keeps` table is the FLT-1 / FLT-2 / FLT-3 reintroduction guard: every case is a row + // class that the deleted heuristics (isResidualPermission, unresolved-layer checks, + // rowHasUnresolvedPermission, the create-proposal name carve-out, and the inactive-plugin + // pre-filter) used to hide silently. Rows may be hidden only by the two explicit toggles. + it.each([ + { + name: 'DAO-granted permissions when enabled', + rows: [buildRow({ whoAddress: daoAddress })], + filters: { showSubpluginPermissions: true }, + }, + { + name: 'rows whose caller is a subplugin when the target is not', + rows: [ + buildRow({ + whoAddress: subpluginAddress, + who: subpluginWhere, + whereAddress: pluginAddress, + }), + ], + daoPlugins: [ + buildPlugin({ + address: subpluginAddress, + isSubPlugin: true, + parentPlugin: parentPluginAddress, + }), + ], + }, + { + name: 'top-level process body proposal permissions by default', + rows: [ + buildRow({ + where: { + address: targetAddress, + label: 'Stage 1 proposal processor', + layer: 'processInternal', + parentPluginName: 'Core Governance', + }, + whereAddress: targetAddress, + }), + buildRow({ whereAddress: daoAddress }), + ], + }, + { + name: 'ALLOW_FLAG rows when the backend sends a condition entity', + rows: [ + buildRow({ + conditionAddress: ALLOW_FLAG, + conditionEntity: { + address: ALLOW_FLAG, + label: 'Allow flag', + layer: 'condition', + }, + whereAddress: daoAddress, + }), + ], + }, + { + name: 'real condition-contract rows when endpoints are primary entities', + rows: [ + buildRow({ + conditionAddress: + '0x6666666666666666666666666666666666666666', + conditionEntity: { + address: '0x6666666666666666666666666666666666666666', + label: 'Condition contract', + layer: 'condition', + status: 'installed', + }, + whereAddress: daoAddress, + }), + ], + }, + { + name: 'rows with missing condition addresses as unconditional', + rows: [ + buildRow({ + conditionAddress: undefined, + conditionEntity: { + address: ALLOW_FLAG, + label: 'Allow flag', + layer: 'condition', + }, + whereAddress: daoAddress, + }), + ], + }, + { + name: 'DAO-granted outgoing rows when DAO permissions are enabled', + rows: [ + buildRow({ + whoAddress: daoAddress, + where: { + address: pluginAddress, + label: 'Core Governance', + layer: 'topLevelPlugin', + status: 'installed', + }, + whereAddress: pluginAddress, + }), + buildRow({ whereAddress: daoAddress }), + ], + }, + { + name: 'DAO-as-caller rows to unknown contracts when DAO permissions are enabled', + rows: [ + buildRow({ + whoAddress: daoAddress, + whereAddress: targetAddress, + where: { + address: targetAddress, + label: 'Unknown address', + layer: 'unknown', + status: 'unknown', + }, + }), + ], + }, + { + name: 'locally undecoded permission hashes', + rows: [ + buildRow({ + permissionId: unknownPermissionId, + whereAddress: daoAddress, + }), + ], + }, + { + name: 'inactive and historical plugin endpoint rows', + rows: [ + buildRow({ + whereAddress: daoAddress, + who: { + address: pluginAddress, + label: 'Historical Core Governance DEPRECATED', + layer: 'historicalPlugin', + status: 'uninstalled', + }, + whoAddress: pluginAddress, + }), + buildRow({ + whereAddress: daoAddress, + who: { + address: parentPluginAddress, + label: 'Unknown status plugin', + layer: 'topLevelPlugin', + status: 'unknown', + }, + whoAddress: parentPluginAddress, + }), + buildRow({ + whereAddress: daoAddress, + who: { + address: targetAddress, + label: 'Core Governance', + layer: 'topLevelPlugin', + status: 'installed', + }, + whoAddress: targetAddress, + }), + ], + filters: { showDaoPermissions: false }, + }, + { + name: 'open create-proposal and ordinary rows under the same rules', + rows: [ + buildRow({ + permissionId: createProposalPermissionId, + whoAddress: ANY_ADDR, + who: { + address: ANY_ADDR, + label: 'Unknown address', + layer: 'unknown', + }, + whereAddress: pluginAddress, + where: { + address: pluginAddress, + interfaceType: 'spp', + label: 'Core Governance', + layer: 'topLevelPlugin', + status: 'installed', + }, + }), + buildRow({ whereAddress: targetAddress }), + ], + }, + { + name: 'ordinary rows that are not connected to the active DAO', + rows: [ + buildRow({ whereAddress: daoAddress }), + buildRow({ whereAddress: targetAddress }), + ], + }, + { + name: 'subplugin rows when enabled', + rows: [buildRow({ whereAddress: subpluginAddress })], + daoPlugins: [ + buildPlugin({ address: subpluginAddress, isSubPlugin: true }), + ], + filters: { showSubpluginPermissions: true }, + }, + ])('keeps $name', ({ rows, daoPlugins = [], filters }) => { + const result = filterPermissionRows(rows, { + ...defaultFilters, + daoPlugins, + ...filters, + }); + + expect(result).toEqual(rows); + }); + + // Every `hides` case pairs the hidden row with a kept second row, proving the two explicit + // predicates hide precisely their own row class and nothing else. + it.each([ + { + name: 'permissions granted to the active DAO by default', + rows: [ + buildRow({ whoAddress: daoAddress }), + buildRow({ whoAddress: pluginAddress }), + ], + filters: { + showDaoPermissions: false, + showSubpluginPermissions: true, + }, + }, + { + name: 'rows targeting installed subplugins by default', + rows: [ + buildRow({ whereAddress: subpluginAddress }), + buildRow({ + whoAddress: pluginAddress, + whereAddress: daoAddress, + }), + ], + daoPlugins: [ + buildPlugin({ + address: subpluginAddress, + isSubPlugin: true, + parentPlugin: parentPluginAddress, + }), + ], + }, + { + name: 'rows targeting plugins with a parent plugin by default', + rows: [ + buildRow({ whereAddress: subpluginAddress }), + buildRow({ + whoAddress: pluginAddress, + whereAddress: daoAddress, + }), + ], + daoPlugins: [ + buildPlugin({ + address: subpluginAddress, + parentPlugin: parentPluginAddress, + }), + ], + }, + { + name: 'rows targeting addresses listed by a parent plugin subPlugins field', + rows: [ + buildRow({ whereAddress: subpluginAddress }), + buildRow({ + whoAddress: pluginAddress, + whereAddress: daoAddress, + }), + ], + daoPlugins: [ + buildPlugin({ + address: parentPluginAddress, + subPlugins: [{ addresses: [subpluginAddress] }], + }), + ], + }, + { + name: 'backend-classified subplugin rows without installed-plugin metadata', + rows: [ + buildRow({ + whoAddress: pluginAddress, + where: subpluginWhere, + whereAddress: subpluginAddress, + }), + buildRow({ + whoAddress: pluginAddress, + whereAddress: daoAddress, + }), + ], + }, + { + name: 'process-internal child rows whose target has a parent plugin', + rows: [ + buildRow({ + whoAddress: pluginAddress, + where: { + address: targetAddress, + label: 'Core Governance Delegate (Veto)', + layer: 'processInternal', + parentPluginAddress, + parentPluginName: 'Core Governance', + }, + whereAddress: targetAddress, + }), + buildRow({ whereAddress: daoAddress }), + ], + }, + { + // FLT-2 guard: the deleted CREATE_PROPOSAL_PERMISSION_NAME carve-out must not return. + name: 'create-proposal rows targeting a subplugin without a name carve-out', + rows: [ + buildRow({ + permissionId: createProposalPermissionId, + whoAddress: pluginAddress, + who: { + address: pluginAddress, + interfaceType: 'spp', + label: 'Core Governance', + layer: 'topLevelPlugin', + status: 'installed', + }, + whereAddress: subpluginAddress, + where: { + address: subpluginAddress, + brandId: 'safe', + label: 'Process internal', + layer: 'processInternal', + parentPluginAddress: pluginAddress, + }, + }), + buildRow({ whereAddress: daoAddress }), + ], + }, + ])('hides $name', ({ rows, daoPlugins = [], filters }) => { + const result = filterPermissionRows(rows, { + ...defaultFilters, + daoPlugins, + ...filters, + }); + + expect(result).toEqual([rows[1]]); + }); + + it.each([ + { + showDaoPermissions: false, + showSubpluginPermissions: false, + expectedIndexes: [0, 2], + }, + { + showDaoPermissions: true, + showSubpluginPermissions: false, + expectedIndexes: [0, 1, 2], + }, + { + showDaoPermissions: false, + showSubpluginPermissions: true, + expectedIndexes: [0, 2], + }, + { + showDaoPermissions: true, + showSubpluginPermissions: true, + expectedIndexes: [0, 1, 2, 3], + }, + ])('applies only the two visible controls ($showDaoPermissions, $showSubpluginPermissions)', ({ + showDaoPermissions, + showSubpluginPermissions, + expectedIndexes, + }) => { + const rows = [ + buildRow({ whereAddress: daoAddress }), + buildRow({ + whoAddress: daoAddress, + whereAddress: targetAddress, + }), + buildRow({ + who: subpluginWhere, + whoAddress: subpluginAddress, + whereAddress: daoAddress, + }), + buildRow({ + who: { + address: daoAddress, + label: 'DAO-managed subplugin process', + layer: 'processInternal', + }, + whoAddress: daoAddress, + where: subpluginWhere, + whereAddress: subpluginAddress, + }), + ]; + + const result = filterPermissionRows(rows, { + activeAccountAddress: daoAddress, + daoPlugins: [], + showDaoPermissions, + showSubpluginPermissions, + }); + + expect(result).toEqual(expectedIndexes.map((index) => rows[index])); + }); +}); + +describe('getPermissionRowToggleAvailability', () => { + const bothHidden: IPermissionRowFilters = { + activeAccountAddress: daoAddress, + daoPlugins: [], + showDaoPermissions: false, + showSubpluginPermissions: false, + }; + + const bothAffectedRow = buildRow({ + whoAddress: daoAddress, + where: subpluginWhere, + whereAddress: subpluginAddress, + }); + + it.each([ + { + name: 'no affected rows', + rows: [buildRow({ whereAddress: daoAddress })], + filters: bothHidden, + expected: { daoPermissions: false, subpluginPermissions: false }, + }, + { + name: 'a DAO-granted row', + rows: [ + buildRow({ + whoAddress: daoAddress, + whereAddress: targetAddress, + }), + ], + filters: bothHidden, + expected: { daoPermissions: true, subpluginPermissions: false }, + }, + { + name: 'a backend-classified subplugin row', + rows: [ + buildRow({ + where: subpluginWhere, + whereAddress: subpluginAddress, + }), + ], + filters: bothHidden, + expected: { daoPermissions: false, subpluginPermissions: true }, + }, + { + // AC-13 guard: a subplugin as the caller must never activate the subplugin control. + name: 'a caller-side subplugin row', + rows: [ + buildRow({ + who: subpluginWhere, + whoAddress: subpluginAddress, + }), + ], + filters: bothHidden, + expected: { daoPermissions: false, subpluginPermissions: false }, + }, + { + name: 'a row hidden by both active controls', + rows: [bothAffectedRow], + filters: bothHidden, + expected: { daoPermissions: false, subpluginPermissions: false }, + }, + { + name: 'a row hidden by both with the subplugin control off', + rows: [bothAffectedRow], + filters: { ...bothHidden, showSubpluginPermissions: true }, + expected: { daoPermissions: true, subpluginPermissions: false }, + }, + { + name: 'a row hidden by both with the DAO control off', + rows: [bothAffectedRow], + filters: { ...bothHidden, showDaoPermissions: true }, + expected: { daoPermissions: false, subpluginPermissions: true }, + }, + ])('reports availability for $name', ({ rows, filters, expected }) => { + expect(getPermissionRowToggleAvailability(rows, filters)).toEqual( + expected, + ); + }); +}); diff --git a/apps/app/src/modules/settings/utils/permissionRowFilters/permissionRowFilters.ts b/apps/app/src/modules/settings/utils/permissionRowFilters/permissionRowFilters.ts new file mode 100644 index 0000000000..f6486b330a --- /dev/null +++ b/apps/app/src/modules/settings/utils/permissionRowFilters/permissionRowFilters.ts @@ -0,0 +1,134 @@ +import { addressUtils } from '@aragon/gov-ui-kit'; +import type { IDaoPermission, IDaoPlugin } from '@/shared/api/daoService'; +import type { IFilterComponentPlugin } from '@/shared/components/pluginFilterComponent'; + +export interface IPermissionRowFilters { + /** + * Address of the DAO / linked account currently being inspected. + */ + activeAccountAddress?: string; + /** + * Installed plugin metadata used to identify current subplugins. + */ + daoPlugins?: IFilterComponentPlugin[]; + /** + * When false, rows where the active DAO is the permission holder are hidden. + */ + showDaoPermissions: boolean; + /** + * When false, rows whose target (Where) is a subplugin are hidden. + */ + showSubpluginPermissions: boolean; +} + +const isSubplugin = (plugin: IFilterComponentPlugin): boolean => { + const { meta } = plugin; + + return meta.isSubPlugin === true || meta.parentPlugin != null; +}; + +const isSubpluginAddress = ( + address: string, + daoPlugins?: IFilterComponentPlugin[], +): boolean => + daoPlugins?.some((plugin) => { + const { meta } = plugin; + + if ( + isSubplugin(plugin) && + addressUtils.isAddressEqual(meta.address, address) + ) { + return true; + } + + return meta.subPlugins?.some((subPlugin) => + subPlugin.addresses.some((subPluginAddress) => + addressUtils.isAddressEqual(subPluginAddress, address), + ), + ); + }) ?? false; + +const rowTargetsSubplugin = ( + row: IDaoPermission, + daoPlugins?: IFilterComponentPlugin[], +): boolean => + row.where?.parentPluginAddress != null || + isSubpluginAddress(row.whereAddress, daoPlugins); + +const isDaoGrantedPermission = ( + row: IDaoPermission, + activeAccountAddress?: string, +): boolean => + activeAccountAddress != null && + addressUtils.isAddressEqual(row.whoAddress, activeAccountAddress); + +export interface IPermissionRowToggleAvailability { + daoPermissions: boolean; + subpluginPermissions: boolean; +} + +export const filterPermissionRows = ( + rows: IDaoPermission[], + filters: IPermissionRowFilters, +): IDaoPermission[] => { + const { + activeAccountAddress, + daoPlugins, + showDaoPermissions, + showSubpluginPermissions, + } = filters; + + return rows.filter((row) => { + if ( + !showDaoPermissions && + isDaoGrantedPermission(row, activeAccountAddress) + ) { + return false; + } + + if (!showSubpluginPermissions && rowTargetsSubplugin(row, daoPlugins)) { + return false; + } + + return true; + }); +}; + +export const getPermissionRowToggleAvailability = ( + rows: IDaoPermission[], + filters: IPermissionRowFilters, +): IPermissionRowToggleAvailability => { + const { + activeAccountAddress, + daoPlugins, + showDaoPermissions, + showSubpluginPermissions, + } = filters; + let daoPermissions = false; + let subpluginPermissions = false; + + for (const row of rows) { + const isDaoPermission = isDaoGrantedPermission( + row, + activeAccountAddress, + ); + const isSubpluginPermission = rowTargetsSubplugin(row, daoPlugins); + + if ( + isDaoPermission && + (showSubpluginPermissions || !isSubpluginPermission) + ) { + daoPermissions = true; + } + + if (isSubpluginPermission && (showDaoPermissions || !isDaoPermission)) { + subpluginPermissions = true; + } + + if (daoPermissions && subpluginPermissions) { + break; + } + } + + return { daoPermissions, subpluginPermissions }; +};