From 3c7c109880a985bc57315340b07d4a383282ac67 Mon Sep 17 00:00:00 2001 From: Kevin Davis Date: Tue, 30 Jun 2026 10:48:50 +0200 Subject: [PATCH 01/29] docs: Add ADR for permissions page condition-slot architecture --- ...ssions-page-condition-slot-architecture.md | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 docs/adr/0001-permissions-page-condition-slot-architecture.md diff --git a/docs/adr/0001-permissions-page-condition-slot-architecture.md b/docs/adr/0001-permissions-page-condition-slot-architecture.md new file mode 100644 index 0000000000..8ecba9488a --- /dev/null +++ b/docs/adr/0001-permissions-page-condition-slot-architecture.md @@ -0,0 +1,55 @@ +# ADR 0001 — Permissions page condition-slot architecture (APP-954) + +- **Status:** Accepted +- **Date:** 2026-06-30 +- **Ticket:** APP-954 (Build UI for permission page), child of APP-321 +- **Scope boundary:** APP-954 is the UI, preview-mocked ahead of the backend ticket APP-953. + +## Context + +The DAO Settings → Permissions subpage lists each permission as a row (WHO / WHERE / PERMISSION / CONDITION). The CONDITION column must render a per-condition UI that varies by condition type (e.g. a voting-power condition vs an execute-selector condition vs no condition at all). We need an extensible way to route a permission's condition to the right UI component without coupling the permissions page to specific condition contract addresses, and without prematurely building generic on-chain condition resolution that is the backend ticket's job (APP-953). + +A permission is `IDaoPermission = { permissionId, whoAddress, whereAddress, conditionAddress }`. `conditionAddress === ALLOW_FLAG` is the sentinel meaning "no condition" (the granted address may call unconditionally). + +## Decision + +### Decision 1 — Condition components register through a STANDALONE registry, not plugin index files + +Condition components are registered from a dedicated initialiser at `src/modules/settings/initConditionRegistry.ts`, which calls `pluginRegistryUtils.registerSlotComponent` for each `(SettingsSlotId.PERMISSION_CONDITION, conditionType)` pair and is invoked from the registry init path (`initPluginRegistry.ts` or equivalent) alongside the plugin initialisers. + +**`src/plugins/tokenPlugin/index.ts` and `src/plugins/sppPlugin/index.ts` MUST NOT be edited for condition registration.** A reviewer finding such edits should block the PR. + +**Rationale:** conditions are on-chain *contracts*, not plugin features. Routing condition UI through a plugin's index file conflates plugin ownership with condition ownership and rests on an unverified guess about which plugin "owns" a given condition type. A standalone registry keeps that ownership boundary clean. + +### Decision 2 — No address→conditionType routing table in APP-954 + +APP-954 trusts `conditionType` as supplied directly on the condition payload (`IConditionData.conditionType`) and read straight off `IPermissionRow`. There is **no** hardcoded map of known condition contract addresses → condition types. + +Condition-type resolution has exactly two rules: + +1. `conditionAddress === ALLOW_FLAG` → `conditionType = 'none'` +2. absent or unrecognised `conditionType` → `conditionType = 'unknown'` + +**Generic address→type resolution is explicitly APP-953's responsibility** and must not be pre-implemented here. Hardcoding condition addresses is fragile and is exactly the "support conditions generically" work the backend ticket owns. + +### Preserved decisions + +- **`IConditionData` shape:** `{ conditionType: string; [key: string]: unknown }`. +- **`conditionType` is the synthetic `pluginId`** passed to `PluginSingleComponent` (slotId `SettingsSlotId.PERMISSION_CONDITION`) for routing — it is not a real plugin id. +- **`IPermissionRow = IDaoPermission & { condition?: IConditionData }`** is a UI-only type (it does not change the domain `IDaoPermission`). +- **Fallback = a 'No condition' card.** `PluginSingleComponent` renders this Fallback when `conditionType` is `'none'`, or `'unknown'`, or when no component is registered for the conditionType. +- **Graph view is OUT OF SCOPE** for APP-954 (list view only; the Graph toggle is visible but disabled). +- **`permissionsDefinitionList` must NOT be reused.** It is a different concept (a proposal-creation permission guard); reusing it is a name-collision trap. + +## Consequences + +- Adding a new condition UI later = add a component + one `registerSlotComponent` line in `initConditionRegistry.ts`; no plugin edits, no page edits. +- The permissions page is decoupled from condition contract addresses; APP-953 can layer generic resolution by supplying `conditionType` on the payload (and/or registering more components) without touching the page. +- Until APP-953 lands, condition data is mock-supplied via the `useMocks` fetch-interceptor seam; if a condition's payload fields are unavailable (see the T02 spike), its component ships as a stub rendering the `conditionType` label + a "data pending" notice. +- Unknown/unregistered condition types degrade gracefully to the 'No condition' Fallback rather than erroring. + +## Downstream tracks gated by this ADR + +- **T04** — `SettingsSlotId.PERMISSION_CONDITION` + the 'No condition' Fallback component. +- **T07** — `initConditionRegistry.ts` + condition components (no plugin-index edits). +- **T08** — `IPermissionRow` / `IConditionData` types + `resolveConditionType` (the two rules above). From 136772fb84485fc81e4c19e63f23b49cefefbbd5 Mon Sep 17 00:00:00 2001 From: Kevin Davis Date: Tue, 30 Jun 2026 10:48:52 +0200 Subject: [PATCH 02/29] feat: Add permission-id reverse dictionary util --- src/shared/utils/permissionNameUtils/index.ts | 1 + .../permissionNameUtils.test.ts | 80 +++++++++++++++++++ .../permissionNameUtils.ts | 51 ++++++++++++ 3 files changed, 132 insertions(+) create mode 100644 src/shared/utils/permissionNameUtils/index.ts create mode 100644 src/shared/utils/permissionNameUtils/permissionNameUtils.test.ts create mode 100644 src/shared/utils/permissionNameUtils/permissionNameUtils.ts diff --git a/src/shared/utils/permissionNameUtils/index.ts b/src/shared/utils/permissionNameUtils/index.ts new file mode 100644 index 0000000000..f1420bf1d6 --- /dev/null +++ b/src/shared/utils/permissionNameUtils/index.ts @@ -0,0 +1 @@ +export { permissionNameUtils } from './permissionNameUtils'; diff --git a/src/shared/utils/permissionNameUtils/permissionNameUtils.test.ts b/src/shared/utils/permissionNameUtils/permissionNameUtils.test.ts new file mode 100644 index 0000000000..5261c789be --- /dev/null +++ b/src/shared/utils/permissionNameUtils/permissionNameUtils.test.ts @@ -0,0 +1,80 @@ +import { permissionNameUtils } from './permissionNameUtils'; + +describe('permissionNameUtils', () => { + describe('getPermissionName', () => { + it.each([ + { + permissionId: + '0x815fe80e4b37c8582a3b773d1d7071f983eacfd56b5965db654f3087c25ada33', + expected: 'Root', + }, + { + permissionId: + '0x8c433a4cd6b51969eca37f974940894297b9fcf4b282a213fea5cd8f85289c90', + expected: 'Create proposal', + }, + { + permissionId: + '0xbf04b4486c9663d805744005c3da000eda93de6e3308a4a7a812eb565327b78d', + expected: 'Execute', + }, + { + permissionId: + '0x821b6e3a557148015a918c89e5d092e878a69854a2d1a410635f771bd5a8a3f5', + expected: 'Upgrade plugin', + }, + { + permissionId: + '0x485a22b473de7ee3091c71c5ce05019fd1466a1650b1228784a9bcd5b7bed510', + expected: 'Manage selectors', + }, + ])('maps the known hash $permissionId to "$expected"', ({ + permissionId, + expected, + }) => { + expect(permissionNameUtils.getPermissionName(permissionId)).toEqual( + expected, + ); + }); + + it.each([ + { + permissionId: + '0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + expected: '0x0123…cdef', + }, + { + permissionId: + '0xdeadbeef00000000000000000000000000000000000000000000000000001234', + expected: '0xdead…1234', + }, + ])('returns the truncated fallback $expected for the unmapped hash $permissionId', ({ + permissionId, + expected, + }) => { + expect(permissionNameUtils.getPermissionName(permissionId)).toEqual( + expected, + ); + }); + + it.each([ + { + permissionId: + '0x815FE80E4B37C8582A3B773D1D7071F983EACFD56B5965DB654F3087C25ADA33', + expected: 'Root', + }, + { + permissionId: + '0xBF04B4486C9663D805744005C3DA000EDA93DE6E3308A4A7A812EB565327B78D', + expected: 'Execute', + }, + ])('resolves known hashes case-insensitively for $permissionId', ({ + permissionId, + expected, + }) => { + expect(permissionNameUtils.getPermissionName(permissionId)).toEqual( + expected, + ); + }); + }); +}); diff --git a/src/shared/utils/permissionNameUtils/permissionNameUtils.ts b/src/shared/utils/permissionNameUtils/permissionNameUtils.ts new file mode 100644 index 0000000000..9b4a002193 --- /dev/null +++ b/src/shared/utils/permissionNameUtils/permissionNameUtils.ts @@ -0,0 +1,51 @@ +import { keccak256, toBytes } from 'viem'; + +const truncatedHashPrefixLength = 6; +const truncatedHashSuffixLength = 4; + +const permissionFriendlyNames: Record = { + ROOT_PERMISSION: 'Root', + CREATE_PROPOSAL_PERMISSION: 'Create proposal', + EXECUTE_PERMISSION: 'Execute', + UPGRADE_PLUGIN_PERMISSION: 'Upgrade plugin', + MANAGE_SELECTORS_PERMISSION: 'Manage selectors', +}; + +class PermissionNameUtils { + // TODO(APP-954): replace/extend with the ticket-attached seed permission dictionary once the + // Linear attachment is merged. Until then we only resolve the generic protocol permission ids. + private permissionNamesByHash: Record = Object.fromEntries( + Object.entries(permissionFriendlyNames).map( + ([permissionId, friendlyName]) => [ + keccak256(toBytes(permissionId)).toLowerCase(), + friendlyName, + ], + ), + ); + + /** + * Resolves a keccak256 permission-id hash to its friendly, human-readable name. + * + * @param permissionId The keccak256 hash of the permission identifier. + * @returns The mapped friendly name, or a truncated `0x1234…abcd` form of the hash when unknown. + */ + getPermissionName = (permissionId: string): string => { + const normalisedId = permissionId.toLowerCase(); + const friendlyName = this.permissionNamesByHash[normalisedId]; + + if (friendlyName != null) { + return friendlyName; + } + + return this.truncateHash(permissionId); + }; + + private truncateHash = (hash: string): string => { + const prefix = hash.slice(0, truncatedHashPrefixLength); + const suffix = hash.slice(-truncatedHashSuffixLength); + + return `${prefix}…${suffix}`; + }; +} + +export const permissionNameUtils = new PermissionNameUtils(); From 75fd75d0a803f8fea643d580f78f897fb8556b93 Mon Sep 17 00:00:00 2001 From: Kevin Davis Date: Tue, 30 Jun 2026 10:48:54 +0200 Subject: [PATCH 03/29] feat: Add permission entity resolver and ALLOW_FLAG/ANY_ADDR sentinels --- .../settings/constants/permissionSentinels.ts | 25 ++++++ .../utils/permissionEntityUtils/index.ts | 4 + .../permissionEntityUtils.test.ts | 80 +++++++++++++++++ .../permissionEntityUtils.ts | 87 +++++++++++++++++++ 4 files changed, 196 insertions(+) create mode 100644 src/modules/settings/constants/permissionSentinels.ts create mode 100644 src/modules/settings/utils/permissionEntityUtils/index.ts create mode 100644 src/modules/settings/utils/permissionEntityUtils/permissionEntityUtils.test.ts create mode 100644 src/modules/settings/utils/permissionEntityUtils/permissionEntityUtils.ts diff --git a/src/modules/settings/constants/permissionSentinels.ts b/src/modules/settings/constants/permissionSentinels.ts new file mode 100644 index 0000000000..3dd201088f --- /dev/null +++ b/src/modules/settings/constants/permissionSentinels.ts @@ -0,0 +1,25 @@ +/** + * Canonical OSx permission sentinels. + * + * These are special addresses used by the OSx `PermissionManager` to encode + * permission semantics rather than referencing a concrete account. + * + * NOTE: APP-953 may promote these to a shared location (e.g. a shared constants + * module) once other modules need them. They live in the settings module for now + * because the permission UI is the only consumer. + */ + +/** + * The OSx "any address" sentinel. When used as the `who` or `where` of a + * permission, it means the permission applies to / from any address ("Anyone"). + * @see https://github.com/aragon/osx/blob/main/packages/contracts/src/core/permission/PermissionManager.sol + */ +export const ANY_ADDR = '0xffffffffffffffffffffffffffffffffffffffff' as const; + +/** + * The OSx `ALLOW_FLAG` sentinel, equal to `address(2)`. It is stored as the + * permission condition to signal that the permission is granted unconditionally + * ("no condition"), as opposed to being gated by a condition contract. + * @see https://github.com/aragon/osx/blob/main/packages/contracts/src/core/permission/PermissionManager.sol + */ +export const ALLOW_FLAG = '0x0000000000000000000000000000000000000002' as const; diff --git a/src/modules/settings/utils/permissionEntityUtils/index.ts b/src/modules/settings/utils/permissionEntityUtils/index.ts new file mode 100644 index 0000000000..6636be5a7a --- /dev/null +++ b/src/modules/settings/utils/permissionEntityUtils/index.ts @@ -0,0 +1,4 @@ +export { + type IPermissionEntity, + permissionEntityUtils, +} from './permissionEntityUtils'; diff --git a/src/modules/settings/utils/permissionEntityUtils/permissionEntityUtils.test.ts b/src/modules/settings/utils/permissionEntityUtils/permissionEntityUtils.test.ts new file mode 100644 index 0000000000..22b89d602d --- /dev/null +++ b/src/modules/settings/utils/permissionEntityUtils/permissionEntityUtils.test.ts @@ -0,0 +1,80 @@ +import { addressUtils } from '@aragon/gov-ui-kit'; +import type { IDaoPlugin } from '@/shared/api/daoService'; +import type { IFilterComponentPlugin } from '@/shared/components/pluginFilterComponent'; +import { ALLOW_FLAG, ANY_ADDR } from '../../constants/permissionSentinels'; +import { + type IPermissionEntity, + permissionEntityUtils, +} from './permissionEntityUtils'; + +describe('permissionEntity Utils', () => { + describe('resolvePermissionEntity', () => { + const pluginAddress = '0x1234567890123456789012345678901234567890'; + const unknownAddress = '0x000000000000000000000000000000000000dead'; + + const daoPlugins = [ + { + id: 'multisig', + uniqueId: `${pluginAddress}-multisig`, + label: 'Multisig', + meta: { + address: pluginAddress, + name: 'Multisig', + } as IDaoPlugin, + props: {}, + }, + ] satisfies IFilterComponentPlugin[]; + + it.each([ + { + description: 'resolves ANY_ADDR to "Anyone" sentinel', + address: ANY_ADDR, + expected: { label: 'Anyone', isSentinel: true, tag: undefined }, + }, + { + description: 'resolves ANY_ADDR case-insensitively to "Anyone"', + address: ANY_ADDR.toUpperCase(), + expected: { label: 'Anyone', isSentinel: true, tag: undefined }, + }, + { + description: 'resolves ALLOW_FLAG to "Any Address" sentinel', + address: ALLOW_FLAG, + expected: { + label: 'Any Address', + isSentinel: true, + tag: undefined, + }, + }, + { + description: 'resolves a matching plugin address to its tag', + address: pluginAddress, + expected: { + label: 'Multisig', + isSentinel: false, + tag: 'Multisig', + }, + }, + { + description: + 'falls back to a truncated address for unknown addresses', + address: unknownAddress, + expected: { + label: addressUtils.truncateAddress(unknownAddress), + isSentinel: false, + tag: undefined, + }, + }, + ])('$description', ({ address, expected }) => { + const result: IPermissionEntity = + permissionEntityUtils.resolvePermissionEntity( + address, + daoPlugins, + ); + + expect(result.label).toEqual(expected.label); + expect(result.tag).toEqual(expected.tag); + expect(result.isSentinel).toEqual(expected.isSentinel); + expect(result.address).toEqual(address); + }); + }); +}); diff --git a/src/modules/settings/utils/permissionEntityUtils/permissionEntityUtils.ts b/src/modules/settings/utils/permissionEntityUtils/permissionEntityUtils.ts new file mode 100644 index 0000000000..6de42f62db --- /dev/null +++ b/src/modules/settings/utils/permissionEntityUtils/permissionEntityUtils.ts @@ -0,0 +1,87 @@ +import { addressUtils } from '@aragon/gov-ui-kit'; +import type { IDaoPlugin } 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'; + +/** + * Resolved, display-ready representation of a permission `who` / `where` entity. + */ +export interface IPermissionEntity { + /** + * Human-readable label for the entity (e.g. a plugin name, a sentinel name, + * or a truncated address fallback). + */ + label: string; + /** + * Short type tag for the entity when it can be classified (e.g. the plugin + * name). Undefined for plain addresses and sentinels. + */ + tag?: string; + /** + * The original, unmodified address that was resolved. + */ + address: string; + /** + * Whether the address is an OSx permission sentinel (ANY_ADDR / ALLOW_FLAG) + * rather than a concrete account. + */ + isSentinel: boolean; +} + +type DaoPluginEntries = IFilterComponentPlugin[]; + +class PermissionEntityUtils { + /** + * Resolves a permission `who` / `where` address to a display-ready entity. + * + * Resolution order: + * 1. {@link ANY_ADDR} sentinel -> "Anyone". + * 2. {@link ALLOW_FLAG} sentinel -> "Any Address". + * 3. A matching installed DAO plugin -> the plugin name (as label and tag). + * 4. Otherwise -> the checksummed, truncated address. + * + * @param address - The `who` or `where` address to resolve. + * @param daoPlugins - Optional list of installed DAO plugin entries (as + * returned by `useDaoPlugins`) used to match the address against a plugin. + * @returns The resolved permission entity. + */ + resolvePermissionEntity = ( + address: string, + daoPlugins?: DaoPluginEntries, + ): IPermissionEntity => { + if (this.isAddressEqual(address, ANY_ADDR)) { + return { label: 'Anyone', address, isSentinel: true }; + } + + if (this.isAddressEqual(address, ALLOW_FLAG)) { + return { label: 'Any Address', address, isSentinel: true }; + } + + const matchedPlugin = daoPlugins?.find((plugin) => + this.isAddressEqual(plugin.meta.address, address), + ); + + if (matchedPlugin != null) { + const pluginName = daoUtils.getPluginName(matchedPlugin.meta); + + return { + label: pluginName, + tag: pluginName, + address, + isSentinel: false, + }; + } + + return { + label: addressUtils.truncateAddress(address), + address, + isSentinel: false, + }; + }; + + private isAddressEqual = (a?: string, b?: string): boolean => + a != null && b != null && a.toLowerCase() === b.toLowerCase(); +} + +export const permissionEntityUtils = new PermissionEntityUtils(); From 0ad2a20602db351b5c9b051108dee7ccd3a684dd Mon Sep 17 00:00:00 2001 From: Kevin Davis Date: Tue, 30 Jun 2026 10:48:55 +0200 Subject: [PATCH 04/29] feat: Add settings permissions page route and shell --- .../(daoPage)/settings/permissions/page.tsx | 3 + src/assets/locales/en.json | 18 ++++ .../daoPermissionsPage/daoPermissionsPage.tsx | 32 ++++++ .../daoPermissionsPageClient.tsx | 98 +++++++++++++++++++ .../pages/daoPermissionsPage/index.ts | 4 + 5 files changed, 155 insertions(+) create mode 100644 src/app/dao/[network]/[addressOrEns]/(daoPage)/settings/permissions/page.tsx create mode 100644 src/modules/settings/pages/daoPermissionsPage/daoPermissionsPage.tsx create mode 100644 src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClient.tsx create mode 100644 src/modules/settings/pages/daoPermissionsPage/index.ts diff --git a/src/app/dao/[network]/[addressOrEns]/(daoPage)/settings/permissions/page.tsx b/src/app/dao/[network]/[addressOrEns]/(daoPage)/settings/permissions/page.tsx new file mode 100644 index 0000000000..cdd82da90f --- /dev/null +++ b/src/app/dao/[network]/[addressOrEns]/(daoPage)/settings/permissions/page.tsx @@ -0,0 +1,3 @@ +import { DaoPermissionsPage } from '@/modules/settings/pages/daoPermissionsPage'; + +export default DaoPermissionsPage; diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index 2ab1d511df..601f3a1cac 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -3491,6 +3491,24 @@ "info": "This policy can't be dispatched once it's uninstalled." } }, + "daoPermissionsPage": { + "header": { + "breadcrumb": { + "settings": "Settings", + "permissions": "Permissions" + }, + "title": "Permissions", + "description": "Review who can do what across this DAO and its linked accounts" + }, + "view": { + "list": "List", + "graph": "Graph" + }, + "placeholder": { + "heading": "Permissions coming soon", + "description": "The permissions overview for this DAO will appear here." + } + }, "daoPolicyDetailsPage": { "error": { "action": "Explore settings", diff --git a/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPage.tsx b/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPage.tsx new file mode 100644 index 0000000000..52f5e59c9e --- /dev/null +++ b/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPage.tsx @@ -0,0 +1,32 @@ +import { Page } from '@/shared/components/page'; +import type { IDaoPageParams } from '@/shared/types'; +import { daoUtils } from '@/shared/utils/daoUtils'; +import { networkUtils } from '@/shared/utils/networkUtils'; +import { DaoPermissionsPageClient } from './daoPermissionsPageClient'; + +export interface IDaoPermissionsPageProps { + /** + * DAO page parameters. + */ + params: Promise; +} + +export const DaoPermissionsPage: React.FC = async ( + props, +) => { + const { params } = props; + const daoPageParams = await params; + + if (!networkUtils.isValidNetwork(daoPageParams.network)) { + // invalid network handled in DAO layout + return null; + } + + const daoId = await daoUtils.resolveDaoId(daoPageParams); + + return ( + + + + ); +}; diff --git a/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClient.tsx b/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClient.tsx new file mode 100644 index 0000000000..a7c2dda814 --- /dev/null +++ b/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClient.tsx @@ -0,0 +1,98 @@ +'use client'; + +import { CardEmptyState, Toggle, ToggleGroup } from '@aragon/gov-ui-kit'; +import { useState } from 'react'; +import { useDao } from '@/shared/api/daoService'; +import { Page } from '@/shared/components/page'; +import { useTranslations } from '@/shared/components/translationsProvider'; +import { daoUtils } from '@/shared/utils/daoUtils'; + +export interface IDaoPermissionsPageClientProps { + /** + * ID of the DAO. + */ + daoId: string; +} + +type PermissionsView = 'list' | 'graph'; + +export const DaoPermissionsPageClient: React.FC< + IDaoPermissionsPageClientProps +> = (props) => { + const { daoId } = props; + + const { t } = useTranslations(); + + const { data: dao } = useDao({ urlParams: { id: daoId } }); + + // Graph view is out of scope for now (T05 shell); only the list view is wired up. + const [view, setView] = useState('list'); + + const handleViewChange = (value: string | string[] | undefined) => { + if (value === 'list' || value === 'graph') { + setView(value); + } + }; + + // TODO(APP-954): confirm copy against Figma + const pageBreadcrumbs = [ + { + href: daoUtils.getDaoUrl(dao, 'settings'), + label: t( + 'app.settings.daoPermissionsPage.header.breadcrumb.settings', + ), + }, + { + label: t( + 'app.settings.daoPermissionsPage.header.breadcrumb.permissions', + ), + }, + ]; + + return ( + <> + + + +
+ + + + + {/* Placeholder for the permissions list mounted in T09. */} + +
+
+
+ + ); +}; diff --git a/src/modules/settings/pages/daoPermissionsPage/index.ts b/src/modules/settings/pages/daoPermissionsPage/index.ts new file mode 100644 index 0000000000..c7028d8cd1 --- /dev/null +++ b/src/modules/settings/pages/daoPermissionsPage/index.ts @@ -0,0 +1,4 @@ +export { + DaoPermissionsPage, + type IDaoPermissionsPageProps, +} from './daoPermissionsPage'; From eb2e91e036b0247fa03dcc840a0470a755a7777b Mon Sep 17 00:00:00 2001 From: Kevin Davis Date: Tue, 30 Jun 2026 10:52:47 +0200 Subject: [PATCH 05/29] feat: Add permission row types and condition-type resolver --- src/modules/settings/types/index.ts | 1 + src/modules/settings/types/permissionRow.ts | 31 ++++++++++++ .../conditionTypeUtils.test.ts | 45 +++++++++++++++++ .../conditionTypeUtils/conditionTypeUtils.ts | 49 +++++++++++++++++++ .../utils/conditionTypeUtils/index.ts | 1 + 5 files changed, 127 insertions(+) create mode 100644 src/modules/settings/types/permissionRow.ts create mode 100644 src/modules/settings/utils/conditionTypeUtils/conditionTypeUtils.test.ts create mode 100644 src/modules/settings/utils/conditionTypeUtils/conditionTypeUtils.ts create mode 100644 src/modules/settings/utils/conditionTypeUtils/index.ts diff --git a/src/modules/settings/types/index.ts b/src/modules/settings/types/index.ts index 176224a0ff..d6aa45df8e 100644 --- a/src/modules/settings/types/index.ts +++ b/src/modules/settings/types/index.ts @@ -2,5 +2,6 @@ export type { IBuildPreparePluginUpdateDataParams } from './buildPreparePluginUp export type { IDaoPolicyDetailsPageParams } from './daoPolicyDetailsPageParams'; export type { IDaoProcessDetailsPageParams } from './daoProcessDetailsPageParams'; export type { IGetUninstallHelpersParams } from './getUninstallHelpersParams'; +export type { IConditionData, IPermissionRow } from './permissionRow'; export type { IPluginToFormDataParams } from './pluginToFormDataParams'; export type { IUseGovernanceSettingsParams } from './useGovernanceSettingsParams'; diff --git a/src/modules/settings/types/permissionRow.ts b/src/modules/settings/types/permissionRow.ts new file mode 100644 index 0000000000..c032c6a93e --- /dev/null +++ b/src/modules/settings/types/permissionRow.ts @@ -0,0 +1,31 @@ +import type { IDaoPermission } from '@/shared/api/daoService'; + +/** + * UI-side representation of a permission's resolved condition. + * + * `conditionType` is read straight off the payload (see ADR 0001). Additional, + * condition-specific fields may be present, hence the open-ended index + * signature. + */ +export interface IConditionData { + /** + * The resolved condition type discriminator (e.g. `'voting-power'`). An + * empty or absent value is treated as `'unknown'` by the resolver. + */ + conditionType: string; + [key: string]: unknown; +} + +/** + * UI-only view model for a single permission row. Extends the domain + * {@link IDaoPermission} with an optional resolved {@link IConditionData}. + * + * The domain type is never mutated; condition resolution happens at the UI + * layer. + */ +export type IPermissionRow = IDaoPermission & { + /** + * Resolved condition payload for the permission, when available. + */ + condition?: IConditionData; +}; diff --git a/src/modules/settings/utils/conditionTypeUtils/conditionTypeUtils.test.ts b/src/modules/settings/utils/conditionTypeUtils/conditionTypeUtils.test.ts new file mode 100644 index 0000000000..9e3a2fa0cf --- /dev/null +++ b/src/modules/settings/utils/conditionTypeUtils/conditionTypeUtils.test.ts @@ -0,0 +1,45 @@ +import { ALLOW_FLAG } from '../../constants/permissionSentinels'; +import { conditionTypeUtils } from './conditionTypeUtils'; + +describe('conditionType Utils', () => { + describe('resolveConditionType', () => { + const someAddress = '0x000000000000000000000000000000000000dead'; + + it.each([ + { + description: 'resolves ALLOW_FLAG to "none"', + conditionAddress: ALLOW_FLAG, + conditionData: undefined, + expected: 'none', + }, + { + description: + 'returns the payload conditionType when it is a non-empty string', + conditionAddress: someAddress, + conditionData: { conditionType: 'voting-power' }, + expected: 'voting-power', + }, + { + description: + 'resolves to "unknown" when condition data is absent', + conditionAddress: someAddress, + conditionData: undefined, + expected: 'unknown', + }, + { + description: + 'resolves to "unknown" when conditionType is an empty string', + conditionAddress: someAddress, + conditionData: { conditionType: '' }, + expected: 'unknown', + }, + ])('$description', ({ conditionAddress, conditionData, expected }) => { + expect( + conditionTypeUtils.resolveConditionType( + conditionAddress, + conditionData, + ), + ).toBe(expected); + }); + }); +}); diff --git a/src/modules/settings/utils/conditionTypeUtils/conditionTypeUtils.ts b/src/modules/settings/utils/conditionTypeUtils/conditionTypeUtils.ts new file mode 100644 index 0000000000..823539ebd1 --- /dev/null +++ b/src/modules/settings/utils/conditionTypeUtils/conditionTypeUtils.ts @@ -0,0 +1,49 @@ +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'; + +/** + * Discriminator returned when the condition type cannot be resolved from the + * payload (absent condition data or an empty/unrecognised `conditionType`). + */ +const UNKNOWN_CONDITION = 'unknown'; + +class ConditionTypeUtils { + /** + * Resolves the display condition type for a permission. + * + * Per ADR 0001, the type is read straight off the payload; the only + * resolution rules are: + * - condition address equals {@link ALLOW_FLAG} (case-insensitive) -> + * `'none'`. + * - a non-empty `conditionType` on the supplied data -> that value. + * - otherwise -> `'unknown'`. + * + * @param conditionAddress The permission's on-chain condition address. + * @param conditionData Optional resolved condition payload. + * @returns The resolved condition type discriminator. + */ + resolveConditionType = ( + conditionAddress: string, + conditionData?: IConditionData, + ): string => { + if (conditionAddress.toLowerCase() === ALLOW_FLAG.toLowerCase()) { + return NO_CONDITION; + } + + const conditionType = conditionData?.conditionType; + + if (typeof conditionType === 'string' && conditionType.length > 0) { + return conditionType; + } + + return UNKNOWN_CONDITION; + }; +} + +export const conditionTypeUtils = new ConditionTypeUtils(); diff --git a/src/modules/settings/utils/conditionTypeUtils/index.ts b/src/modules/settings/utils/conditionTypeUtils/index.ts new file mode 100644 index 0000000000..11ba8f604d --- /dev/null +++ b/src/modules/settings/utils/conditionTypeUtils/index.ts @@ -0,0 +1 @@ +export { conditionTypeUtils } from './conditionTypeUtils'; From c85c7cd3cc40a7f6d9d3ade0a9de2b235a82bfa2 Mon Sep 17 00:00:00 2001 From: Kevin Davis Date: Tue, 30 Jun 2026 10:52:49 +0200 Subject: [PATCH 06/29] feat: Add PERMISSION_CONDITION slot and no-condition fallback component --- src/assets/locales/en.json | 4 ++++ .../components/noConditionSlot/index.ts | 1 + .../noConditionSlot/noConditionSlot.test.tsx | 20 +++++++++++++++++++ .../noConditionSlot/noConditionSlot.tsx | 17 ++++++++++++++++ src/modules/settings/constants/moduleSlots.ts | 1 + 5 files changed, 43 insertions(+) create mode 100644 src/modules/settings/components/noConditionSlot/index.ts create mode 100644 src/modules/settings/components/noConditionSlot/noConditionSlot.test.tsx create mode 100644 src/modules/settings/components/noConditionSlot/noConditionSlot.tsx diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index 601f3a1cac..92bfd6a34b 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -3509,6 +3509,10 @@ "description": "The permissions overview for this DAO will appear here." } }, + "noConditionSlot": { + "heading": "No condition", + "description": "Functions guarded by this permission can be called by the granted address unconditionally." + }, "daoPolicyDetailsPage": { "error": { "action": "Explore settings", diff --git a/src/modules/settings/components/noConditionSlot/index.ts b/src/modules/settings/components/noConditionSlot/index.ts new file mode 100644 index 0000000000..fc5bedf58c --- /dev/null +++ b/src/modules/settings/components/noConditionSlot/index.ts @@ -0,0 +1 @@ +export { NoConditionSlot } from './noConditionSlot'; diff --git a/src/modules/settings/components/noConditionSlot/noConditionSlot.test.tsx b/src/modules/settings/components/noConditionSlot/noConditionSlot.test.tsx new file mode 100644 index 0000000000..5bb9e5176b --- /dev/null +++ b/src/modules/settings/components/noConditionSlot/noConditionSlot.test.tsx @@ -0,0 +1,20 @@ +import { GukModulesProvider } from '@aragon/gov-ui-kit'; +import { render, screen } from '@testing-library/react'; +import { NoConditionSlot } from './noConditionSlot'; + +describe(' component', () => { + const createTestComponent = () => ( + + + + ); + + it('renders the no condition heading and description copy', () => { + render(createTestComponent()); + + expect(screen.getByText(/noConditionSlot.heading/)).toBeInTheDocument(); + expect( + screen.getByText(/noConditionSlot.description/), + ).toBeInTheDocument(); + }); +}); diff --git a/src/modules/settings/components/noConditionSlot/noConditionSlot.tsx b/src/modules/settings/components/noConditionSlot/noConditionSlot.tsx new file mode 100644 index 0000000000..19cadd0889 --- /dev/null +++ b/src/modules/settings/components/noConditionSlot/noConditionSlot.tsx @@ -0,0 +1,17 @@ +'use client'; + +import { CardEmptyState } from '@aragon/gov-ui-kit'; +import { useTranslations } from '@/shared/components/translationsProvider'; + +export const NoConditionSlot: React.FC = () => { + const { t } = useTranslations(); + + return ( + + ); +}; diff --git a/src/modules/settings/constants/moduleSlots.ts b/src/modules/settings/constants/moduleSlots.ts index a03ccc18aa..66889f5cdf 100644 --- a/src/modules/settings/constants/moduleSlots.ts +++ b/src/modules/settings/constants/moduleSlots.ts @@ -3,4 +3,5 @@ export enum SettingsSlotId { SETTINGS_BUILD_PREPARE_PLUGIN_UPDATE_DATA = 'SETTINGS_BUILD_PREPARE_PLUGIN_UPDATE_DATA', SETTINGS_GET_UNINSTALL_HELPERS = 'SETTINGS_GET_UNINSTALL_HELPERS', SETTINGS_PLUGIN_TO_FORM_DATA = 'SETTINGS_PLUGIN_TO_FORM_DATA', + PERMISSION_CONDITION = 'PERMISSION_CONDITION', } From 712108ea9333a58eda33ca72cf59e32dd0b6dbdb Mon Sep 17 00:00:00 2001 From: Kevin Davis Date: Tue, 30 Jun 2026 11:03:59 +0200 Subject: [PATCH 07/29] feat: Add condition slot registry and voting-power/execute-selector components --- src/assets/locales/en.json | 10 ++++ src/initPluginRegistry.ts | 2 + .../executeSelectorConditionSlot.test.tsx | 53 ++++++++++++++++++ .../executeSelectorConditionSlot.tsx | 54 +++++++++++++++++++ .../executeSelectorConditionSlot/index.ts | 7 +++ .../votingPowerConditionSlot/index.ts | 7 +++ .../votingPowerConditionSlot.test.tsx | 28 ++++++++++ .../votingPowerConditionSlot.tsx | 28 ++++++++++ .../settings/initConditionRegistry.test.ts | 45 ++++++++++++++++ src/modules/settings/initConditionRegistry.ts | 28 ++++++++++ 10 files changed, 262 insertions(+) create mode 100644 src/modules/settings/components/executeSelectorConditionSlot/executeSelectorConditionSlot.test.tsx create mode 100644 src/modules/settings/components/executeSelectorConditionSlot/executeSelectorConditionSlot.tsx create mode 100644 src/modules/settings/components/executeSelectorConditionSlot/index.ts create mode 100644 src/modules/settings/components/votingPowerConditionSlot/index.ts create mode 100644 src/modules/settings/components/votingPowerConditionSlot/votingPowerConditionSlot.test.tsx create mode 100644 src/modules/settings/components/votingPowerConditionSlot/votingPowerConditionSlot.tsx create mode 100644 src/modules/settings/initConditionRegistry.test.ts create mode 100644 src/modules/settings/initConditionRegistry.ts diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index 92bfd6a34b..cd554cd920 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -3513,6 +3513,16 @@ "heading": "No condition", "description": "Functions guarded by this permission can be called by the granted address unconditionally." }, + "votingPowerConditionSlot": { + "type": "Condition", + "details": "Details", + "pending": "Condition details pending (APP-953)" + }, + "executeSelectorConditionSlot": { + "target": "Target", + "allowedActions": "Allowed actions", + "noActions": "No allowed actions" + }, "daoPolicyDetailsPage": { "error": { "action": "Explore settings", diff --git a/src/initPluginRegistry.ts b/src/initPluginRegistry.ts index e09f255ca6..5c536b941a 100644 --- a/src/initPluginRegistry.ts +++ b/src/initPluginRegistry.ts @@ -1,9 +1,11 @@ import { initialiseDaos } from './daos'; import { initialisePolicyPluginRegistry } from './modules/capitalFlow/utils/policyPluginRegistryUtils'; +import { initialiseConditionRegistry } from './modules/settings/initConditionRegistry'; import { initialisePlugins } from './plugins'; export const initPluginRegistry = () => { initialisePlugins(); initialisePolicyPluginRegistry(); initialiseDaos(); + initialiseConditionRegistry(); }; diff --git a/src/modules/settings/components/executeSelectorConditionSlot/executeSelectorConditionSlot.test.tsx b/src/modules/settings/components/executeSelectorConditionSlot/executeSelectorConditionSlot.test.tsx new file mode 100644 index 0000000000..094cdc5ddf --- /dev/null +++ b/src/modules/settings/components/executeSelectorConditionSlot/executeSelectorConditionSlot.test.tsx @@ -0,0 +1,53 @@ +import { GukModulesProvider } from '@aragon/gov-ui-kit'; +import { render, screen } from '@testing-library/react'; +import type { IConditionData } from '@/modules/settings/types'; +import { ExecuteSelectorConditionSlot } from './executeSelectorConditionSlot'; + +describe(' component', () => { + const createTestComponent = (props?: Partial) => { + const completeProps: IConditionData = { + conditionType: 'execute-selector', + ...props, + }; + + return ( + + + + ); + }; + + it('renders the target and the list of allowed selectors from the payload', () => { + render( + createTestComponent({ + target: '0xTarget', + selectors: ['0xaaaaaaaa', '0xbbbbbbbb'], + }), + ); + + expect(screen.getByText('0xTarget')).toBeInTheDocument(); + expect(screen.getByText('0xaaaaaaaa')).toBeInTheDocument(); + expect(screen.getByText('0xbbbbbbbb')).toBeInTheDocument(); + }); + + it('shows the no allowed actions fallback when selectors are absent', () => { + render(createTestComponent({ selectors: undefined })); + + expect( + screen.getByText(/executeSelectorConditionSlot.noActions/), + ).toBeInTheDocument(); + }); + + it('ignores non-string selector entries when narrowing the payload', () => { + render( + createTestComponent({ + selectors: ['0xaaaaaaaa', 42, null, ''], + }), + ); + + expect(screen.getByText('0xaaaaaaaa')).toBeInTheDocument(); + expect( + screen.queryByText(/executeSelectorConditionSlot.noActions/), + ).not.toBeInTheDocument(); + }); +}); diff --git a/src/modules/settings/components/executeSelectorConditionSlot/executeSelectorConditionSlot.tsx b/src/modules/settings/components/executeSelectorConditionSlot/executeSelectorConditionSlot.tsx new file mode 100644 index 0000000000..a0c1fa7acd --- /dev/null +++ b/src/modules/settings/components/executeSelectorConditionSlot/executeSelectorConditionSlot.tsx @@ -0,0 +1,54 @@ +'use client'; + +import { DefinitionList } from '@aragon/gov-ui-kit'; +import type { IConditionData } from '@/modules/settings/types'; +import { useTranslations } from '@/shared/components/translationsProvider'; + +const isNonEmptyString = (value: unknown): value is string => + typeof value === 'string' && value.length > 0; + +const toSelectorList = (value: unknown): string[] => { + if (!Array.isArray(value)) { + return []; + } + + return value.filter(isNonEmptyString); +}; + +export const ExecuteSelectorConditionSlot: React.FC = ( + props, +) => { + const { target, selectors } = props; + const { t } = useTranslations(); + + const targetLabel = isNonEmptyString(target) ? target : undefined; + const selectorList = toSelectorList(selectors); + const hasAllowedActions = selectorList.length > 0; + + return ( + + {targetLabel != null && ( + + {targetLabel} + + )} + + {hasAllowedActions ? ( +
    + {selectorList.map((selector) => ( +
  • {selector}
  • + ))} +
+ ) : ( + t('app.settings.executeSelectorConditionSlot.noActions') + )} +
+
+ ); +}; diff --git a/src/modules/settings/components/executeSelectorConditionSlot/index.ts b/src/modules/settings/components/executeSelectorConditionSlot/index.ts new file mode 100644 index 0000000000..58753cab67 --- /dev/null +++ b/src/modules/settings/components/executeSelectorConditionSlot/index.ts @@ -0,0 +1,7 @@ +import dynamic from 'next/dynamic'; + +export const ExecuteSelectorConditionSlot = dynamic(() => + import('./executeSelectorConditionSlot').then( + (mod) => mod.ExecuteSelectorConditionSlot, + ), +); diff --git a/src/modules/settings/components/votingPowerConditionSlot/index.ts b/src/modules/settings/components/votingPowerConditionSlot/index.ts new file mode 100644 index 0000000000..355dd17d4b --- /dev/null +++ b/src/modules/settings/components/votingPowerConditionSlot/index.ts @@ -0,0 +1,7 @@ +import dynamic from 'next/dynamic'; + +export const VotingPowerConditionSlot = dynamic(() => + import('./votingPowerConditionSlot').then( + (mod) => mod.VotingPowerConditionSlot, + ), +); diff --git a/src/modules/settings/components/votingPowerConditionSlot/votingPowerConditionSlot.test.tsx b/src/modules/settings/components/votingPowerConditionSlot/votingPowerConditionSlot.test.tsx new file mode 100644 index 0000000000..788af250cc --- /dev/null +++ b/src/modules/settings/components/votingPowerConditionSlot/votingPowerConditionSlot.test.tsx @@ -0,0 +1,28 @@ +import { GukModulesProvider } from '@aragon/gov-ui-kit'; +import { render, screen } from '@testing-library/react'; +import type { IConditionData } from '@/modules/settings/types'; +import { VotingPowerConditionSlot } from './votingPowerConditionSlot'; + +describe(' component', () => { + const createTestComponent = (props?: Partial) => { + const completeProps: IConditionData = { + conditionType: 'voting-power', + ...props, + }; + + return ( + + + + ); + }; + + it('renders the condition type and the pending placeholder copy', () => { + render(createTestComponent({ conditionType: 'voting-power' })); + + expect(screen.getByText('voting-power')).toBeInTheDocument(); + expect( + screen.getByText(/votingPowerConditionSlot.pending/), + ).toBeInTheDocument(); + }); +}); diff --git a/src/modules/settings/components/votingPowerConditionSlot/votingPowerConditionSlot.tsx b/src/modules/settings/components/votingPowerConditionSlot/votingPowerConditionSlot.tsx new file mode 100644 index 0000000000..c2d9a934ff --- /dev/null +++ b/src/modules/settings/components/votingPowerConditionSlot/votingPowerConditionSlot.tsx @@ -0,0 +1,28 @@ +'use client'; + +import { DefinitionList } from '@aragon/gov-ui-kit'; +import type { IConditionData } from '@/modules/settings/types'; +import { useTranslations } from '@/shared/components/translationsProvider'; + +// TODO(APP-953): wire the real VotingPower condition data source. The spike +// confirmed no in-repo VotingPower payload exists yet, so this slot renders a +// placeholder until the data source lands. +export const VotingPowerConditionSlot: React.FC = (props) => { + const { conditionType } = props; + const { t } = useTranslations(); + + return ( + + + {conditionType} + + + {t('app.settings.votingPowerConditionSlot.pending')} + + + ); +}; diff --git a/src/modules/settings/initConditionRegistry.test.ts b/src/modules/settings/initConditionRegistry.test.ts new file mode 100644 index 0000000000..5bead2e759 --- /dev/null +++ b/src/modules/settings/initConditionRegistry.test.ts @@ -0,0 +1,45 @@ +import { SettingsSlotId } from '@/modules/settings/constants/moduleSlots'; +import { pluginRegistryUtils } from '@/shared/utils/pluginRegistryUtils'; +import { ExecuteSelectorConditionSlot } from './components/executeSelectorConditionSlot'; +import { NoConditionSlot } from './components/noConditionSlot'; +import { VotingPowerConditionSlot } from './components/votingPowerConditionSlot'; +import { initialiseConditionRegistry } from './initConditionRegistry'; + +describe('initialiseConditionRegistry', () => { + beforeEach(() => { + initialiseConditionRegistry(); + }); + + it.each([ + { pluginId: 'voting-power', component: VotingPowerConditionSlot }, + { + pluginId: 'execute-selector', + component: ExecuteSelectorConditionSlot, + }, + ])('resolves the $pluginId condition component from the slot', ({ + pluginId, + component, + }) => { + const resolved = pluginRegistryUtils.getSlotComponent({ + slotId: SettingsSlotId.PERMISSION_CONDITION, + pluginId, + }); + + expect(resolved).toBe(component); + }); + + it.each([ + { pluginId: 'none' }, + { pluginId: 'unknown' }, + ])('does not resolve a component for the unregistered $pluginId condition type', ({ + pluginId, + }) => { + const resolved = pluginRegistryUtils.getSlotComponent({ + slotId: SettingsSlotId.PERMISSION_CONDITION, + pluginId, + }); + + expect(resolved).toBeUndefined(); + expect(resolved).not.toBe(NoConditionSlot); + }); +}); diff --git a/src/modules/settings/initConditionRegistry.ts b/src/modules/settings/initConditionRegistry.ts new file mode 100644 index 0000000000..141c699ebc --- /dev/null +++ b/src/modules/settings/initConditionRegistry.ts @@ -0,0 +1,28 @@ +import { SettingsSlotId } from '@/modules/settings/constants/moduleSlots'; +import { pluginRegistryUtils } from '@/shared/utils/pluginRegistryUtils'; +import { ExecuteSelectorConditionSlot } from './components/executeSelectorConditionSlot'; +import { VotingPowerConditionSlot } from './components/votingPowerConditionSlot'; + +/** + * Registers the permission-condition slot components against the + * {@link SettingsSlotId.PERMISSION_CONDITION} slot. + * + * Condition registration is standalone: the synthetic `conditionType` + * discriminator (see {@link IConditionData}) is used as the `pluginId`, so each + * resolved condition payload renders its matching component without coupling to + * any real governance plugin. This module is the sole location for condition + * registrations. + */ +export const initialiseConditionRegistry = () => { + pluginRegistryUtils + .registerSlotComponent({ + slotId: SettingsSlotId.PERMISSION_CONDITION, + pluginId: 'voting-power', + component: VotingPowerConditionSlot, + }) + .registerSlotComponent({ + slotId: SettingsSlotId.PERMISSION_CONDITION, + pluginId: 'execute-selector', + component: ExecuteSelectorConditionSlot, + }); +}; From 633b6aea34ac0d06a00a82757b88fbc14786ed73 Mon Sep 17 00:00:00 2001 From: Kevin Davis Date: Tue, 30 Jun 2026 11:04:00 +0200 Subject: [PATCH 08/29] feat: Add permissions endpoint mock data for preview mode --- src/backendApiMocks.ts | 3 +- .../settings/constants/permissionsMocks.ts | 93 +++++++++++++++++++ 2 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 src/modules/settings/constants/permissionsMocks.ts diff --git a/src/backendApiMocks.ts b/src/backendApiMocks.ts index 1ce3b10fe0..b59494841b 100644 --- a/src/backendApiMocks.ts +++ b/src/backendApiMocks.ts @@ -1,3 +1,4 @@ +import { permissionsMocks } from './modules/settings/constants/permissionsMocks'; import type { IBackendApiMock } from './shared/types'; -export const backendApiMocks: IBackendApiMock[] = []; +export const backendApiMocks: IBackendApiMock[] = [...permissionsMocks]; diff --git a/src/modules/settings/constants/permissionsMocks.ts b/src/modules/settings/constants/permissionsMocks.ts new file mode 100644 index 0000000000..51d1816036 --- /dev/null +++ b/src/modules/settings/constants/permissionsMocks.ts @@ -0,0 +1,93 @@ +import type { IPaginatedResponse } from '@/shared/api/aragonBackendService'; +import type { IDaoPermission } from '@/shared/api/daoService'; +import type { IBackendApiMock } from '@/shared/types'; +import type { IPermissionRow } from '../types'; +import { ALLOW_FLAG, ANY_ADDR } from './permissionSentinels'; + +/** + * Real permission-id hashes (keccak256 of the permission strings) so the UI + * resolves them to human-readable names. + */ +const ROOT_PERMISSION_ID = + '0x815fe80e4b37c8582a3b773d1d7071f983eacfd56b5965db654f3087c25ada33'; +const EXECUTE_PERMISSION_ID = + '0xbf04b4486c9663d805744005c3da000eda93de6e3308a4a7a812eb565327b78d'; +const CREATE_PROPOSAL_PERMISSION_ID = + '0x8c433a4cd6b51969eca37f974940894297b9fcf4b282a213fea5cd8f85289c90'; +const MANAGE_SELECTORS_PERMISSION_ID = + '0x485a22b473de7ee3091c71c5ce05019fd1466a1650b1228784a9bcd5b7bed510'; + +const daoAddress = '0x1F2e3D4C5b6A70819283746556473829100AbCdE'; +const pluginAddress = '0xA1b2C3d4E5F60718293A4b5C6d7E8f9001234567'; +const tokenAddress = '0x0bA45A8b5d5575935B8158a88C631E9F9C95a2e5'; +const votingConditionAddress = '0xC0Ffee254729296a45a3885639AC7E10F9d54979'; +const selectorConditionAddress = '0xDe0B295669a9FD93d5F28D9Ec85E40f4cb697BAe'; +const unknownConditionAddress = '0xaB5801a7D398351b8bE11C439e05C5B3259aeC9B'; + +const permissions: Array = [ + { + // No condition: granted unconditionally to "Anyone". + permissionId: ROOT_PERMISSION_ID, + whoAddress: ANY_ADDR, + whereAddress: daoAddress, + conditionAddress: ALLOW_FLAG, + }, + { + // Voting-power gated condition. + permissionId: EXECUTE_PERMISSION_ID, + whoAddress: pluginAddress, + whereAddress: daoAddress, + conditionAddress: votingConditionAddress, + condition: { + conditionType: 'voting-power', + token: tokenAddress, + minVotingPower: '1000000000000000000', + }, + }, + { + // Execute-selector gated condition. + permissionId: MANAGE_SELECTORS_PERMISSION_ID, + whoAddress: pluginAddress, + whereAddress: daoAddress, + conditionAddress: selectorConditionAddress, + condition: { + conditionType: 'execute-selector', + selectors: ['0xa9059cbb'], + targets: [tokenAddress], + }, + }, + { + // Unknown / unrecognised condition type → resolves to Fallback. + permissionId: CREATE_PROPOSAL_PERMISSION_ID, + whoAddress: pluginAddress, + whereAddress: daoAddress, + conditionAddress: unknownConditionAddress, + condition: { + conditionType: 'mystery-condition', + }, + }, +]; + +const permissionsResponse: IPaginatedResponse = + { + metadata: { + page: 1, + pageSize: permissions.length, + totalPages: 1, + totalRecords: permissions.length, + }, + data: permissions, + }; + +/** + * Preview-mode mock for `GET /permissions/:network/:daoAddress`. Covers every + * conditionType scenario (no-condition, voting-power, execute-selector, + * unknown) so the permissions UI can be exercised without a live backend. + */ +export const permissionsMocks: IBackendApiMock[] = [ + { + url: /\/permissions\//, + type: 'replace', + data: permissionsResponse, + }, +]; From 2ae0bdc66585afbdbd00ce678c54fc0325daa65d Mon Sep 17 00:00:00 2001 From: Kevin Davis Date: Tue, 30 Jun 2026 11:18:59 +0200 Subject: [PATCH 09/29] feat: Add permissions list with linked-account tabs and condition routing --- src/assets/locales/en.json | 16 +- .../components/permissionsList/index.ts | 4 + .../permissionsList/permissionsList.test.tsx | 206 +++++++++++++ .../permissionsList/permissionsList.tsx | 279 ++++++++++++++++++ .../daoPermissionsPageClient.tsx | 14 +- 5 files changed, 505 insertions(+), 14 deletions(-) create mode 100644 src/modules/settings/components/permissionsList/index.ts create mode 100644 src/modules/settings/components/permissionsList/permissionsList.test.tsx create mode 100644 src/modules/settings/components/permissionsList/permissionsList.tsx diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index cd554cd920..c045311eeb 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -3503,10 +3503,20 @@ "view": { "list": "List", "graph": "Graph" + } + }, + "permissionsList": { + "header": { + "who": "Who", + "where": "Where", + "permission": "Permission", + "condition": "Condition" }, - "placeholder": { - "heading": "Permissions coming soon", - "description": "The permissions overview for this DAO will appear here." + "expandAll": "Expand all", + "collapseAll": "Collapse all", + "empty": { + "heading": "No permissions", + "description": "This account has no indexed permissions." } }, "noConditionSlot": { diff --git a/src/modules/settings/components/permissionsList/index.ts b/src/modules/settings/components/permissionsList/index.ts new file mode 100644 index 0000000000..f047f20338 --- /dev/null +++ b/src/modules/settings/components/permissionsList/index.ts @@ -0,0 +1,4 @@ +export { + type IPermissionsListProps, + PermissionsList, +} from './permissionsList'; diff --git a/src/modules/settings/components/permissionsList/permissionsList.test.tsx b/src/modules/settings/components/permissionsList/permissionsList.test.tsx new file mode 100644 index 0000000000..483c3c7051 --- /dev/null +++ b/src/modules/settings/components/permissionsList/permissionsList.test.tsx @@ -0,0 +1,206 @@ +import { GukModulesProvider } from '@aragon/gov-ui-kit'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import * as daoService from '@/shared/api/daoService'; +import { + type IDao, + type ILinkedAccountSummary, + Network, +} from '@/shared/api/daoService'; +import * as featureFlagsProvider from '@/shared/components/featureFlagsProvider'; +import * as useDaoPluginsModule from '@/shared/hooks/useDaoPlugins'; +import { + generateDao, + generateDaoMetrics, + generateReactQueryResultSuccess, +} from '@/shared/testUtils'; +import { ALLOW_FLAG, ANY_ADDR } from '../../constants/permissionSentinels'; +import type { IPermissionRow } from '../../types'; +import { PermissionsList } from './permissionsList'; + +const ROOT_PERMISSION_ID = + '0x815fe80e4b37c8582a3b773d1d7071f983eacfd56b5965db654f3087c25ada33'; +const EXECUTE_PERMISSION_ID = + '0xbf04b4486c9663d805744005c3da000eda93de6e3308a4a7a812eb565327b78d'; + +describe(' component', () => { + 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 = (linkedAccountEnabled: boolean) => { + useFeatureFlagsSpy.mockReturnValue({ + isEnabled: (key) => key === 'linkedAccount' && linkedAccountEnabled, + } as ReturnType); + }; + + const setDao = (dao?: Partial) => { + useDaoSpy.mockReturnValue( + generateReactQueryResultSuccess({ + data: generateDao(dao), + }) as ReturnType, + ); + }; + + const setPermissions = ( + result: Partial>, + ) => { + useAllDaoPermissionsSpy.mockReturnValue({ + data: [], + isLoading: false, + error: null, + refetch: jest.fn(), + ...result, + } as ReturnType); + }; + + beforeEach(() => { + setFeatureFlags(false); + setDao(); + setPermissions({ data: [], isLoading: false }); + useDaoPluginsSpy.mockReturnValue([]); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + const createTestComponent = (props?: { daoId?: string }) => ( + + + + ); + + it('renders a skeleton while the permissions are loading', () => { + setPermissions({ data: [], isLoading: true }); + + render(createTestComponent()); + + expect( + screen.getByTestId('permissions-list-skeleton'), + ).toBeInTheDocument(); + expect( + screen.queryByText(/permissionsList.empty.heading/), + ).not.toBeInTheDocument(); + }); + + it('renders the empty state when the account has no permissions', () => { + setPermissions({ data: [], isLoading: false }); + + render(createTestComponent()); + + expect( + screen.getByText(/permissionsList.empty.heading/), + ).toBeInTheDocument(); + expect( + screen.queryByTestId('permissions-list-skeleton'), + ).not.toBeInTheDocument(); + }); + + it('renders rows with resolved who, where and permission names', () => { + const rows: IPermissionRow[] = [ + { + permissionId: ROOT_PERMISSION_ID, + whoAddress: ANY_ADDR, + whereAddress: ALLOW_FLAG, + conditionAddress: ALLOW_FLAG, + }, + { + permissionId: EXECUTE_PERMISSION_ID, + whoAddress: ANY_ADDR, + whereAddress: ALLOW_FLAG, + conditionAddress: '0xC0Ffee254729296a45a3885639AC7E10F9d54979', + condition: { conditionType: 'voting-power' }, + }, + ]; + setPermissions({ data: rows, isLoading: false }); + + render(createTestComponent()); + + expect(screen.getByText('Root')).toBeInTheDocument(); + expect(screen.getByText('Execute')).toBeInTheDocument(); + expect(screen.getAllByText('Anyone').length).toBeGreaterThan(0); + expect(screen.getAllByText('Any Address').length).toBeGreaterThan(0); + expect( + screen.getByText(/permissionsList.header.condition/), + ).toBeInTheDocument(); + }); + + it('routes the condition cell to the fallback slot when expanded', async () => { + const user = userEvent.setup(); + const rows: IPermissionRow[] = [ + { + permissionId: ROOT_PERMISSION_ID, + whoAddress: ANY_ADDR, + whereAddress: ALLOW_FLAG, + conditionAddress: ALLOW_FLAG, + }, + ]; + setPermissions({ data: rows, isLoading: false }); + + render(createTestComponent()); + + await user.click( + screen.getByRole('button', { name: /permissionsList.expandAll/ }), + ); + + expect(screen.getByText(/noConditionSlot.heading/)).toBeInTheDocument(); + }); + + it('re-queries permissions with the selected linked account params on tab switch', async () => { + const user = userEvent.setup(); + const linkedAccount: ILinkedAccountSummary = { + 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: '', + }; + setFeatureFlags(true); + setDao({ + id: 'main-dao', + address: '0xMainAddress', + network: Network.ETHEREUM_MAINNET, + name: 'Main DAO', + linkedAccounts: [linkedAccount], + }); + + render(createTestComponent({ daoId: 'main-dao' })); + + expect(useAllDaoPermissionsSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ + urlParams: { + network: Network.ETHEREUM_MAINNET, + daoAddress: '0xMainAddress', + }, + }), + expect.anything(), + ); + + await user.click(screen.getByRole('tab', { name: 'Linked Treasury' })); + + expect(useAllDaoPermissionsSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ + urlParams: { + network: Network.POLYGON_MAINNET, + daoAddress: '0xLinkedAddress', + }, + }), + expect.anything(), + ); + }); +}); diff --git a/src/modules/settings/components/permissionsList/permissionsList.tsx b/src/modules/settings/components/permissionsList/permissionsList.tsx new file mode 100644 index 0000000000..031b9b9fc3 --- /dev/null +++ b/src/modules/settings/components/permissionsList/permissionsList.tsx @@ -0,0 +1,279 @@ +'use client'; + +import { + Accordion, + Button, + CardEmptyState, + StateSkeletonBar, + Tabs, +} from '@aragon/gov-ui-kit'; +import { useMemo, useState } from 'react'; +import { + type Network, + useAllDaoPermissions, + useDao, +} from '@/shared/api/daoService'; +import { useFeatureFlags } from '@/shared/components/featureFlagsProvider'; +import { PluginSingleComponent } from '@/shared/components/pluginSingleComponent'; +import { useTranslations } from '@/shared/components/translationsProvider'; +import { useDaoPlugins } from '@/shared/hooks/useDaoPlugins'; +import { permissionNameUtils } from '@/shared/utils/permissionNameUtils'; +import { SettingsSlotId } from '../../constants/moduleSlots'; +import type { IPermissionRow } from '../../types'; +import { conditionTypeUtils } from '../../utils/conditionTypeUtils'; +import { permissionEntityUtils } from '../../utils/permissionEntityUtils'; +import { NoConditionSlot } from '../noConditionSlot'; + +export interface IPermissionsListProps { + /** + * ID of the DAO to display permissions for. + */ + daoId: string; +} + +interface IPermissionsAccount { + id: string; + name: string; + network: Network; + daoAddress: string; +} + +const SKELETON_ROW_KEYS = [ + 'skeleton-1', + 'skeleton-2', + 'skeleton-3', + 'skeleton-4', +]; + +const getRowKey = (row: IPermissionRow): string => + `${row.permissionId}-${row.whoAddress}-${row.whereAddress}`; + +export const PermissionsList: React.FC = (props) => { + const { daoId } = props; + + const { t } = useTranslations(); + const { isEnabled } = useFeatureFlags(); + + const { data: dao } = useDao({ urlParams: { id: daoId } }); + const daoPlugins = useDaoPlugins({ + daoId, + includeLinkedAccounts: true, + }); + + const accounts = useMemo(() => { + if (dao == null) { + return []; + } + + const mainAccount: IPermissionsAccount = { + id: dao.id, + name: dao.name, + network: dao.network, + daoAddress: dao.address, + }; + + 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, + })), + ]; + }, [dao, isEnabled]); + + const [selectedAccountId, setSelectedAccountId] = useState(); + const activeAccountId = selectedAccountId ?? accounts[0]?.id; + const activeAccount = + accounts.find((account) => account.id === activeAccountId) ?? + accounts[0]; + + const { data, isLoading } = useAllDaoPermissions( + { + urlParams: { + network: activeAccount?.network as Network, + daoAddress: activeAccount?.daoAddress ?? '', + }, + }, + { enabled: activeAccount != null }, + ); + + // NOTE: the optional `condition` field is supplied by the preview mock until + // APP-953 formalizes it on the permissions response; cast at this boundary. + const rows = (data ?? []) as IPermissionRow[]; + + const [expandedRows, setExpandedRows] = useState([]); + const allExpanded = rows.length > 0 && expandedRows.length === rows.length; + + const handleToggleAll = () => { + setExpandedRows(allExpanded ? [] : rows.map(getRowKey)); + }; + + const renderBody = () => { + if (isLoading) { + return ; + } + + if (rows.length === 0) { + return ( + + ); + } + + return ( +
+
+ +
+ + setExpandedRows(value ?? [])} + value={expandedRows} + > + {rows.map((row) => ( + + ))} + +
+ ); + }; + + if (accounts.length <= 1) { + return renderBody(); + } + + return ( + + + {accounts.map((account) => ( + + ))} + + {accounts.map((account) => ( + + {renderBody()} + + ))} + + ); +}; + +type DaoPlugins = ReturnType; + +interface IPermissionsListRowProps { + row: IPermissionRow; + rowKey: string; + daoPlugins: DaoPlugins; +} + +const PermissionsListRow: React.FC = (props) => { + const { row, rowKey, daoPlugins } = props; + + const who = permissionEntityUtils.resolvePermissionEntity( + row.whoAddress, + daoPlugins, + ); + const where = permissionEntityUtils.resolvePermissionEntity( + row.whereAddress, + daoPlugins, + ); + const permissionName = permissionNameUtils.getPermissionName( + row.permissionId, + ); + const conditionType = conditionTypeUtils.resolveConditionType( + row.conditionAddress, + row.condition, + ); + + return ( + + +
+ + {who.label} + + + {where.label} + + + {permissionName} + +
+
+ + + +
+ ); +}; + +const PermissionsListHeader: React.FC = () => { + const { t } = useTranslations(); + + return ( +
+ {t('app.settings.permissionsList.header.who')} + {t('app.settings.permissionsList.header.where')} + {t('app.settings.permissionsList.header.permission')} + {t('app.settings.permissionsList.header.condition')} +
+ ); +}; + +const PermissionsListSkeleton: React.FC = () => ( +
+ + {SKELETON_ROW_KEYS.map((rowKey) => ( +
+ + + + +
+ ))} +
+); diff --git a/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClient.tsx b/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClient.tsx index a7c2dda814..1231927556 100644 --- a/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClient.tsx +++ b/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClient.tsx @@ -1,11 +1,12 @@ 'use client'; -import { CardEmptyState, Toggle, ToggleGroup } from '@aragon/gov-ui-kit'; +import { Toggle, ToggleGroup } from '@aragon/gov-ui-kit'; import { useState } from 'react'; import { useDao } from '@/shared/api/daoService'; import { Page } from '@/shared/components/page'; import { useTranslations } from '@/shared/components/translationsProvider'; import { daoUtils } from '@/shared/utils/daoUtils'; +import { PermissionsList } from '../../components/permissionsList'; export interface IDaoPermissionsPageClientProps { /** @@ -80,16 +81,7 @@ export const DaoPermissionsPageClient: React.FC< value="graph" /> - {/* Placeholder for the permissions list mounted in T09. */} - + {view === 'list' && } From 88476b216358cbe38f5d45f00a350270a3db83cc Mon Sep 17 00:00:00 2001 From: Kevin Davis Date: Tue, 30 Jun 2026 12:21:55 +0200 Subject: [PATCH 10/29] feat: Use full permission dictionary with raw permission names --- .../permissionNameUtils.test.ts | 29 +++- .../permissionNameUtils.ts | 150 +++++++++++++++--- 2 files changed, 150 insertions(+), 29 deletions(-) diff --git a/src/shared/utils/permissionNameUtils/permissionNameUtils.test.ts b/src/shared/utils/permissionNameUtils/permissionNameUtils.test.ts index 5261c789be..73f1347573 100644 --- a/src/shared/utils/permissionNameUtils/permissionNameUtils.test.ts +++ b/src/shared/utils/permissionNameUtils/permissionNameUtils.test.ts @@ -6,27 +6,42 @@ describe('permissionNameUtils', () => { { permissionId: '0x815fe80e4b37c8582a3b773d1d7071f983eacfd56b5965db654f3087c25ada33', - expected: 'Root', + expected: 'ROOT_PERMISSION', }, { permissionId: '0x8c433a4cd6b51969eca37f974940894297b9fcf4b282a213fea5cd8f85289c90', - expected: 'Create proposal', + expected: 'CREATE_PROPOSAL_PERMISSION', }, { permissionId: '0xbf04b4486c9663d805744005c3da000eda93de6e3308a4a7a812eb565327b78d', - expected: 'Execute', + expected: 'EXECUTE_PERMISSION', }, { permissionId: '0x821b6e3a557148015a918c89e5d092e878a69854a2d1a410635f771bd5a8a3f5', - expected: 'Upgrade plugin', + expected: 'UPGRADE_PLUGIN_PERMISSION', }, { permissionId: '0x485a22b473de7ee3091c71c5ce05019fd1466a1650b1228784a9bcd5b7bed510', - expected: 'Manage selectors', + expected: 'MANAGE_SELECTORS_PERMISSION', + }, + { + permissionId: + '0xb737b436e6cc542520cb79ec04245c720c38eebfa56d9e2d99b043979db20e4c', + expected: 'MINT_PERMISSION', + }, + { + permissionId: + '0x595f29b9b81abb2cfafd1caa277c849a6317ded4aa7672cd5e076bacaf78ba3e', + expected: 'PAUSE_PERMISSION', + }, + { + permissionId: + '0x9c81fc3cf68d43a5ff1c09ddb652dc0e85041298a2c6bb91eec4ba1dabf138bb', + expected: 'BURN_PERMISSION', }, ])('maps the known hash $permissionId to "$expected"', ({ permissionId, @@ -61,12 +76,12 @@ describe('permissionNameUtils', () => { { permissionId: '0x815FE80E4B37C8582A3B773D1D7071F983EACFD56B5965DB654F3087C25ADA33', - expected: 'Root', + expected: 'ROOT_PERMISSION', }, { permissionId: '0xBF04B4486C9663D805744005C3DA000EDA93DE6E3308A4A7A812EB565327B78D', - expected: 'Execute', + expected: 'EXECUTE_PERMISSION', }, ])('resolves known hashes case-insensitively for $permissionId', ({ permissionId, diff --git a/src/shared/utils/permissionNameUtils/permissionNameUtils.ts b/src/shared/utils/permissionNameUtils/permissionNameUtils.ts index 9b4a002193..c9e1fc0d32 100644 --- a/src/shared/utils/permissionNameUtils/permissionNameUtils.ts +++ b/src/shared/utils/permissionNameUtils/permissionNameUtils.ts @@ -3,43 +3,149 @@ import { keccak256, toBytes } from 'viem'; const truncatedHashPrefixLength = 6; const truncatedHashSuffixLength = 4; -const permissionFriendlyNames: Record = { - ROOT_PERMISSION: 'Root', - CREATE_PROPOSAL_PERMISSION: 'Create proposal', - EXECUTE_PERMISSION: 'Execute', - UPGRADE_PLUGIN_PERMISSION: 'Upgrade plugin', - MANAGE_SELECTORS_PERMISSION: 'Manage selectors', -}; +/** + * Canonical list of Aragon OSx, plugin, and AccessControl permission names. + * + * Mirrors the ticket's `permissions-dictionary.ts` seed (APP-954). Each entry is + * hashed with `keccak256(toBytes(name))` to build the hash → name lookup used by + * the permissions page. + */ +const permissionNames: string[] = [ + // OSx DAO core + 'ROOT_PERMISSION', + 'EXECUTE_PERMISSION', + 'UPGRADE_DAO_PERMISSION', + 'SET_METADATA_PERMISSION', + 'SET_TRUSTED_FORWARDER_PERMISSION', + 'SET_SIGNATURE_VALIDATOR_PERMISSION', + 'VALIDATE_SIGNATURE_PERMISSION', + 'REGISTER_STANDARD_CALLBACK_PERMISSION', + + // OSx plugin framework + 'UPGRADE_PLUGIN_PERMISSION', + 'MAINTAINER_PERMISSION', + 'UPGRADE_REPO_PERMISSION', + 'APPLY_INSTALLATION_PERMISSION', + 'APPLY_UPDATE_PERMISSION', + 'APPLY_UNINSTALLATION_PERMISSION', + 'REGISTER_DAO_PERMISSION', + 'REGISTER_ENS_SUBDOMAIN_PERMISSION', + 'REGISTER_PLUGIN_REPO_PERMISSION', + 'UPGRADE_REGISTRY_PERMISSION', + 'UPGRADE_REGISTRAR_PERMISSION', + + // Proposal lifecycle + 'CREATE_PROPOSAL_PERMISSION', + 'EXECUTE_PROPOSAL_PERMISSION', + 'PROPOSER_PERMISSION', + 'CANCEL_PERMISSION', + 'ADVANCE_PERMISSION', + 'EDIT_PERMISSION', + + // Multisig + 'UPDATE_MULTISIG_SETTINGS_PERMISSION', + 'SET_SECONDARY_METADATA_PERMISSION', + + // Token voting / majority voting + 'UPDATE_VOTING_SETTINGS_PERMISSION', + 'UPDATE_ADDRESSES_PERMISSION', + 'UPDATE_PROPOSAL_VALIDATOR_PERMISSION', + 'UPDATE_RULES_PERMISSION', + 'UPDATE_SETTINGS_PERMISSION', + + // Lock-to-vote + 'LOCK_MANAGER_PERMISSION', + + // Membership / Geo + 'EDITOR_PERMISSION', + 'MEMBER_PERMISSION', + + // Optimistic governance / L2 veto / multichain voting + 'UPDATE_OPTIMISTIC_GOVERNANCE_SETTINGS_PERMISSION', + 'UPDATE_L2_VETO_RECEIVER_PERMISSION', + 'SET_TRUSTED_L2_AGGREGATOR_PERMISSION', + 'UPDATE_BRIDGE_SETTINGS_PERMISSION', + + // Staged proposal processor (SPP) + 'UPDATE_STAGES_PERMISSION', + + // Signer list (delegation/attestation plugins) + 'UPDATE_SIGNER_LIST_PERMISSION', + 'UPDATE_SIGNER_LIST_SETTINGS_PERMISSION', + + // Target / execution routing + 'SET_TARGET_CONFIG_PERMISSION', + 'AUTHORIZED_FUNC_PERMISSION', + 'MANAGE_SELECTORS_PERMISSION', + 'DISPATCH_PERMISSION', + 'ID_GATED_ACTION_PERMISSION', + + // Capital distribution / Merkle campaigns + 'CAMPAIGN_MANAGER_PERMISSION', + 'CHANGE_DISTRIBUTOR_PERMISSION', + 'MERKLE_MINT_PERMISSION', + + // ERC-20 / token + 'MINT_PERMISSION', + 'BURN_PERMISSION', + + // Misc + 'MANAGER_PERMISSION', + 'REGISTER_PERMISSION', + 'PREPARE_PERMISSION', + 'STORE_PERMISSION', + 'STORE_ACCOUNT_PERMISSION', + 'STORE_NUMBER_PERMISSION', + 'PAUSE_PERMISSION', + 'GRANT_PERMISSION', + 'GREET_PERMISSION', + 'MULTIPLY_PERMISSION', + 'DO_SOMETHING_PERMISSION', + 'ADMIN_EXECUTE_PERMISSION', + 'MOCK_PERMISSION', + 'TEST_PERMISSION', + 'TEST_PERMISSION_1', + 'TEST_PERMISSION_2', + + // OpenZeppelin AccessControl roles used by Aragon plugins + 'SWEEPER_ROLE', + // Legacy aragonOS ACL roles + 'CREATE_PERMISSIONS_ROLE', +]; class PermissionNameUtils { - // TODO(APP-954): replace/extend with the ticket-attached seed permission dictionary once the - // Linear attachment is merged. Until then we only resolve the generic protocol permission ids. private permissionNamesByHash: Record = Object.fromEntries( - Object.entries(permissionFriendlyNames).map( - ([permissionId, friendlyName]) => [ - keccak256(toBytes(permissionId)).toLowerCase(), - friendlyName, - ], - ), + permissionNames.map((name) => [ + keccak256(toBytes(name)).toLowerCase(), + name, + ]), ); /** - * Resolves a keccak256 permission-id hash to its friendly, human-readable name. + * Resolves a keccak256 permission-id hash to its raw permission name string. * - * @param permissionId The keccak256 hash of the permission identifier. - * @returns The mapped friendly name, or a truncated `0x1234…abcd` form of the hash when unknown. + * @param permissionId The keccak256 hash of the permission identifier. Matched + * case-insensitively and tolerant of a missing `0x` prefix. + * @returns The raw UPPER_SNAKE permission name (e.g. `ROOT_PERMISSION`), or a + * truncated `0x1234…abcd` form of the hash when unknown. */ getPermissionName = (permissionId: string): string => { - const normalisedId = permissionId.toLowerCase(); - const friendlyName = this.permissionNamesByHash[normalisedId]; + const normalisedId = this.normaliseHash(permissionId); + const name = this.permissionNamesByHash[normalisedId]; - if (friendlyName != null) { - return friendlyName; + if (name != null) { + return name; } return this.truncateHash(permissionId); }; + private normaliseHash = (hash: string): string => { + const lowerCased = hash.toLowerCase(); + + return lowerCased.startsWith('0x') ? lowerCased : `0x${lowerCased}`; + }; + private truncateHash = (hash: string): string => { const prefix = hash.slice(0, truncatedHashPrefixLength); const suffix = hash.slice(-truncatedHashSuffixLength); From d67435649e42fe50e49c6efe78f8277979ffe874 Mon Sep 17 00:00:00 2001 From: Kevin Davis Date: Tue, 30 Jun 2026 12:21:57 +0200 Subject: [PATCH 11/29] feat: Redesign permission rows with details and condition definition lists --- src/assets/locales/en.json | 25 ++- .../executeSelectorConditionSlot.test.tsx | 30 ++-- .../executeSelectorConditionSlot.tsx | 79 +++++---- .../noConditionSlot/noConditionSlot.tsx | 2 + .../permissionsList/permissionsList.test.tsx | 65 +++++++- .../permissionsList/permissionsList.tsx | 153 ++++++++++++++++-- .../votingPowerConditionSlot.test.tsx | 28 +++- .../votingPowerConditionSlot.tsx | 37 +++-- .../daoPermissionsPageClient.tsx | 1 - .../conditionTypeUtils.test.ts | 40 +++++ .../conditionTypeUtils/conditionTypeUtils.ts | 51 ++++++ 11 files changed, 433 insertions(+), 78 deletions(-) diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index c045311eeb..1238b247a1 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -3498,7 +3498,7 @@ "permissions": "Permissions" }, "title": "Permissions", - "description": "Review who can do what across this DAO and its linked accounts" + "description": "All permissions configured in this DAO — who has access, to which contract, and under what conditions." }, "view": { "list": "List", @@ -3512,6 +3512,17 @@ "permission": "Permission", "condition": "Condition" }, + "details": { + "heading": "Details", + "who": "Who", + "where": "Where", + "permission": "Permission", + "condition": "Condition", + "noCondition": "No condition" + }, + "condition": { + "heading": "Condition detail" + }, "expandAll": "Expand all", "collapseAll": "Collapse all", "empty": { @@ -3521,15 +3532,14 @@ }, "noConditionSlot": { "heading": "No condition", - "description": "Functions guarded by this permission can be called by the granted address unconditionally." + "description": "Functions guarded by this permission can be called by the granted address directly." }, "votingPowerConditionSlot": { - "type": "Condition", - "details": "Details", - "pending": "Condition details pending (APP-953)" + "token": "Token", + "minVotingPower": "Minimum voting power" }, "executeSelectorConditionSlot": { - "target": "Target", + "description": "The ExecuteSelectorCondition authorizes the caller to execute actions only on explicitly authorized contract functions.", "allowedActions": "Allowed actions", "noActions": "No allowed actions" }, @@ -3600,6 +3610,9 @@ "daoAction": "Linked account", "governanceAction": "Process", "governanceInfoTitle": "Governance", + "permissionsAction": "View permissions", + "permissionsDescription": "Review who can do what across this DAO and its linked accounts, and any conditions on each grant.", + "permissionsInfoTitle": "Permissions", "settingsInfoTitle": "Account", "title": "Settings" } diff --git a/src/modules/settings/components/executeSelectorConditionSlot/executeSelectorConditionSlot.test.tsx b/src/modules/settings/components/executeSelectorConditionSlot/executeSelectorConditionSlot.test.tsx index 094cdc5ddf..5f018eb8d2 100644 --- a/src/modules/settings/components/executeSelectorConditionSlot/executeSelectorConditionSlot.test.tsx +++ b/src/modules/settings/components/executeSelectorConditionSlot/executeSelectorConditionSlot.test.tsx @@ -17,17 +17,24 @@ describe(' component', () => { ); }; - it('renders the target and the list of allowed selectors from the payload', () => { + it('renders the description and the selectors mapped to their truncated targets', () => { render( createTestComponent({ - target: '0xTarget', - selectors: ['0xaaaaaaaa', '0xbbbbbbbb'], + selectors: ['0xa9059cbb', '0x23b872dd'], + targets: [ + '0x0bA45A8b5d5575935B8158a88C631E9F9C95a2e5', + '0xDe0B295669a9FD93d5F28D9Ec85E40f4cb697BAe', + ], }), ); - expect(screen.getByText('0xTarget')).toBeInTheDocument(); - expect(screen.getByText('0xaaaaaaaa')).toBeInTheDocument(); - expect(screen.getByText('0xbbbbbbbb')).toBeInTheDocument(); + expect( + screen.getByText(/executeSelectorConditionSlot.description/), + ).toBeInTheDocument(); + expect(screen.getByText('0xa9059cbb')).toBeInTheDocument(); + expect(screen.getByText('0x0bA4…a2e5')).toBeInTheDocument(); + expect(screen.getByText('0x23b872dd')).toBeInTheDocument(); + expect(screen.getByText('0xDe0B…7BAe')).toBeInTheDocument(); }); it('shows the no allowed actions fallback when selectors are absent', () => { @@ -40,9 +47,7 @@ describe(' component', () => { it('ignores non-string selector entries when narrowing the payload', () => { render( - createTestComponent({ - selectors: ['0xaaaaaaaa', 42, null, ''], - }), + createTestComponent({ selectors: ['0xaaaaaaaa', 42, null, ''] }), ); expect(screen.getByText('0xaaaaaaaa')).toBeInTheDocument(); @@ -50,4 +55,11 @@ describe(' component', () => { screen.queryByText(/executeSelectorConditionSlot.noActions/), ).not.toBeInTheDocument(); }); + + it('renders a placeholder target when no matching target is provided', () => { + render(createTestComponent({ selectors: ['0xaaaaaaaa'], targets: [] })); + + expect(screen.getByText('0xaaaaaaaa')).toBeInTheDocument(); + expect(screen.getByText('—')).toBeInTheDocument(); + }); }); diff --git a/src/modules/settings/components/executeSelectorConditionSlot/executeSelectorConditionSlot.tsx b/src/modules/settings/components/executeSelectorConditionSlot/executeSelectorConditionSlot.tsx index a0c1fa7acd..a0d65e6577 100644 --- a/src/modules/settings/components/executeSelectorConditionSlot/executeSelectorConditionSlot.tsx +++ b/src/modules/settings/components/executeSelectorConditionSlot/executeSelectorConditionSlot.tsx @@ -1,54 +1,67 @@ 'use client'; -import { DefinitionList } from '@aragon/gov-ui-kit'; +import { addressUtils, DefinitionList } from '@aragon/gov-ui-kit'; import type { IConditionData } from '@/modules/settings/types'; import { useTranslations } from '@/shared/components/translationsProvider'; +const EMPTY_VALUE = '—'; + +interface IAllowedAction { + selector: string; + target: string; +} + const isNonEmptyString = (value: unknown): value is string => typeof value === 'string' && value.length > 0; -const toSelectorList = (value: unknown): string[] => { - if (!Array.isArray(value)) { - return []; - } +const toStringList = (value: unknown): string[] => + Array.isArray(value) ? value.filter(isNonEmptyString) : []; + +const toAllowedActions = ( + selectors: unknown, + targets: unknown, +): IAllowedAction[] => { + const selectorList = toStringList(selectors); + const targetList = toStringList(targets); - return value.filter(isNonEmptyString); + return selectorList.map((selector, index) => ({ + selector, + target: targetList[index] ?? EMPTY_VALUE, + })); }; export const ExecuteSelectorConditionSlot: React.FC = ( props, ) => { - const { target, selectors } = props; + const { selectors, targets } = props; const { t } = useTranslations(); - const targetLabel = isNonEmptyString(target) ? target : undefined; - const selectorList = toSelectorList(selectors); - const hasAllowedActions = selectorList.length > 0; + const allowedActions = toAllowedActions(selectors, targets); + const hasAllowedActions = allowedActions.length > 0; return ( - - {targetLabel != null && ( - - {targetLabel} - +
+

+ {t('app.settings.executeSelectorConditionSlot.description')} +

+ {hasAllowedActions ? ( + + {allowedActions.map((action) => ( + + {action.target === EMPTY_VALUE + ? EMPTY_VALUE + : addressUtils.truncateAddress(action.target)} + + ))} + + ) : ( +

+ {t('app.settings.executeSelectorConditionSlot.noActions')} +

)} - - {hasAllowedActions ? ( -
    - {selectorList.map((selector) => ( -
  • {selector}
  • - ))} -
- ) : ( - t('app.settings.executeSelectorConditionSlot.noActions') - )} -
- +
); }; diff --git a/src/modules/settings/components/noConditionSlot/noConditionSlot.tsx b/src/modules/settings/components/noConditionSlot/noConditionSlot.tsx index 19cadd0889..5890410b76 100644 --- a/src/modules/settings/components/noConditionSlot/noConditionSlot.tsx +++ b/src/modules/settings/components/noConditionSlot/noConditionSlot.tsx @@ -3,6 +3,8 @@ import { CardEmptyState } from '@aragon/gov-ui-kit'; import { useTranslations } from '@/shared/components/translationsProvider'; +// Rendered as the PERMISSION_CONDITION fallback, so it must tolerate (and +// ignore) any condition payload props forwarded by the slot. export const NoConditionSlot: React.FC = () => { const { t } = useTranslations(); diff --git a/src/modules/settings/components/permissionsList/permissionsList.test.tsx b/src/modules/settings/components/permissionsList/permissionsList.test.tsx index 483c3c7051..bdc1bb6682 100644 --- a/src/modules/settings/components/permissionsList/permissionsList.test.tsx +++ b/src/modules/settings/components/permissionsList/permissionsList.test.tsx @@ -15,6 +15,7 @@ import { generateReactQueryResultSuccess, } from '@/shared/testUtils'; import { ALLOW_FLAG, ANY_ADDR } from '../../constants/permissionSentinels'; +import { initialiseConditionRegistry } from '../../initConditionRegistry'; import type { IPermissionRow } from '../../types'; import { PermissionsList } from './permissionsList'; @@ -124,8 +125,8 @@ describe(' component', () => { render(createTestComponent()); - expect(screen.getByText('Root')).toBeInTheDocument(); - expect(screen.getByText('Execute')).toBeInTheDocument(); + expect(screen.getByText('ROOT_PERMISSION')).toBeInTheDocument(); + expect(screen.getByText('EXECUTE_PERMISSION')).toBeInTheDocument(); expect(screen.getAllByText('Anyone').length).toBeGreaterThan(0); expect(screen.getAllByText('Any Address').length).toBeGreaterThan(0); expect( @@ -133,6 +134,66 @@ describe(' component', () => { ).toBeInTheDocument(); }); + it('renders the collapsed CONDITION cell with the resolved label or a dash', () => { + const rows: IPermissionRow[] = [ + { + permissionId: ROOT_PERMISSION_ID, + whoAddress: ANY_ADDR, + whereAddress: ALLOW_FLAG, + conditionAddress: ALLOW_FLAG, + }, + { + permissionId: EXECUTE_PERMISSION_ID, + whoAddress: ANY_ADDR, + whereAddress: ALLOW_FLAG, + conditionAddress: '0xC0Ffee254729296a45a3885639AC7E10F9d54979', + condition: { conditionType: 'voting-power' }, + }, + ]; + setPermissions({ data: rows, isLoading: false }); + + render(createTestComponent()); + + expect(screen.getByText('VotingPower')).toBeInTheDocument(); + expect(screen.getByText('-')).toBeInTheDocument(); + }); + + it('renders both the Details and Condition lists when a row is expanded', async () => { + initialiseConditionRegistry(); + const user = userEvent.setup(); + const rows: IPermissionRow[] = [ + { + permissionId: EXECUTE_PERMISSION_ID, + whoAddress: ANY_ADDR, + whereAddress: ALLOW_FLAG, + conditionAddress: '0xC0Ffee254729296a45a3885639AC7E10F9d54979', + condition: { + conditionType: 'voting-power', + token: '0x0bA45A8b5d5575935B8158a88C631E9F9C95a2e5', + minVotingPower: '1000000000000000000', + }, + }, + ]; + setPermissions({ data: rows, isLoading: false }); + + render(createTestComponent()); + + await user.click( + screen.getByRole('button', { name: /permissionsList.expandAll/ }), + ); + + expect( + screen.getByText(/permissionsList.details.heading/), + ).toBeInTheDocument(); + expect( + screen.getByText(/permissionsList.condition.heading/), + ).toBeInTheDocument(); + expect( + screen.getByText(/votingPowerConditionSlot.token/), + ).toBeInTheDocument(); + expect(screen.getByText('1000000000000000000')).toBeInTheDocument(); + }); + it('routes the condition cell to the fallback slot when expanded', async () => { const user = userEvent.setup(); const rows: IPermissionRow[] = [ diff --git a/src/modules/settings/components/permissionsList/permissionsList.tsx b/src/modules/settings/components/permissionsList/permissionsList.tsx index 031b9b9fc3..da6693d82c 100644 --- a/src/modules/settings/components/permissionsList/permissionsList.tsx +++ b/src/modules/settings/components/permissionsList/permissionsList.tsx @@ -2,10 +2,13 @@ import { Accordion, + addressUtils, Button, CardEmptyState, + DefinitionList, StateSkeletonBar, Tabs, + Tag, } from '@aragon/gov-ui-kit'; import { useMemo, useState } from 'react'; import { @@ -19,9 +22,13 @@ import { useTranslations } from '@/shared/components/translationsProvider'; import { useDaoPlugins } from '@/shared/hooks/useDaoPlugins'; import { permissionNameUtils } from '@/shared/utils/permissionNameUtils'; import { SettingsSlotId } from '../../constants/moduleSlots'; +import { ALLOW_FLAG } from '../../constants/permissionSentinels'; import type { IPermissionRow } from '../../types'; import { conditionTypeUtils } from '../../utils/conditionTypeUtils'; -import { permissionEntityUtils } from '../../utils/permissionEntityUtils'; +import { + type IPermissionEntity, + permissionEntityUtils, +} from '../../utils/permissionEntityUtils'; import { NoConditionSlot } from '../noConditionSlot'; export interface IPermissionsListProps { @@ -199,9 +206,49 @@ interface IPermissionsListRowProps { daoPlugins: DaoPlugins; } +interface IPermissionEntityCellProps { + entity: IPermissionEntity; +} + +const PermissionEntityCell: React.FC = ({ + entity, +}) => ( + + {entity.isSentinel && ( + +); + +interface IPermissionDetailValueProps { + primary: string; + secondary?: string; +} + +const PermissionDetailValue: React.FC = ({ + primary, + secondary, +}) => ( +
+ {primary} + {secondary != null && ( + + {secondary} + + )} +
+); + const PermissionsListRow: React.FC = (props) => { const { row, rowKey, daoPlugins } = props; + const { t } = useTranslations(); + const who = permissionEntityUtils.resolvePermissionEntity( row.whoAddress, daoPlugins, @@ -217,29 +264,107 @@ const PermissionsListRow: React.FC = (props) => { row.conditionAddress, row.condition, ); + const conditionLabel = conditionTypeUtils.getConditionLabel(conditionType); + + const hasCondition = !addressUtils.isAddressEqual( + row.conditionAddress, + ALLOW_FLAG, + ); + const conditionDetail = hasCondition + ? addressUtils.truncateAddress(row.conditionAddress) + : t('app.settings.permissionsList.details.noCondition'); return ( -
- - {who.label} - - - {where.label} +
+ + + + {permissionName} - {permissionName} + {conditionLabel}
- +
+
+

+ {t('app.settings.permissionsList.details.heading')} +

+ + + + + + + + + + + + + + +
+
+

+ {t( + 'app.settings.permissionsList.condition.heading', + )} +

+ +
+
); diff --git a/src/modules/settings/components/votingPowerConditionSlot/votingPowerConditionSlot.test.tsx b/src/modules/settings/components/votingPowerConditionSlot/votingPowerConditionSlot.test.tsx index 788af250cc..548ff72583 100644 --- a/src/modules/settings/components/votingPowerConditionSlot/votingPowerConditionSlot.test.tsx +++ b/src/modules/settings/components/votingPowerConditionSlot/votingPowerConditionSlot.test.tsx @@ -17,12 +17,32 @@ describe(' component', () => { ); }; - it('renders the condition type and the pending placeholder copy', () => { - render(createTestComponent({ conditionType: 'voting-power' })); + it('renders the truncated token address and the minimum voting power', () => { + render( + createTestComponent({ + token: '0x0bA45A8b5d5575935B8158a88C631E9F9C95a2e5', + minVotingPower: '1000000000000000000', + }), + ); - expect(screen.getByText('voting-power')).toBeInTheDocument(); expect( - screen.getByText(/votingPowerConditionSlot.pending/), + screen.getByText(/votingPowerConditionSlot.token/), + ).toBeInTheDocument(); + expect(screen.getByText('0x0bA4…a2e5')).toBeInTheDocument(); + expect( + screen.getByText(/votingPowerConditionSlot.minVotingPower/), ).toBeInTheDocument(); + expect(screen.getByText('1000000000000000000')).toBeInTheDocument(); + }); + + it('falls back to a placeholder when payload fields are absent', () => { + render( + createTestComponent({ + token: undefined, + minVotingPower: undefined, + }), + ); + + expect(screen.getAllByText('—')).toHaveLength(2); }); }); diff --git a/src/modules/settings/components/votingPowerConditionSlot/votingPowerConditionSlot.tsx b/src/modules/settings/components/votingPowerConditionSlot/votingPowerConditionSlot.tsx index c2d9a934ff..4645b3ca0d 100644 --- a/src/modules/settings/components/votingPowerConditionSlot/votingPowerConditionSlot.tsx +++ b/src/modules/settings/components/votingPowerConditionSlot/votingPowerConditionSlot.tsx @@ -1,27 +1,46 @@ 'use client'; -import { DefinitionList } from '@aragon/gov-ui-kit'; +import { addressUtils, DefinitionList } from '@aragon/gov-ui-kit'; import type { IConditionData } from '@/modules/settings/types'; import { useTranslations } from '@/shared/components/translationsProvider'; -// TODO(APP-953): wire the real VotingPower condition data source. The spike -// confirmed no in-repo VotingPower payload exists yet, so this slot renders a -// placeholder until the data source lands. +const EMPTY_VALUE = '—'; + +const isNonEmptyString = (value: unknown): value is string => + typeof value === 'string' && value.length > 0; + +const resolveMinVotingPower = (value: unknown): string => { + if (isNonEmptyString(value)) { + return value; + } + + if (typeof value === 'number' || typeof value === 'bigint') { + return value.toString(); + } + + return EMPTY_VALUE; +}; + export const VotingPowerConditionSlot: React.FC = (props) => { - const { conditionType } = props; + const { token, minVotingPower } = props; const { t } = useTranslations(); + const tokenLabel = isNonEmptyString(token) + ? addressUtils.truncateAddress(token) + : EMPTY_VALUE; + const minVotingPowerLabel = resolveMinVotingPower(minVotingPower); + return ( - {conditionType} + {tokenLabel} - {t('app.settings.votingPowerConditionSlot.pending')} + {minVotingPowerLabel} ); diff --git a/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClient.tsx b/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClient.tsx index 1231927556..a45ababad5 100644 --- a/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClient.tsx +++ b/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClient.tsx @@ -35,7 +35,6 @@ export const DaoPermissionsPageClient: React.FC< } }; - // TODO(APP-954): confirm copy against Figma const pageBreadcrumbs = [ { href: daoUtils.getDaoUrl(dao, 'settings'), diff --git a/src/modules/settings/utils/conditionTypeUtils/conditionTypeUtils.test.ts b/src/modules/settings/utils/conditionTypeUtils/conditionTypeUtils.test.ts index 9e3a2fa0cf..44d12f0fb3 100644 --- a/src/modules/settings/utils/conditionTypeUtils/conditionTypeUtils.test.ts +++ b/src/modules/settings/utils/conditionTypeUtils/conditionTypeUtils.test.ts @@ -42,4 +42,44 @@ describe('conditionType Utils', () => { ).toBe(expected); }); }); + + describe('getConditionLabel', () => { + it.each([ + { + description: 'maps voting-power to VotingPower', + conditionType: 'voting-power', + expected: 'VotingPower', + }, + { + description: 'maps execute-selector to ExecuteSelector', + conditionType: 'execute-selector', + expected: 'ExecuteSelector', + }, + { + description: 'maps "none" to the empty placeholder', + conditionType: 'none', + expected: '-', + }, + { + description: 'maps "unknown" to the empty placeholder', + conditionType: 'unknown', + expected: '-', + }, + { + description: 'maps an empty string to the empty placeholder', + conditionType: '', + expected: '-', + }, + { + description: + 'Pascal-cases an unrecognised but present condition type', + conditionType: 'merkle-claim', + expected: 'MerkleClaim', + }, + ])('$description', ({ conditionType, expected }) => { + expect(conditionTypeUtils.getConditionLabel(conditionType)).toBe( + expected, + ); + }); + }); }); diff --git a/src/modules/settings/utils/conditionTypeUtils/conditionTypeUtils.ts b/src/modules/settings/utils/conditionTypeUtils/conditionTypeUtils.ts index 823539ebd1..8dc5e87f6b 100644 --- a/src/modules/settings/utils/conditionTypeUtils/conditionTypeUtils.ts +++ b/src/modules/settings/utils/conditionTypeUtils/conditionTypeUtils.ts @@ -13,6 +13,23 @@ const NO_CONDITION = 'none'; */ const UNKNOWN_CONDITION = 'unknown'; +/** + * Placeholder rendered for conditions that have no human-readable label + * (unconditional grants and unresolvable condition types). + */ +const NO_LABEL = '-'; + +/** + * Explicit display labels for the known condition types. Any other non-empty + * type falls back to a Pascal-cased rendering of its discriminator. + */ +const CONDITION_LABELS: Record = { + 'voting-power': 'VotingPower', + 'execute-selector': 'ExecuteSelector', +}; + +const WORD_SEPARATOR_REGEX = /[^a-z0-9]+/i; + class ConditionTypeUtils { /** * Resolves the display condition type for a permission. @@ -44,6 +61,40 @@ class ConditionTypeUtils { return UNKNOWN_CONDITION; }; + + /** + * Resolves a human-readable label for a condition type, used by the + * collapsed permission row's CONDITION cell. + * + * - `'none'` / `'unknown'` -> {@link NO_LABEL} (`'-'`). + * - a known type -> its explicit label (e.g. `'voting-power'` -> + * `'VotingPower'`). + * - any other non-empty type -> a Pascal-cased fallback (e.g. + * `'merkle-claim'` -> `'MerkleClaim'`). + * + * @param conditionType The resolved condition type discriminator. + * @returns The display label for the condition type. + */ + getConditionLabel = (conditionType: string): string => { + if ( + conditionType === NO_CONDITION || + conditionType === UNKNOWN_CONDITION || + conditionType.length === 0 + ) { + return NO_LABEL; + } + + return ( + CONDITION_LABELS[conditionType] ?? this.toPascalCase(conditionType) + ); + }; + + private toPascalCase = (value: string): string => + value + .split(WORD_SEPARATOR_REGEX) + .filter((word) => word.length > 0) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(''); } export const conditionTypeUtils = new ConditionTypeUtils(); From 06e0cdd9520214ddb30229d283158cdd7ab12927 Mon Sep 17 00:00:00 2001 From: Kevin Davis Date: Tue, 30 Jun 2026 12:21:58 +0200 Subject: [PATCH 12/29] feat: Link to permissions page from DAO settings --- .../daoSettingsPage/daoSettingsPageClient.tsx | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/src/modules/settings/pages/daoSettingsPage/daoSettingsPageClient.tsx b/src/modules/settings/pages/daoSettingsPage/daoSettingsPageClient.tsx index afb9a0d895..9b39c26770 100644 --- a/src/modules/settings/pages/daoSettingsPage/daoSettingsPageClient.tsx +++ b/src/modules/settings/pages/daoSettingsPage/daoSettingsPageClient.tsx @@ -1,6 +1,6 @@ 'use client'; -import { IconType } from '@aragon/gov-ui-kit'; +import { Button, IconType } from '@aragon/gov-ui-kit'; import { useRouter } from 'next/navigation'; import { GovernanceDialogId } from '@/modules/governance/constants/governanceDialogId'; import { GovernanceSlotId } from '@/modules/governance/constants/moduleSlots'; @@ -210,6 +210,35 @@ export const DaoSettingsPageClient: React.FC = ( )} + +
+

+ {t( + 'app.settings.daoSettingsPage.main.permissionsDescription', + )} +

+ +
+
{hasSupportedPlugins && ( Date: Tue, 30 Jun 2026 12:38:20 +0200 Subject: [PATCH 13/29] fix: Show plugin type tag for permission who and where entities --- .../permissionEntityUtils.test.ts | 5 +++-- .../permissionEntityUtils.ts | 16 +++++++++++----- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/modules/settings/utils/permissionEntityUtils/permissionEntityUtils.test.ts b/src/modules/settings/utils/permissionEntityUtils/permissionEntityUtils.test.ts index 22b89d602d..8d10b661e5 100644 --- a/src/modules/settings/utils/permissionEntityUtils/permissionEntityUtils.test.ts +++ b/src/modules/settings/utils/permissionEntityUtils/permissionEntityUtils.test.ts @@ -20,6 +20,7 @@ describe('permissionEntity Utils', () => { meta: { address: pluginAddress, name: 'Multisig', + interfaceType: 'multisig', } as IDaoPlugin, props: {}, }, @@ -46,12 +47,12 @@ describe('permissionEntity Utils', () => { }, }, { - description: 'resolves a matching plugin address to its tag', + description: 'resolves a matching plugin to name + type tag', address: pluginAddress, expected: { label: 'Multisig', isSentinel: false, - tag: 'Multisig', + tag: 'MULTISIG', }, }, { diff --git a/src/modules/settings/utils/permissionEntityUtils/permissionEntityUtils.ts b/src/modules/settings/utils/permissionEntityUtils/permissionEntityUtils.ts index 6de42f62db..463bed49f8 100644 --- a/src/modules/settings/utils/permissionEntityUtils/permissionEntityUtils.ts +++ b/src/modules/settings/utils/permissionEntityUtils/permissionEntityUtils.ts @@ -15,7 +15,8 @@ export interface IPermissionEntity { label: string; /** * Short type tag for the entity when it can be classified (e.g. the plugin - * name). Undefined for plain addresses and sentinels. + * type such as `MULTISIG` / `SPP`). Undefined for plain addresses and + * sentinels. */ tag?: string; /** @@ -38,7 +39,8 @@ 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 (as label and tag). + * 3. A matching installed DAO plugin -> the plugin name (label) plus its + * interface type as an uppercase tag (e.g. `MULTISIG`). * 4. Otherwise -> the checksummed, truncated address. * * @param address - The `who` or `where` address to resolve. @@ -63,11 +65,15 @@ class PermissionEntityUtils { ); if (matchedPlugin != null) { - const pluginName = daoUtils.getPluginName(matchedPlugin.meta); + const { interfaceType } = matchedPlugin.meta; return { - label: pluginName, - tag: pluginName, + label: daoUtils.getPluginName(matchedPlugin.meta), + tag: interfaceType + ? daoUtils + .parsePluginInterfaceType(interfaceType) + .toUpperCase() + : undefined, address, isSentinel: false, }; From 1746cfbb4572e3abb6eb877a2c8ac1f1a612c56b Mon Sep 17 00:00:00 2001 From: Kevin Davis Date: Tue, 30 Jun 2026 13:10:55 +0200 Subject: [PATCH 14/29] chore: Add changeset for permissions page --- .changeset/app-954-permissions-page.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/app-954-permissions-page.md diff --git a/.changeset/app-954-permissions-page.md b/.changeset/app-954-permissions-page.md new file mode 100644 index 0000000000..a37bfc6b35 --- /dev/null +++ b/.changeset/app-954-permissions-page.md @@ -0,0 +1,5 @@ +--- +"@aragon/app": patch +--- + +Add a permissions overview subpage to DAO settings (list view): linked-account tabs, permission rows (who/where/permission/condition) with expandable details and a per-condition slot system, plus a "Permissions" entry link on the settings page From 124944506c758a0c0a854386a40f7002bf43a55b Mon Sep 17 00:00:00 2001 From: Kevin Davis Date: Tue, 30 Jun 2026 13:19:42 +0200 Subject: [PATCH 15/29] chore: Document PERMISSION_CONDITION in generated slot docs --- docs/slots/_metrics.md | 7 ++++--- docs/slots/settings-slots.md | 3 ++- scripts/generateSlotDocs.mjs | 10 +++++++++- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/docs/slots/_metrics.md b/docs/slots/_metrics.md index f56e7f7a83..a4c7fdd6c1 100644 --- a/docs/slots/_metrics.md +++ b/docs/slots/_metrics.md @@ -8,8 +8,8 @@ | Metric | Value | |---|---| -| Total slots | 33 | -| Registered plugins | 7 | +| Total slots | 34 | +| Registered plugins | 8 | | Unregistered slots | 4 | ## Slots Per Module @@ -17,7 +17,7 @@ | Module | Enum | Count | |---|---|---| | Governance | `GovernanceSlotId` | 20 | -| Settings | `SettingsSlotId` | 4 | +| Settings | `SettingsSlotId` | 5 | | Create DAO | `CreateDaoSlotId` | 5 | | Application | `ApplicationSlotId` | 1 | | Dashboard (DAO-level) | `DashboardDaoSlotId` | 1 | @@ -34,6 +34,7 @@ | SPP | 7 | 2 | - | - | 9 | | Capital Distributor | - | - | - | 1 | 1 | | Gauge Voter | - | - | - | 1 | 1 | +| Condition registry | - | 2 | - | - | 2 | ## Unregistered Slots diff --git a/docs/slots/settings-slots.md b/docs/slots/settings-slots.md index 1c3b04761a..55c2230b57 100644 --- a/docs/slots/settings-slots.md +++ b/docs/slots/settings-slots.md @@ -5,7 +5,7 @@ **Enum:** `SettingsSlotId` **Location:** `src/modules/settings/constants/moduleSlots.ts` -The settings module defines 4 slots for plugin-specific governance settings, updates, and uninstallation. +The settings module defines 5 slots for plugin-specific governance settings, updates, and uninstallation. | Slot ID | Type | Description | Registered By | |---|---|---|---| @@ -13,3 +13,4 @@ The settings module defines 4 slots for plugin-specific governance settings, upd | `SETTINGS_BUILD_PREPARE_PLUGIN_UPDATE_DATA` | Function | Builds the transaction data for preparing a plugin update | Token Voting, Multisig, Lock To Vote | | `SETTINGS_GET_UNINSTALL_HELPERS` | Function | Returns helper data needed for uninstalling the plugin | Token Voting, Multisig, Lock To Vote, SPP | | `SETTINGS_PLUGIN_TO_FORM_DATA` | Function | Converts plugin settings to form-compatible data for the settings editor | Token Voting, Lock To Vote | +| `PERMISSION_CONDITION` | Component | Renders the condition-specific detail for a permission grant (routed by condition type) | Condition registry | diff --git a/scripts/generateSlotDocs.mjs b/scripts/generateSlotDocs.mjs index 26cccc11c9..e462005840 100644 --- a/scripts/generateSlotDocs.mjs +++ b/scripts/generateSlotDocs.mjs @@ -90,6 +90,12 @@ const PLUGIN_SOURCES = [ name: 'gaugeVoterPlugin', label: 'Gauge Voter', }, + // Standalone condition registry (conditions are contracts, not plugins). + { + file: 'src/modules/settings/initConditionRegistry.ts', + name: 'conditionRegistry', + label: 'Condition registry', + }, ]; // Human-readable descriptions for slot IDs. Falls back to the ID itself if not listed. @@ -141,6 +147,8 @@ const SLOT_DESCRIPTIONS = { 'Converts plugin settings to form-compatible data for the settings editor', SETTINGS_GET_UNINSTALL_HELPERS: 'Returns helper data needed for uninstalling the plugin', + PERMISSION_CONDITION: + 'Renders the condition-specific detail for a permission grant (routed by condition type)', // Create DAO CREATE_DAO_BUILD_PREPARE_PLUGIN_INSTALL_DATA: 'Builds the transaction data for installing the plugin during DAO creation', @@ -309,7 +317,7 @@ function slotRegisteredBy(id) { if (!entry || entry.registeredBy.length === 0) { return '-'; } - return entry.registeredBy.join(', '); + return [...new Set(entry.registeredBy)].join(', '); } function slotDescription(id) { From 54b6f1f0f4bdd9b45fd2f6d8ef1f9f7330b5f02d Mon Sep 17 00:00:00 2001 From: Kevin Davis Date: Tue, 30 Jun 2026 23:21:54 +0200 Subject: [PATCH 16/29] feat: Show permissions entry as a DAO settings info row --- src/assets/locales/en.json | 6 ++-- .../components/daoHierarchy/daoHierarchy.tsx | 25 +++++++++++++-- .../daoSettingsInfo/daoSettingsInfo.test.tsx | 21 +++++++++++++ .../daoSettingsInfo/daoSettingsInfo.tsx | 11 +++++++ .../daoSettingsPage/daoSettingsPageClient.tsx | 31 +------------------ 5 files changed, 58 insertions(+), 36 deletions(-) diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index 1238b247a1..235d763581 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -3595,6 +3595,9 @@ "links": "Links", "name": "Name", "notChangeable": "Not changeable", + "permissions": "Permissions", + "permissionsDescription": "Get an overview of all permissions related to this account and its actors", + "permissionsLink": "View all permissions", "readLess": "Read less", "readMore": "Read more", "summary": "Summary", @@ -3610,9 +3613,6 @@ "daoAction": "Linked account", "governanceAction": "Process", "governanceInfoTitle": "Governance", - "permissionsAction": "View permissions", - "permissionsDescription": "Review who can do what across this DAO and its linked accounts, and any conditions on each grant.", - "permissionsInfoTitle": "Permissions", "settingsInfoTitle": "Account", "title": "Settings" } diff --git a/src/modules/settings/components/daoHierarchy/daoHierarchy.tsx b/src/modules/settings/components/daoHierarchy/daoHierarchy.tsx index 40de8838fa..c926bcc6c1 100644 --- a/src/modules/settings/components/daoHierarchy/daoHierarchy.tsx +++ b/src/modules/settings/components/daoHierarchy/daoHierarchy.tsx @@ -14,6 +14,7 @@ import type { IDao, ILinkedAccountSummary } from '@/shared/api/daoService'; import { DaoTypeTag } from '@/shared/components/daoTypeTag'; import { useTranslations } from '@/shared/components/translationsProvider'; import { networkDefinitions } from '@/shared/constants/networkDefinitions'; +import { daoUtils } from '@/shared/utils/daoUtils'; import { ipfsUtils } from '@/shared/utils/ipfsUtils'; export interface IDaoHierarchyProps { @@ -32,9 +33,14 @@ interface IDaoInfoProps { * DAO or linked account object. */ dao: IDao | ILinkedAccountSummary; + /** + * Link to the permissions page. Only set for the main DAO so the entry + * renders once. + */ + permissionsHref?: string; } -const DaoInfo: React.FC = ({ dao }) => { +const DaoInfo: React.FC = ({ dao, permissionsHref }) => { const { t } = useTranslations(); const { id: chainId } = networkDefinitions[dao.network]; const { buildEntityUrl } = useBlockExplorer({ chainId }); @@ -133,6 +139,17 @@ const DaoInfo: React.FC = ({ dao }) => {
)} + {permissionsHref != null && ( + + {t('app.settings.daoSettingsInfo.permissionsLink')} + + )}
); }; @@ -147,6 +164,8 @@ export const DaoHierarchy: React.FC = (props) => { const getDaoAvatar = (d: IDao | ILinkedAccountSummary) => ipfsUtils.cidToSrc(d.avatar); + const permissionsHref = daoUtils.getDaoUrl(dao, 'settings/permissions'); + // If viewing main DAO with linked accounts, show accordion structure if (isViewingMainDao && hasLinkedAccounts) { return ( @@ -168,7 +187,7 @@ export const DaoHierarchy: React.FC = (props) => { - + {dao.linkedAccounts?.map((linkedAccount) => ( @@ -203,7 +222,7 @@ export const DaoHierarchy: React.FC = (props) => { // Default: regular view for main DAO without linked accounts or when viewing a linked account return ( - + ); }; diff --git a/src/modules/settings/components/daoSettingsInfo/daoSettingsInfo.test.tsx b/src/modules/settings/components/daoSettingsInfo/daoSettingsInfo.test.tsx index 1134b7175c..603e1197ba 100644 --- a/src/modules/settings/components/daoSettingsInfo/daoSettingsInfo.test.tsx +++ b/src/modules/settings/components/daoSettingsInfo/daoSettingsInfo.test.tsx @@ -58,6 +58,27 @@ describe(' component', () => { expect(screen.getByText(/daoSettingsInfo.links/)).toBeInTheDocument(); }); + it('renders the permissions link to the permissions page', () => { + const dao = generateDao({ + address: '0x123', + ens: 'somedao.dao.eth', + network: Network.ETHEREUM_MAINNET, + }); + render(createTestComponent({ dao })); + + expect( + screen.getByText(/daoSettingsInfo.permissionsLink/), + ).toBeInTheDocument(); + expect( + screen.getByRole('link', { + name: /daoSettingsInfo.permissionsLink/, + }), + ).toHaveAttribute( + 'href', + '/dao/ethereum-mainnet/somedao.dao.eth/settings/permissions', + ); + }); + it('renders the correct definition values of the dao', () => { const dao = generateDao({ name: 'Some DAO', diff --git a/src/modules/settings/components/daoSettingsInfo/daoSettingsInfo.tsx b/src/modules/settings/components/daoSettingsInfo/daoSettingsInfo.tsx index 5a854607ba..9f714e6a74 100644 --- a/src/modules/settings/components/daoSettingsInfo/daoSettingsInfo.tsx +++ b/src/modules/settings/components/daoSettingsInfo/daoSettingsInfo.tsx @@ -111,6 +111,17 @@ export const DaoSettingsInfo: React.FC = (props) => { )} + + {t('app.settings.daoSettingsInfo.permissionsLink')} + ); diff --git a/src/modules/settings/pages/daoSettingsPage/daoSettingsPageClient.tsx b/src/modules/settings/pages/daoSettingsPage/daoSettingsPageClient.tsx index 9b39c26770..afb9a0d895 100644 --- a/src/modules/settings/pages/daoSettingsPage/daoSettingsPageClient.tsx +++ b/src/modules/settings/pages/daoSettingsPage/daoSettingsPageClient.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Button, IconType } from '@aragon/gov-ui-kit'; +import { IconType } from '@aragon/gov-ui-kit'; import { useRouter } from 'next/navigation'; import { GovernanceDialogId } from '@/modules/governance/constants/governanceDialogId'; import { GovernanceSlotId } from '@/modules/governance/constants/moduleSlots'; @@ -210,35 +210,6 @@ export const DaoSettingsPageClient: React.FC = ( )} - -
-

- {t( - 'app.settings.daoSettingsPage.main.permissionsDescription', - )} -

- -
-
{hasSupportedPlugins && ( Date: Wed, 1 Jul 2026 11:30:31 +0545 Subject: [PATCH 17/29] feat: Add MembershipConditionSlot component for multisig membership checks --- src/assets/locales/en.json | 5 +++ .../membershipConditionSlot/index.ts | 1 + .../membershipConditionSlot.test.tsx | 38 ++++++++++++++++++ .../membershipConditionSlot.tsx | 39 +++++++++++++++++++ .../settings/initConditionRegistry.test.ts | 2 + src/modules/settings/initConditionRegistry.ts | 6 +++ 6 files changed, 91 insertions(+) create mode 100644 src/modules/settings/components/membershipConditionSlot/index.ts create mode 100644 src/modules/settings/components/membershipConditionSlot/membershipConditionSlot.test.tsx create mode 100644 src/modules/settings/components/membershipConditionSlot/membershipConditionSlot.tsx diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index 235d763581..d7cb5fc081 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -3543,6 +3543,11 @@ "allowedActions": "Allowed actions", "noActions": "No allowed actions" }, + "membershipConditionSlot": { + "memberOfMultisig": "Member of multisig", + "true": "True", + "false": "False" + }, "daoPolicyDetailsPage": { "error": { "action": "Explore settings", diff --git a/src/modules/settings/components/membershipConditionSlot/index.ts b/src/modules/settings/components/membershipConditionSlot/index.ts new file mode 100644 index 0000000000..c4782e1376 --- /dev/null +++ b/src/modules/settings/components/membershipConditionSlot/index.ts @@ -0,0 +1 @@ +export { MembershipConditionSlot } from './membershipConditionSlot'; diff --git a/src/modules/settings/components/membershipConditionSlot/membershipConditionSlot.test.tsx b/src/modules/settings/components/membershipConditionSlot/membershipConditionSlot.test.tsx new file mode 100644 index 0000000000..7fa42cdaa8 --- /dev/null +++ b/src/modules/settings/components/membershipConditionSlot/membershipConditionSlot.test.tsx @@ -0,0 +1,38 @@ +import { GukModulesProvider } from '@aragon/gov-ui-kit'; +import { render, screen } from '@testing-library/react'; +import type { IConditionData } from '@/modules/settings/types'; +import { MembershipConditionSlot } from './membershipConditionSlot'; + +describe(' component', () => { + const createTestComponent = (props?: Partial) => { + const completeProps: IConditionData = { + conditionType: 'membership', + ...props, + }; + + return ( + + + + ); + }; + + it('renders "true" when the multisig gates proposal creation to members', () => { + render(createTestComponent({ onlyListed: true })); + + expect( + screen.getByText(/membershipConditionSlot.memberOfMultisig/), + ).toBeInTheDocument(); + expect( + screen.getByText(/membershipConditionSlot.true/), + ).toBeInTheDocument(); + }); + + it('renders "false" when membership is not enforced', () => { + render(createTestComponent({ onlyListed: false })); + + expect( + screen.getByText(/membershipConditionSlot.false/), + ).toBeInTheDocument(); + }); +}); diff --git a/src/modules/settings/components/membershipConditionSlot/membershipConditionSlot.tsx b/src/modules/settings/components/membershipConditionSlot/membershipConditionSlot.tsx new file mode 100644 index 0000000000..408a8726e3 --- /dev/null +++ b/src/modules/settings/components/membershipConditionSlot/membershipConditionSlot.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { DefinitionList, Tag } from '@aragon/gov-ui-kit'; +import type { IConditionData } from '@/modules/settings/types'; +import { useTranslations } from '@/shared/components/translationsProvider'; + +/** + * Renders the detail for a multisig `membership` condition (ListedCheckCondition). + * The condition only gates proposal creation to listed members when the multisig + * `onlyListed` setting is enabled, so that boolean is surfaced as + * "Member of multisig". + */ +export const MembershipConditionSlot: React.FC = (props) => { + const { onlyListed } = props; + const { t } = useTranslations(); + + const isMemberGated = onlyListed === true; + + return ( + + + + + + + + ); +}; diff --git a/src/modules/settings/initConditionRegistry.test.ts b/src/modules/settings/initConditionRegistry.test.ts index 5bead2e759..5edae60a45 100644 --- a/src/modules/settings/initConditionRegistry.test.ts +++ b/src/modules/settings/initConditionRegistry.test.ts @@ -1,6 +1,7 @@ import { SettingsSlotId } from '@/modules/settings/constants/moduleSlots'; import { pluginRegistryUtils } from '@/shared/utils/pluginRegistryUtils'; import { ExecuteSelectorConditionSlot } from './components/executeSelectorConditionSlot'; +import { MembershipConditionSlot } from './components/membershipConditionSlot'; import { NoConditionSlot } from './components/noConditionSlot'; import { VotingPowerConditionSlot } from './components/votingPowerConditionSlot'; import { initialiseConditionRegistry } from './initConditionRegistry'; @@ -16,6 +17,7 @@ describe('initialiseConditionRegistry', () => { pluginId: 'execute-selector', component: ExecuteSelectorConditionSlot, }, + { pluginId: 'membership', component: MembershipConditionSlot }, ])('resolves the $pluginId condition component from the slot', ({ pluginId, component, diff --git a/src/modules/settings/initConditionRegistry.ts b/src/modules/settings/initConditionRegistry.ts index 141c699ebc..798409b7c8 100644 --- a/src/modules/settings/initConditionRegistry.ts +++ b/src/modules/settings/initConditionRegistry.ts @@ -1,6 +1,7 @@ import { SettingsSlotId } from '@/modules/settings/constants/moduleSlots'; import { pluginRegistryUtils } from '@/shared/utils/pluginRegistryUtils'; import { ExecuteSelectorConditionSlot } from './components/executeSelectorConditionSlot'; +import { MembershipConditionSlot } from './components/membershipConditionSlot'; import { VotingPowerConditionSlot } from './components/votingPowerConditionSlot'; /** @@ -24,5 +25,10 @@ export const initialiseConditionRegistry = () => { slotId: SettingsSlotId.PERMISSION_CONDITION, pluginId: 'execute-selector', component: ExecuteSelectorConditionSlot, + }) + .registerSlotComponent({ + slotId: SettingsSlotId.PERMISSION_CONDITION, + pluginId: 'membership', + component: MembershipConditionSlot, }); }; From 6fb67b6d872b67e880cf9be9a9bfdf4eb021cf86 Mon Sep 17 00:00:00 2001 From: ccir Date: Wed, 1 Jul 2026 11:37:17 +0545 Subject: [PATCH 18/29] fix: Update Condition registry metrics to reflect accurate counts --- docs/slots/_metrics.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/slots/_metrics.md b/docs/slots/_metrics.md index a4c7fdd6c1..d61cfbe6db 100644 --- a/docs/slots/_metrics.md +++ b/docs/slots/_metrics.md @@ -34,7 +34,7 @@ | SPP | 7 | 2 | - | - | 9 | | Capital Distributor | - | - | - | 1 | 1 | | Gauge Voter | - | - | - | 1 | 1 | -| Condition registry | - | 2 | - | - | 2 | +| Condition registry | - | 3 | - | - | 3 | ## Unregistered Slots From ebca8a8b3ba5679bf8b5d9e4bcbdd3fb99f187b0 Mon Sep 17 00:00:00 2001 From: Kevin Davis Date: Wed, 1 Jul 2026 21:25:57 +0200 Subject: [PATCH 19/29] feat: Add permissions graph view for DAO settings --- package.json | 2 + pnpm-lock.yaml | 162 +++++++++++ src/assets/locales/en.json | 22 ++ .../components/permissionsGraph/index.ts | 8 + .../permissionsGraph/permissionDetailNode.tsx | 163 +++++++++++ .../permissionsGraph/permissionGraphEdge.tsx | 87 ++++++ .../permissionsGraph/permissionGraphNode.tsx | 107 +++++++ .../permissionsGraph/permissionsGraph.tsx | 162 +++++++++++ .../permissionsGraphCanvas.tsx | 264 ++++++++++++++++++ .../daoPermissionsPageClient.tsx | 32 ++- src/modules/settings/types/index.ts | 6 + src/modules/settings/types/permissionGraph.ts | 89 ++++++ .../buildPermissionGraph.test.ts | 209 ++++++++++++++ .../buildPermissionGraph.ts | 139 +++++++++ .../utils/buildPermissionGraph/index.ts | 4 + .../utils/permissionGraphLayout/index.ts | 5 + .../permissionGraphLayout.test.ts | 57 ++++ .../permissionGraphLayout.ts | 81 ++++++ src/test/setup.ts | 8 + 19 files changed, 1599 insertions(+), 8 deletions(-) create mode 100644 src/modules/settings/components/permissionsGraph/index.ts create mode 100644 src/modules/settings/components/permissionsGraph/permissionDetailNode.tsx create mode 100644 src/modules/settings/components/permissionsGraph/permissionGraphEdge.tsx create mode 100644 src/modules/settings/components/permissionsGraph/permissionGraphNode.tsx create mode 100644 src/modules/settings/components/permissionsGraph/permissionsGraph.tsx create mode 100644 src/modules/settings/components/permissionsGraph/permissionsGraphCanvas.tsx create mode 100644 src/modules/settings/types/permissionGraph.ts create mode 100644 src/modules/settings/utils/buildPermissionGraph/buildPermissionGraph.test.ts create mode 100644 src/modules/settings/utils/buildPermissionGraph/buildPermissionGraph.ts create mode 100644 src/modules/settings/utils/buildPermissionGraph/index.ts create mode 100644 src/modules/settings/utils/permissionGraphLayout/index.ts create mode 100644 src/modules/settings/utils/permissionGraphLayout/permissionGraphLayout.test.ts create mode 100644 src/modules/settings/utils/permissionGraphLayout/permissionGraphLayout.ts diff --git a/package.json b/package.json index 2a20c669c7..198c2203fb 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ }, "dependencies": { "@aragon/gov-ui-kit": "^2.8.0", + "@dagrejs/dagre": "^3.0.0", "@floating-ui/react": "^0.27.19", "@number-flow/react": "^0.6.0", "@radix-ui/react-dialog": "^1.1.17", @@ -50,6 +51,7 @@ "@tanstack/react-query-devtools": "^5.101.0", "@walletconnect/core": "^2.23.9", "@walletconnect/utils": "^2.23.9", + "@xyflow/react": "^12.11.1", "classnames": "^2.5.1", "deepmerge-ts": "^7.1.5", "framer-motion": "^12.40.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ce7850d69a..4767e293af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: '@aragon/gov-ui-kit': specifier: ^2.8.0 version: 2.8.0(@emotion/is-prop-valid@1.4.0)(@floating-ui/dom@1.7.6)(@tailwindcss/typography@0.5.20(tailwindcss@4.3.1))(@tanstack/react-query@5.101.0(react@19.2.7))(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-hook-form@7.79.0(react@19.2.7))(react@19.2.7)(tailwindcss@4.3.1)(viem@2.52.2(typescript@5.9.3)(zod@3.25.76))(wagmi@3.6.17(@coinbase/wallet-sdk@4.3.6(@types/react@19.2.17)(immer@11.1.8)(react@19.2.7)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@19.2.7))(zod@3.25.76))(@safe-global/safe-apps-provider@0.18.6(typescript@5.9.3)(zod@3.25.76))(@safe-global/safe-apps-sdk@9.1.0(typescript@5.9.3)(zod@3.25.76))(@tanstack/query-core@5.101.0)(@tanstack/react-query@5.101.0(react@19.2.7))(@types/react@19.2.17)(immer@11.1.8)(react@19.2.7)(typescript@5.9.3)(viem@2.52.2(typescript@5.9.3)(zod@3.25.76))) + '@dagrejs/dagre': + specifier: ^3.0.0 + version: 3.0.0 '@floating-ui/react': specifier: ^0.27.19 version: 0.27.19(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -74,6 +77,9 @@ importers: '@walletconnect/utils': specifier: ^2.23.9 version: 2.23.9(@vercel/blob@2.3.0)(typescript@5.9.3)(zod@3.25.76) + '@xyflow/react': + specifier: ^12.11.1 + version: 12.11.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(immer@11.1.8)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) classnames: specifier: ^2.5.1 version: 2.5.1 @@ -668,6 +674,12 @@ packages: resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} engines: {node: '>=20.19.0'} + '@dagrejs/dagre@3.0.0': + resolution: {integrity: sha512-ZzhnTy1rfuoew9Ez3EIw4L2znPGnYYhfn8vc9c4oB8iw6QAsszbiU0vRhlxWPFnmmNSFAkrYeF1PhM5m4lAN0Q==} + + '@dagrejs/graphlib@4.0.1': + resolution: {integrity: sha512-IvcV6FduIIAmLwnH+yun+QtV36SC7mERqa86aClNqmMN09WhmPPYU8ckHrZBozErf+UvHPWOTJYaGYiIcs0DgA==} + '@depay/solana-web3.js@1.98.3': resolution: {integrity: sha512-wxr+2gpjKRZ1eVBLhQYJxImDsRukk0DvCsEElkTMyybP+7SamWRs48o3DYE6VLEgQJFZgOoUec3t5FM5s1J1ww==} @@ -3383,6 +3395,9 @@ packages: '@types/d3-color@3.1.3': resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + '@types/d3-ease@3.0.2': resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} @@ -3395,6 +3410,9 @@ packages: '@types/d3-scale@4.0.9': resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + '@types/d3-shape@3.1.8': resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} @@ -3404,6 +3422,12 @@ packages: '@types/d3-timer@3.0.2': resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} @@ -3921,6 +3945,22 @@ packages: '@xtuc/long@4.2.2': resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + '@xyflow/react@12.11.1': + resolution: {integrity: sha512-L+zBoLGSXham0MnlY8QqjfR7/C5JNw0zxkaey5aZ5XmCgJBAdH4+WRIu8CR40d3l/BdU635V6YbhBK1jMo8/6Q==} + peerDependencies: + '@types/react': '>=17' + '@types/react-dom': '>=17' + react: '>=17' + react-dom: '>=17' + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@xyflow/system@0.0.78': + resolution: {integrity: sha512-lY0z2qP33fUhTva9Vaxrk0lqZta2pkbxB1trHAx1omnJqRtPvDlAQYV2r5fhS6AdpkulYmbNW0svy+A4/t4B/g==} + '@yarnpkg/parsers@3.0.3': resolution: {integrity: sha512-mQZgUSgFurUtA07ceMjxrWkYz8QtDuYkvPlu0ZqncgjopQ0t6CNEo/OSealkmnagSUx8ZD5ewvezUwUuMqutQg==} engines: {node: '>=18.12.0'} @@ -4374,6 +4414,9 @@ packages: cjs-module-lexer@2.2.0: resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==} + classcat@5.0.5: + resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==} + classnames@2.5.1: resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} @@ -4531,6 +4574,14 @@ packages: resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} engines: {node: '>=12'} + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + d3-ease@3.0.1: resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} engines: {node: '>=12'} @@ -4551,6 +4602,10 @@ packages: resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} engines: {node: '>=12'} + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + d3-shape@3.2.0: resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} engines: {node: '>=12'} @@ -4567,6 +4622,16 @@ packages: resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} engines: {node: '>=12'} + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + data-uri-to-buffer@6.0.2: resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} engines: {node: '>= 14'} @@ -7729,6 +7794,21 @@ packages: zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zustand@4.5.7: + resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} + engines: {node: '>=12.7.0'} + peerDependencies: + '@types/react': '>=16.8' + immer: '>=9.0.6' + react: '>=16.8' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + zustand@5.0.0: resolution: {integrity: sha512-LE+VcmbartOPM+auOjCCLQOsQ05zUTp8RkgwRzefUk+2jISdMMFnxvyTjA4YNWr5ZGXYbVsEMZosttuxUBkojQ==} engines: {node: '>=12.20.0'} @@ -8364,6 +8444,12 @@ snapshots: '@csstools/css-tokenizer@4.0.0': {} + '@dagrejs/dagre@3.0.0': + dependencies: + '@dagrejs/graphlib': 4.0.1 + + '@dagrejs/graphlib@4.0.1': {} + '@depay/solana-web3.js@1.98.3': dependencies: bs58: 5.0.0 @@ -11619,6 +11705,10 @@ snapshots: '@types/d3-color@3.1.3': {} + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + '@types/d3-ease@3.0.2': {} '@types/d3-interpolate@3.0.4': @@ -11631,6 +11721,8 @@ snapshots: dependencies: '@types/d3-time': 3.0.4 + '@types/d3-selection@3.0.11': {} + '@types/d3-shape@3.1.8': dependencies: '@types/d3-path': 3.1.1 @@ -11639,6 +11731,15 @@ snapshots: '@types/d3-timer@3.0.2': {} + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + '@types/estree@1.0.9': {} '@types/istanbul-lib-coverage@2.0.6': {} @@ -12721,6 +12822,31 @@ snapshots: '@xtuc/long@4.2.2': {} + '@xyflow/react@12.11.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(immer@11.1.8)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@xyflow/system': 0.0.78 + classcat: 5.0.5 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + zustand: 4.5.7(@types/react@19.2.17)(immer@11.1.8)(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + transitivePeerDependencies: + - immer + + '@xyflow/system@0.0.78': + dependencies: + '@types/d3-drag': 3.0.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-zoom: 3.0.0 + '@yarnpkg/parsers@3.0.3': dependencies: js-yaml: 3.14.2 @@ -13131,6 +13257,8 @@ snapshots: cjs-module-lexer@2.2.0: {} + classcat@5.0.5: {} + classnames@2.5.1: {} cli-cursor@5.0.0: @@ -13266,6 +13394,13 @@ snapshots: d3-color@3.1.0: {} + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + d3-ease@3.0.1: {} d3-format@3.1.2: {} @@ -13284,6 +13419,8 @@ snapshots: d3-time: 3.1.0 d3-time-format: 4.1.0 + d3-selection@3.0.0: {} + d3-shape@3.2.0: dependencies: d3-path: 3.1.0 @@ -13298,6 +13435,23 @@ snapshots: d3-timer@3.0.1: {} + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + data-uri-to-buffer@6.0.2: {} data-urls@5.0.0: @@ -16795,6 +16949,14 @@ snapshots: zod@4.4.3: {} + zustand@4.5.7(@types/react@19.2.17)(immer@11.1.8)(react@19.2.7): + dependencies: + use-sync-external-store: 1.6.0(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + immer: 11.1.8 + react: 19.2.7 + zustand@5.0.0(@types/react@19.2.17)(immer@11.1.8)(react@19.2.7)(use-sync-external-store@1.4.0(react@19.2.7)): optionalDependencies: '@types/react': 19.2.17 diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index d7cb5fc081..1609c710ac 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -3503,6 +3503,28 @@ "view": { "list": "List", "graph": "Graph" + }, + "graphView": { + "node": { + "primaryDao": "Primary DAO", + "linkedDao": "Linked DAO", + "plugin": "Aragon OSx Plugin", + "actor": "Any address", + "who": "Who", + "where": "Where" + }, + "edge": { + "condition": "if {{condition}}" + }, + "detail": { + "close": "Close", + "permission": "Permission", + "condition": "Condition" + }, + "empty": { + "heading": "No permissions", + "description": "This account has no indexed permissions to visualize." + } } }, "permissionsList": { diff --git a/src/modules/settings/components/permissionsGraph/index.ts b/src/modules/settings/components/permissionsGraph/index.ts new file mode 100644 index 0000000000..093029d0b1 --- /dev/null +++ b/src/modules/settings/components/permissionsGraph/index.ts @@ -0,0 +1,8 @@ +import dynamic from 'next/dynamic'; + +export const PermissionsGraph = dynamic( + () => import('./permissionsGraph').then((mod) => mod.PermissionsGraph), + { ssr: false }, +); + +export type { IPermissionsGraphProps } from './permissionsGraph'; diff --git a/src/modules/settings/components/permissionsGraph/permissionDetailNode.tsx b/src/modules/settings/components/permissionsGraph/permissionDetailNode.tsx new file mode 100644 index 0000000000..26c22e13cb --- /dev/null +++ b/src/modules/settings/components/permissionsGraph/permissionDetailNode.tsx @@ -0,0 +1,163 @@ +import { + addressUtils, + Button, + DefinitionList, + IconType, + Toggle, + ToggleGroup, +} from '@aragon/gov-ui-kit'; +import { Handle, type Node, type NodeProps, Position } from '@xyflow/react'; +import { useState } from 'react'; +import { PluginSingleComponent } from '@/shared/components/pluginSingleComponent'; +import { useTranslations } from '@/shared/components/translationsProvider'; +import { SettingsSlotId } from '../../constants/moduleSlots'; +import { ALLOW_FLAG } from '../../constants/permissionSentinels'; +import type { IPermissionGraphEdge } from '../../types'; +import { conditionTypeUtils } from '../../utils/conditionTypeUtils'; +import { NoConditionSlot } from '../noConditionSlot'; + +/** + * Data for the expanded detail node shown when a permission edge is selected. + */ +export interface IPermissionDetailNodeData { + edge: IPermissionGraphEdge; + whoLabel: string; + whereLabel: string; + onClose: () => void; + [key: string]: unknown; +} + +export type IPermissionDetailFlowNode = Node< + IPermissionDetailNodeData, + 'permissionDetail' +>; + +type DetailTab = 'permission' | 'condition'; + +export const PermissionDetailNode: React.FC< + NodeProps +> = ({ data }) => { + const { t } = useTranslations(); + const { edge, whoLabel, whereLabel, onClose } = data; + const { row } = edge; + + const [tab, setTab] = useState('permission'); + + const hasCondition = !addressUtils.isAddressEqual( + row.conditionAddress, + ALLOW_FLAG, + ); + const conditionType = conditionTypeUtils.resolveConditionType( + row.conditionAddress, + row.condition, + ); + + const handleTabChange = (value?: string | string[]) => { + if (value === 'permission' || value === 'condition') { + setTab(value); + } + }; + + return ( +
+ + +
+ + {edge.permissionName} + {edge.conditionLabel != null && ( + + {` ${t( + 'app.settings.daoPermissionsPage.graphView.edge.condition', + { condition: edge.conditionLabel }, + )}`} + + )} + +
+ +
+ + + + + + {tab === 'permission' ? ( + + + {whoLabel} + + + {whereLabel} + + + {edge.permissionName} + + + ) : ( + + )} +
+ + +
+ ); +}; diff --git a/src/modules/settings/components/permissionsGraph/permissionGraphEdge.tsx b/src/modules/settings/components/permissionsGraph/permissionGraphEdge.tsx new file mode 100644 index 0000000000..1839c71199 --- /dev/null +++ b/src/modules/settings/components/permissionsGraph/permissionGraphEdge.tsx @@ -0,0 +1,87 @@ +import { + BaseEdge, + type Edge, + EdgeLabelRenderer, + type EdgeProps, + getSmoothStepPath, +} from '@xyflow/react'; +import classNames from 'classnames'; +import { useTranslations } from '@/shared/components/translationsProvider'; + +/** + * Data carried by a permission-graph edge: the resolved permission name and an + * optional condition label rendered as an `if